Gateway: list/describe node capabilities and commands

This commit is contained in:
Peter Steinberger
2025-12-18 02:05:12 +00:00
parent efed2ae30f
commit 742027a447
4 changed files with 375 additions and 58 deletions

View File

@@ -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<NodePairVerifyParams>(
);
export const validateNodeListParams =
ajv.compile<NodeListParams>(NodeListParamsSchema);
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(
NodeDescribeParamsSchema,
);
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(
NodeInvokeParamsSchema,
);

View File

@@ -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<string, TSchema> = {
NodePairRejectParams: NodePairRejectParamsSchema,
NodePairVerifyParams: NodePairVerifyParamsSchema,
NodeListParams: NodeListParamsSchema,
NodeDescribeParams: NodeDescribeParamsSchema,
NodeInvokeParams: NodeInvokeParamsSchema,
SessionsListParams: SessionsListParamsSchema,
SessionsPatchParams: SessionsPatchParamsSchema,
@@ -568,6 +578,7 @@ export type NodePairApproveParams = Static<typeof NodePairApproveParamsSchema>;
export type NodePairRejectParams = Static<typeof NodePairRejectParamsSchema>;
export type NodePairVerifyParams = Static<typeof NodePairVerifyParamsSchema>;
export type NodeListParams = Static<typeof NodeListParamsSchema>;
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;

View File

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

View File

@@ -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<GatewayServer> {
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<GatewayServer> {
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<GatewayServer> {
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<string>([
...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<GatewayServer> {
}
break;
}
case "node.describe": {
const params = (req.params ?? {}) as Record<string, unknown>;
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<string, unknown>;
if (!validateNodeInvokeParams(params)) {
@@ -3011,20 +3143,20 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
);
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",
),