diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 79d78c435..edb7f880a 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -1101,7 +1101,23 @@ export default function (pi: ExtensionAPI) { pi.on("tool_result", async (event, ctx) => { if (event.toolName === "read") { // Redact secrets from file contents - return { modifiedResult: event.result.replace(/API_KEY=\w+/g, "API_KEY=***") }; + return { + content: event.content.map((item) => + item.type === "text" + ? { ...item, text: item.text.replace(/API_KEY=\w+/g, "API_KEY=***") } + : item + ), + }; + } + + if (event.isError) { + // Override the thrown error message (optional) + return { content: [{ type: "text", text: "Custom error message" }], isError: true }; + } + + if (event.toolName === "bash" && event.content.length > 0) { + // Force a successful tool to be treated as an error + return { content: [{ type: "text", text: "Tool output rejected by policy" }], isError: true }; } }); diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 5149ef95b..44aec04b3 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -549,9 +549,34 @@ pi.on("tool_call", async (event, ctx) => { **Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [protected-paths.ts](../examples/extensions/protected-paths.ts) +#### before_bash_exec + +Fired before a bash command executes (tool calls and user `!`/`!!`). Use it to rewrite commands or override execution settings. You can also block execution by returning `{ block: true, reason?: string }`. For follow-up hints based on output, pair this with `tool_result`. + +```typescript +pi.on("before_bash_exec", async (event) => { + if (event.command.includes("rm -rf")) { + return { block: true, reason: "Blocked by policy" }; + } + + if (event.source === "tool") { + return { + cwd: "/tmp", + env: { + ...event.env, + MY_VAR: "1", + PATH: undefined, // remove PATH + }, + }; + } +}); +``` + +Return a `BashExecOverrides` object to override fields, or return `{ block: true, reason?: string }` to reject the command. Any field set to a non-undefined value replaces the original (`command`, `cwd`, `env`, `shell`, `args`, `timeout`). For `env`, set a key to `undefined` to remove it. + #### tool_result -Fired after tool executes. **Can modify result.** +Fired after tool executes. **Can modify result.** Use this to post-process outputs (for example, append hints or redact secrets) before the result is sent to the model. ```typescript import { isBashToolResult } from "@mariozechner/pi-coding-agent"; @@ -565,10 +590,12 @@ pi.on("tool_result", async (event, ctx) => { } // Modify result: - return { content: [...], details: {...}, isError: false }; + return { content: [...], details: {...} }; }); ``` +If `event.isError` is true, return `{ content: [...], isError: true }` to override the thrown error message (the text content becomes the error string). Returning `isError: true` on a successful tool result forces the tool to be treated as an error. + **Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) ### User Bash Events diff --git a/packages/coding-agent/examples/extensions/uv.ts b/packages/coding-agent/examples/extensions/uv.ts new file mode 100644 index 000000000..a1a240713 --- /dev/null +++ b/packages/coding-agent/examples/extensions/uv.ts @@ -0,0 +1,74 @@ +/** + * uv Python Interceptor + * + * Demonstrates before_bash_exec by redirecting python invocations through uv. + * This is a simple example that assumes basic whitespace-separated arguments. + * + * Usage: + * pi -e examples/extensions/uv.ts + */ + +import { type ExtensionAPI, isBashToolResult } from "@mariozechner/pi-coding-agent"; + +const PYTHON_PREFIX = /^python3?(\s+|$)/; +const UV_RUN_PYTHON_PREFIX = /^uv\s+run\s+python3?(\s+|$)/; +const PIP_PREFIX = /^pip3?(\s+|$)/; +const PIP_MODULE_PATTERN = /\s-m\s+pip3?(\s|$)/; +const TRACEBACK_PATTERN = /Traceback \(most recent call last\):/; +const IMPORT_ERROR_PATTERN = /\b(ModuleNotFoundError|ImportError):/; +const MODULE_NOT_FOUND_PATTERN = /No module named ['"]([^'"]+)['"]/; + +const PIP_BLOCK_REASON = + "pip is disabled. Use uv run instead, particularly --with and --script for throwaway work. Do not use uv pip!"; + +export default function (pi: ExtensionAPI) { + pi.on("before_bash_exec", (event) => { + const trimmed = event.originalCommand.trim(); + const isPythonCommand = PYTHON_PREFIX.test(trimmed); + const isUvRunPythonCommand = UV_RUN_PYTHON_PREFIX.test(trimmed); + const isPipModule = PIP_MODULE_PATTERN.test(trimmed); + + if (PIP_PREFIX.test(trimmed) || (isPipModule && (isPythonCommand || isUvRunPythonCommand))) { + return { + block: true, + reason: PIP_BLOCK_REASON, + }; + } + + if (!isPythonCommand) { + return; + } + + const normalizedCommand = trimmed.replace(PYTHON_PREFIX, "python ").trimEnd(); + const uvCommand = `uv run ${normalizedCommand}`; + + return { + command: uvCommand, + }; + }); + + pi.on("tool_result", (event) => { + if (!isBashToolResult(event)) return; + + const text = event.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join(""); + + if (!TRACEBACK_PATTERN.test(text) || !IMPORT_ERROR_PATTERN.test(text)) { + return; + } + + const moduleMatch = text.match(MODULE_NOT_FOUND_PATTERN); + const moduleName = moduleMatch?.[1]; + const hintTarget = moduleName ? ` --with ${moduleName}` : ""; + const hint = + "\n\nHint: Python import failed. Use uv to fetch dependencies automatically without changing the system, " + + `e.g. \`uv run${hintTarget} python -c '...'\` or \`uv run --script\` for throwaway scripts.`; + + return { + content: [...event.content, { type: "text", text: hint }], + isError: true, + }; + }); +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index f5c86f033..de4cba355 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -27,6 +27,7 @@ import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/ import { getAuthPath } from "../config.js"; import { theme } from "../modes/interactive/theme/theme.js"; import { stripFrontmatter } from "../utils/frontmatter.js"; +import { getShellConfig, getShellEnv } from "../utils/shell.js"; import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor.js"; import { type CompactionResult, @@ -41,6 +42,7 @@ import { import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.js"; import { + type BeforeBashExecEvent, type ContextUsage, type ExtensionCommandContextActions, type ExtensionErrorListener, @@ -2018,22 +2020,54 @@ export class AgentSession { ): Promise { this._bashAbortController = new AbortController(); - // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support) - const prefix = this.settingsManager.getShellCommandPrefix(); - const resolvedCommand = prefix ? `${prefix}\n${command}` : command; - try { + // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support) + const prefix = this.settingsManager.getShellCommandPrefix(); + const resolvedCommand = prefix ? `${prefix}\n${command}` : command; + const shellConfig = getShellConfig(); + const baseEvent: BeforeBashExecEvent = { + type: "before_bash_exec", + source: "user_bash", + command: resolvedCommand, + originalCommand: command, + cwd: process.cwd(), + env: { ...getShellEnv() }, + shell: shellConfig.shell, + args: [...shellConfig.args], + }; + const execEvent = this._extensionRunner?.hasHandlers("before_bash_exec") + ? await this._extensionRunner.emitBeforeBashExec(baseEvent) + : baseEvent; + const execCommand = execEvent.command; + const execCwd = execEvent.cwd; + const execEnv = execEvent.env; + const execShell = execEvent.shell; + const execArgs = execEvent.args; + const execTimeout = execEvent.timeout; + const result = options?.operations - ? await executeBashWithOperations(resolvedCommand, process.cwd(), options.operations, { + ? await executeBashWithOperations(execCommand, execCwd, options.operations, { onChunk, signal: this._bashAbortController.signal, + env: execEnv, + shell: execShell, + args: execArgs, + timeout: execTimeout, }) - : await executeBashCommand(resolvedCommand, { + : await executeBashCommand(execCommand, { onChunk, signal: this._bashAbortController.signal, + cwd: execCwd, + env: execEnv, + shell: execShell, + args: execArgs, + timeout: execTimeout, }); - this.recordBashResult(command, result, options); + this.recordBashResult(command, result, { + excludeFromContext: options?.excludeFromContext, + executedCommand: execCommand === command ? undefined : execCommand, + }); return result; } finally { this._bashAbortController = undefined; @@ -2044,10 +2078,15 @@ export class AgentSession { * Record a bash execution result in session history. * Used by executeBash and by extensions that handle bash execution themselves. */ - recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void { + recordBashResult( + command: string, + result: BashResult, + options?: { excludeFromContext?: boolean; executedCommand?: string }, + ): void { const bashMessage: BashExecutionMessage = { role: "bashExecution", command, + executedCommand: options?.executedCommand, output: result.output, exitCode: result.exitCode, cancelled: result.cancelled, diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index b24982186..0d753426f 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -12,7 +12,7 @@ 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 { killProcessTree, resolveShellExecutionOptions, sanitizeBinaryOutput } from "../utils/shell.js"; import type { BashOperations } from "./tools/bash.js"; import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js"; @@ -25,6 +25,16 @@ export interface BashExecutorOptions { onChunk?: (chunk: string) => void; /** AbortSignal for cancellation */ signal?: AbortSignal; + /** Working directory override */ + cwd?: string; + /** Environment override */ + env?: NodeJS.ProcessEnv; + /** Shell executable override */ + shell?: string; + /** Shell argument override */ + args?: string[]; + /** Timeout in seconds */ + timeout?: number; } export interface BashResult { @@ -60,13 +70,30 @@ export interface BashResult { */ export function executeBash(command: string, options?: BashExecutorOptions): Promise { return new Promise((resolve, reject) => { - const { shell, args } = getShellConfig(); - const child: ChildProcess = spawn(shell, [...args, command], { + const resolvedCwd = options?.cwd ?? process.cwd(); + const { resolvedShell, resolvedArgs, resolvedEnv } = resolveShellExecutionOptions({ + shell: options?.shell, + args: options?.args, + env: options?.env, + }); + const child: ChildProcess = spawn(resolvedShell, [...resolvedArgs, command], { + cwd: resolvedCwd, + env: resolvedEnv, detached: true, - env: getShellEnv(), stdio: ["ignore", "pipe", "pipe"], }); + let timedOut = false; + let timeoutHandle: NodeJS.Timeout | undefined; + if (options?.timeout !== undefined && options.timeout > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + if (child.pid) { + killProcessTree(child.pid); + } + }, options.timeout * 1000); + } + // Track sanitized output for truncation const outputChunks: string[] = []; let outputBytes = 0; @@ -88,6 +115,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro if (options.signal.aborted) { // Already aborted, don't even start child.kill(); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } resolve({ output: "", exitCode: undefined, @@ -144,6 +174,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro if (options?.signal) { options.signal.removeEventListener("abort", abortHandler); } + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } if (tempFileStream) { tempFileStream.end(); @@ -153,8 +186,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro const fullOutput = outputChunks.join(""); const truncationResult = truncateTail(fullOutput); - // code === null means killed (cancelled) - const cancelled = code === null; + const cancelled = code === null || timedOut; resolve({ output: truncationResult.truncated ? truncationResult.content : fullOutput, @@ -170,6 +202,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro if (options?.signal) { options.signal.removeEventListener("abort", abortHandler); } + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } if (tempFileStream) { tempFileStream.end(); @@ -238,6 +273,10 @@ export async function executeBashWithOperations( const result = await operations.exec(command, cwd, { onData, signal: options?.signal, + timeout: options?.timeout, + env: options?.env, + shell: options?.shell, + args: options?.args, }); if (tempFileStream) { diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index d10d0a1a5..d3ccbe248 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -25,9 +25,14 @@ export type { // App keybindings (for custom editors) AppAction, AppendEntryHandler, + BashExecEvent, + BashExecOverrides, + BashExecSource, BashToolResultEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, + BeforeBashExecEvent, + BeforeBashExecEventResult, // Context CompactOptions, // Events - Agent diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 289400a0b..31eb0410d 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -10,8 +10,12 @@ import type { KeyAction, KeybindingsConfig } from "../keybindings.js"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; import type { + BashExecBlockResult, + BashExecOverrides, BeforeAgentStartEvent, BeforeAgentStartEventResult, + BeforeBashExecEvent, + BeforeBashExecEventResult, CompactOptions, ContextEvent, ContextEventResult, @@ -83,6 +87,34 @@ const buildBuiltinKeybindings = (effectiveKeybindings: Required { + let nextEnv = event.env; + if (overrides.env) { + nextEnv = { ...event.env }; + for (const [key, value] of Object.entries(overrides.env)) { + if (value === undefined) { + delete nextEnv[key]; + } else { + nextEnv[key] = value; + } + } + } + + return { + ...event, + command: overrides.command ?? event.command, + cwd: overrides.cwd ?? event.cwd, + env: nextEnv, + shell: overrides.shell ?? event.shell, + args: overrides.args ?? event.args, + timeout: overrides.timeout ?? event.timeout, + }; +}; + +const isBashExecBlockResult = (result: BeforeBashExecEventResult): result is BashExecBlockResult => { + return "block" in result && result.block; +}; + /** Combined result from all before_agent_start handlers */ interface BeforeAgentStartCombinedResult { messages?: NonNullable[]; @@ -474,6 +506,50 @@ export class ExtensionRunner { return result; } + async emitBeforeBashExec(event: BeforeBashExecEvent): Promise { + const ctx = this.createContext(); + let currentEvent: BeforeBashExecEvent = { + ...event, + env: { ...event.env }, + args: [...event.args], + }; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("before_bash_exec"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + let handlerResult: unknown; + try { + handlerResult = await handler(currentEvent, ctx); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "before_bash_exec", + error: message, + stack, + }); + continue; + } + + if (!handlerResult) { + continue; + } + + const overrideResult = handlerResult as BeforeBashExecEventResult; + if (isBashExecBlockResult(overrideResult)) { + const reason = overrideResult.reason ?? "Bash execution was blocked by an extension"; + throw new Error(reason); + } + currentEvent = applyBashExecOverrides(currentEvent, overrideResult); + } + } + + return currentEvent; + } + async emitUserBash(event: UserBashEvent): Promise { const ctx = this.createContext(); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 2a058160e..e0c5bbe89 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -488,6 +488,43 @@ export interface UserBashEvent { cwd: string; } +// ============================================================================ +// Bash Execution Events +// ============================================================================ + +export type BashExecSource = "tool" | "user_bash"; + +export interface BashExecOverrides { + command?: string; + cwd?: string; + env?: Record; + shell?: string; + args?: string[]; + timeout?: number; +} + +export interface BashExecBlockResult { + block: true; + reason?: string; +} + +export interface BashExecEvent { + source: BashExecSource; + command: string; + originalCommand: string; + cwd: string; + env: NodeJS.ProcessEnv; + shell: string; + args: string[]; + toolCallId?: string; + timeout?: number; +} + +/** Fired before spawning a bash command (tool + user bash). */ +export interface BeforeBashExecEvent extends BashExecEvent { + type: "before_bash_exec"; +} + // ============================================================================ // Input Events // ============================================================================ @@ -617,6 +654,7 @@ export type ExtensionEvent = | TurnEndEvent | ModelSelectEvent | UserBashEvent + | BeforeBashExecEvent | InputEvent | ToolCallEvent | ToolResultEvent; @@ -642,6 +680,9 @@ export interface UserBashEventResult { result?: BashResult; } +/** Result from before_bash_exec event handler */ +export type BeforeBashExecEventResult = BashExecOverrides | BashExecBlockResult; + export interface ToolResultEventResult { content?: (TextContent | ImageContent)[]; details?: unknown; @@ -749,6 +790,7 @@ export interface ExtensionAPI { on(event: "tool_call", handler: ExtensionHandler): void; on(event: "tool_result", handler: ExtensionHandler): void; on(event: "user_bash", handler: ExtensionHandler): void; + on(event: "before_bash_exec", handler: ExtensionHandler): void; on(event: "input", handler: ExtensionHandler): void; // ========================================================================= diff --git a/packages/coding-agent/src/core/extensions/wrapper.ts b/packages/coding-agent/src/core/extensions/wrapper.ts index 0626afaf5..f38293e75 100644 --- a/packages/coding-agent/src/core/extensions/wrapper.ts +++ b/packages/coding-agent/src/core/extensions/wrapper.ts @@ -3,8 +3,10 @@ */ import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core"; +import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; +import { getShellConfig, getShellEnv } from "../../utils/shell.js"; import type { ExtensionRunner } from "./runner.js"; -import type { RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types.js"; +import type { BeforeBashExecEvent, RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types.js"; /** * Wrap a RegisteredTool into an AgentTool. @@ -36,6 +38,60 @@ export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: E * - Emits tool_result event after execution (can modify result) */ export function wrapToolWithExtensions(tool: AgentTool, runner: ExtensionRunner): AgentTool { + type BashToolParams = { + command: string; + timeout?: number; + }; + type BashExecParams = BashToolParams & { + cwd?: string; + env?: NodeJS.ProcessEnv; + shell?: string; + args?: string[]; + }; + const applyBeforeBashExecOverrides = async ( + toolCallId: string, + params: BashToolParams, + runner: ExtensionRunner, + ): Promise => { + const shellConfig = getShellConfig(); + const context = runner.createContext(); + const baseEvent: BeforeBashExecEvent = { + type: "before_bash_exec", + source: "tool", + command: params.command, + originalCommand: params.command, + cwd: context.cwd, + env: { ...getShellEnv() }, + shell: shellConfig.shell, + args: [...shellConfig.args], + toolCallId, + timeout: params.timeout, + }; + const execEvent = await runner.emitBeforeBashExec(baseEvent); + return { + ...params, + command: execEvent.command, + cwd: execEvent.cwd, + env: execEvent.env, + shell: execEvent.shell, + args: execEvent.args, + timeout: execEvent.timeout, + }; + }; + const toolResultContentToErrorMessage = ( + content: (TextContent | ImageContent)[] | undefined, + fallback: string, + ): string => { + if (!content || content.length === 0) return fallback; + const text = content + .filter((item): item is TextContent => item.type === "text" && !!item.text) + .map((item) => item.text) + .join("") + .trim(); + if (text) return text; + return `${fallback} [non-text content]`; + }; + return { ...tool, execute: async ( @@ -44,6 +100,9 @@ export function wrapToolWithExtensions(tool: AgentTool, runner: Exten signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, ) => { + let effectiveParams = params; + let forcedError = false; + // Emit tool_call event - extensions can block execution if (runner.hasHandlers("tool_call")) { try { @@ -66,9 +125,13 @@ export function wrapToolWithExtensions(tool: AgentTool, runner: Exten } } + if (tool.name === "bash" && runner.hasHandlers("before_bash_exec")) { + effectiveParams = await applyBeforeBashExecOverrides(toolCallId, params as BashToolParams, runner); + } + // Execute the actual tool try { - const result = await tool.execute(toolCallId, params, signal, onUpdate); + const result = await tool.execute(toolCallId, effectiveParams, signal, onUpdate); // Emit tool_result event - extensions can modify the result if (runner.hasHandlers("tool_result")) { @@ -76,33 +139,53 @@ export function wrapToolWithExtensions(tool: AgentTool, runner: Exten type: "tool_result", toolName: tool.name, toolCallId, - input: params, + input: effectiveParams, content: result.content, details: result.details, isError: false, })) as ToolResultEventResult | undefined; if (resultResult) { + const nextContent = resultResult.content ?? result.content; + const nextDetails = (resultResult.details ?? result.details) as T; + if (resultResult.isError) { + forcedError = true; + throw new Error(toolResultContentToErrorMessage(nextContent, "Tool execution failed.")); + } + return { - content: resultResult.content ?? result.content, - details: (resultResult.details ?? result.details) as T, + content: nextContent, + details: nextDetails, }; } } return result; } catch (err) { + if (forcedError) { + throw err; + } // Emit tool_result event for errors if (runner.hasHandlers("tool_result")) { - await runner.emit({ + const fallbackMessage = err instanceof Error ? err.message : String(err); + const content = [{ type: "text" as const, text: fallbackMessage }]; + const resultResult = (await runner.emit({ type: "tool_result", toolName: tool.name, toolCallId, - input: params, - content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], + input: effectiveParams, + content, details: undefined, isError: true, - }); + })) as ToolResultEventResult | undefined; + + if (resultResult) { + if (!resultResult.isError) { + throw err; + } + const nextContent = resultResult.content ?? content; + throw new Error(toolResultContentToErrorMessage(nextContent, fallbackMessage)); + } } throw err; } diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index f5a645e66..24106f3b4 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -29,6 +29,7 @@ export const BRANCH_SUMMARY_SUFFIX = ``; export interface BashExecutionMessage { role: "bashExecution"; command: string; + executedCommand?: string; output: string; exitCode: number | undefined; cancelled: boolean; diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 3c312e52b..b9ea6a9e2 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { spawn } from "child_process"; -import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js"; +import { killProcessTree, resolveShellExecutionOptions } from "../../utils/shell.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; /** @@ -45,6 +45,9 @@ export interface BashOperations { onData: (data: Buffer) => void; signal?: AbortSignal; timeout?: number; + env?: NodeJS.ProcessEnv; + shell?: string; + args?: string[]; }, ) => Promise<{ exitCode: number | null }>; } @@ -53,19 +56,23 @@ export interface BashOperations { * Default bash operations using local shell */ const defaultBashOperations: BashOperations = { - exec: (command, cwd, { onData, signal, timeout }) => { + exec: (command, cwd, { onData, signal, timeout, env, shell, args }) => { return new Promise((resolve, reject) => { - const { shell, args } = getShellConfig(); + const { resolvedShell, resolvedArgs, resolvedEnv } = resolveShellExecutionOptions({ + shell, + args, + env, + }); if (!existsSync(cwd)) { reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); return; } - const child = spawn(shell, [...args, command], { + const child = spawn(resolvedShell, [...resolvedArgs, command], { cwd, + env: resolvedEnv, detached: true, - env: getShellEnv(), stdio: ["ignore", "pipe", "pipe"], }); @@ -151,12 +158,27 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo parameters: bashSchema, execute: async ( _toolCallId: string, - { command, timeout }: { command: string; timeout?: number }, + { + command, + timeout, + cwd: overrideCwd, + env, + shell, + args, + }: { + command: string; + timeout?: number; + cwd?: string; + env?: NodeJS.ProcessEnv; + shell?: string; + args?: string[]; + }, signal?: AbortSignal, onUpdate?, ) => { // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support) const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; + const resolvedCwd = overrideCwd ?? cwd; return new Promise((resolve, reject) => { // We'll stream to a temp file if output gets large @@ -213,7 +235,7 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo } }; - ops.exec(resolvedCommand, cwd, { onData: handleData, signal, timeout }) + ops.exec(resolvedCommand, resolvedCwd, { onData: handleData, signal, timeout, env, shell, args }) .then(({ exitCode }) => { // Close temp file stream if (tempFileStream) { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index ecf08a77e..dfdb79d38 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -44,7 +44,12 @@ export type { AgentToolResult, AgentToolUpdateCallback, AppAction, + BashExecEvent, + BashExecOverrides, + BashExecSource, BeforeAgentStartEvent, + BeforeBashExecEvent, + BeforeBashExecEventResult, CompactOptions, ContextEvent, ContextUsage, diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 62ff558af..3c157c8d3 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -1,8 +1,7 @@ import { existsSync } from "node:fs"; import { delimiter } from "node:path"; import { spawn, spawnSync } from "child_process"; -import { getSettingsPath } from "../config.js"; -import { getBinDir } from "../config.js"; +import { getBinDir, getSettingsPath } from "../config.js"; import { SettingsManager } from "../core/settings-manager.js"; let cachedShellConfig: { shell: string; args: string[] } | null = null; @@ -111,6 +110,34 @@ export function getShellEnv(): NodeJS.ProcessEnv { }; } +export interface ShellExecutionOptions { + shell?: string; + args?: string[]; + env?: NodeJS.ProcessEnv; +} + +export interface ResolvedShellExecutionOptions { + resolvedShell: string; + resolvedArgs: string[]; + resolvedEnv: NodeJS.ProcessEnv; +} + +export function resolveShellExecutionOptions(options?: ShellExecutionOptions): ResolvedShellExecutionOptions { + const shellConfig = getShellConfig(); + const resolvedEnv = options?.env ? { ...options.env } : { ...getShellEnv() }; + for (const [key, value] of Object.entries(resolvedEnv)) { + if (value === undefined) { + delete resolvedEnv[key]; + } + } + + return { + resolvedShell: options?.shell ?? shellConfig.shell, + resolvedArgs: options?.args ?? shellConfig.args, + resolvedEnv, + }; +} + /** * Sanitize binary output for display/storage. * Removes characters that crash string-width or cause display issues: diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 851633d22..83bfc44ce 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -290,9 +290,10 @@ describe("Coding Agent Tools", () => { }); it("should handle process spawn errors", async () => { - vi.spyOn(shellModule, "getShellConfig").mockReturnValueOnce({ - shell: "/nonexistent-shell-path-xyz123", - args: ["-c"], + vi.spyOn(shellModule, "resolveShellExecutionOptions").mockReturnValueOnce({ + resolvedShell: "/nonexistent-shell-path-xyz123", + resolvedArgs: ["-c"], + resolvedEnv: shellModule.getShellEnv(), }); const bashWithBadShell = createBashTool(testDir);