From 8b05cd40742aa547f09862bf3cd13109bb448ddf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:43:08 +0000 Subject: [PATCH] test: add exec approvals store helper coverage --- src/infra/exec-approvals-store.test.ts | 235 +++++++++++++++++++++++++ src/infra/exec-approvals.test.ts | 56 ------ 2 files changed, 235 insertions(+), 56 deletions(-) create mode 100644 src/infra/exec-approvals-store.test.ts diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts new file mode 100644 index 00000000000..d30b3263129 --- /dev/null +++ b/src/infra/exec-approvals-store.test.ts @@ -0,0 +1,235 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeTempDir } from "./exec-approvals-test-helpers.js"; + +const requestJsonlSocketMock = vi.hoisted(() => vi.fn()); + +vi.mock("./jsonl-socket.js", () => ({ + requestJsonlSocket: (...args: unknown[]) => requestJsonlSocketMock(...args), +})); + +import { + addAllowlistEntry, + ensureExecApprovals, + mergeExecApprovalsSocketDefaults, + normalizeExecApprovals, + readExecApprovalsSnapshot, + recordAllowlistUse, + requestExecApprovalViaSocket, + resolveExecApprovalsPath, + resolveExecApprovalsSocketPath, + type ExecApprovalsFile, +} from "./exec-approvals.js"; + +const tempDirs: string[] = []; +const originalOpenClawHome = process.env.OPENCLAW_HOME; + +beforeEach(() => { + requestJsonlSocketMock.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (originalOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = originalOpenClawHome; + } + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function createHomeDir(): string { + const dir = makeTempDir(); + tempDirs.push(dir); + process.env.OPENCLAW_HOME = dir; + return dir; +} + +function approvalsFilePath(homeDir: string): string { + return path.join(homeDir, ".openclaw", "exec-approvals.json"); +} + +function readApprovalsFile(homeDir: string): ExecApprovalsFile { + return JSON.parse(fs.readFileSync(approvalsFilePath(homeDir), "utf8")) as ExecApprovalsFile; +} + +describe("exec approvals store helpers", () => { + it("expands home-prefixed default file and socket paths", () => { + const dir = createHomeDir(); + + expect(path.normalize(resolveExecApprovalsPath())).toBe( + path.normalize(path.join(dir, ".openclaw", "exec-approvals.json")), + ); + expect(path.normalize(resolveExecApprovalsSocketPath())).toBe( + path.normalize(path.join(dir, ".openclaw", "exec-approvals.sock")), + ); + }); + + it("merges socket defaults from normalized, current, and built-in fallback", () => { + const normalized = normalizeExecApprovals({ + version: 1, + agents: {}, + socket: { path: "/tmp/a.sock", token: "a" }, + }); + const current = normalizeExecApprovals({ + version: 1, + agents: {}, + socket: { path: "/tmp/b.sock", token: "b" }, + }); + + expect(mergeExecApprovalsSocketDefaults({ normalized, current }).socket).toEqual({ + path: "/tmp/a.sock", + token: "a", + }); + + const merged = mergeExecApprovalsSocketDefaults({ + normalized: normalizeExecApprovals({ version: 1, agents: {} }), + current, + }); + expect(merged.socket).toEqual({ + path: "/tmp/b.sock", + token: "b", + }); + + createHomeDir(); + expect( + mergeExecApprovalsSocketDefaults({ + normalized: normalizeExecApprovals({ version: 1, agents: {} }), + }).socket, + ).toEqual({ + path: resolveExecApprovalsSocketPath(), + token: "", + }); + }); + + it("returns normalized empty snapshots for missing and invalid approvals files", () => { + const dir = createHomeDir(); + + const missing = readExecApprovalsSnapshot(); + expect(missing.exists).toBe(false); + expect(missing.raw).toBeNull(); + expect(missing.file).toEqual(normalizeExecApprovals({ version: 1, agents: {} })); + expect(missing.path).toBe(approvalsFilePath(dir)); + + fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); + fs.writeFileSync(approvalsFilePath(dir), "{invalid", "utf8"); + + const invalid = readExecApprovalsSnapshot(); + expect(invalid.exists).toBe(true); + expect(invalid.raw).toBe("{invalid"); + expect(invalid.file).toEqual(normalizeExecApprovals({ version: 1, agents: {} })); + }); + + it("ensures approvals file with default socket path and generated token", () => { + const dir = createHomeDir(); + + const ensured = ensureExecApprovals(); + const raw = fs.readFileSync(approvalsFilePath(dir), "utf8"); + + expect(ensured.socket?.path).toBe(resolveExecApprovalsSocketPath()); + expect(ensured.socket?.token).toMatch(/^[A-Za-z0-9_-]{32}$/); + expect(raw.endsWith("\n")).toBe(true); + expect(readApprovalsFile(dir).socket).toEqual(ensured.socket); + }); + + it("adds trimmed allowlist entries once and persists generated ids", () => { + const dir = createHomeDir(); + vi.spyOn(Date, "now").mockReturnValue(123_456); + + const approvals = ensureExecApprovals(); + addAllowlistEntry(approvals, "worker", " /usr/bin/rg "); + addAllowlistEntry(approvals, "worker", "/usr/bin/rg"); + addAllowlistEntry(approvals, "worker", " "); + + expect(readApprovalsFile(dir).agents?.worker?.allowlist).toEqual([ + expect.objectContaining({ + pattern: "/usr/bin/rg", + lastUsedAt: 123_456, + }), + ]); + expect(readApprovalsFile(dir).agents?.worker?.allowlist?.[0]?.id).toMatch(/^[0-9a-f-]{36}$/i); + }); + + it("records allowlist usage on the matching entry and backfills missing ids", () => { + const dir = createHomeDir(); + vi.spyOn(Date, "now").mockReturnValue(999_000); + + const approvals: ExecApprovalsFile = { + version: 1, + agents: { + main: { + allowlist: [{ pattern: "/usr/bin/rg" }, { pattern: "/usr/bin/jq", id: "keep-id" }], + }, + }, + }; + fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); + fs.writeFileSync(approvalsFilePath(dir), JSON.stringify(approvals, null, 2), "utf8"); + + recordAllowlistUse( + approvals, + undefined, + { pattern: "/usr/bin/rg" }, + "rg needle", + "/opt/homebrew/bin/rg", + ); + + expect(readApprovalsFile(dir).agents?.main?.allowlist).toEqual([ + expect.objectContaining({ + pattern: "/usr/bin/rg", + lastUsedAt: 999_000, + lastUsedCommand: "rg needle", + lastResolvedPath: "/opt/homebrew/bin/rg", + }), + { pattern: "/usr/bin/jq", id: "keep-id" }, + ]); + expect(readApprovalsFile(dir).agents?.main?.allowlist?.[0]?.id).toMatch(/^[0-9a-f-]{36}$/i); + }); + + it("returns null when approval socket credentials are missing", async () => { + await expect( + requestExecApprovalViaSocket({ + socketPath: "", + token: "secret", + request: { command: "echo hi" }, + }), + ).resolves.toBeNull(); + await expect( + requestExecApprovalViaSocket({ + socketPath: "/tmp/socket", + token: "", + request: { command: "echo hi" }, + }), + ).resolves.toBeNull(); + expect(requestJsonlSocketMock).not.toHaveBeenCalled(); + }); + + it("builds approval socket payloads and accepts decision responses only", async () => { + requestJsonlSocketMock.mockImplementationOnce(async ({ payload, accept, timeoutMs }) => { + expect(timeoutMs).toBe(15_000); + const parsed = JSON.parse(payload) as { + type: string; + token: string; + id: string; + request: { command: string }; + }; + expect(parsed.type).toBe("request"); + expect(parsed.token).toBe("secret"); + expect(parsed.request).toEqual({ command: "echo hi" }); + expect(parsed.id).toMatch(/^[0-9a-f-]{36}$/i); + expect(accept({ type: "noop", decision: "allow-once" })).toBeUndefined(); + expect(accept({ type: "decision", decision: "allow-always" })).toBe("allow-always"); + return "deny"; + }); + + await expect( + requestExecApprovalViaSocket({ + socketPath: "/tmp/socket", + token: "secret", + request: { command: "echo hi" }, + }), + ).resolves.toBe("deny"); + }); +}); diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 34a265bd4e0..9edd3f3909c 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -10,67 +10,11 @@ import { evaluateExecAllowlist, evaluateShellAllowlist, maxAsk, - mergeExecApprovalsSocketDefaults, minSecurity, - normalizeExecApprovals, normalizeSafeBins, requiresExecApproval, - resolveExecApprovalsPath, - resolveExecApprovalsSocketPath, } from "./exec-approvals.js"; -describe("mergeExecApprovalsSocketDefaults", () => { - it("prefers normalized socket, then current, then default path", () => { - const normalized = normalizeExecApprovals({ - version: 1, - agents: {}, - socket: { path: "/tmp/a.sock", token: "a" }, - }); - const current = normalizeExecApprovals({ - version: 1, - agents: {}, - socket: { path: "/tmp/b.sock", token: "b" }, - }); - const merged = mergeExecApprovalsSocketDefaults({ normalized, current }); - expect(merged.socket?.path).toBe("/tmp/a.sock"); - expect(merged.socket?.token).toBe("a"); - }); - - it("falls back to current token when missing in normalized", () => { - const normalized = normalizeExecApprovals({ version: 1, agents: {} }); - const current = normalizeExecApprovals({ - version: 1, - agents: {}, - socket: { path: "/tmp/b.sock", token: "b" }, - }); - const merged = mergeExecApprovalsSocketDefaults({ normalized, current }); - expect(merged.socket?.path).toBeTruthy(); - expect(merged.socket?.token).toBe("b"); - }); -}); - -describe("resolve exec approvals defaults", () => { - it("expands home-prefixed default file and socket paths", () => { - const dir = makeTempDir(); - const prevOpenClawHome = process.env.OPENCLAW_HOME; - try { - process.env.OPENCLAW_HOME = dir; - expect(path.normalize(resolveExecApprovalsPath())).toBe( - path.normalize(path.join(dir, ".openclaw", "exec-approvals.json")), - ); - expect(path.normalize(resolveExecApprovalsSocketPath())).toBe( - path.normalize(path.join(dir, ".openclaw", "exec-approvals.sock")), - ); - } finally { - if (prevOpenClawHome === undefined) { - delete process.env.OPENCLAW_HOME; - } else { - process.env.OPENCLAW_HOME = prevOpenClawHome; - } - } - }); -}); - describe("exec approvals safe shell command builder", () => { it("quotes only safeBins segments (leaves other segments untouched)", () => { if (process.platform === "win32") {