SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
e249c6d verified
Raw History Blame Contribute Delete
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));
}