From 6a9d2e74f5c90399b6fa98810630e00c16819653 Mon Sep 17 00:00:00 2001 From: Minh Nguyen <44449792+quickbeard@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:53:47 +0700 Subject: [PATCH 1/3] fix(sandbox): give each WSL sandbox its own guest broker stub port Every backend parked the guest-loopback broker stub on 8788, which is fine for vz/bwrap (private guest loopbacks) but not for WSL: all WSL2 distros share the VM loopback, so a second concurrent sandbox's socat lost the bind silently and its model traffic landed on the first sandbox's broker, where the per-start auth secret rejected it. The WSL provider now reserves a per-sandbox stub port (against the Windows loopback, where WSL republishes guest binds) and resolveEgress receives the port so the gateway guest config points at it; vz and bwrap pass their fixed 8788. e2e-wsl gains --name for concurrent instances and per-instance broker markers so cross-sandbox leakage fails loudly. Verified live: two sandboxes ran concurrently, each probe returned its own broker's marker, and the first sandbox's broker still answered correctly after the second's full lifecycle. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++++ packages/codev-sandbox/script/e2e-wsl.ts | 18 ++++++++--- packages/codev-sandbox/src/bwrap/provider.ts | 8 +++-- packages/codev-sandbox/src/vz/provider.ts | 8 +++-- .../codev-sandbox/src/wsl/provider.test.ts | 32 ++++++++++++------- packages/codev-sandbox/src/wsl/provider.ts | 26 +++++++++------ packages/desktop/src/main/sandbox/egress.ts | 20 ++++++------ 7 files changed, 79 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d796aec37..b102d67316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ file is the human-readable summary of what changed and why. open. **Requires the updated sandbox image** — after updating, use a sandbox's **Reset** button to pick it up; a sandbox still on the old image fails to start with "nft is missing from the guest image". +- **Two Windows sandboxes no longer cross their model traffic** — every WSL + sandbox parked its in-guest relay on the same port, but all WSL + distributions share one loopback, so with two sandboxes running the second + relay silently failed to bind and the second sandbox's model requests landed + on the first sandbox's relay (and were rejected there). Each sandbox now + gets its own relay port. macOS and Linux sandboxes were never affected — + their guests do not share a loopback. - **Chat now works inside Windows sandboxes** — every model request from a WSL sandbox failed with `Cannot connect to API: other side closed` and retried forever. The guest-side bridge that relays model traffic to the desktop app diff --git a/packages/codev-sandbox/script/e2e-wsl.ts b/packages/codev-sandbox/script/e2e-wsl.ts index 57ee926311..f3cb7dc773 100644 --- a/packages/codev-sandbox/script/e2e-wsl.ts +++ b/packages/codev-sandbox/script/e2e-wsl.ts @@ -36,6 +36,9 @@ const bundle = resolve(requireArg("--bundle")) const hold = process.argv.includes("--hold") const keep = process.argv.includes("--keep") const egress = process.argv.includes("--egress") +// --name lets two instances run concurrently (distinct distros, brokers and +// stub ports) — which is exactly what the shared-VM-loopback regression needs. +const name = argValue("--name") ?? "e2e" const work = await mkdtemp(join(tmpdir(), "codev-wsl-e2e-")) const projectDir = argValue("--project") ? resolve(argValue("--project")!) : join(work, "project") @@ -50,7 +53,10 @@ for await (const chunk of payload.stream()) hash.update(chunk) const sha256 = hash.digest("hex") const server = Bun.serve({ port: 0, fetch: () => new Response(Bun.file(image)) }) -const upstream = egress ? Bun.serve({ port: 0, fetch: () => Response.json({ marker: "broker-upstream" }) }) : null +// The marker carries this instance's name so a stub that bridged to another +// sandbox's broker (the shared-loopback collision) shows up as a wrong marker +// rather than a false pass. +const upstream = egress ? Bun.serve({ port: 0, fetch: () => Response.json({ marker: `broker-${name}` }) }) : null const provider = createWslProvider({ dataDir: join(work, "data"), @@ -81,7 +87,7 @@ if (!availability.supported) { } if (availability.warning) console.log(`warning: ${availability.warning}`) -const config: SandboxConfig = { id: "sandbox:e2e", name: "e2e", backend: "wsl", projectDir } +const config: SandboxConfig = { id: `sandbox:${name}`, name, backend: "wsl", projectDir } console.time("create") await provider.create(config) @@ -120,11 +126,15 @@ if (egress) { `s.on('timeout',()=>{console.log('TIMEOUT');process.exit(2)});` + `s.on('error',(e)=>{console.log('BLOCKED:'+e.code);process.exit(3)})"` + // The stub port is per sandbox now; read it off the running socat bridge + // instead of assuming a fixed number. const broker = await runGuestScript([ enterCgroup, - `setpriv --reuid=codev --regid=codev --init-groups node -e "fetch('http://127.0.0.1:8788/probe/x').then((r)=>r.text()).then((t)=>console.log('BROKER:'+t)).catch((e)=>{console.log('BROKER-FAILED:'+e.message);process.exit(4)})"`, + `STUB_PORT=$(ps -eo args | sed -n 's/.*TCP-LISTEN:\\([0-9]*\\),bind=127.*/\\1/p' | head -1)`, + `[ -n "$STUB_PORT" ] || { echo BROKER-FAILED:no-stub; exit 4; }`, + `STUB_PORT=$STUB_PORT setpriv --reuid=codev --regid=codev --init-groups node -e "fetch('http://127.0.0.1:'+process.env.STUB_PORT+'/probe/x').then((r)=>r.text()).then((t)=>console.log('BROKER:'+t)).catch((e)=>{console.log('BROKER-FAILED:'+e.message);process.exit(4)})"`, ]) - const brokerOk = broker.includes(`BROKER:{"marker":"broker-upstream"}`) + const brokerOk = broker.includes(`BROKER:{"marker":"broker-${name}"}`) console.log(`broker via cgroup: ${brokerOk ? "OK" : `FAILED (${broker.trim()})`}`) const control = argValue("--egress-control") ?? `${await hostAddress()}:443` diff --git a/packages/codev-sandbox/src/bwrap/provider.ts b/packages/codev-sandbox/src/bwrap/provider.ts index 9b063b48b7..cbaf925c84 100644 --- a/packages/codev-sandbox/src/bwrap/provider.ts +++ b/packages/codev-sandbox/src/bwrap/provider.ts @@ -34,8 +34,10 @@ export type BwrapProviderOptions = { 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 + // Absent or null, the guest boots with no egress at all. guestBrokerPort is + // fixed: the bwrap guest's network namespace (and thus loopback) is private, + // so the stub port cannot collide with another sandbox's. + resolveEgress?: (opts: { guestBrokerPort: number }) => Promise bwrapCommand?: string guestNodeCommand?: string platform?: NodeJS.Platform @@ -115,7 +117,7 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi // 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 egress = (await options.resolveEgress?.({ guestBrokerPort: GUEST_BROKER_PORT })) ?? null // No routes means no egress at all — the config injection below can // still apply without a listener. const broker = egress?.routes.length diff --git a/packages/codev-sandbox/src/vz/provider.ts b/packages/codev-sandbox/src/vz/provider.ts index ba008e82fa..a1447dbfec 100644 --- a/packages/codev-sandbox/src/vz/provider.ts +++ b/packages/codev-sandbox/src/vz/provider.ts @@ -36,8 +36,10 @@ export type VzProviderOptions = { 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 + // Absent or null, the guest boots with no egress at all. guestBrokerPort is + // fixed: the image's codev-init bakes the loopback stub on 8788, and the vz + // guest's loopback is private so it cannot collide. + resolveEgress?: (opts: { guestBrokerPort: number }) => Promise // Defaults for sandboxes that do not set their own. cpus?: number memoryMiB?: number @@ -131,7 +133,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { // 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 egress = (await options.resolveEgress?.({ guestBrokerPort: VZ_BROKER_VSOCK_PORT })) ?? null // No routes means no egress at all — the config injection below can // still apply without a listener. const broker = egress?.routes.length diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index cf52850120..90cf67c965 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -180,18 +180,22 @@ test("egress wires the broker bridge, guest config, and CODEV_CONFIG", async () fetch: (request) => Response.json({ authorization: request.headers.get("authorization") }), }) const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const seenStubPorts: number[] = [] 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}`, - }), + resolveEgress: async (opts) => { + seenStubPorts.push(opts.guestBrokerPort) + return { + routes: [ + { + prefix: "/probe", + upstream: `http://127.0.0.1:${upstream.port}`, + headers: { authorization: "Bearer real" }, + }, + ], + guestConfig: `{"marker":true}`, + } + }, }), ) await provider.create(config) @@ -199,7 +203,13 @@ test("egress wires the broker bridge, guest config, and CODEV_CONFIG", async () 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:") + // The stub port handed to resolveEgress (for the guest config) is the one + // the socat bridge actually listens on, and it is reserved per sandbox + // rather than shared: the WSL VM loopback is one namespace for all distros. + const stubPort = Number(state.bootScript.match(/socat TCP-LISTEN:(\d+),bind=127\.0\.0\.1,fork,reuseaddr/)?.[1]) + expect(seenStubPorts).toEqual([stubPort]) + expect(stubPort).toBeGreaterThan(0) + expect(state.bootScript).not.toContain("TCP-LISTEN:8788,") expect(state.codevConfigEnv).toBe("/codev-config/codev.json") expect(await Bun.file(join(dir, "data", config.name, "config", "codev.json")).text()).toBe(`{"marker":true}`) diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index 63d29ac218..2a8f597356 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -38,10 +38,10 @@ export type WslProviderOptions = { 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 + // so the broker listens on TCP. guestBrokerPort is where the stub listens on + // the guest loopback — per-sandbox here because every WSL2 distro shares the + // VM loopback, so a fixed port would collide across concurrent sandboxes. + resolveEgress?: (opts: { guestBrokerPort: number }) => Promise wslCommand?: string platform?: NodeJS.Platform osRelease?: () => string @@ -180,8 +180,14 @@ 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() + // The broker stub cannot sit on a fixed guest port here: every WSL2 + // distro shares the VM loopback, so a second sandbox's stub would lose + // the bind silently and its model traffic would land on the first + // sandbox's broker. Reserving against the Windows loopback is the right + // uniqueness domain because WSL republishes guest loopback binds there. + const guestBrokerPort = await reserveLoopbackPort() - const egress = (await options.resolveEgress?.()) ?? null + const egress = (await options.resolveEgress?.({ guestBrokerPort })) ?? null // No routes means no egress at all — the config injection below can // still apply without a listener. const broker = egress?.routes.length @@ -213,10 +219,10 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider `cp -Rn /etc/skel/. "$HOME"/ 2>/dev/null || true`, `chown -R codev:codev "$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. The gateway can - // lag the distro on a cold VM boot, so poll briefly instead of - // capturing whatever the route table says first. + // The guest-loopback broker stub; on WSL it relays to the host's TCP + // broker at the NAT gateway. The gateway can lag the distro on a cold + // VM boot, so poll briefly instead of capturing whatever the route + // table says first. ...(brokerPort !== null ? [ `HOST_IP=""`, @@ -232,7 +238,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider // confined cgroup and rides the broker allow rule. ...wslFirewallScript({ distro, serverPort: port, brokerPort }), ...(brokerPort !== null - ? [`socat TCP-LISTEN:8788,bind=127.0.0.1,fork,reuseaddr TCP:$HOST_IP:${brokerPort} &`] + ? [`socat TCP-LISTEN:${guestBrokerPort},bind=127.0.0.1,fork,reuseaddr TCP:$HOST_IP:${brokerPort} &`] : []), `cd ${shellQuote(await guestPath(distro, config.projectDir))}`, // Root did the bind mounts and the socat bridge; the agent itself runs diff --git a/packages/desktop/src/main/sandbox/egress.ts b/packages/desktop/src/main/sandbox/egress.ts index 231f142332..81389e9598 100644 --- a/packages/desktop/src/main/sandbox/egress.ts +++ b/packages/desktop/src/main/sandbox/egress.ts @@ -6,17 +6,19 @@ import { buildGatewayConfig, loadApiKey, loadGatewayUrl, normalizeGatewayBaseUrl import { parseBrokerRules } from "@codev/sandbox" 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. Without a gateway // login the routes are empty (no broker, no egress at all), but the guest // config is still injected for the permission default below. -export async function resolveSandboxEgress(): Promise { +// +// guestBrokerPort is where this sandbox's guest-loopback stub bridges to the +// host broker, whatever the transport (vsock, unix socket, NAT TCP). vz and +// bwrap guests have private loopbacks and pass a fixed 8788; the WSL backend +// passes a per-sandbox port because all WSL2 distros share one VM loopback. +// buildGatewayConfig normalizes the base to exactly one trailing /v1, which +// is also the broker route prefix below. +export async function resolveSandboxEgress(opts: { guestBrokerPort: number }): Promise { + const guestBrokerBase = `http://127.0.0.1:${opts.guestBrokerPort}/llm` const creds = loadApiKey() // base_url is only written by the custom-provider flow; an SSO login records // gateway_url instead, and the config it writes is that plus /v1. Falling @@ -49,7 +51,7 @@ export async function resolveSandboxEgress(): Promise { guestConfig: JSON.stringify( { ...(gateway - ? buildGatewayConfig({ apiKey: auth, baseUrl: GUEST_BROKER_BASE, model: gateway.model }) + ? buildGatewayConfig({ apiKey: auth, baseUrl: guestBrokerBase, model: gateway.model }) : {}), // Everything the agent does is confined to the VM and the one mounted // project folder, so permission prompts buy nothing here — auto-allow @@ -82,7 +84,7 @@ export async function resolveSandboxEgress(): Promise { // Optional admin-editable allowlist of extra egress routes (a mirror registry, // an internal API): userData/sandbox-egress.json, an object of name → -// upstream URL. Reachable in the guest at http://127.0.0.1:8788//... +// upstream URL. Reachable in the guest at the broker stub's //... path // A malformed file throws, so the sandbox start fails with the parse message // instead of silently granting less than the admin intended. async function namedEgressRules() { From 307a53b3c889bb68e114f4f297d83adaa469c0ea Mon Sep 17 00:00:00 2001 From: Minh Nguyen <44449792+quickbeard@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:58:17 +0700 Subject: [PATCH 2/3] chore(sandbox): force LF checkouts for guest image sources Docker COPYs these files into Linux guest images verbatim, so a CRLF checkout (Windows autocrlf default) ships \r into the guest: the office layer died on `set: pipefail: invalid option name` when built from a Windows working tree, and codev-init would break the same way as the vz guest's PID 1. Co-Authored-By: Claude Fable 5 --- packages/codev-sandbox/.gitattributes | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/codev-sandbox/.gitattributes diff --git a/packages/codev-sandbox/.gitattributes b/packages/codev-sandbox/.gitattributes new file mode 100644 index 0000000000..13b8a03cf5 --- /dev/null +++ b/packages/codev-sandbox/.gitattributes @@ -0,0 +1,5 @@ +# Guest-image sources are COPY'd into Linux images verbatim, so a CRLF +# checkout (Windows default) ships \r into the guest: bash rejects +# `set -o pipefail\r` at image build and codev-init would break as vz PID 1. +image/** text eol=lf +script/*.ts text eol=lf From ee0bdad3b376018b6278826dc88c17776060b67a Mon Sep 17 00:00:00 2001 From: Minh Nguyen <44449792+quickbeard@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:18:38 +0700 Subject: [PATCH 3/3] fix(sandbox): manifest asset URLs use basename, not split("/") A Windows image build hands describeAsset a backslash path, which split("/") returns whole, so the manifest URL embedded the local build path verbatim. Co-Authored-By: Claude Fable 5 --- packages/codev-sandbox/script/build-image.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/codev-sandbox/script/build-image.ts b/packages/codev-sandbox/script/build-image.ts index b3682f4f7a..ea57677d47 100644 --- a/packages/codev-sandbox/script/build-image.ts +++ b/packages/codev-sandbox/script/build-image.ts @@ -27,7 +27,7 @@ import { $ } from "bun" import { createHash } from "node:crypto" import { cp, mkdir, readdir, rm, stat, writeFile } from "node:fs/promises" import { arch as hostArch } from "node:os" -import { join } from "node:path" +import { basename, join } from "node:path" import { fileURLToPath } from "node:url" import { SANDBOX_IMAGES_BASE_URL } from "../src/const" import { parseSandboxManifest, type SandboxAsset } from "../src/manifest" @@ -207,7 +207,9 @@ async function describeAsset(path: string): Promise { const hash = createHash("sha256") for await (const chunk of file.stream()) hash.update(chunk) return { - url: `${SANDBOX_IMAGES_BASE_URL}/${version}/${path.split("/").pop()}`, + // basename, not split("/"): a Windows build hands this a backslash path, + // which split("/") returns whole and the manifest URL swallows verbatim. + url: `${SANDBOX_IMAGES_BASE_URL}/${version}/${basename(path)}`, sha256: hash.digest("hex"), bytes: file.size, }