diff --git a/src/cli/nodes-cli/pairing-render.ts b/src/cli/nodes-cli/pairing-render.ts index 8963b27f9fd..10022466c7e 100644 --- a/src/cli/nodes-cli/pairing-render.ts +++ b/src/cli/nodes-cli/pairing-render.ts @@ -19,7 +19,7 @@ export function renderPendingPairingRequestsTable(params: { IP: r.remoteIp ?? "", Requested: typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : theme.muted("unknown"), - Repair: r.isRepair ? theme.warn("yes") : "", + Repair: r.repairReason ? theme.warn(r.repairReason) : r.isRepair ? theme.warn("yes") : "", })); return { heading: theme.heading("Pending"), @@ -30,7 +30,7 @@ export function renderPendingPairingRequestsTable(params: { { key: "Node", header: "Node", minWidth: 14, flex: true }, { key: "IP", header: "IP", minWidth: 10 }, { key: "Requested", header: "Requested", minWidth: 12 }, - { key: "Repair", header: "Repair", minWidth: 6 }, + { key: "Repair", header: "Repair", minWidth: 12 }, ], rows, }).trimEnd(), diff --git a/src/gateway/node-catalog.test.ts b/src/gateway/node-catalog.test.ts index 6e01acbfc12..38d1c4629b5 100644 --- a/src/gateway/node-catalog.test.ts +++ b/src/gateway/node-catalog.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "./node-catalog.js"; +import { + createKnownNodeCatalog, + getKnownNode, + getKnownNodeEntry, + listKnownNodes, +} from "./node-catalog.js"; describe("gateway/node-catalog", () => { it("filters paired nodes by active node token instead of sticky historical roles", () => { @@ -43,6 +48,7 @@ describe("gateway/node-catalog", () => { approvedAtMs: 1, }, ], + pairedNodes: [], connectedNodes: [], }); @@ -74,6 +80,22 @@ describe("gateway/node-catalog", () => { approvedAtMs: 99, }, ], + pairedNodes: [ + { + nodeId: "mac-1", + token: "node-token", + displayName: "Mac", + platform: "darwin", + version: "1.2.0", + coreVersion: "1.2.0", + uiVersion: "1.2.0", + remoteIp: "100.0.0.9", + caps: ["camera"], + commands: ["system.run"], + createdAtMs: 1, + approvedAtMs: 100, + }, + ], connectedNodes: [ { nodeId: "mac-1", @@ -84,8 +106,8 @@ describe("gateway/node-catalog", () => { displayName: "Mac", platform: "darwin", version: "1.2.3", - caps: ["screen"], - commands: ["screen.snapshot"], + caps: ["camera", "screen"], + commands: ["screen.snapshot", "system.run"], remoteIp: "100.0.0.11", pathEnv: "/usr/bin:/bin", connectedAtMs, @@ -93,6 +115,14 @@ describe("gateway/node-catalog", () => { ], }); + const entry = getKnownNodeEntry(catalog, "mac-1"); + expect(entry?.nodePairing).toEqual( + expect.objectContaining({ + commands: ["system.run"], + caps: ["camera"], + approvedAtMs: 100, + }), + ); expect(getKnownNode(catalog, "mac-1")).toEqual( expect.objectContaining({ nodeId: "mac-1", @@ -100,14 +130,72 @@ describe("gateway/node-catalog", () => { clientId: "openclaw-macos", clientMode: "node", remoteIp: "100.0.0.11", - caps: ["screen"], - commands: ["screen.snapshot"], + caps: ["camera", "screen"], + commands: ["screen.snapshot", "system.run"], pathEnv: "/usr/bin:/bin", - approvedAtMs: 99, + approvedAtMs: 100, connectedAtMs, paired: true, connected: true, }), ); }); + + it("surfaces node-pair metadata even when the node is offline", () => { + const catalog = createKnownNodeCatalog({ + pairedDevices: [ + { + deviceId: "mac-1", + publicKey: "public-key", + displayName: "Mac", + clientId: "openclaw-macos", + clientMode: "node", + role: "node", + roles: ["node"], + tokens: { + node: { + token: "current-token", + role: "node", + scopes: [], + createdAtMs: 1, + }, + }, + createdAtMs: 1, + approvedAtMs: 99, + }, + ], + pairedNodes: [ + { + nodeId: "mac-1", + token: "node-token", + platform: "darwin", + caps: ["system"], + commands: ["system.run"], + createdAtMs: 1, + approvedAtMs: 123, + }, + ], + connectedNodes: [], + }); + + const entry = getKnownNodeEntry(catalog, "mac-1"); + expect(entry?.live).toBeUndefined(); + expect(entry?.nodePairing).toEqual( + expect.objectContaining({ + commands: ["system.run"], + caps: ["system"], + approvedAtMs: 123, + }), + ); + expect(getKnownNode(catalog, "mac-1")).toEqual( + expect.objectContaining({ + nodeId: "mac-1", + caps: ["system"], + commands: ["system.run"], + approvedAtMs: 123, + paired: true, + connected: false, + }), + ); + }); }); diff --git a/src/gateway/node-catalog.ts b/src/gateway/node-catalog.ts index 39d1496633d..079c1cb752b 100644 --- a/src/gateway/node-catalog.ts +++ b/src/gateway/node-catalog.ts @@ -1,10 +1,44 @@ import { hasEffectivePairedDeviceRole, type PairedDevice } from "../infra/device-pairing.js"; +import type { NodePairingPairedNode } from "../infra/node-pairing.js"; import type { NodeListNode } from "../shared/node-list-types.js"; import type { NodeSession } from "./node-registry.js"; +export type KnownNodeDevicePairingSource = { + nodeId: string; + displayName?: string; + platform?: string; + clientId?: string; + clientMode?: string; + remoteIp?: string; + approvedAtMs?: number; +}; + +export type KnownNodeApprovedSource = { + nodeId: string; + displayName?: string; + platform?: string; + version?: string; + coreVersion?: string; + uiVersion?: string; + remoteIp?: string; + deviceFamily?: string; + modelIdentifier?: string; + caps: string[]; + commands: string[]; + permissions?: Record; + approvedAtMs?: number; +}; + +export type KnownNodeEntry = { + nodeId: string; + devicePairing?: KnownNodeDevicePairingSource; + nodePairing?: KnownNodeApprovedSource; + live?: NodeSession; + effective: NodeListNode; +}; + export type KnownNodeCatalog = { - pairedById: Map; - connectedById: Map; + entriesById: Map; }; function uniqueSortedStrings(...items: Array): string[] { @@ -23,53 +57,62 @@ function uniqueSortedStrings(...items: Array): st return [...values].toSorted((left, right) => left.localeCompare(right)); } -function buildPairedNodeRecord(entry: PairedDevice): NodeListNode { +function buildDevicePairingSource(entry: PairedDevice): KnownNodeDevicePairingSource { return { nodeId: entry.deviceId, displayName: entry.displayName, platform: entry.platform, - version: undefined, - coreVersion: undefined, - uiVersion: undefined, clientId: entry.clientId, clientMode: entry.clientMode, - deviceFamily: undefined, - modelIdentifier: undefined, remoteIp: entry.remoteIp, - caps: [], - commands: [], - permissions: undefined, approvedAtMs: entry.approvedAtMs, - paired: true, - connected: false, }; } -function buildKnownNodeEntry(params: { +function buildApprovedNodeSource(entry: NodePairingPairedNode): KnownNodeApprovedSource { + return { + nodeId: entry.nodeId, + displayName: entry.displayName, + platform: entry.platform, + version: entry.version, + coreVersion: entry.coreVersion, + uiVersion: entry.uiVersion, + remoteIp: entry.remoteIp, + deviceFamily: entry.deviceFamily, + modelIdentifier: entry.modelIdentifier, + caps: entry.caps ?? [], + commands: entry.commands ?? [], + permissions: entry.permissions, + approvedAtMs: entry.approvedAtMs, + }; +} + +function buildEffectiveKnownNode(entry: { nodeId: string; - paired?: NodeListNode; + devicePairing?: KnownNodeDevicePairingSource; + nodePairing?: KnownNodeApprovedSource; live?: NodeSession; }): NodeListNode { - const { nodeId, paired, live } = params; + const { nodeId, devicePairing, nodePairing, live } = entry; return { nodeId, - displayName: live?.displayName ?? paired?.displayName, - platform: live?.platform ?? paired?.platform, - version: live?.version ?? paired?.version, - coreVersion: live?.coreVersion ?? paired?.coreVersion, - uiVersion: live?.uiVersion ?? paired?.uiVersion, - clientId: live?.clientId ?? paired?.clientId, - clientMode: live?.clientMode ?? paired?.clientMode, - deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, - modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier, - remoteIp: live?.remoteIp ?? paired?.remoteIp, - caps: uniqueSortedStrings(live?.caps, paired?.caps), - commands: uniqueSortedStrings(live?.commands, paired?.commands), + displayName: live?.displayName ?? nodePairing?.displayName ?? devicePairing?.displayName, + platform: live?.platform ?? nodePairing?.platform ?? devicePairing?.platform, + version: live?.version ?? nodePairing?.version, + coreVersion: live?.coreVersion ?? nodePairing?.coreVersion, + uiVersion: live?.uiVersion ?? nodePairing?.uiVersion, + clientId: live?.clientId ?? devicePairing?.clientId, + clientMode: live?.clientMode ?? devicePairing?.clientMode, + deviceFamily: live?.deviceFamily ?? nodePairing?.deviceFamily, + modelIdentifier: live?.modelIdentifier ?? nodePairing?.modelIdentifier, + remoteIp: live?.remoteIp ?? nodePairing?.remoteIp ?? devicePairing?.remoteIp, + caps: uniqueSortedStrings(live?.caps, nodePairing?.caps), + commands: uniqueSortedStrings(live?.commands, nodePairing?.commands), pathEnv: live?.pathEnv, - permissions: live?.permissions ?? paired?.permissions, + permissions: live?.permissions ?? nodePairing?.permissions, connectedAtMs: live?.connectedAtMs, - approvedAtMs: paired?.approvedAtMs, - paired: Boolean(paired), + approvedAtMs: nodePairing?.approvedAtMs ?? devicePairing?.approvedAtMs, + paired: Boolean(devicePairing ?? nodePairing), connected: Boolean(live), }; } @@ -91,35 +134,57 @@ function compareKnownNodes(left: NodeListNode, right: NodeListNode): number { export function createKnownNodeCatalog(params: { pairedDevices: readonly PairedDevice[]; + pairedNodes?: readonly NodePairingPairedNode[]; connectedNodes: readonly NodeSession[]; }): KnownNodeCatalog { - const pairedById = new Map( + const devicePairingById = new Map( params.pairedDevices .filter((entry) => hasEffectivePairedDeviceRole(entry, "node")) - .map((entry) => [entry.deviceId, buildPairedNodeRecord(entry)]), + .map((entry) => [entry.deviceId, buildDevicePairingSource(entry)]), ); - const connectedById = new Map(params.connectedNodes.map((entry) => [entry.nodeId, entry])); - return { pairedById, connectedById }; + const nodePairingById = new Map( + (params.pairedNodes ?? []).map((entry) => [entry.nodeId, buildApprovedNodeSource(entry)]), + ); + const liveById = new Map(params.connectedNodes.map((entry) => [entry.nodeId, entry])); + const nodeIds = new Set([ + ...devicePairingById.keys(), + ...nodePairingById.keys(), + ...liveById.keys(), + ]); + const entriesById = new Map(); + for (const nodeId of nodeIds) { + const devicePairing = devicePairingById.get(nodeId); + const nodePairing = nodePairingById.get(nodeId); + const live = liveById.get(nodeId); + entriesById.set(nodeId, { + nodeId, + devicePairing, + nodePairing, + live, + effective: buildEffectiveKnownNode({ + nodeId, + devicePairing, + nodePairing, + live, + }), + }); + } + return { entriesById }; } export function listKnownNodes(catalog: KnownNodeCatalog): NodeListNode[] { - const nodeIds = new Set([...catalog.pairedById.keys(), ...catalog.connectedById.keys()]); - return [...nodeIds] - .map((nodeId) => - buildKnownNodeEntry({ - nodeId, - paired: catalog.pairedById.get(nodeId), - live: catalog.connectedById.get(nodeId), - }), - ) + return [...catalog.entriesById.values()] + .map((entry) => entry.effective) .toSorted(compareKnownNodes); } -export function getKnownNode(catalog: KnownNodeCatalog, nodeId: string): NodeListNode | null { - const paired = catalog.pairedById.get(nodeId); - const live = catalog.connectedById.get(nodeId); - if (!paired && !live) { - return null; - } - return buildKnownNodeEntry({ nodeId, paired, live }); +export function getKnownNodeEntry( + catalog: KnownNodeCatalog, + nodeId: string, +): KnownNodeEntry | null { + return catalog.entriesById.get(nodeId) ?? null; +} + +export function getKnownNode(catalog: KnownNodeCatalog, nodeId: string): NodeListNode | null { + return getKnownNodeEntry(catalog, nodeId)?.effective ?? null; } diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts new file mode 100644 index 00000000000..b2a547562a2 --- /dev/null +++ b/src/gateway/node-command-policy.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { diffApprovedNodeCommands, normalizeDeclaredNodeCommands } from "./node-command-policy.js"; + +describe("gateway/node-command-policy", () => { + it("normalizes declared node commands against the allowlist", () => { + const allowlist = new Set(["canvas.snapshot", "system.run"]); + expect( + normalizeDeclaredNodeCommands({ + declaredCommands: [" canvas.snapshot ", "", "system.run", "system.run", "screen.record"], + allowlist, + }), + ).toEqual(["canvas.snapshot", "system.run"]); + }); + + it("reports command drift against the approved node command set", () => { + const allowlist = new Set(["canvas.snapshot", "system.run", "system.which"]); + expect( + diffApprovedNodeCommands({ + declaredCommands: ["canvas.snapshot", "system.run"], + approvedCommands: ["canvas.snapshot", "system.which"], + allowlist, + }), + ).toEqual({ + declared: ["canvas.snapshot", "system.run"], + approved: ["canvas.snapshot", "system.which"], + missingApproved: ["system.run"], + extraApproved: ["system.which"], + effective: ["canvas.snapshot"], + needsRepair: true, + }); + }); +}); diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index 6b5429e7a3f..22ddf323d10 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -191,6 +191,69 @@ export function resolveNodeCommandAllowlist( return allow; } +function normalizeDeclaredCommands(commands?: readonly string[]): string[] { + if (!Array.isArray(commands)) { + return []; + } + const seen = new Set(); + const normalized: string[] = []; + for (const value of commands) { + const trimmed = value.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + normalized.push(trimmed); + } + return normalized; +} + +export function normalizeDeclaredNodeCommands(params: { + declaredCommands?: readonly string[]; + allowlist: Set; +}): string[] { + return normalizeDeclaredCommands(params.declaredCommands).filter((command) => + params.allowlist.has(command), + ); +} + +export type NodeApprovedCommandDiff = { + declared: string[]; + approved: string[]; + missingApproved: string[]; + extraApproved: string[]; + effective: string[]; + needsRepair: boolean; +}; + +export function diffApprovedNodeCommands(params: { + declaredCommands?: readonly string[]; + approvedCommands?: readonly string[]; + allowlist: Set; +}): NodeApprovedCommandDiff { + const declared = normalizeDeclaredNodeCommands({ + declaredCommands: params.declaredCommands, + allowlist: params.allowlist, + }); + const approved = normalizeDeclaredNodeCommands({ + declaredCommands: params.approvedCommands, + allowlist: params.allowlist, + }); + const approvedSet = new Set(approved); + const declaredSet = new Set(declared); + const missingApproved = declared.filter((command) => !approvedSet.has(command)); + const extraApproved = approved.filter((command) => !declaredSet.has(command)); + const effective = declared.filter((command) => approvedSet.has(command)); + return { + declared, + approved, + missingApproved, + extraApproved, + effective, + needsRepair: missingApproved.length > 0, + }; +} + export function isNodeCommandAllowed(params: { command: string; declaredCommands?: string[]; diff --git a/src/gateway/node-connect-reconcile.ts b/src/gateway/node-connect-reconcile.ts new file mode 100644 index 00000000000..412c07650e1 --- /dev/null +++ b/src/gateway/node-connect-reconcile.ts @@ -0,0 +1,108 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { + NodePairingPairedNode, + NodePairingPendingRequest, + NodePairingRequestInput, +} from "../infra/node-pairing.js"; +import { + diffApprovedNodeCommands, + resolveNodeCommandAllowlist, + type NodeApprovedCommandDiff, +} from "./node-command-policy.js"; +import type { ConnectParams } from "./protocol/index.js"; + +type PendingNodePairingResult = { + status: "pending"; + request: NodePairingPendingRequest; + created: boolean; +}; + +export type NodeConnectPairingReconcileResult = { + nodeId: string; + commandDiff: NodeApprovedCommandDiff; + effectiveCommands: string[]; + pendingPairing?: PendingNodePairingResult; +}; + +function buildNodePairingRequestInput(params: { + nodeId: string; + connectParams: ConnectParams; + commands: string[]; + remoteIp?: string; + repairReason?: NodePairingRequestInput["repairReason"]; +}): NodePairingRequestInput { + return { + nodeId: params.nodeId, + displayName: params.connectParams.client.displayName, + platform: params.connectParams.client.platform, + version: params.connectParams.client.version, + deviceFamily: params.connectParams.client.deviceFamily, + modelIdentifier: params.connectParams.client.modelIdentifier, + caps: params.connectParams.caps, + commands: params.commands, + remoteIp: params.remoteIp, + repairReason: params.repairReason, + }; +} + +export async function reconcileNodePairingOnConnect(params: { + cfg: OpenClawConfig; + connectParams: ConnectParams; + pairedNode: NodePairingPairedNode | null; + reportedClientIp?: string; + requestPairing: (input: NodePairingRequestInput) => Promise; +}): Promise { + const nodeId = params.connectParams.device?.id ?? params.connectParams.client.id; + const allowlist = resolveNodeCommandAllowlist(params.cfg, { + platform: params.connectParams.client.platform, + deviceFamily: params.connectParams.client.deviceFamily, + }); + const commandDiff = diffApprovedNodeCommands({ + declaredCommands: Array.isArray(params.connectParams.commands) + ? params.connectParams.commands + : [], + approvedCommands: params.pairedNode?.commands, + allowlist, + }); + + if (!params.pairedNode) { + const pendingPairing = await params.requestPairing( + buildNodePairingRequestInput({ + nodeId, + connectParams: params.connectParams, + commands: commandDiff.declared, + remoteIp: params.reportedClientIp, + }), + ); + return { + nodeId, + commandDiff, + effectiveCommands: [], + pendingPairing, + }; + } + + if (commandDiff.needsRepair) { + const pendingPairing = await params.requestPairing( + buildNodePairingRequestInput({ + nodeId, + connectParams: params.connectParams, + commands: commandDiff.declared, + remoteIp: params.reportedClientIp, + repairReason: "approved-command-drift", + }), + ); + return { + nodeId, + commandDiff, + effectiveCommands: commandDiff.effective, + pendingPairing, + }; + } + + return { + nodeId, + commandDiff, + effectiveCommands: commandDiff.effective, + }; +} diff --git a/src/gateway/protocol/schema/devices.ts b/src/gateway/protocol/schema/devices.ts index 813390775c7..6dc2e9d74c1 100644 --- a/src/gateway/protocol/schema/devices.ts +++ b/src/gateway/protocol/schema/devices.ts @@ -51,6 +51,7 @@ export const DevicePairRequestedEventSchema = Type.Object( remoteIp: Type.Optional(NonEmptyString), silent: Type.Optional(Type.Boolean()), isRepair: Type.Optional(Type.Boolean()), + repairReason: Type.Optional(NonEmptyString), ts: Type.Integer({ minimum: 0 }), }, { additionalProperties: false }, diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 4083fbc75df..77446ecbd56 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -657,9 +657,13 @@ export const nodeHandlers: GatewayRequestHandlers = { return; } await respondUnavailableOnThrow(respond, async () => { - const list = await listDevicePairing(); + const [devicePairing, nodePairing] = await Promise.all([ + listDevicePairing(), + listNodePairing(), + ]); const catalog = createKnownNodeCatalog({ - pairedDevices: list.paired, + pairedDevices: devicePairing.paired, + pairedNodes: nodePairing.paired, connectedNodes: context.nodeRegistry.listConnected(), }); const nodes = listKnownNodes(catalog); @@ -682,9 +686,13 @@ export const nodeHandlers: GatewayRequestHandlers = { return; } await respondUnavailableOnThrow(respond, async () => { - const list = await listDevicePairing(); + const [devicePairing, nodePairing] = await Promise.all([ + listDevicePairing(), + listNodePairing(), + ]); const catalog = createKnownNodeCatalog({ - pairedDevices: list.paired, + pairedDevices: devicePairing.paired, + pairedNodes: nodePairing.paired, connectedNodes: context.nodeRegistry.listConnected(), }); const node = getKnownNode(catalog, id); diff --git a/src/gateway/server.node-pairing-authz.test.ts b/src/gateway/server.node-pairing-authz.test.ts index 3da874c5298..04fa810cd33 100644 --- a/src/gateway/server.node-pairing-authz.test.ts +++ b/src/gateway/server.node-pairing-authz.test.ts @@ -1,12 +1,17 @@ import { describe, expect, test } from "vitest"; import { WebSocket } from "ws"; -import { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; -import { approveNodePairing, getPairedNode, requestNodePairing } from "../infra/node-pairing.js"; +import { + approveNodePairing, + getPairedNode, + listNodePairing, + requestNodePairing, +} from "../infra/node-pairing.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { issueOperatorToken, loadDeviceIdentity, openTrackedWs, + pairDeviceIdentity, } from "./device-authz.test-helpers.js"; import { connectGatewayClient } from "./test-helpers.e2e.js"; import { @@ -18,39 +23,25 @@ import { installGatewayTestHooks({ scope: "suite" }); -async function connectNodeClientWithPairing(params: { +async function connectNodeClient(params: { port: number; deviceIdentity: ReturnType["identity"]; commands: string[]; }) { - const connect = async () => - await connectGatewayClient({ - url: `ws://127.0.0.1:${params.port}`, - token: "secret", - role: "node", - clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, - clientDisplayName: "node-command-pin", - clientVersion: "1.0.0", - platform: "darwin", - mode: GATEWAY_CLIENT_MODES.NODE, - commands: params.commands, - deviceIdentity: params.deviceIdentity, - timeoutMessage: "timeout waiting for paired node to connect", - }); - - try { - return await connect(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("pairing required")) { - throw error; - } - const pairing = await listDevicePairing(); - for (const pending of pairing.pending) { - await approveDevicePairing(pending.requestId); - } - return await connect(); - } + return await connectGatewayClient({ + url: `ws://127.0.0.1:${params.port}`, + token: "secret", + role: "node", + clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientDisplayName: "node-command-pin", + clientVersion: "1.0.0", + platform: "darwin", + mode: GATEWAY_CLIENT_MODES.NODE, + scopes: [], + commands: params.commands, + deviceIdentity: params.deviceIdentity, + timeoutMessage: "timeout waiting for paired node to connect", + }); } describe("gateway node pairing authorization", () => { @@ -138,7 +129,13 @@ describe("gateway node pairing authorization", () => { test("pins connected node commands to the approved pairing record", async () => { const started = await startServerWithClient("secret"); - const pairedNode = loadDeviceIdentity("node-command-pin"); + const pairedNode = await pairDeviceIdentity({ + name: "node-command-pin", + role: "node", + scopes: [], + clientId: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientMode: GATEWAY_CLIENT_MODES.NODE, + }); let controlWs: WebSocket | undefined; let firstClient: Awaited> | undefined; @@ -147,7 +144,7 @@ describe("gateway node pairing authorization", () => { controlWs = await openTrackedWs(started.port); await connectOk(controlWs, { token: "secret" }); - firstClient = await connectNodeClientWithPairing({ + firstClient = await connectNodeClient({ port: started.port, deviceIdentity: pairedNode.identity, commands: ["canvas.snapshot"], @@ -161,7 +158,7 @@ describe("gateway node pairing authorization", () => { }); await approveNodePairing(request.request.requestId); - nodeClient = await connectNodeClientWithPairing({ + nodeClient = await connectNodeClient({ port: started.port, deviceIdentity: pairedNode.identity, commands: ["canvas.snapshot", "system.run"], @@ -209,31 +206,29 @@ describe("gateway node pairing authorization", () => { } }); - test("treats paired nodes without stored commands as having no approved commands", async () => { + test("requests repair pairing and restores approved commands after reconnect", async () => { const started = await startServerWithClient("secret"); - const pairedNode = loadDeviceIdentity("node-command-empty"); + const pairedNode = await pairDeviceIdentity({ + name: "node-command-empty", + role: "node", + scopes: [], + clientId: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientMode: GATEWAY_CLIENT_MODES.NODE, + }); let controlWs: WebSocket | undefined; - let firstClient: Awaited> | undefined; let nodeClient: Awaited> | undefined; try { controlWs = await openTrackedWs(started.port); await connectOk(controlWs, { token: "secret" }); - firstClient = await connectNodeClientWithPairing({ - port: started.port, - deviceIdentity: pairedNode.identity, - commands: ["canvas.snapshot"], - }); - await firstClient.stopAndWait(); - - const request = await requestNodePairing({ + const initialApproval = await requestNodePairing({ nodeId: pairedNode.identity.deviceId, platform: "darwin", }); - await approveNodePairing(request.request.requestId); + await approveNodePairing(initialApproval.request.requestId); - nodeClient = await connectNodeClientWithPairing({ + nodeClient = await connectNodeClient({ port: started.port, deviceIdentity: pairedNode.identity, commands: ["canvas.snapshot", "system.run"], @@ -258,9 +253,66 @@ describe("gateway node pairing authorization", () => { (entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected, ); expect(connectedNode?.commands ?? [], JSON.stringify(lastNodes)).toEqual([]); + + const repairDeadline = Date.now() + 2_000; + let repairRequestId = ""; + while (Date.now() < repairDeadline) { + const pairing = await listNodePairing(); + const repair = pairing.pending.find( + (entry) => entry.nodeId === pairedNode.identity.deviceId, + ); + if (repair) { + repairRequestId = repair.requestId; + expect(repair.isRepair).toBe(true); + expect(repair.repairReason).toBe("approved-command-drift"); + expect(repair.commands).toEqual(["canvas.snapshot", "system.run"]); + break; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(repairRequestId).toBeTruthy(); + + await approveNodePairing(repairRequestId); + await nodeClient.stopAndWait(); + nodeClient = await connectNodeClient({ + port: started.port, + deviceIdentity: pairedNode.identity, + commands: ["canvas.snapshot", "system.run"], + }); + + const restoredDeadline = Date.now() + 2_000; + while (Date.now() < restoredDeadline) { + const list = await rpcReq<{ + nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>; + }>(controlWs, "node.list", {}); + lastNodes = list.payload?.nodes ?? []; + const node = lastNodes.find( + (entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected, + ); + if ( + JSON.stringify(node?.commands?.toSorted() ?? []) === + JSON.stringify(["canvas.snapshot", "system.run"]) + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + const repairedNode = lastNodes.find( + (entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected, + ); + expect(repairedNode?.commands?.toSorted(), JSON.stringify(lastNodes)).toEqual([ + "canvas.snapshot", + "system.run", + ]); + + await expect(getPairedNode(pairedNode.identity.deviceId)).resolves.toEqual( + expect.objectContaining({ + commands: ["canvas.snapshot", "system.run"], + }), + ); } finally { controlWs?.close(); - await firstClient?.stopAndWait(); await nodeClient?.stopAndWait(); started.ws.close(); await started.server.close(); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 288d9ec89f1..532c07f11af 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -54,7 +54,7 @@ import { isTrustedProxyAddress, resolveClientIp, } from "../../net.js"; -import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; +import { reconcileNodePairingOnConnect } from "../../node-connect-reconcile.js"; import { checkBrowserOrigin } from "../../origin-check.js"; import { ConnectErrorDetailCodes, @@ -992,40 +992,20 @@ export function attachGatewayWsMessageHandler(params: { : null; if (role === "node") { - const cfg = loadConfig(); - const nodeId = connectParams.device?.id ?? connectParams.client.id; - const declared = Array.isArray(connectParams.commands) ? connectParams.commands : []; - const allowlist = resolveNodeCommandAllowlist(cfg, { - platform: connectParams.client.platform, - deviceFamily: connectParams.client.deviceFamily, + const reconciliation = await reconcileNodePairingOnConnect({ + cfg: loadConfig(), + connectParams, + pairedNode: await getPairedNode(connectParams.device?.id ?? connectParams.client.id), + reportedClientIp, + requestPairing: async (input) => await requestNodePairing(input), }); - const allowlistedDeclared = declared - .map((cmd) => cmd.trim()) - .filter((cmd) => cmd.length > 0 && allowlist.has(cmd)); - let pairedNode = await getPairedNode(nodeId); - if (!pairedNode) { - const pending = await requestNodePairing({ - nodeId, - displayName: connectParams.client.displayName, - platform: connectParams.client.platform, - version: connectParams.client.version, - deviceFamily: connectParams.client.deviceFamily, - modelIdentifier: connectParams.client.modelIdentifier, - caps: connectParams.caps, - commands: allowlistedDeclared, - remoteIp: reportedClientIp, + if (reconciliation.pendingPairing?.created) { + const requestContext = buildRequestContext(); + requestContext.broadcast("node.pair.requested", reconciliation.pendingPairing.request, { + dropIfSlow: true, }); - if (pending.status === "pending" && pending.created) { - const requestContext = buildRequestContext(); - requestContext.broadcast("node.pair.requested", pending.request, { - dropIfSlow: true, - }); - } - pairedNode = await getPairedNode(nodeId); } - const pairedCommands = new Set(pairedNode?.commands ?? []); - const filtered = allowlistedDeclared.filter((cmd) => pairedCommands.has(cmd)); - connectParams.commands = filtered; + connectParams.commands = reconciliation.effectiveCommands; } const shouldTrackPresence = !isGatewayCliClient(connectParams.client); diff --git a/src/gateway/test-helpers.e2e.ts b/src/gateway/test-helpers.e2e.ts index 6504cea4c5d..0f6db445f34 100644 --- a/src/gateway/test-helpers.e2e.ts +++ b/src/gateway/test-helpers.e2e.ts @@ -30,6 +30,7 @@ export async function getFreeGatewayPort(): Promise { export async function connectGatewayClient(params: { url: string; token?: string; + deviceToken?: string; clientName?: GatewayClientName; clientDisplayName?: string; clientVersion?: string; @@ -48,6 +49,7 @@ export async function connectGatewayClient(params: { timeoutMessage?: string; }) { const role = params.role ?? "operator"; + const scopes = params.scopes ?? (role === "node" ? [] : undefined); const platform = params.platform ?? process.platform; const identityRoot = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir(); const deviceIdentity = @@ -78,6 +80,7 @@ export async function connectGatewayClient(params: { const client = new GatewayClient({ url: params.url, token: params.token, + deviceToken: params.deviceToken, connectChallengeTimeoutMs: params.connectChallengeTimeoutMs ?? 0, clientName: params.clientName ?? GATEWAY_CLIENT_NAMES.TEST, clientDisplayName: params.clientDisplayName ?? "vitest", @@ -86,7 +89,7 @@ export async function connectGatewayClient(params: { deviceFamily: params.deviceFamily, mode: params.mode ?? GATEWAY_CLIENT_MODES.TEST, role, - scopes: params.scopes, + scopes, caps: params.caps, commands: params.commands, instanceId: params.instanceId, diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 9c905ccd6dd..d894d202081 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -5,6 +5,7 @@ import { createAsyncLock, pruneExpiredPending, readJsonFile, + reconcilePendingPairingRequests, resolvePairingPaths, writeJsonAtomic, } from "./pairing-files.js"; @@ -419,57 +420,43 @@ export async function requestDevicePairing( const pendingForDevice = Object.values(state.pendingById) .filter((pending) => pending.deviceId === deviceId) .toSorted((left, right) => right.ts - left.ts); - const latestPending = pendingForDevice[0]; - if (latestPending && pendingForDevice.length === 1) { - if (samePendingApprovalSnapshot(latestPending, req)) { - const refreshed = refreshPendingDevicePairingRequest(latestPending, req, isRepair); - state.pendingById[latestPending.requestId] = refreshed; - await persistState(state, baseDir); - return { status: "pending" as const, request: refreshed, created: false }; - } - } - if (pendingForDevice.length > 0) { - const mergedRoles = mergeRoles( - ...pendingForDevice.flatMap((pending) => [pending.roles, pending.role]), - req.roles, - req.role, - ); - const mergedScopes = mergeScopes( - ...pendingForDevice.map((pending) => pending.scopes), - req.scopes, - ); - for (const pending of pendingForDevice) { - delete state.pendingById[pending.requestId]; - } - const superseded = buildPendingDevicePairingRequest({ - deviceId, - isRepair, - req: { - ...req, - role: normalizeRole(req.role) ?? latestPending?.role, - roles: mergedRoles, - scopes: mergedScopes, - // Preserve interactive visibility when superseding pending requests: - // if any previous pending request was interactive, keep this one interactive. - silent: resolveSupersededPendingSilent({ - existing: pendingForDevice, - incomingSilent: req.silent, - }), - }, - }); - state.pendingById[superseded.requestId] = superseded; - await persistState(state, baseDir); - return { status: "pending" as const, request: superseded, created: true }; - } - - const request = buildPendingDevicePairingRequest({ - deviceId, - isRepair, - req, + return await reconcilePendingPairingRequests({ + pendingById: state.pendingById, + existing: pendingForDevice, + incoming: req, + canRefreshSingle: (existing, incoming) => samePendingApprovalSnapshot(existing, incoming), + refreshSingle: (existing, incoming) => + refreshPendingDevicePairingRequest(existing, incoming, isRepair), + buildReplacement: ({ existing, incoming }) => { + const latestPending = existing[0]; + const mergedRoles = mergeRoles( + ...existing.flatMap((pending) => [pending.roles, pending.role]), + incoming.roles, + incoming.role, + ); + const mergedScopes = mergeScopes( + ...existing.map((pending) => pending.scopes), + incoming.scopes, + ); + return buildPendingDevicePairingRequest({ + deviceId, + isRepair, + req: { + ...incoming, + role: normalizeRole(incoming.role) ?? latestPending?.role, + roles: mergedRoles, + scopes: mergedScopes, + // Preserve interactive visibility when superseding pending requests: + // if any previous pending request was interactive, keep this one interactive. + silent: resolveSupersededPendingSilent({ + existing, + incomingSilent: incoming.silent, + }), + }, + }); + }, + persist: async () => await persistState(state, baseDir), }); - state.pendingById[request.requestId] = request; - await persistState(state, baseDir); - return { status: "pending" as const, request, created: true }; }); } diff --git a/src/infra/node-pairing.test.ts b/src/infra/node-pairing.test.ts index 0c4f066d390..ed21368fdc8 100644 --- a/src/infra/node-pairing.test.ts +++ b/src/infra/node-pairing.test.ts @@ -50,6 +50,47 @@ describe("node pairing tokens", () => { expect(second.request.requestId).toBe(first.request.requestId); }); + test("refreshes pending requests with newer commands and repair metadata", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-")); + const first = await requestNodePairing( + { + nodeId: "node-1", + platform: "darwin", + commands: ["canvas.snapshot"], + }, + baseDir, + ); + await approveNodePairing(first.request.requestId, baseDir); + + const second = await requestNodePairing( + { + nodeId: "node-1", + platform: "darwin", + displayName: "Updated Node", + commands: ["canvas.snapshot", "system.run"], + }, + baseDir, + ); + const third = await requestNodePairing( + { + nodeId: "node-1", + platform: "darwin", + displayName: "Updated Node", + commands: ["canvas.snapshot", "system.run", "system.which"], + }, + baseDir, + ); + + expect(second.created).toBe(true); + expect(second.request.isRepair).toBe(true); + expect(second.request.repairReason).toBe("paired-node-refresh"); + expect(third.created).toBe(false); + expect(third.request.requestId).toBe(second.request.requestId); + expect(third.request.displayName).toBe("Updated Node"); + expect(third.request.commands).toEqual(["canvas.snapshot", "system.run", "system.which"]); + expect(third.request.repairReason).toBe("paired-node-refresh"); + }); + test("generates base64url node tokens with 256-bit entropy output length", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-")); const token = await setupPairedNode(baseDir); diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index aba914ad56c..2a35bb9220f 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -5,14 +5,14 @@ import { createAsyncLock, pruneExpiredPending, readJsonFile, + reconcilePendingPairingRequests, resolvePairingPaths, - upsertPendingPairingRequest, writeJsonAtomic, } from "./pairing-files.js"; import { rejectPendingPairingRequest } from "./pairing-pending.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; -type NodePairingNodeMetadata = { +export type NodeDeclaredSurface = { nodeId: string; displayName?: string; platform?: string; @@ -27,14 +27,24 @@ type NodePairingNodeMetadata = { remoteIp?: string; }; -export type NodePairingPendingRequest = NodePairingNodeMetadata & { +export type NodeApprovedSurface = NodeDeclaredSurface; + +export type NodePairingRepairReason = "approved-command-drift" | "paired-node-refresh"; + +export type NodePairingRequestInput = NodeDeclaredSurface & { + silent?: boolean; + repairReason?: NodePairingRepairReason; +}; + +export type NodePairingPendingRequest = NodePairingRequestInput & { requestId: string; silent?: boolean; isRepair?: boolean; + repairReason?: NodePairingRepairReason; ts: number; }; -export type NodePairingPairedNode = Omit & { +export type NodePairingPairedNode = NodeApprovedSurface & { token: string; bins?: string[]; createdAtMs: number; @@ -59,6 +69,79 @@ const OPERATOR_ADMIN_SCOPE = "operator.admin"; const withLock = createAsyncLock(); +function normalizeStringList(values?: string[]): string[] | undefined { + if (!Array.isArray(values)) { + return undefined; + } + const normalized = values.map((value) => value.trim()).filter(Boolean); + return normalized.length > 0 ? normalized : []; +} + +function resolveNodePairingRepairReason(params: { + existingPairedNode: boolean; + requestedRepairReason?: NodePairingRepairReason; +}): NodePairingRepairReason | undefined { + if (params.requestedRepairReason) { + return params.requestedRepairReason; + } + if (params.existingPairedNode) { + return "paired-node-refresh"; + } + return undefined; +} + +function buildPendingNodePairingRequest(params: { + requestId?: string; + req: NodePairingRequestInput; +}): NodePairingPendingRequest { + const repairReason = params.req.repairReason; + return { + requestId: params.requestId ?? randomUUID(), + nodeId: params.req.nodeId, + displayName: params.req.displayName, + platform: params.req.platform, + version: params.req.version, + coreVersion: params.req.coreVersion, + uiVersion: params.req.uiVersion, + deviceFamily: params.req.deviceFamily, + modelIdentifier: params.req.modelIdentifier, + caps: normalizeStringList(params.req.caps), + commands: normalizeStringList(params.req.commands), + permissions: params.req.permissions, + remoteIp: params.req.remoteIp, + silent: params.req.silent, + repairReason, + isRepair: Boolean(repairReason), + ts: Date.now(), + }; +} + +function refreshPendingNodePairingRequest( + existing: NodePairingPendingRequest, + incoming: NodePairingRequestInput, +): NodePairingPendingRequest { + const repairReason = incoming.repairReason ?? existing.repairReason; + return { + ...existing, + displayName: incoming.displayName ?? existing.displayName, + platform: incoming.platform ?? existing.platform, + version: incoming.version ?? existing.version, + coreVersion: incoming.coreVersion ?? existing.coreVersion, + uiVersion: incoming.uiVersion ?? existing.uiVersion, + deviceFamily: incoming.deviceFamily ?? existing.deviceFamily, + modelIdentifier: incoming.modelIdentifier ?? existing.modelIdentifier, + caps: normalizeStringList(incoming.caps) ?? existing.caps, + commands: normalizeStringList(incoming.commands) ?? existing.commands, + permissions: incoming.permissions ?? existing.permissions, + remoteIp: incoming.remoteIp ?? existing.remoteIp, + // Preserve interactive visibility if either request needs attention. + silent: Boolean(existing.silent && incoming.silent), + repairReason, + isRepair: Boolean(repairReason), + ts: Date.now(), + }; +} + function resolveNodeApprovalRequiredScope(pending: NodePairingPendingRequest): string | null { const commands = Array.isArray(pending.commands) ? pending.commands : []; if (commands.some((command) => NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command))) { @@ -122,7 +205,7 @@ export async function getPairedNode( } export async function requestNodePairing( - req: Omit, + req: NodePairingRequestInput, baseDir?: string, ): Promise<{ status: "pending"; @@ -135,29 +218,32 @@ export async function requestNodePairing( if (!nodeId) { throw new Error("nodeId required"); } - - return await upsertPendingPairingRequest({ + const repairReason = resolveNodePairingRepairReason({ + existingPairedNode: Boolean(state.pairedByNodeId[nodeId]), + requestedRepairReason: req.repairReason, + }); + const pendingForNode = Object.values(state.pendingById) + .filter((pending) => pending.nodeId === nodeId) + .toSorted((left, right) => right.ts - left.ts); + return await reconcilePendingPairingRequests({ pendingById: state.pendingById, - isExisting: (pending) => pending.nodeId === nodeId, - isRepair: Boolean(state.pairedByNodeId[nodeId]), - createRequest: (isRepair) => ({ - requestId: randomUUID(), + existing: pendingForNode, + incoming: { + ...req, nodeId, - displayName: req.displayName, - platform: req.platform, - version: req.version, - coreVersion: req.coreVersion, - uiVersion: req.uiVersion, - deviceFamily: req.deviceFamily, - modelIdentifier: req.modelIdentifier, - caps: req.caps, - commands: req.commands, - permissions: req.permissions, - remoteIp: req.remoteIp, - silent: req.silent, - isRepair, - ts: Date.now(), - }), + repairReason, + }, + canRefreshSingle: () => true, + refreshSingle: (existing, incoming) => refreshPendingNodePairingRequest(existing, incoming), + buildReplacement: ({ existing, incoming }) => + buildPendingNodePairingRequest({ + req: { + ...incoming, + silent: Boolean( + incoming.silent && existing.every((pending) => pending.silent === true), + ), + }, + }), persist: async () => await persistState(state, baseDir), }); }); diff --git a/src/infra/pairing-files.test.ts b/src/infra/pairing-files.test.ts index ad3ac504fac..59a963db805 100644 --- a/src/infra/pairing-files.test.ts +++ b/src/infra/pairing-files.test.ts @@ -2,8 +2,8 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { pruneExpiredPending, + reconcilePendingPairingRequests, resolvePairingPaths, - upsertPendingPairingRequest, } from "./pairing-files.js"; describe("pairing file helpers", () => { @@ -30,57 +30,59 @@ describe("pairing file helpers", () => { }); }); - it("reuses existing pending requests without persisting again", async () => { + it("refreshes a single matching pending request in place", async () => { const persist = vi.fn(async () => undefined); - const existing = { requestId: "req-1", deviceId: "device-1", ts: 1 }; + const existing = { requestId: "req-1", deviceId: "device-1", ts: 1, version: 1 }; const pendingById = { "req-1": existing }; await expect( - upsertPendingPairingRequest({ + reconcilePendingPairingRequests({ pendingById, - isExisting: (pending) => pending.deviceId === "device-1", - createRequest: vi.fn(() => ({ requestId: "req-2", deviceId: "device-1", ts: 2 })), - isRepair: false, + existing: [existing], + incoming: { version: 2 }, + canRefreshSingle: () => true, + refreshSingle: (pending, incoming) => ({ ...pending, version: incoming.version, ts: 2 }), + buildReplacement: vi.fn(() => ({ requestId: "req-2", deviceId: "device-1", ts: 2 })), persist, }), ).resolves.toEqual({ status: "pending", - request: existing, + request: { requestId: "req-1", deviceId: "device-1", ts: 2, version: 2 }, created: false, }); - expect(persist).not.toHaveBeenCalled(); + expect(persist).toHaveBeenCalledOnce(); }); - it("creates and persists new pending requests with the repair flag", async () => { + it("replaces existing pending requests with one merged request", async () => { const persist = vi.fn(async () => undefined); - const createRequest = vi.fn((isRepair: boolean) => ({ - requestId: "req-2", - deviceId: "device-2", - ts: 2, - isRepair, - })); - const pendingById: Record< - string, - { requestId: string; deviceId: string; ts: number; isRepair: boolean } - > = {}; + const pendingById = { + "req-1": { requestId: "req-1", deviceId: "device-2", ts: 1 }, + "req-2": { requestId: "req-2", deviceId: "device-2", ts: 2 }, + }; await expect( - upsertPendingPairingRequest({ + reconcilePendingPairingRequests({ pendingById, - isExisting: (pending) => pending.deviceId === "device-2", - createRequest, - isRepair: true, + existing: Object.values(pendingById).toSorted((left, right) => right.ts - left.ts), + incoming: { deviceId: "device-2" }, + canRefreshSingle: () => false, + refreshSingle: (pending) => pending, + buildReplacement: vi.fn(() => ({ + requestId: "req-3", + deviceId: "device-2", + ts: 3, + isRepair: true, + })), persist, }), ).resolves.toEqual({ status: "pending", - request: { requestId: "req-2", deviceId: "device-2", ts: 2, isRepair: true }, + request: { requestId: "req-3", deviceId: "device-2", ts: 3, isRepair: true }, created: true, }); - expect(createRequest).toHaveBeenCalledWith(true); expect(persist).toHaveBeenCalledOnce(); expect(pendingById).toEqual({ - "req-2": { requestId: "req-2", deviceId: "device-2", ts: 2, isRepair: true }, + "req-3": { requestId: "req-3", deviceId: "device-2", ts: 3, isRepair: true }, }); }); }); diff --git a/src/infra/pairing-files.ts b/src/infra/pairing-files.ts index 6c5bf0ee738..06e2f729812 100644 --- a/src/infra/pairing-files.ts +++ b/src/infra/pairing-files.ts @@ -31,19 +31,36 @@ export type PendingPairingRequestResult = { created: boolean; }; -export async function upsertPendingPairingRequest(params: { +export async function reconcilePendingPairingRequests< + TPending extends { requestId: string }, + TIncoming, +>(params: { pendingById: Record; - isExisting: (pending: TPending) => boolean; - createRequest: (isRepair: boolean) => TPending; - isRepair: boolean; + existing: readonly TPending[]; + incoming: TIncoming; + canRefreshSingle: (existing: TPending, incoming: TIncoming) => boolean; + refreshSingle: (existing: TPending, incoming: TIncoming) => TPending; + buildReplacement: (params: { existing: readonly TPending[]; incoming: TIncoming }) => TPending; persist: () => Promise; }): Promise> { - const existing = Object.values(params.pendingById).find(params.isExisting); - if (existing) { - return { status: "pending", request: existing, created: false }; + if ( + params.existing.length === 1 && + params.canRefreshSingle(params.existing[0], params.incoming) + ) { + const refreshed = params.refreshSingle(params.existing[0], params.incoming); + params.pendingById[refreshed.requestId] = refreshed; + await params.persist(); + return { status: "pending", request: refreshed, created: false }; } - const request = params.createRequest(params.isRepair); + for (const existing of params.existing) { + delete params.pendingById[existing.requestId]; + } + + const request = params.buildReplacement({ + existing: params.existing, + incoming: params.incoming, + }); params.pendingById[request.requestId] = request; await params.persist(); return { status: "pending", request, created: true }; diff --git a/src/shared/node-list-types.ts b/src/shared/node-list-types.ts index acd312184c0..dcfa871daa7 100644 --- a/src/shared/node-list-types.ts +++ b/src/shared/node-list-types.ts @@ -30,6 +30,7 @@ export type PendingRequest = { uiVersion?: string; remoteIp?: string; isRepair?: boolean; + repairReason?: string; ts: number; };