mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 16:04:03 +00:00
mom: Docker sandbox support with --sandbox=docker:container-name option
This commit is contained in:
parent
da26edb2a7
commit
f140f2e432
10 changed files with 885 additions and 814 deletions
95
packages/mom/docker.sh
Executable file
95
packages/mom/docker.sh
Executable 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
|
||||
|
|
@ -1,95 +1,153 @@
|
|||
# Mom Sandbox Implementation
|
||||
# Mom Docker Sandbox
|
||||
|
||||
## Overview
|
||||
|
||||
Mom uses [@anthropic-ai/sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) to restrict what the bash tool can do at the OS level.
|
||||
Mom can run tools either directly on the host or inside a Docker container for isolation.
|
||||
|
||||
## Current Implementation
|
||||
## Why Docker?
|
||||
|
||||
Located in `src/sandbox.ts`:
|
||||
When mom runs on your machine and is accessible via Slack, anyone in your workspace could potentially:
|
||||
- Execute arbitrary commands on your machine
|
||||
- Access your files, credentials, etc.
|
||||
- Cause damage via prompt injection
|
||||
|
||||
```typescript
|
||||
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
||||
The Docker sandbox isolates mom's tools to a container where she can only access what you explicitly mount.
|
||||
|
||||
const runtimeConfig: SandboxRuntimeConfig = {
|
||||
network: {
|
||||
allowedDomains: [], // Currently no network - should be ["*"] for full access
|
||||
deniedDomains: [],
|
||||
},
|
||||
filesystem: {
|
||||
denyRead: ["~/.ssh", "~/.aws", ...], // Sensitive paths
|
||||
allowWrite: [channelDir, scratchpadDir], // Only mom's folders
|
||||
denyWrite: [],
|
||||
},
|
||||
};
|
||||
## Quick Start
|
||||
|
||||
await SandboxManager.initialize(runtimeConfig);
|
||||
const sandboxedCommand = await SandboxManager.wrapWithSandbox(command);
|
||||
```bash
|
||||
# 1. Create and start the container
|
||||
cd packages/mom
|
||||
./docker.sh create ./data
|
||||
|
||||
# 2. Run mom with Docker sandbox
|
||||
mom --sandbox=docker:mom-sandbox ./data
|
||||
```
|
||||
|
||||
## Key Limitation: Read Access
|
||||
|
||||
**Read is deny-only** - allowed everywhere by default. We can only deny specific paths, NOT allow only specific paths.
|
||||
|
||||
This means:
|
||||
- ❌ Cannot say "only allow reads from channelDir and scratchpadDir"
|
||||
- ✅ Can say "deny reads from ~/.ssh, ~/.aws, etc."
|
||||
|
||||
The bash tool CAN read files outside the mom data folder. We mitigate by denying sensitive directories.
|
||||
|
||||
## Write Access
|
||||
|
||||
**Write is allow-only** - denied everywhere by default. This works perfectly for our use case:
|
||||
- Only `channelDir` and `scratchpadDir` can be written to
|
||||
- Everything else is blocked
|
||||
|
||||
## Network Access
|
||||
|
||||
- `allowedDomains: []` = no network access
|
||||
- `allowedDomains: ["*"]` = full network access
|
||||
- `allowedDomains: ["github.com", "*.github.com"]` = specific domains
|
||||
|
||||
## How It Works
|
||||
|
||||
- **macOS**: Uses `sandbox-exec` with Seatbelt profiles
|
||||
- **Linux**: Uses `bubblewrap` for containerization
|
||||
|
||||
The sandbox wraps commands - `SandboxManager.wrapWithSandbox("ls")` returns a modified command that runs inside the sandbox.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/sandbox.ts` - Sandbox initialization and command wrapping
|
||||
- `src/tools/bash.ts` - Uses `wrapCommand()` before executing
|
||||
|
||||
## Usage in Agent
|
||||
|
||||
```typescript
|
||||
// In runAgent():
|
||||
await initializeSandbox({ channelDir, scratchpadDir });
|
||||
try {
|
||||
// ... run agent
|
||||
} finally {
|
||||
await resetSandbox();
|
||||
}
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Host │
|
||||
│ │
|
||||
│ mom process (Node.js) │
|
||||
│ ├── Slack connection │
|
||||
│ ├── LLM API calls │
|
||||
│ └── Tool execution ──────┐ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ Docker Container │ │
|
||||
│ │ ├── bash, git, gh, etc │ │
|
||||
│ │ └── /workspace (mount) │ │
|
||||
│ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## TODO
|
||||
- Mom process runs on host (handles Slack, LLM calls)
|
||||
- All tool execution (`bash`, `read`, `write`, `edit`) happens inside the container
|
||||
- Only `/workspace` (your data dir) is accessible to the container
|
||||
|
||||
1. **Update network config** - Change `allowedDomains: []` to `["*"]` for full network access
|
||||
2. **Consider stricter read restrictions** - Current approach denies known sensitive paths but allows reads elsewhere
|
||||
3. **Test on Linux** - Requires `bubblewrap` and `socat` installed
|
||||
## Container Setup
|
||||
|
||||
## Dependencies
|
||||
Use the provided script:
|
||||
|
||||
macOS:
|
||||
- `ripgrep` (brew install ripgrep)
|
||||
```bash
|
||||
./docker.sh create <data-dir> # Create and start container
|
||||
./docker.sh start # Start existing container
|
||||
./docker.sh stop # Stop container
|
||||
./docker.sh remove # Remove container
|
||||
./docker.sh status # Check if running
|
||||
./docker.sh shell # Open shell in container
|
||||
```
|
||||
|
||||
Linux:
|
||||
- `bubblewrap` (apt install bubblewrap)
|
||||
- `socat` (apt install socat)
|
||||
- `ripgrep` (apt install ripgrep)
|
||||
Or manually:
|
||||
|
||||
## Reference
|
||||
```bash
|
||||
docker run -d --name mom-sandbox \
|
||||
-v /path/to/mom-data:/workspace \
|
||||
alpine:latest tail -f /dev/null
|
||||
```
|
||||
|
||||
- [sandbox-runtime README](https://github.com/anthropic-experimental/sandbox-runtime)
|
||||
- [Claude Code Sandboxing Docs](https://docs.claude.com/en/docs/claude-code/sandboxing)
|
||||
## Mom Manages Her Own Computer
|
||||
|
||||
The container is treated as mom's personal computer. She can:
|
||||
|
||||
- Install tools: `apk add github-cli git curl`
|
||||
- Configure credentials: `gh auth login`
|
||||
- Create files and directories
|
||||
- Persist state across restarts
|
||||
|
||||
When mom needs a tool, she installs it. When she needs credentials, she asks you.
|
||||
|
||||
### Example Flow
|
||||
|
||||
```
|
||||
User: "@mom check the spine-runtimes repo"
|
||||
Mom: "I need gh CLI. Installing..."
|
||||
(runs: apk add github-cli)
|
||||
Mom: "I need a GitHub token. Please provide one."
|
||||
User: "ghp_xxxx..."
|
||||
Mom: (runs: echo "ghp_xxxx" | gh auth login --with-token)
|
||||
Mom: "Done. Checking repo..."
|
||||
```
|
||||
|
||||
## Persistence
|
||||
|
||||
The container persists across:
|
||||
- `docker stop` / `docker start`
|
||||
- Host reboots
|
||||
|
||||
Installed tools and configs remain until you `docker rm` the container.
|
||||
|
||||
To start fresh: `./docker.sh remove && ./docker.sh create ./data`
|
||||
|
||||
## CLI Options
|
||||
|
||||
```bash
|
||||
# Run on host (default, no isolation)
|
||||
mom ./data
|
||||
|
||||
# Run with Docker sandbox
|
||||
mom --sandbox=docker:mom-sandbox ./data
|
||||
|
||||
# Explicit host mode
|
||||
mom --sandbox=host ./data
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**What the container CAN do:**
|
||||
- Read/write files in `/workspace` (your data dir)
|
||||
- Make network requests (for git, gh, curl, etc.)
|
||||
- Install packages
|
||||
- Run any commands
|
||||
|
||||
**What the container CANNOT do:**
|
||||
- Access files outside `/workspace`
|
||||
- Access your host's credentials
|
||||
- Affect your host system
|
||||
|
||||
**For maximum security:**
|
||||
1. Create a dedicated GitHub bot account with limited repo access
|
||||
2. Only share that bot's token with mom
|
||||
3. Don't mount sensitive directories
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container not running
|
||||
```bash
|
||||
./docker.sh status # Check status
|
||||
./docker.sh start # Start it
|
||||
```
|
||||
|
||||
### Reset container
|
||||
```bash
|
||||
./docker.sh remove
|
||||
./docker.sh create ./data
|
||||
```
|
||||
|
||||
### Missing tools
|
||||
Ask mom to install them, or manually:
|
||||
```bash
|
||||
docker exec mom-sandbox apk add <package>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { Agent, type AgentEvent, ProviderTransport } from "@mariozechner/pi-agent-core";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { existsSync, readFileSync, rmSync } from "fs";
|
||||
import { mkdtemp } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { mkdir } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
import { createExecutor, type SandboxConfig } from "./sandbox.js";
|
||||
import type { SlackContext } from "./slack.js";
|
||||
import type { ChannelStore } from "./store.js";
|
||||
import { momTools, setUploadFunction } from "./tools/index.js";
|
||||
import { createMomTools, setUploadFunction } from "./tools/index.js";
|
||||
|
||||
// Hardcoded model for now
|
||||
const model = getModel("anthropic", "claude-opus-4-5");
|
||||
|
|
@ -42,7 +42,9 @@ function getRecentMessages(channelDir: string, count: number): string {
|
|||
return recentLines.join("\n");
|
||||
}
|
||||
|
||||
function buildSystemPrompt(channelDir: string, scratchpadDir: string, recentMessages: string): string {
|
||||
function buildSystemPrompt(workspacePath: string, channelId: string, recentMessages: string): string {
|
||||
const channelPath = `${workspacePath}/${channelId}`;
|
||||
|
||||
return `You are mom, a helpful Slack bot assistant.
|
||||
|
||||
## Communication Style
|
||||
|
|
@ -59,46 +61,36 @@ function buildSystemPrompt(channelDir: string, scratchpadDir: string, recentMess
|
|||
- Links: <url|text>
|
||||
- Do NOT use **double asterisks** or [markdown](links)
|
||||
|
||||
## Channel Data
|
||||
The channel's data directory is: ${channelDir}
|
||||
## Your Workspace
|
||||
Your working directory is: ${channelPath}
|
||||
|
||||
### Message History
|
||||
- File: ${channelDir}/log.jsonl
|
||||
- Format: One JSON object per line (JSONL)
|
||||
- Each line has: {"ts", "user", "userName", "displayName", "text", "attachments", "isBot"}
|
||||
- "ts" is the Slack timestamp
|
||||
- "user" is the user ID, "userName" is their handle, "displayName" is their full name
|
||||
- "attachments" is an array of {"original", "local"} where "local" is the path relative to the working directory
|
||||
- "isBot" is true for bot responses
|
||||
This is YOUR computer - you have full control. You can:
|
||||
- Install tools with the system package manager (apk, apt, etc.)
|
||||
- Configure tools and save credentials
|
||||
- Create files and directories as needed
|
||||
|
||||
### Channel Data
|
||||
- Message history: ${channelPath}/log.jsonl (JSONL format)
|
||||
- Attachments from users: ${channelPath}/attachments/
|
||||
|
||||
### Recent Messages (last 50)
|
||||
Below are the most recent messages. If you need more context, read ${channelDir}/log.jsonl directly.
|
||||
|
||||
${recentMessages}
|
||||
|
||||
### Attachments
|
||||
Files shared in the channel are stored in: ${channelDir}/attachments/
|
||||
The "local" field in attachments points to these files.
|
||||
|
||||
## Scratchpad
|
||||
Your temporary working directory is: ${scratchpadDir}
|
||||
Use this for any file operations. It will be deleted after you complete.
|
||||
|
||||
## Tools
|
||||
You have access to: read, edit, write, bash, attach tools.
|
||||
You have access to: bash, read, edit, write, attach tools.
|
||||
- bash: Run shell commands (this is your main tool)
|
||||
- read: Read files
|
||||
- edit: Edit files
|
||||
- write: Write new files
|
||||
- bash: Run shell commands
|
||||
- attach: Attach a file to your response (share files with the user)
|
||||
- edit: Edit files surgically
|
||||
- write: Create/overwrite files
|
||||
- attach: Share a file with the user in Slack
|
||||
|
||||
Each tool requires a "label" parameter - this is a brief description of what you're doing that will be shown to the user.
|
||||
Keep labels short and informative, e.g., "Reading message history" or "Searching for user's previous questions".
|
||||
Each tool requires a "label" parameter - brief description shown to the user.
|
||||
|
||||
## Guidelines
|
||||
- Be concise and helpful
|
||||
- If you need more conversation history beyond the recent messages above, read log.jsonl
|
||||
- Use the scratchpad for any temporary work
|
||||
- Use bash for most operations
|
||||
- If you need a tool, install it
|
||||
- If you need credentials, ask the user
|
||||
|
||||
## CRITICAL
|
||||
- DO NOT USE EMOJIS. KEEP YOUR RESPONSES AS SHORT AS POSSIBLE.
|
||||
|
|
@ -110,129 +102,127 @@ function truncate(text: string, maxLen: number): string {
|
|||
return text.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
|
||||
export function createAgentRunner(): AgentRunner {
|
||||
export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||
let agent: Agent | null = null;
|
||||
const executor = createExecutor(sandboxConfig);
|
||||
|
||||
return {
|
||||
async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<void> {
|
||||
// Create scratchpad
|
||||
const scratchpadDir = await mkdtemp(join(tmpdir(), "mom-scratchpad-"));
|
||||
// Ensure channel directory exists
|
||||
await mkdir(channelDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const recentMessages = getRecentMessages(channelDir, 50);
|
||||
const systemPrompt = buildSystemPrompt(channelDir, scratchpadDir, recentMessages);
|
||||
const channelId = ctx.message.channel;
|
||||
const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
|
||||
const recentMessages = getRecentMessages(channelDir, 50);
|
||||
const systemPrompt = buildSystemPrompt(workspacePath, channelId, recentMessages);
|
||||
|
||||
// Set up file upload function for the attach tool
|
||||
setUploadFunction(async (filePath: string, title?: string) => {
|
||||
await ctx.uploadFile(filePath, title);
|
||||
});
|
||||
// Set up file upload function for the attach tool
|
||||
// For Docker, we need to translate paths back to host
|
||||
setUploadFunction(async (filePath: string, title?: string) => {
|
||||
const hostPath = translateToHostPath(filePath, channelDir, workspacePath, channelId);
|
||||
await ctx.uploadFile(hostPath, title);
|
||||
});
|
||||
|
||||
// Create ephemeral agent
|
||||
agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools: momTools,
|
||||
},
|
||||
transport: new ProviderTransport({
|
||||
getApiKey: async () => getAnthropicApiKey(),
|
||||
}),
|
||||
});
|
||||
// Create tools with executor
|
||||
const tools = createMomTools(executor);
|
||||
|
||||
// Subscribe to events
|
||||
agent.subscribe(async (event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
case "tool_execution_start": {
|
||||
const args = event.args as { label?: string };
|
||||
const label = args.label || event.toolName;
|
||||
// Create ephemeral agent
|
||||
agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools,
|
||||
},
|
||||
transport: new ProviderTransport({
|
||||
getApiKey: async () => getAnthropicApiKey(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Log to console
|
||||
console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`);
|
||||
// Subscribe to events
|
||||
agent.subscribe(async (event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
case "tool_execution_start": {
|
||||
const args = event.args as { label?: string };
|
||||
const label = args.label || event.toolName;
|
||||
|
||||
// Log to jsonl
|
||||
await store.logMessage(ctx.message.channel, {
|
||||
ts: Date.now().toString(),
|
||||
user: "bot",
|
||||
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
|
||||
attachments: [],
|
||||
isBot: true,
|
||||
});
|
||||
// Log to console
|
||||
console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`);
|
||||
|
||||
// Show only label to user (italic)
|
||||
await ctx.respond(`_${label}_`);
|
||||
break;
|
||||
}
|
||||
// Log to jsonl
|
||||
await store.logMessage(ctx.message.channel, {
|
||||
ts: Date.now().toString(),
|
||||
user: "bot",
|
||||
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
|
||||
attachments: [],
|
||||
isBot: true,
|
||||
});
|
||||
|
||||
case "tool_execution_end": {
|
||||
const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
|
||||
|
||||
// Log to console
|
||||
console.log(`[Tool Result] ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}\n`);
|
||||
|
||||
// Log to jsonl
|
||||
await store.logMessage(ctx.message.channel, {
|
||||
ts: Date.now().toString(),
|
||||
user: "bot",
|
||||
text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
|
||||
attachments: [],
|
||||
isBot: true,
|
||||
});
|
||||
|
||||
// Show brief status to user (only on error)
|
||||
if (event.isError) {
|
||||
await ctx.respond(`_Error: ${truncate(resultStr, 200)}_`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_update": {
|
||||
const ev = event.assistantMessageEvent;
|
||||
// Stream deltas to console
|
||||
if (ev.type === "text_delta") {
|
||||
process.stdout.write(ev.delta);
|
||||
} else if (ev.type === "thinking_delta") {
|
||||
process.stdout.write(ev.delta);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_start":
|
||||
if (event.message.role === "assistant") {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
break;
|
||||
|
||||
case "message_end":
|
||||
if (event.message.role === "assistant") {
|
||||
process.stdout.write("\n");
|
||||
// Extract text from assistant message
|
||||
const content = event.message.content;
|
||||
let text = "";
|
||||
for (const part of content) {
|
||||
if (part.type === "text") {
|
||||
text += part.text;
|
||||
}
|
||||
}
|
||||
if (text.trim()) {
|
||||
await ctx.respond(text);
|
||||
}
|
||||
}
|
||||
break;
|
||||
// Show only label to user (italic)
|
||||
await ctx.respond(`_${label}_`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Run the agent with user's message
|
||||
await agent.prompt(ctx.message.text || "(attached files)");
|
||||
} finally {
|
||||
agent = null;
|
||||
// Cleanup scratchpad
|
||||
try {
|
||||
rmSync(scratchpadDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
case "tool_execution_end": {
|
||||
const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
|
||||
|
||||
// Log to console
|
||||
console.log(`[Tool Result] ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}\n`);
|
||||
|
||||
// Log to jsonl
|
||||
await store.logMessage(ctx.message.channel, {
|
||||
ts: Date.now().toString(),
|
||||
user: "bot",
|
||||
text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
|
||||
attachments: [],
|
||||
isBot: true,
|
||||
});
|
||||
|
||||
// Show brief status to user (only on error)
|
||||
if (event.isError) {
|
||||
await ctx.respond(`_Error: ${truncate(resultStr, 200)}_`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_update": {
|
||||
const ev = event.assistantMessageEvent;
|
||||
// Stream deltas to console
|
||||
if (ev.type === "text_delta") {
|
||||
process.stdout.write(ev.delta);
|
||||
} else if (ev.type === "thinking_delta") {
|
||||
process.stdout.write(ev.delta);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_start":
|
||||
if (event.message.role === "assistant") {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
break;
|
||||
|
||||
case "message_end":
|
||||
if (event.message.role === "assistant") {
|
||||
process.stdout.write("\n");
|
||||
// Extract text from assistant message
|
||||
const content = event.message.content;
|
||||
let text = "";
|
||||
for (const part of content) {
|
||||
if (part.type === "text") {
|
||||
text += part.text;
|
||||
}
|
||||
}
|
||||
if (text.trim()) {
|
||||
await ctx.respond(text);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Run the agent with user's message
|
||||
await agent.prompt(ctx.message.text || "(attached files)");
|
||||
},
|
||||
|
||||
abort(): void {
|
||||
|
|
@ -240,3 +230,27 @@ export function createAgentRunner(): AgentRunner {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate container path back to host path for file operations
|
||||
*/
|
||||
function translateToHostPath(
|
||||
containerPath: string,
|
||||
channelDir: string,
|
||||
workspacePath: string,
|
||||
channelId: string,
|
||||
): string {
|
||||
if (workspacePath === "/workspace") {
|
||||
// Docker mode - translate /workspace/channelId/... to host path
|
||||
const prefix = `/workspace/${channelId}/`;
|
||||
if (containerPath.startsWith(prefix)) {
|
||||
return join(channelDir, containerPath.slice(prefix.length));
|
||||
}
|
||||
// Maybe it's just /workspace/...
|
||||
if (containerPath.startsWith("/workspace/")) {
|
||||
return join(channelDir, "..", containerPath.slice("/workspace/".length));
|
||||
}
|
||||
}
|
||||
// Host mode or already a host path
|
||||
return containerPath;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,24 +2,60 @@
|
|||
|
||||
import { join, resolve } from "path";
|
||||
import { type AgentRunner, createAgentRunner } from "./agent.js";
|
||||
import { parseSandboxArg, type SandboxConfig, validateSandbox } from "./sandbox.js";
|
||||
import { MomBot, type SlackContext } from "./slack.js";
|
||||
|
||||
console.log("Starting mom bot...");
|
||||
|
||||
const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
|
||||
const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
|
||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||
const ANTHROPIC_OAUTH_TOKEN = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 1) {
|
||||
console.error("Usage: mom <working-directory>");
|
||||
console.error("Example: mom ./mom-data");
|
||||
process.exit(1);
|
||||
function parseArgs(): { workingDir: string; sandbox: SandboxConfig } {
|
||||
const args = process.argv.slice(2);
|
||||
let sandbox: SandboxConfig = { type: "host" };
|
||||
let workingDir: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg.startsWith("--sandbox=")) {
|
||||
sandbox = parseSandboxArg(arg.slice("--sandbox=".length));
|
||||
} else if (arg === "--sandbox") {
|
||||
const next = args[++i];
|
||||
if (!next) {
|
||||
console.error("Error: --sandbox requires a value (host or docker:<container-name>)");
|
||||
process.exit(1);
|
||||
}
|
||||
sandbox = parseSandboxArg(next);
|
||||
} else if (!arg.startsWith("-")) {
|
||||
workingDir = arg;
|
||||
} else {
|
||||
console.error(`Unknown option: ${arg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!workingDir) {
|
||||
console.error("Usage: mom [--sandbox=host|docker:<container-name>] <working-directory>");
|
||||
console.error("");
|
||||
console.error("Options:");
|
||||
console.error(" --sandbox=host Run tools directly on host (default)");
|
||||
console.error(" --sandbox=docker:<container> Run tools in Docker container");
|
||||
console.error("");
|
||||
console.error("Examples:");
|
||||
console.error(" mom ./data");
|
||||
console.error(" mom --sandbox=docker:mom-sandbox ./data");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { workingDir: resolve(workingDir), sandbox };
|
||||
}
|
||||
|
||||
const workingDir = resolve(args[0]);
|
||||
const { workingDir, sandbox } = parseArgs();
|
||||
|
||||
console.log("Starting mom bot...");
|
||||
console.log(` Working directory: ${workingDir}`);
|
||||
console.log(` Sandbox: ${sandbox.type === "host" ? "host" : `docker:${sandbox.container}`}`);
|
||||
|
||||
if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN)) {
|
||||
console.error("Missing required environment variables:");
|
||||
|
|
@ -29,6 +65,9 @@ if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTH
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate sandbox configuration
|
||||
await validateSandbox(sandbox);
|
||||
|
||||
// Track active agent runs per channel
|
||||
const activeRuns = new Map<string, AgentRunner>();
|
||||
|
||||
|
|
@ -58,7 +97,7 @@ async function handleMessage(ctx: SlackContext, source: "channel" | "dm"): Promi
|
|||
console.log(`${source === "channel" ? "Channel mention" : "DM"} from <@${ctx.message.user}>: ${ctx.message.text}`);
|
||||
const channelDir = join(workingDir, channelId);
|
||||
|
||||
const runner = createAgentRunner();
|
||||
const runner = createAgentRunner(sandbox);
|
||||
activeRuns.set(channelId, runner);
|
||||
|
||||
await ctx.setTyping(true);
|
||||
|
|
|
|||
221
packages/mom/src/sandbox.ts
Normal file
221
packages/mom/src/sandbox.ts
Normal 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, "'\\''")}'`;
|
||||
}
|
||||
|
|
@ -1,65 +1,6 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { spawn } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
/**
|
||||
* Get shell configuration based on platform
|
||||
*/
|
||||
function getShellConfig(): { shell: string; args: string[] } {
|
||||
if (process.platform === "win32") {
|
||||
const paths: string[] = [];
|
||||
const programFiles = process.env.ProgramFiles;
|
||||
if (programFiles) {
|
||||
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
||||
if (programFilesX86) {
|
||||
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
if (existsSync(path)) {
|
||||
return { shell: path, args: ["-c"] };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win\n` +
|
||||
`Searched in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return { shell: "sh", args: ["-c"] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a process and all its children
|
||||
*/
|
||||
function killProcessTree(pid: number): void {
|
||||
if (process.platform === "win32") {
|
||||
// Use taskkill on Windows to kill process tree
|
||||
try {
|
||||
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors if taskkill fails
|
||||
}
|
||||
} else {
|
||||
// Use SIGKILL on Unix/Linux/Mac
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch {
|
||||
// Fallback to killing just the child if process group kill fails
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Process already dead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
import type { Executor } from "../sandbox.js";
|
||||
|
||||
const bashSchema = Type.Object({
|
||||
label: Type.String({ description: "Brief description of what this command does (shown to user)" }),
|
||||
|
|
@ -67,123 +8,31 @@ const bashSchema = Type.Object({
|
|||
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
||||
});
|
||||
|
||||
export const bashTool: AgentTool<typeof bashSchema> = {
|
||||
name: "bash",
|
||||
label: "bash",
|
||||
description:
|
||||
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
|
||||
parameters: bashSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ command, timeout }: { label: string; command: string; timeout?: number },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { shell, args } = getShellConfig();
|
||||
const child = spawn(shell, [...args, command], {
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
|
||||
// Set timeout if provided
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
onAbort();
|
||||
}, timeout * 1000);
|
||||
export function createBashTool(executor: Executor): AgentTool<typeof bashSchema> {
|
||||
return {
|
||||
name: "bash",
|
||||
label: "bash",
|
||||
description:
|
||||
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
|
||||
parameters: bashSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ command, timeout }: { label: string; command: string; timeout?: number },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const result = await executor.exec(command, { timeout, signal });
|
||||
let output = "";
|
||||
if (result.stdout) output += result.stdout;
|
||||
if (result.stderr) {
|
||||
if (output) output += "\n";
|
||||
output += result.stderr;
|
||||
}
|
||||
|
||||
// Collect stdout
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
// Limit buffer size
|
||||
if (stdout.length > 10 * 1024 * 1024) {
|
||||
stdout = stdout.slice(0, 10 * 1024 * 1024);
|
||||
}
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`${output}\n\nCommand exited with code ${result.code}`.trim());
|
||||
}
|
||||
|
||||
// Collect stderr
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
// Limit buffer size
|
||||
if (stderr.length > 10 * 1024 * 1024) {
|
||||
stderr = stderr.slice(0, 10 * 1024 * 1024);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle process exit
|
||||
child.on("close", (code) => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
let output = "";
|
||||
if (stdout) output += stdout;
|
||||
if (stderr) {
|
||||
if (output) output += "\n";
|
||||
output += stderr;
|
||||
}
|
||||
if (output) output += "\n\n";
|
||||
output += "Command aborted";
|
||||
reject(new Error(output));
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
let output = "";
|
||||
if (stdout) output += stdout;
|
||||
if (stderr) {
|
||||
if (output) output += "\n";
|
||||
output += stderr;
|
||||
}
|
||||
if (output) output += "\n\n";
|
||||
output += `Command timed out after ${timeout} seconds`;
|
||||
reject(new Error(output));
|
||||
return;
|
||||
}
|
||||
|
||||
let output = "";
|
||||
if (stdout) output += stdout;
|
||||
if (stderr) {
|
||||
if (output) output += "\n";
|
||||
output += stderr;
|
||||
}
|
||||
|
||||
if (code !== 0 && code !== null) {
|
||||
if (output) output += "\n\n";
|
||||
reject(new Error(`${output}Command exited with code ${code}`));
|
||||
} else {
|
||||
resolve({ content: [{ type: "text", text: output || "(no output)" }], details: undefined });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle abort signal - kill entire process tree
|
||||
const onAbort = () => {
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
return { content: [{ type: "text", text: output || "(no output)" }], details: undefined };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,7 @@
|
|||
import * as os from "node:os";
|
||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import * as Diff from "diff";
|
||||
import { constants } from "fs";
|
||||
import { access, readFile, writeFile } from "fs/promises";
|
||||
import { resolve as resolvePath } from "path";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return os.homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
import type { Executor } from "../sandbox.js";
|
||||
|
||||
/**
|
||||
* Generate a unified diff string with line numbers and context
|
||||
|
|
@ -43,14 +27,12 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
|
|||
}
|
||||
|
||||
if (part.added || part.removed) {
|
||||
// Show the change
|
||||
for (const line of raw) {
|
||||
if (part.added) {
|
||||
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(`+${lineNum} ${line}`);
|
||||
newLineNum++;
|
||||
} else {
|
||||
// removed
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(`-${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
|
|
@ -58,28 +40,23 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
|
|||
}
|
||||
lastWasChange = true;
|
||||
} else {
|
||||
// Context lines - only show a few before/after changes
|
||||
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
|
||||
|
||||
if (lastWasChange || nextPartIsChange) {
|
||||
// Show context
|
||||
let linesToShow = raw;
|
||||
let skipStart = 0;
|
||||
let skipEnd = 0;
|
||||
|
||||
if (!lastWasChange) {
|
||||
// Show only last N lines as leading context
|
||||
skipStart = Math.max(0, raw.length - contextLines);
|
||||
linesToShow = raw.slice(skipStart);
|
||||
}
|
||||
|
||||
if (!nextPartIsChange && linesToShow.length > contextLines) {
|
||||
// Show only first N lines as trailing context
|
||||
skipEnd = linesToShow.length - contextLines;
|
||||
linesToShow = linesToShow.slice(0, contextLines);
|
||||
}
|
||||
|
||||
// Add ellipsis if we skipped lines at start
|
||||
if (skipStart > 0) {
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
}
|
||||
|
|
@ -91,16 +68,13 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
|
|||
newLineNum++;
|
||||
}
|
||||
|
||||
// Add ellipsis if we skipped lines at end
|
||||
if (skipEnd > 0) {
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
}
|
||||
|
||||
// Update line numbers for skipped lines
|
||||
oldLineNum += skipStart + skipEnd;
|
||||
newLineNum += skipStart + skipEnd;
|
||||
} else {
|
||||
// Skip these context lines entirely
|
||||
oldLineNum += raw.length;
|
||||
newLineNum += raw.length;
|
||||
}
|
||||
|
|
@ -119,151 +93,73 @@ const editSchema = Type.Object({
|
|||
newText: Type.String({ description: "New text to replace the old text with" }),
|
||||
});
|
||||
|
||||
export const editTool: AgentTool<typeof editSchema> = {
|
||||
name: "edit",
|
||||
label: "edit",
|
||||
description:
|
||||
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
|
||||
parameters: editSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const absolutePath = resolvePath(expandPath(path));
|
||||
|
||||
return new Promise<{
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: { diff: string } | undefined;
|
||||
}>((resolve, reject) => {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
export function createEditTool(executor: Executor): AgentTool<typeof editSchema> {
|
||||
return {
|
||||
name: "edit",
|
||||
label: "edit",
|
||||
description:
|
||||
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
|
||||
parameters: editSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
// Read the file
|
||||
const readResult = await executor.exec(`cat ${shellEscape(path)}`, { signal });
|
||||
if (readResult.code !== 0) {
|
||||
throw new Error(readResult.stderr || `File not found: ${path}`);
|
||||
}
|
||||
|
||||
let aborted = false;
|
||||
const content = readResult.stdout;
|
||||
|
||||
// Set up abort handler
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
// Check if old text exists
|
||||
if (!content.includes(oldText)) {
|
||||
throw new Error(
|
||||
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Count occurrences
|
||||
const occurrences = content.split(oldText).length - 1;
|
||||
|
||||
if (occurrences > 1) {
|
||||
throw new Error(
|
||||
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Perform replacement
|
||||
const index = content.indexOf(oldText);
|
||||
const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
|
||||
|
||||
if (content === newContent) {
|
||||
throw new Error(
|
||||
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Write the file back
|
||||
const writeResult = await executor.exec(`printf '%s' ${shellEscape(newContent)} > ${shellEscape(path)}`, {
|
||||
signal,
|
||||
});
|
||||
if (writeResult.code !== 0) {
|
||||
throw new Error(writeResult.stderr || `Failed to write file: ${path}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
|
||||
},
|
||||
],
|
||||
details: { diff: generateDiffString(content, newContent) },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
// Perform the edit operation
|
||||
(async () => {
|
||||
try {
|
||||
// Check if file exists
|
||||
try {
|
||||
await access(absolutePath, constants.R_OK | constants.W_OK);
|
||||
} catch {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(new Error(`File not found: ${path}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if aborted before reading
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const content = await readFile(absolutePath, "utf-8");
|
||||
|
||||
// Check if aborted after reading
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if old text exists
|
||||
if (!content.includes(oldText)) {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Count occurrences
|
||||
const occurrences = content.split(oldText).length - 1;
|
||||
|
||||
if (occurrences > 1) {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if aborted before writing
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform replacement using indexOf + substring (raw string replace, no special character interpretation)
|
||||
// String.replace() interprets $ in the replacement string, so we do manual replacement
|
||||
const index = content.indexOf(oldText);
|
||||
const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
|
||||
|
||||
// Verify the replacement actually changed something
|
||||
if (content === newContent) {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(absolutePath, newContent, "utf-8");
|
||||
|
||||
// Check if aborted after writing
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
|
||||
},
|
||||
],
|
||||
details: { diff: generateDiffString(content, newContent) },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!aborted) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
function shellEscape(s: string): string {
|
||||
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
export { attachTool, setUploadFunction } from "./attach.js";
|
||||
export { bashTool } from "./bash.js";
|
||||
export { editTool } from "./edit.js";
|
||||
export { readTool } from "./read.js";
|
||||
export { writeTool } from "./write.js";
|
||||
|
||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import type { Executor } from "../sandbox.js";
|
||||
import { attachTool } from "./attach.js";
|
||||
import { bashTool } from "./bash.js";
|
||||
import { editTool } from "./edit.js";
|
||||
import { readTool } from "./read.js";
|
||||
import { writeTool } from "./write.js";
|
||||
import { createBashTool } from "./bash.js";
|
||||
import { createEditTool } from "./edit.js";
|
||||
import { createReadTool } from "./read.js";
|
||||
import { createWriteTool } from "./write.js";
|
||||
|
||||
export const momTools = [readTool, bashTool, editTool, writeTool, attachTool];
|
||||
export { setUploadFunction } from "./attach.js";
|
||||
|
||||
export function createMomTools(executor: Executor): AgentTool<any>[] {
|
||||
return [
|
||||
createReadTool(executor),
|
||||
createBashTool(executor),
|
||||
createEditTool(executor),
|
||||
createWriteTool(executor),
|
||||
attachTool,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,7 @@
|
|||
import * as os from "node:os";
|
||||
import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { constants } from "fs";
|
||||
import { access, readFile } from "fs/promises";
|
||||
import { extname, resolve as resolvePath } from "path";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return os.homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
import { extname } from "path";
|
||||
import type { Executor } from "../sandbox.js";
|
||||
|
||||
/**
|
||||
* Map of file extensions to MIME types for common image formats
|
||||
|
|
@ -47,133 +32,96 @@ const readSchema = Type.Object({
|
|||
const MAX_LINES = 2000;
|
||||
const MAX_LINE_LENGTH = 2000;
|
||||
|
||||
export const readTool: AgentTool<typeof readSchema> = {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description:
|
||||
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
|
||||
parameters: readSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const absolutePath = resolvePath(expandPath(path));
|
||||
const mimeType = isImageFile(absolutePath);
|
||||
export function createReadTool(executor: Executor): AgentTool<typeof readSchema> {
|
||||
return {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description:
|
||||
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
|
||||
parameters: readSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const mimeType = isImageFile(path);
|
||||
|
||||
return new Promise<{ content: (TextContent | ImageContent)[]; details: undefined }>((resolve, reject) => {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
let aborted = false;
|
||||
|
||||
// Set up abort handler
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
// Perform the read operation
|
||||
(async () => {
|
||||
try {
|
||||
// Check if file exists
|
||||
await access(absolutePath, constants.R_OK);
|
||||
|
||||
// Check if aborted before reading
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the file based on type
|
||||
let content: (TextContent | ImageContent)[];
|
||||
|
||||
if (mimeType) {
|
||||
// Read as image (binary)
|
||||
const buffer = await readFile(absolutePath);
|
||||
const base64 = buffer.toString("base64");
|
||||
|
||||
content = [
|
||||
{ type: "text", text: `Read image file [${mimeType}]` },
|
||||
{ type: "image", data: base64, mimeType },
|
||||
];
|
||||
} else {
|
||||
// Read as text
|
||||
const textContent = await readFile(absolutePath, "utf-8");
|
||||
const lines = textContent.split("\n");
|
||||
|
||||
// Apply offset and limit (matching Claude Code Read tool behavior)
|
||||
const startLine = offset ? Math.max(0, offset - 1) : 0; // 1-indexed to 0-indexed
|
||||
const maxLines = limit || MAX_LINES;
|
||||
const endLine = Math.min(startLine + maxLines, lines.length);
|
||||
|
||||
// Check if offset is out of bounds
|
||||
if (startLine >= lines.length) {
|
||||
throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
|
||||
}
|
||||
|
||||
// Get the relevant lines
|
||||
const selectedLines = lines.slice(startLine, endLine);
|
||||
|
||||
// Truncate long lines and track which were truncated
|
||||
let hadTruncatedLines = false;
|
||||
const formattedLines = selectedLines.map((line) => {
|
||||
if (line.length > MAX_LINE_LENGTH) {
|
||||
hadTruncatedLines = true;
|
||||
return line.slice(0, MAX_LINE_LENGTH);
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
let outputText = formattedLines.join("\n");
|
||||
|
||||
// Add notices
|
||||
const notices: string[] = [];
|
||||
|
||||
if (hadTruncatedLines) {
|
||||
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
|
||||
}
|
||||
|
||||
if (endLine < lines.length) {
|
||||
const remaining = lines.length - endLine;
|
||||
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
|
||||
}
|
||||
|
||||
if (notices.length > 0) {
|
||||
outputText += `\n\n... (${notices.join(". ")})`;
|
||||
}
|
||||
|
||||
content = [{ type: "text", text: outputText }];
|
||||
}
|
||||
|
||||
// Check if aborted after reading
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
resolve({ content, details: undefined });
|
||||
} catch (error: unknown) {
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!aborted) {
|
||||
reject(error);
|
||||
}
|
||||
if (mimeType) {
|
||||
// Read as image (binary) - use base64
|
||||
const result = await executor.exec(`base64 < ${shellEscape(path)}`, { signal });
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `Failed to read file: ${path}`);
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
const base64 = result.stdout.replace(/\s/g, ""); // Remove whitespace from base64
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Read image file [${mimeType}]` },
|
||||
{ type: "image", data: base64, mimeType },
|
||||
] as (TextContent | ImageContent)[],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Read as text using cat with offset/limit via sed/head/tail
|
||||
let cmd: string;
|
||||
const startLine = offset ? Math.max(1, offset) : 1;
|
||||
const maxLines = limit || MAX_LINES;
|
||||
|
||||
if (startLine === 1) {
|
||||
cmd = `head -n ${maxLines} ${shellEscape(path)}`;
|
||||
} else {
|
||||
cmd = `sed -n '${startLine},${startLine + maxLines - 1}p' ${shellEscape(path)}`;
|
||||
}
|
||||
|
||||
// Also get total line count
|
||||
const countResult = await executor.exec(`wc -l < ${shellEscape(path)}`, { signal });
|
||||
const totalLines = Number.parseInt(countResult.stdout.trim(), 10) || 0;
|
||||
|
||||
const result = await executor.exec(cmd, { signal });
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `Failed to read file: ${path}`);
|
||||
}
|
||||
|
||||
const lines = result.stdout.split("\n");
|
||||
|
||||
// Truncate long lines
|
||||
let hadTruncatedLines = false;
|
||||
const formattedLines = lines.map((line) => {
|
||||
if (line.length > MAX_LINE_LENGTH) {
|
||||
hadTruncatedLines = true;
|
||||
return line.slice(0, MAX_LINE_LENGTH);
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
let outputText = formattedLines.join("\n");
|
||||
|
||||
// Add notices
|
||||
const notices: string[] = [];
|
||||
const endLine = startLine + lines.length - 1;
|
||||
|
||||
if (hadTruncatedLines) {
|
||||
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
|
||||
}
|
||||
|
||||
if (endLine < totalLines) {
|
||||
const remaining = totalLines - endLine;
|
||||
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
|
||||
}
|
||||
|
||||
if (notices.length > 0) {
|
||||
outputText += `\n\n... (${notices.join(". ")})`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: outputText }] as (TextContent | ImageContent)[],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shellEscape(s: string): string {
|
||||
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,6 @@
|
|||
import * as os from "node:os";
|
||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import { dirname, resolve as resolvePath } from "path";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return os.homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
import type { Executor } from "../sandbox.js";
|
||||
|
||||
const writeSchema = Type.Object({
|
||||
label: Type.String({ description: "Brief description of what you're writing (shown to user)" }),
|
||||
|
|
@ -23,78 +8,38 @@ const writeSchema = Type.Object({
|
|||
content: Type.String({ description: "Content to write to the file" }),
|
||||
});
|
||||
|
||||
export const writeTool: AgentTool<typeof writeSchema> = {
|
||||
name: "write",
|
||||
label: "write",
|
||||
description:
|
||||
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
||||
parameters: writeSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, content }: { label: string; path: string; content: string },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const absolutePath = resolvePath(expandPath(path));
|
||||
const dir = dirname(absolutePath);
|
||||
export function createWriteTool(executor: Executor): AgentTool<typeof writeSchema> {
|
||||
return {
|
||||
name: "write",
|
||||
label: "write",
|
||||
description:
|
||||
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
||||
parameters: writeSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, content }: { label: string; path: string; content: string },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
// Create parent directories and write file using heredoc
|
||||
const dir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : ".";
|
||||
|
||||
return new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>((resolve, reject) => {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
// Use printf to handle content with special characters, pipe to file
|
||||
// This avoids issues with heredoc and special characters
|
||||
const cmd = `mkdir -p ${shellEscape(dir)} && printf '%s' ${shellEscape(content)} > ${shellEscape(path)}`;
|
||||
|
||||
const result = await executor.exec(cmd, { signal });
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `Failed to write file: ${path}`);
|
||||
}
|
||||
|
||||
let aborted = false;
|
||||
|
||||
// Set up abort handler
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
// Perform the write operation
|
||||
(async () => {
|
||||
try {
|
||||
// Create parent directories if needed
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
// Check if aborted before writing
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Write the file
|
||||
await writeFile(absolutePath, content, "utf-8");
|
||||
|
||||
// Check if aborted after writing
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
resolve({
|
||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||
details: undefined,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Clean up abort handler
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!aborted) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
function shellEscape(s: string): string {
|
||||
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue