/**
* 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<string, string> | undefined {
if (!env) return undefined;
const out: Record<string, string> = {};
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<VM> | 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 };
});
}