feat: add exec host routing + node daemon
This commit is contained in:
@@ -9,11 +9,16 @@ Docs: https://docs.clawd.bot
|
|||||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
||||||
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
||||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
- macOS: add approvals socket UI server + node exec lifecycle events.
|
||||||
|
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
||||||
|
- Nodes: add node daemon service install/status/start/stop/restart.
|
||||||
|
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||||
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
||||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
||||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ enum ExecApprovalsStore {
|
|||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||||
if decoded.version != 1 {
|
if decoded.version != 1 {
|
||||||
return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents)
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
}
|
}
|
||||||
return decoded
|
return decoded
|
||||||
} catch {
|
} catch {
|
||||||
@@ -397,11 +397,32 @@ struct ExecCommandResolution: Sendable {
|
|||||||
let executableName: String
|
let executableName: String
|
||||||
let cwd: String?
|
let cwd: String?
|
||||||
|
|
||||||
|
static func resolve(
|
||||||
|
command: [String],
|
||||||
|
rawCommand: String?,
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?
|
||||||
|
) -> ExecCommandResolution? {
|
||||||
|
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
|
||||||
|
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
return self.resolve(command: command, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
|
||||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||||
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
|
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveExecutable(
|
||||||
|
rawExecutable: String,
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?
|
||||||
|
) -> ExecCommandResolution? {
|
||||||
|
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
|
||||||
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
||||||
let resolvedPath: String? = {
|
let resolvedPath: String? = {
|
||||||
if hasPathSeparator {
|
if hasPathSeparator {
|
||||||
@@ -419,6 +440,20 @@ struct ExecCommandResolution: Sendable {
|
|||||||
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func parseFirstToken(_ command: String) -> String? {
|
||||||
|
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
guard let first = trimmed.first else { return nil }
|
||||||
|
if first == "\"" || first == "'" {
|
||||||
|
let rest = trimmed.dropFirst()
|
||||||
|
if let end = rest.firstIndex(of: first) {
|
||||||
|
return String(rest[..<end])
|
||||||
|
}
|
||||||
|
return String(rest)
|
||||||
|
}
|
||||||
|
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||||
|
}
|
||||||
|
|
||||||
private static func searchPaths(from env: [String: String]?) -> [String] {
|
private static func searchPaths(from env: [String: String]?) -> [String] {
|
||||||
let raw = env?["PATH"]
|
let raw = env?["PATH"]
|
||||||
if let raw, !raw.isEmpty {
|
if let raw, !raw.isEmpty {
|
||||||
@@ -439,6 +474,12 @@ enum ExecCommandFormatter {
|
|||||||
return "\"\(escaped)\""
|
return "\"\(escaped)\""
|
||||||
}.joined(separator: " ")
|
}.joined(separator: " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func displayString(for argv: [String], rawCommand: String?) -> String {
|
||||||
|
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmed.isEmpty { return trimmed }
|
||||||
|
return self.displayString(for: argv)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ExecAllowlistMatcher {
|
enum ExecAllowlistMatcher {
|
||||||
@@ -522,7 +563,7 @@ struct ExecEventPayload: Codable, Sendable {
|
|||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
if trimmed.count <= maxChars { return trimmed }
|
if trimmed.count <= maxChars { return trimmed }
|
||||||
let suffix = trimmed.suffix(maxChars)
|
let suffix = trimmed.suffix(maxChars)
|
||||||
return "… (truncated) \(suffix)"
|
return "... (truncated) \(suffix)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ actor MacNodeRuntime {
|
|||||||
guard !command.isEmpty else {
|
guard !command.isEmpty else {
|
||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||||
}
|
}
|
||||||
|
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
|
||||||
|
|
||||||
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||||
@@ -444,7 +445,12 @@ actor MacNodeRuntime {
|
|||||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
: self.mainSessionKey
|
: self.mainSessionKey
|
||||||
let runId = UUID().uuidString
|
let runId = UUID().uuidString
|
||||||
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env)
|
let env = Self.sanitizedEnv(params.env)
|
||||||
|
let resolution = ExecCommandResolution.resolve(
|
||||||
|
command: command,
|
||||||
|
rawCommand: params.rawCommand,
|
||||||
|
cwd: params.cwd,
|
||||||
|
env: env)
|
||||||
let allowlistMatch = security == .allowlist
|
let allowlistMatch = security == .allowlist
|
||||||
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||||
: nil
|
: nil
|
||||||
@@ -463,7 +469,7 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
reason: "security=deny"))
|
reason: "security=deny"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
@@ -477,12 +483,13 @@ actor MacNodeRuntime {
|
|||||||
return false
|
return false
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var approvedByAsk = false
|
||||||
if requiresAsk {
|
if requiresAsk {
|
||||||
let decision = await ExecApprovalsSocketClient.requestDecision(
|
let decision = await ExecApprovalsSocketClient.requestDecision(
|
||||||
socketPath: approvals.socketPath,
|
socketPath: approvals.socketPath,
|
||||||
token: approvals.token,
|
token: approvals.token,
|
||||||
request: ExecApprovalPromptRequest(
|
request: ExecApprovalPromptRequest(
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
host: "node",
|
host: "node",
|
||||||
security: security.rawValue,
|
security: security.rawValue,
|
||||||
@@ -498,21 +505,40 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
reason: "user-denied"))
|
reason: "user-denied"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
message: "SYSTEM_RUN_DENIED: user denied")
|
message: "SYSTEM_RUN_DENIED: user denied")
|
||||||
case nil:
|
case nil:
|
||||||
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) {
|
if askFallback == .full {
|
||||||
|
approvedByAsk = true
|
||||||
|
} else if askFallback == .allowlist {
|
||||||
|
if allowlistMatch != nil || skillAllow {
|
||||||
|
approvedByAsk = true
|
||||||
|
} else {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: displayCommand,
|
||||||
|
reason: "approval-required"))
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: approval required")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
await self.emitExecEvent(
|
await self.emitExecEvent(
|
||||||
"exec.denied",
|
"exec.denied",
|
||||||
payload: ExecEventPayload(
|
payload: ExecEventPayload(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
reason: "approval-required"))
|
reason: "approval-required"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
@@ -520,6 +546,7 @@ actor MacNodeRuntime {
|
|||||||
message: "SYSTEM_RUN_DENIED: approval required")
|
message: "SYSTEM_RUN_DENIED: approval required")
|
||||||
}
|
}
|
||||||
case .allowAlways?:
|
case .allowAlways?:
|
||||||
|
approvedByAsk = true
|
||||||
if security == .allowlist {
|
if security == .allowlist {
|
||||||
let pattern = resolution?.resolvedPath ??
|
let pattern = resolution?.resolvedPath ??
|
||||||
resolution?.rawExecutable ??
|
resolution?.rawExecutable ??
|
||||||
@@ -530,20 +557,33 @@ actor MacNodeRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .allowOnce?:
|
case .allowOnce?:
|
||||||
break
|
approvedByAsk = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: displayCommand,
|
||||||
|
reason: "allowlist-miss"))
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: allowlist miss")
|
||||||
|
}
|
||||||
|
|
||||||
if let match = allowlistMatch {
|
if let match = allowlistMatch {
|
||||||
ExecApprovalsStore.recordAllowlistUse(
|
ExecApprovalsStore.recordAllowlistUse(
|
||||||
agentId: agentId,
|
agentId: agentId,
|
||||||
pattern: match.pattern,
|
pattern: match.pattern,
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
resolvedPath: resolution?.resolvedPath)
|
resolvedPath: resolution?.resolvedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
let env = Self.sanitizedEnv(params.env)
|
|
||||||
|
|
||||||
if params.needsScreenRecording == true {
|
if params.needsScreenRecording == true {
|
||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
@@ -554,7 +594,7 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
reason: "permission:screenRecording"))
|
reason: "permission:screenRecording"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
@@ -570,7 +610,7 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command)))
|
command: displayCommand))
|
||||||
let result = await ShellExecutor.runDetailed(
|
let result = await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
@@ -583,7 +623,7 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
command: displayCommand,
|
||||||
exitCode: result.exitCode,
|
exitCode: result.exitCode,
|
||||||
timedOut: result.timedOut,
|
timedOut: result.timedOut,
|
||||||
success: result.success,
|
success: result.success,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable {
|
|||||||
|
|
||||||
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||||
public var command: [String]
|
public var command: [String]
|
||||||
|
public var rawCommand: String?
|
||||||
public var cwd: String?
|
public var cwd: String?
|
||||||
public var env: [String: String]?
|
public var env: [String: String]?
|
||||||
public var timeoutMs: Int?
|
public var timeoutMs: Int?
|
||||||
@@ -29,6 +30,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
|
|
||||||
public init(
|
public init(
|
||||||
command: [String],
|
command: [String],
|
||||||
|
rawCommand: String? = nil,
|
||||||
cwd: String? = nil,
|
cwd: String? = nil,
|
||||||
env: [String: String]? = nil,
|
env: [String: String]? = nil,
|
||||||
timeoutMs: Int? = nil,
|
timeoutMs: Int? = nil,
|
||||||
@@ -37,6 +39,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
sessionKey: String? = nil)
|
sessionKey: String? = nil)
|
||||||
{
|
{
|
||||||
self.command = command
|
self.command = command
|
||||||
|
self.rawCommand = rawCommand
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.env = env
|
self.env = env
|
||||||
self.timeoutMs = timeoutMs
|
self.timeoutMs = timeoutMs
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
- [`nodes`](/cli/nodes)
|
- [`nodes`](/cli/nodes)
|
||||||
|
- [`node`](/cli/node)
|
||||||
- [`sandbox`](/cli/sandbox)
|
- [`sandbox`](/cli/sandbox)
|
||||||
- [`tui`](/cli/tui)
|
- [`tui`](/cli/tui)
|
||||||
- [`browser`](/cli/browser)
|
- [`browser`](/cli/browser)
|
||||||
@@ -168,21 +169,15 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
runs
|
runs
|
||||||
run
|
run
|
||||||
nodes
|
nodes
|
||||||
status
|
node
|
||||||
describe
|
start
|
||||||
list
|
daemon
|
||||||
pending
|
status
|
||||||
approve
|
install
|
||||||
reject
|
uninstall
|
||||||
rename
|
start
|
||||||
invoke
|
stop
|
||||||
run
|
restart
|
||||||
notify
|
|
||||||
camera list|snap|clip
|
|
||||||
canvas snapshot|present|hide|navigate|eval
|
|
||||||
canvas a2ui push|reset
|
|
||||||
screen record
|
|
||||||
location get
|
|
||||||
browser
|
browser
|
||||||
status
|
status
|
||||||
start
|
start
|
||||||
@@ -772,6 +767,20 @@ Subcommands:
|
|||||||
|
|
||||||
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
||||||
|
|
||||||
|
## Node host
|
||||||
|
|
||||||
|
`node` runs a **headless node host** or manages it as a background service. See
|
||||||
|
[`clawdbot node`](/cli/node).
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
- `node start --host <gateway-host> --port 18790`
|
||||||
|
- `node daemon status`
|
||||||
|
- `node daemon install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
||||||
|
- `node daemon uninstall`
|
||||||
|
- `node daemon start`
|
||||||
|
- `node daemon stop`
|
||||||
|
- `node daemon restart`
|
||||||
|
|
||||||
## Nodes
|
## Nodes
|
||||||
|
|
||||||
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
||||||
@@ -788,7 +797,7 @@ Subcommands:
|
|||||||
- `nodes reject <requestId>`
|
- `nodes reject <requestId>`
|
||||||
- `nodes rename --node <id|name|ip> --name <displayName>`
|
- `nodes rename --node <id|name|ip> --name <displayName>`
|
||||||
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
|
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
|
||||||
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac only)
|
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac node or headless node host)
|
||||||
- `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only)
|
- `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only)
|
||||||
|
|
||||||
Camera:
|
Camera:
|
||||||
|
|||||||
72
docs/cli/node.md
Normal file
72
docs/cli/node.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot node` (headless node host)"
|
||||||
|
read_when:
|
||||||
|
- Running the headless node host
|
||||||
|
- Pairing a non-macOS node for system.run
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot node`
|
||||||
|
|
||||||
|
Run a **headless node host** that connects to the Gateway bridge and exposes
|
||||||
|
`system.run` / `system.which` on this machine.
|
||||||
|
|
||||||
|
## Start (foreground)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node start --host <gateway-host> --port 18790
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||||
|
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||||
|
- `--tls`: Use TLS for the bridge connection
|
||||||
|
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||||
|
- `--node-id <id>`: Override node id (clears pairing token)
|
||||||
|
- `--display-name <name>`: Override the node display name
|
||||||
|
|
||||||
|
## Daemon (background service)
|
||||||
|
|
||||||
|
Install a headless node host as a user service.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node daemon install --host <gateway-host> --port 18790
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||||
|
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||||
|
- `--tls`: Use TLS for the bridge connection
|
||||||
|
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||||
|
- `--node-id <id>`: Override node id (clears pairing token)
|
||||||
|
- `--display-name <name>`: Override the node display name
|
||||||
|
- `--runtime <runtime>`: Service runtime (`node` or `bun`)
|
||||||
|
- `--force`: Reinstall/overwrite if already installed
|
||||||
|
|
||||||
|
Manage the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node daemon status
|
||||||
|
clawdbot node daemon start
|
||||||
|
clawdbot node daemon stop
|
||||||
|
clawdbot node daemon restart
|
||||||
|
clawdbot node daemon uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pairing
|
||||||
|
|
||||||
|
The first connection creates a pending node pair request on the Gateway.
|
||||||
|
Approve it via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot nodes pending
|
||||||
|
clawdbot nodes approve <requestId>
|
||||||
|
```
|
||||||
|
|
||||||
|
The node host stores its node id + token in `~/.clawdbot/node.json`.
|
||||||
|
|
||||||
|
## Exec approvals
|
||||||
|
|
||||||
|
`system.run` is gated by local exec approvals:
|
||||||
|
|
||||||
|
- `~/.clawdbot/exec-approvals.json`
|
||||||
|
- [Exec approvals](/tools/exec-approvals)
|
||||||
@@ -46,7 +46,7 @@ When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
|
|||||||
## Frames
|
## Frames
|
||||||
|
|
||||||
Client → Gateway:
|
Client → Gateway:
|
||||||
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake)
|
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins)
|
||||||
- `event`: node signals (voice transcript, agent request, chat subscribe)
|
- `event`: node signals (voice transcript, agent request, chat subscribe)
|
||||||
|
|
||||||
Gateway → Client:
|
Gateway → Client:
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ stronger isolation between agents, run them under separate OS users or separate
|
|||||||
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
||||||
|
|
||||||
- Requires node pairing (approval + token).
|
- Requires node pairing (approval + token).
|
||||||
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never".
|
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
|
||||||
- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac.
|
- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
|
||||||
|
|
||||||
## Dynamic skills (watcher / remote nodes)
|
## Dynamic skills (watcher / remote nodes)
|
||||||
|
|
||||||
|
|||||||
@@ -147,9 +147,10 @@ Notes:
|
|||||||
- The permission prompt must be accepted on the Android device before the capability is advertised.
|
- The permission prompt must be accepted on the Android device before the capability is advertised.
|
||||||
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
|
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
|
||||||
|
|
||||||
## System commands (mac node)
|
## System commands (node host / mac node)
|
||||||
|
|
||||||
The macOS node exposes `system.run` and `system.notify`.
|
The macOS node exposes `system.run` and `system.notify`. The headless node host
|
||||||
|
exposes `system.run` and `system.which`.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
@@ -163,12 +164,33 @@ Notes:
|
|||||||
- `system.notify` respects notification permission state on the macOS app.
|
- `system.notify` respects notification permission state on the macOS app.
|
||||||
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
||||||
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
||||||
- `system.run` is gated by the macOS app policy (Settings → "Node Run Commands"): "Always Ask" prompts per command, "Always Allow" runs without prompts, and "Never" disables the tool. Denied prompts return `SYSTEM_RUN_DENIED`; disabled returns `SYSTEM_RUN_DISABLED`.
|
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
|
||||||
|
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
|
||||||
|
- On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`).
|
||||||
|
|
||||||
## Permissions map
|
## Permissions map
|
||||||
|
|
||||||
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
|
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
|
||||||
|
|
||||||
|
## Headless node host (cross-platform)
|
||||||
|
|
||||||
|
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
|
||||||
|
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
|
||||||
|
or for running a minimal node alongside a server.
|
||||||
|
|
||||||
|
Start it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node start --host <gateway-host> --port 18790
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Pairing is still required (the Gateway will show a node approval prompt).
|
||||||
|
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
|
||||||
|
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
|
||||||
|
(see [Exec approvals](/tools/exec-approvals)).
|
||||||
|
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
|
||||||
|
|
||||||
## Mac node mode
|
## Mac node mode
|
||||||
|
|
||||||
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
|
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
|
||||||
|
|||||||
@@ -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.
|
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:
|
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
||||||
|
Security + ask + allowlist are stored locally on the Mac in:
|
||||||
- `Always Ask`: prompt per command (default).
|
|
||||||
- `Always Allow`: run without prompts.
|
|
||||||
- `Never`: disable `system.run` (tool not advertised).
|
|
||||||
|
|
||||||
The policy + allowlist live on the Mac in:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.clawdbot/macos-node.json
|
~/.clawdbot/exec-approvals.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema:
|
Example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"systemRun": {
|
"version": 1,
|
||||||
"policy": "ask",
|
"defaults": {
|
||||||
"allowlist": [
|
"security": "deny",
|
||||||
"[\"/bin/echo\",\"hello\"]"
|
"ask": "on-miss"
|
||||||
]
|
},
|
||||||
|
"agents": {
|
||||||
|
"main": {
|
||||||
|
"security": "allowlist",
|
||||||
|
"ask": "on-miss",
|
||||||
|
"allowlist": [
|
||||||
|
{ "pattern": "/opt/homebrew/bin/rg" }
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ read_when:
|
|||||||
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
|
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
|
||||||
- **Node identity:** use existing `nodeId`.
|
- **Node identity:** use existing `nodeId`.
|
||||||
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
||||||
|
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
||||||
|
|
||||||
## Key concepts
|
## Key concepts
|
||||||
### Host
|
### Host
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||||
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
||||||
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
||||||
|
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
|
||||||
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
||||||
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
- [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)
|
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
|
||||||
@@ -405,7 +406,7 @@ You have three supported patterns:
|
|||||||
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
|
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
|
||||||
|
|
||||||
**Option B - use a macOS node (no SSH).**
|
**Option B - use a macOS node (no SSH).**
|
||||||
Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist.
|
Run the Gateway on Linux, pair a macOS node (menubar app), and configure **Exec approvals** (Settings → Exec approvals) to "Ask" or "Always Allow". Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Ask", selecting "Always Allow" in the prompt adds that command to the allowlist.
|
||||||
|
|
||||||
**Option C - proxy macOS binaries over SSH (advanced).**
|
**Option C - proxy macOS binaries over SSH (advanced).**
|
||||||
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
|
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
|
||||||
@@ -742,6 +743,23 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
|
|||||||
|
|
||||||
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
||||||
|
|
||||||
|
### Can I run a headless node host without the macOS app?
|
||||||
|
|
||||||
|
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
|
||||||
|
without any UI. It has no screen/camera/notify support (use the macOS app for those).
|
||||||
|
|
||||||
|
Start it:
|
||||||
|
```bash
|
||||||
|
clawdbot node start --host <gateway-host> --port 18790
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
|
||||||
|
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
|
||||||
|
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
|
||||||
|
|
||||||
|
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
|
||||||
|
|
||||||
### Is there an API / RPC way to apply config?
|
### Is there an API / RPC way to apply config?
|
||||||
|
|
||||||
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
|
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ read_when:
|
|||||||
|
|
||||||
# Exec approvals
|
# Exec approvals
|
||||||
|
|
||||||
Exec approvals are the **companion app guardrail** for letting a sandboxed agent run
|
Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
|
||||||
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
||||||
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
||||||
Exec approvals are **in addition** to tool policy and elevated gating.
|
Exec approvals are **in addition** to tool policy and elevated gating.
|
||||||
@@ -20,11 +20,11 @@ resolved by the **ask fallback** (default: deny).
|
|||||||
|
|
||||||
Exec approvals are enforced locally on the execution host:
|
Exec approvals are enforced locally on the execution host:
|
||||||
- **gateway host** → `clawdbot` process on the gateway machine
|
- **gateway host** → `clawdbot` process on the gateway machine
|
||||||
- **node host** → node runner (macOS companion app or headless node)
|
- **node host** → node runner (macOS companion app or headless node host)
|
||||||
|
|
||||||
## Settings and storage
|
## Settings and storage
|
||||||
|
|
||||||
Approvals live in a local JSON file:
|
Approvals live in a local JSON file on the execution host:
|
||||||
|
|
||||||
`~/.clawdbot/exec-approvals.json`
|
`~/.clawdbot/exec-approvals.json`
|
||||||
|
|
||||||
@@ -97,8 +97,8 @@ Each allowlist entry tracks:
|
|||||||
## Auto-allow skill CLIs
|
## Auto-allow skill CLIs
|
||||||
|
|
||||||
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
|
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
|
||||||
are treated as allowlisted (node hosts only). Disable this if you want strict
|
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
|
||||||
manual allowlists.
|
gateway for the skill bin list. Disable this if you want strict manual allowlists.
|
||||||
|
|
||||||
## Approval flow
|
## Approval flow
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Notes:
|
|||||||
- `host` defaults to `sandbox`.
|
- `host` defaults to `sandbox`.
|
||||||
- `elevated` is ignored when sandboxing is off (exec already runs on the host).
|
- `elevated` is ignored when sandboxing is off (exec already runs on the host).
|
||||||
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
|
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
|
||||||
- `node` requires a paired node (macOS companion app).
|
- `node` requires a paired node (companion app or headless node host).
|
||||||
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
|
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
@@ -51,7 +51,7 @@ Example:
|
|||||||
/exec host=gateway security=allowlist ask=on-miss node=mac-1
|
/exec host=gateway security=allowlist ask=on-miss node=mac-1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Exec approvals (macOS app)
|
## Exec approvals (companion app / node host)
|
||||||
|
|
||||||
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
||||||
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ Notes:
|
|||||||
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
||||||
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
|
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
|
||||||
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
- `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).
|
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
|
||||||
|
|
||||||
### `process`
|
### `process`
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ Skills can also refresh mid-session when the skills watcher is enabled or when a
|
|||||||
|
|
||||||
## Remote macOS nodes (Linux gateway)
|
## Remote macOS nodes (Linux gateway)
|
||||||
|
|
||||||
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Node Run Commands policy not set to "Never"), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
|
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Exec approvals security not set to `deny`), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
|
||||||
|
|
||||||
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.
|
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
resolveExecApprovals,
|
resolveExecApprovals,
|
||||||
} from "../infra/exec-approvals.js";
|
} from "../infra/exec-approvals.js";
|
||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
|
import { buildNodeShellCommand } from "../infra/node-shell.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { logInfo } from "../logger.js";
|
import { logInfo } from "../logger.js";
|
||||||
import {
|
import {
|
||||||
@@ -392,7 +393,7 @@ export function createExecTool(
|
|||||||
const nodes = await listNodes({});
|
const nodes = await listNodes({});
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"exec host=node requires a paired node (none available). This requires the macOS companion app.",
|
"exec host=node requires a paired node (none available). This requires a companion app or node host.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let nodeId: string;
|
let nodeId: string;
|
||||||
@@ -411,14 +412,17 @@ export function createExecTool(
|
|||||||
? nodeInfo?.commands?.includes("system.run")
|
? nodeInfo?.commands?.includes("system.run")
|
||||||
: false;
|
: false;
|
||||||
if (!supportsSystemRun) {
|
if (!supportsSystemRun) {
|
||||||
throw new Error("exec host=node requires a node that supports system.run.");
|
throw new Error(
|
||||||
|
"exec host=node requires a node that supports system.run (companion app or node host).",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const argv = ["/bin/sh", "-lc", params.command];
|
const argv = buildNodeShellCommand(params.command, nodeInfo?.platform);
|
||||||
const invokeParams: Record<string, unknown> = {
|
const invokeParams: Record<string, unknown> = {
|
||||||
nodeId,
|
nodeId,
|
||||||
command: "system.run",
|
command: "system.run",
|
||||||
params: {
|
params: {
|
||||||
command: argv,
|
command: argv,
|
||||||
|
rawCommand: params.command,
|
||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
env: params.env,
|
env: params.env,
|
||||||
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
||||||
@@ -471,6 +475,7 @@ export function createExecTool(
|
|||||||
hostAsk === "always" ||
|
hostAsk === "always" ||
|
||||||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
|
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
|
||||||
|
|
||||||
|
let approvedByAsk = false;
|
||||||
if (requiresAsk) {
|
if (requiresAsk) {
|
||||||
const decision =
|
const decision =
|
||||||
(await requestExecApprovalViaSocket({
|
(await requestExecApprovalViaSocket({
|
||||||
@@ -491,31 +496,43 @@ export function createExecTool(
|
|||||||
throw new Error("exec denied: user denied");
|
throw new Error("exec denied: user denied");
|
||||||
}
|
}
|
||||||
if (!decision) {
|
if (!decision) {
|
||||||
if (askFallback === "deny") {
|
if (askFallback === "full") {
|
||||||
throw new Error(
|
approvedByAsk = true;
|
||||||
"exec denied: approval required (companion app approval UI not available)",
|
} else if (askFallback === "allowlist") {
|
||||||
);
|
|
||||||
}
|
|
||||||
if (askFallback === "allowlist") {
|
|
||||||
if (!allowlistMatch) {
|
if (!allowlistMatch) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"exec denied: approval required (companion app approval UI not available)",
|
"exec denied: approval required (companion app approval UI not available)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
approvedByAsk = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"exec denied: approval required (companion app approval UI not available)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (decision === "allow-always" && hostSecurity === "allowlist") {
|
if (decision === "allow-once") {
|
||||||
const pattern =
|
approvedByAsk = true;
|
||||||
resolution?.resolvedPath ??
|
}
|
||||||
resolution?.rawExecutable ??
|
if (decision === "allow-always") {
|
||||||
params.command.split(/\s+/).shift() ??
|
approvedByAsk = true;
|
||||||
"";
|
if (hostSecurity === "allowlist") {
|
||||||
if (pattern) {
|
const pattern =
|
||||||
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
resolution?.resolvedPath ??
|
||||||
|
resolution?.rawExecutable ??
|
||||||
|
params.command.split(/\s+/).shift() ??
|
||||||
|
"";
|
||||||
|
if (pattern) {
|
||||||
|
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) {
|
||||||
|
throw new Error("exec denied: allowlist miss");
|
||||||
|
}
|
||||||
|
|
||||||
if (allowlistMatch) {
|
if (allowlistMatch) {
|
||||||
recordAllowlistUse(
|
recordAllowlistUse(
|
||||||
approvals.file,
|
approvals.file,
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ export function createNodesTool(options?: {
|
|||||||
const nodes = await listNodes(gatewayOpts);
|
const nodes = await listNodes(gatewayOpts);
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"system.run requires a paired macOS companion app (no nodes available).",
|
"system.run requires a paired companion app or node host (no nodes available).",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const nodeId = resolveNodeIdFromList(nodes, node);
|
const nodeId = resolveNodeIdFromList(nodes, node);
|
||||||
@@ -398,7 +398,7 @@ export function createNodesTool(options?: {
|
|||||||
: false;
|
: false;
|
||||||
if (!supportsSystemRun) {
|
if (!supportsSystemRun) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"system.run requires the macOS companion app; the selected node does not support system.run.",
|
"system.run requires a companion app or node host; the selected node does not support system.run.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const commandRaw = params.command;
|
const commandRaw = params.command;
|
||||||
|
|||||||
2
src/cli/node-cli.ts
Normal file
2
src/cli/node-cli.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { registerNodeCli } from "./node-cli/register.js";
|
||||||
|
|
||||||
577
src/cli/node-cli/daemon.ts
Normal file
577
src/cli/node-cli/daemon.ts
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
import { buildNodeInstallPlan } from "../../commands/node-daemon-install-helpers.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_NODE_DAEMON_RUNTIME,
|
||||||
|
isNodeDaemonRuntime,
|
||||||
|
} from "../../commands/node-daemon-runtime.js";
|
||||||
|
import {
|
||||||
|
resolveNodeLaunchAgentLabel,
|
||||||
|
resolveNodeSystemdServiceName,
|
||||||
|
resolveNodeWindowsTaskName,
|
||||||
|
} from "../../daemon/constants.js";
|
||||||
|
import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
|
||||||
|
import { resolveNodeService } from "../../daemon/node-service.js";
|
||||||
|
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
|
||||||
|
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
|
||||||
|
import { resolveIsNixMode } from "../../config/paths.js";
|
||||||
|
import { isWSL } from "../../infra/wsl.js";
|
||||||
|
import { loadNodeHostConfig } from "../../node-host/config.js";
|
||||||
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
|
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||||
|
import {
|
||||||
|
buildDaemonServiceSnapshot,
|
||||||
|
createNullWriter,
|
||||||
|
emitDaemonActionJson,
|
||||||
|
} from "../daemon-cli/response.js";
|
||||||
|
import { formatRuntimeStatus, parsePort } from "../daemon-cli/shared.js";
|
||||||
|
|
||||||
|
type NodeDaemonInstallOptions = {
|
||||||
|
host?: string;
|
||||||
|
port?: string | number;
|
||||||
|
tls?: boolean;
|
||||||
|
tlsFingerprint?: string;
|
||||||
|
nodeId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
runtime?: string;
|
||||||
|
force?: boolean;
|
||||||
|
json?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NodeDaemonLifecycleOptions = {
|
||||||
|
json?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NodeDaemonStatusOptions = {
|
||||||
|
json?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderNodeServiceStartHints(): string[] {
|
||||||
|
const base = ["clawdbot node daemon install", "clawdbot node start"];
|
||||||
|
switch (process.platform) {
|
||||||
|
case "darwin":
|
||||||
|
return [
|
||||||
|
...base,
|
||||||
|
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${resolveNodeLaunchAgentLabel()}.plist`,
|
||||||
|
];
|
||||||
|
case "linux":
|
||||||
|
return [...base, `systemctl --user start ${resolveNodeSystemdServiceName()}.service`];
|
||||||
|
case "win32":
|
||||||
|
return [...base, `schtasks /Run /TN "${resolveNodeWindowsTaskName()}"`];
|
||||||
|
default:
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodeRuntimeHints(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
const logs = resolveGatewayLogPaths(env);
|
||||||
|
return [
|
||||||
|
`Launchd stdout (if installed): ${logs.stdoutPath}`,
|
||||||
|
`Launchd stderr (if installed): ${logs.stderrPath}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
const unit = resolveNodeSystemdServiceName();
|
||||||
|
return [`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`];
|
||||||
|
}
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const task = resolveNodeWindowsTaskName();
|
||||||
|
return [`Logs: schtasks /Query /TN "${task}" /V /FO LIST`];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNodeDefaults(opts: NodeDaemonInstallOptions, config: Awaited<ReturnType<typeof loadNodeHostConfig>>) {
|
||||||
|
const host = opts.host?.trim() || config?.gateway?.host || "127.0.0.1";
|
||||||
|
const portOverride = parsePort(opts.port);
|
||||||
|
if (opts.port !== undefined && portOverride === null) {
|
||||||
|
return { host, port: null };
|
||||||
|
}
|
||||||
|
const port = portOverride ?? config?.gateway?.port ?? 18790;
|
||||||
|
return { host, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
|
||||||
|
const json = Boolean(opts.json);
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const stdout = json ? createNullWriter() : process.stdout;
|
||||||
|
const emit = (payload: {
|
||||||
|
ok: boolean;
|
||||||
|
result?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
service?: {
|
||||||
|
label: string;
|
||||||
|
loaded: boolean;
|
||||||
|
loadedText: string;
|
||||||
|
notLoadedText: string;
|
||||||
|
};
|
||||||
|
hints?: string[];
|
||||||
|
warnings?: string[];
|
||||||
|
}) => {
|
||||||
|
if (!json) return;
|
||||||
|
emitDaemonActionJson({ action: "install", ...payload });
|
||||||
|
};
|
||||||
|
const fail = (message: string, hints?: string[]) => {
|
||||||
|
if (json) {
|
||||||
|
emit({
|
||||||
|
ok: false,
|
||||||
|
error: message,
|
||||||
|
hints,
|
||||||
|
warnings: warnings.length ? warnings : undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
defaultRuntime.error(message);
|
||||||
|
if (hints?.length) {
|
||||||
|
for (const hint of hints) defaultRuntime.log(`Tip: ${hint}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resolveIsNixMode(process.env)) {
|
||||||
|
fail("Nix mode detected; daemon install is disabled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await loadNodeHostConfig();
|
||||||
|
const { host, port } = resolveNodeDefaults(opts, config);
|
||||||
|
if (!Number.isFinite(port ?? NaN) || (port ?? 0) <= 0) {
|
||||||
|
fail("Invalid port");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeRaw = opts.runtime ? String(opts.runtime) : DEFAULT_NODE_DAEMON_RUNTIME;
|
||||||
|
if (!isNodeDaemonRuntime(runtimeRaw)) {
|
||||||
|
fail('Invalid --runtime (use "node" or "bun")');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = resolveNodeService();
|
||||||
|
let loaded = false;
|
||||||
|
try {
|
||||||
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
|
} catch (err) {
|
||||||
|
fail(`Node service check failed: ${String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loaded && !opts.force) {
|
||||||
|
emit({
|
||||||
|
ok: true,
|
||||||
|
result: "already-installed",
|
||||||
|
message: `Node service already ${service.loadedText}.`,
|
||||||
|
service: buildDaemonServiceSnapshot(service, loaded),
|
||||||
|
warnings: warnings.length ? warnings : undefined,
|
||||||
|
});
|
||||||
|
if (!json) {
|
||||||
|
defaultRuntime.log(`Node service already ${service.loadedText}.`);
|
||||||
|
defaultRuntime.log("Reinstall with: clawdbot node daemon install --force");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tlsFingerprint = opts.tlsFingerprint?.trim() || config?.gateway?.tlsFingerprint;
|
||||||
|
const tls =
|
||||||
|
Boolean(opts.tls) ||
|
||||||
|
Boolean(tlsFingerprint) ||
|
||||||
|
Boolean(config?.gateway?.tls);
|
||||||
|
const { programArguments, workingDirectory, environment, description } =
|
||||||
|
await buildNodeInstallPlan({
|
||||||
|
env: process.env,
|
||||||
|
host,
|
||||||
|
port: port ?? 18790,
|
||||||
|
tls,
|
||||||
|
tlsFingerprint: tlsFingerprint || undefined,
|
||||||
|
nodeId: opts.nodeId,
|
||||||
|
displayName: opts.displayName,
|
||||||
|
runtime: runtimeRaw,
|
||||||
|
warn: (message) => {
|
||||||
|
if (json) warnings.push(message);
|
||||||
|
else defaultRuntime.log(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.install({
|
||||||
|
env: process.env,
|
||||||
|
stdout,
|
||||||
|
programArguments,
|
||||||
|
workingDirectory,
|
||||||
|
environment,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
fail(`Node install failed: ${String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = true;
|
||||||
|
try {
|
||||||
|
installed = await service.isLoaded({ env: process.env });
|
||||||
|
} catch {
|
||||||
|
installed = true;
|
||||||
|
}
|
||||||
|
emit({
|
||||||
|
ok: true,
|
||||||
|
result: "installed",
|
||||||
|
service: buildDaemonServiceSnapshot(service, installed),
|
||||||
|
warnings: warnings.length ? warnings : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runNodeDaemonUninstall(opts: NodeDaemonLifecycleOptions = {}) {
|
||||||
|
const json = Boolean(opts.json);
|
||||||
|
const stdout = json ? createNullWriter() : process.stdout;
|
||||||
|
const emit = (payload: {
|
||||||
|
ok: boolean;
|
||||||
|
result?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
service?: {
|
||||||
|
label: string;
|
||||||
|
loaded: boolean;
|
||||||
|
loadedText: string;
|
||||||
|
notLoadedText: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
if (!json) return;
|
||||||
|
emitDaemonActionJson({ action: "uninstall", ...payload });
|
||||||
|
};
|
||||||
|
const fail = (message: string) => {
|
||||||
|
if (json) emit({ ok: false, error: message });
|
||||||
|
else defaultRuntime.error(message);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resolveIsNixMode(process.env)) {
|
||||||
|
fail("Nix mode detected; daemon uninstall is disabled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = resolveNodeService();
|
||||||
|
try {
|
||||||
|
await service.uninstall({ env: process.env, stdout });
|
||||||
|
} catch (err) {
|
||||||
|
fail(`Node uninstall failed: ${String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let loaded = false;
|
||||||
|
try {
|
||||||
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
|
} catch {
|
||||||
|
loaded = false;
|
||||||
|
}
|
||||||
|
emit({
|
||||||
|
ok: true,
|
||||||
|
result: "uninstalled",
|
||||||
|
service: buildDaemonServiceSnapshot(service, loaded),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runNodeDaemonStart(opts: NodeDaemonLifecycleOptions = {}) {
|
||||||
|
const json = Boolean(opts.json);
|
||||||
|
const stdout = json ? createNullWriter() : process.stdout;
|
||||||
|
const emit = (payload: {
|
||||||
|
ok: boolean;
|
||||||
|
result?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
hints?: string[];
|
||||||
|
service?: {
|
||||||
|
label: string;
|
||||||
|
loaded: boolean;
|
||||||
|
loadedText: string;
|
||||||
|
notLoadedText: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
if (!json) return;
|
||||||
|
emitDaemonActionJson({ action: "start", ...payload });
|
||||||
|
};
|
||||||
|
const fail = (message: string, hints?: string[]) => {
|
||||||
|
if (json) emit({ ok: false, error: message, hints });
|
||||||
|
else defaultRuntime.error(message);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = resolveNodeService();
|
||||||
|
let loaded = false;
|
||||||
|
try {
|
||||||
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
|
} catch (err) {
|
||||||
|
fail(`Node service check failed: ${String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!loaded) {
|
||||||
|
let hints = renderNodeServiceStartHints();
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false);
|
||||||
|
if (!systemdAvailable) {
|
||||||
|
hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit({
|
||||||
|
ok: true,
|
||||||
|
result: "not-loaded",
|
||||||
|
message: `Node service ${service.notLoadedText}.`,
|
||||||
|
hints,
|
||||||
|
service: buildDaemonServiceSnapshot(service, loaded),
|
||||||
|
});
|
||||||
|
if (!json) {
|
||||||
|
defaultRuntime.log(`Node service ${service.notLoadedText}.`);
|
||||||
|
for (const hint of hints) {
|
||||||
|
defaultRuntime.log(`Start with: ${hint}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await service.restart({ env: process.env, stdout });
|
||||||
|
} catch (err) {
|
||||||
|
const hints = renderNodeServiceStartHints();
|
||||||
|
fail(`Node start failed: ${String(err)}`, hints);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let started = true;
|
||||||
|
try {
|
||||||
|
started = await service.isLoaded({ env: process.env });
|
||||||
|
} catch {
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
emit({
|
||||||
|
ok: true,
|
||||||
|
result: "started",
|
||||||
|
service: buildDaemonServiceSnapshot(service, started),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runNodeDaemonRestart(opts: NodeDaemonLifecycleOptions = {}) {
|
||||||
|
const json = Boolean(opts.json);
|
||||||
|
const stdout = json ? createNullWriter() : process.stdout;
|
||||||
|
const emit = (payload: {
|
||||||
|
ok: boolean;
|
||||||
|
result?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
hints?: string[];
|
||||||
|
service?: {
|
||||||
|
label: string;
|
||||||
|
loaded: boolean;
|
||||||
|
loadedText: string;
|
||||||
|
notLoadedText: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
if (!json) return;
|
||||||
|
emitDaemonActionJson({ action: "restart", ...payload });
|
||||||
|
};
|
||||||
|
const fail = (message: string, hints?: string[]) => {
|
||||||
|
if (json) emit({ ok: false, error: message, hints });
|
||||||
|
else defaultRuntime.error(message);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = resolveNodeService();
|
||||||
|
let loaded = false;
|
||||||
|
try {
|
||||||
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
|
} catch (err) {
|
||||||
|
fail(`Node service check failed: ${String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!loaded) {
|
||||||
|
let hints = renderNodeServiceStartHints();
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false);
|
||||||
|
if (!systemdAvailable) {
|
||||||
|
hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit({
|
||||||
|
ok: true,
|
||||||
|
result: "not-loaded",
|
||||||
|
message: `Node service ${service.notLoadedText}.`,
|
||||||
|
hints,
|
||||||
|
service: buildDaemonServiceSnapshot(service, loaded),
|
||||||
|
});
|
||||||
|
if (!json) {
|
||||||
|
defaultRuntime.log(`Node service ${service.notLoadedText}.`);
|
||||||
|
for (const hint of hints) {
|
||||||
|
defaultRuntime.log(`Start with: ${hint}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await service.restart({ env: process.env, stdout });
|
||||||
|
} catch (err) {
|
||||||
|
const hints = renderNodeServiceStartHints();
|
||||||
|
fail(`Node restart failed: ${String(err)}`, hints);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let restarted = true;
|
||||||
|
try {
|
||||||
|
restarted = await service.isLoaded({ env: process.env });
|
||||||
|
} catch {
|
||||||
|
restarted = true;
|
||||||
|
}
|
||||||
|
emit({
|
||||||
|
ok: true,
|
||||||
|
result: "restarted",
|
||||||
|
service: buildDaemonServiceSnapshot(service, restarted),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runNodeDaemonStop(opts: NodeDaemonLifecycleOptions = {}) {
|
||||||
|
const json = Boolean(opts.json);
|
||||||
|
const stdout = json ? createNullWriter() : process.stdout;
|
||||||
|
const emit = (payload: {
|
||||||
|
ok: boolean;
|
||||||
|
result?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
service?: {
|
||||||
|
label: string;
|
||||||
|
loaded: boolean;
|
||||||
|
loadedText: string;
|
||||||
|
notLoadedText: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
if (!json) return;
|
||||||
|
emitDaemonActionJson({ action: "stop", ...payload });
|
||||||
|
};
|
||||||
|
const fail = (message: string) => {
|
||||||
|
if (json) emit({ ok: false, error: message });
|
||||||
|
else defaultRuntime.error(message);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = resolveNodeService();
|
||||||
|
let loaded = false;
|
||||||
|
try {
|
||||||
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
|
} catch (err) {
|
||||||
|
fail(`Node service check failed: ${String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!loaded) {
|
||||||
|
emit({
|
||||||
|
ok: true,
|
||||||
|
result: "not-loaded",
|
||||||
|
message: `Node service ${service.notLoadedText}.`,
|
||||||
|
service: buildDaemonServiceSnapshot(service, loaded),
|
||||||
|
});
|
||||||
|
if (!json) {
|
||||||
|
defaultRuntime.log(`Node service ${service.notLoadedText}.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await service.stop({ env: process.env, stdout });
|
||||||
|
} catch (err) {
|
||||||
|
fail(`Node stop failed: ${String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
try {
|
||||||
|
stopped = await service.isLoaded({ env: process.env });
|
||||||
|
} catch {
|
||||||
|
stopped = false;
|
||||||
|
}
|
||||||
|
emit({
|
||||||
|
ok: true,
|
||||||
|
result: "stopped",
|
||||||
|
service: buildDaemonServiceSnapshot(service, stopped),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) {
|
||||||
|
const json = Boolean(opts.json);
|
||||||
|
const service = resolveNodeService();
|
||||||
|
const [loaded, command, runtime] = await Promise.all([
|
||||||
|
service.isLoaded({ env: process.env }).catch(() => false),
|
||||||
|
service.readCommand(process.env).catch(() => null),
|
||||||
|
service.readRuntime(process.env).catch((err) => ({ status: "unknown", detail: String(err) })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
service: {
|
||||||
|
...buildDaemonServiceSnapshot(service, loaded),
|
||||||
|
command,
|
||||||
|
runtime,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(payload, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rich = isRich();
|
||||||
|
const label = (value: string) => colorize(rich, theme.muted, value);
|
||||||
|
const accent = (value: string) => colorize(rich, theme.accent, value);
|
||||||
|
const infoText = (value: string) => colorize(rich, theme.info, value);
|
||||||
|
const okText = (value: string) => colorize(rich, theme.success, value);
|
||||||
|
const warnText = (value: string) => colorize(rich, theme.warn, value);
|
||||||
|
const errorText = (value: string) => colorize(rich, theme.error, value);
|
||||||
|
|
||||||
|
const serviceStatus = loaded ? okText(service.loadedText) : warnText(service.notLoadedText);
|
||||||
|
defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`);
|
||||||
|
|
||||||
|
if (command?.programArguments?.length) {
|
||||||
|
defaultRuntime.log(`${label("Command:")} ${infoText(command.programArguments.join(" "))}`);
|
||||||
|
}
|
||||||
|
if (command?.sourcePath) {
|
||||||
|
defaultRuntime.log(`${label("Service file:")} ${infoText(command.sourcePath)}`);
|
||||||
|
}
|
||||||
|
if (command?.workingDirectory) {
|
||||||
|
defaultRuntime.log(`${label("Working dir:")} ${infoText(command.workingDirectory)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeLine = formatRuntimeStatus(runtime);
|
||||||
|
if (runtimeLine) {
|
||||||
|
const runtimeStatus = runtime?.status ?? "unknown";
|
||||||
|
const runtimeColor =
|
||||||
|
runtimeStatus === "running"
|
||||||
|
? theme.success
|
||||||
|
: runtimeStatus === "stopped"
|
||||||
|
? theme.error
|
||||||
|
: runtimeStatus === "unknown"
|
||||||
|
? theme.muted
|
||||||
|
: theme.warn;
|
||||||
|
defaultRuntime.log(`${label("Runtime:")} ${colorize(rich, runtimeColor, runtimeLine)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
defaultRuntime.log("");
|
||||||
|
for (const hint of renderNodeServiceStartHints()) {
|
||||||
|
defaultRuntime.log(`${warnText("Start with:")} ${infoText(hint)}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseEnv = {
|
||||||
|
...(process.env as Record<string, string | undefined>),
|
||||||
|
...(command?.environment ?? undefined),
|
||||||
|
};
|
||||||
|
const hintEnv = {
|
||||||
|
...baseEnv,
|
||||||
|
CLAWDBOT_LOG_PREFIX: baseEnv.CLAWDBOT_LOG_PREFIX ?? "node",
|
||||||
|
} as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
if (runtime?.missingUnit) {
|
||||||
|
defaultRuntime.error(errorText("Service unit not found."));
|
||||||
|
for (const hint of buildNodeRuntimeHints(hintEnv)) {
|
||||||
|
defaultRuntime.error(errorText(hint));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime?.status === "stopped") {
|
||||||
|
defaultRuntime.error(errorText("Service is loaded but not running."));
|
||||||
|
for (const hint of buildNodeRuntimeHints(hintEnv)) {
|
||||||
|
defaultRuntime.error(errorText(hint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/cli/node-cli/register.ts
Normal file
116
src/cli/node-cli/register.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import type { Command } from "commander";
|
||||||
|
import { formatDocsLink } from "../../terminal/links.js";
|
||||||
|
import { theme } from "../../terminal/theme.js";
|
||||||
|
import { loadNodeHostConfig } from "../../node-host/config.js";
|
||||||
|
import { runNodeHost } from "../../node-host/runner.js";
|
||||||
|
import {
|
||||||
|
runNodeDaemonInstall,
|
||||||
|
runNodeDaemonRestart,
|
||||||
|
runNodeDaemonStart,
|
||||||
|
runNodeDaemonStatus,
|
||||||
|
runNodeDaemonStop,
|
||||||
|
runNodeDaemonUninstall,
|
||||||
|
} from "./daemon.js";
|
||||||
|
import { parsePort } from "../daemon-cli/shared.js";
|
||||||
|
|
||||||
|
function parsePortWithFallback(value: unknown, fallback: number): number {
|
||||||
|
const parsed = parsePort(value);
|
||||||
|
return parsed ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerNodeCli(program: Command) {
|
||||||
|
const node = program
|
||||||
|
.command("node")
|
||||||
|
.description("Run a headless node host (system.run/system.which)")
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
() =>
|
||||||
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/node", "docs.clawd.bot/cli/node")}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
node
|
||||||
|
.command("start")
|
||||||
|
.description("Start the headless node host (foreground)")
|
||||||
|
.option("--host <host>", "Gateway bridge host")
|
||||||
|
.option("--port <port>", "Gateway bridge port")
|
||||||
|
.option("--tls", "Use TLS for the bridge connection", false)
|
||||||
|
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
||||||
|
.option("--node-id <id>", "Override node id (clears pairing token)")
|
||||||
|
.option("--display-name <name>", "Override node display name")
|
||||||
|
.action(async (opts) => {
|
||||||
|
const existing = await loadNodeHostConfig();
|
||||||
|
const host =
|
||||||
|
(opts.host as string | undefined)?.trim() ||
|
||||||
|
existing?.gateway?.host ||
|
||||||
|
"127.0.0.1";
|
||||||
|
const port = parsePortWithFallback(opts.port, existing?.gateway?.port ?? 18790);
|
||||||
|
await runNodeHost({
|
||||||
|
gatewayHost: host,
|
||||||
|
gatewayPort: port,
|
||||||
|
gatewayTls: Boolean(opts.tls) || Boolean(opts.tlsFingerprint),
|
||||||
|
gatewayTlsFingerprint: opts.tlsFingerprint,
|
||||||
|
nodeId: opts.nodeId,
|
||||||
|
displayName: opts.displayName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const daemon = node
|
||||||
|
.command("daemon")
|
||||||
|
.description("Manage the headless node daemon service (launchd/systemd/schtasks)");
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command("status")
|
||||||
|
.description("Show node daemon status")
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonStatus(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command("install")
|
||||||
|
.description("Install the node daemon service (launchd/systemd/schtasks)")
|
||||||
|
.option("--host <host>", "Gateway bridge host")
|
||||||
|
.option("--port <port>", "Gateway bridge port")
|
||||||
|
.option("--tls", "Use TLS for the bridge connection", false)
|
||||||
|
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
||||||
|
.option("--node-id <id>", "Override node id (clears pairing token)")
|
||||||
|
.option("--display-name <name>", "Override node display name")
|
||||||
|
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
|
||||||
|
.option("--force", "Reinstall/overwrite if already installed", false)
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonInstall(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command("uninstall")
|
||||||
|
.description("Uninstall the node daemon service (launchd/systemd/schtasks)")
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonUninstall(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command("start")
|
||||||
|
.description("Start the node daemon service (launchd/systemd/schtasks)")
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonStart(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command("stop")
|
||||||
|
.description("Stop the node daemon service (launchd/systemd/schtasks)")
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonStop(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command("restart")
|
||||||
|
.description("Restart the node daemon service (launchd/systemd/schtasks)")
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonRestart(opts);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { registerWebhooksCli } from "../webhooks-cli.js";
|
|||||||
import { registerLogsCli } from "../logs-cli.js";
|
import { registerLogsCli } from "../logs-cli.js";
|
||||||
import { registerModelsCli } from "../models-cli.js";
|
import { registerModelsCli } from "../models-cli.js";
|
||||||
import { registerNodesCli } from "../nodes-cli.js";
|
import { registerNodesCli } from "../nodes-cli.js";
|
||||||
|
import { registerNodeCli } from "../node-cli.js";
|
||||||
import { registerPairingCli } from "../pairing-cli.js";
|
import { registerPairingCli } from "../pairing-cli.js";
|
||||||
import { registerPluginsCli } from "../plugins-cli.js";
|
import { registerPluginsCli } from "../plugins-cli.js";
|
||||||
import { registerSandboxCli } from "../sandbox-cli.js";
|
import { registerSandboxCli } from "../sandbox-cli.js";
|
||||||
@@ -27,6 +28,7 @@ export function registerSubCliCommands(program: Command) {
|
|||||||
registerLogsCli(program);
|
registerLogsCli(program);
|
||||||
registerModelsCli(program);
|
registerModelsCli(program);
|
||||||
registerNodesCli(program);
|
registerNodesCli(program);
|
||||||
|
registerNodeCli(program);
|
||||||
registerSandboxCli(program);
|
registerSandboxCli(program);
|
||||||
registerTuiCli(program);
|
registerTuiCli(program);
|
||||||
registerCronCli(program);
|
registerCronCli(program);
|
||||||
|
|||||||
65
src/commands/node-daemon-install-helpers.ts
Normal file
65
src/commands/node-daemon-install-helpers.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { formatNodeServiceDescription } from "../daemon/constants.js";
|
||||||
|
import { resolveNodeProgramArguments } from "../daemon/program-args.js";
|
||||||
|
import {
|
||||||
|
renderSystemNodeWarning,
|
||||||
|
resolvePreferredNodePath,
|
||||||
|
resolveSystemNodeInfo,
|
||||||
|
} from "../daemon/runtime-paths.js";
|
||||||
|
import { buildNodeServiceEnvironment } from "../daemon/service-env.js";
|
||||||
|
import { resolveGatewayDevMode } from "./daemon-install-helpers.js";
|
||||||
|
import type { NodeDaemonRuntime } from "./node-daemon-runtime.js";
|
||||||
|
|
||||||
|
type WarnFn = (message: string, title?: string) => void;
|
||||||
|
|
||||||
|
export type NodeInstallPlan = {
|
||||||
|
programArguments: string[];
|
||||||
|
workingDirectory?: string;
|
||||||
|
environment: Record<string, string | undefined>;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function buildNodeInstallPlan(params: {
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
tls?: boolean;
|
||||||
|
tlsFingerprint?: string;
|
||||||
|
nodeId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
runtime: NodeDaemonRuntime;
|
||||||
|
devMode?: boolean;
|
||||||
|
nodePath?: string;
|
||||||
|
warn?: WarnFn;
|
||||||
|
}): Promise<NodeInstallPlan> {
|
||||||
|
const devMode = params.devMode ?? resolveGatewayDevMode();
|
||||||
|
const nodePath =
|
||||||
|
params.nodePath ??
|
||||||
|
(await resolvePreferredNodePath({
|
||||||
|
env: params.env,
|
||||||
|
runtime: params.runtime,
|
||||||
|
}));
|
||||||
|
const { programArguments, workingDirectory } = await resolveNodeProgramArguments({
|
||||||
|
host: params.host,
|
||||||
|
port: params.port,
|
||||||
|
tls: params.tls,
|
||||||
|
tlsFingerprint: params.tlsFingerprint,
|
||||||
|
nodeId: params.nodeId,
|
||||||
|
displayName: params.displayName,
|
||||||
|
dev: devMode,
|
||||||
|
runtime: params.runtime,
|
||||||
|
nodePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (params.runtime === "node") {
|
||||||
|
const systemNode = await resolveSystemNodeInfo({ env: params.env });
|
||||||
|
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
||||||
|
if (warning) params.warn?.(warning, "Node daemon runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = buildNodeServiceEnvironment({ env: params.env });
|
||||||
|
const description = formatNodeServiceDescription({
|
||||||
|
version: environment.CLAWDBOT_SERVICE_VERSION,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { programArguments, workingDirectory, environment, description };
|
||||||
|
}
|
||||||
16
src/commands/node-daemon-runtime.ts
Normal file
16
src/commands/node-daemon-runtime.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||||
|
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||||
|
isGatewayDaemonRuntime,
|
||||||
|
type GatewayDaemonRuntime,
|
||||||
|
} from "./daemon-runtime.js";
|
||||||
|
|
||||||
|
export type NodeDaemonRuntime = GatewayDaemonRuntime;
|
||||||
|
|
||||||
|
export const DEFAULT_NODE_DAEMON_RUNTIME = DEFAULT_GATEWAY_DAEMON_RUNTIME;
|
||||||
|
|
||||||
|
export const NODE_DAEMON_RUNTIME_OPTIONS = GATEWAY_DAEMON_RUNTIME_OPTIONS;
|
||||||
|
|
||||||
|
export function isNodeDaemonRuntime(value: string | undefined): value is NodeDaemonRuntime {
|
||||||
|
return isGatewayDaemonRuntime(value);
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ export const GATEWAY_SYSTEMD_SERVICE_NAME = "clawdbot-gateway";
|
|||||||
export const GATEWAY_WINDOWS_TASK_NAME = "Clawdbot Gateway";
|
export const GATEWAY_WINDOWS_TASK_NAME = "Clawdbot Gateway";
|
||||||
export const GATEWAY_SERVICE_MARKER = "clawdbot";
|
export const GATEWAY_SERVICE_MARKER = "clawdbot";
|
||||||
export const GATEWAY_SERVICE_KIND = "gateway";
|
export const GATEWAY_SERVICE_KIND = "gateway";
|
||||||
|
export const NODE_LAUNCH_AGENT_LABEL = "com.clawdbot.node";
|
||||||
|
export const NODE_SYSTEMD_SERVICE_NAME = "clawdbot-node";
|
||||||
|
export const NODE_WINDOWS_TASK_NAME = "Clawdbot Node";
|
||||||
|
export const NODE_SERVICE_MARKER = "clawdbot";
|
||||||
|
export const NODE_SERVICE_KIND = "node";
|
||||||
|
export const NODE_WINDOWS_TASK_SCRIPT_NAME = "node.cmd";
|
||||||
export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = ["com.steipete.clawdbot.gateway"];
|
export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = ["com.steipete.clawdbot.gateway"];
|
||||||
export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = [];
|
export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = [];
|
||||||
export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES: string[] = [];
|
export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES: string[] = [];
|
||||||
@@ -51,3 +57,21 @@ export function formatGatewayServiceDescription(params?: {
|
|||||||
if (parts.length === 0) return "Clawdbot Gateway";
|
if (parts.length === 0) return "Clawdbot Gateway";
|
||||||
return `Clawdbot Gateway (${parts.join(", ")})`;
|
return `Clawdbot Gateway (${parts.join(", ")})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveNodeLaunchAgentLabel(): string {
|
||||||
|
return NODE_LAUNCH_AGENT_LABEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNodeSystemdServiceName(): string {
|
||||||
|
return NODE_SYSTEMD_SERVICE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNodeWindowsTaskName(): string {
|
||||||
|
return NODE_WINDOWS_TASK_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNodeServiceDescription(params?: { version?: string }): string {
|
||||||
|
const version = params?.version?.trim();
|
||||||
|
if (!version) return "Clawdbot Node Host";
|
||||||
|
return `Clawdbot Node Host (v${version})`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ export function resolveGatewayLogPaths(env: Record<string, string | undefined>):
|
|||||||
} {
|
} {
|
||||||
const stateDir = resolveGatewayStateDir(env);
|
const stateDir = resolveGatewayStateDir(env);
|
||||||
const logDir = path.join(stateDir, "logs");
|
const logDir = path.join(stateDir, "logs");
|
||||||
|
const prefix = env.CLAWDBOT_LOG_PREFIX?.trim() || "gateway";
|
||||||
return {
|
return {
|
||||||
logDir,
|
logDir,
|
||||||
stdoutPath: path.join(logDir, "gateway.log"),
|
stdoutPath: path.join(logDir, `${prefix}.log`),
|
||||||
stderrPath: path.join(logDir, "gateway.err.log"),
|
stderrPath: path.join(logDir, `${prefix}.err.log`),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,12 +341,14 @@ export async function installLaunchAgent({
|
|||||||
programArguments,
|
programArguments,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
environment,
|
environment,
|
||||||
|
description,
|
||||||
}: {
|
}: {
|
||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
stdout: NodeJS.WritableStream;
|
stdout: NodeJS.WritableStream;
|
||||||
programArguments: string[];
|
programArguments: string[];
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
environment?: Record<string, string | undefined>;
|
environment?: Record<string, string | undefined>;
|
||||||
|
description?: string;
|
||||||
}): Promise<{ plistPath: string }> {
|
}): Promise<{ plistPath: string }> {
|
||||||
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
|
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
|
||||||
await fs.mkdir(logDir, { recursive: true });
|
await fs.mkdir(logDir, { recursive: true });
|
||||||
@@ -366,13 +369,15 @@ export async function installLaunchAgent({
|
|||||||
const plistPath = resolveLaunchAgentPlistPathForLabel(env, label);
|
const plistPath = resolveLaunchAgentPlistPathForLabel(env, label);
|
||||||
await fs.mkdir(path.dirname(plistPath), { recursive: true });
|
await fs.mkdir(path.dirname(plistPath), { recursive: true });
|
||||||
|
|
||||||
const description = formatGatewayServiceDescription({
|
const serviceDescription =
|
||||||
profile: env.CLAWDBOT_PROFILE,
|
description ??
|
||||||
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
|
formatGatewayServiceDescription({
|
||||||
});
|
profile: env.CLAWDBOT_PROFILE,
|
||||||
|
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
|
||||||
|
});
|
||||||
const plist = buildLaunchAgentPlist({
|
const plist = buildLaunchAgentPlist({
|
||||||
label,
|
label,
|
||||||
comment: description,
|
comment: serviceDescription,
|
||||||
programArguments,
|
programArguments,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
stdoutPath,
|
stdoutPath,
|
||||||
|
|||||||
66
src/daemon/node-service.ts
Normal file
66
src/daemon/node-service.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { GatewayService, GatewayServiceInstallArgs } from "./service.js";
|
||||||
|
import { resolveGatewayService } from "./service.js";
|
||||||
|
import {
|
||||||
|
NODE_SERVICE_KIND,
|
||||||
|
NODE_SERVICE_MARKER,
|
||||||
|
NODE_WINDOWS_TASK_SCRIPT_NAME,
|
||||||
|
resolveNodeLaunchAgentLabel,
|
||||||
|
resolveNodeSystemdServiceName,
|
||||||
|
resolveNodeWindowsTaskName,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
function withNodeServiceEnv(
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
): Record<string, string | undefined> {
|
||||||
|
return {
|
||||||
|
...env,
|
||||||
|
CLAWDBOT_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(),
|
||||||
|
CLAWDBOT_SYSTEMD_UNIT: resolveNodeSystemdServiceName(),
|
||||||
|
CLAWDBOT_WINDOWS_TASK_NAME: resolveNodeWindowsTaskName(),
|
||||||
|
CLAWDBOT_TASK_SCRIPT_NAME: NODE_WINDOWS_TASK_SCRIPT_NAME,
|
||||||
|
CLAWDBOT_LOG_PREFIX: "node",
|
||||||
|
CLAWDBOT_SERVICE_MARKER: NODE_SERVICE_MARKER,
|
||||||
|
CLAWDBOT_SERVICE_KIND: NODE_SERVICE_KIND,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withNodeInstallEnv(args: GatewayServiceInstallArgs): GatewayServiceInstallArgs {
|
||||||
|
return {
|
||||||
|
...args,
|
||||||
|
env: withNodeServiceEnv(args.env),
|
||||||
|
environment: {
|
||||||
|
...args.environment,
|
||||||
|
CLAWDBOT_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(),
|
||||||
|
CLAWDBOT_SYSTEMD_UNIT: resolveNodeSystemdServiceName(),
|
||||||
|
CLAWDBOT_WINDOWS_TASK_NAME: resolveNodeWindowsTaskName(),
|
||||||
|
CLAWDBOT_TASK_SCRIPT_NAME: NODE_WINDOWS_TASK_SCRIPT_NAME,
|
||||||
|
CLAWDBOT_LOG_PREFIX: "node",
|
||||||
|
CLAWDBOT_SERVICE_MARKER: NODE_SERVICE_MARKER,
|
||||||
|
CLAWDBOT_SERVICE_KIND: NODE_SERVICE_KIND,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNodeService(): GatewayService {
|
||||||
|
const base = resolveGatewayService();
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
install: async (args) => {
|
||||||
|
return base.install(withNodeInstallEnv(args));
|
||||||
|
},
|
||||||
|
uninstall: async (args) => {
|
||||||
|
return base.uninstall({ ...args, env: withNodeServiceEnv(args.env) });
|
||||||
|
},
|
||||||
|
stop: async (args) => {
|
||||||
|
return base.stop({ ...args, env: withNodeServiceEnv(args.env ?? {}) });
|
||||||
|
},
|
||||||
|
restart: async (args) => {
|
||||||
|
return base.restart({ ...args, env: withNodeServiceEnv(args.env ?? {}) });
|
||||||
|
},
|
||||||
|
isLoaded: async (args) => {
|
||||||
|
return base.isLoaded({ env: withNodeServiceEnv(args.env ?? {}) });
|
||||||
|
},
|
||||||
|
readCommand: (env) => base.readCommand(withNodeServiceEnv(env)),
|
||||||
|
readRuntime: (env) => base.readRuntime(withNodeServiceEnv(env)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -138,13 +138,12 @@ async function resolveBinaryPath(binary: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveGatewayProgramArguments(params: {
|
async function resolveCliProgramArguments(params: {
|
||||||
port: number;
|
args: string[];
|
||||||
dev?: boolean;
|
dev?: boolean;
|
||||||
runtime?: GatewayRuntimePreference;
|
runtime?: GatewayRuntimePreference;
|
||||||
nodePath?: string;
|
nodePath?: string;
|
||||||
}): Promise<GatewayProgramArgs> {
|
}): Promise<GatewayProgramArgs> {
|
||||||
const gatewayArgs = ["gateway", "--port", String(params.port)];
|
|
||||||
const execPath = process.execPath;
|
const execPath = process.execPath;
|
||||||
const runtime = params.runtime ?? "auto";
|
const runtime = params.runtime ?? "auto";
|
||||||
|
|
||||||
@@ -153,7 +152,7 @@ export async function resolveGatewayProgramArguments(params: {
|
|||||||
params.nodePath ?? (isNodeRuntime(execPath) ? execPath : await resolveNodePath());
|
params.nodePath ?? (isNodeRuntime(execPath) ? execPath : await resolveNodePath());
|
||||||
const cliEntrypointPath = await resolveCliEntrypointPathForService();
|
const cliEntrypointPath = await resolveCliEntrypointPathForService();
|
||||||
return {
|
return {
|
||||||
programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs],
|
programArguments: [nodePath, cliEntrypointPath, ...params.args],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +163,7 @@ export async function resolveGatewayProgramArguments(params: {
|
|||||||
await fs.access(devCliPath);
|
await fs.access(devCliPath);
|
||||||
const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath();
|
const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath();
|
||||||
return {
|
return {
|
||||||
programArguments: [bunPath, devCliPath, ...gatewayArgs],
|
programArguments: [bunPath, devCliPath, ...params.args],
|
||||||
workingDirectory: repoRoot,
|
workingDirectory: repoRoot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -172,7 +171,7 @@ export async function resolveGatewayProgramArguments(params: {
|
|||||||
const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath();
|
const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath();
|
||||||
const cliEntrypointPath = await resolveCliEntrypointPathForService();
|
const cliEntrypointPath = await resolveCliEntrypointPathForService();
|
||||||
return {
|
return {
|
||||||
programArguments: [bunPath, cliEntrypointPath, ...gatewayArgs],
|
programArguments: [bunPath, cliEntrypointPath, ...params.args],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,12 +179,12 @@ export async function resolveGatewayProgramArguments(params: {
|
|||||||
try {
|
try {
|
||||||
const cliEntrypointPath = await resolveCliEntrypointPathForService();
|
const cliEntrypointPath = await resolveCliEntrypointPathForService();
|
||||||
return {
|
return {
|
||||||
programArguments: [execPath, cliEntrypointPath, ...gatewayArgs],
|
programArguments: [execPath, cliEntrypointPath, ...params.args],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If running under bun or another runtime that can execute TS directly
|
// If running under bun or another runtime that can execute TS directly
|
||||||
if (!isNodeRuntime(execPath)) {
|
if (!isNodeRuntime(execPath)) {
|
||||||
return { programArguments: [execPath, ...gatewayArgs] };
|
return { programArguments: [execPath, ...params.args] };
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -199,7 +198,7 @@ export async function resolveGatewayProgramArguments(params: {
|
|||||||
// If already running under bun, use current execPath
|
// If already running under bun, use current execPath
|
||||||
if (isBunRuntime(execPath)) {
|
if (isBunRuntime(execPath)) {
|
||||||
return {
|
return {
|
||||||
programArguments: [execPath, devCliPath, ...gatewayArgs],
|
programArguments: [execPath, devCliPath, ...params.args],
|
||||||
workingDirectory: repoRoot,
|
workingDirectory: repoRoot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -207,7 +206,46 @@ export async function resolveGatewayProgramArguments(params: {
|
|||||||
// Otherwise resolve bun from PATH
|
// Otherwise resolve bun from PATH
|
||||||
const bunPath = await resolveBunPath();
|
const bunPath = await resolveBunPath();
|
||||||
return {
|
return {
|
||||||
programArguments: [bunPath, devCliPath, ...gatewayArgs],
|
programArguments: [bunPath, devCliPath, ...params.args],
|
||||||
workingDirectory: repoRoot,
|
workingDirectory: repoRoot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveGatewayProgramArguments(params: {
|
||||||
|
port: number;
|
||||||
|
dev?: boolean;
|
||||||
|
runtime?: GatewayRuntimePreference;
|
||||||
|
nodePath?: string;
|
||||||
|
}): Promise<GatewayProgramArgs> {
|
||||||
|
const gatewayArgs = ["gateway", "--port", String(params.port)];
|
||||||
|
return resolveCliProgramArguments({
|
||||||
|
args: gatewayArgs,
|
||||||
|
dev: params.dev,
|
||||||
|
runtime: params.runtime,
|
||||||
|
nodePath: params.nodePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveNodeProgramArguments(params: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
tls?: boolean;
|
||||||
|
tlsFingerprint?: string;
|
||||||
|
nodeId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
dev?: boolean;
|
||||||
|
runtime?: GatewayRuntimePreference;
|
||||||
|
nodePath?: string;
|
||||||
|
}): Promise<GatewayProgramArgs> {
|
||||||
|
const args = ["node", "start", "--host", params.host, "--port", String(params.port)];
|
||||||
|
if (params.tls || params.tlsFingerprint) args.push("--tls");
|
||||||
|
if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint);
|
||||||
|
if (params.nodeId) args.push("--node-id", params.nodeId);
|
||||||
|
if (params.displayName) args.push("--display-name", params.displayName);
|
||||||
|
return resolveCliProgramArguments({
|
||||||
|
args,
|
||||||
|
dev: params.dev,
|
||||||
|
runtime: params.runtime,
|
||||||
|
nodePath: params.nodePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,9 +16,18 @@ const formatLine = (label: string, value: string) => {
|
|||||||
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
|
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolveTaskName(env: Record<string, string | undefined>): string {
|
||||||
|
const override = env.CLAWDBOT_WINDOWS_TASK_NAME?.trim();
|
||||||
|
if (override) return override;
|
||||||
|
return resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveTaskScriptPath(env: Record<string, string | undefined>): string {
|
export function resolveTaskScriptPath(env: Record<string, string | undefined>): string {
|
||||||
|
const override = env.CLAWDBOT_TASK_SCRIPT?.trim();
|
||||||
|
if (override) return override;
|
||||||
|
const scriptName = env.CLAWDBOT_TASK_SCRIPT_NAME?.trim() || "gateway.cmd";
|
||||||
const stateDir = resolveGatewayStateDir(env);
|
const stateDir = resolveGatewayStateDir(env);
|
||||||
return path.join(stateDir, "gateway.cmd");
|
return path.join(stateDir, scriptName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function quoteCmdArg(value: string): string {
|
function quoteCmdArg(value: string): string {
|
||||||
@@ -201,29 +210,33 @@ export async function installScheduledTask({
|
|||||||
programArguments,
|
programArguments,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
environment,
|
environment,
|
||||||
|
description,
|
||||||
}: {
|
}: {
|
||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
stdout: NodeJS.WritableStream;
|
stdout: NodeJS.WritableStream;
|
||||||
programArguments: string[];
|
programArguments: string[];
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
environment?: Record<string, string | undefined>;
|
environment?: Record<string, string | undefined>;
|
||||||
|
description?: string;
|
||||||
}): Promise<{ scriptPath: string }> {
|
}): Promise<{ scriptPath: string }> {
|
||||||
await assertSchtasksAvailable();
|
await assertSchtasksAvailable();
|
||||||
const scriptPath = resolveTaskScriptPath(env);
|
const scriptPath = resolveTaskScriptPath(env);
|
||||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||||
const description = formatGatewayServiceDescription({
|
const taskDescription =
|
||||||
profile: env.CLAWDBOT_PROFILE,
|
description ??
|
||||||
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
|
formatGatewayServiceDescription({
|
||||||
});
|
profile: env.CLAWDBOT_PROFILE,
|
||||||
|
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
|
||||||
|
});
|
||||||
const script = buildTaskScript({
|
const script = buildTaskScript({
|
||||||
description,
|
description: taskDescription,
|
||||||
programArguments,
|
programArguments,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
environment,
|
environment,
|
||||||
});
|
});
|
||||||
await fs.writeFile(scriptPath, script, "utf8");
|
await fs.writeFile(scriptPath, script, "utf8");
|
||||||
|
|
||||||
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
|
const taskName = resolveTaskName(env);
|
||||||
const quotedScript = quoteCmdArg(scriptPath);
|
const quotedScript = quoteCmdArg(scriptPath);
|
||||||
const baseArgs = [
|
const baseArgs = [
|
||||||
"/Create",
|
"/Create",
|
||||||
@@ -268,7 +281,7 @@ export async function uninstallScheduledTask({
|
|||||||
stdout: NodeJS.WritableStream;
|
stdout: NodeJS.WritableStream;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await assertSchtasksAvailable();
|
await assertSchtasksAvailable();
|
||||||
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
|
const taskName = resolveTaskName(env);
|
||||||
await execSchtasks(["/Delete", "/F", "/TN", taskName]);
|
await execSchtasks(["/Delete", "/F", "/TN", taskName]);
|
||||||
|
|
||||||
const scriptPath = resolveTaskScriptPath(env);
|
const scriptPath = resolveTaskScriptPath(env);
|
||||||
@@ -293,7 +306,7 @@ export async function stopScheduledTask({
|
|||||||
env?: Record<string, string | undefined>;
|
env?: Record<string, string | undefined>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await assertSchtasksAvailable();
|
await assertSchtasksAvailable();
|
||||||
const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE);
|
const taskName = resolveTaskName(env ?? (process.env as Record<string, string | undefined>));
|
||||||
const res = await execSchtasks(["/End", "/TN", taskName]);
|
const res = await execSchtasks(["/End", "/TN", taskName]);
|
||||||
if (res.code !== 0 && !isTaskNotRunning(res)) {
|
if (res.code !== 0 && !isTaskNotRunning(res)) {
|
||||||
throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim());
|
throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim());
|
||||||
@@ -309,7 +322,7 @@ export async function restartScheduledTask({
|
|||||||
env?: Record<string, string | undefined>;
|
env?: Record<string, string | undefined>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await assertSchtasksAvailable();
|
await assertSchtasksAvailable();
|
||||||
const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE);
|
const taskName = resolveTaskName(env ?? (process.env as Record<string, string | undefined>));
|
||||||
await execSchtasks(["/End", "/TN", taskName]);
|
await execSchtasks(["/End", "/TN", taskName]);
|
||||||
const res = await execSchtasks(["/Run", "/TN", taskName]);
|
const res = await execSchtasks(["/Run", "/TN", taskName]);
|
||||||
if (res.code !== 0) {
|
if (res.code !== 0) {
|
||||||
@@ -322,7 +335,7 @@ export async function isScheduledTaskInstalled(args: {
|
|||||||
env?: Record<string, string | undefined>;
|
env?: Record<string, string | undefined>;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
await assertSchtasksAvailable();
|
await assertSchtasksAvailable();
|
||||||
const taskName = resolveGatewayWindowsTaskName(args.env?.CLAWDBOT_PROFILE);
|
const taskName = resolveTaskName(args.env ?? (process.env as Record<string, string | undefined>));
|
||||||
const res = await execSchtasks(["/Query", "/TN", taskName]);
|
const res = await execSchtasks(["/Query", "/TN", taskName]);
|
||||||
return res.code === 0;
|
return res.code === 0;
|
||||||
}
|
}
|
||||||
@@ -338,7 +351,7 @@ export async function readScheduledTaskRuntime(
|
|||||||
detail: String(err),
|
detail: String(err),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
|
const taskName = resolveTaskName(env);
|
||||||
const res = await execSchtasks(["/Query", "/TN", taskName, "/V", "/FO", "LIST"]);
|
const res = await execSchtasks(["/Query", "/TN", taskName, "/V", "/FO", "LIST"]);
|
||||||
if (res.code !== 0) {
|
if (res.code !== 0) {
|
||||||
const detail = (res.stderr || res.stdout).trim();
|
const detail = (res.stderr || res.stdout).trim();
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import {
|
|||||||
GATEWAY_SERVICE_MARKER,
|
GATEWAY_SERVICE_MARKER,
|
||||||
resolveGatewayLaunchAgentLabel,
|
resolveGatewayLaunchAgentLabel,
|
||||||
resolveGatewaySystemdServiceName,
|
resolveGatewaySystemdServiceName,
|
||||||
|
NODE_SERVICE_KIND,
|
||||||
|
NODE_SERVICE_MARKER,
|
||||||
|
NODE_WINDOWS_TASK_SCRIPT_NAME,
|
||||||
|
resolveNodeLaunchAgentLabel,
|
||||||
|
resolveNodeSystemdServiceName,
|
||||||
|
resolveNodeWindowsTaskName,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
|
|
||||||
export type MinimalServicePathOptions = {
|
export type MinimalServicePathOptions = {
|
||||||
@@ -82,3 +88,22 @@ export function buildServiceEnvironment(params: {
|
|||||||
CLAWDBOT_SERVICE_VERSION: VERSION,
|
CLAWDBOT_SERVICE_VERSION: VERSION,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildNodeServiceEnvironment(params: {
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
}): Record<string, string | undefined> {
|
||||||
|
const { env } = params;
|
||||||
|
return {
|
||||||
|
PATH: buildMinimalServicePath({ env }),
|
||||||
|
CLAWDBOT_STATE_DIR: env.CLAWDBOT_STATE_DIR,
|
||||||
|
CLAWDBOT_CONFIG_PATH: env.CLAWDBOT_CONFIG_PATH,
|
||||||
|
CLAWDBOT_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(),
|
||||||
|
CLAWDBOT_SYSTEMD_UNIT: resolveNodeSystemdServiceName(),
|
||||||
|
CLAWDBOT_WINDOWS_TASK_NAME: resolveNodeWindowsTaskName(),
|
||||||
|
CLAWDBOT_TASK_SCRIPT_NAME: NODE_WINDOWS_TASK_SCRIPT_NAME,
|
||||||
|
CLAWDBOT_LOG_PREFIX: "node",
|
||||||
|
CLAWDBOT_SERVICE_MARKER: NODE_SERVICE_MARKER,
|
||||||
|
CLAWDBOT_SERVICE_KIND: NODE_SERVICE_KIND,
|
||||||
|
CLAWDBOT_SERVICE_VERSION: VERSION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type GatewayServiceInstallArgs = {
|
|||||||
programArguments: string[];
|
programArguments: string[];
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
environment?: Record<string, string | undefined>;
|
environment?: Record<string, string | undefined>;
|
||||||
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayService = {
|
export type GatewayService = {
|
||||||
|
|||||||
@@ -186,23 +186,27 @@ export async function installSystemdService({
|
|||||||
programArguments,
|
programArguments,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
environment,
|
environment,
|
||||||
|
description,
|
||||||
}: {
|
}: {
|
||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
stdout: NodeJS.WritableStream;
|
stdout: NodeJS.WritableStream;
|
||||||
programArguments: string[];
|
programArguments: string[];
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
environment?: Record<string, string | undefined>;
|
environment?: Record<string, string | undefined>;
|
||||||
|
description?: string;
|
||||||
}): Promise<{ unitPath: string }> {
|
}): Promise<{ unitPath: string }> {
|
||||||
await assertSystemdAvailable();
|
await assertSystemdAvailable();
|
||||||
|
|
||||||
const unitPath = resolveSystemdUnitPath(env);
|
const unitPath = resolveSystemdUnitPath(env);
|
||||||
await fs.mkdir(path.dirname(unitPath), { recursive: true });
|
await fs.mkdir(path.dirname(unitPath), { recursive: true });
|
||||||
const description = formatGatewayServiceDescription({
|
const serviceDescription =
|
||||||
profile: env.CLAWDBOT_PROFILE,
|
description ??
|
||||||
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
|
formatGatewayServiceDescription({
|
||||||
});
|
profile: env.CLAWDBOT_PROFILE,
|
||||||
|
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
|
||||||
|
});
|
||||||
const unit = buildSystemdUnit({
|
const unit = buildSystemdUnit({
|
||||||
description,
|
description: serviceDescription,
|
||||||
programArguments,
|
programArguments,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
environment,
|
environment,
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
|
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
|
||||||
import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "../infra/voicewake.js";
|
import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "../infra/voicewake.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
@@ -72,6 +76,20 @@ export const handleSystemBridgeMethods: BridgeMethodHandler = async (
|
|||||||
const models = await ctx.loadGatewayModelCatalog();
|
const models = await ctx.loadGatewayModelCatalog();
|
||||||
return { ok: true, payloadJSON: JSON.stringify({ models }) };
|
return { ok: true, payloadJSON: JSON.stringify({ models }) };
|
||||||
}
|
}
|
||||||
|
case "skills.bins": {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||||
|
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||||
|
config: cfg,
|
||||||
|
eligibility: { remote: getRemoteSkillEligibility() },
|
||||||
|
});
|
||||||
|
const bins = Array.from(
|
||||||
|
new Set(
|
||||||
|
report.skills.flatMap((skill) => skill.requirements?.bins ?? []).filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { ok: true, payloadJSON: JSON.stringify({ bins }) };
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function loadExecApprovals(): ExecApprovalsFile {
|
|||||||
const raw = fs.readFileSync(filePath, "utf8");
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
const parsed = JSON.parse(raw) as ExecApprovalsFile;
|
const parsed = JSON.parse(raw) as ExecApprovalsFile;
|
||||||
if (parsed?.version !== 1) {
|
if (parsed?.version !== 1) {
|
||||||
return normalizeExecApprovals({ version: 1, agents: parsed?.agents ?? {} });
|
return normalizeExecApprovals({ version: 1, agents: {} });
|
||||||
}
|
}
|
||||||
return normalizeExecApprovals(parsed);
|
return normalizeExecApprovals(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -117,7 +117,12 @@ export function loadExecApprovals(): ExecApprovalsFile {
|
|||||||
export function saveExecApprovals(file: ExecApprovalsFile) {
|
export function saveExecApprovals(file: ExecApprovalsFile) {
|
||||||
const filePath = resolveExecApprovalsPath();
|
const filePath = resolveExecApprovalsPath();
|
||||||
ensureDir(filePath);
|
ensureDir(filePath);
|
||||||
fs.writeFileSync(filePath, JSON.stringify(file, null, 2));
|
fs.writeFileSync(filePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
try {
|
||||||
|
fs.chmodSync(filePath, 0o600);
|
||||||
|
} catch {
|
||||||
|
// best-effort on platforms without chmod
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureExecApprovals(): ExecApprovalsFile {
|
export function ensureExecApprovals(): ExecApprovalsFile {
|
||||||
|
|||||||
10
src/infra/node-shell.ts
Normal file
10
src/infra/node-shell.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export function buildNodeShellCommand(command: string, platform?: string | null) {
|
||||||
|
const normalized = String(platform ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (normalized.includes("win")) {
|
||||||
|
return ["cmd.exe", "/d", "/s", "/c", command];
|
||||||
|
}
|
||||||
|
return ["/bin/sh", "-lc", command];
|
||||||
|
}
|
||||||
|
|
||||||
306
src/node-host/bridge-client.ts
Normal file
306
src/node-host/bridge-client.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import net from "node:net";
|
||||||
|
import tls from "node:tls";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BridgeErrorFrame,
|
||||||
|
BridgeEventFrame,
|
||||||
|
BridgeHelloFrame,
|
||||||
|
BridgeHelloOkFrame,
|
||||||
|
BridgeInvokeRequestFrame,
|
||||||
|
BridgeInvokeResponseFrame,
|
||||||
|
BridgePairOkFrame,
|
||||||
|
BridgePairRequestFrame,
|
||||||
|
BridgePingFrame,
|
||||||
|
BridgePongFrame,
|
||||||
|
BridgeRPCRequestFrame,
|
||||||
|
BridgeRPCResponseFrame,
|
||||||
|
} from "../infra/bridge/server/types.js";
|
||||||
|
|
||||||
|
export type BridgeClientOptions = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
tls?: boolean;
|
||||||
|
tlsFingerprint?: string;
|
||||||
|
nodeId: string;
|
||||||
|
token?: string;
|
||||||
|
displayName?: string;
|
||||||
|
platform?: string;
|
||||||
|
version?: string;
|
||||||
|
deviceFamily?: string;
|
||||||
|
modelIdentifier?: string;
|
||||||
|
caps?: string[];
|
||||||
|
commands?: string[];
|
||||||
|
permissions?: Record<string, boolean>;
|
||||||
|
onInvoke?: (frame: BridgeInvokeRequestFrame) => void | Promise<void>;
|
||||||
|
onEvent?: (frame: BridgeEventFrame) => void | Promise<void>;
|
||||||
|
onPairToken?: (token: string) => void | Promise<void>;
|
||||||
|
onAuthReset?: () => void | Promise<void>;
|
||||||
|
onConnected?: (hello: BridgeHelloOkFrame) => void | Promise<void>;
|
||||||
|
onDisconnected?: (err?: Error) => void | Promise<void>;
|
||||||
|
log?: { info?: (msg: string) => void; warn?: (msg: string) => void };
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRpc = {
|
||||||
|
resolve: (frame: BridgeRPCResponseFrame) => void;
|
||||||
|
reject: (err: Error) => void;
|
||||||
|
timer?: NodeJS.Timeout;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function normalizeFingerprint(input: string): string {
|
||||||
|
return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFingerprint(raw: tls.PeerCertificate | tls.DetailedPeerCertificate): string | null {
|
||||||
|
const value = "fingerprint256" in raw ? raw.fingerprint256 : undefined;
|
||||||
|
if (!value) return null;
|
||||||
|
return normalizeFingerprint(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BridgeClient {
|
||||||
|
private opts: BridgeClientOptions;
|
||||||
|
private socket: net.Socket | tls.TLSSocket | null = null;
|
||||||
|
private buffer = "";
|
||||||
|
private pendingRpc = new Map<string, PendingRpc>();
|
||||||
|
private connected = false;
|
||||||
|
private helloReady: Promise<void> | null = null;
|
||||||
|
private helloResolve: (() => void) | null = null;
|
||||||
|
private helloReject: ((err: Error) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(opts: BridgeClientOptions) {
|
||||||
|
this.opts = opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.connected) return;
|
||||||
|
this.helloReady = new Promise<void>((resolve, reject) => {
|
||||||
|
this.helloResolve = resolve;
|
||||||
|
this.helloReject = reject;
|
||||||
|
});
|
||||||
|
const socket = this.opts.tls
|
||||||
|
? tls.connect({
|
||||||
|
host: this.opts.host,
|
||||||
|
port: this.opts.port,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
})
|
||||||
|
: net.connect({ host: this.opts.host, port: this.opts.port });
|
||||||
|
this.socket = socket;
|
||||||
|
socket.setNoDelay(true);
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
this.sendHello();
|
||||||
|
});
|
||||||
|
socket.on("error", (err) => {
|
||||||
|
this.handleDisconnect(err);
|
||||||
|
});
|
||||||
|
socket.on("close", () => {
|
||||||
|
this.handleDisconnect();
|
||||||
|
});
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
this.buffer += chunk.toString("utf8");
|
||||||
|
this.flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.opts.tls && socket instanceof tls.TLSSocket && this.opts.tlsFingerprint) {
|
||||||
|
socket.once("secureConnect", () => {
|
||||||
|
const cert = socket.getPeerCertificate(true);
|
||||||
|
const fingerprint = cert ? extractFingerprint(cert) : null;
|
||||||
|
if (!fingerprint || fingerprint !== normalizeFingerprint(this.opts.tlsFingerprint ?? "")) {
|
||||||
|
const err = new Error("bridge tls fingerprint mismatch");
|
||||||
|
this.handleDisconnect(err);
|
||||||
|
socket.destroy(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.helloReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.destroy();
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
this.connected = false;
|
||||||
|
this.pendingRpc.forEach((pending) => {
|
||||||
|
pending.timer && clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error("bridge client closed"));
|
||||||
|
});
|
||||||
|
this.pendingRpc.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method: string, params: Record<string, unknown> | null = null, timeoutMs = 5000) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const frame: BridgeRPCRequestFrame = {
|
||||||
|
type: "req",
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
paramsJSON: params ? JSON.stringify(params) : null,
|
||||||
|
};
|
||||||
|
const res = await new Promise<BridgeRPCResponseFrame>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingRpc.delete(id);
|
||||||
|
reject(new Error(`bridge request timeout (${method})`));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.pendingRpc.set(id, { resolve, reject, timer });
|
||||||
|
this.send(frame);
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(res.error?.message ?? "bridge request failed");
|
||||||
|
}
|
||||||
|
return res.payloadJSON ? JSON.parse(res.payloadJSON) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent(event: string, payload?: unknown) {
|
||||||
|
const frame: BridgeEventFrame = {
|
||||||
|
type: "event",
|
||||||
|
event,
|
||||||
|
payloadJSON: payload ? JSON.stringify(payload) : null,
|
||||||
|
};
|
||||||
|
this.send(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendInvokeResponse(frame: BridgeInvokeResponseFrame) {
|
||||||
|
this.send(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendHello() {
|
||||||
|
const hello: BridgeHelloFrame = {
|
||||||
|
type: "hello",
|
||||||
|
nodeId: this.opts.nodeId,
|
||||||
|
token: this.opts.token,
|
||||||
|
displayName: this.opts.displayName,
|
||||||
|
platform: this.opts.platform,
|
||||||
|
version: this.opts.version,
|
||||||
|
deviceFamily: this.opts.deviceFamily,
|
||||||
|
modelIdentifier: this.opts.modelIdentifier,
|
||||||
|
caps: this.opts.caps,
|
||||||
|
commands: this.opts.commands,
|
||||||
|
permissions: this.opts.permissions,
|
||||||
|
};
|
||||||
|
this.send(hello);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendPairRequest() {
|
||||||
|
const req: BridgePairRequestFrame = {
|
||||||
|
type: "pair-request",
|
||||||
|
nodeId: this.opts.nodeId,
|
||||||
|
displayName: this.opts.displayName,
|
||||||
|
platform: this.opts.platform,
|
||||||
|
version: this.opts.version,
|
||||||
|
deviceFamily: this.opts.deviceFamily,
|
||||||
|
modelIdentifier: this.opts.modelIdentifier,
|
||||||
|
caps: this.opts.caps,
|
||||||
|
commands: this.opts.commands,
|
||||||
|
permissions: this.opts.permissions,
|
||||||
|
};
|
||||||
|
this.send(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(frame: object) {
|
||||||
|
if (!this.socket) return;
|
||||||
|
this.socket.write(`${JSON.stringify(frame)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDisconnect(err?: Error) {
|
||||||
|
if (!this.connected && this.helloReject) {
|
||||||
|
this.helloReject(err ?? new Error("bridge connection failed"));
|
||||||
|
this.helloResolve = null;
|
||||||
|
this.helloReject = null;
|
||||||
|
}
|
||||||
|
if (!this.connected && !this.socket) return;
|
||||||
|
this.connected = false;
|
||||||
|
this.socket = null;
|
||||||
|
this.pendingRpc.forEach((pending) => {
|
||||||
|
pending.timer && clearTimeout(pending.timer);
|
||||||
|
pending.reject(err ?? new Error("bridge connection closed"));
|
||||||
|
});
|
||||||
|
this.pendingRpc.clear();
|
||||||
|
void this.opts.onDisconnected?.(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
private flush() {
|
||||||
|
while (true) {
|
||||||
|
const idx = this.buffer.indexOf("\n");
|
||||||
|
if (idx === -1) break;
|
||||||
|
const line = this.buffer.slice(0, idx).trim();
|
||||||
|
this.buffer = this.buffer.slice(idx + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
let frame: { type?: string; [key: string]: unknown };
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(line) as { type?: string };
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.handleFrame(frame as BridgeErrorFrame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFrame(frame: {
|
||||||
|
type?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) {
|
||||||
|
const type = String(frame.type ?? "");
|
||||||
|
switch (type) {
|
||||||
|
case "hello-ok": {
|
||||||
|
this.connected = true;
|
||||||
|
this.helloResolve?.();
|
||||||
|
this.helloResolve = null;
|
||||||
|
this.helloReject = null;
|
||||||
|
void this.opts.onConnected?.(frame as BridgeHelloOkFrame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "pair-ok": {
|
||||||
|
const token = String((frame as BridgePairOkFrame).token ?? "").trim();
|
||||||
|
if (token) {
|
||||||
|
this.opts.token = token;
|
||||||
|
void this.opts.onPairToken?.(token);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "error": {
|
||||||
|
const code = String((frame as BridgeErrorFrame).code ?? "");
|
||||||
|
if (code === "NOT_PAIRED" || code === "UNAUTHORIZED") {
|
||||||
|
this.opts.token = undefined;
|
||||||
|
void this.opts.onAuthReset?.();
|
||||||
|
this.sendPairRequest();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.handleDisconnect(new Error((frame as BridgeErrorFrame).message ?? "bridge error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "pong":
|
||||||
|
return;
|
||||||
|
case "ping": {
|
||||||
|
const ping = frame as BridgePingFrame;
|
||||||
|
const pong: BridgePongFrame = { type: "pong", id: String(ping.id ?? "") };
|
||||||
|
this.send(pong);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "event": {
|
||||||
|
void this.opts.onEvent?.(frame as BridgeEventFrame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "res": {
|
||||||
|
const res = frame as BridgeRPCResponseFrame;
|
||||||
|
const pending = this.pendingRpc.get(res.id);
|
||||||
|
if (pending) {
|
||||||
|
pending.timer && clearTimeout(pending.timer);
|
||||||
|
this.pendingRpc.delete(res.id);
|
||||||
|
pending.resolve(res);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "invoke": {
|
||||||
|
void this.opts.onInvoke?.(frame as BridgeInvokeRequestFrame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "invoke-res": {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/node-host/config.ts
Normal file
74
src/node-host/config.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
|
|
||||||
|
export type NodeHostGatewayConfig = {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
tls?: boolean;
|
||||||
|
tlsFingerprint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeHostConfig = {
|
||||||
|
version: 1;
|
||||||
|
nodeId: string;
|
||||||
|
token?: string;
|
||||||
|
displayName?: string;
|
||||||
|
gateway?: NodeHostGatewayConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NODE_HOST_FILE = "node.json";
|
||||||
|
|
||||||
|
export function resolveNodeHostConfigPath(): string {
|
||||||
|
return path.join(resolveStateDir(), NODE_HOST_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(config: Partial<NodeHostConfig> | null): NodeHostConfig {
|
||||||
|
const base: NodeHostConfig = {
|
||||||
|
version: 1,
|
||||||
|
nodeId: "",
|
||||||
|
token: config?.token,
|
||||||
|
displayName: config?.displayName,
|
||||||
|
gateway: config?.gateway,
|
||||||
|
};
|
||||||
|
if (config?.version === 1 && typeof config.nodeId === "string") {
|
||||||
|
base.nodeId = config.nodeId.trim();
|
||||||
|
}
|
||||||
|
if (!base.nodeId) {
|
||||||
|
base.nodeId = crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadNodeHostConfig(): Promise<NodeHostConfig | null> {
|
||||||
|
const filePath = resolveNodeHostConfigPath();
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(filePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as Partial<NodeHostConfig>;
|
||||||
|
return normalizeConfig(parsed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveNodeHostConfig(config: NodeHostConfig): Promise<void> {
|
||||||
|
const filePath = resolveNodeHostConfigPath();
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
const payload = JSON.stringify(config, null, 2);
|
||||||
|
await fs.writeFile(filePath, `${payload}\n`, { mode: 0o600 });
|
||||||
|
try {
|
||||||
|
await fs.chmod(filePath, 0o600);
|
||||||
|
} catch {
|
||||||
|
// best-effort on platforms without chmod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureNodeHostConfig(): Promise<NodeHostConfig> {
|
||||||
|
const existing = await loadNodeHostConfig();
|
||||||
|
const normalized = normalizeConfig(existing);
|
||||||
|
await saveNodeHostConfig(normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
654
src/node-host/runner.ts
Normal file
654
src/node-host/runner.ts
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { BridgeInvokeRequestFrame } from "../infra/bridge/server/types.js";
|
||||||
|
import {
|
||||||
|
addAllowlistEntry,
|
||||||
|
matchAllowlist,
|
||||||
|
recordAllowlistUse,
|
||||||
|
requestExecApprovalViaSocket,
|
||||||
|
resolveCommandResolution,
|
||||||
|
resolveExecApprovals,
|
||||||
|
} from "../infra/exec-approvals.js";
|
||||||
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
|
import { VERSION } from "../version.js";
|
||||||
|
|
||||||
|
import { BridgeClient } from "./bridge-client.js";
|
||||||
|
import {
|
||||||
|
ensureNodeHostConfig,
|
||||||
|
saveNodeHostConfig,
|
||||||
|
type NodeHostGatewayConfig,
|
||||||
|
} from "./config.js";
|
||||||
|
|
||||||
|
type NodeHostRunOptions = {
|
||||||
|
gatewayHost: string;
|
||||||
|
gatewayPort: number;
|
||||||
|
gatewayTls?: boolean;
|
||||||
|
gatewayTlsFingerprint?: string;
|
||||||
|
nodeId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SystemRunParams = {
|
||||||
|
command: string[];
|
||||||
|
rawCommand?: string | null;
|
||||||
|
cwd?: string | null;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
timeoutMs?: number | null;
|
||||||
|
needsScreenRecording?: boolean | null;
|
||||||
|
agentId?: string | null;
|
||||||
|
sessionKey?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SystemWhichParams = {
|
||||||
|
bins: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunResult = {
|
||||||
|
exitCode?: number;
|
||||||
|
timedOut: boolean;
|
||||||
|
success: boolean;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
error?: string | null;
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecEventPayload = {
|
||||||
|
sessionKey: string;
|
||||||
|
runId: string;
|
||||||
|
host: string;
|
||||||
|
command?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
timedOut?: boolean;
|
||||||
|
success?: boolean;
|
||||||
|
output?: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OUTPUT_CAP = 200_000;
|
||||||
|
const OUTPUT_EVENT_TAIL = 20_000;
|
||||||
|
|
||||||
|
const blockedEnvKeys = new Set([
|
||||||
|
"PATH",
|
||||||
|
"NODE_OPTIONS",
|
||||||
|
"PYTHONHOME",
|
||||||
|
"PYTHONPATH",
|
||||||
|
"PERL5LIB",
|
||||||
|
"PERL5OPT",
|
||||||
|
"RUBYOPT",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const blockedEnvPrefixes = ["DYLD_", "LD_"];
|
||||||
|
|
||||||
|
class SkillBinsCache {
|
||||||
|
private bins = new Set<string>();
|
||||||
|
private lastRefresh = 0;
|
||||||
|
private readonly ttlMs = 90_000;
|
||||||
|
private readonly fetch: () => Promise<string[]>;
|
||||||
|
|
||||||
|
constructor(fetch: () => Promise<string[]>) {
|
||||||
|
this.fetch = fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async current(force = false): Promise<Set<string>> {
|
||||||
|
if (force || Date.now() - this.lastRefresh > this.ttlMs) {
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
return this.bins;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refresh() {
|
||||||
|
try {
|
||||||
|
const bins = await this.fetch();
|
||||||
|
this.bins = new Set(bins);
|
||||||
|
this.lastRefresh = Date.now();
|
||||||
|
} catch {
|
||||||
|
if (!this.lastRefresh) {
|
||||||
|
this.bins = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEnv(overrides?: Record<string, string> | null): Record<string, string> | undefined {
|
||||||
|
if (!overrides) return undefined;
|
||||||
|
const merged = { ...process.env } as Record<string, string>;
|
||||||
|
for (const [rawKey, value] of Object.entries(overrides)) {
|
||||||
|
const key = rawKey.trim();
|
||||||
|
if (!key) continue;
|
||||||
|
const upper = key.toUpperCase();
|
||||||
|
if (blockedEnvKeys.has(upper)) continue;
|
||||||
|
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) continue;
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommand(argv: string[]): string {
|
||||||
|
return argv
|
||||||
|
.map((arg) => {
|
||||||
|
const trimmed = arg.trim();
|
||||||
|
if (!trimmed) return "\"\"";
|
||||||
|
const needsQuotes = /\s|"/.test(trimmed);
|
||||||
|
if (!needsQuotes) return trimmed;
|
||||||
|
return `"${trimmed.replace(/"/g, '\\"')}"`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateOutput(raw: string, maxChars: number): { text: string; truncated: boolean } {
|
||||||
|
if (raw.length <= maxChars) return { text: raw, truncated: false };
|
||||||
|
return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(
|
||||||
|
argv: string[],
|
||||||
|
cwd: string | undefined,
|
||||||
|
env: Record<string, string> | undefined,
|
||||||
|
timeoutMs: number | undefined,
|
||||||
|
): Promise<RunResult> {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let outputLen = 0;
|
||||||
|
let truncated = false;
|
||||||
|
let timedOut = false;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const child = spawn(argv[0], argv.slice(1), {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onChunk = (chunk: Buffer, target: "stdout" | "stderr") => {
|
||||||
|
if (outputLen >= OUTPUT_CAP) {
|
||||||
|
truncated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remaining = OUTPUT_CAP - outputLen;
|
||||||
|
const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
|
||||||
|
const str = slice.toString("utf8");
|
||||||
|
outputLen += slice.length;
|
||||||
|
if (target === "stdout") stdout += str;
|
||||||
|
else stderr += str;
|
||||||
|
if (chunk.length > remaining) truncated = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk) => onChunk(chunk as Buffer, "stdout"));
|
||||||
|
child.stderr?.on("data", (chunk) => onChunk(chunk as Buffer, "stderr"));
|
||||||
|
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
if (timeoutMs && timeoutMs > 0) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
try {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalize = (exitCode?: number, error?: string | null) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
resolve({
|
||||||
|
exitCode,
|
||||||
|
timedOut,
|
||||||
|
success: exitCode === 0 && !timedOut && !error,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
error: error ?? null,
|
||||||
|
truncated,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
finalize(undefined, err.message);
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
finalize(code === null ? undefined : code, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEnvPath(env?: Record<string, string>): string[] {
|
||||||
|
const raw =
|
||||||
|
env?.PATH ??
|
||||||
|
(env as Record<string, string>)?.Path ??
|
||||||
|
process.env.PATH ??
|
||||||
|
process.env.Path ??
|
||||||
|
"";
|
||||||
|
return raw.split(path.delimiter).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExecutable(bin: string, env?: Record<string, string>) {
|
||||||
|
if (bin.includes("/") || bin.includes("\\")) return null;
|
||||||
|
const extensions =
|
||||||
|
process.platform === "win32"
|
||||||
|
? (process.env.PATHEXT ?? process.env.PathExt ?? ".EXE;.CMD;.BAT;.COM")
|
||||||
|
.split(";")
|
||||||
|
.map((ext) => ext.toLowerCase())
|
||||||
|
: [""];
|
||||||
|
for (const dir of resolveEnvPath(env)) {
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const candidate = path.join(dir, bin + ext);
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSystemWhich(params: SystemWhichParams, env?: Record<string, string>) {
|
||||||
|
const bins = params.bins
|
||||||
|
.map((bin) => bin.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const found: Record<string, string> = {};
|
||||||
|
for (const bin of bins) {
|
||||||
|
const path = resolveExecutable(bin, env);
|
||||||
|
if (path) found[bin] = path;
|
||||||
|
}
|
||||||
|
return { bins: found };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExecEventPayload(payload: ExecEventPayload): ExecEventPayload {
|
||||||
|
if (!payload.output) return payload;
|
||||||
|
const trimmed = payload.output.trim();
|
||||||
|
if (!trimmed) return payload;
|
||||||
|
const { text } = truncateOutput(trimmed, OUTPUT_EVENT_TAIL);
|
||||||
|
return { ...payload, output: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||||
|
const config = await ensureNodeHostConfig();
|
||||||
|
const nodeId = opts.nodeId?.trim() || config.nodeId;
|
||||||
|
if (nodeId !== config.nodeId) {
|
||||||
|
config.nodeId = nodeId;
|
||||||
|
config.token = undefined;
|
||||||
|
}
|
||||||
|
const displayName =
|
||||||
|
opts.displayName?.trim() || config.displayName || (await getMachineDisplayName());
|
||||||
|
config.displayName = displayName;
|
||||||
|
const gateway: NodeHostGatewayConfig = {
|
||||||
|
host: opts.gatewayHost,
|
||||||
|
port: opts.gatewayPort,
|
||||||
|
tls: opts.gatewayTls === true,
|
||||||
|
tlsFingerprint: opts.gatewayTlsFingerprint,
|
||||||
|
};
|
||||||
|
config.gateway = gateway;
|
||||||
|
await saveNodeHostConfig(config);
|
||||||
|
|
||||||
|
let disconnectResolve: (() => void) | null = null;
|
||||||
|
let disconnectSignal = false;
|
||||||
|
const waitForDisconnect = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (disconnectSignal) {
|
||||||
|
disconnectSignal = false;
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
disconnectResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new BridgeClient({
|
||||||
|
host: gateway.host ?? "127.0.0.1",
|
||||||
|
port: gateway.port ?? 18790,
|
||||||
|
tls: gateway.tls,
|
||||||
|
tlsFingerprint: gateway.tlsFingerprint,
|
||||||
|
nodeId,
|
||||||
|
token: config.token,
|
||||||
|
displayName,
|
||||||
|
platform: process.platform,
|
||||||
|
version: VERSION,
|
||||||
|
deviceFamily: os.platform(),
|
||||||
|
modelIdentifier: os.hostname(),
|
||||||
|
caps: ["system"],
|
||||||
|
commands: ["system.run", "system.which"],
|
||||||
|
onPairToken: async (token) => {
|
||||||
|
config.token = token;
|
||||||
|
await saveNodeHostConfig(config);
|
||||||
|
},
|
||||||
|
onAuthReset: async () => {
|
||||||
|
if (!config.token) return;
|
||||||
|
config.token = undefined;
|
||||||
|
await saveNodeHostConfig(config);
|
||||||
|
},
|
||||||
|
onInvoke: async (frame) => {
|
||||||
|
await handleInvoke(frame, client, skillBins);
|
||||||
|
},
|
||||||
|
onDisconnected: () => {
|
||||||
|
if (disconnectResolve) {
|
||||||
|
disconnectResolve();
|
||||||
|
disconnectResolve = null;
|
||||||
|
} else {
|
||||||
|
disconnectSignal = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const skillBins = new SkillBinsCache(async () => {
|
||||||
|
const res = await client.request("skills.bins", {});
|
||||||
|
const bins = Array.isArray(res?.bins) ? res.bins.map((b) => String(b)) : [];
|
||||||
|
return bins;
|
||||||
|
});
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
await waitForDisconnect();
|
||||||
|
} catch {
|
||||||
|
// ignore connect errors; retry
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInvoke(
|
||||||
|
frame: BridgeInvokeRequestFrame,
|
||||||
|
client: BridgeClient,
|
||||||
|
skillBins: SkillBinsCache,
|
||||||
|
) {
|
||||||
|
const command = String(frame.command ?? "");
|
||||||
|
if (command === "system.which") {
|
||||||
|
try {
|
||||||
|
const params = decodeParams<SystemWhichParams>(frame.paramsJSON);
|
||||||
|
if (!Array.isArray(params.bins)) {
|
||||||
|
throw new Error("INVALID_REQUEST: bins required");
|
||||||
|
}
|
||||||
|
const env = sanitizeEnv(undefined);
|
||||||
|
const payload = await handleSystemWhich(params, env);
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command !== "system.run") {
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "UNAVAILABLE", message: "command not supported" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let params: SystemRunParams;
|
||||||
|
try {
|
||||||
|
params = decodeParams<SystemRunParams>(frame.paramsJSON);
|
||||||
|
} catch (err) {
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(params.command) || params.command.length === 0) {
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "INVALID_REQUEST", message: "command required" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const argv = params.command.map((item) => String(item));
|
||||||
|
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : "";
|
||||||
|
const cmdText = rawCommand || formatCommand(argv);
|
||||||
|
const agentId = params.agentId?.trim() || undefined;
|
||||||
|
const approvals = resolveExecApprovals(agentId);
|
||||||
|
const security = approvals.agent.security;
|
||||||
|
const ask = approvals.agent.ask;
|
||||||
|
const askFallback = approvals.agent.askFallback;
|
||||||
|
const autoAllowSkills = approvals.agent.autoAllowSkills;
|
||||||
|
const sessionKey = params.sessionKey?.trim() || "node";
|
||||||
|
const runId = crypto.randomUUID();
|
||||||
|
const env = sanitizeEnv(params.env ?? undefined);
|
||||||
|
const resolution = resolveCommandResolution(cmdText, params.cwd ?? undefined, env);
|
||||||
|
const allowlistMatch =
|
||||||
|
security === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
|
||||||
|
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
|
||||||
|
const skillAllow =
|
||||||
|
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
|
||||||
|
|
||||||
|
if (security === "deny") {
|
||||||
|
client.sendEvent(
|
||||||
|
"exec.denied",
|
||||||
|
buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason: "security=deny",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiresAsk =
|
||||||
|
ask === "always" ||
|
||||||
|
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow);
|
||||||
|
|
||||||
|
let approvedByAsk = false;
|
||||||
|
if (requiresAsk) {
|
||||||
|
const decision = await requestExecApprovalViaSocket({
|
||||||
|
socketPath: approvals.socketPath,
|
||||||
|
token: approvals.token,
|
||||||
|
request: {
|
||||||
|
command: cmdText,
|
||||||
|
cwd: params.cwd ?? undefined,
|
||||||
|
host: "node",
|
||||||
|
security,
|
||||||
|
ask,
|
||||||
|
agentId,
|
||||||
|
resolvedPath: resolution?.resolvedPath ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (decision === "deny") {
|
||||||
|
client.sendEvent(
|
||||||
|
"exec.denied",
|
||||||
|
buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason: "user-denied",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!decision) {
|
||||||
|
if (askFallback === "full") {
|
||||||
|
approvedByAsk = true;
|
||||||
|
} else if (askFallback === "allowlist") {
|
||||||
|
if (allowlistMatch || skillAllow) {
|
||||||
|
approvedByAsk = true;
|
||||||
|
} else {
|
||||||
|
client.sendEvent(
|
||||||
|
"exec.denied",
|
||||||
|
buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason: "approval-required",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
client.sendEvent(
|
||||||
|
"exec.denied",
|
||||||
|
buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason: "approval-required",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (decision === "allow-once") {
|
||||||
|
approvedByAsk = true;
|
||||||
|
}
|
||||||
|
if (decision === "allow-always") {
|
||||||
|
approvedByAsk = true;
|
||||||
|
if (security === "allowlist") {
|
||||||
|
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
|
||||||
|
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (security === "allowlist" && !allowlistMatch && !skillAllow && !approvedByAsk) {
|
||||||
|
client.sendEvent(
|
||||||
|
"exec.denied",
|
||||||
|
buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason: "allowlist-miss",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowlistMatch) {
|
||||||
|
recordAllowlistUse(approvals.file, agentId, allowlistMatch, cmdText, resolution?.resolvedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.needsScreenRecording === true) {
|
||||||
|
client.sendEvent(
|
||||||
|
"exec.denied",
|
||||||
|
buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
reason: "permission:screenRecording",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.sendEvent(
|
||||||
|
"exec.started",
|
||||||
|
buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runCommand(
|
||||||
|
argv,
|
||||||
|
params.cwd?.trim() || undefined,
|
||||||
|
env,
|
||||||
|
params.timeoutMs ?? undefined,
|
||||||
|
);
|
||||||
|
if (result.truncated) {
|
||||||
|
const suffix = "... (truncated)";
|
||||||
|
if (result.stderr.trim().length > 0) {
|
||||||
|
result.stderr = `${result.stderr}\n${suffix}`;
|
||||||
|
} else {
|
||||||
|
result.stdout = `${result.stdout}\n${suffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
|
||||||
|
client.sendEvent(
|
||||||
|
"exec.finished",
|
||||||
|
buildExecEventPayload({
|
||||||
|
sessionKey,
|
||||||
|
runId,
|
||||||
|
host: "node",
|
||||||
|
command: cmdText,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
timedOut: result.timedOut,
|
||||||
|
success: result.success,
|
||||||
|
output: combined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
timedOut: result.timedOut,
|
||||||
|
success: result.success,
|
||||||
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
error: result.error ?? null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeParams<T>(raw?: string | null): T {
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("INVALID_REQUEST: paramsJSON required");
|
||||||
|
}
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user