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
|
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
|
||||||
|
|||||||
@@ -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 == "~" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
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).
|
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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
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:
|
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)).
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
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")
|
.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")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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 { 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`,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
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 {
|
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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user