import path from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { type BashOperations, createBashTool, createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool, DEFAULT_MAX_BYTES, type EditOperations, type FindOperations, formatSize, type GrepToolDetails, type GrepToolInput, type LsOperations, type ReadOperations, truncateHead, truncateLine, type WriteOperations, } from "@earendil-works/pi-coding-agent"; import { parseMatchingSandboxes, type SbxSandbox } from "./discovery.ts"; import { type DiscoveredSkillPath, resolveHostSkillReadPath } from "./skill-access.ts"; import { SbxTransport, type SbxExecOptions, type SbxExecResult } from "./transport.ts"; const STATUS_ID = "pi-sbx"; const STATE_ENTRY = "pi-sbx-selection"; const ROUTED_TOOLS = new Set(["bash", "edit", "find", "grep", "ls", "read", "write"]); const DEFAULT_COMMAND_TIMEOUT_SECONDS = 60; const DEFAULT_GREP_LIMIT = 100; const EXECUTION_TARGET_DESCRIPTION = 'Where to execute this tool call. Omit this or use "sandbox" normally. Use "host" only when sandbox execution cannot perform the operation; host execution requires user approval while sandboxing is active.'; export type ExecutionTarget = "sandbox" | "host"; type WithExecutionTarget = T & { execution_target?: ExecutionTarget }; interface SelectionState { name?: string; hostFallback?: boolean; } export function withExecutionTarget(schema: Type.TObject) { return Type.Object({ ...schema.properties, execution_target: Type.Optional( Type.Unsafe({ type: "string", enum: ["sandbox", "host"], description: EXECUTION_TARGET_DESCRIPTION, }), ), }); } export function withoutExecutionTarget( params: T, ): Omit { const { execution_target: _executionTarget, ...toolParams } = params; return toolParams; } function hostRequestFingerprint(toolName: string, input: Record): string { return JSON.stringify([toolName, withoutExecutionTarget(input as WithExecutionTarget>)]); } export function hostApprovalMessage(toolName: string, input: Record, cwd: string): string { return [ `Tool: ${toolName}`, `Working directory: ${cwd}`, "", "Arguments:", JSON.stringify(withoutExecutionTarget(input as WithExecutionTarget>), null, 2), "", "This operation will run outside the selected sbx sandbox with the host process's permissions and environment.", ].join("\n"); } function commandError(command: string[], result: SbxExecResult): Error { const detail = result.stderr.toString().trim() || result.stdout.toString().trim(); return new Error(detail || `${command[0] ?? "command"} exited with code ${result.exitCode}`); } async function successfulExec( transport: SbxTransport, cwd: string, command: string[], options?: SbxExecOptions, ): Promise { const result = await transport.execute(cwd, command, options); if (result.exitCode !== 0) throw commandError(command, result); return result; } function createSbxReadOps(transport: SbxTransport, cwd: string): ReadOperations { return { readFile: async (filePath) => (await successfulExec(transport, cwd, ["sh", "-c", 'cat -- "$1"', "sbx-read", filePath])).stdout, access: async (filePath) => { await successfulExec(transport, cwd, ["sh", "-c", 'test -r "$1"', "sbx-read", filePath]); }, detectImageMimeType: async (filePath) => { const result = await transport.execute(cwd, ["file", "--mime-type", "-b", filePath]); if (result.exitCode !== 0) return null; const mimeType = result.stdout.toString().trim(); return ["image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp"].includes(mimeType) ? mimeType : null; }, }; } function createSbxWriteOps(transport: SbxTransport, cwd: string): WriteOperations { return { mkdir: async (dirPath) => { await successfulExec(transport, cwd, ["mkdir", "-p", "--", dirPath]); }, writeFile: async (filePath, content) => { await successfulExec(transport, cwd, ["sh", "-c", 'cat > "$1"', "sbx-write", filePath], { input: content }); }, }; } function createSbxEditOps(transport: SbxTransport, cwd: string): EditOperations { const read = createSbxReadOps(transport, cwd); const write = createSbxWriteOps(transport, cwd); return { readFile: read.readFile, writeFile: write.writeFile, access: async (filePath) => { await successfulExec(transport, cwd, ["sh", "-c", 'test -r "$1" && test -w "$1"', "sbx-edit", filePath]); }, }; } export function createSandboxBashCommand(command: string): string[] { return ["bash", "-lc", command]; } function createSbxBashOps(transport: SbxTransport): BashOperations { return { exec: async (command, cwd, { onData, signal, timeout }) => { const result = await transport.execute(cwd, createSandboxBashCommand(command), { onStdout: onData, onStderr: onData, signal, timeoutSeconds: timeout ?? 0, }); return { exitCode: result.exitCode }; }, }; } function createSbxLsOps(transport: SbxTransport, cwd: string): LsOperations { const directoryCache = new Map(); return { exists: async (filePath) => { const result = await transport.execute(cwd, ["sh", "-c", 'test -e "$1"', "sbx-ls", filePath]); return result.exitCode === 0; }, stat: async (filePath) => { let isDirectory = directoryCache.get(filePath); if (isDirectory === undefined) { const result = await transport.execute(cwd, ["sh", "-c", 'test -d "$1"', "sbx-ls", filePath]); isDirectory = result.exitCode === 0; directoryCache.set(filePath, isDirectory); } return { isDirectory: () => isDirectory }; }, readdir: async (dirPath) => { const script = [ 'root=$1', 'for entry in "$root"/* "$root"/.[!.]* "$root"/..?*; do', ' if [ -e "$entry" ] || [ -L "$entry" ]; then', ' name=${entry##*/}', ' if [ -d "$entry" ]; then kind=d; else kind=f; fi', ' printf "%s\\0%s\\0" "$name" "$kind"', ' fi', 'done', ].join("\n"); const result = await successfulExec(transport, cwd, ["sh", "-c", script, "sbx-ls", dirPath]); const fields = result.stdout.toString().split("\0"); fields.pop(); const entries: Array<{ name: string; directory: boolean }> = []; for (let index = 0; index < fields.length; index += 2) { const name = fields[index]; if (name === undefined) continue; entries.push({ name, directory: fields[index + 1] === "d" }); } for (const entry of entries) directoryCache.set(path.join(dirPath, entry.name), entry.directory); return entries.map((entry) => entry.name); }, }; } function matchesToolGlob(relativePath: string, pattern: string): boolean { const normalized = pattern.replaceAll("\\", "/"); if (normalized.includes("/")) { return path.posix.matchesGlob(relativePath, normalized) || path.posix.matchesGlob(relativePath, `**/${normalized}`); } return path.posix.matchesGlob(path.posix.basename(relativePath), normalized); } function createSbxFindOps(transport: SbxTransport, sessionCwd: string): FindOperations { return { exists: async (filePath) => { const result = await transport.execute(sessionCwd, ["sh", "-c", 'test -e "$1"', "sbx-find", filePath]); return result.exitCode === 0; }, glob: async (pattern, cwd, options) => { const result = await transport.execute(cwd, [ "rg", "--files", "--hidden", "--glob", "!.git", "--glob", "!**/.git/**", "--glob", "!**/node_modules/**", cwd, ]); if (result.exitCode !== 0 && result.exitCode !== 1) throw commandError(["rg"], result); const matches: string[] = []; for (const filePath of result.stdout.toString().split("\n")) { if (!filePath) continue; const relativePath = path.relative(cwd, filePath).replaceAll("\\", "/"); if (matchesToolGlob(relativePath, pattern)) matches.push(filePath); if (matches.length >= options.limit) break; } return matches; }, }; } function formatGrepPath(searchPath: string, filePath: string, isDirectory: boolean): string { if (!isDirectory) return path.basename(filePath); const relativePath = path.relative(searchPath, filePath).replaceAll("\\", "/"); return relativePath && !relativePath.startsWith("..") ? relativePath : filePath; } async function executeSbxGrep( transport: SbxTransport, cwd: string, params: GrepToolInput, signal?: AbortSignal, ): Promise<{ content: Array<{ type: "text"; text: string }>; details: GrepToolDetails | undefined }> { const searchPath = path.resolve(cwd, params.path ?? "."); const directoryResult = await transport.execute(cwd, ["sh", "-c", 'test -d "$1"', "sbx-grep", searchPath], { signal, }); const isDirectory = directoryResult.exitCode === 0; const contextLines = params.context && params.context > 0 ? params.context : 0; const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT); const args = ["rg", "--json", "--line-number", "--color=never", "--hidden"]; if (params.ignoreCase) args.push("--ignore-case"); if (params.literal) args.push("--fixed-strings"); if (params.glob) args.push("--glob", params.glob); if (contextLines > 0) args.push("--context", String(contextLines)); args.push("--", params.pattern, searchPath); const result = await transport.execute(cwd, args, { signal, timeoutSeconds: DEFAULT_COMMAND_TIMEOUT_SECONDS }); if (result.exitCode !== 0 && result.exitCode !== 1) throw commandError(args, result); type Record = { filePath: string; line: number; text: string; match: boolean }; const records = new Map(); const matches: Record[] = []; for (const line of result.stdout.toString().split("\n")) { if (!line) continue; let event: { type?: string; data?: { path?: { text?: string }; line_number?: number; lines?: { text?: string } } }; try { event = JSON.parse(line) as typeof event; } catch { continue; } if (event.type !== "match" && event.type !== "context") continue; const filePath = event.data?.path?.text; const lineNumber = event.data?.line_number; const text = event.data?.lines?.text; if (!filePath || typeof lineNumber !== "number" || typeof text !== "string") continue; const record = { filePath, line: lineNumber, text: text.replace(/\r?\n$/, ""), match: event.type === "match" }; records.set(`${filePath}\0${lineNumber}`, record); if (record.match) matches.push(record); } if (matches.length === 0) return { content: [{ type: "text", text: "No matches found" }], details: undefined }; const outputLines: string[] = []; let linesTruncated = false; for (const match of matches.slice(0, effectiveLimit)) { const start = contextLines > 0 ? Math.max(1, match.line - contextLines) : match.line; const end = contextLines > 0 ? match.line + contextLines : match.line; for (let lineNumber = start; lineNumber <= end; lineNumber++) { const record = records.get(`${match.filePath}\0${lineNumber}`); if (!record) continue; const truncated = truncateLine(record.text.replace(/\r/g, "")); if (truncated.wasTruncated) linesTruncated = true; const separator = lineNumber === match.line ? ":" : "-"; outputLines.push(`${formatGrepPath(searchPath, match.filePath, isDirectory)}${separator}${lineNumber}${separator} ${truncated.text}`); } } const truncation = truncateHead(outputLines.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER }); const details: GrepToolDetails = {}; const notices: string[] = []; if (matches.length > effectiveLimit) { details.matchLimitReached = effectiveLimit; notices.push(`${effectiveLimit} matches limit reached`); } if (linesTruncated) { details.linesTruncated = true; notices.push("long lines truncated"); } if (truncation.truncated) { details.truncation = truncation; notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); } const output = notices.length > 0 ? `${truncation.content}\n\n[${notices.join(". ")}]` : truncation.content; return { content: [{ type: "text", text: output }], details: Object.keys(details).length > 0 ? details : undefined }; } export default function piSbxExtension(pi: ExtensionAPI) { const cwd = process.cwd(); const localRead = createReadTool(cwd); const localWrite = createWriteTool(cwd); const localEdit = createEditTool(cwd); const localBash = createBashTool(cwd); const localLs = createLsTool(cwd); const localFind = createFindTool(cwd); const localGrep = createGrepTool(cwd); let sandboxes: SbxSandbox[] = []; let selectedName: string | undefined; let sandboxingEnabled = true; let transport: SbxTransport | undefined; let transportSandbox: string | undefined; let discoveredSkills: DiscoveredSkillPath[] = []; const approvedHostCalls = new Map(); function disposeTransport(): void { transport?.dispose(); transport = undefined; transportSandbox = undefined; } function selectedTransport(): SbxTransport | undefined { const sandbox = selectedSandbox(); if (!sandbox) { disposeTransport(); return undefined; } if (!transport || transportSandbox !== sandbox) { disposeTransport(); transport = new SbxTransport(sandbox, cwd); transportSandbox = sandbox; } return transport; } function restoredSelection(ctx: ExtensionContext): SelectionState | undefined { let restored: SelectionState | undefined; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "custom" || entry.customType !== STATE_ENTRY) continue; const data = entry.data as SelectionState | undefined; if (typeof data?.name === "string") restored = { name: data.name }; else if (data?.hostFallback === true) restored = { hostFallback: true }; } return restored; } function updateStatus(ctx: ExtensionContext): void { if (sandboxingEnabled && selectedName) { ctx.ui.setStatus(STATUS_ID, ctx.ui.theme.fg("muted", `sbx: ${selectedName}`)); } else { ctx.ui.setStatus(STATUS_ID, ctx.ui.theme.fg("warning", "sbx: host fallback")); } } async function discover(ctx: ExtensionContext): Promise { const result = await pi.exec("sbx", ["ls", "--json"], { timeout: 10_000 }); if (result.code !== 0) { throw new Error(result.stderr.trim() || `sbx ls --json exited with code ${result.code}`); } sandboxes = parseMatchingSandboxes(result.stdout, cwd); if (selectedName && !sandboxes.some((sandbox) => sandbox.name === selectedName)) { selectedName = undefined; disposeTransport(); } updateStatus(ctx); return sandboxes; } function selectedSandbox(): string | undefined { return sandboxingEnabled ? selectedName : undefined; } function useHostFallback(ctx: ExtensionContext): void { sandboxingEnabled = false; disposeTransport(); pi.appendEntry(STATE_ENTRY, { hostFallback: true }); updateStatus(ctx); } function requireApprovedHostExecution(toolName: string, id: string, params: Record): void { if (!selectedSandbox()) return; const approvedFingerprint = approvedHostCalls.get(id); approvedHostCalls.delete(id); if (approvedFingerprint !== hostRequestFingerprint(toolName, params)) { throw new Error("Host execution was not approved for this exact tool call."); } } pi.registerTool({ ...localRead, label: "read (sbx/host)", parameters: withExecutionTarget(localRead.parameters), async execute(id, params, signal, onUpdate) { const toolParams = withoutExecutionTarget(params); if (params.execution_target === "host") { requireApprovedHostExecution(localRead.name, id, params); return localRead.execute(id, toolParams, signal, onUpdate); } const activeTransport = selectedTransport(); if (!activeTransport) return localRead.execute(id, toolParams, signal, onUpdate); const hostSkillPath = await resolveHostSkillReadPath(toolParams.path, cwd, discoveredSkills); if (hostSkillPath) { return localRead.execute(id, { ...toolParams, path: hostSkillPath }, signal, onUpdate); } return createReadTool(cwd, { operations: createSbxReadOps(activeTransport, cwd) }).execute( id, toolParams, signal, onUpdate, ); }, }); pi.registerTool({ ...localWrite, label: "write (sbx/host)", parameters: withExecutionTarget(localWrite.parameters), async execute(id, params, signal, onUpdate) { const toolParams = withoutExecutionTarget(params); if (params.execution_target === "host") { requireApprovedHostExecution(localWrite.name, id, params); return localWrite.execute(id, toolParams, signal, onUpdate); } const activeTransport = selectedTransport(); if (!activeTransport) return localWrite.execute(id, toolParams, signal, onUpdate); return createWriteTool(cwd, { operations: createSbxWriteOps(activeTransport, cwd) }).execute( id, toolParams, signal, onUpdate, ); }, }); pi.registerTool({ ...localEdit, label: "edit (sbx/host)", parameters: withExecutionTarget(localEdit.parameters), async execute(id, params, signal, onUpdate) { const toolParams = withoutExecutionTarget(params); if (params.execution_target === "host") { requireApprovedHostExecution(localEdit.name, id, params); return localEdit.execute(id, toolParams, signal, onUpdate); } const activeTransport = selectedTransport(); if (!activeTransport) return localEdit.execute(id, toolParams, signal, onUpdate); return createEditTool(cwd, { operations: createSbxEditOps(activeTransport, cwd) }).execute( id, toolParams, signal, onUpdate, ); }, }); pi.registerTool({ ...localBash, label: "bash (sbx/host)", parameters: withExecutionTarget(localBash.parameters), async execute(id, params, signal, onUpdate) { const toolParams = withoutExecutionTarget(params); if (params.execution_target === "host") { requireApprovedHostExecution(localBash.name, id, params); return localBash.execute(id, toolParams, signal, onUpdate); } const activeTransport = selectedTransport(); if (!activeTransport) return localBash.execute(id, toolParams, signal, onUpdate); return createBashTool(cwd, { operations: createSbxBashOps(activeTransport) }).execute( id, toolParams, signal, onUpdate, ); }, }); pi.registerTool({ ...localLs, label: "ls (sbx/host)", parameters: withExecutionTarget(localLs.parameters), async execute(id, params, signal, onUpdate) { const toolParams = withoutExecutionTarget(params); if (params.execution_target === "host") { requireApprovedHostExecution(localLs.name, id, params); return localLs.execute(id, toolParams, signal, onUpdate); } const activeTransport = selectedTransport(); if (!activeTransport) return localLs.execute(id, toolParams, signal, onUpdate); return createLsTool(cwd, { operations: createSbxLsOps(activeTransport, cwd) }).execute( id, toolParams, signal, onUpdate, ); }, }); pi.registerTool({ ...localFind, label: "find (sbx/host)", parameters: withExecutionTarget(localFind.parameters), async execute(id, params, signal, onUpdate) { const toolParams = withoutExecutionTarget(params); if (params.execution_target === "host") { requireApprovedHostExecution(localFind.name, id, params); return localFind.execute(id, toolParams, signal, onUpdate); } const activeTransport = selectedTransport(); if (!activeTransport) return localFind.execute(id, toolParams, signal, onUpdate); return createFindTool(cwd, { operations: createSbxFindOps(activeTransport, cwd) }).execute( id, toolParams, signal, onUpdate, ); }, }); pi.registerTool({ ...localGrep, label: "grep (sbx/host)", parameters: withExecutionTarget(localGrep.parameters), async execute(id, params, signal, onUpdate) { const toolParams = withoutExecutionTarget(params); if (params.execution_target === "host") { requireApprovedHostExecution(localGrep.name, id, params); return localGrep.execute(id, toolParams, signal, onUpdate); } const activeTransport = selectedTransport(); if (!activeTransport) return localGrep.execute(id, toolParams, signal, onUpdate); return executeSbxGrep(activeTransport, cwd, toolParams, signal); }, }); pi.on("tool_call", async (event, ctx) => { if (!selectedSandbox()) return; if (!ROUTED_TOOLS.has(event.toolName)) { return { block: true, reason: `Tool ${event.toolName} is not sandbox-aware and cannot run in the selected sbx sandbox.`, }; } const input = event.input as Record; if (input.execution_target !== "host") return; if (!ctx.hasUI) { return { block: true, reason: "Host execution requires user approval, but no interactive UI is available." }; } const approved = await ctx.ui.confirm( "Allow host execution?", hostApprovalMessage(event.toolName, input, cwd), ); if (!approved) return { block: true, reason: "Host execution was denied by the user." }; approvedHostCalls.set(event.toolCallId, hostRequestFingerprint(event.toolName, input)); }); pi.on("tool_execution_end", (event) => { approvedHostCalls.delete(event.toolCallId); }); pi.on("user_bash", () => { const activeTransport = selectedTransport(); return activeTransport ? { operations: createSbxBashOps(activeTransport) } : undefined; }); pi.on("before_agent_start", (event) => { discoveredSkills = (event.systemPromptOptions?.skills ?? []).map(({ filePath, baseDir }) => ({ filePath, baseDir, })); const sandbox = selectedSandbox(); const environment = sandbox ? [ `Tool execution environment: sbx sandbox ${sandbox}. Pi itself runs on the host; tool processes and filesystem operations run in the sandbox.`, 'Routed built-in tools accept execution_target: "sandbox" | "host". Omit execution_target or use "sandbox" normally. Use "host" only when absolutely necessary and sandbox execution cannot perform the operation. Every host-targeted tool call requires explicit user approval and interrupts the user, so avoid unnecessary or repeated host requests.', ].join("\n") : "Tool execution environment: host fallback. Sandboxing is disabled or no matching sbx sandbox is available, so Pi tools run directly on the host as they normally do. execution_target does not require approval in this mode."; return { systemPrompt: `${event.systemPrompt}\n\n${environment}` }; }); pi.on("session_shutdown", () => { approvedHostCalls.clear(); discoveredSkills = []; disposeTransport(); }); pi.on("session_start", async (_event, ctx) => { const restored = restoredSelection(ctx); sandboxingEnabled = restored?.hostFallback !== true; try { await discover(ctx); if (sandboxingEnabled) { selectedName = sandboxes.find((sandbox) => sandbox.name === restored?.name)?.name ?? sandboxes[0]?.name; } updateStatus(ctx); if (!selectedSandbox()) { ctx.ui.notify(`No sbx sandbox is active for ${cwd}. Tool calls will run on the host.`, "warning"); } } catch (error) { selectedName = undefined; updateStatus(ctx); ctx.ui.notify(`Could not discover sbx sandboxes; tool calls will run on the host: ${error instanceof Error ? error.message : String(error)}`, "warning"); } }); pi.registerCommand("sbx", { description: "Select an sbx sandbox, or use /sbx off to run tools on the host", handler: async (args, ctx) => { await ctx.waitForIdle(); const action = args.trim().toLowerCase(); if (action === "off" || action === "host") { useHostFallback(ctx); ctx.ui.notify("Sandboxing disabled for this session; tool calls now run on the host.", "info"); return; } if (action && action !== "on") { ctx.ui.notify("Usage: /sbx, /sbx on, or /sbx off", "warning"); return; } try { await discover(ctx); } catch (error) { useHostFallback(ctx); ctx.ui.notify(`Could not discover sbx sandboxes; tool calls will run on the host: ${error instanceof Error ? error.message : String(error)}`, "warning"); return; } if (sandboxes.length === 0) { ctx.ui.notify(`No sbx sandbox mounts ${cwd}; tool calls will run on the host.`, "warning"); return; } if (action === "on" && selectedName && sandboxes.some((sandbox) => sandbox.name === selectedName)) { sandboxingEnabled = true; pi.appendEntry(STATE_ENTRY, { name: selectedName }); updateStatus(ctx); ctx.ui.notify(`Tool calls now execute in ${selectedName}.`, "info"); return; } const hostLabel = "Host (disable sandboxing)"; const labels = [ hostLabel, ...sandboxes.map((sandbox) => { const selected = sandboxingEnabled && sandbox.name === selectedName ? " • selected" : ""; return `${sandbox.name} (${sandbox.status ?? "unknown"})${selected}`; }), ]; const choice = await ctx.ui.select("Tool execution environment", labels); if (!choice) return; if (choice === hostLabel) { useHostFallback(ctx); ctx.ui.notify("Sandboxing disabled for this session; tool calls now run on the host.", "info"); return; } const index = labels.indexOf(choice) - 1; const nextName = sandboxes[index]?.name; if (!nextName) return; if (selectedName !== nextName) disposeTransport(); selectedName = nextName; sandboxingEnabled = true; pi.appendEntry(STATE_ENTRY, { name: selectedName }); updateStatus(ctx); ctx.ui.notify(`Tool calls now execute in ${selectedName}.`, "info"); }, }); }