/** * 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(), cancel: vi.fn(), cancelScope: vi.fn(), getRecord: vi.fn(), })); 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["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["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[0]; executeArgs?: Partial["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 | 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[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>( async (params) => ({ argv: ["remote-shell", params.command], env: {}, stdinMode: "pipe-open" as const, }), ); const validateWorkdir = vi.fn>( 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>( async (params) => ({ argv: ["remote-shell", params.command], env: {}, stdinMode: "pipe-open" as const, finalizeToken, }), ); const finalizeExec = vi.fn>(async () => {}); const validateWorkdir = vi.fn>( 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>( async (params) => ({ argv: ["remote-shell", params.command], env: {}, stdinMode: "pipe-open" as const, }), ); const validateWorkdir = vi.fn>( async (workdir) => workdir, ); const discardPreparedWorkdir = vi.fn>(); 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>( async (params) => ({ argv: ["remote-shell", params.command], env: {}, stdinMode: "pipe-open" as const, }), ); const validateWorkdir = vi.fn>( 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>( async (params) => ({ argv: ["remote-shell", params.command], env: {}, stdinMode: "pipe-open" as const, }), ); const validateWorkdir = vi.fn>( 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>( async () => null, ); const buildExecSpec = vi.fn>( 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 }); } }); });