import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; export type SmokeStatus = "ok" | "timeout" | "crash" | "capability-denied" | "output-limit" | "sandbox-unavailable"; export interface SmokeRegistrations { tools: string[]; commands: string[]; shortcuts: string[]; flags: string[]; providers: string[]; events: string[]; renderers: string[]; /** * Names, within tools/commands/shortcuts/flags respectively, whose registration call carried an * explicit `shared: true` marker -- see @danypops/vehicle-client-pi's own markSharedRegistration. * doctor.ts only suppresses a name's conflict once EVERY one of its claimants marked it shared. */ sharedTools: string[]; sharedCommands: string[]; sharedShortcuts: string[]; sharedFlags: string[]; } export interface ExtensionSmokeResult { path: string; status: SmokeStatus; durationMs: number; registrations: SmokeRegistrations; message?: string; } export interface SmokeOptions { timeoutMs?: number; maxOutputBytes?: number; maxProcesses?: number; } const DEFAULT_TIMEOUT_MS = 3_000; const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024; const DEFAULT_MAX_PROCESSES = 16; const MAX_TIMEOUT_MS = 10_000; const MAX_OUTPUT_BYTES = 1024 * 1024; const MAX_PROCESSES = 32; const EMPTY_REGISTRATIONS: SmokeRegistrations = { tools: [], commands: [], shortcuts: [], flags: [], providers: [], events: [], renderers: [], sharedTools: [], sharedCommands: [], sharedShortcuts: [], sharedFlags: [], }; function bounded(value: number | undefined, fallback: number, maximum: number): number { if (!Number.isFinite(value) || value === undefined || value <= 0) return fallback; return Math.min(Math.floor(value), maximum); } function addReadOnlyBind(args: string[], path: string): void { if (existsSync(path)) args.push("--ro-bind", path, path); } const MAX_ANCESTOR_WALK = 32; /** * Every ancestor directory's own node_modules from packageRoot's parent up to * the filesystem root -- mirrors Node's real module-resolution walk * (require.resolve checks /node_modules at every level up to /). * Confirmed live: packed doctor's own widget reported real extensions * (pi-lector, pi-pipes, pi-tickets, pi-jittor, pi-web-spider) as "crash" on a * plain Cannot-find-module for a sibling dependency npm/bun hoisted to a * shared ancestor node_modules, though Pi's own real, unsandboxed process * resolves the exact same import fine -- the sandbox only ever bound the * package's own root, never the hoisted siblings one or more levels above it. * Bounded to MAX_ANCESTOR_WALK levels as a defensive cap; a real filesystem * hierarchy never gets remotely close to that before reaching /. */ function collectAncestorNodeModulesDirs(packageRoot: string): string[] { const dirs: string[] = []; let current = dirname(packageRoot); for (let i = 0; i < MAX_ANCESTOR_WALK; i++) { const candidate = join(current, "node_modules"); if (existsSync(candidate)) dirs.push(realpathSync(candidate)); const parent = dirname(current); if (parent === current) break; current = parent; } return dirs; } /** * Resolves an installed dependency's real on-disk package directory via * Node's own module-resolution algorithm (require.resolve against its * package.json), starting from `fromDir` -- correct regardless of whether * the package manager nested it under fromDir's own node_modules or * hoisted it to a shared ancestor node_modules (npm's own hoisting * decision, driven by what else in the tree also depends on it). * Confirmed live: `packed doctor --json` crashed hard reconstructing a * fixed nested-only path (`/node_modules/jiti`) that * doesn't exist once jiti is hoisted to Pi's own npm prefix instead of * pi-packed's own node_modules. Never executes the resolved module -- * require.resolve only locates a path, it never runs anything. undefined * (not a throw) when genuinely unresolvable from here. */ export function resolveDependencyModulesDir(fromDir: string, name: string): string | undefined { try { const req = createRequire(join(fromDir, "package.json")); return realpathSync(dirname(req.resolve(`${name}/package.json`))); } catch { return undefined; } } const MAX_PROCESS_SCAN_ENTRIES = 100_000; /** * RLIMIT_NPROC (prlimit's own --nproc) is a per-real-UID, system-wide limit -- NOT scoped to * this sandbox's own process tree. A small absolute constant breaks the instant the invoking * user's REAL total process count already exceeds it (routine on a heavily-used workstation * with several long-running agent sessions): any further thread/fork Bun's own runtime needs * internally (confirmed live: its native TS transpiler spawns one) immediately fails, surfacing * as an unrecoverable SIGABRT (exit 134) with no output at all, not a catchable error. Correct * enforcement of "at most N NEW processes the sandboxed extension can spawn" requires an offset * from the CURRENT real count, not an absolute cap. Returns 0 (never throws) if /proc can't be * read at all -- the caller's own maxProcesses then applies as an absolute floor, same as before * this existed. */ function currentProcessCountForUid(uid: number): number { let entries: string[]; try { entries = readdirSync("/proc"); } catch { return 0; } let count = 0; for (const entry of entries.slice(0, MAX_PROCESS_SCAN_ENTRIES)) { if (!/^\d+$/.test(entry)) continue; try { const status = readFileSync(`/proc/${entry}/status`, "utf8"); const match = status.match(/^Uid:\s+(\d+)/m); if (match && Number(match[1]) === uid) count++; } catch { // The process exited mid-scan, or is otherwise unreadable -- not this sandbox's concern. } } return count; } function sandboxCommand(packageRoot: string, extensionPath: string, maxProcesses: number): string[] | undefined { if (process.platform !== "linux" || !existsSync("/usr/bin/bwrap") || !existsSync("/usr/bin/prlimit")) return undefined; const serviceRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); const packageDirectory = dirname(serviceRoot); const childHarness = join(serviceRoot, "src/adoption/smoke-child.ts"); const jitiModules = resolveDependencyModulesDir(packageDirectory, "jiti"); if (!jitiModules) return undefined; const bun = realpathSync(process.execPath); const args = [ "/usr/bin/bwrap", "--unshare-user", "--disable-userns", "--unshare-pid", "--unshare-net", "--unshare-ipc", "--unshare-uts", "--cap-drop", "ALL", "--die-with-parent", "--new-session", ]; addReadOnlyBind(args, "/usr"); addReadOnlyBind(args, "/lib"); addReadOnlyBind(args, "/lib64"); args.push( "--dir", "/runner", "--dir", "/runner/src", "--ro-bind", childHarness, "/runner/src/smoke-child.ts", "--dir", "/runner/node_modules", "--ro-bind", jitiModules, "/runner/node_modules/jiti", "--ro-bind", bun, "/bun", "--dev", "/dev", "--proc", "/proc", "--size", "16777216", "--tmpfs", "/tmp", "--dir", "/tmp/home", ); // Package-related identity binds go AFTER the virtual/tmpfs mounts above -- // bwrap applies mounts in argument order, so a bind whose real path happens // to sit under one of those special mounts (e.g. a test fixture under // /tmp, which --tmpfs /tmp would otherwise shadow) must come later to win. addReadOnlyBind(args, packageRoot); // Real-path identity binds so a hoisted sibling dependency resolves exactly // as it would in Pi's own real, unsandboxed process -- see // collectAncestorNodeModulesDirs's own doc comment for why this is needed. for (const dir of collectAncestorNodeModulesDirs(packageRoot)) addReadOnlyBind(args, dir); args.push( "--remount-ro", "/", "--clearenv", "--setenv", "HOME", "/tmp/home", "--setenv", "TMPDIR", "/tmp", "--setenv", "PATH", "/usr/bin:/bin", "--setenv", "NODE_PATH", "/runner/node_modules", "--chdir", packageRoot, "--", "/usr/bin/prlimit", `--nproc=${(typeof process.getuid === "function" ? currentProcessCountForUid(process.getuid()) : 0) + maxProcesses}`, "--nofile=64", "--fsize=16777216", "--as=8589934592", "--cpu=5", "--", "/bun", "/runner/src/smoke-child.ts", extensionPath, ); return args; } async function readBounded(stream: ReadableStream, maxBytes: number, onLimit: () => void): Promise { const reader = stream.getReader(); const chunks: Uint8Array[] = []; let bytes = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; if (bytes > maxBytes) { onLimit(); break; } chunks.push(value); } } finally { await reader.cancel().catch(() => undefined); } const output = new Uint8Array(Math.min(bytes, maxBytes)); let offset = 0; for (const chunk of chunks) { const remaining = output.byteLength - offset; if (remaining <= 0) break; output.set(chunk.subarray(0, remaining), offset); offset += Math.min(chunk.byteLength, remaining); } return new TextDecoder().decode(output.subarray(0, offset)); } function normalizeRegistrations(value: unknown): SmokeRegistrations { const record = typeof value === "object" && value !== null ? (value as Record) : {}; let remaining = 100; const names = (kind: keyof SmokeRegistrations): string[] => { if (!Array.isArray(record[kind]) || remaining === 0) return []; const result = record[kind] .filter((item): item is string => typeof item === "string") .slice(0, remaining) .map((item) => item.slice(0, 128)); remaining -= result.length; return result; }; return { tools: names("tools"), commands: names("commands"), shortcuts: names("shortcuts"), flags: names("flags"), providers: names("providers"), events: names("events"), renderers: names("renderers"), sharedTools: names("sharedTools"), sharedCommands: names("sharedCommands"), sharedShortcuts: names("sharedShortcuts"), sharedFlags: names("sharedFlags"), }; } export async function runExtensionSmoke( packagePath: string, extensionFile: string, options: SmokeOptions = {}, ): Promise { const startedAt = Date.now(); const packageRoot = realpathSync(resolve(packagePath)); const extensionPath = realpathSync(resolve(extensionFile)); const relativePath = relative(packageRoot, extensionPath).split(sep).join("/"); if (relativePath.startsWith("../") || relativePath === "..") return { path: relativePath, status: "capability-denied", durationMs: Date.now() - startedAt, registrations: { ...EMPTY_REGISTRATIONS }, message: "extension resolves outside the package", }; const timeoutMs = bounded(options.timeoutMs, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS); const maxOutputBytes = bounded(options.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES, MAX_OUTPUT_BYTES); const maxProcesses = bounded(options.maxProcesses, DEFAULT_MAX_PROCESSES, MAX_PROCESSES); const command = sandboxCommand(packageRoot, extensionPath, maxProcesses); if (!command) return { path: relativePath, status: "sandbox-unavailable", durationMs: Date.now() - startedAt, registrations: { ...EMPTY_REGISTRATIONS }, message: "bubblewrap and prlimit are required for smoke mode", }; let timedOut = false; let outputLimited = false; const process = Bun.spawn(command, { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); const kill = () => { try { process.kill("SIGKILL"); } catch { /* already exited */ } }; const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs); const stdoutPromise = readBounded(process.stdout, maxOutputBytes, () => { outputLimited = true; kill(); }); const stderrPromise = readBounded(process.stderr, maxOutputBytes, () => { outputLimited = true; kill(); }); const [exitCode, stdout, stderr] = await Promise.all([process.exited, stdoutPromise, stderrPromise]); clearTimeout(timer); const durationMs = Date.now() - startedAt; if (timedOut) return { path: relativePath, status: "timeout", durationMs, registrations: { ...EMPTY_REGISTRATIONS }, message: `extension exceeded ${timeoutMs}ms`, }; if (outputLimited) return { path: relativePath, status: "output-limit", durationMs, registrations: { ...EMPTY_REGISTRATIONS }, message: `extension exceeded ${maxOutputBytes} output bytes`, }; const line = stdout.trim().split("\n").at(-1) ?? ""; try { const payload = JSON.parse(line) as { status?: unknown; message?: unknown; registrations?: unknown }; const status = payload.status === "ok" || payload.status === "capability-denied" || payload.status === "crash" ? payload.status : "crash"; return { path: relativePath, status, durationMs, registrations: normalizeRegistrations(payload.registrations), ...(typeof payload.message === "string" ? { message: payload.message.slice(0, 1_000) } : {}), }; } catch { const message = (stderr.trim() || `smoke child exited ${exitCode}`).slice(0, 1_000); const status: SmokeStatus = /^(?:bwrap:|prlimit:)/i.test(message) ? "sandbox-unavailable" : "crash"; return { path: relativePath, status, durationMs, registrations: { ...EMPTY_REGISTRATIONS }, message }; } }