From e96b939732e7d609129c314f595e50e1cf00c86a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 07:31:26 +0000 Subject: [PATCH] feat: add system.which bin probe --- CHANGELOG.md | 1 + .../NodeMode/MacNodeModeCoordinator.swift | 1 + .../Clawdbot/NodeMode/MacNodeRuntime.swift | 29 ++++++++ .../MacNodeRuntimeTests.swift | 9 +++ .../Sources/ClawdbotKit/SystemCommands.swift | 9 +++ src/infra/skills-remote.ts | 68 +++++++++++++------ 6 files changed, 96 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91c2a4dd9..8bd93da39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Agents: avoid false positives when logging unsupported Google tool schema keywords. - Status: restore usage summary line for current provider when no OAuth profiles exist. - Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields. +- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`). - Docs: add Date & Time guide and update prompt/timezone configuration docs. - Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc. - Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev. diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index f18cc9e8c..7bfa8c15b 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -164,6 +164,7 @@ final class MacNodeModeCoordinator { ] if SystemRunPolicy.load() != .never { + commands.append(ClawdbotSystemCommand.which.rawValue) commands.append(ClawdbotSystemCommand.run.rawValue) } diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index 6d9188db6..e0bcbfae3 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -55,6 +55,8 @@ actor MacNodeRuntime { return try await self.handleScreenRecordInvoke(req) case ClawdbotSystemCommand.run.rawValue: return try await self.handleSystemRun(req) + case ClawdbotSystemCommand.which.rawValue: + return try await self.handleSystemWhich(req) case ClawdbotSystemCommand.notify.rawValue: return try await self.handleSystemNotify(req) default: @@ -494,6 +496,33 @@ actor MacNodeRuntime { return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) } + private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = try Self.decodeParams(ClawdbotSystemWhichParams.self, from: req.paramsJSON) + let bins = params.bins + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !bins.isEmpty else { + return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required") + } + + let searchPaths = CommandResolver.preferredPaths() + var matches: [String] = [] + var paths: [String: String] = [:] + for bin in bins { + if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) { + matches.append(bin) + paths[bin] = path + } + } + + struct WhichPayload: Encodable { + let bins: [String] + let paths: [String: String] + } + let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths)) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON) let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift index 53737e7aa..12d03c185 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift @@ -21,6 +21,15 @@ struct MacNodeRuntimeTests { #expect(response.ok == false) } + @Test func handleInvokeRejectsEmptySystemWhich() async throws { + let runtime = MacNodeRuntime() + let params = ClawdbotSystemWhichParams(bins: []) + let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-2b", command: ClawdbotSystemCommand.which.rawValue, paramsJSON: json)) + #expect(response.ok == false) + } + @Test func handleInvokeRejectsEmptyNotification() async throws { let runtime = MacNodeRuntime() let params = ClawdbotSystemNotifyParams(title: "", body: "") diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift index 88292602a..ca35a25b3 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift @@ -2,6 +2,7 @@ import Foundation public enum ClawdbotSystemCommand: String, Codable, Sendable { case run = "system.run" + case which = "system.which" case notify = "system.notify" } @@ -39,6 +40,14 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable { } } +public struct ClawdbotSystemWhichParams: Codable, Sendable, Equatable { + public var bins: [String] + + public init(bins: [String]) { + self.bins = bins + } +} + public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable { public var title: String public var body: String diff --git a/src/infra/skills-remote.ts b/src/infra/skills-remote.ts index 335fc2ecb..50646459f 100644 --- a/src/infra/skills-remote.ts +++ b/src/infra/skills-remote.ts @@ -37,6 +37,10 @@ function supportsSystemRun(commands?: string[]): boolean { return Array.isArray(commands) && commands.includes("system.run"); } +function supportsSystemWhich(commands?: string[]): boolean { + return Array.isArray(commands) && commands.includes("system.which"); +} + function upsertNode(record: { nodeId: string; displayName?: string; @@ -136,6 +140,27 @@ function buildBinProbeScript(bins: string[]): string { return `for b in ${escaped}; do if command -v "$b" >/dev/null 2>&1; then echo "$b"; fi; done`; } +function parseBinProbePayload(payloadJSON: string | null | undefined): string[] { + if (!payloadJSON) return []; + try { + const parsed = JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown }; + if (Array.isArray(parsed.bins)) { + return parsed.bins + .map((bin) => String(bin).trim()) + .filter(Boolean); + } + if (typeof parsed.stdout === "string") { + return parsed.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + } + } catch { + return []; + } + return []; +} + export async function refreshRemoteNodeBins(params: { nodeId: string; platform?: string; @@ -146,7 +171,9 @@ export async function refreshRemoteNodeBins(params: { }) { if (!remoteBridge) return; if (!isMacPlatform(params.platform, params.deviceFamily)) return; - if (!supportsSystemRun(params.commands)) return; + const canWhich = supportsSystemWhich(params.commands); + const canRun = supportsSystemRun(params.commands); + if (!canWhich && !canRun) return; const workspaceDirs = listWorkspaceDirs(params.cfg); const requiredBins = new Set(); @@ -158,31 +185,30 @@ export async function refreshRemoteNodeBins(params: { } 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, - }); + const binsList = [...requiredBins]; + const res = await remoteBridge.invoke( + canWhich + ? { + nodeId: params.nodeId, + command: "system.which", + paramsJSON: JSON.stringify({ bins: binsList }), + timeoutMs: params.timeoutMs ?? 15_000, + } + : { + nodeId: params.nodeId, + command: "system.run", + paramsJSON: JSON.stringify({ + command: ["/bin/sh", "-lc", buildBinProbeScript(binsList)], + }), + 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); + const bins = parseBinProbePayload(res.payloadJSON); recordRemoteNodeBins(params.nodeId, bins); await updatePairedNodeMetadata(params.nodeId, { bins }); bumpSkillsSnapshotVersion({ reason: "remote-node" });