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 ## 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 The Docker sandbox isolates mom's tools to a container where she can only access what you explicitly mount.
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
const runtimeConfig: SandboxRuntimeConfig = { ## Quick Start
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: [],
},
};
await SandboxManager.initialize(runtimeConfig); ```bash
const sandboxedCommand = await SandboxManager.wrapWithSandbox(command); # 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 ## How It Works
- **macOS**: Uses `sandbox-exec` with Seatbelt profiles ```
- **Linux**: Uses `bubblewrap` for containerization ┌─────────────────────────────────────────────────────┐
│ Host │
The sandbox wraps commands - `SandboxManager.wrapWithSandbox("ls")` returns a modified command that runs inside the sandbox. │ │
│ mom process (Node.js) │
## Files │ ├── Slack connection │
│ ├── LLM API calls │
- `src/sandbox.ts` - Sandbox initialization and command wrapping │ └── Tool execution ──────┐ │
- `src/tools/bash.ts` - Uses `wrapCommand()` before executing │ ▼ │
│ ┌─────────────────────────┐ │
## Usage in Agent │ │ Docker Container │ │
│ │ ├── bash, git, gh, etc │ │
```typescript │ │ └── /workspace (mount) │ │
// In runAgent(): │ └─────────────────────────┘ │
await initializeSandbox({ channelDir, scratchpadDir }); └─────────────────────────────────────────────────────┘
try {
// ... run agent
} finally {
await resetSandbox();
}
``` ```
## 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 ## Container Setup
2. **Consider stricter read restrictions** - Current approach denies known sensitive paths but allows reads elsewhere
3. **Test on Linux** - Requires `bubblewrap` and `socat` installed
## Dependencies Use the provided script:
macOS: ```bash
- `ripgrep` (brew install ripgrep) ./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: Or manually:
- `bubblewrap` (apt install bubblewrap)
- `socat` (apt install socat)
- `ripgrep` (apt install ripgrep)
## 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) ## Mom Manages Her Own Computer
- [Claude Code Sandboxing Docs](https://docs.claude.com/en/docs/claude-code/sandboxing)
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 { Agent, type AgentEvent, ProviderTransport } from "@mariozechner/pi-agent-core";
import { getModel } from "@mariozechner/pi-ai"; import { getModel } from "@mariozechner/pi-ai";
import { existsSync, readFileSync, rmSync } from "fs"; import { existsSync, readFileSync } from "fs";
import { mkdtemp } from "fs/promises"; import { mkdir } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { createExecutor, type SandboxConfig } from "./sandbox.js";
import type { SlackContext } from "./slack.js"; import type { SlackContext } from "./slack.js";
import type { ChannelStore } from "./store.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 // Hardcoded model for now
const model = getModel("anthropic", "claude-opus-4-5"); const model = getModel("anthropic", "claude-opus-4-5");
@ -42,7 +42,9 @@ function getRecentMessages(channelDir: string, count: number): string {
return recentLines.join("\n"); 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. return `You are mom, a helpful Slack bot assistant.
## Communication Style ## Communication Style
@ -59,46 +61,36 @@ function buildSystemPrompt(channelDir: string, scratchpadDir: string, recentMess
- Links: <url|text> - Links: <url|text>
- Do NOT use **double asterisks** or [markdown](links) - Do NOT use **double asterisks** or [markdown](links)
## Channel Data ## Your Workspace
The channel's data directory is: ${channelDir} Your working directory is: ${channelPath}
### Message History This is YOUR computer - you have full control. You can:
- File: ${channelDir}/log.jsonl - Install tools with the system package manager (apk, apt, etc.)
- Format: One JSON object per line (JSONL) - Configure tools and save credentials
- Each line has: {"ts", "user", "userName", "displayName", "text", "attachments", "isBot"} - Create files and directories as needed
- "ts" is the Slack timestamp
- "user" is the user ID, "userName" is their handle, "displayName" is their full name ### Channel Data
- "attachments" is an array of {"original", "local"} where "local" is the path relative to the working directory - Message history: ${channelPath}/log.jsonl (JSONL format)
- "isBot" is true for bot responses - Attachments from users: ${channelPath}/attachments/
### Recent Messages (last 50) ### Recent Messages (last 50)
Below are the most recent messages. If you need more context, read ${channelDir}/log.jsonl directly.
${recentMessages} ${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 ## 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 - read: Read files
- edit: Edit files - edit: Edit files surgically
- write: Write new files - write: Create/overwrite files
- bash: Run shell commands - attach: Share a file with the user in Slack
- attach: Attach a file to your response (share files with the user)
Each tool requires a "label" parameter - this is a brief description of what you're doing that will be shown to the user. Each tool requires a "label" parameter - brief description shown to the user.
Keep labels short and informative, e.g., "Reading message history" or "Searching for user's previous questions".
## Guidelines ## Guidelines
- Be concise and helpful - Be concise and helpful
- If you need more conversation history beyond the recent messages above, read log.jsonl - Use bash for most operations
- Use the scratchpad for any temporary work - If you need a tool, install it
- If you need credentials, ask the user
## CRITICAL ## CRITICAL
- DO NOT USE EMOJIS. KEEP YOUR RESPONSES AS SHORT AS POSSIBLE. - 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) + "..."; return text.substring(0, maxLen - 3) + "...";
} }
export function createAgentRunner(): AgentRunner { export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
let agent: Agent | null = null; let agent: Agent | null = null;
const executor = createExecutor(sandboxConfig);
return { return {
async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<void> { async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<void> {
// Create scratchpad // Ensure channel directory exists
const scratchpadDir = await mkdtemp(join(tmpdir(), "mom-scratchpad-")); await mkdir(channelDir, { recursive: true });
try { const channelId = ctx.message.channel;
const recentMessages = getRecentMessages(channelDir, 50); const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
const systemPrompt = buildSystemPrompt(channelDir, scratchpadDir, recentMessages); const recentMessages = getRecentMessages(channelDir, 50);
const systemPrompt = buildSystemPrompt(workspacePath, channelId, recentMessages);
// Set up file upload function for the attach tool // Set up file upload function for the attach tool
setUploadFunction(async (filePath: string, title?: string) => { // For Docker, we need to translate paths back to host
await ctx.uploadFile(filePath, title); setUploadFunction(async (filePath: string, title?: string) => {
}); const hostPath = translateToHostPath(filePath, channelDir, workspacePath, channelId);
await ctx.uploadFile(hostPath, title);
});
// Create ephemeral agent // Create tools with executor
agent = new Agent({ const tools = createMomTools(executor);
initialState: {
systemPrompt,
model,
thinkingLevel: "off",
tools: momTools,
},
transport: new ProviderTransport({
getApiKey: async () => getAnthropicApiKey(),
}),
});
// Subscribe to events // Create ephemeral agent
agent.subscribe(async (event: AgentEvent) => { agent = new Agent({
switch (event.type) { initialState: {
case "tool_execution_start": { systemPrompt,
const args = event.args as { label?: string }; model,
const label = args.label || event.toolName; thinkingLevel: "off",
tools,
},
transport: new ProviderTransport({
getApiKey: async () => getAnthropicApiKey(),
}),
});
// Log to console // Subscribe to events
console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`); 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 // Log to console
await store.logMessage(ctx.message.channel, { console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`);
ts: Date.now().toString(),
user: "bot",
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
attachments: [],
isBot: true,
});
// Show only label to user (italic) // Log to jsonl
await ctx.respond(`_${label}_`); await store.logMessage(ctx.message.channel, {
break; ts: Date.now().toString(),
} user: "bot",
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
attachments: [],
isBot: true,
});
case "tool_execution_end": { // Show only label to user (italic)
const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result); await ctx.respond(`_${label}_`);
break;
// 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 case "tool_execution_end": {
await agent.prompt(ctx.message.text || "(attached files)"); const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
} finally {
agent = null; // Log to console
// Cleanup scratchpad console.log(`[Tool Result] ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}\n`);
try {
rmSync(scratchpadDir, { recursive: true, force: true }); // Log to jsonl
} catch { await store.logMessage(ctx.message.channel, {
// Ignore cleanup errors 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 { 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 { join, resolve } from "path";
import { type AgentRunner, createAgentRunner } from "./agent.js"; import { type AgentRunner, createAgentRunner } from "./agent.js";
import { parseSandboxArg, type SandboxConfig, validateSandbox } from "./sandbox.js";
import { MomBot, type SlackContext } from "./slack.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_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN; const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
const ANTHROPIC_OAUTH_TOKEN = process.env.ANTHROPIC_OAUTH_TOKEN; const ANTHROPIC_OAUTH_TOKEN = process.env.ANTHROPIC_OAUTH_TOKEN;
// Parse command line arguments // Parse command line arguments
const args = process.argv.slice(2); function parseArgs(): { workingDir: string; sandbox: SandboxConfig } {
if (args.length !== 1) { const args = process.argv.slice(2);
console.error("Usage: mom <working-directory>"); let sandbox: SandboxConfig = { type: "host" };
console.error("Example: mom ./mom-data"); let workingDir: string | undefined;
process.exit(1);
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)) { if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN)) {
console.error("Missing required environment variables:"); 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); process.exit(1);
} }
// Validate sandbox configuration
await validateSandbox(sandbox);
// Track active agent runs per channel // Track active agent runs per channel
const activeRuns = new Map<string, AgentRunner>(); 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}`); console.log(`${source === "channel" ? "Channel mention" : "DM"} from <@${ctx.message.user}>: ${ctx.message.text}`);
const channelDir = join(workingDir, channelId); const channelDir = join(workingDir, channelId);
const runner = createAgentRunner(); const runner = createAgentRunner(sandbox);
activeRuns.set(channelId, runner); activeRuns.set(channelId, runner);
await ctx.setTyping(true); 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 { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { spawn } from "child_process"; import type { Executor } from "../sandbox.js";
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
}
}
}
}
const bashSchema = Type.Object({ const bashSchema = Type.Object({
label: Type.String({ description: "Brief description of what this command does (shown to user)" }), 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)" })), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
}); });
export const bashTool: AgentTool<typeof bashSchema> = { export function createBashTool(executor: Executor): AgentTool<typeof bashSchema> {
name: "bash", return {
label: "bash", name: "bash",
description: label: "bash",
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.", description:
parameters: bashSchema, "Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
execute: async ( parameters: bashSchema,
_toolCallId: string, execute: async (
{ command, timeout }: { label: string; command: string; timeout?: number }, _toolCallId: string,
signal?: AbortSignal, { command, timeout }: { label: string; command: string; timeout?: number },
) => { signal?: AbortSignal,
return new Promise((resolve, reject) => { ) => {
const { shell, args } = getShellConfig(); const result = await executor.exec(command, { timeout, signal });
const child = spawn(shell, [...args, command], { let output = "";
detached: true, if (result.stdout) output += result.stdout;
stdio: ["ignore", "pipe", "pipe"], if (result.stderr) {
}); if (output) output += "\n";
output += result.stderr;
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);
} }
// Collect stdout if (result.code !== 0) {
if (child.stdout) { throw new Error(`${output}\n\nCommand exited with code ${result.code}`.trim());
child.stdout.on("data", (data) => {
stdout += data.toString();
// Limit buffer size
if (stdout.length > 10 * 1024 * 1024) {
stdout = stdout.slice(0, 10 * 1024 * 1024);
}
});
} }
// Collect stderr return { content: [{ type: "text", text: output || "(no output)" }], details: undefined };
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 });
}
}
});
},
};

View file

@ -1,23 +1,7 @@
import * as os from "node:os";
import type { AgentTool } from "@mariozechner/pi-ai"; import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import * as Diff from "diff"; import * as Diff from "diff";
import { constants } from "fs"; import type { Executor } from "../sandbox.js";
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;
}
/** /**
* Generate a unified diff string with line numbers and context * 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) { if (part.added || part.removed) {
// Show the change
for (const line of raw) { for (const line of raw) {
if (part.added) { if (part.added) {
const lineNum = String(newLineNum).padStart(lineNumWidth, " "); const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
output.push(`+${lineNum} ${line}`); output.push(`+${lineNum} ${line}`);
newLineNum++; newLineNum++;
} else { } else {
// removed
const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(`-${lineNum} ${line}`); output.push(`-${lineNum} ${line}`);
oldLineNum++; oldLineNum++;
@ -58,28 +40,23 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
} }
lastWasChange = true; lastWasChange = true;
} else { } else {
// Context lines - only show a few before/after changes
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
if (lastWasChange || nextPartIsChange) { if (lastWasChange || nextPartIsChange) {
// Show context
let linesToShow = raw; let linesToShow = raw;
let skipStart = 0; let skipStart = 0;
let skipEnd = 0; let skipEnd = 0;
if (!lastWasChange) { if (!lastWasChange) {
// Show only last N lines as leading context
skipStart = Math.max(0, raw.length - contextLines); skipStart = Math.max(0, raw.length - contextLines);
linesToShow = raw.slice(skipStart); linesToShow = raw.slice(skipStart);
} }
if (!nextPartIsChange && linesToShow.length > contextLines) { if (!nextPartIsChange && linesToShow.length > contextLines) {
// Show only first N lines as trailing context
skipEnd = linesToShow.length - contextLines; skipEnd = linesToShow.length - contextLines;
linesToShow = linesToShow.slice(0, contextLines); linesToShow = linesToShow.slice(0, contextLines);
} }
// Add ellipsis if we skipped lines at start
if (skipStart > 0) { if (skipStart > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`); output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
} }
@ -91,16 +68,13 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
newLineNum++; newLineNum++;
} }
// Add ellipsis if we skipped lines at end
if (skipEnd > 0) { if (skipEnd > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`); output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
} }
// Update line numbers for skipped lines
oldLineNum += skipStart + skipEnd; oldLineNum += skipStart + skipEnd;
newLineNum += skipStart + skipEnd; newLineNum += skipStart + skipEnd;
} else { } else {
// Skip these context lines entirely
oldLineNum += raw.length; oldLineNum += raw.length;
newLineNum += 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" }), newText: Type.String({ description: "New text to replace the old text with" }),
}); });
export const editTool: AgentTool<typeof editSchema> = { export function createEditTool(executor: Executor): AgentTool<typeof editSchema> {
name: "edit", return {
label: "edit", name: "edit",
description: label: "edit",
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.", description:
parameters: editSchema, "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
execute: async ( parameters: editSchema,
_toolCallId: string, execute: async (
{ path, oldText, newText }: { label: string; path: string; oldText: string; newText: string }, _toolCallId: string,
signal?: AbortSignal, { path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },
) => { signal?: AbortSignal,
const absolutePath = resolvePath(expandPath(path)); ) => {
// Read the file
return new Promise<{ const readResult = await executor.exec(`cat ${shellEscape(path)}`, { signal });
content: Array<{ type: "text"; text: string }>; if (readResult.code !== 0) {
details: { diff: string } | undefined; throw new Error(readResult.stderr || `File not found: ${path}`);
}>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
} }
let aborted = false; const content = readResult.stdout;
// Set up abort handler // Check if old text exists
const onAbort = () => { if (!content.includes(oldText)) {
aborted = true; throw new Error(
reject(new Error("Operation aborted")); `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) { function shellEscape(s: string): string {
signal.addEventListener("abort", onAbort, { once: true }); return `'${s.replace(/'/g, "'\\''")}'`;
} }
// 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);
}
}
})();
});
},
};

View file

@ -1,13 +1,19 @@
export { attachTool, setUploadFunction } from "./attach.js"; import type { AgentTool } from "@mariozechner/pi-ai";
export { bashTool } from "./bash.js"; import type { Executor } from "../sandbox.js";
export { editTool } from "./edit.js";
export { readTool } from "./read.js";
export { writeTool } from "./write.js";
import { attachTool } from "./attach.js"; import { attachTool } from "./attach.js";
import { bashTool } from "./bash.js"; import { createBashTool } from "./bash.js";
import { editTool } from "./edit.js"; import { createEditTool } from "./edit.js";
import { readTool } from "./read.js"; import { createReadTool } from "./read.js";
import { writeTool } from "./write.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 { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { constants } from "fs"; import { extname } from "path";
import { access, readFile } from "fs/promises"; import type { Executor } from "../sandbox.js";
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;
}
/** /**
* Map of file extensions to MIME types for common image formats * 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_LINES = 2000;
const MAX_LINE_LENGTH = 2000; const MAX_LINE_LENGTH = 2000;
export const readTool: AgentTool<typeof readSchema> = { export function createReadTool(executor: Executor): AgentTool<typeof readSchema> {
name: "read", return {
label: "read", name: "read",
description: label: "read",
"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.", description:
parameters: readSchema, "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.",
execute: async ( parameters: readSchema,
_toolCallId: string, execute: async (
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number }, _toolCallId: string,
signal?: AbortSignal, { path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
) => { signal?: AbortSignal,
const absolutePath = resolvePath(expandPath(path)); ) => {
const mimeType = isImageFile(absolutePath); const mimeType = isImageFile(path);
return new Promise<{ content: (TextContent | ImageContent)[]; details: undefined }>((resolve, reject) => { if (mimeType) {
// Check if already aborted // Read as image (binary) - use base64
if (signal?.aborted) { const result = await executor.exec(`base64 < ${shellEscape(path)}`, { signal });
reject(new Error("Operation aborted")); if (result.code !== 0) {
return; throw new Error(result.stderr || `Failed to read file: ${path}`);
}
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);
}
} }
})(); 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 { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { mkdir, writeFile } from "fs/promises"; import type { Executor } from "../sandbox.js";
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;
}
const writeSchema = Type.Object({ const writeSchema = Type.Object({
label: Type.String({ description: "Brief description of what you're writing (shown to user)" }), 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" }), content: Type.String({ description: "Content to write to the file" }),
}); });
export const writeTool: AgentTool<typeof writeSchema> = { export function createWriteTool(executor: Executor): AgentTool<typeof writeSchema> {
name: "write", return {
label: "write", name: "write",
description: label: "write",
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", description:
parameters: writeSchema, "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
execute: async ( parameters: writeSchema,
_toolCallId: string, execute: async (
{ path, content }: { label: string; path: string; content: string }, _toolCallId: string,
signal?: AbortSignal, { path, content }: { label: string; path: string; content: string },
) => { signal?: AbortSignal,
const absolutePath = resolvePath(expandPath(path)); ) => {
const dir = dirname(absolutePath); // 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) => { // Use printf to handle content with special characters, pipe to file
// Check if already aborted // This avoids issues with heredoc and special characters
if (signal?.aborted) { const cmd = `mkdir -p ${shellEscape(dir)} && printf '%s' ${shellEscape(content)} > ${shellEscape(path)}`;
reject(new Error("Operation aborted"));
return; const result = await executor.exec(cmd, { signal });
if (result.code !== 0) {
throw new Error(result.stderr || `Failed to write file: ${path}`);
} }
let aborted = false; return {
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
// Set up abort handler details: undefined,
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
}; };
},
};
}
if (signal) { function shellEscape(s: string): string {
signal.addEventListener("abort", onAbort, { once: true }); return `'${s.replace(/'/g, "'\\''")}'`;
} }
// 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);
}
}
})();
});
},
};