From 91cc9f87acc9b3043f483a8fbac66e8153e61516 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 14 Mar 2026 05:26:00 +0000 Subject: [PATCH 01/11] feat(pi-integration): add pi coding agent extension for bashkit virtual bash Pi extension that replaces the built-in bash tool with bashkit's virtual bash interpreter and virtual filesystem. Commands execute in a sandboxed in-memory environment with 100+ builtins, state persistence across calls. Includes: - bashkit-extension.ts: Pi extension registering virtual bash tool - bashkit_server.py: Persistent Python process bridging pi to bashkit - README.md: Setup and usage documentation --- pi-integration/README.md | 73 ++++++++++++ pi-integration/bashkit-extension.ts | 174 ++++++++++++++++++++++++++++ pi-integration/bashkit_server.py | 68 +++++++++++ 3 files changed, 315 insertions(+) create mode 100644 pi-integration/README.md create mode 100644 pi-integration/bashkit-extension.ts create mode 100644 pi-integration/bashkit_server.py diff --git a/pi-integration/README.md b/pi-integration/README.md new file mode 100644 index 00000000..ca076f0e --- /dev/null +++ b/pi-integration/README.md @@ -0,0 +1,73 @@ +# Pi + Bashkit Integration + +Run [pi](https://pi.dev/) (terminal coding agent) with bashkit's virtual bash interpreter and virtual filesystem instead of real shell access. + +## What This Does + +Replaces pi's built-in `bash` tool with bashkit. When the LLM calls the bash tool, commands execute in bashkit's sandboxed virtual environment: + +- **Virtual filesystem** — all file operations are in-memory, no real FS access +- **100+ builtins** — echo, grep, sed, awk, jq, curl, find, ls, cat, etc. +- **State persistence** — variables, files, and cwd persist across tool calls within a session +- **Resource limits** — bounded command count, loop iterations, function depth + +## Setup + +### Prerequisites + +```bash +# Install pi +npm install -g @mariozechner/pi-coding-agent + +# Install bashkit Python package +pip install bashkit +# Or build from source: +cd crates/bashkit-python && maturin develop --release +``` + +### Run + +```bash +# With OpenAI +pi --provider openai --model gpt-4o \ + -e pi-integration/bashkit-extension.ts \ + --api-key "$OPENAI_API_KEY" + +# With Anthropic +pi --provider anthropic --model claude-sonnet-4-20250514 \ + -e pi-integration/bashkit-extension.ts \ + --api-key "$ANTHROPIC_API_KEY" + +# Non-interactive (print mode) +pi --provider openai --model gpt-4o-mini \ + -e pi-integration/bashkit-extension.ts \ + -p "Create a directory structure and write some files" \ + --no-session +``` + +## Architecture + +``` +pi (LLM agent) → bash tool call → bashkit-extension.ts → bashkit_server.py → bashkit (Rust via PyO3) +``` + +1. **bashkit-extension.ts** — Pi extension that registers a `bash` tool, replacing the built-in +2. **bashkit_server.py** — Persistent Python process running bashkit, communicates via JSON-line protocol over stdin/stdout +3. **bashkit** — Rust virtual bash interpreter with in-memory VFS + +The server process stays alive for the session, maintaining VFS and shell state across tool calls. + +## Configuration + +Set `BASHKIT_PYTHON` env var to override the Python path: + +```bash +BASHKIT_PYTHON=/path/to/venv/bin/python3 pi -e pi-integration/bashkit-extension.ts +``` + +## Limitations + +- No real filesystem access (by design — that's the point) +- No background processes or job control +- Network access (curl/wget) requires allowlist configuration in bashkit +- pi's `read`, `write`, `edit` tools still use real FS — only `bash` is virtualized diff --git a/pi-integration/bashkit-extension.ts b/pi-integration/bashkit-extension.ts new file mode 100644 index 00000000..24875e5e --- /dev/null +++ b/pi-integration/bashkit-extension.ts @@ -0,0 +1,174 @@ +/** + * Pi extension: replaces the built-in bash tool with bashkit's virtual bash interpreter. + * + * All commands run in bashkit's sandboxed virtual filesystem — no real filesystem access. + * State (variables, files, cwd) persists across tool calls within a session. + * + * Install: pi -e /path/to/bashkit/pi-integration/bashkit-extension.ts + */ + +import { spawn, type ChildProcess } from "child_process"; +import { randomBytes } from "crypto"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +// Resolve server script path relative to this extension +const __dirname_ext = + typeof __dirname !== "undefined" + ? __dirname + : dirname(fileURLToPath(import.meta.url)); +const SERVER_SCRIPT = resolve(__dirname_ext, "bashkit_server.py"); + +// Find python in venv or PATH +const PYTHON = + process.env.BASHKIT_PYTHON || "/home/user/.venv/bin/python3" || "python3"; + +interface PendingRequest { + resolve: (resp: any) => void; + reject: (err: Error) => void; +} + +let serverProcess: ChildProcess | null = null; +let pendingRequests: Map = new Map(); +let lineBuffer = ""; +let serverReady: Promise; +let resolveReady: () => void; + +function ensureServer(): Promise { + if (serverProcess && !serverProcess.killed) { + return serverReady; + } + + serverReady = new Promise((resolve) => { + resolveReady = resolve; + }); + + serverProcess = spawn(PYTHON, [SERVER_SCRIPT], { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, PYTHONUNBUFFERED: "1" }, + }); + + serverProcess.stdout!.on("data", (data: Buffer) => { + lineBuffer += data.toString("utf-8"); + const lines = lineBuffer.split("\n"); + lineBuffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.ready) { + resolveReady(); + continue; + } + const pending = pendingRequests.get(msg.id); + if (pending) { + pendingRequests.delete(msg.id); + pending.resolve(msg); + } + } catch { + // skip malformed lines + } + } + }); + + serverProcess.stderr!.on("data", (data: Buffer) => { + process.stderr.write(`[bashkit-server] ${data.toString()}`); + }); + + serverProcess.on("exit", (code) => { + serverProcess = null; + // Reject all pending requests + for (const [id, pending] of pendingRequests) { + pending.reject(new Error(`bashkit server exited with code ${code}`)); + } + pendingRequests.clear(); + }); + + return serverReady; +} + +async function execInBashkit( + command: string, + timeoutMs?: number, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + await ensureServer(); + + const id = randomBytes(8).toString("hex"); + const req = { id, command, timeout_ms: timeoutMs }; + + return new Promise((resolve, reject) => { + pendingRequests.set(id, { + resolve: (resp) => + resolve({ + stdout: resp.stdout || "", + stderr: resp.stderr || "", + exitCode: resp.exit_code ?? 0, + }), + reject, + }); + + serverProcess!.stdin!.write(JSON.stringify(req) + "\n"); + }); +} + +export default function (pi: any) { + // Register our bashkit-powered bash tool, replacing the built-in + pi.registerTool({ + name: "bash", + label: "bashkit", + description: `Execute bash commands in bashkit's virtual sandbox. Provides a full bash interpreter with 100+ builtins (echo, grep, sed, awk, jq, curl, find, etc.) running entirely in-memory. All file operations use a virtual filesystem — no real filesystem access. State persists across calls.`, + parameters: { + type: "object", + properties: { + command: { + type: "string", + description: "Bash command to execute", + }, + timeout: { + type: "number", + description: "Timeout in seconds (optional)", + }, + }, + required: ["command"], + }, + async execute( + toolCallId: string, + params: { command: string; timeout?: number }, + signal: AbortSignal, + onUpdate: (update: any) => void, + ) { + const { command, timeout } = params; + const timeoutMs = timeout ? timeout * 1000 : undefined; + + try { + const result = await execInBashkit(command, timeoutMs); + + let output = ""; + if (result.stdout) output += result.stdout; + if (result.stderr) output += result.stderr; + if (!output) output = "(no output)"; + + if (result.exitCode !== 0) { + output += `\n\nCommand exited with code ${result.exitCode}`; + throw new Error(output); + } + + return { + content: [{ type: "text", text: output }], + details: { virtual: true, engine: "bashkit" }, + }; + } catch (err: any) { + throw err; + } + }, + }); + + // Cleanup on session end + pi.on("session_end", async () => { + if (serverProcess && !serverProcess.killed) { + serverProcess.kill(); + serverProcess = null; + } + }); +} diff --git a/pi-integration/bashkit_server.py b/pi-integration/bashkit_server.py new file mode 100644 index 00000000..e60e8f8e --- /dev/null +++ b/pi-integration/bashkit_server.py @@ -0,0 +1,68 @@ +""" +Bashkit server: persistent process that receives bash commands via JSON-line protocol +and executes them in bashkit's virtual bash interpreter with virtual filesystem. + +Protocol (stdin/stdout, one JSON object per line): + Request: {"id": "...", "command": "echo hello"} + Response: {"id": "...", "stdout": "hello\n", "stderr": "", "exit_code": 0} + Ready: {"ready": true} (sent on startup) +""" + +import json +import sys +import asyncio + +from bashkit import BashTool + + +async def main(): + tool = BashTool( + username="user", + hostname="pi-sandbox", + max_commands=50000, + max_loop_iterations=100000, + ) + + # Signal ready + sys.stdout.write(json.dumps({"ready": True}) + "\n") + sys.stdout.flush() + + loop = asyncio.get_event_loop() + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + await loop.connect_read_pipe(lambda: protocol, sys.stdin) + + while True: + line = await reader.readline() + if not line: + break + + try: + req = json.loads(line.decode("utf-8").strip()) + except json.JSONDecodeError: + continue + + req_id = req.get("id", "") + command = req.get("command", "") + timeout_ms = req.get("timeout_ms") + + if req.get("type") == "reset": + tool.reset() + resp = {"id": req_id, "stdout": "", "stderr": "", "exit_code": 0} + else: + result = await tool.execute(command) + resp = { + "id": req_id, + "stdout": result.stdout, + "stderr": result.stderr, + "exit_code": result.exit_code, + } + if result.error: + resp["error"] = result.error + + sys.stdout.write(json.dumps(resp) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + asyncio.run(main()) From 88f09ba244c2fb1d29dbbd7ce230165a8d3a35f6 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 14 Mar 2026 05:41:40 +0000 Subject: [PATCH 02/11] feat(pi-integration): rewrite with Rust server, add read/write/edit tools Replace Python bashkit_server with Rust pi_server binary. Now all four pi tools (bash, read, write, edit) are backed by bashkit's virtual FS. - pi_server.rs: JSON-line Rust server using bashkit directly - bashkit-extension.ts: replaces all 4 pi tools with VFS-backed versions - Move from pi-integration/ to examples/bashkit-pi/ --- crates/bashkit/examples/pi_server.rs | 146 ++++++++++ examples/bashkit-pi/README.md | 71 +++++ examples/bashkit-pi/bashkit-extension.ts | 345 +++++++++++++++++++++++ pi-integration/README.md | 73 ----- pi-integration/bashkit-extension.ts | 174 ------------ pi-integration/bashkit_server.py | 68 ----- 6 files changed, 562 insertions(+), 315 deletions(-) create mode 100644 crates/bashkit/examples/pi_server.rs create mode 100644 examples/bashkit-pi/README.md create mode 100644 examples/bashkit-pi/bashkit-extension.ts delete mode 100644 pi-integration/README.md delete mode 100644 pi-integration/bashkit-extension.ts delete mode 100644 pi-integration/bashkit_server.py diff --git a/crates/bashkit/examples/pi_server.rs b/crates/bashkit/examples/pi_server.rs new file mode 100644 index 00000000..c3a5507d --- /dev/null +++ b/crates/bashkit/examples/pi_server.rs @@ -0,0 +1,146 @@ +//! JSON-line server bridging pi coding agent to bashkit virtual bash + VFS. +//! +//! Reads JSON requests from stdin, executes via bashkit, writes JSON responses to stdout. +//! All operations (bash, read, write, edit) use bashkit's in-memory virtual filesystem. +//! +//! Build: cargo build --example pi_server --release +//! See: examples/bashkit-pi/README.md + +use bashkit::Bash; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tokio::io::{AsyncBufReadExt, BufReader}; + +#[derive(Debug, Deserialize)] +struct Request { + id: String, + #[serde(flatten)] + op: Operation, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "op")] +enum Operation { + #[serde(rename = "bash")] + Bash { command: String }, + #[serde(rename = "read")] + Read { path: String }, + #[serde(rename = "write")] + Write { path: String, content: String }, + #[serde(rename = "mkdir")] + Mkdir { path: String }, + #[serde(rename = "exists")] + Exists { path: String }, +} + +#[derive(Debug, Serialize)] +struct Response { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + stdout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stderr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + exists: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl Response { + fn ok(id: String) -> Self { + Self { + id, + stdout: None, + stderr: None, + exit_code: None, + content: None, + exists: None, + error: None, + } + } + + fn err(id: String, msg: String) -> Self { + Self { + error: Some(msg), + ..Self::ok(id) + } + } +} + +#[tokio::main] +async fn main() { + let mut bash = Bash::builder().build(); + let fs = bash.fs(); + + // Signal ready + println!( + "{}", + serde_json::to_string(&serde_json::json!({"ready": true})).unwrap() + ); + + let stdin = tokio::io::stdin(); + let reader = BufReader::new(stdin); + let mut lines = reader.lines(); + + while let Ok(Some(line)) = lines.next_line().await { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + + let req: Request = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + let resp = Response::err(String::new(), format!("parse error: {e}")); + println!("{}", serde_json::to_string(&resp).unwrap()); + continue; + } + }; + + let resp = match req.op { + Operation::Bash { command } => match bash.exec(&command).await { + Ok(result) => Response { + stdout: Some(result.stdout), + stderr: Some(result.stderr), + exit_code: Some(result.exit_code), + ..Response::ok(req.id) + }, + Err(e) => Response::err(req.id, format!("{e}")), + }, + Operation::Read { path } => match fs.read_file(Path::new(&path)).await { + Ok(bytes) => Response { + content: Some(String::from_utf8_lossy(&bytes).into_owned()), + ..Response::ok(req.id) + }, + Err(e) => Response::err(req.id, format!("{e}")), + }, + Operation::Write { path, content } => { + // Ensure parent directory exists + if let Some(parent) = Path::new(&path).parent() { + let _ = fs.mkdir(parent, true).await; + } + match fs.write_file(Path::new(&path), content.as_bytes()).await { + Ok(()) => Response::ok(req.id), + Err(e) => Response::err(req.id, format!("{e}")), + } + } + Operation::Mkdir { path } => match fs.mkdir(Path::new(&path), true).await { + Ok(()) => Response::ok(req.id), + Err(e) => Response::err(req.id, format!("{e}")), + }, + Operation::Exists { path } => match fs.exists(Path::new(&path)).await { + Ok(exists) => Response { + exists: Some(exists), + ..Response::ok(req.id) + }, + Err(e) => Response::err(req.id, format!("{e}")), + }, + }; + + println!("{}", serde_json::to_string(&resp).unwrap()); + } +} diff --git a/examples/bashkit-pi/README.md b/examples/bashkit-pi/README.md new file mode 100644 index 00000000..0fb677d7 --- /dev/null +++ b/examples/bashkit-pi/README.md @@ -0,0 +1,71 @@ +# Pi + Bashkit Integration + +Run [pi](https://pi.dev/) (terminal coding agent) with bashkit's virtual bash interpreter and virtual filesystem instead of real shell/filesystem access. + +## What This Does + +Replaces all four of pi's core tools (bash, read, write, edit) with bashkit-backed virtual implementations: + +- **bash** — commands execute in bashkit's sandboxed virtual bash (100+ builtins) +- **read** — reads files from bashkit's in-memory VFS +- **write** — writes files to bashkit's in-memory VFS +- **edit** — edits files in bashkit's in-memory VFS (find-and-replace) + +No real filesystem access. All state persists across tool calls within a session. + +## Setup + +```bash +# 1. Build the server binary +cargo build --example pi_server --release + +# 2. Install pi +npm install -g @mariozechner/pi-coding-agent +``` + +## Run + +```bash +# With OpenAI +pi --provider openai --model gpt-5.4 \ + -e examples/bashkit-pi/bashkit-extension.ts \ + --api-key "$OPENAI_API_KEY" + +# With Anthropic +pi --provider anthropic --model claude-sonnet-4-20250514 \ + -e examples/bashkit-pi/bashkit-extension.ts \ + --api-key "$ANTHROPIC_API_KEY" + +# Non-interactive +pi --provider openai --model gpt-5.4 \ + -e examples/bashkit-pi/bashkit-extension.ts \ + -p "Create a project structure, write some code, and grep for patterns" \ + --no-session +``` + +## Architecture + +``` +pi (LLM agent) + ├── bash tool ──→ pi_server (Rust binary) ──→ bashkit virtual bash + ├── read tool ──→ pi_server ──→ bashkit VFS read + ├── write tool ──→ pi_server ──→ bashkit VFS write + └── edit tool ──→ pi_server ──→ bashkit VFS read+write +``` + +The `pi_server` binary (`crates/bashkit/examples/pi_server.rs`) is a JSON-line protocol server that keeps bashkit state alive for the session. The TypeScript extension talks to it over stdin/stdout. + +## Configuration + +Override the server binary path: + +```bash +BASHKIT_PI_SERVER=/path/to/pi_server pi -e examples/bashkit-pi/bashkit-extension.ts +``` + +## How It Works + +1. Extension starts `pi_server` as a child process on first tool call +2. Each tool call sends a JSON request over stdin: `{"id":"...","op":"bash","command":"echo hi"}` +3. Server executes in bashkit, returns JSON response: `{"id":"...","stdout":"hi\n","exit_code":0}` +4. VFS and shell state persist across all calls — files created by bash are visible to read/write/edit and vice versa diff --git a/examples/bashkit-pi/bashkit-extension.ts b/examples/bashkit-pi/bashkit-extension.ts new file mode 100644 index 00000000..a918dcfa --- /dev/null +++ b/examples/bashkit-pi/bashkit-extension.ts @@ -0,0 +1,345 @@ +/** + * Pi extension: replaces bash, read, write, and edit tools with bashkit virtual implementations. + * + * All operations run against bashkit's in-memory virtual filesystem — no real FS access. + * State (variables, files, cwd) persists across tool calls within a session. + * + * Usage: pi -e examples/bashkit-pi/bashkit-extension.ts + * + * Requires: cargo build --example pi_server --release + */ + +import { spawn, type ChildProcess } from "child_process"; +import { randomBytes } from "crypto"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { existsSync } from "fs"; + +// Resolve server binary path +const __dirname_ext = + typeof __dirname !== "undefined" + ? __dirname + : dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = resolve(__dirname_ext, "../.."); + +// Find the pi_server binary +function findServerBinary(): string { + const candidates = [ + process.env.BASHKIT_PI_SERVER, + resolve(PROJECT_ROOT, "target/release/examples/pi_server"), + resolve(PROJECT_ROOT, "target/debug/examples/pi_server"), + ].filter(Boolean) as string[]; + + for (const path of candidates) { + if (existsSync(path)) return path; + } + throw new Error( + `pi_server binary not found. Run: cargo build --example pi_server --release\nSearched: ${candidates.join(", ")}`, + ); +} + +interface PendingRequest { + resolve: (resp: any) => void; + reject: (err: Error) => void; +} + +let serverProcess: ChildProcess | null = null; +let pendingRequests: Map = new Map(); +let lineBuffer = ""; +let serverReady: Promise; +let resolveReady: () => void; + +function ensureServer(): Promise { + if (serverProcess && !serverProcess.killed) { + return serverReady; + } + + serverReady = new Promise((res) => { + resolveReady = res; + }); + + const binary = findServerBinary(); + serverProcess = spawn(binary, [], { + stdio: ["pipe", "pipe", "pipe"], + }); + + serverProcess.stdout!.on("data", (data: Buffer) => { + lineBuffer += data.toString("utf-8"); + const lines = lineBuffer.split("\n"); + lineBuffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.ready) { + resolveReady(); + continue; + } + const pending = pendingRequests.get(msg.id); + if (pending) { + pendingRequests.delete(msg.id); + pending.resolve(msg); + } + } catch { + // skip malformed + } + } + }); + + serverProcess.stderr!.on("data", (data: Buffer) => { + process.stderr.write(`[bashkit] ${data.toString()}`); + }); + + serverProcess.on("exit", (code) => { + serverProcess = null; + for (const [, pending] of pendingRequests) { + pending.reject(new Error(`bashkit server exited with code ${code}`)); + } + pendingRequests.clear(); + }); + + return serverReady; +} + +function rpcCall(payload: Record): Promise { + const id = randomBytes(8).toString("hex"); + return new Promise((resolve, reject) => { + pendingRequests.set(id, { resolve, reject }); + serverProcess!.stdin!.write(JSON.stringify({ id, ...payload }) + "\n"); + }); +} + +async function execBash( + command: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + await ensureServer(); + const resp = await rpcCall({ op: "bash", command }); + if (resp.error) throw new Error(resp.error); + return { + stdout: resp.stdout || "", + stderr: resp.stderr || "", + exitCode: resp.exit_code ?? 0, + }; +} + +async function vfsRead(path: string): Promise { + await ensureServer(); + const resp = await rpcCall({ op: "read", path }); + if (resp.error) throw new Error(resp.error); + return resp.content ?? ""; +} + +async function vfsWrite(path: string, content: string): Promise { + await ensureServer(); + const resp = await rpcCall({ op: "write", path, content }); + if (resp.error) throw new Error(resp.error); +} + +async function vfsExists(path: string): Promise { + await ensureServer(); + const resp = await rpcCall({ op: "exists", path }); + if (resp.error) throw new Error(resp.error); + return resp.exists ?? false; +} + +async function vfsMkdir(path: string): Promise { + await ensureServer(); + const resp = await rpcCall({ op: "mkdir", path }); + if (resp.error) throw new Error(resp.error); +} + +// Resolve path relative to virtual cwd (always /home/user in bashkit) +function resolvePath(userPath: string): string { + if (userPath.startsWith("/")) return userPath; + return `/home/user/${userPath}`; +} + +export default function (pi: any) { + // --- bash tool (replaces built-in) --- + pi.registerTool({ + name: "bash", + label: "bashkit", + description: + "Execute bash commands in bashkit's virtual sandbox. Full bash interpreter with 100+ builtins (echo, grep, sed, awk, jq, curl, find, etc.) running in-memory. All file operations use a virtual filesystem. State persists across calls.", + parameters: { + type: "object", + properties: { + command: { + type: "string", + description: "Bash command to execute", + }, + timeout: { + type: "number", + description: "Timeout in seconds (optional)", + }, + }, + required: ["command"], + }, + async execute( + _toolCallId: string, + params: { command: string; timeout?: number }, + _signal: AbortSignal, + _onUpdate: (update: any) => void, + ) { + const result = await execBash(params.command); + let output = ""; + if (result.stdout) output += result.stdout; + if (result.stderr) output += result.stderr; + if (!output) output = "(no output)"; + if (result.exitCode !== 0) { + output += `\n\nCommand exited with code ${result.exitCode}`; + throw new Error(output); + } + return { + content: [{ type: "text", text: output }], + details: { engine: "bashkit" }, + }; + }, + }); + + // --- read tool (replaces built-in) --- + pi.registerTool({ + name: "read", + label: "bashkit-read", + description: + "Read file contents from bashkit's virtual filesystem. Returns file content with line numbers.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "File path to read", + }, + offset: { + type: "number", + description: "Line offset to start reading from (1-based)", + }, + limit: { + type: "number", + description: "Maximum number of lines to return", + }, + }, + required: ["path"], + }, + async execute( + _toolCallId: string, + params: { path: string; offset?: number; limit?: number }, + ) { + const absPath = resolvePath(params.path); + const content = await vfsRead(absPath); + let lines = content.split("\n"); + + // Apply offset/limit + const offset = (params.offset ?? 1) - 1; + if (offset > 0) lines = lines.slice(offset); + if (params.limit) lines = lines.slice(0, params.limit); + + // Add line numbers + const numbered = lines + .map((line, i) => `${offset + i + 1}\t${line}`) + .join("\n"); + + return { + content: [{ type: "text", text: numbered || "(empty file)" }], + details: { engine: "bashkit" }, + }; + }, + }); + + // --- write tool (replaces built-in) --- + pi.registerTool({ + name: "write", + label: "bashkit-write", + description: + "Write file contents to bashkit's virtual filesystem. Creates parent directories automatically.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "File path to write", + }, + content: { + type: "string", + description: "Content to write to the file", + }, + }, + required: ["path", "content"], + }, + async execute( + _toolCallId: string, + params: { path: string; content: string }, + ) { + const absPath = resolvePath(params.path); + await vfsWrite(absPath, params.content); + return { + content: [ + { type: "text", text: `Wrote ${params.content.length} bytes to ${absPath}` }, + ], + details: { engine: "bashkit" }, + }; + }, + }); + + // --- edit tool (replaces built-in) --- + pi.registerTool({ + name: "edit", + label: "bashkit-edit", + description: + "Edit a file in bashkit's virtual filesystem by replacing oldText with newText. The oldText must appear exactly once in the file.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "File path to edit", + }, + oldText: { + type: "string", + description: "Exact text to find and replace (must be unique in file)", + }, + newText: { + type: "string", + description: "Replacement text", + }, + }, + required: ["path", "oldText", "newText"], + }, + async execute( + _toolCallId: string, + params: { path: string; oldText: string; newText: string }, + ) { + const absPath = resolvePath(params.path); + const content = await vfsRead(absPath); + + const count = content.split(params.oldText).length - 1; + if (count === 0) { + throw new Error( + `oldText not found in ${absPath}. File content:\n${content}`, + ); + } + if (count > 1) { + throw new Error( + `oldText found ${count} times in ${absPath}. Must be unique.`, + ); + } + + const newContent = content.replace(params.oldText, params.newText); + await vfsWrite(absPath, newContent); + + return { + content: [{ type: "text", text: `Edited ${absPath}` }], + details: { engine: "bashkit" }, + }; + }, + }); + + // Cleanup on exit + pi.on("session_end", async () => { + if (serverProcess && !serverProcess.killed) { + serverProcess.kill(); + serverProcess = null; + } + }); +} diff --git a/pi-integration/README.md b/pi-integration/README.md deleted file mode 100644 index ca076f0e..00000000 --- a/pi-integration/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Pi + Bashkit Integration - -Run [pi](https://pi.dev/) (terminal coding agent) with bashkit's virtual bash interpreter and virtual filesystem instead of real shell access. - -## What This Does - -Replaces pi's built-in `bash` tool with bashkit. When the LLM calls the bash tool, commands execute in bashkit's sandboxed virtual environment: - -- **Virtual filesystem** — all file operations are in-memory, no real FS access -- **100+ builtins** — echo, grep, sed, awk, jq, curl, find, ls, cat, etc. -- **State persistence** — variables, files, and cwd persist across tool calls within a session -- **Resource limits** — bounded command count, loop iterations, function depth - -## Setup - -### Prerequisites - -```bash -# Install pi -npm install -g @mariozechner/pi-coding-agent - -# Install bashkit Python package -pip install bashkit -# Or build from source: -cd crates/bashkit-python && maturin develop --release -``` - -### Run - -```bash -# With OpenAI -pi --provider openai --model gpt-4o \ - -e pi-integration/bashkit-extension.ts \ - --api-key "$OPENAI_API_KEY" - -# With Anthropic -pi --provider anthropic --model claude-sonnet-4-20250514 \ - -e pi-integration/bashkit-extension.ts \ - --api-key "$ANTHROPIC_API_KEY" - -# Non-interactive (print mode) -pi --provider openai --model gpt-4o-mini \ - -e pi-integration/bashkit-extension.ts \ - -p "Create a directory structure and write some files" \ - --no-session -``` - -## Architecture - -``` -pi (LLM agent) → bash tool call → bashkit-extension.ts → bashkit_server.py → bashkit (Rust via PyO3) -``` - -1. **bashkit-extension.ts** — Pi extension that registers a `bash` tool, replacing the built-in -2. **bashkit_server.py** — Persistent Python process running bashkit, communicates via JSON-line protocol over stdin/stdout -3. **bashkit** — Rust virtual bash interpreter with in-memory VFS - -The server process stays alive for the session, maintaining VFS and shell state across tool calls. - -## Configuration - -Set `BASHKIT_PYTHON` env var to override the Python path: - -```bash -BASHKIT_PYTHON=/path/to/venv/bin/python3 pi -e pi-integration/bashkit-extension.ts -``` - -## Limitations - -- No real filesystem access (by design — that's the point) -- No background processes or job control -- Network access (curl/wget) requires allowlist configuration in bashkit -- pi's `read`, `write`, `edit` tools still use real FS — only `bash` is virtualized diff --git a/pi-integration/bashkit-extension.ts b/pi-integration/bashkit-extension.ts deleted file mode 100644 index 24875e5e..00000000 --- a/pi-integration/bashkit-extension.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Pi extension: replaces the built-in bash tool with bashkit's virtual bash interpreter. - * - * All commands run in bashkit's sandboxed virtual filesystem — no real filesystem access. - * State (variables, files, cwd) persists across tool calls within a session. - * - * Install: pi -e /path/to/bashkit/pi-integration/bashkit-extension.ts - */ - -import { spawn, type ChildProcess } from "child_process"; -import { randomBytes } from "crypto"; -import { resolve, dirname } from "path"; -import { fileURLToPath } from "url"; - -// Resolve server script path relative to this extension -const __dirname_ext = - typeof __dirname !== "undefined" - ? __dirname - : dirname(fileURLToPath(import.meta.url)); -const SERVER_SCRIPT = resolve(__dirname_ext, "bashkit_server.py"); - -// Find python in venv or PATH -const PYTHON = - process.env.BASHKIT_PYTHON || "/home/user/.venv/bin/python3" || "python3"; - -interface PendingRequest { - resolve: (resp: any) => void; - reject: (err: Error) => void; -} - -let serverProcess: ChildProcess | null = null; -let pendingRequests: Map = new Map(); -let lineBuffer = ""; -let serverReady: Promise; -let resolveReady: () => void; - -function ensureServer(): Promise { - if (serverProcess && !serverProcess.killed) { - return serverReady; - } - - serverReady = new Promise((resolve) => { - resolveReady = resolve; - }); - - serverProcess = spawn(PYTHON, [SERVER_SCRIPT], { - stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, PYTHONUNBUFFERED: "1" }, - }); - - serverProcess.stdout!.on("data", (data: Buffer) => { - lineBuffer += data.toString("utf-8"); - const lines = lineBuffer.split("\n"); - lineBuffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.trim()) continue; - try { - const msg = JSON.parse(line); - if (msg.ready) { - resolveReady(); - continue; - } - const pending = pendingRequests.get(msg.id); - if (pending) { - pendingRequests.delete(msg.id); - pending.resolve(msg); - } - } catch { - // skip malformed lines - } - } - }); - - serverProcess.stderr!.on("data", (data: Buffer) => { - process.stderr.write(`[bashkit-server] ${data.toString()}`); - }); - - serverProcess.on("exit", (code) => { - serverProcess = null; - // Reject all pending requests - for (const [id, pending] of pendingRequests) { - pending.reject(new Error(`bashkit server exited with code ${code}`)); - } - pendingRequests.clear(); - }); - - return serverReady; -} - -async function execInBashkit( - command: string, - timeoutMs?: number, -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - await ensureServer(); - - const id = randomBytes(8).toString("hex"); - const req = { id, command, timeout_ms: timeoutMs }; - - return new Promise((resolve, reject) => { - pendingRequests.set(id, { - resolve: (resp) => - resolve({ - stdout: resp.stdout || "", - stderr: resp.stderr || "", - exitCode: resp.exit_code ?? 0, - }), - reject, - }); - - serverProcess!.stdin!.write(JSON.stringify(req) + "\n"); - }); -} - -export default function (pi: any) { - // Register our bashkit-powered bash tool, replacing the built-in - pi.registerTool({ - name: "bash", - label: "bashkit", - description: `Execute bash commands in bashkit's virtual sandbox. Provides a full bash interpreter with 100+ builtins (echo, grep, sed, awk, jq, curl, find, etc.) running entirely in-memory. All file operations use a virtual filesystem — no real filesystem access. State persists across calls.`, - parameters: { - type: "object", - properties: { - command: { - type: "string", - description: "Bash command to execute", - }, - timeout: { - type: "number", - description: "Timeout in seconds (optional)", - }, - }, - required: ["command"], - }, - async execute( - toolCallId: string, - params: { command: string; timeout?: number }, - signal: AbortSignal, - onUpdate: (update: any) => void, - ) { - const { command, timeout } = params; - const timeoutMs = timeout ? timeout * 1000 : undefined; - - try { - const result = await execInBashkit(command, timeoutMs); - - let output = ""; - if (result.stdout) output += result.stdout; - if (result.stderr) output += result.stderr; - if (!output) output = "(no output)"; - - if (result.exitCode !== 0) { - output += `\n\nCommand exited with code ${result.exitCode}`; - throw new Error(output); - } - - return { - content: [{ type: "text", text: output }], - details: { virtual: true, engine: "bashkit" }, - }; - } catch (err: any) { - throw err; - } - }, - }); - - // Cleanup on session end - pi.on("session_end", async () => { - if (serverProcess && !serverProcess.killed) { - serverProcess.kill(); - serverProcess = null; - } - }); -} diff --git a/pi-integration/bashkit_server.py b/pi-integration/bashkit_server.py deleted file mode 100644 index e60e8f8e..00000000 --- a/pi-integration/bashkit_server.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -Bashkit server: persistent process that receives bash commands via JSON-line protocol -and executes them in bashkit's virtual bash interpreter with virtual filesystem. - -Protocol (stdin/stdout, one JSON object per line): - Request: {"id": "...", "command": "echo hello"} - Response: {"id": "...", "stdout": "hello\n", "stderr": "", "exit_code": 0} - Ready: {"ready": true} (sent on startup) -""" - -import json -import sys -import asyncio - -from bashkit import BashTool - - -async def main(): - tool = BashTool( - username="user", - hostname="pi-sandbox", - max_commands=50000, - max_loop_iterations=100000, - ) - - # Signal ready - sys.stdout.write(json.dumps({"ready": True}) + "\n") - sys.stdout.flush() - - loop = asyncio.get_event_loop() - reader = asyncio.StreamReader() - protocol = asyncio.StreamReaderProtocol(reader) - await loop.connect_read_pipe(lambda: protocol, sys.stdin) - - while True: - line = await reader.readline() - if not line: - break - - try: - req = json.loads(line.decode("utf-8").strip()) - except json.JSONDecodeError: - continue - - req_id = req.get("id", "") - command = req.get("command", "") - timeout_ms = req.get("timeout_ms") - - if req.get("type") == "reset": - tool.reset() - resp = {"id": req_id, "stdout": "", "stderr": "", "exit_code": 0} - else: - result = await tool.execute(command) - resp = { - "id": req_id, - "stdout": result.stdout, - "stderr": result.stderr, - "exit_code": result.exit_code, - } - if result.error: - resp["error"] = result.error - - sys.stdout.write(json.dumps(resp) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - asyncio.run(main()) From 1d1bf7eba4ab4a9112c4e0b97526bffa9cea05d8 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 14 Mar 2026 06:01:20 +0000 Subject: [PATCH 03/11] refactor(pi-integration): use NAPI-RS bindings, drop Rust server subprocess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace pi_server.rs subprocess with direct @everruns/bashkit NAPI-RS bindings loaded in-process. No Python, no subprocess — bashkit runs natively in pi's Node.js process. All 4 pi tools (bash, read, write, edit) backed by a single shared Bash instance and virtual filesystem. --- crates/bashkit/examples/pi_server.rs | 146 -------------- examples/bashkit-pi/.gitignore | 1 + examples/bashkit-pi/README.md | 37 ++-- examples/bashkit-pi/bashkit-extension.ts | 240 +++++++---------------- examples/bashkit-pi/package-lock.json | 36 ++++ examples/bashkit-pi/package.json | 10 + 6 files changed, 137 insertions(+), 333 deletions(-) delete mode 100644 crates/bashkit/examples/pi_server.rs create mode 100644 examples/bashkit-pi/.gitignore create mode 100644 examples/bashkit-pi/package-lock.json create mode 100644 examples/bashkit-pi/package.json diff --git a/crates/bashkit/examples/pi_server.rs b/crates/bashkit/examples/pi_server.rs deleted file mode 100644 index c3a5507d..00000000 --- a/crates/bashkit/examples/pi_server.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! JSON-line server bridging pi coding agent to bashkit virtual bash + VFS. -//! -//! Reads JSON requests from stdin, executes via bashkit, writes JSON responses to stdout. -//! All operations (bash, read, write, edit) use bashkit's in-memory virtual filesystem. -//! -//! Build: cargo build --example pi_server --release -//! See: examples/bashkit-pi/README.md - -use bashkit::Bash; -use serde::{Deserialize, Serialize}; -use std::path::Path; -use tokio::io::{AsyncBufReadExt, BufReader}; - -#[derive(Debug, Deserialize)] -struct Request { - id: String, - #[serde(flatten)] - op: Operation, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "op")] -enum Operation { - #[serde(rename = "bash")] - Bash { command: String }, - #[serde(rename = "read")] - Read { path: String }, - #[serde(rename = "write")] - Write { path: String, content: String }, - #[serde(rename = "mkdir")] - Mkdir { path: String }, - #[serde(rename = "exists")] - Exists { path: String }, -} - -#[derive(Debug, Serialize)] -struct Response { - id: String, - #[serde(skip_serializing_if = "Option::is_none")] - stdout: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stderr: Option, - #[serde(skip_serializing_if = "Option::is_none")] - exit_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - content: Option, - #[serde(skip_serializing_if = "Option::is_none")] - exists: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -impl Response { - fn ok(id: String) -> Self { - Self { - id, - stdout: None, - stderr: None, - exit_code: None, - content: None, - exists: None, - error: None, - } - } - - fn err(id: String, msg: String) -> Self { - Self { - error: Some(msg), - ..Self::ok(id) - } - } -} - -#[tokio::main] -async fn main() { - let mut bash = Bash::builder().build(); - let fs = bash.fs(); - - // Signal ready - println!( - "{}", - serde_json::to_string(&serde_json::json!({"ready": true})).unwrap() - ); - - let stdin = tokio::io::stdin(); - let reader = BufReader::new(stdin); - let mut lines = reader.lines(); - - while let Ok(Some(line)) = lines.next_line().await { - let line = line.trim().to_string(); - if line.is_empty() { - continue; - } - - let req: Request = match serde_json::from_str(&line) { - Ok(r) => r, - Err(e) => { - let resp = Response::err(String::new(), format!("parse error: {e}")); - println!("{}", serde_json::to_string(&resp).unwrap()); - continue; - } - }; - - let resp = match req.op { - Operation::Bash { command } => match bash.exec(&command).await { - Ok(result) => Response { - stdout: Some(result.stdout), - stderr: Some(result.stderr), - exit_code: Some(result.exit_code), - ..Response::ok(req.id) - }, - Err(e) => Response::err(req.id, format!("{e}")), - }, - Operation::Read { path } => match fs.read_file(Path::new(&path)).await { - Ok(bytes) => Response { - content: Some(String::from_utf8_lossy(&bytes).into_owned()), - ..Response::ok(req.id) - }, - Err(e) => Response::err(req.id, format!("{e}")), - }, - Operation::Write { path, content } => { - // Ensure parent directory exists - if let Some(parent) = Path::new(&path).parent() { - let _ = fs.mkdir(parent, true).await; - } - match fs.write_file(Path::new(&path), content.as_bytes()).await { - Ok(()) => Response::ok(req.id), - Err(e) => Response::err(req.id, format!("{e}")), - } - } - Operation::Mkdir { path } => match fs.mkdir(Path::new(&path), true).await { - Ok(()) => Response::ok(req.id), - Err(e) => Response::err(req.id, format!("{e}")), - }, - Operation::Exists { path } => match fs.exists(Path::new(&path)).await { - Ok(exists) => Response { - exists: Some(exists), - ..Response::ok(req.id) - }, - Err(e) => Response::err(req.id, format!("{e}")), - }, - }; - - println!("{}", serde_json::to_string(&resp).unwrap()); - } -} diff --git a/examples/bashkit-pi/.gitignore b/examples/bashkit-pi/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/examples/bashkit-pi/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/examples/bashkit-pi/README.md b/examples/bashkit-pi/README.md index 0fb677d7..317ae53b 100644 --- a/examples/bashkit-pi/README.md +++ b/examples/bashkit-pi/README.md @@ -11,15 +11,18 @@ Replaces all four of pi's core tools (bash, read, write, edit) with bashkit-back - **write** — writes files to bashkit's in-memory VFS - **edit** — edits files in bashkit's in-memory VFS (find-and-replace) -No real filesystem access. All state persists across tool calls within a session. +No real filesystem access. No subprocess. Uses `@everruns/bashkit` Node.js native bindings (NAPI-RS) loaded directly in pi's process. ## Setup ```bash -# 1. Build the server binary -cargo build --example pi_server --release +# 1. Build the Node.js bindings +cd crates/bashkit-js && npm install && npm run build && cd - -# 2. Install pi +# 2. Install this example's dependencies +cd examples/bashkit-pi && npm install && cd - + +# 3. Install pi npm install -g @mariozechner/pi-coding-agent ``` @@ -47,25 +50,17 @@ pi --provider openai --model gpt-5.4 \ ``` pi (LLM agent) - ├── bash tool ──→ pi_server (Rust binary) ──→ bashkit virtual bash - ├── read tool ──→ pi_server ──→ bashkit VFS read - ├── write tool ──→ pi_server ──→ bashkit VFS write - └── edit tool ──→ pi_server ──→ bashkit VFS read+write + ├── bash tool ──→ @everruns/bashkit (NAPI) ──→ bashkit virtual bash + ├── read tool ──→ bashkit VFS via bash cat + ├── write tool ──→ bashkit VFS via bash heredoc + └── edit tool ──→ bashkit VFS read + modify + write ``` -The `pi_server` binary (`crates/bashkit/examples/pi_server.rs`) is a JSON-line protocol server that keeps bashkit state alive for the session. The TypeScript extension talks to it over stdin/stdout. - -## Configuration - -Override the server binary path: - -```bash -BASHKIT_PI_SERVER=/path/to/pi_server pi -e examples/bashkit-pi/bashkit-extension.ts -``` +Single `Bash` instance shared across all tools. No subprocess — bashkit runs in-process via native Node.js bindings. ## How It Works -1. Extension starts `pi_server` as a child process on first tool call -2. Each tool call sends a JSON request over stdin: `{"id":"...","op":"bash","command":"echo hi"}` -3. Server executes in bashkit, returns JSON response: `{"id":"...","stdout":"hi\n","exit_code":0}` -4. VFS and shell state persist across all calls — files created by bash are visible to read/write/edit and vice versa +1. Extension creates a single `Bash` instance on load +2. All four tools (bash, read, write, edit) operate on the same virtual filesystem +3. Files created by `write` are visible to `bash`, `read`, `edit` — and vice versa +4. Shell state (variables, cwd, functions) persists across `bash` calls diff --git a/examples/bashkit-pi/bashkit-extension.ts b/examples/bashkit-pi/bashkit-extension.ts index a918dcfa..2a51a0c6 100644 --- a/examples/bashkit-pi/bashkit-extension.ts +++ b/examples/bashkit-pi/bashkit-extension.ts @@ -1,162 +1,85 @@ /** * Pi extension: replaces bash, read, write, and edit tools with bashkit virtual implementations. * - * All operations run against bashkit's in-memory virtual filesystem — no real FS access. + * Uses @everruns/bashkit Node.js bindings (NAPI-RS) — no subprocess, no Python. + * All operations run against bashkit's in-memory virtual filesystem. * State (variables, files, cwd) persists across tool calls within a session. * - * Usage: pi -e examples/bashkit-pi/bashkit-extension.ts - * - * Requires: cargo build --example pi_server --release + * Usage: + * cd examples/bashkit-pi && npm install + * pi -e examples/bashkit-pi/bashkit-extension.ts */ -import { spawn, type ChildProcess } from "child_process"; -import { randomBytes } from "crypto"; -import { resolve, dirname } from "path"; -import { fileURLToPath } from "url"; -import { existsSync } from "fs"; +import { createRequire } from "node:module"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; -// Resolve server binary path const __dirname_ext = typeof __dirname !== "undefined" ? __dirname : dirname(fileURLToPath(import.meta.url)); -const PROJECT_ROOT = resolve(__dirname_ext, "../.."); - -// Find the pi_server binary -function findServerBinary(): string { - const candidates = [ - process.env.BASHKIT_PI_SERVER, - resolve(PROJECT_ROOT, "target/release/examples/pi_server"), - resolve(PROJECT_ROOT, "target/debug/examples/pi_server"), - ].filter(Boolean) as string[]; - - for (const path of candidates) { - if (existsSync(path)) return path; - } - throw new Error( - `pi_server binary not found. Run: cargo build --example pi_server --release\nSearched: ${candidates.join(", ")}`, - ); -} - -interface PendingRequest { - resolve: (resp: any) => void; - reject: (err: Error) => void; -} - -let serverProcess: ChildProcess | null = null; -let pendingRequests: Map = new Map(); -let lineBuffer = ""; -let serverReady: Promise; -let resolveReady: () => void; - -function ensureServer(): Promise { - if (serverProcess && !serverProcess.killed) { - return serverReady; - } - - serverReady = new Promise((res) => { - resolveReady = res; - }); - - const binary = findServerBinary(); - serverProcess = spawn(binary, [], { - stdio: ["pipe", "pipe", "pipe"], - }); - - serverProcess.stdout!.on("data", (data: Buffer) => { - lineBuffer += data.toString("utf-8"); - const lines = lineBuffer.split("\n"); - lineBuffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.trim()) continue; - try { - const msg = JSON.parse(line); - if (msg.ready) { - resolveReady(); - continue; - } - const pending = pendingRequests.get(msg.id); - if (pending) { - pendingRequests.delete(msg.id); - pending.resolve(msg); - } - } catch { - // skip malformed - } - } - }); - - serverProcess.stderr!.on("data", (data: Buffer) => { - process.stderr.write(`[bashkit] ${data.toString()}`); - }); - - serverProcess.on("exit", (code) => { - serverProcess = null; - for (const [, pending] of pendingRequests) { - pending.reject(new Error(`bashkit server exited with code ${code}`)); - } - pendingRequests.clear(); - }); - return serverReady; -} +// Load bashkit native bindings from the bashkit-js crate (or node_modules) +const require_ext = createRequire(resolve(__dirname_ext, "node_modules") + "/"); +const { Bash } = require_ext("@everruns/bashkit"); -function rpcCall(payload: Record): Promise { - const id = randomBytes(8).toString("hex"); - return new Promise((resolve, reject) => { - pendingRequests.set(id, { resolve, reject }); - serverProcess!.stdin!.write(JSON.stringify({ id, ...payload }) + "\n"); - }); -} +// Single bashkit instance — state persists across all tool calls +const bash = new Bash({ username: "user", hostname: "pi-sandbox" }); -async function execBash( - command: string, -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - await ensureServer(); - const resp = await rpcCall({ op: "bash", command }); - if (resp.error) throw new Error(resp.error); +// Helper: execute bash and return stdout/stderr +function execBash(command: string): { + stdout: string; + stderr: string; + exitCode: number; +} { + const result = bash.executeSync(command); return { - stdout: resp.stdout || "", - stderr: resp.stderr || "", - exitCode: resp.exit_code ?? 0, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + exitCode: result.exitCode ?? 0, }; } -async function vfsRead(path: string): Promise { - await ensureServer(); - const resp = await rpcCall({ op: "read", path }); - if (resp.error) throw new Error(resp.error); - return resp.content ?? ""; -} - -async function vfsWrite(path: string, content: string): Promise { - await ensureServer(); - const resp = await rpcCall({ op: "write", path, content }); - if (resp.error) throw new Error(resp.error); +// Helper: read file via bash cat (uses the shared VFS) +function vfsRead(path: string): string { + const result = bash.executeSync(`cat '${path.replace(/'/g, "'\\''")}'`); + if (result.exitCode !== 0) { + throw new Error(result.stderr || `Failed to read ${path}`); + } + return result.stdout ?? ""; } -async function vfsExists(path: string): Promise { - await ensureServer(); - const resp = await rpcCall({ op: "exists", path }); - if (resp.error) throw new Error(resp.error); - return resp.exists ?? false; +// Helper: write file via bash (uses the shared VFS) +function vfsWrite(path: string, content: string): void { + // Ensure parent dir exists + const dir = path.replace(/\/[^/]*$/, ""); + if (dir && dir !== path) { + bash.executeSync(`mkdir -p '${dir.replace(/'/g, "'\\''")}'`); + } + // Use heredoc to write content safely + const marker = `__BASHKIT_EOF_${Date.now()}__`; + const result = bash.executeSync(`cat > '${path.replace(/'/g, "'\\''")}' <<'${marker}'\n${content}\n${marker}`); + if (result.exitCode !== 0) { + throw new Error(result.stderr || `Failed to write ${path}`); + } } -async function vfsMkdir(path: string): Promise { - await ensureServer(); - const resp = await rpcCall({ op: "mkdir", path }); - if (resp.error) throw new Error(resp.error); +// Helper: check if file exists +function vfsExists(path: string): boolean { + const result = bash.executeSync( + `test -e '${path.replace(/'/g, "'\\''")}'`, + ); + return result.exitCode === 0; } -// Resolve path relative to virtual cwd (always /home/user in bashkit) +// Resolve relative paths against bashkit home function resolvePath(userPath: string): string { if (userPath.startsWith("/")) return userPath; return `/home/user/${userPath}`; } export default function (pi: any) { - // --- bash tool (replaces built-in) --- + // --- bash tool --- pi.registerTool({ name: "bash", label: "bashkit", @@ -179,10 +102,8 @@ export default function (pi: any) { async execute( _toolCallId: string, params: { command: string; timeout?: number }, - _signal: AbortSignal, - _onUpdate: (update: any) => void, ) { - const result = await execBash(params.command); + const result = execBash(params.command); let output = ""; if (result.stdout) output += result.stdout; if (result.stderr) output += result.stderr; @@ -198,7 +119,7 @@ export default function (pi: any) { }, }); - // --- read tool (replaces built-in) --- + // --- read tool --- pi.registerTool({ name: "read", label: "bashkit-read", @@ -207,10 +128,7 @@ export default function (pi: any) { parameters: { type: "object", properties: { - path: { - type: "string", - description: "File path to read", - }, + path: { type: "string", description: "File path to read" }, offset: { type: "number", description: "Line offset to start reading from (1-based)", @@ -227,15 +145,18 @@ export default function (pi: any) { params: { path: string; offset?: number; limit?: number }, ) { const absPath = resolvePath(params.path); - const content = await vfsRead(absPath); + const content = vfsRead(absPath); let lines = content.split("\n"); - // Apply offset/limit + // Remove trailing empty line from cat output + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + const offset = (params.offset ?? 1) - 1; if (offset > 0) lines = lines.slice(offset); if (params.limit) lines = lines.slice(0, params.limit); - // Add line numbers const numbered = lines .map((line, i) => `${offset + i + 1}\t${line}`) .join("\n"); @@ -247,7 +168,7 @@ export default function (pi: any) { }, }); - // --- write tool (replaces built-in) --- + // --- write tool --- pi.registerTool({ name: "write", label: "bashkit-write", @@ -256,10 +177,7 @@ export default function (pi: any) { parameters: { type: "object", properties: { - path: { - type: "string", - description: "File path to write", - }, + path: { type: "string", description: "File path to write" }, content: { type: "string", description: "Content to write to the file", @@ -272,17 +190,20 @@ export default function (pi: any) { params: { path: string; content: string }, ) { const absPath = resolvePath(params.path); - await vfsWrite(absPath, params.content); + vfsWrite(absPath, params.content); return { content: [ - { type: "text", text: `Wrote ${params.content.length} bytes to ${absPath}` }, + { + type: "text", + text: `Wrote ${params.content.length} bytes to ${absPath}`, + }, ], details: { engine: "bashkit" }, }; }, }); - // --- edit tool (replaces built-in) --- + // --- edit tool --- pi.registerTool({ name: "edit", label: "bashkit-edit", @@ -291,18 +212,13 @@ export default function (pi: any) { parameters: { type: "object", properties: { - path: { - type: "string", - description: "File path to edit", - }, + path: { type: "string", description: "File path to edit" }, oldText: { type: "string", - description: "Exact text to find and replace (must be unique in file)", - }, - newText: { - type: "string", - description: "Replacement text", + description: + "Exact text to find and replace (must be unique in file)", }, + newText: { type: "string", description: "Replacement text" }, }, required: ["path", "oldText", "newText"], }, @@ -311,7 +227,7 @@ export default function (pi: any) { params: { path: string; oldText: string; newText: string }, ) { const absPath = resolvePath(params.path); - const content = await vfsRead(absPath); + const content = vfsRead(absPath); const count = content.split(params.oldText).length - 1; if (count === 0) { @@ -326,7 +242,7 @@ export default function (pi: any) { } const newContent = content.replace(params.oldText, params.newText); - await vfsWrite(absPath, newContent); + vfsWrite(absPath, newContent); return { content: [{ type: "text", text: `Edited ${absPath}` }], @@ -334,12 +250,4 @@ export default function (pi: any) { }; }, }); - - // Cleanup on exit - pi.on("session_end", async () => { - if (serverProcess && !serverProcess.killed) { - serverProcess.kill(); - serverProcess = null; - } - }); } diff --git a/examples/bashkit-pi/package-lock.json b/examples/bashkit-pi/package-lock.json new file mode 100644 index 00000000..589bc827 --- /dev/null +++ b/examples/bashkit-pi/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "bashkit-pi-example", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bashkit-pi-example", + "version": "0.0.1", + "dependencies": { + "@everruns/bashkit": "file:../../crates/bashkit-js" + } + }, + "../../crates/bashkit-js": { + "name": "@everruns/bashkit", + "version": "0.1.9", + "license": "MIT", + "devDependencies": { + "@napi-rs/cli": "^3.0.0", + "@types/node": "^25.5.0", + "ava": "^6.2.0", + "oxlint": "^0.16.0", + "prettier": "^3.4.0", + "tsx": "^4.21.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@everruns/bashkit": { + "resolved": "../../crates/bashkit-js", + "link": true + } + } +} diff --git a/examples/bashkit-pi/package.json b/examples/bashkit-pi/package.json new file mode 100644 index 00000000..f1e1944d --- /dev/null +++ b/examples/bashkit-pi/package.json @@ -0,0 +1,10 @@ +{ + "name": "bashkit-pi-example", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "Pi coding agent with bashkit virtual bash + VFS", + "dependencies": { + "@everruns/bashkit": "file:../../crates/bashkit-js" + } +} From 7b72763eecc49bd32c67d8db0599a3965e521e91 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 14 Mar 2026 17:59:14 +0000 Subject: [PATCH 04/11] feat(bashkit-js): expose VFS APIs via NAPI (readFile, writeFile, mkdir, exists, remove) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add direct filesystem methods to the Bash NAPI class so consumers can read/write/mkdir/exists/remove without routing through bash commands. Pi extension updated to use direct VFS for read/write/edit tools — eliminates shell quoting and heredoc fragility. --- crates/bashkit-js/src/lib.rs | 76 ++++++++++++++++++++++- crates/bashkit-js/wrapper.ts | 41 ++++++------- examples/bashkit-pi/README.md | 10 +-- examples/bashkit-pi/bashkit-extension.ts | 77 +++++++----------------- 4 files changed, 122 insertions(+), 82 deletions(-) diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index cf4f8ab2..2c1f0356 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -7,9 +7,10 @@ //! and `ExecResult` via napi-rs for use from JavaScript/TypeScript. use bashkit::tool::VERSION; -use bashkit::{Bash as RustBash, BashTool as RustBashTool, ExecutionLimits, Tool}; +use bashkit::{Bash as RustBash, BashTool as RustBashTool, ExecutionLimits, FileSystem, Tool}; use napi_derive::napi; use std::collections::HashMap; +use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::Mutex; @@ -186,6 +187,79 @@ impl Bash { Ok(()) }) } + + // ======================================================================== + // VFS — direct filesystem access + // ======================================================================== + + /// Read a file from the virtual filesystem. Returns contents as a UTF-8 string. + #[napi] + pub fn read_file(&self, path: String) -> napi::Result { + let inner = self.inner.clone(); + self.rt.block_on(async move { + let bash = inner.lock().await; + let bytes = bash + .fs() + .read_file(Path::new(&path)) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + String::from_utf8(bytes) + .map_err(|e| napi::Error::from_reason(format!("Invalid UTF-8: {e}"))) + }) + } + + /// Write a string to a file in the virtual filesystem. + /// Creates the file if it doesn't exist, replaces contents if it does. + #[napi] + pub fn write_file(&self, path: String, content: String) -> napi::Result<()> { + let inner = self.inner.clone(); + self.rt.block_on(async move { + let bash = inner.lock().await; + bash.fs() + .write_file(Path::new(&path), content.as_bytes()) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } + + /// Create a directory. If recursive is true, creates parent directories as needed. + #[napi] + pub fn mkdir(&self, path: String, recursive: Option) -> napi::Result<()> { + let inner = self.inner.clone(); + self.rt.block_on(async move { + let bash = inner.lock().await; + bash.fs() + .mkdir(Path::new(&path), recursive.unwrap_or(false)) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } + + /// Check if a path exists in the virtual filesystem. + #[napi] + pub fn exists(&self, path: String) -> napi::Result { + let inner = self.inner.clone(); + self.rt.block_on(async move { + let bash = inner.lock().await; + bash.fs() + .exists(Path::new(&path)) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } + + /// Remove a file or directory. If recursive is true, removes directory contents. + #[napi] + pub fn remove(&self, path: String, recursive: Option) -> napi::Result<()> { + let inner = self.inner.clone(); + self.rt.block_on(async move { + let bash = inner.lock().await; + bash.fs() + .remove(Path::new(&path), recursive.unwrap_or(false)) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } } // ============================================================================ diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index 1dee3401..e88e5722 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -261,32 +261,31 @@ export class Bash { this.native.reset(); } - // ========================================================================== - // VFS file helpers - // ========================================================================== - - /** - * Check whether a path exists in the virtual filesystem. - */ - exists(path: string): boolean { - return this.executeSync(`test -e '${path.replace(/'/g, "'\\''")}'`).exitCode === 0; - } + // VFS — direct filesystem access - /** - * Read file contents from the virtual filesystem. - * Throws `BashError` if the file does not exist. - */ + /** Read a file from the virtual filesystem as a UTF-8 string. */ readFile(path: string): string { - const result = this.executeSyncOrThrow(`cat '${path.replace(/'/g, "'\\''")}'`); - return result.stdout; + return this.native.readFile(path); } - /** - * Write content to a file in the virtual filesystem. - * Creates parent directories as needed. - */ + /** Write a string to a file in the virtual filesystem. */ writeFile(path: string, content: string): void { - this.executeSyncOrThrow(buildWriteCmd(path, content)); + this.native.writeFile(path, content); + } + + /** Create a directory. If recursive is true, creates parents as needed. */ + mkdir(path: string, recursive?: boolean): void { + this.native.mkdir(path, recursive); + } + + /** Check if a path exists in the virtual filesystem. */ + exists(path: string): boolean { + return this.native.exists(path); + } + + /** Remove a file or directory. If recursive is true, removes contents. */ + remove(path: string, recursive?: boolean): void { + this.native.remove(path, recursive); } /** diff --git a/examples/bashkit-pi/README.md b/examples/bashkit-pi/README.md index 317ae53b..4c145feb 100644 --- a/examples/bashkit-pi/README.md +++ b/examples/bashkit-pi/README.md @@ -50,13 +50,13 @@ pi --provider openai --model gpt-5.4 \ ``` pi (LLM agent) - ├── bash tool ──→ @everruns/bashkit (NAPI) ──→ bashkit virtual bash - ├── read tool ──→ bashkit VFS via bash cat - ├── write tool ──→ bashkit VFS via bash heredoc - └── edit tool ──→ bashkit VFS read + modify + write + ├── bash tool ──→ Bash.executeSync() ──→ bashkit virtual bash + ├── read tool ──→ Bash.readFile() ──→ bashkit VFS (direct) + ├── write tool ──→ Bash.writeFile() ──→ bashkit VFS (direct) + └── edit tool ──→ Bash.readFile() + writeFile() ──→ bashkit VFS (direct) ``` -Single `Bash` instance shared across all tools. No subprocess — bashkit runs in-process via native Node.js bindings. +Single `Bash` instance shared across all tools. read/write/edit use direct VFS APIs (no shell quoting). bash tool uses `executeSync()`. Both share the same VFS — files created by any tool are visible to all others. ## How It Works diff --git a/examples/bashkit-pi/bashkit-extension.ts b/examples/bashkit-pi/bashkit-extension.ts index 2a51a0c6..6716a1cc 100644 --- a/examples/bashkit-pi/bashkit-extension.ts +++ b/examples/bashkit-pi/bashkit-extension.ts @@ -5,6 +5,10 @@ * All operations run against bashkit's in-memory virtual filesystem. * State (variables, files, cwd) persists across tool calls within a session. * + * read/write/edit use direct VFS APIs (readFile, writeFile, mkdir, exists). + * bash tool uses executeSync for shell commands. + * Both share the same Bash instance so VFS and shell state are always in sync. + * * Usage: * cd examples/bashkit-pi && npm install * pi -e examples/bashkit-pi/bashkit-extension.ts @@ -26,58 +30,20 @@ const { Bash } = require_ext("@everruns/bashkit"); // Single bashkit instance — state persists across all tool calls const bash = new Bash({ username: "user", hostname: "pi-sandbox" }); -// Helper: execute bash and return stdout/stderr -function execBash(command: string): { - stdout: string; - stderr: string; - exitCode: number; -} { - const result = bash.executeSync(command); - return { - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - exitCode: result.exitCode ?? 0, - }; -} - -// Helper: read file via bash cat (uses the shared VFS) -function vfsRead(path: string): string { - const result = bash.executeSync(`cat '${path.replace(/'/g, "'\\''")}'`); - if (result.exitCode !== 0) { - throw new Error(result.stderr || `Failed to read ${path}`); - } - return result.stdout ?? ""; -} - -// Helper: write file via bash (uses the shared VFS) -function vfsWrite(path: string, content: string): void { - // Ensure parent dir exists - const dir = path.replace(/\/[^/]*$/, ""); - if (dir && dir !== path) { - bash.executeSync(`mkdir -p '${dir.replace(/'/g, "'\\''")}'`); - } - // Use heredoc to write content safely - const marker = `__BASHKIT_EOF_${Date.now()}__`; - const result = bash.executeSync(`cat > '${path.replace(/'/g, "'\\''")}' <<'${marker}'\n${content}\n${marker}`); - if (result.exitCode !== 0) { - throw new Error(result.stderr || `Failed to write ${path}`); - } -} - -// Helper: check if file exists -function vfsExists(path: string): boolean { - const result = bash.executeSync( - `test -e '${path.replace(/'/g, "'\\''")}'`, - ); - return result.exitCode === 0; -} - // Resolve relative paths against bashkit home function resolvePath(userPath: string): string { if (userPath.startsWith("/")) return userPath; return `/home/user/${userPath}`; } +// Ensure parent directory exists for a file path +function ensureParentDir(filePath: string): void { + const dir = filePath.replace(/\/[^/]*$/, ""); + if (dir && dir !== filePath && !bash.exists(dir)) { + bash.mkdir(dir, true); + } +} + export default function (pi: any) { // --- bash tool --- pi.registerTool({ @@ -103,7 +69,7 @@ export default function (pi: any) { _toolCallId: string, params: { command: string; timeout?: number }, ) { - const result = execBash(params.command); + const result = bash.executeSync(params.command); let output = ""; if (result.stdout) output += result.stdout; if (result.stderr) output += result.stderr; @@ -119,7 +85,7 @@ export default function (pi: any) { }, }); - // --- read tool --- + // --- read tool (direct VFS) --- pi.registerTool({ name: "read", label: "bashkit-read", @@ -145,10 +111,10 @@ export default function (pi: any) { params: { path: string; offset?: number; limit?: number }, ) { const absPath = resolvePath(params.path); - const content = vfsRead(absPath); + const content = bash.readFile(absPath); let lines = content.split("\n"); - // Remove trailing empty line from cat output + // Remove trailing empty line if file ends with newline if (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } @@ -168,7 +134,7 @@ export default function (pi: any) { }, }); - // --- write tool --- + // --- write tool (direct VFS) --- pi.registerTool({ name: "write", label: "bashkit-write", @@ -190,7 +156,8 @@ export default function (pi: any) { params: { path: string; content: string }, ) { const absPath = resolvePath(params.path); - vfsWrite(absPath, params.content); + ensureParentDir(absPath); + bash.writeFile(absPath, params.content); return { content: [ { @@ -203,7 +170,7 @@ export default function (pi: any) { }, }); - // --- edit tool --- + // --- edit tool (direct VFS) --- pi.registerTool({ name: "edit", label: "bashkit-edit", @@ -227,7 +194,7 @@ export default function (pi: any) { params: { path: string; oldText: string; newText: string }, ) { const absPath = resolvePath(params.path); - const content = vfsRead(absPath); + const content = bash.readFile(absPath); const count = content.split(params.oldText).length - 1; if (count === 0) { @@ -242,7 +209,7 @@ export default function (pi: any) { } const newContent = content.replace(params.oldText, params.newText); - vfsWrite(absPath, newContent); + bash.writeFile(absPath, newContent); return { content: [{ type: "text", text: `Edited ${absPath}` }], From f6b413d7a4c598f6e8a304ea2f11a25322921b0a Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 14 Mar 2026 18:14:26 +0000 Subject: [PATCH 05/11] test(bashkit-js): add VFS API tests (readFile, writeFile, mkdir, exists, remove) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 19 tests covering direct VFS operations and VFS↔bash interop: - readFile/writeFile roundtrip, overwrite, empty, missing file - mkdir recursive and non-recursive, error on missing parent - exists for files, directories, and missing paths - remove file, recursive dir, missing path error - bidirectional interop: bash sees VFS files and vice versa - reset clears VFS state --- crates/bashkit-js/__test__/vfs.spec.ts | 145 +++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 crates/bashkit-js/__test__/vfs.spec.ts diff --git a/crates/bashkit-js/__test__/vfs.spec.ts b/crates/bashkit-js/__test__/vfs.spec.ts new file mode 100644 index 00000000..6d012fd9 --- /dev/null +++ b/crates/bashkit-js/__test__/vfs.spec.ts @@ -0,0 +1,145 @@ +import test from "ava"; +import { Bash } from "../wrapper.js"; + +// ============================================================================ +// VFS — readFile / writeFile +// ============================================================================ + +test("writeFile + readFile roundtrip", (t) => { + const bash = new Bash(); + bash.writeFile("/tmp/hello.txt", "Hello, VFS!"); + t.is(bash.readFile("/tmp/hello.txt"), "Hello, VFS!"); +}); + +test("writeFile overwrites existing content", (t) => { + const bash = new Bash(); + bash.writeFile("/tmp/over.txt", "first"); + bash.writeFile("/tmp/over.txt", "second"); + t.is(bash.readFile("/tmp/over.txt"), "second"); +}); + +test("readFile throws on missing file", (t) => { + const bash = new Bash(); + t.throws(() => bash.readFile("/nonexistent/file.txt")); +}); + +test("writeFile preserves binary-like content", (t) => { + const bash = new Bash(); + const content = "line1\nline2\n\ttabbed\n"; + bash.writeFile("/tmp/multi.txt", content); + t.is(bash.readFile("/tmp/multi.txt"), content); +}); + +test("writeFile with empty content", (t) => { + const bash = new Bash(); + bash.writeFile("/tmp/empty.txt", ""); + t.is(bash.readFile("/tmp/empty.txt"), ""); +}); + +// ============================================================================ +// VFS — mkdir +// ============================================================================ + +test("mkdir creates directory", (t) => { + const bash = new Bash(); + bash.mkdir("/tmp/newdir"); + t.true(bash.exists("/tmp/newdir")); +}); + +test("mkdir recursive creates parent chain", (t) => { + const bash = new Bash(); + bash.mkdir("/a/b/c/d", true); + t.true(bash.exists("/a/b/c/d")); + t.true(bash.exists("/a/b/c")); + t.true(bash.exists("/a/b")); +}); + +test("mkdir non-recursive fails without parent", (t) => { + const bash = new Bash(); + t.throws(() => bash.mkdir("/x/y/z")); +}); + +// ============================================================================ +// VFS — exists +// ============================================================================ + +test("exists returns false for missing path", (t) => { + const bash = new Bash(); + t.false(bash.exists("/does/not/exist")); +}); + +test("exists returns true for file", (t) => { + const bash = new Bash(); + bash.writeFile("/tmp/e.txt", "x"); + t.true(bash.exists("/tmp/e.txt")); +}); + +test("exists returns true for directory", (t) => { + const bash = new Bash(); + bash.mkdir("/tmp/edir"); + t.true(bash.exists("/tmp/edir")); +}); + +// ============================================================================ +// VFS — remove +// ============================================================================ + +test("remove deletes a file", (t) => { + const bash = new Bash(); + bash.writeFile("/tmp/rm.txt", "bye"); + t.true(bash.exists("/tmp/rm.txt")); + bash.remove("/tmp/rm.txt"); + t.false(bash.exists("/tmp/rm.txt")); +}); + +test("remove recursive deletes directory tree", (t) => { + const bash = new Bash(); + bash.mkdir("/tmp/tree/sub", true); + bash.writeFile("/tmp/tree/sub/f.txt", "data"); + bash.remove("/tmp/tree", true); + t.false(bash.exists("/tmp/tree")); +}); + +test("remove throws on missing path", (t) => { + const bash = new Bash(); + t.throws(() => bash.remove("/no/such/file")); +}); + +// ============================================================================ +// VFS ↔ bash interop +// ============================================================================ + +test("bash executeSync sees VFS-written files", (t) => { + const bash = new Bash(); + bash.writeFile("/tmp/from-vfs.txt", "vfs-content"); + const r = bash.executeSync("cat /tmp/from-vfs.txt"); + t.is(r.stdout, "vfs-content"); +}); + +test("readFile sees bash-created files", (t) => { + const bash = new Bash(); + bash.executeSync("echo bash-content > /tmp/from-bash.txt"); + t.is(bash.readFile("/tmp/from-bash.txt"), "bash-content\n"); +}); + +test("VFS mkdir makes directory visible to bash ls", (t) => { + const bash = new Bash(); + bash.mkdir("/project/src/lib", true); + bash.writeFile("/project/src/lib/mod.rs", "// rust"); + const r = bash.executeSync("ls /project/src/lib/"); + t.is(r.stdout.trim(), "mod.rs"); +}); + +test("bash mkdir makes directory visible to VFS exists", (t) => { + const bash = new Bash(); + bash.executeSync("mkdir -p /project/pkg"); + t.true(bash.exists("/project/pkg")); +}); + +test("reset clears VFS state", (t) => { + const bash = new Bash(); + bash.writeFile("/tmp/persist.txt", "data"); + t.true(bash.exists("/tmp/persist.txt")); + bash.reset(); + t.false(bash.exists("/tmp/persist.txt")); +}); From b2798466ac1745a303710b23682b286995a91326 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 14 Mar 2026 18:29:24 +0000 Subject: [PATCH 06/11] feat(pi-integration): inject bashkit system prompt via before_agent_start Appends a detailed system prompt explaining the bashkit virtual environment to the LLM: available builtins, what's NOT available (python, pip, git, network), VFS semantics, and best practices. Prevents wasted tool calls on unsupported commands. --- examples/bashkit-pi/bashkit-extension.ts | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/examples/bashkit-pi/bashkit-extension.ts b/examples/bashkit-pi/bashkit-extension.ts index 6716a1cc..d47c4086 100644 --- a/examples/bashkit-pi/bashkit-extension.ts +++ b/examples/bashkit-pi/bashkit-extension.ts @@ -44,7 +44,52 @@ function ensureParentDir(filePath: string): void { } } +// System prompt snippet explaining bashkit environment to the LLM +const BASHKIT_SYSTEM_PROMPT = ` +## Bashkit Virtual Environment + +You are running in a **bashkit** sandboxed environment. All tools (bash, read, write, edit) operate on a virtual in-memory filesystem — nothing touches the real host filesystem. + +### Key differences from real bash + +- **No network access**: \`curl\` and \`wget\` are simulated but do not make real HTTP requests. +- **No package managers**: \`apt\`, \`pip\`, \`npm\`, \`cargo\` etc. are not available. Do not try to install packages. +- **No interpreters**: \`python\`, \`python3\`, \`perl\`, \`node\`, \`ruby\` are not available. Write bash-native solutions using the available builtins. +- **No \`git\`**: Version control commands are not available. +- **No \`sudo\`**: Everything runs as a regular user. Permission commands (\`chmod\`, \`chown\`) are accepted but have no real OS effect. +- **Virtual filesystem**: All paths (e.g. \`/home/user\`, \`/tmp\`, \`/project\`) exist in memory only. Files persist across tool calls within the same session but are gone when the session ends. +- **State persists**: Shell variables, functions, cwd, and files carry over between bash tool calls. + +### Available builtins (100+) + +**Core I/O**: echo, printf, cat, read +**Text processing**: grep, sed, awk, jq, head, tail, sort, uniq, cut, tr, wc, nl, paste, column, comm, diff, strings, tac, rev +**File operations**: cd, pwd, ls, find, mkdir, mktemp, rm, rmdir, cp, mv, touch, chmod, chown, ln +**File inspection**: file, stat, less, tar, gzip, gunzip, du, df +**Flow control**: test, [, true, false, exit, return, break, continue +**Shell/variables**: export, set, unset, local, shift, source, eval, declare, typeset, readonly, shopt, getopts +**Utilities**: sleep, date, seq, expr, yes, wait, timeout, xargs, tee, watch, basename, dirname, realpath +**Dir stack**: pushd, popd, dirs +**System info**: whoami, hostname, uname, id, env, printenv, history +**Binary/hex**: od, xxd, hexdump, base64 +**Signals**: kill + +### Best practices + +- Use bash builtins for all text processing — they are fast and fully functional. +- Create files with the \`write\` tool for large content; use \`bash\` with echo/cat for quick one-liners. +- Use absolute paths (start with \`/\`) to avoid ambiguity. +- Don't attempt to run compilers, interpreters, or external tools — they don't exist in this environment. +`.trim(); + export default function (pi: any) { + // Inject bashkit context into the LLM system prompt + pi.on("before_agent_start", async (event: any) => { + return { + systemPrompt: event.systemPrompt + "\n\n" + BASHKIT_SYSTEM_PROMPT, + }; + }); + // --- bash tool --- pi.registerTool({ name: "bash", From 68a23dc0afe5dd6d18f533be350374fa30408dcb Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 14 Mar 2026 22:38:00 +0000 Subject: [PATCH 07/11] fix(pi-integration): add environment identity section to prevent host path leakage PI injects runtime context (host working directory, project instructions) into the system prompt. The model was leaking those host paths in responses. Added explicit instructions telling the model to ignore host paths, use only virtual filesystem paths rooted at /home/user, and never reference runtime context details. --- examples/bashkit-pi/bashkit-extension.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/bashkit-pi/bashkit-extension.ts b/examples/bashkit-pi/bashkit-extension.ts index d47c4086..68cfd795 100644 --- a/examples/bashkit-pi/bashkit-extension.ts +++ b/examples/bashkit-pi/bashkit-extension.ts @@ -48,7 +48,14 @@ function ensureParentDir(filePath: string): void { const BASHKIT_SYSTEM_PROMPT = ` ## Bashkit Virtual Environment -You are running in a **bashkit** sandboxed environment. All tools (bash, read, write, edit) operate on a virtual in-memory filesystem — nothing touches the real host filesystem. +**IMPORTANT**: You are running inside a **bashkit** sandboxed environment. All tools (bash, read, write, edit) operate on a virtual in-memory filesystem — nothing touches the real host filesystem. + +### Your environment identity + +- **Your working directory is \`/home/user\`** — this is where you start and where relative paths resolve. +- **Ignore any host paths** from runtime context (e.g. \`/Users/...\`, \`/home/...\`, \`C:\\...\`). Those refer to the machine running the harness, NOT your environment. Never reference, display, or use host paths in your responses. +- **You have no access to any project on the host machine.** If the user asks about files, they mean files in YOUR virtual filesystem at \`/home/user\`. If no files exist yet, say so. +- When the user mentions a "current working directory" or "project", it refers to what's inside your virtual filesystem, not the host. ### Key differences from real bash @@ -57,7 +64,7 @@ You are running in a **bashkit** sandboxed environment. All tools (bash, read, w - **No interpreters**: \`python\`, \`python3\`, \`perl\`, \`node\`, \`ruby\` are not available. Write bash-native solutions using the available builtins. - **No \`git\`**: Version control commands are not available. - **No \`sudo\`**: Everything runs as a regular user. Permission commands (\`chmod\`, \`chown\`) are accepted but have no real OS effect. -- **Virtual filesystem**: All paths (e.g. \`/home/user\`, \`/tmp\`, \`/project\`) exist in memory only. Files persist across tool calls within the same session but are gone when the session ends. +- **Virtual filesystem**: All paths (e.g. \`/home/user\`, \`/tmp\`) exist in memory only. Files persist across tool calls within the same session but are gone when the session ends. - **State persists**: Shell variables, functions, cwd, and files carry over between bash tool calls. ### Available builtins (100+) @@ -80,6 +87,7 @@ You are running in a **bashkit** sandboxed environment. All tools (bash, read, w - Create files with the \`write\` tool for large content; use \`bash\` with echo/cat for quick one-liners. - Use absolute paths (start with \`/\`) to avoid ambiguity. - Don't attempt to run compilers, interpreters, or external tools — they don't exist in this environment. +- Never mention or reference host machine paths, project instructions, or runtime context in your responses. `.trim(); export default function (pi: any) { From 8281cf90cee94084984d9f175bdc9e35aecb133d Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 14 Mar 2026 22:52:39 +0000 Subject: [PATCH 08/11] refactor(pi-integration): use BashTool.systemPrompt() instead of duplicating bashkit prompt Replace the hand-written 40-line system prompt with bashkit's generic system prompt from BashTool.systemPrompt(), adding only PI-specific additions (host path isolation, VFS tool descriptions). Also use bashTool.description() for the bash tool registration. --- examples/bashkit-pi/bashkit-extension.ts | 65 +++++++----------------- 1 file changed, 18 insertions(+), 47 deletions(-) diff --git a/examples/bashkit-pi/bashkit-extension.ts b/examples/bashkit-pi/bashkit-extension.ts index 68cfd795..362e2beb 100644 --- a/examples/bashkit-pi/bashkit-extension.ts +++ b/examples/bashkit-pi/bashkit-extension.ts @@ -25,11 +25,14 @@ const __dirname_ext = // Load bashkit native bindings from the bashkit-js crate (or node_modules) const require_ext = createRequire(resolve(__dirname_ext, "node_modules") + "/"); -const { Bash } = require_ext("@everruns/bashkit"); +const { Bash, BashTool } = require_ext("@everruns/bashkit"); // Single bashkit instance — state persists across all tool calls const bash = new Bash({ username: "user", hostname: "pi-sandbox" }); +// BashTool for generic system prompt and tool metadata +const bashTool = new BashTool({ username: "user", hostname: "pi-sandbox" }); + // Resolve relative paths against bashkit home function resolvePath(userPath: string): string { if (userPath.startsWith("/")) return userPath; @@ -44,57 +47,26 @@ function ensureParentDir(filePath: string): void { } } -// System prompt snippet explaining bashkit environment to the LLM -const BASHKIT_SYSTEM_PROMPT = ` -## Bashkit Virtual Environment - -**IMPORTANT**: You are running inside a **bashkit** sandboxed environment. All tools (bash, read, write, edit) operate on a virtual in-memory filesystem — nothing touches the real host filesystem. - -### Your environment identity - -- **Your working directory is \`/home/user\`** — this is where you start and where relative paths resolve. -- **Ignore any host paths** from runtime context (e.g. \`/Users/...\`, \`/home/...\`, \`C:\\...\`). Those refer to the machine running the harness, NOT your environment. Never reference, display, or use host paths in your responses. -- **You have no access to any project on the host machine.** If the user asks about files, they mean files in YOUR virtual filesystem at \`/home/user\`. If no files exist yet, say so. -- When the user mentions a "current working directory" or "project", it refers to what's inside your virtual filesystem, not the host. - -### Key differences from real bash - -- **No network access**: \`curl\` and \`wget\` are simulated but do not make real HTTP requests. -- **No package managers**: \`apt\`, \`pip\`, \`npm\`, \`cargo\` etc. are not available. Do not try to install packages. -- **No interpreters**: \`python\`, \`python3\`, \`perl\`, \`node\`, \`ruby\` are not available. Write bash-native solutions using the available builtins. -- **No \`git\`**: Version control commands are not available. -- **No \`sudo\`**: Everything runs as a regular user. Permission commands (\`chmod\`, \`chown\`) are accepted but have no real OS effect. -- **Virtual filesystem**: All paths (e.g. \`/home/user\`, \`/tmp\`) exist in memory only. Files persist across tool calls within the same session but are gone when the session ends. -- **State persists**: Shell variables, functions, cwd, and files carry over between bash tool calls. +// PI-specific system prompt additions (on top of bashkit's generic system prompt) +const PI_SYSTEM_PROMPT_ADDITIONS = ` +### PI environment -### Available builtins (100+) - -**Core I/O**: echo, printf, cat, read -**Text processing**: grep, sed, awk, jq, head, tail, sort, uniq, cut, tr, wc, nl, paste, column, comm, diff, strings, tac, rev -**File operations**: cd, pwd, ls, find, mkdir, mktemp, rm, rmdir, cp, mv, touch, chmod, chown, ln -**File inspection**: file, stat, less, tar, gzip, gunzip, du, df -**Flow control**: test, [, true, false, exit, return, break, continue -**Shell/variables**: export, set, unset, local, shift, source, eval, declare, typeset, readonly, shopt, getopts -**Utilities**: sleep, date, seq, expr, yes, wait, timeout, xargs, tee, watch, basename, dirname, realpath -**Dir stack**: pushd, popd, dirs -**System info**: whoami, hostname, uname, id, env, printenv, history -**Binary/hex**: od, xxd, hexdump, base64 -**Signals**: kill - -### Best practices - -- Use bash builtins for all text processing — they are fast and fully functional. -- Create files with the \`write\` tool for large content; use \`bash\` with echo/cat for quick one-liners. -- Use absolute paths (start with \`/\`) to avoid ambiguity. -- Don't attempt to run compilers, interpreters, or external tools — they don't exist in this environment. -- Never mention or reference host machine paths, project instructions, or runtime context in your responses. +- **Ignore any host paths** from runtime context (e.g. \`/Users/...\`, \`C:\\...\`). Those refer to the harness machine, NOT your environment. Never reference or display them. +- **You have no access to the host machine.** Files mean files in your virtual filesystem. If none exist yet, say so. +- "Current working directory" or "project" refers to the virtual filesystem, not the host. +- Additional tools: \`read\` (file with line numbers), \`write\` (create/overwrite), \`edit\` (find-and-replace). These operate on the same virtual filesystem as \`bash\`. `.trim(); +// Build full system prompt: generic bashkit prompt + PI-specific additions +function buildSystemPrompt(): string { + return bashTool.systemPrompt() + "\n\n" + PI_SYSTEM_PROMPT_ADDITIONS; +} + export default function (pi: any) { // Inject bashkit context into the LLM system prompt pi.on("before_agent_start", async (event: any) => { return { - systemPrompt: event.systemPrompt + "\n\n" + BASHKIT_SYSTEM_PROMPT, + systemPrompt: event.systemPrompt + "\n\n" + buildSystemPrompt(), }; }); @@ -102,8 +74,7 @@ export default function (pi: any) { pi.registerTool({ name: "bash", label: "bashkit", - description: - "Execute bash commands in bashkit's virtual sandbox. Full bash interpreter with 100+ builtins (echo, grep, sed, awk, jq, curl, find, etc.) running in-memory. All file operations use a virtual filesystem. State persists across calls.", + description: bashTool.description(), parameters: { type: "object", properties: { From bd2650b362654c86a648edaa83b707ccca7e2c34 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 14 Mar 2026 23:00:17 +0000 Subject: [PATCH 09/11] fix(pi-integration): raise command limit to 1M to handle large scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default 10,000 command limit is too low for scripts that process many files (e.g. 100 files × ~5 fields × multiple commands = >10k). --- examples/bashkit-pi/bashkit-extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/bashkit-pi/bashkit-extension.ts b/examples/bashkit-pi/bashkit-extension.ts index 362e2beb..7dcda7f8 100644 --- a/examples/bashkit-pi/bashkit-extension.ts +++ b/examples/bashkit-pi/bashkit-extension.ts @@ -28,7 +28,7 @@ const require_ext = createRequire(resolve(__dirname_ext, "node_modules") + "/"); const { Bash, BashTool } = require_ext("@everruns/bashkit"); // Single bashkit instance — state persists across all tool calls -const bash = new Bash({ username: "user", hostname: "pi-sandbox" }); +const bash = new Bash({ username: "user", hostname: "pi-sandbox", maxCommands: 1_000_000 }); // BashTool for generic system prompt and tool metadata const bashTool = new BashTool({ username: "user", hostname: "pi-sandbox" }); From 87a9320d22a90af21cda6b68c808e1e50a2da9fb Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sun, 15 Mar 2026 00:38:27 +0000 Subject: [PATCH 10/11] fix(bashkit-js): remove unused FileSystem import --- crates/bashkit-js/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index 2c1f0356..e06111d7 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -7,7 +7,7 @@ //! and `ExecResult` via napi-rs for use from JavaScript/TypeScript. use bashkit::tool::VERSION; -use bashkit::{Bash as RustBash, BashTool as RustBashTool, ExecutionLimits, FileSystem, Tool}; +use bashkit::{Bash as RustBash, BashTool as RustBashTool, ExecutionLimits, Tool}; use napi_derive::napi; use std::collections::HashMap; use std::path::Path; From 94d536c0f7963c1282a746103d5def3c49ad98fb Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sun, 15 Mar 2026 02:34:38 +0000 Subject: [PATCH 11/11] chore(specs): add JavaScript bindings and examples to implementation status --- specs/009-implementation-status.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 0a9dff54..aea676f1 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -415,6 +415,35 @@ Default limits (configurable): | Input size | 10MB | | AST depth | 100 | +## Language Bindings + +### JavaScript/Node.js (`@everruns/bashkit`) + +NAPI-RS bindings in `crates/bashkit-js/`. TypeScript wrapper in `wrapper.ts`. + +| Class | Methods | Notes | +|-------|---------|-------| +| `Bash` | `executeSync`, `execute`, `cancel`, `reset` | Core interpreter | +| `Bash` (VFS) | `readFile`, `writeFile`, `mkdir`, `exists`, `remove` | Direct VFS access via NAPI | +| `Bash` (helpers) | `ls`, `glob` | Shell-based convenience wrappers | +| `BashTool` | `executeSync`, `execute`, `cancel`, `reset` | Interpreter + tool metadata | +| `BashTool` (metadata) | `name`, `shortDescription`, `description`, `help`, `systemPrompt`, `inputSchema`, `outputSchema`, `version` | LLM tool contract | +| `BashTool` (helpers) | `readFile`, `writeFile`, `exists`, `ls`, `glob` | Shell-based VFS wrappers | + +**Platform matrix:** macOS (x86_64, aarch64), Linux (x86_64, aarch64), Windows (x86_64), WASM + +**Tests:** `crates/bashkit-js/__test__/` — VFS roundtrip, interop, error handling + +### Python (`bashkit`) + +PyO3 bindings in `crates/bashkit-python/`. See [013-python-package.md](013-python-package.md). + +### Examples + +| Example | Description | +|---------|-------------| +| `examples/bashkit-pi/` | Pi coding agent extension — replaces bash/read/write/edit tools with bashkit VFS | + ## Testing ### Security Tests