1dba122c创建于 1 天前历史提交

Bash tool runtime

This document describes the bash tool runtime path used by agent tool calls, from command normalization to execution, truncation/artifacts, and rendering.

It also calls out where behavior diverges in interactive TUI, print mode, RPC mode, and user-initiated bang (!) shell execution.

Scope and runtime surfaces

There are two different bash execution surfaces in coding-agent:

  1. Tool-call surface (toolName: "bash"): used when the model calls the bash tool.
    • Entry point: BashTool.execute().
    • Parameters include command, optional env, timeout, cwd, pty, and, when async.enabled is true, async.
  2. User bang-command surface (!cmd from interactive input or RPC bash command): session-level helper path.
    • Entry point: AgentSession.executeBash().

Both eventually use executeBash() in src/exec/bash-executor.ts for non-PTY execution, but only the tool-call path runs normalization/interception, optional managed background-job handling, and tool renderer logic.

End-to-end tool-call pipeline

1) Input handling and parameter merge

BashTool.execute() currently handles input before execution as follows:

  • validates optional env names against shell-variable syntax,
  • when bash.stripTrailingHeadTail is enabled (default), applies conservative native fixups that remove safe trailing | head / | tail pipes and redundant trailing 2>&1,
  • extracts a leading single-line cd <path> && ... into cwd when cwd was not supplied,
  • rejects async: true when async.enabled is false.

There are no structured head or tail tool parameters in the current schema. Output limiting is handled by OutputSink truncation/artifacts, and the optional trailing-pipe fixup exists to avoid hiding output before the harness can capture it.

2) Optional interception (blocked-command path)

If bashInterceptor.enabled is true, BashTool loads rules from settings and runs checkBashInterception() against the normalized command.

Interception behavior:

  • command is blocked only when:
    • regex rule matches, and
    • the suggested tool is present in ctx.toolNames.
  • invalid regex rules are silently skipped.
  • on block, BashTool throws ToolError with message:
    • Blocked: ...
    • original command included.

Default rule patterns (defined in code) target common misuses:

  • file readers (cat, head, tail, ...)
  • search tools (grep, rg, ...)
  • file finders (find, fd, ...)
  • in-place editors (sed -i, perl -i, awk -i inplace)
  • shell redirection writes (echo ... > file, heredoc redirection)

Caveat

InterceptionResult includes suggestedTool, but BashTool currently surfaces only the message text (no structured suggested-tool field in details).

3) CWD validation and timeout clamping

cwd is resolved relative to session cwd (resolveToCwd), then validated via stat:

  • missing path -> ToolError("Working directory does not exist: ...")
  • non-directory -> ToolError("Working directory is not a directory: ...")

Timeout is clamped to [1, 3600] seconds and converted to milliseconds.

4) Artifact allocation

Before execution, the tool allocates an artifact path/id (best-effort) for truncated output storage.

  • artifact allocation failure is non-fatal (execution continues without artifact spill file),
  • artifact id/path are passed into execution path for full-output persistence on truncation.

5) PTY vs non-PTY execution selection

BashTool chooses PTY execution only when all are true:

  • tool input pty === true
  • PI_NO_PTY !== "1"
  • tool context has UI (ctx.hasUI === true and ctx.ui set)

Otherwise it uses non-interactive executeBash().

That means print mode and non-UI RPC/tool contexts always use non-PTY.

Non-interactive execution engine (executeBash)

Shell session reuse model

executeBash() caches native Shell instances in a process-global map keyed by:

  • shell path,
  • configured command prefix,
  • snapshot path,
  • serialized shell env,
  • optional agent session key.

Session-level bang-command executions pass sessionKey: this.sessionId.

Tool-call executions pass sessionKey: this.session.getSessionId?.(), when available. In both surfaces, a session key isolates shell reuse per session; without one, reuse falls back to shell config/snapshot/env.

Shell config and snapshot behavior

At each call, executor loads settings shell config (shell, env, optional prefix).

If selected shell includes bash, it attempts getOrCreateSnapshot():

  • snapshot captures aliases/functions/options from user rc,
  • snapshot creation is best-effort,
  • failure falls back to no snapshot.

If prefix is configured, command becomes:

<prefix> <command>

Streaming and cancellation

Shell.run() streams chunks to OutputSink and optional onChunk callback.

Cancellation:

  • aborted signal triggers shellSession.abort(...),
  • timeout from native result is mapped to cancelled: true + annotation text,
  • explicit cancellation similarly returns cancelled: true + annotation.

No exception is thrown inside executor for timeout/cancel; it returns structured BashResult and lets caller map error semantics.

Interactive PTY path (runInteractiveBashPty)

When PTY is enabled, tool runs runInteractiveBashPty() which opens an overlay console component and drives a native PtySession.

Behavior highlights:

  • xterm-headless virtual terminal renders viewport in overlay,
  • keyboard input is normalized (including Kitty sequences and application cursor mode handling),
  • esc while running kills the PTY session,
  • terminal resize propagates to PTY (session.resize(cols, rows)).

Environment hardening defaults are injected for unattended runs:

  • pagers disabled (PAGER=cat, GIT_PAGER=cat, etc.),
  • editor prompts disabled (GIT_EDITOR=true, EDITOR=true, ...),
  • terminal/auth prompts reduced (GIT_TERMINAL_PROMPT=0, SSH_ASKPASS=/usr/bin/false, CI=1),
  • package-manager/tool automation flags for non-interactive behavior.

PTY output is normalized (CRLF/CR to LF, sanitizeText) and written into OutputSink, including artifact spill support.

On PTY startup/runtime error, sink receives PTY error: ... line and command finalizes with undefined exit code.

Output handling: streaming, truncation, artifact spill

Both PTY and non-PTY paths use OutputSink.

OutputSink semantics

  • keeps an in-memory UTF-8-safe tail buffer (DEFAULT_MAX_BYTES, currently 50KB),
  • tracks total bytes/lines seen,
  • if artifact path exists and output overflows (or file already active), writes full stream to artifact file,
  • when memory threshold overflows, trims in-memory buffer to tail (UTF-8 boundary safe),
  • marks truncated when overflow/file spill occurs.

dump() returns:

  • output (possibly annotated prefix),
  • truncated,
  • totalLines/totalBytes,
  • outputLines/outputBytes,
  • artifactId if artifact file was active.

Long-output caveat

Runtime truncation is byte-threshold based in OutputSink (50KB default). It does not enforce a hard 2000-line cap in this code path.

Shell output minimizer

Non-PTY execution also passes shell-minimizer settings into the native Shell session. When the minimizer rewrites verbose output, the executor replaces the sink's visible text with the minimized text and, when possible, saves the raw original capture as a separate bash-original artifact referenced by a [raw output: artifact://<id>] footer.

Live tool updates and async jobs

For non-PTY foreground execution, BashTool uses a separate TailBuffer for partial updates and emits onUpdate snapshots while command is running.

For PTY execution, live rendering is handled by custom UI overlay, not by onUpdate text chunks.

When async.enabled is true and the call passes async: true, BashTool starts a managed bash job, returns a running job result with a job id, and stores completion through the session managed-job path. Auto-backgrounding can also start this path after bash.autoBackground.thresholdMs.

Result shaping, metadata, and error mapping

After execution:

  1. cancelled handling:
    • if abort signal is aborted -> throw ToolAbortError (abort semantics),
    • else -> throw ToolError (treated as tool failure).
  2. PTY timedOut -> throw ToolError.
  3. empty output becomes (no output).
  4. attach truncation metadata via toolResult(...).truncationFromSummary(result, { direction: "tail" }).
  5. exit-code mapping:
    • missing exit code -> throw ToolError("... missing exit status")
    • non-zero exit -> error result with "Command exited with code N" and details.exitCode
    • zero exit -> success result.

Success payload structure:

  • content: text output,
  • details.meta.truncation when truncated, including:
    • direction, truncatedBy, total/output line+byte counts,
    • shownRange,
    • artifactId when available.

Because built-in tools are wrapped with wrapToolWithMetaNotice(), truncation notice text is appended to final text content automatically (for example: Full: artifact://<id>).

Rendering paths

Tool-call renderer (bashToolRenderer)

bashToolRenderer is used for tool-call messages (toolCall / toolResult):

  • collapsed mode shows visual-line-truncated preview,
  • expanded mode shows all currently available output text,
  • warning line includes truncation reason and artifact://<id> when truncated,
  • timeout value (from args) is shown in footer metadata line.

Caveat: full artifact expansion

BashRenderContext has isFullOutput, but current renderer context builder does not set it for bash tool results. Expanded view still uses the text already in result content (tail/truncated output) unless another caller provides full artifact content.

User bang-command component (BashExecutionComponent)

BashExecutionComponent is for user ! commands in interactive mode (not model tool calls):

  • streams chunks live,
  • collapsed preview keeps last 20 logical lines,
  • line clamp at 4000 chars per line,
  • shows truncation + artifact warnings when metadata is present,
  • marks cancelled/error/exit state separately.

This component is wired by CommandController.handleBashCommand() and fed from AgentSession.executeBash().

Mode-specific behavior differences

Surface Entry path PTY eligible Live output UX Error surfacing
Interactive tool call BashTool.execute Yes, when pty=true and UI exists and PI_NO_PTY!=1 PTY overlay (interactive) or streamed tail updates Tool errors become toolResult.isError
Print mode tool call BashTool.execute No (no UI context) No TUI overlay; output appears in event stream/final assistant text flow Same tool error mapping
RPC tool call (agent tooling) BashTool.execute Usually no UI -> non-PTY Structured tool events/results Same tool error mapping
Interactive bang command (!) AgentSession.executeBash + BashExecutionComponent No (uses executor directly) Dedicated bash execution component Controller catches exceptions and shows UI error
RPC bash command rpc-mode -> session.executeBash No Returns BashResult directly Consumer handles returned fields

Operational caveats

  • Interceptor only blocks commands when suggested tool is currently available in context.
  • If artifact allocation fails, truncation still occurs but no artifact:// back-reference is available.
  • Shell session cache has no explicit eviction in this module; lifetime is process-scoped.
  • PTY and non-PTY timeout surfaces differ:
    • PTY exposes explicit timedOut result field,
    • non-PTY maps timeout into cancelled + annotation summary.

Implementation files