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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/codev-sandbox/.gitattributes
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions packages/codev-sandbox/script/build-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -207,7 +207,9 @@ async function describeAsset(path: string): Promise<SandboxAsset> {
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,
}
Expand Down
18 changes: 14 additions & 4 deletions packages/codev-sandbox/script/e2e-wsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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`
Expand Down
8 changes: 5 additions & 3 deletions packages/codev-sandbox/src/bwrap/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ export type BwrapProviderOptions = {
resolveImage: (opts?: { signal?: AbortSignal }) => Promise<SandboxAsset>
// 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<SandboxEgress | null>
// 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<SandboxEgress | null>
bwrapCommand?: string
guestNodeCommand?: string
platform?: NodeJS.Platform
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions packages/codev-sandbox/src/vz/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ export type VzProviderOptions = {
resolveImage: (opts?: { signal?: AbortSignal }) => Promise<SandboxAsset>
// 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<SandboxEgress | null>
// 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<SandboxEgress | null>
// Defaults for sandboxes that do not set their own.
cpus?: number
memoryMiB?: number
Expand Down Expand Up @@ -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
Expand Down
32 changes: 21 additions & 11 deletions packages/codev-sandbox/src/wsl/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,26 +180,36 @@ 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)
const sandbox = await provider.start(config)

const state = JSON.parse(await Bun.file(join(dir, "state.json")).text())
expect(state.bootScript).toContain(`mount --bind '/mnt/hostpath' /codev-config`)
expect(state.bootScript).toContain("socat TCP-LISTEN:8788,bind=127.0.0.1,fork,reuseaddr TCP:$HOST_IP:")
// 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}`)

Expand Down
26 changes: 16 additions & 10 deletions packages/codev-sandbox/src/wsl/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ export type WslProviderOptions = {
resolveImage: (opts?: { signal?: AbortSignal }) => Promise<SandboxAsset>
// 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<SandboxEgress | null>
// 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<SandboxEgress | null>
wslCommand?: string
platform?: NodeJS.Platform
osRelease?: () => string
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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=""`,
Expand All @@ -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
Expand Down
20 changes: 11 additions & 9 deletions packages/desktop/src/main/sandbox/egress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SandboxEgress> {
//
// 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<SandboxEgress> {
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
Expand Down Expand Up @@ -49,7 +51,7 @@ export async function resolveSandboxEgress(): Promise<SandboxEgress> {
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
Expand Down Expand Up @@ -82,7 +84,7 @@ export async function resolveSandboxEgress(): Promise<SandboxEgress> {

// 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/<name>/...
// upstream URL. Reachable in the guest at the broker stub's /<name>/... 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() {
Expand Down
Loading