diff --git a/docs/tools/exec.md b/docs/tools/exec.md index f55435fbe..310008e35 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -48,6 +48,7 @@ Notes: - `tools.exec.node` (default: unset) - `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs. - `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries. +- `tools.exec.pty` (default: false): enable PTY mode by default for exec commands. Useful when commands hang without a TTY. Ignored when sandboxed. Example: ```json5 diff --git a/src/agents/bash-tools.exec.pty.test.ts b/src/agents/bash-tools.exec.pty.test.ts index 8ada2ecca..263e273bf 100644 --- a/src/agents/bash-tools.exec.pty.test.ts +++ b/src/agents/bash-tools.exec.pty.test.ts @@ -18,3 +18,28 @@ test("exec supports pty output", async () => { const text = result.content?.[0]?.text ?? ""; expect(text).toContain("ok"); }); + +test("exec uses pty when defaults.pty is true", async () => { + const tool = createExecTool({ allowBackground: false, pty: true }); + // Note: pty is NOT passed in params - should use default + const result = await tool.execute("toolcall", { + command: 'node -e "process.stdout.write(String.fromCharCode(111,107))"', + }); + + expect(result.details.status).toBe("completed"); + const text = result.content?.[0]?.text ?? ""; + expect(text).toContain("ok"); +}); + +test("exec params.pty overrides defaults.pty", async () => { + const tool = createExecTool({ allowBackground: false, pty: true }); + // Explicitly set pty: false to override default + const result = await tool.execute("toolcall", { + command: "echo override", + pty: false, + }); + + expect(result.details.status).toBe("completed"); + const text = result.content?.[0]?.text ?? ""; + expect(text).toContain("override"); +}); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index ad77d10e6..a5e5bd9e9 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -134,6 +134,7 @@ export type ExecToolDefaults = { messageProvider?: string; notifyOnExit?: boolean; cwd?: string; + pty?: boolean; }; export type { BashSandboxConfig } from "./bash-tools.shared.js"; @@ -1295,7 +1296,7 @@ export function createExecTool( env, sandbox: undefined, containerWorkdir: null, - usePty: params.pty === true && !sandbox, + usePty: (params.pty ?? defaults?.pty) === true && !sandbox, warnings, maxOutput, pendingMaxOutput, @@ -1381,7 +1382,7 @@ export function createExecTool( const effectiveTimeout = typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec; const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : ""); - const usePty = params.pty === true && !sandbox; + const usePty = (params.pty ?? defaults?.pty) === true && !sandbox; const run = await runExecProcess({ command: params.command, workdir, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 802f1aced..4dd77e442 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -92,6 +92,7 @@ function resolveExecConfig(cfg: OpenClawConfig | undefined) { approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs, cleanupMs: globalExec?.cleanupMs, notifyOnExit: globalExec?.notifyOnExit, + pty: globalExec?.pty, applyPatch: globalExec?.applyPatch, }; } @@ -269,6 +270,7 @@ export function createOpenClawCodingTools(options?: { approvalRunningNoticeMs: options?.exec?.approvalRunningNoticeMs ?? execConfig.approvalRunningNoticeMs, notifyOnExit: options?.exec?.notifyOnExit ?? execConfig.notifyOnExit, + pty: options?.exec?.pty ?? execConfig.pty, sandbox: sandbox ? { containerName: sandbox.containerName, diff --git a/src/config/schema.ts b/src/config/schema.ts index 1401b0574..3363954a5 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -180,6 +180,7 @@ const FIELD_LABELS: Record = { "tools.exec.node": "Exec Node Binding", "tools.exec.pathPrepend": "Exec PATH Prepend", "tools.exec.safeBins": "Exec Safe Bins", + "tools.exec.pty": "Exec PTY Mode", "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", @@ -421,6 +422,8 @@ const FIELD_HELP: Record = { "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", "tools.exec.safeBins": "Allow stdin-only safe binaries to run without explicit allowlist entries.", + "tools.exec.pty": + "Enable PTY mode by default for exec commands. Ignored when sandboxed. (default: false).", "tools.message.allowCrossContextSend": "Legacy override: allow cross-context sends across all providers.", "tools.message.crossContext.allowWithinProvider": diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index db32cb59d..cef370b91 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -183,6 +183,8 @@ export type ExecToolConfig = { cleanupMs?: number; /** Emit a system event and heartbeat when a backgrounded exec exits. */ notifyOnExit?: boolean; + /** Enable PTY mode by default for exec commands (ignored when sandboxed). */ + pty?: boolean; /** apply_patch subtool configuration (experimental). */ applyPatch?: { /** Enable apply_patch for OpenAI models (default: false). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7e95c3538..d6b3bda31 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -271,6 +271,7 @@ export const AgentToolsSchema = z approvalRunningNoticeMs: z.number().int().nonnegative().optional(), cleanupMs: z.number().int().positive().optional(), notifyOnExit: z.boolean().optional(), + pty: z.boolean().optional(), applyPatch: z .object({ enabled: z.boolean().optional(), @@ -512,6 +513,7 @@ export const ToolsSchema = z timeoutSec: z.number().int().positive().optional(), cleanupMs: z.number().int().positive().optional(), notifyOnExit: z.boolean().optional(), + pty: z.boolean().optional(), applyPatch: z .object({ enabled: z.boolean().optional(),