diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 25d620ecb1..8b3632680b 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -232,6 +232,7 @@ from the squash message first, or use a plain merge. | `packages/core/src/plugin/skill.ts`, `packages/opencode/src/skill/index.ts`, `packages/core/test/plugin/skill.test.ts` | Built-in skill `customize-opencode` → `customize-codev` in both registrations (plus `location` `/builtin/customize-codev.md`); descriptions rewritten to name CoDev Code; the markdown body wrapped in `rebrand(...)`; `Skill.get`/`Skill.require` resolve the old name through a `lookup` helper; test expectations updated | The skill's name and description are listed in the system prompt, so they are among the most visible brand strings the model sees; the body is upstream's config guide. The alias keeps existing configs and `skill` permission rules that name `customize-opencode` working. | Keep our name, the alias, and the `rebrand` wrapper; take upstream's `customize-opencode.md` body verbatim (it is deliberately not hand-edited for branding). | | `packages/opencode/src/command/template/initialize.txt` | "OpenCode" → "CoDev Code" (×2) | `/init` writes this guidance into the user's `AGENTS.md`. Edited in place rather than wrapped because the file is already fork-divergent (config-file rename row) and barely churns. | Keep our two strings; take upstream's surrounding text and rebrand any new "OpenCode" mention. | | `packages/core/package.json` | Added dependency `"@codev/brand": "workspace:*"` | The built-in skill registration rebrands its body through the fork-only `packages/codev-brand`. | Keep our dependency line; take upstream's other fields. | +| `turbo.json` | Added task `"@codev/sandbox#test"` (no deps, no outputs) | CI's `bun turbo test` only runs test tasks registered here; the fork-only sandbox package's tests must gate PRs. | Keep our task entry; take upstream's other tasks. | ## Known rebrand gaps diff --git a/packages/codev-sandbox/README.md b/packages/codev-sandbox/README.md index 2f2f4383db..d406a23e80 100644 --- a/packages/codev-sandbox/README.md +++ b/packages/codev-sandbox/README.md @@ -21,6 +21,26 @@ Phase 0 of the sandbox plan — the pieces that need no platform backend: - `boot.ts` — the guest boot contract shared with the WSL sidecar (credentials, env, serve args, health polling). -Later phases add the platform providers, the desktop main-process wiring, and -the `packages/app` UI. Runtime code sticks to Node APIs (no Bun-only calls) so -it can run inside the Electron main process. +Phase 1 adds the first platform backend, `bwrap/` — the Ubuntu provider: + +- `bwrap/args.ts` — the bubblewrap invocation: unshared user/pid/net/uts/ipc + namespaces (loopback-only networking), read-only rootfs binds with merged-usr + symlinks preserved, workspace rw at `/workspace`, server bundle ro at + `/opt/codev`, per-sandbox home, cleared env. +- `bwrap/bridge.ts` — how TCP crosses the guest's network namespace: unix + sockets in the shared `/run/codev` dir, with a host-side TCP→unix forwarder + and a guest-side node forward script for the server and broker paths. +- `bwrap/rootfs.ts` — image unpack (tar) and rootfs layout probing. +- `bwrap/provider.ts` — the `SandboxProvider`: availability probe (detects the + Ubuntu 23.10+ AppArmor userns restriction), create/start/destroy/reset, + health-gated startup with stderr capture. + +`bwrap.integration.test.ts` exercises real bubblewrap and auto-skips where +bwrap can't run (macOS, restricted CI runners). Packaging note for later +phases: the .deb should ship an AppArmor profile permitting unprivileged user +namespaces on Ubuntu 24.04+. + +Later phases add the macOS (Virtualization.framework) and Windows (managed +WSL distro) providers, the desktop main-process wiring, and the `packages/app` +UI. Runtime code sticks to Node APIs (no Bun-only calls) so it can run inside +the Electron main process. diff --git a/packages/codev-sandbox/src/boot.ts b/packages/codev-sandbox/src/boot.ts index 4bf3484307..9d1f1f9e4e 100644 --- a/packages/codev-sandbox/src/boot.ts +++ b/packages/codev-sandbox/src/boot.ts @@ -25,6 +25,13 @@ export function sandboxServeArgs(options: { hostname: string; port: number }) { return ["serve", "--hostname", options.hostname, "--port", String(options.port)] } +export async function checkSandboxHealth(url: string, credentials: SandboxCredentials, fetchImpl: typeof fetch = fetch) { + const response = await fetchImpl(`${url}/global/health`, { + headers: { authorization: `Basic ${btoa(`${credentials.username}:${credentials.password}`)}` }, + }).catch(() => null) + return !!response?.ok +} + export async function pollSandboxHealth(check: () => Promise, signal: AbortSignal, interval = 100) { while (!signal.aborted) { if (await check()) return diff --git a/packages/codev-sandbox/src/broker.test.ts b/packages/codev-sandbox/src/broker.test.ts index 4dd214d860..025e27c1ad 100644 --- a/packages/codev-sandbox/src/broker.test.ts +++ b/packages/codev-sandbox/src/broker.test.ts @@ -1,4 +1,7 @@ import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" import { createBrokerServer, forwardableRequestHeaders, resolveBrokerTarget } from "./broker" const routes = [{ prefix: "/llm", upstream: "https://gateway.test/api/" }] @@ -65,6 +68,29 @@ test("proxies allowed requests end-to-end and rejects the rest", async () => { upstream.stop(true) }) +test("serves the same routes over a unix socket for netns-isolated guests", async () => { + const upstream = Bun.serve({ + port: 0, + fetch: (request) => Response.json({ path: new URL(request.url).pathname }), + }) + const broker = createBrokerServer({ + routes: [{ prefix: "/llm", upstream: `http://127.0.0.1:${upstream.port}/v1` }], + }) + const socketPath = join(await mkdtemp(join(tmpdir(), "codev-broker-")), "broker.sock") + await broker.listenUnix(socketPath) + + const proxied = await fetch("http://sandbox-broker.local/llm/models", { unix: socketPath }) + expect(proxied.status).toBe(200) + expect(await proxied.json()).toEqual({ path: "/v1/models" }) + + const denied = await fetch("http://sandbox-broker.local/direct", { unix: socketPath }) + expect(denied.status).toBe(403) + + await broker.close() + upstream.stop(true) + await rm(dirname(socketPath), { recursive: true, force: true }) +}) + test("reports upstream failures as 502 instead of hanging", async () => { const broker = createBrokerServer({ routes: [{ prefix: "/llm", upstream: "http://127.0.0.1:1/nowhere" }], diff --git a/packages/codev-sandbox/src/broker.ts b/packages/codev-sandbox/src/broker.ts index 448a487e9c..4f8e76f2f2 100644 --- a/packages/codev-sandbox/src/broker.ts +++ b/packages/codev-sandbox/src/broker.ts @@ -90,6 +90,14 @@ export function createBrokerServer(options: { routes: BrokerRoute[]; fetchImpl?: if (!address || typeof address === "string") throw new Error("sandbox broker failed to bind") return address.port }, + // For bwrap guests the broker listens on a unix socket bind-mounted into + // the sandbox; unix sockets cross the guest's network namespace. + async listenUnix(path: string) { + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(path, () => resolve()) + }) + }, async close() { await new Promise((resolve) => server.close(() => resolve())) }, diff --git a/packages/codev-sandbox/src/bwrap/args.test.ts b/packages/codev-sandbox/src/bwrap/args.test.ts new file mode 100644 index 0000000000..32d8acd369 --- /dev/null +++ b/packages/codev-sandbox/src/bwrap/args.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test" +import { GUEST_WORKSPACE, bindableRootfsEntries, buildBwrapArgs } from "./args" + +const spec = { + rootfsDir: "/data/default/rootfs", + rootfsEntries: [ + { name: "usr", symlinkTarget: null }, + { name: "bin", symlinkTarget: "usr/bin" }, + { name: "etc", symlinkTarget: null }, + { name: "proc", symlinkTarget: null }, + { name: "home", symlinkTarget: null }, + ], + projectDir: "/host/project", + bundleDir: "/host/bundle", + bridgeDir: "/data/default/bridge", + runtimeDir: "/data/default/runtime", + homeDir: "/data/default/home", + env: { CODEV_SERVER_PASSWORD: "secret", PATH: "/usr/bin" }, + command: ["/bin/sh", "-c", "exec node server"], +} + +test("drops rootfs entries that would shadow managed mounts", () => { + expect(bindableRootfsEntries(spec.rootfsEntries).map((entry) => entry.name)).toEqual(["usr", "bin", "etc"]) +}) + +test("builds an isolated namespace with loopback-only networking", () => { + const args = buildBwrapArgs(spec) + expect(args).toContain("--unshare-net") + expect(args).toContain("--unshare-pid") + expect(args).toContain("--die-with-parent") + expect(args).toContain("--clearenv") + expect(args.join(" ")).toContain("--proc /proc") +}) + +test("binds rootfs dirs read-only and recreates merged-usr symlinks", () => { + const joined = buildBwrapArgs(spec).join(" ") + expect(joined).toContain("--ro-bind /data/default/rootfs/usr /usr") + expect(joined).toContain("--symlink usr/bin /bin") + expect(joined).not.toContain("/rootfs/proc") + expect(joined).not.toContain("/rootfs/home") +}) + +test("mounts the workspace rw and the bundle/bridge ro after the rootfs binds", () => { + const joined = buildBwrapArgs(spec).join(" ") + expect(joined).toContain("--bind /host/project /workspace") + expect(joined).toContain("--ro-bind /host/bundle /opt/codev") + expect(joined).toContain("--ro-bind /data/default/bridge /opt/codev-bridge") + expect(joined).toContain("--bind /data/default/runtime /run/codev") + expect(joined).toContain("--bind /data/default/home /home/codev") + expect(joined.indexOf("--bind /host/project")).toBeGreaterThan(joined.indexOf("--ro-bind /data/default/rootfs/usr")) + expect(joined).toContain(`--chdir ${GUEST_WORKSPACE}`) +}) + +test("clears the environment before setting the boot env", () => { + const args = buildBwrapArgs(spec) + const joined = args.join(" ") + expect(joined).toContain("--setenv CODEV_SERVER_PASSWORD secret") + expect(joined.indexOf("--setenv")).toBeGreaterThan(joined.indexOf("--clearenv")) + expect(args.slice(-4)).toEqual(["--", "/bin/sh", "-c", "exec node server"]) +}) diff --git a/packages/codev-sandbox/src/bwrap/args.ts b/packages/codev-sandbox/src/bwrap/args.ts new file mode 100644 index 0000000000..27173b2ca3 --- /dev/null +++ b/packages/codev-sandbox/src/bwrap/args.ts @@ -0,0 +1,84 @@ +import { join } from "node:path" + +export const GUEST_WORKSPACE = "/workspace" +export const GUEST_BUNDLE = "/opt/codev" +export const GUEST_BRIDGE = "/opt/codev-bridge" +export const GUEST_RUNTIME = "/run/codev" +export const GUEST_HOME = "/home/codev" +// Fixed ports inside the guest's private network namespace — no conflicts are +// possible there, so no allocation is needed. +export const GUEST_SERVER_PORT = 4096 +export const GUEST_BROKER_PORT = 8788 +export const GUEST_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +export type RootfsEntry = { + name: string + symlinkTarget: string | null +} + +export type BwrapSpec = { + rootfsDir: string + rootfsEntries: RootfsEntry[] + projectDir: string + bundleDir: string + bridgeDir: string + runtimeDir: string + homeDir: string + env: Record + command: string[] +} + +// Mounts we manage ourselves; a rootfs copy of these must never shadow them. +const MANAGED_ROOTFS_ENTRIES = new Set(["proc", "dev", "sys", "tmp", "run", "home", "workspace"]) + +export function bindableRootfsEntries(entries: RootfsEntry[]) { + return entries.filter((entry) => !MANAGED_ROOTFS_ENTRIES.has(entry.name)) +} + +// --unshare-net gives the guest a network namespace whose only interface is +// loopback: the sole paths out are the unix sockets bind-mounted at /run/codev. +// Rootfs binds come first so the workspace/bundle/runtime binds mount over them. +export function buildBwrapArgs(spec: BwrapSpec) { + return [ + "--die-with-parent", + "--unshare-user", + "--unshare-pid", + "--unshare-net", + "--unshare-uts", + "--unshare-ipc", + "--hostname", + "codev-sandbox", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + ...bindableRootfsEntries(spec.rootfsEntries).flatMap((entry) => + entry.symlinkTarget + ? ["--symlink", entry.symlinkTarget, `/${entry.name}`] + : ["--ro-bind", join(spec.rootfsDir, entry.name), `/${entry.name}`], + ), + "--bind", + spec.projectDir, + GUEST_WORKSPACE, + "--ro-bind", + spec.bundleDir, + GUEST_BUNDLE, + "--ro-bind", + spec.bridgeDir, + GUEST_BRIDGE, + "--bind", + spec.runtimeDir, + GUEST_RUNTIME, + "--bind", + spec.homeDir, + GUEST_HOME, + "--chdir", + GUEST_WORKSPACE, + "--clearenv", + ...Object.entries(spec.env).flatMap(([key, value]) => ["--setenv", key, value]), + "--", + ...spec.command, + ] +} diff --git a/packages/codev-sandbox/src/bwrap/bridge.test.ts b/packages/codev-sandbox/src/bwrap/bridge.test.ts new file mode 100644 index 0000000000..4a625c3293 --- /dev/null +++ b/packages/codev-sandbox/src/bwrap/bridge.test.ts @@ -0,0 +1,128 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { connect, createServer } from "node:net" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { Server, Socket } from "node:net" +import { GUEST_FORWARD_SCRIPT, forwardTcpToUnix } from "./bridge" + +function listen(server: Server, target: string | number) { + return new Promise((resolve) => { + if (typeof target === "string") server.listen(target, () => resolve()) + else server.listen(target, "127.0.0.1", () => resolve()) + }) +} + +function roundtrip(socket: Socket, message: string) { + return new Promise((resolve, reject) => { + socket.on("data", (data) => { + socket.end() + resolve(String(data)) + }) + socket.on("error", reject) + socket.write(message) + }) +} + +function upperEcho(socket: Socket) { + socket.on("data", (data) => socket.write(String(data).toUpperCase())) +} + +test("forwards host tcp connections into a unix socket", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bridge-")) + const socketPath = join(dir, "server.sock") + const guest = createServer(upperEcho) + await listen(guest, socketPath) + + const forwarder = await forwardTcpToUnix(socketPath) + const reply = await new Promise((resolve, reject) => { + const socket = connect(forwarder.port, "127.0.0.1", () => { + roundtrip(socket, "ping").then(resolve, reject) + }) + }) + expect(reply).toBe("PING") + + await forwarder.close() + guest.close() + await rm(dir, { recursive: true, force: true }) +}) + +test("guest forward script bridges unix-to-tcp so the full server path works end-to-end", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bridge-")) + const socketPath = join(dir, "server.sock") + const script = join(dir, "forward.mjs") + await writeFile(script, GUEST_FORWARD_SCRIPT) + + // "Guest server": a TCP echo the script dials on 127.0.0.1, as the codev + // server would be inside the namespace. + const guestServer = createServer(upperEcho) + await listen(guestServer, 0) + const guestPort = (guestServer.address() as { port: number }).port + + const bridge = Bun.spawn([process.execPath, script, "unix-to-tcp", socketPath, String(guestPort)]) + const until = Date.now() + 5000 + while (Date.now() < until) { + const ready = await new Promise((resolve) => { + const probe = connect(socketPath, () => { + probe.end() + resolve(true) + }) + probe.on("error", () => resolve(false)) + }) + if (ready) break + await Bun.sleep(25) + } + + // Full chain: host tcp -> forwarder -> unix socket -> guest script -> guest tcp. + const forwarder = await forwardTcpToUnix(socketPath) + const reply = await new Promise((resolve, reject) => { + const socket = connect(forwarder.port, "127.0.0.1", () => { + roundtrip(socket, "hello").then(resolve, reject) + }) + }) + expect(reply).toBe("HELLO") + + await forwarder.close() + bridge.kill() + guestServer.close() + await rm(dir, { recursive: true, force: true }) +}) + +test("guest forward script bridges tcp-to-unix so the broker path works end-to-end", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bridge-")) + const socketPath = join(dir, "broker.sock") + const script = join(dir, "forward.mjs") + await writeFile(script, GUEST_FORWARD_SCRIPT) + + // "Host broker": listens on the unix socket the guest script dials. + const broker = createServer(upperEcho) + await listen(broker, socketPath) + + // Find a free port for the script's guest-side TCP listener. + const placeholder = createServer() + await listen(placeholder, 0) + const guestPort = (placeholder.address() as { port: number }).port + await new Promise((resolve) => placeholder.close(() => resolve())) + + const bridge = Bun.spawn([process.execPath, script, "tcp-to-unix", String(guestPort), socketPath]) + const until = Date.now() + 5000 + let reply = "" + while (Date.now() < until) { + const attempt = await new Promise((resolve) => { + const socket = connect(guestPort, "127.0.0.1", () => { + roundtrip(socket, "broker").then(resolve, () => resolve(null)) + }) + socket.on("error", () => resolve(null)) + }) + if (attempt !== null) { + reply = attempt + break + } + await Bun.sleep(25) + } + expect(reply).toBe("BROKER") + + bridge.kill() + broker.close() + await rm(dir, { recursive: true, force: true }) +}) diff --git a/packages/codev-sandbox/src/bwrap/bridge.ts b/packages/codev-sandbox/src/bwrap/bridge.ts new file mode 100644 index 0000000000..7834edcea2 --- /dev/null +++ b/packages/codev-sandbox/src/bwrap/bridge.ts @@ -0,0 +1,51 @@ +import { connect, createServer } from "node:net" + +// Host side of the server path: the desktop connects to 127.0.0.1: and +// each connection is piped into the guest over a unix socket in the shared +// runtime dir. Unix sockets are filesystem objects, so they cross the guest's +// network namespace where TCP cannot. +export async function forwardTcpToUnix(socketPath: string) { + const server = createServer((connection) => { + const upstream = connect(socketPath) + const drop = () => { + connection.destroy() + upstream.destroy() + } + connection.on("error", drop) + upstream.on("error", drop) + connection.pipe(upstream) + upstream.pipe(connection) + }) + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", () => resolve()) + }) + const address = server.address() + if (!address || typeof address === "string") throw new Error("sandbox forwarder failed to bind") + return { + port: address.port, + close: () => new Promise((resolve) => server.close(() => resolve())), + } +} + +// Runs inside the guest with the image's node. `unix-to-tcp ` +// listens on a unix socket and dials guest-local TCP (server path); +// `tcp-to-unix ` listens on guest-local TCP and dials a unix +// socket (broker path). +export const GUEST_FORWARD_SCRIPT = `import { connect, createServer } from "node:net" +const [mode, from, to] = process.argv.slice(2) +const dial = mode === "unix-to-tcp" ? () => connect(Number(to), "127.0.0.1") : () => connect(to) +const server = createServer((connection) => { + const upstream = dial() + const drop = () => { + connection.destroy() + upstream.destroy() + } + connection.on("error", drop) + upstream.on("error", drop) + connection.pipe(upstream) + upstream.pipe(connection) +}) +if (mode === "unix-to-tcp") server.listen(from) +else server.listen(Number(from), "127.0.0.1") +` diff --git a/packages/codev-sandbox/src/bwrap/bwrap.integration.test.ts b/packages/codev-sandbox/src/bwrap/bwrap.integration.test.ts new file mode 100644 index 0000000000..3806b4592c --- /dev/null +++ b/packages/codev-sandbox/src/bwrap/bwrap.integration.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { buildBwrapArgs, GUEST_HOME, GUEST_PATH } from "./args" +import { readRootfsEntries, runCommand } from "./rootfs" + +// Real-bwrap integration; runs only on Linux hosts where unprivileged user +// namespaces work (skipped on macOS/CI runners with AppArmor restrictions). +function probeBwrap() { + if (process.platform !== "linux") return false + try { + return Bun.spawnSync(["bwrap", "--ro-bind", "/", "/", "true"]).exitCode === 0 + } catch { + return false + } +} + +const bwrapUsable = probeBwrap() + +// The host's own / serves as the rootfs (the safe subset of it), so the test +// needs no image download; entries keep their real dir/symlink shape. +const HOST_ROOTFS_ALLOWLIST = new Set(["usr", "bin", "sbin", "lib", "lib32", "lib64", "libx32", "etc"]) + +async function hostSpec(dir: string) { + const projectDir = join(dir, "project") + const dirs = { bridge: join(dir, "bridge"), runtime: join(dir, "runtime"), home: join(dir, "home") } + await mkdir(projectDir, { recursive: true }) + await mkdir(dirs.bridge, { recursive: true }) + await mkdir(dirs.runtime, { recursive: true }) + await mkdir(dirs.home, { recursive: true }) + await writeFile(join(projectDir, "file.txt"), "hello from host") + return { + rootfsDir: "/", + rootfsEntries: (await readRootfsEntries("/")).filter((entry) => HOST_ROOTFS_ALLOWLIST.has(entry.name)), + projectDir, + bundleDir: dirs.bridge, + bridgeDir: dirs.bridge, + runtimeDir: dirs.runtime, + homeDir: dirs.home, + env: { PATH: GUEST_PATH, HOME: GUEST_HOME, CODEV_SERVER_PASSWORD: "sekret" }, + } +} + +test.skipIf(!bwrapUsable)("guest sees only loopback, the workspace, and the cleared env", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-int-")) + const spec = await hostSpec(dir) + const result = await runCommand( + "bwrap", + buildBwrapArgs({ + ...spec, + command: [ + "/bin/sh", + "-c", + 'cat /workspace/file.txt; echo; cat /proc/net/dev; echo "HOME=$HOME PASS=$CODEV_SERVER_PASSWORD"', + ], + }), + ) + expect(result.code).toBe(0) + expect(result.stdout).toContain("hello from host") + expect(result.stdout).toContain("HOME=/home/codev PASS=sekret") + const interfaces = result.stdout.match(/^\s*(\w+):/gm) ?? [] + expect(interfaces.map((line) => line.trim())).toEqual(["lo:"]) + + await rm(dir, { recursive: true, force: true }) +}) + +test.skipIf(!bwrapUsable)("workspace is writable while the rootfs stays read-only", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-int-")) + const spec = await hostSpec(dir) + const result = await runCommand( + "bwrap", + buildBwrapArgs({ + ...spec, + command: [ + "/bin/sh", + "-c", + "touch /workspace/written && echo wrote-workspace; touch /usr/should-fail 2>/dev/null || echo usr-readonly", + ], + }), + ) + expect(result.code).toBe(0) + expect(result.stdout).toContain("wrote-workspace") + expect(result.stdout).toContain("usr-readonly") + expect(await stat(join(spec.projectDir, "written")).catch(() => null)).not.toBe(null) + + await rm(dir, { recursive: true, force: true }) +}) diff --git a/packages/codev-sandbox/src/bwrap/provider.test.ts b/packages/codev-sandbox/src/bwrap/provider.test.ts new file mode 100644 index 0000000000..867630ca43 --- /dev/null +++ b/packages/codev-sandbox/src/bwrap/provider.test.ts @@ -0,0 +1,221 @@ +import { expect, test } from "bun:test" +import type { spawn } from "node:child_process" +import { EventEmitter } from "node:events" +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createHash } from "node:crypto" +import type { SandboxConfig } from "../provider" +import { createBwrapProvider, sandboxDirs, type BwrapProviderOptions } from "./provider" +import { runCommand } from "./rootfs" + +type FakeChild = EventEmitter & { stderr: EventEmitter; killed: boolean; kill: () => boolean } + +function fakeChild(): FakeChild { + const child = new EventEmitter() as FakeChild + child.stderr = new EventEmitter() + child.killed = false + child.kill = () => { + child.killed = true + return true + } + return child +} + +const config: SandboxConfig = { id: "sandbox:default", name: "default", backend: "bwrap", projectDir: "/tmp" } + +async function startOptions(dir: string, child: FakeChild, overrides?: Partial) { + const spawnCalls: { command: string; args: string[] }[] = [] + const dirs = sandboxDirs(join(dir, "data"), config.name) + await mkdir(join(dirs.rootfs, "usr"), { recursive: true }) + const projectDir = join(dir, "project") + await mkdir(projectDir, { recursive: true }) + const options: BwrapProviderOptions = { + dataDir: join(dir, "data"), + cacheDir: join(dir, "cache"), + bundleDir: join(dir, "bundle"), + resolveImage: async () => { + throw new Error("not used") + }, + platform: "linux", + spawnImpl: ((command: string, args: string[]) => { + spawnCalls.push({ command, args }) + return child + }) as unknown as typeof spawn, + fetchImpl: (async () => new Response("ok")) as unknown as typeof fetch, + ...overrides, + } + return { options, spawnCalls, projectDir } +} + +test("reports non-linux hosts as unsupported without probing", async () => { + const provider = createBwrapProvider({ + dataDir: "/x", + cacheDir: "/x", + bundleDir: "/x", + resolveImage: async () => { + throw new Error("not used") + }, + platform: "darwin", + }) + expect(await provider.availability()).toEqual({ supported: false, message: "bubblewrap sandboxes require Linux" }) +}) + +test("reports a missing bwrap binary", async () => { + const provider = createBwrapProvider({ + dataDir: "/x", + cacheDir: "/x", + bundleDir: "/x", + resolveImage: async () => { + throw new Error("not used") + }, + platform: "linux", + bwrapCommand: "codev-definitely-not-installed", + }) + expect(await provider.availability()).toEqual({ supported: false, message: "bubblewrap (bwrap) is not installed" }) +}) + +test("reports a bwrap that cannot create sandboxes (AppArmor-style failure)", async () => { + const provider = createBwrapProvider({ + dataDir: "/x", + cacheDir: "/x", + bundleDir: "/x", + resolveImage: async () => { + throw new Error("not used") + }, + platform: "linux", + bwrapCommand: "false", + }) + const availability = await provider.availability() + expect(availability.supported).toBe(false) + if (!availability.supported) expect(availability.message).toContain("cannot create sandboxes") +}) + +test("starts the sandbox: spawns bwrap with the isolated spec and returns the forwarded server", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-")) + const child = fakeChild() + const prepared = await startOptions(dir, child) + const provider = createBwrapProvider(prepared.options) + + const sandbox = await provider.start({ ...config, projectDir: prepared.projectDir }) + expect(sandbox.url).toStartWith("http://127.0.0.1:") + expect(sandbox.username).toBe("codev") + + expect(prepared.spawnCalls.length).toBe(1) + const joined = prepared.spawnCalls[0]!.args.join(" ") + expect(prepared.spawnCalls[0]!.command).toBe("bwrap") + expect(joined).toContain("--unshare-net") + expect(joined).toContain(`--bind ${prepared.projectDir} /workspace`) + expect(joined).toContain(`--setenv CODEV_SERVER_PASSWORD ${sandbox.password}`) + 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") + + let exit: { code: number | null } | undefined + sandbox.onExit((code) => { + exit = { code } + }) + sandbox.stop() + expect(child.killed).toBe(true) + child.emit("exit", 0, null) + await Bun.sleep(1) + expect(exit).toEqual({ code: 0 }) + + await rm(dir, { recursive: true, force: true }) +}) + +test("start fails with stderr detail when the guest exits during boot", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-")) + const child = fakeChild() + const prepared = await startOptions(dir, child, { + fetchImpl: (async () => { + throw new Error("no server yet") + }) as unknown as typeof fetch, + }) + const provider = createBwrapProvider(prepared.options) + setTimeout(() => { + child.stderr.emit("data", "bwrap: setting up uid map: Permission denied") + child.emit("exit", 1, null) + }, 10) + expect(provider.start({ ...config, projectDir: prepared.projectDir })).rejects.toThrow( + /exited during startup \(code=1 signal=null\).*Permission denied/, + ) + await Bun.sleep(30) + await rm(dir, { recursive: true, force: true }) +}) + +test("start fails after the timeout when the server never becomes healthy", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-")) + const child = fakeChild() + const prepared = await startOptions(dir, child, { + startTimeoutMs: 40, + fetchImpl: (async () => { + throw new Error("no server yet") + }) as unknown as typeof fetch, + }) + const provider = createBwrapProvider(prepared.options) + await expect(provider.start({ ...config, projectDir: prepared.projectDir })).rejects.toThrow( + "did not become healthy within 40ms", + ) + expect(child.killed).toBe(true) + await rm(dir, { recursive: true, force: true }) +}) + +test("start requires create to have run first", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-")) + const child = fakeChild() + const prepared = await startOptions(dir, child) + await rm(sandboxDirs(prepared.options.dataDir, config.name).rootfs, { recursive: true, force: true }) + const provider = createBwrapProvider(prepared.options) + expect(provider.start({ ...config, projectDir: prepared.projectDir })).rejects.toThrow("missing rootfs") + await rm(dir, { recursive: true, force: true }) +}) + +test("create/reset/destroy manage the sandbox dirs from a staged image", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-")) + const tree = join(dir, "tree") + await mkdir(join(tree, "etc"), { recursive: true }) + await writeFile(join(tree, "etc", "os-release"), "ID=codev-sandbox\n") + const tarPath = join(dir, "rootfs.tar") + expect((await runCommand("tar", ["-cf", tarPath, "-C", tree, "etc"])).code).toBe(0) + const payload = await Bun.file(tarPath).bytes() + + let downloads = 0 + const server = Bun.serve({ + port: 0, + fetch: () => { + downloads++ + return new Response(payload) + }, + }) + const options: BwrapProviderOptions = { + dataDir: join(dir, "data"), + cacheDir: join(dir, "cache"), + bundleDir: join(dir, "bundle"), + platform: "linux", + resolveImage: async () => ({ + url: `http://127.0.0.1:${server.port}/rootfs.tar`, + sha256: createHash("sha256").update(payload).digest("hex"), + bytes: payload.byteLength, + }), + } + const provider = createBwrapProvider(options) + const dirs = sandboxDirs(options.dataDir, config.name) + + await provider.create(config) + expect(await Bun.file(join(dirs.rootfs, "etc", "os-release")).text()).toBe("ID=codev-sandbox\n") + expect((await stat(dirs.home)).isDirectory()).toBe(true) + + await writeFile(join(dirs.home, "state.db"), "guest state") + await provider.reset(config) + expect(await stat(join(dirs.home, "state.db")).catch(() => null)).toBe(null) + expect(await Bun.file(join(dirs.rootfs, "etc", "os-release")).text()).toBe("ID=codev-sandbox\n") + expect(downloads).toBe(1) + + await provider.destroy(config) + expect(await stat(dirs.base).catch(() => null)).toBe(null) + + server.stop(true) + await rm(dir, { recursive: true, force: true }) +}) diff --git a/packages/codev-sandbox/src/bwrap/provider.ts b/packages/codev-sandbox/src/bwrap/provider.ts new file mode 100644 index 0000000000..d9010fab6f --- /dev/null +++ b/packages/codev-sandbox/src/bwrap/provider.ts @@ -0,0 +1,189 @@ +import { spawn } from "node:child_process" +import { mkdir, rm, stat, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { + checkSandboxHealth, + generateSandboxCredentials, + pollSandboxHealth, + sandboxBootEnv, + sandboxServeArgs, +} from "../boot" +import { stageAsset } from "../image" +import type { SandboxAsset } from "../manifest" +import type { SandboxAvailability, SandboxConfig, SandboxProvider } from "../provider" +import { + GUEST_BRIDGE, + GUEST_BROKER_PORT, + GUEST_BUNDLE, + GUEST_HOME, + GUEST_PATH, + GUEST_RUNTIME, + GUEST_SERVER_PORT, + buildBwrapArgs, +} from "./args" +import { GUEST_FORWARD_SCRIPT, forwardTcpToUnix } from "./bridge" +import { readRootfsEntries, runCommand, unpackRootfs } from "./rootfs" + +export type BwrapProviderOptions = { + dataDir: string + cacheDir: string + // Host path of the server bundle (dist/node), mounted read-only into the + // guest so version parity with the desktop app is automatic. + bundleDir: string + resolveImage: (opts?: { signal?: AbortSignal }) => Promise + bwrapCommand?: string + guestNodeCommand?: string + platform?: NodeJS.Platform + spawnImpl?: typeof spawn + fetchImpl?: typeof fetch + startTimeoutMs?: number +} + +export function sandboxDirs(dataDir: string, name: string) { + const base = join(dataDir, name) + return { + base, + rootfs: join(base, "rootfs"), + home: join(base, "home"), + runtime: join(base, "runtime"), + bridge: join(base, "bridge"), + } +} + +export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvider { + const bwrap = options.bwrapCommand ?? "bwrap" + const guestNode = options.guestNodeCommand ?? "node" + const platform = options.platform ?? process.platform + const spawnImpl = options.spawnImpl ?? spawn + const fetchImpl = options.fetchImpl ?? fetch + const startTimeoutMs = options.startTimeoutMs ?? 30_000 + + const prepare = async (config: SandboxConfig, opts?: { signal?: AbortSignal }) => { + const asset = await options.resolveImage(opts) + const image = await stageAsset(options.cacheDir, asset, { fetchImpl, signal: opts?.signal }) + const dirs = sandboxDirs(options.dataDir, config.name) + await unpackRootfs(image, dirs.rootfs, opts) + await mkdir(dirs.home, { recursive: true }) + } + + return { + backend: "bwrap", + + async availability(): Promise { + if (platform !== "linux") return { supported: false, message: "bubblewrap sandboxes require Linux" } + const probe = await runCommand(bwrap, ["--ro-bind", "/", "/", "true"]).catch(() => null) + if (!probe) return { supported: false, message: "bubblewrap (bwrap) is not installed" } + if (probe.code !== 0) + return { + supported: false, + message: `bubblewrap cannot create sandboxes on this system: ${probe.stderr.trim() || `bwrap exited ${probe.code}`}. On Ubuntu 23.10+ this usually means unprivileged user namespaces are restricted by AppArmor.`, + } + return { supported: true, warning: null } + }, + + async create(config, opts) { + await prepare(config, opts) + }, + + async start(config) { + const dirs = sandboxDirs(options.dataDir, config.name) + const project = await stat(config.projectDir).catch(() => null) + if (!project?.isDirectory()) throw new Error(`project directory does not exist: ${config.projectDir}`) + const rootfs = await stat(dirs.rootfs).catch(() => null) + if (!rootfs?.isDirectory()) throw new Error(`sandbox has not been created: missing rootfs at ${dirs.rootfs}`) + await rm(dirs.runtime, { recursive: true, force: true }) + await mkdir(dirs.runtime, { recursive: true }) + await mkdir(dirs.bridge, { recursive: true }) + await mkdir(dirs.home, { recursive: true }) + await writeFile(join(dirs.bridge, "forward.mjs"), GUEST_FORWARD_SCRIPT) + + const credentials = generateSandboxCredentials() + const bootScript = [ + `${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(" ")}`, + ].join("\n") + const child = spawnImpl( + bwrap, + buildBwrapArgs({ + rootfsDir: dirs.rootfs, + rootfsEntries: await readRootfsEntries(dirs.rootfs), + projectDir: config.projectDir, + bundleDir: options.bundleDir, + bridgeDir: dirs.bridge, + runtimeDir: dirs.runtime, + homeDir: dirs.home, + env: { + ...sandboxBootEnv(credentials, GUEST_HOME), + PATH: GUEST_PATH, + HOME: GUEST_HOME, + }, + command: ["/bin/sh", "-c", bootScript], + }), + { stdio: ["ignore", "ignore", "pipe"] }, + ) + let stderrTail = "" + child.stderr?.on("data", (chunk) => { + stderrTail = `${stderrTail}${chunk}`.slice(-2000) + }) + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.once("exit", (code, signal) => resolve({ code, signal })) + }) + const forwarder = await forwardTcpToUnix(join(dirs.runtime, "server.sock")) + const url = `http://127.0.0.1:${forwarder.port}` + + const abort = new AbortController() + let timeoutHandle: ReturnType | undefined + const outcome = await Promise.race([ + pollSandboxHealth(() => checkSandboxHealth(url, credentials, fetchImpl), abort.signal).then( + () => "healthy" as const, + ), + exited, + new Promise<"timeout">((resolve) => { + timeoutHandle = setTimeout(() => resolve("timeout"), startTimeoutMs) + }), + ]) + abort.abort() + clearTimeout(timeoutHandle) + if (outcome !== "healthy") { + child.kill() + await forwarder.close() + const detail = stderrTail.trim() + if (outcome === "timeout") + throw new Error( + `sandbox server did not become healthy within ${startTimeoutMs}ms${detail ? `: ${detail}` : ""}`, + ) + throw new Error( + `sandbox server exited during startup (code=${outcome.code ?? "null"} signal=${outcome.signal ?? "null"})${detail ? `: ${detail}` : ""}`, + ) + } + + return { + url, + username: credentials.username, + password: credentials.password, + stop: () => { + child.kill() + void forwarder.close() + }, + onExit: (cb) => { + void exited.then((exit) => { + void forwarder.close() + cb(exit.code, exit.signal) + }) + }, + } + }, + + async destroy(config) { + await rm(sandboxDirs(options.dataDir, config.name).base, { recursive: true, force: true }) + }, + + async reset(config, opts) { + const dirs = sandboxDirs(options.dataDir, config.name) + await rm(dirs.home, { recursive: true, force: true }) + await rm(dirs.rootfs, { recursive: true, force: true }) + await prepare(config, opts) + }, + } +} diff --git a/packages/codev-sandbox/src/bwrap/rootfs.test.ts b/packages/codev-sandbox/src/bwrap/rootfs.test.ts new file mode 100644 index 0000000000..e1ee4a12d2 --- /dev/null +++ b/packages/codev-sandbox/src/bwrap/rootfs.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, symlink, writeFile, stat } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { readRootfsEntries, runCommand, unpackRootfs } from "./rootfs" + +async function buildFixtureTar(dir: string) { + const tree = join(dir, "tree") + await mkdir(join(tree, "usr", "bin"), { recursive: true }) + await mkdir(join(tree, "etc"), { recursive: true }) + await writeFile(join(tree, "usr", "bin", "hello"), "#!/bin/sh\necho hello\n") + await writeFile(join(tree, "etc", "os-release"), "ID=codev-sandbox\n") + await symlink("usr/bin", join(tree, "bin")) + const tarPath = join(dir, "rootfs.tar") + const result = await runCommand("tar", ["-cf", tarPath, "-C", tree, "usr", "etc", "bin"]) + expect(result.code).toBe(0) + return tarPath +} + +test("unpacks a rootfs image and reads its bindable layout", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-rootfs-")) + const tarPath = await buildFixtureTar(dir) + const rootfsDir = join(dir, "rootfs") + + await unpackRootfs(tarPath, rootfsDir) + expect(await Bun.file(join(rootfsDir, "etc", "os-release")).text()).toBe("ID=codev-sandbox\n") + + const entries = (await readRootfsEntries(rootfsDir)).sort((a, b) => a.name.localeCompare(b.name)) + expect(entries).toEqual([ + { name: "bin", symlinkTarget: "usr/bin" }, + { name: "etc", symlinkTarget: null }, + { name: "usr", symlinkTarget: null }, + ]) + + await rm(dir, { recursive: true, force: true }) +}) + +test("re-unpacking wipes previous rootfs contents (reset semantics)", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-rootfs-")) + const tarPath = await buildFixtureTar(dir) + const rootfsDir = join(dir, "rootfs") + + await unpackRootfs(tarPath, rootfsDir) + await writeFile(join(rootfsDir, "leftover.txt"), "state from a previous run") + await unpackRootfs(tarPath, rootfsDir) + expect(await stat(join(rootfsDir, "leftover.txt")).catch(() => null)).toBe(null) + expect(await stat(join(rootfsDir, "usr", "bin", "hello")).catch(() => null)).not.toBe(null) + + await rm(dir, { recursive: true, force: true }) +}) + +test("surfaces tar failures with stderr detail", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-rootfs-")) + const notATar = join(dir, "bogus.tar") + await writeFile(notATar, "this is not a tar archive") + expect(unpackRootfs(notATar, join(dir, "rootfs"))).rejects.toThrow("sandbox rootfs unpack failed") + await Bun.sleep(10) + await rm(dir, { recursive: true, force: true }) +}) diff --git a/packages/codev-sandbox/src/bwrap/rootfs.ts b/packages/codev-sandbox/src/bwrap/rootfs.ts new file mode 100644 index 0000000000..9a36e3d46d --- /dev/null +++ b/packages/codev-sandbox/src/bwrap/rootfs.ts @@ -0,0 +1,53 @@ +import { spawn } from "node:child_process" +import { mkdir, readdir, readlink, rm } from "node:fs/promises" +import { join } from "node:path" +import type { RootfsEntry } from "./args" + +export type RunCommandResult = { + code: number | null + stdout: string + stderr: string +} + +export function runCommand( + command: string, + args: string[], + opts?: { signal?: AbortSignal }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], signal: opts?.signal }) + let stdout = "" + let stderr = "" + child.stdout?.on("data", (chunk) => { + stdout += chunk + }) + child.stderr?.on("data", (chunk) => { + stderr += chunk + }) + child.once("error", reject) + child.once("exit", (code) => resolve({ code, stdout, stderr })) + }) +} + +export async function unpackRootfs(imagePath: string, rootfsDir: string, opts?: { signal?: AbortSignal }) { + await rm(rootfsDir, { recursive: true, force: true }) + await mkdir(rootfsDir, { recursive: true }) + const result = await runCommand("tar", ["-xf", imagePath, "-C", rootfsDir], opts) + if (result.code !== 0) + throw new Error(`sandbox rootfs unpack failed: ${result.stderr.trim() || `tar exited ${result.code}`}`) +} + +// Top-level dirs and symlinks of the unpacked rootfs; symlinks are reported +// with their target so merged-usr layouts (/bin -> usr/bin) are recreated as +// symlinks instead of duplicate binds. +export async function readRootfsEntries(rootfsDir: string): Promise { + const entries = await readdir(rootfsDir, { withFileTypes: true }) + return Promise.all( + entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map(async (entry) => ({ + name: entry.name, + symlinkTarget: entry.isSymbolicLink() ? await readlink(join(rootfsDir, entry.name)) : null, + })), + ) +} diff --git a/packages/codev-sandbox/src/index.ts b/packages/codev-sandbox/src/index.ts index 948b8dbfc8..92420de425 100644 --- a/packages/codev-sandbox/src/index.ts +++ b/packages/codev-sandbox/src/index.ts @@ -6,3 +6,7 @@ export * from "./image" export * from "./broker" export * from "./boot" export * from "./const" +export * from "./bwrap/args" +export * from "./bwrap/bridge" +export * from "./bwrap/rootfs" +export * from "./bwrap/provider" diff --git a/turbo.json b/turbo.json index 06e6e3f522..be9fbf02f0 100644 --- a/turbo.json +++ b/turbo.json @@ -28,6 +28,10 @@ "@opencode-ai/session-ui#test": { "dependsOn": ["^build"], "outputs": [] + }, + "@codev/sandbox#test": { + "dependsOn": [], + "outputs": [] } } }