diff --git a/CHANGELOG.md b/CHANGELOG.md index e928c80cb..e942ffc75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,16 @@ Docs: https://docs.clawd.bot - 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: 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. - 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: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node ### 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. - Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe. diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift index 03e552bdb..adec360a9 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovals.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovals.swift @@ -162,7 +162,7 @@ enum ExecApprovalsStore { let data = try Data(contentsOf: url) let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) 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 } catch { @@ -397,11 +397,32 @@ struct ExecCommandResolution: Sendable { let executableName: 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? { guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { 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 resolvedPath: String? = { if hasPathSeparator { @@ -419,6 +440,20 @@ struct ExecCommandResolution: Sendable { 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[.. [String] { let raw = env?["PATH"] if let raw, !raw.isEmpty { @@ -439,6 +474,12 @@ enum ExecCommandFormatter { return "\"\(escaped)\"" }.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 { @@ -522,7 +563,7 @@ struct ExecEventPayload: Codable, Sendable { guard !trimmed.isEmpty else { return nil } if trimmed.count <= maxChars { return trimmed } let suffix = trimmed.suffix(maxChars) - return "… (truncated) \(suffix)" + return "... (truncated) \(suffix)" } } diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index 710a125b1..502b29a46 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -432,6 +432,7 @@ actor MacNodeRuntime { guard !command.isEmpty else { 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 agentId = trimmedAgent.isEmpty ? nil : trimmedAgent @@ -444,7 +445,12 @@ actor MacNodeRuntime { ? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines) : self.mainSessionKey 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 ? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution) : nil @@ -463,7 +469,7 @@ actor MacNodeRuntime { sessionKey: sessionKey, runId: runId, host: "node", - command: ExecCommandFormatter.displayString(for: command), + command: displayCommand, reason: "security=deny")) return Self.errorResponse( req, @@ -477,12 +483,13 @@ actor MacNodeRuntime { return false }() + var approvedByAsk = false if requiresAsk { let decision = await ExecApprovalsSocketClient.requestDecision( socketPath: approvals.socketPath, token: approvals.token, request: ExecApprovalPromptRequest( - command: ExecCommandFormatter.displayString(for: command), + command: displayCommand, cwd: params.cwd, host: "node", security: security.rawValue, @@ -498,21 +505,40 @@ actor MacNodeRuntime { sessionKey: sessionKey, runId: runId, host: "node", - command: ExecCommandFormatter.displayString(for: command), + command: displayCommand, reason: "user-denied")) return Self.errorResponse( req, code: .unavailable, message: "SYSTEM_RUN_DENIED: user denied") 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( "exec.denied", payload: ExecEventPayload( sessionKey: sessionKey, runId: runId, host: "node", - command: ExecCommandFormatter.displayString(for: command), + command: displayCommand, reason: "approval-required")) return Self.errorResponse( req, @@ -520,6 +546,7 @@ actor MacNodeRuntime { message: "SYSTEM_RUN_DENIED: approval required") } case .allowAlways?: + approvedByAsk = true if security == .allowlist { let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? @@ -530,20 +557,33 @@ actor MacNodeRuntime { } } 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 { ExecApprovalsStore.recordAllowlistUse( agentId: agentId, pattern: match.pattern, - command: ExecCommandFormatter.displayString(for: command), + command: displayCommand, resolvedPath: resolution?.resolvedPath) } - let env = Self.sanitizedEnv(params.env) - if params.needsScreenRecording == true { let authorized = await PermissionManager .status([.screenRecording])[.screenRecording] ?? false @@ -554,7 +594,7 @@ actor MacNodeRuntime { sessionKey: sessionKey, runId: runId, host: "node", - command: ExecCommandFormatter.displayString(for: command), + command: displayCommand, reason: "permission:screenRecording")) return Self.errorResponse( req, @@ -570,7 +610,7 @@ actor MacNodeRuntime { sessionKey: sessionKey, runId: runId, host: "node", - command: ExecCommandFormatter.displayString(for: command))) + command: displayCommand)) let result = await ShellExecutor.runDetailed( command: command, cwd: params.cwd, @@ -583,7 +623,7 @@ actor MacNodeRuntime { sessionKey: sessionKey, runId: runId, host: "node", - command: ExecCommandFormatter.displayString(for: command), + command: displayCommand, exitCode: result.exitCode, timedOut: result.timedOut, success: result.success, diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift index f41f56f13..1db944d91 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift @@ -20,6 +20,7 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable { public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable { public var command: [String] + public var rawCommand: String? public var cwd: String? public var env: [String: String]? public var timeoutMs: Int? @@ -29,6 +30,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable { public init( command: [String], + rawCommand: String? = nil, cwd: String? = nil, env: [String: String]? = nil, timeoutMs: Int? = nil, @@ -37,6 +39,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable { sessionKey: String? = nil) { self.command = command + self.rawCommand = rawCommand self.cwd = cwd self.env = env self.timeoutMs = timeoutMs diff --git a/docs/cli/index.md b/docs/cli/index.md index 760924519..2af75ccdc 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -32,6 +32,7 @@ This page describes the current CLI behavior. If commands change, update this do - [`models`](/cli/models) - [`memory`](/cli/memory) - [`nodes`](/cli/nodes) +- [`node`](/cli/node) - [`sandbox`](/cli/sandbox) - [`tui`](/cli/tui) - [`browser`](/cli/browser) @@ -168,21 +169,15 @@ clawdbot [--dev] [--profile ] runs run nodes - status - describe - list - pending - approve - reject - rename - invoke - run - notify - camera list|snap|clip - canvas snapshot|present|hide|navigate|eval - canvas a2ui push|reset - screen record - location get + node + start + daemon + status + install + uninstall + start + stop + restart browser status start @@ -772,6 +767,20 @@ Subcommands: 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 --port 18790` +- `node daemon status` +- `node daemon install [--host ] [--port ] [--tls] [--tls-fingerprint ] [--node-id ] [--display-name ] [--runtime ] [--force]` +- `node daemon uninstall` +- `node daemon start` +- `node daemon stop` +- `node daemon restart` + ## Nodes `nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes). @@ -788,7 +797,7 @@ Subcommands: - `nodes reject ` - `nodes rename --node --name ` - `nodes invoke --node --command [--params ] [--invoke-timeout ] [--idempotency-key ]` -- `nodes run --node [--cwd ] [--env KEY=VAL] [--command-timeout ] [--needs-screen-recording] [--invoke-timeout ] ` (mac only) +- `nodes run --node [--cwd ] [--env KEY=VAL] [--command-timeout ] [--needs-screen-recording] [--invoke-timeout ] ` (mac node or headless node host) - `nodes notify --node [--title ] [--body ] [--sound ] [--priority ] [--delivery ] [--invoke-timeout ]` (mac only) Camera: diff --git a/docs/cli/node.md b/docs/cli/node.md new file mode 100644 index 000000000..5c05303e7 --- /dev/null +++ b/docs/cli/node.md @@ -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 --port 18790 +``` + +Options: +- `--host `: Gateway bridge host (default: `127.0.0.1`) +- `--port `: Gateway bridge port (default: `18790`) +- `--tls`: Use TLS for the bridge connection +- `--tls-fingerprint `: Pin the bridge certificate fingerprint +- `--node-id `: Override node id (clears pairing token) +- `--display-name `: Override the node display name + +## Daemon (background service) + +Install a headless node host as a user service. + +```bash +clawdbot node daemon install --host --port 18790 +``` + +Options: +- `--host `: Gateway bridge host (default: `127.0.0.1`) +- `--port `: Gateway bridge port (default: `18790`) +- `--tls`: Use TLS for the bridge connection +- `--tls-fingerprint `: Pin the bridge certificate fingerprint +- `--node-id `: Override node id (clears pairing token) +- `--display-name `: Override the node display name +- `--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 +``` + +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) diff --git a/docs/gateway/bridge-protocol.md b/docs/gateway/bridge-protocol.md index c5ef56016..ff5ad56a6 100644 --- a/docs/gateway/bridge-protocol.md +++ b/docs/gateway/bridge-protocol.md @@ -46,7 +46,7 @@ When TLS is enabled, discovery TXT records include `bridgeTls=1` plus ## Frames 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) Gateway → Client: diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 4625ed610..e18936fd2 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -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: - Requires node pairing (approval + token). -- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never". -- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac. +- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist). +- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac. ## Dynamic skills (watcher / remote nodes) diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 08aa643d9..bf1af742b 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -147,9 +147,10 @@ Notes: - 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`. -## 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: @@ -163,12 +164,33 @@ Notes: - `system.notify` respects notification permission state on the macOS app. - `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`. - `system.notify` supports `--priority ` and `--delivery `. -- `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 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 --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 - The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac). diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index c54ccdba2..cc873556e 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -54,29 +54,32 @@ The macOS app presents itself as a node. Common commands: The node reports a `permissions` map so agents can decide what’s allowed. -## Node run policy + allowlist +## Exec approvals (system.run) -`system.run` is controlled by the macOS app **Node Run Commands** policy: - -- `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: +`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals). +Security + ask + allowlist are stored locally on the Mac in: ``` -~/.clawdbot/macos-node.json +~/.clawdbot/exec-approvals.json ``` -Schema: +Example: ```json { - "systemRun": { - "policy": "ask", - "allowlist": [ - "[\"/bin/echo\",\"hello\"]" - ] + "version": 1, + "defaults": { + "security": "deny", + "ask": "on-miss" + }, + "agents": { + "main": { + "security": "allowlist", + "ask": "on-miss", + "allowlist": [ + { "pattern": "/opt/homebrew/bin/rg" } + ] + } } } ``` diff --git a/docs/refactor/exec-host.md b/docs/refactor/exec-host.md index b51882a38..7419cd013 100644 --- a/docs/refactor/exec-host.md +++ b/docs/refactor/exec-host.md @@ -29,6 +29,7 @@ read_when: - **Runner:** headless system service; UI app hosts a Unix socket for approvals. - **Node identity:** use existing `nodeId`. - **Socket auth:** Unix socket + token (cross-platform); split later if needed. +- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token). ## Key concepts ### Host diff --git a/docs/start/faq.md b/docs/start/faq.md index 9f185b0e4..7dca8ee87 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -60,6 +60,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [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) - [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) - [What’s 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) @@ -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. **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).** 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. +### 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 --port 18790 +``` + +Notes: +- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve `). +- 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? Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 495a0d838..5ba049299 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -8,7 +8,7 @@ read_when: # 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 are allowed only when policy + allowlist + (optional) user approval all agree. 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: - **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 -Approvals live in a local JSON file: +Approvals live in a local JSON file on the execution host: `~/.clawdbot/exec-approvals.json` @@ -97,8 +97,8 @@ Each allowlist entry tracks: ## Auto-allow skill CLIs 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 -manual allowlists. +are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the +gateway for the skill bin list. Disable this if you want strict manual allowlists. ## Approval flow diff --git a/docs/tools/exec.md b/docs/tools/exec.md index a021d3c21..ee07945a7 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -30,7 +30,7 @@ Notes: - `host` defaults to `sandbox`. - `elevated` is ignored when sandboxing is off (exec already runs on the host). - `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. ## Config @@ -51,7 +51,7 @@ Example: /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. See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow. diff --git a/docs/tools/index.md b/docs/tools/index.md index 7bd954179..e702f3951 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -181,6 +181,7 @@ Notes: - 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` only changes behavior when the agent is sandboxed (otherwise it’s 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). ### `process` diff --git a/docs/tools/skills.md b/docs/tools/skills.md index feec2c4d2..36abdc66a 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -187,7 +187,7 @@ Skills can also refresh mid-session when the skills watcher is enabled or when a ## 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. diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 7d59b7578..0847273f4 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -17,6 +17,7 @@ import { resolveExecApprovals, } from "../infra/exec-approvals.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; +import { buildNodeShellCommand } from "../infra/node-shell.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { logInfo } from "../logger.js"; import { @@ -392,7 +393,7 @@ export function createExecTool( const nodes = await listNodes({}); if (nodes.length === 0) { 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; @@ -411,14 +412,17 @@ export function createExecTool( ? nodeInfo?.commands?.includes("system.run") : false; 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 = { nodeId, command: "system.run", params: { command: argv, + rawCommand: params.command, cwd: workdir, env: params.env, timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined, @@ -471,6 +475,7 @@ export function createExecTool( hostAsk === "always" || (hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch); + let approvedByAsk = false; if (requiresAsk) { const decision = (await requestExecApprovalViaSocket({ @@ -491,31 +496,43 @@ export function createExecTool( throw new Error("exec denied: user denied"); } if (!decision) { - if (askFallback === "deny") { - throw new Error( - "exec denied: approval required (companion app approval UI not available)", - ); - } - if (askFallback === "allowlist") { + if (askFallback === "full") { + approvedByAsk = true; + } else if (askFallback === "allowlist") { if (!allowlistMatch) { throw new Error( "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") { - const pattern = - resolution?.resolvedPath ?? - resolution?.rawExecutable ?? - params.command.split(/\s+/).shift() ?? - ""; - if (pattern) { - addAllowlistEntry(approvals.file, defaults?.agentId, pattern); + if (decision === "allow-once") { + approvedByAsk = true; + } + if (decision === "allow-always") { + approvedByAsk = true; + if (hostSecurity === "allowlist") { + const 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) { recordAllowlistUse( approvals.file, diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 3b4a44d91..63f8a41d0 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -388,7 +388,7 @@ export function createNodesTool(options?: { const nodes = await listNodes(gatewayOpts); if (nodes.length === 0) { 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); @@ -398,7 +398,7 @@ export function createNodesTool(options?: { : false; if (!supportsSystemRun) { 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; diff --git a/src/cli/node-cli.ts b/src/cli/node-cli.ts new file mode 100644 index 000000000..b03b70b08 --- /dev/null +++ b/src/cli/node-cli.ts @@ -0,0 +1,2 @@ +export { registerNodeCli } from "./node-cli/register.js"; + diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts new file mode 100644 index 000000000..874e7b39d --- /dev/null +++ b/src/cli/node-cli/daemon.ts @@ -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>) { + 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), + ...(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)); + } + } +} diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts new file mode 100644 index 000000000..dbf7059b1 --- /dev/null +++ b/src/cli/node-cli/register.ts @@ -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 ", "Gateway bridge host") + .option("--port ", "Gateway bridge port") + .option("--tls", "Use TLS for the bridge connection", false) + .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") + .option("--node-id ", "Override node id (clears pairing token)") + .option("--display-name ", "Override node display name") + .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 ", "Gateway bridge host") + .option("--port ", "Gateway bridge port") + .option("--tls", "Use TLS for the bridge connection", false) + .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") + .option("--node-id ", "Override node id (clears pairing token)") + .option("--display-name ", "Override node display name") + .option("--runtime ", "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); + }); +} diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index fee57b0a0..5790a75a7 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -13,6 +13,7 @@ import { registerWebhooksCli } from "../webhooks-cli.js"; import { registerLogsCli } from "../logs-cli.js"; import { registerModelsCli } from "../models-cli.js"; import { registerNodesCli } from "../nodes-cli.js"; +import { registerNodeCli } from "../node-cli.js"; import { registerPairingCli } from "../pairing-cli.js"; import { registerPluginsCli } from "../plugins-cli.js"; import { registerSandboxCli } from "../sandbox-cli.js"; @@ -27,6 +28,7 @@ export function registerSubCliCommands(program: Command) { registerLogsCli(program); registerModelsCli(program); registerNodesCli(program); + registerNodeCli(program); registerSandboxCli(program); registerTuiCli(program); registerCronCli(program); diff --git a/src/commands/node-daemon-install-helpers.ts b/src/commands/node-daemon-install-helpers.ts new file mode 100644 index 000000000..56aa9c1c3 --- /dev/null +++ b/src/commands/node-daemon-install-helpers.ts @@ -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; + description?: string; +}; + +export async function buildNodeInstallPlan(params: { + env: Record; + host: string; + port: number; + tls?: boolean; + tlsFingerprint?: string; + nodeId?: string; + displayName?: string; + runtime: NodeDaemonRuntime; + devMode?: boolean; + nodePath?: string; + warn?: WarnFn; +}): Promise { + 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 }; +} diff --git a/src/commands/node-daemon-runtime.ts b/src/commands/node-daemon-runtime.ts new file mode 100644 index 000000000..c1375f979 --- /dev/null +++ b/src/commands/node-daemon-runtime.ts @@ -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); +} diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts index 6890881f6..6a48597f7 100644 --- a/src/daemon/constants.ts +++ b/src/daemon/constants.ts @@ -4,6 +4,12 @@ export const GATEWAY_SYSTEMD_SERVICE_NAME = "clawdbot-gateway"; export const GATEWAY_WINDOWS_TASK_NAME = "Clawdbot Gateway"; export const GATEWAY_SERVICE_MARKER = "clawdbot"; 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_SYSTEMD_SERVICE_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"; 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})`; +} diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 46d6b25e2..261e99696 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -52,10 +52,11 @@ export function resolveGatewayLogPaths(env: Record): } { const stateDir = resolveGatewayStateDir(env); const logDir = path.join(stateDir, "logs"); + const prefix = env.CLAWDBOT_LOG_PREFIX?.trim() || "gateway"; return { logDir, - stdoutPath: path.join(logDir, "gateway.log"), - stderrPath: path.join(logDir, "gateway.err.log"), + stdoutPath: path.join(logDir, `${prefix}.log`), + stderrPath: path.join(logDir, `${prefix}.err.log`), }; } @@ -340,12 +341,14 @@ export async function installLaunchAgent({ programArguments, workingDirectory, environment, + description, }: { env: Record; stdout: NodeJS.WritableStream; programArguments: string[]; workingDirectory?: string; environment?: Record; + description?: string; }): Promise<{ plistPath: string }> { const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env); await fs.mkdir(logDir, { recursive: true }); @@ -366,13 +369,15 @@ export async function installLaunchAgent({ const plistPath = resolveLaunchAgentPlistPathForLabel(env, label); await fs.mkdir(path.dirname(plistPath), { recursive: true }); - const description = formatGatewayServiceDescription({ - profile: env.CLAWDBOT_PROFILE, - version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION, - }); + const serviceDescription = + description ?? + formatGatewayServiceDescription({ + profile: env.CLAWDBOT_PROFILE, + version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION, + }); const plist = buildLaunchAgentPlist({ label, - comment: description, + comment: serviceDescription, programArguments, workingDirectory, stdoutPath, diff --git a/src/daemon/node-service.ts b/src/daemon/node-service.ts new file mode 100644 index 000000000..c31dfd18d --- /dev/null +++ b/src/daemon/node-service.ts @@ -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, +): Record { + 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)), + }; +} diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index 3b6767ff5..b9f41feca 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -138,13 +138,12 @@ async function resolveBinaryPath(binary: string): Promise { } } -export async function resolveGatewayProgramArguments(params: { - port: number; +async function resolveCliProgramArguments(params: { + args: string[]; dev?: boolean; runtime?: GatewayRuntimePreference; nodePath?: string; }): Promise { - const gatewayArgs = ["gateway", "--port", String(params.port)]; const execPath = process.execPath; const runtime = params.runtime ?? "auto"; @@ -153,7 +152,7 @@ export async function resolveGatewayProgramArguments(params: { params.nodePath ?? (isNodeRuntime(execPath) ? execPath : await resolveNodePath()); const cliEntrypointPath = await resolveCliEntrypointPathForService(); return { - programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs], + programArguments: [nodePath, cliEntrypointPath, ...params.args], }; } @@ -164,7 +163,7 @@ export async function resolveGatewayProgramArguments(params: { await fs.access(devCliPath); const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath(); return { - programArguments: [bunPath, devCliPath, ...gatewayArgs], + programArguments: [bunPath, devCliPath, ...params.args], workingDirectory: repoRoot, }; } @@ -172,7 +171,7 @@ export async function resolveGatewayProgramArguments(params: { const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath(); const cliEntrypointPath = await resolveCliEntrypointPathForService(); return { - programArguments: [bunPath, cliEntrypointPath, ...gatewayArgs], + programArguments: [bunPath, cliEntrypointPath, ...params.args], }; } @@ -180,12 +179,12 @@ export async function resolveGatewayProgramArguments(params: { try { const cliEntrypointPath = await resolveCliEntrypointPathForService(); return { - programArguments: [execPath, cliEntrypointPath, ...gatewayArgs], + programArguments: [execPath, cliEntrypointPath, ...params.args], }; } catch (error) { // If running under bun or another runtime that can execute TS directly if (!isNodeRuntime(execPath)) { - return { programArguments: [execPath, ...gatewayArgs] }; + return { programArguments: [execPath, ...params.args] }; } throw error; } @@ -199,7 +198,7 @@ export async function resolveGatewayProgramArguments(params: { // If already running under bun, use current execPath if (isBunRuntime(execPath)) { return { - programArguments: [execPath, devCliPath, ...gatewayArgs], + programArguments: [execPath, devCliPath, ...params.args], workingDirectory: repoRoot, }; } @@ -207,7 +206,46 @@ export async function resolveGatewayProgramArguments(params: { // Otherwise resolve bun from PATH const bunPath = await resolveBunPath(); return { - programArguments: [bunPath, devCliPath, ...gatewayArgs], + programArguments: [bunPath, devCliPath, ...params.args], workingDirectory: repoRoot, }; } + +export async function resolveGatewayProgramArguments(params: { + port: number; + dev?: boolean; + runtime?: GatewayRuntimePreference; + nodePath?: string; +}): Promise { + 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 { + 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, + }); +} diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 2631e5b83..2c68b1e63 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -16,9 +16,18 @@ const formatLine = (label: string, value: string) => { return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; }; +function resolveTaskName(env: Record): string { + const override = env.CLAWDBOT_WINDOWS_TASK_NAME?.trim(); + if (override) return override; + return resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE); +} + export function resolveTaskScriptPath(env: Record): 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); - return path.join(stateDir, "gateway.cmd"); + return path.join(stateDir, scriptName); } function quoteCmdArg(value: string): string { @@ -201,29 +210,33 @@ export async function installScheduledTask({ programArguments, workingDirectory, environment, + description, }: { env: Record; stdout: NodeJS.WritableStream; programArguments: string[]; workingDirectory?: string; environment?: Record; + description?: string; }): Promise<{ scriptPath: string }> { await assertSchtasksAvailable(); const scriptPath = resolveTaskScriptPath(env); await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - const description = formatGatewayServiceDescription({ - profile: env.CLAWDBOT_PROFILE, - version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION, - }); + const taskDescription = + description ?? + formatGatewayServiceDescription({ + profile: env.CLAWDBOT_PROFILE, + version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION, + }); const script = buildTaskScript({ - description, + description: taskDescription, programArguments, workingDirectory, environment, }); await fs.writeFile(scriptPath, script, "utf8"); - const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE); + const taskName = resolveTaskName(env); const quotedScript = quoteCmdArg(scriptPath); const baseArgs = [ "/Create", @@ -268,7 +281,7 @@ export async function uninstallScheduledTask({ stdout: NodeJS.WritableStream; }): Promise { await assertSchtasksAvailable(); - const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE); + const taskName = resolveTaskName(env); await execSchtasks(["/Delete", "/F", "/TN", taskName]); const scriptPath = resolveTaskScriptPath(env); @@ -293,7 +306,7 @@ export async function stopScheduledTask({ env?: Record; }): Promise { await assertSchtasksAvailable(); - const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE); + const taskName = resolveTaskName(env ?? (process.env as Record)); const res = await execSchtasks(["/End", "/TN", taskName]); if (res.code !== 0 && !isTaskNotRunning(res)) { throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim()); @@ -309,7 +322,7 @@ export async function restartScheduledTask({ env?: Record; }): Promise { await assertSchtasksAvailable(); - const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE); + const taskName = resolveTaskName(env ?? (process.env as Record)); await execSchtasks(["/End", "/TN", taskName]); const res = await execSchtasks(["/Run", "/TN", taskName]); if (res.code !== 0) { @@ -322,7 +335,7 @@ export async function isScheduledTaskInstalled(args: { env?: Record; }): Promise { await assertSchtasksAvailable(); - const taskName = resolveGatewayWindowsTaskName(args.env?.CLAWDBOT_PROFILE); + const taskName = resolveTaskName(args.env ?? (process.env as Record)); const res = await execSchtasks(["/Query", "/TN", taskName]); return res.code === 0; } @@ -338,7 +351,7 @@ export async function readScheduledTaskRuntime( detail: String(err), }; } - const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE); + const taskName = resolveTaskName(env); const res = await execSchtasks(["/Query", "/TN", taskName, "/V", "/FO", "LIST"]); if (res.code !== 0) { const detail = (res.stderr || res.stdout).trim(); diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index fc133611e..ba2e3b225 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -6,6 +6,12 @@ import { GATEWAY_SERVICE_MARKER, resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, + NODE_SERVICE_KIND, + NODE_SERVICE_MARKER, + NODE_WINDOWS_TASK_SCRIPT_NAME, + resolveNodeLaunchAgentLabel, + resolveNodeSystemdServiceName, + resolveNodeWindowsTaskName, } from "./constants.js"; export type MinimalServicePathOptions = { @@ -82,3 +88,22 @@ export function buildServiceEnvironment(params: { CLAWDBOT_SERVICE_VERSION: VERSION, }; } + +export function buildNodeServiceEnvironment(params: { + env: Record; +}): Record { + 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, + }; +} diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 8edfda83e..45fe2f638 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -33,6 +33,7 @@ export type GatewayServiceInstallArgs = { programArguments: string[]; workingDirectory?: string; environment?: Record; + description?: string; }; export type GatewayService = { diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index fef4eadd3..7a28304f3 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -186,23 +186,27 @@ export async function installSystemdService({ programArguments, workingDirectory, environment, + description, }: { env: Record; stdout: NodeJS.WritableStream; programArguments: string[]; workingDirectory?: string; environment?: Record; + description?: string; }): Promise<{ unitPath: string }> { await assertSystemdAvailable(); const unitPath = resolveSystemdUnitPath(env); await fs.mkdir(path.dirname(unitPath), { recursive: true }); - const description = formatGatewayServiceDescription({ - profile: env.CLAWDBOT_PROFILE, - version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION, - }); + const serviceDescription = + description ?? + formatGatewayServiceDescription({ + profile: env.CLAWDBOT_PROFILE, + version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION, + }); const unit = buildSystemdUnit({ - description, + description: serviceDescription, programArguments, workingDirectory, environment, diff --git a/src/gateway/server-bridge-methods-system.ts b/src/gateway/server-bridge-methods-system.ts index acc10e476..8f548bb60 100644 --- a/src/gateway/server-bridge-methods-system.ts +++ b/src/gateway/server-bridge-methods-system.ts @@ -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 { ErrorCodes, @@ -72,6 +76,20 @@ export const handleSystemBridgeMethods: BridgeMethodHandler = async ( const models = await ctx.loadGatewayModelCatalog(); 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: return null; } diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index e92abbdef..c78e14912 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -106,7 +106,7 @@ export function loadExecApprovals(): ExecApprovalsFile { const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as ExecApprovalsFile; if (parsed?.version !== 1) { - return normalizeExecApprovals({ version: 1, agents: parsed?.agents ?? {} }); + return normalizeExecApprovals({ version: 1, agents: {} }); } return normalizeExecApprovals(parsed); } catch { @@ -117,7 +117,12 @@ export function loadExecApprovals(): ExecApprovalsFile { export function saveExecApprovals(file: ExecApprovalsFile) { const filePath = resolveExecApprovalsPath(); 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 { diff --git a/src/infra/node-shell.ts b/src/infra/node-shell.ts new file mode 100644 index 000000000..f310542a9 --- /dev/null +++ b/src/infra/node-shell.ts @@ -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]; +} + diff --git a/src/node-host/bridge-client.ts b/src/node-host/bridge-client.ts new file mode 100644 index 000000000..ac5751665 --- /dev/null +++ b/src/node-host/bridge-client.ts @@ -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; + onInvoke?: (frame: BridgeInvokeRequestFrame) => void | Promise; + onEvent?: (frame: BridgeEventFrame) => void | Promise; + onPairToken?: (token: string) => void | Promise; + onAuthReset?: () => void | Promise; + onConnected?: (hello: BridgeHelloOkFrame) => void | Promise; + onDisconnected?: (err?: Error) => void | Promise; + 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(); + private connected = false; + private helloReady: Promise | null = null; + private helloResolve: (() => void) | null = null; + private helloReject: ((err: Error) => void) | null = null; + + constructor(opts: BridgeClientOptions) { + this.opts = opts; + } + + async connect(): Promise { + if (this.connected) return; + this.helloReady = new Promise((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 { + 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 | 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((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; + } + } +} diff --git a/src/node-host/config.ts b/src/node-host/config.ts new file mode 100644 index 000000000..b790061f6 --- /dev/null +++ b/src/node-host/config.ts @@ -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 | 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 { + const filePath = resolveNodeHostConfigPath(); + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + return normalizeConfig(parsed); + } catch { + return null; + } +} + +export async function saveNodeHostConfig(config: NodeHostConfig): Promise { + 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 { + const existing = await loadNodeHostConfig(); + const normalized = normalizeConfig(existing); + await saveNodeHostConfig(normalized); + return normalized; +} + diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts new file mode 100644 index 000000000..a96bb5a9f --- /dev/null +++ b/src/node-host/runner.ts @@ -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; + 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(); + private lastRefresh = 0; + private readonly ttlMs = 90_000; + private readonly fetch: () => Promise; + + constructor(fetch: () => Promise) { + this.fetch = fetch; + } + + async current(force = false): Promise> { + 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 | null): Record | undefined { + if (!overrides) return undefined; + const merged = { ...process.env } as Record; + 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 | undefined, + timeoutMs: number | undefined, +): Promise { + 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[] { + const raw = + env?.PATH ?? + (env as Record)?.Path ?? + process.env.PATH ?? + process.env.Path ?? + ""; + return raw.split(path.delimiter).filter(Boolean); +} + +function resolveExecutable(bin: string, env?: Record) { + 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) { + const bins = params.bins + .map((bin) => bin.trim()) + .filter(Boolean); + const found: Record = {}; + 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 { + 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((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(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(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(); + 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(raw?: string | null): T { + if (!raw) { + throw new Error("INVALID_REQUEST: paramsJSON required"); + } + return JSON.parse(raw) as T; +}