Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
| `tools.sandbox` | boolean or string | Sandbox execution environment (can be a boolean or a path string). | `undefined` | |
| `tools.sandboxImage` | string | Sandbox image URI used by Docker/Podman when `--sandbox-image` and `QWEN_SANDBOX_IMAGE` are not set. | `undefined` | |
| `tools.shell.enableInteractiveShell` | boolean | Use `node-pty` for an interactive shell experience. Fallback to `child_process` still applies. | `true` | |
| `tools.shell.defaultTimeoutMs` | number | Default timeout, in milliseconds, for foreground shell commands started by the agent. A per-call timeout on the shell tool overrides this. When unset, foreground commands time out after 120000 ms (2 minutes). Set to 0 to disable the timeout. | `undefined` | |
| `tools.core` | array of strings | **Deprecated.** Will be removed in next version. Use `permissions.allow` + `permissions.deny` instead. Restricts built-in tools to an allowlist. All tools not in the list are disabled. | `undefined` | |
| `tools.exclude` | array of strings | **Deprecated.** Use `permissions.deny` instead. Tool names to exclude from discovery. Automatically migrated to the `permissions` format on first load. | `undefined` | |
| `tools.disabled` | array of strings | Tool names hidden from the registry entirely. Unlike `permissions.deny` (which blocks calls at runtime), disabled tools are never registered, so they do not appear in `/tools` and cannot be discovered or called by the model. For example, `["enter_plan_mode"]` prevents the model from switching into plan mode on its own. Merged as a union across scopes. | `undefined` | |
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,17 @@ describe('loadCliConfig', () => {
expect(config.getAgentsSettings().maxParallelAgents).toBe(2);
});

it('passes tools.shell.defaultTimeoutMs from settings to core config', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig(
{ tools: { shell: { defaultTimeoutMs: 300000 } } },
argv,
);

expect(config.getShellDefaultTimeoutMs()).toBe(300000);
});

it('should ignore blank settings fallback models', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2164,6 +2164,7 @@ export async function loadCliConfig(
useRipgrep: settings.tools?.useRipgrep,
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
shellDefaultTimeoutMs: settings.tools?.shell?.defaultTimeoutMs,
preventSystemSleep: settings.general?.preventSystemSleep ?? true,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
skipWorkflowUsageWarning: settings.model?.skipWorkflowUsageWarning ?? false,
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2245,6 +2245,18 @@ const SETTINGS_SCHEMA = {
description: 'Show color in shell output.',
showInDialog: false,
},
defaultTimeoutMs: {
type: 'integer',
minimum: 0,
maximum: 600000,
label: 'Default Command Timeout (ms)',
category: 'Tools',
requiresRestart: true,
default: undefined as number | undefined,
description:
'Default timeout, in milliseconds, for foreground shell commands started by the agent. A per-call timeout on the shell tool overrides this. When unset, foreground commands time out after 120000 ms (2 minutes). Set to 0 to disable the timeout.',
showInDialog: false,
},
},
},
// Legacy tool permission fields – kept for backward compatibility.
Expand Down
63 changes: 63 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,69 @@ describe('Server Config (config.ts)', () => {
});
});

describe('getShellDefaultTimeoutMs', () => {
it('returns undefined when unset', () => {
expect(new Config(baseParams).getShellDefaultTimeoutMs()).toBeUndefined();
});

it('passes through positive values', () => {
expect(
new Config({
...baseParams,
shellDefaultTimeoutMs: 300_000,
}).getShellDefaultTimeoutMs(),
).toBe(300_000);
});

it('accepts 0 (disables the timeout — unlike the vision bridge)', () => {
expect(
new Config({
...baseParams,
shellDefaultTimeoutMs: 0,
}).getShellDefaultTimeoutMs(),
).toBe(0);
});

it('treats negative values as unset (schema validation is bypassed on load)', () => {
expect(
new Config({
...baseParams,
shellDefaultTimeoutMs: -5000,
}).getShellDefaultTimeoutMs(),
).toBeUndefined();
});

it('rejects values AbortSignal.timeout cannot take (fractional, over 2^31-1, non-finite)', () => {
// A hand-edited settings.json bypasses the schema and can reach
// AbortSignal.timeout, which would throw RangeError or degrade to a
// 1ms timer on these. Coerce to undefined → built-in default.
for (const bad of [
30_000.5,
2_147_483_648,
4_294_967_296,
1e300,
Number.NaN,
Number.POSITIVE_INFINITY,
]) {
expect(
new Config({
...baseParams,
shellDefaultTimeoutMs: bad,
}).getShellDefaultTimeoutMs(),
).toBeUndefined();
}
});

it('accepts the maximum supported integer timeout', () => {
expect(
new Config({
...baseParams,
shellDefaultTimeoutMs: 2_147_483_647,
}).getShellDefaultTimeoutMs(),
).toBe(2_147_483_647);
});
});

describe('getMaxSubagentDepth', () => {
it('defaults to 5 when unset', () => {
expect(new Config(baseParams).getMaxSubagentDepth()).toBe(5);
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,13 @@ export interface ConfigParameters {
truncateToolOutputThreshold?: number;
truncateToolOutputLines?: number;
toolOutputBatchBudget?: number;
/**
* Default timeout, in ms, for foreground shell commands. A per-call
* timeout on the shell tool takes precedence; when both are unset the
* shell tool falls back to its built-in default. See
* getShellDefaultTimeoutMs.
*/
shellDefaultTimeoutMs?: number;
eventEmitter?: EventEmitter;
output?: OutputSettings;
inputFormat?: InputFormat;
Expand Down Expand Up @@ -1763,6 +1770,7 @@ export class Config {
private readonly truncateToolOutputThreshold: number;
private readonly truncateToolOutputLines: number;
private readonly toolOutputBatchBudget: number;
private readonly shellDefaultTimeoutMs: number | undefined;
private readonly eventEmitter?: EventEmitter;
private readonly channel: string | undefined;
private readonly jsonFd: number | undefined;
Expand Down Expand Up @@ -2025,6 +2033,20 @@ export class Config {
params.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES;
this.toolOutputBatchBudget =
params.toolOutputBatchBudget ?? DEFAULT_TOOL_OUTPUT_BATCH_BUDGET;
// Guard: nothing validates settings.json on the load path (the schema only
// runs on the /config write path), so this is the only real gate. The value
// reaches `AbortSignal.timeout()`, which requires an integer in [0, 2^31-1];
// a negative or fractional value would throw RangeError or silently degrade
// to a 1ms timeout. Unlike the vision bridge, 0 is valid here and disables
// the timeout. Reject anything the timer can't take and fall back to the
// built-in default.
this.shellDefaultTimeoutMs =
params.shellDefaultTimeoutMs !== undefined &&
Number.isInteger(params.shellDefaultTimeoutMs) &&
params.shellDefaultTimeoutMs >= 0 &&
params.shellDefaultTimeoutMs <= 2_147_483_647
? params.shellDefaultTimeoutMs
: undefined;
this.channel = params.channel;
this.jsonFd = params.jsonFd;
this.jsonFile = params.jsonFile;
Expand Down Expand Up @@ -5957,6 +5979,16 @@ export class Config {
return this.truncateToolOutputLines;
}

/**
* Configured default timeout (ms) for foreground shell commands, or
* `undefined` when unset. The shell tool applies the precedence
* per-call timeout > this setting > its built-in default, so returning
* `undefined` here preserves the built-in fallback.
*/
getShellDefaultTimeoutMs(): number | undefined {
return this.shellDefaultTimeoutMs;
}

getToolOutputBatchBudget(): number {
if (this.toolOutputBatchBudget <= 0) {
return Number.POSITIVE_INFINITY;
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/core/coreToolScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13260,7 +13260,9 @@ describe('CoreToolScheduler shell-tool promote integration (#3831 PR-2)', () =>
}
}

const tool = new TestShellTool({} as Config);
const tool = new TestShellTool({
getShellDefaultTimeoutMs: () => undefined,
} as unknown as Config);
const mockToolRegistry = {
getTool: () => tool,
ensureTool: async () => tool,
Expand Down
Loading
Loading