From b2b331230b00705f95b8bdf6e18f1aecd38855f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 03:45:03 +0000 Subject: [PATCH] feat: mac node exec policy + remote skills hot reload --- apps/macos/Sources/Clawdbot/AppState.swift | 5 + apps/macos/Sources/Clawdbot/Constants.swift | 3 + .../Sources/Clawdbot/GeneralSettings.swift | 19 ++ .../Sources/Clawdbot/MacNodeConfigFile.swift | 80 +++++++ .../Sources/Clawdbot/MenuContentView.swift | 13 + .../NodeMode/MacNodeModeCoordinator.swift | 15 +- .../Clawdbot/NodeMode/MacNodeRuntime.swift | 44 +++- .../MacNodeRuntimeMainActorServices.swift | 36 +++ .../Sources/Clawdbot/SystemRunPolicy.swift | 89 +++++++ docs/gateway/security.md | 16 ++ docs/nodes/index.md | 1 + docs/platforms/macos.md | 32 +++ docs/start/faq.md | 35 +++ docs/tools/skills-config.md | 8 +- docs/tools/skills.md | 23 ++ src/agents/skills-status.ts | 25 +- src/agents/skills.ts | 1 + src/agents/skills/config.ts | 20 +- src/agents/skills/refresh.ts | 158 ++++++++++++ src/agents/skills/types.ts | 10 + src/agents/skills/workspace.ts | 37 ++- .../reply/commands-context-report.ts | 8 +- src/auto-reply/reply/session-updates.ts | 40 +++- src/commands/agent.ts | 9 +- src/commands/status-all.ts | 2 + src/config/schema.ts | 2 + src/config/sessions/types.ts | 1 + src/config/types.skills.ts | 4 + src/config/zod-schema.ts | 2 + src/cron/isolated-agent/run.ts | 5 + src/gateway/server-bridge-runtime.ts | 12 +- src/gateway/server-methods/skills.ts | 2 + src/gateway/server-node-bridge.ts | 19 ++ src/gateway/server.impl.ts | 13 + src/infra/node-pairing.ts | 2 + src/infra/skills-remote.ts | 226 ++++++++++++++++++ 36 files changed, 977 insertions(+), 40 deletions(-) create mode 100644 apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift create mode 100644 apps/macos/Sources/Clawdbot/SystemRunPolicy.swift create mode 100644 src/agents/skills/refresh.ts create mode 100644 src/infra/skills-remote.ts diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift index ead6406ce..79705aade 100644 --- a/apps/macos/Sources/Clawdbot/AppState.swift +++ b/apps/macos/Sources/Clawdbot/AppState.swift @@ -170,6 +170,10 @@ final class AppState { didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } } + var systemRunPolicy: SystemRunPolicy { + didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } } + } + /// Tracks whether the Canvas panel is currently visible (not persisted). var canvasPanelVisible: Bool = false @@ -292,6 +296,7 @@ final class AppState { self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true + self.systemRunPolicy = SystemRunPolicy.load() self.peekabooBridgeEnabled = UserDefaults.standard .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true if !self.isPreview { diff --git a/apps/macos/Sources/Clawdbot/Constants.swift b/apps/macos/Sources/Clawdbot/Constants.swift index 8f205f566..25f2589e3 100644 --- a/apps/macos/Sources/Clawdbot/Constants.swift +++ b/apps/macos/Sources/Clawdbot/Constants.swift @@ -26,6 +26,9 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot" let remoteCliPathKey = "clawdbot.remoteCliPath" let canvasEnabledKey = "clawdbot.canvasEnabled" let cameraEnabledKey = "clawdbot.cameraEnabled" +let systemRunPolicyKey = "clawdbot.systemRunPolicy" +let systemRunAllowlistKey = "clawdbot.systemRunAllowlist" +let systemRunEnabledKey = "clawdbot.systemRunEnabled" let locationModeKey = "clawdbot.locationMode" let locationPreciseKey = "clawdbot.locationPreciseEnabled" let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled" diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index 37bb8d76b..1e123faf9 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -83,6 +83,25 @@ struct GeneralSettings: View { subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", binding: self.$cameraEnabled) + VStack(alignment: .leading, spacing: 6) { + Text("Node Run Commands") + .font(.body) + + Picker("", selection: self.$state.systemRunPolicy) { + ForEach(SystemRunPolicy.allCases) { policy in + Text(policy.title).tag(policy) + } + } + .pickerStyle(.segmented) + + Text(""" + Controls remote command execution on this Mac when it is paired as a node. "Always Ask" prompts on each command; "Always Allow" runs without prompts; "Never" disables `system.run`. + """) + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + VStack(alignment: .leading, spacing: 6) { Text("Location Access") .font(.body) diff --git a/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift b/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift new file mode 100644 index 000000000..e70deb98f --- /dev/null +++ b/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift @@ -0,0 +1,80 @@ +import Foundation +import OSLog + +enum MacNodeConfigFile { + private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config") + + static func url() -> URL { + ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json") + } + + static func loadDict() -> [String: Any] { + let url = self.url() + guard FileManager.default.fileExists(atPath: url.path) else { return [:] } + do { + let data = try Data(contentsOf: url) + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + self.logger.warning("mac node config JSON root invalid") + return [:] + } + return root + } catch { + self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)") + return [:] + } + } + + static func saveDict(_ dict: [String: Any]) { + do { + let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) + let url = self.url() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + } catch { + self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)") + } + } + + static func systemRunPolicy() -> SystemRunPolicy? { + let root = self.loadDict() + let systemRun = root["systemRun"] as? [String: Any] + let raw = systemRun?["policy"] as? String + guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil } + return policy + } + + static func setSystemRunPolicy(_ policy: SystemRunPolicy) { + var root = self.loadDict() + var systemRun = root["systemRun"] as? [String: Any] ?? [:] + systemRun["policy"] = policy.rawValue + root["systemRun"] = systemRun + self.saveDict(root) + } + + static func systemRunAllowlist() -> [String]? { + let root = self.loadDict() + let systemRun = root["systemRun"] as? [String: Any] + return systemRun?["allowlist"] as? [String] + } + + static func setSystemRunAllowlist(_ allowlist: [String]) { + let cleaned = allowlist + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + var root = self.loadDict() + var systemRun = root["systemRun"] as? [String: Any] ?? [:] + if cleaned.isEmpty { + systemRun.removeValue(forKey: "allowlist") + } else { + systemRun["allowlist"] = cleaned + } + if systemRun.isEmpty { + root.removeValue(forKey: "systemRun") + } else { + root["systemRun"] = systemRun + } + self.saveDict(root) + } +} diff --git a/apps/macos/Sources/Clawdbot/MenuContentView.swift b/apps/macos/Sources/Clawdbot/MenuContentView.swift index 8848475ce..34773bd8b 100644 --- a/apps/macos/Sources/Clawdbot/MenuContentView.swift +++ b/apps/macos/Sources/Clawdbot/MenuContentView.swift @@ -31,6 +31,12 @@ struct MenuContent: View { self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled) } + private var systemRunPolicyBinding: Binding { + Binding( + get: { self.state.systemRunPolicy }, + set: { self.state.systemRunPolicy = $0 }) + } + var body: some View { VStack(alignment: .leading, spacing: 8) { Toggle(isOn: self.activeBinding) { @@ -68,6 +74,13 @@ struct MenuContent: View { Toggle(isOn: self.$cameraEnabled) { Label("Allow Camera", systemImage: "camera") } + Picker(selection: self.systemRunPolicyBinding) { + ForEach(SystemRunPolicy.allCases) { policy in + Text(policy.title).tag(policy) + } + } label: { + Label("Node Run Commands", systemImage: "terminal") + } Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) { Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis") } diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index 808d5ad2e..c7f86d6c9 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -37,6 +37,7 @@ final class MacNodeModeCoordinator { private func run() async { var retryDelay: UInt64 = 1_000_000_000 var lastCameraEnabled: Bool? + var lastSystemRunPolicy: SystemRunPolicy? let defaults = UserDefaults.standard while !Task.isCancelled { if await MainActor.run(body: { AppStateStore.shared.isPaused }) { @@ -53,6 +54,15 @@ final class MacNodeModeCoordinator { try? await Task.sleep(nanoseconds: 200_000_000) } + let systemRunPolicy = SystemRunPolicy.load() + if lastSystemRunPolicy == nil { + lastSystemRunPolicy = systemRunPolicy + } else if lastSystemRunPolicy != systemRunPolicy { + lastSystemRunPolicy = systemRunPolicy + await self.session.disconnect() + try? await Task.sleep(nanoseconds: 200_000_000) + } + guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else { try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000)) retryDelay = min(retryDelay * 2, 10_000_000_000) @@ -143,10 +153,13 @@ final class MacNodeModeCoordinator { ClawdbotCanvasA2UICommand.pushJSONL.rawValue, ClawdbotCanvasA2UICommand.reset.rawValue, MacNodeScreenCommand.record.rawValue, - ClawdbotSystemCommand.run.rawValue, ClawdbotSystemCommand.notify.rawValue, ] + if SystemRunPolicy.load() != .never { + commands.append(ClawdbotSystemCommand.run.rawValue) + } + let capsSet = Set(caps) if capsSet.contains(ClawdbotCapability.camera.rawValue) { commands.append(ClawdbotCameraCommand.list.rawValue) diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index 42605aca8..088aba104 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -426,6 +426,44 @@ actor MacNodeRuntime { return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") } + let wasAllowlisted = SystemRunAllowlist.contains(command) + switch Self.systemRunPolicy() { + case .never: + return Self.errorResponse( + req, + code: .unavailable, + message: "SYSTEM_RUN_DISABLED: policy=never") + case .always: + break + case .ask: + if !wasAllowlisted { + let services = await self.mainActorServices() + let decision = await services.confirmSystemRun( + command: SystemRunAllowlist.displayString(for: command), + cwd: params.cwd) + switch decision { + case .allowOnce: + break + case .allowAlways: + SystemRunAllowlist.add(command) + case .deny: + return Self.errorResponse( + req, + code: .unavailable, + message: "SYSTEM_RUN_DENIED: user denied") + } + } + } + + var env = params.env + if wasAllowlisted, let overrides = env { + var merged = ProcessInfo.processInfo.environment + for (key, value) in overrides where key != "PATH" { + merged[key] = value + } + env = merged + } + if params.needsScreenRecording == true { let authorized = await PermissionManager .status([.screenRecording])[.screenRecording] ?? false @@ -441,7 +479,7 @@ actor MacNodeRuntime { let result = await ShellExecutor.runDetailed( command: command, cwd: params.cwd, - env: params.env, + env: env, timeout: timeoutSec) struct RunPayload: Encodable { @@ -529,6 +567,10 @@ actor MacNodeRuntime { UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false } + private nonisolated static func systemRunPolicy() -> SystemRunPolicy { + SystemRunPolicy.load() + } + private nonisolated static func locationMode() -> ClawdbotLocationMode { let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" return ClawdbotLocationMode(rawValue: raw) ?? .off diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift index a6e03e3e3..5f5008713 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift @@ -1,7 +1,14 @@ +import AppKit import ClawdbotKit import CoreLocation import Foundation +enum SystemRunDecision: Sendable { + case allowOnce + case allowAlways + case deny +} + @MainActor protocol MacNodeRuntimeMainActorServices: Sendable { func recordScreen( @@ -17,6 +24,8 @@ protocol MacNodeRuntimeMainActorServices: Sendable { desiredAccuracy: ClawdbotLocationAccuracy, maxAgeMs: Int?, timeoutMs: Int?) async throws -> CLLocation + + func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision } @MainActor @@ -57,4 +66,31 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices maxAgeMs: maxAgeMs, timeoutMs: timeoutMs) } + + func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Allow this command?" + + var details = "Clawdbot wants to run:\n\n\(command)" + let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedCwd.isEmpty { + details += "\n\nWorking directory:\n\(trimmedCwd)" + } + details += "\n\nThis runs on this Mac via node mode." + alert.informativeText = details + + alert.addButton(withTitle: "Allow Once") + alert.addButton(withTitle: "Always Allow") + alert.addButton(withTitle: "Don't Allow") + + switch alert.runModal() { + case .alertFirstButtonReturn: + return .allowOnce + case .alertSecondButtonReturn: + return .allowAlways + default: + return .deny + } + } } diff --git a/apps/macos/Sources/Clawdbot/SystemRunPolicy.swift b/apps/macos/Sources/Clawdbot/SystemRunPolicy.swift new file mode 100644 index 000000000..aa76eb7a5 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/SystemRunPolicy.swift @@ -0,0 +1,89 @@ +import Foundation + +enum SystemRunPolicy: String, CaseIterable, Identifiable { + case never + case ask + case always + + var id: String { self.rawValue } + + var title: String { + switch self { + case .never: + return "Never" + case .ask: + return "Always Ask" + case .always: + return "Always Allow" + } + } + + static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy { + if let policy = MacNodeConfigFile.systemRunPolicy() { + return policy + } + if let raw = defaults.string(forKey: systemRunPolicyKey), + let policy = SystemRunPolicy(rawValue: raw) + { + MacNodeConfigFile.setSystemRunPolicy(policy) + return policy + } + if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool { + let policy: SystemRunPolicy = legacy ? .ask : .never + MacNodeConfigFile.setSystemRunPolicy(policy) + return policy + } + let fallback: SystemRunPolicy = .ask + MacNodeConfigFile.setSystemRunPolicy(fallback) + return fallback + } +} + +enum SystemRunAllowlist { + static func key(for argv: [String]) -> String { + let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !trimmed.isEmpty else { return "" } + if let data = try? JSONEncoder().encode(trimmed), + let json = String(data: data, encoding: .utf8) + { + return json + } + return trimmed.joined(separator: " ") + } + + static func displayString(for argv: [String]) -> String { + argv.map { arg in + let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "\"\"" } + let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" } + if !needsQuotes { return trimmed } + let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + }.joined(separator: " ") + } + + static func load(from defaults: UserDefaults = .standard) -> Set { + if let allowlist = MacNodeConfigFile.systemRunAllowlist() { + return Set(allowlist) + } + if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty { + MacNodeConfigFile.setSystemRunAllowlist(legacy) + return Set(legacy) + } + return [] + } + + static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool { + let key = key(for: argv) + return load(from: defaults).contains(key) + } + + static func add(_ argv: [String], defaults: UserDefaults = .standard) { + let key = key(for: argv) + guard !key.isEmpty else { return } + var allowlist = load(from: defaults) + if allowlist.insert(key).inserted { + MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted()) + } + } +} diff --git a/docs/gateway/security.md b/docs/gateway/security.md index b086f0cd1..8d32b2b44 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -52,6 +52,22 @@ When the audit prints findings, treat this as a priority order: 5. **Plugins/extensions**: only load what you explicitly trust. 6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools. +## Node execution (system.run) + +If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac: + +- Requires node pairing (approval + token). +- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never". +- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac. + +## Dynamic skills (watcher / remote nodes) + +Clawdbot can refresh the skills list mid-session: +- **Skills watcher**: changes to `SKILL.md` can update the skills snapshot on the next agent turn. +- **Remote nodes**: connecting a macOS node can make macOS-only skills eligible (based on bin probing). + +Treat skill folders as **trusted code** and restrict who can modify them. + ## The Threat Model Your AI assistant can: diff --git a/docs/nodes/index.md b/docs/nodes/index.md index dcbb6f76c..08aa643d9 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -163,6 +163,7 @@ Notes: - `system.notify` respects notification permission state on the macOS app. - `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`. - `system.notify` supports `--priority ` and `--delivery `. +- `system.run` is gated by the macOS app policy (Settings → "Node Run Commands"): "Always Ask" prompts per command, "Always Allow" runs without prompts, and "Never" disables the tool. Denied prompts return `SYSTEM_RUN_DENIED`; disabled returns `SYSTEM_RUN_DISABLED`. ## Permissions map diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 111f9bcc7..81225dc16 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -54,6 +54,38 @@ The macOS app presents itself as a node. Common commands: The node reports a `permissions` map so agents can decide what’s allowed. +## Node run policy + allowlist + +`system.run` is controlled by the macOS app **Node Run Commands** policy: + +- `Always Ask`: prompt per command (default). +- `Always Allow`: run without prompts. +- `Never`: disable `system.run` (tool not advertised). + +The policy + allowlist live on the Mac in: + +``` +~/.clawdbot/macos-node.json +``` + +Schema: + +```json +{ + "systemRun": { + "policy": "ask", + "allowlist": [ + "[\"/bin/echo\",\"hello\"]" + ] + } +} +``` + +Notes: +- `allowlist` entries are JSON-encoded argv arrays. +- Choosing “Always Allow” in the prompt adds that command to the allowlist. +- Allowlisted runs ignore `PATH` overrides; other env vars are merged with the app’s environment. + ## Deep links The app registers the `clawdbot://` URL scheme for local actions. diff --git a/docs/start/faq.md b/docs/start/faq.md index 89526d8c4..332015b1b 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -34,6 +34,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder) - [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks) - [How do I install skills on Linux?](#how-do-i-install-skills-on-linux) + - [Can I run Apple/macOS-only skills from Linux?](#can-i-run-applemacos-only-skills-from-linux) - [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration) - [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover) - [Sandboxing and memory](#sandboxing-and-memory) @@ -399,6 +400,40 @@ npm i -g clawdhub pnpm add -g clawdhub ``` +### Is there a way to run Apple/macOS-only skills if my Gateway runs on Linux? + +Not directly. macOS skills are gated by `metadata.clawdbot.os` plus required binaries, and skills only appear in the system prompt when they are eligible on the **Gateway host**. On Linux, `darwin`-only skills (like `imsg`, `apple-notes`, `apple-reminders`) will not load unless you override the gating. + +You have three supported patterns: + +**Option A - run the Gateway on a Mac (simplest).** +Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS. + +**Option B - use a macOS node (no SSH).** +Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist. + +**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. + +1) Create an SSH wrapper for the binary (example: `imsg`): + ```bash + #!/usr/bin/env bash + set -euo pipefail + exec ssh -T user@mac-host /opt/homebrew/bin/imsg "$@" + ``` +2) Put the wrapper on `PATH` on the Linux host (for example `~/bin/imsg`). +3) Override the skill metadata (workspace or `~/.clawdbot/skills`) to allow Linux: + ```markdown + --- + name: imsg + description: iMessage/SMS CLI for listing chats, history, watch, and sending. + metadata: {"clawdbot":{"os":["darwin","linux"],"requires":{"bins":["imsg"]}}} + --- + ``` +4) Start a new session so the skills snapshot refreshes. + +For iMessage specifically, you can also point `channels.imessage.cliPath` at an SSH wrapper (Clawdbot only needs stdio). See [iMessage](/channels/imessage). + ### Do you have a Notion or HeyGen integration? Not built‑in today. diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index 89d09a35b..64c6cc28b 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -16,7 +16,9 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j extraDirs: [ "~/Projects/agent-scripts/skills", "~/Projects/oss/some-skill-pack/skills" - ] + ], + watch: true, + watchDebounceMs: 250 }, install: { preferBrew: true, @@ -42,6 +44,8 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j - `allowBundled`: optional allowlist for **bundled** skills only. When set, only bundled skills in the list are eligible (managed/workspace skills unaffected). - `load.extraDirs`: additional skill directories to scan (lowest precedence). +- `load.watch`: watch skill folders and refresh the skills snapshot (default: true). +- `load.watchDebounceMs`: debounce for skill watcher events in milliseconds (default: 250). - `install.preferBrew`: prefer brew installers when available (default: true). - `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn` | `bun`, default: npm). This only affects **skill installs**; the Gateway runtime should still be Node @@ -57,4 +61,4 @@ Per-skill fields: - Keys under `entries` map to the skill name by default. If a skill defines `metadata.clawdbot.skillKey`, use that key instead. -- Changes to skills are picked up on the next new session. +- Changes to skills are picked up on the next agent turn when the watcher is enabled. diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 424251761..a7aeb43f7 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -181,6 +181,29 @@ This is **scoped to the agent run**, not a global shell environment. Clawdbot snapshots the eligible skills **when a session starts** and reuses that list for subsequent turns in the same session. Changes to skills or config take effect on the next new session. +Skills can also refresh mid-session when the skills watcher is enabled or when a new eligible remote node appears (see below). Think of this as a **hot reload**: the refreshed list is picked up on the next agent turn. + +## 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`). + +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. + +## Skills watcher (auto-refresh) + +By default, Clawdbot watches skill folders and bumps the skills snapshot when `SKILL.md` files change. Configure this under `skills.load`: + +```json5 +{ + skills: { + load: { + watch: true, + watchDebounceMs: 250 + } + } +} +``` + ## Token impact (skills list) When skills are eligible, Clawdbot injects a compact XML list of available skills into the system prompt (via `formatSkillsForPrompt` in `pi-coding-agent`). The cost is deterministic: diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index e618b0c47..ae876dd57 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -12,6 +12,7 @@ import { resolveSkillConfig, resolveSkillsInstallPreferences, type SkillEntry, + type SkillEligibilityContext, type SkillInstallSpec, type SkillsInstallPreferences, } from "./skills.js"; @@ -135,6 +136,7 @@ function buildSkillStatus( entry: SkillEntry, config?: ClawdbotConfig, prefs?: SkillsInstallPreferences, + eligibility?: SkillEligibilityContext, ): SkillStatusEntry { const skillKey = resolveSkillKey(entry); const skillConfig = resolveSkillConfig(config, skillKey); @@ -156,13 +158,25 @@ function buildSkillStatus( const requiredConfig = entry.clawdbot?.requires?.config ?? []; const requiredOs = entry.clawdbot?.os ?? []; - const missingBins = requiredBins.filter((bin) => !hasBinary(bin)); + const missingBins = requiredBins.filter((bin) => { + if (hasBinary(bin)) return false; + if (eligibility?.remote?.hasBin?.(bin)) return false; + return true; + }); const missingAnyBins = - requiredAnyBins.length > 0 && !requiredAnyBins.some((bin) => hasBinary(bin)) + requiredAnyBins.length > 0 && + !( + requiredAnyBins.some((bin) => hasBinary(bin)) || + eligibility?.remote?.hasAnyBin?.(requiredAnyBins) + ) ? requiredAnyBins : []; const missingOs = - requiredOs.length > 0 && !requiredOs.includes(process.platform) ? requiredOs : []; + requiredOs.length > 0 && + !requiredOs.includes(process.platform) && + !eligibility?.remote?.platforms?.some((platform) => requiredOs.includes(platform)) + ? requiredOs + : []; const missingEnv: string[] = []; for (const envName of requiredEnv) { @@ -233,6 +247,7 @@ export function buildWorkspaceSkillStatus( config?: ClawdbotConfig; managedSkillsDir?: string; entries?: SkillEntry[]; + eligibility?: SkillEligibilityContext; }, ): SkillStatusReport { const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); @@ -241,6 +256,8 @@ export function buildWorkspaceSkillStatus( return { workspaceDir, managedSkillsDir, - skills: skillEntries.map((entry) => buildSkillStatus(entry, opts?.config, prefs)), + skills: skillEntries.map((entry) => + buildSkillStatus(entry, opts?.config, prefs, opts?.eligibility), + ), }; } diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 7ef5cfe16..034f70865 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -15,6 +15,7 @@ export { } from "./skills/env-overrides.js"; export type { ClawdbotSkillMetadata, + SkillEligibilityContext, SkillEntry, SkillInstallSpec, SkillSnapshot, diff --git a/src/agents/skills/config.ts b/src/agents/skills/config.ts index 3bf513172..a0dab4125 100644 --- a/src/agents/skills/config.ts +++ b/src/agents/skills/config.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { ClawdbotConfig, SkillConfig } from "../../config/config.js"; import { resolveSkillKey } from "./frontmatter.js"; -import type { SkillEntry } from "./types.js"; +import type { SkillEligibilityContext, SkillEntry } from "./types.js"; const DEFAULT_CONFIG_VALUES: Record = { "browser.enabled": true, @@ -89,16 +89,22 @@ export function hasBinary(bin: string): boolean { export function shouldIncludeSkill(params: { entry: SkillEntry; config?: ClawdbotConfig; + eligibility?: SkillEligibilityContext; }): boolean { - const { entry, config } = params; + const { entry, config, eligibility } = params; const skillKey = resolveSkillKey(entry.skill, entry); const skillConfig = resolveSkillConfig(config, skillKey); const allowBundled = normalizeAllowlist(config?.skills?.allowBundled); const osList = entry.clawdbot?.os ?? []; + const remotePlatforms = eligibility?.remote?.platforms ?? []; if (skillConfig?.enabled === false) return false; if (!isBundledSkillAllowed(entry, allowBundled)) return false; - if (osList.length > 0 && !osList.includes(resolveRuntimePlatform())) { + if ( + osList.length > 0 && + !osList.includes(resolveRuntimePlatform()) && + !remotePlatforms.some((platform) => osList.includes(platform)) + ) { return false; } if (entry.clawdbot?.always === true) { @@ -108,12 +114,16 @@ export function shouldIncludeSkill(params: { const requiredBins = entry.clawdbot?.requires?.bins ?? []; if (requiredBins.length > 0) { for (const bin of requiredBins) { - if (!hasBinary(bin)) return false; + if (hasBinary(bin)) continue; + if (eligibility?.remote?.hasBin?.(bin)) continue; + return false; } } const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? []; if (requiredAnyBins.length > 0) { - const anyFound = requiredAnyBins.some((bin) => hasBinary(bin)); + const anyFound = + requiredAnyBins.some((bin) => hasBinary(bin)) || + eligibility?.remote?.hasAnyBin?.(requiredAnyBins); if (!anyFound) return false; } diff --git a/src/agents/skills/refresh.ts b/src/agents/skills/refresh.ts new file mode 100644 index 000000000..6e85bdb57 --- /dev/null +++ b/src/agents/skills/refresh.ts @@ -0,0 +1,158 @@ +import path from "node:path"; + +import chokidar, { type FSWatcher } from "chokidar"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { createSubsystemLogger } from "../../logging.js"; +import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; + +type SkillsChangeEvent = { + workspaceDir?: string; + reason: "watch" | "manual" | "remote-node"; + changedPath?: string; +}; + +type SkillsWatchState = { + watcher: FSWatcher; + pathsKey: string; + debounceMs: number; + timer?: ReturnType; + pendingPath?: string; +}; + +const log = createSubsystemLogger("gateway/skills"); +const listeners = new Set<(event: SkillsChangeEvent) => void>(); +const workspaceVersions = new Map(); +const watchers = new Map(); +let globalVersion = 0; + +function bumpVersion(current: number): number { + const now = Date.now(); + return now <= current ? current + 1 : now; +} + +function emit(event: SkillsChangeEvent) { + for (const listener of listeners) { + try { + listener(event); + } catch (err) { + log.warn(`skills change listener failed: ${String(err)}`); + } + } +} + +function resolveWatchPaths(workspaceDir: string, config?: ClawdbotConfig): string[] { + const paths: string[] = []; + if (workspaceDir.trim()) { + paths.push(path.join(workspaceDir, "skills")); + } + paths.push(path.join(CONFIG_DIR, "skills")); + const extraDirsRaw = config?.skills?.load?.extraDirs ?? []; + const extraDirs = extraDirsRaw + .map((d) => (typeof d === "string" ? d.trim() : "")) + .filter(Boolean) + .map((dir) => resolveUserPath(dir)); + paths.push(...extraDirs); + return paths; +} + +export function registerSkillsChangeListener(listener: (event: SkillsChangeEvent) => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function bumpSkillsSnapshotVersion(params?: { + workspaceDir?: string; + reason?: SkillsChangeEvent["reason"]; + changedPath?: string; +}): number { + const reason = params?.reason ?? "manual"; + const changedPath = params?.changedPath; + if (params?.workspaceDir) { + const current = workspaceVersions.get(params.workspaceDir) ?? 0; + const next = bumpVersion(current); + workspaceVersions.set(params.workspaceDir, next); + emit({ workspaceDir: params.workspaceDir, reason, changedPath }); + return next; + } + globalVersion = bumpVersion(globalVersion); + emit({ reason, changedPath }); + return globalVersion; +} + +export function getSkillsSnapshotVersion(workspaceDir?: string): number { + if (!workspaceDir) return globalVersion; + const local = workspaceVersions.get(workspaceDir) ?? 0; + return Math.max(globalVersion, local); +} + +export function ensureSkillsWatcher(params: { + workspaceDir: string; + config?: ClawdbotConfig; +}) { + const workspaceDir = params.workspaceDir.trim(); + if (!workspaceDir) return; + const watchEnabled = params.config?.skills?.load?.watch !== false; + const debounceMsRaw = params.config?.skills?.load?.watchDebounceMs; + const debounceMs = + typeof debounceMsRaw === "number" && Number.isFinite(debounceMsRaw) + ? Math.max(0, debounceMsRaw) + : 250; + + const existing = watchers.get(workspaceDir); + if (!watchEnabled) { + if (existing) { + watchers.delete(workspaceDir); + existing.timer && clearTimeout(existing.timer); + void existing.watcher.close().catch(() => {}); + } + return; + } + + const watchPaths = resolveWatchPaths(workspaceDir, params.config); + const pathsKey = watchPaths.join("|"); + if (existing && existing.pathsKey === pathsKey && existing.debounceMs === debounceMs) { + return; + } + if (existing) { + watchers.delete(workspaceDir); + existing.timer && clearTimeout(existing.timer); + void existing.watcher.close().catch(() => {}); + } + + const watcher = chokidar.watch(watchPaths, { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: debounceMs, + pollInterval: 100, + }, + }); + + const state: SkillsWatchState = { watcher, pathsKey, debounceMs }; + + const schedule = (changedPath?: string) => { + state.pendingPath = changedPath ?? state.pendingPath; + if (state.timer) clearTimeout(state.timer); + state.timer = setTimeout(() => { + const pendingPath = state.pendingPath; + state.pendingPath = undefined; + state.timer = undefined; + bumpSkillsSnapshotVersion({ + workspaceDir, + reason: "watch", + changedPath: pendingPath, + }); + }, debounceMs); + }; + + watcher.on("add", (p) => schedule(p)); + watcher.on("change", (p) => schedule(p)); + watcher.on("unlink", (p) => schedule(p)); + watcher.on("error", (err) => { + log.warn(`skills watcher error (${workspaceDir}): ${String(err)}`); + }); + + watchers.set(workspaceDir, state); +} diff --git a/src/agents/skills/types.ts b/src/agents/skills/types.ts index e4602330d..d3914f940 100644 --- a/src/agents/skills/types.ts +++ b/src/agents/skills/types.ts @@ -39,8 +39,18 @@ export type SkillEntry = { clawdbot?: ClawdbotSkillMetadata; }; +export type SkillEligibilityContext = { + remote?: { + platforms: string[]; + hasBin: (bin: string) => boolean; + hasAnyBin: (bins: string[]) => boolean; + note?: string; + }; +}; + export type SkillSnapshot = { prompt: string; skills: Array<{ name: string; primaryEnv?: string }>; resolvedSkills?: Skill[]; + version?: number; }; diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index 22e7bac8b..c6573b769 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -13,7 +13,12 @@ import { resolveBundledSkillsDir } from "./bundled-dir.js"; import { shouldIncludeSkill } from "./config.js"; import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js"; import { serializeByKey } from "./serialize.js"; -import type { ParsedSkillFrontmatter, SkillEntry, SkillSnapshot } from "./types.js"; +import type { + ParsedSkillFrontmatter, + SkillEligibilityContext, + SkillEntry, + SkillSnapshot, +} from "./types.js"; const fsp = fs.promises; @@ -21,8 +26,9 @@ function filterSkillEntries( entries: SkillEntry[], config?: ClawdbotConfig, skillFilter?: string[], + eligibility?: SkillEligibilityContext, ): SkillEntry[] { - let filtered = entries.filter((entry) => shouldIncludeSkill({ entry, config })); + let filtered = entries.filter((entry) => shouldIncludeSkill({ entry, config, eligibility })); // If skillFilter is provided, only include skills in the filter list. if (skillFilter !== undefined) { const normalized = skillFilter.map((entry) => String(entry).trim()).filter(Boolean); @@ -122,18 +128,28 @@ export function buildWorkspaceSkillSnapshot( entries?: SkillEntry[]; /** If provided, only include skills with these names */ skillFilter?: string[]; + eligibility?: SkillEligibilityContext; + snapshotVersion?: number; }, ): SkillSnapshot { const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); - const eligible = filterSkillEntries(skillEntries, opts?.config, opts?.skillFilter); + const eligible = filterSkillEntries( + skillEntries, + opts?.config, + opts?.skillFilter, + opts?.eligibility, + ); const resolvedSkills = eligible.map((entry) => entry.skill); + const remoteNote = opts?.eligibility?.remote?.note?.trim(); + const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n"); return { - prompt: formatSkillsForPrompt(resolvedSkills), + prompt, skills: eligible.map((entry) => ({ name: entry.skill.name, primaryEnv: entry.clawdbot?.primaryEnv, })), resolvedSkills, + version: opts?.snapshotVersion, }; } @@ -146,11 +162,20 @@ export function buildWorkspaceSkillsPrompt( entries?: SkillEntry[]; /** If provided, only include skills with these names */ skillFilter?: string[]; + eligibility?: SkillEligibilityContext; }, ): string { const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); - const eligible = filterSkillEntries(skillEntries, opts?.config, opts?.skillFilter); - return formatSkillsForPrompt(eligible.map((entry) => entry.skill)); + const eligible = filterSkillEntries( + skillEntries, + opts?.config, + opts?.skillFilter, + opts?.eligibility, + ); + const remoteNote = opts?.eligibility?.remote?.note?.trim(); + return [remoteNote, formatSkillsForPrompt(eligible.map((entry) => entry.skill))] + .filter(Boolean) + .join("\n"); } export function resolveSkillsPromptForRun(params: { diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index a10c1f4fa..d8555d13c 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -5,6 +5,7 @@ import { import { createClawdbotCodingTools } from "../../agents/pi-tools.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; +import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import { buildAgentSystemPrompt } from "../../agents/system-prompt.js"; import { buildSystemPromptReport } from "../../agents/system-prompt-report.js"; import { buildToolSummaryMap } from "../../agents/tool-summaries.js"; @@ -13,6 +14,7 @@ import { loadWorkspaceBootstrapFiles, } from "../../agents/workspace.js"; import type { SessionSystemPromptReport } from "../../config/sessions/types.js"; +import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import type { ReplyPayload } from "../types.js"; import type { HandleCommandsParams } from "./commands-types.js"; @@ -62,7 +64,11 @@ async function resolveContextReport( }); const skillsSnapshot = (() => { try { - return buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg }); + return buildWorkspaceSkillSnapshot(workspaceDir, { + config: params.cfg, + eligibility: { remote: getRemoteSkillEligibility() }, + snapshotVersion: getSkillsSnapshotVersion(workspaceDir), + }); } catch { return { prompt: "", skills: [], resolvedSkills: [] }; } diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index c9480d846..e04a31453 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -1,9 +1,14 @@ import crypto from "node:crypto"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; +import { + ensureSkillsWatcher, + getSkillsSnapshotVersion, +} from "../../agents/skills/refresh.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; import { buildChannelSummary } from "../../infra/channel-summary.js"; +import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import { drainSystemEventEntries } from "../../infra/system-events.js"; export async function prependSystemEvents(params: { @@ -88,6 +93,11 @@ export async function ensureSkillSnapshot(params: { let nextEntry = sessionEntry; let systemSent = sessionEntry?.systemSent ?? false; + const remoteEligibility = getRemoteSkillEligibility(); + const snapshotVersion = getSkillsSnapshotVersion(workspaceDir); + ensureSkillsWatcher({ workspaceDir, config: cfg }); + const shouldRefreshSnapshot = + snapshotVersion > 0 && (nextEntry?.skillsSnapshot?.version ?? 0) < snapshotVersion; if (isFirstTurnInSession && sessionStore && sessionKey) { const current = nextEntry ?? @@ -96,10 +106,12 @@ export async function ensureSkillSnapshot(params: { updatedAt: Date.now(), }; const skillSnapshot = - isFirstTurnInSession || !current.skillsSnapshot + isFirstTurnInSession || !current.skillsSnapshot || shouldRefreshSnapshot ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg, skillFilter, + eligibility: { remote: remoteEligibility }, + snapshotVersion, }) : current.skillsSnapshot; nextEntry = { @@ -118,20 +130,28 @@ export async function ensureSkillSnapshot(params: { systemSent = true; } - const skillsSnapshot = - nextEntry?.skillsSnapshot ?? - (isFirstTurnInSession - ? undefined - : buildWorkspaceSkillSnapshot(workspaceDir, { - config: cfg, - skillFilter, - })); + const skillsSnapshot = shouldRefreshSnapshot + ? buildWorkspaceSkillSnapshot(workspaceDir, { + config: cfg, + skillFilter, + eligibility: { remote: remoteEligibility }, + snapshotVersion, + }) + : nextEntry?.skillsSnapshot ?? + (isFirstTurnInSession + ? undefined + : buildWorkspaceSkillSnapshot(workspaceDir, { + config: cfg, + skillFilter, + eligibility: { remote: remoteEligibility }, + snapshotVersion, + })); if ( skillsSnapshot && sessionStore && sessionKey && !isFirstTurnInSession && - !nextEntry?.skillsSnapshot + (!nextEntry?.skillsSnapshot || shouldRefreshSnapshot) ) { const current = nextEntry ?? { sessionId: sessionId ?? crypto.randomUUID(), diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 42c3f7bf2..231bbdfe4 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -19,6 +19,7 @@ import { } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; +import { getSkillsSnapshotVersion } from "../agents/skills/refresh.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { ensureAgentWorkspace } from "../agents/workspace.js"; import { @@ -43,6 +44,7 @@ import { emitAgentEvent, registerAgentRunContext, } from "../infra/agent-events.js"; +import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; @@ -157,8 +159,13 @@ export async function agentCommand( } const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot; + const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir); const skillsSnapshot = needsSkillsSnapshot - ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }) + ? buildWorkspaceSkillSnapshot(workspaceDir, { + config: cfg, + eligibility: { remote: getRemoteSkillEligibility() }, + snapshotVersion: skillsSnapshotVersion, + }) : sessionEntry?.skillsSnapshot; if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) { diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 0cbee54db..f32fbabed 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -13,6 +13,7 @@ import { inspectPortUsage } from "../infra/ports.js"; import { readRestartSentinel } from "../infra/restart-sentinel.js"; import { readTailscaleStatusJson } from "../infra/tailscale.js"; import { checkUpdateStatus, compareSemverStrings } from "../infra/update-check.js"; +import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { VERSION } from "../version.js"; @@ -217,6 +218,7 @@ export async function statusAllCommand( try { return buildWorkspaceSkillStatus(defaultWorkspace, { config: cfg, + eligibility: { remote: getRemoteSkillEligibility() }, }); } catch { return null; diff --git a/src/config/schema.ts b/src/config/schema.ts index adc17721f..74dd93504 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -120,6 +120,8 @@ const FIELD_LABELS: Record = { "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", "gateway.reload.mode": "Config Reload Mode", "gateway.reload.debounceMs": "Config Reload Debounce (ms)", + "skills.load.watch": "Watch Skills", + "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "agents.defaults.workspace": "Workspace", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", "agents.defaults.memorySearch": "Memory Search", diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 1ca929995..ba09f4ca5 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -95,6 +95,7 @@ export type SessionSkillSnapshot = { prompt: string; skills: Array<{ name: string; primaryEnv?: string }>; resolvedSkills?: Skill[]; + version?: number; }; export type SessionSystemPromptReport = { diff --git a/src/config/types.skills.ts b/src/config/types.skills.ts index 0bb19bed3..26e6d28e4 100644 --- a/src/config/types.skills.ts +++ b/src/config/types.skills.ts @@ -11,6 +11,10 @@ export type SkillsLoadConfig = { * Each directory should contain skill subfolders with `SKILL.md`. */ extraDirs?: string[]; + /** Watch skill folders for changes and refresh the skills snapshot. */ + watch?: boolean; + /** Debounce for the skills watcher (ms). */ + watchDebounceMs?: number; }; export type SkillsInstallConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 0051bcae3..a50c420ba 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -270,6 +270,8 @@ export const ClawdbotSchema = z load: z .object({ extraDirs: z.array(z.string()).optional(), + watch: z.boolean().optional(), + watchDebounceMs: z.number().int().min(0).optional(), }) .optional(), install: z diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 6e1128bb0..65e8ccde7 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -20,6 +20,7 @@ import { } from "../../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; +import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { hasNonzeroUsage } from "../../agents/usage.js"; import { ensureAgentWorkspace } from "../../agents/workspace.js"; @@ -34,6 +35,7 @@ import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/s import type { AgentDefaultsConfig } from "../../config/types.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; +import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js"; import type { CronJob } from "../types.js"; import { resolveDeliveryTarget } from "./delivery-target.js"; @@ -205,9 +207,12 @@ export async function runCronIsolatedAgentTurn(params: { const commandBody = base; const needsSkillsSnapshot = cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot; + const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir); const skillsSnapshot = needsSkillsSnapshot ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfgWithAgentDefaults, + eligibility: { remote: getRemoteSkillEligibility() }, + snapshotVersion: skillsSnapshotVersion, }) : cronSession.sessionEntry.skillsSnapshot; if (needsSkillsSnapshot && skillsSnapshot) { diff --git a/src/gateway/server-bridge-runtime.ts b/src/gateway/server-bridge-runtime.ts index 0df120c18..1aa15dbe2 100644 --- a/src/gateway/server-bridge-runtime.ts +++ b/src/gateway/server-bridge-runtime.ts @@ -3,6 +3,7 @@ import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server. import { startCanvasHost } from "../canvas-host/server.js"; import type { CliDeps } from "../cli/deps.js"; import type { HealthSummary } from "../commands/health.js"; +import type { ClawdbotConfig } from "../config/config.js"; import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort } from "../config/port-defaults.js"; import type { NodeBridgeServer } from "../infra/bridge/server.js"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; @@ -33,15 +34,7 @@ export type GatewayBridgeRuntime = { }; export async function startGatewayBridgeRuntime(params: { - cfg: { - bridge?: { - enabled?: boolean; - port?: number; - bind?: "loopback" | "lan" | "auto" | "custom"; - }; - canvasHost?: { port?: number; root?: string; liveReload?: boolean }; - discovery?: { wideArea?: { enabled?: boolean } }; - }; + cfg: ClawdbotConfig; port: number; canvasHostEnabled: boolean; canvasHost: CanvasHostHandler | null; @@ -200,6 +193,7 @@ export async function startGatewayBridgeRuntime(params: { : undefined; const bridgeRuntime = await startGatewayNodeBridge({ + cfg: params.cfg, bridgeEnabled, bridgePort, bridgeHost, diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 3900958c7..d6767353e 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -3,6 +3,7 @@ import { installSkill } from "../../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { loadConfig, writeConfigFile } from "../../config/config.js"; +import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import { ErrorCodes, errorShape, @@ -30,6 +31,7 @@ export const skillsHandlers: GatewayRequestHandlers = { const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg, + eligibility: { remote: getRemoteSkillEligibility() }, }); respond(true, report, undefined); }, diff --git a/src/gateway/server-node-bridge.ts b/src/gateway/server-node-bridge.ts index be69c2c25..d07c847ad 100644 --- a/src/gateway/server-node-bridge.ts +++ b/src/gateway/server-node-bridge.ts @@ -1,5 +1,8 @@ import type { NodeBridgeServer } from "../infra/bridge/server.js"; import { startNodeBridgeServer } from "../infra/bridge/server.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js"; +import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../infra/skills-remote.js"; import { listSystemPresence, upsertPresence } from "../infra/system-presence.js"; import { loadVoiceWakeConfig } from "../infra/voicewake.js"; import { isLoopbackAddress } from "./net.js"; @@ -16,6 +19,7 @@ export type GatewayNodeBridgeRuntime = { }; export async function startGatewayNodeBridge(params: { + cfg: ClawdbotConfig; bridgeEnabled: boolean; bridgePort: number; bridgeHost: string | null; @@ -114,6 +118,21 @@ export async function startGatewayNodeBridge(params: { onAuthenticated: async (node) => { beaconNodePresence(node, "node-connected"); startNodePresenceTimer(node); + recordRemoteNodeInfo({ + nodeId: node.nodeId, + displayName: node.displayName, + platform: node.platform, + deviceFamily: node.deviceFamily, + commands: node.commands, + }); + bumpSkillsSnapshotVersion({ reason: "remote-node" }); + await refreshRemoteNodeBins({ + nodeId: node.nodeId, + platform: node.platform, + deviceFamily: node.deviceFamily, + commands: node.commands, + cfg: params.cfg, + }); try { const cfg = await loadVoiceWakeConfig(); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index a59018b29..2e8be05a5 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1,5 +1,6 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { initSubagentRegistry } from "../agents/subagent-registry.js"; +import { registerSkillsChangeListener } from "../agents/skills/refresh.js"; import type { CanvasHostServer } from "../canvas-host/server.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { createDefaultDeps } from "../cli/deps.js"; @@ -16,6 +17,11 @@ import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; +import { + primeRemoteSkillsCache, + refreshRemoteBinsForConnectedNodes, + setSkillsRemoteBridge, +} from "../infra/skills-remote.js"; import { autoMigrateLegacyState } from "../infra/state-migrations.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging.js"; import type { PluginServicesHandle } from "../plugins/services.js"; @@ -288,6 +294,13 @@ export async function startGatewayServer( const bridgeSendToAllSubscribed = bridgeRuntime.bridgeSendToAllSubscribed; const broadcastVoiceWakeChanged = bridgeRuntime.broadcastVoiceWakeChanged; + setSkillsRemoteBridge(bridge); + void primeRemoteSkillsCache(); + registerSkillsChangeListener(() => { + const latest = loadConfig(); + void refreshRemoteBinsForConnectedNodes(latest); + }); + const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({ broadcast, bridgeSendToAllSubscribed, diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index a8d98025d..eac680316 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -30,6 +30,7 @@ export type NodePairingPairedNode = { modelIdentifier?: string; caps?: string[]; commands?: string[]; + bins?: string[]; permissions?: Record; remoteIp?: string; createdAtMs: number; @@ -272,6 +273,7 @@ export async function updatePairedNodeMetadata( remoteIp: patch.remoteIp ?? existing.remoteIp, caps: patch.caps ?? existing.caps, commands: patch.commands ?? existing.commands, + bins: patch.bins ?? existing.bins, permissions: patch.permissions ?? existing.permissions, }; diff --git a/src/infra/skills-remote.ts b/src/infra/skills-remote.ts new file mode 100644 index 000000000..0fe6209d2 --- /dev/null +++ b/src/infra/skills-remote.ts @@ -0,0 +1,226 @@ +import type { SkillEligibilityContext, SkillEntry } from "../agents/skills.js"; +import { loadWorkspaceSkillEntries } from "../agents/skills.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { NodeBridgeServer } from "./bridge/server.js"; +import { listNodePairing, updatePairedNodeMetadata } from "./node-pairing.js"; +import { createSubsystemLogger } from "../logging.js"; +import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js"; + +type RemoteNodeRecord = { + nodeId: string; + displayName?: string; + platform?: string; + deviceFamily?: string; + commands?: string[]; + bins: Set; +}; + +const log = createSubsystemLogger("gateway/skills-remote"); +const remoteNodes = new Map(); +let remoteBridge: NodeBridgeServer | null = null; + +function isMacPlatform(platform?: string, deviceFamily?: string): boolean { + const platformNorm = String(platform ?? "").trim().toLowerCase(); + const familyNorm = String(deviceFamily ?? "").trim().toLowerCase(); + if (platformNorm.includes("mac")) return true; + if (platformNorm.includes("darwin")) return true; + if (familyNorm === "mac") return true; + return false; +} + +function supportsSystemRun(commands?: string[]): boolean { + return Array.isArray(commands) && commands.includes("system.run"); +} + +function upsertNode(record: { + nodeId: string; + displayName?: string; + platform?: string; + deviceFamily?: string; + commands?: string[]; + bins?: string[]; +}) { + const existing = remoteNodes.get(record.nodeId); + const bins = new Set(record.bins ?? existing?.bins ?? []); + remoteNodes.set(record.nodeId, { + nodeId: record.nodeId, + displayName: record.displayName ?? existing?.displayName, + platform: record.platform ?? existing?.platform, + deviceFamily: record.deviceFamily ?? existing?.deviceFamily, + commands: record.commands ?? existing?.commands, + bins, + }); +} + +export function setSkillsRemoteBridge(bridge: NodeBridgeServer | null) { + remoteBridge = bridge; +} + +export async function primeRemoteSkillsCache() { + try { + const list = await listNodePairing(); + let sawMac = false; + for (const node of list.paired) { + upsertNode({ + nodeId: node.nodeId, + displayName: node.displayName, + platform: node.platform, + deviceFamily: node.deviceFamily, + commands: node.commands, + bins: node.bins, + }); + if (isMacPlatform(node.platform, node.deviceFamily) && supportsSystemRun(node.commands)) { + sawMac = true; + } + } + if (sawMac) { + bumpSkillsSnapshotVersion({ reason: "remote-node" }); + } + } catch (err) { + log.warn(`failed to prime remote skills cache: ${String(err)}`); + } +} + +export function recordRemoteNodeInfo(node: { + nodeId: string; + displayName?: string; + platform?: string; + deviceFamily?: string; + commands?: string[]; +}) { + upsertNode(node); +} + +export function recordRemoteNodeBins(nodeId: string, bins: string[]) { + upsertNode({ nodeId, bins }); +} + +function listWorkspaceDirs(cfg: ClawdbotConfig): string[] { + const dirs = new Set(); + const list = cfg.agents?.list; + if (Array.isArray(list)) { + for (const entry of list) { + if (entry && typeof entry === "object" && typeof entry.id === "string") { + dirs.add(resolveAgentWorkspaceDir(cfg, entry.id)); + } + } + } + dirs.add(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg))); + return [...dirs]; +} + +function collectRequiredBins(entries: SkillEntry[], targetPlatform: string): string[] { + const bins = new Set(); + for (const entry of entries) { + const os = entry.clawdbot?.os ?? []; + if (os.length > 0 && !os.includes(targetPlatform)) continue; + const required = entry.clawdbot?.requires?.bins ?? []; + const anyBins = entry.clawdbot?.requires?.anyBins ?? []; + for (const bin of required) { + if (bin.trim()) bins.add(bin.trim()); + } + for (const bin of anyBins) { + if (bin.trim()) bins.add(bin.trim()); + } + } + return [...bins]; +} + +function buildBinProbeScript(bins: string[]): string { + const escaped = bins.map((bin) => `'${bin.replace(/'/g, `'\\''`)}'`).join(" "); + return `for b in ${escaped}; do if command -v "$b" >/dev/null 2>&1; then echo "$b"; fi; done`; +} + +export async function refreshRemoteNodeBins(params: { + nodeId: string; + platform?: string; + deviceFamily?: string; + commands?: string[]; + cfg: ClawdbotConfig; + timeoutMs?: number; +}) { + if (!remoteBridge) return; + if (!isMacPlatform(params.platform, params.deviceFamily)) return; + if (!supportsSystemRun(params.commands)) return; + + const workspaceDirs = listWorkspaceDirs(params.cfg); + const requiredBins = new Set(); + for (const workspaceDir of workspaceDirs) { + const entries = loadWorkspaceSkillEntries(workspaceDir, { config: params.cfg }); + for (const bin of collectRequiredBins(entries, "darwin")) { + requiredBins.add(bin); + } + } + if (requiredBins.size === 0) return; + + const script = buildBinProbeScript([...requiredBins]); + const payload = { + command: ["/bin/sh", "-lc", script], + }; + try { + const res = await remoteBridge.invoke({ + nodeId: params.nodeId, + command: "system.run", + paramsJSON: JSON.stringify(payload), + timeoutMs: params.timeoutMs ?? 15_000, + }); + if (!res.ok) { + log.warn(`remote bin probe failed (${params.nodeId}): ${res.error?.message ?? "unknown"}`); + return; + } + const raw = typeof res.payloadJSON === "string" ? res.payloadJSON : ""; + const parsed = + raw && raw.trim().length > 0 + ? (JSON.parse(raw) as { stdout?: string }) + : ({ stdout: "" } as { stdout?: string }); + const stdout = typeof parsed.stdout === "string" ? parsed.stdout : ""; + const bins = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + recordRemoteNodeBins(params.nodeId, bins); + await updatePairedNodeMetadata(params.nodeId, { bins }); + bumpSkillsSnapshotVersion({ reason: "remote-node" }); + } catch (err) { + log.warn(`remote bin probe error (${params.nodeId}): ${String(err)}`); + } +} + +export function getRemoteSkillEligibility(): SkillEligibilityContext["remote"] | undefined { + const macNodes = [...remoteNodes.values()].filter( + (node) => isMacPlatform(node.platform, node.deviceFamily) && supportsSystemRun(node.commands), + ); + if (macNodes.length === 0) return undefined; + const bins = new Set(); + for (const node of macNodes) { + for (const bin of node.bins) bins.add(bin); + } + const labels = macNodes + .map((node) => node.displayName ?? node.nodeId) + .filter(Boolean); + const note = + labels.length > 0 + ? `Remote macOS node available (${labels.join(", ")}). Run macOS-only skills via nodes.run on that node.` + : "Remote macOS node available. Run macOS-only skills via nodes.run on that node."; + return { + platforms: ["darwin"], + hasBin: (bin) => bins.has(bin), + hasAnyBin: (required) => required.some((bin) => bins.has(bin)), + note, + }; +} + +export async function refreshRemoteBinsForConnectedNodes(cfg: ClawdbotConfig) { + if (!remoteBridge) return; + const connected = remoteBridge.listConnected(); + for (const node of connected) { + await refreshRemoteNodeBins({ + nodeId: node.nodeId, + platform: node.platform, + deviceFamily: node.deviceFamily, + commands: node.commands, + cfg, + }); + } +}