Files
openclaw/src/agents/bash-tools.exec-foreground-failures.test.ts

645 lines
21 KiB
TypeScript

/**
* Foreground exec failure tests.
* Verifies failed process outcomes surface useful text/details for shell
* errors, timeouts, signals, and runtime failures.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { ProcessSupervisor, SpawnInput } from "../process/supervisor/index.js";
import { captureEnv } from "../test-utils/env.js";
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
import { createExecTool } from "./bash-tools.exec.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js";
import { resolveShellFromPath } from "./shell-utils.js";
const supervisorMock = vi.hoisted(() => ({
spawn: vi.fn<ProcessSupervisor["spawn"]>(),
cancel: vi.fn<ProcessSupervisor["cancel"]>(),
cancelScope: vi.fn<ProcessSupervisor["cancelScope"]>(),
getRecord: vi.fn<ProcessSupervisor["getRecord"]>(),
}));
vi.mock("../process/supervisor/index.js", () => ({
getProcessSupervisor: () => supervisorMock,
}));
const isWin = process.platform === "win32";
const defaultShell = isWin
? undefined
: process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh";
const tempDirs = createTempDirTracker();
function requireTextContent(
result: Awaited<ReturnType<ReturnType<typeof createExecTool>["execute"]>>,
) {
const content = result.content[0];
expect(content?.type).toBe("text");
if (content?.type !== "text") {
throw new Error(`expected text content, got ${content?.type ?? "missing"}`);
}
return content.text;
}
function requireFailedDetails(
details: Awaited<ReturnType<ReturnType<typeof createExecTool>["execute"]>>["details"],
) {
expect(details.status).toBe("failed");
if (details.status !== "failed") {
throw new Error(`expected failed details, got ${details.status}`);
}
return details;
}
function mockSuccessfulSpawn(stdout = "ok\n") {
supervisorMock.spawn.mockImplementationOnce(async (input: SpawnInput) => ({
runId: input.runId ?? "call-success",
pid: 1234,
startedAtMs: Date.now(),
stdin: {
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
},
wait: vi.fn(async () => ({
reason: "exit" as const,
exitCode: 0,
exitSignal: null,
durationMs: 1,
stdout,
stderr: "",
timedOut: false,
noOutputTimedOut: false,
})),
cancel: vi.fn(),
}));
}
async function expectUnavailableWorkdir(params: {
workdir: string;
toolDefaults?: Parameters<typeof createExecTool>[0];
executeArgs?: Partial<Parameters<ReturnType<typeof createExecTool>["execute"]>[1]>;
cleanup?: () => void;
}) {
const tool = createExecTool({
security: "full",
ask: "off",
allowBackground: false,
...params.toolDefaults,
});
try {
const executeArgs = params.executeArgs ?? { workdir: params.workdir };
const result = await tool.execute("call-unavailable-workdir", {
command: "echo should-not-run",
...executeArgs,
});
const text = requireTextContent(result);
expect(text).toContain(`workdir "${params.workdir}" is unavailable or not a directory`);
expect(text).toContain("command was not executed");
expect(text).toContain("workdir is treated as a literal path");
expect(text).toContain('shell expansions such as "~" are not applied');
const details = requireFailedDetails(result.details);
expect(details.exitCode).toBeNull();
expect(details.timedOut).toBe(false);
expect(details.aggregated).toBe("");
expect(details.cwd).toBe(params.workdir);
expect(supervisorMock.spawn).not.toHaveBeenCalled();
} finally {
params.cleanup?.();
}
}
describe("exec foreground failures", () => {
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
beforeEach(() => {
vi.useRealTimers();
envSnapshot = captureEnv(["SHELL"]);
if (!isWin && defaultShell) {
process.env.SHELL = defaultShell;
}
supervisorMock.spawn.mockReset();
supervisorMock.cancel.mockReset();
supervisorMock.cancelScope.mockReset();
supervisorMock.getRecord.mockReset();
resetProcessRegistryForTests();
});
afterEach(() => {
vi.useRealTimers();
envSnapshot?.restore();
envSnapshot = undefined;
tempDirs.cleanup();
});
it("returns a failed text result when the default timeout is exceeded", async () => {
const tool = createExecTool({
security: "full",
ask: "off",
timeoutSec: 1,
backgroundMs: 10,
allowBackground: false,
});
supervisorMock.spawn.mockImplementationOnce(async (input: SpawnInput) => ({
runId: input.runId ?? "call-timeout",
pid: 1234,
startedAtMs: Date.now(),
stdin: {
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
},
wait: vi.fn(async () => ({
reason: "overall-timeout" as const,
exitCode: null,
exitSignal: "SIGKILL" as NodeJS.Signals,
durationMs: input.timeoutMs ?? 50,
stdout: "",
stderr: "",
timedOut: true,
noOutputTimedOut: false,
})),
cancel: vi.fn(),
}));
const result = await tool.execute("call-timeout", {
command: "echo never-runs",
host: "gateway",
});
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
expect(supervisorMock.spawn.mock.calls[0]?.[0]?.timeoutMs).toBe(1_000);
const text = requireTextContent(result);
expect(text).toMatch(/timed out/i);
expect(text).toMatch(/re-run with a higher timeout/i);
const details = requireFailedDetails(result.details);
expect(details.exitCode).toBeNull();
expect(details.exitSignal).toBe("SIGKILL");
expect(details.failureKind).toBe("overall-timeout");
expect(details.exitReason).toBe("overall-timeout");
expect(details.timedOut).toBe(true);
expect(details.noOutputTimedOut).toBe(false);
expect(details.aggregated).toBe("");
expect(details.durationMs).toBeTypeOf("number");
expect(details.durationMs).toBeGreaterThanOrEqual(0);
});
it("rejects invalid host values before launching a command", async () => {
const tool = createExecTool({
security: "full",
ask: "off",
allowBackground: false,
});
for (const testCase of [
{
host: "spark-ff13",
message: 'Invalid exec host "spark-ff13". Allowed values: auto, sandbox, gateway, node.',
},
{
host: 42,
message:
"Invalid exec host value type number. Allowed values: auto, sandbox, gateway, node.",
},
]) {
const malformedArgs = {
command: "echo should-not-run",
host: testCase.host,
} as unknown as Parameters<typeof tool.execute>[1];
await expect(tool.execute("call-invalid-host", malformedArgs)).rejects.toThrow(
testCase.message,
);
}
});
it("returns a failed result for unavailable explicit host workdirs before launching", async () => {
const missingWorkdir = path.join(
os.tmpdir(),
`openclaw-missing-workdir-${process.pid}-${Date.now()}`,
);
fs.rmSync(missingWorkdir, { recursive: true, force: true });
const fileWorkdir = path.join(
os.tmpdir(),
`openclaw-file-workdir-${process.pid}-${Date.now()}`,
);
fs.writeFileSync(fileWorkdir, "not a directory");
try {
for (const workdir of [missingWorkdir, " ", fileWorkdir]) {
await expectUnavailableWorkdir({ workdir });
supervisorMock.spawn.mockClear();
}
} finally {
fs.rmSync(fileWorkdir, { force: true });
}
});
it("returns a failed result for unavailable configured host workdirs before launching", async () => {
const missingDefaultWorkdir = path.join(
os.tmpdir(),
`openclaw-missing-default-workdir-${process.pid}-${Date.now()}`,
);
fs.rmSync(missingDefaultWorkdir, { recursive: true, force: true });
await expectUnavailableWorkdir({
workdir: missingDefaultWorkdir,
toolDefaults: { cwd: missingDefaultWorkdir },
executeArgs: {},
});
});
it("returns a failed result when the current gateway cwd is unavailable", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => {
throw new Error("current cwd unavailable");
});
try {
await expectUnavailableWorkdir({
workdir: "current working directory",
executeArgs: {},
});
} finally {
cwdSpy.mockRestore();
}
});
it("returns a failed result for unavailable configured sandbox workdirs before launching", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
try {
await expectUnavailableWorkdir({
workdir: "/workspace/missing",
toolDefaults: {
cwd: "/workspace/missing",
host: "sandbox",
sandbox: {
containerName: "sandbox-workdir-test",
workspaceDir,
containerWorkdir: "/workspace",
},
},
executeArgs: {},
});
} finally {
fs.rmSync(workspaceDir, { recursive: true, force: true });
}
});
it("defaults omitted sandbox workdirs to the sandbox workspace", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
mockSuccessfulSpawn();
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
allowBackground: false,
sandbox: {
containerName: "sandbox-workdir-test",
workspaceDir,
containerWorkdir: "/workspace",
},
});
try {
const result = await tool.execute("call-sandbox-default-workdir", {
command: "echo ok",
});
expect(result.details.status).toBe("completed");
expect(result.details.cwd).toBe(workspaceDir);
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
const input = supervisorMock.spawn.mock.calls[0]?.[0];
expect(input?.cwd).toBe(workspaceDir);
expect(input?.mode).toBe("child");
if (input?.mode === "child") {
expect(input.argv).toContain("-w");
expect(input.argv).toContain("/workspace");
}
} finally {
fs.rmSync(workspaceDir, { recursive: true, force: true });
}
});
it("lets backend-validated sandbox workdirs reach the backend without host stat fallback", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
async (params) => ({
argv: ["remote-shell", params.command],
env: {},
stdinMode: "pipe-open" as const,
}),
);
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
async (workdir) => workdir,
);
mockSuccessfulSpawn();
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
allowBackground: false,
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir,
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
buildExecSpec,
},
});
try {
const result = await tool.execute("call-remote-sandbox-workdir", {
command: "echo ok",
workdir: "/remote/workspace/generated",
});
expect(result.details.status).toBe("completed");
expect(result.details.cwd).toBe(workspaceDir);
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated");
expect(buildExecSpec).toHaveBeenCalledOnce();
expect(buildExecSpec.mock.calls[0]?.[0]?.workdir).toBe("/remote/workspace/generated");
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
expect(supervisorMock.spawn.mock.calls[0]?.[0]?.cwd).toBe(workspaceDir);
} finally {
fs.rmSync(workspaceDir, { recursive: true, force: true });
}
});
it("finalizes backend sandbox exec tokens when process spawn fails", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
const finalizeToken = { session: "remote-session" };
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
async (params) => ({
argv: ["remote-shell", params.command],
env: {},
stdinMode: "pipe-open" as const,
finalizeToken,
}),
);
const finalizeExec = vi.fn<NonNullable<BashSandboxConfig["finalizeExec"]>>(async () => {});
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
async (workdir) => workdir,
);
supervisorMock.spawn.mockRejectedValueOnce(new Error("spawn failed"));
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
allowBackground: false,
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir,
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
buildExecSpec,
finalizeExec,
},
});
try {
await expect(
tool.execute("call-remote-sandbox-spawn-failure", {
command: "echo ok",
workdir: "/remote/workspace/generated",
}),
).rejects.toThrow("spawn failed");
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated");
expect(buildExecSpec).toHaveBeenCalledOnce();
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
expect(finalizeExec).toHaveBeenCalledOnce();
expect(finalizeExec).toHaveBeenCalledWith({
status: "failed",
exitCode: null,
timedOut: false,
token: finalizeToken,
});
} finally {
fs.rmSync(workspaceDir, { recursive: true, force: true });
}
});
it("rejects unsafe commands before backend workdir validation", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
async (params) => ({
argv: ["remote-shell", params.command],
env: {},
stdinMode: "pipe-open" as const,
}),
);
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
async (workdir) => workdir,
);
const discardPreparedWorkdir =
vi.fn<NonNullable<BashSandboxConfig["discardPreparedWorkdir"]>>();
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
allowBackground: false,
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir,
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
discardPreparedWorkdir,
buildExecSpec,
},
});
try {
await expect(
tool.execute("call-remote-sandbox-rejected-command", {
command: "/approve approval-1 deny",
workdir: "/remote/workspace/generated",
}),
).rejects.toThrow("exec cannot run /approve commands");
expect(validateWorkdir).not.toHaveBeenCalled();
expect(discardPreparedWorkdir).not.toHaveBeenCalled();
expect(buildExecSpec).not.toHaveBeenCalled();
expect(supervisorMock.spawn).not.toHaveBeenCalled();
} finally {
fs.rmSync(workspaceDir, { recursive: true, force: true });
}
});
it("does not preflight remote-only backend workdirs from the local workspace root", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
fs.writeFileSync(path.join(workspaceDir, "script.py"), "print($TOKEN)\n");
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
async (params) => ({
argv: ["remote-shell", params.command],
env: {},
stdinMode: "pipe-open" as const,
}),
);
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
async (workdir) => workdir,
);
mockSuccessfulSpawn();
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
allowBackground: false,
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir,
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
buildExecSpec,
},
});
try {
const result = await tool.execute("call-remote-only-script", {
command: "python script.py",
workdir: "/remote/workspace/generated",
});
expect(result.details.status).toBe("completed");
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated");
expect(buildExecSpec).toHaveBeenCalledOnce();
expect(buildExecSpec.mock.calls[0]?.[0]?.workdir).toBe("/remote/workspace/generated");
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
} finally {
fs.rmSync(workspaceDir, { recursive: true, force: true });
}
});
it("uses the mapped host cwd for existing relative backend-validated sandbox workdirs", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
const srcDir = path.join(workspaceDir, "src");
fs.mkdirSync(srcDir);
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
async (params) => ({
argv: ["remote-shell", params.command],
env: {},
stdinMode: "pipe-open" as const,
}),
);
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
async (workdir) => workdir,
);
mockSuccessfulSpawn();
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
allowBackground: false,
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir,
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
buildExecSpec,
},
});
try {
const result = await tool.execute("call-relative-remote-sandbox-workdir", {
command: "echo ok",
workdir: "src",
});
expect(result.details.status).toBe("completed");
expect(result.details.cwd).toBe(srcDir);
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/src");
expect(buildExecSpec).toHaveBeenCalledOnce();
expect(buildExecSpec.mock.calls[0]?.[0]?.workdir).toBe("/remote/workspace/src");
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
expect(supervisorMock.spawn.mock.calls[0]?.[0]?.cwd).toBe(srcDir);
} finally {
fs.rmSync(workspaceDir, { recursive: true, force: true });
}
});
it("fails backend-validated sandbox workdirs before launch when backend validation rejects", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
async () => null,
);
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
async (params) => ({
argv: ["remote-shell", params.command],
env: {},
stdinMode: "pipe-open" as const,
}),
);
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
allowBackground: false,
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir,
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
buildExecSpec,
},
});
try {
const result = await tool.execute("call-remote-sandbox-workdir", {
command: "echo ok",
workdir: "/remote/workspace/generated",
});
expect(result.details).toMatchObject({
status: "failed",
cwd: "/remote/workspace/generated",
});
expect(JSON.stringify(result)).toContain("unavailable or not a directory");
expect(validateWorkdir).toHaveBeenCalledOnce();
expect(buildExecSpec).not.toHaveBeenCalled();
expect(supervisorMock.spawn).not.toHaveBeenCalled();
} finally {
fs.rmSync(workspaceDir, { recursive: true, force: true });
}
});
it("returns a failed result for unavailable explicit sandbox workdirs before launching a command", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
const outsideDir = tempDirs.make("openclaw-outside-workdir-");
fs.writeFileSync(path.join(workspaceDir, "not-dir"), "not a directory");
try {
for (const workdir of ["/workspace/missing", " ", "/workspace/not-dir", outsideDir]) {
await expectUnavailableWorkdir({
workdir,
toolDefaults: {
host: "sandbox",
sandbox: {
containerName: "sandbox-workdir-test",
workspaceDir,
containerWorkdir: "/workspace",
},
},
});
supervisorMock.spawn.mockClear();
}
} finally {
fs.rmSync(workspaceDir, { recursive: true, force: true });
fs.rmSync(outsideDir, { recursive: true, force: true });
}
});
});