docs: add containerization guide and Gondolin example (#5356)

This commit is contained in:
Vegard Stikbakke
2026-06-03 15:53:16 +02:00
committed by GitHub
parent 564ad70fb8
commit 86314bf38d
15 changed files with 1052 additions and 3 deletions

View File

@@ -56,6 +56,16 @@ I regularly publish my own `pi-mono` work sessions here:
For Slack/chat automation and workflows see [earendil-works/pi-chat](https://github.com/earendil-works/pi-chat).
## Permissions & Containerization
Pi does not include a built-in permission system for restricting filesystem, process, network, or credential access. By default, it runs with the permissions of the user and process that launched it.
If you need stronger boundaries, containerize or sandbox Pi. See [packages/coding-agent/docs/containerization.md](packages/coding-agent/docs/containerization.md) for three patterns:
- **OpenShell**: run the whole `pi` process in a policy-controlled sandbox.
- **Gondolin extension**: keep `pi` and provider auth on the host while routing built-in tools and `!` commands into a local Linux micro-VM.
- **Plain Docker**: run the whole `pi` process in a local container for simple isolation.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.md](AGENTS.md) for project-specific rules (for both humans and agents).

184
package-lock.json generated
View File

@@ -12,7 +12,8 @@
"packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
"packages/coding-agent/examples/extensions/sandbox"
"packages/coding-agent/examples/extensions/sandbox",
"packages/coding-agent/examples/extensions/gondolin"
],
"devDependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.26",
@@ -722,6 +723,78 @@
"node": ">=14.21.3"
}
},
"node_modules/@cto.af/wtf8": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-0.0.5.tgz",
"integrity": "sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/@earendil-works/gondolin": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz",
"integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==",
"license": "Apache-2.0",
"dependencies": {
"cbor2": "^2.3.0",
"node-forge": "^1.3.3",
"ssh2": "^1.17.0",
"undici": "^6.21.0"
},
"bin": {
"gondolin": "dist/bin/gondolin.js"
},
"engines": {
"node": ">=23.6.0"
},
"optionalDependencies": {
"@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0",
"@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0"
}
},
"node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz",
"integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"bin": {
"gondolin-krun-runner": "bin/gondolin-krun-runner"
}
},
"node_modules/@earendil-works/gondolin-krun-runner-linux-x64": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz",
"integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"bin": {
"gondolin-krun-runner": "bin/gondolin-krun-runner"
}
},
"node_modules/@earendil-works/gondolin/node_modules/undici": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz",
"integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/@earendil-works/pi-agent-core": {
"resolved": "packages/agent",
"link": true
@@ -2572,6 +2645,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2623,6 +2705,15 @@
],
"license": "MIT"
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@@ -2706,6 +2797,15 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -2731,6 +2831,18 @@
"node": "^18.12.0 || >= 20.9.0"
}
},
"node_modules/cbor2": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.3.0.tgz",
"integrity": "sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==",
"license": "MIT",
"dependencies": {
"@cto.af/wtf8": "0.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -2806,6 +2918,20 @@
"node": ">=18"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3943,6 +4069,13 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nan": {
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz",
"integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -4034,6 +4167,15 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/npm-run-path": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
@@ -4202,6 +4344,10 @@
"resolved": "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
"link": true
},
"node_modules/pi-extension-gondolin": {
"resolved": "packages/coding-agent/examples/extensions/gondolin",
"link": true
},
"node_modules/pi-extension-sandbox": {
"resolved": "packages/coding-agent/examples/extensions/sandbox",
"link": true
@@ -4544,6 +4690,12 @@
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
@@ -4696,6 +4848,23 @@
"node": ">=0.10.0"
}
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -5116,6 +5285,12 @@
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/typebox": {
"version": "1.1.38",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz",
@@ -6154,6 +6329,13 @@
"name": "pi-extension-custom-provider-gitlab-duo",
"version": "0.78.0"
},
"packages/coding-agent/examples/extensions/gondolin": {
"name": "pi-extension-gondolin",
"version": "0.78.0",
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}
},
"packages/coding-agent/examples/extensions/sandbox": {
"name": "pi-extension-sandbox",
"version": "1.8.0",

View File

@@ -7,7 +7,8 @@
"packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
"packages/coding-agent/examples/extensions/sandbox"
"packages/coding-agent/examples/extensions/sandbox",
"packages/coding-agent/examples/extensions/gondolin"
],
"scripts": {
"clean": "npm run clean --workspaces",

View File

@@ -4,6 +4,7 @@
### Added
- Added containerization documentation and a Gondolin extension example for routing built-in tools into a local micro-VM.
- Added Ant Ling provider selection and setup documentation.
- Added NVIDIA NIM provider selection, setup documentation, and direct NIM request attribution headers.
- Added `ctx.mode` to extension contexts so extensions can distinguish TUI, RPC, JSON, and print mode.

View File

@@ -95,7 +95,7 @@ pi
Then just talk to pi. By default, pi gives the model four tools: `read`, `write`, `edit`, and `bash`. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [pi packages](#pi-packages).
**Platform notes:** [Windows](docs/windows.md) | [Termux (Android)](docs/termux.md) | [tmux](docs/tmux.md) | [Terminal setup](docs/terminal-setup.md) | [Shell aliases](docs/shell-aliases.md)
**Platform notes:** [Windows](docs/windows.md) | [Termux (Android)](docs/termux.md) | [tmux](docs/tmux.md) | [Terminal setup](docs/terminal-setup.md) | [Shell aliases](docs/shell-aliases.md) | [Containerization](docs/containerization.md)
---

View File

@@ -0,0 +1,111 @@
# Containerization
Pi runs with all permissions by default, but in some cases, you will want to have more control over what directories Pi can write to and which accesses it has.
There are two general options. You can either
1. run the whole `pi` process inside an isolated environment, or
2. run `pi` on the host and route tool execution into an isolated environment.
## Choose a pattern
| Pattern | What is isolated | Best for | Notes |
| --- | --- | --- | --- |
| OpenShell | Whole `pi` process in a policy-controlled sandbox | Local or remote managed sandbox | Requires an OpenShell gateway |
| Gondolin extension | Built-in tools and `!` commands | Local micro-VM isolation while keeping auth on host | See [`examples/extensions/gondolin/`](../examples/extensions/gondolin/). |
| Plain Docker | Whole `pi` process in a local container | Simple local isolation | Provider API keys enter the container. |
Extensions run wherever the `pi` process runs. If you run host `pi` with a tool-routing extension, other custom extension tools still run on the host unless they also delegate their operations.
## OpenShell
Use [NVIDIA OpenShell](https://docs.nvidia.com/openshell/about/overview) when you want a policy-controlled sandbox with filesystem, process, network, credential, and inference controls.
OpenShell can run sandboxes through a local gateway backed by Docker, Podman, or a VM runtime, or through a remote Kubernetes gateway.
Every sandbox requires an active gateway.
Register and select one before creating a sandbox:
```bash
openshell gateway add <gateway-url> --name <name>
openshell gateway select <name>
```
Launch `pi` inside an OpenShell sandbox:
```bash
openshell sandbox create --name pi-sandbox --from pi -- pi
```
In this pattern, the whole `pi` process runs inside the sandbox.
Built-in tools, `!` commands, and extension tools execute inside the OpenShell boundary.
If the gateway is remote, project files are not bind-mounted from the host, meaning writes in the sandbox are not reflected on your machine.
Clone the repository inside the sandbox or use OpenShell file transfer commands:
```bash
openshell sandbox upload pi-sandbox ./repo /workspace
openshell sandbox download pi-sandbox /workspace/repo ./repo-out
```
OpenShell providers can keep raw model API keys outside the sandbox.
When inference routing is configured, code inside the sandbox can call `https://inference.local`, and the gateway injects the configured provider credentials upstream.
Configure Pi to use the corresponding OpenAI-compatible or Anthropic-compatible endpoint if you want model traffic to use this route.
## Gondolin
[Gondolin](https://github.com/earendil-works/gondolin) is a local Linux micro-VM.
Use the [example extension](../examples/extensions/gondolin) when you want `pi` on the host but all built-in tools routed into the VM.
Setup:
```bash
cp -R packages/coding-agent/examples/extensions/gondolin ~/.pi/agent/extensions/gondolin
cd ~/.pi/agent/extensions/gondolin
npm install --ignore-scripts
```
Run from the project you want mounted:
```bash
cd /path/to/project
pi -e ~/.pi/agent/extensions/gondolin
```
The extension mounts the host cwd at `/workspace` in the VM and overrides `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`.
User `!` commands are routed into the VM, as well.
File changes under `/workspace` write through to the host.
Requirements: Node.js >= 23.6.0 for `@earendil-works/gondolin`, plus QEMU (requires installation through your package manager).
## Plain Docker
Run the whole `pi` process in Docker when you want the simplest local container boundary.
`Dockerfile.pi`:
```dockerfile
FROM node:24-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
WORKDIR /workspace
ENTRYPOINT ["pi"]
```
Build and run:
```bash
docker build -t pi-sandbox -f Dockerfile.pi .
docker run --rm -it \
-e ANTHROPIC_API_KEY \
-v "$PWD:/workspace" \
-v pi-agent-home:/root/.pi/agent \
pi-sandbox
```
The `-v "$PWD:/workspace"` mounts your current directory into the container at /workspace such that reads and writes in `/workspace` inside Docker directly affect your host files, like in the Gondolin example.
Use a named volume for `/root/.pi/agent` if you want container-local settings and sessions. Mounting your host `~/.pi/agent` exposes host auth and session files to the container.

View File

@@ -19,6 +19,10 @@
"title": "Providers",
"path": "providers.md"
},
{
"title": "Containerization",
"path": "containerization.md"
},
{
"title": "Settings",
"path": "settings.md"

View File

@@ -2606,6 +2606,7 @@ All examples in [examples/extensions/](../examples/extensions/).
| `ssh.ts` | SSH remote execution | `registerFlag`, `on("user_bash")`, `on("before_agent_start")`, tool operations |
| `interactive-shell.ts` | Persistent shell session | `on("user_bash")` |
| `sandbox/` | Sandboxed tool execution | Tool operations |
| `gondolin/` | Route built-in tools and `!` commands into a Gondolin micro-VM | Tool operations, built-in tool overrides, `on("user_bash")` |
| `subagent/` | Spawn sub-agents | `registerTool`, `exec` |
| **Games** |||
| `snake.ts` | Snake game | `registerCommand`, `ui.custom`, keyboard handling |

View File

@@ -41,6 +41,7 @@ For the full first-run flow, see [Quickstart](quickstart.md).
- [Quickstart](quickstart.md) - install, authenticate, and run a first session.
- [Using Pi](usage.md) - interactive mode, slash commands, context files, and CLI reference.
- [Providers](providers.md) - subscription and API-key setup for built-in providers.
- [Containerization](containerization.md) - sandbox pi with OpenShell, Gondolin, or Docker.
- [Settings](settings.md) - global and project settings.
- [Keybindings](keybindings.md) - default shortcuts and custom keybindings.
- [Sessions](sessions.md) - session management, branching, and tree navigation.

View File

@@ -23,6 +23,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) |
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
| `sandbox/` | OS-level sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config |
| `gondolin/` | Route built-in tools and `!` commands into a Gondolin micro-VM |
### Custom Tools

View File

@@ -0,0 +1 @@
node_modules/

View File

@@ -0,0 +1,531 @@
/**
* Gondolin Tool Routing Example
*
* Runs pi's built-in tools inside a local Gondolin micro-VM. The host working
* directory is mounted at /workspace in the guest. File changes under
* /workspace write through to the host; other guest filesystem changes are
* isolated to the VM.
*
* Setup:
* cd packages/coding-agent/examples/extensions/gondolin
* npm install --ignore-scripts
*
* Usage:
* cd /path/to/project
* pi -e /path/to/pi/packages/coding-agent/examples/extensions/gondolin
*
* Requirements:
* - Node.js >= 23.6.0 for @earendil-works/gondolin
* - QEMU installed (for example, `brew install qemu` on macOS)
*/
import path from "node:path";
import { RealFSProvider, 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;
type TextToolResult<TDetails> = {
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(localCwd: string, hostPath: string): string {
const relativePath = path.relative(localCwd, hostPath);
if (!isInsideHostPath(localCwd, hostPath)) return toPosix(hostPath);
return relativePath ? path.posix.join(GUEST_WORKSPACE, toPosix(relativePath)) : GUEST_WORKSPACE;
}
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);
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<boolean>,
signal?: AbortSignal,
): Promise<boolean> {
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<boolean> => {
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<ReturnType<VM["fs"]["stat"]>>;
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<TextToolResult<GrepToolDetails>> {
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<string, string> | undefined {
if (!env) return undefined;
const result: Record<string, string> = {};
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<VM> | undefined;
let shellPath = "/bin/sh";
async function startVm(ctx?: ExtensionContext): Promise<VM> {
ctx?.ui.setStatus("gondolin", ctx.ui.theme.fg("accent", `Gondolin: starting ${GUEST_WORKSPACE}`));
const created = await VM.create({
sessionLabel: `pi ${path.basename(localCwd)}`,
vfs: {
mounts: {
[GUEST_WORKSPACE]: new RealFSProvider(localCwd),
},
},
});
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<VM> {
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}`;
const guestLine = `Current working directory: ${GUEST_WORKSPACE} (Gondolin VM; host workspace mounted from ${localCwd})`;
const systemPrompt = event.systemPrompt.includes(localLine)
? event.systemPrompt.replace(localLine, guestLine)
: `${event.systemPrompt}\n\n${guestLine}`;
return { systemPrompt };
});
}

View File

@@ -0,0 +1,185 @@
{
"name": "pi-extension-gondolin",
"version": "0.78.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-gondolin",
"version": "0.78.0",
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}
},
"node_modules/@cto.af/wtf8": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-0.0.5.tgz",
"integrity": "sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/@earendil-works/gondolin": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz",
"integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==",
"license": "Apache-2.0",
"dependencies": {
"cbor2": "^2.3.0",
"node-forge": "^1.3.3",
"ssh2": "^1.17.0",
"undici": "^6.21.0"
},
"bin": {
"gondolin": "dist/bin/gondolin.js"
},
"engines": {
"node": ">=23.6.0"
},
"optionalDependencies": {
"@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0",
"@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0"
}
},
"node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz",
"integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"bin": {
"gondolin-krun-runner": "bin/gondolin-krun-runner"
}
},
"node_modules/@earendil-works/gondolin-krun-runner-linux-x64": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz",
"integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"bin": {
"gondolin-krun-runner": "bin/gondolin-krun-runner"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cbor2": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.3.0.tgz",
"integrity": "sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==",
"license": "MIT",
"dependencies": {
"@cto.af/wtf8": "0.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/nan": {
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz",
"integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==",
"license": "MIT",
"optional": true
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/undici": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz",
"integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
}
}
}

View File

@@ -0,0 +1,19 @@
{
"name": "pi-extension-gondolin",
"private": true,
"version": "0.78.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
"build": "echo 'nothing to build'",
"check": "echo 'nothing to check'"
},
"pi": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}
}

View File

@@ -25,6 +25,7 @@
"dist",
"docs",
"examples",
"containerization.md",
"CHANGELOG.md",
"npm-shrinkwrap.json"
],