From e7a6ed8eb6a537a08f0e94621ab110422df0c54b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 23:48:01 +0100 Subject: [PATCH] chore(pairing): retire vestigial node token plumbing and flatten cleanup claims (#103140) --- docs/channels/pairing.md | 5 +-- docs/gateway/troubleshooting.md | 2 +- docs/nodes/index.md | 4 +-- src/cli/node-cli/register.ts | 4 +-- src/infra/device-pairing.ts | 44 ++++++++++++++++++++++- src/infra/node-pairing.ts | 40 +++++++++------------ src/infra/pairing-files.test.ts | 64 ++------------------------------- src/infra/pairing-files.ts | 43 ---------------------- src/node-host/config.ts | 2 -- 9 files changed, 70 insertions(+), 138 deletions(-) diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 4d8728190689..dcc390751752 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -226,8 +226,9 @@ Stored under `~/.openclaw/devices/`: ### Notes -- The legacy `node.pair.*` API (CLI: `openclaw nodes pending|approve|reject|remove|rename`) is a - separate gateway-owned pairing store. WS nodes still require device pairing. +- The `node.pair.*` API (CLI: `openclaw nodes pending|approve|reject|remove|rename`) manages + node capability approvals stored on the same paired device records. WS nodes + still require device pairing; see [Node pairing](/gateway/pairing). - The pairing record is the durable source of truth for approved roles. Active device tokens stay bounded to that approved role set; a stray token entry outside the approved roles does not create new access. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 903b444922a0..cc00d25fcca5 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -844,7 +844,7 @@ Related: - [Authentication](/gateway/authentication) - [Background exec and process tool](/gateway/background-process) -- [Gateway-owned pairing](/gateway/pairing) +- [Node pairing](/gateway/pairing) ## Related diff --git a/docs/nodes/index.md b/docs/nodes/index.md index e041ccfd72d6..b61f6c3d9f37 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -29,7 +29,7 @@ openclaw nodes status openclaw nodes describe --node ``` -Pending pairing requests expire 5 minutes after the device's last retry — a device that keeps reconnecting keeps its one pending request (and `requestId`) alive instead of minting a new prompt every few minutes; see [Gateway-owned pairing](/gateway/pairing) for the full request/approve/token lifecycle. If a node retries with changed auth details (role/scopes/public key), the prior pending request is superseded and a new `requestId` is created — clients get a `device.pair.resolved` event for the superseded request, and you should re-run `openclaw devices list` before approving. +Pending pairing requests expire 5 minutes after the device's last retry — a device that keeps reconnecting keeps its one pending request (and `requestId`) alive instead of minting a new prompt every few minutes; see [Node pairing](/gateway/pairing) for the full request/approve lifecycle. If a node retries with changed auth details (role/scopes/public key), the prior pending request is superseded and a new `requestId` is created — clients get a `device.pair.resolved` event for the superseded request, and you should re-run `openclaw devices list` before approving. - `nodes status` marks a node as **paired** when its device pairing role includes `node`. - The device pairing record is the durable approved-role contract. Token rotation stays inside that contract; it cannot upgrade a paired node into a role that pairing approval never granted. @@ -82,7 +82,7 @@ On the node machine: openclaw node run --host --port 18789 --display-name "Build Node" ``` -`node run` also accepts `--context-path` (Gateway WS context path), `--tls`, `--tls-fingerprint `, and `--node-id` (overriding it clears the pairing token). +`node run` also accepts `--context-path` (Gateway WS context path), `--tls`, `--tls-fingerprint `, and `--node-id` (override the generated node instance id). ### Remote gateway via SSH tunnel (loopback bind) diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index ff099db37fe8..c2905b17f943 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -54,7 +54,7 @@ export function registerNodeCli(program: Command) { .option("--tls", "Use TLS for the gateway connection") .option("--no-tls", "Disable TLS for the gateway connection") .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") - .option("--node-id ", "Override node id (clears pairing token)") + .option("--node-id ", "Override the generated node instance id") .option("--display-name ", "Override node display name") .action(async (opts) => { const existing = await loadNodeHostConfig(); @@ -111,7 +111,7 @@ export function registerNodeCli(program: Command) { .option("--context-path ", "Gateway WebSocket context path (e.g. /openclaw-gw)") .option("--tls", "Use TLS for the gateway connection", false) .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") - .option("--node-id ", "Override node id (clears pairing token)") + .option("--node-id ", "Override the generated node instance id") .option("--display-name ", "Override node display name") .option("--runtime ", "Service runtime (node|bun). Default: node") .option("--force", "Reinstall/overwrite if already installed", false) diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 105beb00c235..f11c11f42ad4 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -17,7 +17,6 @@ import { createAsyncLock, pruneExpiredPending, readJsonIfExists, - reconcilePendingPairingRequests, coercePairingStateRecord, resolvePairingPaths, writeJson, @@ -791,6 +790,49 @@ export async function getPendingDevicePairing( return pending ? toPublicPendingDevicePairingRequest(pending) : null; } +/** Result shape for creating or refreshing a pending pairing request. */ +type PendingPairingRequestResult = { + status: "pending"; + request: TPending; + created: boolean; +}; + +/** Refresh one compatible pending request or replace a superseded request set atomically. */ +async function reconcilePendingPairingRequests< + TPending extends { requestId: string }, + TIncoming, +>(params: { + pendingById: Record; + existing: readonly TPending[]; + incoming: TIncoming; + canRefreshSingle: (existing: TPending, incoming: TIncoming) => boolean; + refreshSingle: (existing: TPending, incoming: TIncoming) => TPending; + buildReplacement: (params: { existing: readonly TPending[]; incoming: TIncoming }) => TPending; + persist: () => Promise; +}): Promise> { + 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 }; + } + + 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 }; +} + /** Create or refresh a pending device pairing request for owner approval. */ export async function requestDevicePairing( req: Omit, diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index df95ba747d02..b6c2df364a6f 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -54,7 +54,7 @@ export type NodePairingCleanupClaim = { baseDir: string | undefined; generation: number; nodeId: string; - observed: NodePairingPendingSnapshot[]; + observed: NodePairingPendingSnapshot; }; /** Pending request summary returned when a new approval surface supersedes older requests. */ @@ -273,29 +273,23 @@ function buildCleanupRevisionClaimKey( } function addCleanupClaim(claim: NodePairingCleanupClaim): void { - for (const observed of claim.observed) { - const key = buildCleanupRevisionClaimKey(claim.baseDir, observed); - const generations = activeCleanupRevisionClaims.get(key) ?? new Set(); - generations.add(claim.generation); - activeCleanupRevisionClaims.set(key, generations); - } + const key = buildCleanupRevisionClaimKey(claim.baseDir, claim.observed); + const generations = activeCleanupRevisionClaims.get(key) ?? new Set(); + generations.add(claim.generation); + activeCleanupRevisionClaims.set(key, generations); } function cleanupClaimIsActive(claim: NodePairingCleanupClaim): boolean { - return claim.observed.some((observed) => { - const key = buildCleanupRevisionClaimKey(claim.baseDir, observed); - return activeCleanupRevisionClaims.get(key)?.has(claim.generation) === true; - }); + const key = buildCleanupRevisionClaimKey(claim.baseDir, claim.observed); + return activeCleanupRevisionClaims.get(key)?.has(claim.generation) === true; } function removeCleanupClaim(claim: NodePairingCleanupClaim): void { - for (const observed of claim.observed) { - const key = buildCleanupRevisionClaimKey(claim.baseDir, observed); - const generations = activeCleanupRevisionClaims.get(key); - generations?.delete(claim.generation); - if (!generations || generations.size === 0) { - activeCleanupRevisionClaims.delete(key); - } + const key = buildCleanupRevisionClaimKey(claim.baseDir, claim.observed); + const generations = activeCleanupRevisionClaims.get(key); + generations?.delete(claim.generation); + if (!generations || generations.size === 0) { + activeCleanupRevisionClaims.delete(key); } } @@ -369,7 +363,7 @@ export async function beginNodePairingConnect( baseDir, generation: ++nextCleanupClaimGeneration, nodeId: device.deviceId, - observed: [toPendingSnapshot(device, pending)], + observed: toPendingSnapshot(device, pending), }; addCleanupClaim(claim); return { value: { pairedNode, cleanupClaim: claim }, persist: false }; @@ -397,10 +391,10 @@ export async function finalizeNodePairingCleanupClaim( if (!device || !pending) { return { value: [], persist: false }; } - const observed = claim.observed.find( - (entry) => entry.requestId === pending.requestId && entry.revision === pending.revision, - ); - if (!observed) { + if ( + claim.observed.requestId !== pending.requestId || + claim.observed.revision !== pending.revision + ) { return { value: [], persist: false }; } delete device.pendingNodeSurface; diff --git a/src/infra/pairing-files.test.ts b/src/infra/pairing-files.test.ts index 124a19880bb8..ea168a53c1cd 100644 --- a/src/infra/pairing-files.test.ts +++ b/src/infra/pairing-files.test.ts @@ -1,11 +1,7 @@ // Covers shared pairing file helpers. import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { - pruneExpiredPending, - reconcilePendingPairingRequests, - resolvePairingPaths, -} from "./pairing-files.js"; +import { describe, expect, it } from "vitest"; +import { pruneExpiredPending, resolvePairingPaths } from "./pairing-files.js"; describe("pairing file helpers", () => { it("resolves pairing file paths from explicit base dirs", () => { @@ -30,60 +26,4 @@ describe("pairing file helpers", () => { fresh: { ts: 70, requestId: "fresh" }, }); }); - - 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, version: 1 }; - const pendingById = { "req-1": existing }; - - await expect( - reconcilePendingPairingRequests({ - pendingById, - 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: { requestId: "req-1", deviceId: "device-1", ts: 2, version: 2 }, - created: false, - }); - expect(persist).toHaveBeenCalledOnce(); - }); - - it("replaces existing pending requests with one merged request", async () => { - const persist = vi.fn(async () => undefined); - const pendingById = { - "req-1": { requestId: "req-1", deviceId: "device-2", ts: 1 }, - "req-2": { requestId: "req-2", deviceId: "device-2", ts: 2 }, - }; - - await expect( - reconcilePendingPairingRequests({ - pendingById, - 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-3", deviceId: "device-2", ts: 3, isRepair: true }, - created: true, - }); - expect(persist).toHaveBeenCalledOnce(); - expect(pendingById).toEqual({ - "req-3": { requestId: "req-3", deviceId: "device-2", ts: 3, isRepair: true }, - }); - }); }); diff --git a/src/infra/pairing-files.ts b/src/infra/pairing-files.ts index e8905c9bdbcc..58403eb62767 100644 --- a/src/infra/pairing-files.ts +++ b/src/infra/pairing-files.ts @@ -37,46 +37,3 @@ export function pruneExpiredPending = { - status: "pending"; - request: TPending; - created: boolean; -}; - -/** Refresh one compatible pending request or replace a superseded request set atomically. */ -export async function reconcilePendingPairingRequests< - TPending extends { requestId: string }, - TIncoming, ->(params: { - pendingById: Record; - existing: readonly TPending[]; - incoming: TIncoming; - canRefreshSingle: (existing: TPending, incoming: TIncoming) => boolean; - refreshSingle: (existing: TPending, incoming: TIncoming) => TPending; - buildReplacement: (params: { existing: readonly TPending[]; incoming: TIncoming }) => TPending; - persist: () => Promise; -}): Promise> { - 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 }; - } - - for (const existing of params.existing) { - delete params.pendingById[existing.requestId]; - } - - const request = params.buildReplacement({ - existing: params.existing, - incoming: params.incoming, - }); - params.pendingById[request.requestId] = request; - await params.persist(); - return { status: "pending", request, created: true }; -} diff --git a/src/node-host/config.ts b/src/node-host/config.ts index c0fe3a6c40aa..43e762411dbc 100644 --- a/src/node-host/config.ts +++ b/src/node-host/config.ts @@ -24,7 +24,6 @@ export type NodeHostGatewayConfig = { type NodeHostConfig = { version: 1; nodeId: string; - token?: string; displayName?: string; gateway?: NodeHostGatewayConfig; }; @@ -39,7 +38,6 @@ function normalizeConfig(config: Partial | null): NodeHostConfig const base: NodeHostConfig = { version: 1, nodeId: "", - token: config?.token, displayName: config?.displayName, gateway: config?.gateway, };