From 6b24dd03832333bb1cfbdffe48b08c25d4635cd2 Mon Sep 17 00:00:00 2001 From: Robby Date: Wed, 28 Jan 2026 10:18:17 +0000 Subject: [PATCH] fix(cli): add 'approve' command for exec approval requests (#3258) Add a new CLI command `moltmate approve ` that allows users to resolve exec approval requests directly from the command line. This mirrors the functionality of the `/approve` chat command, fixing the issue where the agent would incorrectly suggest running `clawdbot approve` which didn't exist. The command: - Takes an approval ID and decision (allow-once|allow-always|deny) - Supports common aliases (once, always, reject, block, etc.) - Calls the same gateway `exec.approval.resolve` endpoint as /approve - Provides user-friendly success/error messages Closes #3258 --- src/cli/approve-cli.test.ts | 160 ++++++++++++++++++++++++++++ src/cli/approve-cli.ts | 89 ++++++++++++++++ src/cli/program/command-registry.ts | 5 + 3 files changed, 254 insertions(+) create mode 100644 src/cli/approve-cli.test.ts create mode 100644 src/cli/approve-cli.ts diff --git a/src/cli/approve-cli.test.ts b/src/cli/approve-cli.test.ts new file mode 100644 index 000000000..f76381aec --- /dev/null +++ b/src/cli/approve-cli.test.ts @@ -0,0 +1,160 @@ +import { Command } from "commander"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const callGateway = vi.fn(async () => ({})); + +const runtimeLogs: string[] = []; +const runtimeErrors: string[] = []; +const defaultRuntime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (msg: string) => runtimeErrors.push(msg), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, +}; + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGateway(opts), +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +describe("approve CLI", () => { + beforeEach(() => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + callGateway.mockResolvedValue({}); + }); + + it("resolves approval with allow-once", async () => { + const { registerApproveCli } = await import("./approve-cli.js"); + const program = new Command(); + program.exitOverride(); + registerApproveCli(program); + + await program.parseAsync(["approve", "req-123", "allow-once"], { from: "user" }); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "req-123", decision: "allow-once" }, + }), + ); + expect(runtimeLogs).toContain("✅ Exec approval allow-once submitted for req-123."); + expect(runtimeErrors).toHaveLength(0); + }); + + it("resolves approval with allow-always", async () => { + const { registerApproveCli } = await import("./approve-cli.js"); + const program = new Command(); + program.exitOverride(); + registerApproveCli(program); + + await program.parseAsync(["approve", "req-456", "allow-always"], { from: "user" }); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "req-456", decision: "allow-always" }, + }), + ); + expect(runtimeLogs).toContain("✅ Exec approval allow-always submitted for req-456."); + }); + + it("resolves approval with deny", async () => { + const { registerApproveCli } = await import("./approve-cli.js"); + const program = new Command(); + program.exitOverride(); + registerApproveCli(program); + + await program.parseAsync(["approve", "req-789", "deny"], { from: "user" }); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "req-789", decision: "deny" }, + }), + ); + expect(runtimeLogs).toContain("✅ Exec approval deny submitted for req-789."); + }); + + it("accepts decision aliases", async () => { + const { registerApproveCli } = await import("./approve-cli.js"); + const program = new Command(); + program.exitOverride(); + registerApproveCli(program); + + // Test "always" alias for "allow-always" + await program.parseAsync(["approve", "req-alias", "always"], { from: "user" }); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + params: { id: "req-alias", decision: "allow-always" }, + }), + ); + }); + + it("accepts 'once' alias for allow-once", async () => { + const { registerApproveCli } = await import("./approve-cli.js"); + const program = new Command(); + program.exitOverride(); + registerApproveCli(program); + + await program.parseAsync(["approve", "req-once", "once"], { from: "user" }); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + params: { id: "req-once", decision: "allow-once" }, + }), + ); + }); + + it("accepts 'reject' alias for deny", async () => { + const { registerApproveCli } = await import("./approve-cli.js"); + const program = new Command(); + program.exitOverride(); + registerApproveCli(program); + + await program.parseAsync(["approve", "req-reject", "reject"], { from: "user" }); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + params: { id: "req-reject", decision: "deny" }, + }), + ); + }); + + it("rejects invalid decision", async () => { + const { registerApproveCli } = await import("./approve-cli.js"); + const program = new Command(); + program.exitOverride(); + registerApproveCli(program); + + await expect( + program.parseAsync(["approve", "req-bad", "invalid-decision"], { from: "user" }), + ).rejects.toThrow("__exit__:1"); + + expect(callGateway).not.toHaveBeenCalled(); + expect(runtimeErrors).toContain( + 'Invalid decision "invalid-decision". Use: allow-once, allow-always, or deny', + ); + }); + + it("handles gateway errors", async () => { + callGateway.mockRejectedValue(new Error("Gateway timeout")); + + const { registerApproveCli } = await import("./approve-cli.js"); + const program = new Command(); + program.exitOverride(); + registerApproveCli(program); + + await expect( + program.parseAsync(["approve", "req-fail", "allow-once"], { from: "user" }), + ).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toContain("❌ Failed to submit approval: Error: Gateway timeout"); + }); +}); diff --git a/src/cli/approve-cli.ts b/src/cli/approve-cli.ts new file mode 100644 index 000000000..f3c4f1284 --- /dev/null +++ b/src/cli/approve-cli.ts @@ -0,0 +1,89 @@ +import type { Command } from "commander"; + +import { callGateway } from "../gateway/call.js"; +import { defaultRuntime } from "../runtime.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { formatDocsLink } from "../terminal/links.js"; +import { theme } from "../terminal/theme.js"; + +type ApproveDecision = "allow-once" | "allow-always" | "deny"; + +const DECISION_ALIASES: Record = { + allow: "allow-once", + once: "allow-once", + "allow-once": "allow-once", + allowonce: "allow-once", + always: "allow-always", + "allow-always": "allow-always", + allowalways: "allow-always", + deny: "deny", + reject: "deny", + block: "deny", +}; + +function normalizeDecision(raw: string): ApproveDecision | null { + return DECISION_ALIASES[raw.toLowerCase().trim()] ?? null; +} + +type ApproveCliOpts = { + url?: string; + token?: string; + timeout?: string; +}; + +export function registerApproveCli(program: Command) { + program + .command("approve ") + .description("Resolve an exec approval request") + .option("--url ", "Gateway WebSocket URL") + .option("--token ", "Gateway token (if required)") + .option("--timeout ", "Timeout in ms", "10000") + .addHelpText( + "after", + () => + `\n${theme.heading("Decisions:")}\n` + + ` ${theme.command("allow-once")} Allow once (aliases: allow, once)\n` + + ` ${theme.command("allow-always")} Allow always and add to allowlist (aliases: always)\n` + + ` ${theme.command("deny")} Deny the request (aliases: reject, block)\n` + + `\n${theme.heading("Examples:")}\n` + + ` ${theme.command("moltmate approve abc123 allow-once")}\n` + + ` ${theme.command("moltmate approve abc123 always")}\n` + + ` ${theme.command("moltmate approve abc123 deny")}\n` + + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approve", "docs.clawd.bot/cli/approve")}\n`, + ) + .action(async (id: string, decision: string, opts: ApproveCliOpts) => { + const trimmedId = id.trim(); + if (!trimmedId) { + defaultRuntime.error("Approval ID is required."); + defaultRuntime.exit(1); + return; + } + + const normalizedDecision = normalizeDecision(decision); + if (!normalizedDecision) { + defaultRuntime.error( + `Invalid decision "${decision}". Use: allow-once, allow-always, or deny`, + ); + defaultRuntime.exit(1); + return; + } + + try { + await callGateway({ + url: opts.url, + token: opts.token, + method: "exec.approval.resolve", + params: { id: trimmedId, decision: normalizedDecision }, + timeoutMs: Number(opts.timeout ?? 10_000), + clientName: GATEWAY_CLIENT_NAMES.CLI, + clientDisplayName: "CLI approve command", + mode: GATEWAY_CLIENT_MODES.CLI, + }); + + defaultRuntime.log(`✅ Exec approval ${normalizedDecision} submitted for ${trimmedId}.`); + } catch (err) { + defaultRuntime.error(`❌ Failed to submit approval: ${String(err)}`); + defaultRuntime.exit(1); + } + }); +} diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 0b4618ef0..6628a3eb7 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -6,6 +6,7 @@ import { sessionsCommand } from "../../commands/sessions.js"; import { statusCommand } from "../../commands/status.js"; import { defaultRuntime } from "../../runtime.js"; import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js"; +import { registerApproveCli } from "../approve-cli.js"; import { registerBrowserCli } from "../browser-cli.js"; import { registerConfigCli } from "../config-cli.js"; import { registerMemoryCli, runMemoryStatus } from "../memory-cli.js"; @@ -120,6 +121,10 @@ export const commandRegistry: CommandRegistration[] = [ id: "config", register: ({ program }) => registerConfigCli(program), }, + { + id: "approve", + register: ({ program }) => registerApproveCli(program), + }, { id: "maintenance", register: ({ program }) => registerMaintenanceCommands(program),