feat: add system.which bin probe
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
||||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
- 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.
|
- 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.
|
- 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: 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.
|
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ final class MacNodeModeCoordinator {
|
|||||||
]
|
]
|
||||||
|
|
||||||
if SystemRunPolicy.load() != .never {
|
if SystemRunPolicy.load() != .never {
|
||||||
|
commands.append(ClawdbotSystemCommand.which.rawValue)
|
||||||
commands.append(ClawdbotSystemCommand.run.rawValue)
|
commands.append(ClawdbotSystemCommand.run.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ actor MacNodeRuntime {
|
|||||||
return try await self.handleScreenRecordInvoke(req)
|
return try await self.handleScreenRecordInvoke(req)
|
||||||
case ClawdbotSystemCommand.run.rawValue:
|
case ClawdbotSystemCommand.run.rawValue:
|
||||||
return try await self.handleSystemRun(req)
|
return try await self.handleSystemRun(req)
|
||||||
|
case ClawdbotSystemCommand.which.rawValue:
|
||||||
|
return try await self.handleSystemWhich(req)
|
||||||
case ClawdbotSystemCommand.notify.rawValue:
|
case ClawdbotSystemCommand.notify.rawValue:
|
||||||
return try await self.handleSystemNotify(req)
|
return try await self.handleSystemNotify(req)
|
||||||
default:
|
default:
|
||||||
@@ -494,6 +496,33 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
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 {
|
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
||||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ struct MacNodeRuntimeTests {
|
|||||||
#expect(response.ok == false)
|
#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 {
|
@Test func handleInvokeRejectsEmptyNotification() async throws {
|
||||||
let runtime = MacNodeRuntime()
|
let runtime = MacNodeRuntime()
|
||||||
let params = ClawdbotSystemNotifyParams(title: "", body: "")
|
let params = ClawdbotSystemNotifyParams(title: "", body: "")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
||||||
case run = "system.run"
|
case run = "system.run"
|
||||||
|
case which = "system.which"
|
||||||
case notify = "system.notify"
|
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 struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
|
||||||
public var title: String
|
public var title: String
|
||||||
public var body: String
|
public var body: String
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ function supportsSystemRun(commands?: string[]): boolean {
|
|||||||
return Array.isArray(commands) && commands.includes("system.run");
|
return Array.isArray(commands) && commands.includes("system.run");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function supportsSystemWhich(commands?: string[]): boolean {
|
||||||
|
return Array.isArray(commands) && commands.includes("system.which");
|
||||||
|
}
|
||||||
|
|
||||||
function upsertNode(record: {
|
function upsertNode(record: {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
displayName?: 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`;
|
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: {
|
export async function refreshRemoteNodeBins(params: {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
@@ -146,7 +171,9 @@ export async function refreshRemoteNodeBins(params: {
|
|||||||
}) {
|
}) {
|
||||||
if (!remoteBridge) return;
|
if (!remoteBridge) return;
|
||||||
if (!isMacPlatform(params.platform, params.deviceFamily)) 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 workspaceDirs = listWorkspaceDirs(params.cfg);
|
||||||
const requiredBins = new Set<string>();
|
const requiredBins = new Set<string>();
|
||||||
@@ -158,31 +185,30 @@ export async function refreshRemoteNodeBins(params: {
|
|||||||
}
|
}
|
||||||
if (requiredBins.size === 0) return;
|
if (requiredBins.size === 0) return;
|
||||||
|
|
||||||
const script = buildBinProbeScript([...requiredBins]);
|
|
||||||
const payload = {
|
|
||||||
command: ["/bin/sh", "-lc", script],
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
const res = await remoteBridge.invoke({
|
const binsList = [...requiredBins];
|
||||||
nodeId: params.nodeId,
|
const res = await remoteBridge.invoke(
|
||||||
command: "system.run",
|
canWhich
|
||||||
paramsJSON: JSON.stringify(payload),
|
? {
|
||||||
timeoutMs: params.timeoutMs ?? 15_000,
|
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) {
|
if (!res.ok) {
|
||||||
log.warn(`remote bin probe failed (${params.nodeId}): ${res.error?.message ?? "unknown"}`);
|
log.warn(`remote bin probe failed (${params.nodeId}): ${res.error?.message ?? "unknown"}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raw = typeof res.payloadJSON === "string" ? res.payloadJSON : "";
|
const bins = parseBinProbePayload(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);
|
recordRemoteNodeBins(params.nodeId, bins);
|
||||||
await updatePairedNodeMetadata(params.nodeId, { bins });
|
await updatePairedNodeMetadata(params.nodeId, { bins });
|
||||||
bumpSkillsSnapshotVersion({ reason: "remote-node" });
|
bumpSkillsSnapshotVersion({ reason: "remote-node" });
|
||||||
|
|||||||
Reference in New Issue
Block a user