From 742027a4475be2afa3aad07d6a28cad1f3ea4729 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 18 Dec 2025 02:05:12 +0000 Subject: [PATCH] Gateway: list/describe node capabilities and commands --- src/gateway/protocol/index.ts | 5 + src/gateway/protocol/schema.ts | 11 ++ src/gateway/server.test.ts | 229 ++++++++++++++++++++++++++++----- src/gateway/server.ts | 188 +++++++++++++++++++++++---- 4 files changed, 375 insertions(+), 58 deletions(-) diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 0da06cb59..3e31ff1b5 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -38,6 +38,8 @@ import { GatewayFrameSchema, type HelloOk, HelloOkSchema, + type NodeDescribeParams, + NodeDescribeParamsSchema, type NodeInvokeParams, NodeInvokeParamsSchema, type NodeListParams, @@ -111,6 +113,9 @@ export const validateNodePairVerifyParams = ajv.compile( ); export const validateNodeListParams = ajv.compile(NodeListParamsSchema); +export const validateNodeDescribeParams = ajv.compile( + NodeDescribeParamsSchema, +); export const validateNodeInvokeParams = ajv.compile( NodeInvokeParamsSchema, ); diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index ce9bea42c..e78c68cf2 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -222,6 +222,10 @@ export const NodePairRequestParamsSchema = Type.Object( displayName: Type.Optional(NonEmptyString), platform: Type.Optional(NonEmptyString), version: Type.Optional(NonEmptyString), + deviceFamily: Type.Optional(NonEmptyString), + modelIdentifier: Type.Optional(NonEmptyString), + caps: Type.Optional(Type.Array(NonEmptyString)), + commands: Type.Optional(Type.Array(NonEmptyString)), remoteIp: Type.Optional(NonEmptyString), }, { additionalProperties: false }, @@ -252,6 +256,11 @@ export const NodeListParamsSchema = Type.Object( { additionalProperties: false }, ); +export const NodeDescribeParamsSchema = Type.Object( + { nodeId: NonEmptyString }, + { additionalProperties: false }, +); + export const NodeInvokeParamsSchema = Type.Object( { nodeId: NonEmptyString, @@ -528,6 +537,7 @@ export const ProtocolSchemas: Record = { NodePairRejectParams: NodePairRejectParamsSchema, NodePairVerifyParams: NodePairVerifyParamsSchema, NodeListParams: NodeListParamsSchema, + NodeDescribeParams: NodeDescribeParamsSchema, NodeInvokeParams: NodeInvokeParamsSchema, SessionsListParams: SessionsListParamsSchema, SessionsPatchParams: SessionsPatchParamsSchema, @@ -568,6 +578,7 @@ export type NodePairApproveParams = Static; export type NodePairRejectParams = Static; export type NodePairVerifyParams = Static; export type NodeListParams = Static; +export type NodeDescribeParams = Static; export type NodeInvokeParams = Static; export type SessionsListParams = Static; export type SessionsPatchParams = Static; diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index f6836b615..ba8aa0932 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -18,6 +18,10 @@ type BridgeClientInfo = { platform?: string; version?: string; remoteIp?: string; + deviceFamily?: string; + modelIdentifier?: string; + caps?: string[]; + commands?: string[]; }; type BridgeStartOpts = { @@ -543,23 +547,188 @@ describe("gateway server", () => { try { await connectOk(ws); - const res = await rpcReq(ws, "node.invoke", { - nodeId: "ios-node", - command: "canvas.eval", - params: { javaScript: "2+2" }, - timeoutMs: 123, - idempotencyKey: "idem-1", - }); + const res = await rpcReq(ws, "node.invoke", { + nodeId: "ios-node", + command: "canvas.eval", + params: { javaScript: "2+2" }, + timeoutMs: 123, + idempotencyKey: "idem-1", + }); expect(res.ok).toBe(true); - expect(bridgeInvoke).toHaveBeenCalledWith( - expect.objectContaining({ - nodeId: "ios-node", - command: "canvas.eval", - paramsJSON: JSON.stringify({ javaScript: "2+2" }), - timeoutMs: 123, - }), - ); + expect(bridgeInvoke).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: "ios-node", + command: "canvas.eval", + paramsJSON: JSON.stringify({ javaScript: "2+2" }), + timeoutMs: 123, + }), + ); + } finally { + ws.close(); + await server.close(); + } + } finally { + await fs.rm(homeDir, { recursive: true, force: true }); + if (prevHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = prevHome; + } + } + }); + + test("node.describe returns supported invoke commands for paired nodes", async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); + const prevHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + const { server, ws } = await startServerWithClient(); + try { + await connectOk(ws); + + const reqRes = await rpcReq<{ + status?: string; + request?: { requestId?: string }; + }>(ws, "node.pair.request", { + nodeId: "n1", + displayName: "iPad", + platform: "iPadOS", + version: "dev", + deviceFamily: "iPad", + modelIdentifier: "iPad16,6", + caps: ["canvas", "camera"], + commands: ["canvas.eval", "canvas.snapshot", "camera.snap"], + remoteIp: "10.0.0.10", + }); + expect(reqRes.ok).toBe(true); + const requestId = reqRes.payload?.request?.requestId; + expect(typeof requestId).toBe("string"); + + const approveRes = await rpcReq(ws, "node.pair.approve", { + requestId, + }); + expect(approveRes.ok).toBe(true); + + const describeRes = await rpcReq<{ commands?: string[] }>( + ws, + "node.describe", + { nodeId: "n1" }, + ); + expect(describeRes.ok).toBe(true); + expect(describeRes.payload?.commands).toEqual([ + "camera.snap", + "canvas.eval", + "canvas.snapshot", + ]); + } finally { + ws.close(); + await server.close(); + } + } finally { + await fs.rm(homeDir, { recursive: true, force: true }); + if (prevHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = prevHome; + } + } + }); + + test("node.list includes connected unpaired nodes with capabilities + commands", async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); + const prevHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + const { server, ws } = await startServerWithClient(); + try { + await connectOk(ws); + + const reqRes = await rpcReq<{ + status?: string; + request?: { requestId?: string }; + }>(ws, "node.pair.request", { + nodeId: "p1", + displayName: "Paired", + platform: "iPadOS", + version: "dev", + deviceFamily: "iPad", + modelIdentifier: "iPad16,6", + caps: ["canvas"], + commands: ["canvas.eval"], + remoteIp: "10.0.0.10", + }); + expect(reqRes.ok).toBe(true); + const requestId = reqRes.payload?.request?.requestId; + expect(typeof requestId).toBe("string"); + + const approveRes = await rpcReq(ws, "node.pair.approve", { requestId }); + expect(approveRes.ok).toBe(true); + + bridgeListConnected.mockReturnValueOnce([ + { + nodeId: "p1", + displayName: "Paired Live", + platform: "iPadOS", + version: "dev-live", + remoteIp: "10.0.0.11", + deviceFamily: "iPad", + modelIdentifier: "iPad16,6", + caps: ["canvas", "camera"], + commands: ["canvas.snapshot", "canvas.eval"], + }, + { + nodeId: "u1", + displayName: "Unpaired Live", + platform: "Android", + version: "dev", + remoteIp: "10.0.0.12", + deviceFamily: "Android", + modelIdentifier: "samsung SM-X926B", + caps: ["canvas"], + commands: ["canvas.eval"], + }, + ]); + + const listRes = await rpcReq<{ + nodes?: Array<{ + nodeId: string; + paired?: boolean; + connected?: boolean; + caps?: string[]; + commands?: string[]; + displayName?: string; + remoteIp?: string; + }>; + }>(ws, "node.list", {}); + expect(listRes.ok).toBe(true); + const nodes = listRes.payload?.nodes ?? []; + + const pairedNode = nodes.find((n) => n.nodeId === "p1"); + expect(pairedNode).toMatchObject({ + nodeId: "p1", + paired: true, + connected: true, + displayName: "Paired Live", + remoteIp: "10.0.0.11", + }); + expect(pairedNode?.caps?.slice().sort()).toEqual(["camera", "canvas"]); + expect(pairedNode?.commands?.slice().sort()).toEqual([ + "canvas.eval", + "canvas.snapshot", + ]); + + const unpairedNode = nodes.find((n) => n.nodeId === "u1"); + expect(unpairedNode).toMatchObject({ + nodeId: "u1", + paired: false, + connected: true, + displayName: "Unpaired Live", + }); + expect(unpairedNode?.caps).toEqual(["canvas"]); + expect(unpairedNode?.commands).toEqual(["canvas.eval"]); } finally { ws.close(); await server.close(); @@ -2275,11 +2444,11 @@ describe("gateway server", () => { }), ); - const abortRes = await onceMessage( - ws, - (o) => o.type === "res" && o.id === "abort-mismatch-1", - 10_000, - ); + const abortRes = await onceMessage( + ws, + (o) => o.type === "res" && o.id === "abort-mismatch-1", + 10_000, + ); expect(abortRes.ok).toBe(false); expect(abortRes.error?.code).toBe("INVALID_REQUEST"); @@ -2292,18 +2461,18 @@ describe("gateway server", () => { }), ); - const abortRes2 = await onceMessage( - ws, - (o) => o.type === "res" && o.id === "abort-mismatch-2", - 10_000, - ); + const abortRes2 = await onceMessage( + ws, + (o) => o.type === "res" && o.id === "abort-mismatch-2", + 10_000, + ); expect(abortRes2.ok).toBe(true); - const sendRes = await onceMessage( - ws, - (o) => o.type === "res" && o.id === "send-mismatch-1", - 10_000, - ); + const sendRes = await onceMessage( + ws, + (o) => o.type === "res" && o.id === "send-mismatch-1", + 10_000, + ); expect(sendRes.ok).toBe(true); ws.close(); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 3033d0320..dc6384c8f 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -113,6 +113,7 @@ import { validateCronRunsParams, validateCronStatusParams, validateCronUpdateParams, + validateNodeDescribeParams, validateNodeInvokeParams, validateNodeListParams, validateNodePairApproveParams, @@ -194,6 +195,7 @@ const METHODS = [ "node.pair.reject", "node.pair.verify", "node.list", + "node.describe", "node.invoke", "cron.list", "cron.status", @@ -2777,6 +2779,10 @@ export async function startGatewayServer(port = 18789): Promise { displayName?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; + caps?: string[]; + commands?: string[]; remoteIp?: string; }; try { @@ -2785,6 +2791,10 @@ export async function startGatewayServer(port = 18789): Promise { displayName: p.displayName, platform: p.platform, version: p.version, + deviceFamily: p.deviceFamily, + modelIdentifier: p.modelIdentifier, + caps: p.caps, + commands: p.commands, remoteIp: p.remoteIp, }); if (result.status === "pending" && result.created) { @@ -2960,25 +2970,64 @@ export async function startGatewayServer(port = 18789): Promise { try { const list = await listNodePairing(); + const pairedById = new Map( + list.paired.map((n) => [n.nodeId, n]), + ); + const connected = bridge?.listConnected?.() ?? []; const connectedById = new Map( connected.map((n) => [n.nodeId, n]), ); - const nodes = list.paired.map((n) => { - const live = connectedById.get(n.nodeId); - return { - nodeId: n.nodeId, - displayName: live?.displayName ?? n.displayName, - platform: live?.platform ?? n.platform, - version: live?.version ?? n.version, - deviceFamily: live?.deviceFamily ?? n.deviceFamily, - modelIdentifier: live?.modelIdentifier ?? n.modelIdentifier, - remoteIp: live?.remoteIp ?? n.remoteIp, - caps: live?.caps ?? n.caps, - connected: Boolean(live), - }; - }); + const nodeIds = new Set([ + ...pairedById.keys(), + ...connectedById.keys(), + ]); + + const nodes = [...nodeIds].map((nodeId) => { + const paired = pairedById.get(nodeId); + const live = connectedById.get(nodeId); + + const caps = [ + ...new Set( + (live?.caps ?? paired?.caps ?? []) + .map((c) => String(c).trim()) + .filter(Boolean), + ), + ].sort(); + + const commands = [ + ...new Set( + (live?.commands ?? paired?.commands ?? []) + .map((c) => String(c).trim()) + .filter(Boolean), + ), + ].sort(); + + return { + nodeId, + displayName: live?.displayName ?? paired?.displayName, + platform: live?.platform ?? paired?.platform, + version: live?.version ?? paired?.version, + deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, + modelIdentifier: + live?.modelIdentifier ?? paired?.modelIdentifier, + remoteIp: live?.remoteIp ?? paired?.remoteIp, + caps, + commands, + paired: Boolean(paired), + connected: Boolean(live), + }; + }); + + nodes.sort((a, b) => { + if (a.connected !== b.connected) return a.connected ? -1 : 1; + const an = (a.displayName ?? a.nodeId).toLowerCase(); + const bn = (b.displayName ?? b.nodeId).toLowerCase(); + if (an < bn) return -1; + if (an > bn) return 1; + return a.nodeId.localeCompare(b.nodeId); + }); respond(true, { ts: Date.now(), nodes }, undefined); } catch (err) { @@ -2990,6 +3039,89 @@ export async function startGatewayServer(port = 18789): Promise { } break; } + case "node.describe": { + const params = (req.params ?? {}) as Record; + if (!validateNodeDescribeParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.describe params: ${formatValidationErrors(validateNodeDescribeParams.errors)}`, + ), + ); + break; + } + const { nodeId } = params as { nodeId: string }; + const id = String(nodeId ?? "").trim(); + if (!id) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"), + ); + break; + } + + try { + const list = await listNodePairing(); + const paired = list.paired.find((n) => n.nodeId === id); + const connected = bridge?.listConnected?.() ?? []; + const live = connected.find((n) => n.nodeId === id); + + if (!paired && !live) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"), + ); + break; + } + + const caps = [ + ...new Set( + (live?.caps ?? paired?.caps ?? []) + .map((c) => String(c).trim()) + .filter(Boolean), + ), + ].sort(); + + const commands = [ + ...new Set( + (live?.commands ?? paired?.commands ?? []) + .map((c) => String(c).trim()) + .filter(Boolean), + ), + ].sort(); + + respond( + true, + { + ts: Date.now(), + nodeId: id, + displayName: live?.displayName ?? paired?.displayName, + platform: live?.platform ?? paired?.platform, + version: live?.version ?? paired?.version, + deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, + modelIdentifier: + live?.modelIdentifier ?? paired?.modelIdentifier, + remoteIp: live?.remoteIp ?? paired?.remoteIp, + caps, + commands, + paired: Boolean(paired), + connected: Boolean(live), + }, + undefined, + ); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } case "node.invoke": { const params = (req.params ?? {}) as Record; if (!validateNodeInvokeParams(params)) { @@ -3011,20 +3143,20 @@ export async function startGatewayServer(port = 18789): Promise { ); break; } - const p = params as { - nodeId: string; - command: string; - params?: unknown; - timeoutMs?: number; - idempotencyKey: string; - }; - const nodeId = String(p.nodeId ?? "").trim(); - const command = String(p.command ?? "").trim(); - if (!nodeId || !command) { - respond( - false, - undefined, - errorShape( + const p = params as { + nodeId: string; + command: string; + params?: unknown; + timeoutMs?: number; + idempotencyKey: string; + }; + const nodeId = String(p.nodeId ?? "").trim(); + const command = String(p.command ?? "").trim(); + if (!nodeId || !command) { + respond( + false, + undefined, + errorShape( ErrorCodes.INVALID_REQUEST, "nodeId and command required", ),