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,41 +381,56 @@ async function runExecProcess(opts: {
) as ChildProcessWithoutNullStreams; ) as ChildProcessWithoutNullStreams;
stdin = child.stdin; stdin = child.stdin;
} else if (opts.usePty) { } 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(); const { shell, args: shellArgs } = getShellConfig();
pty = spawnPty(shell, [...shellArgs, opts.command], { try {
cwd: opts.workdir, const ptyModule = (await import("@lydell/node-pty")) as unknown as {
env: opts.env, spawn?: PtySpawn;
name: process.env.TERM ?? "xterm-256color", default?: { spawn?: PtySpawn };
cols: 120, };
rows: 30, const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
}); if (!spawnPty) {
stdin = { throw new Error("PTY support is unavailable (node-pty spawn not found).");
destroyed: false, }
write: (data, cb) => { pty = spawnPty(shell, [...shellArgs, opts.command], {
try { cwd: opts.workdir,
pty?.write(data); env: opts.env,
cb?.(null); name: process.env.TERM ?? "xterm-256color",
} catch (err) { cols: 120,
cb?.(err as Error); rows: 30,
} });
}, stdin = {
end: () => { destroyed: false,
try { write: (data, cb) => {
const eof = process.platform === "win32" ? "\x1a" : "\x04"; try {
pty?.write(eof); pty?.write(data);
} catch { cb?.(null);
// ignore EOF errors } 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 { } 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: {