diff --git a/CHANGELOG.md b/CHANGELOG.md index a3b627d1c..0f53d03bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ Docs: https://docs.clawd.bot +## 2026.1.18-5 + +### Changes +- Exec approvals: add `clawdbot approvals` CLI for viewing and updating gateway/node allowlists. +- CLI: add `clawdbot service` gateway/node management and a `clawdbot node status` alias. +- Status: show gateway + node service summaries in `clawdbot status` and `status --all`. +- Control UI: add gateway/node target selector for exec approvals. +- Docs: add approvals/service references and refresh node/control UI docs. + ## 2026.1.18-4 ### Changes diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift index bf1407d96..eab1aea11 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovals.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovals.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import OSLog import Security @@ -121,6 +122,13 @@ struct ExecApprovalsFile: Codable { var agents: [String: ExecApprovalsAgent]? } +struct ExecApprovalsSnapshot: Codable { + var path: String + var exists: Bool + var hash: String + var file: ExecApprovalsFile +} + struct ExecApprovalsResolved { let url: URL let socketPath: String @@ -153,6 +161,58 @@ enum ExecApprovalsStore { ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path } + static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { + let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return ExecApprovalsFile( + version: 1, + socket: ExecApprovalsSocketConfig( + path: socketPath.isEmpty ? nil : socketPath, + token: token.isEmpty ? nil : token), + defaults: file.defaults, + agents: file.agents) + } + + static func readSnapshot() -> ExecApprovalsSnapshot { + let url = self.fileURL() + guard FileManager.default.fileExists(atPath: url.path) else { + return ExecApprovalsSnapshot( + path: url.path, + exists: false, + hash: self.hashRaw(nil), + file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) + } + let raw = try? String(contentsOf: url, encoding: .utf8) + let data = raw.flatMap { $0.data(using: .utf8) } + let decoded: ExecApprovalsFile = { + if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 { + return file + } + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + }() + return ExecApprovalsSnapshot( + path: url.path, + exists: true, + hash: self.hashRaw(raw), + file: decoded) + } + + static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile { + let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if socketPath.isEmpty { + return ExecApprovalsFile( + version: file.version, + socket: nil, + defaults: file.defaults, + agents: file.agents) + } + return ExecApprovalsFile( + version: file.version, + socket: ExecApprovalsSocketConfig(path: socketPath, token: nil), + defaults: file.defaults, + agents: file.agents) + } + static func loadFile() -> ExecApprovalsFile { let url = self.fileURL() guard FileManager.default.fileExists(atPath: url.path) else { @@ -372,6 +432,12 @@ enum ExecApprovalsStore { return UUID().uuidString } + private static func hashRaw(_ raw: String?) -> String { + let data = Data((raw ?? "").utf8) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + private static func expandPath(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed == "~" { diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index dc2c8c168..06187f973 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -158,6 +158,8 @@ final class MacNodeModeCoordinator { ClawdbotSystemCommand.notify.rawValue, ClawdbotSystemCommand.which.rawValue, ClawdbotSystemCommand.run.rawValue, + ClawdbotSystemCommand.execApprovalsGet.rawValue, + ClawdbotSystemCommand.execApprovalsSet.rawValue, ] let capsSet = Set(caps) diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index 52dd96abb..16d5189ba 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -64,6 +64,10 @@ actor MacNodeRuntime { return try await self.handleSystemWhich(req) case ClawdbotSystemCommand.notify.rawValue: return try await self.handleSystemNotify(req) + case ClawdbotSystemCommand.execApprovalsGet.rawValue: + return try await self.handleSystemExecApprovalsGet(req) + case ClawdbotSystemCommand.execApprovalsSet.rawValue: + return try await self.handleSystemExecApprovalsSet(req) default: return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") } @@ -676,6 +680,72 @@ actor MacNodeRuntime { return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) } + private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + _ = ExecApprovalsStore.ensureFile() + let snapshot = ExecApprovalsStore.readSnapshot() + let redacted = ExecApprovalsSnapshot( + path: snapshot.path, + exists: snapshot.exists, + hash: snapshot.hash, + file: ExecApprovalsStore.redactForSnapshot(snapshot.file)) + let payload = try Self.encodePayload(redacted) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + + private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + struct SetParams: Decodable { + var file: ExecApprovalsFile + var baseHash: String? + } + + let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON) + let current = ExecApprovalsStore.ensureFile() + let snapshot = ExecApprovalsStore.readSnapshot() + if snapshot.exists { + if snapshot.hash.isEmpty { + return Self.errorResponse( + req, + code: .invalidRequest, + message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry") + } + let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if baseHash.isEmpty { + return Self.errorResponse( + req, + code: .invalidRequest, + message: "INVALID_REQUEST: exec approvals base hash required; reload and retry") + } + if baseHash != snapshot.hash { + return Self.errorResponse( + req, + code: .invalidRequest, + message: "INVALID_REQUEST: exec approvals changed; reload and retry") + } + } + + var normalized = ExecApprovalsStore.normalizeIncoming(params.file) + let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) + let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedPath = (socketPath?.isEmpty == false) + ? socketPath! + : current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? + ExecApprovalsStore.socketPath() + let resolvedToken = (token?.isEmpty == false) + ? token! + : current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken) + + ExecApprovalsStore.saveFile(normalized) + let nextSnapshot = ExecApprovalsStore.readSnapshot() + let redacted = ExecApprovalsSnapshot( + path: nextSnapshot.path, + exists: nextSnapshot.exists, + hash: nextSnapshot.hash, + file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file)) + let payload = try Self.encodePayload(redacted) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + private func emitExecEvent(_ event: String, payload: ExecEventPayload) async { guard let sender = self.eventSender else { return } guard let data = try? JSONEncoder().encode(payload), diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift index 1db944d91..1de76dbc6 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift @@ -4,6 +4,8 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable { case run = "system.run" case which = "system.which" case notify = "system.notify" + case execApprovalsGet = "system.execApprovals.get" + case execApprovalsSet = "system.execApprovals.set" } public enum ClawdbotNotificationPriority: String, Codable, Sendable { diff --git a/docs/cli/approvals.md b/docs/cli/approvals.md new file mode 100644 index 000000000..d91eb4bf9 --- /dev/null +++ b/docs/cli/approvals.md @@ -0,0 +1,44 @@ +--- +summary: "CLI reference for `clawdbot approvals` (exec approvals for gateway or node hosts)" +read_when: + - You want to edit exec approvals from the CLI + - You need to manage allowlists on gateway or node hosts +--- + +# `clawdbot approvals` + +Manage exec approvals for the **gateway host** or a **node host**. +By default, commands target the gateway. Use `--node` to edit a node’s approvals. + +Related: +- Exec approvals: [Exec approvals](/tools/exec-approvals) +- Nodes: [Nodes](/nodes) + +## Common commands + +```bash +clawdbot approvals get +clawdbot approvals get --node +``` + +## Replace approvals from a file + +```bash +clawdbot approvals set --file ./exec-approvals.json +clawdbot approvals set --node --file ./exec-approvals.json +``` + +## Allowlist helpers + +```bash +clawdbot approvals allowlist add "~/Projects/**/bin/rg" +clawdbot approvals allowlist add --agent main --node "/usr/bin/uptime" + +clawdbot approvals allowlist remove "~/Projects/**/bin/rg" +``` + +## Notes + +- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix). +- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host). +- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`. diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 9de629040..71c43d1f8 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -9,6 +9,9 @@ read_when: Manage the Gateway daemon (background service). +Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains +as a legacy alias for compatibility. + Related: - Gateway CLI: [Gateway](/cli/gateway) - macOS platform notes: [macOS](/platforms/macos) diff --git a/docs/cli/index.md b/docs/cli/index.md index 123841cdd..a28192fd2 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -29,11 +29,13 @@ This page describes the current CLI behavior. If commands change, update this do - [`sessions`](/cli/sessions) - [`gateway`](/cli/gateway) - [`daemon`](/cli/daemon) +- [`service`](/cli/service) - [`logs`](/cli/logs) - [`models`](/cli/models) - [`memory`](/cli/memory) - [`nodes`](/cli/nodes) - [`node`](/cli/node) +- [`approvals`](/cli/approvals) - [`sandbox`](/cli/sandbox) - [`tui`](/cli/tui) - [`browser`](/cli/browser) @@ -143,6 +145,21 @@ clawdbot [--dev] [--profile ] start stop restart + service + gateway + status + install + uninstall + start + stop + restart + node + status + install + uninstall + start + stop + restart logs models list @@ -180,6 +197,10 @@ clawdbot [--dev] [--profile ] start stop restart + approvals + get + set + allowlist add|remove browser status start @@ -520,6 +541,9 @@ Options: - `--verbose` - `--debug` (alias for `--verbose`) +Notes: +- Overview includes Gateway + Node service status when available. + ### Usage tracking Clawdbot can surface provider usage/quota when OAuth/API creds are available. diff --git a/docs/cli/node.md b/docs/cli/node.md index 8433499e3..367d13adb 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -43,6 +43,8 @@ Install a headless node host as a user service. ```bash clawdbot node daemon install --host --port 18790 +# or +clawdbot service node install --host --port 18790 ``` Options: @@ -58,6 +60,8 @@ Options: Manage the service: ```bash +clawdbot node status +clawdbot service node status clawdbot node daemon status clawdbot node daemon start clawdbot node daemon stop @@ -83,3 +87,4 @@ The node host stores its node id + token in `~/.clawdbot/node.json`. - `~/.clawdbot/exec-approvals.json` - [Exec approvals](/tools/exec-approvals) +- `clawdbot approvals --node ` (edit from the Gateway) diff --git a/docs/cli/service.md b/docs/cli/service.md new file mode 100644 index 000000000..de6d199fc --- /dev/null +++ b/docs/cli/service.md @@ -0,0 +1,50 @@ +--- +summary: "CLI reference for `clawdbot service` (manage gateway + node services)" +read_when: + - You want to manage Gateway or node services cross-platform + - You want a single surface for start/stop/install/uninstall +--- + +# `clawdbot service` + +Manage the **Gateway** service and **node host** services. + +Related: +- Gateway daemon (legacy alias): [Daemon](/cli/daemon) +- Node host: [Node](/cli/node) + +## Gateway service + +```bash +clawdbot service gateway status +clawdbot service gateway install --port 18789 +clawdbot service gateway start +clawdbot service gateway stop +clawdbot service gateway restart +clawdbot service gateway uninstall +``` + +Notes: +- `service gateway status` supports `--json` and `--deep` for system checks. +- `service gateway install` supports `--runtime node|bun` and `--token`. + +## Node host service + +```bash +clawdbot service node status +clawdbot service node install --host --port 18790 +clawdbot service node start +clawdbot service node stop +clawdbot service node restart +clawdbot service node uninstall +``` + +Notes: +- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`, + and TLS options (`--tls`, `--tls-fingerprint`). + +## Aliases + +- `clawdbot daemon …` → `clawdbot service gateway …` +- `clawdbot node daemon …` → `clawdbot service node …` +- `clawdbot node status` → `clawdbot service node status` diff --git a/docs/cli/status.md b/docs/cli/status.md index c8794429c..f69614b2d 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -19,4 +19,5 @@ clawdbot status --usage Notes: - `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal). - Output includes per-agent session stores when multiple agents are configured. +- Overview includes Gateway + Node service install/runtime status when available. - Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)). diff --git a/docs/docs.json b/docs/docs.json index b7ac1375b..73e67a310 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -822,8 +822,10 @@ "cli/models", "cli/logs", "cli/nodes", + "cli/approvals", "cli/gateway", "cli/daemon", + "cli/service", "cli/tui", "cli/voicecall", "cli/wake", diff --git a/docs/nodes/index.md b/docs/nodes/index.md index f0ab9ec98..aac3fcf0b 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -149,8 +149,8 @@ Notes: ## System commands (node host / mac node) -The macOS node exposes `system.run` and `system.notify`. The headless node host -exposes `system.run` and `system.which`. +The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`. +The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`. Examples: diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index fdf131d0e..8f569e425 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -107,8 +107,12 @@ overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata per pattern so you can keep the list tidy. -Note: the Control UI edits the approvals file on the **Gateway host**. For a -headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly. +The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes +must advertise `system.execApprovals.get/set` (macOS app or headless node host). +If a node does not advertise exec approvals yet, edit its local +`~/.clawdbot/exec-approvals.json` directly. + +CLI: `clawdbot approvals` supports gateway or node editing (see [Approvals CLI](/cli/approvals)). ## Approval flow diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 645b3ee68..b1102c47d 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -36,7 +36,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on - Cron jobs: list/add/run/enable/disable + run history (`cron.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`) - Nodes: list + caps (`node.list`) -- Exec approvals: edit allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`) +- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`) - Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`) - Config: apply + restart with validation (`config.apply`) and wake the last active session - Config writes include a base-hash guard to prevent clobbering concurrent edits diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 0847273f4..3713e5bf4 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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"; diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts new file mode 100644 index 000000000..8f4261e7d --- /dev/null +++ b/src/cli/exec-approvals-cli.test.ts @@ -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( + "./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); + }); +}); diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts new file mode 100644 index 000000000..64e932969 --- /dev/null +++ b/src/cli/exec-approvals-cli.ts @@ -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 { + 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 { + 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 { + 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 { + 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 ", "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 ", "Target node id/name/IP (defaults to gateway)") + .option("--file ", "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 ") + .description("Add a glob pattern to an allowlist") + .option("--node ", "Target node id/name/IP (defaults to gateway)") + .option("--agent ", "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 ") + .description("Remove a glob pattern from an allowlist") + .option("--node ", "Target node id/name/IP (defaults to gateway)") + .option("--agent ", "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); +} diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index f0ede7318..e7d28c7f0 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -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") diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 16d9c29bf..6ac2fd58f 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -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); diff --git a/src/cli/service-cli.coverage.test.ts b/src/cli/service-cli.coverage.test.ts new file mode 100644 index 000000000..e0bb6604a --- /dev/null +++ b/src/cli/service-cli.coverage.test.ts @@ -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); + }); +}); diff --git a/src/cli/service-cli.ts b/src/cli/service-cli.ts new file mode 100644 index 000000000..50fe1a087 --- /dev/null +++ b/src/cli/service-cli.ts @@ -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 ", "Gateway WebSocket URL (defaults to config/remote/local)") + .option("--token ", "Gateway token (if required)") + .option("--password ", "Gateway password (password auth)") + .option("--timeout ", "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 ", "Gateway port") + .option("--runtime ", "Service runtime (node|bun). Default: node") + .option("--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 ", "Gateway bridge host") + .option("--port ", "Gateway bridge port") + .option("--tls", "Use TLS for the bridge connection", false) + .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") + .option("--node-id ", "Override node id (clears pairing token)") + .option("--display-name ", "Override node display name") + .option("--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(); +} diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index dbcf62211..f65a3d164 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -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`, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 0c1e7b667..d87eb1817 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -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 }, diff --git a/src/commands/status.daemon.ts b/src/commands/status.daemon.ts index ecd9711c4..91cbab769 100644 --- a/src/commands/status.daemon.ts +++ b/src/commands/status.daemon.ts @@ -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 { 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 { + return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon"); +} + +export async function getNodeDaemonStatusSummary(): Promise { + return await buildDaemonStatusSummary(resolveNodeService(), "Node"); +} diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index f81bbf4ec..e80c0d84f 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -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 () => { diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index d90a5461e..983e94ed8 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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( ExecApprovalsSetParamsSchema, ); +export const validateExecApprovalsNodeGetParams = ajv.compile( + ExecApprovalsNodeGetParamsSchema, +); +export const validateExecApprovalsNodeSetParams = ajv.compile( + ExecApprovalsNodeSetParamsSchema, +); export const validateLogsTailParams = ajv.compile(LogsTailParamsSchema); export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema); export const validateChatSendParams = ajv.compile(ChatSendParamsSchema); diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index c9f1f80db..ac744fdb7 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -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 }, +); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 4177069bb..88f8f7cbc 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -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 = { LogsTailResult: LogsTailResultSchema, ExecApprovalsGetParams: ExecApprovalsGetParamsSchema, ExecApprovalsSetParams: ExecApprovalsSetParamsSchema, + ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema, + ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema, ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema, ChatHistoryParams: ChatHistoryParamsSchema, ChatSendParams: ChatSendParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index cc54d34ac..cbcb165d9 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -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; export type LogsTailResult = Static; export type ExecApprovalsGetParams = Static; export type ExecApprovalsSetParams = Static; +export type ExecApprovalsNodeGetParams = Static; +export type ExecApprovalsNodeSetParams = Static; export type ExecApprovalsSnapshot = Static; export type ChatAbortParams = Static; export type ChatInjectParams = Static; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 08ebd6e23..27ba19e53 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -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", diff --git a/src/gateway/server-methods/exec-approvals.ts b/src/gateway/server-methods/exec-approvals.ts index 2127752fd..73e6de660 100644 --- a/src/gateway/server-methods/exec-approvals.ts +++ b/src/gateway/server-methods/exec-approvals.ts @@ -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); + }); + }, }; diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts new file mode 100644 index 000000000..aa58e8321 --- /dev/null +++ b/src/infra/exec-approvals.test.ts @@ -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"); + }); +}); diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 968abcd67..1b186d7fe 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -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 { 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(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(frame.paramsJSON); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index fa4e0e50b..09dbd1832 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -310,9 +310,17 @@ export function renderApp(state: AppViewState) { execApprovalsSnapshot: state.execApprovalsSnapshot, execApprovalsForm: state.execApprovalsForm, execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, + execApprovalsTarget: state.execApprovalsTarget, + execApprovalsTargetNodeId: state.execApprovalsTargetNodeId, onRefresh: () => loadNodes(state), onLoadConfig: () => loadConfig(state), - onLoadExecApprovals: () => loadExecApprovals(state), + onLoadExecApprovals: () => { + const target = + state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId + ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } + : { kind: "gateway" as const }; + return loadExecApprovals(state, target); + }, onBindDefault: (nodeId) => { if (nodeId) { updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); @@ -329,6 +337,14 @@ export function renderApp(state: AppViewState) { } }, onSaveBindings: () => saveConfig(state), + onExecApprovalsTargetChange: (kind, nodeId) => { + state.execApprovalsTarget = kind; + state.execApprovalsTargetNodeId = nodeId; + state.execApprovalsSnapshot = null; + state.execApprovalsForm = null; + state.execApprovalsDirty = false; + state.execApprovalsSelectedAgent = null; + }, onExecApprovalsSelectAgent: (agentId) => { state.execApprovalsSelectedAgent = agentId; }, @@ -336,7 +352,13 @@ export function renderApp(state: AppViewState) { updateExecApprovalsFormValue(state, path, value), onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(state, path), - onSaveExecApprovals: () => saveExecApprovals(state), + onSaveExecApprovals: () => { + const target = + state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId + ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } + : { kind: "gateway" as const }; + return saveExecApprovals(state, target); + }, }) : nothing} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index f7e301faa..dc97d335f 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -54,6 +54,8 @@ export type AppViewState = { execApprovalsSnapshot: ExecApprovalsSnapshot | null; execApprovalsForm: ExecApprovalsFile | null; execApprovalsSelectedAgent: string | null; + execApprovalsTarget: "gateway" | "node"; + execApprovalsTargetNodeId: string | null; configLoading: boolean; configRaw: string; configValid: boolean | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 8a755902b..1c5f1fff3 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -114,6 +114,8 @@ export class ClawdbotApp extends LitElement { @state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null; @state() execApprovalsForm: ExecApprovalsFile | null = null; @state() execApprovalsSelectedAgent: string | null = null; + @state() execApprovalsTarget: "gateway" | "node" = "gateway"; + @state() execApprovalsTargetNodeId: string | null = null; @state() configLoading = false; @state() configRaw = "{\n}\n"; diff --git a/ui/src/ui/controllers/exec-approvals.ts b/ui/src/ui/controllers/exec-approvals.ts index 0f81fbd0b..4f59caae2 100644 --- a/ui/src/ui/controllers/exec-approvals.ts +++ b/ui/src/ui/controllers/exec-approvals.ts @@ -33,6 +33,10 @@ export type ExecApprovalsSnapshot = { file: ExecApprovalsFile; }; +export type ExecApprovalsTarget = + | { kind: "gateway" } + | { kind: "node"; nodeId: string }; + export type ExecApprovalsState = { client: GatewayBrowserClient | null; connected: boolean; @@ -45,16 +49,45 @@ export type ExecApprovalsState = { lastError: string | null; }; -export async function loadExecApprovals(state: ExecApprovalsState) { +function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): { + method: string; + params: Record; +} | null { + if (!target || target.kind === "gateway") { + return { method: "exec.approvals.get", params: {} }; + } + const nodeId = target.nodeId.trim(); + if (!nodeId) return null; + return { method: "exec.approvals.node.get", params: { nodeId } }; +} + +function resolveExecApprovalsSaveRpc( + target: ExecApprovalsTarget | null | undefined, + params: { file: ExecApprovalsFile; baseHash: string }, +): { method: string; params: Record } | null { + if (!target || target.kind === "gateway") { + return { method: "exec.approvals.set", params }; + } + const nodeId = target.nodeId.trim(); + if (!nodeId) return null; + return { method: "exec.approvals.node.set", params: { ...params, nodeId } }; +} + +export async function loadExecApprovals( + state: ExecApprovalsState, + target?: ExecApprovalsTarget | null, +) { if (!state.client || !state.connected) return; if (state.execApprovalsLoading) return; state.execApprovalsLoading = true; state.lastError = null; try { - const res = (await state.client.request( - "exec.approvals.get", - {}, - )) as ExecApprovalsSnapshot; + const rpc = resolveExecApprovalsRpc(target); + if (!rpc) { + state.lastError = "Select a node before loading exec approvals."; + return; + } + const res = (await state.client.request(rpc.method, rpc.params)) as ExecApprovalsSnapshot; applyExecApprovalsSnapshot(state, res); } catch (err) { state.lastError = String(err); @@ -73,7 +106,10 @@ export function applyExecApprovalsSnapshot( } } -export async function saveExecApprovals(state: ExecApprovalsState) { +export async function saveExecApprovals( + state: ExecApprovalsState, + target?: ExecApprovalsTarget | null, +) { if (!state.client || !state.connected) return; state.execApprovalsSaving = true; state.lastError = null; @@ -87,9 +123,14 @@ export async function saveExecApprovals(state: ExecApprovalsState) { state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}; - await state.client.request("exec.approvals.set", { file, baseHash }); + const rpc = resolveExecApprovalsSaveRpc(target, { file, baseHash }); + if (!rpc) { + state.lastError = "Select a node before saving exec approvals."; + return; + } + await state.client.request(rpc.method, rpc.params); state.execApprovalsDirty = false; - await loadExecApprovals(state); + await loadExecApprovals(state, target); } catch (err) { state.lastError = String(err); } finally { diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts index 7aac00c2d..43c6b3e7e 100644 --- a/ui/src/ui/views/nodes.ts +++ b/ui/src/ui/views/nodes.ts @@ -21,12 +21,15 @@ export type NodesProps = { execApprovalsSnapshot: ExecApprovalsSnapshot | null; execApprovalsForm: ExecApprovalsFile | null; execApprovalsSelectedAgent: string | null; + execApprovalsTarget: "gateway" | "node"; + execApprovalsTargetNodeId: string | null; onRefresh: () => void; onLoadConfig: () => void; onLoadExecApprovals: () => void; onBindDefault: (nodeId: string | null) => void; onBindAgent: (agentIndex: number, nodeId: string | null) => void; onSaveBindings: () => void; + onExecApprovalsTargetChange: (kind: "gateway" | "node", nodeId: string | null) => void; onExecApprovalsSelectAgent: (agentId: string) => void; onExecApprovalsPatch: (path: Array, value: unknown) => void; onExecApprovalsRemove: (path: Array) => void; @@ -103,6 +106,11 @@ type ExecApprovalsAgentOption = { isDefault?: boolean; }; +type ExecApprovalsTargetNode = { + id: string; + label: string; +}; + type ExecApprovalsState = { ready: boolean; disabled: boolean; @@ -115,7 +123,11 @@ type ExecApprovalsState = { selectedAgent: Record | null; agents: ExecApprovalsAgentOption[]; allowlist: ExecApprovalsAllowlistEntry[]; + target: "gateway" | "node"; + targetNodeId: string | null; + targetNodes: ExecApprovalsTargetNode[]; onSelectScope: (agentId: string) => void; + onSelectTarget: (kind: "gateway" | "node", nodeId: string | null) => void; onPatch: (path: Array, value: unknown) => void; onRemove: (path: Array) => void; onLoad: () => void; @@ -237,6 +249,15 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState { const ready = Boolean(form); const defaults = resolveExecApprovalsDefaults(form); const agents = resolveExecApprovalsAgents(props.configForm, form); + const targetNodes = resolveExecApprovalsNodes(props.nodes); + const target = props.execApprovalsTarget; + let targetNodeId = + target === "node" && props.execApprovalsTargetNodeId + ? props.execApprovalsTargetNodeId + : null; + if (target === "node" && targetNodeId && !targetNodes.some((node) => node.id === targetNodeId)) { + targetNodeId = null; + } const selectedScope = resolveExecApprovalsScope(props.execApprovalsSelectedAgent, agents); const selectedAgent = selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE @@ -259,7 +280,11 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState { selectedAgent, agents, allowlist, + target, + targetNodeId, + targetNodes, onSelectScope: props.onExecApprovalsSelectAgent, + onSelectTarget: props.onExecApprovalsTargetChange, onPatch: props.onExecApprovalsPatch, onRemove: props.onExecApprovalsRemove, onLoad: props.onLoadExecApprovals, @@ -350,6 +375,7 @@ function renderBindings(state: BindingState) { function renderExecApprovals(state: ExecApprovalsState) { const ready = state.ready; + const targetReady = state.target !== "node" || Boolean(state.targetNodeId); return html`
@@ -361,17 +387,19 @@ function renderExecApprovals(state: ExecApprovalsState) {
+ ${renderExecApprovalsTarget(state)} + ${!ready ? html`
Load exec approvals to edit allowlists.
-
` @@ -386,6 +414,73 @@ function renderExecApprovals(state: ExecApprovalsState) { `; } +function renderExecApprovalsTarget(state: ExecApprovalsState) { + const hasNodes = state.targetNodes.length > 0; + const nodeValue = state.targetNodeId ?? ""; + return html` +
+
+
+
Target
+
+ Gateway edits local approvals; node edits the selected node. +
+
+
+ + ${state.target === "node" + ? html` + + ` + : nothing} +
+
+ ${state.target === "node" && !hasNodes + ? html`
No nodes advertise exec approvals yet.
` + : nothing} +
+ `; +} + function renderExecApprovalsTabs(state: ExecApprovalsState) { return html`
@@ -747,6 +842,26 @@ function resolveExecNodes(nodes: Array>): BindingNode[] return list; } +function resolveExecApprovalsNodes(nodes: Array>): ExecApprovalsTargetNode[] { + const list: ExecApprovalsTargetNode[] = []; + for (const node of nodes) { + const commands = Array.isArray(node.commands) ? node.commands : []; + const supports = commands.some( + (cmd) => String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set", + ); + if (!supports) continue; + const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : ""; + if (!nodeId) continue; + const displayName = + typeof node.displayName === "string" && node.displayName.trim() + ? node.displayName.trim() + : nodeId; + list.push({ id: nodeId, label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}` }); + } + list.sort((a, b) => a.label.localeCompare(b.label)); + return list; +} + function resolveAgentBindings(config: Record | null): { defaultBinding?: string | null; agents: BindingAgent[];