/** * Integration-style tests for the public Bash/process tool barrel. * Exercises exec and process behavior through the shared exported tool factory. */ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { drainFormattedSystemEvents } from "../auto-reply/reply/session-system-events.js"; import type { OpenClawConfig } from "../config/config.js"; import { requestHeartbeat, setHeartbeatWakeHandler } from "../infra/heartbeat-wake.js"; import { applyPathPrepend, findPathKey } from "../infra/path-prepend.js"; import { peekSystemEventEntries, peekSystemEvents, resetSystemEventsForTest, } from "../infra/system-events.js"; import { captureEnv } from "../test-utils/env.js"; import { addSession, appendOutput, getFinishedSession, markBackgrounded, markExited, type ProcessSession, } from "./bash-process-registry.js"; import { resetProcessRegistryForTests } from "./bash-process-registry.test-support.js"; import { createExecTool, createProcessTool } from "./bash-tools.js"; import { getBashShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; vi.mock("../infra/channel-summary.js", () => ({ buildChannelSummary: vi.fn(async () => []), })); vi.mock("../infra/exec-approval-surface.js", () => ({ describeNativeExecApprovalClientSetup: () => null, listNativeExecApprovalClientLabels: () => [], resolveExecApprovalInitiatingSurfaceState: (params: { channel?: string | null; accountId?: string | null; }) => { const channel = params.channel ?? undefined; return { kind: "enabled", channel, channelLabel: channel === "tui" ? "terminal UI" : channel === "internal" ? "Web UI" : "this platform", accountId: params.accountId ?? undefined, }; }, supportsNativeExecApprovalClient: (channel?: string | null) => !channel || channel === "internal" || channel === "tui", })); vi.mock("../utils/delivery-context.shared.js", () => ({ normalizeDeliveryContext: (context?: { channel?: string | null; to?: string | number | null; accountId?: string | null; threadId?: string | number | null; }) => { if (!context) { return undefined; } const channel = context.channel?.trim().toLowerCase(); const to = context.to == null ? undefined : String(context.to).trim(); const accountId = context.accountId?.trim(); const threadId = context.threadId == null ? undefined : context.threadId; if (!channel && !to && !accountId && threadId == null) { return undefined; } return { channel: channel || undefined, to: to || undefined, accountId: accountId || undefined, ...(threadId != null && threadId !== "" ? { threadId } : {}), }; }, })); vi.mock("./bash-tools.exec-approval-followup.js", () => ({ sendExecApprovalFollowup: vi.fn(async () => false), })); vi.mock("./tools/gateway.js", () => ({ callGatewayTool: vi.fn(async () => ({ ok: true })), readGatewayCallOptions: vi.fn(() => ({})), })); vi.mock("../infra/shell-env.js", async () => { const actual = await vi.importActual("../infra/shell-env.js"); return { ...actual, getShellPathFromLoginShell: vi.fn(() => null), resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0), }; }); vi.mock("../process/supervisor/index.js", () => { type SpawnInput = { argv?: string[]; ptyCommand?: string; env?: NodeJS.ProcessEnv; onStdout?: (chunk: string) => void; }; const immediate = () => new Promise((resolve) => { setImmediate(resolve); }); const readPathKey = (env?: NodeJS.ProcessEnv) => env && "Path" in env && !("PATH" in env) ? "Path" : "PATH"; const readEnvPath = (env?: NodeJS.ProcessEnv) => env?.[readPathKey(env)] ?? ""; const writeEnvPath = (env: NodeJS.ProcessEnv, value: string) => { env[readPathKey(env)] = value; }; const extractCommand = (input: SpawnInput) => input.ptyCommand ?? input.argv?.at(-1) ?? ""; const parseShellSingleQuoted = (input: string) => { if (!input.startsWith("'")) { return null; } let output = ""; for (let index = 1; index < input.length; index += 1) { const char = input[index]; if (char !== "'") { output += char; continue; } if (input.startsWith("'\\''", index)) { output += "'"; index += 3; continue; } return input.slice(index + 1).trim().length === 0 ? output : null; } return null; }; const unwrapSnapshotEvalCommand = (command: string) => { const evalIndex = command.lastIndexOf("\neval "); const evalCommand = evalIndex === -1 ? command.trimStart().startsWith("eval ") ? command.trimStart().slice("eval ".length) : null : command.slice(evalIndex + "\neval ".length); return evalCommand ? (parseShellSingleQuoted(evalCommand.trim()) ?? command) : command; }; const splitCommands = (command: string) => { const commands: string[] = []; for (const part of command.split(";")) { const trimmed = part.trim(); if (trimmed.length > 0) { commands.push(trimmed); } } return commands; }; const applySegmentShellEffects = (segment: string, env: NodeJS.ProcessEnv) => { if (segment === 'export PATH="${OPENCLAW_PREPEND_PATH}${PATH:+:$PATH}"') { const prepend = env.OPENCLAW_PREPEND_PATH ?? ""; const current = readEnvPath(env); writeEnvPath(env, `${prepend}${current ? `:${current}` : ""}`); return; } if (segment === "unset OPENCLAW_PREPEND_PATH") { delete env.OPENCLAW_PREPEND_PATH; } }; const stdoutForSegment = (segment: string, env: NodeJS.ProcessEnv) => { if (segment === "echo $PATH" || segment === "Write-Output $env:PATH") { return `${readEnvPath(env)}\n`; } if (segment.startsWith("echo ")) { return `${segment.slice("echo ".length)}\n`; } if (segment.startsWith("Write-Output ")) { return `${segment.slice("Write-Output ".length)}\n`; } return ""; }; const commandOutput = (command: string, env?: NodeJS.ProcessEnv) => { const shellEnv = { ...env }; return splitCommands(unwrapSnapshotEvalCommand(command)) .map((segment) => { applySegmentShellEffects(segment, shellEnv); return stdoutForSegment(segment, shellEnv); }) .join(""); }; return { getProcessSupervisor: () => ({ spawn: async (input: SpawnInput) => { const command = extractCommand(input); const output = commandOutput(command, input.env); const exitCode = splitCommands(unwrapSnapshotEvalCommand(command)).includes("exit 1") ? 1 : 0; const stagedOutput = command.includes("after") ? output.replace(/after[^\n]*\n?/gu, "") : output; const deferredOutput = output.slice(stagedOutput.length); if (stagedOutput) { input.onStdout?.(stagedOutput); } return { runId: "mock-bash-run", startedAtMs: Date.now(), pid: 123, stdin: undefined, wait: async () => { await immediate(); await immediate(); if (deferredOutput) { input.onStdout?.(deferredOutput); } return { reason: "exit" as const, exitCode, exitSignal: null, durationMs: 0, stdout: "", stderr: "", timedOut: false, noOutputTimedOut: false, }; }, cancel: vi.fn(), }; }, cancel: vi.fn(), cancelScope: vi.fn(), getRecord: vi.fn(), }), }; }); const isWin = process.platform === "win32"; const defaultShell = isWin ? undefined : process.env.OPENCLAW_TEST_SHELL || getBashShellConfig().shell; // PowerShell: Start-Sleep for delays, ; for command separation, $null for null device const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 4" : "sleep 0.004"; const POLL_INTERVAL_MS = isWin ? 15 : 2; 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 = ["OPENCLAW_EXEC_SHELL_SNAPSHOT", "SHELL"] as const; const PATH_SHELL_ENV_KEYS = ["OPENCLAW_EXEC_SHELL_SNAPSHOT", "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_NOOP = isWin ? "$null" : ":"; 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 = { host: "gateway" as const, 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}`; const notifyCfg = {} as OpenClawConfig; 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 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 waitOneTurn = () => new Promise((resolve) => { setImmediate(resolve); }); 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() { process.env.OPENCLAW_EXEC_SHELL_SNAPSHOT = "0"; if (!isWin && defaultShell) { process.env.SHELL = defaultShell; } } function useCapturedEnv(keys: string[], afterCapture?: () => void) { let envSnapshot: ReturnType; beforeEach(() => { envSnapshot = captureEnv(keys); afterCapture?.(); }); afterEach(() => { envSnapshot.restore(); }); } 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, sessionKey = DEFAULT_NOTIFY_SESSION_KEY): boolean { return peekSystemEvents(sessionKey).some((event) => event.includes(prefix)); } async function waitForNotifyEvent(sessionId: string, sessionKey = DEFAULT_NOTIFY_SESSION_KEY) { const prefix = sessionId.slice(0, 8); let finished = getFinishedSession(sessionId); let hasEvent = hasNotifyEventForPrefix(prefix, sessionKey); await expect .poll(() => { finished = getFinishedSession(sessionId); hasEvent = hasNotifyEventForPrefix(prefix, sessionKey); 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 expectNotifyOnExitWake(tool: ExecToolInstance, expected: Record) { const wakeHandler = vi.fn().mockResolvedValue({ status: "skipped", reason: "disabled" }); const dispose = setHeartbeatWakeHandler( wakeHandler as unknown as Parameters[0], ); try { await startBackgroundCommand(tool, shellEcho("notify")); await expect .poll(() => wakeHandler.mock.calls.at(0)?.[0], NOTIFY_POLL_OPTIONS) .toEqual(expected); } finally { dispose(); } } async function drainNotifyEvents(sessionKey = DEFAULT_NOTIFY_SESSION_KEY) { return await drainFormattedSystemEvents({ cfg: notifyCfg, agentId: "main", sessionKey, isMainSession: false, isNewSession: false, }); } 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 & { defaults?: Partial; expectNotification: boolean; }; const NOOP_NOTIFY_CASES: NotifyNoopCase[] = [ withLabel("default behavior skips no-op completion events", { expectNotification: false }), withLabel("chat providers default no-op completion notifications on", { defaults: { messageProvider: " Telegram " }, expectNotification: true, }), withLabel("explicit false keeps chat provider no-op completions silent", { defaults: { messageProvider: "telegram", notifyOnExitEmptySuccess: false }, expectNotification: false, }), withLabel("generic providers keep no-op completions silent by default", { defaults: { messageProvider: "generic" }, expectNotification: false, }), withLabel("explicitly enabling no-op completion emits completion events", { defaults: { notifyOnExitEmptySuccess: true }, expectNotification: 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[], expectNotification: boolean, sessionId: string, label: string, ) => { if (!expectNotification) { expect(events, label).toStrictEqual([]); return; } expect(events, label).toStrictEqual([ `${OUTPUT_EXEC_COMPLETED} (${sessionId.slice(0, 8)}, code 0)`, ]); }; 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 = seedFinishedLogSession(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 seedFinishedLogSession = (lines: string[]) => { const session: ProcessSession = { id: `seeded-log-${nextCallId()}`, command: "seeded log", startedAt: Date.now(), maxOutputChars: 100_000, pendingMaxOutputChars: 100_000, pendingStdout: [], pendingStderr: [], pendingStdoutChars: 0, pendingStderrChars: 0, pendingOutputDropped: false, totalOutputChars: 0, aggregated: "", tail: "", exited: false, truncated: false, backgrounded: false, cursorKeyMode: "unknown", }; addSession(session); appendOutput(session, "stdout", lines.join("\n")); markBackgrounded(session); markExited(session, 0, null, PROCESS_STATUS_COMPLETED); return session.id; }; 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, defaults, expectNotification }: NotifyNoopCase) => { const tool = createNotifyOnExitExecTool(defaults); const sessionId = await startBackgroundCommand(tool, COMMAND_NOOP); await expect .poll(() => getFinishedSession(sessionId)?.status, BACKGROUND_POLL_OPTIONS) .toBe(PROCESS_STATUS_COMPLETED); const events = peekSystemEvents(DEFAULT_NOTIFY_SESSION_KEY); expectNotifyNoopEvents(events, expectNotification, sessionId, label); }; describe("tool descriptions", () => { it("adds cron-specific deferred follow-up guidance only when cron is available", () => { const execWithCron = createTestExecTool({ hasCronTool: true }); const processWithCron = createProcessTool({ hasCronTool: true }); expect(execWithCron.description).toContain( "automatic completion wake when enabled and output/failure occurs; otherwise process confirms completion", ); expect(processWithCron.description).toContain("completion without auto-wake"); expect(processWithCron.description).toContain("write, send-keys, submit, paste, kill"); expect(execWithCron.description).toContain( "No sleep/delay loops for reminders/follow-ups; use cron.", ); expect(processWithCron.description).toContain( "No polling as timer/reminder; scheduled follow-up uses cron.", ); expect(execTool.description).not.toContain("use cron instead"); expect(processTool.description).not.toContain("scheduled follow-ups"); expect(execTool.description).toContain("otherwise process confirms completion"); expect(processTool.description).toContain("completion without auto-wake"); expect(processTool.description).toContain("write, send-keys, submit, paste, kill"); }); }); 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, shellEcho(OUTPUT_DONE), { yieldMs: 0 }); // 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.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", () => { useCapturedEnv([...SHELL_ENV_KEYS], applyDefaultShellEnv); async function drainPendingHeartbeatWakes(): Promise { const handler = vi.fn(async () => ({ status: "ran" as const, durationMs: 0 })); const dispose = setHeartbeatWakeHandler(handler); try { requestHeartbeat({ source: "other", intent: "immediate", reason: "test-cleanup", coalesceMs: 0, }); await expect.poll(() => handler.mock.calls.length, NOTIFY_POLL_OPTIONS).toBeGreaterThan(0); } finally { dispose(); } } beforeEach(drainPendingHeartbeatWakes); afterEach(drainPendingHeartbeatWakes); it("enqueues a system event when a backgrounded exec exits", async () => { const tool = createNotifyOnExitExecTool(); const sessionId = await startBackgroundCommand(tool, shellEcho("notify")); const { finished, hasEvent } = await waitForNotifyEvent(sessionId); const queuedEvent = peekSystemEventEntries(DEFAULT_NOTIFY_SESSION_KEY).find((event) => event.text.includes(sessionId.slice(0, 8)), ); const formatted = await drainNotifyEvents(); expect(finished?.id).toBe(sessionId); expect(finished?.status).toBe(PROCESS_STATUS_COMPLETED); expect(finished?.exitCode).toBe(0); expect(hasEvent).toBe(true); expect(queuedEvent).toBeDefined(); expect(formatted).toBeUndefined(); }); it("consumes only the polled completion event", async () => { const tool = createNotifyOnExitExecTool(); const unpolledSessionId = await startBackgroundCommand(tool, shellEcho("unpolled")); await waitForNotifyEvent(unpolledSessionId); const sessionId = await startBackgroundCommand(tool, shellEcho("polled")); await waitForNotifyEvent(sessionId); const poll = await pollProcessSession({ tool: processTool, sessionId }); expect(poll.status).toBe(PROCESS_STATUS_COMPLETED); expect(hasNotifyEventForPrefix(sessionId.slice(0, 8))).toBe(false); expect(hasNotifyEventForPrefix(unpolledSessionId.slice(0, 8))).toBe(true); }); it("preserves the origin delivery context on background exec completion events", async () => { const sessionKey = "agent:main:telegram:group:-1003774691294:topic:47"; const tool = createNotifyOnExitExecTool({ sessionKey, messageProvider: "telegram", currentChannelId: "telegram:-1003774691294:topic:47", currentThreadTs: "47", }); const sessionId = await startBackgroundCommand(tool, shellEcho("notify")); await waitForNotifyEvent(sessionId, sessionKey); const queuedEvent = peekSystemEventEntries(sessionKey).find((event) => event.text.includes(sessionId.slice(0, 8)), ); expect(queuedEvent).toBeDefined(); expect(queuedEvent?.deliveryContext?.channel).toBe("telegram"); expect(queuedEvent?.deliveryContext?.to).toBe("telegram:-1003774691294:topic:47"); expect(queuedEvent?.deliveryContext?.threadId).toBe("47"); }); it("scopes notifyOnExit heartbeat wake to the exec session key", async () => { await expectNotifyOnExitWake(createNotifyOnExitExecTool(), { source: "exec-event", intent: "event", reason: "exec-event", sessionKey: DEFAULT_NOTIFY_SESSION_KEY, }); }); it("keeps notifyOnExit heartbeat wake unscoped for non-agent session keys", async () => { await expectNotifyOnExitWake(createNotifyOnExitExecTool({ sessionKey: "global" }), { source: "exec-event", intent: "event", reason: "exec-event", }); }); 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( expectDefined(prependIndexes[i - 1], "prependIndexes[i - 1] test invariant"), ); } const baseIndex = entries.indexOf(basePath); expect(baseIndex).toBeGreaterThanOrEqual(0); for (const index of prependIndexes) { expect(index).toBeLessThan(baseIndex); } }); it("protects POSIX prepended paths from shell startup overrides", async () => { if (isWin) { return; } process.env.PATH = "/evil/bin:/usr/bin"; const tool = createTestExecTool({ pathPrepend: ["/custom/bin"] }); const result = await executeExecCommand(tool, COMMAND_PRINT_PATH); const text = readNormalizedTextContent(result.content); const entries = text.split(path.delimiter); // Simulate a shell startup file prepending /evil/bin before the command runs. // The exec wrapper must still restore configured pathPrepend entries to the front. expect(entries).toEqual(["/custom/bin", "/evil/bin", "/usr/bin"]); }); }); 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 = expectDefined(env.Path, "env.Path test invariant").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); }); }); describe("exec backgrounded onUpdate suppression", () => { useCapturedEnv([...SHELL_ENV_KEYS], applyDefaultShellEnv); it( "does not invoke onUpdate after the session is backgrounded", async () => { const onUpdateSpy = vi.fn(); const tool = createTestExecTool({ allowBackground: true, backgroundMs: 0 }); const command = joinCommands([shellEcho("before"), shortDelayCmd, shellEcho("after")]); const result = await tool.execute( nextCallId(), { command, background: true }, undefined, onUpdateSpy, ); expect(readProcessStatus(result.details)).toBe(PROCESS_STATUS_RUNNING); const sessionId = requireSessionId(result.details as { sessionId?: string }); const callsBeforeBackground = onUpdateSpy.mock.calls.length; await expect .poll(() => { const finished = getFinishedSession(sessionId); return Boolean(finished); }, BACKGROUND_POLL_OPTIONS) .toBe(true); expect(onUpdateSpy.mock.calls.length).toBe(callsBeforeBackground); }, isWin ? 15_000 : 5_000, ); it( "does not invoke onUpdate after the foreground exec process exits", async () => { const onUpdateSpy = vi.fn(); // Run a foreground command that produces output then exits. const command = joinCommands([shellEcho("line1"), shellEcho("line2")]); await execTool.execute(nextCallId(), { command }, undefined, onUpdateSpy); const callsAtExit = onUpdateSpy.mock.calls.length; // Allow a tick for any straggling stdout data events. await waitOneTurn(); expect(onUpdateSpy.mock.calls.length).toBe(callsAtExit); }, isWin ? 10_000 : 5_000, ); it("removes the abort listener after a foreground exec process exits", async () => { const abortController = new AbortController(); const addListenerSpy = vi.spyOn(abortController.signal, "addEventListener"); const removeListenerSpy = vi.spyOn(abortController.signal, "removeEventListener"); await execTool.execute( nextCallId(), { command: shellEcho("foreground-cleanup") }, abortController.signal, vi.fn(), ); const abortListener = addListenerSpy.mock.calls.find(([type]) => type === "abort")?.[1]; expect(abortListener).toBeDefined(); expect(removeListenerSpy).toHaveBeenCalledWith("abort", abortListener); }); it("removes the abort listener when an exec process is backgrounded", async () => { const abortController = new AbortController(); const addListenerSpy = vi.spyOn(abortController.signal, "addEventListener"); const removeListenerSpy = vi.spyOn(abortController.signal, "removeEventListener"); const tool = createTestExecTool({ allowBackground: true, backgroundMs: 0 }); const result = await tool.execute( nextCallId(), { command: shellEcho("background-cleanup"), background: true }, abortController.signal, vi.fn(), ); expect(readProcessStatus(result.details)).toBe(PROCESS_STATUS_RUNNING); const abortListener = addListenerSpy.mock.calls.find(([type]) => type === "abort")?.[1]; expect(abortListener).toBeDefined(); expect(removeListenerSpy).toHaveBeenCalledWith("abort", abortListener); }); it( "suppresses onUpdate after abort signal fires", async () => { const abortController = new AbortController(); const onUpdateSpy = vi.fn(() => abortController.abort()); // Run a command that produces output over time. const beforeAbort = shellEcho("before-abort"); const afterAbort = shellEcho("after-abort"); const command = joinCommands([beforeAbort, shortDelayCmd, afterAbort]); await expect( execTool.execute(nextCallId(), { command }, abortController.signal, onUpdateSpy), ).rejects.toMatchObject({ name: "AbortError" }); expect(onUpdateSpy).toHaveBeenCalledTimes(1); // Allow a tick for any straggling stdout data events. await waitOneTurn(); // After abort, no new onUpdate calls should have been made. expect(onUpdateSpy).toHaveBeenCalledTimes(1); }, isWin ? 10_000 : 5_000, ); });