refactor(gateway): extract node pairing reconciliation

This commit is contained in:
Peter Steinberger 2026-04-01 17:57:59 +09:00
parent 4590ac31cc
commit db0cea5689
No known key found for this signature in database
17 changed files with 789 additions and 255 deletions

View File

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

View File

@ -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,
}),
);
});
});

View File

@ -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<string, boolean>;
approvedAtMs?: number;
};
export type KnownNodeEntry = {
nodeId: string;
devicePairing?: KnownNodeDevicePairingSource;
nodePairing?: KnownNodeApprovedSource;
live?: NodeSession;
effective: NodeListNode;
};
export type KnownNodeCatalog = {
pairedById: Map<string, NodeListNode>;
connectedById: Map<string, NodeSession>;
entriesById: Map<string, KnownNodeEntry>;
};
function uniqueSortedStrings(...items: Array<readonly string[] | undefined>): string[] {
@ -23,53 +57,62 @@ function uniqueSortedStrings(...items: Array<readonly string[] | undefined>): 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<string>([
...devicePairingById.keys(),
...nodePairingById.keys(),
...liveById.keys(),
]);
const entriesById = new Map<string, KnownNodeEntry>();
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<string>([...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;
}

View File

@ -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,
});
});
});

View File

@ -191,6 +191,69 @@ export function resolveNodeCommandAllowlist(
return allow;
}
function normalizeDeclaredCommands(commands?: readonly string[]): string[] {
if (!Array.isArray(commands)) {
return [];
}
const seen = new Set<string>();
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>;
}): 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<string>;
}): 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[];

View File

@ -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<PendingNodePairingResult>;
}): Promise<NodeConnectPairingReconcileResult> {
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,
};
}

View File

@ -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 },

View File

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

View File

@ -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<typeof loadDeviceIdentity>["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<ReturnType<typeof connectGatewayClient>> | 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<ReturnType<typeof connectGatewayClient>> | undefined;
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | 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();

View File

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

View File

@ -30,6 +30,7 @@ export async function getFreeGatewayPort(): Promise<number> {
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,

View File

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

View File

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

View File

@ -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<NodePairingNodeMetadata, "requestId"> & {
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<NodePairingPendingRequest, "requestId" | "ts" | "isRepair">,
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),
});
});

View File

@ -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 },
});
});
});

View File

@ -31,19 +31,36 @@ export type PendingPairingRequestResult<TPending> = {
created: boolean;
};
export async function upsertPendingPairingRequest<TPending extends { requestId: string }>(params: {
export async function reconcilePendingPairingRequests<
TPending extends { requestId: string },
TIncoming,
>(params: {
pendingById: Record<string, TPending>;
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<void>;
}): Promise<PendingPairingRequestResult<TPending>> {
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 };

View File

@ -30,6 +30,7 @@ export type PendingRequest = {
uiVersion?: string;
remoteIp?: string;
isRepair?: boolean;
repairReason?: string;
ts: number;
};