mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
refactor: centralize node identity resolution
This commit is contained in:
36
docs/internal/steipete/2026-03-29-node-identity-refactor.md
Normal file
36
docs/internal/steipete/2026-03-29-node-identity-refactor.md
Normal 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
|
||||
113
src/gateway/node-catalog.test.ts
Normal file
113
src/gateway/node-catalog.test.ts
Normal 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
125
src/gateway/node-catalog.ts
Normal 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 });
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user