From 8a48e97c74e8feedf258ca3bd09cc6214d405009 Mon Sep 17 00:00:00 2001 From: Minh Nguyen <44449792+quickbeard@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:26:36 +0700 Subject: [PATCH] fix(sandbox): deliver the WSL boot script over stdin, not argv wsl.exe reassembles its command line from Windows argv and drops the backslash-escaped quotes node spawn produces, which corrupted the one quoted space in the boot script: `cut -d" " -f3` reached the guest with a broken delimiter, HOST_IP computed empty, and the socat broker bridge dialed an empty host - every model request from a WSL sandbox failed with "other side closed". Feed /bin/sh the script over stdin (no quoting layer to corrupt), poll for the default route on cold VM boots, and run the provider test suite's project dir through tmpdir() so it also passes on Windows machines. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 +++++ .../codev-sandbox/src/wsl/provider.test.ts | 9 +++-- packages/codev-sandbox/src/wsl/provider.ts | 34 ++++++++++++------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0094b6a3d..9799351c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **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 + got its target address through `wsl.exe`'s command line, which corrupts + quoted spaces, so the bridge dialed an empty host. The sandbox boot script + now reaches the guest over stdin instead of the command line, and the bridge + waits for the guest network on a cold start instead of grabbing whatever the + route table says first. - **Code and Work** — the sidebar now opens on two tabs. **Code** is your machine: the local server, plus any WSL, remote or SSH servers you have added. **Work** is your sandboxes. Each side keeps its own projects, pinned diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index 4337c693e0..12ae8213c8 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -7,7 +7,9 @@ import type { SandboxConfig } from "../provider" import { decodeWslOutput, parseDistroList, wslEnvSpec } from "./cli" import { createWslProvider, wslDistroName, type WslProviderOptions } from "./provider" -const config: SandboxConfig = { id: "sandbox:default", name: "default", backend: "wsl", projectDir: "/tmp" } +// tmpdir() rather than a literal /tmp so the suite also runs on the Windows +// machines this backend actually targets. +const config: SandboxConfig = { id: "sandbox:default", name: "default", backend: "wsl", projectDir: tmpdir() } const ASSET_SHA = "a".repeat(64) // A fake wsl.exe: tracks registered distros in a state file, answers wslpath, @@ -35,8 +37,9 @@ if (args[0] === "--unregister") { } if (args.includes("wslpath")) { process.stdout.write("/mnt/hostpath\\n"); process.exit(0) } -const script = args[args.length - 1] -// Import-time distro configuration runs through the same -- /bin/sh -c shape. +// The boot script arrives on stdin (never argv — wsl.exe corrupts quoted +// spaces there); import-time configuration still uses -- /bin/sh -c. +const script = args[args.length - 1] === "/bin/sh" ? readFileSync(0, "utf8") : args[args.length - 1] if (script.includes("/etc/wsl.conf")) { const value = read(); value.wslConf = script; write(value) process.exit(0) diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index e9914e760d..eff8b3de44 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -213,10 +213,17 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider `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. + // 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=$(ip route show default | cut -d" " -f3)`, + `HOST_IP=""`, + `for _ in $(seq 1 50); do`, + ` HOST_IP=$(ip route show default | awk '{print $3; exit}')`, + ` [ -n "$HOST_IP" ] && break`, + ` sleep 0.2`, + `done`, `socat TCP-LISTEN:8788,bind=127.0.0.1,fork,reuseaddr TCP:$HOST_IP:${brokerPort} &`, ] : []), @@ -227,16 +234,19 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider `exec setpriv --reuid=codev --regid=codev --init-groups node ${SANDBOX_GUEST_LAUNCHER}`, ].join("\n") - const child = spawnImpl( - options.wslCommand ?? "wsl.exe", - ["-d", distro, "-u", "root", "--", "/bin/sh", "-c", bootScript], - { - stdio: ["ignore", "ignore", "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)) }, - }, - ) + // The script goes to /bin/sh over stdin, never through wsl.exe's argv: + // wsl.exe reassembles its command line from Windows argv and drops the + // backslash-escaped quotes node's spawn produces, which silently + // corrupted any quoted space (observed live: `cut -d" " -f3` reached the + // 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"], + // 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)) }, + }) + child.stdin?.end(`${bootScript}\n`) let stderrTail = "" child.stderr?.on("data", (chunk) => { stderrTail = `${stderrTail}${chunk}`.slice(-8000)