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), + } +}