From fd8d70d0fc2c69416d7f032bbbd7a8f8507c01a7 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 12 Aug 2026 10:51:48 +0700 Subject: [PATCH] fix(sandbox): boot the guest server via a launcher, not a CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real VM boot (via script/e2e-vz.ts on macOS/arm64) surfaced that the guest never served: the mounted bundle dist/node/node.js is a library module (exports Server/Config/bootstrap), not a CLI, so `node node.js serve ...` executed nothing and init exited, panicking the kernel. - image/guest/server-launch.mjs: import the bundle and call Server.listen({...}) with env-provided credentials/hostname/port — the same entry the desktop sidecar uses. Baked at /opt/codev-guest/server-launch.mjs; both providers run it. - image/Dockerfile: install the two externalized bundle deps (jsonc-parser, @lydell/node-pty) into /opt/node_modules so ESM resolution finds them without touching the host bundle; add busybox-static for a clean poweroff. - boot.ts: sandboxBootEnv now carries CODEV_SERVER_HOSTNAME/PORT and drops sandboxServeArgs; export SANDBOX_GUEST_LAUNCHER. Both providers and the vz boot.json updated. - codev-init: run the launcher, seed /etc/skel into the home, poweroff via busybox with a sysrq fallback. - build-image.ts: clear only *.zip from the office staging dir so its tracked .gitignore/.keep survive a build. Verified end-to-end: create -> codev-vmm boot -> kernel -> virtio-fs -> launcher -> Server.listen -> vsock bridge -> authenticated /global/health returns OK. 60 tests pass, typecheck clean. Co-Authored-By: Claude Fable 5 --- packages/codev-sandbox/README.md | 13 +++++++++--- packages/codev-sandbox/image/Dockerfile | 9 ++++++++- packages/codev-sandbox/image/guest/codev-init | 9 ++++++--- .../image/guest/server-launch.mjs | 19 ++++++++++++++++++ packages/codev-sandbox/script/build-image.ts | 6 ++++-- packages/codev-sandbox/src/boot.test.ts | 20 +++++++------------ packages/codev-sandbox/src/boot.ts | 17 +++++++++++----- .../codev-sandbox/src/bwrap/provider.test.ts | 4 +++- packages/codev-sandbox/src/bwrap/provider.ts | 8 ++++---- .../codev-sandbox/src/vz/provider.test.ts | 3 ++- packages/codev-sandbox/src/vz/provider.ts | 13 +++--------- 11 files changed, 78 insertions(+), 43 deletions(-) create mode 100644 packages/codev-sandbox/image/guest/server-launch.mjs diff --git a/packages/codev-sandbox/README.md b/packages/codev-sandbox/README.md index 37b411fafa..27654c3f2e 100644 --- a/packages/codev-sandbox/README.md +++ b/packages/codev-sandbox/README.md @@ -63,8 +63,8 @@ Phase 2 adds the macOS backend, `vz/` + `native/vzhelper`: Guest image contract for vz (image pipeline): the init must mount the `workspace`/`bundle`/`config` virtio-fs tags, read `/…/config/boot.json`, run -the server bundle with its env and args, and bridge vsock port 4096 to the -server's TCP port and TCP port 8788 to vsock (e.g. via socat) for the broker. +the launcher with its env, and bridge vsock port 4096 to the server's TCP port +and TCP port 8788 to vsock (e.g. via socat) for the broker. The image pipeline lives in `image/` and builds every backend's guest from one Debian rootfs, fully containerized (any machine with Docker): @@ -80,7 +80,14 @@ one Debian rootfs, fully containerized (any machine with Docker): script), and images declare extra env in `/etc/codev-guest.env`. - `image/guest/codev-init` — the vz guest's PID 1: mounts the virtio-fs tags, runs the socat vsock bridges (4096 server / 8788 broker, host CID 2), and - execs the server bundle per `boot.json`; powers off when the server dies. + runs the launcher per `boot.json`; powers off (busybox) when it dies. +- `image/guest/server-launch.mjs` — the boot entrypoint every backend runs. + The bundle's `node.js` is a library module (exports `Server`), not a CLI, so + the launcher imports it and calls `Server.listen({...})` with the env-provided + credentials/host/port — exactly what the desktop sidecar does. Baked at + `/opt/codev-guest/server-launch.mjs`; `/opt/node_modules` holds the two + externalized deps (`jsonc-parser`, `@lydell/node-pty`) so the host bundle + needs nothing added. - `image/kernel/` — a minimal pinned kernel (6.6 LTS) with virtio blk/fs/vsock/console + ext4 compiled in: no initrd, no modules, no network drivers. Cached by Docker layers after the first build. diff --git a/packages/codev-sandbox/image/Dockerfile b/packages/codev-sandbox/image/Dockerfile index c2ebec943d..e04272ec69 100644 --- a/packages/codev-sandbox/image/Dockerfile +++ b/packages/codev-sandbox/image/Dockerfile @@ -7,7 +7,7 @@ ARG DEBIAN_FRONTEND=noninteractive # Base toolchain: node/git/socat/ca-certs always; python + build tools + the # CLI set per the sandbox plan. iproute2 is needed by the vz init (loopback). RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl wget git unzip zip jq ripgrep sqlite3 socat iproute2 \ + ca-certificates curl wget git unzip zip jq ripgrep sqlite3 socat iproute2 busybox-static \ procps less vim-tiny openssh-client \ python3 python3-pip python3-venv python3-dev \ build-essential pkg-config \ @@ -20,6 +20,12 @@ RUN set -eux; \ | tar -xJ -C /usr/local --strip-components=1; \ node --version RUN useradd --create-home --shell /bin/bash --uid 1000 codev +# The server bundle (mounted at /opt/codev) is ESM with two externals; ESM +# resolution walks parent dirs, so /opt/node_modules serves them without +# touching the host bundle. @lydell/node-pty resolves its native prebuild for +# this arch via optionalDependencies. +RUN cd /opt && npm install --no-save --no-audit --no-fund jsonc-parser @lydell/node-pty \ + && rm -f /opt/package.json /opt/package-lock.json # Office layer: the codev-office skill dependencies (dotnet/pandoc/libreoffice/ # python packages/node module trees/skills), system-wide instead of per-user @@ -35,4 +41,5 @@ RUN if [ "$OFFICE" = "1" ]; then bash /tmp/install-office.sh /tmp/office-bundle; # Harmless in the bwrap/wsl tars. FROM office AS guest COPY guest/codev-init /sbin/codev-init +COPY guest/server-launch.mjs /opt/codev-guest/server-launch.mjs RUN chmod 755 /sbin/codev-init && mkdir -p /workspace /opt/codev /codev-config diff --git a/packages/codev-sandbox/image/guest/codev-init b/packages/codev-sandbox/image/guest/codev-init index 9d0464bb7c..8c76b7b0b2 100644 --- a/packages/codev-sandbox/image/guest/codev-init +++ b/packages/codev-sandbox/image/guest/codev-init @@ -4,6 +4,7 @@ # codev server per /codev-config/boot.json. The guest has no NIC: socat over # vsock (host CID 2) is the only way bytes leave this machine. set -u +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin mount -t proc proc /proc mount -t sysfs sys /sys @@ -24,15 +25,17 @@ ip link set lo up socat VSOCK-LISTEN:4096,fork,reuseaddr TCP:127.0.0.1:4096 & socat TCP-LISTEN:8788,bind=127.0.0.1,fork,reuseaddr VSOCK-CONNECT:2:8788 & +# boot.json carries the server env (credentials, hostname, port); the guest +# launcher (baked at /opt/codev-guest) reads them and calls Server.listen. eval "$(node -e ' const boot = require("/codev-config/boot.json") for (const [key, value] of Object.entries(boot.env)) console.log("export " + key + "=" + JSON.stringify(value)) -console.log("CODEV_SERVE_ARGS=" + JSON.stringify(boot.serveArgs.join(" "))) ')" export HOME=/home/codev +cp -Rn /etc/skel/. "$HOME"/ 2>/dev/null || true cd /workspace -node /opt/codev/node.js $CODEV_SERVE_ARGS & +node /opt/codev-guest/server-launch.mjs & SERVER=$! # Stay PID 1: reap orphans, power off when the server dies so the helper's @@ -41,4 +44,4 @@ while :; do wait -n 2>/dev/null kill -0 "$SERVER" 2>/dev/null || break done -poweroff -f +busybox poweroff -f 2>/dev/null || poweroff -f 2>/dev/null || echo o > /proc/sysrq-trigger diff --git a/packages/codev-sandbox/image/guest/server-launch.mjs b/packages/codev-sandbox/image/guest/server-launch.mjs new file mode 100644 index 0000000000..7078ac953f --- /dev/null +++ b/packages/codev-sandbox/image/guest/server-launch.mjs @@ -0,0 +1,19 @@ +// Boots the codev server inside the sandbox guest by calling Server.listen on +// the host-mounted bundle — the same entry the desktop sidecar uses +// (packages/desktop/src/main/sidecar.ts). The bundle's node.js is a library +// module, not a CLI, so there is no `serve` subcommand to run. +import { Server } from "/opt/codev/node.js" + +const hostname = process.env.CODEV_SERVER_HOSTNAME ?? "127.0.0.1" +const port = Number(process.env.CODEV_SERVER_PORT ?? "4096") + +const listener = await Server.listen({ + port, + hostname, + username: process.env.CODEV_SERVER_USERNAME ?? "codev", + password: process.env.CODEV_SERVER_PASSWORD ?? "", + cors: ["oc://renderer"], +}) + +process.on("SIGTERM", () => void listener.stop(true).then(() => process.exit(0))) +process.on("SIGINT", () => void listener.stop(true).then(() => process.exit(0))) diff --git a/packages/codev-sandbox/script/build-image.ts b/packages/codev-sandbox/script/build-image.ts index d7b7b76f8a..f0865bb368 100644 --- a/packages/codev-sandbox/script/build-image.ts +++ b/packages/codev-sandbox/script/build-image.ts @@ -159,10 +159,12 @@ function collectArchs(): SandboxArch[] { // context: skill zips and prebuilt pure-JS node-module trees. Binaries // (dotnet, pandoc, wheels) are fetched per-arch inside the build instead. async function stageOfficeBundle() { + // Clear previous zips only — the dir's tracked .gitignore/.keep stay. const stage = join(imageDir, "office", "bundle") - await rm(stage, { recursive: true, force: true }) await mkdir(stage, { recursive: true }) - await writeFile(join(stage, ".keep"), "") + for (const name of await readdir(stage)) { + if (name.endsWith(".zip")) await rm(join(stage, name)) + } if (!officeBundle) return const scratch = join(outDir, "office-bundle-stage") await rm(scratch, { recursive: true, force: true }) diff --git a/packages/codev-sandbox/src/boot.test.ts b/packages/codev-sandbox/src/boot.test.ts index f2eaedbdba..834ccb6ac5 100644 --- a/packages/codev-sandbox/src/boot.test.ts +++ b/packages/codev-sandbox/src/boot.test.ts @@ -1,10 +1,14 @@ import { expect, test } from "bun:test" -import { generateSandboxCredentials, pollSandboxHealth, sandboxBootEnv, sandboxServeArgs } from "./boot" +import { generateSandboxCredentials, pollSandboxHealth, sandboxBootEnv } from "./boot" -test("boot env mirrors the WSL sidecar contract", () => { - expect(sandboxBootEnv({ username: "codev", password: "secret" }, "/home/codev")).toEqual({ +test("boot env mirrors the WSL sidecar contract and carries the server address", () => { + expect( + sandboxBootEnv({ username: "codev", password: "secret" }, "/home/codev", { hostname: "127.0.0.1", port: 4096 }), + ).toEqual({ CODEV_SERVER_USERNAME: "codev", CODEV_SERVER_PASSWORD: "secret", + CODEV_SERVER_HOSTNAME: "127.0.0.1", + CODEV_SERVER_PORT: "4096", CODEV_CLIENT: "desktop", CODEV_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", XDG_STATE_HOME: "/home/codev/.local/state", @@ -18,16 +22,6 @@ test("generated credentials are unique per boot", () => { expect(first.password).not.toBe(second.password) }) -test("serve args bind the requested host and port", () => { - expect(sandboxServeArgs({ hostname: "0.0.0.0", port: 4096 })).toEqual([ - "serve", - "--hostname", - "0.0.0.0", - "--port", - "4096", - ]) -}) - test("stops health polling when startup settles", async () => { const abort = new AbortController() let checks = 0 diff --git a/packages/codev-sandbox/src/boot.ts b/packages/codev-sandbox/src/boot.ts index 9d1f1f9e4e..65d9c55e64 100644 --- a/packages/codev-sandbox/src/boot.ts +++ b/packages/codev-sandbox/src/boot.ts @@ -10,20 +10,27 @@ export function generateSandboxCredentials(): SandboxCredentials { } // Mirrors the WSL sidecar env contract (packages/desktop/src/main/wsl/sidecar.ts) -// so the same server bundle boots identically in every backend. -export function sandboxBootEnv(credentials: SandboxCredentials, guestHome: string): Record { +// so the same server bundle boots identically in every backend. The guest +// launcher (image/guest/server-launch.mjs) reads the hostname/port from here +// and calls Server.listen — the bundle is a library, not a CLI. +export function sandboxBootEnv( + credentials: SandboxCredentials, + guestHome: string, + server: { hostname: string; port: number }, +): Record { return { CODEV_SERVER_USERNAME: credentials.username, CODEV_SERVER_PASSWORD: credentials.password, + CODEV_SERVER_HOSTNAME: server.hostname, + CODEV_SERVER_PORT: String(server.port), CODEV_CLIENT: "desktop", CODEV_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", XDG_STATE_HOME: `${guestHome}/.local/state`, } } -export function sandboxServeArgs(options: { hostname: string; port: number }) { - return ["serve", "--hostname", options.hostname, "--port", String(options.port)] -} +// Absolute guest path of the launcher baked into every image. +export const SANDBOX_GUEST_LAUNCHER = "/opt/codev-guest/server-launch.mjs" export async function checkSandboxHealth(url: string, credentials: SandboxCredentials, fetchImpl: typeof fetch = fetch) { const response = await fetchImpl(`${url}/global/health`, { diff --git a/packages/codev-sandbox/src/bwrap/provider.test.ts b/packages/codev-sandbox/src/bwrap/provider.test.ts index 867630ca43..167a76a614 100644 --- a/packages/codev-sandbox/src/bwrap/provider.test.ts +++ b/packages/codev-sandbox/src/bwrap/provider.test.ts @@ -110,7 +110,9 @@ test("starts the sandbox: spawns bwrap with the isolated spec and returns the fo expect(joined).toContain("--setenv CODEV_CLIENT desktop") expect(joined).toContain("unix-to-tcp /run/codev/server.sock 4096") expect(joined).toContain("tcp-to-unix 8788 /run/codev/broker.sock") - expect(joined).toContain("node /opt/codev/node.js serve --hostname 127.0.0.1 --port 4096") + expect(joined).toContain("--setenv CODEV_SERVER_HOSTNAME 127.0.0.1") + expect(joined).toContain("--setenv CODEV_SERVER_PORT 4096") + expect(joined).toContain("node /opt/codev-guest/server-launch.mjs") let exit: { code: number | null } | undefined sandbox.onExit((code) => { diff --git a/packages/codev-sandbox/src/bwrap/provider.ts b/packages/codev-sandbox/src/bwrap/provider.ts index 166254d50c..1827f36bf8 100644 --- a/packages/codev-sandbox/src/bwrap/provider.ts +++ b/packages/codev-sandbox/src/bwrap/provider.ts @@ -2,11 +2,11 @@ import { spawn } from "node:child_process" import { mkdir, rm, stat, writeFile } from "node:fs/promises" import { join } from "node:path" import { + SANDBOX_GUEST_LAUNCHER, checkSandboxHealth, generateSandboxCredentials, pollSandboxHealth, sandboxBootEnv, - sandboxServeArgs, } from "../boot" import { stageAsset } from "../image" import type { SandboxAsset } from "../manifest" @@ -105,7 +105,7 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi `[ -f /etc/codev-guest.env ] && . /etc/codev-guest.env`, `${guestNode} ${GUEST_BRIDGE}/forward.mjs unix-to-tcp ${GUEST_RUNTIME}/server.sock ${GUEST_SERVER_PORT} &`, `${guestNode} ${GUEST_BRIDGE}/forward.mjs tcp-to-unix ${GUEST_BROKER_PORT} ${GUEST_RUNTIME}/broker.sock &`, - `exec ${guestNode} ${GUEST_BUNDLE}/node.js ${sandboxServeArgs({ hostname: "127.0.0.1", port: GUEST_SERVER_PORT }).join(" ")}`, + `exec ${guestNode} ${SANDBOX_GUEST_LAUNCHER}`, ].join("\n") const child = spawnImpl( bwrap, @@ -118,7 +118,7 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi runtimeDir: dirs.runtime, homeDir: dirs.home, env: { - ...sandboxBootEnv(credentials, GUEST_HOME), + ...sandboxBootEnv(credentials, GUEST_HOME, { hostname: "127.0.0.1", port: GUEST_SERVER_PORT }), PATH: GUEST_PATH, HOME: GUEST_HOME, }, @@ -128,7 +128,7 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi ) let stderrTail = "" child.stderr?.on("data", (chunk) => { - stderrTail = `${stderrTail}${chunk}`.slice(-2000) + stderrTail = `${stderrTail}${chunk}`.slice(-8000) }) const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { child.once("exit", (code, signal) => resolve({ code, signal })) diff --git a/packages/codev-sandbox/src/vz/provider.test.ts b/packages/codev-sandbox/src/vz/provider.test.ts index e674e00134..9e887cf023 100644 --- a/packages/codev-sandbox/src/vz/provider.test.ts +++ b/packages/codev-sandbox/src/vz/provider.test.ts @@ -132,7 +132,8 @@ test("starts the VM via the helper protocol and serves health through the vsock ]) const boot = JSON.parse(await readFile(join(prepared.dirs.guestConfig, "boot.json"), "utf8")) expect(boot.env.CODEV_SERVER_PASSWORD).toBe(sandbox.password) - expect(boot.serveArgs).toEqual(["serve", "--hostname", "127.0.0.1", "--port", "4096"]) + expect(boot.env.CODEV_SERVER_HOSTNAME).toBe("127.0.0.1") + expect(boot.env.CODEV_SERVER_PORT).toBe("4096") let exited = false sandbox.onExit(() => { diff --git a/packages/codev-sandbox/src/vz/provider.ts b/packages/codev-sandbox/src/vz/provider.ts index 135fbd5712..74deabf410 100644 --- a/packages/codev-sandbox/src/vz/provider.ts +++ b/packages/codev-sandbox/src/vz/provider.ts @@ -1,13 +1,7 @@ import { spawn } from "node:child_process" import { constants, copyFile, mkdir, rm, stat, writeFile } from "node:fs/promises" import { join } from "node:path" -import { - checkSandboxHealth, - generateSandboxCredentials, - pollSandboxHealth, - sandboxBootEnv, - sandboxServeArgs, -} from "../boot" +import { checkSandboxHealth, generateSandboxCredentials, pollSandboxHealth, sandboxBootEnv } from "../boot" import { forwardTcpToUnix } from "../bwrap/bridge" import { runCommand, unpackRootfs } from "../bwrap/rootfs" import { stageAsset } from "../image" @@ -117,8 +111,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { await writeFile( join(dirs.guestConfig, "boot.json"), JSON.stringify({ - env: sandboxBootEnv(credentials, VZ_GUEST_HOME), - serveArgs: sandboxServeArgs({ hostname: "127.0.0.1", port: VZ_SERVER_VSOCK_PORT }), + env: sandboxBootEnv(credentials, VZ_GUEST_HOME, { hostname: "127.0.0.1", port: VZ_SERVER_VSOCK_PORT }), }), ) const serverSocket = join(dirs.runtime, "server.sock") @@ -146,7 +139,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { const child = spawnImpl(options.helperPath, [configPath], { stdio: ["pipe", "pipe", "pipe"] }) let stderrTail = "" child.stderr?.on("data", (chunk) => { - stderrTail = `${stderrTail}${chunk}`.slice(-2000) + stderrTail = `${stderrTail}${chunk}`.slice(-8000) }) const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { child.once("exit", (code, signal) => resolve({ code, signal }))