Download src/agents/sessions/tools/bash.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 19.7 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/sessions/tools/bash.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/sessions/tools/bash.ts
-
curl -L -o bash.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/sessions/tools/bash.ts
19.7 kB
| /** | |
| * Built-in bash session tool. | |
| * | |
| * Executes local shell commands with streaming output accumulation and TUI renderers. | |
| */ | |
| import { existsSync } from "node:fs"; | |
| import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"; | |
| import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; | |
| import { Type } from "typebox"; | |
| import { toErrorObject } from "../../../infra/errors.js"; | |
| import { formatDurationSeconds } from "../../../infra/format-time/format-duration.js"; | |
| import { releaseChildProcessOutputAfterExit } from "../../../process/child-process.js"; | |
| import { COMMAND_PROCESS_TREE_KILL_GRACE_MS } from "../../../process/exec-spawn.js"; | |
| import { createCommandTerminationController } from "../../../process/exec-termination.js"; | |
| import { spawnCommand } from "../../../process/exec.js"; | |
| import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; | |
| import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js"; | |
| import { interactiveAgentTheme as theme } from "../../modes/interactive/theme/theme.js"; | |
| import type { AgentTool } from "../../runtime/index.js"; | |
| import { executionTitleSchema } from "../../schema/typebox.js"; | |
| import { | |
| buildShellCommandInvocation, | |
| getBashShellConfig, | |
| getBashShellEnv, | |
| } from "../../shell-utils.js"; | |
| import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; | |
| import type { BashOperations } from "./bash-operations.js"; | |
| import { OutputAccumulator } from "./output-accumulator.js"; | |
| import { getTextOutput, invalidArgText, reuseTextComponent, str } from "./render-utils.js"; | |
| import { formatFullOutputFooter, type BashToolDetails } from "./tool-contracts.js"; | |
| import { wrapToolDefinition } from "./tool-definition-wrapper.js"; | |
| import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "./truncate.js"; | |
| const bashSchema = Type.Object({ | |
| title: executionTitleSchema(), | |
| command: Type.String({ description: "Bash command." }), | |
| timeout: Type.Optional(Type.Number({ description: "Optional timeout seconds; default none." })), | |
| }); | |
| function resolveBashTimeoutMs(timeoutSeconds: unknown): number | undefined { | |
| if (timeoutSeconds === undefined) { | |
| return undefined; | |
| } | |
| if ( | |
| typeof timeoutSeconds !== "number" || | |
| !Number.isFinite(timeoutSeconds) || | |
| timeoutSeconds <= 0 | |
| ) { | |
| throw new Error("Invalid timeout: must be a positive finite number of seconds"); | |
| } | |
| return resolveTimerTimeoutMs(timeoutSeconds * 1000, 1); | |
| } | |
| if (process.env.VITEST || process.env.NODE_ENV === "test") { | |
| (globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.bashToolTestApi")] = { | |
| resolveBashTimeoutMs, | |
| }; | |
| } | |
| /** | |
| * Create bash operations using OpenClaw runtime's built-in local shell execution backend. | |
| * | |
| * This is useful for extensions that intercept user_bash and still want OpenClaw runtime's | |
| * standard local shell behavior while wrapping or rewriting commands. | |
| */ | |
| export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { | |
| return { | |
| exec: (command, cwd, { onData, signal, timeout, env }) => { | |
| return new Promise((resolve, reject) => { | |
| const shellConfig = getBashShellConfig(options?.shellPath); | |
| const invocation = buildShellCommandInvocation(command, shellConfig); | |
| if (!existsSync(cwd)) { | |
| reject( | |
| new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`), | |
| ); | |
| return; | |
| } | |
| const cancelController = new AbortController(); | |
| const shellEnv = env ?? getBashShellEnv(shellConfig.shell); | |
| const child = spawnCommand(invocation.argv, { | |
| baseEnv: {}, | |
| buffer: false, | |
| cancelSignal: cancelController.signal, | |
| cwd, | |
| detached: process.platform !== "win32", | |
| env: shellEnv, | |
| forceKillAfterDelay: COMMAND_PROCESS_TREE_KILL_GRACE_MS, | |
| ...(invocation.input === undefined ? {} : { input: invocation.input }), | |
| reject: false, | |
| stdio: [invocation.stdin, "pipe", "pipe"], | |
| }); | |
| const releaseOutput = releaseChildProcessOutputAfterExit(child.nodeChildProcess); | |
| let childExited = false; | |
| child.nodeChildProcess.once("exit", () => { | |
| childExited = true; | |
| }); | |
| let commandSettled = false; | |
| const terminationController = createCommandTerminationController({ | |
| child: child.nodeChildProcess, | |
| cancelController, | |
| baseEnv: {}, | |
| env: shellEnv, | |
| processTree: { mode: "force" }, | |
| killGraceMs: COMMAND_PROCESS_TREE_KILL_GRACE_MS, | |
| isChildExited: () => childExited, | |
| isCommandSettled: () => commandSettled, | |
| }); | |
| let terminationStarted = false; | |
| const terminate = () => { | |
| if (terminationStarted) { | |
| return; | |
| } | |
| terminationStarted = true; | |
| if (!terminationController.terminate()) { | |
| cancelController.abort(); | |
| } | |
| }; | |
| let timedOut = false; | |
| let timeoutHandle: NodeJS.Timeout | undefined; | |
| const timeoutMs = resolveBashTimeoutMs(timeout); | |
| if (timeoutMs !== undefined) { | |
| timeoutHandle = setTimeout(() => { | |
| timedOut = true; | |
| terminate(); | |
| }, timeoutMs); | |
| } | |
| // Stream stdout and stderr. Tag each pipe so downstream decode state | |
| // stays per-stream; a pending sequence on one must not eat the other. | |
| child.stdout?.on("data", (data: Buffer) => onData(data, "stdout")); | |
| child.stderr?.on("data", (data: Buffer) => onData(data, "stderr")); | |
| // Handle abort signal by killing the entire process tree. | |
| const onAbort = terminate; | |
| if (signal) { | |
| if (signal.aborted) { | |
| onAbort(); | |
| } else { | |
| signal.addEventListener("abort", onAbort, { once: true }); | |
| } | |
| } | |
| void child | |
| .then(async (result) => { | |
| commandSettled = true; | |
| await terminationController.settle(); | |
| if (result.failed && result.exitCode === undefined && result.signal === undefined) { | |
| if (result instanceof Error) { | |
| throw result; | |
| } | |
| throw new Error(`Failed to launch shell: ${shellConfig.shell}`, { cause: result }); | |
| } | |
| 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: result.exitCode ?? (result.failed ? 1 : 0) }); | |
| }) | |
| .catch(async (err: unknown) => { | |
| commandSettled = true; | |
| await terminationController.settle(); | |
| if (timeoutHandle) { | |
| clearTimeout(timeoutHandle); | |
| } | |
| if (signal) { | |
| signal.removeEventListener("abort", onAbort); | |
| } | |
| reject(toErrorObject(err, "Non-Error rejection")); | |
| }) | |
| .finally(releaseOutput); | |
| }); | |
| }, | |
| }; | |
| } | |
| export interface BashSpawnContext { | |
| command: string; | |
| cwd: string; | |
| env: NodeJS.ProcessEnv; | |
| } | |
| export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext; | |
| function resolveSpawnContext( | |
| command: string, | |
| cwd: string, | |
| spawnHook?: BashSpawnHook, | |
| shellPath?: string, | |
| ): BashSpawnContext { | |
| const baseContext: BashSpawnContext = { command, cwd, env: getBashShellEnv(shellPath) }; | |
| return spawnHook ? spawnHook(baseContext) : baseContext; | |
| } | |
| export interface BashToolOptions { | |
| /** Custom operations for command execution. Default: local shell */ | |
| operations?: BashOperations; | |
| /** Command prefix prepended to every command (for example shell setup commands) */ | |
| commandPrefix?: string; | |
| /** Optional explicit shell path from settings */ | |
| shellPath?: string; | |
| /** Hook to adjust command, cwd, or env before execution */ | |
| spawnHook?: BashSpawnHook; | |
| } | |
| const BASH_PREVIEW_LINES = 5; | |
| const BASH_UPDATE_THROTTLE_MS = 100; | |
| type BashRenderState = { | |
| startedAt: number | undefined; | |
| endedAt: number | undefined; | |
| interval: NodeJS.Timeout | undefined; | |
| }; | |
| type BashResultRenderState = { | |
| cachedWidth: number | undefined; | |
| cachedLines: string[] | undefined; | |
| cachedSkipped: number | undefined; | |
| }; | |
| class BashResultRenderComponent extends Container { | |
| state: BashResultRenderState = { | |
| cachedWidth: undefined, | |
| cachedLines: undefined, | |
| cachedSkipped: undefined, | |
| }; | |
| } | |
| function formatBashCall(args: { command?: string; timeout?: number } | undefined): string { | |
| const command = str(args?.command); | |
| const timeout = args?.timeout; | |
| const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; | |
| const commandDisplay = | |
| command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "..."); | |
| return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix; | |
| } | |
| function rebuildBashResultRenderComponent( | |
| component: BashResultRenderComponent, | |
| result: { | |
| content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; | |
| details?: BashToolDetails; | |
| }, | |
| options: ToolRenderResultOptions, | |
| showImages: boolean, | |
| startedAt: number | undefined, | |
| endedAt: number | undefined, | |
| ): void { | |
| const state = component.state; | |
| component.clear(); | |
| let output = getTextOutput(result, showImages).trim(); | |
| const truncation = result.details?.truncation; | |
| const fullOutputPath = result.details?.fullOutputPath; | |
| if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) { | |
| const footerStart = output.lastIndexOf("\n\n["); | |
| if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) { | |
| output = output.slice(0, footerStart).trimEnd(); | |
| } | |
| } | |
| if (output) { | |
| const styledOutput = output | |
| .split("\n") | |
| .map((line) => theme.fg("toolOutput", line)) | |
| .join("\n"); | |
| if (options.expanded) { | |
| component.addChild(new Text(`\n${styledOutput}`, 0, 0)); | |
| } else { | |
| component.addChild({ | |
| render: (width: number) => { | |
| if (state.cachedLines === undefined || state.cachedWidth !== width) { | |
| const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width); | |
| state.cachedLines = preview.visualLines; | |
| state.cachedSkipped = preview.skippedCount; | |
| state.cachedWidth = width; | |
| } | |
| if (state.cachedSkipped && state.cachedSkipped > 0) { | |
| const hint = | |
| theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) + | |
| ` ${keyHint("app.tools.expand", "to expand")})`; | |
| return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])]; | |
| } | |
| return ["", ...(state.cachedLines ?? [])]; | |
| }, | |
| invalidate: () => { | |
| state.cachedWidth = undefined; | |
| state.cachedLines = undefined; | |
| state.cachedSkipped = undefined; | |
| }, | |
| }); | |
| } | |
| } | |
| if (truncation?.truncated || fullOutputPath) { | |
| const warnings: string[] = []; | |
| if (fullOutputPath) { | |
| warnings.push(formatFullOutputFooter(fullOutputPath)); | |
| } | |
| if (truncation?.truncated) { | |
| if (truncation.truncatedBy === "lines") { | |
| warnings.push( | |
| `Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`, | |
| ); | |
| } else { | |
| warnings.push( | |
| `Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`, | |
| ); | |
| } | |
| } | |
| component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0)); | |
| } | |
| if (startedAt !== undefined) { | |
| const label = options.isPartial ? "Elapsed" : "Took"; | |
| const endTime = endedAt ?? Date.now(); | |
| component.addChild( | |
| new Text( | |
| `\n${theme.fg("muted", `${label} ${formatDurationSeconds(endTime - startedAt)}`)}`, | |
| 0, | |
| 0, | |
| ), | |
| ); | |
| } | |
| } | |
| export function createBashToolDefinition( | |
| cwd: string, | |
| options?: BashToolOptions, | |
| ): ToolDefinition<typeof bashSchema, BashToolDetails | undefined, BashRenderState> { | |
| const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath }); | |
| const commandPrefix = options?.commandPrefix; | |
| const spawnHook = options?.spawnHook; | |
| return { | |
| name: "bash", | |
| label: "bash", | |
| description: `Run bash in cwd; stdout+stderr. Returns last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB; full truncated output saved temp. Optional timeout seconds.`, | |
| promptSnippet: "Execute bash commands (ls, grep, find, etc.)", | |
| parameters: bashSchema, | |
| async execute( | |
| toolCallId, | |
| { command, timeout }: { command: string; timeout?: number }, | |
| signal?: AbortSignal, | |
| onUpdate?, | |
| ctx?, | |
| ) { | |
| void toolCallId; | |
| void ctx; | |
| resolveBashTimeoutMs(timeout); | |
| const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; | |
| const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook, options?.shellPath); | |
| const output = new OutputAccumulator({ tempFilePrefix: "openclaw-bash" }); | |
| let acceptingOutput = true; | |
| let updateTimer: NodeJS.Timeout | undefined; | |
| let updateDirty = false; | |
| let lastUpdateAt = 0; | |
| const emitOutputUpdate = () => { | |
| if (!onUpdate || !updateDirty) { | |
| return; | |
| } | |
| updateDirty = false; | |
| lastUpdateAt = Date.now(); | |
| const snapshot = output.snapshot({ persistIfTruncated: true }); | |
| onUpdate({ | |
| content: [{ type: "text", text: snapshot.content || "" }], | |
| details: { | |
| truncation: snapshot.truncation.truncated ? snapshot.truncation : undefined, | |
| fullOutputPath: snapshot.fullOutputPath, | |
| }, | |
| }); | |
| }; | |
| const clearUpdateTimer = () => { | |
| if (updateTimer) { | |
| clearTimeout(updateTimer); | |
| updateTimer = undefined; | |
| } | |
| }; | |
| const scheduleOutputUpdate = () => { | |
| if (!onUpdate) { | |
| return; | |
| } | |
| updateDirty = true; | |
| const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt); | |
| if (delay <= 0) { | |
| clearUpdateTimer(); | |
| emitOutputUpdate(); | |
| return; | |
| } | |
| updateTimer ??= setTimeout(() => { | |
| updateTimer = undefined; | |
| emitOutputUpdate(); | |
| }, delay); | |
| }; | |
| if (onUpdate) { | |
| onUpdate({ content: [], details: undefined }); | |
| } | |
| const handleData = (data: Buffer, stream?: "stdout" | "stderr") => { | |
| if (!acceptingOutput) { | |
| return; | |
| } | |
| output.append(data, stream); | |
| scheduleOutputUpdate(); | |
| }; | |
| const finishOutput = async () => { | |
| acceptingOutput = false; | |
| output.finish(); | |
| clearUpdateTimer(); | |
| emitOutputUpdate(); | |
| const snapshot = output.snapshot({ persistIfTruncated: true }); | |
| await output.closeTempFile(); | |
| return snapshot; | |
| }; | |
| const formatOutput = ( | |
| snapshot: Awaited<ReturnType<typeof finishOutput>>, | |
| emptyText = "(no output)", | |
| ) => { | |
| const truncation = snapshot.truncation; | |
| let text = snapshot.content || emptyText; | |
| let details: BashToolDetails | undefined; | |
| if (truncation.truncated) { | |
| const fullOutputPath = snapshot.fullOutputPath; | |
| if (!fullOutputPath) { | |
| throw new Error("Missing full output path for truncated bash output"); | |
| } | |
| details = { truncation, fullOutputPath }; | |
| const startLine = truncation.totalLines - truncation.outputLines + 1; | |
| const endLine = truncation.totalLines; | |
| if (truncation.lastLinePartial) { | |
| const lastLineSize = formatSize(output.getLastLineBytes()); | |
| text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). ${formatFullOutputFooter(fullOutputPath)}]`; | |
| } else if (truncation.truncatedBy === "lines") { | |
| text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. ${formatFullOutputFooter(fullOutputPath)}]`; | |
| } else { | |
| text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). ${formatFullOutputFooter(fullOutputPath)}]`; | |
| } | |
| } | |
| return { text, details }; | |
| }; | |
| const appendStatus = (text: string, status: string) => | |
| `${text ? `${text}\n\n` : ""}${status}`; | |
| try { | |
| let exitCode: number | null; | |
| try { | |
| const result = await ops.exec(spawnContext.command, spawnContext.cwd, { | |
| onData: handleData, | |
| signal, | |
| timeout, | |
| env: spawnContext.env, | |
| }); | |
| exitCode = result.exitCode; | |
| } catch (err) { | |
| const snapshot = await finishOutput(); | |
| const { text } = formatOutput(snapshot, ""); | |
| if (err instanceof Error && err.message === "aborted") { | |
| throw new Error(appendStatus(text, "Command aborted"), { cause: err }); | |
| } | |
| if (err instanceof Error && err.message.startsWith("timeout:")) { | |
| const timeoutSecs = err.message.split(":")[1]; | |
| throw new Error(appendStatus(text, `Command timed out after ${timeoutSecs} seconds`), { | |
| cause: err, | |
| }); | |
| } | |
| throw err; | |
| } | |
| const snapshot = await finishOutput(); | |
| const { text: outputText, details } = formatOutput(snapshot); | |
| if (exitCode !== 0 && exitCode !== null) { | |
| throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`)); | |
| } | |
| return { content: [{ type: "text", text: outputText }], details }; | |
| } finally { | |
| clearUpdateTimer(); | |
| } | |
| }, | |
| renderCall(args, themeValue, context) { | |
| void themeValue; | |
| const state = context.state; | |
| if (context.executionStarted && state.startedAt === undefined) { | |
| state.startedAt = Date.now(); | |
| state.endedAt = undefined; | |
| } | |
| return reuseTextComponent(context.lastComponent, formatBashCall(args)); | |
| }, | |
| renderResult(result, optionsLocal, themeLocal, context) { | |
| void themeLocal; | |
| const state = context.state; | |
| if (state.startedAt !== undefined && optionsLocal.isPartial && !state.interval) { | |
| state.interval = setInterval(() => context.invalidate(), 1000); | |
| } | |
| if (!optionsLocal.isPartial || context.isError) { | |
| state.endedAt ??= Date.now(); | |
| if (state.interval) { | |
| clearInterval(state.interval); | |
| state.interval = undefined; | |
| } | |
| } | |
| const component = | |
| (context.lastComponent as BashResultRenderComponent | undefined) ?? | |
| new BashResultRenderComponent(); | |
| rebuildBashResultRenderComponent( | |
| component, | |
| result, | |
| optionsLocal, | |
| context.showImages, | |
| state.startedAt, | |
| state.endedAt, | |
| ); | |
| component.invalidate(); | |
| return component; | |
| }, | |
| }; | |
| } | |
| export function createBashTool( | |
| cwd: string, | |
| options?: BashToolOptions, | |
| ): AgentTool<typeof bashSchema> { | |
| return wrapToolDefinition(createBashToolDefinition(cwd, options)); | |
| } | |