From fd304d6f1c17d7b0b2263f4f50d85347354e496c Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 13 Aug 2026 10:44:32 +0700 Subject: [PATCH] feat(sandbox): serve the egress broker and inject the gateway config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every backend gains a resolveEgress hook: at start the provider serves the broker on the runtime socket (TCP at the NAT gateway for WSL, which also grows a socat stub on guest loopback 8788), writes an optional guest codev.json, and announces it via CODEV_CONFIG — merged between the guest's global and project configs, so no image respin is needed. Broker routes can now carry header overrides: the guest holds a placeholder credential and the broker swaps in the real gateway token host-side, so it never enters the sandbox. The desktop resolves egress from the hub auth file at each sandbox start (rotation needs no restart), builds the guest provider block with buildGatewayConfig pointed at the guest broker stub, and forwards upstream through Electron's net stack so host proxy/PAC/trust roots apply. e2e-vz now proves the chain live: a guest pty curls the broker and the host reads the response from the shared workspace. Verified: vz e2e health OK / path api OK / broker OK on the Node-24 image; sandbox suite 75 pass, desktop 76 pass. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 ++++ DIVERGENCES.md | 2 +- bun.lock | 1 + packages/codev-sandbox/script/e2e-vz.ts | 40 +++++++++++++++- packages/codev-sandbox/src/broker.test.ts | 31 +++++++++++++ packages/codev-sandbox/src/broker.ts | 38 ++++++++++++--- packages/codev-sandbox/src/bwrap/provider.ts | 17 +++++++ packages/codev-sandbox/src/vz/provider.ts | 22 ++++++++- .../codev-sandbox/src/wsl/provider.test.ts | 46 +++++++++++++++++++ packages/codev-sandbox/src/wsl/provider.ts | 43 +++++++++++++++-- packages/desktop/package.json | 1 + .../desktop/src/main/sandbox/controller.ts | 2 + packages/desktop/src/main/sandbox/egress.ts | 40 ++++++++++++++++ 13 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 packages/desktop/src/main/sandbox/egress.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 073dc1b90a..2bf372fdf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Sandboxes can now talk to the model gateway** — each running sandbox gets + a host-side egress broker: the guest (which has no network access of its + own) reaches exactly one brokered path that forwards to the configured LLM + gateway, with the real gateway token attached on the host so it never + enters the sandbox. The upstream call is made with the host's proxy + settings, PAC scripts, and enterprise trust roots. Sandboxes created + without a gateway login simply have no egress, as before. + - **Sandboxes (preview)** — the desktop app can now create isolated sandbox servers that run the codev server inside a lightweight VM or container (Virtualization.framework on macOS, a managed WSL distribution on Windows, diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 08592dec5c..5148af8d36 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -189,7 +189,7 @@ from the squash message first, or use a plain merge. | `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev Code…"; artifactName → `codev-code-desktop-…`; protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. Also the sandbox vz helper: `mac.extraResources` ships `packages/codev-sandbox/native/vzhelper/dist/codev-vmm` (built universal by `scripts/prebuild.ts`) as `Resources/codev-vmm`, and an `afterSign` hook (`signVmmHelper`) re-signs it with `vzhelper.entitlements` (`com.apple.security.virtualization` — an entitlement the Electron app itself must not carry) under the same identity that signed the app, hardened runtime on, timestamp only for non-ad-hoc, before electron-builder's built-in notarization runs. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which answers a multipart range request with the whole object; electron-updater hardcodes the same `false` for its own `s3` provider citing minio/minio#5285 but defaults it to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `UPDATER_ENABLED` covers that channel and a build with no publish config gets no `app-update.yml` at all, so every check would fail with ENOENT. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId (`ai.opencode.desktop`) and the `opencode://` deep-link scheme stay upstream — identity plumbing wired into main/index.ts and app deep-links; do not rename casually. `rpm.packageName` (`opencode`/`opencode-beta`/`opencode-dev`) also stays — it names the installed Linux package, not a publish target. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource and the `codev-vmm` extraResource + `afterSign` hook (all covered by `electron-builder.config.test.ts`) — renaming either strands packaged sandbox providers, and dropping `afterSign` ships a helper without the virtualization entitlement, which fails at VM creation (or fails notarization if left ad-hoc signed). | | `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. | Keep `false`; take upstream's other updater settings. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). | | Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. | -| Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` workspace devDependency (bundled into the main process by electron-vite, so not a runtime dependency). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. | +| Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. | | Desktop sandbox renderer slice: `packages/app/package.json`, `packages/app/src/{index.ts,app.tsx}`, `packages/app/src/context/{platform,server}.tsx`, `packages/app/src/components/{settings-v2/servers.tsx,dialog-select-server.tsx}`, `packages/app/src/wsl/settings.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Surfaces the fork-only sandbox modules (`packages/app/src/sandbox/*`, `packages/desktop/src/renderer/sandbox/*`) in the UI: app `package.json` exports `./sandbox/types` (mirror of `./wsl/types`); `index.ts` re-exports `useSandboxes` + the sandbox types; `app.tsx` mounts `SandboxesProvider` inside `WslServersProvider`; `platform.tsx` gains optional `sandboxes?: SandboxesPlatform`; `server.tsx` adds the third `ServerConnection.Sidecar` variant `{ variant: "sandbox"; name }` with key `sandbox:`; `settings-v2/servers.tsx` filters sandbox rows out of the generic list and renders `SandboxServerSettings` beside the WSL section; `dialog-select-server.tsx` `handleRemove` routes `sandbox:` keys to `platform.sandboxes.remove` (like `wsl:` keys); `wsl/settings.tsx` `AddServerMenu` shows the menu when either wslServers or sandboxes exist and gains an "Add sandbox" item; i18n gains the `sandbox.*` key family; preload exposes `api.sandboxes` over the `sandbox-*` channels and `renderer/index.tsx` wires the platform api, loading gate, ready-connection list, and the `startupServer` fallback (fork-only wrapper that validates `sandbox:` default keys the way `availableStartupServer` validates `wsl:` ones). | Ready sandboxes must appear as first-class servers and be manageable from settings; the slice mirrors the WSL vertical exactly so upstream conflicts resolve the same way in both. | Keep every listed edit; take upstream's surrounding changes. If upstream adds a `Sidecar` variant or reworks `AddServerMenu`, re-apply the sandbox variant/menu item on top. The `sandbox:` key format is persisted as the default-server key and as `config.id` in the main-process store — never change one side alone. | | `script/publish.ts` | The `if (Script.release)` block calling `packages/desktop/scripts/finalize-latest-{json,yml}.ts` removed, replaced by a comment. | Both finalizers existed to assemble update metadata into a GitHub release, which the fork no longer publishes to. electron-builder already writes a correct per-platform `latest*.yml` next to each installer, and the fork builds one arch per platform (macOS is universal — see the universal-builds row), so there is nothing left to merge. | Keep removed. If upstream adds further desktop release steps here, drop them unless they target the bucket. | | `packages/desktop/scripts/finalize-latest-json.ts`, `packages/desktop/scripts/finalize-latest-yml.ts` | Deleted. | Both `gh release upload` into a GitHub release the fork does not use. `finalize-latest-yml.ts` merged per-arch `latest*.yml` from an upstream build matrix the fork does not run; `finalize-latest-json.ts` additionally built a Tauri-style `latest.json` signed with `@tauri-apps/cli signer` that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded `opencode-desktop-mac-{x64,arm64}.app.tar.gz`, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If a future build matrix produces two `latest.yml` files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. | diff --git a/bun.lock b/bun.lock index 41a809b14d..4a20d4572f 100644 --- a/bun.lock +++ b/bun.lock @@ -417,6 +417,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@codev/gateway": "workspace:*", "@codev/sandbox": "workspace:*", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/packages/codev-sandbox/script/e2e-vz.ts b/packages/codev-sandbox/script/e2e-vz.ts index 41b2740fac..34287b52d1 100644 --- a/packages/codev-sandbox/script/e2e-vz.ts +++ b/packages/codev-sandbox/script/e2e-vz.ts @@ -28,7 +28,13 @@ await writeFile(join(projectDir, "hello.txt"), "hello from the host project\n") const payload = Bun.file(image) const hash = createHash("sha256") for await (const chunk of payload.stream()) hash.update(chunk) -const server = Bun.serve({ port: 0, fetch: () => new Response(Bun.file(image)) }) +const server = Bun.serve({ + port: 0, + fetch: (request) => + new URL(request.url).pathname === "/broker-selftest" + ? new Response("broker-ok") + : new Response(Bun.file(image)), +}) const provider = createVzProvider({ dataDir: join(work, "data"), @@ -40,6 +46,11 @@ const provider = createVzProvider({ sha256: hash.digest("hex"), bytes: payload.size, }), + // The image server doubles as the broker upstream: guest 127.0.0.1:8788 → + // vsock → host broker → this Bun.serve. + resolveEgress: async () => ({ + routes: [{ prefix: "/probe", upstream: `http://127.0.0.1:${server.port}` }], + }), startTimeoutMs: 180_000, }) @@ -70,6 +81,31 @@ const pathsOk = await fetch(`${sandbox.url}/path`, { .catch(() => false) console.log(`path api: ${pathsOk ? "OK" : "FAILED"}`) +// Egress: have the guest itself pull from the broker (curl over the guest's +// loopback stub) and drop the response into the shared workspace, then read +// it host-side — proves the whole guest→vsock→broker→upstream chain. +const auth = `Basic ${Buffer.from(`${sandbox.username ?? ""}:${sandbox.password}`).toString("base64")}` +await fetch(`${sandbox.url}/api/pty`, { + method: "POST", + headers: { authorization: auth, "content-type": "application/json" }, + body: JSON.stringify({ + command: "/bin/sh", + args: ["-c", "curl -s --max-time 15 http://127.0.0.1:8788/probe/broker-selftest > /workspace/broker-probe.txt 2>&1"], + cwd: "/workspace", + }), +}).catch(() => null) +const brokerOk = await (async () => { + for (let attempt = 0; attempt < 30; attempt++) { + const text = await Bun.file(join(projectDir, "broker-probe.txt")) + .text() + .catch(() => "") + if (text.trim() === "broker-ok") return true + await new Promise((resolve) => setTimeout(resolve, 500)) + } + return false +})() +console.log(`broker: ${brokerOk ? "OK" : "FAILED"}`) + if (hold) { console.log("holding the VM up; Ctrl-C to stop") await new Promise(() => {}) @@ -81,7 +117,7 @@ await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 10_000) await provider.destroy(config) server.stop(true) await rm(work, { recursive: true, force: true }) -if (!healthy || !pathsOk) process.exit(1) +if (!healthy || !pathsOk || !brokerOk) process.exit(1) console.log("e2e OK") process.exit(0) diff --git a/packages/codev-sandbox/src/broker.test.ts b/packages/codev-sandbox/src/broker.test.ts index 025e27c1ad..43ce9088c4 100644 --- a/packages/codev-sandbox/src/broker.test.ts +++ b/packages/codev-sandbox/src/broker.test.ts @@ -91,6 +91,37 @@ test("serves the same routes over a unix socket for netns-isolated guests", asyn await rm(dirname(socketPath), { recursive: true, force: true }) }) +test("route headers replace the guest's placeholder credential", async () => { + const upstream = Bun.serve({ + port: 0, + fetch: (request) => + Response.json({ + authorization: request.headers.get("authorization"), + extra: request.headers.get("x-extra"), + }), + }) + const broker = createBrokerServer({ + routes: [ + { + prefix: "/llm", + upstream: `http://127.0.0.1:${upstream.port}/v1`, + // Mixed case on purpose: node lowercases incoming header names, so the + // override must land case-insensitively to actually replace. + headers: { Authorization: "Bearer real-token", "x-extra": "host-side" }, + }, + ], + }) + const port = await broker.listen() + + const proxied = await fetch(`http://127.0.0.1:${port}/llm/messages`, { + headers: { authorization: "Bearer sandbox-broker" }, + }) + expect(await proxied.json()).toEqual({ authorization: "Bearer real-token", extra: "host-side" }) + + await broker.close() + upstream.stop(true) +}) + test("reports upstream failures as 502 instead of hanging", async () => { const broker = createBrokerServer({ routes: [{ prefix: "/llm", upstream: "http://127.0.0.1:1/nowhere" }], diff --git a/packages/codev-sandbox/src/broker.ts b/packages/codev-sandbox/src/broker.ts index 4f8e76f2f2..df4963dd0e 100644 --- a/packages/codev-sandbox/src/broker.ts +++ b/packages/codev-sandbox/src/broker.ts @@ -8,15 +8,36 @@ import { Readable } from "node:stream" export type BrokerRoute = { prefix: string upstream: string + // Applied over the forwarded headers. The guest sends a placeholder + // credential and the broker swaps in the real one, so tokens never enter + // the guest and rotation stays a host-side concern. + headers?: Record } -export function resolveBrokerTarget(routes: BrokerRoute[], requestUrl: string) { +// What a provider needs to grant a sandbox egress: the broker's route table +// plus an optional codev config written where the guest can read it and +// announced via CODEV_CONFIG — typically pointing the model provider at the +// guest-side broker stub (127.0.0.1:8788). +export type SandboxEgress = { + routes: BrokerRoute[] + guestConfig?: string + fetchImpl?: typeof fetch +} + +export function resolveBrokerRoute(routes: BrokerRoute[], requestUrl: string) { const url = new URL(requestUrl, "http://sandbox-broker.local") const route = routes.find( (candidate) => url.pathname === candidate.prefix || url.pathname.startsWith(`${candidate.prefix}/`), ) if (!route) return null - return `${route.upstream.replace(/\/+$/, "")}${url.pathname.slice(route.prefix.length)}${url.search}` + return { + route, + target: `${route.upstream.replace(/\/+$/, "")}${url.pathname.slice(route.prefix.length)}${url.search}`, + } +} + +export function resolveBrokerTarget(routes: BrokerRoute[], requestUrl: string) { + return resolveBrokerRoute(routes, requestUrl)?.target ?? null } const REQUEST_HEADER_DROP = new Set([ @@ -51,19 +72,24 @@ export function forwardableResponseHeaders(headers: Headers) { export function createBrokerServer(options: { routes: BrokerRoute[]; fetchImpl?: typeof fetch }) { const fetchImpl = options.fetchImpl ?? fetch const server = createServer(async (request, response) => { - const target = resolveBrokerTarget(options.routes, request.url ?? "/") - if (!target) { + const resolved = resolveBrokerRoute(options.routes, request.url ?? "/") + if (!resolved) { response.writeHead(403, { "content-type": "application/json" }) response.end(JSON.stringify({ error: "sandbox egress denied", path: request.url ?? "/" })) return } + // node lowercases incoming header names, so overrides must match case to + // actually replace the guest's placeholder values. + const overrides = Object.fromEntries( + Object.entries(resolved.route.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]), + ) const body = request.method === "GET" || request.method === "HEAD" ? undefined : (Readable.toWeb(request) as RequestInit["body"]) - const upstream = await fetchImpl(target, { + const upstream = await fetchImpl(resolved.target, { method: request.method, - headers: forwardableRequestHeaders(request.headers), + headers: { ...forwardableRequestHeaders(request.headers), ...overrides }, body, ...(body ? { duplex: "half" } : {}), } as RequestInit).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error)))) diff --git a/packages/codev-sandbox/src/bwrap/provider.ts b/packages/codev-sandbox/src/bwrap/provider.ts index 1827f36bf8..67cb6cb764 100644 --- a/packages/codev-sandbox/src/bwrap/provider.ts +++ b/packages/codev-sandbox/src/bwrap/provider.ts @@ -8,6 +8,7 @@ import { pollSandboxHealth, sandboxBootEnv, } from "../boot" +import { createBrokerServer, type SandboxEgress } from "../broker" import { stageAsset } from "../image" import type { SandboxAsset } from "../manifest" import type { SandboxAvailability, SandboxConfig, SandboxProvider } from "../provider" @@ -31,6 +32,10 @@ export type BwrapProviderOptions = { // guest so version parity with the desktop app is automatic. bundleDir: string resolveImage: (opts?: { signal?: AbortSignal }) => Promise + // Grants the guest egress: broker routes served on the runtime broker + // socket, plus an optional guest codev config announced via CODEV_CONFIG. + // Absent or null, the guest boots with no egress at all. + resolveEgress?: () => Promise bwrapCommand?: string guestNodeCommand?: string platform?: NodeJS.Platform @@ -97,6 +102,13 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi await mkdir(dirs.home, { recursive: true }) await writeFile(join(dirs.bridge, "forward.mjs"), GUEST_FORWARD_SCRIPT) + // Listening before boot: the guest-side bridge dials broker.sock per + // connection and drops it when nothing serves the socket. + const egress = (await options.resolveEgress?.()) ?? null + const broker = egress ? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl }) : null + if (broker) await broker.listenUnix(join(dirs.runtime, "broker.sock")) + if (egress?.guestConfig) await writeFile(join(dirs.bridge, "codev.json"), egress.guestConfig) + const credentials = generateSandboxCredentials() const bootScript = [ // Fresh homes get the image's skel (skills config); images may also @@ -121,6 +133,8 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi ...sandboxBootEnv(credentials, GUEST_HOME, { hostname: "127.0.0.1", port: GUEST_SERVER_PORT }), PATH: GUEST_PATH, HOME: GUEST_HOME, + // Merged between the guest's global and project configs. + ...(egress?.guestConfig ? { CODEV_CONFIG: `${GUEST_BRIDGE}/codev.json` } : {}), }, command: ["/bin/sh", "-c", bootScript], }), @@ -152,6 +166,7 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi if (outcome !== "healthy") { child.kill() await forwarder.close() + await broker?.close() const detail = stderrTail.trim() if (outcome === "timeout") throw new Error( @@ -169,10 +184,12 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi stop: () => { child.kill() void forwarder.close() + void broker?.close() }, onExit: (cb) => { void exited.then((exit) => { void forwarder.close() + void broker?.close() cb(exit.code, exit.signal) }) }, diff --git a/packages/codev-sandbox/src/vz/provider.ts b/packages/codev-sandbox/src/vz/provider.ts index 74deabf410..35f23105ab 100644 --- a/packages/codev-sandbox/src/vz/provider.ts +++ b/packages/codev-sandbox/src/vz/provider.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process" import { constants, copyFile, mkdir, rm, stat, writeFile } from "node:fs/promises" import { join } from "node:path" import { checkSandboxHealth, generateSandboxCredentials, pollSandboxHealth, sandboxBootEnv } from "../boot" +import { createBrokerServer, type SandboxEgress } from "../broker" import { forwardTcpToUnix } from "../bwrap/bridge" import { runCommand, unpackRootfs } from "../bwrap/rootfs" import { stageAsset } from "../image" @@ -32,6 +33,10 @@ export type VzProviderOptions = { // The signed codev-vmm binary (native/vzhelper). helperPath: string resolveImage: (opts?: { signal?: AbortSignal }) => Promise + // Grants the guest egress: broker routes served on the runtime broker + // socket, plus an optional guest codev config announced via CODEV_CONFIG. + // Absent or null, the guest boots with no egress at all. + resolveEgress?: () => Promise cpus?: number memoryMiB?: number platform?: NodeJS.Platform @@ -107,11 +112,23 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { await mkdir(dirs.runtime, { recursive: true }) await mkdir(dirs.guestConfig, { recursive: true }) + // The broker must be listening before guest traffic flows: codev-vmm + // dials runtime/broker.sock per guest connection and refuses when + // nothing serves it. + const egress = (await options.resolveEgress?.()) ?? null + const broker = egress ? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl }) : null + if (broker) await broker.listenUnix(join(dirs.runtime, "broker.sock")) + if (egress?.guestConfig) await writeFile(join(dirs.guestConfig, "codev.json"), egress.guestConfig) + const credentials = generateSandboxCredentials() await writeFile( join(dirs.guestConfig, "boot.json"), JSON.stringify({ - env: sandboxBootEnv(credentials, VZ_GUEST_HOME, { hostname: "127.0.0.1", port: VZ_SERVER_VSOCK_PORT }), + env: { + ...sandboxBootEnv(credentials, VZ_GUEST_HOME, { hostname: "127.0.0.1", port: VZ_SERVER_VSOCK_PORT }), + // Merged between the guest's global and project configs. + ...(egress?.guestConfig ? { CODEV_CONFIG: "/codev-config/codev.json" } : {}), + }, }), ) const serverSocket = join(dirs.runtime, "server.sock") @@ -163,6 +180,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { // stderr a beat to flush so failures carry the diagnostic tail. const failure = async (detail: string) => { child.kill() + void broker?.close() await new Promise((resolve) => setTimeout(resolve, 100)) const tail = stderrTail.trim() return new Error(`${detail}${tail ? `: ${tail}` : ""}`) @@ -216,10 +234,12 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { child.stdin?.write('{"cmd":"stop"}\n') stopKill = setTimeout(() => child.kill(), 3000) void forwarder.close() + void broker?.close() }, onExit: (cb) => { void exited.then((exit) => { void forwarder.close() + void broker?.close() cb(exit.code, exit.signal) }) }, diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index ad679ff895..9f36a5b12c 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -40,6 +40,9 @@ const script = args[args.length - 1] if (!script.includes("mount --bind") || !script.includes("server-launch.mjs")) { process.stderr.write("unexpected boot script\\n"); process.exit(2) } +{ + const value = read(); value.bootScript = script; value.codevConfigEnv = process.env.CODEV_CONFIG ?? null; write(value) +} const listener = createServer((socket) => { socket.on("data", () => socket.end("HTTP/1.1 200 OK\\r\\ncontent-length: 2\\r\\nconnection: close\\r\\n\\r\\nok")) }) @@ -146,6 +149,49 @@ test("create imports the distro once and is idempotent", async () => { await rm(dir, { recursive: true, force: true }) }) +test("egress wires the broker bridge, guest config, and CODEV_CONFIG", async () => { + const upstream = Bun.serve({ + port: 0, + fetch: (request) => Response.json({ authorization: request.headers.get("authorization") }), + }) + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider( + await preparedOptions(dir, { + resolveEgress: async () => ({ + routes: [ + { + prefix: "/probe", + upstream: `http://127.0.0.1:${upstream.port}`, + headers: { authorization: "Bearer real" }, + }, + ], + guestConfig: `{"marker":true}`, + }), + }), + ) + await provider.create(config) + const sandbox = await provider.start(config) + + const state = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + expect(state.bootScript).toContain(`mount --bind '/mnt/hostpath' /codev-config`) + expect(state.bootScript).toContain("socat TCP-LISTEN:8788,bind=127.0.0.1,fork,reuseaddr TCP:$HOST_IP:") + expect(state.codevConfigEnv).toBe("/codev-config/codev.json") + expect(await Bun.file(join(dir, "data", config.name, "config", "codev.json")).text()).toBe(`{"marker":true}`) + + // The socat line carries the host broker port; the listener must be live and + // must swap the placeholder credential for the route's real one. + const brokerPort = Number(state.bootScript.match(/TCP:\$HOST_IP:(\d+)/)?.[1]) + const proxied = await fetch(`http://127.0.0.1:${brokerPort}/probe/x`, { + headers: { authorization: "Bearer sandbox-broker" }, + }) + expect(await proxied.json()).toEqual({ authorization: "Bearer real" }) + + sandbox.stop() + await provider.destroy(config) + upstream.stop(true) + await rm(dir, { recursive: true, force: true }) +}) + test("create then start reaches an authenticated health check, and destroy unregisters", async () => { const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) const provider = createWslProvider(await preparedOptions(dir)) diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index 8a3d67a507..db51ed3bdc 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process" -import { mkdir, rm, stat } from "node:fs/promises" +import { mkdir, rm, stat, writeFile } from "node:fs/promises" import { release } from "node:os" import { join } from "node:path" import { @@ -9,6 +9,7 @@ import { pollSandboxHealth, sandboxBootEnv, } from "../boot" +import { createBrokerServer, type SandboxEgress } from "../broker" import { stageAsset } from "../image" import type { SandboxAsset } from "../manifest" import { windowsBuildNumber, windowsSandboxAvailability } from "../policy" @@ -34,6 +35,12 @@ export type WslProviderOptions = { // automatic — the image never bakes a server. bundleDir: string resolveImage: (opts?: { signal?: AbortSignal }) => Promise + // Grants the guest egress via the host-side broker. Unlike vz/bwrap the WSL + // guest reaches the host over the NAT default gateway, not a unix socket, + // so the broker listens on TCP; the egress *firewall* for this backend is + // separate follow-up work — until it lands, WSL isolation rests on the + // guest's own routing. + resolveEgress?: () => Promise wslCommand?: string platform?: NodeJS.Platform osRelease?: () => string @@ -50,7 +57,7 @@ export function wslDistroName(name: string) { export function wslSandboxDirs(dataDir: string, name: string) { const base = join(dataDir, name) - return { base, install: join(base, "distro") } + return { base, install: join(base, "distro"), config: join(base, "config") } } export function createWslProvider(options: WslProviderOptions): SandboxProvider { @@ -135,17 +142,42 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider // Host and guest must agree on one port: WSL2 republishes the guest's // loopback port under the same number on the Windows loopback. const port = await reserveLoopbackPort() + + const egress = (await options.resolveEgress?.()) ?? null + const broker = egress ? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl }) : null + // The guest dials the host at the NAT default gateway address, so the + // broker cannot bind loopback here. + const brokerPort = broker ? await broker.listen("0.0.0.0") : null + const configDir = wslSandboxDirs(options.dataDir, config.name).config + if (egress?.guestConfig) { + await mkdir(configDir, { recursive: true }) + await writeFile(join(configDir, "codev.json"), egress.guestConfig) + } + const env = { ...sandboxBootEnv(credentials, GUEST_HOME, { hostname: "127.0.0.1", port }), HOME: GUEST_HOME, + // Merged between the guest's global and project configs. + ...(egress?.guestConfig ? { CODEV_CONFIG: "/codev-config/codev.json" } : {}), } const bootScript = [ `set -u`, // The image ships an empty GUEST_BUNDLE; binding the host bundle over it // keeps the launcher's import path identical across backends. `mount --bind ${shellQuote(await guestPath(distro, options.bundleDir))} ${GUEST_BUNDLE}`, + ...(egress?.guestConfig + ? [`mount --bind ${shellQuote(await guestPath(distro, configDir))} /codev-config`] + : []), `cp -Rn /etc/skel/. "$HOME"/ 2>/dev/null || true`, `if [ -f /etc/codev-guest.env ]; then . /etc/codev-guest.env; fi`, + // Guest loopback 8788 is the broker stub on every backend; on WSL it + // relays to the host's TCP broker at the NAT gateway. + ...(brokerPort !== null + ? [ + `HOST_IP=$(ip route show default | cut -d" " -f3)`, + `socat TCP-LISTEN:8788,bind=127.0.0.1,fork,reuseaddr TCP:$HOST_IP:${brokerPort} &`, + ] + : []), `cd ${shellQuote(await guestPath(distro, config.projectDir))}`, `exec node ${SANDBOX_GUEST_LAUNCHER}`, ].join("\n") @@ -185,6 +217,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider if (outcome !== "healthy") { child.kill() void wsl(["--terminate", distro]) + await broker?.close() const detail = stderrTail.trim() if (outcome === "timeout") throw new Error( @@ -204,9 +237,13 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider // Killing wsl.exe ends the command but leaves the distro's VM running; // terminating it releases the loopback port mapping too. void wsl(["--terminate", distro]) + void broker?.close() }, onExit: (cb) => { - void exited.then((exit) => cb(exit.code, exit.signal)) + void exited.then((exit) => { + void broker?.close() + cb(exit.code, exit.signal) + }) }, } }, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 87ed66e6ce..7eea2b724c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@codev/gateway": "workspace:*", "@codev/sandbox": "workspace:*", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/packages/desktop/src/main/sandbox/controller.ts b/packages/desktop/src/main/sandbox/controller.ts index b7e10bcd27..015c3c60ab 100644 --- a/packages/desktop/src/main/sandbox/controller.ts +++ b/packages/desktop/src/main/sandbox/controller.ts @@ -14,6 +14,7 @@ import type { SandboxArch, SandboxAvailability, SandboxBackend, SandboxConfig, S import { getStore } from "../store" import { SANDBOXES_KEY } from "../store-keys" import { serverBundleDir } from "./bundle" +import { resolveSandboxEgress } from "./egress" const root = dirname(fileURLToPath(import.meta.url)) @@ -56,6 +57,7 @@ function platformProvider(): SandboxProvider | undefined { const manifest = await fetchSandboxManifest(SANDBOX_MANIFEST_URL, { signal: opts?.signal }) return requireImageAsset(manifest, imageBackend, arch) }, + resolveEgress: resolveSandboxEgress, }) // The manifest publishes bwrap and vz images only: WSL boots the same rootfs // tarball as bubblewrap (wsl --import brings its own kernel), so both diff --git a/packages/desktop/src/main/sandbox/egress.ts b/packages/desktop/src/main/sandbox/egress.ts new file mode 100644 index 0000000000..132ba0bfde --- /dev/null +++ b/packages/desktop/src/main/sandbox/egress.ts @@ -0,0 +1,40 @@ +import { net } from "electron" +import { buildGatewayConfig, loadApiKey, normalizeGatewayBaseUrl } from "@codev/gateway" +import type { SandboxEgress } from "@codev/sandbox" + +// Guest-side view of the broker: every backend bridges guest 127.0.0.1:8788 +// to the host broker, whatever the transport (vsock, unix socket, NAT TCP). +// buildGatewayConfig normalizes this to exactly one trailing /v1, which is +// also the broker route prefix below. +const GUEST_BROKER_BASE = "http://127.0.0.1:8788/llm" + +// Resolved at every sandbox start so a rotated gateway token is picked up by +// the next start rather than requiring an app restart. Null (no gateway login +// on this machine) leaves the sandbox with no egress at all: the guest boots +// and serves, but model calls fail — the same behavior as before the broker. +export async function resolveSandboxEgress(): Promise { + const creds = loadApiKey() + if (!creds?.baseUrl || !creds.model) return null + return { + routes: [ + { + prefix: "/llm/v1", + upstream: normalizeGatewayBaseUrl(creds.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. + headers: { authorization: `Bearer ${creds.apiKey}` }, + }, + ], + guestConfig: JSON.stringify( + buildGatewayConfig({ apiKey: "sandbox-broker", baseUrl: GUEST_BROKER_BASE, model: creds.model }), + 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. 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), + } +}