import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js"; import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js"; import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js"; import { buildDockerExecArgs } from "./bash-tools.shared.js"; import { sanitizeBinaryOutput } from "./shell-utils.js"; const isWin = process.platform === "win32"; // PowerShell: Start-Sleep for delays, ; for command separation, $null for null device const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 50" : "sleep 0.05"; const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2"; const longDelayCmd = isWin ? "Start-Sleep -Seconds 2" : "sleep 2"; // Both PowerShell and bash use ; for command separation const joinCommands = (commands: string[]) => commands.join("; "); const echoAfterDelay = (message: string) => joinCommands([shortDelayCmd, `echo ${message}`]); const echoLines = (lines: string[]) => joinCommands(lines.map((line) => `echo ${line}`)); const normalizeText = (value?: string) => sanitizeBinaryOutput(value ?? "") .replace(/\r\n/g, "\n") .replace(/\r/g, "\n") .split("\n") .map((line) => line.replace(/\s+$/u, "")) .join("\n") .trim(); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitForCompletion(sessionId: string) { let status = "running"; const deadline = Date.now() + (process.platform === "win32" ? 8000 : 2000); while (Date.now() < deadline && status === "running") { const poll = await processTool.execute("call-wait", { action: "poll", sessionId, }); status = (poll.details as { status: string }).status; if (status === "running") { await sleep(20); } } return status; } beforeEach(() => { resetProcessRegistryForTests(); resetSystemEventsForTest(); }); describe("exec tool backgrounding", () => { const originalShell = process.env.SHELL; beforeEach(() => { if (!isWin) process.env.SHELL = "/bin/bash"; }); afterEach(() => { if (!isWin) process.env.SHELL = originalShell; }); it( "backgrounds after yield and can be polled", async () => { const result = await execTool.execute("call1", { command: joinCommands([yieldDelayCmd, "echo done"]), yieldMs: 10, }); expect(result.details.status).toBe("running"); const sessionId = (result.details as { sessionId: string }).sessionId; let status = "running"; let output = ""; const deadline = Date.now() + (process.platform === "win32" ? 8000 : 2000); while (Date.now() < deadline && status === "running") { const poll = await processTool.execute("call2", { action: "poll", sessionId, }); status = (poll.details as { status: string }).status; const textBlock = poll.content.find((c) => c.type === "text"); output = textBlock?.text ?? ""; if (status === "running") { await sleep(20); } } expect(status).toBe("completed"); expect(output).toContain("done"); }, isWin ? 15_000 : 5_000, ); it("supports explicit background", async () => { const result = await execTool.execute("call1", { command: echoAfterDelay("later"), background: true, }); expect(result.details.status).toBe("running"); const sessionId = (result.details as { sessionId: string }).sessionId; const list = await processTool.execute("call2", { action: "list" }); const sessions = (list.details as { sessions: Array<{ sessionId: string }> }).sessions; expect(sessions.some((s) => s.sessionId === sessionId)).toBe(true); }); it("derives a session name from the command", async () => { const result = await execTool.execute("call1", { command: "echo hello", background: true, }); const sessionId = (result.details as { sessionId: string }).sessionId; await sleep(25); const list = await processTool.execute("call2", { action: "list" }); const sessions = (list.details as { sessions: Array<{ sessionId: string; name?: string }> }) .sessions; const entry = sessions.find((s) => s.sessionId === sessionId); expect(entry?.name).toBe("echo hello"); }); it("uses default timeout when timeout is omitted", async () => { const customBash = createExecTool({ timeoutSec: 1, backgroundMs: 10 }); const customProcess = createProcessTool(); const result = await customBash.execute("call1", { command: longDelayCmd, background: true, }); const sessionId = (result.details as { sessionId: string }).sessionId; let status = "running"; const deadline = Date.now() + 5000; while (Date.now() < deadline && status === "running") { const poll = await customProcess.execute("call2", { action: "poll", sessionId, }); status = (poll.details as { status: string }).status; if (status === "running") { await sleep(50); } } expect(status).toBe("failed"); }); it("rejects elevated requests when not allowed", async () => { const customBash = createExecTool({ elevated: { enabled: true, allowed: false, defaultLevel: "off" }, messageProvider: "telegram", sessionKey: "agent:main:main", }); await expect( customBash.execute("call1", { command: "echo hi", elevated: true, }), ).rejects.toThrow("Context: provider=telegram session=agent:main:main"); }); it("does not default to elevated when not allowed", async () => { const customBash = createExecTool({ elevated: { enabled: true, allowed: false, defaultLevel: "on" }, backgroundMs: 1000, timeoutSec: 5, }); const result = await customBash.execute("call1", { command: "echo hi", }); const text = result.content.find((c) => c.type === "text")?.text ?? ""; expect(text).toContain("hi"); }); it("logs line-based slices and defaults to last lines", async () => { const result = await execTool.execute("call1", { command: echoLines(["one", "two", "three"]), background: true, }); const sessionId = (result.details as { sessionId: string }).sessionId; const status = await waitForCompletion(sessionId); const log = await processTool.execute("call3", { action: "log", sessionId, limit: 2, }); const textBlock = log.content.find((c) => c.type === "text"); expect(normalizeText(textBlock?.text)).toBe("two\nthree"); expect((log.details as { totalLines?: number }).totalLines).toBe(3); expect(status).toBe("completed"); }); it("supports line offsets for log slices", async () => { const result = await execTool.execute("call1", { command: echoLines(["alpha", "beta", "gamma"]), background: true, }); const sessionId = (result.details as { sessionId: string }).sessionId; await waitForCompletion(sessionId); const log = await processTool.execute("call2", { action: "log", sessionId, offset: 1, limit: 1, }); const textBlock = log.content.find((c) => c.type === "text"); expect(normalizeText(textBlock?.text)).toBe("beta"); }); it("scopes process sessions by scopeKey", async () => { const bashA = createExecTool({ backgroundMs: 10, scopeKey: "agent:alpha" }); const processA = createProcessTool({ scopeKey: "agent:alpha" }); const bashB = createExecTool({ backgroundMs: 10, scopeKey: "agent:beta" }); const processB = createProcessTool({ scopeKey: "agent:beta" }); const resultA = await bashA.execute("call1", { command: shortDelayCmd, background: true, }); const resultB = await bashB.execute("call2", { command: shortDelayCmd, background: true, }); const sessionA = (resultA.details as { sessionId: string }).sessionId; const sessionB = (resultB.details as { sessionId: string }).sessionId; const listA = await processA.execute("call3", { action: "list" }); const sessionsA = (listA.details as { sessions: Array<{ sessionId: string }> }).sessions; expect(sessionsA.some((s) => s.sessionId === sessionA)).toBe(true); expect(sessionsA.some((s) => s.sessionId === sessionB)).toBe(false); const pollB = await processB.execute("call4", { action: "poll", sessionId: sessionA, }); expect(pollB.details.status).toBe("failed"); }); }); describe("exec notifyOnExit", () => { it("enqueues a system event when a backgrounded exec exits", async () => { const tool = createExecTool({ allowBackground: true, backgroundMs: 0, notifyOnExit: true, sessionKey: "agent:main:main", }); const result = await tool.execute("call1", { command: echoAfterDelay("notify"), background: true, }); expect(result.details.status).toBe("running"); const sessionId = (result.details as { sessionId: string }).sessionId; let finished = getFinishedSession(sessionId); const deadline = Date.now() + (isWin ? 8000 : 2000); while (!finished && Date.now() < deadline) { await sleep(20); finished = getFinishedSession(sessionId); } expect(finished).toBeTruthy(); const events = peekSystemEvents("agent:main:main"); expect(events.some((event) => event.includes(sessionId.slice(0, 8)))).toBe(true); }); }); describe("exec PATH handling", () => { const originalPath = process.env.PATH; const originalShell = process.env.SHELL; beforeEach(() => { if (!isWin) process.env.SHELL = "/bin/bash"; }); afterEach(() => { process.env.PATH = originalPath; if (!isWin) process.env.SHELL = originalShell; }); it("prepends configured path entries", async () => { const basePath = isWin ? "C:\\Windows\\System32" : "/usr/bin"; const prepend = isWin ? ["C:\\custom\\bin", "C:\\oss\\bin"] : ["/custom/bin", "/opt/oss/bin"]; process.env.PATH = basePath; const tool = createExecTool({ pathPrepend: prepend }); const result = await tool.execute("call1", { command: isWin ? "Write-Output $env:PATH" : "echo $PATH", }); const text = normalizeText(result.content.find((c) => c.type === "text")?.text); expect(text).toBe([...prepend, basePath].join(path.delimiter)); }); }); describe("buildDockerExecArgs", () => { it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => { const args = buildDockerExecArgs({ containerName: "test-container", command: "echo hello", env: { PATH: "/custom/bin:/usr/local/bin:/usr/bin", HOME: "/home/user", }, tty: false, }); const commandArg = args[args.length - 1]; expect(args).toContain("CLAWDBOT_PREPEND_PATH=/custom/bin:/usr/local/bin:/usr/bin"); expect(commandArg).toContain('export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"'); expect(commandArg).toContain("echo hello"); expect(commandArg).toBe( 'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; echo hello', ); }); it("does not interpolate PATH into the shell command", () => { const injectedPath = "$(touch /tmp/clawdbot-path-injection)"; const args = buildDockerExecArgs({ containerName: "test-container", command: "echo hello", env: { PATH: injectedPath, HOME: "/home/user", }, tty: false, }); const commandArg = args[args.length - 1]; expect(args).toContain(`CLAWDBOT_PREPEND_PATH=${injectedPath}`); expect(commandArg).not.toContain(injectedPath); expect(commandArg).toContain("CLAWDBOT_PREPEND_PATH"); }); it("does not add PATH export when PATH is not in env", () => { const args = buildDockerExecArgs({ containerName: "test-container", command: "echo hello", env: { HOME: "/home/user", }, tty: false, }); const commandArg = args[args.length - 1]; expect(commandArg).toBe("echo hello"); expect(commandArg).not.toContain("export PATH"); }); it("includes workdir flag when specified", () => { const args = buildDockerExecArgs({ containerName: "test-container", command: "pwd", workdir: "/workspace", env: { HOME: "/home/user" }, tty: false, }); expect(args).toContain("-w"); expect(args).toContain("/workspace"); }); it("uses login shell for consistent environment", () => { const args = buildDockerExecArgs({ containerName: "test-container", command: "echo test", env: { HOME: "/home/user" }, tty: false, }); expect(args).toContain("sh"); expect(args).toContain("-lc"); }); it("includes tty flag when requested", () => { const args = buildDockerExecArgs({ containerName: "test-container", command: "bash", env: { HOME: "/home/user" }, tty: true, }); expect(args).toContain("-t"); }); });