mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(cli): list and resolve pending approvals headlessly (#111060)
* feat(cli): manage pending approvals * fix(cli): show terminal-safe approval ids raw, reserve id64 tokens for hostile ids * fix(cli): preserve opaque approval ids verbatim * fix(cli): tokenize leading-hyphen approval ids for pasteability * fix(cli): lossless utf16 id64 tokens for opaque approval ids * fix(cli): resolve approval ids verbatim, no input trim * fix(cli): scope-only approval auth, reviewer-safe system-agent summaries, skip ill-formed ids * fix(cli): validate pending approval ids * fix(cli): align approvals catalog and docs map
This commit is contained in:
committed by
GitHub
parent
5e4a324f22
commit
a2ccbdfa96
@@ -3,6 +3,7 @@ summary: "CLI reference for `openclaw approvals` and `openclaw exec-policy`"
|
||||
read_when:
|
||||
- You want to edit exec approvals from the CLI
|
||||
- You need to manage allowlists on gateway or node hosts
|
||||
- You need to list or resolve a pending approval without a chat surface
|
||||
title: "Approvals"
|
||||
---
|
||||
|
||||
@@ -44,6 +45,8 @@ For remote host approvals, use `openclaw approvals set --gateway` or `openclaw a
|
||||
openclaw approvals get
|
||||
openclaw approvals get --node <id|name|ip>
|
||||
openclaw approvals get --gateway
|
||||
openclaw approvals pending
|
||||
openclaw approvals resolve <id> <allow-once|allow-always|deny>
|
||||
```
|
||||
|
||||
`get` shows the effective exec policy for the target: the requested `tools.exec` policy, the host approvals-file policy, and the merged effective result. Nodes with a host-native policy, such as the Windows companion, show that policy directly instead of applying OpenClaw approvals-file policy math.
|
||||
@@ -61,6 +64,33 @@ Precedence:
|
||||
- `--node` combines the node host approvals file with gateway `tools.exec` policy (both apply at runtime).
|
||||
- If gateway config is unavailable, the CLI falls back to the node approvals snapshot and notes that the final runtime policy could not be computed.
|
||||
|
||||
## Pending approvals
|
||||
|
||||
List pending exec, plugin, and OpenClaw system-agent approvals from the Gateway:
|
||||
|
||||
```bash
|
||||
openclaw approvals pending
|
||||
openclaw approvals pending --json
|
||||
```
|
||||
|
||||
Complete enumeration and the matching operator-wide `resolve` flow use `operator.admin` because approval records otherwise retain requester/reviewer filtering. Resolution also requests the dedicated `operator.approvals` scope. The standard CLI operator grant includes both scopes; a restricted third-party client should not request admin merely to emulate this command.
|
||||
|
||||
Human output shows the approval kind, agent/session attribution, request age, time until expiry, a shortened command or summary, and a shell-neutral `id64_<base64url>` id token. A `Full request text` block always follows the compact table with every complete token and a losslessly escaped request, so terminal-width shortening cannot hide a suffix or the token needed for resolution. Copy the complete token into `resolve`. Unsafe terminal characters in other fields are shown as visible Unicode escapes. JSON output returns normalized entries under `approvals`, preserving the original raw `id`, `summary`, `createdAtMs`, and `expiresAtMs` for scripts; raw ids remain accepted by `resolve` unless they use the reserved `id64_` display-token prefix.
|
||||
|
||||
If a supplied `id64_` value matches both a literal raw id and the decoded display token for another approval, the CLI rejects it as ambiguous instead of risking resolution of the wrong request.
|
||||
|
||||
Resolve one approval by its full id:
|
||||
|
||||
```bash
|
||||
openclaw approvals resolve <id> allow-once
|
||||
openclaw approvals resolve <id> allow-always
|
||||
openclaw approvals resolve <id> deny --reason "Not expected during maintenance"
|
||||
```
|
||||
|
||||
The CLI reads the unified approval record to select its kind, checks the requested decision against the record's allowed decisions, and then calls the unified resolver. A first successful decision exits `0`. Repeating the recorded decision also exits `0` and reports `already resolved (same decision)`. A conflicting decision, missing approval, expired approval, or decision unavailable for that approval kind prints a clear error and exits non-zero.
|
||||
|
||||
`--reason` adds a local note to the CLI confirmation. The current Gateway approval record has no free-text resolution-reason field, so this note is not persisted or sent to other approval surfaces.
|
||||
|
||||
## Replace approvals from a file
|
||||
|
||||
```bash
|
||||
@@ -146,6 +176,8 @@ No target flag means the local approvals file on disk.
|
||||
|
||||
`allowlist add|remove` also supports `--agent <id>` (defaults to `"*"`, applying to all agents).
|
||||
|
||||
`pending` and `resolve` always use the Gateway because pending requests are live Gateway state. They support the shared Gateway connection options `--url`, `--token`, and `--timeout`; `pending` also supports `--json`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The node host must advertise `system.execApprovals.get/set` (macOS app, headless node host, or Windows companion).
|
||||
|
||||
@@ -1283,6 +1283,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H1: openclaw approvals
|
||||
- H2: openclaw exec-policy
|
||||
- H2: Common commands
|
||||
- H2: Pending approvals
|
||||
- H2: Replace approvals from a file
|
||||
- H2: "Never prompt" / YOLO example
|
||||
- H2: Allowlist helpers
|
||||
@@ -9860,6 +9861,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H3: Safe bins versus allowlist
|
||||
- H2: Interpreter/runtime commands
|
||||
- H3: Followup delivery behavior
|
||||
- H2: Minimal scopes for third-party clients
|
||||
- H2: Approval forwarding to chat channels
|
||||
- H3: Plugin approval forwarding
|
||||
- H3: Same-chat approvals on any channel
|
||||
|
||||
@@ -183,6 +183,10 @@ main session are either suppressed or reported through a safe direct route when
|
||||
- If a caller explicitly requests strict external delivery with no resolvable external channel, the request fails with `INVALID_REQUEST`.
|
||||
- If `bestEffortDeliver` is enabled and no external channel can be resolved, delivery is downgraded to session-only instead of failing.
|
||||
|
||||
## Minimal scopes for third-party clients
|
||||
|
||||
Gateway approval resolution is guarded by the dedicated `operator.approvals` scope. This applies to both the owner-specific `exec.approval.resolve` method and the kind-agnostic `approval.resolve` method; `operator.write` does not subsume it. Dashboards and integrations should request only the scopes required by the methods they use. Treat approval-resolution access as remote-execution-grade authority and grant `operator.approvals` deliberately, even when the client only presents a small approval UI.
|
||||
|
||||
## Approval forwarding to chat channels
|
||||
|
||||
You can forward exec approval prompts to any chat channel (including plugin channels) and approve
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
// Pending and resolve CLI tests stay separate from policy-management coverage.
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerExecApprovalsCli } from "./exec-approvals-cli.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const runtimeErrors: string[] = [];
|
||||
const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" ");
|
||||
const defaultRuntime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn((...args: unknown[]) => {
|
||||
runtimeErrors.push(stringifyArgs(args));
|
||||
}),
|
||||
writeStdout: vi.fn((value: string) => {
|
||||
defaultRuntime.log(value.endsWith("\n") ? value.slice(0, -1) : value);
|
||||
}),
|
||||
writeJson: vi.fn((value: unknown, space = 2) => {
|
||||
defaultRuntime.log(JSON.stringify(value, null, space > 0 ? space : undefined));
|
||||
}),
|
||||
exit: vi.fn((code: number) => {
|
||||
throw new Error(`__exit__:${code}`);
|
||||
}),
|
||||
};
|
||||
return {
|
||||
callGatewayFromCli: vi.fn(),
|
||||
defaultRuntime,
|
||||
runtimeErrors,
|
||||
};
|
||||
});
|
||||
|
||||
const { callGatewayFromCli, defaultRuntime, runtimeErrors } = mocks;
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`Expected ${label}`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function firstMockArg(mock: { mock: { calls: ReadonlyArray<ReadonlyArray<unknown>> } }): unknown {
|
||||
const call = mock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("Expected mock to have at least one call");
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
function writtenJson(): Record<string, unknown> {
|
||||
return requireRecord(firstMockArg(vi.mocked(defaultRuntime.writeJson)), "written json");
|
||||
}
|
||||
|
||||
function runtimeOutput(): string {
|
||||
return defaultRuntime.log.mock.calls.map(([line]) => String(line ?? "")).join("\n");
|
||||
}
|
||||
|
||||
function approvalDisplayId(id: string): string {
|
||||
return /^[A-Za-z0-9._:][A-Za-z0-9._:-]{0,127}$/.test(id)
|
||||
? id
|
||||
: `id64_${Buffer.from(id, "utf16le").toString("base64url")}`;
|
||||
}
|
||||
|
||||
function pendingApprovalSnapshot(params: {
|
||||
id: string;
|
||||
kind?: "exec" | "plugin" | "system-agent";
|
||||
allowedDecisions?: string[];
|
||||
expiresAtMs?: number;
|
||||
}) {
|
||||
const kind = params.kind ?? "exec";
|
||||
return {
|
||||
approval: {
|
||||
id: params.id,
|
||||
status: "pending",
|
||||
urlPath: `/approve/${params.id}`,
|
||||
createdAtMs: Date.now() - 1_000,
|
||||
expiresAtMs: params.expiresAtMs ?? Date.now() + 60_000,
|
||||
presentation:
|
||||
kind === "exec"
|
||||
? {
|
||||
kind,
|
||||
commandText: "echo ready",
|
||||
allowedDecisions: params.allowedDecisions ?? ["allow-once", "allow-always", "deny"],
|
||||
}
|
||||
: {
|
||||
kind,
|
||||
title: kind === "plugin" ? "Plugin action" : "OpenClaw change",
|
||||
description: "Apply the requested change",
|
||||
...(kind === "plugin" ? { severity: "warning" } : { proposalHash: "a".repeat(64) }),
|
||||
allowedDecisions: params.allowedDecisions ?? ["allow-once", "deny"],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function terminalApprovalSnapshot(params: {
|
||||
id: string;
|
||||
decision: "allow-once" | "allow-always" | "deny";
|
||||
resolverId?: string;
|
||||
}) {
|
||||
const allowed = params.decision !== "deny";
|
||||
return {
|
||||
id: params.id,
|
||||
status: allowed ? "allowed" : "denied",
|
||||
decision: params.decision,
|
||||
reason: "user",
|
||||
urlPath: `/approve/${params.id}`,
|
||||
createdAtMs: Date.now() - 1_000,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
resolvedAtMs: Date.now(),
|
||||
presentation: {
|
||||
kind: "exec",
|
||||
commandText: "echo ready",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
resolver: { kind: "device", id: params.resolverId ?? "device-1" },
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("./gateway-rpc.js", () => ({
|
||||
callGatewayFromCli: (method: string, opts: unknown, params?: unknown, extra?: unknown) =>
|
||||
mocks.callGatewayFromCli(method, opts, params, extra),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: mocks.defaultRuntime,
|
||||
}));
|
||||
|
||||
describe("exec approvals pending and resolve CLI", () => {
|
||||
const createProgram = () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerExecApprovalsCli(program);
|
||||
return program;
|
||||
};
|
||||
|
||||
const runApprovalsCommand = async (args: string[]) => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(args, { from: "user" });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
runtimeErrors.length = 0;
|
||||
callGatewayFromCli.mockClear();
|
||||
defaultRuntime.log.mockClear();
|
||||
defaultRuntime.error.mockClear();
|
||||
defaultRuntime.writeStdout.mockClear();
|
||||
defaultRuntime.writeJson.mockClear();
|
||||
defaultRuntime.exit.mockClear();
|
||||
});
|
||||
|
||||
it("renders pending approvals from all three approval kinds", async () => {
|
||||
const now = Date.now();
|
||||
callGatewayFromCli.mockImplementation(async (method: string) => {
|
||||
if (method === "exec.approval.list") {
|
||||
return [
|
||||
{
|
||||
id: "exec-\u202E1",
|
||||
request: {
|
||||
command: `printf '${"x".repeat(120)}' \u001B]52;c;--osc-hidden-action\u0007 --full-command-tail`,
|
||||
agentId: "m\u202Ea",
|
||||
sessionKey: "agent:main:discord:dm:1",
|
||||
},
|
||||
createdAtMs: now - 5_000,
|
||||
expiresAtMs: now + 60_000,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (method === "plugin.approval.list") {
|
||||
return [
|
||||
{
|
||||
id: "plugin:1",
|
||||
request: {
|
||||
title: "Publish package",
|
||||
description: "Publish the prepared plugin package",
|
||||
agentId: "release",
|
||||
sessionKey: "agent:release:main",
|
||||
},
|
||||
createdAtMs: now - 4_000,
|
||||
expiresAtMs: now + 55_000,
|
||||
},
|
||||
{
|
||||
id: "plugin:blank",
|
||||
request: { title: " ", description: "\t" },
|
||||
createdAtMs: now - 3_500,
|
||||
expiresAtMs: now + 54_000,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (method === "openclaw.approval.list") {
|
||||
return [
|
||||
{
|
||||
id: "system-agent:1",
|
||||
request: {
|
||||
title: "OpenClaw change",
|
||||
description: "Change the system configuration",
|
||||
command: "apply-system-change --force",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
createdAtMs: now - 3_000,
|
||||
// The Gateway list is authoritative even when the CLI clock is ahead.
|
||||
expiresAtMs: now - 500,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
await runApprovalsCommand(["approvals", "pending"]);
|
||||
|
||||
expect(callGatewayFromCli.mock.calls.map((call) => call[0])).toEqual([
|
||||
"exec.approval.list",
|
||||
"plugin.approval.list",
|
||||
"openclaw.approval.list",
|
||||
]);
|
||||
for (const call of callGatewayFromCli.mock.calls) {
|
||||
expect(call[3]).toEqual({ scopes: ["operator.admin"] });
|
||||
}
|
||||
const output = runtimeOutput();
|
||||
const execDisplayId = approvalDisplayId("exec-\u202E1");
|
||||
expect(output).toContain("Pending approvals");
|
||||
expect(output).toContain(execDisplayId);
|
||||
expect(output).toContain("m\\u{202E}a");
|
||||
expect(output).toContain(approvalDisplayId("plugin:1"));
|
||||
expect(output).toContain(approvalDisplayId("system-agent:1"));
|
||||
expect(output).toContain(approvalDisplayId("plugin:blank"));
|
||||
expect(output).toContain("Publish package");
|
||||
// System-agent approvals show only their reviewer-safe presentation; the
|
||||
// raw host-local operation must never reach the terminal.
|
||||
expect(output).not.toContain("apply-system-change");
|
||||
expect(output).toContain("OpenClaw change: Change the system configuration");
|
||||
expect(output).toContain("\\u{9}");
|
||||
expect(output).toContain("Full request text");
|
||||
expect(output).toContain("--osc-hidden-action");
|
||||
expect(output).toContain("\\u{1B}]52;c;");
|
||||
expect(output).toContain("--full-command-tail");
|
||||
expect(output).toContain("Agent / Session");
|
||||
expect(output).toContain("Expires In");
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes normalized pending approvals as JSON", async () => {
|
||||
const now = Date.now();
|
||||
callGatewayFromCli.mockImplementation(async (method: string) => {
|
||||
if (method === "exec.approval.list") {
|
||||
return [
|
||||
{
|
||||
id: "exec-json",
|
||||
request: {
|
||||
command: "uname -a\u001B]52;c;hidden-action\u0007",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
createdAtMs: now - 2_000,
|
||||
expiresAtMs: now + 60_000,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
await runApprovalsCommand(["approvals", "pending", "--json"]);
|
||||
|
||||
expect(defaultRuntime.writeJson).toHaveBeenCalledTimes(1);
|
||||
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(writtenJson(), 0);
|
||||
expect(writtenJson()).toEqual({
|
||||
approvals: [
|
||||
{
|
||||
id: "exec-json",
|
||||
kind: "exec",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
createdAtMs: now - 2_000,
|
||||
expiresAtMs: now + 60_000,
|
||||
summary: "uname -a\u001B]52;c;hidden-action\u0007",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves whitespace-bearing ids verbatim and keeps them distinct", async () => {
|
||||
const now = Date.now();
|
||||
callGatewayFromCli.mockImplementation(async (method: string) => {
|
||||
if (method === "exec.approval.list") {
|
||||
return [
|
||||
{
|
||||
id: " victim ",
|
||||
request: { command: "echo padded" },
|
||||
createdAtMs: now - 2_000,
|
||||
expiresAtMs: now + 60_000,
|
||||
},
|
||||
{
|
||||
id: "victim",
|
||||
request: { command: "echo exact" },
|
||||
createdAtMs: now - 1_000,
|
||||
expiresAtMs: now + 60_000,
|
||||
},
|
||||
{
|
||||
// Ill-formed ids are unresolvable through the unified schema and
|
||||
// must be skipped rather than listed with a dead token.
|
||||
id: "bad-\uD800",
|
||||
request: { command: "echo surrogate" },
|
||||
createdAtMs: now - 500,
|
||||
expiresAtMs: now + 60_000,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
await runApprovalsCommand(["approvals", "pending", "--json"]);
|
||||
|
||||
const ids = (writtenJson() as { approvals: { id: string }[] }).approvals.map(
|
||||
(entry) => entry.id,
|
||||
);
|
||||
expect(ids).toContain(" victim ");
|
||||
expect(ids).toContain("victim");
|
||||
expect(ids).not.toContain("bad-\uD800");
|
||||
// Display forms stay distinct: raw for the safe id, exact id64 token for
|
||||
// the padded one.
|
||||
expect(approvalDisplayId("victim")).toBe("victim");
|
||||
expect(approvalDisplayId(" victim ")).toBe(
|
||||
`id64_${Buffer.from(" victim ", "utf16le").toString("base64url")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves an approval and prints the settled decision and resolver", async () => {
|
||||
const approvalId = "approval-\u202E1";
|
||||
const displayId = approvalDisplayId(approvalId);
|
||||
callGatewayFromCli.mockImplementation(
|
||||
async (method: string, _opts: unknown, params?: unknown) => {
|
||||
if (method === "approval.get") {
|
||||
const requestedId = requireRecord(params, "approval lookup params").id;
|
||||
if (requestedId === displayId) {
|
||||
throw new Error("approval not found");
|
||||
}
|
||||
return pendingApprovalSnapshot({ id: approvalId });
|
||||
}
|
||||
if (method === "approval.resolve") {
|
||||
return {
|
||||
applied: true,
|
||||
approval: terminalApprovalSnapshot({
|
||||
id: approvalId,
|
||||
decision: "allow-once",
|
||||
resolverId: "device-\u202E1",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
);
|
||||
|
||||
await runApprovalsCommand(["approvals", "resolve", displayId, "allow-once"]);
|
||||
|
||||
expect(callGatewayFromCli.mock.calls[2]?.[0]).toBe("approval.resolve");
|
||||
expect(callGatewayFromCli.mock.calls[2]?.[2]).toEqual({
|
||||
id: approvalId,
|
||||
kind: "exec",
|
||||
decision: "allow-once",
|
||||
});
|
||||
for (const call of callGatewayFromCli.mock.calls) {
|
||||
expect(call[3]).toEqual({
|
||||
scopes: ["operator.admin", "operator.approvals"],
|
||||
});
|
||||
}
|
||||
expect(runtimeOutput()).toContain(
|
||||
`Approval ${displayId} resolved allow-once by device:device-\\u{202E}1`,
|
||||
);
|
||||
expect(defaultRuntime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats an already-resolved same decision as idempotent success", async () => {
|
||||
const approvalId = "job\\u{41}";
|
||||
callGatewayFromCli.mockImplementation(async (method: string) => {
|
||||
if (method === "approval.get") {
|
||||
return pendingApprovalSnapshot({ id: approvalId });
|
||||
}
|
||||
return {
|
||||
applied: false,
|
||||
approval: terminalApprovalSnapshot({
|
||||
id: approvalId,
|
||||
decision: "deny",
|
||||
resolverId: "other-device",
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await runApprovalsCommand(["approvals", "resolve", approvalId, "deny"]);
|
||||
|
||||
expect(callGatewayFromCli.mock.calls[0]?.[2]).toEqual({ id: approvalId });
|
||||
expect(callGatewayFromCli.mock.calls[1]?.[2]).toMatchObject({ id: approvalId });
|
||||
expect(runtimeOutput()).toContain("already resolved (same decision: deny)");
|
||||
expect(runtimeOutput()).toContain("device:other-device");
|
||||
expect(defaultRuntime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves with shared credentials and no device identity", async () => {
|
||||
callGatewayFromCli.mockImplementation(async (method: string) => {
|
||||
if (method === "approval.get") {
|
||||
return pendingApprovalSnapshot({ id: "approval-no-device" });
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
approval: terminalApprovalSnapshot({
|
||||
id: "approval-no-device",
|
||||
decision: "deny",
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await runApprovalsCommand([
|
||||
"approvals",
|
||||
"resolve",
|
||||
"approval-no-device",
|
||||
"deny",
|
||||
"--url",
|
||||
"ws://127.0.0.1:18789",
|
||||
"--token",
|
||||
"test-token",
|
||||
]);
|
||||
|
||||
expect(callGatewayFromCli).toHaveBeenCalledTimes(2);
|
||||
for (const call of callGatewayFromCli.mock.calls) {
|
||||
expect(call[3]).toEqual({ scopes: ["operator.admin", "operator.approvals"] });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an id token that also exists as a raw approval id", async () => {
|
||||
// Explicit token form: the display helper renders safe ids raw, but the
|
||||
// resolve path must stay ambiguity-safe for pasted tokens regardless.
|
||||
const displayId = `id64_${Buffer.from("foo", "utf16le").toString("base64url")}`;
|
||||
callGatewayFromCli.mockImplementation(
|
||||
async (method: string, _opts: unknown, params?: unknown) => {
|
||||
if (method !== "approval.get") {
|
||||
throw new Error("resolve must not be called");
|
||||
}
|
||||
const id = String(requireRecord(params, "approval lookup params").id);
|
||||
return pendingApprovalSnapshot({ id });
|
||||
},
|
||||
);
|
||||
|
||||
await expect(runApprovalsCommand(["approvals", "resolve", displayId, "deny"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(runtimeErrors[0]).toContain("matches both a raw id and a displayed id token");
|
||||
expect(callGatewayFromCli).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("exits non-zero when an approval already has a different decision", async () => {
|
||||
callGatewayFromCli.mockImplementation(async (method: string) => {
|
||||
if (method === "approval.get") {
|
||||
return pendingApprovalSnapshot({ id: "approval-3" });
|
||||
}
|
||||
return {
|
||||
applied: false,
|
||||
approval: terminalApprovalSnapshot({ id: "approval-3", decision: "deny" }),
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
runApprovalsCommand(["approvals", "resolve", "approval-3", "allow-once"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors[0]).toContain("already resolved with deny by device:device-1");
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("exits non-zero when an approval is not found", async () => {
|
||||
callGatewayFromCli.mockRejectedValue(new Error("approval not found"));
|
||||
|
||||
await expect(runApprovalsCommand(["approvals", "resolve", "missing", "deny"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(runtimeErrors[0]).toBe("approval not found");
|
||||
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lets the gateway decide that an approval expired", async () => {
|
||||
callGatewayFromCli.mockImplementation(async (method: string) => {
|
||||
if (method === "approval.get") {
|
||||
return pendingApprovalSnapshot({ id: "expired-1", expiresAtMs: Date.now() - 1 });
|
||||
}
|
||||
const pending = pendingApprovalSnapshot({ id: "expired-1" }).approval;
|
||||
return {
|
||||
applied: false,
|
||||
approval: {
|
||||
...pending,
|
||||
status: "expired",
|
||||
reason: "timeout",
|
||||
resolvedAtMs: pending.expiresAtMs,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
runApprovalsCommand(["approvals", "resolve", "expired-1", "deny"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors[0]).toBe(`Approval ${approvalDisplayId("expired-1")} expired.`);
|
||||
expect(callGatewayFromCli).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects decisions unavailable for the approval kind", async () => {
|
||||
callGatewayFromCli.mockResolvedValueOnce(
|
||||
pendingApprovalSnapshot({
|
||||
id: "system-agent:2",
|
||||
kind: "system-agent",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runApprovalsCommand(["approvals", "resolve", "system-agent:2", "allow-always"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors[0]).toContain(
|
||||
"allow-always is not allowed for system-agent approvals; allowed decisions: allow-once, deny",
|
||||
);
|
||||
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -38,40 +38,47 @@ const mocks = vi.hoisted(() => {
|
||||
}),
|
||||
};
|
||||
return {
|
||||
callGatewayFromCli: vi.fn(async (method: string, _opts: unknown, params?: unknown) => {
|
||||
if (method.endsWith(".get")) {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
callGatewayFromCli: vi.fn(
|
||||
async (
|
||||
method: string,
|
||||
_opts: unknown,
|
||||
params?: unknown,
|
||||
_extra?: unknown,
|
||||
): Promise<unknown> => {
|
||||
if (method.endsWith(".get")) {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const snapshot = {
|
||||
path: "/tmp/exec-approvals.json",
|
||||
exists: true,
|
||||
hash: "hash-1",
|
||||
file: { version: 1, agents: {} },
|
||||
};
|
||||
return method === "exec.approvals.node.get"
|
||||
? {
|
||||
...snapshot,
|
||||
resolvedDefaults: {
|
||||
security: "allowlist" as const,
|
||||
ask: "on-miss" as const,
|
||||
askFallback: "deny" as const,
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
}
|
||||
: snapshot;
|
||||
}
|
||||
const snapshot = {
|
||||
path: "/tmp/exec-approvals.json",
|
||||
exists: true,
|
||||
hash: "hash-1",
|
||||
file: { version: 1, agents: {} },
|
||||
};
|
||||
return method === "exec.approvals.node.get"
|
||||
? {
|
||||
...snapshot,
|
||||
resolvedDefaults: {
|
||||
security: "allowlist" as const,
|
||||
ask: "on-miss" as const,
|
||||
askFallback: "deny" as const,
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
}
|
||||
: snapshot;
|
||||
}
|
||||
return { method, params };
|
||||
}),
|
||||
return { method, params };
|
||||
},
|
||||
),
|
||||
defaultRuntime,
|
||||
readBestEffortConfig,
|
||||
runtimeErrors,
|
||||
@@ -167,8 +174,8 @@ function resetLocalSnapshot() {
|
||||
}
|
||||
|
||||
vi.mock("./gateway-rpc.js", () => ({
|
||||
callGatewayFromCli: (method: string, opts: unknown, params?: unknown) =>
|
||||
mocks.callGatewayFromCli(method, opts, params),
|
||||
callGatewayFromCli: (method: string, opts: unknown, params?: unknown, extra?: unknown) =>
|
||||
mocks.callGatewayFromCli(method, opts, params, extra),
|
||||
}));
|
||||
|
||||
vi.mock("./nodes-cli/rpc.js", async () => {
|
||||
|
||||
@@ -6,11 +6,20 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import type { Command } from "commander";
|
||||
import JSON5 from "json5";
|
||||
import {
|
||||
isWellFormedApprovalId,
|
||||
type ApprovalDecision,
|
||||
type ApprovalGetResult,
|
||||
type ApprovalKind,
|
||||
type ApprovalResolveResult,
|
||||
type ApprovalSnapshot,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
|
||||
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
|
||||
import { getTerminalTableWidth, renderTable } from "../../packages/terminal-core/src/table.js";
|
||||
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import { readBestEffortConfig, type OpenClawConfig } from "../config/config.js";
|
||||
import { ADMIN_SCOPE, APPROVALS_SCOPE, type OperatorScope } from "../gateway/method-scopes.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import {
|
||||
collectExecPolicyScopeSnapshots,
|
||||
@@ -87,8 +96,25 @@ type ExecApprovalsCliOpts = NodesRpcOpts & {
|
||||
file?: string;
|
||||
stdin?: boolean;
|
||||
agent?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type PendingApprovalCliEntry = {
|
||||
id: string;
|
||||
kind: ApprovalKind;
|
||||
agentId: string | null;
|
||||
sessionKey: string | null;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
const APPROVAL_DECISIONS = ["allow-once", "allow-always", "deny"] as const;
|
||||
const PENDING_APPROVAL_SUMMARY_MAX_LENGTH = 96;
|
||||
const APPROVAL_ID_TOKEN_PREFIX = "id64_";
|
||||
const APPROVAL_TERMINAL_UNSAFE_CHAR =
|
||||
/^[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]$/u;
|
||||
|
||||
async function readStdin(
|
||||
stream: NodeJS.ReadableStream = process.stdin,
|
||||
maxBytes = EXEC_APPROVALS_STDIN_MAX_BYTES,
|
||||
@@ -356,6 +382,342 @@ function formatCliError(err: unknown): string {
|
||||
return safe.length > 300 ? `${truncateUtf16Safe(safe, 300)}...` : safe;
|
||||
}
|
||||
|
||||
function isApprovalDecision(value: string): value is ApprovalDecision {
|
||||
return (APPROVAL_DECISIONS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function shortenPendingApprovalSummary(value: string): string {
|
||||
if (value.length <= PENDING_APPROVAL_SUMMARY_MAX_LENGTH) {
|
||||
return value;
|
||||
}
|
||||
return `${truncateUtf16Safe(value, PENDING_APPROVAL_SUMMARY_MAX_LENGTH - 3)}...`;
|
||||
}
|
||||
|
||||
function escapeApprovalTextForTerminal(value: string): string {
|
||||
let escaped = "";
|
||||
for (const char of value) {
|
||||
if (char === "\\") {
|
||||
escaped += "\\\\";
|
||||
continue;
|
||||
}
|
||||
if (APPROVAL_TERMINAL_UNSAFE_CHAR.test(char)) {
|
||||
escaped += `\\u{${char.codePointAt(0)?.toString(16).toUpperCase() ?? "FFFD"}}`;
|
||||
continue;
|
||||
}
|
||||
escaped += char;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
// Gateway-minted ids are UUID-shaped, but explicit ids from an agent host are
|
||||
// stored verbatim, so hostile ids (ANSI escapes, controls) are possible. Show
|
||||
// the raw id when it is terminal-safe; wrap only unsafe ids in a copyable
|
||||
// token that `resolve` decodes.
|
||||
// Leading hyphen excluded: a raw `-x`/`--flag` id could not be pasted into
|
||||
// `approvals resolve <id>` without Commander eating it as an option.
|
||||
const APPROVAL_ID_TERMINAL_SAFE_RE = /^[A-Za-z0-9._:][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
// Tokens encode UTF-16 code units, not UTF-8: ids are opaque JS strings and
|
||||
// UTF-8 replaces lone surrogates with U+FFFD, which would let two distinct
|
||||
// ids collide into one token on this remote-execution surface.
|
||||
function formatApprovalIdForTerminal(value: string): string {
|
||||
if (APPROVAL_ID_TERMINAL_SAFE_RE.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return `${APPROVAL_ID_TOKEN_PREFIX}${Buffer.from(value, "utf16le").toString("base64url")}`;
|
||||
}
|
||||
|
||||
function decodeDisplayedApprovalId(value: string): string | null {
|
||||
if (!value.startsWith(APPROVAL_ID_TOKEN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const encoded = value.slice(APPROVAL_ID_TOKEN_PREFIX.length);
|
||||
if (!encoded || !/^[a-zA-Z0-9_-]+$/.test(encoded)) {
|
||||
return null;
|
||||
}
|
||||
const decoded = Buffer.from(encoded, "base64url").toString("utf16le");
|
||||
return Buffer.from(decoded, "utf16le").toString("base64url") === encoded ? decoded : null;
|
||||
}
|
||||
|
||||
function readPendingApprovalEntry(
|
||||
value: unknown,
|
||||
kind: ApprovalKind,
|
||||
): PendingApprovalCliEntry | null {
|
||||
if (!isRecord(value) || !isRecord(value.request)) {
|
||||
return null;
|
||||
}
|
||||
// Approval ids are opaque and stored verbatim by the gateway — never trim
|
||||
// them, or two ids differing only in whitespace collapse into one display
|
||||
// form and resolving could target the wrong request. Whitespace-bearing ids
|
||||
// fail the terminal-safe charset and render as exact-round-trip id64 tokens.
|
||||
// Ill-formed (lone-surrogate) ids are skipped outright: the unified
|
||||
// approval.get/resolve schema rejects them, so listing one would advertise
|
||||
// a token that can never be resolved.
|
||||
const id = typeof value.id === "string" && isWellFormedApprovalId(value.id) ? value.id : null;
|
||||
const createdAtMs = value.createdAtMs;
|
||||
const expiresAtMs = value.expiresAtMs;
|
||||
if (
|
||||
!id ||
|
||||
typeof createdAtMs !== "number" ||
|
||||
!Number.isFinite(createdAtMs) ||
|
||||
typeof expiresAtMs !== "number" ||
|
||||
!Number.isFinite(expiresAtMs)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const request = value.request;
|
||||
const agentId = normalizeOptionalString(request.agentId) ?? null;
|
||||
const sessionKey = normalizeOptionalString(request.sessionKey) ?? null;
|
||||
const command = typeof request.command === "string" && request.command ? request.command : null;
|
||||
const title = typeof request.title === "string" && request.title ? request.title : null;
|
||||
const description =
|
||||
typeof request.description === "string" && request.description ? request.description : null;
|
||||
const prose = title && description ? `${title}: ${description}` : (title ?? description);
|
||||
// System-agent approvals stay on their reviewer-safe presentation (title,
|
||||
// description); the raw operation is host-local by contract and must not
|
||||
// leak into terminals, scripts, or logs.
|
||||
const summarySource =
|
||||
kind === "exec"
|
||||
? command
|
||||
: kind === "plugin" && command
|
||||
? `${prose ? `${prose} — ` : ""}Command: ${command}`
|
||||
: prose;
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
agentId,
|
||||
sessionKey,
|
||||
createdAtMs,
|
||||
expiresAtMs,
|
||||
summary: summarySource ?? "(summary unavailable)",
|
||||
};
|
||||
}
|
||||
|
||||
function readPendingApprovalList(value: unknown, kind: ApprovalKind): PendingApprovalCliEntry[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Invalid ${kind} approval list response.`);
|
||||
}
|
||||
return value.flatMap((entry) => {
|
||||
const parsed = readPendingApprovalEntry(entry, kind);
|
||||
return parsed ? [parsed] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPendingApprovals(
|
||||
opts: ExecApprovalsCliOpts,
|
||||
): Promise<PendingApprovalCliEntry[]> {
|
||||
// The owner-specific list methods retain requester filtering unless the caller is an admin.
|
||||
// Request admin explicitly so this operator command cannot silently omit live approvals.
|
||||
const listCall = (method: string) =>
|
||||
callGatewayFromCli(method, opts, {}, { scopes: [ADMIN_SCOPE] });
|
||||
const [exec, plugin, systemAgent] = await Promise.all([
|
||||
listCall("exec.approval.list"),
|
||||
listCall("plugin.approval.list"),
|
||||
listCall("openclaw.approval.list"),
|
||||
]);
|
||||
return [
|
||||
...readPendingApprovalList(exec, "exec"),
|
||||
...readPendingApprovalList(plugin, "plugin"),
|
||||
...readPendingApprovalList(systemAgent, "system-agent"),
|
||||
].toSorted((a, b) => b.createdAtMs - a.createdAtMs);
|
||||
}
|
||||
|
||||
function formatPendingAgentSession(entry: PendingApprovalCliEntry): string {
|
||||
const parts = [entry.agentId, entry.sessionKey].filter((value): value is string =>
|
||||
Boolean(value),
|
||||
);
|
||||
return parts.length > 0 ? escapeApprovalTextForTerminal(parts.join(" / ")) : "-";
|
||||
}
|
||||
|
||||
function renderPendingApprovals(entries: PendingApprovalCliEntry[]): void {
|
||||
if (entries.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No pending approvals."));
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
defaultRuntime.log(`${theme.heading("Pending approvals")} ${theme.muted(`(${entries.length})`)}`);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: getTerminalTableWidth(),
|
||||
columns: [
|
||||
{ key: "ID", header: "ID", minWidth: 16, flex: true },
|
||||
{ key: "Kind", header: "Kind", minWidth: 12 },
|
||||
{ key: "AgentSession", header: "Agent / Session", minWidth: 16, flex: true },
|
||||
{ key: "Requested", header: "Requested", minWidth: 12 },
|
||||
{ key: "Expires", header: "Expires In", minWidth: 10 },
|
||||
{ key: "Summary", header: "Command / Summary", minWidth: 20, flex: true },
|
||||
],
|
||||
rows: entries.map((entry) => {
|
||||
const summary = escapeApprovalTextForTerminal(entry.summary);
|
||||
return {
|
||||
ID: formatApprovalIdForTerminal(entry.id),
|
||||
Kind: entry.kind,
|
||||
AgentSession: formatPendingAgentSession(entry),
|
||||
Requested: formatTimeAgo(Math.max(0, now - entry.createdAtMs)),
|
||||
Expires: formatTimeAgo(Math.max(0, entry.expiresAtMs - now), { suffix: false }),
|
||||
Summary: shortenPendingApprovalSummary(summary),
|
||||
};
|
||||
}),
|
||||
}).trimEnd(),
|
||||
);
|
||||
defaultRuntime.log(theme.heading("Full request text"));
|
||||
for (const entry of entries) {
|
||||
defaultRuntime.log(
|
||||
`${formatApprovalIdForTerminal(entry.id)}: ${escapeApprovalTextForTerminal(entry.summary)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function approvalRecordedDecision(approval: ApprovalSnapshot): ApprovalDecision | null {
|
||||
return "decision" in approval && isApprovalDecision(approval.decision) ? approval.decision : null;
|
||||
}
|
||||
|
||||
function formatResolver(approval: ApprovalResolveResult["approval"]): string {
|
||||
const resolver = approval.resolver;
|
||||
if (!resolver) {
|
||||
return "unknown resolver";
|
||||
}
|
||||
return resolver.id
|
||||
? `${resolver.kind}:${escapeApprovalTextForTerminal(resolver.id)}`
|
||||
: resolver.kind;
|
||||
}
|
||||
|
||||
function describeTerminalApprovalFailure(approval: ApprovalResolveResult["approval"]): string {
|
||||
const id = formatApprovalIdForTerminal(approval.id);
|
||||
if (approval.status === "expired") {
|
||||
return `Approval ${id} expired.`;
|
||||
}
|
||||
if (approval.status === "cancelled") {
|
||||
return `Approval ${id} was cancelled (${approval.reason}).`;
|
||||
}
|
||||
return `Approval ${id} did not settle to a recorded decision.`;
|
||||
}
|
||||
|
||||
async function resolvePendingApproval(
|
||||
idInput: string,
|
||||
decisionInput: string,
|
||||
opts: ExecApprovalsCliOpts,
|
||||
): Promise<void> {
|
||||
// Never trim the id: `pending --json` emits ids verbatim, and a
|
||||
// whitespace-bearing id fed back through a script must target exactly that
|
||||
// approval, not its trimmed sibling.
|
||||
if (idInput.length === 0) {
|
||||
exitWithError("Approval id required.");
|
||||
}
|
||||
const rawId = idInput;
|
||||
const decision = requireTrimmedNonEmpty(decisionInput, "Decision required.");
|
||||
if (!isApprovalDecision(decision)) {
|
||||
exitWithError(`Decision must be one of: ${APPROVAL_DECISIONS.join(", ")}.`);
|
||||
}
|
||||
const reason = opts.reason === undefined ? null : normalizeOptionalString(opts.reason);
|
||||
if (opts.reason !== undefined && !reason) {
|
||||
exitWithError("Reason must not be empty.");
|
||||
}
|
||||
|
||||
// No explicit device identity: operator.admin authorizes resolution on its
|
||||
// own (canReviewOperatorApproval), and forcing a local identity onto a
|
||||
// loopback token/password session can trigger pairing for an otherwise
|
||||
// authorized credential.
|
||||
const approvalCallOptions = {
|
||||
scopes: [ADMIN_SCOPE, APPROVALS_SCOPE] as OperatorScope[],
|
||||
};
|
||||
|
||||
const lookupOne = async (id: string, tolerateNotFound = false) => {
|
||||
try {
|
||||
return (await callGatewayFromCli(
|
||||
"approval.get",
|
||||
opts,
|
||||
{ id },
|
||||
approvalCallOptions,
|
||||
)) as ApprovalGetResult;
|
||||
} catch (error) {
|
||||
if (
|
||||
tolerateNotFound &&
|
||||
formatErrorMessage(error).toLowerCase().includes("approval not found")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const decodedId = decodeDisplayedApprovalId(rawId);
|
||||
let id = rawId;
|
||||
let lookup: ApprovalGetResult;
|
||||
if (decodedId && decodedId !== rawId) {
|
||||
const [rawLookup, decodedLookup] = await Promise.all([
|
||||
lookupOne(rawId, true),
|
||||
lookupOne(decodedId, true),
|
||||
]);
|
||||
if (rawLookup && decodedLookup) {
|
||||
exitWithError(
|
||||
"Approval id is ambiguous: it matches both a raw id and a displayed id token. This CLI cannot resolve it safely.",
|
||||
);
|
||||
}
|
||||
if (rawLookup) {
|
||||
lookup = rawLookup;
|
||||
} else if (decodedLookup) {
|
||||
id = decodedId;
|
||||
lookup = decodedLookup;
|
||||
} else {
|
||||
exitWithError("Approval not found.");
|
||||
}
|
||||
} else {
|
||||
lookup = expectDefined(await lookupOne(rawId), "approval lookup result");
|
||||
}
|
||||
const displayId = formatApprovalIdForTerminal(id);
|
||||
const current = lookup.approval;
|
||||
if (current.status === "pending") {
|
||||
const allowedDecisions = current.presentation.allowedDecisions as readonly ApprovalDecision[];
|
||||
if (!allowedDecisions.includes(decision)) {
|
||||
exitWithError(
|
||||
`Decision ${decision} is not allowed for ${current.presentation.kind} approvals; allowed decisions: ${allowedDecisions.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = (await callGatewayFromCli(
|
||||
"approval.resolve",
|
||||
opts,
|
||||
{
|
||||
id,
|
||||
kind: current.presentation.kind,
|
||||
decision,
|
||||
},
|
||||
approvalCallOptions,
|
||||
)) as ApprovalResolveResult;
|
||||
const recordedDecision = approvalRecordedDecision(result.approval);
|
||||
if (!recordedDecision) {
|
||||
exitWithError(describeTerminalApprovalFailure(result.approval));
|
||||
}
|
||||
if (recordedDecision !== decision) {
|
||||
exitWithError(
|
||||
`Approval ${displayId} was already resolved with ${recordedDecision} by ${formatResolver(result.approval)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson(
|
||||
{
|
||||
...result,
|
||||
alreadyResolved: !result.applied,
|
||||
...(reason ? { cliReason: reason } : {}),
|
||||
},
|
||||
0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const settled = result.applied
|
||||
? `resolved ${recordedDecision}`
|
||||
: `already resolved (same decision: ${recordedDecision})`;
|
||||
const reasonSuffix = reason
|
||||
? `; CLI reason: ${shortenPendingApprovalSummary(escapeApprovalTextForTerminal(reason))}`
|
||||
: "";
|
||||
defaultRuntime.log(
|
||||
`Approval ${displayId} ${settled} by ${formatResolver(result.approval)}${reasonSuffix}.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadConfigForApprovalsTarget(params: {
|
||||
opts: ExecApprovalsCliOpts;
|
||||
source: ApprovalsTargetSource;
|
||||
@@ -748,13 +1110,45 @@ export function registerExecApprovalsCli(program: Command) {
|
||||
const approvals = program
|
||||
.command("approvals")
|
||||
.alias("exec-approvals")
|
||||
.description("Manage exec approvals (gateway or node host)")
|
||||
.description("Manage approval policy and pending requests")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.openclaw.ai/cli/approvals")}\n`,
|
||||
);
|
||||
|
||||
const pendingCmd = approvals
|
||||
.command("pending")
|
||||
.description("List pending exec, plugin, and system-agent approvals")
|
||||
.action(async (opts: ExecApprovalsCliOpts) => {
|
||||
try {
|
||||
const entries = await loadPendingApprovals(opts);
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson({ approvals: entries }, 0);
|
||||
return;
|
||||
}
|
||||
renderPendingApprovals(entries);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(formatCliError(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
nodesCallOpts(pendingCmd);
|
||||
|
||||
const resolveCmd = approvals
|
||||
.command("resolve <id> <decision>")
|
||||
.description("Resolve a pending approval")
|
||||
.option("--reason <text>", "Add a local note to the CLI confirmation")
|
||||
.action(async (id: string, decision: string, opts: ExecApprovalsCliOpts) => {
|
||||
try {
|
||||
await resolvePendingApproval(id, decision, opts);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(formatCliError(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
nodesCallOpts(resolveCmd);
|
||||
|
||||
const getCmd = approvals
|
||||
.command("get")
|
||||
.description("Fetch exec approvals snapshot")
|
||||
|
||||
@@ -46,7 +46,7 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
|
||||
},
|
||||
{
|
||||
name: "approvals",
|
||||
description: "Manage exec approvals (gateway or node host)",
|
||||
description: "Manage approval policy and pending requests",
|
||||
hasSubcommands: true,
|
||||
parentDefaultHelp: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user