diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e6b95e2..89a63f827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.clawd.bot - Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer. - Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok. - Agents: surface concrete API error details instead of generic AI service errors. +- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484) - Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj. - Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider. - Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c. diff --git a/src/agents/bash-tools.exec.pty-fallback.test.ts b/src/agents/bash-tools.exec.pty-fallback.test.ts new file mode 100644 index 000000000..18455a1b1 --- /dev/null +++ b/src/agents/bash-tools.exec.pty-fallback.test.ts @@ -0,0 +1,31 @@ +import { afterEach, expect, test, vi } from "vitest"; + +import { resetProcessRegistryForTests } from "./bash-process-registry"; + +afterEach(() => { + resetProcessRegistryForTests(); + vi.resetModules(); + vi.clearAllMocks(); +}); + +test("exec falls back when PTY spawn fails", async () => { + vi.doMock("@lydell/node-pty", () => ({ + spawn: () => { + const err = new Error("spawn EBADF"); + (err as NodeJS.ErrnoException).code = "EBADF"; + throw err; + }, + })); + + const { createExecTool } = await import("./bash-tools.exec"); + const tool = createExecTool({ allowBackground: false }); + const result = await tool.execute("toolcall", { + command: "printf ok", + pty: true, + }); + + expect(result.details.status).toBe("completed"); + const text = result.content?.[0]?.text ?? ""; + expect(text).toContain("ok"); + expect(text).toContain("PTY spawn failed"); +}); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 1f102d8ab..ee801b840 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -26,7 +26,7 @@ import { resolveShellEnvFallbackTimeoutMs, } from "../infra/shell-env.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; -import { logInfo } from "../logger.js"; +import { logInfo, logWarn } from "../logger.js"; import { type ProcessSession, type SessionStdin, @@ -381,41 +381,56 @@ async function runExecProcess(opts: { ) as ChildProcessWithoutNullStreams; stdin = child.stdin; } else if (opts.usePty) { - const ptyModule = (await import("@lydell/node-pty")) as unknown as { - spawn?: PtySpawn; - default?: { spawn?: PtySpawn }; - }; - const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn; - if (!spawnPty) { - throw new Error("PTY support is unavailable (node-pty spawn not found)."); - } const { shell, args: shellArgs } = getShellConfig(); - pty = spawnPty(shell, [...shellArgs, opts.command], { - cwd: opts.workdir, - env: opts.env, - name: process.env.TERM ?? "xterm-256color", - cols: 120, - rows: 30, - }); - stdin = { - destroyed: false, - write: (data, cb) => { - try { - pty?.write(data); - cb?.(null); - } catch (err) { - cb?.(err as Error); - } - }, - end: () => { - try { - const eof = process.platform === "win32" ? "\x1a" : "\x04"; - pty?.write(eof); - } catch { - // ignore EOF errors - } - }, - }; + try { + const ptyModule = (await import("@lydell/node-pty")) as unknown as { + spawn?: PtySpawn; + default?: { spawn?: PtySpawn }; + }; + const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn; + if (!spawnPty) { + throw new Error("PTY support is unavailable (node-pty spawn not found)."); + } + pty = spawnPty(shell, [...shellArgs, opts.command], { + cwd: opts.workdir, + env: opts.env, + name: process.env.TERM ?? "xterm-256color", + cols: 120, + rows: 30, + }); + stdin = { + destroyed: false, + write: (data, cb) => { + try { + pty?.write(data); + cb?.(null); + } catch (err) { + cb?.(err as Error); + } + }, + end: () => { + try { + const eof = process.platform === "win32" ? "\x1a" : "\x04"; + pty?.write(eof); + } catch { + // ignore EOF errors + } + }, + }; + } catch (err) { + const errText = String(err); + const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`; + logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`); + opts.warnings.push(warning); + child = spawn(shell, [...shellArgs, opts.command], { + cwd: opts.workdir, + env: opts.env, + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }) as ChildProcessWithoutNullStreams; + stdin = child.stdin; + } } else { const { shell, args: shellArgs } = getShellConfig(); child = spawn(shell, [...shellArgs, opts.command], { @@ -1320,7 +1335,7 @@ export function createExecTool( const effectiveTimeout = typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec; - const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : ""; + const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : ""); const usePty = params.pty === true && !sandbox; const run = await runExecProcess({ command: params.command, @@ -1360,7 +1375,7 @@ export function createExecTool( { type: "text", text: - `${warningText}` + + `${getWarningText()}` + `Command still running (session ${run.session.id}, pid ${ run.session.pid ?? "n/a" }). ` + @@ -1410,7 +1425,7 @@ export function createExecTool( content: [ { type: "text", - text: `${warningText}${outcome.aggregated || "(no output)"}`, + text: `${getWarningText()}${outcome.aggregated || "(no output)"}`, }, ], details: {