import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { applyPathPrepend, findPathKey } from "../infra/path-prepend.js"; import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js"; import { captureEnv } from "../test-utils/env.js"; import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js"; import { createExecTool, createProcessTool } from "./bash-tools.js"; import { resolveShellFromPath, sanitizeBinaryOutput } from "./shell-utils.js"; const isWin = process.platform === "win32"; const defaultShell = isWin ? undefined : process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh"; // PowerShell: Start-Sleep for delays, ; for command separation, $null for null device const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 4" : "sleep 0.004"; const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 16" : "sleep 0.016"; const longDelayCmd = isWin ? "Start-Sleep -Milliseconds 72" : "sleep 0.072"; const POLL_INTERVAL_MS = 15; const BACKGROUND_POLL_TIMEOUT_MS = isWin ? 8000 : 1200; const NOTIFY_EVENT_TIMEOUT_MS = isWin ? 12_000 : 5_000; const BACKGROUND_POLL_OPTIONS = { timeout: BACKGROUND_POLL_TIMEOUT_MS, interval: POLL_INTERVAL_MS, }; const NOTIFY_POLL_OPTIONS = { timeout: NOTIFY_EVENT_TIMEOUT_MS, interval: POLL_INTERVAL_MS, }; const SHELL_ENV_KEYS = ["SHELL"] as const; const PATH_SHELL_ENV_KEYS = ["PATH", "SHELL"] as const; const PROCESS_STATUS_RUNNING = "running"; const PROCESS_STATUS_COMPLETED = "completed"; const PROCESS_STATUS_FAILED = "failed"; const OUTPUT_DONE = "done"; const OUTPUT_NOPE = "nope"; const OUTPUT_EXEC_COMPLETED = "Exec completed"; const OUTPUT_EXIT_CODE_1 = "Command exited with code 1"; const shellEcho = (message: string) => (isWin ? `Write-Output ${message}` : `echo ${message}`); const COMMAND_ECHO_HELLO = shellEcho("hello"); const COMMAND_PRINT_PATH = isWin ? "Write-Output $env:PATH" : "echo $PATH"; const COMMAND_EXIT_WITH_ERROR = "exit 1"; const SCOPE_KEY_ALPHA = "agent:alpha"; const SCOPE_KEY_BETA = "agent:beta"; const TEST_EXEC_DEFAULTS = { security: "full" as const, ask: "off" as const }; const DEFAULT_NOTIFY_SESSION_KEY = "agent:main:main"; const ECHO_HI_COMMAND = shellEcho("hi"); let callIdCounter = 0; const nextCallId = () => `call${++callIdCounter}`; type ExecToolInstance = ReturnType; type ProcessToolInstance = ReturnType; type ExecToolArgs = Parameters[1]; type ProcessToolArgs = Parameters[1]; type ExecToolConfig = Exclude[0], undefined>; type ExecToolRunOptions = Omit; type LabeledCase = { label: string }; const createTestExecTool = ( defaults?: Parameters[0], ): ReturnType => createExecTool({ ...TEST_EXEC_DEFAULTS, ...defaults }); const createDisallowedElevatedExecTool = ( defaultLevel: "off" | "on", overrides: Partial = {}, ) => createTestExecTool({ elevated: { enabled: true, allowed: false, defaultLevel }, ...overrides, }); const createNotifyOnExitExecTool = (overrides: Partial = {}) => createTestExecTool({ allowBackground: true, backgroundMs: 0, notifyOnExit: true, sessionKey: DEFAULT_NOTIFY_SESSION_KEY, ...overrides, }); const createScopedToolSet = (scopeKey: string) => ({ exec: createTestExecTool({ backgroundMs: 10, scopeKey }), process: createProcessTool({ scopeKey }), }); const execTool = createTestExecTool(); const processTool = createProcessTool(); const withLabel = (label: string, fields: T): T & LabeledCase => ({ label, ...fields, }); // Both PowerShell and bash use ; for command separation const joinCommands = (commands: string[]) => commands.join("; "); const echoAfterDelay = (message: string) => joinCommands([shortDelayCmd, shellEcho(message)]); const echoLines = (lines: string[]) => joinCommands(lines.map((line) => shellEcho(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(); type ToolTextContent = Array<{ type: string; text?: string }>; const readTextContent = (content: ToolTextContent) => content.find((part) => part.type === "text")?.text; const readNormalizedTextContent = (content: ToolTextContent) => normalizeText(readTextContent(content)); const readTrimmedLines = (content: ToolTextContent) => (readTextContent(content) ?? "").split("\n").map((line) => line.trim()); const readTotalLines = (details: unknown) => (details as { totalLines?: number }).totalLines; const readProcessStatus = (details: unknown) => (details as { status?: string }).status; const readProcessStatusOrRunning = (details: unknown) => readProcessStatus(details) ?? PROCESS_STATUS_RUNNING; const expectTextContainsValues = ( text: string, values: string[] | undefined, shouldContain: boolean, ) => { if (!values) { return; } for (const value of values) { if (shouldContain) { expect(text).toContain(value); } else { expect(text).not.toContain(value); } } }; type ProcessSessionSummary = { sessionId: string; name?: string }; const hasSession = (sessions: ProcessSessionSummary[], sessionId: string) => sessions.some((session) => session.sessionId === sessionId); const executeExecTool = (tool: ExecToolInstance, params: ExecToolArgs) => tool.execute(nextCallId(), params); const executeExecCommand = ( tool: ExecToolInstance, command: string, options: ExecToolRunOptions = {}, ) => executeExecTool(tool, { command, ...options }); const executeProcessTool = (tool: ProcessToolInstance, params: ProcessToolArgs) => tool.execute(nextCallId(), params); type ProcessPollResult = { status: string; output?: string }; async function listProcessSessions(tool: ProcessToolInstance) { const list = await executeProcessTool(tool, { action: "list" }); return (list.details as { sessions: ProcessSessionSummary[] }).sessions; } async function pollProcessSession(params: { tool: ProcessToolInstance; sessionId: string; }): Promise { const poll = await executeProcessTool(params.tool, { action: "poll", sessionId: params.sessionId, }); return { status: readProcessStatusOrRunning(poll.details), output: readTextContent(poll.content), }; } function applyDefaultShellEnv() { if (!isWin && defaultShell) { process.env.SHELL = defaultShell; } } function useCapturedEnv(keys: string[], afterCapture?: () => void) { let envSnapshot: ReturnType; beforeEach(() => { envSnapshot = captureEnv(keys); afterCapture?.(); }); afterEach(() => { envSnapshot.restore(); }); } async function waitForCompletion(sessionId: string) { let status = PROCESS_STATUS_RUNNING; await expect .poll(async () => { status = (await pollProcessSession({ tool: processTool, sessionId })).status; return status; }, BACKGROUND_POLL_OPTIONS) .not.toBe(PROCESS_STATUS_RUNNING); return status; } function requireSessionId(details: { sessionId?: string }): string { if (!details.sessionId) { throw new Error("expected sessionId in exec result details"); } return details.sessionId; } const requireRunningSessionId = (result: { details: unknown }) => { expect(readProcessStatus(result.details)).toBe(PROCESS_STATUS_RUNNING); return requireSessionId(result.details as { sessionId?: string }); }; function hasNotifyEventForPrefix(prefix: string): boolean { return peekSystemEvents(DEFAULT_NOTIFY_SESSION_KEY).some((event) => event.includes(prefix)); } async function waitForNotifyEvent(sessionId: string) { const prefix = sessionId.slice(0, 8); let finished = getFinishedSession(sessionId); let hasEvent = hasNotifyEventForPrefix(prefix); await expect .poll(() => { finished = getFinishedSession(sessionId); hasEvent = hasNotifyEventForPrefix(prefix); return Boolean(finished && hasEvent); }, NOTIFY_POLL_OPTIONS) .toBe(true); return { finished: finished ?? getFinishedSession(sessionId), hasEvent: hasEvent || hasNotifyEventForPrefix(prefix), }; } async function startBackgroundCommand(tool: ExecToolInstance, command: string) { const result = await executeExecCommand(tool, command, { background: true }); return requireRunningSessionId(result); } async function runBackgroundCommandToCompletion(tool: ExecToolInstance, command: string) { const sessionId = await startBackgroundCommand(tool, command); const status = await waitForCompletion(sessionId); return { sessionId, status }; } type ProcessLogWindow = { offset?: number; limit?: number }; async function readProcessLog(sessionId: string, options: ProcessLogWindow = {}) { return executeProcessTool(processTool, { action: "log", sessionId, ...options, }); } const LONG_LOG_LINE_COUNT = 201; type LongLogExpectationCase = LabeledCase & { options?: ProcessLogWindow; firstLine: string; lastLine?: string; mustContain?: string[]; mustNotContain?: string[]; }; type ShortLogExpectationCase = LabeledCase & { lines: string[]; options: ProcessLogWindow; expectedText: string; expectedTotalLines: number; }; type ProcessLogSnapshot = { text: string; normalizedText: string; lines: string[]; totalLines: number | undefined; }; const EXPECTED_TOTAL_LINES_THREE = 3; type DisallowedElevationCase = LabeledCase & { defaultLevel: "off" | "on"; overrides?: Partial; requestElevated?: boolean; expectedError?: string; expectedOutputIncludes?: string; }; type NotifyNoopCase = LabeledCase & { notifyOnExitEmptySuccess: boolean; }; const NOOP_NOTIFY_CASES: NotifyNoopCase[] = [ withLabel("default behavior skips no-op completion events", { notifyOnExitEmptySuccess: false }), withLabel("explicitly enabling no-op completion emits completion events", { notifyOnExitEmptySuccess: true, }), ]; const DISALLOWED_ELEVATION_CASES: DisallowedElevationCase[] = [ withLabel("rejects elevated requests when not allowed", { defaultLevel: "off", overrides: { messageProvider: "telegram", sessionKey: DEFAULT_NOTIFY_SESSION_KEY, }, requestElevated: true, expectedError: "Context: provider=telegram session=agent:main:main", }), withLabel("does not default to elevated when not allowed", { defaultLevel: "on", overrides: { backgroundMs: 1000, timeoutSec: 5, }, expectedOutputIncludes: "hi", }), ]; const SHORT_LOG_EXPECTATION_CASES: ShortLogExpectationCase[] = [ withLabel("logs line-based slices and defaults to last lines", { lines: ["one", "two", "three"], options: { limit: 2 }, expectedText: "two\nthree", expectedTotalLines: EXPECTED_TOTAL_LINES_THREE, }), withLabel("supports line offsets for log slices", { lines: ["alpha", "beta", "gamma"], options: { offset: 1, limit: 1 }, expectedText: "beta", expectedTotalLines: EXPECTED_TOTAL_LINES_THREE, }), ]; const LONG_LOG_EXPECTATION_CASES: LongLogExpectationCase[] = [ withLabel("applies default tail only when no explicit log window is provided", { firstLine: "line-2", mustContain: ["showing last 200 of 201 lines", "line-2", "line-201"], }), withLabel("keeps offset-only log requests unbounded by default tail mode", { options: { offset: 30 }, firstLine: "line-31", lastLine: "line-201", mustNotContain: ["showing last 200"], }), ]; const expectNotifyNoopEvents = ( events: string[], notifyOnExitEmptySuccess: boolean, label: string, ) => { if (!notifyOnExitEmptySuccess) { expect(events, label).toEqual([]); return; } expect(events.length, label).toBeGreaterThan(0); expect( events.some((event) => event.includes(OUTPUT_EXEC_COMPLETED)), label, ).toBe(true); }; const runDisallowedElevationCase = async ({ defaultLevel, overrides, requestElevated, expectedError, expectedOutputIncludes, }: DisallowedElevationCase) => { const customBash = createDisallowedElevatedExecTool(defaultLevel, overrides); if (expectedError) { await expect( executeExecCommand(customBash, ECHO_HI_COMMAND, { elevated: requestElevated }), ).rejects.toThrow(expectedError); return; } const result = await executeExecCommand(customBash, ECHO_HI_COMMAND); if (expectedOutputIncludes === undefined) { throw new Error("expected text assertion value"); } expect(readTextContent(result.content) ?? "").toContain(expectedOutputIncludes); }; const runShortLogExpectationCase = async ({ lines, options, expectedText, expectedTotalLines, }: ShortLogExpectationCase) => { const snapshot = await readBackgroundLogSnapshot(lines, options); expect(snapshot.normalizedText).toBe(expectedText); expect(snapshot.totalLines).toBe(expectedTotalLines); }; const readBackgroundLogSnapshot = async ( lines: string[], options: ProcessLogWindow = {}, ): Promise => { const { sessionId } = await runBackgroundCommandToCompletion(execTool, echoLines(lines)); const log = await readProcessLog(sessionId, options); return { text: readTextContent(log.content) ?? "", normalizedText: readNormalizedTextContent(log.content), lines: readTrimmedLines(log.content), totalLines: readTotalLines(log.details), }; }; const runLongLogExpectationCase = async ({ options, firstLine, lastLine, mustContain, mustNotContain, }: LongLogExpectationCase) => { const snapshot = await readBackgroundLogSnapshot( Array.from({ length: LONG_LOG_LINE_COUNT }, (_value, index) => `line-${index + 1}`), options, ); expect(snapshot.lines[0]).toBe(firstLine); if (lastLine) { expect(snapshot.lines[snapshot.lines.length - 1]).toBe(lastLine); } expect(snapshot.totalLines).toBe(LONG_LOG_LINE_COUNT); expectTextContainsValues(snapshot.text, mustContain, true); expectTextContainsValues(snapshot.text, mustNotContain, false); }; const runNotifyNoopCase = async ({ label, notifyOnExitEmptySuccess }: NotifyNoopCase) => { const tool = createNotifyOnExitExecTool( notifyOnExitEmptySuccess ? { notifyOnExitEmptySuccess: true } : {}, ); const { status } = await runBackgroundCommandToCompletion(tool, shortDelayCmd); expect(status).toBe(PROCESS_STATUS_COMPLETED); const events = peekSystemEvents(DEFAULT_NOTIFY_SESSION_KEY); expectNotifyNoopEvents(events, notifyOnExitEmptySuccess, label); }; beforeEach(() => { callIdCounter = 0; resetProcessRegistryForTests(); resetSystemEventsForTest(); }); describe("exec tool backgrounding", () => { useCapturedEnv([...SHELL_ENV_KEYS], applyDefaultShellEnv); it( "backgrounds after yield and can be polled", async () => { const result = await executeExecCommand( execTool, joinCommands([yieldDelayCmd, shellEcho(OUTPUT_DONE)]), { yieldMs: 10 }, ); // Timing can race here: command may already be complete before the first response. if (result.details.status === PROCESS_STATUS_COMPLETED) { expect(readTextContent(result.content) ?? "").toContain(OUTPUT_DONE); return; } const sessionId = requireRunningSessionId(result); let output = ""; await expect .poll(async () => { const pollResult = await pollProcessSession({ tool: processTool, sessionId }); output = pollResult.output ?? ""; return pollResult.status; }, BACKGROUND_POLL_OPTIONS) .toBe(PROCESS_STATUS_COMPLETED); expect(output).toContain(OUTPUT_DONE); }, isWin ? 15_000 : 5_000, ); it("supports explicit background and derives session name from the command", async () => { const sessionId = await startBackgroundCommand(execTool, COMMAND_ECHO_HELLO); const sessions = await listProcessSessions(processTool); expect(hasSession(sessions, sessionId)).toBe(true); expect(sessions.find((s) => s.sessionId === sessionId)?.name).toBe(COMMAND_ECHO_HELLO); }); it("uses default timeout when timeout is omitted", async () => { const customBash = createTestExecTool({ timeoutSec: 0.05, backgroundMs: 10, allowBackground: false, }); await expect(executeExecCommand(customBash, longDelayCmd)).rejects.toThrow(/timed out/i); }); it.each(DISALLOWED_ELEVATION_CASES)( "$label", runDisallowedElevationCase, ); it.each(SHORT_LOG_EXPECTATION_CASES)( "$label", runShortLogExpectationCase, ); it.each(LONG_LOG_EXPECTATION_CASES)("$label", runLongLogExpectationCase); it("scopes process sessions by scopeKey", async () => { const alphaTools = createScopedToolSet(SCOPE_KEY_ALPHA); const betaTools = createScopedToolSet(SCOPE_KEY_BETA); const sessionA = await startBackgroundCommand(alphaTools.exec, shortDelayCmd); const sessionB = await startBackgroundCommand(betaTools.exec, shortDelayCmd); const sessionsA = await listProcessSessions(alphaTools.process); expect(hasSession(sessionsA, sessionA)).toBe(true); expect(hasSession(sessionsA, sessionB)).toBe(false); const pollB = await pollProcessSession({ tool: betaTools.process, sessionId: sessionA, }); expect(pollB.status).toBe(PROCESS_STATUS_FAILED); }); }); describe("exec exit codes", () => { useCapturedEnv([...SHELL_ENV_KEYS], applyDefaultShellEnv); it("treats non-zero exits as completed and appends exit code", async () => { const command = joinCommands([shellEcho(OUTPUT_NOPE), COMMAND_EXIT_WITH_ERROR]); const result = await executeExecCommand(execTool, command); const resultDetails = result.details as { status?: string; exitCode?: number | null }; expect(readProcessStatus(resultDetails)).toBe(PROCESS_STATUS_COMPLETED); expect(resultDetails.exitCode).toBe(1); const text = readNormalizedTextContent(result.content); expect(text).toContain(OUTPUT_NOPE); expect(text).toContain(OUTPUT_EXIT_CODE_1); }); }); describe("exec notifyOnExit", () => { it("enqueues a system event when a backgrounded exec exits", async () => { const tool = createNotifyOnExitExecTool(); const sessionId = await startBackgroundCommand(tool, echoAfterDelay("notify")); const { finished, hasEvent } = await waitForNotifyEvent(sessionId); expect(finished).toBeTruthy(); expect(hasEvent).toBe(true); }); it.each(NOOP_NOTIFY_CASES)("$label", runNotifyNoopCase); }); describe("exec PATH handling", () => { useCapturedEnv([...PATH_SHELL_ENV_KEYS], applyDefaultShellEnv); 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 = createTestExecTool({ pathPrepend: prepend }); const result = await executeExecCommand(tool, COMMAND_PRINT_PATH); const text = readNormalizedTextContent(result.content); const entries = text.split(path.delimiter); const prependIndexes = prepend.map((entry) => entries.indexOf(entry)); for (const index of prependIndexes) { expect(index).toBeGreaterThanOrEqual(0); } for (let i = 1; i < prependIndexes.length; i += 1) { expect(prependIndexes[i]).toBeGreaterThan(prependIndexes[i - 1]); } const baseIndex = entries.indexOf(basePath); expect(baseIndex).toBeGreaterThanOrEqual(0); for (const index of prependIndexes) { expect(index).toBeLessThan(baseIndex); } }); }); describe("findPathKey", () => { it("returns PATH when key is uppercase", () => { expect(findPathKey({ PATH: "/usr/bin" })).toBe("PATH"); }); it("returns Path when key is mixed-case (Windows style)", () => { expect(findPathKey({ Path: "C:\\Windows\\System32" })).toBe("Path"); }); it("returns PATH as default when no PATH-like key exists", () => { expect(findPathKey({ HOME: "/home/user" })).toBe("PATH"); }); it("prefers uppercase PATH when both PATH and Path exist", () => { expect(findPathKey({ PATH: "/usr/bin", Path: "C:\\Windows" })).toBe("PATH"); }); }); describe("applyPathPrepend with case-insensitive PATH key", () => { it("prepends to Path key on Windows-style env (no uppercase PATH)", () => { const env: Record = { Path: "C:\\Windows\\System32" }; applyPathPrepend(env, ["C:\\custom\\bin"]); // Should write back to the same `Path` key, not create a new `PATH` expect(env.Path).toContain("C:\\custom\\bin"); expect(env.Path).toContain("C:\\Windows\\System32"); expect("PATH" in env).toBe(false); }); it("preserves all existing entries when prepending via Path key", () => { // Use platform-appropriate paths and delimiters const delim = path.delimiter; const existing = isWin ? ["C:\\Windows\\System32", "C:\\Windows", "C:\\Program Files\\nodejs"] : ["/usr/bin", "/usr/local/bin", "/opt/node/bin"]; const prepend = isWin ? ["C:\\custom\\bin"] : ["/custom/bin"]; const existingPath = existing.join(delim); const env: Record = { Path: existingPath }; applyPathPrepend(env, prepend); const parts = env.Path.split(delim); expect(parts[0]).toBe(prepend[0]); for (const entry of existing) { expect(parts).toContain(entry); } }); it("respects requireExisting option with Path key", () => { const env: Record = { HOME: "/home/user" }; applyPathPrepend(env, ["C:\\custom\\bin"], { requireExisting: true }); // No Path/PATH key exists, so nothing should be written expect("PATH" in env).toBe(false); expect("Path" in env).toBe(false); }); });