/** * Pi + Gondolin Sandbox Example (pi extension) * * This extension overrides pi's built-in `read`/`write`/`edit`/`bash` tools so * they execute inside a Gondolin micro-VM instead of on the host. * * The directory you start `pi` in is mounted read-write at `/workspace` inside * the VM. * * How to run: * 1. Install dependencies for this repo (so imports resolve): * pnpm install * 2. Ensure QEMU is installed (see the gondolin README "Quick Start") * 3. Start pi in the project you want to sandbox: * cd /path/to/your/project * pi -e /absolute/path/to/gondolin/host/examples/pi-gondolin.ts * * Notes: * - The VM is started on `session_start` (and lazily if a tool is used before that) * - User `!` commands are also executed inside the VM * - Module resolution happens relative to this file, so keeping it inside the * gondolin repo (or installing `@earendil-works/gondolin` next to it) is easiest */ import path from "node:path"; import type { ExtensionAPI, ExtensionContext, } from "@mariozechner/pi-coding-agent"; import { type BashOperations, createBashTool, createEditTool, createReadTool, createWriteTool, type EditOperations, type ReadOperations, type WriteOperations, } from "@mariozechner/pi-coding-agent"; import { RealFSProvider, VM } from "@earendil-works/gondolin"; const GUEST_WORKSPACE = "/workspace"; function shQuote(value: string): string { // POSIX shell quoting: wraps in single quotes and escapes internal quotes return "'" + value.replace(/'/g, "'\\''") + "'"; } function toGuestPath(localCwd: string, localPath: string): string { // pi tools pass absolute local paths; map them into /workspace. const rel = path.relative(localCwd, localPath); if (rel === "") return GUEST_WORKSPACE; if (rel.startsWith("..") || path.isAbsolute(rel)) { throw new Error(`path escapes workspace: ${localPath}`); } // Convert platform separators to POSIX for the Linux guest const posixRel = rel.split(path.sep).join(path.posix.sep); return path.posix.join(GUEST_WORKSPACE, posixRel); } function createGondolinReadOps(vm: VM, localCwd: string): ReadOperations { return { readFile: async (p) => { const guestPath = toGuestPath(localCwd, p); const r = await vm.exec(["/bin/cat", guestPath]); if (!r.ok) { throw new Error(`cat failed (${r.exitCode}): ${r.stderr}`); } return r.stdoutBuffer; }, access: async (p) => { const guestPath = toGuestPath(localCwd, p); const r = await vm.exec([ "/bin/sh", "-lc", `test -r ${shQuote(guestPath)}`, ]); if (!r.ok) { throw new Error(`not readable: ${p}`); } }, detectImageMimeType: async (p) => { const guestPath = toGuestPath(localCwd, p); try { // Run through the shell because `file` might live in `/usr/bin` depending on the image const r = await vm.exec([ "/bin/sh", "-lc", `file --mime-type -b ${shQuote(guestPath)}`, ]); if (!r.ok) return null; const m = r.stdout.trim(); return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes( m, ) ? m : null; } catch { return null; } }, }; } function createGondolinWriteOps(vm: VM, localCwd: string): WriteOperations { return { writeFile: async (p, content) => { const guestPath = toGuestPath(localCwd, p); const dir = path.posix.dirname(guestPath); // Base64 roundtrip to avoid quoting issues const b64 = Buffer.from(content, "utf8").toString("base64"); const script = [ `set -eu`, `mkdir -p ${shQuote(dir)}`, `echo ${shQuote(b64)} | base64 -d > ${shQuote(guestPath)}`, ].join("\n"); const r = await vm.exec(["/bin/sh", "-lc", script]); if (!r.ok) { throw new Error(`write failed (${r.exitCode}): ${r.stderr}`); } }, mkdir: async (dir) => { const guestDir = toGuestPath(localCwd, dir); const r = await vm.exec(["/bin/mkdir", "-p", guestDir]); if (!r.ok) { throw new Error(`mkdir failed (${r.exitCode}): ${r.stderr}`); } }, }; } function createGondolinEditOps(vm: VM, localCwd: string): EditOperations { const r = createGondolinReadOps(vm, localCwd); const w = createGondolinWriteOps(vm, localCwd); return { readFile: r.readFile, access: r.access, writeFile: w.writeFile }; } function sanitizeEnv( env?: NodeJS.ProcessEnv, ): Record | undefined { if (!env) return undefined; const out: Record = {}; for (const [k, v] of Object.entries(env)) { if (typeof v === "string") out[k] = v; } return out; } function createGondolinBashOps(vm: VM, localCwd: string): BashOperations { return { exec: async (command, cwd, { onData, signal, timeout, env }) => { const guestCwd = toGuestPath(localCwd, cwd); const ac = new AbortController(); const onAbort = () => ac.abort(); signal?.addEventListener("abort", onAbort, { once: true }); let timedOut = false; const timer = timeout && timeout > 0 ? setTimeout(() => { timedOut = true; ac.abort(); }, timeout * 1000) : undefined; try { // `/bin/bash -lc` for a familiar environment (pipelines, expansions, etc.) const proc = vm.exec(["/bin/bash", "-lc", command], { cwd: guestCwd, signal: ac.signal, env: sanitizeEnv(env), stdout: "pipe", stderr: "pipe", }); for await (const chunk of proc.output()) { onData(chunk.data); } const r = await proc; return { exitCode: r.exitCode }; } catch (err) { if (signal?.aborted) throw new Error("aborted"); if (timedOut) throw new Error(`timeout:${timeout}`); throw err; } finally { if (timer) clearTimeout(timer); signal?.removeEventListener("abort", onAbort); } }, }; } export default function (pi: ExtensionAPI) { const localCwd = process.cwd(); const localRead = createReadTool(localCwd); const localWrite = createWriteTool(localCwd); const localEdit = createEditTool(localCwd); const localBash = createBashTool(localCwd); let vm: VM | null = null; let vmStarting: Promise | null = null; async function ensureVm(ctx?: ExtensionContext) { if (vm) return vm; if (vmStarting) return vmStarting; vmStarting = (async () => { ctx?.ui.setStatus( "gondolin", ctx.ui.theme.fg( "accent", `Gondolin: starting (mount ${GUEST_WORKSPACE})`, ), ); const created = await VM.create({ vfs: { mounts: { [GUEST_WORKSPACE]: new RealFSProvider(localCwd), }, }, }); vm = created; ctx?.ui.setStatus( "gondolin", ctx.ui.theme.fg( "accent", `Gondolin: running (${localCwd} -> ${GUEST_WORKSPACE})`, ), ); ctx?.ui.notify( `Gondolin VM ready. Host ${localCwd} mounted at ${GUEST_WORKSPACE}`, "info", ); return created; })(); return vmStarting; } pi.on("session_start", async (_event, ctx) => { // Start eagerly so the user sees errors early (missing qemu, etc.) await ensureVm(ctx); }); pi.on("session_shutdown", async (_event, ctx) => { if (!vm) return; ctx.ui.setStatus( "gondolin", ctx.ui.theme.fg("muted", "Gondolin: stopping"), ); try { await vm.close(); } finally { vm = null; vmStarting = null; } }); pi.registerTool({ ...localRead, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createReadTool(localCwd, { operations: createGondolinReadOps(activeVm, localCwd), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localWrite, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createWriteTool(localCwd, { operations: createGondolinWriteOps(activeVm, localCwd), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localEdit, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createEditTool(localCwd, { operations: createGondolinEditOps(activeVm, localCwd), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localBash, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createBashTool(localCwd, { operations: createGondolinBashOps(activeVm, localCwd), }); return tool.execute(id, params, signal, onUpdate); }, }); // Run user `!` commands inside the VM too pi.on("user_bash", (_event, ctx) => { if (!vm) return; return { operations: createGondolinBashOps(vm, localCwd) }; }); // Replace the CWD line in the system prompt so the model sees /workspace pi.on("before_agent_start", async (event, ctx) => { await ensureVm(ctx); const modified = event.systemPrompt.replace( `Current working directory: ${localCwd}`, `Current working directory: ${GUEST_WORKSPACE} (Gondolin VM, mounted from host: ${localCwd})`, ); return { systemPrompt: modified }; }); }