mirror of https://github.com/openclaw/openclaw.git
test: add exec approvals store helper coverage
This commit is contained in:
parent
5f0e97b22a
commit
8b05cd4074
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue