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
13 changes: 10 additions & 3 deletions packages/codev-sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion packages/codev-sandbox/image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
Expand All @@ -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
9 changes: 6 additions & 3 deletions packages/codev-sandbox/image/guest/codev-init
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
19 changes: 19 additions & 0 deletions packages/codev-sandbox/image/guest/server-launch.mjs
Original file line number Diff line number Diff line change
@@ -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)))
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 @@ -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 })
Expand Down
20 changes: 7 additions & 13 deletions packages/codev-sandbox/src/boot.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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
Expand Down
17 changes: 12 additions & 5 deletions packages/codev-sandbox/src/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
// 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<string, string> {
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`, {
Expand Down
4 changes: 3 additions & 1 deletion packages/codev-sandbox/src/bwrap/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
8 changes: 4 additions & 4 deletions packages/codev-sandbox/src/bwrap/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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,
},
Expand All @@ -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 }))
Expand Down
3 changes: 2 additions & 1 deletion packages/codev-sandbox/src/vz/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
13 changes: 3 additions & 10 deletions packages/codev-sandbox/src/vz/provider.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 }))
Expand Down
Loading