fix(bash): add 120s default timeout to prevent autonomous mode hangs

- Add BUILT_IN_DEFAULT_TIMEOUT_SECS = 120 constant to bash tool
- Compute effectiveTimeout = timeout ?? resolvedDefaultTimeout so LLM
  calls without a timeout get the 120s guard automatically
- Add defaultTimeoutSeconds? to BashToolOptions for override at creation
- Dynamic bashSchemaWithDefault describes the actual default in the LLM
  tool description, improving model awareness
- Add BashSettings interface + getBashDefaultTimeoutSeconds() to
  SettingsManager so users can override or disable via settings.json
- Wire defaultTimeoutSeconds into agent-session.ts _buildRuntime()

Root cause: npx sf --help triggered npm package download, hanging for
4+ minutes without timeout, consuming entire autonomous run budget.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-11 19:12:33 +02:00
parent 7ef58422b1
commit ca7368e5f1
4 changed files with 63 additions and 4 deletions

View file

@ -2530,6 +2530,8 @@ export class AgentSession {
read: { autoResizeImages },
bash: {
commandPrefix: shellCommandPrefix,
defaultTimeoutSeconds:
this.settingsManager.getBashDefaultTimeoutSeconds(),
interceptor: {
enabled: this.settingsManager.getBashInterceptorEnabled(),
rules: this.settingsManager.getBashInterceptorRules(),

View file

@ -51,6 +51,14 @@ export interface BashInterceptorSettings {
rules?: BashInterceptorRule[]; // override default rules
}
export interface BashSettings {
/**
* Default timeout in seconds for bash commands when the LLM does not provide one.
* Set to 0 to disable (commands run without a timeout). Defaults to 120.
*/
defaultTimeoutSeconds?: number;
}
export interface MarkdownSettings {
codeBlockIndent?: string; // default: " "
}
@ -173,6 +181,7 @@ export interface Settings {
memory?: MemorySettings;
async?: AsyncSettings;
bashInterceptor?: BashInterceptorSettings;
bash?: BashSettings;
taskIsolation?: TaskIsolationSettings;
fallback?: FallbackSettings;
modelDiscovery?: ModelDiscoverySettings;
@ -505,6 +514,16 @@ export class SettingsManager {
return this.settings.bashInterceptor?.rules;
}
/**
* Default bash command timeout in seconds. Returns undefined when unset,
* which lets the bash tool use its built-in default (120s).
* Returns 0 explicitly when the user has disabled the default.
*/
getBashDefaultTimeoutSeconds(): number | undefined {
const val = this.settings.bash?.defaultTimeoutSeconds;
return typeof val === "number" ? val : undefined;
}
reload(): void {
const globalLoad = SettingsManager.tryLoadFromStorage(
this.storage,

View file

@ -28,6 +28,15 @@ import {
truncateTail,
} from "./truncate.js";
/**
* Built-in default timeout (seconds) applied when the caller does not provide
* one and `BashToolOptions.defaultTimeoutSeconds` is not set.
* Prevents stuck commands (e.g. `npx pkg --help` triggering a download) from
* consuming the entire autonomous-mode run budget.
* Set `defaultTimeoutSeconds: 0` in BashToolOptions to opt out.
*/
const BUILT_IN_DEFAULT_TIMEOUT_SECS = 120;
// Cached Win32 FFI handles for restoring VT input after child processes
let _vtHandles: {
GetConsoleMode: any;
@ -145,7 +154,8 @@ const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(
Type.Number({
description: "Timeout in seconds (optional, no default timeout)",
description:
"Timeout in seconds. Omitting uses the built-in default (see createBashTool). Pass 0 to run without timeout.",
}),
),
});
@ -316,6 +326,12 @@ export interface BashToolOptions {
};
/** Tool names available in the session, used by the interceptor to check if replacement tools exist */
availableToolNames?: string[] | (() => string[]);
/**
* Default timeout in seconds applied when the LLM does not provide one.
* Set to 0 to disable the built-in default (allows commands to run indefinitely).
* Defaults to BUILT_IN_DEFAULT_TIMEOUT_SECS (120s) when not set.
*/
defaultTimeoutSeconds?: number;
}
export function createBashTool(
@ -326,6 +342,12 @@ export function createBashTool(
const commandPrefix = options?.commandPrefix;
const spawnHook = options?.spawnHook;
const artifactManager = options?.artifactManager;
// Resolve the default timeout once at construction time.
// Explicit 0 in options disables the built-in default; undefined falls through to BUILT_IN_DEFAULT_TIMEOUT_SECS.
const resolvedDefaultTimeout =
options?.defaultTimeoutSeconds !== undefined
? options.defaultTimeoutSeconds
: BUILT_IN_DEFAULT_TIMEOUT_SECS;
// Pre-compile interceptor rules once at construction time
const interceptorInstance = options?.interceptor?.enabled
@ -334,17 +356,32 @@ export function createBashTool(
)
: null;
const timeoutDescription =
resolvedDefaultTimeout > 0
? `Default: ${resolvedDefaultTimeout}s. Pass 0 to run without a timeout.`
: "No default timeout — commands run until completion.";
const bashSchemaWithDefault = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(
Type.Number({
description: `Timeout in seconds. ${timeoutDescription}`,
}),
),
});
return {
name: "bash",
label: "bash",
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
parameters: bashSchema,
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Default timeout: ${resolvedDefaultTimeout > 0 ? `${resolvedDefaultTimeout}s` : "none"} — pass a higher value for long-running commands (builds, installs, tests).`,
parameters: bashSchemaWithDefault,
execute: async (
_toolCallId: string,
{ command, timeout }: { command: string; timeout?: number },
signal?: AbortSignal,
onUpdate?,
) => {
// Apply the resolved default when the LLM does not provide a timeout.
const effectiveTimeout = timeout ?? resolvedDefaultTimeout;
// Check bash interceptor — block commands that duplicate dedicated tools
if (interceptorInstance) {
const toolNames =
@ -463,7 +500,7 @@ export function createBashTool(
.exec(spawnContext.command, spawnContext.cwd, {
onData: handleData,
signal,
timeout,
timeout: effectiveTimeout,
env: spawnContext.env,
})
.then(({ exitCode }) => {

View file

@ -280,6 +280,7 @@ export {
} from "./core/session-manager.js";
export {
type AsyncSettings,
type BashSettings,
type CompactionSettings,
type ImageSettings,
type MemorySettings,