Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions packages/desktop/src/main/sandbox/egress.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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<SandboxEgress> {
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.
Expand All @@ -37,7 +43,7 @@ export async function resolveSandboxEgress(): Promise<SandboxEgress> {
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
Expand All @@ -52,9 +58,19 @@ export async function resolveSandboxEgress(): Promise<SandboxEgress> {
),
// 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<typeof net.fetch>[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<typeof net.fetch>[1])
},
}
}

Expand Down
Loading