mom: Docker sandbox support with --sandbox=docker:container-name option

This commit is contained in:
Mario Zechner 2025-11-26 01:06:00 +01:00
parent da26edb2a7
commit f140f2e432
10 changed files with 885 additions and 814 deletions

95
packages/mom/docker.sh Executable file
View file

@ -0,0 +1,95 @@
#!/bin/bash
# Mom Docker Sandbox Management Script
# Usage:
# ./docker.sh create <data-dir> - Create and start the container
# ./docker.sh start - Start the container
# ./docker.sh stop - Stop the container
# ./docker.sh remove - Remove the container
# ./docker.sh status - Check container status
# ./docker.sh shell - Open a shell in the container
CONTAINER_NAME="mom-sandbox"
IMAGE="alpine:latest"
case "$1" in
create)
if [ -z "$2" ]; then
echo "Usage: $0 create <data-dir>"
echo "Example: $0 create ./data"
exit 1
fi
DATA_DIR=$(cd "$2" && pwd)
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "Container '${CONTAINER_NAME}' already exists. Remove it first with: $0 remove"
exit 1
fi
echo "Creating container '${CONTAINER_NAME}'..."
echo " Data dir: ${DATA_DIR} -> /workspace"
docker run -d \
--name "$CONTAINER_NAME" \
-v "${DATA_DIR}:/workspace" \
"$IMAGE" \
tail -f /dev/null
if [ $? -eq 0 ]; then
echo "Container created and running."
echo ""
echo "Run mom with: mom --sandbox=docker:${CONTAINER_NAME} $2"
else
echo "Failed to create container."
exit 1
fi
;;
start)
echo "Starting container '${CONTAINER_NAME}'..."
docker start "$CONTAINER_NAME"
;;
stop)
echo "Stopping container '${CONTAINER_NAME}'..."
docker stop "$CONTAINER_NAME"
;;
remove)
echo "Removing container '${CONTAINER_NAME}'..."
docker rm -f "$CONTAINER_NAME"
;;
status)
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "Container '${CONTAINER_NAME}' is running."
docker ps --filter "name=${CONTAINER_NAME}" --format "table {{.ID}}\t{{.Image}}\t{{.Status}}"
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "Container '${CONTAINER_NAME}' exists but is not running."
echo "Start it with: $0 start"
else
echo "Container '${CONTAINER_NAME}' does not exist."
echo "Create it with: $0 create <data-dir>"
fi
;;
shell)
echo "Opening shell in '${CONTAINER_NAME}'..."
docker exec -it "$CONTAINER_NAME" /bin/sh
;;
*)
echo "Mom Docker Sandbox Management"
echo ""
echo "Usage: $0 <command> [args]"
echo ""
echo "Commands:"
echo " create <data-dir> - Create and start the container"
echo " start - Start the container"
echo " stop - Stop the container"
echo " remove - Remove the container"
echo " status - Check container status"
echo " shell - Open a shell in the container"
;;
esac

View file

@ -1,95 +1,153 @@
# Mom Sandbox Implementation
# Mom Docker Sandbox
## Overview
Mom uses [@anthropic-ai/sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) to restrict what the bash tool can do at the OS level.
Mom can run tools either directly on the host or inside a Docker container for isolation.
## Current Implementation
## Why Docker?
Located in `src/sandbox.ts`:
When mom runs on your machine and is accessible via Slack, anyone in your workspace could potentially:
- Execute arbitrary commands on your machine
- Access your files, credentials, etc.
- Cause damage via prompt injection
```typescript
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
The Docker sandbox isolates mom's tools to a container where she can only access what you explicitly mount.
const runtimeConfig: SandboxRuntimeConfig = {
network: {
allowedDomains: [], // Currently no network - should be ["*"] for full access
deniedDomains: [],
},
filesystem: {
denyRead: ["~/.ssh", "~/.aws", ...], // Sensitive paths
allowWrite: [channelDir, scratchpadDir], // Only mom's folders
denyWrite: [],
},
};
## Quick Start
await SandboxManager.initialize(runtimeConfig);
const sandboxedCommand = await SandboxManager.wrapWithSandbox(command);
```bash
# 1. Create and start the container
cd packages/mom
./docker.sh create ./data
# 2. Run mom with Docker sandbox
mom --sandbox=docker:mom-sandbox ./data
```
## Key Limitation: Read Access
**Read is deny-only** - allowed everywhere by default. We can only deny specific paths, NOT allow only specific paths.
This means:
- ❌ Cannot say "only allow reads from channelDir and scratchpadDir"
- ✅ Can say "deny reads from ~/.ssh, ~/.aws, etc."
The bash tool CAN read files outside the mom data folder. We mitigate by denying sensitive directories.
## Write Access
**Write is allow-only** - denied everywhere by default. This works perfectly for our use case:
- Only `channelDir` and `scratchpadDir` can be written to
- Everything else is blocked
## Network Access
- `allowedDomains: []` = no network access
- `allowedDomains: ["*"]` = full network access
- `allowedDomains: ["github.com", "*.github.com"]` = specific domains
## How It Works
- **macOS**: Uses `sandbox-exec` with Seatbelt profiles
- **Linux**: Uses `bubblewrap` for containerization
The sandbox wraps commands - `SandboxManager.wrapWithSandbox("ls")` returns a modified command that runs inside the sandbox.
## Files
- `src/sandbox.ts` - Sandbox initialization and command wrapping
- `src/tools/bash.ts` - Uses `wrapCommand()` before executing
## Usage in Agent
```typescript
// In runAgent():
await initializeSandbox({ channelDir, scratchpadDir });
try {
// ... run agent
} finally {
await resetSandbox();
}
```
┌─────────────────────────────────────────────────────┐
│ Host │
│ │
│ mom process (Node.js) │
│ ├── Slack connection │
│ ├── LLM API calls │
│ └── Tool execution ──────┐ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Docker Container │ │
│ │ ├── bash, git, gh, etc │ │
│ │ └── /workspace (mount) │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
## TODO
- Mom process runs on host (handles Slack, LLM calls)
- All tool execution (`bash`, `read`, `write`, `edit`) happens inside the container
- Only `/workspace` (your data dir) is accessible to the container
1. **Update network config** - Change `allowedDomains: []` to `["*"]` for full network access
2. **Consider stricter read restrictions** - Current approach denies known sensitive paths but allows reads elsewhere
3. **Test on Linux** - Requires `bubblewrap` and `socat` installed
## Container Setup
## Dependencies
Use the provided script:
macOS:
- `ripgrep` (brew install ripgrep)
```bash
./docker.sh create <data-dir> # Create and start container
./docker.sh start # Start existing container
./docker.sh stop # Stop container
./docker.sh remove # Remove container
./docker.sh status # Check if running
./docker.sh shell # Open shell in container
```
Linux:
- `bubblewrap` (apt install bubblewrap)
- `socat` (apt install socat)
- `ripgrep` (apt install ripgrep)
Or manually:
## Reference
```bash
docker run -d --name mom-sandbox \
-v /path/to/mom-data:/workspace \
alpine:latest tail -f /dev/null
```
- [sandbox-runtime README](https://github.com/anthropic-experimental/sandbox-runtime)
- [Claude Code Sandboxing Docs](https://docs.claude.com/en/docs/claude-code/sandboxing)
## Mom Manages Her Own Computer
The container is treated as mom's personal computer. She can:
- Install tools: `apk add github-cli git curl`
- Configure credentials: `gh auth login`
- Create files and directories
- Persist state across restarts
When mom needs a tool, she installs it. When she needs credentials, she asks you.
### Example Flow
```
User: "@mom check the spine-runtimes repo"
Mom: "I need gh CLI. Installing..."
(runs: apk add github-cli)
Mom: "I need a GitHub token. Please provide one."
User: "ghp_xxxx..."
Mom: (runs: echo "ghp_xxxx" | gh auth login --with-token)
Mom: "Done. Checking repo..."
```
## Persistence
The container persists across:
- `docker stop` / `docker start`
- Host reboots
Installed tools and configs remain until you `docker rm` the container.
To start fresh: `./docker.sh remove && ./docker.sh create ./data`
## CLI Options
```bash
# Run on host (default, no isolation)
mom ./data
# Run with Docker sandbox
mom --sandbox=docker:mom-sandbox ./data
# Explicit host mode
mom --sandbox=host ./data
```
## Security Considerations
**What the container CAN do:**
- Read/write files in `/workspace` (your data dir)
- Make network requests (for git, gh, curl, etc.)
- Install packages
- Run any commands
**What the container CANNOT do:**
- Access files outside `/workspace`
- Access your host's credentials
- Affect your host system
**For maximum security:**
1. Create a dedicated GitHub bot account with limited repo access
2. Only share that bot's token with mom
3. Don't mount sensitive directories
## Troubleshooting
### Container not running
```bash
./docker.sh status # Check status
./docker.sh start # Start it
```
### Reset container
```bash
./docker.sh remove
./docker.sh create ./data
```
### Missing tools
Ask mom to install them, or manually:
```bash
docker exec mom-sandbox apk add <package>
```

View file

@ -1,13 +1,13 @@
import { Agent, type AgentEvent, ProviderTransport } from "@mariozechner/pi-agent-core";
import { getModel } from "@mariozechner/pi-ai";
import { existsSync, readFileSync, rmSync } from "fs";
import { mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { existsSync, readFileSync } from "fs";
import { mkdir } from "fs/promises";
import { join } from "path";
import { createExecutor, type SandboxConfig } from "./sandbox.js";
import type { SlackContext } from "./slack.js";
import type { ChannelStore } from "./store.js";
import { momTools, setUploadFunction } from "./tools/index.js";
import { createMomTools, setUploadFunction } from "./tools/index.js";
// Hardcoded model for now
const model = getModel("anthropic", "claude-opus-4-5");
@ -42,7 +42,9 @@ function getRecentMessages(channelDir: string, count: number): string {
return recentLines.join("\n");
}
function buildSystemPrompt(channelDir: string, scratchpadDir: string, recentMessages: string): string {
function buildSystemPrompt(workspacePath: string, channelId: string, recentMessages: string): string {
const channelPath = `${workspacePath}/${channelId}`;
return `You are mom, a helpful Slack bot assistant.
## Communication Style
@ -59,46 +61,36 @@ function buildSystemPrompt(channelDir: string, scratchpadDir: string, recentMess
- Links: <url|text>
- Do NOT use **double asterisks** or [markdown](links)
## Channel Data
The channel's data directory is: ${channelDir}
## Your Workspace
Your working directory is: ${channelPath}
### Message History
- File: ${channelDir}/log.jsonl
- Format: One JSON object per line (JSONL)
- Each line has: {"ts", "user", "userName", "displayName", "text", "attachments", "isBot"}
- "ts" is the Slack timestamp
- "user" is the user ID, "userName" is their handle, "displayName" is their full name
- "attachments" is an array of {"original", "local"} where "local" is the path relative to the working directory
- "isBot" is true for bot responses
This is YOUR computer - you have full control. You can:
- Install tools with the system package manager (apk, apt, etc.)
- Configure tools and save credentials
- Create files and directories as needed
### Channel Data
- Message history: ${channelPath}/log.jsonl (JSONL format)
- Attachments from users: ${channelPath}/attachments/
### Recent Messages (last 50)
Below are the most recent messages. If you need more context, read ${channelDir}/log.jsonl directly.
${recentMessages}
### Attachments
Files shared in the channel are stored in: ${channelDir}/attachments/
The "local" field in attachments points to these files.
## Scratchpad
Your temporary working directory is: ${scratchpadDir}
Use this for any file operations. It will be deleted after you complete.
## Tools
You have access to: read, edit, write, bash, attach tools.
You have access to: bash, read, edit, write, attach tools.
- bash: Run shell commands (this is your main tool)
- read: Read files
- edit: Edit files
- write: Write new files
- bash: Run shell commands
- attach: Attach a file to your response (share files with the user)
- edit: Edit files surgically
- write: Create/overwrite files
- attach: Share a file with the user in Slack
Each tool requires a "label" parameter - this is a brief description of what you're doing that will be shown to the user.
Keep labels short and informative, e.g., "Reading message history" or "Searching for user's previous questions".
Each tool requires a "label" parameter - brief description shown to the user.
## Guidelines
- Be concise and helpful
- If you need more conversation history beyond the recent messages above, read log.jsonl
- Use the scratchpad for any temporary work
- Use bash for most operations
- If you need a tool, install it
- If you need credentials, ask the user
## CRITICAL
- DO NOT USE EMOJIS. KEEP YOUR RESPONSES AS SHORT AS POSSIBLE.
@ -110,129 +102,127 @@ function truncate(text: string, maxLen: number): string {
return text.substring(0, maxLen - 3) + "...";
}
export function createAgentRunner(): AgentRunner {
export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
let agent: Agent | null = null;
const executor = createExecutor(sandboxConfig);
return {
async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<void> {
// Create scratchpad
const scratchpadDir = await mkdtemp(join(tmpdir(), "mom-scratchpad-"));
// Ensure channel directory exists
await mkdir(channelDir, { recursive: true });
try {
const recentMessages = getRecentMessages(channelDir, 50);
const systemPrompt = buildSystemPrompt(channelDir, scratchpadDir, recentMessages);
const channelId = ctx.message.channel;
const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
const recentMessages = getRecentMessages(channelDir, 50);
const systemPrompt = buildSystemPrompt(workspacePath, channelId, recentMessages);
// Set up file upload function for the attach tool
setUploadFunction(async (filePath: string, title?: string) => {
await ctx.uploadFile(filePath, title);
});
// Set up file upload function for the attach tool
// For Docker, we need to translate paths back to host
setUploadFunction(async (filePath: string, title?: string) => {
const hostPath = translateToHostPath(filePath, channelDir, workspacePath, channelId);
await ctx.uploadFile(hostPath, title);
});
// Create ephemeral agent
agent = new Agent({
initialState: {
systemPrompt,
model,
thinkingLevel: "off",
tools: momTools,
},
transport: new ProviderTransport({
getApiKey: async () => getAnthropicApiKey(),
}),
});
// Create tools with executor
const tools = createMomTools(executor);
// Subscribe to events
agent.subscribe(async (event: AgentEvent) => {
switch (event.type) {
case "tool_execution_start": {
const args = event.args as { label?: string };
const label = args.label || event.toolName;
// Create ephemeral agent
agent = new Agent({
initialState: {
systemPrompt,
model,
thinkingLevel: "off",
tools,
},
transport: new ProviderTransport({
getApiKey: async () => getAnthropicApiKey(),
}),
});
// Log to console
console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`);
// Subscribe to events
agent.subscribe(async (event: AgentEvent) => {
switch (event.type) {
case "tool_execution_start": {
const args = event.args as { label?: string };
const label = args.label || event.toolName;
// Log to jsonl
await store.logMessage(ctx.message.channel, {
ts: Date.now().toString(),
user: "bot",
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
attachments: [],
isBot: true,
});
// Log to console
console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`);
// Show only label to user (italic)
await ctx.respond(`_${label}_`);
break;
}
// Log to jsonl
await store.logMessage(ctx.message.channel, {
ts: Date.now().toString(),
user: "bot",
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
attachments: [],
isBot: true,
});
case "tool_execution_end": {
const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
// Log to console
console.log(`[Tool Result] ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}\n`);
// Log to jsonl
await store.logMessage(ctx.message.channel, {
ts: Date.now().toString(),
user: "bot",
text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
attachments: [],
isBot: true,
});
// Show brief status to user (only on error)
if (event.isError) {
await ctx.respond(`_Error: ${truncate(resultStr, 200)}_`);
}
break;
}
case "message_update": {
const ev = event.assistantMessageEvent;
// Stream deltas to console
if (ev.type === "text_delta") {
process.stdout.write(ev.delta);
} else if (ev.type === "thinking_delta") {
process.stdout.write(ev.delta);
}
break;
}
case "message_start":
if (event.message.role === "assistant") {
process.stdout.write("\n");
}
break;
case "message_end":
if (event.message.role === "assistant") {
process.stdout.write("\n");
// Extract text from assistant message
const content = event.message.content;
let text = "";
for (const part of content) {
if (part.type === "text") {
text += part.text;
}
}
if (text.trim()) {
await ctx.respond(text);
}
}
break;
// Show only label to user (italic)
await ctx.respond(`_${label}_`);
break;
}
});
// Run the agent with user's message
await agent.prompt(ctx.message.text || "(attached files)");
} finally {
agent = null;
// Cleanup scratchpad
try {
rmSync(scratchpadDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
case "tool_execution_end": {
const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
// Log to console
console.log(`[Tool Result] ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}\n`);
// Log to jsonl
await store.logMessage(ctx.message.channel, {
ts: Date.now().toString(),
user: "bot",
text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
attachments: [],
isBot: true,
});
// Show brief status to user (only on error)
if (event.isError) {
await ctx.respond(`_Error: ${truncate(resultStr, 200)}_`);
}
break;
}
case "message_update": {
const ev = event.assistantMessageEvent;
// Stream deltas to console
if (ev.type === "text_delta") {
process.stdout.write(ev.delta);
} else if (ev.type === "thinking_delta") {
process.stdout.write(ev.delta);
}
break;
}
case "message_start":
if (event.message.role === "assistant") {
process.stdout.write("\n");
}
break;
case "message_end":
if (event.message.role === "assistant") {
process.stdout.write("\n");
// Extract text from assistant message
const content = event.message.content;
let text = "";
for (const part of content) {
if (part.type === "text") {
text += part.text;
}
}
if (text.trim()) {
await ctx.respond(text);
}
}
break;
}
}
});
// Run the agent with user's message
await agent.prompt(ctx.message.text || "(attached files)");
},
abort(): void {
@ -240,3 +230,27 @@ export function createAgentRunner(): AgentRunner {
},
};
}
/**
* Translate container path back to host path for file operations
*/
function translateToHostPath(
containerPath: string,
channelDir: string,
workspacePath: string,
channelId: string,
): string {
if (workspacePath === "/workspace") {
// Docker mode - translate /workspace/channelId/... to host path
const prefix = `/workspace/${channelId}/`;
if (containerPath.startsWith(prefix)) {
return join(channelDir, containerPath.slice(prefix.length));
}
// Maybe it's just /workspace/...
if (containerPath.startsWith("/workspace/")) {
return join(channelDir, "..", containerPath.slice("/workspace/".length));
}
}
// Host mode or already a host path
return containerPath;
}

View file

@ -2,24 +2,60 @@
import { join, resolve } from "path";
import { type AgentRunner, createAgentRunner } from "./agent.js";
import { parseSandboxArg, type SandboxConfig, validateSandbox } from "./sandbox.js";
import { MomBot, type SlackContext } from "./slack.js";
console.log("Starting mom bot...");
const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
const ANTHROPIC_OAUTH_TOKEN = process.env.ANTHROPIC_OAUTH_TOKEN;
// Parse command line arguments
const args = process.argv.slice(2);
if (args.length !== 1) {
console.error("Usage: mom <working-directory>");
console.error("Example: mom ./mom-data");
process.exit(1);
function parseArgs(): { workingDir: string; sandbox: SandboxConfig } {
const args = process.argv.slice(2);
let sandbox: SandboxConfig = { type: "host" };
let workingDir: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith("--sandbox=")) {
sandbox = parseSandboxArg(arg.slice("--sandbox=".length));
} else if (arg === "--sandbox") {
const next = args[++i];
if (!next) {
console.error("Error: --sandbox requires a value (host or docker:<container-name>)");
process.exit(1);
}
sandbox = parseSandboxArg(next);
} else if (!arg.startsWith("-")) {
workingDir = arg;
} else {
console.error(`Unknown option: ${arg}`);
process.exit(1);
}
}
if (!workingDir) {
console.error("Usage: mom [--sandbox=host|docker:<container-name>] <working-directory>");
console.error("");
console.error("Options:");
console.error(" --sandbox=host Run tools directly on host (default)");
console.error(" --sandbox=docker:<container> Run tools in Docker container");
console.error("");
console.error("Examples:");
console.error(" mom ./data");
console.error(" mom --sandbox=docker:mom-sandbox ./data");
process.exit(1);
}
return { workingDir: resolve(workingDir), sandbox };
}
const workingDir = resolve(args[0]);
const { workingDir, sandbox } = parseArgs();
console.log("Starting mom bot...");
console.log(` Working directory: ${workingDir}`);
console.log(` Sandbox: ${sandbox.type === "host" ? "host" : `docker:${sandbox.container}`}`);
if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN)) {
console.error("Missing required environment variables:");
@ -29,6 +65,9 @@ if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTH
process.exit(1);
}
// Validate sandbox configuration
await validateSandbox(sandbox);
// Track active agent runs per channel
const activeRuns = new Map<string, AgentRunner>();
@ -58,7 +97,7 @@ async function handleMessage(ctx: SlackContext, source: "channel" | "dm"): Promi
console.log(`${source === "channel" ? "Channel mention" : "DM"} from <@${ctx.message.user}>: ${ctx.message.text}`);
const channelDir = join(workingDir, channelId);
const runner = createAgentRunner();
const runner = createAgentRunner(sandbox);
activeRuns.set(channelId, runner);
await ctx.setTyping(true);

221
packages/mom/src/sandbox.ts Normal file
View file

@ -0,0 +1,221 @@
import { spawn } from "child_process";
export type SandboxConfig = { type: "host" } | { type: "docker"; container: string };
export function parseSandboxArg(value: string): SandboxConfig {
if (value === "host") {
return { type: "host" };
}
if (value.startsWith("docker:")) {
const container = value.slice("docker:".length);
if (!container) {
console.error("Error: docker sandbox requires container name (e.g., docker:mom-sandbox)");
process.exit(1);
}
return { type: "docker", container };
}
console.error(`Error: Invalid sandbox type '${value}'. Use 'host' or 'docker:<container-name>'`);
process.exit(1);
}
export async function validateSandbox(config: SandboxConfig): Promise<void> {
if (config.type === "host") {
return;
}
// Check if Docker is available
try {
await execSimple("docker", ["--version"]);
} catch {
console.error("Error: Docker is not installed or not in PATH");
process.exit(1);
}
// Check if container exists and is running
try {
const result = await execSimple("docker", ["inspect", "-f", "{{.State.Running}}", config.container]);
if (result.trim() !== "true") {
console.error(`Error: Container '${config.container}' is not running.`);
console.error(`Start it with: docker start ${config.container}`);
process.exit(1);
}
} catch {
console.error(`Error: Container '${config.container}' does not exist.`);
console.error("Create it with: ./docker.sh create <data-dir>");
process.exit(1);
}
console.log(` Docker container '${config.container}' is running.`);
}
function execSimple(cmd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout?.on("data", (d) => {
stdout += d;
});
child.stderr?.on("data", (d) => {
stderr += d;
});
child.on("close", (code) => {
if (code === 0) resolve(stdout);
else reject(new Error(stderr || `Exit code ${code}`));
});
});
}
/**
* Create an executor that runs commands either on host or in Docker container
*/
export function createExecutor(config: SandboxConfig): Executor {
if (config.type === "host") {
return new HostExecutor();
}
return new DockerExecutor(config.container);
}
export interface Executor {
/**
* Execute a bash command
*/
exec(command: string, options?: ExecOptions): Promise<ExecResult>;
/**
* Get the workspace path prefix for this executor
* Host: returns the actual path
* Docker: returns /workspace
*/
getWorkspacePath(hostPath: string): string;
}
export interface ExecOptions {
timeout?: number;
signal?: AbortSignal;
}
export interface ExecResult {
stdout: string;
stderr: string;
code: number;
}
class HostExecutor implements Executor {
async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
return new Promise((resolve, reject) => {
const shell = process.platform === "win32" ? "cmd" : "sh";
const shellArgs = process.platform === "win32" ? ["/c"] : ["-c"];
const child = spawn(shell, [...shellArgs, command], {
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timeoutHandle =
options?.timeout && options.timeout > 0
? setTimeout(() => {
timedOut = true;
killProcessTree(child.pid!);
}, options.timeout * 1000)
: undefined;
const onAbort = () => {
if (child.pid) killProcessTree(child.pid);
};
if (options?.signal) {
if (options.signal.aborted) {
onAbort();
} else {
options.signal.addEventListener("abort", onAbort, { once: true });
}
}
child.stdout?.on("data", (data) => {
stdout += data.toString();
if (stdout.length > 10 * 1024 * 1024) {
stdout = stdout.slice(0, 10 * 1024 * 1024);
}
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
if (stderr.length > 10 * 1024 * 1024) {
stderr = stderr.slice(0, 10 * 1024 * 1024);
}
});
child.on("close", (code) => {
if (timeoutHandle) clearTimeout(timeoutHandle);
if (options?.signal) {
options.signal.removeEventListener("abort", onAbort);
}
if (options?.signal?.aborted) {
reject(new Error(`${stdout}\n${stderr}\nCommand aborted`.trim()));
return;
}
if (timedOut) {
reject(new Error(`${stdout}\n${stderr}\nCommand timed out after ${options?.timeout} seconds`.trim()));
return;
}
resolve({ stdout, stderr, code: code ?? 0 });
});
});
}
getWorkspacePath(hostPath: string): string {
return hostPath;
}
}
class DockerExecutor implements Executor {
constructor(private container: string) {}
async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
// Wrap command for docker exec
const dockerCmd = `docker exec ${this.container} sh -c ${shellEscape(command)}`;
const hostExecutor = new HostExecutor();
return hostExecutor.exec(dockerCmd, options);
}
getWorkspacePath(_hostPath: string): string {
// Docker container sees /workspace
return "/workspace";
}
}
function killProcessTree(pid: number): void {
if (process.platform === "win32") {
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
});
} catch {
// Ignore errors
}
} else {
try {
process.kill(-pid, "SIGKILL");
} catch {
try {
process.kill(pid, "SIGKILL");
} catch {
// Process already dead
}
}
}
}
function shellEscape(s: string): string {
// Escape for passing to sh -c
return `'${s.replace(/'/g, "'\\''")}'`;
}

View file

@ -1,65 +1,6 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { existsSync } from "fs";
/**
* Get shell configuration based on platform
*/
function getShellConfig(): { shell: string; args: string[] } {
if (process.platform === "win32") {
const paths: string[] = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
for (const path of paths) {
if (existsSync(path)) {
return { shell: path, args: ["-c"] };
}
}
throw new Error(
`Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win\n` +
`Searched in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
);
}
return { shell: "sh", args: ["-c"] };
}
/**
* Kill a process and all its children
*/
function killProcessTree(pid: number): void {
if (process.platform === "win32") {
// Use taskkill on Windows to kill process tree
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
});
} catch {
// Ignore errors if taskkill fails
}
} else {
// Use SIGKILL on Unix/Linux/Mac
try {
process.kill(-pid, "SIGKILL");
} catch {
// Fallback to killing just the child if process group kill fails
try {
process.kill(pid, "SIGKILL");
} catch {
// Process already dead
}
}
}
}
import type { Executor } from "../sandbox.js";
const bashSchema = Type.Object({
label: Type.String({ description: "Brief description of what this command does (shown to user)" }),
@ -67,123 +8,31 @@ const bashSchema = Type.Object({
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
export const bashTool: AgentTool<typeof bashSchema> = {
name: "bash",
label: "bash",
description:
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
parameters: bashSchema,
execute: async (
_toolCallId: string,
{ command, timeout }: { label: string; command: string; timeout?: number },
signal?: AbortSignal,
) => {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
const child = spawn(shell, [...args, command], {
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
// Set timeout if provided
let timeoutHandle: NodeJS.Timeout | undefined;
if (timeout !== undefined && timeout > 0) {
timeoutHandle = setTimeout(() => {
timedOut = true;
onAbort();
}, timeout * 1000);
export function createBashTool(executor: Executor): AgentTool<typeof bashSchema> {
return {
name: "bash",
label: "bash",
description:
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
parameters: bashSchema,
execute: async (
_toolCallId: string,
{ command, timeout }: { label: string; command: string; timeout?: number },
signal?: AbortSignal,
) => {
const result = await executor.exec(command, { timeout, signal });
let output = "";
if (result.stdout) output += result.stdout;
if (result.stderr) {
if (output) output += "\n";
output += result.stderr;
}
// Collect stdout
if (child.stdout) {
child.stdout.on("data", (data) => {
stdout += data.toString();
// Limit buffer size
if (stdout.length > 10 * 1024 * 1024) {
stdout = stdout.slice(0, 10 * 1024 * 1024);
}
});
if (result.code !== 0) {
throw new Error(`${output}\n\nCommand exited with code ${result.code}`.trim());
}
// Collect stderr
if (child.stderr) {
child.stderr.on("data", (data) => {
stderr += data.toString();
// Limit buffer size
if (stderr.length > 10 * 1024 * 1024) {
stderr = stderr.slice(0, 10 * 1024 * 1024);
}
});
}
// Handle process exit
child.on("close", (code) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (signal?.aborted) {
let output = "";
if (stdout) output += stdout;
if (stderr) {
if (output) output += "\n";
output += stderr;
}
if (output) output += "\n\n";
output += "Command aborted";
reject(new Error(output));
return;
}
if (timedOut) {
let output = "";
if (stdout) output += stdout;
if (stderr) {
if (output) output += "\n";
output += stderr;
}
if (output) output += "\n\n";
output += `Command timed out after ${timeout} seconds`;
reject(new Error(output));
return;
}
let output = "";
if (stdout) output += stdout;
if (stderr) {
if (output) output += "\n";
output += stderr;
}
if (code !== 0 && code !== null) {
if (output) output += "\n\n";
reject(new Error(`${output}Command exited with code ${code}`));
} else {
resolve({ content: [{ type: "text", text: output || "(no output)" }], details: undefined });
}
});
// Handle abort signal - kill entire process tree
const onAbort = () => {
if (child.pid) {
killProcessTree(child.pid);
}
};
if (signal) {
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
}
});
},
};
return { content: [{ type: "text", text: output || "(no output)" }], details: undefined };
},
};
}

View file

@ -1,23 +1,7 @@
import * as os from "node:os";
import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import * as Diff from "diff";
import { constants } from "fs";
import { access, readFile, writeFile } from "fs/promises";
import { resolve as resolvePath } from "path";
/**
* Expand ~ to home directory
*/
function expandPath(filePath: string): string {
if (filePath === "~") {
return os.homedir();
}
if (filePath.startsWith("~/")) {
return os.homedir() + filePath.slice(1);
}
return filePath;
}
import type { Executor } from "../sandbox.js";
/**
* Generate a unified diff string with line numbers and context
@ -43,14 +27,12 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
}
if (part.added || part.removed) {
// Show the change
for (const line of raw) {
if (part.added) {
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
output.push(`+${lineNum} ${line}`);
newLineNum++;
} else {
// removed
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(`-${lineNum} ${line}`);
oldLineNum++;
@ -58,28 +40,23 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
}
lastWasChange = true;
} else {
// Context lines - only show a few before/after changes
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
if (lastWasChange || nextPartIsChange) {
// Show context
let linesToShow = raw;
let skipStart = 0;
let skipEnd = 0;
if (!lastWasChange) {
// Show only last N lines as leading context
skipStart = Math.max(0, raw.length - contextLines);
linesToShow = raw.slice(skipStart);
}
if (!nextPartIsChange && linesToShow.length > contextLines) {
// Show only first N lines as trailing context
skipEnd = linesToShow.length - contextLines;
linesToShow = linesToShow.slice(0, contextLines);
}
// Add ellipsis if we skipped lines at start
if (skipStart > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
}
@ -91,16 +68,13 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
newLineNum++;
}
// Add ellipsis if we skipped lines at end
if (skipEnd > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
}
// Update line numbers for skipped lines
oldLineNum += skipStart + skipEnd;
newLineNum += skipStart + skipEnd;
} else {
// Skip these context lines entirely
oldLineNum += raw.length;
newLineNum += raw.length;
}
@ -119,151 +93,73 @@ const editSchema = Type.Object({
newText: Type.String({ description: "New text to replace the old text with" }),
});
export const editTool: AgentTool<typeof editSchema> = {
name: "edit",
label: "edit",
description:
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
parameters: editSchema,
execute: async (
_toolCallId: string,
{ path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },
signal?: AbortSignal,
) => {
const absolutePath = resolvePath(expandPath(path));
return new Promise<{
content: Array<{ type: "text"; text: string }>;
details: { diff: string } | undefined;
}>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
export function createEditTool(executor: Executor): AgentTool<typeof editSchema> {
return {
name: "edit",
label: "edit",
description:
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
parameters: editSchema,
execute: async (
_toolCallId: string,
{ path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },
signal?: AbortSignal,
) => {
// Read the file
const readResult = await executor.exec(`cat ${shellEscape(path)}`, { signal });
if (readResult.code !== 0) {
throw new Error(readResult.stderr || `File not found: ${path}`);
}
let aborted = false;
const content = readResult.stdout;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
// Check if old text exists
if (!content.includes(oldText)) {
throw new Error(
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
);
}
// Count occurrences
const occurrences = content.split(oldText).length - 1;
if (occurrences > 1) {
throw new Error(
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
);
}
// Perform replacement
const index = content.indexOf(oldText);
const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
if (content === newContent) {
throw new Error(
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
);
}
// Write the file back
const writeResult = await executor.exec(`printf '%s' ${shellEscape(newContent)} > ${shellEscape(path)}`, {
signal,
});
if (writeResult.code !== 0) {
throw new Error(writeResult.stderr || `Failed to write file: ${path}`);
}
return {
content: [
{
type: "text",
text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
},
],
details: { diff: generateDiffString(content, newContent) },
};
},
};
}
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the edit operation
(async () => {
try {
// Check if file exists
try {
await access(absolutePath, constants.R_OK | constants.W_OK);
} catch {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(new Error(`File not found: ${path}`));
return;
}
// Check if aborted before reading
if (aborted) {
return;
}
// Read the file
const content = await readFile(absolutePath, "utf-8");
// Check if aborted after reading
if (aborted) {
return;
}
// Check if old text exists
if (!content.includes(oldText)) {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(
new Error(
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
),
);
return;
}
// Count occurrences
const occurrences = content.split(oldText).length - 1;
if (occurrences > 1) {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(
new Error(
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
),
);
return;
}
// Check if aborted before writing
if (aborted) {
return;
}
// Perform replacement using indexOf + substring (raw string replace, no special character interpretation)
// String.replace() interprets $ in the replacement string, so we do manual replacement
const index = content.indexOf(oldText);
const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
// Verify the replacement actually changed something
if (content === newContent) {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(
new Error(
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
),
);
return;
}
await writeFile(absolutePath, newContent, "utf-8");
// Check if aborted after writing
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({
content: [
{
type: "text",
text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
},
],
details: { diff: generateDiffString(content, newContent) },
});
} catch (error: unknown) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
}
})();
});
},
};
function shellEscape(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;
}

View file

@ -1,13 +1,19 @@
export { attachTool, setUploadFunction } from "./attach.js";
export { bashTool } from "./bash.js";
export { editTool } from "./edit.js";
export { readTool } from "./read.js";
export { writeTool } from "./write.js";
import type { AgentTool } from "@mariozechner/pi-ai";
import type { Executor } from "../sandbox.js";
import { attachTool } from "./attach.js";
import { bashTool } from "./bash.js";
import { editTool } from "./edit.js";
import { readTool } from "./read.js";
import { writeTool } from "./write.js";
import { createBashTool } from "./bash.js";
import { createEditTool } from "./edit.js";
import { createReadTool } from "./read.js";
import { createWriteTool } from "./write.js";
export const momTools = [readTool, bashTool, editTool, writeTool, attachTool];
export { setUploadFunction } from "./attach.js";
export function createMomTools(executor: Executor): AgentTool<any>[] {
return [
createReadTool(executor),
createBashTool(executor),
createEditTool(executor),
createWriteTool(executor),
attachTool,
];
}

View file

@ -1,22 +1,7 @@
import * as os from "node:os";
import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access, readFile } from "fs/promises";
import { extname, resolve as resolvePath } from "path";
/**
* Expand ~ to home directory
*/
function expandPath(filePath: string): string {
if (filePath === "~") {
return os.homedir();
}
if (filePath.startsWith("~/")) {
return os.homedir() + filePath.slice(1);
}
return filePath;
}
import { extname } from "path";
import type { Executor } from "../sandbox.js";
/**
* Map of file extensions to MIME types for common image formats
@ -47,133 +32,96 @@ const readSchema = Type.Object({
const MAX_LINES = 2000;
const MAX_LINE_LENGTH = 2000;
export const readTool: AgentTool<typeof readSchema> = {
name: "read",
label: "read",
description:
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
parameters: readSchema,
execute: async (
_toolCallId: string,
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
signal?: AbortSignal,
) => {
const absolutePath = resolvePath(expandPath(path));
const mimeType = isImageFile(absolutePath);
export function createReadTool(executor: Executor): AgentTool<typeof readSchema> {
return {
name: "read",
label: "read",
description:
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
parameters: readSchema,
execute: async (
_toolCallId: string,
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
signal?: AbortSignal,
) => {
const mimeType = isImageFile(path);
return new Promise<{ content: (TextContent | ImageContent)[]; details: undefined }>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the read operation
(async () => {
try {
// Check if file exists
await access(absolutePath, constants.R_OK);
// Check if aborted before reading
if (aborted) {
return;
}
// Read the file based on type
let content: (TextContent | ImageContent)[];
if (mimeType) {
// Read as image (binary)
const buffer = await readFile(absolutePath);
const base64 = buffer.toString("base64");
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
} else {
// Read as text
const textContent = await readFile(absolutePath, "utf-8");
const lines = textContent.split("\n");
// Apply offset and limit (matching Claude Code Read tool behavior)
const startLine = offset ? Math.max(0, offset - 1) : 0; // 1-indexed to 0-indexed
const maxLines = limit || MAX_LINES;
const endLine = Math.min(startLine + maxLines, lines.length);
// Check if offset is out of bounds
if (startLine >= lines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
}
// Get the relevant lines
const selectedLines = lines.slice(startLine, endLine);
// Truncate long lines and track which were truncated
let hadTruncatedLines = false;
const formattedLines = selectedLines.map((line) => {
if (line.length > MAX_LINE_LENGTH) {
hadTruncatedLines = true;
return line.slice(0, MAX_LINE_LENGTH);
}
return line;
});
let outputText = formattedLines.join("\n");
// Add notices
const notices: string[] = [];
if (hadTruncatedLines) {
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
}
if (endLine < lines.length) {
const remaining = lines.length - endLine;
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
}
if (notices.length > 0) {
outputText += `\n\n... (${notices.join(". ")})`;
}
content = [{ type: "text", text: outputText }];
}
// Check if aborted after reading
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({ content, details: undefined });
} catch (error: unknown) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
if (mimeType) {
// Read as image (binary) - use base64
const result = await executor.exec(`base64 < ${shellEscape(path)}`, { signal });
if (result.code !== 0) {
throw new Error(result.stderr || `Failed to read file: ${path}`);
}
})();
});
},
};
const base64 = result.stdout.replace(/\s/g, ""); // Remove whitespace from base64
return {
content: [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
] as (TextContent | ImageContent)[],
details: undefined,
};
}
// Read as text using cat with offset/limit via sed/head/tail
let cmd: string;
const startLine = offset ? Math.max(1, offset) : 1;
const maxLines = limit || MAX_LINES;
if (startLine === 1) {
cmd = `head -n ${maxLines} ${shellEscape(path)}`;
} else {
cmd = `sed -n '${startLine},${startLine + maxLines - 1}p' ${shellEscape(path)}`;
}
// Also get total line count
const countResult = await executor.exec(`wc -l < ${shellEscape(path)}`, { signal });
const totalLines = Number.parseInt(countResult.stdout.trim(), 10) || 0;
const result = await executor.exec(cmd, { signal });
if (result.code !== 0) {
throw new Error(result.stderr || `Failed to read file: ${path}`);
}
const lines = result.stdout.split("\n");
// Truncate long lines
let hadTruncatedLines = false;
const formattedLines = lines.map((line) => {
if (line.length > MAX_LINE_LENGTH) {
hadTruncatedLines = true;
return line.slice(0, MAX_LINE_LENGTH);
}
return line;
});
let outputText = formattedLines.join("\n");
// Add notices
const notices: string[] = [];
const endLine = startLine + lines.length - 1;
if (hadTruncatedLines) {
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
}
if (endLine < totalLines) {
const remaining = totalLines - endLine;
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
}
if (notices.length > 0) {
outputText += `\n\n... (${notices.join(". ")})`;
}
return {
content: [{ type: "text", text: outputText }] as (TextContent | ImageContent)[],
details: undefined,
};
},
};
}
function shellEscape(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;
}

View file

@ -1,21 +1,6 @@
import * as os from "node:os";
import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { mkdir, writeFile } from "fs/promises";
import { dirname, resolve as resolvePath } from "path";
/**
* Expand ~ to home directory
*/
function expandPath(filePath: string): string {
if (filePath === "~") {
return os.homedir();
}
if (filePath.startsWith("~/")) {
return os.homedir() + filePath.slice(1);
}
return filePath;
}
import type { Executor } from "../sandbox.js";
const writeSchema = Type.Object({
label: Type.String({ description: "Brief description of what you're writing (shown to user)" }),
@ -23,78 +8,38 @@ const writeSchema = Type.Object({
content: Type.String({ description: "Content to write to the file" }),
});
export const writeTool: AgentTool<typeof writeSchema> = {
name: "write",
label: "write",
description:
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
parameters: writeSchema,
execute: async (
_toolCallId: string,
{ path, content }: { label: string; path: string; content: string },
signal?: AbortSignal,
) => {
const absolutePath = resolvePath(expandPath(path));
const dir = dirname(absolutePath);
export function createWriteTool(executor: Executor): AgentTool<typeof writeSchema> {
return {
name: "write",
label: "write",
description:
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
parameters: writeSchema,
execute: async (
_toolCallId: string,
{ path, content }: { label: string; path: string; content: string },
signal?: AbortSignal,
) => {
// Create parent directories and write file using heredoc
const dir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : ".";
return new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
// Use printf to handle content with special characters, pipe to file
// This avoids issues with heredoc and special characters
const cmd = `mkdir -p ${shellEscape(dir)} && printf '%s' ${shellEscape(content)} > ${shellEscape(path)}`;
const result = await executor.exec(cmd, { signal });
if (result.code !== 0) {
throw new Error(result.stderr || `Failed to write file: ${path}`);
}
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
return {
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
details: undefined,
};
},
};
}
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the write operation
(async () => {
try {
// Create parent directories if needed
await mkdir(dir, { recursive: true });
// Check if aborted before writing
if (aborted) {
return;
}
// Write the file
await writeFile(absolutePath, content, "utf-8");
// Check if aborted after writing
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
details: undefined,
});
} catch (error: unknown) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
}
})();
});
},
};
function shellEscape(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;
}