/** Tests node-host invoke command routing and event emission. */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { GatewayClient } from "../gateway/client.js"; import { saveExecApprovals, type ExecApprovalsSnapshot } from "../infra/exec-approvals.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withEnvAsync } from "../test-utils/env.js"; import type { SkillBinsProvider } from "./invoke-types.js"; import { handleInvoke } from "./invoke.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); afterEach(() => { resetPluginRuntimeStateForTest(); }); const approvalResolutionFailure = vi.hoisted(() => ({ error: null as Error | null })); type ExecApprovalsUpdate = Parameters< typeof import("../infra/exec-approvals.js").updateExecApprovals >[0]; const execApprovalsStoreMock = vi.hoisted(() => ({ ensureError: undefined as Error | undefined, ensureResult: undefined as unknown, hasEnsureResult: false, ensureCalls: 0, readError: undefined as Error | undefined, readResult: undefined as unknown, hasReadResult: false, readCalls: 0, updateError: undefined as Error | undefined, updateResult: undefined as unknown, hasUpdateResult: false, updateCalls: 0, updateParams: undefined as ExecApprovalsUpdate | undefined, })); vi.mock("../infra/exec-approvals.js", async (importOriginal) => { const original = await importOriginal(); return { ...original, ensureExecApprovalsSnapshot: async () => { execApprovalsStoreMock.ensureCalls += 1; if (execApprovalsStoreMock.ensureError !== undefined) { throw execApprovalsStoreMock.ensureError; } if (execApprovalsStoreMock.hasEnsureResult) { return execApprovalsStoreMock.ensureResult as Awaited< ReturnType >; } return await original.ensureExecApprovalsSnapshot(); }, readExecApprovalsSnapshot: () => { execApprovalsStoreMock.readCalls += 1; if (execApprovalsStoreMock.readError !== undefined) { throw execApprovalsStoreMock.readError; } if (execApprovalsStoreMock.hasReadResult) { return execApprovalsStoreMock.readResult as ReturnType< typeof original.readExecApprovalsSnapshot >; } return original.readExecApprovalsSnapshot(); }, updateExecApprovals: async (...args: Parameters) => { execApprovalsStoreMock.updateCalls += 1; execApprovalsStoreMock.updateParams = args[0]; if (execApprovalsStoreMock.updateError !== undefined) { throw execApprovalsStoreMock.updateError; } if (execApprovalsStoreMock.hasUpdateResult) { return execApprovalsStoreMock.updateResult as Awaited< ReturnType >; } return await original.updateExecApprovals(...args); }, resolveExecApprovalsLocked: async ( ...args: Parameters ) => { const error = approvalResolutionFailure.error; approvalResolutionFailure.error = null; if (error) { throw error; } return await original.resolveExecApprovalsLocked(...args); }, }; }); vi.mock("../logger.js", async (importOriginal) => ({ ...(await importOriginal()), logWarn: vi.fn(), })); function createExecApprovalsSnapshot( overrides: Partial = {}, ): ExecApprovalsSnapshot { return { path: "/tmp/exec-approvals.json", exists: true, raw: "{}", hash: "hash-before", file: { version: 1, socket: { path: "/tmp/exec-approvals.sock", token: "secret-token", }, }, ...overrides, }; } type InvokeResult = { ok?: boolean; payloadJSON?: string; error?: { code?: string; message?: string }; }; async function invokeExecApprovals( command: "system.execApprovals.get" | "system.execApprovals.set", params?: unknown, ): Promise { const request = vi.fn().mockResolvedValue(null); await handleInvoke( { id: `invoke-${command}`, nodeId: "node-1", command, paramsJSON: params === undefined ? undefined : JSON.stringify(params), }, { request } as unknown as GatewayClient, { current: async () => [] }, ); return (request.mock.calls[0]?.[1] ?? {}) as InvokeResult; } describe("node host invoke", () => { beforeEach(() => { approvalResolutionFailure.error = null; execApprovalsStoreMock.ensureError = undefined; execApprovalsStoreMock.ensureResult = undefined; execApprovalsStoreMock.hasEnsureResult = false; execApprovalsStoreMock.ensureCalls = 0; execApprovalsStoreMock.readError = undefined; execApprovalsStoreMock.readResult = undefined; execApprovalsStoreMock.hasReadResult = false; execApprovalsStoreMock.readCalls = 0; execApprovalsStoreMock.updateError = undefined; execApprovalsStoreMock.updateResult = undefined; execApprovalsStoreMock.hasUpdateResult = false; execApprovalsStoreMock.updateCalls = 0; execApprovalsStoreMock.updateParams = undefined; }); it("passes the owning agent session to plugin node commands", async () => { const handle = vi.fn(async () => '{"ok":true}'); const registry = createEmptyPluginRegistry(); registry.nodeHostCommands = [ { pluginId: "canvas", pluginName: "Canvas", command: { command: "canvas.present", cap: "canvas", handle }, source: "test", }, ]; setActivePluginRegistry(registry); const request = vi.fn().mockResolvedValue(null); const sendNodeEvent = vi.fn(async () => undefined); await handleInvoke( { id: "invoke-canvas", nodeId: "node-1", command: "canvas.present", paramsJSON: "{}", sessionKey: "agent:main:canvas", }, { request } as unknown as GatewayClient, { current: async () => [] }, undefined, { pluginCommandContext: { sendNodeEvent } }, ); expect(handle).toHaveBeenCalledWith("{}", undefined, { sendNodeEvent, sessionKey: "agent:main:canvas", }); }); it("does not publish a canceled non-duplex plugin result", async () => { const controller = new AbortController(); let resolvePlugin: ((result: string) => void) | undefined; const handle = vi.fn( () => new Promise((resolve) => { resolvePlugin = resolve; }), ); const registry = createEmptyPluginRegistry(); registry.nodeHostCommands = [ { pluginId: "canvas", pluginName: "Canvas", command: { command: "canvas.present", cap: "canvas", handle }, source: "test", }, ]; setActivePluginRegistry(registry); const request = vi.fn().mockResolvedValue(null); const invoking = handleInvoke( { id: "invoke-canvas-canceled", nodeId: "node-1", command: "canvas.present", paramsJSON: "{}", }, { request } as unknown as GatewayClient, { current: async () => [] }, undefined, { signal: controller.signal }, ); await vi.waitFor(() => expect(handle).toHaveBeenCalledOnce()); controller.abort(); resolvePlugin?.('{"stale":true}'); await invoking; expect(request).not.toHaveBeenCalled(); }); it("publishes only the replacement result for a redelivered plugin invocation", async () => { const firstController = new AbortController(); const secondController = new AbortController(); const resolvePlugins: Array<(result: string) => void> = []; const handle = vi.fn( () => new Promise((resolve) => { resolvePlugins.push(resolve); }), ); const registry = createEmptyPluginRegistry(); registry.nodeHostCommands = [ { pluginId: "canvas", pluginName: "Canvas", command: { command: "canvas.present", cap: "canvas", handle }, source: "test", }, ]; setActivePluginRegistry(registry); const request = vi.fn().mockResolvedValue(null); const client = { request } as unknown as GatewayClient; const skillBins: SkillBinsProvider = { current: async () => [] }; const frame = { id: "invoke-canvas-redelivered", nodeId: "node-1", command: "canvas.present", paramsJSON: "{}", }; const first = handleInvoke(frame, client, skillBins, undefined, { signal: firstController.signal, }); await vi.waitFor(() => expect(handle).toHaveBeenCalledOnce()); firstController.abort(); const replacement = handleInvoke(frame, client, skillBins, undefined, { signal: secondController.signal, }); await vi.waitFor(() => expect(handle).toHaveBeenCalledTimes(2)); resolvePlugins[0]?.('{"stale":true}'); await first; expect(request).not.toHaveBeenCalled(); resolvePlugins[1]?.('{"replacement":true}'); await replacement; expect(request).toHaveBeenCalledOnce(); expect(request).toHaveBeenCalledWith( "node.invoke.result", expect.objectContaining({ id: frame.id, nodeId: frame.nodeId, ok: true, payloadJSON: '{"replacement":true}', }), ); expect(secondController.signal.aborted).toBe(false); }); it("lists node-host directories for the folder browser", async () => { const root = fs.realpathSync(tempDirs.make("openclaw-node-fs-listdir-")); fs.mkdirSync(path.join(root, "Projects")); fs.writeFileSync(path.join(root, "notes.txt"), "hidden from directory listing"); const request = vi.fn().mockResolvedValue(null); await handleInvoke( { id: "invoke-fs-listdir", nodeId: "node-1", command: "fs.listDir", paramsJSON: JSON.stringify({ path: root }), }, { request } as unknown as GatewayClient, { current: async () => [] }, ); const result = request.mock.calls[0]?.[1] as InvokeResult | undefined; expect(JSON.parse(result?.payloadJSON ?? "{}")).toMatchObject({ path: root, entries: [{ name: "Projects", path: path.join(root, "Projects") }], }); }); it("stages terminal uploads on the node host", async () => { const request = vi.fn().mockResolvedValue(null); await handleInvoke( { id: "invoke-terminal-upload", nodeId: "node-1", command: "terminal.upload", paramsJSON: JSON.stringify({ name: "node report.pdf", contentBase64: Buffer.from("node bytes").toString("base64"), }), }, { request } as unknown as GatewayClient, { current: async () => [] }, ); const result = request.mock.calls[0]?.[1] as InvokeResult | undefined; const payload = JSON.parse(result?.payloadJSON ?? "{}") as { path: string; size: number }; expect(payload.size).toBe(10); expect(fs.readFileSync(payload.path, "utf8")).toBe("node bytes"); fs.rmSync(path.dirname(payload.path), { recursive: true, force: true }); }); it("returns a redacted exec approvals snapshot", async () => { execApprovalsStoreMock.hasEnsureResult = true; execApprovalsStoreMock.ensureResult = createExecApprovalsSnapshot(); const result = await invokeExecApprovals("system.execApprovals.get"); const payload = JSON.parse(result.payloadJSON ?? "{}") as ExecApprovalsSnapshot; expect(payload).toEqual({ path: "/tmp/exec-approvals.json", exists: true, hash: "hash-before", file: { version: 1, socket: { path: "/tmp/exec-approvals.sock" }, }, }); }); it("updates exec approvals and redacts the resulting snapshot", async () => { execApprovalsStoreMock.hasReadResult = true; execApprovalsStoreMock.readResult = createExecApprovalsSnapshot(); execApprovalsStoreMock.hasUpdateResult = true; execApprovalsStoreMock.updateResult = createExecApprovalsSnapshot({ hash: "hash-after", file: { version: 1, defaults: { security: "deny" }, socket: { path: "/tmp/updated.sock", token: "updated-secret" }, }, }); const result = await invokeExecApprovals("system.execApprovals.set", { baseHash: "hash-before", file: { version: 1, defaults: { security: "deny" } }, }); expect(execApprovalsStoreMock.updateCalls).toBe(1); expect(execApprovalsStoreMock.ensureCalls).toBe(0); expect(execApprovalsStoreMock.readCalls).toBe(1); expect(JSON.parse(result.payloadJSON ?? "{}")).toEqual({ path: "/tmp/exec-approvals.json", exists: true, hash: "hash-after", file: { version: 1, defaults: { security: "deny" }, socket: { path: "/tmp/updated.sock" }, }, }); }); it("rejects an exec approvals update with a stale base hash", async () => { execApprovalsStoreMock.hasReadResult = true; execApprovalsStoreMock.readResult = createExecApprovalsSnapshot(); const result = await invokeExecApprovals("system.execApprovals.set", { baseHash: "stale-hash", file: { version: 1 }, }); expect(execApprovalsStoreMock.updateCalls).toBe(0); expect(result).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST", message: expect.stringContaining("exec approvals changed"), }, }); }); it("rejects a stale save without recreating deleted node approval state", async () => { const missingSnapshot = createExecApprovalsSnapshot({ exists: false, raw: null, hash: "sha256:missing", file: { version: 1, agents: {} }, }); execApprovalsStoreMock.hasReadResult = true; execApprovalsStoreMock.readResult = missingSnapshot; const result = await invokeExecApprovals("system.execApprovals.set", { baseHash: "hash-before", file: { version: 1, agents: {} }, }); expect(execApprovalsStoreMock.ensureCalls).toBe(0); expect(execApprovalsStoreMock.readCalls).toBe(1); expect(execApprovalsStoreMock.updateCalls).toBe(0); expect(missingSnapshot.file.socket).toBeUndefined(); expect(result).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST", message: expect.stringContaining("exec approvals changed"), }, }); }); it("initializes socket credentials in the first accepted node approval write", async () => { const missingSnapshot = createExecApprovalsSnapshot({ exists: false, raw: null, hash: "sha256:missing", file: { version: 1, agents: {} }, }); execApprovalsStoreMock.hasReadResult = true; execApprovalsStoreMock.readResult = missingSnapshot; execApprovalsStoreMock.hasUpdateResult = true; execApprovalsStoreMock.updateResult = createExecApprovalsSnapshot({ hash: "sha256:created" }); const result = await invokeExecApprovals("system.execApprovals.set", { file: { version: 1, agents: { main: {} } }, }); const prepared = execApprovalsStoreMock.updateParams?.update(missingSnapshot.file); expect(execApprovalsStoreMock.ensureCalls).toBe(0); expect(execApprovalsStoreMock.readCalls).toBe(1); expect(execApprovalsStoreMock.updateCalls).toBe(1); expect(execApprovalsStoreMock.updateParams?.baseHash).toBe(missingSnapshot.hash); expect(prepared?.socket?.path).toBeTruthy(); expect(prepared?.socket?.token).toMatch(/^[A-Za-z0-9_-]{32}$/); expect(JSON.parse(result.payloadJSON ?? "{}")).toMatchObject({ hash: "sha256:created", file: { socket: { path: "/tmp/exec-approvals.sock" } }, }); }); it("rejects an exec approvals update when the locked CAS loses its race", async () => { execApprovalsStoreMock.hasReadResult = true; execApprovalsStoreMock.readResult = createExecApprovalsSnapshot(); execApprovalsStoreMock.hasUpdateResult = true; execApprovalsStoreMock.updateResult = null; const result = await invokeExecApprovals("system.execApprovals.set", { baseHash: "hash-before", file: { version: 1 }, }); expect(execApprovalsStoreMock.updateCalls).toBe(1); expect(result).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST", message: "INVALID_REQUEST: exec approvals changed; reload and retry", }, }); }); it("classifies typed exec approvals lock timeouts as TIMEOUT", async () => { execApprovalsStoreMock.ensureError = Object.assign(new Error("approval lock unavailable"), { code: "file_lock_timeout", }); const result = await invokeExecApprovals("system.execApprovals.get"); expect(result).toMatchObject({ ok: false, error: { code: "TIMEOUT", message: "Error: approval lock unavailable", }, }); }); it("classifies stale exec approval locks as UNAVAILABLE", async () => { execApprovalsStoreMock.ensureError = Object.assign(new Error("stale approval lock"), { code: "file_lock_stale", }); const result = await invokeExecApprovals("system.execApprovals.get"); expect(result).toMatchObject({ ok: false, error: { code: "UNAVAILABLE", message: "Error: stale approval lock", }, }); }); it("classifies exec approvals filesystem failures as UNAVAILABLE", async () => { execApprovalsStoreMock.readError = Object.assign(new Error("permission denied"), { code: "EACCES", }); const result = await invokeExecApprovals("system.execApprovals.set", { file: { version: 1 }, }); expect(result).toMatchObject({ ok: false, error: { code: "UNAVAILABLE", message: "Error: permission denied", }, }); }); it("classifies exec approvals update failures as UNAVAILABLE", async () => { execApprovalsStoreMock.hasReadResult = true; execApprovalsStoreMock.readResult = createExecApprovalsSnapshot(); execApprovalsStoreMock.updateError = new Error("approval store write failed"); const result = await invokeExecApprovals("system.execApprovals.set", { baseHash: "hash-before", file: { version: 1 }, }); expect(result).toMatchObject({ ok: false, error: { code: "UNAVAILABLE", message: "Error: approval store write failed", }, }); }); it("classifies malformed exec approvals set payloads as INVALID_REQUEST", async () => { const result = await invokeExecApprovals("system.execApprovals.set", {}); expect(result).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST" }, }); }); it.runIf(process.platform !== "win32")( "reports current allow-always coverage for prepared shell-wrapped system.run commands", async () => { const request = vi.fn().mockResolvedValue(null); const skillBins: SkillBinsProvider = { current: async () => [] }; await handleInvoke( { id: "invoke-prepare", nodeId: "node-1", command: "system.run.prepare", paramsJSON: JSON.stringify({ command: ["/bin/sh", "-lc", "/bin/echo ok"], rawCommand: "/bin/echo ok", }), }, { request } as unknown as GatewayClient, skillBins, ); const result = request.mock.calls[0]?.[1] as { payloadJSON?: string } | undefined; const payload = JSON.parse(result?.payloadJSON ?? "{}") as { allowAlwaysCoverage?: { complete?: boolean; patterns?: Array<{ pattern?: string }>; }; }; expect(payload.allowAlwaysCoverage?.complete).toBe(true); expect(payload.allowAlwaysCoverage?.patterns?.[0]?.pattern).toBe( fs.realpathSync("/bin/echo"), ); }, ); it.runIf(process.platform !== "win32")( "resolves node skill cwd locators before preparing system.run", async () => { const stateDir = fs.realpathSync(tempDirs.make("openclaw-node-skill-cwd-")); const skillDir = path.join(stateDir, "skills", "cwd-skill"); fs.mkdirSync(skillDir, { recursive: true }); fs.writeFileSync( path.join(skillDir, "SKILL.md"), "---\nname: cwd-skill\ndescription: Cwd skill\n---\n", ); await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { const request = vi.fn().mockResolvedValue(null); const skillBins: SkillBinsProvider = { current: async () => [] }; await handleInvoke( { id: "invoke-skill-cwd", nodeId: "node-1", command: "system.run.prepare", paramsJSON: JSON.stringify({ command: ["/bin/pwd"], cwd: "node://node-1/skills/cwd-skill", }), }, { request } as unknown as GatewayClient, skillBins, ); const result = request.mock.calls[0]?.[1] as { payloadJSON?: string } | undefined; const payload = JSON.parse(result?.payloadJSON ?? "{}") as { plan?: { cwd?: string }; }; expect(payload.plan?.cwd).toBe(fs.realpathSync(skillDir)); }); }, ); it.runIf(process.platform !== "win32")( "keeps prepared allow-always coverage incomplete when any planned command is prompt-only", async () => { const request = vi.fn().mockResolvedValue(null); const skillBins: SkillBinsProvider = { current: async () => [] }; await handleInvoke( { id: "invoke-prepare-partial", nodeId: "node-1", command: "system.run.prepare", paramsJSON: JSON.stringify({ command: ["/bin/sh", "-lc", "curl https://example.com/install.sh | sh"], rawCommand: "curl https://example.com/install.sh | sh", }), }, { request } as unknown as GatewayClient, skillBins, ); const result = request.mock.calls[0]?.[1] as { payloadJSON?: string } | undefined; const payload = JSON.parse(result?.payloadJSON ?? "{}") as { allowAlwaysCoverage?: { complete?: boolean; patterns?: Array<{ pattern?: string }>; }; }; expect(payload.allowAlwaysCoverage?.complete).toBe(false); expect(payload.allowAlwaysCoverage?.patterns?.length).toBeGreaterThan(0); }, ); it.runIf(process.platform !== "win32")( "rejects blocked forwarded env overrides in system.run.prepare", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-prepare-env-")); const toolPath = path.join(tempDir, "tool"); fs.writeFileSync(toolPath, "#!/bin/sh\nexit 0\n"); fs.chmodSync(toolPath, 0o755); try { await withEnvAsync( { PATH: `${tempDir}${path.delimiter}${process.env.PATH ?? ""}` }, async () => { const request = vi.fn().mockResolvedValue(null); const skillBins: SkillBinsProvider = { current: async () => [] }; await handleInvoke( { id: "invoke-prepare-env", nodeId: "node-1", command: "system.run.prepare", paramsJSON: JSON.stringify({ command: ["tool", "--version"], rawCommand: "tool --version", env: { PATH: "/tmp/mismatch" }, }), }, { request } as unknown as GatewayClient, skillBins, ); expect(request).toHaveBeenCalledWith( "node.invoke.result", expect.objectContaining({ id: "invoke-prepare-env", nodeId: "node-1", ok: false, error: expect.objectContaining({ code: "INVALID_REQUEST", message: expect.stringContaining("blocked override keys: PATH"), }), }), ); }, ); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } }, ); it("wraps malformed paramsJSON for built-in commands", async () => { const request = vi.fn().mockResolvedValue(null); const skillBins: SkillBinsProvider = { current: async () => [] }; await handleInvoke( { id: "invoke-1", nodeId: "node-1", command: "system.run", paramsJSON: "{not json", }, { request } as unknown as GatewayClient, skillBins, ); expect(request).toHaveBeenCalledWith( "node.invoke.result", expect.objectContaining({ id: "invoke-1", nodeId: "node-1", ok: false, error: expect.objectContaining({ code: "INVALID_REQUEST", message: expect.stringContaining("paramsJSON malformed JSON"), }), }), ); }); it("returns a structured failure when system.run approval resolution rejects", async () => { const request = vi.fn().mockResolvedValue(null); const skillBins: SkillBinsProvider = { current: async () => [] }; approvalResolutionFailure.error = new Error("approval lock unavailable"); await expect( handleInvoke( { id: "invoke-approval-read-failure", nodeId: "node-1", command: "system.run", paramsJSON: JSON.stringify({ command: ["echo", "ok"] }), }, { request } as unknown as GatewayClient, skillBins, ), ).resolves.toBeUndefined(); expect(request).toHaveBeenCalledTimes(1); expect(request).toHaveBeenCalledWith( "node.invoke.result", expect.objectContaining({ id: "invoke-approval-read-failure", nodeId: "node-1", ok: false, error: { code: "UNAVAILABLE", message: "node invocation failed", }, }), ); }); it("forwards suppressNotifyOnExit on completed system.run events", async () => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-event-suppress-")); const stateDir = path.join(tempHome, ".openclaw"); try { await withEnvAsync({ OPENCLAW_HOME: tempHome, OPENCLAW_STATE_DIR: stateDir }, async () => { saveExecApprovals({ version: 1, defaults: { security: "allowlist", ask: "on-miss", askFallback: "deny" }, }); const scriptPath = path.join(tempHome, "noop.cjs"); fs.writeFileSync(scriptPath, ""); const request = vi.fn().mockResolvedValue(null); await handleInvoke( { id: "invoke-suppress-notify-prepare", nodeId: "node-1", command: "system.run.prepare", paramsJSON: JSON.stringify({ command: [process.execPath, scriptPath], cwd: tempHome, sessionKey: "agent:main:main", }), }, { request } as unknown as GatewayClient, { current: async () => [] }, ); const prepareResult = request.mock.calls.find( ([method, params]) => method === "node.invoke.result" && (params as { id?: string } | undefined)?.id === "invoke-suppress-notify-prepare", )?.[1] as { payloadJSON?: string | null } | undefined; const prepared = JSON.parse(prepareResult?.payloadJSON ?? "{}") as { plan?: Record; }; expect(prepared.plan).toBeDefined(); await handleInvoke( { id: "invoke-suppress-notify", nodeId: "node-1", command: "system.run", paramsJSON: JSON.stringify({ command: prepared.plan?.argv, rawCommand: prepared.plan?.commandText, cwd: prepared.plan?.cwd, sessionKey: "agent:main:main", systemRunPlan: prepared.plan, approved: true, approvalDecision: "allow-once", suppressNotifyOnExit: true, }), }, { request } as unknown as GatewayClient, { current: async () => [] }, ); const event = request.mock.calls.find( ([method, params]) => method === "node.event" && (params as { event?: string } | undefined)?.event === "exec.finished", )?.[1] as { payloadJSON?: string | null } | undefined; expect(JSON.parse(event?.payloadJSON ?? "{}")).toMatchObject({ suppressNotifyOnExit: true, }); }); } finally { closeOpenClawStateDatabaseForTest(); fs.rmSync(tempHome, { recursive: true, force: true }); } }); it("consumes a failed terminal response after approval resolution rejects", async () => { const request = vi.fn().mockRejectedValue(new Error("gateway down")); const skillBins: SkillBinsProvider = { current: async () => [] }; approvalResolutionFailure.error = new Error("approval lock unavailable"); await expect( handleInvoke( { id: "invoke-approval-read-and-send-failure", nodeId: "node-1", command: "system.run", paramsJSON: JSON.stringify({ command: ["echo", "ok"] }), }, { request } as unknown as GatewayClient, skillBins, ), ).resolves.toBeUndefined(); expect(request).toHaveBeenCalledTimes(1); }); it("includes effective exec policy in system.run.prepare responses", async () => { const request = vi.fn().mockResolvedValue(null); const skillBins: SkillBinsProvider = { current: async () => [] }; await handleInvoke( { id: "invoke-1", nodeId: "node-1", command: "system.run.prepare", paramsJSON: JSON.stringify({ command: ["echo", "ok"], rawCommand: "echo ok", agentId: "main", sessionKey: "agent:main:main", }), }, { request } as unknown as GatewayClient, skillBins, ); expect(request).toHaveBeenCalledWith( "node.invoke.result", expect.objectContaining({ ok: true, payloadJSON: expect.any(String), }), ); const result = request.mock.calls.find(([method]) => method === "node.invoke.result")?.[1] as { payloadJSON?: string; }; const payload = JSON.parse(result.payloadJSON ?? "{}") as { execPolicy?: { security?: string; ask?: string }; plan?: { policySnapshot?: unknown }; }; expect(payload.execPolicy).toEqual({ security: "allowlist", ask: "on-miss" }); // The plan snapshot binds persisted approval state. Effective config-layer // policy is returned separately above and re-evaluated at execution. expect(payload.plan?.policySnapshot).toEqual({ security: "full", ask: "off", askFallback: "deny", autoAllowSkills: false, allowlistRules: [], }); }); });