Skip to content

Commit 714c19c

Browse files
committed
feat(cli): resolve pending exec approvals
1 parent cd15ce3 commit 714c19c

3 files changed

Lines changed: 205 additions & 0 deletions

File tree

docs/cli/approvals.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ or `openclaw approvals set --node <id|name|ip>`.
6363
openclaw approvals get
6464
openclaw approvals get --node <id|name|ip>
6565
openclaw approvals get --gateway
66+
openclaw approvals pending
67+
openclaw approvals resolve <approval-id> allow-once
6668
```
6769

6870
`openclaw approvals get` now shows the effective exec policy for local, gateway, and node targets:
@@ -78,6 +80,19 @@ Precedence is intentional:
7880
- `--node` combines the node host approvals file with gateway `tools.exec` policy, because both still apply at runtime
7981
- 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
8082

83+
## Pending approval prompts
84+
85+
When an agent asks for host exec approval and chat-native buttons are unavailable, the prompt includes a slash command such as `/approve <id> allow-once`. Operators can resolve the same pending request from the CLI:
86+
87+
```bash
88+
openclaw approvals pending
89+
openclaw approvals resolve <approval-id> allow-once
90+
openclaw approvals resolve <approval-id> allow-always
91+
openclaw approvals resolve <approval-id> deny
92+
```
93+
94+
`pending` and `resolve` talk to the running gateway runtime, not the local approvals JSON file. They require the same gateway connection options as other operator RPC commands. Use `pending --json` to script against the raw approval records.
95+
8196
## Replace approvals from a file
8297

8398
```bash

src/cli/exec-approvals-cli.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,42 @@ describe("exec approvals CLI", () => {
222222
expect(runtimeErrors).toHaveLength(0);
223223
});
224224

225+
it("lists pending runtime approvals through the gateway", async () => {
226+
callGatewayFromCli.mockResolvedValueOnce([
227+
{
228+
id: "approval-1",
229+
request: {
230+
command: "echo ok",
231+
host: "gateway",
232+
agentId: "main",
233+
},
234+
expiresAtMs: Date.UTC(2026, 4, 18, 12, 0, 0),
235+
},
236+
]);
237+
238+
await runApprovalsCommand(["approvals", "pending"]);
239+
240+
expectGatewayCall(0, "exec.approval.list", {});
241+
const output = defaultRuntime.log.mock.calls.map((call) => String(call[0])).join("\n");
242+
expect(output).toContain("Pending Exec Approvals");
243+
expect(output).toContain("approval-1");
244+
expect(output).toContain("echo ok");
245+
expect(runtimeErrors).toHaveLength(0);
246+
});
247+
248+
it("resolves pending runtime approvals through the gateway", async () => {
249+
await runApprovalsCommand(["approvals", "resolve", "approval-1", "always"]);
250+
251+
expectGatewayCall(0, "exec.approval.resolve", {
252+
id: "approval-1",
253+
decision: "allow-always",
254+
});
255+
expect(defaultRuntime.log).toHaveBeenCalledWith(
256+
"Approval allow-always submitted for approval-1.",
257+
);
258+
expect(runtimeErrors).toHaveLength(0);
259+
});
260+
225261
it("adds effective policy to json output", async () => {
226262
localSnapshot.file = {
227263
version: 1,

src/cli/exec-approvals-cli.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,15 @@ type EffectivePolicyReport = {
4444
scopes: ExecPolicyScopeSnapshot[];
4545
note?: string;
4646
};
47+
type PendingExecApprovalRow = {
48+
ID: string;
49+
Command: string;
50+
Host: string;
51+
Agent: string;
52+
Expires: string;
53+
};
4754
const APPROVALS_GET_DEFAULT_TIMEOUT_MS = 60_000;
55+
const APPROVALS_PENDING_DEFAULT_TIMEOUT_MS = 60_000;
4856

4957
type ExecApprovalsCliOpts = NodesRpcOpts & {
5058
node?: string;
@@ -171,6 +179,95 @@ function formatCliError(err: unknown): string {
171179
return safe.length > 300 ? `${safe.slice(0, 300)}...` : safe;
172180
}
173181

182+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
183+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
184+
}
185+
186+
function readPendingApprovals(payload: unknown): unknown[] {
187+
if (Array.isArray(payload)) {
188+
return payload;
189+
}
190+
if (isObjectRecord(payload) && Array.isArray(payload.approvals)) {
191+
return payload.approvals;
192+
}
193+
return [];
194+
}
195+
196+
function readPendingApprovalCommand(request: Record<string, unknown>): string {
197+
const systemRunPlan = isObjectRecord(request.systemRunPlan) ? request.systemRunPlan : null;
198+
const command =
199+
normalizeOptionalString(request.command) ??
200+
normalizeOptionalString(request.commandPreview) ??
201+
normalizeOptionalString(systemRunPlan?.commandPreview) ??
202+
normalizeOptionalString(systemRunPlan?.commandText);
203+
return command ?? "(unknown command)";
204+
}
205+
206+
function formatTimestamp(value: unknown): string {
207+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
208+
return "unknown";
209+
}
210+
return new Date(value).toISOString();
211+
}
212+
213+
function buildPendingApprovalRows(payload: unknown): PendingExecApprovalRow[] {
214+
return readPendingApprovals(payload)
215+
.map((approval): PendingExecApprovalRow | null => {
216+
if (!isObjectRecord(approval)) {
217+
return null;
218+
}
219+
const id = normalizeOptionalString(approval.id);
220+
const request = isObjectRecord(approval.request) ? approval.request : {};
221+
if (!id) {
222+
return null;
223+
}
224+
return {
225+
ID: id,
226+
Command: readPendingApprovalCommand(request),
227+
Host: normalizeOptionalString(request.host) ?? "gateway",
228+
Agent: normalizeOptionalString(request.agentId) ?? "unknown",
229+
Expires: formatTimestamp(approval.expiresAtMs),
230+
};
231+
})
232+
.filter((row): row is PendingExecApprovalRow => row !== null);
233+
}
234+
235+
function renderPendingApprovals(payload: unknown) {
236+
const rows = buildPendingApprovalRows(payload);
237+
const rich = isRich();
238+
const heading = (text: string) => (rich ? theme.heading(text) : text);
239+
const muted = (text: string) => (rich ? theme.muted(text) : text);
240+
defaultRuntime.log(heading("Pending Exec Approvals"));
241+
if (rows.length === 0) {
242+
defaultRuntime.log(muted("No pending approvals."));
243+
return;
244+
}
245+
defaultRuntime.log(
246+
renderTable({
247+
width: getTerminalTableWidth(),
248+
columns: [
249+
{ key: "ID", header: "ID", minWidth: 12 },
250+
{ key: "Command", header: "Command", minWidth: 24, flex: true },
251+
{ key: "Host", header: "Host", minWidth: 8 },
252+
{ key: "Agent", header: "Agent", minWidth: 10 },
253+
{ key: "Expires", header: "Expires", minWidth: 20 },
254+
],
255+
rows,
256+
}).trimEnd(),
257+
);
258+
}
259+
260+
function normalizeApprovalDecisionInput(decision: string): string {
261+
const normalized = decision.trim().toLowerCase();
262+
if (normalized === "always") {
263+
return "allow-always";
264+
}
265+
if (normalized === "allow-once" || normalized === "allow-always" || normalized === "deny") {
266+
return normalized;
267+
}
268+
exitWithError("Decision must be allow-once, allow-always, always, or deny.");
269+
}
270+
174271
async function loadConfigForApprovalsTarget(params: {
175272
opts: ExecApprovalsCliOpts;
176273
source: ApprovalsTargetSource;
@@ -553,6 +650,63 @@ export function registerExecApprovalsCli(program: Command) {
553650
});
554651
nodesCallOpts(setCmd);
555652

653+
const pendingCmd = approvals
654+
.command("pending")
655+
.alias("list")
656+
.description("List pending exec approval requests")
657+
.action(async (opts: ExecApprovalsCliOpts) => {
658+
try {
659+
const pending = await callGatewayFromCli("exec.approval.list", opts, {});
660+
if (opts.json) {
661+
defaultRuntime.writeJson({ approvals: readPendingApprovals(pending) }, 0);
662+
return;
663+
}
664+
renderPendingApprovals(pending);
665+
} catch (err) {
666+
defaultRuntime.error(formatCliError(err));
667+
defaultRuntime.exit(1);
668+
}
669+
});
670+
nodesCallOpts(pendingCmd, { timeoutMs: APPROVALS_PENDING_DEFAULT_TIMEOUT_MS });
671+
672+
const resolveCmd = approvals
673+
.command("resolve <id> <decision>")
674+
.alias("respond")
675+
.description("Resolve a pending exec approval request")
676+
.addHelpText(
677+
"after",
678+
() =>
679+
`\n${theme.heading("Examples:")}\n${formatExample(
680+
"openclaw approvals pending",
681+
"Show pending approval IDs.",
682+
)}\n${formatExample(
683+
"openclaw approvals resolve <id> allow-once",
684+
"Approve one pending command.",
685+
)}\n${formatExample(
686+
"openclaw approvals resolve <id> deny",
687+
"Deny one pending command.",
688+
)}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.openclaw.ai/cli/approvals")}\n`,
689+
)
690+
.action(async (id: string, decision: string, opts: ExecApprovalsCliOpts) => {
691+
try {
692+
const trimmedId = requireTrimmedNonEmpty(id, "Approval id required.");
693+
const normalizedDecision = normalizeApprovalDecisionInput(decision);
694+
const result = await callGatewayFromCli("exec.approval.resolve", opts, {
695+
id: trimmedId,
696+
decision: normalizedDecision,
697+
});
698+
if (opts.json) {
699+
defaultRuntime.writeJson(result, 0);
700+
return;
701+
}
702+
defaultRuntime.log(`Approval ${normalizedDecision} submitted for ${trimmedId}.`);
703+
} catch (err) {
704+
defaultRuntime.error(formatCliError(err));
705+
defaultRuntime.exit(1);
706+
}
707+
});
708+
nodesCallOpts(resolveCmd, { timeoutMs: APPROVALS_PENDING_DEFAULT_TIMEOUT_MS });
709+
556710
const allowlist = approvals
557711
.command("allowlist")
558712
.description("Edit the per-agent allowlist")

0 commit comments

Comments
 (0)