Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions packages/codev-sandbox/src/wsl/bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>((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)
138 changes: 138 additions & 0 deletions packages/codev-sandbox/src/wsl/bridge.ts
Original file line number Diff line number Diff line change
@@ -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: <stub port> <rendezvous port>; 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<Socket>()
const timers = new Set<ReturnType<typeof setTimeout>>()
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()
},
}
}
127 changes: 127 additions & 0 deletions packages/codev-sandbox/src/wsl/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"))
})
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading