From 7e55232481d31bc098eb7b76e14ee9da84a4c0c8 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Thu, 20 Aug 2026 10:49:58 +0700 Subject: [PATCH 1/2] fix(sandbox): reach the host broker under WSL mirrored networking The guest found the host by assuming NAT: the default gateway is the Windows host. Under mirrored networking (a .wslconfig opt-in common on corporate proxy/VPN machines) the gateway is the LAN router, so the socat bridge dialed the wrong box and every model call failed with "Cannot connect to API: other side closed" while Code side worked. Detect the mode with wslinfo at start, probe an ordered candidate list (shared loopback first when mirrored, NAT gateway first otherwise) before committing to an address, and bind the broker to loopback on mirrored machines where the LAN never needs to see it. When no candidate answers - a host firewall blocking WSL-to-host traffic - the start now fails immediately with a named error instead of surfacing as a cryptic retry loop on the first model call. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 17 +++++ .../codev-sandbox/src/wsl/provider.test.ts | 76 +++++++++++++++++++ packages/codev-sandbox/src/wsl/provider.ts | 45 ++++++++--- 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bdbab7e62..e8e517e9ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,23 @@ file is the human-readable summary of what changed and why. ### Sandbox +- **Work sessions reach the model on WSL's mirrored networking** — on Windows, + a sandbox's model traffic crosses from the WSL guest to a relay on the + Windows host, and the guest found the host by assuming WSL's default NAT + layout: the host is the network gateway. Machines configured with WSL's + mirrored networking mode — a common IT setup on corporate networks with + proxies or VPNs — have no such layout: the gateway is the office router, so + every model call died with a retry loop of `Cannot connect to API: other +side closed` while the Code side worked fine. The guest now checks which + address actually answers before committing — the shared loopback that + mirrored networking provides, or the NAT gateway — and on mirrored machines + the host relay also stops listening on addresses the local network could + reach, since the loopback is all it needs. When neither address answers — + a host firewall blocking WSL-to-host traffic can do this — starting the + sandbox now fails right away and says the host relay is unreachable and why + that might be, instead of leaving the first model call to fail with the + cryptic retry loop minutes later. + - **CoDev installs WSL for you when a sandbox needs it** — a sandbox on Windows runs inside WSL 2, and a computer without it got a dead end: the create-sandbox dialog said WSL was not installed, offered nothing but **Cancel**, and told you diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index 903f05edd6..e4636b2edf 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -37,6 +37,7 @@ if (args[0] === "--unregister") { process.exit(0) } if (args.includes("wslpath")) { process.stdout.write("/mnt/hostpath\\n"); process.exit(0) } +if (args.includes("wslinfo")) { process.stdout.write((read().networkingMode ?? "nat") + "\\n"); process.exit(0) } // The boot script arrives on stdin (never argv — wsl.exe corrupts quoted // spaces there); import-time configuration still uses -- /bin/sh -c. @@ -385,6 +386,81 @@ test("boot confines the server behind the cgroup firewall before socat starts", await rm(dir, { recursive: true, force: true }) }) +// The boot script must not trust the route table: under NAT the default +// gateway is the host, under mirrored networking it is the LAN router — and a +// host firewall can sever either. The probe picks the address that actually +// answers and fails the start with a named error when nothing does. +test("boot probes broker reachability, gateway first under NAT, and fails closed", async () => { + 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:9" }], + guestConfig: `{}`, + }), + }), + ) + await provider.create(config) + const sandbox = await provider.start(config) + + const state = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + const script = state.bootScript as string + expect(script).toContain(`for CANDIDATE in $GATEWAY_IP 127.0.0.1`) + expect(script).toContain("connect-timeout=2") + expect(script).toContain("cannot reach the host broker") + // The probe runs unconfined — before the firewall moves the shell into the + // sandbox cgroup — so it needs no allow rule of its own. + expect(script.indexOf("HOST_IP=$CANDIDATE")).toBeLessThan(script.indexOf("cgroup.procs")) + + sandbox.stop() + await provider.destroy(config) + await rm(dir, { recursive: true, force: true }) +}) + +// Mirrored networking shares the loopback between host and guest, so the host +// is 127.0.0.1 (the NAT gateway address does not exist) and the broker can +// stay off the LAN entirely. +test("mirrored networking prefers the shared loopback and binds the broker to it", 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: `{}`, + }), + }), + ) + await provider.create(config) + const state = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + await Bun.write(join(dir, "state.json"), JSON.stringify({ ...state, networkingMode: "mirrored" })) + const sandbox = await provider.start(config) + + const started = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + const script = started.bootScript as string + expect(script).toContain(`for CANDIDATE in 127.0.0.1 $GATEWAY_IP`) + // The loopback-bound broker still serves the bridge's traffic. + const brokerPort = Number(script.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("routeless egress still injects the guest config but starts no broker", async () => { const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) const provider = createWslProvider( diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index ab99f7a3a9..3ec28cefe7 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -244,9 +244,20 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider const broker = egress?.routes.length ? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl, auth: egress.auth }) : 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 + // Mirrored networking (a .wslconfig opt-in, standard on corporate + // machines where it is the fix for VPNs and proxies) removes the NAT + // topology this bridge assumes: the guest's default gateway is the LAN + // router, not the Windows host, and the host is reachable as 127.0.0.1 + // because the loopback is shared. wslinfo ships with every WSL that has + // mirrored mode, so a failed query means NAT. + const networking = broker + ? await wsl(["-d", distro, "--exec", "wslinfo", "--networking-mode"]).catch(() => null) + : null + const mirrored = networking?.code === 0 && networking.stdout.trim() === "mirrored" + // Under NAT the guest dials the host's vEthernet address, which loopback + // cannot serve; under mirrored networking the shared loopback reaches + // this listener, so it need not (and should not) be open to the LAN. + const brokerPort = broker ? await broker.listen(mirrored ? "127.0.0.1" : "0.0.0.0") : null const configDir = wslSandboxDirs(options.dataDir, config.name).config if (egress?.guestConfig) { await mkdir(configDir, { recursive: true }) @@ -270,18 +281,32 @@ 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`, - // 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. + // Where the guest-loopback broker stub finds the host's TCP broker. + // Under NAT that is the default gateway; under mirrored networking the + // gateway is the LAN router and the host is the shared 127.0.0.1 — so + // probe an ordered candidate list instead of trusting the route table, + // which also turns an unreachable broker (a host firewall blocking + // WSL-to-host traffic) into an immediate, named start failure instead + // of a cryptic connection error on the first model call. The probe + // runs before the firewall confines this shell, so nothing here needs + // an allow rule. 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=""`, + `GATEWAY_IP=""`, `for _ in $(seq 1 50); do`, - ` HOST_IP=$(ip route show default | awk '{print $3; exit}')`, - ` [ -n "$HOST_IP" ] && break`, + ` GATEWAY_IP=$(ip route show default | awk '{print $3; exit}')`, + ` [ -n "$GATEWAY_IP" ] && break`, ` sleep 0.2`, `done`, + `HOST_IP=""`, + `for CANDIDATE in ${mirrored ? "127.0.0.1 $GATEWAY_IP" : "$GATEWAY_IP 127.0.0.1"}; do`, + ` socat -u -T 2 OPEN:/dev/null TCP:$CANDIDATE:${brokerPort},connect-timeout=2 || continue`, + ` HOST_IP=$CANDIDATE`, + ` break`, + `done`, + `[ -n "$HOST_IP" ] || { echo "codev sandbox: the guest cannot reach the host broker on port ${brokerPort} (tried ${mirrored ? "127.0.0.1 and the default gateway" : "the default gateway and 127.0.0.1"}); a firewall may be blocking WSL-to-host traffic" >&2; exit 1; }`, ] : []), // The firewall confines this boot's process tree before anything that From a534af23dd77dc53a323ac083e49381c589d9c80 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Thu, 20 Aug 2026 12:56:32 +0700 Subject: [PATCH 2/2] fix(sandbox): fall back to a host-dialed bridge when the guest cannot reach the broker A managed Windows firewall can silently drop WSL guest-to-host TCP, and users on those machines cannot allow it. The direction WSL always provides is the opposite one: a guest loopback bind is republished on the Windows loopback - the same channel the sandbox's health check and UI traffic already ride - and Windows does not firewall loopback. When the boot probe finds no reachable broker address, the guest now announces tunnel mode on stdout and runs a small pairing daemon instead of the socat stub: it serves the identical model-endpoint stub on the guest loopback plus a rendezvous listener the host dials into, keeping a pool of idle connections. Each server connection is paired with one pooled connection, marked live with a single claim byte, and spliced - one stream per connection, so there is no framing protocol. The daemon travels inside the boot script's stdin delivery; published images need nothing new. Direct mode is unchanged and remains the first choice. Accepted daemon sockets are explicitly paused until pairing pipes them: bytes arriving in the accept-to-pair gap must wait in the kernel buffer rather than race the pipe attachment. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 +- packages/codev-sandbox/src/wsl/bridge.test.ts | 83 +++++++++++ packages/codev-sandbox/src/wsl/bridge.ts | 138 ++++++++++++++++++ .../codev-sandbox/src/wsl/provider.test.ts | 57 +++++++- packages/codev-sandbox/src/wsl/provider.ts | 81 ++++++++-- 5 files changed, 351 insertions(+), 19 deletions(-) create mode 100644 packages/codev-sandbox/src/wsl/bridge.test.ts create mode 100644 packages/codev-sandbox/src/wsl/bridge.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e517e9ab..03bc29c20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,10 +80,13 @@ side closed` while the Code side worked fine. The guest now checks which mirrored networking provides, or the NAT gateway — and on mirrored machines the host relay also stops listening on addresses the local network could reach, since the loopback is all it needs. When neither address answers — - a host firewall blocking WSL-to-host traffic can do this — starting the - sandbox now fails right away and says the host relay is unreachable and why - that might be, instead of leaving the first model call to fail with the - cryptic retry loop minutes later. + a corporate-managed Windows firewall that silently drops WSL-to-host + traffic can do this, and users on managed machines cannot allow it — the + connection is turned around instead of given up on: the app dials into the + sandbox over the same localhost channel it already uses to talk to the + sandbox's own server, the one direction Windows does not firewall, and + model traffic rides those app-initiated connections. Work sessions now + function on locked-down corporate machines with nothing to configure. - **CoDev installs WSL for you when a sandbox needs it** — a sandbox on Windows runs inside WSL 2, and a computer without it got a dead end: the create-sandbox diff --git a/packages/codev-sandbox/src/wsl/bridge.test.ts b/packages/codev-sandbox/src/wsl/bridge.test.ts new file mode 100644 index 0000000000..9534533b9d --- /dev/null +++ b/packages/codev-sandbox/src/wsl/bridge.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "bun:test" +import { spawn } from "node:child_process" +import { connect } from "node:net" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createBrokerServer } from "../broker" +import { startWslReverseTunnel, WSL_GUEST_BRIDGE_SOURCE } from "./bridge" +import { reserveLoopbackPort } from "./cli" + +// The boot script ships the daemon inside a <<'CODEV_BRIDGE_EOF' heredoc; the +// source containing that delimiter would truncate itself in transit. +test("the guest bridge source cannot collide with its heredoc delivery", () => { + expect(WSL_GUEST_BRIDGE_SOURCE).not.toContain("CODEV_BRIDGE_EOF") +}) + +// The whole reverse path, with real sockets in every role: the guest daemon +// as a child process, the host pool dialing into its rendezvous listener, and +// requests entering the stub as raw bytes the way the guest server's HTTP +// client would send them. Concurrent requests exercise pairing and pool +// replenishment. Raw sockets rather than fetch: bun's fetch pool has its own +// concurrency behavior, and this test is about the bridge carrying bytes. +test("the reverse bridge carries brokered requests end to end", async () => { + const upstream = Bun.serve({ + port: 0, + fetch: async (request) => + Response.json({ echo: await request.text(), authorization: request.headers.get("authorization") }), + }) + const broker = createBrokerServer({ + routes: [ + { prefix: "/llm", upstream: `http://127.0.0.1:${upstream.port}`, headers: { authorization: "Bearer real" } }, + ], + }) + const brokerPort = await broker.listen() + + const dir = await mkdtemp(join(tmpdir(), "wsl-bridge-")) + const daemon = join(dir, "bridge.mjs") + await writeFile(daemon, WSL_GUEST_BRIDGE_SOURCE) + const stubPort = await reserveLoopbackPort() + const rendezvousPort = await reserveLoopbackPort() + const guest = spawn(process.execPath, [daemon, String(stubPort), String(rendezvousPort)], { + stdio: ["ignore", "ignore", "inherit"], + }) + const tunnel = startWslReverseTunnel({ rendezvousPort, brokerPort, retryMs: 50 }) + + const viaStub = async (marker: string) => { + for (let attempt = 0; attempt < 100; attempt += 1) { + const reply = await new Promise((resolve) => { + const socket = connect({ host: "127.0.0.1", port: stubPort }) + let data = "" + socket.on("data", (chunk) => { + data = `${data}${chunk}` + }) + // write, never end: a FIN would read as connection close to the + // daemon's paired sockets and tear the chain down before the response. + // connection: close makes the broker close the chain after answering, + // which surfaces here as 'close' with the response buffered. + socket.write( + `POST /llm/${marker} HTTP/1.1\r\nhost: stub\r\nauthorization: Bearer sandbox-broker\r\ncontent-length: ${marker.length}\r\nconnection: close\r\n\r\n${marker}`, + ) + socket.setTimeout(2_000, () => socket.destroy()) + socket.on("error", () => {}) + socket.on("close", () => resolve(data.length ? data : null)) + }) + if (reply) return reply + await Bun.sleep(50) + } + throw new Error("the stub listener never answered") + } + const replies = await Promise.all(["a", "b", "c"].map(viaStub)) + for (const [index, marker] of ["a", "b", "c"].entries()) { + expect(replies[index]).toStartWith("HTTP/1.1 200") + expect(replies[index]).toContain(`"echo":"${marker}"`) + // The broker swapped the guest's placeholder credential for the real one. + expect(replies[index]).toContain(`"authorization":"Bearer real"`) + } + + tunnel.stop() + guest.kill() + await broker.close() + upstream.stop(true) + await rm(dir, { recursive: true, force: true }) +}, 15_000) diff --git a/packages/codev-sandbox/src/wsl/bridge.ts b/packages/codev-sandbox/src/wsl/bridge.ts new file mode 100644 index 0000000000..14163f1ccf --- /dev/null +++ b/packages/codev-sandbox/src/wsl/bridge.ts @@ -0,0 +1,138 @@ +import { connect, type Socket } from "node:net" + +// Reverse bridge for guests whose direct TCP dial to the host broker is +// severed — a managed Windows host firewall (GPO, endpoint agents) can drop +// guest-to-host traffic on the WSL vEthernet interface, and users on those +// machines cannot allow it. The opposite direction is the one WSL always +// provides: a guest loopback bind is republished on the Windows loopback, +// the same mechanism the sandbox's health check and UI traffic already ride, +// and Windows does not firewall loopback. So the host dials INTO the guest +// and keeps a pool of idle connections; the guest pairs each server +// connection with one, marks it live with a single claim byte, and bytes +// splice both ways from there. Each pooled connection carries exactly one +// stream — connection pairing, not multiplexing — so there is no framing +// protocol to get wrong. +const WSL_BRIDGE_CLAIM = 1 + +// Source of the guest-side pairing daemon, kept as text: the boot script +// writes it into the guest over the same stdin delivery the script itself +// uses, so no image change and no extra mount is needed — the image's node, +// which already runs the server launcher, runs this too. +// argv: ; both listeners bind the guest +// loopback. The stub port is where the guest config points the model +// provider, identical to what socat serves in direct mode. +export const WSL_GUEST_BRIDGE_SOURCE = `import { createServer } from "node:net" +const stubPort = Number(process.argv[2]) +const rendezvousPort = Number(process.argv[3]) +const idle = [] +const waiting = [] +const unlist = (list, socket) => { + const index = list.indexOf(socket) + if (index >= 0) list.splice(index, 1) +} +const pair = () => { + while (idle.length && waiting.length) { + const host = idle.shift() + const server = waiting.shift() + host.write(Buffer.from([${WSL_BRIDGE_CLAIM}])) + server.pipe(host) + host.pipe(server) + const drop = () => { + host.destroy() + server.destroy() + } + host.on("close", drop) + server.on("close", drop) + } +} +// Explicitly paused until pair() pipes them: bytes that arrive between accept +// and pairing must wait in the kernel buffer, not race the pipe attachment. +createServer((socket) => { + socket.pause() + socket.on("error", () => {}) + socket.on("close", () => unlist(idle, socket)) + idle.push(socket) + pair() +}).listen(rendezvousPort, "127.0.0.1") +createServer((socket) => { + socket.pause() + socket.on("error", () => {}) + socket.on("close", () => unlist(waiting, socket)) + waiting.push(socket) + pair() +}).listen(stubPort, "127.0.0.1") +` + +// Host half: keep poolSize idle connections dialed into the guest's +// rendezvous listener (via the Windows-loopback republish of the guest bind). +// A claim byte from the guest promotes a pooled connection into a live +// stream: everything after that byte is request data, spliced to a fresh +// local connection to the broker. Unclaimed drops (guest not up yet, listener +// gone) re-dial on a timer — a refused loopback dial is nearly free. +export function startWslReverseTunnel(options: { + rendezvousPort: number + brokerPort: number + poolSize?: number + retryMs?: number +}) { + const poolSize = options.poolSize ?? 4 + const retryMs = options.retryMs ?? 1_000 + const sockets = new Set() + const timers = new Set>() + let stopped = false + let idle = 0 + + const dial = () => { + if (stopped || idle >= poolSize) return + idle += 1 + const pooled = connect({ host: "127.0.0.1", port: options.rendezvousPort }) + sockets.add(pooled) + // Idle pool members can sit for hours between model calls; keepalive is + // what notices a silently vanished peer. + pooled.setKeepAlive(true, 30_000) + pooled.on("error", () => {}) + let claimed = false + pooled.once("data", (chunk) => { + claimed = true + idle -= 1 + dial() + const upstream = connect({ host: "127.0.0.1", port: options.brokerPort }) + sockets.add(upstream) + upstream.on("error", () => {}) + // The claim byte is pairing control, not payload — and the guest may + // have coalesced the first request bytes behind it. + const payload = chunk.subarray(1) + if (payload.length) upstream.write(payload) + pooled.pipe(upstream) + upstream.pipe(pooled) + const drop = () => { + pooled.destroy() + upstream.destroy() + sockets.delete(pooled) + sockets.delete(upstream) + } + pooled.on("close", drop) + upstream.on("close", drop) + }) + pooled.on("close", () => { + sockets.delete(pooled) + if (claimed) return + idle -= 1 + if (stopped) return + const timer = setTimeout(() => { + timers.delete(timer) + dial() + }, retryMs) + timers.add(timer) + }) + } + + for (let index = 0; index < poolSize; index += 1) dial() + return { + stop() { + stopped = true + for (const timer of timers) clearTimeout(timer) + for (const socket of sockets) socket.destroy() + }, + } +} diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index e4636b2edf..e43163f87b 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -54,6 +54,17 @@ if (!script.includes("mount --bind") || !script.includes("server-launch.mjs")) { { const value = read(); value.bootScript = script; value.codevConfigEnv = process.env.CODEV_CONFIG ?? null; write(value) } +// The real boot script announces its bridge mode on stdout before the server +// starts; tunnel mode also stands up the rendezvous listener the host dials. +const bridgeMode = read().bridgeMode ?? "direct" +process.stdout.write("codev-bridge-mode: " + bridgeMode + "\\n") +if (bridgeMode === "tunnel") { + const rendezvous = Number((script.match(/codev-bridge\\.mjs \\d+ (\\d+)/) ?? [])[1]) + createServer((socket) => { + socket.on("error", () => {}) + const value = read(); value.rendezvousDials = (value.rendezvousDials ?? 0) + 1; write(value) + }).listen(rendezvous, "127.0.0.1") +} 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")) }) @@ -389,8 +400,9 @@ test("boot confines the server behind the cgroup firewall before socat starts", // The boot script must not trust the route table: under NAT the default // gateway is the host, under mirrored networking it is the LAN router — and a // host firewall can sever either. The probe picks the address that actually -// answers and fails the start with a named error when nothing does. -test("boot probes broker reachability, gateway first under NAT, and fails closed", async () => { +// answers, and when nothing does the guest switches to the host-dialed +// reverse bridge instead of failing. +test("boot probes broker reachability, gateway first under NAT, with the tunnel fallback", async () => { const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) const provider = createWslProvider( await preparedOptions(dir, { @@ -407,7 +419,11 @@ test("boot probes broker reachability, gateway first under NAT, and fails closed const script = state.bootScript as string expect(script).toContain(`for CANDIDATE in $GATEWAY_IP 127.0.0.1`) expect(script).toContain("connect-timeout=2") - expect(script).toContain("cannot reach the host broker") + // Neither candidate answering flips the guest into tunnel mode, announced + // on stdout for the host, and boots the bridge daemon instead of socat. + expect(script).toContain(`[ -n "$HOST_IP" ] || { echo "codev sandbox: cannot reach the host broker directly`) + expect(script).toContain(`echo "codev-bridge-mode: $BRIDGE_MODE"`) + expect(script).toContain("node /tmp/codev-bridge.mjs") // The probe runs unconfined — before the firewall moves the shell into the // sandbox cgroup — so it needs no allow rule of its own. expect(script.indexOf("HOST_IP=$CANDIDATE")).toBeLessThan(script.indexOf("cgroup.procs")) @@ -417,6 +433,41 @@ test("boot probes broker reachability, gateway first under NAT, and fails closed await rm(dir, { recursive: true, force: true }) }) +// A guest that announces tunnel mode gets the host half of the reverse +// bridge: the provider must dial the rendezvous port parsed out of the boot +// script — the direction WSL always forwards and Windows does not firewall. +test("a tunnel-mode guest gets the host-dialed reverse bridge", async () => { + 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:9" }], + guestConfig: `{}`, + }), + }), + ) + await provider.create(config) + const seeded = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + await Bun.write(join(dir, "state.json"), JSON.stringify({ ...seeded, bridgeMode: "tunnel" })) + const sandbox = await provider.start(config) + + // Concurrent pool dials race the fake's read-modify-write state updates, so + // at least one recorded dial is the assertion, not the pool size. + const dialed = async () => { + for (let attempt = 0; attempt < 100; attempt += 1) { + const state = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + if ((state.rendezvousDials ?? 0) >= 1) return true + await Bun.sleep(50) + } + return false + } + expect(await dialed()).toBe(true) + + sandbox.stop() + await provider.destroy(config) + await rm(dir, { recursive: true, force: true }) +}) + // Mirrored networking shares the loopback between host and guest, so the host // is 127.0.0.1 (the NAT gateway address does not exist) and the broker can // stay off the LAN entirely. diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index 3ec28cefe7..2019d71ce5 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -9,6 +9,7 @@ import { pollSandboxHealth, sandboxBootEnv, } from "../boot" +import { startWslReverseTunnel, WSL_GUEST_BRIDGE_SOURCE } from "./bridge" import { createBrokerServer, type SandboxEgress } from "../broker" import { localImageAsset, readImageMarker, stageAsset, writeImageMarker } from "../image" import { wslFirewallScript } from "./firewall" @@ -258,6 +259,12 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider // cannot serve; under mirrored networking the shared loopback reaches // this listener, so it need not (and should not) be open to the LAN. const brokerPort = broker ? await broker.listen(mirrored ? "127.0.0.1" : "0.0.0.0") : null + // Rendezvous for the reverse bridge (bridge.ts): when the guest cannot + // reach the broker directly, it listens here on its loopback and the + // host dials in through the Windows-loopback republish of that bind. + // Reserved host-side like the other two because the republish makes the + // Windows loopback the uniqueness domain. + const rendezvousPort = broker ? await reserveLoopbackPort() : null const configDir = wslSandboxDirs(options.dataDir, config.name).config if (egress?.guestConfig) { await mkdir(configDir, { recursive: true }) @@ -284,14 +291,15 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider // Where the guest-loopback broker stub finds the host's TCP broker. // Under NAT that is the default gateway; under mirrored networking the // gateway is the LAN router and the host is the shared 127.0.0.1 — so - // probe an ordered candidate list instead of trusting the route table, - // which also turns an unreachable broker (a host firewall blocking - // WSL-to-host traffic) into an immediate, named start failure instead - // of a cryptic connection error on the first model call. The probe - // runs before the firewall confines this shell, so nothing here needs - // an allow rule. The gateway can lag the distro on a cold VM boot, so - // poll briefly instead of capturing whatever the route table says - // first. + // probe an ordered candidate list instead of trusting the route table. + // When neither answers (a managed host firewall silently dropping + // WSL-to-host traffic), fall back to tunnel mode: the host dials into + // the guest instead (bridge.ts), announced to the host on stdout. The + // probe runs before the firewall confines this shell, so nothing here + // needs an allow rule. The gateway can lag the distro on a cold VM + // boot, so poll briefly instead of capturing whatever the route table + // says first. Tunnel mode keeps a loopback HOST_IP so the nft rule + // below stays well-formed; nothing dials it. ...(brokerPort !== null ? [ `GATEWAY_IP=""`, @@ -306,15 +314,31 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider ` HOST_IP=$CANDIDATE`, ` break`, `done`, - `[ -n "$HOST_IP" ] || { echo "codev sandbox: the guest cannot reach the host broker on port ${brokerPort} (tried ${mirrored ? "127.0.0.1 and the default gateway" : "the default gateway and 127.0.0.1"}); a firewall may be blocking WSL-to-host traffic" >&2; exit 1; }`, + `BRIDGE_MODE=direct`, + `[ -n "$HOST_IP" ] || { echo "codev sandbox: cannot reach the host broker directly; using the reverse bridge" >&2; BRIDGE_MODE=tunnel; HOST_IP=127.0.0.1; }`, + `echo "codev-bridge-mode: $BRIDGE_MODE"`, ] : []), // The firewall confines this boot's process tree before anything that // can reach the network starts; the socat bridge below is inside the // confined cgroup and rides the broker allow rule. ...wslFirewallScript({ distro, serverPort: port, brokerPort }), - ...(brokerPort !== null - ? [`socat TCP-LISTEN:${guestBrokerPort},bind=127.0.0.1,fork,reuseaddr TCP:$HOST_IP:${brokerPort} &`] + // Direct mode bridges with socat; tunnel mode runs the guest half of + // the reverse bridge, which serves the identical loopback stub — the + // server's model base URL never changes. The daemon source travels + // over this script's own stdin delivery, so the published images need + // nothing new; the quoted delimiter keeps its $ and backticks literal. + ...(brokerPort !== null && rendezvousPort !== null + ? [ + `cat > /tmp/codev-bridge.mjs <<'CODEV_BRIDGE_EOF'`, + WSL_GUEST_BRIDGE_SOURCE.trimEnd(), + `CODEV_BRIDGE_EOF`, + `if [ "$BRIDGE_MODE" = direct ]; then`, + ` socat TCP-LISTEN:${guestBrokerPort},bind=127.0.0.1,fork,reuseaddr TCP:$HOST_IP:${brokerPort} &`, + `else`, + ` node /tmp/codev-bridge.mjs ${guestBrokerPort} ${rendezvousPort} &`, + `fi`, + ] : []), `cd ${shellQuote(await guestPath(distro, config.projectDir))}`, // Root did the bind mounts and the socat bridge; the agent itself runs @@ -330,7 +354,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider // guest as a broken delimiter and the broker bridge dialed an empty // host). Stdin delivery has no quoting layer to corrupt. const child = spawnImpl(options.wslCommand ?? "wsl.exe", ["-d", distro, "-u", "root", "--", "/bin/sh"], { - stdio: ["pipe", "ignore", "pipe"], + stdio: ["pipe", "pipe", "pipe"], // WSLENV forwards these into the distro without exposing the password // on a command line that any host process could read. env: { ...process.env, ...env, WSLENV: wslEnvSpec(Object.keys(env)) }, @@ -340,6 +364,20 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider child.stderr?.on("data", (chunk) => { stderrTail = `${stderrTail}${chunk}`.slice(-8000) }) + // The boot script announces which bridge carries broker traffic before + // it starts the server, so the marker is on this pipe before health can + // pass. Resolving is idempotent, which also means nothing the server + // prints later can change the answer. + let announceBridgeMode!: (mode: "direct" | "tunnel") => void + const bridgeModeSeen = new Promise<"direct" | "tunnel">((resolve) => { + announceBridgeMode = resolve + }) + let stdoutTail = "" + child.stdout?.on("data", (chunk) => { + stdoutTail = `${stdoutTail}${chunk}`.slice(-8000) + const mode = stdoutTail.match(/codev-bridge-mode: (direct|tunnel)/)?.[1] + if (mode === "direct" || mode === "tunnel") announceBridgeMode(mode) + }) const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { child.once("exit", (code, signal) => resolve({ code, signal })) }) @@ -372,12 +410,30 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider ) } + // Start the host half of the reverse bridge only when the guest chose + // it. The timeout guards a pathological pipe stall; direct — the + // do-nothing historical behavior — is the safe assumption. + const bridge = await (async () => { + if (brokerPort === null || rendezvousPort === null) return null + let markerTimeout: ReturnType | undefined + const mode = await Promise.race([ + bridgeModeSeen, + new Promise<"direct">((resolve) => { + markerTimeout = setTimeout(() => resolve("direct"), 10_000) + }), + ]) + clearTimeout(markerTimeout) + if (mode !== "tunnel") return null + return startWslReverseTunnel({ rendezvousPort, brokerPort }) + })() + return { url, username: credentials.username, password: credentials.password, mounts, stop: () => { + bridge?.stop() child.kill() // Killing wsl.exe ends the command but leaves the distro's VM running; // terminating it releases the loopback port mapping too. @@ -386,6 +442,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider }, onExit: (cb) => { void exited.then((exit) => { + bridge?.stop() void broker?.close() cb(exit.code, exit.signal) })