feat: add system.which bin probe

This commit is contained in:
Peter Steinberger
2026-01-16 07:31:26 +00:00
parent e479c870fd
commit e96b939732
6 changed files with 96 additions and 21 deletions

View File

@@ -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.

View File

@@ -164,6 +164,7 @@ final class MacNodeModeCoordinator {
]
if SystemRunPolicy.load() != .never {
commands.append(ClawdbotSystemCommand.which.rawValue)
commands.append(ClawdbotSystemCommand.run.rawValue)
}

View File

@@ -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)

View File

@@ -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: "")

View File

@@ -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

View File

@@ -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<string>();
@@ -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" });