diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bdbab7e62..03bc29c20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,26 @@ 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 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 dialog said WSL was not installed, offered nothing but **Cancel**, and told you 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 903f05edd6..e43163f87b 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. @@ -53,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")) }) @@ -385,6 +397,121 @@ 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 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, { + 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") + // 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")) + + sandbox.stop() + await provider.destroy(config) + 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. +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..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" @@ -244,9 +245,26 @@ 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 + // 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 }) @@ -270,26 +288,57 @@ 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. + // 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 ? [ - `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`, + `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 @@ -305,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)) }, @@ -315,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 })) }) @@ -347,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. @@ -361,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) })