From 8818fe5a913c5da405e9f2cae77a2a05cd5c729a Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 17 Aug 2026 11:27:25 +0700 Subject: [PATCH] feat(desktop): give sandboxes the gateway's whole model list A sandbox guest's provider block was built from the auth file alone, which records only the one model chosen at login, so Work offered a single model while Code offered the whole catalogue -- the SSO flow passes fetchModels' result into the same buildGatewayConfig for the local server. resolveSandboxEgress now asks the gateway for the list at start and passes it through, so both sides come from the same source. The lookup goes through Chromium's stack like every other host-side call made on a guest's behalf, so enterprise proxies, PAC files and trust roots apply to it too; that wrapper moves out of the returned egress object to module scope now that the broker is not its only caller. The answer is cached, keyed by the credentials themselves: sandboxes start together at launch and would otherwise each ask, and a machine that cannot reach the gateway would pay the fetch timeout once per sandbox. A rotated token or a different gateway misses the cache by construction, so a stale catalogue cannot outlive the login it came from. A failed lookup resolves to no models rather than rejecting -- the guest falls back to the recorded model, which is exactly what it had before -- and drops the cache entry so the next start retries. Since fetchModels throws rather than returning an empty list, an empty result is exactly the failure signal, and it is logged: a short picker otherwise looks the same as a gateway that really does offer one model. Verified in the app against the real sandbox: the guest config is built from a live lookup ("resolved the gateway model list { models: 1 }"), and this account's gateway does expose exactly one model, matching the local server's config. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 +++ packages/codev-sandbox/README.md | 5 +- packages/desktop/src/main/sandbox/egress.ts | 60 ++++++++++++------ .../src/main/sandbox/gateway-models.test.ts | 61 +++++++++++++++++++ .../src/main/sandbox/gateway-models.ts | 30 +++++++++ 5 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 packages/desktop/src/main/sandbox/gateway-models.test.ts create mode 100644 packages/desktop/src/main/sandbox/gateway-models.ts 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 + } +}