feat: add exec approvals tooling and service status

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

View File

@@ -2,6 +2,15 @@
Docs: https://docs.clawd.bot 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 ## 2026.1.18-4
### Changes ### Changes

View File

@@ -1,3 +1,4 @@
import CryptoKit
import Foundation import Foundation
import OSLog import OSLog
import Security import Security
@@ -121,6 +122,13 @@ struct ExecApprovalsFile: Codable {
var agents: [String: ExecApprovalsAgent]? var agents: [String: ExecApprovalsAgent]?
} }
struct ExecApprovalsSnapshot: Codable {
var path: String
var exists: Bool
var hash: String
var file: ExecApprovalsFile
}
struct ExecApprovalsResolved { struct ExecApprovalsResolved {
let url: URL let url: URL
let socketPath: String let socketPath: String
@@ -153,6 +161,58 @@ enum ExecApprovalsStore {
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path 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 { static func loadFile() -> ExecApprovalsFile {
let url = self.fileURL() let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else { guard FileManager.default.fileExists(atPath: url.path) else {
@@ -372,6 +432,12 @@ enum ExecApprovalsStore {
return UUID().uuidString 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 { private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" { if trimmed == "~" {

View File

@@ -158,6 +158,8 @@ final class MacNodeModeCoordinator {
ClawdbotSystemCommand.notify.rawValue, ClawdbotSystemCommand.notify.rawValue,
ClawdbotSystemCommand.which.rawValue, ClawdbotSystemCommand.which.rawValue,
ClawdbotSystemCommand.run.rawValue, ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.execApprovalsGet.rawValue,
ClawdbotSystemCommand.execApprovalsSet.rawValue,
] ]
let capsSet = Set(caps) let capsSet = Set(caps)

View File

@@ -64,6 +64,10 @@ actor MacNodeRuntime {
return try await self.handleSystemWhich(req) return try await self.handleSystemWhich(req)
case ClawdbotSystemCommand.notify.rawValue: case ClawdbotSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req) 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: default:
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") 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) 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 { private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
guard let sender = self.eventSender else { return } guard let sender = self.eventSender else { return }
guard let data = try? JSONEncoder().encode(payload), guard let data = try? JSONEncoder().encode(payload),

View File

@@ -4,6 +4,8 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
case run = "system.run" case run = "system.run"
case which = "system.which" case which = "system.which"
case notify = "system.notify" case notify = "system.notify"
case execApprovalsGet = "system.execApprovals.get"
case execApprovalsSet = "system.execApprovals.set"
} }
public enum ClawdbotNotificationPriority: String, Codable, Sendable { public enum ClawdbotNotificationPriority: String, Codable, Sendable {

44
docs/cli/approvals.md Normal file
View File

@@ -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 nodes approvals.
Related:
- Exec approvals: [Exec approvals](/tools/exec-approvals)
- Nodes: [Nodes](/nodes)
## Common commands
```bash
clawdbot approvals get
clawdbot approvals get --node <id|name|ip>
```
## Replace approvals from a file
```bash
clawdbot approvals set --file ./exec-approvals.json
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
```
## Allowlist helpers
```bash
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/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`.

View File

@@ -9,6 +9,9 @@ read_when:
Manage the Gateway daemon (background service). Manage the Gateway daemon (background service).
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
as a legacy alias for compatibility.
Related: Related:
- Gateway CLI: [Gateway](/cli/gateway) - Gateway CLI: [Gateway](/cli/gateway)
- macOS platform notes: [macOS](/platforms/macos) - macOS platform notes: [macOS](/platforms/macos)

View File

@@ -29,11 +29,13 @@ This page describes the current CLI behavior. If commands change, update this do
- [`sessions`](/cli/sessions) - [`sessions`](/cli/sessions)
- [`gateway`](/cli/gateway) - [`gateway`](/cli/gateway)
- [`daemon`](/cli/daemon) - [`daemon`](/cli/daemon)
- [`service`](/cli/service)
- [`logs`](/cli/logs) - [`logs`](/cli/logs)
- [`models`](/cli/models) - [`models`](/cli/models)
- [`memory`](/cli/memory) - [`memory`](/cli/memory)
- [`nodes`](/cli/nodes) - [`nodes`](/cli/nodes)
- [`node`](/cli/node) - [`node`](/cli/node)
- [`approvals`](/cli/approvals)
- [`sandbox`](/cli/sandbox) - [`sandbox`](/cli/sandbox)
- [`tui`](/cli/tui) - [`tui`](/cli/tui)
- [`browser`](/cli/browser) - [`browser`](/cli/browser)
@@ -143,6 +145,21 @@ clawdbot [--dev] [--profile <name>] <command>
start start
stop stop
restart restart
service
gateway
status
install
uninstall
start
stop
restart
node
status
install
uninstall
start
stop
restart
logs logs
models models
list list
@@ -180,6 +197,10 @@ clawdbot [--dev] [--profile <name>] <command>
start start
stop stop
restart restart
approvals
get
set
allowlist add|remove
browser browser
status status
start start
@@ -520,6 +541,9 @@ Options:
- `--verbose` - `--verbose`
- `--debug` (alias for `--verbose`) - `--debug` (alias for `--verbose`)
Notes:
- Overview includes Gateway + Node service status when available.
### Usage tracking ### Usage tracking
Clawdbot can surface provider usage/quota when OAuth/API creds are available. Clawdbot can surface provider usage/quota when OAuth/API creds are available.

View File

@@ -43,6 +43,8 @@ Install a headless node host as a user service.
```bash ```bash
clawdbot node daemon install --host <gateway-host> --port 18790 clawdbot node daemon install --host <gateway-host> --port 18790
# or
clawdbot service node install --host <gateway-host> --port 18790
``` ```
Options: Options:
@@ -58,6 +60,8 @@ Options:
Manage the service: Manage the service:
```bash ```bash
clawdbot node status
clawdbot service node status
clawdbot node daemon status clawdbot node daemon status
clawdbot node daemon start clawdbot node daemon start
clawdbot node daemon stop clawdbot node daemon stop
@@ -83,3 +87,4 @@ The node host stores its node id + token in `~/.clawdbot/node.json`.
- `~/.clawdbot/exec-approvals.json` - `~/.clawdbot/exec-approvals.json`
- [Exec approvals](/tools/exec-approvals) - [Exec approvals](/tools/exec-approvals)
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)

50
docs/cli/service.md Normal file
View File

@@ -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 <gateway-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`

View File

@@ -19,4 +19,5 @@ clawdbot status --usage
Notes: Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal). - `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- Output includes per-agent session stores when multiple agents are configured. - 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)). - Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).

View File

@@ -822,8 +822,10 @@
"cli/models", "cli/models",
"cli/logs", "cli/logs",
"cli/nodes", "cli/nodes",
"cli/approvals",
"cli/gateway", "cli/gateway",
"cli/daemon", "cli/daemon",
"cli/service",
"cli/tui", "cli/tui",
"cli/voicecall", "cli/voicecall",
"cli/wake", "cli/wake",

View File

@@ -149,8 +149,8 @@ Notes:
## System commands (node host / mac node) ## System commands (node host / mac node)
The macOS node exposes `system.run` and `system.notify`. The headless node host The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`.
exposes `system.run` and `system.which`. The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`.
Examples: Examples:

View File

@@ -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 add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
per pattern so you can keep the list tidy. per pattern so you can keep the list tidy.
Note: the Control UI edits the approvals file on the **Gateway host**. For a The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes
headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly. 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 ## Approval flow

View File

@@ -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.*`) - Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`) - 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: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session - 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 - Config writes include a base-hash guard to prevent clobbering concurrent edits

View File

@@ -50,15 +50,15 @@ import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
const DEFAULT_MAX_OUTPUT = clampNumber( const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
30_000, 200_000,
1_000, 1_000,
150_000, 200_000,
); );
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber( const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"), readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
30_000, 200_000,
1_000, 1_000,
150_000, 200_000,
); );
const DEFAULT_PATH = const DEFAULT_PATH =
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";

View File

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

View File

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

View File

@@ -55,6 +55,14 @@ export function registerNodeCli(program: Command) {
.command("daemon") .command("daemon")
.description("Manage the headless node daemon service (launchd/systemd/schtasks)"); .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 daemon
.command("status") .command("status")
.description("Show node daemon status") .description("Show node daemon status")

View File

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

View File

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

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

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

View File

@@ -2,7 +2,9 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { withProgress } from "../cli/progress.js"; import { withProgress } from "../cli/progress.js";
import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js"; import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } 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 { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { probeGateway } from "../gateway/probe.js"; import { probeGateway } from "../gateway/probe.js";
@@ -130,10 +132,9 @@ export async function statusAllCommand(
const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null); const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null);
progress.tick(); progress.tick();
progress.setLabel("Checking daemon…"); progress.setLabel("Checking services…");
const daemon = await (async () => { const readServiceSummary = async (service: GatewayService) => {
try { try {
const service = resolveGatewayService();
const [loaded, runtimeInfo, command] = await Promise.all([ const [loaded, runtimeInfo, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false), service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined), service.readRuntime(process.env).catch(() => undefined),
@@ -150,7 +151,9 @@ export async function statusAllCommand(
} catch { } catch {
return null; return null;
} }
})(); };
const daemon = await readServiceSummary(resolveGatewayService());
const nodeService = await readServiceSummary(resolveNodeService());
progress.tick(); progress.tick();
progress.setLabel("Scanning agents…"); progress.setLabel("Scanning agents…");
@@ -340,13 +343,22 @@ export async function statusAllCommand(
: { Item: "Gateway self", Value: "unknown" }, : { Item: "Gateway self", Value: "unknown" },
daemon daemon
? { ? {
Item: "Daemon", Item: "Gateway service",
Value: Value:
daemon.installed === false daemon.installed === false
? `${daemon.label} not installed` ? `${daemon.label} not installed`
: `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`, : `${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", Item: "Agents",
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`, Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,

View File

@@ -15,7 +15,7 @@ import {
} from "../memory/status-format.js"; } from "../memory/status-format.js";
import { formatHealthChannelLines, type HealthSummary } from "./health.js"; import { formatHealthChannelLines, type HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js"; import { resolveControlUiLinks } from "./onboard-helpers.js";
import { getDaemonStatusSummary } from "./status.daemon.js"; import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
import { import {
formatAge, formatAge,
formatDuration, formatDuration,
@@ -116,6 +116,10 @@ export async function statusCommand(
: undefined; : undefined;
if (opts.json) { if (opts.json) {
const [daemon, nodeDaemon] = await Promise.all([
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
runtime.log( runtime.log(
JSON.stringify( JSON.stringify(
{ {
@@ -134,6 +138,8 @@ export async function statusCommand(
self: gatewaySelf, self: gatewaySelf,
error: gatewayProbe?.error ?? null, error: gatewayProbe?.error ?? null,
}, },
gatewayService: daemon,
nodeService: nodeDaemon,
agents: agentStatus, agents: agentStatus,
securityAudit, securityAudit,
...(health || usage ? { health, usage } : {}), ...(health || usage ? { health, usage } : {}),
@@ -210,12 +216,20 @@ export async function statusCommand(
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`; return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
})(); })();
const daemon = await getDaemonStatusSummary(); const [daemon, nodeDaemon] = await Promise.all([
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
const daemonValue = (() => { const daemonValue = (() => {
if (daemon.installed === false) return `${daemon.label} not installed`; if (daemon.installed === false) return `${daemon.label} not installed`;
const installedPrefix = daemon.installed === true ? "installed · " : ""; const installedPrefix = daemon.installed === true ? "installed · " : "";
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`; 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 defaults = summary.sessions.defaults;
const defaultCtx = defaults.contextTokens const defaultCtx = defaults.contextTokens
@@ -298,7 +312,8 @@ export async function statusCommand(
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine, Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
}, },
{ Item: "Gateway", Value: gatewayValue }, { Item: "Gateway", Value: gatewayValue },
{ Item: "Daemon", Value: daemonValue }, { Item: "Gateway service", Value: daemonValue },
{ Item: "Node service", Value: nodeDaemonValue },
{ Item: "Agents", Value: agentsValue }, { Item: "Agents", Value: agentsValue },
{ Item: "Memory", Value: memoryValue }, { Item: "Memory", Value: memoryValue },
{ Item: "Probes", Value: probesValue }, { Item: "Probes", Value: probesValue },

View File

@@ -1,14 +1,20 @@
import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import { resolveNodeService } from "../daemon/node-service.js";
import { formatDaemonRuntimeShort } from "./status.format.js"; import { formatDaemonRuntimeShort } from "./status.format.js";
export async function getDaemonStatusSummary(): Promise<{ type DaemonStatusSummary = {
label: string; label: string;
installed: boolean | null; installed: boolean | null;
loadedText: string; loadedText: string;
runtimeShort: string | null; runtimeShort: string | null;
}> { };
async function buildDaemonStatusSummary(
service: GatewayService,
fallbackLabel: string,
): Promise<DaemonStatusSummary> {
try { try {
const service = resolveGatewayService();
const [loaded, runtime, command] = await Promise.all([ const [loaded, runtime, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false), service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined), service.readRuntime(process.env).catch(() => undefined),
@@ -20,10 +26,18 @@ export async function getDaemonStatusSummary(): Promise<{
return { label: service.label, installed, loadedText, runtimeShort }; return { label: service.label, installed, loadedText, runtimeShort };
} catch { } catch {
return { return {
label: "Daemon", label: fallbackLabel,
installed: null, installed: null,
loadedText: "unknown", loadedText: "unknown",
runtimeShort: null, runtimeShort: null,
}; };
} }
} }
export async function getDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon");
}
export async function getNodeDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveNodeService(), "Node");
}

View File

@@ -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", () => ({ vi.mock("../security/audit.js", () => ({
runSecurityAudit: mocks.runSecurityAudit, runSecurityAudit: mocks.runSecurityAudit,
})); }));
@@ -273,6 +286,8 @@ describe("statusCommand", () => {
expect(payload.sessions.recent[0].flags).toContain("verbose:on"); expect(payload.sessions.recent[0].flags).toContain("verbose:on");
expect(payload.securityAudit.summary.critical).toBe(1); expect(payload.securityAudit.summary.critical).toBe(1);
expect(payload.securityAudit.summary.warn).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 () => { it("prints formatted lines otherwise", async () => {

View File

@@ -58,6 +58,10 @@ import {
CronUpdateParamsSchema, CronUpdateParamsSchema,
type ExecApprovalsGetParams, type ExecApprovalsGetParams,
ExecApprovalsGetParamsSchema, ExecApprovalsGetParamsSchema,
type ExecApprovalsNodeGetParams,
ExecApprovalsNodeGetParamsSchema,
type ExecApprovalsNodeSetParams,
ExecApprovalsNodeSetParamsSchema,
type ExecApprovalsSetParams, type ExecApprovalsSetParams,
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
type ExecApprovalsSnapshot, type ExecApprovalsSnapshot,
@@ -241,6 +245,12 @@ export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>( export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
); );
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
ExecApprovalsNodeGetParamsSchema,
);
export const validateExecApprovalsNodeSetParams = ajv.compile<ExecApprovalsNodeSetParams>(
ExecApprovalsNodeSetParamsSchema,
);
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema); export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema); export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema); export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);

View File

@@ -70,3 +70,19 @@ export const ExecApprovalsSetParamsSchema = Type.Object(
}, },
{ additionalProperties: false }, { 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 },
);

View File

@@ -49,6 +49,8 @@ import {
} from "./cron.js"; } from "./cron.js";
import { import {
ExecApprovalsGetParamsSchema, ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema, ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js"; } from "./exec-approvals.js";
@@ -177,6 +179,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
LogsTailResult: LogsTailResultSchema, LogsTailResult: LogsTailResultSchema,
ExecApprovalsGetParams: ExecApprovalsGetParamsSchema, ExecApprovalsGetParams: ExecApprovalsGetParamsSchema,
ExecApprovalsSetParams: ExecApprovalsSetParamsSchema, ExecApprovalsSetParams: ExecApprovalsSetParamsSchema,
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema, ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
ChatHistoryParams: ChatHistoryParamsSchema, ChatHistoryParams: ChatHistoryParamsSchema,
ChatSendParams: ChatSendParamsSchema, ChatSendParams: ChatSendParamsSchema,

View File

@@ -47,6 +47,8 @@ import type {
} from "./cron.js"; } from "./cron.js";
import type { import type {
ExecApprovalsGetParamsSchema, ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema, ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js"; } from "./exec-approvals.js";
@@ -170,6 +172,8 @@ export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
export type LogsTailResult = Static<typeof LogsTailResultSchema>; export type LogsTailResult = Static<typeof LogsTailResultSchema>;
export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>; export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>;
export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>; 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 ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>; export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>; export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;

View File

@@ -14,6 +14,8 @@ const BASE_METHODS = [
"config.schema", "config.schema",
"exec.approvals.get", "exec.approvals.get",
"exec.approvals.set", "exec.approvals.set",
"exec.approvals.node.get",
"exec.approvals.node.set",
"wizard.start", "wizard.start",
"wizard.next", "wizard.next",
"wizard.cancel", "wizard.cancel",

View File

@@ -12,8 +12,11 @@ import {
errorShape, errorShape,
formatValidationErrors, formatValidationErrors,
validateExecApprovalsGetParams, validateExecApprovalsGetParams,
validateExecApprovalsNodeGetParams,
validateExecApprovalsNodeSetParams,
validateExecApprovalsSetParams, validateExecApprovalsSetParams,
} from "../protocol/index.js"; } from "../protocol/index.js";
import { respondUnavailableOnThrow, safeParseJson } from "./nodes.helpers.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js";
function resolveBaseHash(params: unknown): string | null { function resolveBaseHash(params: unknown): string | null {
@@ -152,4 +155,94 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
undefined, 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);
});
},
}; };

View 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");
});
});

View File

@@ -8,10 +8,16 @@ import type { BridgeInvokeRequestFrame } from "../infra/bridge/server/types.js";
import { import {
addAllowlistEntry, addAllowlistEntry,
matchAllowlist, matchAllowlist,
normalizeExecApprovals,
recordAllowlistUse, recordAllowlistUse,
requestExecApprovalViaSocket, requestExecApprovalViaSocket,
resolveCommandResolution, resolveCommandResolution,
resolveExecApprovals, resolveExecApprovals,
ensureExecApprovals,
readExecApprovalsSnapshot,
resolveExecApprovalsSocketPath,
saveExecApprovals,
type ExecApprovalsFile,
} from "../infra/exec-approvals.js"; } from "../infra/exec-approvals.js";
import { getMachineDisplayName } from "../infra/machine-name.js"; import { getMachineDisplayName } from "../infra/machine-name.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
@@ -43,6 +49,18 @@ type SystemWhichParams = {
bins: string[]; bins: string[];
}; };
type SystemExecApprovalsSetParams = {
file: ExecApprovalsFile;
baseHash?: string | null;
};
type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
type RunResult = { type RunResult = {
exitCode?: number; exitCode?: number;
timedOut: boolean; 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 }; 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( async function runCommand(
argv: string[], argv: string[],
cwd: string | undefined, cwd: string | undefined,
@@ -306,7 +349,12 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
deviceFamily: os.platform(), deviceFamily: os.platform(),
modelIdentifier: os.hostname(), modelIdentifier: os.hostname(),
caps: ["system"], caps: ["system"],
commands: ["system.run", "system.which"], commands: [
"system.run",
"system.which",
"system.execApprovals.get",
"system.execApprovals.set",
],
onPairToken: async (token) => { onPairToken: async (token) => {
config.token = token; config.token = token;
await saveNodeHostConfig(config); await saveNodeHostConfig(config);
@@ -355,6 +403,80 @@ async function handleInvoke(
skillBins: SkillBinsCache, skillBins: SkillBinsCache,
) { ) {
const command = String(frame.command ?? ""); 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") { if (command === "system.which") {
try { try {
const params = decodeParams<SystemWhichParams>(frame.paramsJSON); const params = decodeParams<SystemWhichParams>(frame.paramsJSON);

View File

@@ -310,9 +310,17 @@ export function renderApp(state: AppViewState) {
execApprovalsSnapshot: state.execApprovalsSnapshot, execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm, execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state), onRefresh: () => loadNodes(state),
onLoadConfig: () => loadConfig(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) => { onBindDefault: (nodeId) => {
if (nodeId) { if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
@@ -329,6 +337,14 @@ export function renderApp(state: AppViewState) {
} }
}, },
onSaveBindings: () => saveConfig(state), 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) => { onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId; state.execApprovalsSelectedAgent = agentId;
}, },
@@ -336,7 +352,13 @@ export function renderApp(state: AppViewState) {
updateExecApprovalsFormValue(state, path, value), updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) => onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, 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} : nothing}

View File

@@ -54,6 +54,8 @@ export type AppViewState = {
execApprovalsSnapshot: ExecApprovalsSnapshot | null; execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null; execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null; execApprovalsSelectedAgent: string | null;
execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null;
configLoading: boolean; configLoading: boolean;
configRaw: string; configRaw: string;
configValid: boolean | null; configValid: boolean | null;

View File

@@ -114,6 +114,8 @@ export class ClawdbotApp extends LitElement {
@state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null; @state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null;
@state() execApprovalsForm: ExecApprovalsFile | null = null; @state() execApprovalsForm: ExecApprovalsFile | null = null;
@state() execApprovalsSelectedAgent: string | null = null; @state() execApprovalsSelectedAgent: string | null = null;
@state() execApprovalsTarget: "gateway" | "node" = "gateway";
@state() execApprovalsTargetNodeId: string | null = null;
@state() configLoading = false; @state() configLoading = false;
@state() configRaw = "{\n}\n"; @state() configRaw = "{\n}\n";

View File

@@ -33,6 +33,10 @@ export type ExecApprovalsSnapshot = {
file: ExecApprovalsFile; file: ExecApprovalsFile;
}; };
export type ExecApprovalsTarget =
| { kind: "gateway" }
| { kind: "node"; nodeId: string };
export type ExecApprovalsState = { export type ExecApprovalsState = {
client: GatewayBrowserClient | null; client: GatewayBrowserClient | null;
connected: boolean; connected: boolean;
@@ -45,16 +49,45 @@ export type ExecApprovalsState = {
lastError: string | null; lastError: string | null;
}; };
export async function loadExecApprovals(state: ExecApprovalsState) { function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): {
method: string;
params: Record<string, unknown>;
} | 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<string, unknown> } | 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.client || !state.connected) return;
if (state.execApprovalsLoading) return; if (state.execApprovalsLoading) return;
state.execApprovalsLoading = true; state.execApprovalsLoading = true;
state.lastError = null; state.lastError = null;
try { try {
const res = (await state.client.request( const rpc = resolveExecApprovalsRpc(target);
"exec.approvals.get", if (!rpc) {
{}, state.lastError = "Select a node before loading exec approvals.";
)) as ExecApprovalsSnapshot; return;
}
const res = (await state.client.request(rpc.method, rpc.params)) as ExecApprovalsSnapshot;
applyExecApprovalsSnapshot(state, res); applyExecApprovalsSnapshot(state, res);
} catch (err) { } catch (err) {
state.lastError = String(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; if (!state.client || !state.connected) return;
state.execApprovalsSaving = true; state.execApprovalsSaving = true;
state.lastError = null; state.lastError = null;
@@ -87,9 +123,14 @@ export async function saveExecApprovals(state: ExecApprovalsState) {
state.execApprovalsForm ?? state.execApprovalsForm ??
state.execApprovalsSnapshot?.file ?? 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; state.execApprovalsDirty = false;
await loadExecApprovals(state); await loadExecApprovals(state, target);
} catch (err) { } catch (err) {
state.lastError = String(err); state.lastError = String(err);
} finally { } finally {

View File

@@ -21,12 +21,15 @@ export type NodesProps = {
execApprovalsSnapshot: ExecApprovalsSnapshot | null; execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null; execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null; execApprovalsSelectedAgent: string | null;
execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null;
onRefresh: () => void; onRefresh: () => void;
onLoadConfig: () => void; onLoadConfig: () => void;
onLoadExecApprovals: () => void; onLoadExecApprovals: () => void;
onBindDefault: (nodeId: string | null) => void; onBindDefault: (nodeId: string | null) => void;
onBindAgent: (agentIndex: number, nodeId: string | null) => void; onBindAgent: (agentIndex: number, nodeId: string | null) => void;
onSaveBindings: () => void; onSaveBindings: () => void;
onExecApprovalsTargetChange: (kind: "gateway" | "node", nodeId: string | null) => void;
onExecApprovalsSelectAgent: (agentId: string) => void; onExecApprovalsSelectAgent: (agentId: string) => void;
onExecApprovalsPatch: (path: Array<string | number>, value: unknown) => void; onExecApprovalsPatch: (path: Array<string | number>, value: unknown) => void;
onExecApprovalsRemove: (path: Array<string | number>) => void; onExecApprovalsRemove: (path: Array<string | number>) => void;
@@ -103,6 +106,11 @@ type ExecApprovalsAgentOption = {
isDefault?: boolean; isDefault?: boolean;
}; };
type ExecApprovalsTargetNode = {
id: string;
label: string;
};
type ExecApprovalsState = { type ExecApprovalsState = {
ready: boolean; ready: boolean;
disabled: boolean; disabled: boolean;
@@ -115,7 +123,11 @@ type ExecApprovalsState = {
selectedAgent: Record<string, unknown> | null; selectedAgent: Record<string, unknown> | null;
agents: ExecApprovalsAgentOption[]; agents: ExecApprovalsAgentOption[];
allowlist: ExecApprovalsAllowlistEntry[]; allowlist: ExecApprovalsAllowlistEntry[];
target: "gateway" | "node";
targetNodeId: string | null;
targetNodes: ExecApprovalsTargetNode[];
onSelectScope: (agentId: string) => void; onSelectScope: (agentId: string) => void;
onSelectTarget: (kind: "gateway" | "node", nodeId: string | null) => void;
onPatch: (path: Array<string | number>, value: unknown) => void; onPatch: (path: Array<string | number>, value: unknown) => void;
onRemove: (path: Array<string | number>) => void; onRemove: (path: Array<string | number>) => void;
onLoad: () => void; onLoad: () => void;
@@ -237,6 +249,15 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const ready = Boolean(form); const ready = Boolean(form);
const defaults = resolveExecApprovalsDefaults(form); const defaults = resolveExecApprovalsDefaults(form);
const agents = resolveExecApprovalsAgents(props.configForm, 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 selectedScope = resolveExecApprovalsScope(props.execApprovalsSelectedAgent, agents);
const selectedAgent = const selectedAgent =
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
@@ -259,7 +280,11 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
selectedAgent, selectedAgent,
agents, agents,
allowlist, allowlist,
target,
targetNodeId,
targetNodes,
onSelectScope: props.onExecApprovalsSelectAgent, onSelectScope: props.onExecApprovalsSelectAgent,
onSelectTarget: props.onExecApprovalsTargetChange,
onPatch: props.onExecApprovalsPatch, onPatch: props.onExecApprovalsPatch,
onRemove: props.onExecApprovalsRemove, onRemove: props.onExecApprovalsRemove,
onLoad: props.onLoadExecApprovals, onLoad: props.onLoadExecApprovals,
@@ -350,6 +375,7 @@ function renderBindings(state: BindingState) {
function renderExecApprovals(state: ExecApprovalsState) { function renderExecApprovals(state: ExecApprovalsState) {
const ready = state.ready; const ready = state.ready;
const targetReady = state.target !== "node" || Boolean(state.targetNodeId);
return html` return html`
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between; align-items: center;"> <div class="row" style="justify-content: space-between; align-items: center;">
@@ -361,17 +387,19 @@ function renderExecApprovals(state: ExecApprovalsState) {
</div> </div>
<button <button
class="btn" class="btn"
?disabled=${state.disabled || !state.dirty} ?disabled=${state.disabled || !state.dirty || !targetReady}
@click=${state.onSave} @click=${state.onSave}
> >
${state.saving ? "Saving…" : "Save"} ${state.saving ? "Saving…" : "Save"}
</button> </button>
</div> </div>
${renderExecApprovalsTarget(state)}
${!ready ${!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;"> ? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div> <div class="muted">Load exec approvals to edit allowlists.</div>
<button class="btn" ?disabled=${state.loading} @click=${state.onLoad}> <button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"} ${state.loading ? "Loading…" : "Load approvals"}
</button> </button>
</div>` </div>`
@@ -386,6 +414,73 @@ function renderExecApprovals(state: ExecApprovalsState) {
`; `;
} }
function renderExecApprovalsTarget(state: ExecApprovalsState) {
const hasNodes = state.targetNodes.length > 0;
const nodeValue = state.targetNodeId ?? "";
return html`
<div class="list" style="margin-top: 12px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Target</div>
<div class="list-sub">
Gateway edits local approvals; node edits the selected node.
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Host</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (value === "node") {
const first = state.targetNodes[0]?.id ?? null;
state.onSelectTarget("node", nodeValue || first);
} else {
state.onSelectTarget("gateway", null);
}
}}
>
<option value="gateway" ?selected=${state.target === "gateway"}>Gateway</option>
<option value="node" ?selected=${state.target === "node"}>Node</option>
</select>
</label>
${state.target === "node"
? html`
<label class="field">
<span>Node</span>
<select
?disabled=${state.disabled || !hasNodes}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value.trim();
state.onSelectTarget("node", value ? value : null);
}}
>
<option value="" ?selected=${nodeValue === ""}>Select node</option>
${state.targetNodes.map(
(node) =>
html`<option
value=${node.id}
?selected=${nodeValue === node.id}
>
${node.label}
</option>`,
)}
</select>
</label>
`
: nothing}
</div>
</div>
${state.target === "node" && !hasNodes
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
: nothing}
</div>
`;
}
function renderExecApprovalsTabs(state: ExecApprovalsState) { function renderExecApprovalsTabs(state: ExecApprovalsState) {
return html` return html`
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;"> <div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
@@ -747,6 +842,26 @@ function resolveExecNodes(nodes: Array<Record<string, unknown>>): BindingNode[]
return list; return list;
} }
function resolveExecApprovalsNodes(nodes: Array<Record<string, unknown>>): 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<string, unknown> | null): { function resolveAgentBindings(config: Record<string, unknown> | null): {
defaultBinding?: string | null; defaultBinding?: string | null;
agents: BindingAgent[]; agents: BindingAgent[];