feat(coding-agent): expose local bash operations closes #2299
This commit is contained in:
@@ -641,6 +641,8 @@ pi.on("tool_result", async (event, ctx) => {
|
|||||||
Fired when user executes `!` or `!!` commands. **Can intercept.**
|
Fired when user executes `!` or `!!` commands. **Can intercept.**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
import { createLocalBashOperations } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
pi.on("user_bash", (event, ctx) => {
|
pi.on("user_bash", (event, ctx) => {
|
||||||
// event.command - the bash command
|
// event.command - the bash command
|
||||||
// event.excludeFromContext - true if !! prefix
|
// event.excludeFromContext - true if !! prefix
|
||||||
@@ -649,7 +651,17 @@ pi.on("user_bash", (event, ctx) => {
|
|||||||
// Option 1: Provide custom operations (e.g., SSH)
|
// Option 1: Provide custom operations (e.g., SSH)
|
||||||
return { operations: remoteBashOps };
|
return { operations: remoteBashOps };
|
||||||
|
|
||||||
// Option 2: Full replacement - return result directly
|
// Option 2: Wrap pi's built-in local bash backend
|
||||||
|
const local = createLocalBashOperations();
|
||||||
|
return {
|
||||||
|
operations: {
|
||||||
|
exec(command, cwd, options) {
|
||||||
|
return local.exec(`source ~/.profile\n${command}`, cwd, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Option 3: Full replacement - return result directly
|
||||||
return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } };
|
return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } };
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -1465,6 +1477,8 @@ pi.registerTool({
|
|||||||
|
|
||||||
**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations`
|
**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations`
|
||||||
|
|
||||||
|
For `user_bash`, extensions can reuse pi's local shell backend via `createLocalBashOperations()` instead of reimplementing local process spawning, shell resolution, and process-tree termination.
|
||||||
|
|
||||||
The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution:
|
The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import { randomBytes } from "node:crypto";
|
|||||||
import { createWriteStream, type WriteStream } from "node:fs";
|
import { createWriteStream, type WriteStream } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { type ChildProcess, spawn } from "child_process";
|
|
||||||
import stripAnsi from "strip-ansi";
|
import stripAnsi from "strip-ansi";
|
||||||
import { getShellConfig, getShellEnv, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
|
import { sanitizeBinaryOutput } from "../utils/shell.js";
|
||||||
import type { BashOperations } from "./tools/bash.js";
|
import { type BashOperations, createLocalBashOperations } from "./tools/bash.js";
|
||||||
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
|
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -47,137 +46,18 @@ export interface BashResult {
|
|||||||
/**
|
/**
|
||||||
* Execute a bash command with optional streaming and cancellation support.
|
* Execute a bash command with optional streaming and cancellation support.
|
||||||
*
|
*
|
||||||
* Features:
|
* Uses the same local BashOperations backend as createBashTool() so interactive
|
||||||
* - Streams sanitized output via onChunk callback
|
* user bash and tool-invoked bash share the same process spawning behavior.
|
||||||
* - Writes large output to temp file for later retrieval
|
* Sanitization, newline normalization, temp-file capture, and truncation still
|
||||||
* - Supports cancellation via AbortSignal
|
* happen in executeBashWithOperations(), so reusing the local backend does not
|
||||||
* - Sanitizes output (strips ANSI, removes binary garbage, normalizes newlines)
|
* change output processing behavior.
|
||||||
* - Truncates output if it exceeds the default max bytes
|
|
||||||
*
|
*
|
||||||
* @param command - The bash command to execute
|
* @param command - The bash command to execute
|
||||||
* @param options - Optional streaming callback and abort signal
|
* @param options - Optional streaming callback and abort signal
|
||||||
* @returns Promise resolving to execution result
|
* @returns Promise resolving to execution result
|
||||||
*/
|
*/
|
||||||
export function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
|
export function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return executeBashWithOperations(command, process.cwd(), createLocalBashOperations(), options);
|
||||||
const { shell, args } = getShellConfig();
|
|
||||||
const child: ChildProcess = spawn(shell, [...args, command], {
|
|
||||||
detached: true,
|
|
||||||
env: getShellEnv(),
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track sanitized output for truncation
|
|
||||||
const outputChunks: string[] = [];
|
|
||||||
let outputBytes = 0;
|
|
||||||
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
|
|
||||||
|
|
||||||
// Temp file for large output
|
|
||||||
let tempFilePath: string | undefined;
|
|
||||||
let tempFileStream: WriteStream | undefined;
|
|
||||||
let totalBytes = 0;
|
|
||||||
|
|
||||||
// Handle abort signal
|
|
||||||
const abortHandler = () => {
|
|
||||||
if (child.pid) {
|
|
||||||
killProcessTree(child.pid);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options?.signal) {
|
|
||||||
if (options.signal.aborted) {
|
|
||||||
// Already aborted, don't even start
|
|
||||||
child.kill();
|
|
||||||
resolve({
|
|
||||||
output: "",
|
|
||||||
exitCode: undefined,
|
|
||||||
cancelled: true,
|
|
||||||
truncated: false,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
|
|
||||||
const handleData = (data: Buffer) => {
|
|
||||||
totalBytes += data.length;
|
|
||||||
|
|
||||||
// Sanitize once at the source: strip ANSI, replace binary garbage, normalize newlines
|
|
||||||
const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(data, { stream: true }))).replace(/\r/g, "");
|
|
||||||
|
|
||||||
// Start writing to temp file if exceeds threshold
|
|
||||||
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
|
|
||||||
const id = randomBytes(8).toString("hex");
|
|
||||||
tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
|
|
||||||
tempFileStream = createWriteStream(tempFilePath);
|
|
||||||
// Write already-buffered chunks to temp file
|
|
||||||
for (const chunk of outputChunks) {
|
|
||||||
tempFileStream.write(chunk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tempFileStream) {
|
|
||||||
tempFileStream.write(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep rolling buffer of sanitized text
|
|
||||||
outputChunks.push(text);
|
|
||||||
outputBytes += text.length;
|
|
||||||
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
|
|
||||||
const removed = outputChunks.shift()!;
|
|
||||||
outputBytes -= removed.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream to callback if provided
|
|
||||||
if (options?.onChunk) {
|
|
||||||
options.onChunk(text);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
child.stdout?.on("data", handleData);
|
|
||||||
child.stderr?.on("data", handleData);
|
|
||||||
|
|
||||||
child.on("close", (code) => {
|
|
||||||
// Clean up abort listener
|
|
||||||
if (options?.signal) {
|
|
||||||
options.signal.removeEventListener("abort", abortHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tempFileStream) {
|
|
||||||
tempFileStream.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine buffered chunks for truncation (already sanitized)
|
|
||||||
const fullOutput = outputChunks.join("");
|
|
||||||
const truncationResult = truncateTail(fullOutput);
|
|
||||||
|
|
||||||
// code === null means killed (cancelled)
|
|
||||||
const cancelled = code === null;
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
output: truncationResult.truncated ? truncationResult.content : fullOutput,
|
|
||||||
exitCode: cancelled ? undefined : code,
|
|
||||||
cancelled,
|
|
||||||
truncated: truncationResult.truncated,
|
|
||||||
fullOutputPath: tempFilePath,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on("error", (err) => {
|
|
||||||
// Clean up abort listener
|
|
||||||
if (options?.signal) {
|
|
||||||
options.signal.removeEventListener("abort", abortHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tempFileStream) {
|
|
||||||
tempFileStream.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -53,88 +53,94 @@ export interface BashOperations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default bash operations using local shell
|
* Create bash operations using pi's built-in local shell execution backend.
|
||||||
|
*
|
||||||
|
* This is useful for extensions that intercept user_bash and want to keep
|
||||||
|
* pi's standard local shell behavior while still wrapping or rewriting
|
||||||
|
* commands before execution.
|
||||||
*/
|
*/
|
||||||
const defaultBashOperations: BashOperations = {
|
export function createLocalBashOperations(): BashOperations {
|
||||||
exec: (command, cwd, { onData, signal, timeout, env }) => {
|
return {
|
||||||
return new Promise((resolve, reject) => {
|
exec: (command, cwd, { onData, signal, timeout, env }) => {
|
||||||
const { shell, args } = getShellConfig();
|
return new Promise((resolve, reject) => {
|
||||||
|
const { shell, args } = getShellConfig();
|
||||||
|
|
||||||
if (!existsSync(cwd)) {
|
if (!existsSync(cwd)) {
|
||||||
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
|
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = spawn(shell, [...args, command], {
|
const child = spawn(shell, [...args, command], {
|
||||||
cwd,
|
cwd,
|
||||||
detached: true,
|
detached: true,
|
||||||
env: env ?? getShellEnv(),
|
env: env ?? getShellEnv(),
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
|
|
||||||
// Set timeout if provided
|
// Set timeout if provided
|
||||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||||
if (timeout !== undefined && timeout > 0) {
|
if (timeout !== undefined && timeout > 0) {
|
||||||
timeoutHandle = setTimeout(() => {
|
timeoutHandle = setTimeout(() => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
|
if (child.pid) {
|
||||||
|
killProcessTree(child.pid);
|
||||||
|
}
|
||||||
|
}, timeout * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream stdout and stderr
|
||||||
|
if (child.stdout) {
|
||||||
|
child.stdout.on("data", onData);
|
||||||
|
}
|
||||||
|
if (child.stderr) {
|
||||||
|
child.stderr.on("data", onData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shell spawn errors
|
||||||
|
child.on("error", (err) => {
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
if (signal) signal.removeEventListener("abort", onAbort);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle abort signal - kill entire process tree
|
||||||
|
const onAbort = () => {
|
||||||
if (child.pid) {
|
if (child.pid) {
|
||||||
killProcessTree(child.pid);
|
killProcessTree(child.pid);
|
||||||
}
|
}
|
||||||
}, timeout * 1000);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Stream stdout and stderr
|
if (signal) {
|
||||||
if (child.stdout) {
|
if (signal.aborted) {
|
||||||
child.stdout.on("data", onData);
|
onAbort();
|
||||||
}
|
} else {
|
||||||
if (child.stderr) {
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
child.stderr.on("data", onData);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle shell spawn errors
|
// Handle process exit
|
||||||
child.on("error", (err) => {
|
child.on("close", (code) => {
|
||||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
if (signal) signal.removeEventListener("abort", onAbort);
|
if (signal) signal.removeEventListener("abort", onAbort);
|
||||||
reject(err);
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(new Error("aborted"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timedOut) {
|
||||||
|
reject(new Error(`timeout:${timeout}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ exitCode: code });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
},
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle process exit
|
|
||||||
child.on("close", (code) => {
|
|
||||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
||||||
if (signal) signal.removeEventListener("abort", onAbort);
|
|
||||||
|
|
||||||
if (signal?.aborted) {
|
|
||||||
reject(new Error("aborted"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timedOut) {
|
|
||||||
reject(new Error(`timeout:${timeout}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({ exitCode: code });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface BashSpawnContext {
|
export interface BashSpawnContext {
|
||||||
command: string;
|
command: string;
|
||||||
@@ -164,7 +170,7 @@ export interface BashToolOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
|
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
|
||||||
const ops = options?.operations ?? defaultBashOperations;
|
const ops = options?.operations ?? createLocalBashOperations();
|
||||||
const commandPrefix = options?.commandPrefix;
|
const commandPrefix = options?.commandPrefix;
|
||||||
const spawnHook = options?.spawnHook;
|
const spawnHook = options?.spawnHook;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export {
|
|||||||
type BashToolOptions,
|
type BashToolOptions,
|
||||||
bashTool,
|
bashTool,
|
||||||
createBashTool,
|
createBashTool,
|
||||||
|
createLocalBashOperations,
|
||||||
} from "./bash.js";
|
} from "./bash.js";
|
||||||
export {
|
export {
|
||||||
createEditTool,
|
createEditTool,
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export {
|
|||||||
type BashToolOptions,
|
type BashToolOptions,
|
||||||
bashTool,
|
bashTool,
|
||||||
codingTools,
|
codingTools,
|
||||||
|
createLocalBashOperations,
|
||||||
DEFAULT_MAX_BYTES,
|
DEFAULT_MAX_BYTES,
|
||||||
DEFAULT_MAX_LINES,
|
DEFAULT_MAX_LINES,
|
||||||
type EditOperations,
|
type EditOperations,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { bashTool, createBashTool } from "../src/core/tools/bash.js";
|
import { executeBash } from "../src/core/bash-executor.js";
|
||||||
|
import { bashTool, createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
|
||||||
import { editTool } from "../src/core/tools/edit.js";
|
import { editTool } from "../src/core/tools/edit.js";
|
||||||
import { findTool } from "../src/core/tools/find.js";
|
import { findTool } from "../src/core/tools/find.js";
|
||||||
import { grepTool } from "../src/core/tools/grep.js";
|
import { grepTool } from "../src/core/tools/grep.js";
|
||||||
@@ -324,6 +325,26 @@ describe("Coding Agent Tools", () => {
|
|||||||
const result = await bashWithoutPrefix.execute("test-prefix-3", { command: "echo no-prefix" });
|
const result = await bashWithoutPrefix.execute("test-prefix-3", { command: "echo no-prefix" });
|
||||||
expect(getTextOutput(result).trim()).toBe("no-prefix");
|
expect(getTextOutput(result).trim()).toBe("no-prefix");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should expose local bash operations for extension reuse", async () => {
|
||||||
|
const ops = createLocalBashOperations();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
const result = await ops.exec("echo $TEST_LOCAL_BASH_OPS", testDir, {
|
||||||
|
onData: (data) => chunks.push(data),
|
||||||
|
env: { ...process.env, TEST_LOCAL_BASH_OPS: "from-local-ops" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(Buffer.concat(chunks).toString("utf-8").trim()).toBe("from-local-ops");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve executeBash sanitization when using local bash operations", async () => {
|
||||||
|
const result = await executeBash("printf '\\033[31mred\\033[0m\\r\\n'");
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.output).toBe("red\n");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("grep tool", () => {
|
describe("grep tool", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user