diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts index 5cb2fa9bcf3..eff818af141 100644 --- a/src/agents/apply-patch.test.ts +++ b/src/agents/apply-patch.test.ts @@ -9,6 +9,111 @@ import { import { applyPatch } from "./apply-patch.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; +const pinnedPathHelper = vi.hoisted(() => { + const fs = require("node:fs/promises") as typeof import("node:fs/promises"); + const path = require("node:path") as typeof import("node:path"); + const { pipeline } = require("node:stream/promises") as typeof import("node:stream/promises"); + + async function resolvePinnedParent(params: { + rootPath: string; + relativeParentPath?: string; + mkdir?: boolean; + }): Promise { + let current = params.rootPath; + for (const segment of (params.relativeParentPath ?? "").split("/").filter(Boolean)) { + const next = path.join(current, segment); + try { + const stat = await fs.lstat(next); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error("symbolic link or non-directory path segment"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !params.mkdir) { + throw error; + } + await fs.mkdir(next); + } + current = next; + } + return current; + } + + return { + runPinnedPathHelper: vi.fn( + async (params: { + operation: "mkdirp" | "remove"; + rootPath: string; + relativePath: string; + }) => { + const segments = params.relativePath.split("/").filter(Boolean); + const targetPath = path.join(params.rootPath, ...segments); + if (params.operation === "mkdirp") { + await resolvePinnedParent({ + rootPath: params.rootPath, + relativeParentPath: params.relativePath, + mkdir: true, + }); + return; + } + await resolvePinnedParent({ + rootPath: params.rootPath, + relativeParentPath: segments.slice(0, -1).join("/"), + mkdir: false, + }); + const stat = await fs.lstat(targetPath); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + await fs.rmdir(targetPath); + return; + } + await fs.unlink(targetPath); + }, + ), + runPinnedWriteHelper: vi.fn( + async (params: { + rootPath: string; + relativeParentPath: string; + basename: string; + mkdir: boolean; + mode: number; + input: + | { kind: "buffer"; data: string | Buffer; encoding?: BufferEncoding } + | { kind: "stream"; stream: NodeJS.ReadableStream }; + }) => { + const parentPath = await resolvePinnedParent({ + rootPath: params.rootPath, + relativeParentPath: params.relativeParentPath, + mkdir: params.mkdir, + }); + const targetPath = path.join(parentPath, params.basename); + if (params.input.kind === "buffer") { + await fs.writeFile(targetPath, params.input.data, { + encoding: params.input.encoding, + mode: params.mode, + }); + } else { + const handle = await fs.open(targetPath, "w", params.mode); + try { + await pipeline(params.input.stream, handle.createWriteStream()); + } finally { + await handle.close().catch(() => undefined); + } + } + const stat = await fs.stat(targetPath); + return { dev: stat.dev, ino: stat.ino }; + }, + ), + }; +}); + +vi.mock("../infra/fs-pinned-path-helper.js", () => ({ + isPinnedPathHelperSpawnError: () => false, + runPinnedPathHelper: pinnedPathHelper.runPinnedPathHelper, +})); + +vi.mock("../infra/fs-pinned-write-helper.js", () => ({ + runPinnedWriteHelper: pinnedPathHelper.runPinnedWriteHelper, +})); + async function withTempDir(fn: (dir: string) => Promise) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-patch-")); try { diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 4c04cf38435..379a5238b2c 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -3,7 +3,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/io.js"; import { sendMessage } from "../infra/outbound/message.js"; import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js"; import { createExecTool } from "./bash-tools.exec.js"; @@ -25,13 +24,144 @@ vi.mock("../infra/outbound/message.js", () => ({ sendMessage: vi.fn(async () => ({ ok: true })), })); -vi.mock("../infra/shell-env.js", async () => { - const mod = - await vi.importActual("../infra/shell-env.js"); +vi.mock("../utils/message-channel.js", () => { + const normalizeMessageChannel = (raw?: string | null) => { + const normalized = raw?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if (normalized === "web" || normalized === "webchat") { + return "internal"; + } + return normalized; + }; + const isGatewayMessageChannel = (value: string) => Boolean(normalizeMessageChannel(value)); return { - ...mod, - getShellPathFromLoginShell: vi.fn(() => null), - resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0), + INTERNAL_MESSAGE_CHANNEL: "internal", + isDeliverableMessageChannel: (value: string) => { + const channel = normalizeMessageChannel(value); + return Boolean(channel && channel !== "internal" && channel !== "tui"); + }, + isGatewayMessageChannel, + normalizeMessageChannel, + resolveGatewayMessageChannel: normalizeMessageChannel, + resolveMessageChannel: (primary?: string | null, fallback?: string | null) => + normalizeMessageChannel(primary) ?? normalizeMessageChannel(fallback), + }; +}); + +vi.mock("../utils/delivery-context.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("../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("../infra/shell-env.js", () => ({ + getShellPathFromLoginShell: vi.fn(() => null), + resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0), +})); + +vi.mock("../process/supervisor/index.js", () => { + const stdoutFor = (command: string) => { + if ( + command.includes("calendar events primary --today --json") || + command.includes("gog-wrapper") + ) { + return '{"events":[]}\n'; + } + if (command.includes("printf delayed-ok")) { + return "delayed-ok"; + } + if (command.includes("printf webchat-ok")) { + return "webchat-ok"; + } + if (command.includes("printf approval-one")) { + return "approval-one"; + } + if (command.includes("printf approval-two")) { + return "approval-two"; + } + if (command.includes("echo allow-always")) { + return "allow-always\n"; + } + if (command.includes("echo cron-ok")) { + return "cron-ok\n"; + } + if (command.includes("echo ok")) { + return "ok\n"; + } + return ""; + }; + return { + getProcessSupervisor: () => ({ + spawn: async (input: { argv?: string[]; onStdout?: (chunk: string) => void }) => { + const command = input.argv?.join(" ") ?? ""; + const stdout = stdoutFor(command); + if (stdout) { + input.onStdout?.(stdout); + } + return { + runId: "mock-approval-run", + startedAtMs: Date.now(), + stdin: undefined, + wait: async () => ({ + reason: "exit" as const, + exitCode: 0, + exitSignal: null, + durationMs: 0, + stdout: "", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }), + cancel: vi.fn(), + }; + }, + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }), }; }); @@ -256,8 +386,6 @@ describe("exec approvals", () => { afterEach(() => { vi.clearAllMocks(); - clearRuntimeConfigSnapshot(); - clearConfigCache(); if (previousHome === undefined) { delete process.env.HOME; } else { @@ -336,7 +464,7 @@ describe("exec approvals", () => { ).toMatchObject({ suppressNotifyOnExit: true, }); - await expect.poll(() => agentParams, { timeout: 2_000, interval: 1 }).toBeTruthy(); + await expect.poll(() => agentParams, { timeout: 2000, interval: 1 }).toBeTruthy(); }); it("skips approval when node allowlist is satisfied", async () => { @@ -619,13 +747,20 @@ describe("exec approvals", () => { await expect .poll( async () => { - const raw = await fs.readFile(approvalsPath, "utf8"); - const parsed = JSON.parse(raw) as { - agents?: { main?: { allowlist?: Array<{ source?: string }> } }; - }; - return parsed.agents?.main?.allowlist?.some((entry) => entry.source === "allow-always"); + try { + const raw = await fs.readFile(approvalsPath, "utf8"); + const parsed = JSON.parse(raw) as { + agents?: { main?: { allowlist?: Array<{ source?: string }> } }; + }; + return ( + parsed.agents?.main?.allowlist?.some((entry) => entry.source === "allow-always") === + true + ); + } catch { + return false; + } }, - { timeout: 1_000, interval: 1 }, + { timeout: 2000, interval: 1 }, ) .toBe(true); @@ -796,7 +931,7 @@ describe("exec approvals", () => { }); expect(result.details.status).toBe("approval-pending"); - await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 1 }).toBe(1); + await expect.poll(() => agentCalls.length, { timeout: 3000, interval: 1 }).toBe(1); expect(agentCalls[0]).toEqual( expect.objectContaining({ sessionKey: "agent:main:main", @@ -837,7 +972,7 @@ describe("exec approvals", () => { }); expect(result.details.status).toBe("approval-pending"); - await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 1 }).toBe(1); + await expect.poll(() => agentCalls.length, { timeout: 3000, interval: 1 }).toBe(1); expect(agentCalls[0]).toEqual( expect.objectContaining({ sessionKey: "agent:main:discord:channel:123", @@ -900,7 +1035,7 @@ describe("exec approvals", () => { resolveDecision?.({ decision: "allow-once" }); - await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 1 }).toBe(1); + await expect.poll(() => agentCalls.length, { timeout: 3000, interval: 1 }).toBe(1); expect(agentCalls[0]).toEqual( expect.objectContaining({ sessionKey: "agent:main:discord:channel:123", @@ -944,7 +1079,7 @@ describe("exec approvals", () => { expect(result.details.status).toBe("approval-pending"); - await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 1 }).toBe(1); + await expect.poll(() => agentCalls.length, { timeout: 3000, interval: 1 }).toBe(1); expect(agentCalls[0]).toEqual( expect.objectContaining({ sessionKey: "agent:main:main", @@ -985,7 +1120,7 @@ describe("exec approvals", () => { }); expect(result.details.status).toBe("approval-pending"); - await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 1 }).toBe(1); + await expect.poll(() => agentCalls.length, { timeout: 3000, interval: 1 }).toBe(1); expect(typeof agentCalls[0]?.message).toBe("string"); expect(agentCalls[0]?.message).toContain("An async command did not run."); expect(agentCalls[0]?.message).toContain( @@ -1025,17 +1160,17 @@ describe("exec approvals", () => { const tool = createElevatedAllowlistExecTool(); const first = await tool.execute("call-seq-1", { - command: "npm view diver --json", + command: "printf approval-one", elevated: true, }); const second = await tool.execute("call-seq-2", { - command: "brew outdated", + command: "printf approval-two", elevated: true, }); expect(first.details.status).toBe("approval-pending"); expect(second.details.status).toBe("approval-pending"); - expect(requestCommands).toEqual(["npm view diver --json", "brew outdated"]); + expect(requestCommands).toEqual(["printf approval-one", "printf approval-two"]); expect(requestIds).toHaveLength(2); expect(requestIds[0]).not.toBe(requestIds[1]); expect(waitIds).toEqual(requestIds); diff --git a/src/agents/bash-tools.exec.background-abort.test.ts b/src/agents/bash-tools.exec.background-abort.test.ts index 0067e8e952f..a121dd5e9ef 100644 --- a/src/agents/bash-tools.exec.background-abort.test.ts +++ b/src/agents/bash-tools.exec.background-abort.test.ts @@ -1,10 +1,87 @@ import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vitest"; import { killProcessTree } from "../process/kill-tree.js"; +const supervisorMockState = vi.hoisted(() => ({ + cancelReasons: [] as Array<"manual-cancel" | "overall-timeout">, + spawnInputs: [] as Array<{ timeoutMs?: number }>, +})); + +vi.mock("../process/supervisor/index.js", () => { + let counter = 0; + return { + getProcessSupervisor: () => ({ + spawn: async (input: { timeoutMs?: number }) => { + supervisorMockState.spawnInputs.push(input); + const runId = `mock-run-${++counter}`; + let settled = false; + let settle = (_reason: "manual-cancel" | "overall-timeout", _timedOut: boolean) => {}; + const waitPromise = new Promise<{ + reason: "manual-cancel" | "overall-timeout"; + exitCode: number | null; + exitSignal: NodeJS.Signals | number | null; + durationMs: number; + stdout: string; + stderr: string; + timedOut: boolean; + noOutputTimedOut: boolean; + }>((resolve) => { + settle = (reason, timedOut) => { + if (settled) { + return; + } + settled = true; + resolve({ + reason, + exitCode: null, + exitSignal: null, + durationMs: input.timeoutMs ?? 0, + stdout: "", + stderr: "", + timedOut, + noOutputTimedOut: false, + }); + }; + if (input.timeoutMs !== undefined) { + setTimeout(() => settle("overall-timeout", true), 12); + } + }); + return { + runId, + startedAtMs: Date.now(), + stdin: undefined, + wait: () => waitPromise, + cancel: () => { + supervisorMockState.cancelReasons.push("manual-cancel"); + settle("manual-cancel", false); + }, + }; + }, + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }), + }; +}); + +vi.mock("../infra/shell-env.js", () => ({ + getShellPathFromLoginShell: vi.fn(() => null), + resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0), +})); + +vi.mock("./bash-tools.exec-host-gateway.js", () => ({ + processGatewayAllowlist: vi.fn(async () => ({})), +})); + +vi.mock("./bash-tools.exec-host-node.js", () => ({ + executeNodeHostCommand: vi.fn(async () => { + throw new Error("node host not expected in background abort tests"); + }), +})); + const BACKGROUND_HOLD_CMD = - process.platform === "win32" ? 'node -e "setTimeout(() => {}, 5000)"' : "exec sleep 5"; -const ABORT_SETTLE_MS = process.platform === "win32" ? 200 : 10; -const ABORT_WAIT_TIMEOUT_MS = process.platform === "win32" ? 1_500 : 400; + process.platform === "win32" ? 'node -e "setTimeout(() => {}, 1000)"' : "exec sleep 1"; +const ABORT_SETTLE_MS = process.platform === "win32" ? 200 : 0; const POLL_INTERVAL_MS = process.platform === "win32" ? 15 : 5; const FINISHED_WAIT_TIMEOUT_MS = process.platform === "win32" ? 8_000 : 1_000; const BACKGROUND_TIMEOUT_SEC = process.platform === "win32" ? 0.2 : 0.02; @@ -32,6 +109,8 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks(); + supervisorMockState.cancelReasons.length = 0; + supervisorMockState.spawnInputs.length = 0; }); afterEach(() => { @@ -78,21 +157,14 @@ async function expectBackgroundSessionSurvivesAbort(params: { const sessionId = (result.details as { sessionId: string }).sessionId; abortController.abort(); - const startedAt = Date.now(); - await expect - .poll( - () => { - const running = getSession(sessionId); - const finished = getFinishedSession(sessionId); - return Date.now() - startedAt >= ABORT_SETTLE_MS && !finished && running?.exited === false; - }, - { timeout: ABORT_WAIT_TIMEOUT_MS, interval: POLL_INTERVAL_MS }, - ) - .toBe(true); + if (ABORT_SETTLE_MS > 0) { + await new Promise((resolve) => setTimeout(resolve, ABORT_SETTLE_MS)); + } const running = getSession(sessionId); const finished = getFinishedSession(sessionId); try { + expect(supervisorMockState.cancelReasons).toEqual([]); expect(finished).toBeUndefined(); expect(running?.exited).toBe(false); } finally { @@ -163,22 +235,9 @@ test("background exec without explicit timeout ignores default timeout", async ( const result = await tool.execute("toolcall", { command: BACKGROUND_HOLD_CMD, background: true }); expect(result.details.status).toBe("running"); const sessionId = (result.details as { sessionId: string }).sessionId; - const waitMs = Math.max(ABORT_SETTLE_MS + 30, BACKGROUND_TIMEOUT_SEC * 1000 + 30); - - const startedAt = Date.now(); - await expect - .poll( - () => { - const running = getSession(sessionId); - const finished = getFinishedSession(sessionId); - return Date.now() - startedAt >= waitMs && !finished && running?.exited === false; - }, - { - timeout: waitMs + ABORT_WAIT_TIMEOUT_MS, - interval: POLL_INTERVAL_MS, - }, - ) - .toBe(true); + expect(supervisorMockState.spawnInputs.at(-1)?.timeoutMs).toBeUndefined(); + expect(getFinishedSession(sessionId)).toBeUndefined(); + expect(getSession(sessionId)?.exited).toBe(false); cleanupRunningSession(sessionId); }); diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 0a174005c4d..cd20428ab95 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -31,6 +31,48 @@ vi.mock("../infra/exec-approvals.js", async () => { return { ...mod, resolveExecApprovals: () => createExecApprovals() }; }); +vi.mock("../process/supervisor/index.js", () => ({ + getProcessSupervisor: () => ({ + spawn: async (input: { + argv?: string[]; + env?: NodeJS.ProcessEnv; + onStdout?: (chunk: string) => void; + }) => { + const command = input.argv?.at(-1) ?? ""; + const env = input.env ?? {}; + if (command.includes("OPENCLAW_SHELL")) { + input.onStdout?.(env.OPENCLAW_SHELL ?? ""); + } else if (command.includes("SSLKEYLOGFILE")) { + input.onStdout?.(env.SSLKEYLOGFILE ?? ""); + } else if (command.includes("$PATH")) { + input.onStdout?.(env.PATH ?? ""); + } else if (command === "echo ok") { + input.onStdout?.("ok\n"); + } + return { + runId: "mock-path-run", + startedAtMs: Date.now(), + stdin: undefined, + wait: async () => ({ + reason: "exit" as const, + exitCode: 0, + exitSignal: null, + durationMs: 0, + stdout: "", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }), + cancel: vi.fn(), + }; + }, + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }), +})); + let createExecTool: typeof import("./bash-tools.exec.js").createExecTool; function createExecApprovals(): ExecApprovalsResolved { diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index af18e7d3891..1ab0e483ecf 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -3,7 +3,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { analyzeShellCommand } from "../infra/exec-approvals-analysis.js"; import { type ExecHost, loadExecApprovals, maxAsk, minSecurity } from "../infra/exec-approvals.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; -import { SafeOpenError, readFileWithinRoot } from "../infra/fs-safe.js"; import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js"; import { getShellPathFromLoginShell, @@ -118,7 +117,19 @@ function getNodeErrorCode(error: unknown): string | undefined { return String((error as { code?: unknown }).code); } -function shouldSkipScriptPreflightPathError(error: unknown): boolean { +type FsSafeModule = typeof import("../infra/fs-safe.js"); + +let fsSafeModulePromise: Promise | undefined; + +async function loadFsSafeModule(): Promise { + fsSafeModulePromise ??= import("../infra/fs-safe.js"); + return await fsSafeModulePromise; +} + +function shouldSkipScriptPreflightPathError( + error: unknown, + SafeOpenError: FsSafeModule["SafeOpenError"], +): boolean { if (error instanceof SafeOpenError) { return true; } @@ -951,6 +962,7 @@ async function validateScriptFileForShellBleed(params: { return; } + const { SafeOpenError, readFileWithinRoot } = await loadFsSafeModule(); for (const relOrAbsPath of target.relOrAbsPaths) { const absPath = path.isAbsolute(relOrAbsPath) ? path.resolve(relOrAbsPath) @@ -978,7 +990,7 @@ async function validateScriptFileForShellBleed(params: { }); content = safeRead.buffer.toString("utf-8"); } catch (error) { - if (shouldSkipScriptPreflightPathError(error)) { + if (shouldSkipScriptPreflightPathError(error, SafeOpenError)) { // Preflight validation is best-effort: skip path/read failures and // continue to execute the command normally. continue; diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 890a413b6ff..e294eff5697 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { drainFormattedSystemEvents } from "../auto-reply/reply/session-updates.js"; +import { drainFormattedSystemEvents } from "../auto-reply/reply/session-system-events.js"; import type { OpenClawConfig } from "../config/config.js"; import { resetHeartbeatWakeStateForTests, @@ -25,13 +25,161 @@ import { import { createExecTool, createProcessTool } from "./bash-tools.js"; import { resolveShellFromPath, 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.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", () => ({ + buildExecApprovalFollowupPrompt: (text: string) => text, + 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 readEnvPath = (env?: NodeJS.ProcessEnv) => env?.PATH ?? env?.Path ?? ""; + const extractCommand = (input: SpawnInput) => input.ptyCommand ?? input.argv?.at(-1) ?? ""; + const splitCommands = (command: string) => + command + .split(";") + .map((part) => part.trim()) + .filter(Boolean); + 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) => + splitCommands(command) + .map((segment) => stdoutForSegment(segment, env)) + .join(""); + + return { + getProcessSupervisor: () => ({ + spawn: async (input: SpawnInput) => { + const command = extractCommand(input); + const output = commandOutput(command, input.env); + const exitCode = splitCommands(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(); + 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(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }), + }; +}); + 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 POLL_INTERVAL_MS = isWin ? 15 : 2; const BACKGROUND_POLL_TIMEOUT_MS = isWin ? 8000 : 1200; const NOTIFY_EVENT_TIMEOUT_MS = isWin ? 12_000 : 5_000; @@ -122,6 +270,7 @@ 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) => @@ -516,11 +665,7 @@ describe("exec tool backgrounding", () => { it( "backgrounds after yield and can be polled", async () => { - const result = await executeExecCommand( - execTool, - joinCommands([yieldDelayCmd, shellEcho(OUTPUT_DONE)]), - { yieldMs: 10 }, - ); + 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) { @@ -787,7 +932,7 @@ describe("exec backgrounded onUpdate suppression", () => { await execTool.execute(nextCallId(), { command }, undefined, onUpdateSpy); const callsAtExit = onUpdateSpy.mock.calls.length; // Allow a tick for any straggling stdout data events. - await new Promise((r) => setTimeout(r, 5)); + await waitOneTurn(); expect(onUpdateSpy.mock.calls.length).toBe(callsAtExit); }, isWin ? 10_000 : 5_000, @@ -806,7 +951,7 @@ describe("exec backgrounded onUpdate suppression", () => { ]); // Abort almost immediately so the signal fires while the command // is still producing output. - setTimeout(() => abortController.abort(), 10); + setTimeout(() => abortController.abort(), 0); const result = await execTool.execute( nextCallId(), { command }, @@ -815,7 +960,7 @@ describe("exec backgrounded onUpdate suppression", () => { ); const callsAtAbort = onUpdateSpy.mock.calls.length; // Allow a tick for any straggling stdout data events. - await new Promise((r) => setTimeout(r, 5)); + await waitOneTurn(); // After abort, no new onUpdate calls should have been made. expect(onUpdateSpy.mock.calls.length).toBe(callsAtAbort); expect(result).toBeDefined();