/** * pi-gondolin-mount — Sandbox pi coding agents inside a Gondolin micro-VM * with configurable additional directory mounts. * * The host working directory is mounted at /workspace in the guest via a * ShadowProvider that masks pi-gondolin-mount-config.yml from the VM. File changes under * /workspace write through to the host; other guest filesystem changes are * isolated to the VM. * * Usage (preferred — install globally): * pi install npm:pi-gondolin-mount * pi install git:https://github.com/abobco/pi-gondolin-mount.git * * Usage (development — load from local path): * pi -e /path/to/pi-gondolin-mount * * Requirements: * - Node.js >= 23.6.0 for @earendil-works/gondolin * - QEMU installed */ import fs from "node:fs/promises"; import path from "node:path"; import { parse as parseYaml } from "yaml"; import { ReadonlyProvider, RealFSProvider, ShadowProvider, createShadowPathPredicate, VirtualProvider, VM } from "@earendil-works/gondolin"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; 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"; const GUEST_WORKSPACE = "/workspace"; const DEFAULT_GREP_LIMIT = 100; // Files masked from the VM filesystem (read from host before VM creation) const MASKED_GUEST_FILES = ["/pi-gondolin-mount-config.yml"]; // Additional mounts configured via pi-gondolin-mount-config.yml (read from host before VM creation, masked from guest) interface MountEntry { guestPath: string; // absolute guest path, e.g. "/mnt/data" hostPath: string; // absolute host path, e.g. "/home/user/data" mode: "ro" | "rw"; // read-only or read-write (default: "rw") } let additionalMounts: MountEntry[] = []; async function loadMountsConfig(localCwd: string): Promise<{ mounts: MountEntry[]; errors: string[] }> { const filePath = path.join(localCwd, "pi-gondolin-mount-config.yml"); const errors: string[] = []; let mounts: MountEntry[] = []; try { const raw = await fs.readFile(filePath, "utf8"); let parsed: unknown; try { parsed = parseYaml(raw); } catch { errors.push(`${filePath}: invalid YAML`); return { mounts, errors }; } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { errors.push(`${filePath}: config must be a YAML object`); return { mounts, errors }; } const obj = parsed as Record; const rawMounts = obj.mounts; if (!Array.isArray(rawMounts)) { errors.push(`${filePath}: "mounts" must be a YAML array`); return { mounts, errors }; } const seenGuests = new Set(); for (let i = 0; i < rawMounts.length; i++) { const entry = rawMounts[i]; if (typeof entry !== "string") { errors.push(`${filePath}: mounts[${i}]: entry must be a string (e.g. "/host/path:/guest/path")`); continue; } // Parse docker-compose-like syntax: host:guest or host:guest:mode const parts = entry.split(":"); if (parts.length < 2 || parts.length > 3) { errors.push( `${filePath}: mounts[${i}]: "${entry}" — expected format: host_path:guest_path or host_path:guest_path:ro`, ); continue; } const [hostPath, guestPath, modePart] = parts; let mode: "ro" | "rw" = "rw"; if (modePart !== undefined) { if (modePart !== "ro" && modePart !== "rw") { errors.push( `${filePath}: mounts[${i}]: "${entry}" — mode must be "ro" or "rw", got "${modePart}"`, ); continue; } mode = modePart; } if (!path.posix.isAbsolute(guestPath)) { errors.push( `${filePath}: mounts[${i}]: "${entry}" — guest path must be absolute (e.g. "/mnt/data")`, ); continue; } if (!path.isAbsolute(hostPath)) { errors.push( `${filePath}: mounts[${i}]: "${entry}" — host path must be absolute (e.g. "/home/user/data")`, ); continue; } if (guestPath === GUEST_WORKSPACE || guestPath.startsWith(GUEST_WORKSPACE + "/")) { errors.push( `${filePath}: mounts[${i}]: "${entry}" — guest path cannot overlap with ${GUEST_WORKSPACE}`, ); continue; } if (seenGuests.has(guestPath)) { errors.push( `${filePath}: mounts[${i}]: "${entry}" — duplicate guest path "${guestPath}"`, ); continue; } seenGuests.add(guestPath); try { await fs.access(hostPath); } catch { errors.push( `${filePath}: mounts[${i}]: "${entry}" — host path "${hostPath}" not accessible`, ); } mounts.push({ guestPath: toPosix(guestPath), hostPath, mode }); } } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") { errors.push(`${filePath}: ${(err as Error).message}`); } } // Sort by longest guest path first so nested mounts match correctly mounts.sort((a, b) => b.guestPath.length - a.guestPath.length); return { mounts, errors }; } type TextToolResult = { content: Array<{ type: "text"; text: string }>; details: TDetails | undefined; }; function stripAtPrefix(value: string): string { return value.startsWith("@") ? value.slice(1) : value; } function toPosix(value: string): string { return value.split(path.sep).join(path.posix.sep); } function isInsideHostPath(root: string, value: string): boolean { const relativePath = path.relative(root, value); return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); } function hostPathToGuest(hostRoot: string, hostPath: string, guestRoot: string): string { const relativePath = path.relative(hostRoot, hostPath); if (!isInsideHostPath(hostRoot, hostPath)) return toPosix(hostPath); return relativePath ? path.posix.join(guestRoot, toPosix(relativePath)) : guestRoot; } function toGuestPath(localCwd: string, inputPath: string): string { const trimmed = stripAtPrefix(inputPath.trim()); if (!trimmed) return GUEST_WORKSPACE; if (path.isAbsolute(trimmed)) { if (isInsideHostPath(localCwd, trimmed)) return hostPathToGuest(localCwd, trimmed, GUEST_WORKSPACE); // Check additional mounts (sorted longest-first for correct nested matching) for (const mount of additionalMounts) { if (isInsideHostPath(mount.hostPath, trimmed)) { return hostPathToGuest(mount.hostPath, trimmed, mount.guestPath); } } return path.posix.resolve("/", toPosix(trimmed)); } return path.posix.resolve(GUEST_WORKSPACE, toPosix(trimmed)); } function createGondolinReadOps(vm: VM, localCwd: string): ReadOperations { return { readFile: async (filePath) => vm.fs.readFile(toGuestPath(localCwd, filePath)), access: async (filePath) => { await vm.fs.access(toGuestPath(localCwd, filePath)); }, detectImageMimeType: async (filePath) => { const ext = path.posix.extname(toGuestPath(localCwd, filePath)).toLowerCase(); if (ext === ".png") return "image/png"; if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; if (ext === ".gif") return "image/gif"; if (ext === ".webp") return "image/webp"; return null; }, }; } function createGondolinWriteOps(vm: VM, localCwd: string): WriteOperations { return { writeFile: async (filePath, content) => { await vm.fs.writeFile(toGuestPath(localCwd, filePath), content, { encoding: "utf8" }); }, mkdir: async (dirPath) => { await vm.fs.mkdir(toGuestPath(localCwd, dirPath), { recursive: true }); }, }; } function createGondolinEditOps(vm: VM, localCwd: string): EditOperations { const readOps = createGondolinReadOps(vm, localCwd); const writeOps = createGondolinWriteOps(vm, localCwd); return { readFile: readOps.readFile, writeFile: writeOps.writeFile, access: readOps.access, }; } function createGondolinLsOps(vm: VM, localCwd: string): LsOperations { return { exists: async (filePath) => { try { await vm.fs.access(toGuestPath(localCwd, filePath)); return true; } catch { return false; } }, stat: async (filePath) => vm.fs.stat(toGuestPath(localCwd, filePath)), readdir: async (dirPath) => vm.fs.listDir(toGuestPath(localCwd, dirPath)), }; } async function walkGuestFiles( vm: VM, root: string, visit: (guestPath: string, relativePath: string) => Promise, signal?: AbortSignal, ): Promise { if (signal?.aborted) throw new Error("Operation aborted"); const stat = await vm.fs.stat(root, { signal }); if (!stat.isDirectory()) return visit(root, path.posix.basename(root)); const walkDirectory = async (dir: string, relativeDir: string): Promise => { if (signal?.aborted) throw new Error("Operation aborted"); const entries = await vm.fs.listDir(dir, { signal }); for (const entry of entries) { if (entry === ".git" || entry === "node_modules") continue; const guestPath = path.posix.join(dir, entry); const relativePath = relativeDir ? path.posix.join(relativeDir, entry) : entry; let entryStat: Awaited>; try { entryStat = await vm.fs.stat(guestPath, { signal }); } catch { continue; } if (entryStat.isDirectory()) { if (!(await walkDirectory(guestPath, relativePath))) return false; } else if (!(await visit(guestPath, relativePath))) { return false; } } return true; }; return walkDirectory(root, ""); } function matchesToolGlob(relativePath: string, pattern: string): boolean { const normalizedPattern = toPosix(pattern); if (normalizedPattern.includes("/")) { return ( path.posix.matchesGlob(relativePath, normalizedPattern) || path.posix.matchesGlob(relativePath, `**/${normalizedPattern}`) ); } return path.posix.matchesGlob(path.posix.basename(relativePath), normalizedPattern); } function createGondolinFindOps(vm: VM, localCwd: string): FindOperations { return { exists: async (filePath) => { try { await vm.fs.access(toGuestPath(localCwd, filePath)); return true; } catch { return false; } }, glob: async (pattern, cwd, options) => { const root = toGuestPath(localCwd, cwd); const results: string[] = []; await walkGuestFiles(vm, root, async (guestPath, relativePath) => { if (results.length >= options.limit) return false; if (matchesToolGlob(relativePath, pattern)) results.push(guestPath); return results.length < options.limit; }); return results; }, }; } function createLineMatcher(pattern: string, literal: boolean | undefined, ignoreCase: boolean | undefined) { if (literal) { const needle = ignoreCase ? pattern.toLowerCase() : pattern; return (line: string) => (ignoreCase ? line.toLowerCase() : line).includes(needle); } const regex = new RegExp(pattern, ignoreCase ? "i" : undefined); return (line: string) => regex.test(line); } function appendGrepBlock(params: { outputLines: string[]; lines: string[]; relativePath: string; lineIndex: number; contextLines: number; }): boolean { let linesTruncated = false; const start = params.contextLines > 0 ? Math.max(0, params.lineIndex - params.contextLines) : params.lineIndex; const end = params.contextLines > 0 ? Math.min(params.lines.length - 1, params.lineIndex + params.contextLines) : params.lineIndex; for (let index = start; index <= end; index++) { const rawLine = params.lines[index] ?? ""; const { text, wasTruncated } = truncateLine(rawLine.replace(/\r/g, "")); if (wasTruncated) linesTruncated = true; const separator = index === params.lineIndex ? ":" : "-"; params.outputLines.push(`${params.relativePath}${separator}${index + 1}${separator} ${text}`); } return linesTruncated; } async function executeGondolinGrep( vm: VM, localCwd: string, params: GrepToolInput, signal?: AbortSignal, ): Promise> { const root = toGuestPath(localCwd, params.path ?? "."); const rootStat = await vm.fs.stat(root, { signal }); const rootIsDirectory = rootStat.isDirectory(); const matcher = createLineMatcher(params.pattern, params.literal, params.ignoreCase); const contextLines = params.context && params.context > 0 ? params.context : 0; const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT); const outputLines: string[] = []; const details: GrepToolDetails = {}; let matchCount = 0; let matchLimitReached = false; let linesTruncated = false; await walkGuestFiles( vm, root, async (guestPath, relativePath) => { if (matchCount >= effectiveLimit) return false; if (params.glob && !matchesToolGlob(relativePath, params.glob)) return true; let content: string; try { content = await vm.fs.readFile(guestPath, { encoding: "utf8", signal }); } catch { return true; } const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); const displayPath = rootIsDirectory ? relativePath : path.posix.basename(guestPath); for (let index = 0; index < lines.length; index++) { if (signal?.aborted) throw new Error("Operation aborted"); if (!matcher(lines[index] ?? "")) continue; matchCount++; if (appendGrepBlock({ outputLines, lines, relativePath: displayPath, lineIndex: index, contextLines })) { linesTruncated = true; } if (matchCount >= effectiveLimit) { matchLimitReached = true; return false; } } return true; }, signal, ); if (matchCount === 0) return { content: [{ type: "text", text: "No matches found" }], details: undefined }; const rawOutput = outputLines.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); const notices: string[] = []; let output = truncation.content; if (matchLimitReached) { 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`); } if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; return { content: [{ type: "text", text: output }], details: Object.keys(details).length > 0 ? details : undefined, }; } function sanitizeEnv(env: NodeJS.ProcessEnv | undefined): Record | undefined { if (!env) return undefined; const result: Record = {}; for (const [key, value] of Object.entries(env)) { if (typeof value === "string") result[key] = value; } return result; } function createGondolinBashOps(vm: VM, localCwd: string, shellPath: string): BashOperations { return { exec: async (command, cwd, { onData, signal, timeout, env }) => { if (signal?.aborted) throw new Error("aborted"); const guestCwd = toGuestPath(localCwd, cwd); const controller = new AbortController(); const onAbort = () => controller.abort(); signal?.addEventListener("abort", onAbort, { once: true }); let timedOut = false; const timer = timeout && timeout > 0 ? setTimeout(() => { timedOut = true; controller.abort(); }, timeout * 1000) : undefined; try { const proc = vm.exec([shellPath, "-lc", command], { cwd: guestCwd, env: sanitizeEnv(env), signal: controller.signal, stdout: "pipe", stderr: "pipe", }); for await (const chunk of proc.output()) onData(chunk.data); const result = await proc; return { exitCode: result.exitCode }; } catch (error) { if (signal?.aborted) throw new Error("aborted"); if (timedOut) throw new Error(`timeout:${timeout}`); throw error; } 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); const localGrep = createGrepTool(localCwd); const localFind = createFindTool(localCwd); const localLs = createLsTool(localCwd); let vm: VM | undefined; let vmStarting: Promise | undefined; let shellPath = "/bin/sh"; async function startVm(ctx?: ExtensionContext): Promise { ctx?.ui.setStatus("gondolin", ctx.ui.theme.fg("accent", `Gondolin: starting ${GUEST_WORKSPACE}`)); // Load additional mounts from pi-gondolin-mount-config.yml (host read, masked from guest) const { mounts: loadedMounts, errors: mountErrors } = await loadMountsConfig(localCwd); additionalMounts = loadedMounts; for (const err of mountErrors) { ctx?.ui.notify(`Gondolin mounts config: ${err}`, "warning"); } // Build mounts object: workspace (masked) + additional directories const mounts: Record = { [GUEST_WORKSPACE]: new ShadowProvider(new RealFSProvider(localCwd), { shouldShadow: createShadowPathPredicate(MASKED_GUEST_FILES), }), }; for (const mount of additionalMounts) { const provider = new RealFSProvider(mount.hostPath); mounts[mount.guestPath] = mount.mode === "ro" ? new ReadonlyProvider(provider) : provider; } const created = await VM.create({ sessionLabel: `pi ${path.basename(localCwd)}`, vfs: { mounts }, }); const bashProbe = await created.exec(["/bin/sh", "-lc", "command -v bash || true"]); shellPath = bashProbe.stdout.trim() || "/bin/sh"; vm = created; ctx?.ui.setStatus( "gondolin", ctx.ui.theme.fg("accent", `Gondolin: ${created.id.slice(0, 8)} (${GUEST_WORKSPACE})`), ); ctx?.ui.notify(`Gondolin VM ready. ${localCwd} is mounted at ${GUEST_WORKSPACE}.`, "info"); return created; } async function ensureVm(ctx?: ExtensionContext): Promise { if (vm) return vm; if (!vmStarting) { vmStarting = startVm(ctx).finally(() => { vmStarting = undefined; }); } return vmStarting; } pi.on("session_start", async (_event, ctx) => { await ensureVm(ctx); }); pi.on("session_shutdown", async (_event, ctx) => { const activeVm = vm; vm = undefined; vmStarting = undefined; if (!activeVm) return; ctx.ui.setStatus("gondolin", ctx.ui.theme.fg("muted", "Gondolin: stopping")); try { await activeVm.close(); } finally { ctx.ui.setStatus("gondolin", undefined); } }); pi.registerCommand("gondolin", { description: "Show Gondolin VM status", handler: async (_args, ctx) => { const activeVm = await ensureVm(ctx); ctx.ui.notify( [ `Gondolin VM: ${activeVm.id}`, `Host workspace: ${localCwd}`, `Guest workspace: ${GUEST_WORKSPACE}`, `Shell: ${shellPath}`, ].join("\n"), "info", ); }, }); pi.registerTool({ ...localRead, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createReadTool(GUEST_WORKSPACE, { 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(GUEST_WORKSPACE, { 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(GUEST_WORKSPACE, { 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(GUEST_WORKSPACE, { operations: createGondolinBashOps(activeVm, localCwd, shellPath), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localLs, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createLsTool(GUEST_WORKSPACE, { operations: createGondolinLsOps(activeVm, localCwd), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localFind, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createFindTool(GUEST_WORKSPACE, { operations: createGondolinFindOps(activeVm, localCwd), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localGrep, async execute(_id, params, signal, _onUpdate, ctx) { const activeVm = await ensureVm(ctx); return executeGondolinGrep(activeVm, localCwd, params, signal); }, }); pi.on("user_bash", async (_event, ctx) => { const activeVm = await ensureVm(ctx); return { operations: createGondolinBashOps(activeVm, localCwd, shellPath) }; }); pi.on("before_agent_start", async (event, ctx) => { await ensureVm(ctx); const localLine = `Current working directory: ${localCwd}`; let guestLine = `Current working directory: ${GUEST_WORKSPACE} (Gondolin VM; host workspace mounted from ${localCwd})`; if (additionalMounts.length > 0) { const mountDescs = additionalMounts .map((m) => `${m.guestPath} (mounted from ${m.hostPath}${m.mode === "ro" ? ", read-only" : ""})`) .join(", "); guestLine += `. Additional mounts: ${mountDescs}`; } const systemPrompt = event.systemPrompt.includes(localLine) ? event.systemPrompt.replace(localLine, guestLine) : `${event.systemPrompt}\n\n${guestLine}`; return { systemPrompt }; }); }