From 9651e4114dd38ebcffd729b1892797841d690c2b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 18 Mar 2026 00:41:57 +0100 Subject: [PATCH] feat(coding-agent): expose local bash operations closes #2299 --- packages/coding-agent/docs/extensions.md | 16 +- .../coding-agent/src/core/bash-executor.ts | 136 +--------------- packages/coding-agent/src/core/tools/bash.ts | 150 +++++++++--------- packages/coding-agent/src/core/tools/index.ts | 1 + packages/coding-agent/src/index.ts | 1 + packages/coding-agent/test/tools.test.ts | 23 ++- 6 files changed, 125 insertions(+), 202 deletions(-) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 62327427..69ec4fdd 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -641,6 +641,8 @@ pi.on("tool_result", async (event, ctx) => { Fired when user executes `!` or `!!` commands. **Can intercept.** ```typescript +import { createLocalBashOperations } from "@mariozechner/pi-coding-agent"; + pi.on("user_bash", (event, ctx) => { // event.command - the bash command // event.excludeFromContext - true if !! prefix @@ -649,7 +651,17 @@ pi.on("user_bash", (event, ctx) => { // Option 1: Provide custom operations (e.g., SSH) 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 } }; }); ``` @@ -1465,6 +1477,8 @@ pi.registerTool({ **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: ```typescript diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index b2498218..a5a084f9 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -10,10 +10,9 @@ import { randomBytes } from "node:crypto"; import { createWriteStream, type WriteStream } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { type ChildProcess, spawn } from "child_process"; import stripAnsi from "strip-ansi"; -import { getShellConfig, getShellEnv, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js"; -import type { BashOperations } from "./tools/bash.js"; +import { sanitizeBinaryOutput } from "../utils/shell.js"; +import { type BashOperations, createLocalBashOperations } from "./tools/bash.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. * - * Features: - * - Streams sanitized output via onChunk callback - * - Writes large output to temp file for later retrieval - * - Supports cancellation via AbortSignal - * - Sanitizes output (strips ANSI, removes binary garbage, normalizes newlines) - * - Truncates output if it exceeds the default max bytes + * Uses the same local BashOperations backend as createBashTool() so interactive + * user bash and tool-invoked bash share the same process spawning behavior. + * Sanitization, newline normalization, temp-file capture, and truncation still + * happen in executeBashWithOperations(), so reusing the local backend does not + * change output processing behavior. * * @param command - The bash command to execute * @param options - Optional streaming callback and abort signal * @returns Promise resolving to execution result */ export function executeBash(command: string, options?: BashExecutorOptions): Promise { - return new Promise((resolve, reject) => { - 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); - }); - }); + return executeBashWithOperations(command, process.cwd(), createLocalBashOperations(), options); } /** diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 28a10b2f..f9d948c2 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -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 = { - exec: (command, cwd, { onData, signal, timeout, env }) => { - return new Promise((resolve, reject) => { - const { shell, args } = getShellConfig(); +export function createLocalBashOperations(): BashOperations { + return { + exec: (command, cwd, { onData, signal, timeout, env }) => { + return new Promise((resolve, reject) => { + const { shell, args } = getShellConfig(); - if (!existsSync(cwd)) { - reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); - return; - } + if (!existsSync(cwd)) { + reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); + return; + } - const child = spawn(shell, [...args, command], { - cwd, - detached: true, - env: env ?? getShellEnv(), - stdio: ["ignore", "pipe", "pipe"], - }); + const child = spawn(shell, [...args, command], { + cwd, + detached: true, + env: env ?? getShellEnv(), + stdio: ["ignore", "pipe", "pipe"], + }); - let timedOut = false; + let timedOut = false; - // Set timeout if provided - let timeoutHandle: NodeJS.Timeout | undefined; - if (timeout !== undefined && timeout > 0) { - timeoutHandle = setTimeout(() => { - timedOut = true; + // Set timeout if provided + let timeoutHandle: NodeJS.Timeout | undefined; + if (timeout !== undefined && timeout > 0) { + timeoutHandle = setTimeout(() => { + 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) { 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); - } + if (signal) { + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } - // Handle shell spawn errors - child.on("error", (err) => { - if (timeoutHandle) clearTimeout(timeoutHandle); - if (signal) signal.removeEventListener("abort", onAbort); - reject(err); + // 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 }); + }); }); - - // 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 { command: string; @@ -164,7 +170,7 @@ export interface BashToolOptions { } export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { - const ops = options?.operations ?? defaultBashOperations; + const ops = options?.operations ?? createLocalBashOperations(); const commandPrefix = options?.commandPrefix; const spawnHook = options?.spawnHook; diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 56ccec02..b5ab0f97 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -7,6 +7,7 @@ export { type BashToolOptions, bashTool, createBashTool, + createLocalBashOperations, } from "./bash.js"; export { createEditTool, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7b28fc9b..5e5253eb 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -223,6 +223,7 @@ export { type BashToolOptions, bashTool, codingTools, + createLocalBashOperations, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type EditOperations, diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 4098a837..37e3cf7a 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -2,7 +2,8 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; 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 { findTool } from "../src/core/tools/find.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" }); 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", () => {