mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-01-31 19:37:45 +01:00
feat: add exec approvals tooling and service status
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 == "~" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
44
docs/cli/approvals.md
Normal file
44
docs/cli/approvals.md
Normal 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 node’s 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`.
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <name>] <command>
|
||||
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 <name>] <command>
|
||||
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.
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ Install a headless node host as a user service.
|
||||
|
||||
```bash
|
||||
clawdbot node daemon install --host <gateway-host> --port 18790
|
||||
# or
|
||||
clawdbot service node install --host <gateway-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 <id|name|ip>` (edit from the Gateway)
|
||||
|
||||
50
docs/cli/service.md
Normal file
50
docs/cli/service.md
Normal 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`
|
||||
@@ -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)).
|
||||
|
||||
@@ -822,8 +822,10 @@
|
||||
"cli/models",
|
||||
"cli/logs",
|
||||
"cli/nodes",
|
||||
"cli/approvals",
|
||||
"cli/gateway",
|
||||
"cli/daemon",
|
||||
"cli/service",
|
||||
"cli/tui",
|
||||
"cli/voicecall",
|
||||
"cli/wake",
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,15 +50,15 @@ import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
||||
|
||||
const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
||||
30_000,
|
||||
200_000,
|
||||
1_000,
|
||||
150_000,
|
||||
200_000,
|
||||
);
|
||||
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
|
||||
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
|
||||
30_000,
|
||||
200_000,
|
||||
1_000,
|
||||
150_000,
|
||||
200_000,
|
||||
);
|
||||
const DEFAULT_PATH =
|
||||
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
87
src/cli/exec-approvals-cli.test.ts
Normal file
87
src/cli/exec-approvals-cli.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGatewayFromCli = vi.fn(
|
||||
async (method: string, _opts: unknown, params?: unknown) => {
|
||||
if (method.endsWith(".get")) {
|
||||
return {
|
||||
path: "/tmp/exec-approvals.json",
|
||||
exists: true,
|
||||
hash: "hash-1",
|
||||
file: { version: 1, agents: {} },
|
||||
};
|
||||
}
|
||||
return { method, params };
|
||||
},
|
||||
);
|
||||
|
||||
const runtimeLogs: string[] = [];
|
||||
const runtimeErrors: string[] = [];
|
||||
const defaultRuntime = {
|
||||
log: (msg: string) => runtimeLogs.push(msg),
|
||||
error: (msg: string) => runtimeErrors.push(msg),
|
||||
exit: (code: number) => {
|
||||
throw new Error(`__exit__:${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("./gateway-rpc.js", () => ({
|
||||
callGatewayFromCli: (method: string, opts: unknown, params?: unknown) =>
|
||||
callGatewayFromCli(method, opts, params),
|
||||
}));
|
||||
|
||||
vi.mock("./nodes-cli/rpc.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>(
|
||||
"./nodes-cli/rpc.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveNodeId: vi.fn(async () => "node-1"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime,
|
||||
}));
|
||||
|
||||
describe("exec approvals CLI", () => {
|
||||
it("loads gateway approvals by default", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerExecApprovalsCli(program);
|
||||
|
||||
await program.parseAsync(["approvals", "get"], { from: "user" });
|
||||
|
||||
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
||||
"exec.approvals.get",
|
||||
expect.anything(),
|
||||
{},
|
||||
);
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("loads node approvals when --node is set", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerExecApprovalsCli(program);
|
||||
|
||||
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
|
||||
|
||||
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
||||
"exec.approvals.node.get",
|
||||
expect.anything(),
|
||||
{ nodeId: "node-1" },
|
||||
);
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
243
src/cli/exec-approvals-cli.ts
Normal file
243
src/cli/exec-approvals-cli.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import fs from "node:fs/promises";
|
||||
import JSON5 from "json5";
|
||||
import type { Command } from "commander";
|
||||
|
||||
import type { ExecApprovalsAgent, ExecApprovalsFile } from "../infra/exec-approvals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { callGatewayFromCli } from "./gateway-rpc.js";
|
||||
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
|
||||
import type { NodesRpcOpts } from "./nodes-cli/types.js";
|
||||
|
||||
type ExecApprovalsSnapshot = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
hash: string;
|
||||
file: ExecApprovalsFile;
|
||||
};
|
||||
|
||||
type ExecApprovalsCliOpts = NodesRpcOpts & {
|
||||
node?: string;
|
||||
file?: string;
|
||||
stdin?: boolean;
|
||||
agent?: string;
|
||||
};
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
async function resolveTargetNodeId(opts: ExecApprovalsCliOpts): Promise<string | null> {
|
||||
const raw = opts.node?.trim() ?? "";
|
||||
if (!raw) return null;
|
||||
return await resolveNodeId(opts as NodesRpcOpts, raw);
|
||||
}
|
||||
|
||||
async function loadSnapshot(
|
||||
opts: ExecApprovalsCliOpts,
|
||||
nodeId: string | null,
|
||||
): Promise<ExecApprovalsSnapshot> {
|
||||
const method = nodeId ? "exec.approvals.node.get" : "exec.approvals.get";
|
||||
const params = nodeId ? { nodeId } : {};
|
||||
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function saveSnapshot(
|
||||
opts: ExecApprovalsCliOpts,
|
||||
nodeId: string | null,
|
||||
file: ExecApprovalsFile,
|
||||
baseHash: string,
|
||||
): Promise<ExecApprovalsSnapshot> {
|
||||
const method = nodeId ? "exec.approvals.node.set" : "exec.approvals.set";
|
||||
const params = nodeId ? { nodeId, file, baseHash } : { file, baseHash };
|
||||
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function resolveAgentKey(value?: string | null): string {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed ? trimmed : "default";
|
||||
}
|
||||
|
||||
function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null {
|
||||
const pattern = entry?.pattern?.trim() ?? "";
|
||||
return pattern ? pattern : null;
|
||||
}
|
||||
|
||||
function ensureAgent(file: ExecApprovalsFile, agentKey: string): ExecApprovalsAgent {
|
||||
const agents = file.agents ?? {};
|
||||
const entry = agents[agentKey] ?? {};
|
||||
file.agents = agents;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function isEmptyAgent(agent: ExecApprovalsAgent): boolean {
|
||||
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
||||
return (
|
||||
!agent.security &&
|
||||
!agent.ask &&
|
||||
!agent.askFallback &&
|
||||
agent.autoAllowSkills === undefined &&
|
||||
allowlist.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function registerExecApprovalsCli(program: Command) {
|
||||
const approvals = program
|
||||
.command("approvals")
|
||||
.alias("exec-approvals")
|
||||
.description("Manage exec approvals (gateway or node host)")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.clawd.bot/cli/approvals")}\n`,
|
||||
);
|
||||
|
||||
const getCmd = approvals
|
||||
.command("get")
|
||||
.description("Fetch exec approvals snapshot")
|
||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
||||
.action(async (opts: ExecApprovalsCliOpts) => {
|
||||
const nodeId = await resolveTargetNodeId(opts);
|
||||
const snapshot = await loadSnapshot(opts, nodeId);
|
||||
const payload = opts.json ? JSON.stringify(snapshot) : JSON.stringify(snapshot, null, 2);
|
||||
defaultRuntime.log(payload);
|
||||
});
|
||||
nodesCallOpts(getCmd);
|
||||
|
||||
const setCmd = approvals
|
||||
.command("set")
|
||||
.description("Replace exec approvals with a JSON file")
|
||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
||||
.option("--file <path>", "Path to JSON file to upload")
|
||||
.option("--stdin", "Read JSON from stdin", false)
|
||||
.action(async (opts: ExecApprovalsCliOpts) => {
|
||||
if (!opts.file && !opts.stdin) {
|
||||
defaultRuntime.error("Provide --file or --stdin.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (opts.file && opts.stdin) {
|
||||
defaultRuntime.error("Use either --file or --stdin (not both).");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const nodeId = await resolveTargetNodeId(opts);
|
||||
const snapshot = await loadSnapshot(opts, nodeId);
|
||||
if (!snapshot.hash) {
|
||||
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const raw = opts.stdin ? await readStdin() : await fs.readFile(String(opts.file), "utf8");
|
||||
let file: ExecApprovalsFile;
|
||||
try {
|
||||
file = JSON5.parse(raw) as ExecApprovalsFile;
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Failed to parse approvals JSON: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
file.version = 1;
|
||||
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
||||
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
||||
defaultRuntime.log(payload);
|
||||
});
|
||||
nodesCallOpts(setCmd);
|
||||
|
||||
const allowlist = approvals
|
||||
.command("allowlist")
|
||||
.description("Edit the per-agent allowlist");
|
||||
|
||||
const allowlistAdd = allowlist
|
||||
.command("add <pattern>")
|
||||
.description("Add a glob pattern to an allowlist")
|
||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
||||
.option("--agent <id>", "Agent id (defaults to \"default\")")
|
||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) {
|
||||
defaultRuntime.error("Pattern required.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const nodeId = await resolveTargetNodeId(opts);
|
||||
const snapshot = await loadSnapshot(opts, nodeId);
|
||||
if (!snapshot.hash) {
|
||||
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const file = snapshot.file ?? { version: 1 };
|
||||
file.version = 1;
|
||||
const agentKey = resolveAgentKey(opts.agent);
|
||||
const agent = ensureAgent(file, agentKey);
|
||||
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
||||
if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmed)) {
|
||||
defaultRuntime.log("Already allowlisted.");
|
||||
return;
|
||||
}
|
||||
allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() });
|
||||
agent.allowlist = allowlistEntries;
|
||||
file.agents = { ...(file.agents ?? {}), [agentKey]: agent };
|
||||
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
||||
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
||||
defaultRuntime.log(payload);
|
||||
});
|
||||
nodesCallOpts(allowlistAdd);
|
||||
|
||||
const allowlistRemove = allowlist
|
||||
.command("remove <pattern>")
|
||||
.description("Remove a glob pattern from an allowlist")
|
||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
||||
.option("--agent <id>", "Agent id (defaults to \"default\")")
|
||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) {
|
||||
defaultRuntime.error("Pattern required.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const nodeId = await resolveTargetNodeId(opts);
|
||||
const snapshot = await loadSnapshot(opts, nodeId);
|
||||
if (!snapshot.hash) {
|
||||
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const file = snapshot.file ?? { version: 1 };
|
||||
file.version = 1;
|
||||
const agentKey = resolveAgentKey(opts.agent);
|
||||
const agent = ensureAgent(file, agentKey);
|
||||
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
||||
const nextEntries = allowlistEntries.filter(
|
||||
(entry) => normalizeAllowlistEntry(entry) !== trimmed,
|
||||
);
|
||||
if (nextEntries.length === allowlistEntries.length) {
|
||||
defaultRuntime.log("Pattern not found.");
|
||||
return;
|
||||
}
|
||||
if (nextEntries.length === 0) {
|
||||
delete agent.allowlist;
|
||||
} else {
|
||||
agent.allowlist = nextEntries;
|
||||
}
|
||||
if (isEmptyAgent(agent)) {
|
||||
const agents = { ...(file.agents ?? {}) };
|
||||
delete agents[agentKey];
|
||||
file.agents = Object.keys(agents).length > 0 ? agents : undefined;
|
||||
} else {
|
||||
file.agents = { ...(file.agents ?? {}), [agentKey]: agent };
|
||||
}
|
||||
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
||||
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
||||
defaultRuntime.log(payload);
|
||||
});
|
||||
nodesCallOpts(allowlistRemove);
|
||||
}
|
||||
@@ -55,6 +55,14 @@ export function registerNodeCli(program: Command) {
|
||||
.command("daemon")
|
||||
.description("Manage the headless node daemon service (launchd/systemd/schtasks)");
|
||||
|
||||
node
|
||||
.command("status")
|
||||
.description("Show node service status")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runNodeDaemonStatus(opts);
|
||||
});
|
||||
|
||||
daemon
|
||||
.command("status")
|
||||
.description("Show node daemon status")
|
||||
|
||||
@@ -8,6 +8,7 @@ import { registerDaemonCli } from "../daemon-cli.js";
|
||||
import { registerDnsCli } from "../dns-cli.js";
|
||||
import { registerDirectoryCli } from "../directory-cli.js";
|
||||
import { registerDocsCli } from "../docs-cli.js";
|
||||
import { registerExecApprovalsCli } from "../exec-approvals-cli.js";
|
||||
import { registerGatewayCli } from "../gateway-cli.js";
|
||||
import { registerHooksCli } from "../hooks-cli.js";
|
||||
import { registerWebhooksCli } from "../webhooks-cli.js";
|
||||
@@ -19,6 +20,7 @@ import { registerPairingCli } from "../pairing-cli.js";
|
||||
import { registerPluginsCli } from "../plugins-cli.js";
|
||||
import { registerSandboxCli } from "../sandbox-cli.js";
|
||||
import { registerSecurityCli } from "../security-cli.js";
|
||||
import { registerServiceCli } from "../service-cli.js";
|
||||
import { registerSkillsCli } from "../skills-cli.js";
|
||||
import { registerTuiCli } from "../tui-cli.js";
|
||||
import { registerUpdateCli } from "../update-cli.js";
|
||||
@@ -27,8 +29,10 @@ export function registerSubCliCommands(program: Command) {
|
||||
registerAcpCli(program);
|
||||
registerDaemonCli(program);
|
||||
registerGatewayCli(program);
|
||||
registerServiceCli(program);
|
||||
registerLogsCli(program);
|
||||
registerModelsCli(program);
|
||||
registerExecApprovalsCli(program);
|
||||
registerNodesCli(program);
|
||||
registerNodeCli(program);
|
||||
registerSandboxCli(program);
|
||||
|
||||
59
src/cli/service-cli.coverage.test.ts
Normal file
59
src/cli/service-cli.coverage.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runDaemonStatus = vi.fn(async () => {});
|
||||
const runNodeDaemonStatus = vi.fn(async () => {});
|
||||
|
||||
vi.mock("./daemon-cli/runners.js", () => ({
|
||||
runDaemonInstall: vi.fn(async () => {}),
|
||||
runDaemonRestart: vi.fn(async () => {}),
|
||||
runDaemonStart: vi.fn(async () => {}),
|
||||
runDaemonStatus: (opts: unknown) => runDaemonStatus(opts),
|
||||
runDaemonStop: vi.fn(async () => {}),
|
||||
runDaemonUninstall: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("./node-cli/daemon.js", () => ({
|
||||
runNodeDaemonInstall: vi.fn(async () => {}),
|
||||
runNodeDaemonRestart: vi.fn(async () => {}),
|
||||
runNodeDaemonStart: vi.fn(async () => {}),
|
||||
runNodeDaemonStatus: (opts: unknown) => runNodeDaemonStatus(opts),
|
||||
runNodeDaemonStop: vi.fn(async () => {}),
|
||||
runNodeDaemonUninstall: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("./deps.js", () => ({
|
||||
createDefaultDeps: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("service CLI coverage", () => {
|
||||
it("routes service gateway status to daemon status", async () => {
|
||||
runDaemonStatus.mockClear();
|
||||
runNodeDaemonStatus.mockClear();
|
||||
|
||||
const { registerServiceCli } = await import("./service-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerServiceCli(program);
|
||||
|
||||
await program.parseAsync(["service", "gateway", "status"], { from: "user" });
|
||||
|
||||
expect(runDaemonStatus).toHaveBeenCalledTimes(1);
|
||||
expect(runNodeDaemonStatus).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("routes service node status to node daemon status", async () => {
|
||||
runDaemonStatus.mockClear();
|
||||
runNodeDaemonStatus.mockClear();
|
||||
|
||||
const { registerServiceCli } = await import("./service-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerServiceCli(program);
|
||||
|
||||
await program.parseAsync(["service", "node", "status"], { from: "user" });
|
||||
|
||||
expect(runNodeDaemonStatus).toHaveBeenCalledTimes(1);
|
||||
expect(runDaemonStatus).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
157
src/cli/service-cli.ts
Normal file
157
src/cli/service-cli.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { createDefaultDeps } from "./deps.js";
|
||||
import {
|
||||
runDaemonInstall,
|
||||
runDaemonRestart,
|
||||
runDaemonStart,
|
||||
runDaemonStatus,
|
||||
runDaemonStop,
|
||||
runDaemonUninstall,
|
||||
} from "./daemon-cli/runners.js";
|
||||
import {
|
||||
runNodeDaemonInstall,
|
||||
runNodeDaemonRestart,
|
||||
runNodeDaemonStart,
|
||||
runNodeDaemonStatus,
|
||||
runNodeDaemonStop,
|
||||
runNodeDaemonUninstall,
|
||||
} from "./node-cli/daemon.js";
|
||||
|
||||
export function registerServiceCli(program: Command) {
|
||||
const service = program
|
||||
.command("service")
|
||||
.description("Manage Gateway and node host services (launchd/systemd/schtasks)")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
|
||||
);
|
||||
|
||||
const gateway = service.command("gateway").description("Manage the Gateway service");
|
||||
|
||||
gateway
|
||||
.command("status")
|
||||
.description("Show gateway service status + probe the Gateway")
|
||||
.option("--url <url>", "Gateway WebSocket URL (defaults to config/remote/local)")
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (password auth)")
|
||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||
.option("--no-probe", "Skip RPC probe")
|
||||
.option("--deep", "Scan system-level services", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonStatus({
|
||||
rpc: opts,
|
||||
probe: Boolean(opts.probe),
|
||||
deep: Boolean(opts.deep),
|
||||
json: Boolean(opts.json),
|
||||
});
|
||||
});
|
||||
|
||||
gateway
|
||||
.command("install")
|
||||
.description("Install the Gateway service (launchd/systemd/schtasks)")
|
||||
.option("--port <port>", "Gateway port")
|
||||
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
|
||||
.option("--token <token>", "Gateway token (token auth)")
|
||||
.option("--force", "Reinstall/overwrite if already installed", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonInstall(opts);
|
||||
});
|
||||
|
||||
gateway
|
||||
.command("uninstall")
|
||||
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonUninstall(opts);
|
||||
});
|
||||
|
||||
gateway
|
||||
.command("start")
|
||||
.description("Start the Gateway service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonStart(opts);
|
||||
});
|
||||
|
||||
gateway
|
||||
.command("stop")
|
||||
.description("Stop the Gateway service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonStop(opts);
|
||||
});
|
||||
|
||||
gateway
|
||||
.command("restart")
|
||||
.description("Restart the Gateway service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonRestart(opts);
|
||||
});
|
||||
|
||||
const node = service.command("node").description("Manage the node host service");
|
||||
|
||||
node
|
||||
.command("status")
|
||||
.description("Show node host service status")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runNodeDaemonStatus(opts);
|
||||
});
|
||||
|
||||
node
|
||||
.command("install")
|
||||
.description("Install the node host service (launchd/systemd/schtasks)")
|
||||
.option("--host <host>", "Gateway bridge host")
|
||||
.option("--port <port>", "Gateway bridge port")
|
||||
.option("--tls", "Use TLS for the bridge connection", false)
|
||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
||||
.option("--node-id <id>", "Override node id (clears pairing token)")
|
||||
.option("--display-name <name>", "Override node display name")
|
||||
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
|
||||
.option("--force", "Reinstall/overwrite if already installed", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runNodeDaemonInstall(opts);
|
||||
});
|
||||
|
||||
node
|
||||
.command("uninstall")
|
||||
.description("Uninstall the node host service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runNodeDaemonUninstall(opts);
|
||||
});
|
||||
|
||||
node
|
||||
.command("start")
|
||||
.description("Start the node host service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runNodeDaemonStart(opts);
|
||||
});
|
||||
|
||||
node
|
||||
.command("stop")
|
||||
.description("Stop the node host service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runNodeDaemonStop(opts);
|
||||
});
|
||||
|
||||
node
|
||||
.command("restart")
|
||||
.description("Restart the node host service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runNodeDaemonRestart(opts);
|
||||
});
|
||||
|
||||
// Build default deps (parity with daemon CLI).
|
||||
void createDefaultDeps();
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
|
||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||
import type { GatewayService } from "../daemon/service.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { resolveNodeService } from "../daemon/node-service.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
||||
import { probeGateway } from "../gateway/probe.js";
|
||||
@@ -130,10 +132,9 @@ export async function statusAllCommand(
|
||||
const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null);
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Checking daemon…");
|
||||
const daemon = await (async () => {
|
||||
progress.setLabel("Checking services…");
|
||||
const readServiceSummary = async (service: GatewayService) => {
|
||||
try {
|
||||
const service = resolveGatewayService();
|
||||
const [loaded, runtimeInfo, command] = await Promise.all([
|
||||
service.isLoaded({ env: process.env }).catch(() => false),
|
||||
service.readRuntime(process.env).catch(() => undefined),
|
||||
@@ -150,7 +151,9 @@ export async function statusAllCommand(
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
};
|
||||
const daemon = await readServiceSummary(resolveGatewayService());
|
||||
const nodeService = await readServiceSummary(resolveNodeService());
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Scanning agents…");
|
||||
@@ -340,13 +343,22 @@ export async function statusAllCommand(
|
||||
: { Item: "Gateway self", Value: "unknown" },
|
||||
daemon
|
||||
? {
|
||||
Item: "Daemon",
|
||||
Item: "Gateway service",
|
||||
Value:
|
||||
daemon.installed === false
|
||||
? `${daemon.label} not installed`
|
||||
: `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
|
||||
}
|
||||
: { Item: "Daemon", Value: "unknown" },
|
||||
: { Item: "Gateway service", Value: "unknown" },
|
||||
nodeService
|
||||
? {
|
||||
Item: "Node service",
|
||||
Value:
|
||||
nodeService.installed === false
|
||||
? `${nodeService.label} not installed`
|
||||
: `${nodeService.label} ${nodeService.installed ? "installed · " : ""}${nodeService.loadedText}${nodeService.runtime?.status ? ` · ${nodeService.runtime.status}` : ""}${nodeService.runtime?.pid ? ` (pid ${nodeService.runtime.pid})` : ""}`,
|
||||
}
|
||||
: { Item: "Node service", Value: "unknown" },
|
||||
{
|
||||
Item: "Agents",
|
||||
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "../memory/status-format.js";
|
||||
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
|
||||
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
||||
import { getDaemonStatusSummary } from "./status.daemon.js";
|
||||
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
|
||||
import {
|
||||
formatAge,
|
||||
formatDuration,
|
||||
@@ -116,6 +116,10 @@ export async function statusCommand(
|
||||
: undefined;
|
||||
|
||||
if (opts.json) {
|
||||
const [daemon, nodeDaemon] = await Promise.all([
|
||||
getDaemonStatusSummary(),
|
||||
getNodeDaemonStatusSummary(),
|
||||
]);
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
@@ -134,6 +138,8 @@ export async function statusCommand(
|
||||
self: gatewaySelf,
|
||||
error: gatewayProbe?.error ?? null,
|
||||
},
|
||||
gatewayService: daemon,
|
||||
nodeService: nodeDaemon,
|
||||
agents: agentStatus,
|
||||
securityAudit,
|
||||
...(health || usage ? { health, usage } : {}),
|
||||
@@ -210,12 +216,20 @@ export async function statusCommand(
|
||||
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
|
||||
})();
|
||||
|
||||
const daemon = await getDaemonStatusSummary();
|
||||
const [daemon, nodeDaemon] = await Promise.all([
|
||||
getDaemonStatusSummary(),
|
||||
getNodeDaemonStatusSummary(),
|
||||
]);
|
||||
const daemonValue = (() => {
|
||||
if (daemon.installed === false) return `${daemon.label} not installed`;
|
||||
const installedPrefix = daemon.installed === true ? "installed · " : "";
|
||||
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
|
||||
})();
|
||||
const nodeDaemonValue = (() => {
|
||||
if (nodeDaemon.installed === false) return `${nodeDaemon.label} not installed`;
|
||||
const installedPrefix = nodeDaemon.installed === true ? "installed · " : "";
|
||||
return `${nodeDaemon.label} ${installedPrefix}${nodeDaemon.loadedText}${nodeDaemon.runtimeShort ? ` · ${nodeDaemon.runtimeShort}` : ""}`;
|
||||
})();
|
||||
|
||||
const defaults = summary.sessions.defaults;
|
||||
const defaultCtx = defaults.contextTokens
|
||||
@@ -298,7 +312,8 @@ export async function statusCommand(
|
||||
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
|
||||
},
|
||||
{ Item: "Gateway", Value: gatewayValue },
|
||||
{ Item: "Daemon", Value: daemonValue },
|
||||
{ Item: "Gateway service", Value: daemonValue },
|
||||
{ Item: "Node service", Value: nodeDaemonValue },
|
||||
{ Item: "Agents", Value: agentsValue },
|
||||
{ Item: "Memory", Value: memoryValue },
|
||||
{ Item: "Probes", Value: probesValue },
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { GatewayService } from "../daemon/service.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { resolveNodeService } from "../daemon/node-service.js";
|
||||
import { formatDaemonRuntimeShort } from "./status.format.js";
|
||||
|
||||
export async function getDaemonStatusSummary(): Promise<{
|
||||
type DaemonStatusSummary = {
|
||||
label: string;
|
||||
installed: boolean | null;
|
||||
loadedText: string;
|
||||
runtimeShort: string | null;
|
||||
}> {
|
||||
};
|
||||
|
||||
async function buildDaemonStatusSummary(
|
||||
service: GatewayService,
|
||||
fallbackLabel: string,
|
||||
): Promise<DaemonStatusSummary> {
|
||||
try {
|
||||
const service = resolveGatewayService();
|
||||
const [loaded, runtime, command] = await Promise.all([
|
||||
service.isLoaded({ env: process.env }).catch(() => false),
|
||||
service.readRuntime(process.env).catch(() => undefined),
|
||||
@@ -20,10 +26,18 @@ export async function getDaemonStatusSummary(): Promise<{
|
||||
return { label: service.label, installed, loadedText, runtimeShort };
|
||||
} catch {
|
||||
return {
|
||||
label: "Daemon",
|
||||
label: fallbackLabel,
|
||||
installed: null,
|
||||
loadedText: "unknown",
|
||||
runtimeShort: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDaemonStatusSummary(): Promise<DaemonStatusSummary> {
|
||||
return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon");
|
||||
}
|
||||
|
||||
export async function getNodeDaemonStatusSummary(): Promise<DaemonStatusSummary> {
|
||||
return await buildDaemonStatusSummary(resolveNodeService(), "Node");
|
||||
}
|
||||
|
||||
@@ -243,6 +243,19 @@ vi.mock("../daemon/service.js", () => ({
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../daemon/node-service.js", () => ({
|
||||
resolveNodeService: () => ({
|
||||
label: "LaunchAgent",
|
||||
loadedText: "loaded",
|
||||
notLoadedText: "not loaded",
|
||||
isLoaded: async () => true,
|
||||
readRuntime: async () => ({ status: "running", pid: 4321 }),
|
||||
readCommand: async () => ({
|
||||
programArguments: ["node", "dist/entry.js", "node-host"],
|
||||
sourcePath: "/tmp/Library/LaunchAgents/com.clawdbot.node.plist",
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../security/audit.js", () => ({
|
||||
runSecurityAudit: mocks.runSecurityAudit,
|
||||
}));
|
||||
@@ -273,6 +286,8 @@ describe("statusCommand", () => {
|
||||
expect(payload.sessions.recent[0].flags).toContain("verbose:on");
|
||||
expect(payload.securityAudit.summary.critical).toBe(1);
|
||||
expect(payload.securityAudit.summary.warn).toBe(1);
|
||||
expect(payload.gatewayService.label).toBe("LaunchAgent");
|
||||
expect(payload.nodeService.label).toBe("LaunchAgent");
|
||||
});
|
||||
|
||||
it("prints formatted lines otherwise", async () => {
|
||||
|
||||
@@ -58,6 +58,10 @@ import {
|
||||
CronUpdateParamsSchema,
|
||||
type ExecApprovalsGetParams,
|
||||
ExecApprovalsGetParamsSchema,
|
||||
type ExecApprovalsNodeGetParams,
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
type ExecApprovalsNodeSetParams,
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
type ExecApprovalsSetParams,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
type ExecApprovalsSnapshot,
|
||||
@@ -241,6 +245,12 @@ export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams
|
||||
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
|
||||
ExecApprovalsSetParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalsNodeSetParams = ajv.compile<ExecApprovalsNodeSetParams>(
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
);
|
||||
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
|
||||
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
||||
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
||||
|
||||
@@ -70,3 +70,19 @@ export const ExecApprovalsSetParamsSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ExecApprovalsNodeGetParamsSchema = Type.Object(
|
||||
{
|
||||
nodeId: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ExecApprovalsNodeSetParamsSchema = Type.Object(
|
||||
{
|
||||
nodeId: NonEmptyString,
|
||||
file: ExecApprovalsFileSchema,
|
||||
baseHash: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
} from "./cron.js";
|
||||
import {
|
||||
ExecApprovalsGetParamsSchema,
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
ExecApprovalsSnapshotSchema,
|
||||
} from "./exec-approvals.js";
|
||||
@@ -177,6 +179,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
LogsTailResult: LogsTailResultSchema,
|
||||
ExecApprovalsGetParams: ExecApprovalsGetParamsSchema,
|
||||
ExecApprovalsSetParams: ExecApprovalsSetParamsSchema,
|
||||
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
|
||||
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
|
||||
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
|
||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||
ChatSendParams: ChatSendParamsSchema,
|
||||
|
||||
@@ -47,6 +47,8 @@ import type {
|
||||
} from "./cron.js";
|
||||
import type {
|
||||
ExecApprovalsGetParamsSchema,
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
ExecApprovalsSnapshotSchema,
|
||||
} from "./exec-approvals.js";
|
||||
@@ -170,6 +172,8 @@ export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
|
||||
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
|
||||
export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>;
|
||||
export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>;
|
||||
export type ExecApprovalsNodeGetParams = Static<typeof ExecApprovalsNodeGetParamsSchema>;
|
||||
export type ExecApprovalsNodeSetParams = Static<typeof ExecApprovalsNodeSetParamsSchema>;
|
||||
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
|
||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
||||
|
||||
@@ -14,6 +14,8 @@ const BASE_METHODS = [
|
||||
"config.schema",
|
||||
"exec.approvals.get",
|
||||
"exec.approvals.set",
|
||||
"exec.approvals.node.get",
|
||||
"exec.approvals.node.set",
|
||||
"wizard.start",
|
||||
"wizard.next",
|
||||
"wizard.cancel",
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateExecApprovalsGetParams,
|
||||
validateExecApprovalsNodeGetParams,
|
||||
validateExecApprovalsNodeSetParams,
|
||||
validateExecApprovalsSetParams,
|
||||
} from "../protocol/index.js";
|
||||
import { respondUnavailableOnThrow, safeParseJson } from "./nodes.helpers.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
function resolveBaseHash(params: unknown): string | null {
|
||||
@@ -152,4 +155,94 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"exec.approvals.node.get": async ({ params, respond, context }) => {
|
||||
if (!validateExecApprovalsNodeGetParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid exec.approvals.node.get params: ${formatValidationErrors(validateExecApprovalsNodeGetParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const bridge = context.bridge;
|
||||
if (!bridge) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
|
||||
return;
|
||||
}
|
||||
const { nodeId } = params as { nodeId: string };
|
||||
const id = nodeId.trim();
|
||||
if (!id) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
|
||||
return;
|
||||
}
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const res = await bridge.invoke({
|
||||
nodeId: id,
|
||||
command: "system.execApprovals.get",
|
||||
paramsJSON: "{}",
|
||||
});
|
||||
if (!res.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
|
||||
details: { nodeError: res.error ?? null },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = safeParseJson(res.payloadJSON ?? null);
|
||||
respond(true, payload, undefined);
|
||||
});
|
||||
},
|
||||
"exec.approvals.node.set": async ({ params, respond, context }) => {
|
||||
if (!validateExecApprovalsNodeSetParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid exec.approvals.node.set params: ${formatValidationErrors(validateExecApprovalsNodeSetParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const bridge = context.bridge;
|
||||
if (!bridge) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
|
||||
return;
|
||||
}
|
||||
const { nodeId, file, baseHash } = params as {
|
||||
nodeId: string;
|
||||
file: ExecApprovalsFile;
|
||||
baseHash?: string;
|
||||
};
|
||||
const id = nodeId.trim();
|
||||
if (!id) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
|
||||
return;
|
||||
}
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const res = await bridge.invoke({
|
||||
nodeId: id,
|
||||
command: "system.execApprovals.set",
|
||||
paramsJSON: JSON.stringify({ file, baseHash }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
|
||||
details: { nodeError: res.error ?? null },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = safeParseJson(res.payloadJSON ?? null);
|
||||
respond(true, payload, undefined);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
108
src/infra/exec-approvals.test.ts
Normal file
108
src/infra/exec-approvals.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
matchAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
resolveCommandResolution,
|
||||
type ExecAllowlistEntry,
|
||||
} from "./exec-approvals.js";
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-exec-approvals-"));
|
||||
}
|
||||
|
||||
describe("exec approvals allowlist matching", () => {
|
||||
it("matches by executable name (case-insensitive)", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match?.pattern).toBe("RG");
|
||||
});
|
||||
|
||||
it("matches by resolved path with **", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/**/rg" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match?.pattern).toBe("/opt/**/rg");
|
||||
});
|
||||
|
||||
it("does not let * cross path separators", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/*/rg" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to raw executable when no resolved path", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "bin/rg",
|
||||
resolvedPath: undefined,
|
||||
executableName: "rg",
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match?.pattern).toBe("bin/rg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals command resolution", () => {
|
||||
it("resolves PATH executables", () => {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exe = path.join(binDir, "rg");
|
||||
fs.writeFileSync(exe, "");
|
||||
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
|
||||
expect(res?.resolvedPath).toBe(exe);
|
||||
expect(res?.executableName).toBe("rg");
|
||||
});
|
||||
|
||||
it("resolves relative paths against cwd", () => {
|
||||
const dir = makeTempDir();
|
||||
const cwd = path.join(dir, "project");
|
||||
const script = path.join(cwd, "scripts", "run.sh");
|
||||
fs.mkdirSync(path.dirname(script), { recursive: true });
|
||||
fs.writeFileSync(script, "");
|
||||
const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined);
|
||||
expect(res?.resolvedPath).toBe(script);
|
||||
});
|
||||
|
||||
it("parses quoted executables", () => {
|
||||
const dir = makeTempDir();
|
||||
const cwd = path.join(dir, "project");
|
||||
const script = path.join(cwd, "bin", "tool");
|
||||
fs.mkdirSync(path.dirname(script), { recursive: true });
|
||||
fs.writeFileSync(script, "");
|
||||
const res = resolveCommandResolution("\"./bin/tool\" --version", cwd, undefined);
|
||||
expect(res?.resolvedPath).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals policy helpers", () => {
|
||||
it("minSecurity returns the more restrictive value", () => {
|
||||
expect(minSecurity("deny", "full")).toBe("deny");
|
||||
expect(minSecurity("allowlist", "full")).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("maxAsk returns the more aggressive ask mode", () => {
|
||||
expect(maxAsk("off", "always")).toBe("always");
|
||||
expect(maxAsk("on-miss", "off")).toBe("on-miss");
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,16 @@ import type { BridgeInvokeRequestFrame } from "../infra/bridge/server/types.js";
|
||||
import {
|
||||
addAllowlistEntry,
|
||||
matchAllowlist,
|
||||
normalizeExecApprovals,
|
||||
recordAllowlistUse,
|
||||
requestExecApprovalViaSocket,
|
||||
resolveCommandResolution,
|
||||
resolveExecApprovals,
|
||||
ensureExecApprovals,
|
||||
readExecApprovalsSnapshot,
|
||||
resolveExecApprovalsSocketPath,
|
||||
saveExecApprovals,
|
||||
type ExecApprovalsFile,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import { VERSION } from "../version.js";
|
||||
@@ -43,6 +49,18 @@ type SystemWhichParams = {
|
||||
bins: string[];
|
||||
};
|
||||
|
||||
type SystemExecApprovalsSetParams = {
|
||||
file: ExecApprovalsFile;
|
||||
baseHash?: string | null;
|
||||
};
|
||||
|
||||
type ExecApprovalsSnapshot = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
hash: string;
|
||||
file: ExecApprovalsFile;
|
||||
};
|
||||
|
||||
type RunResult = {
|
||||
exitCode?: number;
|
||||
timedOut: boolean;
|
||||
@@ -143,6 +161,31 @@ function truncateOutput(raw: string, maxChars: number): { text: string; truncate
|
||||
return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true };
|
||||
}
|
||||
|
||||
function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
||||
const socketPath = file.socket?.path?.trim();
|
||||
return {
|
||||
...file,
|
||||
socket: socketPath ? { path: socketPath } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function requireExecApprovalsBaseHash(
|
||||
params: SystemExecApprovalsSetParams,
|
||||
snapshot: ExecApprovalsSnapshot,
|
||||
) {
|
||||
if (!snapshot.exists) return;
|
||||
if (!snapshot.hash) {
|
||||
throw new Error("INVALID_REQUEST: exec approvals base hash unavailable; reload and retry");
|
||||
}
|
||||
const baseHash = typeof params.baseHash === "string" ? params.baseHash.trim() : "";
|
||||
if (!baseHash) {
|
||||
throw new Error("INVALID_REQUEST: exec approvals base hash required; reload and retry");
|
||||
}
|
||||
if (baseHash !== snapshot.hash) {
|
||||
throw new Error("INVALID_REQUEST: exec approvals changed; reload and retry");
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
argv: string[],
|
||||
cwd: string | undefined,
|
||||
@@ -306,7 +349,12 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
deviceFamily: os.platform(),
|
||||
modelIdentifier: os.hostname(),
|
||||
caps: ["system"],
|
||||
commands: ["system.run", "system.which"],
|
||||
commands: [
|
||||
"system.run",
|
||||
"system.which",
|
||||
"system.execApprovals.get",
|
||||
"system.execApprovals.set",
|
||||
],
|
||||
onPairToken: async (token) => {
|
||||
config.token = token;
|
||||
await saveNodeHostConfig(config);
|
||||
@@ -355,6 +403,80 @@ async function handleInvoke(
|
||||
skillBins: SkillBinsCache,
|
||||
) {
|
||||
const command = String(frame.command ?? "");
|
||||
if (command === "system.execApprovals.get") {
|
||||
try {
|
||||
ensureExecApprovals();
|
||||
const snapshot = readExecApprovalsSnapshot();
|
||||
const payload: ExecApprovalsSnapshot = {
|
||||
path: snapshot.path,
|
||||
exists: snapshot.exists,
|
||||
hash: snapshot.hash,
|
||||
file: redactExecApprovals(snapshot.file),
|
||||
};
|
||||
client.sendInvokeResponse({
|
||||
type: "invoke-res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify(payload),
|
||||
});
|
||||
} catch (err) {
|
||||
client.sendInvokeResponse({
|
||||
type: "invoke-res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: String(err) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "system.execApprovals.set") {
|
||||
try {
|
||||
const params = decodeParams<SystemExecApprovalsSetParams>(frame.paramsJSON);
|
||||
if (!params.file || typeof params.file !== "object") {
|
||||
throw new Error("INVALID_REQUEST: exec approvals file required");
|
||||
}
|
||||
ensureExecApprovals();
|
||||
const snapshot = readExecApprovalsSnapshot();
|
||||
requireExecApprovalsBaseHash(params, snapshot);
|
||||
const normalized = normalizeExecApprovals(params.file);
|
||||
const currentSocketPath = snapshot.file.socket?.path?.trim();
|
||||
const currentToken = snapshot.file.socket?.token?.trim();
|
||||
const socketPath =
|
||||
normalized.socket?.path?.trim() ?? currentSocketPath ?? resolveExecApprovalsSocketPath();
|
||||
const token = normalized.socket?.token?.trim() ?? currentToken ?? "";
|
||||
const next: ExecApprovalsFile = {
|
||||
...normalized,
|
||||
socket: {
|
||||
path: socketPath,
|
||||
token,
|
||||
},
|
||||
};
|
||||
saveExecApprovals(next);
|
||||
const nextSnapshot = readExecApprovalsSnapshot();
|
||||
const payload: ExecApprovalsSnapshot = {
|
||||
path: nextSnapshot.path,
|
||||
exists: nextSnapshot.exists,
|
||||
hash: nextSnapshot.hash,
|
||||
file: redactExecApprovals(nextSnapshot.file),
|
||||
};
|
||||
client.sendInvokeResponse({
|
||||
type: "invoke-res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify(payload),
|
||||
});
|
||||
} catch (err) {
|
||||
client.sendInvokeResponse({
|
||||
type: "invoke-res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: String(err) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "system.which") {
|
||||
try {
|
||||
const params = decodeParams<SystemWhichParams>(frame.paramsJSON);
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<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.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 {
|
||||
|
||||
@@ -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<string | number>, value: unknown) => void;
|
||||
onExecApprovalsRemove: (path: Array<string | number>) => 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<string, unknown> | 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<string | number>, value: unknown) => void;
|
||||
onRemove: (path: Array<string | number>) => 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`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between; align-items: center;">
|
||||
@@ -361,17 +387,19 @@ function renderExecApprovals(state: ExecApprovalsState) {
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${state.disabled || !state.dirty}
|
||||
?disabled=${state.disabled || !state.dirty || !targetReady}
|
||||
@click=${state.onSave}
|
||||
>
|
||||
${state.saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${renderExecApprovalsTarget(state)}
|
||||
|
||||
${!ready
|
||||
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
|
||||
<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"}
|
||||
</button>
|
||||
</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) {
|
||||
return html`
|
||||
<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;
|
||||
}
|
||||
|
||||
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): {
|
||||
defaultBinding?: string | null;
|
||||
agents: BindingAgent[];
|
||||
|
||||
Reference in New Issue
Block a user