diff --git a/CHANGELOG.md b/CHANGELOG.md index 7275f96002..9c2aba68d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/codev-sandbox/README.md b/packages/codev-sandbox/README.md index e97883bed0..b32f2808ba 100644 --- a/packages/codev-sandbox/README.md +++ b/packages/codev-sandbox/README.md @@ -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 diff --git a/packages/desktop/src/main/sandbox/egress.ts b/packages/desktop/src/main/sandbox/egress.ts index 81389e9598..9b2f25f1ae 100644 --- a/packages/desktop/src/main/sandbox/egress.ts +++ b/packages/desktop/src/main/sandbox/egress.ts @@ -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[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]) +} + +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 @@ -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 @@ -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 @@ -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[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]) - }, + fetchImpl: hostFetch, } } diff --git a/packages/desktop/src/main/sandbox/gateway-models.test.ts b/packages/desktop/src/main/sandbox/gateway-models.test.ts new file mode 100644 index 0000000000..b08c8796a3 --- /dev/null +++ b/packages/desktop/src/main/sandbox/gateway-models.test.ts @@ -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((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) +}) diff --git a/packages/desktop/src/main/sandbox/gateway-models.ts b/packages/desktop/src/main/sandbox/gateway-models.ts new file mode 100644 index 0000000000..8810d5f9d6 --- /dev/null +++ b/packages/desktop/src/main/sandbox/gateway-models.ts @@ -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) { + let entry: { key: string; models: Promise } | 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 + } +}