feat: add exec host routing + node daemon

This commit is contained in:
Peter Steinberger
2026-01-18 07:44:28 +00:00
parent 49bd2d96fa
commit ae0b4c4990
38 changed files with 2370 additions and 117 deletions

View File

@@ -9,11 +9,16 @@ Docs: https://docs.clawd.bot
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). - Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle. - macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events. - macOS: add approvals socket UI server + node exec lifecycle events.
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
- Nodes: add node daemon service install/status/start/stop/restart.
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639. - Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911. - Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals - Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
### Fixes ### Fixes
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
- Tools: return a companion-app-required message when node exec is requested with no paired node. - Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe. - Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.

View File

@@ -162,7 +162,7 @@ enum ExecApprovalsStore {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
if decoded.version != 1 { if decoded.version != 1 {
return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents) return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
} }
return decoded return decoded
} catch { } catch {
@@ -397,11 +397,32 @@ struct ExecCommandResolution: Sendable {
let executableName: String let executableName: String
let cwd: String? let cwd: String?
static func resolve(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?
) -> ExecCommandResolution? {
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
}
return self.resolve(command: command, cwd: cwd, env: env)
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil return nil
} }
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func resolveExecutable(
rawExecutable: String,
cwd: String?,
env: [String: String]?
) -> ExecCommandResolution? {
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\") let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
let resolvedPath: String? = { let resolvedPath: String? = {
if hasPathSeparator { if hasPathSeparator {
@@ -419,6 +440,20 @@ struct ExecCommandResolution: Sendable {
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd) return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
} }
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let first = trimmed.first else { return nil }
if first == "\"" || first == "'" {
let rest = trimmed.dropFirst()
if let end = rest.firstIndex(of: first) {
return String(rest[..<end])
}
return String(rest)
}
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
}
private static func searchPaths(from env: [String: String]?) -> [String] { private static func searchPaths(from env: [String: String]?) -> [String] {
let raw = env?["PATH"] let raw = env?["PATH"]
if let raw, !raw.isEmpty { if let raw, !raw.isEmpty {
@@ -439,6 +474,12 @@ enum ExecCommandFormatter {
return "\"\(escaped)\"" return "\"\(escaped)\""
}.joined(separator: " ") }.joined(separator: " ")
} }
static func displayString(for argv: [String], rawCommand: String?) -> String {
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty { return trimmed }
return self.displayString(for: argv)
}
} }
enum ExecAllowlistMatcher { enum ExecAllowlistMatcher {
@@ -522,7 +563,7 @@ struct ExecEventPayload: Codable, Sendable {
guard !trimmed.isEmpty else { return nil } guard !trimmed.isEmpty else { return nil }
if trimmed.count <= maxChars { return trimmed } if trimmed.count <= maxChars { return trimmed }
let suffix = trimmed.suffix(maxChars) let suffix = trimmed.suffix(maxChars)
return " (truncated) \(suffix)" return "... (truncated) \(suffix)"
} }
} }

View File

@@ -432,6 +432,7 @@ actor MacNodeRuntime {
guard !command.isEmpty else { guard !command.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
} }
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
@@ -444,7 +445,12 @@ actor MacNodeRuntime {
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines) ? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
: self.mainSessionKey : self.mainSessionKey
let runId = UUID().uuidString let runId = UUID().uuidString
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env) let env = Self.sanitizedEnv(params.env)
let resolution = ExecCommandResolution.resolve(
command: command,
rawCommand: params.rawCommand,
cwd: params.cwd,
env: env)
let allowlistMatch = security == .allowlist let allowlistMatch = security == .allowlist
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution) ? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
: nil : nil
@@ -463,7 +469,7 @@ actor MacNodeRuntime {
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
reason: "security=deny")) reason: "security=deny"))
return Self.errorResponse( return Self.errorResponse(
req, req,
@@ -477,12 +483,13 @@ actor MacNodeRuntime {
return false return false
}() }()
var approvedByAsk = false
if requiresAsk { if requiresAsk {
let decision = await ExecApprovalsSocketClient.requestDecision( let decision = await ExecApprovalsSocketClient.requestDecision(
socketPath: approvals.socketPath, socketPath: approvals.socketPath,
token: approvals.token, token: approvals.token,
request: ExecApprovalPromptRequest( request: ExecApprovalPromptRequest(
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
cwd: params.cwd, cwd: params.cwd,
host: "node", host: "node",
security: security.rawValue, security: security.rawValue,
@@ -498,21 +505,40 @@ actor MacNodeRuntime {
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
reason: "user-denied")) reason: "user-denied"))
return Self.errorResponse( return Self.errorResponse(
req, req,
code: .unavailable, code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied") message: "SYSTEM_RUN_DENIED: user denied")
case nil: case nil:
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) { if askFallback == .full {
approvedByAsk = true
} else if askFallback == .allowlist {
if allowlistMatch != nil || skillAllow {
approvedByAsk = true
} else {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "approval-required"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval required")
}
} else {
await self.emitExecEvent( await self.emitExecEvent(
"exec.denied", "exec.denied",
payload: ExecEventPayload( payload: ExecEventPayload(
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
reason: "approval-required")) reason: "approval-required"))
return Self.errorResponse( return Self.errorResponse(
req, req,
@@ -520,6 +546,7 @@ actor MacNodeRuntime {
message: "SYSTEM_RUN_DENIED: approval required") message: "SYSTEM_RUN_DENIED: approval required")
} }
case .allowAlways?: case .allowAlways?:
approvedByAsk = true
if security == .allowlist { if security == .allowlist {
let pattern = resolution?.resolvedPath ?? let pattern = resolution?.resolvedPath ??
resolution?.rawExecutable ?? resolution?.rawExecutable ??
@@ -530,20 +557,33 @@ actor MacNodeRuntime {
} }
} }
case .allowOnce?: case .allowOnce?:
break approvedByAsk = true
} }
} }
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "allowlist-miss"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: allowlist miss")
}
if let match = allowlistMatch { if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse( ExecApprovalsStore.recordAllowlistUse(
agentId: agentId, agentId: agentId,
pattern: match.pattern, pattern: match.pattern,
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
resolvedPath: resolution?.resolvedPath) resolvedPath: resolution?.resolvedPath)
} }
let env = Self.sanitizedEnv(params.env)
if params.needsScreenRecording == true { if params.needsScreenRecording == true {
let authorized = await PermissionManager let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false .status([.screenRecording])[.screenRecording] ?? false
@@ -554,7 +594,7 @@ actor MacNodeRuntime {
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
reason: "permission:screenRecording")) reason: "permission:screenRecording"))
return Self.errorResponse( return Self.errorResponse(
req, req,
@@ -570,7 +610,7 @@ actor MacNodeRuntime {
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command))) command: displayCommand))
let result = await ShellExecutor.runDetailed( let result = await ShellExecutor.runDetailed(
command: command, command: command,
cwd: params.cwd, cwd: params.cwd,
@@ -583,7 +623,7 @@ actor MacNodeRuntime {
sessionKey: sessionKey, sessionKey: sessionKey,
runId: runId, runId: runId,
host: "node", host: "node",
command: ExecCommandFormatter.displayString(for: command), command: displayCommand,
exitCode: result.exitCode, exitCode: result.exitCode,
timedOut: result.timedOut, timedOut: result.timedOut,
success: result.success, success: result.success,

View File

@@ -20,6 +20,7 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable {
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable { public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public var command: [String] public var command: [String]
public var rawCommand: String?
public var cwd: String? public var cwd: String?
public var env: [String: String]? public var env: [String: String]?
public var timeoutMs: Int? public var timeoutMs: Int?
@@ -29,6 +30,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public init( public init(
command: [String], command: [String],
rawCommand: String? = nil,
cwd: String? = nil, cwd: String? = nil,
env: [String: String]? = nil, env: [String: String]? = nil,
timeoutMs: Int? = nil, timeoutMs: Int? = nil,
@@ -37,6 +39,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
sessionKey: String? = nil) sessionKey: String? = nil)
{ {
self.command = command self.command = command
self.rawCommand = rawCommand
self.cwd = cwd self.cwd = cwd
self.env = env self.env = env
self.timeoutMs = timeoutMs self.timeoutMs = timeoutMs

View File

@@ -32,6 +32,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`models`](/cli/models) - [`models`](/cli/models)
- [`memory`](/cli/memory) - [`memory`](/cli/memory)
- [`nodes`](/cli/nodes) - [`nodes`](/cli/nodes)
- [`node`](/cli/node)
- [`sandbox`](/cli/sandbox) - [`sandbox`](/cli/sandbox)
- [`tui`](/cli/tui) - [`tui`](/cli/tui)
- [`browser`](/cli/browser) - [`browser`](/cli/browser)
@@ -168,21 +169,15 @@ clawdbot [--dev] [--profile <name>] <command>
runs runs
run run
nodes nodes
status node
describe start
list daemon
pending status
approve install
reject uninstall
rename start
invoke stop
run restart
notify
camera list|snap|clip
canvas snapshot|present|hide|navigate|eval
canvas a2ui push|reset
screen record
location get
browser browser
status status
start start
@@ -772,6 +767,20 @@ Subcommands:
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`. All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
## Node host
`node` runs a **headless node host** or manages it as a background service. See
[`clawdbot node`](/cli/node).
Subcommands:
- `node start --host <gateway-host> --port 18790`
- `node daemon status`
- `node daemon install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
- `node daemon uninstall`
- `node daemon start`
- `node daemon stop`
- `node daemon restart`
## Nodes ## Nodes
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes). `nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
@@ -788,7 +797,7 @@ Subcommands:
- `nodes reject <requestId>` - `nodes reject <requestId>`
- `nodes rename --node <id|name|ip> --name <displayName>` - `nodes rename --node <id|name|ip> --name <displayName>`
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]` - `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac only) - `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac node or headless node host)
- `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only) - `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only)
Camera: Camera:

72
docs/cli/node.md Normal file
View File

@@ -0,0 +1,72 @@
---
summary: "CLI reference for `clawdbot node` (headless node host)"
read_when:
- Running the headless node host
- Pairing a non-macOS node for system.run
---
# `clawdbot node`
Run a **headless node host** that connects to the Gateway bridge and exposes
`system.run` / `system.which` on this machine.
## Start (foreground)
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Options:
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
- `--port <port>`: Gateway bridge port (default: `18790`)
- `--tls`: Use TLS for the bridge connection
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
- `--node-id <id>`: Override node id (clears pairing token)
- `--display-name <name>`: Override the node display name
## Daemon (background service)
Install a headless node host as a user service.
```bash
clawdbot node daemon install --host <gateway-host> --port 18790
```
Options:
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
- `--port <port>`: Gateway bridge port (default: `18790`)
- `--tls`: Use TLS for the bridge connection
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
- `--node-id <id>`: Override node id (clears pairing token)
- `--display-name <name>`: Override the node display name
- `--runtime <runtime>`: Service runtime (`node` or `bun`)
- `--force`: Reinstall/overwrite if already installed
Manage the service:
```bash
clawdbot node daemon status
clawdbot node daemon start
clawdbot node daemon stop
clawdbot node daemon restart
clawdbot node daemon uninstall
```
## Pairing
The first connection creates a pending node pair request on the Gateway.
Approve it via:
```bash
clawdbot nodes pending
clawdbot nodes approve <requestId>
```
The node host stores its node id + token in `~/.clawdbot/node.json`.
## Exec approvals
`system.run` is gated by local exec approvals:
- `~/.clawdbot/exec-approvals.json`
- [Exec approvals](/tools/exec-approvals)

View File

@@ -46,7 +46,7 @@ When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
## Frames ## Frames
Client → Gateway: Client → Gateway:
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake) - `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins)
- `event`: node signals (voice transcript, agent request, chat subscribe) - `event`: node signals (voice transcript, agent request, chat subscribe)
Gateway → Client: Gateway → Client:

View File

@@ -65,8 +65,8 @@ stronger isolation between agents, run them under separate OS users or separate
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac: If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
- Requires node pairing (approval + token). - Requires node pairing (approval + token).
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never". - Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
- If you dont want remote execution, set the policy to "Never" and remove node pairing for that Mac. - If you dont want remote execution, set security to **deny** and remove node pairing for that Mac.
## Dynamic skills (watcher / remote nodes) ## Dynamic skills (watcher / remote nodes)

View File

@@ -147,9 +147,10 @@ Notes:
- The permission prompt must be accepted on the Android device before the capability is advertised. - The permission prompt must be accepted on the Android device before the capability is advertised.
- Wi-Fi-only devices without telephony will not advertise `sms.send`. - Wi-Fi-only devices without telephony will not advertise `sms.send`.
## System commands (mac node) ## System commands (node host / mac node)
The macOS node exposes `system.run` and `system.notify`. The macOS node exposes `system.run` and `system.notify`. The headless node host
exposes `system.run` and `system.which`.
Examples: Examples:
@@ -163,12 +164,33 @@ Notes:
- `system.notify` respects notification permission state on the macOS app. - `system.notify` respects notification permission state on the macOS app.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`. - `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`. - `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- `system.run` is gated by the macOS app policy (Settings → "Node Run Commands"): "Always Ask" prompts per command, "Always Allow" runs without prompts, and "Never" disables the tool. Denied prompts return `SYSTEM_RUN_DENIED`; disabled returns `SYSTEM_RUN_DISABLED`. - On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
- On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`).
## Permissions map ## Permissions map
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted). Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
## Headless node host (cross-platform)
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
or for running a minimal node alongside a server.
Start it:
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Notes:
- Pairing is still required (the Gateway will show a node approval prompt).
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
(see [Exec approvals](/tools/exec-approvals)).
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
## Mac node mode ## Mac node mode
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac). - The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).

View File

@@ -54,29 +54,32 @@ The macOS app presents itself as a node. Common commands:
The node reports a `permissions` map so agents can decide whats allowed. The node reports a `permissions` map so agents can decide whats allowed.
## Node run policy + allowlist ## Exec approvals (system.run)
`system.run` is controlled by the macOS app **Node Run Commands** policy: `system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
Security + ask + allowlist are stored locally on the Mac in:
- `Always Ask`: prompt per command (default).
- `Always Allow`: run without prompts.
- `Never`: disable `system.run` (tool not advertised).
The policy + allowlist live on the Mac in:
``` ```
~/.clawdbot/macos-node.json ~/.clawdbot/exec-approvals.json
``` ```
Schema: Example:
```json ```json
{ {
"systemRun": { "version": 1,
"policy": "ask", "defaults": {
"allowlist": [ "security": "deny",
"[\"/bin/echo\",\"hello\"]" "ask": "on-miss"
] },
"agents": {
"main": {
"security": "allowlist",
"ask": "on-miss",
"allowlist": [
{ "pattern": "/opt/homebrew/bin/rg" }
]
}
} }
} }
``` ```

View File

@@ -29,6 +29,7 @@ read_when:
- **Runner:** headless system service; UI app hosts a Unix socket for approvals. - **Runner:** headless system service; UI app hosts a Unix socket for approvals.
- **Node identity:** use existing `nodeId`. - **Node identity:** use existing `nodeId`.
- **Socket auth:** Unix socket + token (cross-platform); split later if needed. - **Socket auth:** Unix socket + token (cross-platform); split later if needed.
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
## Key concepts ## Key concepts
### Host ### Host

View File

@@ -60,6 +60,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Remote gateways + nodes](#remote-gateways-nodes) - [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes) - [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon) - [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config) - [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [Whats a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install) - [Whats a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac) - [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
@@ -405,7 +406,7 @@ You have three supported patterns:
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS. Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
**Option B - use a macOS node (no SSH).** **Option B - use a macOS node (no SSH).**
Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist. Run the Gateway on Linux, pair a macOS node (menubar app), and configure **Exec approvals** (Settings → Exec approvals) to "Ask" or "Always Allow". Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Ask", selecting "Always Allow" in the prompt adds that command to the allowlist.
**Option C - proxy macOS binaries over SSH (advanced).** **Option C - proxy macOS binaries over SSH (advanced).**
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible. Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
@@ -742,6 +743,23 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes. A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
### Can I run a headless node host without the macOS app?
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
without any UI. It has no screen/camera/notify support (use the macOS app for those).
Start it:
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Notes:
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
### Is there an API / RPC way to apply config? ### Is there an API / RPC way to apply config?
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation. Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.

View File

@@ -8,7 +8,7 @@ read_when:
# Exec approvals # Exec approvals
Exec approvals are the **companion app guardrail** for letting a sandboxed agent run Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
commands on a real host (`gateway` or `node`). Think of it like a safety interlock: commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
commands are allowed only when policy + allowlist + (optional) user approval all agree. commands are allowed only when policy + allowlist + (optional) user approval all agree.
Exec approvals are **in addition** to tool policy and elevated gating. Exec approvals are **in addition** to tool policy and elevated gating.
@@ -20,11 +20,11 @@ resolved by the **ask fallback** (default: deny).
Exec approvals are enforced locally on the execution host: Exec approvals are enforced locally on the execution host:
- **gateway host** → `clawdbot` process on the gateway machine - **gateway host** → `clawdbot` process on the gateway machine
- **node host** → node runner (macOS companion app or headless node) - **node host** → node runner (macOS companion app or headless node host)
## Settings and storage ## Settings and storage
Approvals live in a local JSON file: Approvals live in a local JSON file on the execution host:
`~/.clawdbot/exec-approvals.json` `~/.clawdbot/exec-approvals.json`
@@ -97,8 +97,8 @@ Each allowlist entry tracks:
## Auto-allow skill CLIs ## Auto-allow skill CLIs
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
are treated as allowlisted (node hosts only). Disable this if you want strict are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
manual allowlists. gateway for the skill bin list. Disable this if you want strict manual allowlists.
## Approval flow ## Approval flow

View File

@@ -30,7 +30,7 @@ Notes:
- `host` defaults to `sandbox`. - `host` defaults to `sandbox`.
- `elevated` is ignored when sandboxing is off (exec already runs on the host). - `elevated` is ignored when sandboxing is off (exec already runs on the host).
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`. - `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
- `node` requires a paired node (macOS companion app). - `node` requires a paired node (companion app or headless node host).
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one. - If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
## Config ## Config
@@ -51,7 +51,7 @@ Example:
/exec host=gateway security=allowlist ask=on-miss node=mac-1 /exec host=gateway security=allowlist ask=on-miss node=mac-1
``` ```
## Exec approvals (macOS app) ## Exec approvals (companion app / node host)
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host. Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow. See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.

View File

@@ -181,6 +181,7 @@ Notes:
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`. - If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`. - `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
- `elevated` only changes behavior when the agent is sandboxed (otherwise its a no-op). - `elevated` only changes behavior when the agent is sandboxed (otherwise its a no-op).
- `host=node` can target a macOS companion app or a headless node host (`clawdbot node start`).
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals). - gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
### `process` ### `process`

View File

@@ -187,7 +187,7 @@ Skills can also refresh mid-session when the skills watcher is enabled or when a
## Remote macOS nodes (Linux gateway) ## Remote macOS nodes (Linux gateway)
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Node Run Commands policy not set to "Never"), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`). If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Exec approvals security not set to `deny`), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects. This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.

View File

@@ -17,6 +17,7 @@ import {
resolveExecApprovals, resolveExecApprovals,
} from "../infra/exec-approvals.js"; } from "../infra/exec-approvals.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { buildNodeShellCommand } from "../infra/node-shell.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { logInfo } from "../logger.js"; import { logInfo } from "../logger.js";
import { import {
@@ -392,7 +393,7 @@ export function createExecTool(
const nodes = await listNodes({}); const nodes = await listNodes({});
if (nodes.length === 0) { if (nodes.length === 0) {
throw new Error( throw new Error(
"exec host=node requires a paired node (none available). This requires the macOS companion app.", "exec host=node requires a paired node (none available). This requires a companion app or node host.",
); );
} }
let nodeId: string; let nodeId: string;
@@ -411,14 +412,17 @@ export function createExecTool(
? nodeInfo?.commands?.includes("system.run") ? nodeInfo?.commands?.includes("system.run")
: false; : false;
if (!supportsSystemRun) { if (!supportsSystemRun) {
throw new Error("exec host=node requires a node that supports system.run."); throw new Error(
"exec host=node requires a node that supports system.run (companion app or node host).",
);
} }
const argv = ["/bin/sh", "-lc", params.command]; const argv = buildNodeShellCommand(params.command, nodeInfo?.platform);
const invokeParams: Record<string, unknown> = { const invokeParams: Record<string, unknown> = {
nodeId, nodeId,
command: "system.run", command: "system.run",
params: { params: {
command: argv, command: argv,
rawCommand: params.command,
cwd: workdir, cwd: workdir,
env: params.env, env: params.env,
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined, timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
@@ -471,6 +475,7 @@ export function createExecTool(
hostAsk === "always" || hostAsk === "always" ||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch); (hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
let approvedByAsk = false;
if (requiresAsk) { if (requiresAsk) {
const decision = const decision =
(await requestExecApprovalViaSocket({ (await requestExecApprovalViaSocket({
@@ -491,31 +496,43 @@ export function createExecTool(
throw new Error("exec denied: user denied"); throw new Error("exec denied: user denied");
} }
if (!decision) { if (!decision) {
if (askFallback === "deny") { if (askFallback === "full") {
throw new Error( approvedByAsk = true;
"exec denied: approval required (companion app approval UI not available)", } else if (askFallback === "allowlist") {
);
}
if (askFallback === "allowlist") {
if (!allowlistMatch) { if (!allowlistMatch) {
throw new Error( throw new Error(
"exec denied: approval required (companion app approval UI not available)", "exec denied: approval required (companion app approval UI not available)",
); );
} }
approvedByAsk = true;
} else {
throw new Error(
"exec denied: approval required (companion app approval UI not available)",
);
} }
} }
if (decision === "allow-always" && hostSecurity === "allowlist") { if (decision === "allow-once") {
const pattern = approvedByAsk = true;
resolution?.resolvedPath ?? }
resolution?.rawExecutable ?? if (decision === "allow-always") {
params.command.split(/\s+/).shift() ?? approvedByAsk = true;
""; if (hostSecurity === "allowlist") {
if (pattern) { const pattern =
addAllowlistEntry(approvals.file, defaults?.agentId, pattern); resolution?.resolvedPath ??
resolution?.rawExecutable ??
params.command.split(/\s+/).shift() ??
"";
if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
}
} }
} }
} }
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) {
throw new Error("exec denied: allowlist miss");
}
if (allowlistMatch) { if (allowlistMatch) {
recordAllowlistUse( recordAllowlistUse(
approvals.file, approvals.file,

View File

@@ -388,7 +388,7 @@ export function createNodesTool(options?: {
const nodes = await listNodes(gatewayOpts); const nodes = await listNodes(gatewayOpts);
if (nodes.length === 0) { if (nodes.length === 0) {
throw new Error( throw new Error(
"system.run requires a paired macOS companion app (no nodes available).", "system.run requires a paired companion app or node host (no nodes available).",
); );
} }
const nodeId = resolveNodeIdFromList(nodes, node); const nodeId = resolveNodeIdFromList(nodes, node);
@@ -398,7 +398,7 @@ export function createNodesTool(options?: {
: false; : false;
if (!supportsSystemRun) { if (!supportsSystemRun) {
throw new Error( throw new Error(
"system.run requires the macOS companion app; the selected node does not support system.run.", "system.run requires a companion app or node host; the selected node does not support system.run.",
); );
} }
const commandRaw = params.command; const commandRaw = params.command;

2
src/cli/node-cli.ts Normal file
View File

@@ -0,0 +1,2 @@
export { registerNodeCli } from "./node-cli/register.js";

577
src/cli/node-cli/daemon.ts Normal file
View File

@@ -0,0 +1,577 @@
import { buildNodeInstallPlan } from "../../commands/node-daemon-install-helpers.js";
import {
DEFAULT_NODE_DAEMON_RUNTIME,
isNodeDaemonRuntime,
} from "../../commands/node-daemon-runtime.js";
import {
resolveNodeLaunchAgentLabel,
resolveNodeSystemdServiceName,
resolveNodeWindowsTaskName,
} from "../../daemon/constants.js";
import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
import { resolveNodeService } from "../../daemon/node-service.js";
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
import { resolveIsNixMode } from "../../config/paths.js";
import { isWSL } from "../../infra/wsl.js";
import { loadNodeHostConfig } from "../../node-host/config.js";
import { defaultRuntime } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import {
buildDaemonServiceSnapshot,
createNullWriter,
emitDaemonActionJson,
} from "../daemon-cli/response.js";
import { formatRuntimeStatus, parsePort } from "../daemon-cli/shared.js";
type NodeDaemonInstallOptions = {
host?: string;
port?: string | number;
tls?: boolean;
tlsFingerprint?: string;
nodeId?: string;
displayName?: string;
runtime?: string;
force?: boolean;
json?: boolean;
};
type NodeDaemonLifecycleOptions = {
json?: boolean;
};
type NodeDaemonStatusOptions = {
json?: boolean;
};
function renderNodeServiceStartHints(): string[] {
const base = ["clawdbot node daemon install", "clawdbot node start"];
switch (process.platform) {
case "darwin":
return [
...base,
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${resolveNodeLaunchAgentLabel()}.plist`,
];
case "linux":
return [...base, `systemctl --user start ${resolveNodeSystemdServiceName()}.service`];
case "win32":
return [...base, `schtasks /Run /TN "${resolveNodeWindowsTaskName()}"`];
default:
return base;
}
}
function buildNodeRuntimeHints(env: NodeJS.ProcessEnv = process.env): string[] {
if (process.platform === "darwin") {
const logs = resolveGatewayLogPaths(env);
return [
`Launchd stdout (if installed): ${logs.stdoutPath}`,
`Launchd stderr (if installed): ${logs.stderrPath}`,
];
}
if (process.platform === "linux") {
const unit = resolveNodeSystemdServiceName();
return [`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`];
}
if (process.platform === "win32") {
const task = resolveNodeWindowsTaskName();
return [`Logs: schtasks /Query /TN "${task}" /V /FO LIST`];
}
return [];
}
function resolveNodeDefaults(opts: NodeDaemonInstallOptions, config: Awaited<ReturnType<typeof loadNodeHostConfig>>) {
const host = opts.host?.trim() || config?.gateway?.host || "127.0.0.1";
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
return { host, port: null };
}
const port = portOverride ?? config?.gateway?.port ?? 18790;
return { host, port };
}
export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
const json = Boolean(opts.json);
const warnings: string[] = [];
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
hints?: string[];
warnings?: string[];
}) => {
if (!json) return;
emitDaemonActionJson({ action: "install", ...payload });
};
const fail = (message: string, hints?: string[]) => {
if (json) {
emit({
ok: false,
error: message,
hints,
warnings: warnings.length ? warnings : undefined,
});
} else {
defaultRuntime.error(message);
if (hints?.length) {
for (const hint of hints) defaultRuntime.log(`Tip: ${hint}`);
}
}
defaultRuntime.exit(1);
};
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; daemon install is disabled.");
return;
}
const config = await loadNodeHostConfig();
const { host, port } = resolveNodeDefaults(opts, config);
if (!Number.isFinite(port ?? NaN) || (port ?? 0) <= 0) {
fail("Invalid port");
return;
}
const runtimeRaw = opts.runtime ? String(opts.runtime) : DEFAULT_NODE_DAEMON_RUNTIME;
if (!isNodeDaemonRuntime(runtimeRaw)) {
fail('Invalid --runtime (use "node" or "bun")');
return;
}
const service = resolveNodeService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
fail(`Node service check failed: ${String(err)}`);
return;
}
if (loaded && !opts.force) {
emit({
ok: true,
result: "already-installed",
message: `Node service already ${service.loadedText}.`,
service: buildDaemonServiceSnapshot(service, loaded),
warnings: warnings.length ? warnings : undefined,
});
if (!json) {
defaultRuntime.log(`Node service already ${service.loadedText}.`);
defaultRuntime.log("Reinstall with: clawdbot node daemon install --force");
}
return;
}
const tlsFingerprint = opts.tlsFingerprint?.trim() || config?.gateway?.tlsFingerprint;
const tls =
Boolean(opts.tls) ||
Boolean(tlsFingerprint) ||
Boolean(config?.gateway?.tls);
const { programArguments, workingDirectory, environment, description } =
await buildNodeInstallPlan({
env: process.env,
host,
port: port ?? 18790,
tls,
tlsFingerprint: tlsFingerprint || undefined,
nodeId: opts.nodeId,
displayName: opts.displayName,
runtime: runtimeRaw,
warn: (message) => {
if (json) warnings.push(message);
else defaultRuntime.log(message);
},
});
try {
await service.install({
env: process.env,
stdout,
programArguments,
workingDirectory,
environment,
description,
});
} catch (err) {
fail(`Node install failed: ${String(err)}`);
return;
}
let installed = true;
try {
installed = await service.isLoaded({ env: process.env });
} catch {
installed = true;
}
emit({
ok: true,
result: "installed",
service: buildDaemonServiceSnapshot(service, installed),
warnings: warnings.length ? warnings : undefined,
});
}
export async function runNodeDaemonUninstall(opts: NodeDaemonLifecycleOptions = {}) {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "uninstall", ...payload });
};
const fail = (message: string) => {
if (json) emit({ ok: false, error: message });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; daemon uninstall is disabled.");
return;
}
const service = resolveNodeService();
try {
await service.uninstall({ env: process.env, stdout });
} catch (err) {
fail(`Node uninstall failed: ${String(err)}`);
return;
}
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch {
loaded = false;
}
emit({
ok: true,
result: "uninstalled",
service: buildDaemonServiceSnapshot(service, loaded),
});
}
export async function runNodeDaemonStart(opts: NodeDaemonLifecycleOptions = {}) {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
hints?: string[];
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "start", ...payload });
};
const fail = (message: string, hints?: string[]) => {
if (json) emit({ ok: false, error: message, hints });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
const service = resolveNodeService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
fail(`Node service check failed: ${String(err)}`);
return;
}
if (!loaded) {
let hints = renderNodeServiceStartHints();
if (process.platform === "linux") {
const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false);
if (!systemdAvailable) {
hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })];
}
}
emit({
ok: true,
result: "not-loaded",
message: `Node service ${service.notLoadedText}.`,
hints,
service: buildDaemonServiceSnapshot(service, loaded),
});
if (!json) {
defaultRuntime.log(`Node service ${service.notLoadedText}.`);
for (const hint of hints) {
defaultRuntime.log(`Start with: ${hint}`);
}
}
return;
}
try {
await service.restart({ env: process.env, stdout });
} catch (err) {
const hints = renderNodeServiceStartHints();
fail(`Node start failed: ${String(err)}`, hints);
return;
}
let started = true;
try {
started = await service.isLoaded({ env: process.env });
} catch {
started = true;
}
emit({
ok: true,
result: "started",
service: buildDaemonServiceSnapshot(service, started),
});
}
export async function runNodeDaemonRestart(opts: NodeDaemonLifecycleOptions = {}) {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
hints?: string[];
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "restart", ...payload });
};
const fail = (message: string, hints?: string[]) => {
if (json) emit({ ok: false, error: message, hints });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
const service = resolveNodeService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
fail(`Node service check failed: ${String(err)}`);
return;
}
if (!loaded) {
let hints = renderNodeServiceStartHints();
if (process.platform === "linux") {
const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false);
if (!systemdAvailable) {
hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })];
}
}
emit({
ok: true,
result: "not-loaded",
message: `Node service ${service.notLoadedText}.`,
hints,
service: buildDaemonServiceSnapshot(service, loaded),
});
if (!json) {
defaultRuntime.log(`Node service ${service.notLoadedText}.`);
for (const hint of hints) {
defaultRuntime.log(`Start with: ${hint}`);
}
}
return;
}
try {
await service.restart({ env: process.env, stdout });
} catch (err) {
const hints = renderNodeServiceStartHints();
fail(`Node restart failed: ${String(err)}`, hints);
return;
}
let restarted = true;
try {
restarted = await service.isLoaded({ env: process.env });
} catch {
restarted = true;
}
emit({
ok: true,
result: "restarted",
service: buildDaemonServiceSnapshot(service, restarted),
});
}
export async function runNodeDaemonStop(opts: NodeDaemonLifecycleOptions = {}) {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "stop", ...payload });
};
const fail = (message: string) => {
if (json) emit({ ok: false, error: message });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
const service = resolveNodeService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
fail(`Node service check failed: ${String(err)}`);
return;
}
if (!loaded) {
emit({
ok: true,
result: "not-loaded",
message: `Node service ${service.notLoadedText}.`,
service: buildDaemonServiceSnapshot(service, loaded),
});
if (!json) {
defaultRuntime.log(`Node service ${service.notLoadedText}.`);
}
return;
}
try {
await service.stop({ env: process.env, stdout });
} catch (err) {
fail(`Node stop failed: ${String(err)}`);
return;
}
let stopped = false;
try {
stopped = await service.isLoaded({ env: process.env });
} catch {
stopped = false;
}
emit({
ok: true,
result: "stopped",
service: buildDaemonServiceSnapshot(service, stopped),
});
}
export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) {
const json = Boolean(opts.json);
const service = resolveNodeService();
const [loaded, command, runtime] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readCommand(process.env).catch(() => null),
service.readRuntime(process.env).catch((err) => ({ status: "unknown", detail: String(err) })),
]);
const payload = {
service: {
...buildDaemonServiceSnapshot(service, loaded),
command,
runtime,
},
};
if (json) {
defaultRuntime.log(JSON.stringify(payload, null, 2));
return;
}
const rich = isRich();
const label = (value: string) => colorize(rich, theme.muted, value);
const accent = (value: string) => colorize(rich, theme.accent, value);
const infoText = (value: string) => colorize(rich, theme.info, value);
const okText = (value: string) => colorize(rich, theme.success, value);
const warnText = (value: string) => colorize(rich, theme.warn, value);
const errorText = (value: string) => colorize(rich, theme.error, value);
const serviceStatus = loaded ? okText(service.loadedText) : warnText(service.notLoadedText);
defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`);
if (command?.programArguments?.length) {
defaultRuntime.log(`${label("Command:")} ${infoText(command.programArguments.join(" "))}`);
}
if (command?.sourcePath) {
defaultRuntime.log(`${label("Service file:")} ${infoText(command.sourcePath)}`);
}
if (command?.workingDirectory) {
defaultRuntime.log(`${label("Working dir:")} ${infoText(command.workingDirectory)}`);
}
const runtimeLine = formatRuntimeStatus(runtime);
if (runtimeLine) {
const runtimeStatus = runtime?.status ?? "unknown";
const runtimeColor =
runtimeStatus === "running"
? theme.success
: runtimeStatus === "stopped"
? theme.error
: runtimeStatus === "unknown"
? theme.muted
: theme.warn;
defaultRuntime.log(`${label("Runtime:")} ${colorize(rich, runtimeColor, runtimeLine)}`);
}
if (!loaded) {
defaultRuntime.log("");
for (const hint of renderNodeServiceStartHints()) {
defaultRuntime.log(`${warnText("Start with:")} ${infoText(hint)}`);
}
return;
}
const baseEnv = {
...(process.env as Record<string, string | undefined>),
...(command?.environment ?? undefined),
};
const hintEnv = {
...baseEnv,
CLAWDBOT_LOG_PREFIX: baseEnv.CLAWDBOT_LOG_PREFIX ?? "node",
} as NodeJS.ProcessEnv;
if (runtime?.missingUnit) {
defaultRuntime.error(errorText("Service unit not found."));
for (const hint of buildNodeRuntimeHints(hintEnv)) {
defaultRuntime.error(errorText(hint));
}
return;
}
if (runtime?.status === "stopped") {
defaultRuntime.error(errorText("Service is loaded but not running."));
for (const hint of buildNodeRuntimeHints(hintEnv)) {
defaultRuntime.error(errorText(hint));
}
}
}

View File

@@ -0,0 +1,116 @@
import type { Command } from "commander";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { loadNodeHostConfig } from "../../node-host/config.js";
import { runNodeHost } from "../../node-host/runner.js";
import {
runNodeDaemonInstall,
runNodeDaemonRestart,
runNodeDaemonStart,
runNodeDaemonStatus,
runNodeDaemonStop,
runNodeDaemonUninstall,
} from "./daemon.js";
import { parsePort } from "../daemon-cli/shared.js";
function parsePortWithFallback(value: unknown, fallback: number): number {
const parsed = parsePort(value);
return parsed ?? fallback;
}
export function registerNodeCli(program: Command) {
const node = program
.command("node")
.description("Run a headless node host (system.run/system.which)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/node", "docs.clawd.bot/cli/node")}\n`,
);
node
.command("start")
.description("Start the headless node host (foreground)")
.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")
.action(async (opts) => {
const existing = await loadNodeHostConfig();
const host =
(opts.host as string | undefined)?.trim() ||
existing?.gateway?.host ||
"127.0.0.1";
const port = parsePortWithFallback(opts.port, existing?.gateway?.port ?? 18790);
await runNodeHost({
gatewayHost: host,
gatewayPort: port,
gatewayTls: Boolean(opts.tls) || Boolean(opts.tlsFingerprint),
gatewayTlsFingerprint: opts.tlsFingerprint,
nodeId: opts.nodeId,
displayName: opts.displayName,
});
});
const daemon = node
.command("daemon")
.description("Manage the headless node daemon service (launchd/systemd/schtasks)");
daemon
.command("status")
.description("Show node daemon status")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStatus(opts);
});
daemon
.command("install")
.description("Install the node daemon 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>", "Daemon runtime (node|bun). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonInstall(opts);
});
daemon
.command("uninstall")
.description("Uninstall the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonUninstall(opts);
});
daemon
.command("start")
.description("Start the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStart(opts);
});
daemon
.command("stop")
.description("Stop the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStop(opts);
});
daemon
.command("restart")
.description("Restart the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonRestart(opts);
});
}

View File

@@ -13,6 +13,7 @@ import { registerWebhooksCli } from "../webhooks-cli.js";
import { registerLogsCli } from "../logs-cli.js"; import { registerLogsCli } from "../logs-cli.js";
import { registerModelsCli } from "../models-cli.js"; import { registerModelsCli } from "../models-cli.js";
import { registerNodesCli } from "../nodes-cli.js"; import { registerNodesCli } from "../nodes-cli.js";
import { registerNodeCli } from "../node-cli.js";
import { registerPairingCli } from "../pairing-cli.js"; 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";
@@ -27,6 +28,7 @@ export function registerSubCliCommands(program: Command) {
registerLogsCli(program); registerLogsCli(program);
registerModelsCli(program); registerModelsCli(program);
registerNodesCli(program); registerNodesCli(program);
registerNodeCli(program);
registerSandboxCli(program); registerSandboxCli(program);
registerTuiCli(program); registerTuiCli(program);
registerCronCli(program); registerCronCli(program);

View File

@@ -0,0 +1,65 @@
import { formatNodeServiceDescription } from "../daemon/constants.js";
import { resolveNodeProgramArguments } from "../daemon/program-args.js";
import {
renderSystemNodeWarning,
resolvePreferredNodePath,
resolveSystemNodeInfo,
} from "../daemon/runtime-paths.js";
import { buildNodeServiceEnvironment } from "../daemon/service-env.js";
import { resolveGatewayDevMode } from "./daemon-install-helpers.js";
import type { NodeDaemonRuntime } from "./node-daemon-runtime.js";
type WarnFn = (message: string, title?: string) => void;
export type NodeInstallPlan = {
programArguments: string[];
workingDirectory?: string;
environment: Record<string, string | undefined>;
description?: string;
};
export async function buildNodeInstallPlan(params: {
env: Record<string, string | undefined>;
host: string;
port: number;
tls?: boolean;
tlsFingerprint?: string;
nodeId?: string;
displayName?: string;
runtime: NodeDaemonRuntime;
devMode?: boolean;
nodePath?: string;
warn?: WarnFn;
}): Promise<NodeInstallPlan> {
const devMode = params.devMode ?? resolveGatewayDevMode();
const nodePath =
params.nodePath ??
(await resolvePreferredNodePath({
env: params.env,
runtime: params.runtime,
}));
const { programArguments, workingDirectory } = await resolveNodeProgramArguments({
host: params.host,
port: params.port,
tls: params.tls,
tlsFingerprint: params.tlsFingerprint,
nodeId: params.nodeId,
displayName: params.displayName,
dev: devMode,
runtime: params.runtime,
nodePath,
});
if (params.runtime === "node") {
const systemNode = await resolveSystemNodeInfo({ env: params.env });
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
if (warning) params.warn?.(warning, "Node daemon runtime");
}
const environment = buildNodeServiceEnvironment({ env: params.env });
const description = formatNodeServiceDescription({
version: environment.CLAWDBOT_SERVICE_VERSION,
});
return { programArguments, workingDirectory, environment, description };
}

View File

@@ -0,0 +1,16 @@
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
isGatewayDaemonRuntime,
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
export type NodeDaemonRuntime = GatewayDaemonRuntime;
export const DEFAULT_NODE_DAEMON_RUNTIME = DEFAULT_GATEWAY_DAEMON_RUNTIME;
export const NODE_DAEMON_RUNTIME_OPTIONS = GATEWAY_DAEMON_RUNTIME_OPTIONS;
export function isNodeDaemonRuntime(value: string | undefined): value is NodeDaemonRuntime {
return isGatewayDaemonRuntime(value);
}

View File

@@ -4,6 +4,12 @@ export const GATEWAY_SYSTEMD_SERVICE_NAME = "clawdbot-gateway";
export const GATEWAY_WINDOWS_TASK_NAME = "Clawdbot Gateway"; export const GATEWAY_WINDOWS_TASK_NAME = "Clawdbot Gateway";
export const GATEWAY_SERVICE_MARKER = "clawdbot"; export const GATEWAY_SERVICE_MARKER = "clawdbot";
export const GATEWAY_SERVICE_KIND = "gateway"; export const GATEWAY_SERVICE_KIND = "gateway";
export const NODE_LAUNCH_AGENT_LABEL = "com.clawdbot.node";
export const NODE_SYSTEMD_SERVICE_NAME = "clawdbot-node";
export const NODE_WINDOWS_TASK_NAME = "Clawdbot Node";
export const NODE_SERVICE_MARKER = "clawdbot";
export const NODE_SERVICE_KIND = "node";
export const NODE_WINDOWS_TASK_SCRIPT_NAME = "node.cmd";
export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = ["com.steipete.clawdbot.gateway"]; export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = ["com.steipete.clawdbot.gateway"];
export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = []; export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = [];
export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES: string[] = []; export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES: string[] = [];
@@ -51,3 +57,21 @@ export function formatGatewayServiceDescription(params?: {
if (parts.length === 0) return "Clawdbot Gateway"; if (parts.length === 0) return "Clawdbot Gateway";
return `Clawdbot Gateway (${parts.join(", ")})`; return `Clawdbot Gateway (${parts.join(", ")})`;
} }
export function resolveNodeLaunchAgentLabel(): string {
return NODE_LAUNCH_AGENT_LABEL;
}
export function resolveNodeSystemdServiceName(): string {
return NODE_SYSTEMD_SERVICE_NAME;
}
export function resolveNodeWindowsTaskName(): string {
return NODE_WINDOWS_TASK_NAME;
}
export function formatNodeServiceDescription(params?: { version?: string }): string {
const version = params?.version?.trim();
if (!version) return "Clawdbot Node Host";
return `Clawdbot Node Host (v${version})`;
}

View File

@@ -52,10 +52,11 @@ export function resolveGatewayLogPaths(env: Record<string, string | undefined>):
} { } {
const stateDir = resolveGatewayStateDir(env); const stateDir = resolveGatewayStateDir(env);
const logDir = path.join(stateDir, "logs"); const logDir = path.join(stateDir, "logs");
const prefix = env.CLAWDBOT_LOG_PREFIX?.trim() || "gateway";
return { return {
logDir, logDir,
stdoutPath: path.join(logDir, "gateway.log"), stdoutPath: path.join(logDir, `${prefix}.log`),
stderrPath: path.join(logDir, "gateway.err.log"), stderrPath: path.join(logDir, `${prefix}.err.log`),
}; };
} }
@@ -340,12 +341,14 @@ export async function installLaunchAgent({
programArguments, programArguments,
workingDirectory, workingDirectory,
environment, environment,
description,
}: { }: {
env: Record<string, string | undefined>; env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream; stdout: NodeJS.WritableStream;
programArguments: string[]; programArguments: string[];
workingDirectory?: string; workingDirectory?: string;
environment?: Record<string, string | undefined>; environment?: Record<string, string | undefined>;
description?: string;
}): Promise<{ plistPath: string }> { }): Promise<{ plistPath: string }> {
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env); const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
await fs.mkdir(logDir, { recursive: true }); await fs.mkdir(logDir, { recursive: true });
@@ -366,13 +369,15 @@ export async function installLaunchAgent({
const plistPath = resolveLaunchAgentPlistPathForLabel(env, label); const plistPath = resolveLaunchAgentPlistPathForLabel(env, label);
await fs.mkdir(path.dirname(plistPath), { recursive: true }); await fs.mkdir(path.dirname(plistPath), { recursive: true });
const description = formatGatewayServiceDescription({ const serviceDescription =
profile: env.CLAWDBOT_PROFILE, description ??
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION, formatGatewayServiceDescription({
}); profile: env.CLAWDBOT_PROFILE,
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const plist = buildLaunchAgentPlist({ const plist = buildLaunchAgentPlist({
label, label,
comment: description, comment: serviceDescription,
programArguments, programArguments,
workingDirectory, workingDirectory,
stdoutPath, stdoutPath,

View File

@@ -0,0 +1,66 @@
import type { GatewayService, GatewayServiceInstallArgs } from "./service.js";
import { resolveGatewayService } from "./service.js";
import {
NODE_SERVICE_KIND,
NODE_SERVICE_MARKER,
NODE_WINDOWS_TASK_SCRIPT_NAME,
resolveNodeLaunchAgentLabel,
resolveNodeSystemdServiceName,
resolveNodeWindowsTaskName,
} from "./constants.js";
function withNodeServiceEnv(
env: Record<string, string | undefined>,
): Record<string, string | undefined> {
return {
...env,
CLAWDBOT_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(),
CLAWDBOT_SYSTEMD_UNIT: resolveNodeSystemdServiceName(),
CLAWDBOT_WINDOWS_TASK_NAME: resolveNodeWindowsTaskName(),
CLAWDBOT_TASK_SCRIPT_NAME: NODE_WINDOWS_TASK_SCRIPT_NAME,
CLAWDBOT_LOG_PREFIX: "node",
CLAWDBOT_SERVICE_MARKER: NODE_SERVICE_MARKER,
CLAWDBOT_SERVICE_KIND: NODE_SERVICE_KIND,
};
}
function withNodeInstallEnv(args: GatewayServiceInstallArgs): GatewayServiceInstallArgs {
return {
...args,
env: withNodeServiceEnv(args.env),
environment: {
...args.environment,
CLAWDBOT_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(),
CLAWDBOT_SYSTEMD_UNIT: resolveNodeSystemdServiceName(),
CLAWDBOT_WINDOWS_TASK_NAME: resolveNodeWindowsTaskName(),
CLAWDBOT_TASK_SCRIPT_NAME: NODE_WINDOWS_TASK_SCRIPT_NAME,
CLAWDBOT_LOG_PREFIX: "node",
CLAWDBOT_SERVICE_MARKER: NODE_SERVICE_MARKER,
CLAWDBOT_SERVICE_KIND: NODE_SERVICE_KIND,
},
};
}
export function resolveNodeService(): GatewayService {
const base = resolveGatewayService();
return {
...base,
install: async (args) => {
return base.install(withNodeInstallEnv(args));
},
uninstall: async (args) => {
return base.uninstall({ ...args, env: withNodeServiceEnv(args.env) });
},
stop: async (args) => {
return base.stop({ ...args, env: withNodeServiceEnv(args.env ?? {}) });
},
restart: async (args) => {
return base.restart({ ...args, env: withNodeServiceEnv(args.env ?? {}) });
},
isLoaded: async (args) => {
return base.isLoaded({ env: withNodeServiceEnv(args.env ?? {}) });
},
readCommand: (env) => base.readCommand(withNodeServiceEnv(env)),
readRuntime: (env) => base.readRuntime(withNodeServiceEnv(env)),
};
}

View File

@@ -138,13 +138,12 @@ async function resolveBinaryPath(binary: string): Promise<string> {
} }
} }
export async function resolveGatewayProgramArguments(params: { async function resolveCliProgramArguments(params: {
port: number; args: string[];
dev?: boolean; dev?: boolean;
runtime?: GatewayRuntimePreference; runtime?: GatewayRuntimePreference;
nodePath?: string; nodePath?: string;
}): Promise<GatewayProgramArgs> { }): Promise<GatewayProgramArgs> {
const gatewayArgs = ["gateway", "--port", String(params.port)];
const execPath = process.execPath; const execPath = process.execPath;
const runtime = params.runtime ?? "auto"; const runtime = params.runtime ?? "auto";
@@ -153,7 +152,7 @@ export async function resolveGatewayProgramArguments(params: {
params.nodePath ?? (isNodeRuntime(execPath) ? execPath : await resolveNodePath()); params.nodePath ?? (isNodeRuntime(execPath) ? execPath : await resolveNodePath());
const cliEntrypointPath = await resolveCliEntrypointPathForService(); const cliEntrypointPath = await resolveCliEntrypointPathForService();
return { return {
programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs], programArguments: [nodePath, cliEntrypointPath, ...params.args],
}; };
} }
@@ -164,7 +163,7 @@ export async function resolveGatewayProgramArguments(params: {
await fs.access(devCliPath); await fs.access(devCliPath);
const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath(); const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath();
return { return {
programArguments: [bunPath, devCliPath, ...gatewayArgs], programArguments: [bunPath, devCliPath, ...params.args],
workingDirectory: repoRoot, workingDirectory: repoRoot,
}; };
} }
@@ -172,7 +171,7 @@ export async function resolveGatewayProgramArguments(params: {
const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath(); const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath();
const cliEntrypointPath = await resolveCliEntrypointPathForService(); const cliEntrypointPath = await resolveCliEntrypointPathForService();
return { return {
programArguments: [bunPath, cliEntrypointPath, ...gatewayArgs], programArguments: [bunPath, cliEntrypointPath, ...params.args],
}; };
} }
@@ -180,12 +179,12 @@ export async function resolveGatewayProgramArguments(params: {
try { try {
const cliEntrypointPath = await resolveCliEntrypointPathForService(); const cliEntrypointPath = await resolveCliEntrypointPathForService();
return { return {
programArguments: [execPath, cliEntrypointPath, ...gatewayArgs], programArguments: [execPath, cliEntrypointPath, ...params.args],
}; };
} catch (error) { } catch (error) {
// If running under bun or another runtime that can execute TS directly // If running under bun or another runtime that can execute TS directly
if (!isNodeRuntime(execPath)) { if (!isNodeRuntime(execPath)) {
return { programArguments: [execPath, ...gatewayArgs] }; return { programArguments: [execPath, ...params.args] };
} }
throw error; throw error;
} }
@@ -199,7 +198,7 @@ export async function resolveGatewayProgramArguments(params: {
// If already running under bun, use current execPath // If already running under bun, use current execPath
if (isBunRuntime(execPath)) { if (isBunRuntime(execPath)) {
return { return {
programArguments: [execPath, devCliPath, ...gatewayArgs], programArguments: [execPath, devCliPath, ...params.args],
workingDirectory: repoRoot, workingDirectory: repoRoot,
}; };
} }
@@ -207,7 +206,46 @@ export async function resolveGatewayProgramArguments(params: {
// Otherwise resolve bun from PATH // Otherwise resolve bun from PATH
const bunPath = await resolveBunPath(); const bunPath = await resolveBunPath();
return { return {
programArguments: [bunPath, devCliPath, ...gatewayArgs], programArguments: [bunPath, devCliPath, ...params.args],
workingDirectory: repoRoot, workingDirectory: repoRoot,
}; };
} }
export async function resolveGatewayProgramArguments(params: {
port: number;
dev?: boolean;
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise<GatewayProgramArgs> {
const gatewayArgs = ["gateway", "--port", String(params.port)];
return resolveCliProgramArguments({
args: gatewayArgs,
dev: params.dev,
runtime: params.runtime,
nodePath: params.nodePath,
});
}
export async function resolveNodeProgramArguments(params: {
host: string;
port: number;
tls?: boolean;
tlsFingerprint?: string;
nodeId?: string;
displayName?: string;
dev?: boolean;
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise<GatewayProgramArgs> {
const args = ["node", "start", "--host", params.host, "--port", String(params.port)];
if (params.tls || params.tlsFingerprint) args.push("--tls");
if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint);
if (params.nodeId) args.push("--node-id", params.nodeId);
if (params.displayName) args.push("--display-name", params.displayName);
return resolveCliProgramArguments({
args,
dev: params.dev,
runtime: params.runtime,
nodePath: params.nodePath,
});
}

View File

@@ -16,9 +16,18 @@ const formatLine = (label: string, value: string) => {
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
}; };
function resolveTaskName(env: Record<string, string | undefined>): string {
const override = env.CLAWDBOT_WINDOWS_TASK_NAME?.trim();
if (override) return override;
return resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
}
export function resolveTaskScriptPath(env: Record<string, string | undefined>): string { export function resolveTaskScriptPath(env: Record<string, string | undefined>): string {
const override = env.CLAWDBOT_TASK_SCRIPT?.trim();
if (override) return override;
const scriptName = env.CLAWDBOT_TASK_SCRIPT_NAME?.trim() || "gateway.cmd";
const stateDir = resolveGatewayStateDir(env); const stateDir = resolveGatewayStateDir(env);
return path.join(stateDir, "gateway.cmd"); return path.join(stateDir, scriptName);
} }
function quoteCmdArg(value: string): string { function quoteCmdArg(value: string): string {
@@ -201,29 +210,33 @@ export async function installScheduledTask({
programArguments, programArguments,
workingDirectory, workingDirectory,
environment, environment,
description,
}: { }: {
env: Record<string, string | undefined>; env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream; stdout: NodeJS.WritableStream;
programArguments: string[]; programArguments: string[];
workingDirectory?: string; workingDirectory?: string;
environment?: Record<string, string | undefined>; environment?: Record<string, string | undefined>;
description?: string;
}): Promise<{ scriptPath: string }> { }): Promise<{ scriptPath: string }> {
await assertSchtasksAvailable(); await assertSchtasksAvailable();
const scriptPath = resolveTaskScriptPath(env); const scriptPath = resolveTaskScriptPath(env);
await fs.mkdir(path.dirname(scriptPath), { recursive: true }); await fs.mkdir(path.dirname(scriptPath), { recursive: true });
const description = formatGatewayServiceDescription({ const taskDescription =
profile: env.CLAWDBOT_PROFILE, description ??
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION, formatGatewayServiceDescription({
}); profile: env.CLAWDBOT_PROFILE,
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const script = buildTaskScript({ const script = buildTaskScript({
description, description: taskDescription,
programArguments, programArguments,
workingDirectory, workingDirectory,
environment, environment,
}); });
await fs.writeFile(scriptPath, script, "utf8"); await fs.writeFile(scriptPath, script, "utf8");
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE); const taskName = resolveTaskName(env);
const quotedScript = quoteCmdArg(scriptPath); const quotedScript = quoteCmdArg(scriptPath);
const baseArgs = [ const baseArgs = [
"/Create", "/Create",
@@ -268,7 +281,7 @@ export async function uninstallScheduledTask({
stdout: NodeJS.WritableStream; stdout: NodeJS.WritableStream;
}): Promise<void> { }): Promise<void> {
await assertSchtasksAvailable(); await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE); const taskName = resolveTaskName(env);
await execSchtasks(["/Delete", "/F", "/TN", taskName]); await execSchtasks(["/Delete", "/F", "/TN", taskName]);
const scriptPath = resolveTaskScriptPath(env); const scriptPath = resolveTaskScriptPath(env);
@@ -293,7 +306,7 @@ export async function stopScheduledTask({
env?: Record<string, string | undefined>; env?: Record<string, string | undefined>;
}): Promise<void> { }): Promise<void> {
await assertSchtasksAvailable(); await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE); const taskName = resolveTaskName(env ?? (process.env as Record<string, string | undefined>));
const res = await execSchtasks(["/End", "/TN", taskName]); const res = await execSchtasks(["/End", "/TN", taskName]);
if (res.code !== 0 && !isTaskNotRunning(res)) { if (res.code !== 0 && !isTaskNotRunning(res)) {
throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim()); throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim());
@@ -309,7 +322,7 @@ export async function restartScheduledTask({
env?: Record<string, string | undefined>; env?: Record<string, string | undefined>;
}): Promise<void> { }): Promise<void> {
await assertSchtasksAvailable(); await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE); const taskName = resolveTaskName(env ?? (process.env as Record<string, string | undefined>));
await execSchtasks(["/End", "/TN", taskName]); await execSchtasks(["/End", "/TN", taskName]);
const res = await execSchtasks(["/Run", "/TN", taskName]); const res = await execSchtasks(["/Run", "/TN", taskName]);
if (res.code !== 0) { if (res.code !== 0) {
@@ -322,7 +335,7 @@ export async function isScheduledTaskInstalled(args: {
env?: Record<string, string | undefined>; env?: Record<string, string | undefined>;
}): Promise<boolean> { }): Promise<boolean> {
await assertSchtasksAvailable(); await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(args.env?.CLAWDBOT_PROFILE); const taskName = resolveTaskName(args.env ?? (process.env as Record<string, string | undefined>));
const res = await execSchtasks(["/Query", "/TN", taskName]); const res = await execSchtasks(["/Query", "/TN", taskName]);
return res.code === 0; return res.code === 0;
} }
@@ -338,7 +351,7 @@ export async function readScheduledTaskRuntime(
detail: String(err), detail: String(err),
}; };
} }
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE); const taskName = resolveTaskName(env);
const res = await execSchtasks(["/Query", "/TN", taskName, "/V", "/FO", "LIST"]); const res = await execSchtasks(["/Query", "/TN", taskName, "/V", "/FO", "LIST"]);
if (res.code !== 0) { if (res.code !== 0) {
const detail = (res.stderr || res.stdout).trim(); const detail = (res.stderr || res.stdout).trim();

View File

@@ -6,6 +6,12 @@ import {
GATEWAY_SERVICE_MARKER, GATEWAY_SERVICE_MARKER,
resolveGatewayLaunchAgentLabel, resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName, resolveGatewaySystemdServiceName,
NODE_SERVICE_KIND,
NODE_SERVICE_MARKER,
NODE_WINDOWS_TASK_SCRIPT_NAME,
resolveNodeLaunchAgentLabel,
resolveNodeSystemdServiceName,
resolveNodeWindowsTaskName,
} from "./constants.js"; } from "./constants.js";
export type MinimalServicePathOptions = { export type MinimalServicePathOptions = {
@@ -82,3 +88,22 @@ export function buildServiceEnvironment(params: {
CLAWDBOT_SERVICE_VERSION: VERSION, CLAWDBOT_SERVICE_VERSION: VERSION,
}; };
} }
export function buildNodeServiceEnvironment(params: {
env: Record<string, string | undefined>;
}): Record<string, string | undefined> {
const { env } = params;
return {
PATH: buildMinimalServicePath({ env }),
CLAWDBOT_STATE_DIR: env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(),
CLAWDBOT_SYSTEMD_UNIT: resolveNodeSystemdServiceName(),
CLAWDBOT_WINDOWS_TASK_NAME: resolveNodeWindowsTaskName(),
CLAWDBOT_TASK_SCRIPT_NAME: NODE_WINDOWS_TASK_SCRIPT_NAME,
CLAWDBOT_LOG_PREFIX: "node",
CLAWDBOT_SERVICE_MARKER: NODE_SERVICE_MARKER,
CLAWDBOT_SERVICE_KIND: NODE_SERVICE_KIND,
CLAWDBOT_SERVICE_VERSION: VERSION,
};
}

View File

@@ -33,6 +33,7 @@ export type GatewayServiceInstallArgs = {
programArguments: string[]; programArguments: string[];
workingDirectory?: string; workingDirectory?: string;
environment?: Record<string, string | undefined>; environment?: Record<string, string | undefined>;
description?: string;
}; };
export type GatewayService = { export type GatewayService = {

View File

@@ -186,23 +186,27 @@ export async function installSystemdService({
programArguments, programArguments,
workingDirectory, workingDirectory,
environment, environment,
description,
}: { }: {
env: Record<string, string | undefined>; env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream; stdout: NodeJS.WritableStream;
programArguments: string[]; programArguments: string[];
workingDirectory?: string; workingDirectory?: string;
environment?: Record<string, string | undefined>; environment?: Record<string, string | undefined>;
description?: string;
}): Promise<{ unitPath: string }> { }): Promise<{ unitPath: string }> {
await assertSystemdAvailable(); await assertSystemdAvailable();
const unitPath = resolveSystemdUnitPath(env); const unitPath = resolveSystemdUnitPath(env);
await fs.mkdir(path.dirname(unitPath), { recursive: true }); await fs.mkdir(path.dirname(unitPath), { recursive: true });
const description = formatGatewayServiceDescription({ const serviceDescription =
profile: env.CLAWDBOT_PROFILE, description ??
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION, formatGatewayServiceDescription({
}); profile: env.CLAWDBOT_PROFILE,
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const unit = buildSystemdUnit({ const unit = buildSystemdUnit({
description, description: serviceDescription,
programArguments, programArguments,
workingDirectory, workingDirectory,
environment, environment,

View File

@@ -1,3 +1,7 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { loadConfig } from "../config/config.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "../infra/voicewake.js"; import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "../infra/voicewake.js";
import { import {
ErrorCodes, ErrorCodes,
@@ -72,6 +76,20 @@ export const handleSystemBridgeMethods: BridgeMethodHandler = async (
const models = await ctx.loadGatewayModelCatalog(); const models = await ctx.loadGatewayModelCatalog();
return { ok: true, payloadJSON: JSON.stringify({ models }) }; return { ok: true, payloadJSON: JSON.stringify({ models }) };
} }
case "skills.bins": {
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg,
eligibility: { remote: getRemoteSkillEligibility() },
});
const bins = Array.from(
new Set(
report.skills.flatMap((skill) => skill.requirements?.bins ?? []).filter(Boolean),
),
);
return { ok: true, payloadJSON: JSON.stringify({ bins }) };
}
default: default:
return null; return null;
} }

View File

@@ -106,7 +106,7 @@ export function loadExecApprovals(): ExecApprovalsFile {
const raw = fs.readFileSync(filePath, "utf8"); const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as ExecApprovalsFile; const parsed = JSON.parse(raw) as ExecApprovalsFile;
if (parsed?.version !== 1) { if (parsed?.version !== 1) {
return normalizeExecApprovals({ version: 1, agents: parsed?.agents ?? {} }); return normalizeExecApprovals({ version: 1, agents: {} });
} }
return normalizeExecApprovals(parsed); return normalizeExecApprovals(parsed);
} catch { } catch {
@@ -117,7 +117,12 @@ export function loadExecApprovals(): ExecApprovalsFile {
export function saveExecApprovals(file: ExecApprovalsFile) { export function saveExecApprovals(file: ExecApprovalsFile) {
const filePath = resolveExecApprovalsPath(); const filePath = resolveExecApprovalsPath();
ensureDir(filePath); ensureDir(filePath);
fs.writeFileSync(filePath, JSON.stringify(file, null, 2)); fs.writeFileSync(filePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort on platforms without chmod
}
} }
export function ensureExecApprovals(): ExecApprovalsFile { export function ensureExecApprovals(): ExecApprovalsFile {

10
src/infra/node-shell.ts Normal file
View File

@@ -0,0 +1,10 @@
export function buildNodeShellCommand(command: string, platform?: string | null) {
const normalized = String(platform ?? "")
.trim()
.toLowerCase();
if (normalized.includes("win")) {
return ["cmd.exe", "/d", "/s", "/c", command];
}
return ["/bin/sh", "-lc", command];
}

View File

@@ -0,0 +1,306 @@
import crypto from "node:crypto";
import net from "node:net";
import tls from "node:tls";
import type {
BridgeErrorFrame,
BridgeEventFrame,
BridgeHelloFrame,
BridgeHelloOkFrame,
BridgeInvokeRequestFrame,
BridgeInvokeResponseFrame,
BridgePairOkFrame,
BridgePairRequestFrame,
BridgePingFrame,
BridgePongFrame,
BridgeRPCRequestFrame,
BridgeRPCResponseFrame,
} from "../infra/bridge/server/types.js";
export type BridgeClientOptions = {
host: string;
port: number;
tls?: boolean;
tlsFingerprint?: string;
nodeId: string;
token?: string;
displayName?: string;
platform?: string;
version?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
onInvoke?: (frame: BridgeInvokeRequestFrame) => void | Promise<void>;
onEvent?: (frame: BridgeEventFrame) => void | Promise<void>;
onPairToken?: (token: string) => void | Promise<void>;
onAuthReset?: () => void | Promise<void>;
onConnected?: (hello: BridgeHelloOkFrame) => void | Promise<void>;
onDisconnected?: (err?: Error) => void | Promise<void>;
log?: { info?: (msg: string) => void; warn?: (msg: string) => void };
};
type PendingRpc = {
resolve: (frame: BridgeRPCResponseFrame) => void;
reject: (err: Error) => void;
timer?: NodeJS.Timeout;
};
function normalizeFingerprint(input: string): string {
return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
}
function extractFingerprint(raw: tls.PeerCertificate | tls.DetailedPeerCertificate): string | null {
const value = "fingerprint256" in raw ? raw.fingerprint256 : undefined;
if (!value) return null;
return normalizeFingerprint(value);
}
export class BridgeClient {
private opts: BridgeClientOptions;
private socket: net.Socket | tls.TLSSocket | null = null;
private buffer = "";
private pendingRpc = new Map<string, PendingRpc>();
private connected = false;
private helloReady: Promise<void> | null = null;
private helloResolve: (() => void) | null = null;
private helloReject: ((err: Error) => void) | null = null;
constructor(opts: BridgeClientOptions) {
this.opts = opts;
}
async connect(): Promise<void> {
if (this.connected) return;
this.helloReady = new Promise<void>((resolve, reject) => {
this.helloResolve = resolve;
this.helloReject = reject;
});
const socket = this.opts.tls
? tls.connect({
host: this.opts.host,
port: this.opts.port,
rejectUnauthorized: false,
})
: net.connect({ host: this.opts.host, port: this.opts.port });
this.socket = socket;
socket.setNoDelay(true);
socket.on("connect", () => {
this.sendHello();
});
socket.on("error", (err) => {
this.handleDisconnect(err);
});
socket.on("close", () => {
this.handleDisconnect();
});
socket.on("data", (chunk) => {
this.buffer += chunk.toString("utf8");
this.flush();
});
if (this.opts.tls && socket instanceof tls.TLSSocket && this.opts.tlsFingerprint) {
socket.once("secureConnect", () => {
const cert = socket.getPeerCertificate(true);
const fingerprint = cert ? extractFingerprint(cert) : null;
if (!fingerprint || fingerprint !== normalizeFingerprint(this.opts.tlsFingerprint ?? "")) {
const err = new Error("bridge tls fingerprint mismatch");
this.handleDisconnect(err);
socket.destroy(err);
}
});
}
await this.helloReady;
}
async close(): Promise<void> {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
this.connected = false;
this.pendingRpc.forEach((pending) => {
pending.timer && clearTimeout(pending.timer);
pending.reject(new Error("bridge client closed"));
});
this.pendingRpc.clear();
}
async request(method: string, params: Record<string, unknown> | null = null, timeoutMs = 5000) {
const id = crypto.randomUUID();
const frame: BridgeRPCRequestFrame = {
type: "req",
id,
method,
paramsJSON: params ? JSON.stringify(params) : null,
};
const res = await new Promise<BridgeRPCResponseFrame>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRpc.delete(id);
reject(new Error(`bridge request timeout (${method})`));
}, timeoutMs);
this.pendingRpc.set(id, { resolve, reject, timer });
this.send(frame);
});
if (!res.ok) {
throw new Error(res.error?.message ?? "bridge request failed");
}
return res.payloadJSON ? JSON.parse(res.payloadJSON) : null;
}
sendEvent(event: string, payload?: unknown) {
const frame: BridgeEventFrame = {
type: "event",
event,
payloadJSON: payload ? JSON.stringify(payload) : null,
};
this.send(frame);
}
sendInvokeResponse(frame: BridgeInvokeResponseFrame) {
this.send(frame);
}
private sendHello() {
const hello: BridgeHelloFrame = {
type: "hello",
nodeId: this.opts.nodeId,
token: this.opts.token,
displayName: this.opts.displayName,
platform: this.opts.platform,
version: this.opts.version,
deviceFamily: this.opts.deviceFamily,
modelIdentifier: this.opts.modelIdentifier,
caps: this.opts.caps,
commands: this.opts.commands,
permissions: this.opts.permissions,
};
this.send(hello);
}
private sendPairRequest() {
const req: BridgePairRequestFrame = {
type: "pair-request",
nodeId: this.opts.nodeId,
displayName: this.opts.displayName,
platform: this.opts.platform,
version: this.opts.version,
deviceFamily: this.opts.deviceFamily,
modelIdentifier: this.opts.modelIdentifier,
caps: this.opts.caps,
commands: this.opts.commands,
permissions: this.opts.permissions,
};
this.send(req);
}
private send(frame: object) {
if (!this.socket) return;
this.socket.write(`${JSON.stringify(frame)}\n`);
}
private handleDisconnect(err?: Error) {
if (!this.connected && this.helloReject) {
this.helloReject(err ?? new Error("bridge connection failed"));
this.helloResolve = null;
this.helloReject = null;
}
if (!this.connected && !this.socket) return;
this.connected = false;
this.socket = null;
this.pendingRpc.forEach((pending) => {
pending.timer && clearTimeout(pending.timer);
pending.reject(err ?? new Error("bridge connection closed"));
});
this.pendingRpc.clear();
void this.opts.onDisconnected?.(err);
}
private flush() {
while (true) {
const idx = this.buffer.indexOf("\n");
if (idx === -1) break;
const line = this.buffer.slice(0, idx).trim();
this.buffer = this.buffer.slice(idx + 1);
if (!line) continue;
let frame: { type?: string; [key: string]: unknown };
try {
frame = JSON.parse(line) as { type?: string };
} catch {
continue;
}
this.handleFrame(frame as BridgeErrorFrame);
}
}
private handleFrame(frame: {
type?: string;
[key: string]: unknown;
}) {
const type = String(frame.type ?? "");
switch (type) {
case "hello-ok": {
this.connected = true;
this.helloResolve?.();
this.helloResolve = null;
this.helloReject = null;
void this.opts.onConnected?.(frame as BridgeHelloOkFrame);
return;
}
case "pair-ok": {
const token = String((frame as BridgePairOkFrame).token ?? "").trim();
if (token) {
this.opts.token = token;
void this.opts.onPairToken?.(token);
}
return;
}
case "error": {
const code = String((frame as BridgeErrorFrame).code ?? "");
if (code === "NOT_PAIRED" || code === "UNAUTHORIZED") {
this.opts.token = undefined;
void this.opts.onAuthReset?.();
this.sendPairRequest();
return;
}
this.handleDisconnect(new Error((frame as BridgeErrorFrame).message ?? "bridge error"));
return;
}
case "pong":
return;
case "ping": {
const ping = frame as BridgePingFrame;
const pong: BridgePongFrame = { type: "pong", id: String(ping.id ?? "") };
this.send(pong);
return;
}
case "event": {
void this.opts.onEvent?.(frame as BridgeEventFrame);
return;
}
case "res": {
const res = frame as BridgeRPCResponseFrame;
const pending = this.pendingRpc.get(res.id);
if (pending) {
pending.timer && clearTimeout(pending.timer);
this.pendingRpc.delete(res.id);
pending.resolve(res);
}
return;
}
case "invoke": {
void this.opts.onInvoke?.(frame as BridgeInvokeRequestFrame);
return;
}
case "invoke-res": {
return;
}
default:
return;
}
}
}

74
src/node-host/config.ts Normal file
View File

@@ -0,0 +1,74 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
export type NodeHostGatewayConfig = {
host?: string;
port?: number;
tls?: boolean;
tlsFingerprint?: string;
};
export type NodeHostConfig = {
version: 1;
nodeId: string;
token?: string;
displayName?: string;
gateway?: NodeHostGatewayConfig;
};
const NODE_HOST_FILE = "node.json";
export function resolveNodeHostConfigPath(): string {
return path.join(resolveStateDir(), NODE_HOST_FILE);
}
function normalizeConfig(config: Partial<NodeHostConfig> | null): NodeHostConfig {
const base: NodeHostConfig = {
version: 1,
nodeId: "",
token: config?.token,
displayName: config?.displayName,
gateway: config?.gateway,
};
if (config?.version === 1 && typeof config.nodeId === "string") {
base.nodeId = config.nodeId.trim();
}
if (!base.nodeId) {
base.nodeId = crypto.randomUUID();
}
return base;
}
export async function loadNodeHostConfig(): Promise<NodeHostConfig | null> {
const filePath = resolveNodeHostConfigPath();
try {
const raw = await fs.readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as Partial<NodeHostConfig>;
return normalizeConfig(parsed);
} catch {
return null;
}
}
export async function saveNodeHostConfig(config: NodeHostConfig): Promise<void> {
const filePath = resolveNodeHostConfigPath();
await fs.mkdir(path.dirname(filePath), { recursive: true });
const payload = JSON.stringify(config, null, 2);
await fs.writeFile(filePath, `${payload}\n`, { mode: 0o600 });
try {
await fs.chmod(filePath, 0o600);
} catch {
// best-effort on platforms without chmod
}
}
export async function ensureNodeHostConfig(): Promise<NodeHostConfig> {
const existing = await loadNodeHostConfig();
const normalized = normalizeConfig(existing);
await saveNodeHostConfig(normalized);
return normalized;
}

654
src/node-host/runner.ts Normal file
View File

@@ -0,0 +1,654 @@
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { BridgeInvokeRequestFrame } from "../infra/bridge/server/types.js";
import {
addAllowlistEntry,
matchAllowlist,
recordAllowlistUse,
requestExecApprovalViaSocket,
resolveCommandResolution,
resolveExecApprovals,
} from "../infra/exec-approvals.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { VERSION } from "../version.js";
import { BridgeClient } from "./bridge-client.js";
import {
ensureNodeHostConfig,
saveNodeHostConfig,
type NodeHostGatewayConfig,
} from "./config.js";
type NodeHostRunOptions = {
gatewayHost: string;
gatewayPort: number;
gatewayTls?: boolean;
gatewayTlsFingerprint?: string;
nodeId?: string;
displayName?: string;
};
type SystemRunParams = {
command: string[];
rawCommand?: string | null;
cwd?: string | null;
env?: Record<string, string>;
timeoutMs?: number | null;
needsScreenRecording?: boolean | null;
agentId?: string | null;
sessionKey?: string | null;
};
type SystemWhichParams = {
bins: string[];
};
type RunResult = {
exitCode?: number;
timedOut: boolean;
success: boolean;
stdout: string;
stderr: string;
error?: string | null;
truncated: boolean;
};
type ExecEventPayload = {
sessionKey: string;
runId: string;
host: string;
command?: string;
exitCode?: number;
timedOut?: boolean;
success?: boolean;
output?: string;
reason?: string;
};
const OUTPUT_CAP = 200_000;
const OUTPUT_EVENT_TAIL = 20_000;
const blockedEnvKeys = new Set([
"PATH",
"NODE_OPTIONS",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYOPT",
]);
const blockedEnvPrefixes = ["DYLD_", "LD_"];
class SkillBinsCache {
private bins = new Set<string>();
private lastRefresh = 0;
private readonly ttlMs = 90_000;
private readonly fetch: () => Promise<string[]>;
constructor(fetch: () => Promise<string[]>) {
this.fetch = fetch;
}
async current(force = false): Promise<Set<string>> {
if (force || Date.now() - this.lastRefresh > this.ttlMs) {
await this.refresh();
}
return this.bins;
}
private async refresh() {
try {
const bins = await this.fetch();
this.bins = new Set(bins);
this.lastRefresh = Date.now();
} catch {
if (!this.lastRefresh) {
this.bins = new Set();
}
}
}
}
function sanitizeEnv(overrides?: Record<string, string> | null): Record<string, string> | undefined {
if (!overrides) return undefined;
const merged = { ...process.env } as Record<string, string>;
for (const [rawKey, value] of Object.entries(overrides)) {
const key = rawKey.trim();
if (!key) continue;
const upper = key.toUpperCase();
if (blockedEnvKeys.has(upper)) continue;
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) continue;
merged[key] = value;
}
return merged;
}
function formatCommand(argv: string[]): string {
return argv
.map((arg) => {
const trimmed = arg.trim();
if (!trimmed) return "\"\"";
const needsQuotes = /\s|"/.test(trimmed);
if (!needsQuotes) return trimmed;
return `"${trimmed.replace(/"/g, '\\"')}"`;
})
.join(" ");
}
function truncateOutput(raw: string, maxChars: number): { text: string; truncated: boolean } {
if (raw.length <= maxChars) return { text: raw, truncated: false };
return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true };
}
async function runCommand(
argv: string[],
cwd: string | undefined,
env: Record<string, string> | undefined,
timeoutMs: number | undefined,
): Promise<RunResult> {
return await new Promise((resolve) => {
let stdout = "";
let stderr = "";
let outputLen = 0;
let truncated = false;
let timedOut = false;
let settled = false;
const child = spawn(argv[0], argv.slice(1), {
cwd,
env,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const onChunk = (chunk: Buffer, target: "stdout" | "stderr") => {
if (outputLen >= OUTPUT_CAP) {
truncated = true;
return;
}
const remaining = OUTPUT_CAP - outputLen;
const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
const str = slice.toString("utf8");
outputLen += slice.length;
if (target === "stdout") stdout += str;
else stderr += str;
if (chunk.length > remaining) truncated = true;
};
child.stdout?.on("data", (chunk) => onChunk(chunk as Buffer, "stdout"));
child.stderr?.on("data", (chunk) => onChunk(chunk as Buffer, "stderr"));
let timer: NodeJS.Timeout | undefined;
if (timeoutMs && timeoutMs > 0) {
timer = setTimeout(() => {
timedOut = true;
try {
child.kill("SIGKILL");
} catch {
// ignore
}
}, timeoutMs);
}
const finalize = (exitCode?: number, error?: string | null) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve({
exitCode,
timedOut,
success: exitCode === 0 && !timedOut && !error,
stdout,
stderr,
error: error ?? null,
truncated,
});
};
child.on("error", (err) => {
finalize(undefined, err.message);
});
child.on("exit", (code) => {
finalize(code === null ? undefined : code, null);
});
});
}
function resolveEnvPath(env?: Record<string, string>): string[] {
const raw =
env?.PATH ??
(env as Record<string, string>)?.Path ??
process.env.PATH ??
process.env.Path ??
"";
return raw.split(path.delimiter).filter(Boolean);
}
function resolveExecutable(bin: string, env?: Record<string, string>) {
if (bin.includes("/") || bin.includes("\\")) return null;
const extensions =
process.platform === "win32"
? (process.env.PATHEXT ?? process.env.PathExt ?? ".EXE;.CMD;.BAT;.COM")
.split(";")
.map((ext) => ext.toLowerCase())
: [""];
for (const dir of resolveEnvPath(env)) {
for (const ext of extensions) {
const candidate = path.join(dir, bin + ext);
if (fs.existsSync(candidate)) return candidate;
}
}
return null;
}
async function handleSystemWhich(params: SystemWhichParams, env?: Record<string, string>) {
const bins = params.bins
.map((bin) => bin.trim())
.filter(Boolean);
const found: Record<string, string> = {};
for (const bin of bins) {
const path = resolveExecutable(bin, env);
if (path) found[bin] = path;
}
return { bins: found };
}
function buildExecEventPayload(payload: ExecEventPayload): ExecEventPayload {
if (!payload.output) return payload;
const trimmed = payload.output.trim();
if (!trimmed) return payload;
const { text } = truncateOutput(trimmed, OUTPUT_EVENT_TAIL);
return { ...payload, output: text };
}
export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const config = await ensureNodeHostConfig();
const nodeId = opts.nodeId?.trim() || config.nodeId;
if (nodeId !== config.nodeId) {
config.nodeId = nodeId;
config.token = undefined;
}
const displayName =
opts.displayName?.trim() || config.displayName || (await getMachineDisplayName());
config.displayName = displayName;
const gateway: NodeHostGatewayConfig = {
host: opts.gatewayHost,
port: opts.gatewayPort,
tls: opts.gatewayTls === true,
tlsFingerprint: opts.gatewayTlsFingerprint,
};
config.gateway = gateway;
await saveNodeHostConfig(config);
let disconnectResolve: (() => void) | null = null;
let disconnectSignal = false;
const waitForDisconnect = () =>
new Promise<void>((resolve) => {
if (disconnectSignal) {
disconnectSignal = false;
resolve();
return;
}
disconnectResolve = resolve;
});
const client = new BridgeClient({
host: gateway.host ?? "127.0.0.1",
port: gateway.port ?? 18790,
tls: gateway.tls,
tlsFingerprint: gateway.tlsFingerprint,
nodeId,
token: config.token,
displayName,
platform: process.platform,
version: VERSION,
deviceFamily: os.platform(),
modelIdentifier: os.hostname(),
caps: ["system"],
commands: ["system.run", "system.which"],
onPairToken: async (token) => {
config.token = token;
await saveNodeHostConfig(config);
},
onAuthReset: async () => {
if (!config.token) return;
config.token = undefined;
await saveNodeHostConfig(config);
},
onInvoke: async (frame) => {
await handleInvoke(frame, client, skillBins);
},
onDisconnected: () => {
if (disconnectResolve) {
disconnectResolve();
disconnectResolve = null;
} else {
disconnectSignal = true;
}
},
});
const skillBins = new SkillBinsCache(async () => {
const res = await client.request("skills.bins", {});
const bins = Array.isArray(res?.bins) ? res.bins.map((b) => String(b)) : [];
return bins;
});
while (true) {
try {
await client.connect();
await waitForDisconnect();
} catch {
// ignore connect errors; retry
}
await new Promise((resolve) => setTimeout(resolve, 1500));
}
}
async function handleInvoke(
frame: BridgeInvokeRequestFrame,
client: BridgeClient,
skillBins: SkillBinsCache,
) {
const command = String(frame.command ?? "");
if (command === "system.which") {
try {
const params = decodeParams<SystemWhichParams>(frame.paramsJSON);
if (!Array.isArray(params.bins)) {
throw new Error("INVALID_REQUEST: bins required");
}
const env = sanitizeEnv(undefined);
const payload = await handleSystemWhich(params, env);
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.run") {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "command not supported" },
});
return;
}
let params: SystemRunParams;
try {
params = decodeParams<SystemRunParams>(frame.paramsJSON);
} catch (err) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
});
return;
}
if (!Array.isArray(params.command) || params.command.length === 0) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "INVALID_REQUEST", message: "command required" },
});
return;
}
const argv = params.command.map((item) => String(item));
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : "";
const cmdText = rawCommand || formatCommand(argv);
const agentId = params.agentId?.trim() || undefined;
const approvals = resolveExecApprovals(agentId);
const security = approvals.agent.security;
const ask = approvals.agent.ask;
const askFallback = approvals.agent.askFallback;
const autoAllowSkills = approvals.agent.autoAllowSkills;
const sessionKey = params.sessionKey?.trim() || "node";
const runId = crypto.randomUUID();
const env = sanitizeEnv(params.env ?? undefined);
const resolution = resolveCommandResolution(cmdText, params.cwd ?? undefined, env);
const allowlistMatch =
security === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
const skillAllow =
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
if (security === "deny") {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "security=deny",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" },
});
return;
}
const requiresAsk =
ask === "always" ||
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow);
let approvedByAsk = false;
if (requiresAsk) {
const decision = await requestExecApprovalViaSocket({
socketPath: approvals.socketPath,
token: approvals.token,
request: {
command: cmdText,
cwd: params.cwd ?? undefined,
host: "node",
security,
ask,
agentId,
resolvedPath: resolution?.resolvedPath ?? null,
},
});
if (decision === "deny") {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "user-denied",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied" },
});
return;
}
if (!decision) {
if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (allowlistMatch || skillAllow) {
approvedByAsk = true;
} else {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "approval-required",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
});
return;
}
} else {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "approval-required",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
});
return;
}
}
if (decision === "allow-once") {
approvedByAsk = true;
}
if (decision === "allow-always") {
approvedByAsk = true;
if (security === "allowlist") {
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
}
}
}
if (security === "allowlist" && !allowlistMatch && !skillAllow && !approvedByAsk) {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "allowlist-miss",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss" },
});
return;
}
if (allowlistMatch) {
recordAllowlistUse(approvals.file, agentId, allowlistMatch, cmdText, resolution?.resolvedPath);
}
if (params.needsScreenRecording === true) {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "permission:screenRecording",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording" },
});
return;
}
client.sendEvent(
"exec.started",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
}),
);
const result = await runCommand(
argv,
params.cwd?.trim() || undefined,
env,
params.timeoutMs ?? undefined,
);
if (result.truncated) {
const suffix = "... (truncated)";
if (result.stderr.trim().length > 0) {
result.stderr = `${result.stderr}\n${suffix}`;
} else {
result.stdout = `${result.stdout}\n${suffix}`;
}
}
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
client.sendEvent(
"exec.finished",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: combined,
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: true,
payloadJSON: JSON.stringify({
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.error ?? null,
}),
});
}
function decodeParams<T>(raw?: string | null): T {
if (!raw) {
throw new Error("INVALID_REQUEST: paramsJSON required");
}
return JSON.parse(raw) as T;
}