feat: add exec approvals tooling and service status
This commit is contained in:
@@ -50,15 +50,15 @@ import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
||||
|
||||
const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
||||
30_000,
|
||||
200_000,
|
||||
1_000,
|
||||
150_000,
|
||||
200_000,
|
||||
);
|
||||
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
|
||||
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
|
||||
30_000,
|
||||
200_000,
|
||||
1_000,
|
||||
150_000,
|
||||
200_000,
|
||||
);
|
||||
const DEFAULT_PATH =
|
||||
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
87
src/cli/exec-approvals-cli.test.ts
Normal file
87
src/cli/exec-approvals-cli.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
243
src/cli/exec-approvals-cli.ts
Normal file
243
src/cli/exec-approvals-cli.ts
Normal 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);
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
59
src/cli/service-cli.coverage.test.ts
Normal file
59
src/cli/service-cli.coverage.test.ts
Normal 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
157
src/cli/service-cli.ts
Normal 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();
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
|
||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||
import type { GatewayService } from "../daemon/service.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { resolveNodeService } from "../daemon/node-service.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
||||
import { probeGateway } from "../gateway/probe.js";
|
||||
@@ -130,10 +132,9 @@ export async function statusAllCommand(
|
||||
const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null);
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Checking daemon…");
|
||||
const daemon = await (async () => {
|
||||
progress.setLabel("Checking services…");
|
||||
const readServiceSummary = async (service: GatewayService) => {
|
||||
try {
|
||||
const service = resolveGatewayService();
|
||||
const [loaded, runtimeInfo, command] = await Promise.all([
|
||||
service.isLoaded({ env: process.env }).catch(() => false),
|
||||
service.readRuntime(process.env).catch(() => undefined),
|
||||
@@ -150,7 +151,9 @@ export async function statusAllCommand(
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
};
|
||||
const daemon = await readServiceSummary(resolveGatewayService());
|
||||
const nodeService = await readServiceSummary(resolveNodeService());
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Scanning agents…");
|
||||
@@ -340,13 +343,22 @@ export async function statusAllCommand(
|
||||
: { Item: "Gateway self", Value: "unknown" },
|
||||
daemon
|
||||
? {
|
||||
Item: "Daemon",
|
||||
Item: "Gateway service",
|
||||
Value:
|
||||
daemon.installed === false
|
||||
? `${daemon.label} not installed`
|
||||
: `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
|
||||
}
|
||||
: { Item: "Daemon", Value: "unknown" },
|
||||
: { Item: "Gateway service", Value: "unknown" },
|
||||
nodeService
|
||||
? {
|
||||
Item: "Node service",
|
||||
Value:
|
||||
nodeService.installed === false
|
||||
? `${nodeService.label} not installed`
|
||||
: `${nodeService.label} ${nodeService.installed ? "installed · " : ""}${nodeService.loadedText}${nodeService.runtime?.status ? ` · ${nodeService.runtime.status}` : ""}${nodeService.runtime?.pid ? ` (pid ${nodeService.runtime.pid})` : ""}`,
|
||||
}
|
||||
: { Item: "Node service", Value: "unknown" },
|
||||
{
|
||||
Item: "Agents",
|
||||
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "../memory/status-format.js";
|
||||
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
|
||||
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
||||
import { getDaemonStatusSummary } from "./status.daemon.js";
|
||||
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
|
||||
import {
|
||||
formatAge,
|
||||
formatDuration,
|
||||
@@ -116,6 +116,10 @@ export async function statusCommand(
|
||||
: undefined;
|
||||
|
||||
if (opts.json) {
|
||||
const [daemon, nodeDaemon] = await Promise.all([
|
||||
getDaemonStatusSummary(),
|
||||
getNodeDaemonStatusSummary(),
|
||||
]);
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
@@ -134,6 +138,8 @@ export async function statusCommand(
|
||||
self: gatewaySelf,
|
||||
error: gatewayProbe?.error ?? null,
|
||||
},
|
||||
gatewayService: daemon,
|
||||
nodeService: nodeDaemon,
|
||||
agents: agentStatus,
|
||||
securityAudit,
|
||||
...(health || usage ? { health, usage } : {}),
|
||||
@@ -210,12 +216,20 @@ export async function statusCommand(
|
||||
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
|
||||
})();
|
||||
|
||||
const daemon = await getDaemonStatusSummary();
|
||||
const [daemon, nodeDaemon] = await Promise.all([
|
||||
getDaemonStatusSummary(),
|
||||
getNodeDaemonStatusSummary(),
|
||||
]);
|
||||
const daemonValue = (() => {
|
||||
if (daemon.installed === false) return `${daemon.label} not installed`;
|
||||
const installedPrefix = daemon.installed === true ? "installed · " : "";
|
||||
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
|
||||
})();
|
||||
const nodeDaemonValue = (() => {
|
||||
if (nodeDaemon.installed === false) return `${nodeDaemon.label} not installed`;
|
||||
const installedPrefix = nodeDaemon.installed === true ? "installed · " : "";
|
||||
return `${nodeDaemon.label} ${installedPrefix}${nodeDaemon.loadedText}${nodeDaemon.runtimeShort ? ` · ${nodeDaemon.runtimeShort}` : ""}`;
|
||||
})();
|
||||
|
||||
const defaults = summary.sessions.defaults;
|
||||
const defaultCtx = defaults.contextTokens
|
||||
@@ -298,7 +312,8 @@ export async function statusCommand(
|
||||
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
|
||||
},
|
||||
{ Item: "Gateway", Value: gatewayValue },
|
||||
{ Item: "Daemon", Value: daemonValue },
|
||||
{ Item: "Gateway service", Value: daemonValue },
|
||||
{ Item: "Node service", Value: nodeDaemonValue },
|
||||
{ Item: "Agents", Value: agentsValue },
|
||||
{ Item: "Memory", Value: memoryValue },
|
||||
{ Item: "Probes", Value: probesValue },
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { GatewayService } from "../daemon/service.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { resolveNodeService } from "../daemon/node-service.js";
|
||||
import { formatDaemonRuntimeShort } from "./status.format.js";
|
||||
|
||||
export async function getDaemonStatusSummary(): Promise<{
|
||||
type DaemonStatusSummary = {
|
||||
label: string;
|
||||
installed: boolean | null;
|
||||
loadedText: string;
|
||||
runtimeShort: string | null;
|
||||
}> {
|
||||
};
|
||||
|
||||
async function buildDaemonStatusSummary(
|
||||
service: GatewayService,
|
||||
fallbackLabel: string,
|
||||
): Promise<DaemonStatusSummary> {
|
||||
try {
|
||||
const service = resolveGatewayService();
|
||||
const [loaded, runtime, command] = await Promise.all([
|
||||
service.isLoaded({ env: process.env }).catch(() => false),
|
||||
service.readRuntime(process.env).catch(() => undefined),
|
||||
@@ -20,10 +26,18 @@ export async function getDaemonStatusSummary(): Promise<{
|
||||
return { label: service.label, installed, loadedText, runtimeShort };
|
||||
} catch {
|
||||
return {
|
||||
label: "Daemon",
|
||||
label: fallbackLabel,
|
||||
installed: null,
|
||||
loadedText: "unknown",
|
||||
runtimeShort: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDaemonStatusSummary(): Promise<DaemonStatusSummary> {
|
||||
return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon");
|
||||
}
|
||||
|
||||
export async function getNodeDaemonStatusSummary(): Promise<DaemonStatusSummary> {
|
||||
return await buildDaemonStatusSummary(resolveNodeService(), "Node");
|
||||
}
|
||||
|
||||
@@ -243,6 +243,19 @@ vi.mock("../daemon/service.js", () => ({
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../daemon/node-service.js", () => ({
|
||||
resolveNodeService: () => ({
|
||||
label: "LaunchAgent",
|
||||
loadedText: "loaded",
|
||||
notLoadedText: "not loaded",
|
||||
isLoaded: async () => true,
|
||||
readRuntime: async () => ({ status: "running", pid: 4321 }),
|
||||
readCommand: async () => ({
|
||||
programArguments: ["node", "dist/entry.js", "node-host"],
|
||||
sourcePath: "/tmp/Library/LaunchAgents/com.clawdbot.node.plist",
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../security/audit.js", () => ({
|
||||
runSecurityAudit: mocks.runSecurityAudit,
|
||||
}));
|
||||
@@ -273,6 +286,8 @@ describe("statusCommand", () => {
|
||||
expect(payload.sessions.recent[0].flags).toContain("verbose:on");
|
||||
expect(payload.securityAudit.summary.critical).toBe(1);
|
||||
expect(payload.securityAudit.summary.warn).toBe(1);
|
||||
expect(payload.gatewayService.label).toBe("LaunchAgent");
|
||||
expect(payload.nodeService.label).toBe("LaunchAgent");
|
||||
});
|
||||
|
||||
it("prints formatted lines otherwise", async () => {
|
||||
|
||||
@@ -58,6 +58,10 @@ import {
|
||||
CronUpdateParamsSchema,
|
||||
type ExecApprovalsGetParams,
|
||||
ExecApprovalsGetParamsSchema,
|
||||
type ExecApprovalsNodeGetParams,
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
type ExecApprovalsNodeSetParams,
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
type ExecApprovalsSetParams,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
type ExecApprovalsSnapshot,
|
||||
@@ -241,6 +245,12 @@ export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams
|
||||
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
|
||||
ExecApprovalsSetParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalsNodeSetParams = ajv.compile<ExecApprovalsNodeSetParams>(
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
);
|
||||
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
|
||||
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
||||
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
||||
|
||||
@@ -70,3 +70,19 @@ export const ExecApprovalsSetParamsSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ExecApprovalsNodeGetParamsSchema = Type.Object(
|
||||
{
|
||||
nodeId: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ExecApprovalsNodeSetParamsSchema = Type.Object(
|
||||
{
|
||||
nodeId: NonEmptyString,
|
||||
file: ExecApprovalsFileSchema,
|
||||
baseHash: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
} from "./cron.js";
|
||||
import {
|
||||
ExecApprovalsGetParamsSchema,
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
ExecApprovalsSnapshotSchema,
|
||||
} from "./exec-approvals.js";
|
||||
@@ -177,6 +179,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
LogsTailResult: LogsTailResultSchema,
|
||||
ExecApprovalsGetParams: ExecApprovalsGetParamsSchema,
|
||||
ExecApprovalsSetParams: ExecApprovalsSetParamsSchema,
|
||||
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
|
||||
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
|
||||
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
|
||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||
ChatSendParams: ChatSendParamsSchema,
|
||||
|
||||
@@ -47,6 +47,8 @@ import type {
|
||||
} from "./cron.js";
|
||||
import type {
|
||||
ExecApprovalsGetParamsSchema,
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
ExecApprovalsSnapshotSchema,
|
||||
} from "./exec-approvals.js";
|
||||
@@ -170,6 +172,8 @@ export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
|
||||
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
|
||||
export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>;
|
||||
export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>;
|
||||
export type ExecApprovalsNodeGetParams = Static<typeof ExecApprovalsNodeGetParamsSchema>;
|
||||
export type ExecApprovalsNodeSetParams = Static<typeof ExecApprovalsNodeSetParamsSchema>;
|
||||
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
|
||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
||||
|
||||
@@ -14,6 +14,8 @@ const BASE_METHODS = [
|
||||
"config.schema",
|
||||
"exec.approvals.get",
|
||||
"exec.approvals.set",
|
||||
"exec.approvals.node.get",
|
||||
"exec.approvals.node.set",
|
||||
"wizard.start",
|
||||
"wizard.next",
|
||||
"wizard.cancel",
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateExecApprovalsGetParams,
|
||||
validateExecApprovalsNodeGetParams,
|
||||
validateExecApprovalsNodeSetParams,
|
||||
validateExecApprovalsSetParams,
|
||||
} from "../protocol/index.js";
|
||||
import { respondUnavailableOnThrow, safeParseJson } from "./nodes.helpers.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
function resolveBaseHash(params: unknown): string | null {
|
||||
@@ -152,4 +155,94 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"exec.approvals.node.get": async ({ params, respond, context }) => {
|
||||
if (!validateExecApprovalsNodeGetParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid exec.approvals.node.get params: ${formatValidationErrors(validateExecApprovalsNodeGetParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const bridge = context.bridge;
|
||||
if (!bridge) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
|
||||
return;
|
||||
}
|
||||
const { nodeId } = params as { nodeId: string };
|
||||
const id = nodeId.trim();
|
||||
if (!id) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
|
||||
return;
|
||||
}
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const res = await bridge.invoke({
|
||||
nodeId: id,
|
||||
command: "system.execApprovals.get",
|
||||
paramsJSON: "{}",
|
||||
});
|
||||
if (!res.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
|
||||
details: { nodeError: res.error ?? null },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = safeParseJson(res.payloadJSON ?? null);
|
||||
respond(true, payload, undefined);
|
||||
});
|
||||
},
|
||||
"exec.approvals.node.set": async ({ params, respond, context }) => {
|
||||
if (!validateExecApprovalsNodeSetParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid exec.approvals.node.set params: ${formatValidationErrors(validateExecApprovalsNodeSetParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const bridge = context.bridge;
|
||||
if (!bridge) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
|
||||
return;
|
||||
}
|
||||
const { nodeId, file, baseHash } = params as {
|
||||
nodeId: string;
|
||||
file: ExecApprovalsFile;
|
||||
baseHash?: string;
|
||||
};
|
||||
const id = nodeId.trim();
|
||||
if (!id) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
|
||||
return;
|
||||
}
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const res = await bridge.invoke({
|
||||
nodeId: id,
|
||||
command: "system.execApprovals.set",
|
||||
paramsJSON: JSON.stringify({ file, baseHash }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
|
||||
details: { nodeError: res.error ?? null },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = safeParseJson(res.payloadJSON ?? null);
|
||||
respond(true, payload, undefined);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
108
src/infra/exec-approvals.test.ts
Normal file
108
src/infra/exec-approvals.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
matchAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
resolveCommandResolution,
|
||||
type ExecAllowlistEntry,
|
||||
} from "./exec-approvals.js";
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-exec-approvals-"));
|
||||
}
|
||||
|
||||
describe("exec approvals allowlist matching", () => {
|
||||
it("matches by executable name (case-insensitive)", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match?.pattern).toBe("RG");
|
||||
});
|
||||
|
||||
it("matches by resolved path with **", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/**/rg" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match?.pattern).toBe("/opt/**/rg");
|
||||
});
|
||||
|
||||
it("does not let * cross path separators", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/*/rg" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to raw executable when no resolved path", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "bin/rg",
|
||||
resolvedPath: undefined,
|
||||
executableName: "rg",
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match?.pattern).toBe("bin/rg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals command resolution", () => {
|
||||
it("resolves PATH executables", () => {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exe = path.join(binDir, "rg");
|
||||
fs.writeFileSync(exe, "");
|
||||
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
|
||||
expect(res?.resolvedPath).toBe(exe);
|
||||
expect(res?.executableName).toBe("rg");
|
||||
});
|
||||
|
||||
it("resolves relative paths against cwd", () => {
|
||||
const dir = makeTempDir();
|
||||
const cwd = path.join(dir, "project");
|
||||
const script = path.join(cwd, "scripts", "run.sh");
|
||||
fs.mkdirSync(path.dirname(script), { recursive: true });
|
||||
fs.writeFileSync(script, "");
|
||||
const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined);
|
||||
expect(res?.resolvedPath).toBe(script);
|
||||
});
|
||||
|
||||
it("parses quoted executables", () => {
|
||||
const dir = makeTempDir();
|
||||
const cwd = path.join(dir, "project");
|
||||
const script = path.join(cwd, "bin", "tool");
|
||||
fs.mkdirSync(path.dirname(script), { recursive: true });
|
||||
fs.writeFileSync(script, "");
|
||||
const res = resolveCommandResolution("\"./bin/tool\" --version", cwd, undefined);
|
||||
expect(res?.resolvedPath).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals policy helpers", () => {
|
||||
it("minSecurity returns the more restrictive value", () => {
|
||||
expect(minSecurity("deny", "full")).toBe("deny");
|
||||
expect(minSecurity("allowlist", "full")).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("maxAsk returns the more aggressive ask mode", () => {
|
||||
expect(maxAsk("off", "always")).toBe("always");
|
||||
expect(maxAsk("on-miss", "off")).toBe("on-miss");
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,16 @@ import type { BridgeInvokeRequestFrame } from "../infra/bridge/server/types.js";
|
||||
import {
|
||||
addAllowlistEntry,
|
||||
matchAllowlist,
|
||||
normalizeExecApprovals,
|
||||
recordAllowlistUse,
|
||||
requestExecApprovalViaSocket,
|
||||
resolveCommandResolution,
|
||||
resolveExecApprovals,
|
||||
ensureExecApprovals,
|
||||
readExecApprovalsSnapshot,
|
||||
resolveExecApprovalsSocketPath,
|
||||
saveExecApprovals,
|
||||
type ExecApprovalsFile,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import { VERSION } from "../version.js";
|
||||
@@ -43,6 +49,18 @@ type SystemWhichParams = {
|
||||
bins: string[];
|
||||
};
|
||||
|
||||
type SystemExecApprovalsSetParams = {
|
||||
file: ExecApprovalsFile;
|
||||
baseHash?: string | null;
|
||||
};
|
||||
|
||||
type ExecApprovalsSnapshot = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
hash: string;
|
||||
file: ExecApprovalsFile;
|
||||
};
|
||||
|
||||
type RunResult = {
|
||||
exitCode?: number;
|
||||
timedOut: boolean;
|
||||
@@ -143,6 +161,31 @@ function truncateOutput(raw: string, maxChars: number): { text: string; truncate
|
||||
return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true };
|
||||
}
|
||||
|
||||
function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
||||
const socketPath = file.socket?.path?.trim();
|
||||
return {
|
||||
...file,
|
||||
socket: socketPath ? { path: socketPath } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function requireExecApprovalsBaseHash(
|
||||
params: SystemExecApprovalsSetParams,
|
||||
snapshot: ExecApprovalsSnapshot,
|
||||
) {
|
||||
if (!snapshot.exists) return;
|
||||
if (!snapshot.hash) {
|
||||
throw new Error("INVALID_REQUEST: exec approvals base hash unavailable; reload and retry");
|
||||
}
|
||||
const baseHash = typeof params.baseHash === "string" ? params.baseHash.trim() : "";
|
||||
if (!baseHash) {
|
||||
throw new Error("INVALID_REQUEST: exec approvals base hash required; reload and retry");
|
||||
}
|
||||
if (baseHash !== snapshot.hash) {
|
||||
throw new Error("INVALID_REQUEST: exec approvals changed; reload and retry");
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
argv: string[],
|
||||
cwd: string | undefined,
|
||||
@@ -306,7 +349,12 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
deviceFamily: os.platform(),
|
||||
modelIdentifier: os.hostname(),
|
||||
caps: ["system"],
|
||||
commands: ["system.run", "system.which"],
|
||||
commands: [
|
||||
"system.run",
|
||||
"system.which",
|
||||
"system.execApprovals.get",
|
||||
"system.execApprovals.set",
|
||||
],
|
||||
onPairToken: async (token) => {
|
||||
config.token = token;
|
||||
await saveNodeHostConfig(config);
|
||||
@@ -355,6 +403,80 @@ async function handleInvoke(
|
||||
skillBins: SkillBinsCache,
|
||||
) {
|
||||
const command = String(frame.command ?? "");
|
||||
if (command === "system.execApprovals.get") {
|
||||
try {
|
||||
ensureExecApprovals();
|
||||
const snapshot = readExecApprovalsSnapshot();
|
||||
const payload: ExecApprovalsSnapshot = {
|
||||
path: snapshot.path,
|
||||
exists: snapshot.exists,
|
||||
hash: snapshot.hash,
|
||||
file: redactExecApprovals(snapshot.file),
|
||||
};
|
||||
client.sendInvokeResponse({
|
||||
type: "invoke-res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify(payload),
|
||||
});
|
||||
} catch (err) {
|
||||
client.sendInvokeResponse({
|
||||
type: "invoke-res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: String(err) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "system.execApprovals.set") {
|
||||
try {
|
||||
const params = decodeParams<SystemExecApprovalsSetParams>(frame.paramsJSON);
|
||||
if (!params.file || typeof params.file !== "object") {
|
||||
throw new Error("INVALID_REQUEST: exec approvals file required");
|
||||
}
|
||||
ensureExecApprovals();
|
||||
const snapshot = readExecApprovalsSnapshot();
|
||||
requireExecApprovalsBaseHash(params, snapshot);
|
||||
const normalized = normalizeExecApprovals(params.file);
|
||||
const currentSocketPath = snapshot.file.socket?.path?.trim();
|
||||
const currentToken = snapshot.file.socket?.token?.trim();
|
||||
const socketPath =
|
||||
normalized.socket?.path?.trim() ?? currentSocketPath ?? resolveExecApprovalsSocketPath();
|
||||
const token = normalized.socket?.token?.trim() ?? currentToken ?? "";
|
||||
const next: ExecApprovalsFile = {
|
||||
...normalized,
|
||||
socket: {
|
||||
path: socketPath,
|
||||
token,
|
||||
},
|
||||
};
|
||||
saveExecApprovals(next);
|
||||
const nextSnapshot = readExecApprovalsSnapshot();
|
||||
const payload: ExecApprovalsSnapshot = {
|
||||
path: nextSnapshot.path,
|
||||
exists: nextSnapshot.exists,
|
||||
hash: nextSnapshot.hash,
|
||||
file: redactExecApprovals(nextSnapshot.file),
|
||||
};
|
||||
client.sendInvokeResponse({
|
||||
type: "invoke-res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify(payload),
|
||||
});
|
||||
} catch (err) {
|
||||
client.sendInvokeResponse({
|
||||
type: "invoke-res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: String(err) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "system.which") {
|
||||
try {
|
||||
const params = decodeParams<SystemWhichParams>(frame.paramsJSON);
|
||||
|
||||
Reference in New Issue
Block a user