feat: mac node exec policy + remote skills hot reload
This commit is contained in:
@@ -170,6 +170,10 @@ final class AppState {
|
|||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
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).
|
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
||||||
var canvasPanelVisible: Bool = false
|
var canvasPanelVisible: Bool = false
|
||||||
|
|
||||||
@@ -292,6 +296,7 @@ final class AppState {
|
|||||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||||
|
self.systemRunPolicy = SystemRunPolicy.load()
|
||||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||||
if !self.isPreview {
|
if !self.isPreview {
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
|
|||||||
let remoteCliPathKey = "clawdbot.remoteCliPath"
|
let remoteCliPathKey = "clawdbot.remoteCliPath"
|
||||||
let canvasEnabledKey = "clawdbot.canvasEnabled"
|
let canvasEnabledKey = "clawdbot.canvasEnabled"
|
||||||
let cameraEnabledKey = "clawdbot.cameraEnabled"
|
let cameraEnabledKey = "clawdbot.cameraEnabled"
|
||||||
|
let systemRunPolicyKey = "clawdbot.systemRunPolicy"
|
||||||
|
let systemRunAllowlistKey = "clawdbot.systemRunAllowlist"
|
||||||
|
let systemRunEnabledKey = "clawdbot.systemRunEnabled"
|
||||||
let locationModeKey = "clawdbot.locationMode"
|
let locationModeKey = "clawdbot.locationMode"
|
||||||
let locationPreciseKey = "clawdbot.locationPreciseEnabled"
|
let locationPreciseKey = "clawdbot.locationPreciseEnabled"
|
||||||
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
|
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
|
||||||
|
|||||||
@@ -83,6 +83,25 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Location Access")
|
Text("Location Access")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
|
|||||||
80
apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift
Normal file
80
apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,12 @@ struct MenuContent: View {
|
|||||||
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
|
||||||
|
Binding(
|
||||||
|
get: { self.state.systemRunPolicy },
|
||||||
|
set: { self.state.systemRunPolicy = $0 })
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Toggle(isOn: self.activeBinding) {
|
Toggle(isOn: self.activeBinding) {
|
||||||
@@ -68,6 +74,13 @@ struct MenuContent: View {
|
|||||||
Toggle(isOn: self.$cameraEnabled) {
|
Toggle(isOn: self.$cameraEnabled) {
|
||||||
Label("Allow Camera", systemImage: "camera")
|
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 })) {
|
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||||
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ final class MacNodeModeCoordinator {
|
|||||||
private func run() async {
|
private func run() async {
|
||||||
var retryDelay: UInt64 = 1_000_000_000
|
var retryDelay: UInt64 = 1_000_000_000
|
||||||
var lastCameraEnabled: Bool?
|
var lastCameraEnabled: Bool?
|
||||||
|
var lastSystemRunPolicy: SystemRunPolicy?
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||||
@@ -53,6 +54,15 @@ final class MacNodeModeCoordinator {
|
|||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
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 {
|
guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||||
@@ -143,10 +153,13 @@ final class MacNodeModeCoordinator {
|
|||||||
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||||
MacNodeScreenCommand.record.rawValue,
|
MacNodeScreenCommand.record.rawValue,
|
||||||
ClawdbotSystemCommand.run.rawValue,
|
|
||||||
ClawdbotSystemCommand.notify.rawValue,
|
ClawdbotSystemCommand.notify.rawValue,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if SystemRunPolicy.load() != .never {
|
||||||
|
commands.append(ClawdbotSystemCommand.run.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
let capsSet = Set(caps)
|
let capsSet = Set(caps)
|
||||||
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
||||||
commands.append(ClawdbotCameraCommand.list.rawValue)
|
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||||
|
|||||||
@@ -426,6 +426,44 @@ actor MacNodeRuntime {
|
|||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
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 {
|
if params.needsScreenRecording == true {
|
||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
@@ -441,7 +479,7 @@ actor MacNodeRuntime {
|
|||||||
let result = await ShellExecutor.runDetailed(
|
let result = await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: params.env,
|
env: env,
|
||||||
timeout: timeoutSec)
|
timeout: timeoutSec)
|
||||||
|
|
||||||
struct RunPayload: Encodable {
|
struct RunPayload: Encodable {
|
||||||
@@ -529,6 +567,10 @@ actor MacNodeRuntime {
|
|||||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
|
||||||
|
SystemRunPolicy.load()
|
||||||
|
}
|
||||||
|
|
||||||
private nonisolated static func locationMode() -> ClawdbotLocationMode {
|
private nonisolated static func locationMode() -> ClawdbotLocationMode {
|
||||||
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||||
return ClawdbotLocationMode(rawValue: raw) ?? .off
|
return ClawdbotLocationMode(rawValue: raw) ?? .off
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import AppKit
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
enum SystemRunDecision: Sendable {
|
||||||
|
case allowOnce
|
||||||
|
case allowAlways
|
||||||
|
case deny
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||||
func recordScreen(
|
func recordScreen(
|
||||||
@@ -17,6 +24,8 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
|||||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||||
maxAgeMs: Int?,
|
maxAgeMs: Int?,
|
||||||
timeoutMs: Int?) async throws -> CLLocation
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
|
|
||||||
|
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -57,4 +66,31 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
|||||||
maxAgeMs: maxAgeMs,
|
maxAgeMs: maxAgeMs,
|
||||||
timeoutMs: timeoutMs)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
89
apps/macos/Sources/Clawdbot/SystemRunPolicy.swift
Normal file
89
apps/macos/Sources/Clawdbot/SystemRunPolicy.swift
Normal file
@@ -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<String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,22 @@ When the audit prints findings, treat this as a priority order:
|
|||||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
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
|
## The Threat Model
|
||||||
|
|
||||||
Your AI assistant can:
|
Your AI assistant can:
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ 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`.
|
||||||
|
|
||||||
## Permissions map
|
## Permissions map
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Deep links
|
||||||
|
|
||||||
The app registers the `clawdbot://` URL scheme for local actions.
|
The app registers the `clawdbot://` URL scheme for local actions.
|
||||||
|
|||||||
@@ -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)
|
- [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 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)
|
- [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)
|
- [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)
|
- [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)
|
- [Sandboxing and memory](#sandboxing-and-memory)
|
||||||
@@ -399,6 +400,40 @@ npm i -g clawdhub
|
|||||||
pnpm add -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?
|
### Do you have a Notion or HeyGen integration?
|
||||||
|
|
||||||
Not built‑in today.
|
Not built‑in today.
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j
|
|||||||
extraDirs: [
|
extraDirs: [
|
||||||
"~/Projects/agent-scripts/skills",
|
"~/Projects/agent-scripts/skills",
|
||||||
"~/Projects/oss/some-skill-pack/skills"
|
"~/Projects/oss/some-skill-pack/skills"
|
||||||
]
|
],
|
||||||
|
watch: true,
|
||||||
|
watchDebounceMs: 250
|
||||||
},
|
},
|
||||||
install: {
|
install: {
|
||||||
preferBrew: true,
|
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
|
- `allowBundled`: optional allowlist for **bundled** skills only. When set, only
|
||||||
bundled skills in the list are eligible (managed/workspace skills unaffected).
|
bundled skills in the list are eligible (managed/workspace skills unaffected).
|
||||||
- `load.extraDirs`: additional skill directories to scan (lowest precedence).
|
- `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.preferBrew`: prefer brew installers when available (default: true).
|
||||||
- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn` | `bun`, default: npm).
|
- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn` | `bun`, default: npm).
|
||||||
This only affects **skill installs**; the Gateway runtime should still be Node
|
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
|
- Keys under `entries` map to the skill name by default. If a skill defines
|
||||||
`metadata.clawdbot.skillKey`, use that key instead.
|
`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.
|
||||||
|
|||||||
@@ -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.
|
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)
|
## 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:
|
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:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
resolveSkillConfig,
|
resolveSkillConfig,
|
||||||
resolveSkillsInstallPreferences,
|
resolveSkillsInstallPreferences,
|
||||||
type SkillEntry,
|
type SkillEntry,
|
||||||
|
type SkillEligibilityContext,
|
||||||
type SkillInstallSpec,
|
type SkillInstallSpec,
|
||||||
type SkillsInstallPreferences,
|
type SkillsInstallPreferences,
|
||||||
} from "./skills.js";
|
} from "./skills.js";
|
||||||
@@ -135,6 +136,7 @@ function buildSkillStatus(
|
|||||||
entry: SkillEntry,
|
entry: SkillEntry,
|
||||||
config?: ClawdbotConfig,
|
config?: ClawdbotConfig,
|
||||||
prefs?: SkillsInstallPreferences,
|
prefs?: SkillsInstallPreferences,
|
||||||
|
eligibility?: SkillEligibilityContext,
|
||||||
): SkillStatusEntry {
|
): SkillStatusEntry {
|
||||||
const skillKey = resolveSkillKey(entry);
|
const skillKey = resolveSkillKey(entry);
|
||||||
const skillConfig = resolveSkillConfig(config, skillKey);
|
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||||
@@ -156,13 +158,25 @@ function buildSkillStatus(
|
|||||||
const requiredConfig = entry.clawdbot?.requires?.config ?? [];
|
const requiredConfig = entry.clawdbot?.requires?.config ?? [];
|
||||||
const requiredOs = entry.clawdbot?.os ?? [];
|
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 =
|
const missingAnyBins =
|
||||||
requiredAnyBins.length > 0 && !requiredAnyBins.some((bin) => hasBinary(bin))
|
requiredAnyBins.length > 0 &&
|
||||||
|
!(
|
||||||
|
requiredAnyBins.some((bin) => hasBinary(bin)) ||
|
||||||
|
eligibility?.remote?.hasAnyBin?.(requiredAnyBins)
|
||||||
|
)
|
||||||
? requiredAnyBins
|
? requiredAnyBins
|
||||||
: [];
|
: [];
|
||||||
const missingOs =
|
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[] = [];
|
const missingEnv: string[] = [];
|
||||||
for (const envName of requiredEnv) {
|
for (const envName of requiredEnv) {
|
||||||
@@ -233,6 +247,7 @@ export function buildWorkspaceSkillStatus(
|
|||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
managedSkillsDir?: string;
|
managedSkillsDir?: string;
|
||||||
entries?: SkillEntry[];
|
entries?: SkillEntry[];
|
||||||
|
eligibility?: SkillEligibilityContext;
|
||||||
},
|
},
|
||||||
): SkillStatusReport {
|
): SkillStatusReport {
|
||||||
const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
|
const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
|
||||||
@@ -241,6 +256,8 @@ export function buildWorkspaceSkillStatus(
|
|||||||
return {
|
return {
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
managedSkillsDir,
|
managedSkillsDir,
|
||||||
skills: skillEntries.map((entry) => buildSkillStatus(entry, opts?.config, prefs)),
|
skills: skillEntries.map((entry) =>
|
||||||
|
buildSkillStatus(entry, opts?.config, prefs, opts?.eligibility),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export {
|
|||||||
} from "./skills/env-overrides.js";
|
} from "./skills/env-overrides.js";
|
||||||
export type {
|
export type {
|
||||||
ClawdbotSkillMetadata,
|
ClawdbotSkillMetadata,
|
||||||
|
SkillEligibilityContext,
|
||||||
SkillEntry,
|
SkillEntry,
|
||||||
SkillInstallSpec,
|
SkillInstallSpec,
|
||||||
SkillSnapshot,
|
SkillSnapshot,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ClawdbotConfig, SkillConfig } from "../../config/config.js";
|
import type { ClawdbotConfig, SkillConfig } from "../../config/config.js";
|
||||||
import { resolveSkillKey } from "./frontmatter.js";
|
import { resolveSkillKey } from "./frontmatter.js";
|
||||||
import type { SkillEntry } from "./types.js";
|
import type { SkillEligibilityContext, SkillEntry } from "./types.js";
|
||||||
|
|
||||||
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
||||||
"browser.enabled": true,
|
"browser.enabled": true,
|
||||||
@@ -89,16 +89,22 @@ export function hasBinary(bin: string): boolean {
|
|||||||
export function shouldIncludeSkill(params: {
|
export function shouldIncludeSkill(params: {
|
||||||
entry: SkillEntry;
|
entry: SkillEntry;
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
|
eligibility?: SkillEligibilityContext;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const { entry, config } = params;
|
const { entry, config, eligibility } = params;
|
||||||
const skillKey = resolveSkillKey(entry.skill, entry);
|
const skillKey = resolveSkillKey(entry.skill, entry);
|
||||||
const skillConfig = resolveSkillConfig(config, skillKey);
|
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||||
const allowBundled = normalizeAllowlist(config?.skills?.allowBundled);
|
const allowBundled = normalizeAllowlist(config?.skills?.allowBundled);
|
||||||
const osList = entry.clawdbot?.os ?? [];
|
const osList = entry.clawdbot?.os ?? [];
|
||||||
|
const remotePlatforms = eligibility?.remote?.platforms ?? [];
|
||||||
|
|
||||||
if (skillConfig?.enabled === false) return false;
|
if (skillConfig?.enabled === false) return false;
|
||||||
if (!isBundledSkillAllowed(entry, allowBundled)) 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;
|
return false;
|
||||||
}
|
}
|
||||||
if (entry.clawdbot?.always === true) {
|
if (entry.clawdbot?.always === true) {
|
||||||
@@ -108,12 +114,16 @@ export function shouldIncludeSkill(params: {
|
|||||||
const requiredBins = entry.clawdbot?.requires?.bins ?? [];
|
const requiredBins = entry.clawdbot?.requires?.bins ?? [];
|
||||||
if (requiredBins.length > 0) {
|
if (requiredBins.length > 0) {
|
||||||
for (const bin of requiredBins) {
|
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 ?? [];
|
const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? [];
|
||||||
if (requiredAnyBins.length > 0) {
|
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;
|
if (!anyFound) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
158
src/agents/skills/refresh.ts
Normal file
158
src/agents/skills/refresh.ts
Normal file
@@ -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<typeof setTimeout>;
|
||||||
|
pendingPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const log = createSubsystemLogger("gateway/skills");
|
||||||
|
const listeners = new Set<(event: SkillsChangeEvent) => void>();
|
||||||
|
const workspaceVersions = new Map<string, number>();
|
||||||
|
const watchers = new Map<string, SkillsWatchState>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -39,8 +39,18 @@ export type SkillEntry = {
|
|||||||
clawdbot?: ClawdbotSkillMetadata;
|
clawdbot?: ClawdbotSkillMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SkillEligibilityContext = {
|
||||||
|
remote?: {
|
||||||
|
platforms: string[];
|
||||||
|
hasBin: (bin: string) => boolean;
|
||||||
|
hasAnyBin: (bins: string[]) => boolean;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type SkillSnapshot = {
|
export type SkillSnapshot = {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||||
resolvedSkills?: Skill[];
|
resolvedSkills?: Skill[];
|
||||||
|
version?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import { resolveBundledSkillsDir } from "./bundled-dir.js";
|
|||||||
import { shouldIncludeSkill } from "./config.js";
|
import { shouldIncludeSkill } from "./config.js";
|
||||||
import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js";
|
import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js";
|
||||||
import { serializeByKey } from "./serialize.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;
|
const fsp = fs.promises;
|
||||||
|
|
||||||
@@ -21,8 +26,9 @@ function filterSkillEntries(
|
|||||||
entries: SkillEntry[],
|
entries: SkillEntry[],
|
||||||
config?: ClawdbotConfig,
|
config?: ClawdbotConfig,
|
||||||
skillFilter?: string[],
|
skillFilter?: string[],
|
||||||
|
eligibility?: SkillEligibilityContext,
|
||||||
): SkillEntry[] {
|
): 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 is provided, only include skills in the filter list.
|
||||||
if (skillFilter !== undefined) {
|
if (skillFilter !== undefined) {
|
||||||
const normalized = skillFilter.map((entry) => String(entry).trim()).filter(Boolean);
|
const normalized = skillFilter.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
@@ -122,18 +128,28 @@ export function buildWorkspaceSkillSnapshot(
|
|||||||
entries?: SkillEntry[];
|
entries?: SkillEntry[];
|
||||||
/** If provided, only include skills with these names */
|
/** If provided, only include skills with these names */
|
||||||
skillFilter?: string[];
|
skillFilter?: string[];
|
||||||
|
eligibility?: SkillEligibilityContext;
|
||||||
|
snapshotVersion?: number;
|
||||||
},
|
},
|
||||||
): SkillSnapshot {
|
): SkillSnapshot {
|
||||||
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
|
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 resolvedSkills = eligible.map((entry) => entry.skill);
|
||||||
|
const remoteNote = opts?.eligibility?.remote?.note?.trim();
|
||||||
|
const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n");
|
||||||
return {
|
return {
|
||||||
prompt: formatSkillsForPrompt(resolvedSkills),
|
prompt,
|
||||||
skills: eligible.map((entry) => ({
|
skills: eligible.map((entry) => ({
|
||||||
name: entry.skill.name,
|
name: entry.skill.name,
|
||||||
primaryEnv: entry.clawdbot?.primaryEnv,
|
primaryEnv: entry.clawdbot?.primaryEnv,
|
||||||
})),
|
})),
|
||||||
resolvedSkills,
|
resolvedSkills,
|
||||||
|
version: opts?.snapshotVersion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,11 +162,20 @@ export function buildWorkspaceSkillsPrompt(
|
|||||||
entries?: SkillEntry[];
|
entries?: SkillEntry[];
|
||||||
/** If provided, only include skills with these names */
|
/** If provided, only include skills with these names */
|
||||||
skillFilter?: string[];
|
skillFilter?: string[];
|
||||||
|
eligibility?: SkillEligibilityContext;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
|
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
|
||||||
const eligible = filterSkillEntries(skillEntries, opts?.config, opts?.skillFilter);
|
const eligible = filterSkillEntries(
|
||||||
return formatSkillsForPrompt(eligible.map((entry) => entry.skill));
|
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: {
|
export function resolveSkillsPromptForRun(params: {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { createClawdbotCodingTools } from "../../agents/pi-tools.js";
|
import { createClawdbotCodingTools } from "../../agents/pi-tools.js";
|
||||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
||||||
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||||
|
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||||
import { buildAgentSystemPrompt } from "../../agents/system-prompt.js";
|
import { buildAgentSystemPrompt } from "../../agents/system-prompt.js";
|
||||||
import { buildSystemPromptReport } from "../../agents/system-prompt-report.js";
|
import { buildSystemPromptReport } from "../../agents/system-prompt-report.js";
|
||||||
import { buildToolSummaryMap } from "../../agents/tool-summaries.js";
|
import { buildToolSummaryMap } from "../../agents/tool-summaries.js";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
loadWorkspaceBootstrapFiles,
|
loadWorkspaceBootstrapFiles,
|
||||||
} from "../../agents/workspace.js";
|
} from "../../agents/workspace.js";
|
||||||
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
|
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||||
|
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import type { HandleCommandsParams } from "./commands-types.js";
|
import type { HandleCommandsParams } from "./commands-types.js";
|
||||||
|
|
||||||
@@ -62,7 +64,11 @@ async function resolveContextReport(
|
|||||||
});
|
});
|
||||||
const skillsSnapshot = (() => {
|
const skillsSnapshot = (() => {
|
||||||
try {
|
try {
|
||||||
return buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg });
|
return buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||||
|
config: params.cfg,
|
||||||
|
eligibility: { remote: getRemoteSkillEligibility() },
|
||||||
|
snapshotVersion: getSkillsSnapshotVersion(workspaceDir),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return { prompt: "", skills: [], resolvedSkills: [] };
|
return { prompt: "", skills: [], resolvedSkills: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||||
|
import {
|
||||||
|
ensureSkillsWatcher,
|
||||||
|
getSkillsSnapshotVersion,
|
||||||
|
} from "../../agents/skills/refresh.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||||
import { buildChannelSummary } from "../../infra/channel-summary.js";
|
import { buildChannelSummary } from "../../infra/channel-summary.js";
|
||||||
|
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
||||||
import { drainSystemEventEntries } from "../../infra/system-events.js";
|
import { drainSystemEventEntries } from "../../infra/system-events.js";
|
||||||
|
|
||||||
export async function prependSystemEvents(params: {
|
export async function prependSystemEvents(params: {
|
||||||
@@ -88,6 +93,11 @@ export async function ensureSkillSnapshot(params: {
|
|||||||
|
|
||||||
let nextEntry = sessionEntry;
|
let nextEntry = sessionEntry;
|
||||||
let systemSent = sessionEntry?.systemSent ?? false;
|
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) {
|
if (isFirstTurnInSession && sessionStore && sessionKey) {
|
||||||
const current = nextEntry ??
|
const current = nextEntry ??
|
||||||
@@ -96,10 +106,12 @@ export async function ensureSkillSnapshot(params: {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
const skillSnapshot =
|
const skillSnapshot =
|
||||||
isFirstTurnInSession || !current.skillsSnapshot
|
isFirstTurnInSession || !current.skillsSnapshot || shouldRefreshSnapshot
|
||||||
? buildWorkspaceSkillSnapshot(workspaceDir, {
|
? buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
skillFilter,
|
skillFilter,
|
||||||
|
eligibility: { remote: remoteEligibility },
|
||||||
|
snapshotVersion,
|
||||||
})
|
})
|
||||||
: current.skillsSnapshot;
|
: current.skillsSnapshot;
|
||||||
nextEntry = {
|
nextEntry = {
|
||||||
@@ -118,20 +130,28 @@ export async function ensureSkillSnapshot(params: {
|
|||||||
systemSent = true;
|
systemSent = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillsSnapshot =
|
const skillsSnapshot = shouldRefreshSnapshot
|
||||||
nextEntry?.skillsSnapshot ??
|
? buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||||
(isFirstTurnInSession
|
config: cfg,
|
||||||
? undefined
|
skillFilter,
|
||||||
: buildWorkspaceSkillSnapshot(workspaceDir, {
|
eligibility: { remote: remoteEligibility },
|
||||||
config: cfg,
|
snapshotVersion,
|
||||||
skillFilter,
|
})
|
||||||
}));
|
: nextEntry?.skillsSnapshot ??
|
||||||
|
(isFirstTurnInSession
|
||||||
|
? undefined
|
||||||
|
: buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||||
|
config: cfg,
|
||||||
|
skillFilter,
|
||||||
|
eligibility: { remote: remoteEligibility },
|
||||||
|
snapshotVersion,
|
||||||
|
}));
|
||||||
if (
|
if (
|
||||||
skillsSnapshot &&
|
skillsSnapshot &&
|
||||||
sessionStore &&
|
sessionStore &&
|
||||||
sessionKey &&
|
sessionKey &&
|
||||||
!isFirstTurnInSession &&
|
!isFirstTurnInSession &&
|
||||||
!nextEntry?.skillsSnapshot
|
(!nextEntry?.skillsSnapshot || shouldRefreshSnapshot)
|
||||||
) {
|
) {
|
||||||
const current = nextEntry ?? {
|
const current = nextEntry ?? {
|
||||||
sessionId: sessionId ?? crypto.randomUUID(),
|
sessionId: sessionId ?? crypto.randomUUID(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "../agents/model-selection.js";
|
} from "../agents/model-selection.js";
|
||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||||
|
import { getSkillsSnapshotVersion } from "../agents/skills/refresh.js";
|
||||||
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||||
import { ensureAgentWorkspace } from "../agents/workspace.js";
|
import { ensureAgentWorkspace } from "../agents/workspace.js";
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
emitAgentEvent,
|
emitAgentEvent,
|
||||||
registerAgentRunContext,
|
registerAgentRunContext,
|
||||||
} from "../infra/agent-events.js";
|
} from "../infra/agent-events.js";
|
||||||
|
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { applyVerboseOverride } from "../sessions/level-overrides.js";
|
import { applyVerboseOverride } from "../sessions/level-overrides.js";
|
||||||
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
||||||
@@ -157,8 +159,13 @@ export async function agentCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
|
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
|
||||||
|
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
|
||||||
const skillsSnapshot = needsSkillsSnapshot
|
const skillsSnapshot = needsSkillsSnapshot
|
||||||
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
|
? buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||||
|
config: cfg,
|
||||||
|
eligibility: { remote: getRemoteSkillEligibility() },
|
||||||
|
snapshotVersion: skillsSnapshotVersion,
|
||||||
|
})
|
||||||
: sessionEntry?.skillsSnapshot;
|
: sessionEntry?.skillsSnapshot;
|
||||||
|
|
||||||
if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) {
|
if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { inspectPortUsage } from "../infra/ports.js";
|
|||||||
import { readRestartSentinel } from "../infra/restart-sentinel.js";
|
import { readRestartSentinel } from "../infra/restart-sentinel.js";
|
||||||
import { readTailscaleStatusJson } from "../infra/tailscale.js";
|
import { readTailscaleStatusJson } from "../infra/tailscale.js";
|
||||||
import { checkUpdateStatus, compareSemverStrings } from "../infra/update-check.js";
|
import { checkUpdateStatus, compareSemverStrings } from "../infra/update-check.js";
|
||||||
|
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
|
||||||
import { runExec } from "../process/exec.js";
|
import { runExec } from "../process/exec.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
@@ -217,6 +218,7 @@ export async function statusAllCommand(
|
|||||||
try {
|
try {
|
||||||
return buildWorkspaceSkillStatus(defaultWorkspace, {
|
return buildWorkspaceSkillStatus(defaultWorkspace, {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
eligibility: { remote: getRemoteSkillEligibility() },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||||
"gateway.reload.mode": "Config Reload Mode",
|
"gateway.reload.mode": "Config Reload Mode",
|
||||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
"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.workspace": "Workspace",
|
||||||
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
||||||
"agents.defaults.memorySearch": "Memory Search",
|
"agents.defaults.memorySearch": "Memory Search",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export type SessionSkillSnapshot = {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||||
resolvedSkills?: Skill[];
|
resolvedSkills?: Skill[];
|
||||||
|
version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionSystemPromptReport = {
|
export type SessionSystemPromptReport = {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export type SkillsLoadConfig = {
|
|||||||
* Each directory should contain skill subfolders with `SKILL.md`.
|
* Each directory should contain skill subfolders with `SKILL.md`.
|
||||||
*/
|
*/
|
||||||
extraDirs?: string[];
|
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 = {
|
export type SkillsInstallConfig = {
|
||||||
|
|||||||
@@ -270,6 +270,8 @@ export const ClawdbotSchema = z
|
|||||||
load: z
|
load: z
|
||||||
.object({
|
.object({
|
||||||
extraDirs: z.array(z.string()).optional(),
|
extraDirs: z.array(z.string()).optional(),
|
||||||
|
watch: z.boolean().optional(),
|
||||||
|
watchDebounceMs: z.number().int().min(0).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
install: z
|
install: z
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "../../agents/model-selection.js";
|
} from "../../agents/model-selection.js";
|
||||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||||
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||||
|
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||||
import { ensureAgentWorkspace } from "../../agents/workspace.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 type { AgentDefaultsConfig } from "../../config/types.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
||||||
|
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
||||||
import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js";
|
import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js";
|
||||||
import type { CronJob } from "../types.js";
|
import type { CronJob } from "../types.js";
|
||||||
import { resolveDeliveryTarget } from "./delivery-target.js";
|
import { resolveDeliveryTarget } from "./delivery-target.js";
|
||||||
@@ -205,9 +207,12 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
const commandBody = base;
|
const commandBody = base;
|
||||||
|
|
||||||
const needsSkillsSnapshot = cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
|
const needsSkillsSnapshot = cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
|
||||||
|
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
|
||||||
const skillsSnapshot = needsSkillsSnapshot
|
const skillsSnapshot = needsSkillsSnapshot
|
||||||
? buildWorkspaceSkillSnapshot(workspaceDir, {
|
? buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||||
config: cfgWithAgentDefaults,
|
config: cfgWithAgentDefaults,
|
||||||
|
eligibility: { remote: getRemoteSkillEligibility() },
|
||||||
|
snapshotVersion: skillsSnapshotVersion,
|
||||||
})
|
})
|
||||||
: cronSession.sessionEntry.skillsSnapshot;
|
: cronSession.sessionEntry.skillsSnapshot;
|
||||||
if (needsSkillsSnapshot && skillsSnapshot) {
|
if (needsSkillsSnapshot && skillsSnapshot) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.
|
|||||||
import { startCanvasHost } from "../canvas-host/server.js";
|
import { startCanvasHost } from "../canvas-host/server.js";
|
||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
import type { HealthSummary } from "../commands/health.js";
|
import type { HealthSummary } from "../commands/health.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort } from "../config/port-defaults.js";
|
import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort } from "../config/port-defaults.js";
|
||||||
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
||||||
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
||||||
@@ -33,15 +34,7 @@ export type GatewayBridgeRuntime = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function startGatewayBridgeRuntime(params: {
|
export async function startGatewayBridgeRuntime(params: {
|
||||||
cfg: {
|
cfg: ClawdbotConfig;
|
||||||
bridge?: {
|
|
||||||
enabled?: boolean;
|
|
||||||
port?: number;
|
|
||||||
bind?: "loopback" | "lan" | "auto" | "custom";
|
|
||||||
};
|
|
||||||
canvasHost?: { port?: number; root?: string; liveReload?: boolean };
|
|
||||||
discovery?: { wideArea?: { enabled?: boolean } };
|
|
||||||
};
|
|
||||||
port: number;
|
port: number;
|
||||||
canvasHostEnabled: boolean;
|
canvasHostEnabled: boolean;
|
||||||
canvasHost: CanvasHostHandler | null;
|
canvasHost: CanvasHostHandler | null;
|
||||||
@@ -200,6 +193,7 @@ export async function startGatewayBridgeRuntime(params: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const bridgeRuntime = await startGatewayNodeBridge({
|
const bridgeRuntime = await startGatewayNodeBridge({
|
||||||
|
cfg: params.cfg,
|
||||||
bridgeEnabled,
|
bridgeEnabled,
|
||||||
bridgePort,
|
bridgePort,
|
||||||
bridgeHost,
|
bridgeHost,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { installSkill } from "../../agents/skills-install.js";
|
|||||||
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
|
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { loadConfig, writeConfigFile } from "../../config/config.js";
|
import { loadConfig, writeConfigFile } from "../../config/config.js";
|
||||||
|
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
errorShape,
|
errorShape,
|
||||||
@@ -30,6 +31,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
|
|||||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||||
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
eligibility: { remote: getRemoteSkillEligibility() },
|
||||||
});
|
});
|
||||||
respond(true, report, undefined);
|
respond(true, report, undefined);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
||||||
import { startNodeBridgeServer } 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 { listSystemPresence, upsertPresence } from "../infra/system-presence.js";
|
||||||
import { loadVoiceWakeConfig } from "../infra/voicewake.js";
|
import { loadVoiceWakeConfig } from "../infra/voicewake.js";
|
||||||
import { isLoopbackAddress } from "./net.js";
|
import { isLoopbackAddress } from "./net.js";
|
||||||
@@ -16,6 +19,7 @@ export type GatewayNodeBridgeRuntime = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function startGatewayNodeBridge(params: {
|
export async function startGatewayNodeBridge(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
bridgeEnabled: boolean;
|
bridgeEnabled: boolean;
|
||||||
bridgePort: number;
|
bridgePort: number;
|
||||||
bridgeHost: string | null;
|
bridgeHost: string | null;
|
||||||
@@ -114,6 +118,21 @@ export async function startGatewayNodeBridge(params: {
|
|||||||
onAuthenticated: async (node) => {
|
onAuthenticated: async (node) => {
|
||||||
beaconNodePresence(node, "node-connected");
|
beaconNodePresence(node, "node-connected");
|
||||||
startNodePresenceTimer(node);
|
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 {
|
try {
|
||||||
const cfg = await loadVoiceWakeConfig();
|
const cfg = await loadVoiceWakeConfig();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { initSubagentRegistry } from "../agents/subagent-registry.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 { CanvasHostServer } from "../canvas-host/server.js";
|
||||||
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
import { createDefaultDeps } from "../cli/deps.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 { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.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 { autoMigrateLegacyState } from "../infra/state-migrations.js";
|
||||||
import { createSubsystemLogger, runtimeForLogger } from "../logging.js";
|
import { createSubsystemLogger, runtimeForLogger } from "../logging.js";
|
||||||
import type { PluginServicesHandle } from "../plugins/services.js";
|
import type { PluginServicesHandle } from "../plugins/services.js";
|
||||||
@@ -288,6 +294,13 @@ export async function startGatewayServer(
|
|||||||
const bridgeSendToAllSubscribed = bridgeRuntime.bridgeSendToAllSubscribed;
|
const bridgeSendToAllSubscribed = bridgeRuntime.bridgeSendToAllSubscribed;
|
||||||
const broadcastVoiceWakeChanged = bridgeRuntime.broadcastVoiceWakeChanged;
|
const broadcastVoiceWakeChanged = bridgeRuntime.broadcastVoiceWakeChanged;
|
||||||
|
|
||||||
|
setSkillsRemoteBridge(bridge);
|
||||||
|
void primeRemoteSkillsCache();
|
||||||
|
registerSkillsChangeListener(() => {
|
||||||
|
const latest = loadConfig();
|
||||||
|
void refreshRemoteBinsForConnectedNodes(latest);
|
||||||
|
});
|
||||||
|
|
||||||
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
|
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
|
||||||
broadcast,
|
broadcast,
|
||||||
bridgeSendToAllSubscribed,
|
bridgeSendToAllSubscribed,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type NodePairingPairedNode = {
|
|||||||
modelIdentifier?: string;
|
modelIdentifier?: string;
|
||||||
caps?: string[];
|
caps?: string[];
|
||||||
commands?: string[];
|
commands?: string[];
|
||||||
|
bins?: string[];
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
@@ -272,6 +273,7 @@ export async function updatePairedNodeMetadata(
|
|||||||
remoteIp: patch.remoteIp ?? existing.remoteIp,
|
remoteIp: patch.remoteIp ?? existing.remoteIp,
|
||||||
caps: patch.caps ?? existing.caps,
|
caps: patch.caps ?? existing.caps,
|
||||||
commands: patch.commands ?? existing.commands,
|
commands: patch.commands ?? existing.commands,
|
||||||
|
bins: patch.bins ?? existing.bins,
|
||||||
permissions: patch.permissions ?? existing.permissions,
|
permissions: patch.permissions ?? existing.permissions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
226
src/infra/skills-remote.ts
Normal file
226
src/infra/skills-remote.ts
Normal file
@@ -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<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const log = createSubsystemLogger("gateway/skills-remote");
|
||||||
|
const remoteNodes = new Map<string, RemoteNodeRecord>();
|
||||||
|
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<string>(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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user