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)