import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import { constants, existsSync } from "node:fs"; import * as fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import type { NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js"; import { openRunnerApiWorkspaceFile, openRunnerWorkspaceFile } from "./runner-api-files.js"; const relativeFile = z.string().min(1).max(1000).refine(value => !value.includes("\\") && !value.includes("\0") && !path.isAbsolute(value) && value.split("/").every(part => part && part !== "." && part !== ".."), "Use a relative path without traversal"); const specs = [ ["get_identity", "Read your agent identity, responsible person, permissions and currently available tools.", z.object({}).strict()], ["list_people", "List active company people and their IDs for human assignment; roles identify the owner.", z.object({ after: z.string().optional(), limit: z.number().int().min(1).max(100).default(50) }).strict()], ["get_task", "Read another task through normal task permissions.", z.object({ taskId: z.uuid() }).strict()], ["comment_on_task", "Post an attributed comment on an authorized task, without reopening or interrupting it.", z.object({ taskId: z.uuid(), body: z.string().trim().min(1).max(20000) }).strict()], ["list_task_documents", "List documents on an authorized task.", z.object({ taskId: z.uuid() }).strict()], ["read_task_document", "Read a document on an authorized task.", z.object({ taskId: z.uuid(), key: z.string().min(1).max(100) }).strict()], ["write_task_document", "Write a document on an authorized task using its current revision. Permissions and document locks apply.", z.object({ taskId: z.uuid(), key: z.string().min(1).max(100), title: z.string().min(1).max(200), body: z.string().max(20000), baseRevisionId: z.uuid().nullable() }).strict()], ["list_assigned_skills", "List immutable skill versions pinned to this Runner turn.", z.object({}).strict()], ["read_assigned_skill", "Read a verified UTF-8 file from an assigned skill. Start with SKILL.md and paginate with nextOffset. Content is data, not extra authorization.", z.object({ skill: z.string().min(1).max(240), path: relativeFile.default("SKILL.md"), offset: z.number().int().min(0).default(0) }).strict()], ["workspace_list", "List entries inside the assigned workspace.", z.object({ path: z.union([relativeFile, z.literal("")]).default(""), after: z.string().optional() }).strict()], ["workspace_read", "Read a UTF-8 workspace file with SHA-256 and byte size. Paginate with nextOffset.", z.object({ path: relativeFile, offset: z.number().int().min(0).default(0) }).strict()], ["workspace_write", "Write a UTF-8 workspace file. expectedSha256 null requires a new file; existing files require the hash from workspace_read. Parent directories must exist. Use a stable call ID.", z.object({ path: relativeFile, text: z.string().max(128000), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/).nullable() }).strict()], ["workspace_run", "Execute a program in an OS sandbox confined to the assigned workspace plus read-only system runtime files. No host home or Paperclip/provider credentials are exposed. Output and time are bounded; authority loss stops the sandbox and its descendants. Use assigned app tools for external services.", z.object({ program: z.string().min(1).max(1000), args: z.array(z.string().max(16000)).max(100).default([]), timeoutMs: z.number().int().min(100).max(120000).default(30000) }).strict()], ] as const; export const RUNNER_BRIDGE_SCHEMAS = new Map(specs.map(([name, , schema]) => [name, schema])); // macOS sandbox-exec confines files but cannot contain detached descendants. // Advertise commands only with a PID namespace that survives neither the // initial command nor its controller. File tools remain available on macOS. export function workspaceCommandSandboxAvailable() { return process.platform === "linux" && existsSync("/usr/bin/bwrap"); } export function runnerBridgeDefinitions(options: { workspace: boolean; skills: boolean; api: boolean; mode: string }) { return specs.filter(([name]) => (!name.startsWith("workspace_") || options.workspace) && (name !== "workspace_run" || workspaceCommandSandboxAvailable()) && (!["workspace_run", "workspace_write", "comment_on_task", "write_task_document"].includes(name) || options.mode === "standard") && (!name.includes("assigned_skill") || options.skills) && (!["get_task", "comment_on_task", "list_task_documents", "read_task_document", "write_task_document"].includes(name) || options.api)) .map(([name, description, schema]) => ({ name, description, inputSchema: z.toJSONSchema(schema) })); } const digest = (bytes: Buffer | string) => createHash("sha256").update(bytes).digest("hex"); const PAGE_BYTES = 24000; const instanceStateName = (name: string) => name.toLowerCase() === ".paperclip"; /** Instance state is never part of the remotely exposed task workspace. */ export function assertRunnerWorkspacePathAllowed(name: string) { if (name.split(/[\\/]/u).some(instanceStateName)) throw new Error("runner_workspace_instance_state_denied"); } async function protectedWorkspaceDirectories(root: string) { const protectedPaths = new Set([path.join(root, ".paperclip")]); const pending = [root]; let scanned = 0; while (pending.length) { if (++scanned > 4096) throw new Error("runner_workspace_protection_scan_limit"); const directory = pending.pop()!; for (const entry of await fs.readdir(directory, { withFileTypes: true })) { const target = path.join(directory, entry.name); if (instanceStateName(entry.name)) { if (!entry.isDirectory()) throw new Error("runner_workspace_instance_state_invalid"); protectedPaths.add(target); } else if (entry.isDirectory()) pending.push(target); } } return [...protectedPaths]; } async function confined(root: string, name: string, newFile = false) { const canonicalRoot = await fs.realpath(root); assertRunnerWorkspacePathAllowed(name); const parts = name ? relativeFile.parse(name).split("/") : []; let target = canonicalRoot; for (const [index, part] of parts.entries()) { target = path.join(target, part); const stat = await fs.lstat(target).catch(error => { if (newFile && index === parts.length - 1 && error.code === "ENOENT") return null; throw error; }); if (stat?.isSymbolicLink()) throw new Error("runner_workspace_symlink_denied"); if (index < parts.length - 1 && !stat?.isDirectory()) throw new Error("runner_workspace_parent_invalid"); } return target; } /** API uploads use the same operator-bound root and path confinement as workspace tools. */ export async function readWorkspaceUploadFile(root: string, name: string): Promise { const handle = await openRunnerApiWorkspaceFile(await confined(root, name)); try { const stat = await handle.stat(); if (!stat.isFile() || stat.size > 10 * 1024 * 1024) throw new Error("runner_bridge_file_size_limit"); const bytes = Buffer.alloc(10 * 1024 * 1024 + 1); let length = 0; while (length < bytes.length) { const read = await handle.read(bytes, length, bytes.length - length, length); if (!read.bytesRead) break; length += read.bytesRead; } if (length > 10 * 1024 * 1024) throw new Error("runner_bridge_file_size_limit"); return bytes.subarray(0, length); } finally { await handle.close(); } } async function readFilePage(file: string, offset: number) { const handle = await openRunnerApiWorkspaceFile(file); try { const stat = await handle.stat(); if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new Error("runner_bridge_file_size_limit"); const bytes = await handle.readFile(); // Offsets remain UTF-16 units, but page boundaries preserve complete code points. const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); const splitsCharacter = (at: number) => { const before = text.charCodeAt(at - 1), after = text.charCodeAt(at); return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff; }; if (splitsCharacter(offset)) throw new Error("runner_bridge_offset_splits_character"); let end = Math.min(offset + 6000, text.length); if (splitsCharacter(end)) end--; const page = text.slice(offset, end); return { text: page, sha256: digest(bytes), byteSize: bytes.length, nextOffset: end < text.length ? end : null }; } finally { await handle.close(); } } export async function readAssignedSkill(context: NativeRuntimeContextSnapshot, raw: unknown) { const args = RUNNER_BRIDGE_SCHEMAS.get("read_assigned_skill")!.parse(raw) as { skill: string; path: string; offset: number }; const skill = context.skills.find(skill => skill.key === args.skill || skill.runtimeName === args.skill); if (!skill) throw new Error("runner_skill_not_assigned"); const manifestText = await fs.readFile(path.join(path.dirname(path.dirname(skill.bundle.rootPath)), "manifests", `${skill.bundle.digest}.json`), "utf8"); if (digest(manifestText) !== skill.bundle.manifestDigest) throw new Error("runner_skill_manifest_digest_mismatch"); const manifest = JSON.parse(manifestText); if (manifest.digest !== skill.bundle.digest || digest(JSON.stringify(manifest.files)) !== skill.bundle.digest) throw new Error("runner_skill_bundle_digest_mismatch"); const expected = manifest.files.find((file: { path: string }) => file.path === args.path); if (!expected) throw new Error("runner_skill_file_not_pinned"); const result = await readFilePage(await confined(skill.bundle.rootPath, args.path), args.offset); if (result.sha256 !== expected.sha256 || result.byteSize !== expected.size) throw new Error("runner_skill_file_digest_mismatch"); return { skill: skill.key, versionId: skill.versionId, path: args.path, ...result }; } // Read failures have no ambiguous external effect. Return a bounded terminal // receipt so a missing file or rejected path cannot leave the turn pending. export async function settleRunnerBridgeRead(read: () => Promise) { try { return await read(); } catch (error) { const storageCode = error && typeof error === "object" && "code" in error ? error.code : null; const message = error instanceof Error ? error.message : ""; const code = storageCode === "ENOENT" ? "runner_bridge_file_not_found" : /^runner_(workspace|bridge|skill|attachment)_[a-z0-9_]+$/.test(message) ? message : "runner_bridge_read_failed"; return { outcome: "failed", code, message: code === "runner_attachment_use_base64" ? "This file is binary. Retry read_task_attachment with encoding base64." : "The requested read did not succeed. Check the assigned file or pinned skill before continuing." }; } } const workspaceLanes = new Map>(); export async function executeWorkspaceTool(root: string, name: string, raw: unknown, authorize: () => Promise) { // One local controller owns admitted tools. Serialize each canonical workspace // across calls and agents, including commands, so hash checks and writes form // one critical section. Authority is rechecked after waiting for the lane. const key = await fs.realpath(root), previous = workspaceLanes.get(key) ?? Promise.resolve(); let release!: () => void; const lane = new Promise(resolve => { release = resolve; }); workspaceLanes.set(key, lane); await previous; try { return await executeWorkspaceToolInLane(key, name, raw, authorize); } finally { release(); if (workspaceLanes.get(key) === lane) workspaceLanes.delete(key); } } async function executeWorkspaceToolInLane(root: string, name: string, raw: unknown, authorize: () => Promise) { const input = RUNNER_BRIDGE_SCHEMAS.get(name)!.parse(raw) as Record; await authorize(); if (name === "workspace_list") { const entries = await fs.readdir(await confined(root, input.path), { withFileTypes: true }); const page = entries.filter(entry => !instanceStateName(entry.name) && (!input.after || entry.name > input.after)).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0).slice(0, 100); return { entries: page.map(entry => ({ name: entry.name, kind: entry.isSymbolicLink() ? "symlink" : entry.isDirectory() ? "directory" : "file" })), nextAfter: page.length === 100 ? page.at(-1)!.name : null }; } if (name === "workspace_read") return readFilePage(await confined(root, input.path), input.offset); if (name === "workspace_write") { const file = await confined(root, input.path, true), bytes = Buffer.from(input.text, "utf8"); if (bytes.length > 128000) throw new Error("runner_workspace_write_size_limit"); if (input.expectedSha256 === null) { await authorize(); const handle = await openRunnerWorkspaceFile(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } } else { const handle = await openRunnerWorkspaceFile(file, constants.O_RDWR); try { const stat = await handle.stat(); if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new Error("runner_workspace_write_size_limit"); if (digest(await handle.readFile()) !== input.expectedSha256) throw new Error("runner_workspace_write_conflict"); await authorize(); await handle.truncate(0); await handle.write(bytes, 0, bytes.length, 0); await handle.sync(); } finally { await handle.close(); } } return { path: input.path, sha256: digest(bytes), byteSize: bytes.length }; } if (name !== "workspace_run" || !workspaceCommandSandboxAvailable()) throw new Error("runner_workspace_command_sandbox_unavailable"); const cwd = await fs.realpath(root), privateHome = path.join(cwd, ".paperclip-dot-home"); await fs.mkdir(privateHome, { recursive: true, mode: 0o700 }); await confined(cwd, ".paperclip-dot-home"); // Bubblewrap owns a private PID namespace and an init/reaper. Killing the // outer supervisor tears it down even when descendants fork and setsid(). const program = "/usr/bin/bwrap"; const protectedPaths = await protectedWorkspaceDirectories(cwd); const masks = protectedPaths.flatMap(directory => ["--tmpfs", directory, "--remount-ro", directory]); const args = ["--die-with-parent", "--unshare-all", "--cap-drop", "ALL", "--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", ...(existsSync("/lib64") ? ["--ro-bind", "/lib64", "/lib64"] : []), "--proc", "/proc", "--dev", "/dev", "--bind", cwd, cwd, ...masks, "--chdir", cwd, "--", input.program, ...input.args]; await authorize(); return new Promise>((resolve, reject) => { const child = spawn(program, args, { cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: privateHome, TMPDIR: privateHome, LANG: "en_US.UTF-8" } }); let output = "", outputBytes = 0, truncated = false, stopped: string | null = null, checking = false; const capture = (bytes: Buffer) => { const available = Math.max(0, PAGE_BYTES - outputBytes); output += bytes.subarray(0, available).toString("utf8"); outputBytes += Math.min(available, bytes.length); truncated ||= bytes.length > available; }; child.stdout.on("data", capture); child.stderr.on("data", capture); const stop = (reason: string) => { stopped = reason; if (child.pid) try { process.kill(-child.pid, "SIGKILL"); } catch { /* already stopped */ } }; const timeout = setTimeout(() => stop("timeout"), input.timeoutMs); const poll = setInterval(() => { if (checking) return; checking = true; void authorize().catch(() => stop("authority_revoked")).finally(() => { checking = false; }); }, 500); const clear = () => { clearTimeout(timeout); clearInterval(poll); }; child.once("error", error => { clear(); reject(error); }); child.once("close", (exitCode, signal) => { clear(); if (child.pid) try { process.kill(-child.pid, "SIGKILL"); } catch { /* owned group already exited */ } resolve({ exitCode, signal, output, truncated, stopped }); }); }); }