* feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * fix(exec): await approval registration before returning approval-pending Ensures the approval ID is registered in the gateway before the tool returns. Uses exec.approval.request with expectFinal:false for registration, then fire-and-forget exec.approval.waitDecision for the decision phase. Fixes #2402 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
192 lines
6.4 KiB
TypeScript
192 lines
6.4 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("./tools/gateway.js", () => ({
|
|
callGatewayTool: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./tools/nodes-utils.js", () => ({
|
|
listNodes: vi.fn(async () => [
|
|
{ nodeId: "node-1", commands: ["system.run"], platform: "darwin" },
|
|
]),
|
|
resolveNodeIdFromList: vi.fn((nodes: Array<{ nodeId: string }>) => nodes[0]?.nodeId),
|
|
}));
|
|
|
|
describe("exec approvals", () => {
|
|
let previousHome: string | undefined;
|
|
let previousUserProfile: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
previousHome = process.env.HOME;
|
|
previousUserProfile = process.env.USERPROFILE;
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-"));
|
|
process.env.HOME = tempDir;
|
|
// Windows uses USERPROFILE for os.homedir()
|
|
process.env.USERPROFILE = tempDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.resetAllMocks();
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME;
|
|
} else {
|
|
process.env.HOME = previousHome;
|
|
}
|
|
if (previousUserProfile === undefined) {
|
|
delete process.env.USERPROFILE;
|
|
} else {
|
|
process.env.USERPROFILE = previousUserProfile;
|
|
}
|
|
});
|
|
|
|
it("reuses approval id as the node runId", async () => {
|
|
const { callGatewayTool } = await import("./tools/gateway.js");
|
|
let invokeParams: unknown;
|
|
let resolveInvoke: (() => void) | undefined;
|
|
const invokeSeen = new Promise<void>((resolve) => {
|
|
resolveInvoke = resolve;
|
|
});
|
|
|
|
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
|
if (method === "exec.approval.request") {
|
|
// Return registration confirmation (status: "accepted")
|
|
return { status: "accepted", id: (params as { id?: string })?.id };
|
|
}
|
|
if (method === "exec.approval.waitDecision") {
|
|
// Return the decision when waitDecision is called
|
|
return { decision: "allow-once" };
|
|
}
|
|
if (method === "node.invoke") {
|
|
invokeParams = params;
|
|
resolveInvoke?.();
|
|
return { ok: true };
|
|
}
|
|
return { ok: true };
|
|
});
|
|
|
|
const { createExecTool } = await import("./bash-tools.exec.js");
|
|
const tool = createExecTool({
|
|
host: "node",
|
|
ask: "always",
|
|
approvalRunningNoticeMs: 0,
|
|
});
|
|
|
|
const result = await tool.execute("call1", { command: "ls -la" });
|
|
expect(result.details.status).toBe("approval-pending");
|
|
const approvalId = (result.details as { approvalId: string }).approvalId;
|
|
|
|
await invokeSeen;
|
|
|
|
const runId = (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId;
|
|
expect(runId).toBe(approvalId);
|
|
});
|
|
|
|
it("skips approval when node allowlist is satisfied", async () => {
|
|
const { callGatewayTool } = await import("./tools/gateway.js");
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-bin-"));
|
|
const binDir = path.join(tempDir, "bin");
|
|
await fs.mkdir(binDir, { recursive: true });
|
|
const exeName = process.platform === "win32" ? "tool.cmd" : "tool";
|
|
const exePath = path.join(binDir, exeName);
|
|
await fs.writeFile(exePath, "");
|
|
if (process.platform !== "win32") {
|
|
await fs.chmod(exePath, 0o755);
|
|
}
|
|
const approvalsFile = {
|
|
version: 1,
|
|
defaults: { security: "allowlist", ask: "on-miss", askFallback: "deny" },
|
|
agents: {
|
|
main: {
|
|
allowlist: [{ pattern: exePath }],
|
|
},
|
|
},
|
|
};
|
|
|
|
const calls: string[] = [];
|
|
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
|
calls.push(method);
|
|
if (method === "exec.approvals.node.get") {
|
|
return { file: approvalsFile };
|
|
}
|
|
if (method === "node.invoke") {
|
|
return { payload: { success: true, stdout: "ok" } };
|
|
}
|
|
// exec.approval.request should NOT be called when allowlist is satisfied
|
|
return { ok: true };
|
|
});
|
|
|
|
const { createExecTool } = await import("./bash-tools.exec.js");
|
|
const tool = createExecTool({
|
|
host: "node",
|
|
ask: "on-miss",
|
|
approvalRunningNoticeMs: 0,
|
|
});
|
|
|
|
const result = await tool.execute("call2", {
|
|
command: `"${exePath}" --help`,
|
|
});
|
|
expect(result.details.status).toBe("completed");
|
|
expect(calls).toContain("exec.approvals.node.get");
|
|
expect(calls).toContain("node.invoke");
|
|
expect(calls).not.toContain("exec.approval.request");
|
|
});
|
|
|
|
it("honors ask=off for elevated gateway exec without prompting", async () => {
|
|
const { callGatewayTool } = await import("./tools/gateway.js");
|
|
const calls: string[] = [];
|
|
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
|
calls.push(method);
|
|
return { ok: true };
|
|
});
|
|
|
|
const { createExecTool } = await import("./bash-tools.exec.js");
|
|
const tool = createExecTool({
|
|
ask: "off",
|
|
security: "full",
|
|
approvalRunningNoticeMs: 0,
|
|
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
|
|
});
|
|
|
|
const result = await tool.execute("call3", { command: "echo ok", elevated: true });
|
|
expect(result.details.status).toBe("completed");
|
|
expect(calls).not.toContain("exec.approval.request");
|
|
});
|
|
|
|
it("requires approval for elevated ask when allowlist misses", async () => {
|
|
const { callGatewayTool } = await import("./tools/gateway.js");
|
|
const calls: string[] = [];
|
|
let resolveApproval: (() => void) | undefined;
|
|
const approvalSeen = new Promise<void>((resolve) => {
|
|
resolveApproval = resolve;
|
|
});
|
|
|
|
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
|
calls.push(method);
|
|
if (method === "exec.approval.request") {
|
|
resolveApproval?.();
|
|
// Return registration confirmation
|
|
return { status: "accepted", id: (params as { id?: string })?.id };
|
|
}
|
|
if (method === "exec.approval.waitDecision") {
|
|
return { decision: "deny" };
|
|
}
|
|
return { ok: true };
|
|
});
|
|
|
|
const { createExecTool } = await import("./bash-tools.exec.js");
|
|
const tool = createExecTool({
|
|
ask: "on-miss",
|
|
security: "allowlist",
|
|
approvalRunningNoticeMs: 0,
|
|
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
|
|
});
|
|
|
|
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
|
|
expect(result.details.status).toBe("approval-pending");
|
|
await approvalSeen;
|
|
expect(calls).toContain("exec.approval.request");
|
|
});
|
|
});
|