Skip to content
Merged
Show file tree
Hide file tree
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ file is the human-readable summary of what changed and why.
endpoint), or **Connect provider** (Anthropic, OpenAI, and others). The gate
applies to the local server only; escaping any sub-flow returns to it, and
finishing setup in another window (or in the CLI) closes it automatically.
- **Sandboxes offer every model your gateway does** — a sandbox was built from
the single model recorded when you signed in, so **Work** offered one model
while **Code** offered the whole list. Each sandbox now asks the gateway for
the full list when it starts, so the two sides match. The answer is reused
across sandboxes and only looked up again after you sign in again, and a
gateway that cannot be reached costs a shorter model list rather than a
sandbox that will not start.
- **Signing in now reaches your sandboxes too** — a sandbox is handed its model
provider when it starts, so signing in afterwards left **Work** with no model
at all while **Code** had the one you just picked, and the only way to fix it
Expand Down
5 changes: 3 additions & 2 deletions packages/codev-sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,9 @@ This package is platform code only; the desktop app owns everything user-facing.

- `packages/desktop/src/main/sandbox/` — `controller.ts` (picks the platform
provider, persists configs), `ipc.ts` (the `sandbox-*` channels), `egress.ts`
(broker routes, gateway credentials, the injected guest config), `bundle.ts`
(the mounted server bundle).
(broker routes, gateway credentials, the injected guest config — including
the gateway's model catalogue, cached per credential set in
`gateway-models.ts`), `bundle.ts` (the mounted server bundle).
- `packages/app/src/sandbox/` — the UI: add dialog, mounts dialog, settings
rows, and the per-mount project seeding.
- `packages/desktop/src/renderer/sandbox/` — ready sandboxes as server
Expand Down
60 changes: 43 additions & 17 deletions packages/desktop/src/main/sandbox/egress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,32 @@ import { randomUUID } from "node:crypto"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
import { app, net } from "electron"
import { buildGatewayConfig, loadApiKey, loadGatewayUrl, normalizeGatewayBaseUrl } from "@codev/gateway"
import { buildGatewayConfig, fetchModels, loadApiKey, loadGatewayUrl, normalizeGatewayBaseUrl } from "@codev/gateway"
import { parseBrokerRules } from "@codev/sandbox"
import type { SandboxEgress } from "@codev/sandbox"
import { write } from "../logging"
import { createGatewayModelsCache } from "./gateway-models"

// Chromium's network stack: the host's proxy settings, PAC scripts, and
// enterprise trust roots apply to every call the host makes on a guest's
// behalf — the brokered ones and the model-catalogue lookup below — without
// any of them entering the guest.
const hostFetch: typeof fetch = 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])
}

const gatewayModels = createGatewayModelsCache((apiKey, baseUrl) =>
fetchModels(apiKey, baseUrl, { fetchImpl: hostFetch }),
)

// Resolved at every sandbox start so a rotated gateway token is picked up by
// the next start rather than requiring an app restart. Without a gateway
Expand All @@ -31,6 +54,23 @@ export async function resolveSandboxEgress(opts: { guestBrokerPort: number }): P
const auth = randomUUID()
const gateway =
creds?.apiKey && baseUrl && creds.model ? { apiKey: creds.apiKey, baseUrl, model: creds.model } : null
// The whole catalogue, not just the model the login recorded, so the guest's
// model picker matches the local server's instead of offering one entry.
// Cached across starts; the first one after a login pays the lookup, and an
// unreachable gateway falls back to the recorded model alone.
const models = gateway ? await gatewayModels(gateway.apiKey, gateway.baseUrl) : []
// fetchModels throws rather than returning an empty catalogue, so an empty
// list here means the lookup failed and the guest is falling back to the one
// model the login recorded. In the picker that is indistinguishable from a
// gateway that genuinely offers one model, so it is worth saying which
// happened — it is the first question when Work looks thinner than Code.
if (gateway)
write(
"sandbox",
models.length ? "resolved the gateway model list" : "gateway model list unavailable; using the recorded model",
{ models: models.length },
models.length ? "info" : "warn",
)
return {
routes: [
...(gateway
Expand All @@ -51,7 +91,7 @@ export async function resolveSandboxEgress(opts: { guestBrokerPort: number }): P
guestConfig: JSON.stringify(
{
...(gateway
? buildGatewayConfig({ apiKey: auth, baseUrl: guestBrokerBase, model: gateway.model })
? buildGatewayConfig({ apiKey: auth, baseUrl: guestBrokerBase, model: gateway.model, models })
: {}),
// 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 @@ -64,21 +104,7 @@ export async function resolveSandboxEgress(opts: { guestBrokerPort: number }): P
null,
2,
),
// 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.
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])
},
fetchImpl: hostFetch,
}
}

Expand Down
61 changes: 61 additions & 0 deletions packages/desktop/src/main/sandbox/gateway-models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { expect, test } from "bun:test"
import { createGatewayModelsCache } from "./gateway-models"

const deferred = () => {
let resolve!: (models: string[]) => void
let reject!: (error: unknown) => void
const promise = new Promise<string[]>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}

// Sandboxes start together at launch, so the concurrent case is the common one.
test("one lookup serves every start on the same credentials", async () => {
const calls: string[][] = []
const gate = deferred()
const models = createGatewayModelsCache((apiKey, baseUrl) => {
calls.push([apiKey, baseUrl])
return gate.promise
})

const first = models("sk-live", "https://gw.test")
const second = models("sk-live", "https://gw.test")
gate.resolve(["a", "b"])
expect(await first).toEqual(["a", "b"])
expect(await second).toEqual(["a", "b"])
expect(await models("sk-live", "https://gw.test")).toEqual(["a", "b"])
expect(calls).toEqual([["sk-live", "https://gw.test"]])
})

// A rotated token or a different gateway is a different catalogue, and the key
// is what makes that impossible to get wrong.
test("new credentials miss the cache", async () => {
let calls = 0
const models = createGatewayModelsCache(async (apiKey) => {
calls++
return [apiKey]
})

expect(await models("sk-one", "https://gw.test")).toEqual(["sk-one"])
expect(await models("sk-two", "https://gw.test")).toEqual(["sk-two"])
expect(await models("sk-two", "https://other.test")).toEqual(["sk-two"])
expect(calls).toBe(3)
})

// An unreachable gateway leaves the guest with the model the login recorded —
// which is what it had before the catalogue was fetched at all — and must never
// take the sandbox start down with it.
test("a failed lookup yields no models and is retried on the next start", async () => {
let calls = 0
const models = createGatewayModelsCache(async () => {
calls++
if (calls === 1) throw new Error("gateway unreachable")
return ["a"]
})

expect(await models("sk-live", "https://gw.test")).toEqual([])
expect(await models("sk-live", "https://gw.test")).toEqual(["a"])
expect(calls).toBe(2)
})
30 changes: 30 additions & 0 deletions packages/desktop/src/main/sandbox/gateway-models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// The gateway's model catalogue, for the config a sandbox guest is handed at
// boot. The login only records the one model that was chosen, so a guest built
// from the auth file alone offers a single model while the local server offers
// the whole catalogue — asking the gateway is what closes that gap.
//
// Cached because every sandbox start would otherwise re-ask, and a machine that
// cannot reach the gateway would pay the fetch timeout on each one. The key is
// the credentials themselves, so a rotated token or a different gateway misses
// the cache by construction and a stale catalogue cannot outlive the login it
// came from.
export function createGatewayModelsCache(fetchModels: (apiKey: string, baseUrl: string) => Promise<string[]>) {
let entry: { key: string; models: Promise<string[]> } | undefined

return (apiKey: string, baseUrl: string) => {
const key = `${apiKey}\n${baseUrl}`
if (entry?.key === key) return entry.models
// A failure resolves to no models rather than rejecting: the guest then
// gets just the model the login recorded, which is exactly what it got
// before this existed, so an unreachable gateway costs a shorter model
// picker and never a failed sandbox start. It is also dropped from the
// cache, so the next start tries again instead of being stuck without a
// catalogue until the app is relaunched.
const models = fetchModels(apiKey, baseUrl).catch(() => {
if (entry?.key === key) entry = undefined
return []
})
entry = { key, models }
return models
}
}
Loading