import fs from "node:fs"; import fsPromises from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../../infra/agent-events.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; import { buildSystemRunApprovalBinding } from "../../infra/system-run-approval-binding.js"; import { resetLogger, setLoggerOverride } from "../../logging.js"; import { ExecApprovalManager } from "../exec-approval-manager.js"; import { validateExecApprovalRequestParams } from "../protocol/index.js"; import { waitForAgentJob } from "./agent-job.js"; import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; import { sanitizeChatSendMessageInput } from "./chat.js"; import { createExecApprovalHandlers } from "./exec-approval.js"; import { logsHandlers } from "./logs.js"; vi.mock("../../commands/status.js", () => ({ getStatusSummary: vi.fn().mockResolvedValue({ ok: true }), })); describe("waitForAgentJob", () => { async function runLifecycleScenario(params: { runIdPrefix: string; startedAt: number; endedAt: number; aborted?: boolean; }) { const runId = `${params.runIdPrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 }); emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: params.startedAt }, }); emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", endedAt: params.endedAt, aborted: params.aborted }, }); return waitPromise; } it("maps lifecycle end events with aborted=true to timeout", async () => { const snapshot = await runLifecycleScenario({ runIdPrefix: "run-timeout", startedAt: 100, endedAt: 200, aborted: true, }); expect(snapshot).not.toBeNull(); expect(snapshot?.status).toBe("timeout"); expect(snapshot?.startedAt).toBe(100); expect(snapshot?.endedAt).toBe(200); }); it("keeps non-aborted lifecycle end events as ok", async () => { const snapshot = await runLifecycleScenario({ runIdPrefix: "run-ok", startedAt: 300, endedAt: 400, }); expect(snapshot).not.toBeNull(); expect(snapshot?.status).toBe("ok"); expect(snapshot?.startedAt).toBe(300); expect(snapshot?.endedAt).toBe(400); }); it("can ignore cached snapshots and wait for fresh lifecycle events", async () => { const runId = `run-ignore-cache-${Date.now()}-${Math.random().toString(36).slice(2)}`; emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", startedAt: 100, endedAt: 110 }, }); const cached = await waitForAgentJob({ runId, timeoutMs: 1_000 }); expect(cached?.status).toBe("ok"); expect(cached?.startedAt).toBe(100); expect(cached?.endedAt).toBe(110); const freshWait = waitForAgentJob({ runId, timeoutMs: 1_000, ignoreCachedSnapshot: true, }); queueMicrotask(() => { emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 200 }, }); emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", startedAt: 200, endedAt: 210 }, }); }); const fresh = await freshWait; expect(fresh?.status).toBe("ok"); expect(fresh?.startedAt).toBe(200); expect(fresh?.endedAt).toBe(210); }); }); describe("injectTimestamp", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); }); afterEach(() => { vi.useRealTimers(); }); it("prepends a compact timestamp matching formatZonedTimestamp", () => { const result = injectTimestamp("Is it the weekend?", { timezone: "America/New_York", }); expect(result).toMatch(/^\[Wed 2026-01-28 20:30 EST\] Is it the weekend\?$/); }); it("uses channel envelope format with DOW prefix", () => { const now = new Date(); const expected = formatZonedTimestamp(now, { timeZone: "America/New_York" }); const result = injectTimestamp("hello", { timezone: "America/New_York" }); expect(result).toBe(`[Wed ${expected}] hello`); }); it("always uses 24-hour format", () => { const result = injectTimestamp("hello", { timezone: "America/New_York" }); expect(result).toContain("20:30"); expect(result).not.toContain("PM"); expect(result).not.toContain("AM"); }); it("uses the configured timezone", () => { const result = injectTimestamp("hello", { timezone: "America/Chicago" }); expect(result).toMatch(/^\[Wed 2026-01-28 19:30 CST\]/); }); it("defaults to UTC when no timezone specified", () => { const result = injectTimestamp("hello", {}); expect(result).toMatch(/^\[Thu 2026-01-29 01:30/); }); it("returns empty/whitespace messages unchanged", () => { expect(injectTimestamp("", { timezone: "UTC" })).toBe(""); expect(injectTimestamp(" ", { timezone: "UTC" })).toBe(" "); }); it("does NOT double-stamp messages with channel envelope timestamps", () => { const enveloped = "[Discord user1 2026-01-28 20:30 EST] hello there"; const result = injectTimestamp(enveloped, { timezone: "America/New_York" }); expect(result).toBe(enveloped); }); it("does NOT double-stamp messages already injected by us", () => { const alreadyStamped = "[Wed 2026-01-28 20:30 EST] hello there"; const result = injectTimestamp(alreadyStamped, { timezone: "America/New_York" }); expect(result).toBe(alreadyStamped); }); it("does NOT double-stamp messages with cron-injected timestamps", () => { const cronMessage = "[cron:abc123 my-job] do the thing\nCurrent time: Wednesday, January 28th, 2026 — 8:30 PM (America/New_York)"; const result = injectTimestamp(cronMessage, { timezone: "America/New_York" }); expect(result).toBe(cronMessage); }); it("handles midnight correctly", () => { vi.setSystemTime(new Date("2026-02-01T05:00:00.000Z")); const result = injectTimestamp("hello", { timezone: "America/New_York" }); expect(result).toMatch(/^\[Sun 2026-02-01 00:00 EST\]/); }); it("handles date boundaries (just before midnight)", () => { vi.setSystemTime(new Date("2026-02-01T04:59:00.000Z")); const result = injectTimestamp("hello", { timezone: "America/New_York" }); expect(result).toMatch(/^\[Sat 2026-01-31 23:59 EST\]/); }); it("handles DST correctly (same UTC hour, different local time)", () => { vi.setSystemTime(new Date("2026-01-15T05:00:00.000Z")); const winter = injectTimestamp("winter", { timezone: "America/New_York" }); expect(winter).toMatch(/^\[Thu 2026-01-15 00:00 EST\]/); vi.setSystemTime(new Date("2026-07-15T04:00:00.000Z")); const summer = injectTimestamp("summer", { timezone: "America/New_York" }); expect(summer).toMatch(/^\[Wed 2026-07-15 00:00 EDT\]/); }); it("accepts a custom now date", () => { const customDate = new Date("2025-07-04T16:00:00.000Z"); const result = injectTimestamp("fireworks?", { timezone: "America/New_York", now: customDate, }); expect(result).toMatch(/^\[Fri 2025-07-04 12:00 EDT\]/); }); }); describe("timestampOptsFromConfig", () => { it.each([ { name: "extracts timezone from config", // oxlint-disable-next-line typescript/no-explicit-any cfg: { agents: { defaults: { userTimezone: "America/Chicago" } } } as any, expected: "America/Chicago", }, { name: "falls back gracefully with empty config", // oxlint-disable-next-line typescript/no-explicit-any cfg: {} as any, expected: Intl.DateTimeFormat().resolvedOptions().timeZone, }, ])("$name", ({ cfg, expected }) => { expect(timestampOptsFromConfig(cfg).timezone).toBe(expected); }); }); describe("normalizeRpcAttachmentsToChatAttachments", () => { it.each([ { name: "passes through string content", attachments: [{ type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }], expected: [{ type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }], }, { name: "converts Uint8Array content to base64", attachments: [{ content: new TextEncoder().encode("foo") }], expected: [{ type: undefined, mimeType: undefined, fileName: undefined, content: "Zm9v" }], }, { name: "converts ArrayBuffer content to base64", attachments: [{ content: new TextEncoder().encode("bar").buffer }], expected: [{ type: undefined, mimeType: undefined, fileName: undefined, content: "YmFy" }], }, { name: "drops attachments without usable content", attachments: [{ content: undefined }, { mimeType: "image/png" }], expected: [], }, ])("$name", ({ attachments, expected }) => { expect(normalizeRpcAttachmentsToChatAttachments(attachments)).toEqual(expected); }); }); describe("sanitizeChatSendMessageInput", () => { it.each([ { name: "rejects null bytes", input: "before\u0000after", expected: { ok: false as const, error: "message must not contain null bytes" }, }, { name: "strips unsafe control characters while preserving tab/newline/carriage return", input: "a\u0001b\tc\nd\re\u0007f\u007f", expected: { ok: true as const, message: "ab\tc\nd\ref" }, }, { name: "normalizes unicode to NFC", input: "Cafe\u0301", expected: { ok: true as const, message: "Café" }, }, ])("$name", ({ input, expected }) => { expect(sanitizeChatSendMessageInput(input)).toEqual(expected); }); }); describe("gateway chat transcript writes (guardrail)", () => { it("routes transcript writes through helper and SessionManager parentId append", () => { const chatTs = fileURLToPath(new URL("./chat.ts", import.meta.url)); const chatSrc = fs.readFileSync(chatTs, "utf-8"); const helperTs = fileURLToPath(new URL("./chat-transcript-inject.ts", import.meta.url)); const helperSrc = fs.readFileSync(helperTs, "utf-8"); expect(chatSrc.includes("fs.appendFileSync(transcriptPath")).toBe(false); expect(chatSrc).toContain("appendInjectedAssistantMessageToTranscript("); expect(helperSrc.includes("fs.appendFileSync(params.transcriptPath")).toBe(false); expect(helperSrc).toContain("SessionManager.open(params.transcriptPath)"); expect(helperSrc).toContain("appendMessage(messageBody)"); }); }); describe("exec approval handlers", () => { const execApprovalNoop = () => false; type ExecApprovalHandlers = ReturnType; type ExecApprovalRequestArgs = Parameters[0]; type ExecApprovalResolveArgs = Parameters[0]; const defaultExecApprovalRequestParams = { command: "echo ok", commandArgv: ["echo", "ok"], systemRunPlan: { argv: ["/usr/bin/echo", "ok"], cwd: "/tmp", commandText: "/usr/bin/echo ok", agentId: "main", sessionKey: "agent:main:main", }, cwd: "/tmp", nodeId: "node-1", host: "node", timeoutMs: 2000, } as const; function toExecApprovalRequestContext(context: { broadcast: (event: string, payload: unknown) => void; hasExecApprovalClients?: () => boolean; }): ExecApprovalRequestArgs["context"] { return context as unknown as ExecApprovalRequestArgs["context"]; } function toExecApprovalResolveContext(context: { broadcast: (event: string, payload: unknown) => void; }): ExecApprovalResolveArgs["context"] { return context as unknown as ExecApprovalResolveArgs["context"]; } async function requestExecApproval(params: { handlers: ExecApprovalHandlers; respond: ReturnType; context: { broadcast: (event: string, payload: unknown) => void }; params?: Record; }) { const requestParams = { ...defaultExecApprovalRequestParams, ...params.params, } as unknown as ExecApprovalRequestArgs["params"]; const hasExplicitPlan = !!params.params && Object.hasOwn(params.params, "systemRunPlan"); if ( !hasExplicitPlan && (requestParams as { host?: string }).host === "node" && Array.isArray((requestParams as { commandArgv?: unknown }).commandArgv) ) { const commandArgv = (requestParams as { commandArgv: unknown[] }).commandArgv.map((entry) => String(entry), ); const cwdValue = typeof (requestParams as { cwd?: unknown }).cwd === "string" ? ((requestParams as { cwd: string }).cwd ?? null) : null; const commandText = typeof (requestParams as { command?: unknown }).command === "string" ? ((requestParams as { command: string }).command ?? null) : null; requestParams.systemRunPlan = { argv: commandArgv, cwd: cwdValue, commandText: commandText ?? commandArgv.join(" "), agentId: typeof (requestParams as { agentId?: unknown }).agentId === "string" ? ((requestParams as { agentId: string }).agentId ?? null) : null, sessionKey: typeof (requestParams as { sessionKey?: unknown }).sessionKey === "string" ? ((requestParams as { sessionKey: string }).sessionKey ?? null) : null, }; } return params.handlers["exec.approval.request"]({ params: requestParams, respond: params.respond as unknown as ExecApprovalRequestArgs["respond"], context: toExecApprovalRequestContext({ hasExecApprovalClients: () => true, ...params.context, }), client: null, req: { id: "req-1", type: "req", method: "exec.approval.request" }, isWebchatConnect: execApprovalNoop, }); } async function resolveExecApproval(params: { handlers: ExecApprovalHandlers; id: string; respond: ReturnType; context: { broadcast: (event: string, payload: unknown) => void }; }) { return params.handlers["exec.approval.resolve"]({ params: { id: params.id, decision: "allow-once" } as ExecApprovalResolveArgs["params"], respond: params.respond as unknown as ExecApprovalResolveArgs["respond"], context: toExecApprovalResolveContext(params.context), client: null, req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, isWebchatConnect: execApprovalNoop, }); } function createExecApprovalFixture() { const manager = new ExecApprovalManager(); const handlers = createExecApprovalHandlers(manager); const broadcasts: Array<{ event: string; payload: unknown }> = []; const respond = vi.fn(); const context = { broadcast: (event: string, payload: unknown) => { broadcasts.push({ event, payload }); }, hasExecApprovalClients: () => true, }; return { handlers, broadcasts, respond, context }; } function createForwardingExecApprovalFixture() { const manager = new ExecApprovalManager(); const forwarder = { handleRequested: vi.fn(async () => false), handleResolved: vi.fn(async () => {}), stop: vi.fn(), }; const handlers = createExecApprovalHandlers(manager, { forwarder }); const respond = vi.fn(); const context = { broadcast: (_event: string, _payload: unknown) => {}, hasExecApprovalClients: () => false, }; return { manager, handlers, forwarder, respond, context }; } async function drainApprovalRequestTicks() { for (let idx = 0; idx < 20; idx += 1) { await Promise.resolve(); } } describe("ExecApprovalRequestParams validation", () => { const baseParams = { command: "echo hi", cwd: "/tmp", nodeId: "node-1", host: "node", }; it.each([ { label: "omitted", extra: {} }, { label: "string", extra: { resolvedPath: "/usr/bin/echo" } }, { label: "undefined", extra: { resolvedPath: undefined } }, { label: "null", extra: { resolvedPath: null } }, ])("accepts request with resolvedPath $label", ({ extra }) => { const params = { ...baseParams, ...extra }; expect(validateExecApprovalRequestParams(params)).toBe(true); }); }); it("rejects host=node approval requests without nodeId", async () => { const { handlers, respond, context } = createExecApprovalFixture(); await requestExecApproval({ handlers, respond, context, params: { nodeId: undefined, }, }); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: "nodeId is required for host=node", }), ); }); it("rejects host=node approval requests without systemRunPlan", async () => { const { handlers, respond, context } = createExecApprovalFixture(); await requestExecApproval({ handlers, respond, context, params: { systemRunPlan: undefined, }, }); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: "systemRunPlan is required for host=node", }), ); }); it("broadcasts request + resolve", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); const requestPromise = requestExecApproval({ handlers, respond, context, params: { twoPhase: true }, }); const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); expect(requested).toBeTruthy(); const id = (requested?.payload as { id?: string })?.id ?? ""; expect(id).not.toBe(""); expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ status: "accepted", id }), undefined, ); const resolveRespond = vi.fn(); await resolveExecApproval({ handlers, id, respond: resolveRespond, context, }); await requestPromise; expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ id, decision: "allow-once" }), undefined, ); expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true); }); it("does not reuse a resolved exact id as a prefix for another pending approval", () => { const manager = new ExecApprovalManager(); const resolvedRecord = manager.create({ command: "echo old", host: "gateway" }, 2_000, "abc"); void manager.register(resolvedRecord, 2_000); expect(manager.resolve("abc", "allow-once")).toBe(true); const pendingRecord = manager.create({ command: "echo new", host: "gateway" }, 2_000, "abcdef"); void manager.register(pendingRecord, 2_000); expect(manager.lookupPendingId("abc")).toEqual({ kind: "none" }); expect(manager.lookupPendingId("abcdef")).toEqual({ kind: "exact", id: "abcdef" }); }); it("stores versioned system.run binding and sorted env keys on approval request", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); await requestExecApproval({ handlers, respond, context, params: { timeoutMs: 10, commandArgv: ["echo", "ok"], env: { Z_VAR: "z", A_VAR: "a", }, }, }); const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); expect(requested).toBeTruthy(); const request = (requested?.payload as { request?: Record })?.request ?? {}; expect(request["envKeys"]).toEqual(["A_VAR", "Z_VAR"]); expect(request["systemRunBinding"]).toEqual( buildSystemRunApprovalBinding({ argv: ["echo", "ok"], cwd: "/tmp", env: { A_VAR: "a", Z_VAR: "z" }, }).binding, ); }); it("prefers systemRunPlan canonical command/cwd when present", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); await requestExecApproval({ handlers, respond, context, params: { timeoutMs: 10, command: "echo stale", commandArgv: ["echo", "stale"], cwd: "/tmp/link/sub", systemRunPlan: { argv: ["/usr/bin/echo", "ok"], cwd: "/real/cwd", commandText: "/usr/bin/echo ok", commandPreview: "echo ok", agentId: "main", sessionKey: "agent:main:main", }, }, }); const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); expect(requested).toBeTruthy(); const request = (requested?.payload as { request?: Record })?.request ?? {}; expect(request["command"]).toBe("/usr/bin/echo ok"); expect(request["commandPreview"]).toBeUndefined(); expect(request["commandArgv"]).toBeUndefined(); expect(request["cwd"]).toBe("/real/cwd"); expect(request["agentId"]).toBe("main"); expect(request["sessionKey"]).toBe("agent:main:main"); expect(request["systemRunPlan"]).toEqual({ argv: ["/usr/bin/echo", "ok"], cwd: "/real/cwd", commandText: "/usr/bin/echo ok", commandPreview: "echo ok", agentId: "main", sessionKey: "agent:main:main", }); }); it("derives a command preview from the fallback command for older node plans", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); await requestExecApproval({ handlers, respond, context, params: { timeoutMs: 10, command: "jq --version", commandArgv: ["./env", "sh", "-c", "jq --version"], systemRunPlan: { argv: ["./env", "sh", "-c", "jq --version"], cwd: "/real/cwd", commandText: './env sh -c "jq --version"', agentId: "main", sessionKey: "agent:main:main", }, }, }); const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); expect(requested).toBeTruthy(); const request = (requested?.payload as { request?: Record })?.request ?? {}; expect(request["command"]).toBe('./env sh -c "jq --version"'); expect(request["commandPreview"]).toBeUndefined(); expect((request["systemRunPlan"] as { commandPreview?: string }).commandPreview).toBe( "jq --version", ); }); it("sanitizes invisible Unicode format chars in approval display text without changing node bindings", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); await requestExecApproval({ handlers, respond, context, params: { timeoutMs: 10, command: "bash safe\u200B.sh", commandArgv: ["bash", "safe\u200B.sh"], systemRunPlan: { argv: ["bash", "safe\u200B.sh"], cwd: "/real/cwd", commandText: "bash safe\u200B.sh", agentId: "main", sessionKey: "agent:main:main", }, }, }); const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); expect(requested).toBeTruthy(); const request = (requested?.payload as { request?: Record })?.request ?? {}; expect(request["command"]).toBe("bash safe\\u{200B}.sh"); expect((request["systemRunPlan"] as { commandText?: string }).commandText).toBe( "bash safe\u200B.sh", ); }); it("accepts resolve during broadcast", async () => { const manager = new ExecApprovalManager(); const handlers = createExecApprovalHandlers(manager); const respond = vi.fn(); const resolveRespond = vi.fn(); const resolveContext = { broadcast: () => {}, }; const context = { broadcast: (event: string, payload: unknown) => { if (event !== "exec.approval.requested") { return; } const id = (payload as { id?: string })?.id ?? ""; void resolveExecApproval({ handlers, id, respond: resolveRespond, context: resolveContext, }); }, }; await requestExecApproval({ handlers, respond, context, }); expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ decision: "allow-once" }), undefined, ); }); it("accepts explicit approval ids", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); const requestPromise = requestExecApproval({ handlers, respond, context, params: { id: "approval-123", host: "gateway" }, }); const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); const id = (requested?.payload as { id?: string })?.id ?? ""; expect(id).toBe("approval-123"); const resolveRespond = vi.fn(); await resolveExecApproval({ handlers, id, respond: resolveRespond, context, }); await requestPromise; expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ id: "approval-123", decision: "allow-once" }), undefined, ); expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); }); it("accepts unique short approval id prefixes", async () => { const manager = new ExecApprovalManager(); const handlers = createExecApprovalHandlers(manager); const respond = vi.fn(); const context = { broadcast: (_event: string, _payload: unknown) => {}, }; const record = manager.create({ command: "echo ok" }, 60_000, "approval-12345678-aaaa"); void manager.register(record, 60_000); await resolveExecApproval({ handlers, id: "approval-1234", respond, context, }); expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); expect(manager.getSnapshot(record.id)?.decision).toBe("allow-once"); }); it("rejects ambiguous short approval id prefixes", async () => { const manager = new ExecApprovalManager(); const handlers = createExecApprovalHandlers(manager); const respond = vi.fn(); const context = { broadcast: (_event: string, _payload: unknown) => {}, }; void manager.register( manager.create({ command: "echo one" }, 60_000, "approval-abcd-1111"), 60_000, ); void manager.register( manager.create({ command: "echo two" }, 60_000, "approval-abcd-2222"), 60_000, ); await resolveExecApproval({ handlers, id: "approval-abcd", respond, context, }); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("ambiguous approval id prefix"), }), ); }); it("returns deterministic unknown/expired message for missing approval ids", async () => { const { handlers, respond, context } = createExecApprovalFixture(); await resolveExecApproval({ handlers, id: "missing-approval-id", respond, context, }); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: "unknown or expired approval id", }), ); }); it("resolves only the targeted approval id when multiple requests are pending", async () => { const manager = new ExecApprovalManager(); const handlers = createExecApprovalHandlers(manager); const context = { broadcast: (_event: string, _payload: unknown) => {}, hasExecApprovalClients: () => true, }; const respondOne = vi.fn(); const respondTwo = vi.fn(); const requestOne = requestExecApproval({ handlers, respond: respondOne, context, params: { id: "approval-one", host: "gateway", timeoutMs: 60_000 }, }); const requestTwo = requestExecApproval({ handlers, respond: respondTwo, context, params: { id: "approval-two", host: "gateway", timeoutMs: 60_000 }, }); await drainApprovalRequestTicks(); const resolveRespond = vi.fn(); await resolveExecApproval({ handlers, id: "approval-one", respond: resolveRespond, context, }); expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); expect(manager.getSnapshot("approval-one")?.decision).toBe("allow-once"); expect(manager.getSnapshot("approval-two")?.decision).toBeUndefined(); expect(manager.getSnapshot("approval-two")?.resolvedAtMs).toBeUndefined(); expect(manager.expire("approval-two", "test-expire")).toBe(true); await requestOne; await requestTwo; expect(respondOne).toHaveBeenCalledWith( true, expect.objectContaining({ id: "approval-one", decision: "allow-once" }), undefined, ); expect(respondTwo).toHaveBeenCalledWith( true, expect.objectContaining({ id: "approval-two", decision: null }), undefined, ); }); it("forwards turn-source metadata to exec approval forwarding", async () => { vi.useFakeTimers(); try { const { handlers, forwarder, respond, context } = createForwardingExecApprovalFixture(); const requestPromise = requestExecApproval({ handlers, respond, context, params: { timeoutMs: 60_000, turnSourceChannel: "whatsapp", turnSourceTo: "+15555550123", turnSourceAccountId: "work", turnSourceThreadId: "1739201675.123", }, }); await drainApprovalRequestTicks(); expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); expect(forwarder.handleRequested).toHaveBeenCalledWith( expect.objectContaining({ request: expect.objectContaining({ turnSourceChannel: "whatsapp", turnSourceTo: "+15555550123", turnSourceAccountId: "work", turnSourceThreadId: "1739201675.123", }), }), ); await vi.runOnlyPendingTimersAsync(); await requestPromise; } finally { vi.useRealTimers(); } }); it("fast-fails approvals when no approver clients and no forwarding targets", async () => { const { manager, handlers, forwarder, respond, context } = createForwardingExecApprovalFixture(); const expireSpy = vi.spyOn(manager, "expire"); await requestExecApproval({ handlers, respond, context, params: { timeoutMs: 60_000, id: "approval-no-approver", host: "gateway" }, }); expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); expect(expireSpy).toHaveBeenCalledWith("approval-no-approver", "no-approval-route"); expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ id: "approval-no-approver", decision: null }), undefined, ); }); it("keeps approvals pending when no approver clients but forwarding accepted the request", async () => { const { manager, handlers, forwarder, respond, context } = createForwardingExecApprovalFixture(); const expireSpy = vi.spyOn(manager, "expire"); const resolveRespond = vi.fn(); forwarder.handleRequested.mockResolvedValueOnce(true); const requestPromise = requestExecApproval({ handlers, respond, context, params: { timeoutMs: 60_000, id: "approval-forwarded", host: "gateway" }, }); await drainApprovalRequestTicks(); expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); expect(expireSpy).not.toHaveBeenCalled(); await resolveExecApproval({ handlers, id: "approval-forwarded", respond: resolveRespond, context, }); await requestPromise; expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ id: "approval-forwarded", decision: "allow-once" }), undefined, ); }); }); describe("gateway healthHandlers.status scope handling", () => { let statusModule: typeof import("../../commands/status.js"); let healthHandlers: typeof import("./health.js").healthHandlers; beforeAll(async () => { statusModule = await import("../../commands/status.js"); ({ healthHandlers } = await import("./health.js")); }); beforeEach(() => { vi.mocked(statusModule.getStatusSummary).mockClear(); }); async function runHealthStatus(scopes: string[]) { const respond = vi.fn(); await healthHandlers.status({ req: {} as never, params: {} as never, respond: respond as never, context: {} as never, client: { connect: { role: "operator", scopes } } as never, isWebchatConnect: () => false, }); return respond; } it.each([ { scopes: ["operator.read"], includeSensitive: false }, { scopes: ["operator.admin"], includeSensitive: true }, ])( "requests includeSensitive=$includeSensitive for scopes $scopes", async ({ scopes, includeSensitive }) => { const respond = await runHealthStatus(scopes); expect(vi.mocked(statusModule.getStatusSummary)).toHaveBeenCalledWith({ includeSensitive }); expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); }, ); }); describe("logs.tail", () => { const logsNoop = () => false; afterEach(() => { resetLogger(); setLoggerOverride(null); }); it("falls back to latest rolling log file when today is missing", async () => { const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-logs-")); const older = path.join(tempDir, "openclaw-2026-01-20.log"); const newer = path.join(tempDir, "openclaw-2026-01-21.log"); await fsPromises.writeFile(older, '{"msg":"old"}\n'); await fsPromises.writeFile(newer, '{"msg":"new"}\n'); await fsPromises.utimes(older, new Date(0), new Date(0)); await fsPromises.utimes(newer, new Date(), new Date()); setLoggerOverride({ file: path.join(tempDir, "openclaw-2026-01-22.log") }); const respond = vi.fn(); await logsHandlers["logs.tail"]({ params: {}, respond, context: {} as unknown as Parameters<(typeof logsHandlers)["logs.tail"]>[0]["context"], client: null, req: { id: "req-1", type: "req", method: "logs.tail" }, isWebchatConnect: logsNoop, }); expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ file: newer, lines: ['{"msg":"new"}'], }), undefined, ); await fsPromises.rm(tempDir, { recursive: true, force: true }); }); });