diff --git a/packages/mom/docker.sh b/packages/mom/docker.sh new file mode 100755 index 00000000..0362ddbd --- /dev/null +++ b/packages/mom/docker.sh @@ -0,0 +1,95 @@ +#!/bin/bash + +# Mom Docker Sandbox Management Script +# Usage: +# ./docker.sh create - 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 " + 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 " + fi + ;; + + shell) + echo "Opening shell in '${CONTAINER_NAME}'..." + docker exec -it "$CONTAINER_NAME" /bin/sh + ;; + + *) + echo "Mom Docker Sandbox Management" + echo "" + echo "Usage: $0 [args]" + echo "" + echo "Commands:" + echo " create - 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 diff --git a/packages/mom/docs/sandbox.md b/packages/mom/docs/sandbox.md index 53ebd9f8..82350977 100644 --- a/packages/mom/docs/sandbox.md +++ b/packages/mom/docs/sandbox.md @@ -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 # 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 +``` diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index d61847cb..05d0dd47 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -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: - 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 { - // 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; +} diff --git a/packages/mom/src/main.ts b/packages/mom/src/main.ts index b55526b1..26551f78 100644 --- a/packages/mom/src/main.ts +++ b/packages/mom/src/main.ts @@ -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 "); - 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:)"); + 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:] "); + console.error(""); + console.error("Options:"); + console.error(" --sandbox=host Run tools directly on host (default)"); + console.error(" --sandbox=docker: 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(); @@ -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); diff --git a/packages/mom/src/sandbox.ts b/packages/mom/src/sandbox.ts new file mode 100644 index 00000000..05c027f2 --- /dev/null +++ b/packages/mom/src/sandbox.ts @@ -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:'`); + process.exit(1); +} + +export async function validateSandbox(config: SandboxConfig): Promise { + 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 "); + process.exit(1); + } + + console.log(` Docker container '${config.container}' is running.`); +} + +function execSimple(cmd: string, args: string[]): Promise { + 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; + + /** + * 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 { + 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 { + // 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, "'\\''")}'`; +} diff --git a/packages/mom/src/tools/bash.ts b/packages/mom/src/tools/bash.ts index 907b0295..3a88e03a 100644 --- a/packages/mom/src/tools/bash.ts +++ b/packages/mom/src/tools/bash.ts @@ -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 = { - 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 { + 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 }; + }, + }; +} diff --git a/packages/mom/src/tools/edit.ts b/packages/mom/src/tools/edit.ts index 52e147a7..3fce6146 100644 --- a/packages/mom/src/tools/edit.ts +++ b/packages/mom/src/tools/edit.ts @@ -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 = { - 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 { + 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, "'\\''")}'`; +} diff --git a/packages/mom/src/tools/index.ts b/packages/mom/src/tools/index.ts index d310d28e..607e2e83 100644 --- a/packages/mom/src/tools/index.ts +++ b/packages/mom/src/tools/index.ts @@ -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[] { + return [ + createReadTool(executor), + createBashTool(executor), + createEditTool(executor), + createWriteTool(executor), + attachTool, + ]; +} diff --git a/packages/mom/src/tools/read.ts b/packages/mom/src/tools/read.ts index 1cfc99c6..d59f9d6d 100644 --- a/packages/mom/src/tools/read.ts +++ b/packages/mom/src/tools/read.ts @@ -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 = { - 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 { + 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, "'\\''")}'`; +} diff --git a/packages/mom/src/tools/write.ts b/packages/mom/src/tools/write.ts index 82aa63dd..22bdb1e5 100644 --- a/packages/mom/src/tools/write.ts +++ b/packages/mom/src/tools/write.ts @@ -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 = { - 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 { + 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, "'\\''")}'`; +}