fix: fall back to non-PTY exec

This commit is contained in:
Peter Steinberger
2026-01-23 06:26:30 +00:00
parent 39d8ff59aa
commit 4912e85ac8
3 changed files with 85 additions and 38 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.clawd.bot
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer. - 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. - Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
- Agents: surface concrete API error details instead of generic AI service errors. - 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. - 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. - 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. - Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.

View File

@@ -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");
});

View File

@@ -26,7 +26,7 @@ import {
resolveShellEnvFallbackTimeoutMs, resolveShellEnvFallbackTimeoutMs,
} from "../infra/shell-env.js"; } from "../infra/shell-env.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { logInfo } from "../logger.js"; import { logInfo, logWarn } from "../logger.js";
import { import {
type ProcessSession, type ProcessSession,
type SessionStdin, type SessionStdin,
@@ -381,6 +381,8 @@ async function runExecProcess(opts: {
) as ChildProcessWithoutNullStreams; ) as ChildProcessWithoutNullStreams;
stdin = child.stdin; stdin = child.stdin;
} else if (opts.usePty) { } else if (opts.usePty) {
const { shell, args: shellArgs } = getShellConfig();
try {
const ptyModule = (await import("@lydell/node-pty")) as unknown as { const ptyModule = (await import("@lydell/node-pty")) as unknown as {
spawn?: PtySpawn; spawn?: PtySpawn;
default?: { spawn?: PtySpawn }; default?: { spawn?: PtySpawn };
@@ -389,7 +391,6 @@ async function runExecProcess(opts: {
if (!spawnPty) { if (!spawnPty) {
throw new Error("PTY support is unavailable (node-pty spawn not found)."); throw new Error("PTY support is unavailable (node-pty spawn not found).");
} }
const { shell, args: shellArgs } = getShellConfig();
pty = spawnPty(shell, [...shellArgs, opts.command], { pty = spawnPty(shell, [...shellArgs, opts.command], {
cwd: opts.workdir, cwd: opts.workdir,
env: opts.env, env: opts.env,
@@ -416,6 +417,20 @@ async function runExecProcess(opts: {
} }
}, },
}; };
} 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 { } else {
const { shell, args: shellArgs } = getShellConfig(); const { shell, args: shellArgs } = getShellConfig();
child = spawn(shell, [...shellArgs, opts.command], { child = spawn(shell, [...shellArgs, opts.command], {
@@ -1320,7 +1335,7 @@ export function createExecTool(
const effectiveTimeout = const effectiveTimeout =
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec; 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 usePty = params.pty === true && !sandbox;
const run = await runExecProcess({ const run = await runExecProcess({
command: params.command, command: params.command,
@@ -1360,7 +1375,7 @@ export function createExecTool(
{ {
type: "text", type: "text",
text: text:
`${warningText}` + `${getWarningText()}` +
`Command still running (session ${run.session.id}, pid ${ `Command still running (session ${run.session.id}, pid ${
run.session.pid ?? "n/a" run.session.pid ?? "n/a"
}). ` + }). ` +
@@ -1410,7 +1425,7 @@ export function createExecTool(
content: [ content: [
{ {
type: "text", type: "text",
text: `${warningText}${outcome.aggregated || "(no output)"}`, text: `${getWarningText()}${outcome.aggregated || "(no output)"}`,
}, },
], ],
details: { details: {