feat: add exec approvals tooling and service status

This commit is contained in:
Peter Steinberger
2026-01-18 15:23:36 +00:00
parent 9c06689569
commit 3686bde783
39 changed files with 1472 additions and 35 deletions

View File

@@ -0,0 +1,87 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const callGatewayFromCli = vi.fn(
async (method: string, _opts: unknown, params?: unknown) => {
if (method.endsWith(".get")) {
return {
path: "/tmp/exec-approvals.json",
exists: true,
hash: "hash-1",
file: { version: 1, agents: {} },
};
}
return { method, params };
},
);
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-rpc.js", () => ({
callGatewayFromCli: (method: string, opts: unknown, params?: unknown) =>
callGatewayFromCli(method, opts, params),
}));
vi.mock("./nodes-cli/rpc.js", async () => {
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>(
"./nodes-cli/rpc.js",
);
return {
...actual,
resolveNodeId: vi.fn(async () => "node-1"),
};
});
vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
describe("exec approvals CLI", () => {
it("loads gateway approvals by default", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGatewayFromCli.mockClear();
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
await program.parseAsync(["approvals", "get"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"exec.approvals.get",
expect.anything(),
{},
);
expect(runtimeErrors).toHaveLength(0);
});
it("loads node approvals when --node is set", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGatewayFromCli.mockClear();
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"exec.approvals.node.get",
expect.anything(),
{ nodeId: "node-1" },
);
expect(runtimeErrors).toHaveLength(0);
});
});

View File

@@ -0,0 +1,243 @@
import fs from "node:fs/promises";
import JSON5 from "json5";
import type { Command } from "commander";
import type { ExecApprovalsAgent, ExecApprovalsFile } from "../infra/exec-approvals.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
import type { NodesRpcOpts } from "./nodes-cli/types.js";
type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
type ExecApprovalsCliOpts = NodesRpcOpts & {
node?: string;
file?: string;
stdin?: boolean;
agent?: string;
};
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
return Buffer.concat(chunks).toString("utf8");
}
async function resolveTargetNodeId(opts: ExecApprovalsCliOpts): Promise<string | null> {
const raw = opts.node?.trim() ?? "";
if (!raw) return null;
return await resolveNodeId(opts as NodesRpcOpts, raw);
}
async function loadSnapshot(
opts: ExecApprovalsCliOpts,
nodeId: string | null,
): Promise<ExecApprovalsSnapshot> {
const method = nodeId ? "exec.approvals.node.get" : "exec.approvals.get";
const params = nodeId ? { nodeId } : {};
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
return snapshot;
}
async function saveSnapshot(
opts: ExecApprovalsCliOpts,
nodeId: string | null,
file: ExecApprovalsFile,
baseHash: string,
): Promise<ExecApprovalsSnapshot> {
const method = nodeId ? "exec.approvals.node.set" : "exec.approvals.set";
const params = nodeId ? { nodeId, file, baseHash } : { file, baseHash };
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
return snapshot;
}
function resolveAgentKey(value?: string | null): string {
const trimmed = value?.trim() ?? "";
return trimmed ? trimmed : "default";
}
function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null {
const pattern = entry?.pattern?.trim() ?? "";
return pattern ? pattern : null;
}
function ensureAgent(file: ExecApprovalsFile, agentKey: string): ExecApprovalsAgent {
const agents = file.agents ?? {};
const entry = agents[agentKey] ?? {};
file.agents = agents;
return entry;
}
function isEmptyAgent(agent: ExecApprovalsAgent): boolean {
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
return (
!agent.security &&
!agent.ask &&
!agent.askFallback &&
agent.autoAllowSkills === undefined &&
allowlist.length === 0
);
}
export function registerExecApprovalsCli(program: Command) {
const approvals = program
.command("approvals")
.alias("exec-approvals")
.description("Manage exec approvals (gateway or node host)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.clawd.bot/cli/approvals")}\n`,
);
const getCmd = approvals
.command("get")
.description("Fetch exec approvals snapshot")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.action(async (opts: ExecApprovalsCliOpts) => {
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
const payload = opts.json ? JSON.stringify(snapshot) : JSON.stringify(snapshot, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(getCmd);
const setCmd = approvals
.command("set")
.description("Replace exec approvals with a JSON file")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--file <path>", "Path to JSON file to upload")
.option("--stdin", "Read JSON from stdin", false)
.action(async (opts: ExecApprovalsCliOpts) => {
if (!opts.file && !opts.stdin) {
defaultRuntime.error("Provide --file or --stdin.");
defaultRuntime.exit(1);
return;
}
if (opts.file && opts.stdin) {
defaultRuntime.error("Use either --file or --stdin (not both).");
defaultRuntime.exit(1);
return;
}
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
if (!snapshot.hash) {
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
defaultRuntime.exit(1);
return;
}
const raw = opts.stdin ? await readStdin() : await fs.readFile(String(opts.file), "utf8");
let file: ExecApprovalsFile;
try {
file = JSON5.parse(raw) as ExecApprovalsFile;
} catch (err) {
defaultRuntime.error(`Failed to parse approvals JSON: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
file.version = 1;
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(setCmd);
const allowlist = approvals
.command("allowlist")
.description("Edit the per-agent allowlist");
const allowlistAdd = allowlist
.command("add <pattern>")
.description("Add a glob pattern to an allowlist")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--agent <id>", "Agent id (defaults to \"default\")")
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
const trimmed = pattern.trim();
if (!trimmed) {
defaultRuntime.error("Pattern required.");
defaultRuntime.exit(1);
return;
}
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
if (!snapshot.hash) {
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
defaultRuntime.exit(1);
return;
}
const file = snapshot.file ?? { version: 1 };
file.version = 1;
const agentKey = resolveAgentKey(opts.agent);
const agent = ensureAgent(file, agentKey);
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmed)) {
defaultRuntime.log("Already allowlisted.");
return;
}
allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() });
agent.allowlist = allowlistEntries;
file.agents = { ...(file.agents ?? {}), [agentKey]: agent };
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(allowlistAdd);
const allowlistRemove = allowlist
.command("remove <pattern>")
.description("Remove a glob pattern from an allowlist")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--agent <id>", "Agent id (defaults to \"default\")")
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
const trimmed = pattern.trim();
if (!trimmed) {
defaultRuntime.error("Pattern required.");
defaultRuntime.exit(1);
return;
}
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
if (!snapshot.hash) {
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
defaultRuntime.exit(1);
return;
}
const file = snapshot.file ?? { version: 1 };
file.version = 1;
const agentKey = resolveAgentKey(opts.agent);
const agent = ensureAgent(file, agentKey);
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
const nextEntries = allowlistEntries.filter(
(entry) => normalizeAllowlistEntry(entry) !== trimmed,
);
if (nextEntries.length === allowlistEntries.length) {
defaultRuntime.log("Pattern not found.");
return;
}
if (nextEntries.length === 0) {
delete agent.allowlist;
} else {
agent.allowlist = nextEntries;
}
if (isEmptyAgent(agent)) {
const agents = { ...(file.agents ?? {}) };
delete agents[agentKey];
file.agents = Object.keys(agents).length > 0 ? agents : undefined;
} else {
file.agents = { ...(file.agents ?? {}), [agentKey]: agent };
}
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(allowlistRemove);
}

View File

@@ -55,6 +55,14 @@ export function registerNodeCli(program: Command) {
.command("daemon")
.description("Manage the headless node daemon service (launchd/systemd/schtasks)");
node
.command("status")
.description("Show node service status")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStatus(opts);
});
daemon
.command("status")
.description("Show node daemon status")

View File

@@ -8,6 +8,7 @@ import { registerDaemonCli } from "../daemon-cli.js";
import { registerDnsCli } from "../dns-cli.js";
import { registerDirectoryCli } from "../directory-cli.js";
import { registerDocsCli } from "../docs-cli.js";
import { registerExecApprovalsCli } from "../exec-approvals-cli.js";
import { registerGatewayCli } from "../gateway-cli.js";
import { registerHooksCli } from "../hooks-cli.js";
import { registerWebhooksCli } from "../webhooks-cli.js";
@@ -19,6 +20,7 @@ import { registerPairingCli } from "../pairing-cli.js";
import { registerPluginsCli } from "../plugins-cli.js";
import { registerSandboxCli } from "../sandbox-cli.js";
import { registerSecurityCli } from "../security-cli.js";
import { registerServiceCli } from "../service-cli.js";
import { registerSkillsCli } from "../skills-cli.js";
import { registerTuiCli } from "../tui-cli.js";
import { registerUpdateCli } from "../update-cli.js";
@@ -27,8 +29,10 @@ export function registerSubCliCommands(program: Command) {
registerAcpCli(program);
registerDaemonCli(program);
registerGatewayCli(program);
registerServiceCli(program);
registerLogsCli(program);
registerModelsCli(program);
registerExecApprovalsCli(program);
registerNodesCli(program);
registerNodeCli(program);
registerSandboxCli(program);

View File

@@ -0,0 +1,59 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const runDaemonStatus = vi.fn(async () => {});
const runNodeDaemonStatus = vi.fn(async () => {});
vi.mock("./daemon-cli/runners.js", () => ({
runDaemonInstall: vi.fn(async () => {}),
runDaemonRestart: vi.fn(async () => {}),
runDaemonStart: vi.fn(async () => {}),
runDaemonStatus: (opts: unknown) => runDaemonStatus(opts),
runDaemonStop: vi.fn(async () => {}),
runDaemonUninstall: vi.fn(async () => {}),
}));
vi.mock("./node-cli/daemon.js", () => ({
runNodeDaemonInstall: vi.fn(async () => {}),
runNodeDaemonRestart: vi.fn(async () => {}),
runNodeDaemonStart: vi.fn(async () => {}),
runNodeDaemonStatus: (opts: unknown) => runNodeDaemonStatus(opts),
runNodeDaemonStop: vi.fn(async () => {}),
runNodeDaemonUninstall: vi.fn(async () => {}),
}));
vi.mock("./deps.js", () => ({
createDefaultDeps: vi.fn(),
}));
describe("service CLI coverage", () => {
it("routes service gateway status to daemon status", async () => {
runDaemonStatus.mockClear();
runNodeDaemonStatus.mockClear();
const { registerServiceCli } = await import("./service-cli.js");
const program = new Command();
program.exitOverride();
registerServiceCli(program);
await program.parseAsync(["service", "gateway", "status"], { from: "user" });
expect(runDaemonStatus).toHaveBeenCalledTimes(1);
expect(runNodeDaemonStatus).toHaveBeenCalledTimes(0);
});
it("routes service node status to node daemon status", async () => {
runDaemonStatus.mockClear();
runNodeDaemonStatus.mockClear();
const { registerServiceCli } = await import("./service-cli.js");
const program = new Command();
program.exitOverride();
registerServiceCli(program);
await program.parseAsync(["service", "node", "status"], { from: "user" });
expect(runNodeDaemonStatus).toHaveBeenCalledTimes(1);
expect(runDaemonStatus).toHaveBeenCalledTimes(0);
});
});

157
src/cli/service-cli.ts Normal file
View File

@@ -0,0 +1,157 @@
import type { Command } from "commander";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { createDefaultDeps } from "./deps.js";
import {
runDaemonInstall,
runDaemonRestart,
runDaemonStart,
runDaemonStatus,
runDaemonStop,
runDaemonUninstall,
} from "./daemon-cli/runners.js";
import {
runNodeDaemonInstall,
runNodeDaemonRestart,
runNodeDaemonStart,
runNodeDaemonStatus,
runNodeDaemonStop,
runNodeDaemonUninstall,
} from "./node-cli/daemon.js";
export function registerServiceCli(program: Command) {
const service = program
.command("service")
.description("Manage Gateway and node host services (launchd/systemd/schtasks)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
);
const gateway = service.command("gateway").description("Manage the Gateway service");
gateway
.command("status")
.description("Show gateway service status + probe the Gateway")
.option("--url <url>", "Gateway WebSocket URL (defaults to config/remote/local)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--no-probe", "Skip RPC probe")
.option("--deep", "Scan system-level services", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStatus({
rpc: opts,
probe: Boolean(opts.probe),
deep: Boolean(opts.deep),
json: Boolean(opts.json),
});
});
gateway
.command("install")
.description("Install the Gateway service (launchd/systemd/schtasks)")
.option("--port <port>", "Gateway port")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--token <token>", "Gateway token (token auth)")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonInstall(opts);
});
gateway
.command("uninstall")
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonUninstall(opts);
});
gateway
.command("start")
.description("Start the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStart(opts);
});
gateway
.command("stop")
.description("Stop the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStop(opts);
});
gateway
.command("restart")
.description("Restart the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonRestart(opts);
});
const node = service.command("node").description("Manage the node host service");
node
.command("status")
.description("Show node host service status")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStatus(opts);
});
node
.command("install")
.description("Install the node host service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--display-name <name>", "Override node display name")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonInstall(opts);
});
node
.command("uninstall")
.description("Uninstall the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonUninstall(opts);
});
node
.command("start")
.description("Start the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStart(opts);
});
node
.command("stop")
.description("Stop the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStop(opts);
});
node
.command("restart")
.description("Restart the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonRestart(opts);
});
// Build default deps (parity with daemon CLI).
void createDefaultDeps();
}