fix(gateway): resolve iOS node invokes

This commit is contained in:
Peter Steinberger
2025-12-14 00:00:05 +00:00
parent 17e183f5cf
commit aef18b7359
7 changed files with 185 additions and 20 deletions

View File

@@ -1,5 +1,5 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { callGateway } from "../gateway/call.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
type NodesRpcOpts = { type NodesRpcOpts = {
@@ -11,6 +11,16 @@ type NodesRpcOpts = {
command?: string; command?: string;
params?: string; params?: string;
invokeTimeout?: string; invokeTimeout?: string;
idempotencyKey?: string;
};
type NodeListNode = {
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
connected?: boolean;
}; };
type PendingRequest = { type PendingRequest = {
@@ -40,11 +50,15 @@ type PairingList = {
paired: PairedNode[]; paired: PairedNode[];
}; };
const nodesCallOpts = (cmd: Command) => const nodesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
cmd cmd
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789") .option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
.option("--token <token>", "Gateway token (if required)") .option("--token <token>", "Gateway token (if required)")
.option("--timeout <ms>", "Timeout in ms", "10000") .option(
"--timeout <ms>",
"Timeout in ms",
String(defaults?.timeoutMs ?? 10_000),
)
.option("--json", "Output JSON", false); .option("--json", "Output JSON", false);
const callGatewayCli = async ( const callGatewayCli = async (
@@ -85,6 +99,67 @@ function parsePairingList(value: unknown): PairingList {
return { pending, paired }; return { pending, paired };
} }
function parseNodeList(value: unknown): NodeListNode[] {
const obj =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : [];
}
function normalizeNodeKey(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
}
async function resolveNodeId(opts: NodesRpcOpts, query: string) {
const q = String(query ?? "").trim();
if (!q) throw new Error("node required");
let nodes: NodeListNode[] = [];
try {
const res = (await callGatewayCli("node.list", opts, {})) as unknown;
nodes = parseNodeList(res);
} catch {
const res = (await callGatewayCli("node.pair.list", opts, {})) as unknown;
const { paired } = parsePairingList(res);
nodes = paired.map((n) => ({
nodeId: n.nodeId,
displayName: n.displayName,
platform: n.platform,
version: n.version,
remoteIp: n.remoteIp,
}));
}
const qNorm = normalizeNodeKey(q);
const matches = nodes.filter((n) => {
if (n.nodeId === q) return true;
if (typeof n.remoteIp === "string" && n.remoteIp === q) return true;
const name = typeof n.displayName === "string" ? n.displayName : "";
if (name && normalizeNodeKey(name) === qNorm) return true;
if (q.length >= 6 && n.nodeId.startsWith(q)) return true;
return false;
});
if (matches.length === 1) return matches[0].nodeId;
if (matches.length === 0) {
const known = nodes
.map((n) => n.displayName || n.remoteIp || n.nodeId)
.filter(Boolean)
.join(", ");
throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`);
}
throw new Error(
`ambiguous node: ${q} (matches: ${matches
.map((n) => n.displayName || n.remoteIp || n.nodeId)
.join(", ")})`,
);
}
export function registerNodesCli(program: Command) { export function registerNodesCli(program: Command) {
const nodes = program const nodes = program
.command("nodes") .command("nodes")
@@ -215,32 +290,38 @@ export function registerNodesCli(program: Command) {
nodesCallOpts( nodesCallOpts(
nodes nodes
.command("invoke") .command("invoke")
.description("Invoke a command on a connected node") .description("Invoke a command on a paired node")
.requiredOption("--node <nodeId>", "Node id (instanceId)") .requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.requiredOption("--command <command>", "Command (e.g. screen.eval)") .requiredOption("--command <command>", "Command (e.g. screen.eval)")
.option("--params <json>", "JSON object string for params") .option("--params <json>", "JSON object string for params", "{}")
.option( .option(
"--invoke-timeout <ms>", "--invoke-timeout <ms>",
"Node invoke timeout in ms (default 15000)", "Node invoke timeout in ms (default 15000)",
"15000",
) )
.option("--idempotency-key <key>", "Idempotency key (optional)")
.action(async (opts: NodesRpcOpts) => { .action(async (opts: NodesRpcOpts) => {
try { try {
const nodeId = String(opts.node ?? "").trim(); const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const command = String(opts.command ?? "").trim(); const command = String(opts.command ?? "").trim();
if (!nodeId || !command) { if (!nodeId || !command) {
defaultRuntime.error("--node and --command required"); defaultRuntime.error("--node and --command required");
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
const params = opts.params const params = JSON.parse(String(opts.params ?? "{}")) as unknown;
? (JSON.parse(String(opts.params)) as unknown)
: undefined;
const timeoutMs = opts.invokeTimeout const timeoutMs = opts.invokeTimeout
? Number.parseInt(String(opts.invokeTimeout), 10) ? Number.parseInt(String(opts.invokeTimeout), 10)
: undefined; : undefined;
const invokeParams: Record<string, unknown> = { nodeId, command }; const invokeParams: Record<string, unknown> = {
if (params !== undefined) invokeParams.params = params; nodeId,
command,
params,
idempotencyKey: String(
opts.idempotencyKey ?? randomIdempotencyKey(),
),
};
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
invokeParams.timeoutMs = timeoutMs; invokeParams.timeoutMs = timeoutMs;
} }
@@ -250,11 +331,13 @@ export function registerNodesCli(program: Command) {
opts, opts,
invokeParams, invokeParams,
); );
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) { } catch (err) {
defaultRuntime.error(`nodes invoke failed: ${String(err)}`); defaultRuntime.error(`nodes invoke failed: ${String(err)}`);
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
}), }),
{ timeoutMs: 30_000 },
); );
} }

View File

@@ -22,6 +22,7 @@ vi.mock("../provider-web.js", () => ({
})); }));
vi.mock("../gateway/call.js", () => ({ vi.mock("../gateway/call.js", () => ({
callGateway, callGateway,
randomIdempotencyKey: () => "idem-test",
})); }));
vi.mock("../webchat/server.js", () => ({ vi.mock("../webchat/server.js", () => ({
startWebChatServer, startWebChatServer,
@@ -93,12 +94,24 @@ describe("cli program", () => {
}); });
it("runs nodes invoke and calls node.invoke", async () => { it("runs nodes invoke and calls node.invoke", async () => {
callGateway.mockResolvedValue({ callGateway
ok: true, .mockResolvedValueOnce({
nodeId: "ios-node", ts: Date.now(),
command: "screen.eval", nodes: [
payload: { result: "ok" }, {
}); nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
})
.mockResolvedValueOnce({
ok: true,
nodeId: "ios-node",
command: "screen.eval",
payload: { result: "ok" },
});
const program = buildProgram(); const program = buildProgram();
runtime.log.mockClear(); runtime.log.mockClear();
@@ -116,13 +129,20 @@ describe("cli program", () => {
{ from: "user" }, { from: "user" },
); );
expect(callGateway).toHaveBeenCalledWith( expect(callGateway).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ method: "node.list", params: {} }),
);
expect(callGateway).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ expect.objectContaining({
method: "node.invoke", method: "node.invoke",
params: { params: {
nodeId: "ios-node", nodeId: "ios-node",
command: "screen.eval", command: "screen.eval",
params: { javaScript: "1+1" }, params: { javaScript: "1+1" },
timeoutMs: 15000,
idempotencyKey: "idem-test",
}, },
}), }),
); );

View File

@@ -51,7 +51,8 @@ export class GatewayClient {
start() { start() {
if (this.closed) return; if (this.closed) return;
const url = this.opts.url ?? "ws://127.0.0.1:18789"; const url = this.opts.url ?? "ws://127.0.0.1:18789";
this.ws = new WebSocket(url, { maxPayload: 512 * 1024 }); // Allow node screen snapshots and other large responses.
this.ws = new WebSocket(url, { maxPayload: 10 * 1024 * 1024 });
this.ws.on("open", () => this.sendConnect()); this.ws.on("open", () => this.sendConnect());
this.ws.on("message", (data) => this.handleMessage(data.toString())); this.ws.on("message", (data) => this.handleMessage(data.toString()));

View File

@@ -38,6 +38,8 @@ import {
HelloOkSchema, HelloOkSchema,
type NodeInvokeParams, type NodeInvokeParams,
NodeInvokeParamsSchema, NodeInvokeParamsSchema,
type NodeListParams,
NodeListParamsSchema,
type NodePairApproveParams, type NodePairApproveParams,
NodePairApproveParamsSchema, NodePairApproveParamsSchema,
type NodePairListParams, type NodePairListParams,
@@ -105,6 +107,8 @@ export const validateNodePairRejectParams = ajv.compile<NodePairRejectParams>(
export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>( export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>(
NodePairVerifyParamsSchema, NodePairVerifyParamsSchema,
); );
export const validateNodeListParams =
ajv.compile<NodeListParams>(NodeListParamsSchema);
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>( export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(
NodeInvokeParamsSchema, NodeInvokeParamsSchema,
); );
@@ -163,6 +167,7 @@ export {
NodePairApproveParamsSchema, NodePairApproveParamsSchema,
NodePairRejectParamsSchema, NodePairRejectParamsSchema,
NodePairVerifyParamsSchema, NodePairVerifyParamsSchema,
NodeListParamsSchema,
NodeInvokeParamsSchema, NodeInvokeParamsSchema,
SessionsListParamsSchema, SessionsListParamsSchema,
SessionsPatchParamsSchema, SessionsPatchParamsSchema,
@@ -205,6 +210,7 @@ export type {
NodePairApproveParams, NodePairApproveParams,
NodePairRejectParams, NodePairRejectParams,
NodePairVerifyParams, NodePairVerifyParams,
NodeListParams,
NodeInvokeParams, NodeInvokeParams,
SessionsListParams, SessionsListParams,
SessionsPatchParams, SessionsPatchParams,

View File

@@ -243,12 +243,18 @@ export const NodePairVerifyParamsSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
); );
export const NodeListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const NodeInvokeParamsSchema = Type.Object( export const NodeInvokeParamsSchema = Type.Object(
{ {
nodeId: NonEmptyString, nodeId: NonEmptyString,
command: NonEmptyString, command: NonEmptyString,
params: Type.Optional(Type.Unknown()), params: Type.Optional(Type.Unknown()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
idempotencyKey: NonEmptyString,
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
@@ -507,6 +513,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
NodePairApproveParams: NodePairApproveParamsSchema, NodePairApproveParams: NodePairApproveParamsSchema,
NodePairRejectParams: NodePairRejectParamsSchema, NodePairRejectParams: NodePairRejectParamsSchema,
NodePairVerifyParams: NodePairVerifyParamsSchema, NodePairVerifyParams: NodePairVerifyParamsSchema,
NodeListParams: NodeListParamsSchema,
NodeInvokeParams: NodeInvokeParamsSchema, NodeInvokeParams: NodeInvokeParamsSchema,
SessionsListParams: SessionsListParamsSchema, SessionsListParams: SessionsListParamsSchema,
SessionsPatchParams: SessionsPatchParamsSchema, SessionsPatchParams: SessionsPatchParamsSchema,
@@ -545,6 +552,7 @@ export type NodePairListParams = Static<typeof NodePairListParamsSchema>;
export type NodePairApproveParams = Static<typeof NodePairApproveParamsSchema>; export type NodePairApproveParams = Static<typeof NodePairApproveParamsSchema>;
export type NodePairRejectParams = Static<typeof NodePairRejectParamsSchema>; export type NodePairRejectParams = Static<typeof NodePairRejectParamsSchema>;
export type NodePairVerifyParams = Static<typeof NodePairVerifyParamsSchema>; export type NodePairVerifyParams = Static<typeof NodePairVerifyParamsSchema>;
export type NodeListParams = Static<typeof NodeListParamsSchema>;
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>; export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
export type SessionsListParams = Static<typeof SessionsListParamsSchema>; export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>; export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;

View File

@@ -399,6 +399,7 @@ describe("gateway server", () => {
command: "screen.eval", command: "screen.eval",
params: { javaScript: "2+2" }, params: { javaScript: "2+2" },
timeoutMs: 123, timeoutMs: 123,
idempotencyKey: "idem-1",
}); });
expect(res.ok).toBe(true); expect(res.ok).toBe(true);

View File

@@ -101,6 +101,7 @@ import {
validateCronStatusParams, validateCronStatusParams,
validateCronUpdateParams, validateCronUpdateParams,
validateNodeInvokeParams, validateNodeInvokeParams,
validateNodeListParams,
validateNodePairApproveParams, validateNodePairApproveParams,
validateNodePairListParams, validateNodePairListParams,
validateNodePairRejectParams, validateNodePairRejectParams,
@@ -177,6 +178,7 @@ const METHODS = [
"node.pair.approve", "node.pair.approve",
"node.pair.reject", "node.pair.reject",
"node.pair.verify", "node.pair.verify",
"node.list",
"node.invoke", "node.invoke",
"cron.list", "cron.list",
"cron.status", "cron.status",
@@ -2056,6 +2058,49 @@ export async function startGatewayServer(
} }
break; break;
} }
case "node.list": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateNodeListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid node.list params: ${formatValidationErrors(validateNodeListParams.errors)}`,
),
);
break;
}
try {
const list = await listNodePairing();
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,
remoteIp: live?.remoteIp ?? n.remoteIp,
connected: Boolean(live),
};
});
respond(true, { ts: Date.now(), nodes }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "node.invoke": { case "node.invoke": {
const params = (req.params ?? {}) as Record<string, unknown>; const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateNodeInvokeParams(params)) { if (!validateNodeInvokeParams(params)) {
@@ -2082,6 +2127,7 @@ export async function startGatewayServer(
command: string; command: string;
params?: unknown; params?: unknown;
timeoutMs?: number; timeoutMs?: number;
idempotencyKey: string;
}; };
const nodeId = String(p.nodeId ?? "").trim(); const nodeId = String(p.nodeId ?? "").trim();
const command = String(p.command ?? "").trim(); const command = String(p.command ?? "").trim();