refactor: centralize node identity resolution

This commit is contained in:
Peter Steinberger
2026-03-29 23:13:49 +01:00
parent ace876b087
commit 27519cf061
9 changed files with 449 additions and 162 deletions

View File

@@ -0,0 +1,36 @@
---
title: "Node Identity Refactor"
summary: "Refactor: derive node eligibility from active device tokens, centralize node view assembly, and score node resolution ties explicitly"
author: "Peter Steinberger <steipete@gmail.com>"
github_username: "steipete"
created: "2026-03-29"
status: "implemented"
read_when:
- "Tracing why node.list or node.describe shows stale paired devices as nodes"
- "Changing node name/id/IP resolution or legacy clawdbot migration heuristics"
---
Problem:
- device pairing stored historical `roles`
- `node.list` treated that sticky field as current node eligibility
- handshake upgrade checks also read sticky roles
- result: revoked/stale legacy node identities still looked like real nodes
Refactor:
- `src/infra/device-pairing.ts`
- added effective-role helpers
- active non-revoked tokens are source of truth when tokens exist
- legacy `role` / `roles` only used as fallback for token-less records
- `src/gateway/node-catalog.ts`
- shared node view builder for `node.list` / `node.describe`
- `src/shared/node-match.ts`
- explicit match scoring: exact id > IP > normalized name > long prefix
- then connected/current-client tie-breaks
Expected outcome:
- stale revoked node tokens stop surfacing as nodes
- `node.list` / `node.describe` stay in sync
- future matcher tweaks happen in one place instead of ad hoc branches

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "./node-catalog.js";
describe("gateway/node-catalog", () => {
it("filters paired nodes by active node token instead of sticky historical roles", () => {
const catalog = createKnownNodeCatalog({
pairedDevices: [
{
deviceId: "legacy-mac",
publicKey: "legacy-public-key",
displayName: "Peter's Mac Studio",
clientId: "clawdbot-macos",
role: "node",
roles: ["node"],
tokens: {
node: {
token: "legacy-token",
role: "node",
scopes: [],
createdAtMs: 1,
revokedAtMs: 2,
},
},
createdAtMs: 1,
approvedAtMs: 1,
},
{
deviceId: "current-mac",
publicKey: "current-public-key",
displayName: "Peter's Mac Studio",
clientId: "openclaw-macos",
role: "node",
roles: ["node"],
tokens: {
node: {
token: "current-token",
role: "node",
scopes: [],
createdAtMs: 1,
},
},
createdAtMs: 1,
approvedAtMs: 1,
},
],
connectedNodes: [],
});
expect(listKnownNodes(catalog).map((node) => node.nodeId)).toEqual(["current-mac"]);
});
it("builds one merged node view for paired and live state", () => {
const connectedAtMs = 123;
const catalog = createKnownNodeCatalog({
pairedDevices: [
{
deviceId: "mac-1",
publicKey: "public-key",
displayName: "Mac",
clientId: "openclaw-macos",
clientMode: "node",
role: "node",
roles: ["node"],
remoteIp: "100.0.0.10",
tokens: {
node: {
token: "current-token",
role: "node",
scopes: [],
createdAtMs: 1,
},
},
createdAtMs: 1,
approvedAtMs: 99,
},
],
connectedNodes: [
{
nodeId: "mac-1",
connId: "conn-1",
client: {} as never,
clientId: "openclaw-macos",
clientMode: "node",
displayName: "Mac",
platform: "darwin",
version: "1.2.3",
caps: ["screen"],
commands: ["screen.snapshot"],
remoteIp: "100.0.0.11",
pathEnv: "/usr/bin:/bin",
connectedAtMs,
},
],
});
expect(getKnownNode(catalog, "mac-1")).toEqual(
expect.objectContaining({
nodeId: "mac-1",
displayName: "Mac",
clientId: "openclaw-macos",
clientMode: "node",
remoteIp: "100.0.0.11",
caps: ["screen"],
commands: ["screen.snapshot"],
pathEnv: "/usr/bin:/bin",
approvedAtMs: 99,
connectedAtMs,
paired: true,
connected: true,
}),
);
});
});

125
src/gateway/node-catalog.ts Normal file
View File

@@ -0,0 +1,125 @@
import { hasEffectivePairedDeviceRole, type PairedDevice } from "../infra/device-pairing.js";
import type { NodeListNode } from "../shared/node-list-types.js";
import type { NodeSession } from "./node-registry.js";
export type KnownNodeCatalog = {
pairedById: Map<string, NodeListNode>;
connectedById: Map<string, NodeSession>;
};
function uniqueSortedStrings(...items: Array<readonly string[] | undefined>): string[] {
const values = new Set<string>();
for (const item of items) {
if (!item) {
continue;
}
for (const value of item) {
const trimmed = value.trim();
if (trimmed) {
values.add(trimmed);
}
}
}
return [...values].toSorted((left, right) => left.localeCompare(right));
}
function buildPairedNodeRecord(entry: PairedDevice): NodeListNode {
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: {
nodeId: string;
paired?: NodeListNode;
live?: NodeSession;
}): NodeListNode {
const { nodeId, paired, live } = params;
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),
pathEnv: live?.pathEnv,
permissions: live?.permissions ?? paired?.permissions,
connectedAtMs: live?.connectedAtMs,
approvedAtMs: paired?.approvedAtMs,
paired: Boolean(paired),
connected: Boolean(live),
};
}
function compareKnownNodes(left: NodeListNode, right: NodeListNode): number {
if (left.connected !== right.connected) {
return left.connected ? -1 : 1;
}
const leftName = (left.displayName ?? left.nodeId).toLowerCase();
const rightName = (right.displayName ?? right.nodeId).toLowerCase();
if (leftName < rightName) {
return -1;
}
if (leftName > rightName) {
return 1;
}
return left.nodeId.localeCompare(right.nodeId);
}
export function createKnownNodeCatalog(params: {
pairedDevices: readonly PairedDevice[];
connectedNodes: readonly NodeSession[];
}): KnownNodeCatalog {
const pairedById = new Map(
params.pairedDevices
.filter((entry) => hasEffectivePairedDeviceRole(entry, "node"))
.map((entry) => [entry.deviceId, buildPairedNodeRecord(entry)]),
);
const connectedById = new Map(params.connectedNodes.map((entry) => [entry.nodeId, entry]));
return { pairedById, connectedById };
}
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),
}),
)
.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 });
}

View File

@@ -23,6 +23,7 @@ import {
CANVAS_CAPABILITY_TTL_MS,
mintCanvasCapabilityToken,
} from "../canvas-capability.js";
import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "../node-catalog.js";
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js";
import {
@@ -47,7 +48,6 @@ import {
respondUnavailableOnNodeInvokeError,
respondUnavailableOnThrow,
safeParseJson,
uniqueSortedStrings,
} from "./nodes.helpers.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -129,16 +129,6 @@ async function clearStaleApnsRegistrationIfNeeded(
});
}
function isNodeEntry(entry: { role?: string; roles?: string[] }) {
if (entry.role === "node") {
return true;
}
if (Array.isArray(entry.roles) && entry.roles.includes("node")) {
return true;
}
return false;
}
async function delayMs(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
@@ -668,79 +658,11 @@ export const nodeHandlers: GatewayRequestHandlers = {
}
await respondUnavailableOnThrow(respond, async () => {
const list = await listDevicePairing();
const pairedById = new Map(
list.paired
.filter((entry) => isNodeEntry(entry))
.map((entry) => [
entry.deviceId,
{
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,
},
]),
);
const connected = context.nodeRegistry.listConnected();
const connectedById = new Map(connected.map((n) => [n.nodeId, n]));
const nodeIds = new Set<string>([...pairedById.keys(), ...connectedById.keys()]);
const nodes = [...nodeIds].map((nodeId) => {
const paired = pairedById.get(nodeId);
const live = connectedById.get(nodeId);
const caps = uniqueSortedStrings([...(live?.caps ?? paired?.caps ?? [])]);
const commands = uniqueSortedStrings([...(live?.commands ?? paired?.commands ?? [])]);
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,
commands,
pathEnv: live?.pathEnv,
permissions: live?.permissions ?? paired?.permissions,
connectedAtMs: live?.connectedAtMs,
approvedAtMs: paired?.approvedAtMs,
paired: Boolean(paired),
connected: Boolean(live),
};
const catalog = createKnownNodeCatalog({
pairedDevices: list.paired,
connectedNodes: context.nodeRegistry.listConnected(),
});
nodes.sort((a, b) => {
if (a.connected !== b.connected) {
return a.connected ? -1 : 1;
}
const an = (a.displayName ?? a.nodeId).toLowerCase();
const bn = (b.displayName ?? b.nodeId).toLowerCase();
if (an < bn) {
return -1;
}
if (an > bn) {
return 1;
}
return a.nodeId.localeCompare(b.nodeId);
});
const nodes = listKnownNodes(catalog);
respond(true, { ts: Date.now(), nodes }, undefined);
});
},
@@ -761,44 +683,16 @@ export const nodeHandlers: GatewayRequestHandlers = {
}
await respondUnavailableOnThrow(respond, async () => {
const list = await listDevicePairing();
const paired = list.paired.find((n) => n.deviceId === id && isNodeEntry(n));
const connected = context.nodeRegistry.listConnected();
const live = connected.find((n) => n.nodeId === id);
if (!paired && !live) {
const catalog = createKnownNodeCatalog({
pairedDevices: list.paired,
connectedNodes: context.nodeRegistry.listConnected(),
});
const node = getKnownNode(catalog, id);
if (!node) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
return;
}
const caps = uniqueSortedStrings([...(live?.caps ?? [])]);
const commands = uniqueSortedStrings([...(live?.commands ?? [])]);
respond(
true,
{
ts: Date.now(),
nodeId: id,
displayName: live?.displayName ?? paired?.displayName,
platform: live?.platform ?? paired?.platform,
version: live?.version,
coreVersion: live?.coreVersion,
uiVersion: live?.uiVersion,
clientId: live?.clientId ?? paired?.clientId,
clientMode: live?.clientMode ?? paired?.clientMode,
deviceFamily: live?.deviceFamily,
modelIdentifier: live?.modelIdentifier,
remoteIp: live?.remoteIp ?? paired?.remoteIp,
caps,
commands,
pathEnv: live?.pathEnv,
permissions: live?.permissions,
connectedAtMs: live?.connectedAtMs,
approvedAtMs: paired?.approvedAtMs,
paired: Boolean(paired),
connected: Boolean(live),
},
undefined,
);
respond(true, { ts: Date.now(), ...node }, undefined);
});
},
"node.canvas.capability.refresh": async ({ params, respond, client }) => {

View File

@@ -11,7 +11,9 @@ import {
approveDevicePairing,
ensureDeviceToken,
getPairedDevice,
hasEffectivePairedDeviceRole,
listDevicePairing,
listEffectivePairedDeviceRoles,
requestDevicePairing,
updatePairedDeviceMetadata,
verifyDeviceToken,
@@ -749,12 +751,7 @@ export function attachGatewayWsMessageHandler(params: {
if (!pairedCandidate || pairedCandidate.publicKey !== devicePublicKey) {
return false;
}
const pairedRoles = Array.isArray(pairedCandidate.roles)
? pairedCandidate.roles
: pairedCandidate.role
? [pairedCandidate.role]
: [];
if (pairedRoles.length > 0 && !pairedRoles.includes(role)) {
if (!hasEffectivePairedDeviceRole(pairedCandidate, role)) {
return false;
}
if (scopes.length === 0) {
@@ -906,11 +903,7 @@ export function attachGatewayWsMessageHandler(params: {
connectParams.client.deviceFamily = metadataPinning.pinnedDeviceFamily;
}
}
const pairedRoles = Array.isArray(paired.roles)
? paired.roles
: paired.role
? [paired.role]
: [];
const pairedRoles = listEffectivePairedDeviceRoles(paired);
const pairedScopes = Array.isArray(paired.scopes)
? paired.scopes
: Array.isArray(paired.approvedScopes)

View File

@@ -8,9 +8,12 @@ import {
clearDevicePairing,
ensureDeviceToken,
getPairedDevice,
hasEffectivePairedDeviceRole,
listEffectivePairedDeviceRoles,
listDevicePairing,
removePairedDevice,
requestDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
verifyDeviceToken,
type PairedDevice,
@@ -515,6 +518,39 @@ describe("device pairing tokens", () => {
).resolves.toEqual({ ok: false, reason: "token-mismatch" });
});
test("derives effective roles from active tokens instead of sticky historical roles", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
const request = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "node",
},
baseDir,
);
await approveDevicePairing(request.request.requestId, { callerScopes: [] }, baseDir);
let paired = await getPairedDevice("device-1", baseDir);
expect(paired).toBeDefined();
if (!paired) {
throw new Error("expected paired node device");
}
expect(paired?.roles).toContain("node");
expect(listEffectivePairedDeviceRoles(paired)).toEqual(["node"]);
expect(hasEffectivePairedDeviceRole(paired, "node")).toBe(true);
await revokeDeviceToken({ deviceId: "device-1", role: "node", baseDir });
paired = await getPairedDevice("device-1", baseDir);
expect(paired).toBeDefined();
if (!paired) {
throw new Error("expected paired node device after revoke");
}
expect(paired?.roles).toContain("node");
expect(listEffectivePairedDeviceRoles(paired)).toEqual([]);
expect(hasEffectivePairedDeviceRole(paired, "node")).toBe(false);
});
test("removes paired devices by device id", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.read"]);

View File

@@ -152,6 +152,40 @@ function mergeRoles(...items: Array<string | string[] | undefined>): string[] |
return [...roles];
}
function listActiveTokenRoles(
tokens: Record<string, DeviceAuthToken> | undefined,
): string[] | undefined {
if (!tokens) {
return undefined;
}
return mergeRoles(
Object.values(tokens)
.filter((entry) => !entry.revokedAtMs)
.map((entry) => entry.role),
);
}
export function listEffectivePairedDeviceRoles(
device: Pick<PairedDevice, "role" | "roles" | "tokens">,
): string[] {
const activeTokenRoles = listActiveTokenRoles(device.tokens);
if (device.tokens) {
return activeTokenRoles ?? [];
}
return mergeRoles(device.roles, device.role) ?? [];
}
export function hasEffectivePairedDeviceRole(
device: Pick<PairedDevice, "role" | "roles" | "tokens">,
role: string,
): boolean {
const normalized = normalizeRole(role);
if (!normalized) {
return false;
}
return listEffectivePairedDeviceRoles(device).includes(normalized);
}
function mergeScopes(...items: Array<string[] | undefined>): string[] | undefined {
const scopes = new Set<string>();
for (const item of items) {

View File

@@ -35,6 +35,18 @@ describe("shared/node-match", () => {
).toBe("ios-live");
});
it("prefers the strongest match type before client heuristics", () => {
expect(
resolveNodeIdFromCandidates(
[
{ nodeId: "mac-studio", displayName: "Other Node", connected: false },
{ nodeId: "mac-2", displayName: "Mac Studio", connected: true },
],
"mac-studio",
),
).toBe("mac-studio");
});
it("prefers a unique current OpenClaw client over a legacy clawdbot client", () => {
expect(
resolveNodeIdFromCandidates(
@@ -119,7 +131,7 @@ describe("shared/node-match", () => {
"Peter's Mac Studio",
),
).toThrow(
/ambiguous node: Peter's Mac Studio.*node=legacy-mac.*client=clawdbot-macos.*node=other-mac.*client=openclaw-macos.*node=third-mac.*client=openclaw-macos/,
/ambiguous node: Peter's Mac Studio.*node=other-mac.*client=openclaw-macos.*node=third-mac.*client=openclaw-macos/,
);
});

View File

@@ -6,6 +6,12 @@ export type NodeMatchCandidate = {
clientId?: string;
};
type ScoredNodeMatch = {
node: NodeMatchCandidate;
matchScore: number;
selectionScore: number;
};
export function normalizeNodeKey(value: string) {
return value
.toLowerCase()
@@ -54,32 +60,66 @@ function pickPreferredLegacyMigrationMatch(
return current[0];
}
function resolveMatchScore(
node: NodeMatchCandidate,
query: string,
queryNormalized: string,
): number {
if (node.nodeId === query) {
return 4_000;
}
if (typeof node.remoteIp === "string" && node.remoteIp === query) {
return 3_000;
}
const name = typeof node.displayName === "string" ? node.displayName : "";
if (name && normalizeNodeKey(name) === queryNormalized) {
return 2_000;
}
if (query.length >= 6 && node.nodeId.startsWith(query)) {
return 1_000;
}
return 0;
}
function scoreNodeCandidate(node: NodeMatchCandidate, matchScore: number): number {
let score = matchScore;
if (node.connected === true) {
score += 100;
}
if (isCurrentOpenClawClient(node.clientId)) {
score += 10;
} else if (isLegacyClawdbotClient(node.clientId)) {
score -= 10;
}
return score;
}
function resolveScoredMatches(nodes: NodeMatchCandidate[], query: string): ScoredNodeMatch[] {
const trimmed = query.trim();
if (!trimmed) {
return [];
}
const normalized = normalizeNodeKey(trimmed);
return nodes
.map((node) => {
const matchScore = resolveMatchScore(node, trimmed, normalized);
if (matchScore === 0) {
return null;
}
return {
node,
matchScore,
selectionScore: scoreNodeCandidate(node, matchScore),
};
})
.filter((entry): entry is ScoredNodeMatch => entry !== null);
}
export function resolveNodeMatches(
nodes: NodeMatchCandidate[],
query: string,
): NodeMatchCandidate[] {
const q = query.trim();
if (!q) {
return [];
}
const qNorm = normalizeNodeKey(q);
return nodes.filter((n) => {
if (n.nodeId === q) {
return true;
}
if (typeof n.remoteIp === "string" && n.remoteIp === q) {
return true;
}
const name = typeof n.displayName === "string" ? n.displayName : "";
if (name && normalizeNodeKey(name) === qNorm) {
return true;
}
if (q.length >= 6 && n.nodeId.startsWith(q)) {
return true;
}
return false;
});
return resolveScoredMatches(nodes, query).map((entry) => entry.node);
}
export function resolveNodeIdFromCandidates(nodes: NodeMatchCandidate[], query: string): string {
@@ -88,29 +128,33 @@ export function resolveNodeIdFromCandidates(nodes: NodeMatchCandidate[], query:
throw new Error("node required");
}
const rawMatches = resolveNodeMatches(nodes, q);
const rawMatches = resolveScoredMatches(nodes, q);
if (rawMatches.length === 1) {
return rawMatches[0]?.nodeId ?? "";
return rawMatches[0]?.node.nodeId ?? "";
}
if (rawMatches.length === 0) {
const known = listKnownNodes(nodes);
throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`);
}
// Re-pair/reinstall flows can leave multiple nodes with the same display name.
// Prefer a unique connected match when available.
const connectedMatches = rawMatches.filter((match) => match.connected === true);
const matches = connectedMatches.length > 0 ? connectedMatches : rawMatches;
if (matches.length === 1) {
return matches[0]?.nodeId ?? "";
const topMatchScore = Math.max(...rawMatches.map((match) => match.matchScore));
const strongestMatches = rawMatches.filter((match) => match.matchScore === topMatchScore);
if (strongestMatches.length === 1) {
return strongestMatches[0]?.node.nodeId ?? "";
}
const preferred = pickPreferredLegacyMigrationMatch(matches);
const topSelectionScore = Math.max(...strongestMatches.map((match) => match.selectionScore));
const matches = strongestMatches.filter((match) => match.selectionScore === topSelectionScore);
if (matches.length === 1) {
return matches[0]?.node.nodeId ?? "";
}
const preferred = pickPreferredLegacyMigrationMatch(matches.map((match) => match.node));
if (preferred) {
return preferred.nodeId;
}
throw new Error(
`ambiguous node: ${q} (matches: ${matches.map(formatNodeCandidateLabel).join(", ")})`,
`ambiguous node: ${q} (matches: ${matches.map((match) => formatNodeCandidateLabel(match.node)).join(", ")})`,
);
}