diff --git a/packages/desktop/src/main/sandbox/egress.ts b/packages/desktop/src/main/sandbox/egress.ts index 59cad8035a..ad7829a791 100644 --- a/packages/desktop/src/main/sandbox/egress.ts +++ b/packages/desktop/src/main/sandbox/egress.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises" import { join } from "node:path" import { app, net } from "electron" -import { buildGatewayConfig, loadApiKey, normalizeGatewayBaseUrl } from "@codev/gateway" +import { buildGatewayConfig, loadApiKey, loadGatewayUrl, normalizeGatewayBaseUrl } from "@codev/gateway" import { parseBrokerRules } from "@codev/sandbox" import type { SandboxEgress } from "@codev/sandbox" @@ -17,14 +17,20 @@ const GUEST_BROKER_BASE = "http://127.0.0.1:8788/llm" // config is still injected for the permission default below. export async function resolveSandboxEgress(): Promise { const creds = loadApiKey() - const gateway = creds?.baseUrl && creds.model ? creds : null + // base_url is only written by the custom-provider flow; an SSO login records + // gateway_url instead, and the config it writes is that plus /v1. Falling + // back keeps the common case (hub login, no base_url) from silently + // producing a sandbox with no egress. + const baseUrl = creds?.baseUrl ?? loadGatewayUrl() + const gateway = + creds?.apiKey && baseUrl && creds.model ? { apiKey: creds.apiKey, baseUrl, model: creds.model } : null return { routes: [ ...(gateway ? [ { prefix: "/llm/v1", - upstream: normalizeGatewayBaseUrl(gateway.baseUrl!), + upstream: normalizeGatewayBaseUrl(gateway.baseUrl), // The guest config carries a placeholder key; the broker swaps in // the real one, so the token never enters the guest and rotation // stays host-side. @@ -37,7 +43,7 @@ export async function resolveSandboxEgress(): Promise { guestConfig: JSON.stringify( { ...(gateway - ? buildGatewayConfig({ apiKey: "sandbox-broker", baseUrl: GUEST_BROKER_BASE, model: gateway.model! }) + ? buildGatewayConfig({ apiKey: "sandbox-broker", baseUrl: GUEST_BROKER_BASE, model: gateway.model }) : {}), // Everything the agent does is confined to the VM and the one mounted // project folder, so permission prompts buy nothing here — auto-allow @@ -52,9 +58,19 @@ export async function resolveSandboxEgress(): Promise { ), // Chromium's network stack: the host's proxy settings, PAC scripts, and // enterprise trust roots apply to the upstream call without any of them - // entering the guest. net.fetch's typings reject URL inputs, so unwrap - // them; the broker only ever passes strings. - fetchImpl: (input, init) => net.fetch(input instanceof URL ? input.toString() : input, init), + // entering the guest. + fetchImpl: async (input, init) => { + const url = input instanceof URL ? input.toString() : input + if (!init?.body) return net.fetch(url, init as Parameters[1]) + // The broker forwards the guest's request body as a stream, which + // net.fetch rejects outright (net::ERR_INVALID_ARGUMENT) — it wants a + // materialized body, and `duplex` along with it is equally invalid. + // Model requests are small JSON payloads, so buffering costs nothing; + // the response stays streamed, which is what token streaming needs. + const body = await new Response(init.body as BodyInit).arrayBuffer() + const { duplex: _duplex, ...rest } = init as RequestInit & { duplex?: string } + return net.fetch(url, { ...rest, body } as Parameters[1]) + }, } }