From f80d16a90c3db314fc4f420452d6a4b16da4c3d8 Mon Sep 17 00:00:00 2001 From: Saiph77 Date: Fri, 7 Aug 2026 16:48:50 +0800 Subject: [PATCH] sandbox: add per-environment host workspaces --- src/config.ts | 35 +++- src/sandbox/host-sandbox.ts | 298 +++++++++++++++++++++++++++++++++ src/sandbox/sandbox-migrate.ts | 98 +++++------ src/sandbox/sandbox-routing.ts | 2 +- src/wiring.ts | 9 + test/config.test.ts | 25 +++ test/host-sandbox.test.ts | 131 +++++++++++++++ test/sandbox-migrate.test.ts | 4 +- 8 files changed, 546 insertions(+), 56 deletions(-) create mode 100644 src/sandbox/host-sandbox.ts create mode 100644 test/host-sandbox.test.ts diff --git a/src/config.ts b/src/config.ts index 16e85a7b..8874228d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,8 +31,11 @@ export interface Config { databaseUrl?: string; harness: "mock" | "pi" | "opencode" | "codex" | "claude"; securityPosture: SecurityPosture; - sandboxBackend: "aws" | "local" | "sprites"; - sandboxSecondaryBackend?: "aws" | "local" | "sprites"; + sandboxBackend: "aws" | "host" | "local" | "sprites"; + sandboxSecondaryBackend?: "aws" | "host" | "local" | "sprites"; + hostWorkspaceRoot?: string; + hostWorkspacesRoot?: string; + hostProcessEnv: NodeJS.ProcessEnv; deployProvider: "docker" | "aws"; egressServiceHosts?: string[]; brandingDefault?: { accent?: string; mark?: string; selfLabel?: string }; @@ -476,8 +479,8 @@ function harnessEnvStrict(value: string | undefined): Config["harness"] { function sandboxBackendEnvStrict(value: string | undefined, name = "SANDBOX_BACKEND"): Config["sandboxBackend"] { if (value === undefined || value.trim() === "") return "local"; const backend = value.trim(); - if (backend === "aws" || backend === "local" || backend === "sprites") return backend; - throw new Error(`${name}=${JSON.stringify(value)} is not recognized — use aws, local, or sprites, or unset it.`); + if (backend === "aws" || backend === "host" || backend === "local" || backend === "sprites") return backend; + throw new Error(`${name}=${JSON.stringify(value)} is not recognized — use aws, host, local, or sprites, or unset it.`); } function secretsBackendEnvStrict(value: string | undefined, prefix: string): Config["secretsBackend"] { @@ -579,16 +582,26 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { } const dataDir = resolve(env.DATA_DIR ?? "./data"); if (env.NODE_ENV === "production" && !env.SANDBOX_BACKEND?.trim()) { - throw new Error("SANDBOX_BACKEND must be set explicitly in production — use sprites, aws, or local."); + throw new Error("SANDBOX_BACKEND must be set explicitly in production — use sprites, aws, host, or local."); } - const sandboxBackend = sandboxBackendEnvStrict(env.SANDBOX_BACKEND); + const selectHostWorkspace = (backend: Config["sandboxBackend"]): Config["sandboxBackend"] => + env.HOST_WORKSPACE_ROOT && backend === "local" ? "host" : backend; + const sandboxBackend = selectHostWorkspace(sandboxBackendEnvStrict(env.SANDBOX_BACKEND)); + if (env.HOST_WORKSPACE_ROOT && env.HOST_WORKSPACES_ROOT) + throw new Error("HOST_WORKSPACE_ROOT and HOST_WORKSPACES_ROOT are mutually exclusive"); const secondaryRaw = env.SANDBOX_SECONDARY_BACKEND?.trim(); let sandboxSecondaryBackend: Config["sandboxSecondaryBackend"]; if (secondaryRaw) { - const secondary = sandboxBackendEnvStrict(secondaryRaw, "SANDBOX_SECONDARY_BACKEND"); + const secondary = selectHostWorkspace(sandboxBackendEnvStrict(secondaryRaw, "SANDBOX_SECONDARY_BACKEND")); if (secondary === sandboxBackend) throw new Error("SANDBOX_SECONDARY_BACKEND must differ from SANDBOX_BACKEND."); sandboxSecondaryBackend = secondary; } + if ( + (sandboxBackend === "host" || sandboxSecondaryBackend === "host") && + !env.HOST_WORKSPACE_ROOT && + !env.HOST_WORKSPACES_ROOT + ) + throw new Error("SANDBOX_BACKEND=host requires HOST_WORKSPACE_ROOT or HOST_WORKSPACES_ROOT"); const securityScreenBackend = securityScreenBackendEnvStrict(env.SECURITY_SCREEN_BACKEND); const proxyProvider = env.SECURITY_SCREEN_PROXY_PROVIDER?.trim(); const proxyEndpoint = env.SECURITY_SCREEN_PROXY_ENDPOINT?.trim(); @@ -658,6 +671,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { "CODEX_HOME", ].flatMap((name) => (env[name] === undefined ? [] : [[name, env[name]]])), ) as NodeJS.ProcessEnv; + const hostProcessEnv = Object.fromEntries( + ["PATH", "LANG", "LC_ALL", "SHELL", "TERM", "USER", "SSH_AUTH_SOCK"].flatMap((name) => + env[name] === undefined ? [] : [[name, env[name]]], + ), + ) as NodeJS.ProcessEnv; const claudeProcessEnv = Object.fromEntries( [ "PATH", @@ -707,6 +725,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { : {}), sandboxBackend, ...(sandboxSecondaryBackend ? { sandboxSecondaryBackend } : {}), + ...(env.HOST_WORKSPACE_ROOT ? { hostWorkspaceRoot: resolve(env.HOST_WORKSPACE_ROOT) } : {}), + ...(env.HOST_WORKSPACES_ROOT ? { hostWorkspacesRoot: resolve(env.HOST_WORKSPACES_ROOT) } : {}), + hostProcessEnv, deployProvider, ...(env.EGRESS_SERVICE_HOSTS ? { diff --git a/src/sandbox/host-sandbox.ts b/src/sandbox/host-sandbox.ts new file mode 100644 index 00000000..3aef7483 --- /dev/null +++ b/src/sandbox/host-sandbox.ts @@ -0,0 +1,298 @@ +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { arch } from "node:os"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { WorkspaceLayer } from "../types.ts"; +import { createKeyedQueue } from "../util/async.ts"; +import type { WorkspaceStore } from "../workspace/workspace-store.ts"; +import { createExecProcessSessions, type ExecProcessIo } from "./exec-process-session.ts"; +import { materializeRoLayers } from "./ro-layers.ts"; +import type { + AgentComputerProfile, + ExecOptions, + ExecResult, + ProvisionOptions, + Sandbox, + SandboxHandle, +} from "./sandbox.ts"; + +const OUTPUT_LIMIT = 4 * 1024 * 1024; + +export interface HostSandboxOptions { + rootDir?: string; + workspacesRoot?: string; + defaultTimeoutMs?: number; + env?: NodeJS.ProcessEnv; +} + +function inside(root: string, path: string): boolean { + return path === root || path.startsWith(`${root}${sep}`); +} + +async function checkedPath(root: string, relPath: string): Promise { + if (!relPath || relPath.includes("\0") || isAbsolute(relPath)) + throw new Error(`workspace path must be relative: ${relPath}`); + const target = resolve(root, relPath); + if (!inside(root, target)) throw new Error(`workspace path escapes the configured root: ${relPath}`); + let probe = target; + while (inside(root, probe)) { + try { + const probeStats = await lstat(probe); + if (probeStats.isSymbolicLink()) { + const resolvedProbe = await realpath(probe).catch(() => null); + if (!resolvedProbe || !inside(root, resolvedProbe)) + throw new Error(`workspace path escapes through a symlink: ${relPath}`); + return target; + } + const resolvedProbe = await realpath(probe); + if (!inside(root, resolvedProbe)) throw new Error(`workspace path escapes through a symlink: ${relPath}`); + return target; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (probe === root) break; + probe = dirname(probe); + } + } + return target; +} + +async function collectFiles(root: string, relDir: string): Promise { + const base = await checkedPath(root, relDir || "."); + const entries = await readdir(base, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return []; + throw error; + }); + const out: string[] = []; + for (const entry of entries) { + const rel = relative(root, resolve(base, entry.name)); + if (entry.isFile()) out.push(rel); + else if (entry.isDirectory()) out.push(...(await collectFiles(root, rel))); + } + return out; +} + +function safeSegment(value: string): string | null { + return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) ? value : null; +} + +export function hostWorkspaceDirectory(environmentId: string): string { + const projectPrefix = "group:web-project-"; + if (environmentId.startsWith(projectPrefix)) { + const projectId = safeSegment(environmentId.slice(projectPrefix.length)); + if (projectId) return `project-${projectId}`; + } + const channelPrefix = "channel:"; + if (environmentId.startsWith(channelPrefix)) { + const channelId = safeSegment(environmentId.slice(channelPrefix.length)); + if (channelId) return `channel-${channelId}`; + } + const label = safeSegment(environmentId)?.slice(0, 72) ?? "environment"; + const hash = createHash("sha256").update(environmentId).digest("hex").slice(0, 16); + return `${label}-${hash}`; +} + +function environmentIdFrom(layers: WorkspaceLayer[], opts?: ProvisionOptions): string { + const writable = layers.find((layer) => layer.mode === "rw")?.scopeId; + const id = writable ?? opts?.routeScopeId; + if (!id) throw new Error("host workspace requires a writable environment scope"); + return id; +} + +export function createHostSandbox(workspace: WorkspaceStore, options: HostSandboxOptions): Sandbox { + const rootDir = options.rootDir ? resolve(options.rootDir) : undefined; + const workspacesRoot = options.workspacesRoot ? resolve(options.workspacesRoot) : undefined; + if ((rootDir ? 1 : 0) + (workspacesRoot ? 1 : 0) !== 1) + throw new Error("host sandbox requires exactly one of rootDir or workspacesRoot"); + const defaultTimeoutMs = options.defaultTimeoutMs ?? 300_000; + const queue = createKeyedQueue(); + const profile: AgentComputerProfile = { + backend: "host-workspace", + writablePersistence: "resident_disk", + processSessions: true, + egressEnforcement: "none", + spec: { + os: `Host OS on ${arch()} (trusted workspace directories)`, + runtimes: ["host runtimes"], + tools: ["host PATH"], + }, + }; + + async function runCommand(handle: SandboxHandle, command: string, opts?: ExecOptions): Promise { + return queue( + handle.id, + () => + new Promise((resolveResult) => { + const child = spawn("/bin/sh", ["-lc", command], { + cwd: handle.rootDir, + env: { ...options.env, ...handle.env }, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + let settled = false; + const append = (prior: string, chunk: Buffer): string => (prior + chunk.toString("utf8")).slice(-OUTPUT_LIMIT); + child.stdout.on("data", (chunk: Buffer) => { + stdout = append(stdout, chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr = append(stderr, chunk); + }); + const stop = (signal: NodeJS.Signals) => { + if (!child.pid) return; + try { + process.kill(-child.pid, signal); + } catch { + child.kill(signal); + } + }; + const timeout = setTimeout(() => { + timedOut = true; + stop("SIGKILL"); + }, opts?.timeoutMs ?? defaultTimeoutMs); + const onAbort = () => stop("SIGTERM"); + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + opts?.signal?.removeEventListener("abort", onAbort); + resolveResult({ stdout, stderr: `${stderr}${error.message}`, code: 1, timedOut }); + }); + child.on("close", (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + opts?.signal?.removeEventListener("abort", onAbort); + resolveResult({ stdout, stderr, code: timedOut ? 124 : (code ?? (signal ? 128 : 1)), timedOut }); + }); + }), + ); + } + + const processes = createExecProcessSessions({ run: runCommand } satisfies ExecProcessIo); + const sandbox: Sandbox = { + profile, + ...processes, + + async provision(layers, opts) { + if (opts?.scratch) throw new Error("scratch execution is unavailable with host workspaces"); + const environmentId = environmentIdFrom(layers, opts); + if (rootDir) { + const homeDir = resolve(rootDir, "data/host-home"); + const tmpDir = resolve(rootDir, "data/host-tmp"); + await mkdir(homeDir, { recursive: true }); + await mkdir(tmpDir, { recursive: true }); + const rootStats = await stat(rootDir); + if (!rootStats.isDirectory()) throw new Error(`HOST_WORKSPACE_ROOT is not a directory: ${rootDir}`); + const handle: SandboxHandle = { + id: "host-workspace", + rootDir: await realpath(rootDir), + homeDir: await realpath(homeDir), + scopeId: environmentId, + env: { ...opts?.env, HOME: await realpath(homeDir), TMPDIR: await realpath(tmpDir) }, + }; + await materializeRoLayers( + workspace, + layers, + handle, + { + readFile: (h, path) => sandbox.readFile(h, path), + writeFileBytes: (h, path, data) => sandbox.writeFileBytes(h, path, data), + exec: async (script, timeoutSec) => { + const result = await sandbox.run(handle, script, { timeoutMs: timeoutSec * 1000 }); + return { code: result.code, stderr: result.stderr }; + }, + }, + { manifest: ".qm-ro-layers.sha256", tar: ".qm-ro-layers.tar", label: "host workspace" }, + ); + return handle; + } + await mkdir(workspacesRoot!, { recursive: true }); + const configuredRoot = await realpath(workspacesRoot!); + const contextDir = resolve(configuredRoot, hostWorkspaceDirectory(environmentId)); + const contextStats = await lstat(contextDir).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (contextStats?.isSymbolicLink()) throw new Error(`host workspace directory cannot be a symlink: ${contextDir}`); + const workspaceDir = resolve(contextDir, "workspace"); + const homeDir = resolve(contextDir, "home"); + const tmpDir = resolve(contextDir, "tmp"); + await Promise.all([workspaceDir, homeDir, tmpDir].map((dir) => mkdir(dir, { recursive: true }))); + const [resolvedWorkspace, resolvedHome, resolvedTmp] = await Promise.all( + [realpath(workspaceDir), realpath(homeDir), realpath(tmpDir)] as const, + ); + if (![resolvedWorkspace, resolvedHome, resolvedTmp].every((dir) => inside(configuredRoot, dir))) + throw new Error(`host workspace escapes the configured root: ${contextDir}`); + const handle: SandboxHandle = { + id: `host-workspace:${environmentId}`, + rootDir: resolvedWorkspace, + homeDir: resolvedHome, + scopeId: environmentId, + env: { ...opts?.env, HOME: resolvedHome, TMPDIR: resolvedTmp }, + }; + await materializeRoLayers( + workspace, + layers, + handle, + { + readFile: (h, path) => sandbox.readFile(h, path), + writeFileBytes: (h, path, data) => sandbox.writeFileBytes(h, path, data), + exec: async (script, timeoutSec) => { + const result = await sandbox.run(handle, script, { timeoutMs: timeoutSec * 1000 }); + return { code: result.code, stderr: result.stderr }; + }, + }, + { manifest: ".qm-ro-layers.sha256", tar: ".qm-ro-layers.tar", label: "host workspace" }, + ); + return handle; + }, + + run: runCommand, + + async readFileBytes(handle, relPath) { + const path = await checkedPath(handle.rootDir, relPath); + return readFile(path).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "EISDIR") return null; + throw error; + }); + }, + + async readFile(handle, relPath) { + const data = await sandbox.readFileBytes(handle, relPath); + return data === null ? null : Buffer.from(data).toString("utf8"); + }, + + async writeFileBytes(handle, relPath, data) { + const path = await checkedPath(handle.rootDir, relPath); + await queue(handle.id, async () => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, data); + }); + }, + + async writeFile(handle, relPath, data) { + await sandbox.writeFileBytes(handle, relPath, Buffer.from(data, "utf8")); + }, + + async extractFiles(handle, entries) { + for (const entry of entries) await sandbox.writeFileBytes(handle, entry.path, entry.data); + }, + + listDir(handle, relDir) { + return collectFiles(handle.rootDir, relDir); + }, + + async removeDir(handle, relDir) { + const path = await checkedPath(handle.rootDir, relDir); + if (path === handle.rootDir) throw new Error(`refusing to remove host workspace ${basename(handle.rootDir)}`); + await queue(handle.id, () => rm(path, { recursive: true, force: true })); + }, + + async teardown() {}, + }; + return sandbox; +} diff --git a/src/sandbox/sandbox-migrate.ts b/src/sandbox/sandbox-migrate.ts index a6713235..ab14df95 100644 --- a/src/sandbox/sandbox-migrate.ts +++ b/src/sandbox/sandbox-migrate.ts @@ -48,58 +48,62 @@ export async function copyHome(args: CopyHomeArgs): Promise { const { fromSandbox, fromHandle, fromHome, toSandbox, toHandle, toHome } = args; const timeoutMs = (args.timeoutSec ?? 900) * 1000; const uid = randomUUID(); - const tarPath = `/tmp/.home-${uid}.tgz`; + const fromTarPath = posix.join(fromHandle.rootDir, `.qm-home-${uid}.tgz`); + const toTarPath = posix.join(toHandle.rootDir, `.qm-home-${uid}.tgz`); const H = shq(fromHome); const T = shq(toHome); - const fromRel = posix.relative(fromHandle.rootDir, tarPath); - const toRel = posix.relative(toHandle.rootDir, tarPath); - - const packed = await fromSandbox.run( - fromHandle, - `cd ${H} && tar czf ${tarPath} . 2>/dev/null && sha256sum ${tarPath} | cut -d' ' -f1 && wc -c < ${tarPath} && find . -type f | wc -l`, - { timeoutMs }, - ); - if (packed.code !== 0) - throw new Error(`copyHome: source tar failed (${packed.code}): ${(packed.stderr || packed.stdout).slice(0, 200)}`); - const [shaLine = "", sizeLine = "", filesLine = ""] = packed.stdout.trim().split("\n"); - const sha = shaLine.trim(); - const bytes = Number.parseInt(sizeLine.trim(), 10); - const sourceFiles = Number.parseInt(filesLine.trim(), 10); - if (!/^[0-9a-f]{64}$/.test(sha) || !Number.isFinite(bytes) || !Number.isFinite(sourceFiles)) { - throw new Error(`copyHome: unreadable source manifest: ${packed.stdout.slice(0, 200)}`); - } + const fromRel = posix.relative(fromHandle.rootDir, fromTarPath); + const toRel = posix.relative(toHandle.rootDir, toTarPath); + try { + const packed = await fromSandbox.run( + fromHandle, + `cd ${H} && tar czf ${shq(fromTarPath)} . 2>/dev/null && sha256sum ${shq(fromTarPath)} | cut -d' ' -f1 && wc -c < ${shq(fromTarPath)} && find . -type f | wc -l`, + { timeoutMs }, + ); + if (packed.code !== 0) + throw new Error( + `copyHome: source tar failed (${packed.code}): ${(packed.stderr || packed.stdout).slice(0, 200)}`, + ); + const [shaLine = "", sizeLine = "", filesLine = ""] = packed.stdout.trim().split("\n"); + const sha = shaLine.trim(); + const bytes = Number.parseInt(sizeLine.trim(), 10); + const sourceFiles = Number.parseInt(filesLine.trim(), 10); + if (!/^[0-9a-f]{64}$/.test(sha) || !Number.isFinite(bytes) || !Number.isFinite(sourceFiles)) { + throw new Error(`copyHome: unreadable source manifest: ${packed.stdout.slice(0, 200)}`); + } - if (supportsBlobStaging(fromSandbox) && supportsBlobStaging(toSandbox)) { - const blobId = await fromSandbox.stageOut(fromHandle, fromRel); - await toSandbox.stageIn(toHandle, toRel, blobId); - } else { - const tarBytes = await fromSandbox.readFileBytes(fromHandle, fromRel); - if (!tarBytes) throw new Error("copyHome: source tar vanished before read"); - await toSandbox.writeFileBytes(toHandle, toRel, tarBytes); - } + if (supportsBlobStaging(fromSandbox) && supportsBlobStaging(toSandbox)) { + const blobId = await fromSandbox.stageOut(fromHandle, fromRel); + await toSandbox.stageIn(toHandle, toRel, blobId); + } else { + const tarBytes = await fromSandbox.readFileBytes(fromHandle, fromRel); + if (!tarBytes) throw new Error("copyHome: source tar vanished before read"); + await toSandbox.writeFileBytes(toHandle, toRel, tarBytes); + } - const extracted = await toSandbox.run( - toHandle, - `dsha=$(sha256sum ${tarPath} | cut -d' ' -f1); [ "$dsha" = ${shq(sha)} ] || { echo "sha-mismatch:$dsha"; exit 3; }; mkdir -p ${T} && cd ${T} && tar xzf ${tarPath} 2>/dev/null && find . -type f | wc -l`, - { timeoutMs }, - ); - if (extracted.code !== 0) { - throw new Error( - `copyHome: dest verify/extract failed (${extracted.code}): ${(extracted.stderr || extracted.stdout).slice(0, 200)}`, + const extracted = await toSandbox.run( + toHandle, + `dsha=$(sha256sum ${shq(toTarPath)} | cut -d' ' -f1); [ "$dsha" = ${shq(sha)} ] || { echo "sha-mismatch:$dsha"; exit 3; }; mkdir -p ${T} && cd ${T} && tar xzf ${shq(toTarPath)} 2>/dev/null && find . -type f | wc -l`, + { timeoutMs }, ); - } - const destFiles = Number.parseInt(extracted.stdout.trim().split("\n").pop() ?? "", 10); - - if (fromHome !== toHome) { - const t = await toSandbox.run(toHandle, translateScript(toHome, fromHome), { timeoutMs: 120_000 }); - if (t.code !== 0) - throw new Error(`copyHome: translation failed (${t.code}): ${(t.stderr || t.stdout).slice(0, 200)}`); - } + if (extracted.code !== 0) { + throw new Error( + `copyHome: dest verify/extract failed (${extracted.code}): ${(extracted.stderr || extracted.stdout).slice(0, 200)}`, + ); + } + const destFiles = Number.parseInt(extracted.stdout.trim().split("\n").pop() ?? "", 10); - await Promise.all([ - fromSandbox.run(fromHandle, `rm -f ${tarPath}`, { timeoutMs: 30_000 }).catch(() => {}), - toSandbox.run(toHandle, `rm -f ${tarPath}`, { timeoutMs: 30_000 }).catch(() => {}), - ]); + if (fromHome !== toHome) { + const t = await toSandbox.run(toHandle, translateScript(toHome, fromHome), { timeoutMs: 120_000 }); + if (t.code !== 0) + throw new Error(`copyHome: translation failed (${t.code}): ${(t.stderr || t.stdout).slice(0, 200)}`); + } - return { bytes, sha, sourceFiles, destFiles }; + return { bytes, sha, sourceFiles, destFiles }; + } finally { + await Promise.all([ + fromSandbox.run(fromHandle, `rm -f ${shq(fromTarPath)}`, { timeoutMs: 30_000 }).catch(() => {}), + toSandbox.run(toHandle, `rm -f ${shq(toTarPath)}`, { timeoutMs: 30_000 }).catch(() => {}), + ]); + } } diff --git a/src/sandbox/sandbox-routing.ts b/src/sandbox/sandbox-routing.ts index 3f90fc07..970a3f4c 100644 --- a/src/sandbox/sandbox-routing.ts +++ b/src/sandbox/sandbox-routing.ts @@ -14,7 +14,7 @@ import { type TeardownOptions, } from "./sandbox.ts"; -export type SandboxBackendName = "sprites" | "aws" | "local"; +export type SandboxBackendName = "sprites" | "aws" | "host" | "local"; export interface SandboxRoute { backend: SandboxBackendName; diff --git a/src/wiring.ts b/src/wiring.ts index 83540e3d..e1351610 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -100,6 +100,7 @@ import { createMemoryFileArtifactStore, type FileArtifactStore } from "./files/f import { createPostgresFileArtifactStore } from "./files/postgres-file-artifact-store.ts"; import { createAwsSandbox, type StoredMicrovm } from "./sandbox/aws-sandbox.ts"; import { createLocalSandbox } from "./sandbox/local-sandbox.ts"; +import { createHostSandbox } from "./sandbox/host-sandbox.ts"; import { createSpritesSandbox } from "./sandbox/sprites-sandbox.ts"; import { createSandboxRouter, @@ -572,6 +573,13 @@ export function buildApp( ...config.localSandbox, onError: sandboxOnError, }); + const buildHost = (): Sandbox => + createHostSandbox(workspace, { + ...(config.hostWorkspaceRoot ? { rootDir: config.hostWorkspaceRoot } : {}), + ...(config.hostWorkspacesRoot ? { workspacesRoot: config.hostWorkspacesRoot } : {}), + defaultTimeoutMs: config.execTimeoutDefaultMs, + env: config.hostProcessEnv, + }); const buildSprites = (): Sandbox => createSpritesSandbox(workspace, { ...config.spritesSandbox, @@ -597,6 +605,7 @@ export function buildApp( }; const buildBackend: Record Sandbox> = { local: buildLocal, + host: buildHost, sprites: buildSprites, aws: buildAws, }; diff --git a/test/config.test.ts b/test/config.test.ts index ee5ba307..32f6d1d7 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -349,6 +349,31 @@ test("SANDBOX_BACKEND: unset defaults to local (dev only); the secondary must be ); }); +test("host sandbox configuration supports legacy and per-environment roots", () => { + const legacy = loadConfig({ HOST_WORKSPACE_ROOT: "./legacy-host" }); + assert.equal(legacy.sandboxBackend, "host"); + assert.equal(legacy.hostWorkspaceRoot, resolve("./legacy-host")); + assert.equal(legacy.hostWorkspacesRoot, undefined); + const scoped = loadConfig({ SANDBOX_BACKEND: "host", HOST_WORKSPACES_ROOT: "./host-workspaces" }); + assert.equal(scoped.hostWorkspacesRoot, resolve("./host-workspaces")); + const legacySecondary = loadConfig({ + SANDBOX_BACKEND: "aws", + SANDBOX_SECONDARY_BACKEND: "local", + HOST_WORKSPACE_ROOT: "./legacy-host", + }); + assert.equal(legacySecondary.sandboxSecondaryBackend, "host"); + assert.throws(() => loadConfig({ SANDBOX_BACKEND: "host" }), /requires HOST_WORKSPACE_ROOT/); + assert.throws( + () => + loadConfig({ + SANDBOX_BACKEND: "host", + HOST_WORKSPACE_ROOT: "./legacy-host", + HOST_WORKSPACES_ROOT: "./host-workspaces", + }), + /mutually exclusive/, + ); +}); + test("Fly identity and Slack runtime settings are parsed once into Config", () => { const config = loadConfig({ FLY_APP_NAME: "qm-core", diff --git a/test/host-sandbox.test.ts b/test/host-sandbox.test.ts new file mode 100644 index 00000000..0e205053 --- /dev/null +++ b/test/host-sandbox.test.ts @@ -0,0 +1,131 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, realpath, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createHostSandbox, hostWorkspaceDirectory } from "../src/sandbox/host-sandbox.ts"; +import { copyHome } from "../src/sandbox/sandbox-migrate.ts"; +import { scopeId, type WorkspaceLayer } from "../src/types.ts"; +import { createLocalWorkspaceStore } from "../src/workspace/workspace-store.ts"; + +const layers = (id: string): WorkspaceLayer[] => [{ scopeId: id, mountPath: "", mode: "rw" }]; + +async function hostSandbox(options: { rootDir?: string; workspacesRoot?: string }) { + const workspaceRoot = await mkdtemp(join(tmpdir(), "qm-host-store-")); + return { sandbox: createHostSandbox(createLocalWorkspaceStore(workspaceRoot), options), workspaceRoot }; +} + +test("host sandbox assigns separate workspace, home, and tmp directories per environment", async () => { + const root = await mkdtemp(join(tmpdir(), "qm-host-workspaces-")); + const { sandbox } = await hostSandbox({ workspacesRoot: root }); + const project = scopeId("group", "web-project-p001"); + const first = await sandbox.provision(layers(project)); + const second = await sandbox.provision(layers(scopeId("group", "web-project-p002"))); + + const realRoot = await realpath(root); + assert.equal(first.rootDir, join(realRoot, "project-p001", "workspace")); + assert.equal(first.homeDir, join(realRoot, "project-p001", "home")); + assert.equal(first.env?.TMPDIR, join(realRoot, "project-p001", "tmp")); + assert.notEqual(first.rootDir, second.rootDir); + + await sandbox.writeFile(first, "shared/from-tool.txt", "first"); + await sandbox.writeFile(second, "shared/from-tool.txt", "second"); + assert.equal(await readFile(join(first.rootDir, "shared/from-tool.txt"), "utf8"), "first"); + assert.equal(await readFile(join(second.rootDir, "shared/from-tool.txt"), "utf8"), "second"); + const result = await sandbox.run(first, 'pwd; printf "%s\\n%s" "$HOME" "$TMPDIR"'); + assert.equal(result.code, 0); + assert.deepEqual(result.stdout.trim().split("\n"), [first.rootDir, first.homeDir, first.env?.TMPDIR]); +}); + +test("host workspace directory names are stable and safe", () => { + assert.equal(hostWorkspaceDirectory("group:web-project-p001"), "project-p001"); + assert.equal(hostWorkspaceDirectory("channel:C123"), "channel-C123"); + assert.equal(hostWorkspaceDirectory("custom:/unsafe"), hostWorkspaceDirectory("custom:/unsafe")); + assert.doesNotMatch(hostWorkspaceDirectory("custom:/unsafe"), /[/:]/); +}); + +test("host sandbox rejects relative and symlink escapes", async () => { + const root = await mkdtemp(join(tmpdir(), "qm-host-workspaces-")); + const outside = await mkdtemp(join(tmpdir(), "qm-host-outside-")); + const { sandbox } = await hostSandbox({ workspacesRoot: root }); + const handle = await sandbox.provision(layers("group:web-project-p001")); + await writeFile(join(outside, "secret.txt"), "outside"); + await symlink(outside, join(handle.rootDir, "escape")); + await symlink(join(outside, "secret.txt"), join(handle.rootDir, "linked-secret.txt")); + await symlink(join(outside, "created.txt"), join(handle.rootDir, "dangling-secret.txt")); + + await assert.rejects(sandbox.readFile(handle, "../secret.txt"), /escapes/); + await assert.rejects(sandbox.readFile(handle, "escape/secret.txt"), /symlink/); + await assert.rejects(sandbox.writeFile(handle, "escape/new.txt", "no"), /symlink/); + await assert.rejects(sandbox.writeFile(handle, "linked-secret.txt", "no"), /symlink/); + await assert.rejects(sandbox.writeFile(handle, "dangling-secret.txt", "no"), /symlink/); + assert.equal(await readFile(join(outside, "secret.txt"), "utf8"), "outside"); + await assert.rejects(sandbox.removeDir(handle, "."), /refusing/); +}); + +test("legacy host root keeps one shared workspace", async () => { + const root = await mkdtemp(join(tmpdir(), "qm-host-root-")); + const { sandbox } = await hostSandbox({ rootDir: root }); + const first = await sandbox.provision(layers("group:web-project-p001")); + const second = await sandbox.provision(layers("group:web-project-p002")); + const realRoot = await realpath(root); + assert.equal(first.rootDir, realRoot); + assert.equal(second.rootDir, realRoot); + assert.equal(first.homeDir, join(realRoot, "data", "host-home")); +}); + +test("host sandbox background processes stay inside their environment directory", async () => { + const root = await mkdtemp(join(tmpdir(), "qm-host-workspaces-")); + const { sandbox } = await hostSandbox({ workspacesRoot: root }); + const handle = await sandbox.provision(layers("group:web-project-p001")); + assert.ok(sandbox.startProcess && sandbox.readProcess); + const { processId } = await sandbox.startProcess(handle, "pwd; printf background > shared.txt"); + let poll = await sandbox.readProcess(handle, processId, { waitMs: 2_000 }); + if (poll.status.state === "running") poll = await sandbox.readProcess(handle, processId, { waitMs: 2_000 }); + assert.deepEqual(poll.status, { state: "exited", code: 0 }); + assert.match(poll.chunks, new RegExp(handle.rootDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.equal(await readFile(join(handle.rootDir, "shared.txt"), "utf8"), "background"); +}); + +test("host sandbox rejects an environment directory symlink outside the configured root", async () => { + const root = await mkdtemp(join(tmpdir(), "qm-host-workspaces-")); + const outside = await mkdtemp(join(tmpdir(), "qm-host-outside-")); + const { sandbox } = await hostSandbox({ workspacesRoot: root }); + await symlink(outside, join(root, "channel-C1")); + await assert.rejects(sandbox.provision(layers("channel:C1")), /cannot be a symlink/); +}); + +test("host sandbox materializes read-only workspace layers", async () => { + const root = await mkdtemp(join(tmpdir(), "qm-host-workspaces-")); + const { sandbox, workspaceRoot } = await hostSandbox({ workspacesRoot: root }); + const workspace = createLocalWorkspaceStore(workspaceRoot); + const org = scopeId("org", "default-org"); + await mkdir(workspace.scopeDir(org), { recursive: true }); + await writeFile(join(workspace.scopeDir(org), "policy.txt"), "shared policy"); + const handle = await sandbox.provision([ + { scopeId: org, mountPath: "global", mode: "ro" }, + ...layers("group:web-project-p001"), + ]); + assert.equal(await sandbox.readFile(handle, "global/policy.txt"), "shared policy"); +}); + +test("host sandbox home migration uses workspace-relative transfer files", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "qm-host-source-")); + const destinationRoot = await mkdtemp(join(tmpdir(), "qm-host-destination-")); + const { sandbox: source } = await hostSandbox({ workspacesRoot: sourceRoot }); + const { sandbox: destination } = await hostSandbox({ workspacesRoot: destinationRoot }); + const sourceHandle = await source.provision(layers("group:web-project-p001")); + const destinationHandle = await destination.provision(layers("group:web-project-p001")); + await writeFile(join(sourceHandle.homeDir!, "state.txt"), "migrated"); + const result = await copyHome({ + fromSandbox: source, + fromHandle: sourceHandle, + fromHome: sourceHandle.homeDir!, + toSandbox: destination, + toHandle: destinationHandle, + toHome: destinationHandle.homeDir!, + }); + assert.equal(result.sourceFiles, 1); + assert.equal(result.destFiles, 1); + assert.equal(await readFile(join(destinationHandle.homeDir!, "state.txt"), "utf8"), "migrated"); +}); diff --git a/test/sandbox-migrate.test.ts b/test/sandbox-migrate.test.ts index c73f992c..81cc96c1 100644 --- a/test/sandbox-migrate.test.ts +++ b/test/sandbox-migrate.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { copyHome } from "../src/sandbox/sandbox-migrate.ts"; @@ -128,6 +128,8 @@ test("copyHome throws on a corrupt transfer instead of leaving a truncated $HOME }), /sha-mismatch|verify\/extract failed/, ); + assert.deepEqual(readdirSync(src.handle.rootDir).filter((name) => name.startsWith(".qm-home-")), []); + assert.deepEqual(readdirSync(dst.handle.rootDir).filter((name) => name.startsWith(".qm-home-")), []); } finally { rmSync(root, { recursive: true, force: true }); }