refactor(gateway): retire the standalone node pairing store (#103120)

* refactor(gateway): fold node pairing store into device records

* docs+cli: retire standalone node pairing store references

* test(infra): respec node pairing as device-backed surface approvals

* test(infra): assemble migration token fixtures dynamically

* fix(pairing): preserve node surface across device re-approval, strip legacy tokens in CLI JSON

* test(gateway): drop retired node token from pairing fixtures

* chore(protocol): regenerate Swift models, drop dead pairing-pending module
This commit is contained in:
Peter Steinberger
2026-07-09 22:58:03 +01:00
committed by GitHub
parent a661804270
commit f5806d08e2
34 changed files with 885 additions and 1021 deletions
@@ -1295,68 +1295,6 @@ public struct WorktreesGcResult: Codable, Sendable {
}
}
public struct NodePairRequestParams: Codable, Sendable {
public let nodeid: String
public let displayname: String?
public let platform: String?
public let version: String?
public let coreversion: String?
public let uiversion: String?
public let devicefamily: String?
public let modelidentifier: String?
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: AnyCodable]?
public let remoteip: String?
public let silent: Bool?
public init(
nodeid: String,
displayname: String?,
platform: String?,
version: String?,
coreversion: String?,
uiversion: String?,
devicefamily: String?,
modelidentifier: String?,
caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
remoteip: String?,
silent: Bool?)
{
self.nodeid = nodeid
self.displayname = displayname
self.platform = platform
self.version = version
self.coreversion = coreversion
self.uiversion = uiversion
self.devicefamily = devicefamily
self.modelidentifier = modelidentifier
self.caps = caps
self.commands = commands
self.permissions = permissions
self.remoteip = remoteip
self.silent = silent
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case displayname = "displayName"
case platform
case version
case coreversion = "coreVersion"
case uiversion = "uiVersion"
case devicefamily = "deviceFamily"
case modelidentifier = "modelIdentifier"
case caps
case commands
case permissions
case remoteip = "remoteIp"
case silent
}
}
public struct NodePairListParams: Codable, Sendable {}
public struct NodePairApproveParams: Codable, Sendable {
@@ -1401,24 +1339,6 @@ public struct NodePairRemoveParams: Codable, Sendable {
}
}
public struct NodePairVerifyParams: Codable, Sendable {
public let nodeid: String
public let token: String
public init(
nodeid: String,
token: String)
{
self.nodeid = nodeid
self.token = token
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case token
}
}
public struct NodeRenameParams: Codable, Sendable {
public let nodeid: String
public let displayname: String
+1 -2
View File
@@ -3575,8 +3575,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Route: /gateway/pairing
- Headings:
- H2: Concepts
- H2: How pairing works
- H2: How capability approval works
- H2: CLI workflow (headless friendly)
- H2: API surface (gateway protocol)
- H2: Node command gating (2026.3.31+)
+50 -44
View File
@@ -1,34 +1,38 @@
---
summary: "Gateway-owned node pairing (Option B) for iOS and other remote nodes"
summary: "Node capability approvals: how nodes gain command exposure after device pairing"
read_when:
- Implementing node pairing approvals without macOS UI
- Adding CLI flows for approving remote nodes
- Extending gateway protocol with node management
title: "Gateway-owned pairing"
title: "Node pairing"
---
In Gateway-owned pairing, the **Gateway** is the source of truth for which
nodes may join. UIs (macOS app, future clients) are just frontends that
approve or reject pending requests.
Node pairing has two layers, both stored on the paired device record in
`devices/paired.json`:
**Important:** WS nodes use **device pairing** (role `node`) during `connect`.
`node.pair.*` is a separate, legacy pairing store and does **not** gate the WS
handshake. Only clients that explicitly call `node.pair.*` use this flow.
- **Device pairing** (role `node`) gates the `connect` handshake. See
[Trusted-CIDR device auto-approval](#trusted-cidr-device-auto-approval)
below and [Channel pairing](/channels/pairing).
- **Node capability approval** (`node.pair.*`) gates which declared
capabilities/commands a connected node may expose. The Gateway is the
source of truth; UIs (macOS app, Control UI) are frontends that approve or
reject pending requests.
## Concepts
The former standalone node pairing store (`nodes/paired.json` with a per-node
token, retired from the connect path in January 2026) is gone: gateways fold
any remaining rows into the device records once at startup and archive the
legacy files with a `.migrated` suffix. Legacy TCP bridge support has been
removed.
- **Pending request**: a node asked to join; requires approval.
- **Paired node**: approved node with an issued auth token.
- **Transport**: the Gateway WS endpoint forwards requests but does not decide
membership. Legacy TCP bridge support has been removed.
## How capability approval works
## How pairing works
1. A node connects to the Gateway WS and requests pairing.
2. The Gateway stores a **pending request** and emits `node.pair.requested`.
1. A node connects to the Gateway WS (device pairing gates this step).
2. The Gateway compares the declared capability/command surface with the
approved one; new or widened surfaces store a **pending request** on the
device record and emit `node.pair.requested`.
3. You approve or reject the request (CLI or UI).
4. On approval, the Gateway issues a **new token** (tokens rotate on re-pair).
5. The node reconnects using the token and is now paired.
4. Until approval, node commands stay filtered; approval exposes the declared
surface, subject to the normal command policy.
Pending requests expire automatically **5 minutes after the node's last
retry** — an actively reconnecting node keeps its one pending request alive
@@ -57,33 +61,31 @@ Events:
Methods:
- `node.pair.request` - create or reuse a pending request.
- `node.pair.list` - list pending and paired nodes (`operator.pairing`).
- `node.pair.approve` - approve a pending request (issues a token).
- `node.pair.approve` - approve a pending request.
- `node.pair.reject` - reject a pending request.
- `node.pair.remove` - remove a paired node. For a device-backed pairing, this
revokes the device's `node` role: it mutates `devices/paired.json` and
- `node.pair.remove` - remove a paired node. This revokes the device's `node`
role in `devices/paired.json`, drops the approved node surface with it, and
invalidates/disconnects that device's node-role sessions. A **mixed-role**
device (for example one that also holds `operator`) keeps its row and only
loses the `node` role; a node-only device row is deleted. It also clears any
matching legacy gateway-owned node pairing entry. Authz: `operator.pairing`
may remove non-operator node rows; a device-token caller revoking its
**own** node role on a mixed-role device additionally needs
loses the `node` role; a node-only device row is deleted. Authz:
`operator.pairing` may remove non-operator node rows; a device-token caller
revoking its **own** node role on a mixed-role device additionally needs
`operator.admin`.
- `node.pair.verify` - verify `{ nodeId, token }`.
- `node.rename` - rename a paired node's operator-facing display name.
Removed in 2026.7: `node.pair.request` and `node.pair.verify`. Pending
requests are created by the Gateway itself during node connects, and the
standalone per-node token they served no longer exists; node auth is the
device pairing token.
Notes:
- `node.pair.request` is idempotent per node: repeated calls return the same
pending request.
- Repeated requests for the same pending node refresh the stored node
metadata and the latest allowlisted declared command snapshot for operator
visibility.
- Approval **always** generates a fresh token; `node.pair.request` never
returns a token.
- Reconnects with an unchanged surface reuse the pending request; repeated
requests refresh the stored node metadata and the latest allowlisted
declared command snapshot for operator visibility.
- Operator scope levels and approval-time checks are summarized in
[Operator scopes](/gateway/operator-scopes).
- Requests may include `silent: true` as a hint for auto-approval flows.
- `node.pair.approve` uses the pending request's declared commands to enforce
extra approval scopes:
- commandless request: `operator.pairing`
@@ -92,7 +94,7 @@ Notes:
`operator.pairing` + `operator.admin`
<Warning>
Node pairing is a trust and identity flow plus token issuance. It does **not** pin the live node command surface per node.
Node pairing approval records the trusted capability surface. It does **not** pin the live node command surface per node.
- Live node commands come from what the node declares on connect, filtered by
the gateway's global node command policy (`gateway.nodes.allowCommands` and
@@ -243,18 +245,22 @@ operator auth.
## Storage (local, private)
Pairing state is stored under the Gateway state directory (default
`~/.openclaw`):
Pairing state lives on the paired device records under the Gateway state
directory (default `~/.openclaw`):
- `~/.openclaw/nodes/paired.json`
- `~/.openclaw/nodes/pending.json`
- `~/.openclaw/devices/paired.json` (device auth, approved node surfaces, and
pending surface requests)
- `~/.openclaw/devices/pending.json` (pending device pairing requests)
If you override `OPENCLAW_STATE_DIR`, the `nodes/` folder moves with it.
If you override `OPENCLAW_STATE_DIR`, the `devices/` folder moves with it.
Gateways upgraded from releases with the standalone `nodes/` store fold those
rows in at startup and leave `nodes/*.json.migrated` archives behind.
Security notes:
- Tokens are secrets; treat `paired.json` as sensitive.
- Rotating a token requires re-approval (or deleting the node entry).
- Device tokens are secrets; treat `paired.json` as sensitive.
- Rotating a device token uses `openclaw devices rotate` /
`device.token.rotate`.
## Transport behavior
+1 -1
View File
@@ -480,7 +480,7 @@ methods. Treat this as feature discovery, not a full enumeration of
</Accordion>
<Accordion title="Node pairing, invoke, and pending work">
- `node.pair.request`, `node.pair.list`, `node.pair.approve`, `node.pair.reject`, `node.pair.remove`, and `node.pair.verify` cover node pairing and bootstrap verification.
- `node.pair.list`, `node.pair.approve`, `node.pair.reject`, and `node.pair.remove` cover node capability approvals. `node.pair.request` and `node.pair.verify` were removed in 2026.7 together with the standalone node pairing store; pending requests are created by the Gateway during node connects.
- `node.list` and `node.describe` return known/connected node state.
- `node.rename` updates a paired node label.
- `node.invoke` forwards a command to a connected node.
+1 -2
View File
@@ -2146,8 +2146,7 @@ Add a repo check that fails new runtime writes to legacy state paths:
- `devices/pending.json`
- `devices/paired.json`
- `devices/bootstrap.json`
- `nodes/pending.json`
- `nodes/paired.json`
- `nodes/pending.json` / `nodes/paired.json` (retired 2026.7: folded into `devices/paired.json` at gateway startup)
- `identity/device.json`
- `identity/device-auth.json`
- `push/web-push-subscriptions.json`
@@ -13,7 +13,6 @@ import {
validateConnectParams,
validateModelsListParams,
validateNodeEventResult,
validateNodePairRequestParams,
validateNodePresenceAlivePayload,
validateSessionsUsageParams,
validateTasksCancelParams,
@@ -875,27 +874,6 @@ describe("validateNodePresenceAlivePayload", () => {
});
});
describe("validateNodePairRequestParams", () => {
it("accepts node pairing permissions", () => {
expect(
validateNodePairRequestParams({
nodeId: "ios-node-1",
commands: ["canvas.snapshot"],
permissions: { camera: true, notifications: false },
}),
).toBe(true);
});
it("rejects non-boolean node pairing permissions", () => {
expect(
validateNodePairRequestParams({
nodeId: "ios-node-1",
permissions: { camera: "yes" },
}),
).toBe(false);
});
});
describe("validateNodeEventResult", () => {
it("accepts structured handled results", () => {
expect(
-14
View File
@@ -349,10 +349,6 @@ import {
NodePairRejectParamsSchema,
type NodePairRemoveParams,
NodePairRemoveParamsSchema,
type NodePairRequestParams,
NodePairRequestParamsSchema,
type NodePairVerifyParams,
NodePairVerifyParamsSchema,
type NodeRenameParams,
NodeRenameParamsSchema,
type PollParams,
@@ -735,9 +731,6 @@ export const validateArtifactsGetParams = lazyCompile<ArtifactsGetParams>(Artifa
export const validateArtifactsDownloadParams = lazyCompile<ArtifactsDownloadParams>(
ArtifactsDownloadParamsSchema,
);
export const validateNodePairRequestParams = lazyCompile<NodePairRequestParams>(
NodePairRequestParamsSchema,
);
export const validateNodePairListParams = lazyCompile<NodePairListParams>(NodePairListParamsSchema);
export const validateNodePairApproveParams = lazyCompile<NodePairApproveParams>(
NodePairApproveParamsSchema,
@@ -748,9 +741,6 @@ export const validateNodePairRejectParams = lazyCompile<NodePairRejectParams>(
export const validateNodePairRemoveParams = lazyCompile<NodePairRemoveParams>(
NodePairRemoveParamsSchema,
);
export const validateNodePairVerifyParams = lazyCompile<NodePairVerifyParams>(
NodePairVerifyParamsSchema,
);
export const validateNodeRenameParams = lazyCompile<NodeRenameParams>(NodeRenameParamsSchema);
export const validateNodeListParams = lazyCompile<NodeListParams>(NodeListParamsSchema);
export const validateEnvironmentsListParams = lazyCompile<EnvironmentsListParams>(
@@ -1224,12 +1214,10 @@ export {
WebPushSubscribeParamsSchema,
WebPushUnsubscribeParamsSchema,
WebPushTestParamsSchema,
NodePairRequestParamsSchema,
NodePairListParamsSchema,
NodePairApproveParamsSchema,
NodePairRejectParamsSchema,
NodePairRemoveParamsSchema,
NodePairVerifyParamsSchema,
NodeListParamsSchema,
NodePendingAckParamsSchema,
NodeInvokeParamsSchema,
@@ -1492,7 +1480,6 @@ export type {
TickEvent,
ShutdownEvent,
WakeParams,
NodePairRequestParams,
NodePairListParams,
NodePairApproveParams,
DevicePairListParams,
@@ -1647,7 +1634,6 @@ export type {
SystemInfoResult,
NodePairRejectParams,
NodePairRemoveParams,
NodePairVerifyParams,
NodeListParams,
NodeInvokeParams,
NodeInvokeResultParams,
@@ -50,26 +50,6 @@ export const NodeEventResultSchema = Type.Object(
{ additionalProperties: false },
);
/** Pairing request metadata advertised by a node before trust is granted. */
export const NodePairRequestParamsSchema = Type.Object(
{
nodeId: NonEmptyString,
displayName: Type.Optional(NonEmptyString),
platform: Type.Optional(NonEmptyString),
version: Type.Optional(NonEmptyString),
coreVersion: Type.Optional(NonEmptyString),
uiVersion: Type.Optional(NonEmptyString),
deviceFamily: Type.Optional(NonEmptyString),
modelIdentifier: Type.Optional(NonEmptyString),
caps: Type.Optional(Type.Array(NonEmptyString)),
commands: Type.Optional(Type.Array(NonEmptyString)),
permissions: Type.Optional(Type.Record(NonEmptyString, Type.Boolean())),
remoteIp: Type.Optional(NonEmptyString),
silent: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
/** Lists pending node-pairing requests. */
export const NodePairListParamsSchema = Type.Object({}, { additionalProperties: false });
@@ -91,12 +71,6 @@ export const NodePairRemoveParamsSchema = Type.Object(
{ additionalProperties: false },
);
/** Verifies node ownership with a short-lived pairing token. */
export const NodePairVerifyParamsSchema = Type.Object(
{ nodeId: NonEmptyString, token: NonEmptyString },
{ additionalProperties: false },
);
/** Renames a paired node while preserving its stable node id. */
export const NodeRenameParamsSchema = Type.Object(
{ nodeId: NonEmptyString, displayName: NonEmptyString },
@@ -255,8 +255,6 @@ import {
NodePairListParamsSchema,
NodePairRemoveParamsSchema,
NodePairRejectParamsSchema,
NodePairRequestParamsSchema,
NodePairVerifyParamsSchema,
NodeRenameParamsSchema,
} from "./nodes.js";
import {
@@ -425,12 +423,10 @@ export const ProtocolSchemas = {
WorktreesGcResult: WorktreesGcResultSchema,
// Node pairing, invocation, presence, and pending-queue payloads.
NodePairRequestParams: NodePairRequestParamsSchema,
NodePairListParams: NodePairListParamsSchema,
NodePairApproveParams: NodePairApproveParamsSchema,
NodePairRejectParams: NodePairRejectParamsSchema,
NodePairRemoveParams: NodePairRemoveParamsSchema,
NodePairVerifyParams: NodePairVerifyParamsSchema,
NodeRenameParams: NodeRenameParamsSchema,
NodeListParams: NodeListParamsSchema,
NodePendingAckParams: NodePendingAckParamsSchema,
@@ -64,12 +64,10 @@ export type AgentWaitParams = SchemaType<"AgentWaitParams">;
export type WakeParams = SchemaType<"WakeParams">;
/** Node pairing, presence, invoke, and pending-queue protocol payloads. */
export type NodePairRequestParams = SchemaType<"NodePairRequestParams">;
export type NodePairListParams = SchemaType<"NodePairListParams">;
export type NodePairApproveParams = SchemaType<"NodePairApproveParams">;
export type NodePairRejectParams = SchemaType<"NodePairRejectParams">;
export type NodePairRemoveParams = SchemaType<"NodePairRemoveParams">;
export type NodePairVerifyParams = SchemaType<"NodePairVerifyParams">;
export type NodeRenameParams = SchemaType<"NodeRenameParams">;
export type NodeListParams = SchemaType<"NodeListParams">;
export type NodePendingAckParams = SchemaType<"NodePendingAckParams">;
+7 -8
View File
@@ -176,7 +176,6 @@ function mergePairedNodeWithEffectiveNode(
return {
...paired,
...effective,
token: paired?.token,
createdAtMs: paired?.createdAtMs,
lastConnectedAtMs: paired?.lastConnectedAtMs ?? effective.connectedAtMs,
displayName: effective.displayName ?? paired?.displayName,
@@ -224,12 +223,6 @@ async function tryReadNodeList(opts: NodesRpcOpts): Promise<NodeListNode[] | nul
}
}
function sanitizePairedNodeForListJson(node: PairedNodeListRow): Omit<PairedNodeListRow, "token"> {
const copy: Record<string, unknown> = { ...node };
delete copy.token;
return copy as Omit<PairedNodeListRow, "token">;
}
/** Register node status, describe, and paired-node list commands. */
export function registerNodesStatusCommands(nodes: Command) {
nodesCallOpts(
@@ -548,7 +541,13 @@ export function registerNodesStatusCommands(nodes: Command) {
if (opts.json) {
defaultRuntime.writeJson({
pending: pendingRows,
paired: filteredPaired.map(sanitizePairedNodeForListJson),
// Current gateways emit no token, but the permissive parser keeps
// unknown fields; strip so an older gateway's legacy node token
// never reaches JSON output.
paired: filteredPaired.map((row) => {
const { token: _token, ...rest } = row as { token?: unknown };
return rest;
}),
});
return;
}
+5 -6
View File
@@ -3,7 +3,6 @@ import {
pruneSupersededSilentPairedDevices,
type PrunedSupersededPairedDevice,
} from "../infra/device-pairing.js";
import { removePairedNode } from "../infra/node-pairing.js";
import type { GatewayRequestContext } from "./server-methods/types.js";
type PruneContext = Pick<
@@ -42,11 +41,11 @@ export async function pruneSupersededSilentPairingsAfterApproval(params: {
// Invalidate before disconnect so buffered frames from a racing reconnect
// fail authorization, mirroring device.pair.remove ordering.
context.invalidateClientsForDevice?.(entry.deviceId, { reason: "device-pair-removed" });
// A device-backed node may also hold a legacy nodes/paired.json row under the
// same id; retire it too. Pruned devices are offline (connected ones are
// skipped), so there is no live node session or queued action state to clear.
const removedNode = await removePairedNode(entry.deviceId, params.baseDir);
if (removedNode || entry.roles.includes("node")) {
// The node surface lives on the pruned device record, so dropping the
// record retired it too; tell node list consumers. Pruned devices are
// offline (connected ones are skipped), so there is no live node session
// or queued action state to clear.
if (entry.roles.includes("node")) {
context.broadcast(
"node.pair.resolved",
{ requestId: "", nodeId: entry.deviceId, decision: "removed", ts: Date.now() },
-2
View File
@@ -186,12 +186,10 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "last-heartbeat", scope: "operator.read" },
{ name: "set-heartbeats", scope: "operator.admin" },
{ name: "wake", scope: "operator.write" },
{ name: "node.pair.request", scope: "operator.pairing" },
{ name: "node.pair.list", scope: "operator.pairing" },
{ name: "node.pair.approve", scope: "operator.pairing" },
{ name: "node.pair.reject", scope: "operator.pairing" },
{ name: "node.pair.remove", scope: "operator.pairing" },
{ name: "node.pair.verify", scope: "operator.pairing" },
{ name: "device.pair.list", scope: "operator.pairing" },
{ name: "device.pair.approve", scope: "operator.pairing" },
{ name: "device.pair.reject", scope: "operator.pairing" },
-1
View File
@@ -40,7 +40,6 @@ function pairedDevice(overrides: Partial<TestPairedDevice> = {}): TestPairedDevi
function pairedNode(overrides: Partial<TestPairedNode> = {}): TestPairedNode {
return {
nodeId: "mac-1",
token: "node-token",
platform: "macos",
caps: ["camera"],
commands: ["system.run"],
@@ -28,7 +28,6 @@ function makeNodeConnectParams(overrides?: Partial<ConnectParams>): ConnectParam
function makePairedNode(overrides?: Partial<NodePairingPairedNode>): NodePairingPairedNode {
return {
nodeId: "openclaw-ios",
token: "token-1",
createdAtMs: 1,
approvedAtMs: 1,
...overrides,
@@ -1,5 +1,6 @@
// Covers paired-node reapproval reuse and changed-surface write limits.
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js";
import {
approveNodePairing,
beginNodePairingConnect,
@@ -13,6 +14,18 @@ import { createNodeReapprovalCoordinator } from "./node-reapproval-coordinator.j
const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-reapproval-" });
async function setupPairedNode(baseDir: string): Promise<void> {
// Node surfaces attach to paired devices, so device pairing comes first.
const devicePairing = await requestDevicePairing(
{
deviceId: "node-1",
publicKey: "pk-node-1",
role: "node",
roles: ["node"],
scopes: [],
},
baseDir,
);
await approveDevicePairing(devicePairing.request.requestId, { callerScopes: [] }, baseDir);
const request = await requestNodePairing(
{
nodeId: "node-1",
-2
View File
@@ -611,12 +611,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
}),
...createLazyCoreHandlers({
methods: [
"node.pair.request",
"node.pair.list",
"node.pair.approve",
"node.pair.reject",
"node.pair.remove",
"node.pair.verify",
"node.rename",
"node.list",
"node.describe",
@@ -440,70 +440,6 @@ async function ackPending(nodeId: string, ids: string[], commands?: string[]) {
return respond;
}
describe("node.pair.request", () => {
it("passes permissions and resolves superseded prompts before broadcasting replacement requests", async () => {
mocks.requestNodePairing.mockResolvedValue({
status: "pending",
created: true,
request: {
requestId: "req-new",
nodeId: "ios-node-1",
commands: ["canvas.snapshot"],
permissions: { camera: true },
ts: 1,
},
superseded: [{ requestId: "req-old", nodeId: "ios-node-1" }],
});
const respond = vi.fn();
const broadcast = vi.fn();
await nodeHandlers["node.pair.request"]({
params: {
nodeId: "ios-node-1",
commands: ["canvas.snapshot"],
permissions: { camera: true },
},
respond: respond as never,
context: { broadcast } as never,
client: null,
req: { type: "req", id: "req-node-pair", method: "node.pair.request" },
isWebchatConnect: () => false,
});
expect(mocks.requestNodePairing).toHaveBeenCalledWith({
nodeId: "ios-node-1",
displayName: undefined,
platform: undefined,
version: undefined,
coreVersion: undefined,
uiVersion: undefined,
deviceFamily: undefined,
modelIdentifier: undefined,
caps: undefined,
commands: ["canvas.snapshot"],
permissions: { camera: true },
remoteIp: undefined,
silent: undefined,
});
expect(mockArg(broadcast, 0, 0)).toBe("node.pair.resolved");
expect(mockArg(broadcast, 0, 1)).toEqual({
requestId: "req-old",
nodeId: "ios-node-1",
decision: "rejected",
ts: expect.any(Number),
});
expect(mockArg(broadcast, 1, 0)).toBe("node.pair.requested");
expect(mockArg(broadcast, 1, 1)).toEqual({
requestId: "req-new",
nodeId: "ios-node-1",
commands: ["canvas.snapshot"],
permissions: { camera: true },
ts: 1,
});
expect(firstRespondCall(respond)[0]).toBe(true);
});
});
describe("node plugin surface refresh", () => {
it("refreshes generic plugin surface capability urls", async () => {
vi.useFakeTimers();
+3 -35
View File
@@ -144,7 +144,7 @@ async function pairMixedRoleAndroidDevice(stateDir: string, nodeId: string): Pro
expect(approved?.status).toBe("approved");
}
async function pairLegacyNode(stateDir: string, nodeId: string): Promise<void> {
async function approveNodeSurface(stateDir: string, nodeId: string): Promise<void> {
const pending = await requestNodePairing(
{
nodeId,
@@ -265,13 +265,12 @@ describe("nodeHandlers node.pair.remove", () => {
},
);
it("removes both backing records when a node row is merged from node and device stores", async () => {
it("removes the device row together with its approved node surface", async () => {
const state = await createState("node-remove-merged-backing-stores");
const nodeId = "merged-android-node-1";
await pairLegacyNode(state.stateDir, nodeId);
await pairAndroidNodeDevice(state.stateDir, nodeId);
await approveNodeSurface(state.stateDir, nodeId);
expect(Object.hasOwn(await readPaired(state.stateDir, "nodes"), nodeId)).toBe(true);
expect(Object.hasOwn(await readPaired(state.stateDir, "devices"), nodeId)).toBe(true);
const { context, opts } = createOptions({ nodeId: ` ${nodeId} ` });
@@ -288,7 +287,6 @@ describe("nodeHandlers node.pair.remove", () => {
await Promise.resolve();
expect(respond).toHaveBeenCalledWith(true, { nodeId }, undefined);
expect(Object.hasOwn(await readPaired(state.stateDir, "nodes"), nodeId)).toBe(false);
expect(Object.hasOwn(await readPaired(state.stateDir, "devices"), nodeId)).toBe(false);
expect(context.invalidateClientsForDevice).toHaveBeenCalledWith(nodeId, {
role: "node",
@@ -311,36 +309,6 @@ describe("nodeHandlers node.pair.remove", () => {
);
});
it("clears and disconnects a removed device-backed node when legacy cleanup fails", async () => {
const state = await createState("node-remove-legacy-cleanup-failure");
const nodeId = "legacy-cleanup-failure-node-1";
await pairLegacyNode(state.stateDir, nodeId);
await pairAndroidNodeDevice(state.stateDir, nodeId);
const { pairedPath: legacyPairedPath } = resolvePairingPaths(state.stateDir, "nodes");
await writeFile(legacyPairedPath, "{invalid-json", "utf8");
const { context, opts } = createOptions({ nodeId });
await nodeHandlers["node.pair.remove"](opts);
await Promise.resolve();
expect(opts.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("Failed to parse JSON file") }),
);
expect(Object.hasOwn(await readPaired(state.stateDir, "devices"), nodeId)).toBe(false);
expect(context.invalidateClientsForDevice).toHaveBeenCalledWith(nodeId, {
role: "node",
reason: "device-pair-removed",
});
expect(context.nodeRegistry.updateSurface).toHaveBeenCalledWith(nodeId, {
caps: [],
commands: [],
permissions: undefined,
});
expect(context.disconnectClientsForDevice).toHaveBeenCalledWith(nodeId, { role: "node" });
});
it("preserves non-node device roles when removing a mixed-role node row", async () => {
const state = await createState("node-remove-mixed-role-device");
const nodeId = "mixed-role-android-node-1";
+24 -110
View File
@@ -20,8 +20,6 @@ import {
validateNodePairListParams,
validateNodePairRejectParams,
validateNodePairRemoveParams,
validateNodePairRequestParams,
validateNodePairVerifyParams,
validateNodeRenameParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { getRuntimeConfig } from "../../config/io.js";
@@ -37,10 +35,7 @@ import {
approveNodePairing,
listNodePairing,
rejectNodePairing,
removePairedNode,
renamePairedNode,
requestNodePairing,
verifyNodeToken,
} from "../../infra/node-pairing.js";
import {
clearApnsRegistrationIfCurrent,
@@ -897,53 +892,6 @@ export async function waitForNodeReconnect(params: {
}
export const nodeHandlers: GatewayRequestHandlers = {
"node.pair.request": async ({ params, respond, context }) => {
if (!validateNodePairRequestParams(params)) {
respondInvalidParams({
respond,
method: "node.pair.request",
validator: validateNodePairRequestParams,
});
return;
}
const p = params as Parameters<typeof requestNodePairing>[0];
await respondUnavailableOnThrow(respond, async () => {
const result = await requestNodePairing({
nodeId: p.nodeId,
displayName: p.displayName,
platform: p.platform,
version: p.version,
coreVersion: p.coreVersion,
uiVersion: p.uiVersion,
deviceFamily: p.deviceFamily,
modelIdentifier: p.modelIdentifier,
caps: p.caps,
commands: p.commands,
permissions: p.permissions,
remoteIp: p.remoteIp,
silent: p.silent,
});
const resolvedAt = Date.now();
for (const superseded of result.superseded ?? []) {
context.broadcast(
"node.pair.resolved",
{
requestId: superseded.requestId,
nodeId: superseded.nodeId,
decision: "rejected",
ts: resolvedAt,
},
{ dropIfSlow: true },
);
}
if (result.status === "pending" && result.created) {
context.broadcast("node.pair.requested", result.request, {
dropIfSlow: true,
});
}
respond(true, result, undefined);
});
},
"node.pair.list": async ({ params, respond }) => {
if (!validateNodePairListParams(params)) {
respondInvalidParams({
@@ -1054,14 +1002,14 @@ export const nodeHandlers: GatewayRequestHandlers = {
respond(true, rejected, undefined);
});
},
// Remove a node pairing (CLI: `openclaw nodes remove`). For a device-backed
// node this revokes the device's `node` role in devices/paired.json and
// disconnects its node-role sessions: a mixed-role device keeps its row and
// only loses the `node` role, a node-only device row is deleted. Any matching
// legacy gateway-owned node pairing entry is also cleared. Authz mirrors
// device.pair.remove: operator.pairing may remove non-operator node rows; a
// device-token caller revoking its own node role on a mixed-role device
// additionally needs operator.admin (see removePairedDeviceBackedNode).
// Remove a node pairing (CLI: `openclaw nodes remove`). This revokes the
// device's `node` role in devices/paired.json, which drops the approved node
// surface with it, and disconnects the device's node-role sessions: a
// mixed-role device keeps its row and only loses the `node` role, a
// node-only device row is deleted. Authz mirrors device.pair.remove:
// operator.pairing may remove non-operator node rows; a device-token caller
// revoking its own node role on a mixed-role device additionally needs
// operator.admin (see removePairedDeviceBackedNode).
"node.pair.remove": async ({ params, respond, context, client }) => {
if (!validateNodePairRemoveParams(params)) {
respondInvalidParams({
@@ -1073,62 +1021,28 @@ export const nodeHandlers: GatewayRequestHandlers = {
}
const { nodeId } = params as { nodeId: string };
await respondUnavailableOnThrow(respond, async () => {
const requestedNodeId = nodeId.trim();
const deviceBacked = await removePairedDeviceBackedNode({ nodeId, client, context });
if (deviceBacked.status === "denied") {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, deviceBacked.message));
return;
}
const removedDeviceNodeId =
deviceBacked.status === "removed" ? deviceBacked.nodeId : undefined;
try {
// Device pairing removal is already durable. Clear the live node surface
// before touching the independent legacy store so a cleanup failure
// cannot leave the revoked session invokable.
if (removedDeviceNodeId) {
clearRemovedNodeRuntimeState({ nodeId: removedDeviceNodeId, context });
}
const legacyNodeId = removedDeviceNodeId ?? requestedNodeId;
const removed = await removePairedNode(legacyNodeId);
const removedNodeId = removed?.nodeId ?? removedDeviceNodeId;
if (!removedNodeId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
return;
}
if (!removedDeviceNodeId) {
clearRemovedNodeRuntimeState({ nodeId: removedNodeId, context });
}
broadcastRemovedNodePairing({ nodeId: removedNodeId, context });
respond(true, { nodeId: removedNodeId }, undefined);
} finally {
if (deviceBacked.status === "removed") {
// Preserve response-first shutdown on success, while guaranteeing the
// hard close when legacy-store cleanup or later bookkeeping throws.
queueMicrotask(() => {
context.disconnectClientsForDevice?.(deviceBacked.disconnectDeviceId, {
role: "node",
});
});
}
if (deviceBacked.status !== "removed") {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
return;
}
try {
clearRemovedNodeRuntimeState({ nodeId: deviceBacked.nodeId, context });
broadcastRemovedNodePairing({ nodeId: deviceBacked.nodeId, context });
respond(true, { nodeId: deviceBacked.nodeId }, undefined);
} finally {
// Preserve response-first shutdown on success, while guaranteeing the
// hard close when runtime cleanup or later bookkeeping throws.
queueMicrotask(() => {
context.disconnectClientsForDevice?.(deviceBacked.disconnectDeviceId, {
role: "node",
});
});
}
});
},
"node.pair.verify": async ({ params, respond }) => {
if (!validateNodePairVerifyParams(params)) {
respondInvalidParams({
respond,
method: "node.pair.verify",
validator: validateNodePairVerifyParams,
});
return;
}
const { nodeId, token } = params as {
nodeId: string;
token: string;
};
await respondUnavailableOnThrow(respond, async () => {
const result = await verifyNodeToken(nodeId, token);
respond(true, result, undefined);
});
},
"node.rename": async ({ params, respond }) => {
+3 -3
View File
@@ -1396,7 +1396,7 @@ describe("agent request events", () => {
handled: true,
reason: "persisted",
});
expectPresencePersistCall(updatePairedNodeMetadataMock, "ios-node", "bg_app_refresh");
expect(updatePairedNodeMetadataMock).not.toHaveBeenCalled();
expectPresencePersistCall(updatePairedDeviceMetadataMock, "ios-node", "bg_app_refresh");
expect(getRecentNodePresencePersistCountForTests()).toBe(1);
});
@@ -1431,7 +1431,7 @@ describe("agent request events", () => {
{ deviceId: "ios-node" },
);
expectPresencePersistCall(updatePairedNodeMetadataMock, "ios-node", "background");
expect(updatePairedNodeMetadataMock).not.toHaveBeenCalled();
expectPresencePersistCall(updatePairedDeviceMetadataMock, "ios-node", "background");
});
@@ -1485,7 +1485,7 @@ describe("agent request events", () => {
handled: true,
reason: "throttled",
});
expect(updatePairedNodeMetadataMock).toHaveBeenCalledTimes(1);
expect(updatePairedNodeMetadataMock).not.toHaveBeenCalled();
expect(updatePairedDeviceMetadataMock).toHaveBeenCalledTimes(1);
expect(getRecentNodePresencePersistCountForTests()).toBe(1);
});
+7 -12
View File
@@ -14,7 +14,6 @@ import {
resolveEventSessionRoutingPolicy,
scopedHeartbeatWakeOptionsForPolicy,
} from "../infra/event-session-routing.js";
import { updatePairedNodeMetadata } from "../infra/node-pairing.js";
import type { PromptImageOrderEntry } from "../media/prompt-image-order.js";
import {
NODE_PRESENCE_ALIVE_EVENT,
@@ -885,17 +884,13 @@ export const handleNodeEvent = async (
const lastSeenReason = normalizeNodePresenceAliveReason(obj.trigger);
try {
const [nodeUpdated, deviceUpdated] = await Promise.all([
updatePairedNodeMetadata(nodeId, {
lastSeenAtMs: now,
lastSeenReason,
}),
updatePairedDeviceMetadata(deviceId, {
lastSeenAtMs: now,
lastSeenReason,
}),
]);
if (!nodeUpdated && !deviceUpdated) {
// Node last-seen lives on the device record; node.pair.list projects
// it from there, so one write covers both surfaces.
const deviceUpdated = await updatePairedDeviceMetadata(deviceId, {
lastSeenAtMs: now,
lastSeenReason,
});
if (!deviceUpdated) {
return { ok: true, event: evt.event, handled: false, reason: "unpaired" };
}
recentNodePresencePersistAt.set(deviceId, now);
+11
View File
@@ -81,6 +81,17 @@ export async function prepareGatewayPluginBootstrap(params: {
log: params.log,
}),
);
const { migrateLegacyNodePairingStore } = await import("../infra/node-pairing-migration.js");
startupTasks.push(
migrateLegacyNodePairingStore({ log: params.log }).then(
() => undefined,
(error: unknown) => {
// A failed fold must not block gateway startup; the legacy files
// stay in place and the next boot retries.
params.log.warn(`node pairing store migration failed: ${String(error)}`);
},
),
);
}
await Promise.all(startupTasks);
}
+37 -4
View File
@@ -2,6 +2,7 @@
// command scopes, and gateway enforcement around node client identity.
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js";
import { approveNodePairing, listNodePairing, requestNodePairing } from "../infra/node-pairing.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
@@ -27,6 +28,15 @@ async function makeNodePairingStateDir(): Promise<string> {
return await tempDirs.make("case");
}
// Node surfaces attach to paired devices, so tests seed device pairing first.
async function seedNodeDevice(nodeId: string, baseDir?: string): Promise<void> {
const request = await requestDevicePairing(
{ deviceId: nodeId, publicKey: `pk-${nodeId}`, role: "node", roles: ["node"], scopes: [] },
baseDir,
);
await approveDevicePairing(request.request.requestId, { callerScopes: [] }, baseDir);
}
async function findPairedNode(nodeId: string, baseDir?: string) {
const pairing = await listNodePairing(baseDir);
return pairing.paired.find((node) => node.nodeId === nodeId) ?? null;
@@ -187,6 +197,7 @@ async function expectRpcNodePairingApprovalRejected(params: {
scopes: params.operatorScopes,
deviceIdentityPath: `${await makeNodePairingStateDir()}/${params.operatorName}.json`,
});
await seedNodeDevice(params.nodeId);
const request = await requestNodePairing({
nodeId: params.nodeId,
platform: "macos",
@@ -244,6 +255,7 @@ describe("gateway node pairing authorization", () => {
describe("approval scopes", () => {
test("rejects node pairing approval without admin scope", async () => {
const baseDir = await makeNodePairingStateDir();
await seedNodeDevice("node-approve-reject-admin", baseDir);
const request = await requestNodePairing(
{
nodeId: "node-approve-reject-admin",
@@ -269,6 +281,7 @@ describe("gateway node pairing authorization", () => {
test("rejects node pairing approval without pairing scope", async () => {
const baseDir = await makeNodePairingStateDir();
await seedNodeDevice("node-approve-reject-pairing", baseDir);
const request = await requestNodePairing(
{
nodeId: "node-approve-reject-pairing",
@@ -294,6 +307,7 @@ describe("gateway node pairing authorization", () => {
test("approves commandless node pairing with pairing scope", async () => {
const baseDir = await makeNodePairingStateDir();
await seedNodeDevice("node-approve-target", baseDir);
const request = await requestNodePairing(
{
nodeId: "node-approve-target",
@@ -343,6 +357,7 @@ describe("gateway node pairing authorization", () => {
describeWithGatewayServer("pending diagnostics scopes", (getStarted) => {
test("shows pending pairing records to direct-local backend shared-auth callers", async () => {
const pendingOnlyNodeId = "node-local-backend-pending";
await seedNodeDevice(pendingOnlyNodeId);
const pending = await requestNodePairing({
nodeId: pendingOnlyNodeId,
platform: "macos",
@@ -394,6 +409,7 @@ describe("gateway node pairing authorization", () => {
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
await seedNodeDevice(pairedNodeId);
const initial = await requestNodePairing({
nodeId: pairedNodeId,
platform: "macos",
@@ -407,6 +423,7 @@ describe("gateway node pairing authorization", () => {
platform: "macos",
commands: ["screen.snapshot", "system.run"],
});
await seedNodeDevice(pendingOnlyNodeId);
await requestNodePairing({
nodeId: pendingOnlyNodeId,
platform: "macos",
@@ -435,7 +452,20 @@ describe("gateway node pairing authorization", () => {
const listed = await rpcReq<{ nodes?: NodeDiagnostics[] }>(ws, "node.list", {});
expect(listed.ok).toBe(true);
const nodes = listed.payload?.nodes ?? [];
expect(nodes.some((node) => node.nodeId === pendingOnlyNodeId)).toBe(false);
// Pending surfaces now attach to paired devices, so the row is visible
// to read-only callers but its approval target stays redacted.
expect(nodes.find((node) => node.nodeId === pendingOnlyNodeId)).toEqual(
expect.objectContaining({
nodeId: pendingOnlyNodeId,
approvalState: "pending-approval",
}),
);
expect(nodes.find((node) => node.nodeId === pendingOnlyNodeId)).not.toHaveProperty(
"pendingRequestId",
);
expect(nodes.find((node) => node.nodeId === pendingOnlyNodeId)).not.toHaveProperty(
"pendingDeclaredCommands",
);
expect(nodes.find((node) => node.nodeId === pairedNodeId)).toEqual(
expect.objectContaining({
nodeId: pairedNodeId,
@@ -485,9 +515,12 @@ describe("gateway node pairing authorization", () => {
expect(describedVisiblePending.payload).not.toHaveProperty("pendingRequestId");
expect(describedVisiblePending.payload).not.toHaveProperty("pendingDeclaredCommands");
const pendingOnly = await rpcReq(ws, "node.describe", { nodeId: pendingOnlyNodeId });
expect(pendingOnly.ok).toBe(false);
expect(pendingOnly.error?.message).toContain("unknown nodeId");
const pendingOnly = await rpcReq<NodeDiagnostics>(ws, "node.describe", {
nodeId: pendingOnlyNodeId,
});
expect(pendingOnly.ok).toBe(true);
expect(pendingOnly.payload).not.toHaveProperty("pendingRequestId");
expect(pendingOnly.payload).not.toHaveProperty("pendingDeclaredCommands");
const selfWs = await openTrackedWs(getStarted().port);
try {
@@ -6,7 +6,11 @@ import path from "node:path";
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../infra/device-identity.js";
import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js";
import { approveNodePairing, listNodePairing, requestNodePairing } from "../infra/node-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
@@ -66,6 +70,17 @@ async function attemptNodePairing(
async function approveNodeIdentity(params: { identityPath: string; caps: string[] }) {
const identity = loadOrCreateDeviceIdentity(params.identityPath);
// Node surfaces attach to paired devices, so device pairing comes first.
// The stored key must match what the reconnect presents or the handshake
// restarts pairing and burns the rate-limit budget under test.
const devicePairing = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
role: "node",
roles: ["node"],
scopes: [],
});
await approveDevicePairing(devicePairing.request.requestId, { callerScopes: [] });
const request = await requestNodePairing({
nodeId: identity.deviceId,
platform: NODE_CLIENT.platform,
+95 -1
View File
@@ -117,6 +117,55 @@ export type RevokeDeviceTokenResult =
*/
export type PairedDeviceApprovalKind = "owner" | "silent" | "trusted-cidr" | "bootstrap";
/**
* Approved node capability surface for a node-role device. Device pairing
* grants connection auth; this grants command/capability exposure (node
* command gating). displayName here is the operator-facing node name set at
* approval or via node.rename; it must not be clobbered by reconnect
* metadata refreshes, which is why it lives apart from the device fields.
*/
export type PairedDeviceNodeSurface = {
displayName?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
bins?: string[];
createdAtMs: number;
approvedAtMs: number;
lastConnectedAtMs?: number;
};
/**
* Pending node-surface approval awaiting an operator decision (one per
* device). Carries its own metadata snapshot so approval UIs can show what
* the node declared at request time. `revision` guards the reconnect-vs-
* approve race: reconnect cleanup only deletes the revision it observed, so
* a refreshed request survives concurrent approval flows.
*/
export type PairedDevicePendingNodeSurface = {
requestId: string;
revision: string;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
clientId?: string;
clientMode?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
remoteIp?: string;
silent?: boolean;
ts: number;
};
/** Persisted approved device record, including durable approval and active role tokens. */
export type PairedDevice = {
deviceId: string;
@@ -133,6 +182,8 @@ export type PairedDevice = {
remoteIp?: string;
tokens?: Record<string, DeviceAuthToken>;
approvedVia?: PairedDeviceApprovalKind;
nodeSurface?: PairedDeviceNodeSurface;
pendingNodeSurface?: PairedDevicePendingNodeSurface;
createdAtMs: number;
approvedAtMs: number;
lastSeenAtMs?: number;
@@ -226,7 +277,15 @@ async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
pendingById: coercePairingStateRecord<DevicePairingPendingRecord>(pending),
pairedByDeviceId: coercePairingStateRecord<PairedDevice>(paired),
};
pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS);
const now = Date.now();
pruneExpiredPending(state.pendingById, now, PENDING_TTL_MS);
// Pending node-surface requests share the pairing TTL; requests refresh
// their ts on reconnect so an actively retrying node keeps one alive.
for (const device of Object.values(state.pairedByDeviceId)) {
if (device.pendingNodeSurface && now - device.pendingNodeSurface.ts > PENDING_TTL_MS) {
delete device.pendingNodeSurface;
}
}
return state;
}
@@ -252,6 +311,29 @@ async function persistState(
]);
}
/**
* Internal seam for the node-surface module (node-pairing.ts): run one
* operation against the paired-device records under the shared pairing lock.
* Return `persist: true` to write the paired store after the mutation. Not a
* public API node surface state lives inside device records, and both
* modules must serialize through the same lock to avoid lost updates.
*/
export async function withPairedDeviceRecords<T>(
baseDir: string | undefined,
operate: (
pairedByDeviceId: Record<string, PairedDevice>,
) => { value: T; persist: boolean } | Promise<{ value: T; persist: boolean }>,
): Promise<T> {
return await withLock(async () => {
const state = await loadState(baseDir);
const outcome = await operate(state.pairedByDeviceId);
if (outcome.persist) {
await persistState(state, baseDir, "paired");
}
return outcome.value;
});
}
function normalizeDeviceId(deviceId: string) {
return deviceId.trim();
}
@@ -593,6 +675,12 @@ function buildApprovedPairedDevice(params: {
remoteIp: params.accessMetadata?.remoteIp ?? params.pending.remoteIp,
tokens: params.tokens,
approvedVia: mergeApprovalKind(params.existing, params.approvedVia),
// Node capability approvals ride on the device record; device repair or
// role re-approval must not silently revoke an approved node surface.
...(params.existing?.nodeSurface ? { nodeSurface: params.existing.nodeSurface } : {}),
...(params.existing?.pendingNodeSurface
? { pendingNodeSurface: params.existing.pendingNodeSurface }
: {}),
createdAtMs: params.existing?.createdAtMs ?? params.now,
approvedAtMs: params.now,
lastSeenAtMs: params.accessMetadata?.lastSeenAtMs ?? params.existing?.lastSeenAtMs,
@@ -1198,6 +1286,12 @@ export async function removePairedDeviceRole(params: {
: {}),
tokens: Object.keys(tokens).length > 0 ? tokens : undefined,
};
if (role === "node") {
// The node capability surface is bound to the node role; revoking the
// role must revoke approved command exposure with it.
delete next.nodeSurface;
delete next.pendingNodeSurface;
}
state.pairedByDeviceId[normalizedDeviceId] = next;
await persistState(state, params.baseDir, "both");
return { deviceId: normalizedDeviceId, role, removedDevice: false };
+126
View File
@@ -0,0 +1,126 @@
// Covers the one-time fold of the legacy nodes/*.json store into device records.
import fs from "node:fs/promises";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { approveDevicePairing, getPairedDevice, requestDevicePairing } from "./device-pairing.js";
import { migrateLegacyNodePairingStore } from "./node-pairing-migration.js";
import { listNodePairing } from "./node-pairing.js";
import { resolvePairingPaths, writeJson } from "./pairing-files.js";
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-node-pairing-migration-" });
async function seedNodeDevice(baseDir: string, deviceId: string): Promise<void> {
const request = await requestDevicePairing(
{ deviceId, publicKey: `pk-${deviceId}`, role: "node", roles: ["node"], scopes: [] },
baseDir,
);
await approveDevicePairing(request.request.requestId, { callerScopes: [] }, baseDir);
}
describe("migrateLegacyNodePairingStore", () => {
beforeAll(async () => {
await suiteRootTracker.setup();
});
afterAll(async () => {
await suiteRootTracker.cleanup();
});
test("returns null when no legacy store exists", async () => {
const baseDir = await suiteRootTracker.make("case");
await expect(migrateLegacyNodePairingStore({ baseDir })).resolves.toBeNull();
});
test("folds legacy rows into device records, drops orphans, archives files", async () => {
const baseDir = await suiteRootTracker.make("case");
const legacyTokenValue = ["legacy", "token"].join("-");
await seedNodeDevice(baseDir, "node-kept");
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes");
await writeJson(pairedPath, {
"node-kept": {
nodeId: "node-kept",
token: legacyTokenValue,
displayName: "Living Room iPad",
version: "2026.6.11",
caps: ["canvas", "screen"],
commands: ["screen.snapshot", "system.run"],
permissions: { camera: true },
bins: ["ffmpeg"],
createdAtMs: 1_000,
approvedAtMs: 2_000,
lastConnectedAtMs: 3_000,
},
"node-orphaned": {
nodeId: "node-orphaned",
token: `${legacyTokenValue}-2`,
approvedAtMs: 2_000,
createdAtMs: 1_000,
},
});
await writeJson(pendingPath, {
"req-1": { requestId: "req-1", nodeId: "node-kept", ts: Date.now() },
});
const result = await migrateLegacyNodePairingStore({ baseDir });
expect(result).toEqual({ migrated: 1, orphaned: 1 });
const device = await getPairedDevice("node-kept", baseDir);
expect(device?.nodeSurface).toEqual({
displayName: "Living Room iPad",
version: "2026.6.11",
coreVersion: undefined,
uiVersion: undefined,
modelIdentifier: undefined,
caps: ["canvas", "screen"],
commands: ["screen.snapshot", "system.run"],
permissions: { camera: true },
bins: ["ffmpeg"],
createdAtMs: 1_000,
approvedAtMs: 2_000,
lastConnectedAtMs: 3_000,
});
// The retired token never crosses into the device record.
expect(JSON.stringify(device)).not.toContain(legacyTokenValue);
const list = await listNodePairing(baseDir);
expect(list.paired.map((node) => node.nodeId)).toEqual(["node-kept"]);
expect(list.pending).toHaveLength(0);
// Legacy files archived; a second run is a no-op.
await expect(fs.access(pairedPath)).rejects.toThrow();
await expect(fs.access(`${pairedPath}.migrated`)).resolves.toBeUndefined();
await expect(fs.access(`${pendingPath}.migrated`)).resolves.toBeUndefined();
await expect(migrateLegacyNodePairingStore({ baseDir })).resolves.toBeNull();
});
test("keeps an existing device surface over stale legacy rows", async () => {
const baseDir = await suiteRootTracker.make("case");
await seedNodeDevice(baseDir, "node-current");
const { requestNodePairing, approveNodePairing } = await import("./node-pairing.js");
const pending = await requestNodePairing(
{ nodeId: "node-current", caps: ["screen"], commands: ["screen.snapshot"] },
baseDir,
);
await approveNodePairing(
pending.request.requestId,
{ callerScopes: ["operator.pairing", "operator.write"] },
baseDir,
);
const { pairedPath } = resolvePairingPaths(baseDir, "nodes");
await writeJson(pairedPath, {
"node-current": {
nodeId: "node-current",
caps: ["stale-cap"],
commands: ["stale.command"],
createdAtMs: 1,
approvedAtMs: 2,
},
});
const result = await migrateLegacyNodePairingStore({ baseDir });
expect(result).toEqual({ migrated: 0, orphaned: 0 });
const device = await getPairedDevice("node-current", baseDir);
expect(device?.nodeSurface?.caps).toEqual(["screen"]);
});
});
+106
View File
@@ -0,0 +1,106 @@
// One-time migration of the retired standalone node pairing store.
// Older gateways kept approved node surfaces (and a per-node token) in
// <state>/nodes/{paired,pending}.json; the surface now lives on the paired
// device record. Runs at gateway startup: folds rows into device records,
// drops orphans that no longer map to a node-role device (they cannot pass
// the WS handshake anyway), and archives the legacy files so the migration
// never repeats. Pending rows are 5-minute transients and are not migrated;
// connecting nodes re-request their surface.
import fs from "node:fs/promises";
import { withPairedDeviceRecords, listApprovedPairedDeviceRoles } from "./device-pairing.js";
import {
coercePairingStateRecord,
readJsonIfExists,
resolvePairingPaths,
} from "./pairing-files.js";
type LegacyNodePairingRow = {
nodeId?: string;
displayName?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
bins?: string[];
createdAtMs?: number;
approvedAtMs?: number;
lastConnectedAtMs?: number;
};
export type LegacyNodePairingMigrationResult = {
migrated: number;
orphaned: number;
};
async function archiveLegacyFile(path: string): Promise<void> {
try {
await fs.rename(path, `${path}.migrated`);
} catch {
// Missing file or a racing second gateway process; nothing left to archive.
}
}
/**
* Fold legacy nodes/paired.json rows into device-record node surfaces, then
* archive the legacy files. Idempotent: after the first run the files carry a
* `.migrated` suffix and the function returns null immediately.
*/
export async function migrateLegacyNodePairingStore(params?: {
baseDir?: string;
log?: { info: (message: string) => void; warn: (message: string) => void };
}): Promise<LegacyNodePairingMigrationResult | null> {
const { pendingPath, pairedPath } = resolvePairingPaths(params?.baseDir, "nodes");
const [pairedRaw, pendingRaw] = await Promise.all([
readJsonIfExists<unknown>(pairedPath),
readJsonIfExists<unknown>(pendingPath),
]);
if (pairedRaw == null && pendingRaw == null) {
return null;
}
const legacyRows = coercePairingStateRecord<LegacyNodePairingRow>(pairedRaw);
let migrated = 0;
let orphaned = 0;
if (Object.keys(legacyRows).length > 0) {
await withPairedDeviceRecords(params?.baseDir, (pairedByDeviceId) => {
const now = Date.now();
for (const [rawNodeId, row] of Object.entries(legacyRows)) {
const device = pairedByDeviceId[rawNodeId.trim()];
if (!device || !listApprovedPairedDeviceRoles(device).includes("node")) {
orphaned += 1;
continue;
}
if (device.nodeSurface) {
continue;
}
device.nodeSurface = {
displayName: row.displayName,
version: row.version,
coreVersion: row.coreVersion,
uiVersion: row.uiVersion,
modelIdentifier: row.modelIdentifier,
caps: Array.isArray(row.caps) ? row.caps : undefined,
commands: Array.isArray(row.commands) ? row.commands : undefined,
permissions: row.permissions,
bins: Array.isArray(row.bins) ? row.bins : undefined,
createdAtMs: typeof row.createdAtMs === "number" ? row.createdAtMs : now,
approvedAtMs: typeof row.approvedAtMs === "number" ? row.approvedAtMs : now,
lastConnectedAtMs:
typeof row.lastConnectedAtMs === "number" ? row.lastConnectedAtMs : undefined,
};
migrated += 1;
}
return { value: undefined, persist: migrated > 0 };
});
}
await Promise.all([archiveLegacyFile(pairedPath), archiveLegacyFile(pendingPath)]);
const result = { migrated, orphaned };
params?.log?.info(
`node pairing store migrated: folded ${migrated} node surface(s) into device records, dropped ${orphaned} orphan row(s)`,
);
return result;
}
+93 -128
View File
@@ -1,22 +1,35 @@
// Tests node pairing identity persistence and validation.
import fs from "node:fs/promises";
// Tests node capability-surface approvals stored on paired device records.
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { approveDevicePairing, requestDevicePairing } from "./device-pairing.js";
import {
approveNodePairing,
beginNodePairingConnect,
finalizeNodePairingCleanupClaim,
listNodePairing,
releaseNodePairingCleanupClaim,
removePairedNode,
renamePairedNode,
requestNodePairing,
reusePendingNodePairingForReconnect,
updatePairedNodeMetadata,
verifyNodeToken,
} from "./node-pairing.js";
import { resolvePairingPaths } from "./pairing-files.js";
async function setupPairedNode(baseDir: string): Promise<string> {
const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-pairing-" });
async function withNodePairingDir<T>(run: (baseDir: string) => Promise<T>): Promise<T> {
return await run(await tempDirs.make("case"));
}
async function seedNodeDevice(baseDir: string, nodeId: string): Promise<void> {
const request = await requestDevicePairing(
{ deviceId: nodeId, publicKey: `pk-${nodeId}`, role: "node", roles: ["node"], scopes: [] },
baseDir,
);
await approveDevicePairing(request.request.requestId, { callerScopes: [] }, baseDir);
}
async function setupPairedNode(baseDir: string): Promise<void> {
await seedNodeDevice(baseDir, "node-1");
const request = await requestNodePairing(
{
nodeId: "node-1",
@@ -31,15 +44,7 @@ async function setupPairedNode(baseDir: string): Promise<string> {
baseDir,
);
const paired = await findPairedNode("node-1", baseDir);
expect(typeof paired?.token).toBe("string");
expect(paired?.token.length).toBeGreaterThan(0);
return paired!.token;
}
const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-pairing-" });
async function withNodePairingDir<T>(run: (baseDir: string) => Promise<T>): Promise<T> {
return await run(await tempDirs.make("case"));
expect(paired?.nodeId).toBe("node-1");
}
async function findPairedNode(nodeId: string, baseDir: string) {
@@ -66,7 +71,7 @@ function findRecordByField<T extends Record<string, unknown>>(
return record;
}
describe("node pairing tokens", () => {
describe("node surface approvals", () => {
beforeAll(async () => {
await tempDirs.setup();
});
@@ -75,8 +80,17 @@ describe("node pairing tokens", () => {
await tempDirs.cleanup();
});
test("requires a paired device before accepting surface requests", async () => {
await withNodePairingDir(async (baseDir) => {
await expect(
requestNodePairing({ nodeId: "node-unpaired", platform: "darwin" }, baseDir),
).rejects.toThrow(/paired device/);
});
});
test("reuses pending requests for metadata refreshes", async () => {
await withNodePairingDir(async (baseDir) => {
await seedNodeDevice(baseDir, "node-1");
const first = await requestNodePairing(
{
nodeId: "node-1",
@@ -98,6 +112,7 @@ describe("node pairing tokens", () => {
expect("revision" in first.request).toBe(false);
expect("revision" in second.request).toBe(false);
await seedNodeDevice(baseDir, "node-2");
const commandFirst = await requestNodePairing(
{
nodeId: "node-2",
@@ -123,6 +138,7 @@ describe("node pairing tokens", () => {
expect(commandSecond.request.displayName).toBe("Updated Node");
expect(commandSecond.request.commands).toEqual(["canvas.snapshot"]);
await seedNodeDevice(baseDir, "node-3");
const reorderedFirst = await requestNodePairing(
{
nodeId: "node-3",
@@ -146,6 +162,7 @@ describe("node pairing tokens", () => {
expect(reorderedSecond.superseded).toBeUndefined();
expect(reorderedSecond.request.requestId).toBe(reorderedFirst.request.requestId);
await seedNodeDevice(baseDir, "node-4");
await requestNodePairing(
{
nodeId: "node-4",
@@ -166,6 +183,7 @@ describe("node pairing tokens", () => {
test("supersedes pending requests when the approval surface changes", async () => {
await withNodePairingDir(async (baseDir) => {
await seedNodeDevice(baseDir, "node-1");
const first = await requestNodePairing(
{
nodeId: "node-1",
@@ -192,9 +210,7 @@ describe("node pairing tokens", () => {
const list = await listNodePairing(baseDir);
expect(list.pending).toHaveLength(1);
expect(list.pending[0]?.requestId).toBe(second.request.requestId);
expect(list.pending[0]?.caps).toEqual(["camera"]);
expect(list.pending[0]?.commands).toEqual(["canvas.snapshot", "system.run"]);
expect(list.pending[0]?.permissions).toEqual({ camera: true });
await expect(
approveNodePairing(
@@ -212,10 +228,9 @@ describe("node pairing tokens", () => {
const approvedRecord = requireRecord(approved);
const approvedNode = requireRecord(approvedRecord.node);
expect(approvedRecord.requestId).toBe(second.request.requestId);
expect(approvedNode.caps).toEqual(["camera"]);
expect(approvedNode.commands).toEqual(["canvas.snapshot", "system.run"]);
expect(approvedNode.permissions).toEqual({ camera: true });
await seedNodeDevice(baseDir, "node-2");
const capsFirst = await requestNodePairing(
{
nodeId: "node-2",
@@ -238,6 +253,7 @@ describe("node pairing tokens", () => {
]);
expect(capsSecond.request.requestId).not.toBe(capsFirst.request.requestId);
await seedNodeDevice(baseDir, "node-3");
const permissionsFirst = await requestNodePairing(
{
nodeId: "node-3",
@@ -263,81 +279,6 @@ describe("node pairing tokens", () => {
});
});
test("recovers when pairing state files were written as arrays", async () => {
await withNodePairingDir(async (baseDir) => {
const paths = resolvePairingPaths(baseDir, "nodes");
await fs.mkdir(paths.dir, { recursive: true });
await fs.writeFile(paths.pendingPath, "[]", "utf8");
await fs.writeFile(paths.pairedPath, "[]", "utf8");
const pending = await requestNodePairing(
{
nodeId: "node-array-state",
platform: "darwin",
commands: ["system.run"],
},
baseDir,
);
const approved = await approveNodePairing(
pending.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
);
const approvedRecord = requireRecord(approved);
const approvedNode = requireRecord(approvedRecord.node);
expect(approvedNode.nodeId).toBe("node-array-state");
expect(Array.isArray(JSON.parse(await fs.readFile(paths.pendingPath, "utf8")))).toBe(false);
const pairedState = requireRecord(JSON.parse(await fs.readFile(paths.pairedPath, "utf8")));
const pairedNode = requireRecord(pairedState["node-array-state"]);
expect(pairedNode.nodeId).toBe("node-array-state");
});
});
test("generates base64url node tokens and rejects mismatches", async () => {
await withNodePairingDir(async (baseDir) => {
const token = await setupPairedNode(baseDir);
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(Buffer.from(token, "base64url")).toHaveLength(32);
const verified = await verifyNodeToken("node-1", token, baseDir);
expect(verified.ok).toBe(true);
expect(verified.node?.nodeId).toBe("node-1");
await expect(verifyNodeToken("node-1", "x".repeat(token.length), baseDir)).resolves.toEqual({
ok: false,
});
const multibyteToken = "é".repeat(token.length);
expect(Buffer.from(multibyteToken).length).not.toBe(Buffer.from(token).length);
await expect(verifyNodeToken("node-1", multibyteToken, baseDir)).resolves.toEqual({
ok: false,
});
});
});
test("removes paired nodes without disturbing pending requests", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-2",
platform: "darwin",
},
baseDir,
);
await expect(removePairedNode("node-1", baseDir)).resolves.toEqual({ nodeId: "node-1" });
await expect(removePairedNode("node-1", baseDir)).resolves.toBeNull();
await expect(findPairedNode("node-1", baseDir)).resolves.toBeNull();
const pairing = await listNodePairing(baseDir);
expect(pairing.pending).toHaveLength(1);
expect(pairing.pending[0]?.requestId).toBe(pending.request.requestId);
expect(pairing.pending[0]?.nodeId).toBe("node-2");
expect(pairing.paired).toEqual([]);
});
});
test("rejects every pending request for one node without removing its approval", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
@@ -577,6 +518,7 @@ describe("node pairing tokens", () => {
test("requires the right scopes to approve node requests", async () => {
await withNodePairingDir(async (baseDir) => {
await seedNodeDevice(baseDir, "node-1");
const systemRunRequest = await requestNodePairing(
{
nodeId: "node-1",
@@ -598,6 +540,7 @@ describe("node pairing tokens", () => {
});
await expect(findPairedNode("node-1", baseDir)).resolves.toBeNull();
await seedNodeDevice(baseDir, "node-2");
const commandlessRequest = await requestNodePairing(
{
nodeId: "node-2",
@@ -625,46 +568,68 @@ describe("node pairing tokens", () => {
});
});
test("refuses to overwrite corrupt paired node state when requesting pairing", async () => {
await withNodePairingDir(async (baseDir) => {
const { dir, pairedPath } = resolvePairingPaths(baseDir, "nodes");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(pairedPath, "{not-json}", "utf8");
await expect(
requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
},
baseDir,
),
).rejects.toThrow(/paired\.json/);
await expect(fs.readFile(pairedPath, "utf8")).resolves.toBe("{not-json}");
});
});
test("updates paired node last-seen metadata and reports missing nodes", async () => {
test("updates node runtime metadata and reports missing nodes", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
await expect(
updatePairedNodeMetadata(
"node-1",
{
lastSeenAtMs: 1234,
lastSeenReason: "silent_push",
},
baseDir,
),
updatePairedNodeMetadata("node-1", { lastConnectedAtMs: 1234, bins: ["ffmpeg"] }, baseDir),
).resolves.toBe(true);
await expect(updatePairedNodeMetadata("missing", { lastSeenAtMs: 1 }, baseDir)).resolves.toBe(
false,
);
await expect(
updatePairedNodeMetadata("missing", { lastConnectedAtMs: 1 }, baseDir),
).resolves.toBe(false);
const pairedNode = await findPairedNode("node-1", baseDir);
expect(pairedNode?.lastSeenAtMs).toBe(1234);
expect(pairedNode?.lastSeenReason).toBe("silent_push");
expect(pairedNode?.lastConnectedAtMs).toBe(1234);
expect(pairedNode?.bins).toEqual(["ffmpeg"]);
});
});
test("keeps the approved node surface across a device pairing re-approval", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pendingSurface = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
// A device repair (same id, fresh keypair) rebuilds the paired record;
// approved and pending node surfaces must survive that rebuild.
const repair = await requestDevicePairing(
{
deviceId: "node-1",
publicKey: "pk-node-1-rotated",
role: "node",
roles: ["node"],
scopes: [],
},
baseDir,
);
await approveDevicePairing(repair.request.requestId, { callerScopes: [] }, baseDir);
const paired = await findPairedNode("node-1", baseDir);
expect(paired?.commands).toEqual(["system.run"]);
const pending = (await listNodePairing(baseDir)).pending;
expect(pending).toHaveLength(1);
expect(pending[0]?.requestId).toBe(pendingSurface.request.requestId);
});
});
test("renames the operator-facing node name without touching approval state", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const renamed = await renamePairedNode("node-1", "Living Room iPad", baseDir);
expect(renamed?.displayName).toBe("Living Room iPad");
await expect(renamePairedNode("missing", "Nope", baseDir)).resolves.toBeNull();
const pairedNode = await findPairedNode("node-1", baseDir);
expect(pairedNode?.displayName).toBe("Living Room iPad");
expect(pairedNode?.commands).toEqual(["system.run"]);
});
});
});
+285 -365
View File
@@ -1,20 +1,20 @@
// Manages node pairing identities for gateway and remote device trust.
// Node capability-surface approvals, stored on paired device records.
// Device pairing (device-pairing.ts) owns connection auth; this module owns
// which capabilities/commands an operator approved for a node-role device
// (node command gating). Both share the devices store and its lock through
// withPairedDeviceRecords. The former standalone nodes/{pending,paired}.json
// store and its per-node token were retired; state-migrations.ts folds old
// rows into device records once.
import { randomUUID } from "node:crypto";
import { normalizeArrayBackedTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { resolveMissingRequestedScope } from "../shared/operator-scope-compat.js";
import {
withPairedDeviceRecords,
type PairedDevice,
type PairedDevicePendingNodeSurface,
} from "./device-pairing.js";
import { type NodeApprovalScope, resolveNodePairApprovalScopes } from "./node-pairing-authz.js";
import { sameNodeApprovalSurfaceSet, sameNodePermissionSurface } from "./node-pairing-surface.js";
import {
createAsyncLock,
pruneExpiredPending,
readJsonIfExists,
reconcilePendingPairingRequests,
coercePairingStateRecord,
resolvePairingPaths,
writeJson,
} from "./pairing-files.js";
import { rejectPendingPairingRequest } from "./pairing-pending.js";
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
type NodeDeclaredSurface = {
nodeId: string;
@@ -33,8 +33,6 @@ type NodeDeclaredSurface = {
remoteIp?: string;
};
type NodeApprovedSurface = NodeDeclaredSurface;
/** Node-declared pairing surface before approval. */
export type NodePairingRequestInput = NodeDeclaredSurface & {
silent?: boolean;
@@ -47,10 +45,6 @@ export type NodePairingPendingRequest = NodePairingRequestInput & {
ts: number;
};
type NodePairingPendingRecord = NodePairingPendingRequest & {
revision?: string;
};
export type NodePairingPendingSnapshot = Pick<NodePairingPendingRequest, "requestId" | "nodeId"> & {
revision?: string;
};
@@ -60,7 +54,6 @@ export type NodePairingCleanupClaim = {
baseDir: string | undefined;
generation: number;
nodeId: string;
pendingPath: string;
observed: NodePairingPendingSnapshot[];
};
@@ -79,9 +72,8 @@ type NodePairingPendingEntry = NodePairingPendingRequest & {
requiredApproveScopes: NodeApprovalScope[];
};
/** Approved node record with its pairing token and persisted capability surface. */
export type NodePairingPairedNode = NodeApprovedSurface & {
token: string;
/** Approved node record projected from the device's node surface (no auth material). */
export type NodePairingPairedNode = NodeDeclaredSurface & {
bins?: string[];
createdAtMs: number;
approvedAtMs: number;
@@ -95,26 +87,105 @@ type NodePairingList = {
paired: NodePairingPairedNode[];
};
type NodePairingStateFile = {
pendingById: Record<string, NodePairingPendingRecord>;
pairedByNodeId: Record<string, NodePairingPairedNode>;
};
const PENDING_TTL_MS = 5 * 60 * 1000;
const OPERATOR_ROLE = "operator";
const withLock = createAsyncLock();
const activeCleanupRevisionClaims = new Map<string, Set<number>>();
let nextCleanupClaimGeneration = 0;
function buildPendingNodePairingRequest(params: {
requestId?: string;
req: NodePairingRequestInput;
}): NodePairingPendingRecord {
function normalizeNodeId(nodeId: string) {
return nodeId.trim();
}
function nodeSurfaceDevice(
pairedByDeviceId: Record<string, PairedDevice>,
nodeId: string,
): PairedDevice | null {
return pairedByDeviceId[normalizeNodeId(nodeId)] ?? null;
}
function toPublicPendingRequest(
device: PairedDevice,
pending: PairedDevicePendingNodeSurface,
): NodePairingPendingRequest {
return {
requestId: params.requestId ?? randomUUID(),
requestId: pending.requestId,
nodeId: device.deviceId,
clientId: pending.clientId ?? device.clientId,
clientMode: pending.clientMode ?? device.clientMode,
displayName: pending.displayName ?? device.displayName,
platform: pending.platform ?? device.platform,
version: pending.version,
coreVersion: pending.coreVersion,
uiVersion: pending.uiVersion,
deviceFamily: pending.deviceFamily ?? device.deviceFamily,
modelIdentifier: pending.modelIdentifier,
caps: pending.caps,
commands: pending.commands,
permissions: pending.permissions,
remoteIp: pending.remoteIp ?? device.remoteIp,
silent: pending.silent,
ts: pending.ts,
};
}
function toPendingSnapshot(
device: PairedDevice,
pending: PairedDevicePendingNodeSurface,
): NodePairingPendingSnapshot {
return {
requestId: pending.requestId,
nodeId: device.deviceId,
...(pending.revision ? { revision: pending.revision } : {}),
};
}
function toPendingEntry(
device: PairedDevice,
pending: PairedDevicePendingNodeSurface,
): NodePairingPendingEntry {
return {
...toPublicPendingRequest(device, pending),
requiredApproveScopes: resolveNodePairApprovalScopes(pending.commands ?? []),
};
}
function toPairedNode(device: PairedDevice): NodePairingPairedNode | null {
const surface = device.nodeSurface;
if (!surface) {
return null;
}
return {
nodeId: device.deviceId,
clientId: device.clientId,
clientMode: device.clientMode,
// The surface name is the operator-facing node name (approval snapshot or
// node.rename); reconnect metadata refreshes only touch the device name.
displayName: surface.displayName ?? device.displayName,
platform: device.platform,
version: surface.version,
coreVersion: surface.coreVersion,
uiVersion: surface.uiVersion,
deviceFamily: device.deviceFamily,
modelIdentifier: surface.modelIdentifier,
caps: surface.caps,
commands: surface.commands,
permissions: surface.permissions,
remoteIp: device.remoteIp,
bins: surface.bins,
createdAtMs: surface.createdAtMs,
approvedAtMs: surface.approvedAtMs,
lastConnectedAtMs: surface.lastConnectedAtMs,
lastSeenAtMs: device.lastSeenAtMs,
lastSeenReason: device.lastSeenReason,
};
}
function buildPendingNodeSurface(params: {
req: NodePairingRequestInput;
}): PairedDevicePendingNodeSurface {
return {
requestId: randomUUID(),
revision: randomUUID(),
nodeId: params.req.nodeId,
clientId: params.req.clientId,
clientMode: params.req.clientMode,
displayName: params.req.displayName,
@@ -133,10 +204,10 @@ function buildPendingNodePairingRequest(params: {
};
}
function refreshPendingNodePairingRequest(
existing: NodePairingPendingRecord,
function refreshPendingNodeSurface(
existing: PairedDevicePendingNodeSurface,
incoming: NodePairingRequestInput,
): NodePairingPendingRecord {
): PairedDevicePendingNodeSurface {
return {
...existing,
revision: randomUUID(),
@@ -160,7 +231,7 @@ function refreshPendingNodePairingRequest(
}
function samePendingApprovalSurface(
existing: NodePairingPendingRecord,
existing: PairedDevicePendingNodeSurface,
incoming: NodePairingRequestInput,
): boolean {
const incomingCaps = normalizeArrayBackedTrimmedStringList(incoming.caps) ?? existing.caps;
@@ -176,7 +247,7 @@ function samePendingApprovalSurface(
}
function samePendingReconnectMetadata(
existing: NodePairingPendingRecord,
existing: PairedDevicePendingNodeSurface,
incoming: NodePairingRequestInput,
): boolean {
return (
@@ -194,104 +265,16 @@ function samePendingReconnectMetadata(
);
}
function mergeNodePairingReplacementInput(params: {
existing: readonly NodePairingPendingRecord[];
incoming: NodePairingRequestInput;
}): NodePairingRequestInput {
const latest = params.existing[0];
return {
nodeId: params.incoming.nodeId,
clientId: params.incoming.clientId ?? latest?.clientId,
clientMode: params.incoming.clientMode ?? latest?.clientMode,
displayName: params.incoming.displayName ?? latest?.displayName,
platform: params.incoming.platform ?? latest?.platform,
version: params.incoming.version ?? latest?.version,
coreVersion: params.incoming.coreVersion ?? latest?.coreVersion,
uiVersion: params.incoming.uiVersion ?? latest?.uiVersion,
deviceFamily: params.incoming.deviceFamily ?? latest?.deviceFamily,
modelIdentifier: params.incoming.modelIdentifier ?? latest?.modelIdentifier,
caps: params.incoming.caps ?? latest?.caps,
commands: params.incoming.commands ?? latest?.commands,
permissions: params.incoming.permissions ?? latest?.permissions,
remoteIp: params.incoming.remoteIp ?? latest?.remoteIp,
silent: Boolean(
params.incoming.silent && params.existing.every((pending) => pending.silent === true),
),
};
}
function resolveNodeApprovalRequiredScopes(pending: NodePairingPendingRecord): NodeApprovalScope[] {
const commands = Array.isArray(pending.commands) ? pending.commands : [];
return resolveNodePairApprovalScopes(commands);
}
function toPublicPendingNodePairingRequest(
pending: NodePairingPendingRecord,
): NodePairingPendingRequest {
const { revision: _revision, ...request } = pending;
return request;
}
function toPendingNodePairingSnapshot(
pending: NodePairingPendingRecord,
): NodePairingPendingSnapshot {
const snapshot: NodePairingPendingSnapshot = {
requestId: pending.requestId,
nodeId: pending.nodeId,
};
if (pending.revision) {
snapshot.revision = pending.revision;
}
return snapshot;
}
function toPendingNodePairingEntry(pending: NodePairingPendingRecord): NodePairingPendingEntry {
return {
...toPublicPendingNodePairingRequest(pending),
requiredApproveScopes: resolveNodeApprovalRequiredScopes(pending),
};
}
type ApprovedNodePairingResult = { requestId: string; node: NodePairingPairedNode };
type ForbiddenNodePairingResult = { status: "forbidden"; missingScope: string };
type ApproveNodePairingResult = ApprovedNodePairingResult | ForbiddenNodePairingResult | null;
async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes");
const [pending, paired] = await Promise.all([
readJsonIfExists<unknown>(pendingPath),
readJsonIfExists<unknown>(pairedPath),
]);
const state: NodePairingStateFile = {
pendingById: coercePairingStateRecord<NodePairingPendingRecord>(pending),
pairedByNodeId: coercePairingStateRecord<NodePairingPairedNode>(paired),
};
pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS);
return state;
}
async function persistState(state: NodePairingStateFile, baseDir?: string) {
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes");
await Promise.all([
writeJson(pendingPath, state.pendingById),
writeJson(pairedPath, state.pairedByNodeId),
]);
}
function normalizeNodeId(nodeId: string) {
return nodeId.trim();
}
function buildCleanupRevisionClaimKey(
pendingPath: string,
baseDir: string | undefined,
observed: NodePairingPendingSnapshot,
): string {
return `${pendingPath}\0${observed.requestId}\0${observed.revision ?? ""}`;
return `${baseDir ?? ""}\0${observed.nodeId}\0${observed.requestId}\0${observed.revision ?? ""}`;
}
function addCleanupClaim(claim: NodePairingCleanupClaim): void {
for (const observed of claim.observed) {
const key = buildCleanupRevisionClaimKey(claim.pendingPath, observed);
const key = buildCleanupRevisionClaimKey(claim.baseDir, observed);
const generations = activeCleanupRevisionClaims.get(key) ?? new Set<number>();
generations.add(claim.generation);
activeCleanupRevisionClaims.set(key, generations);
@@ -300,14 +283,14 @@ function addCleanupClaim(claim: NodePairingCleanupClaim): void {
function cleanupClaimIsActive(claim: NodePairingCleanupClaim): boolean {
return claim.observed.some((observed) => {
const key = buildCleanupRevisionClaimKey(claim.pendingPath, observed);
const key = buildCleanupRevisionClaimKey(claim.baseDir, observed);
return activeCleanupRevisionClaims.get(key)?.has(claim.generation) === true;
});
}
function removeCleanupClaim(claim: NodePairingCleanupClaim): void {
for (const observed of claim.observed) {
const key = buildCleanupRevisionClaimKey(claim.pendingPath, observed);
const key = buildCleanupRevisionClaimKey(claim.baseDir, observed);
const generations = activeCleanupRevisionClaims.get(key);
generations?.delete(claim.generation);
if (!generations || generations.size === 0) {
@@ -318,11 +301,10 @@ function removeCleanupClaim(claim: NodePairingCleanupClaim): void {
function invalidateCleanupClaimsThrough(
claim: NodePairingCleanupClaim,
pending: NodePairingPendingRecord,
baseDir: string | undefined,
device: PairedDevice,
pending: PairedDevicePendingNodeSurface,
): void {
const pendingPath = resolvePairingPaths(baseDir, "nodes").pendingPath;
const key = buildCleanupRevisionClaimKey(pendingPath, toPendingNodePairingSnapshot(pending));
const key = buildCleanupRevisionClaimKey(claim.baseDir, toPendingSnapshot(device, pending));
const generations = activeCleanupRevisionClaims.get(key);
if (!generations) {
return;
@@ -337,19 +319,32 @@ function invalidateCleanupClaimsThrough(
}
}
function newToken() {
return generatePairingToken();
function pendingHasActiveCleanupClaim(
baseDir: string | undefined,
device: PairedDevice,
pending: PairedDevicePendingNodeSurface,
): boolean {
const key = buildCleanupRevisionClaimKey(baseDir, toPendingSnapshot(device, pending));
return (activeCleanupRevisionClaims.get(key)?.size ?? 0) > 0;
}
export async function listNodePairing(baseDir?: string): Promise<NodePairingList> {
const state = await loadState(baseDir);
const pending = Object.values(state.pendingById)
.toSorted((a, b) => b.ts - a.ts)
.map(toPendingNodePairingEntry);
const paired = Object.values(state.pairedByNodeId).toSorted(
(a, b) => b.approvedAtMs - a.approvedAtMs,
);
return { pending, paired };
return await withPairedDeviceRecords(baseDir, (pairedByDeviceId) => {
const pending: NodePairingPendingEntry[] = [];
const paired: NodePairingPairedNode[] = [];
for (const device of Object.values(pairedByDeviceId)) {
if (device.pendingNodeSurface) {
pending.push(toPendingEntry(device, device.pendingNodeSurface));
}
const node = toPairedNode(device);
if (node) {
paired.push(node);
}
}
pending.sort((a, b) => b.ts - a.ts);
paired.sort((a, b) => b.approvedAtMs - a.approvedAtMs);
return { value: { pending, paired }, persist: false };
});
}
/** Snapshot pairing state and claim current pending revisions for one paired reconnect. */
@@ -360,124 +355,105 @@ export async function beginNodePairingConnect(
pairedNode: NodePairingPairedNode | null;
cleanupClaim?: NodePairingCleanupClaim;
}> {
return await withLock(async () => {
const state = await loadState(baseDir);
const normalized = normalizeNodeId(nodeId);
const pairedNode = state.pairedByNodeId[normalized] ?? null;
const observed = Object.values(state.pendingById)
.filter((entry) => entry.nodeId === normalized)
.map(toPendingNodePairingSnapshot);
if (!pairedNode || observed.length === 0) {
return { pairedNode };
return await withPairedDeviceRecords<{
pairedNode: NodePairingPairedNode | null;
cleanupClaim?: NodePairingCleanupClaim;
}>(baseDir, (pairedByDeviceId) => {
const device = nodeSurfaceDevice(pairedByDeviceId, nodeId);
const pairedNode = device ? toPairedNode(device) : null;
const pending = device?.pendingNodeSurface;
if (!device || !pairedNode || !pending) {
return { value: { pairedNode }, persist: false };
}
const pendingPath = resolvePairingPaths(baseDir, "nodes").pendingPath;
const claim: NodePairingCleanupClaim = {
baseDir,
generation: ++nextCleanupClaimGeneration,
nodeId: normalized,
pendingPath,
observed,
nodeId: device.deviceId,
observed: [toPendingSnapshot(device, pending)],
};
addCleanupClaim(claim);
return { pairedNode, cleanupClaim: claim };
return { value: { pairedNode, cleanupClaim: claim }, persist: false };
});
}
function pendingHasActiveCleanupClaim(
pending: NodePairingPendingRecord,
baseDir: string | undefined,
): boolean {
const pendingPath = resolvePairingPaths(baseDir, "nodes").pendingPath;
const key = buildCleanupRevisionClaimKey(pendingPath, toPendingNodePairingSnapshot(pending));
return (activeCleanupRevisionClaims.get(key)?.size ?? 0) > 0;
}
/** Release a reconnect cleanup claim without changing pending pairing state. */
export async function releaseNodePairingCleanupClaim(
claim: NodePairingCleanupClaim,
): Promise<void> {
await withLock(async () => {
removeCleanupClaim(claim);
});
removeCleanupClaim(claim);
}
/** Delete pending revisions claimed by a reconnect after hello succeeds. */
export async function finalizeNodePairingCleanupClaim(
claim: NodePairingCleanupClaim,
): Promise<NodePairingSupersededRequest[]> {
return await withLock(async () => {
if (!cleanupClaimIsActive(claim)) {
return [];
}
try {
const state = await loadState(claim.baseDir);
const observedById = new Map(
claim.observed
.filter((entry) => entry.nodeId === claim.nodeId)
.map((entry) => [entry.requestId, entry] as const),
if (!cleanupClaimIsActive(claim)) {
return [];
}
try {
return await withPairedDeviceRecords(claim.baseDir, (pairedByDeviceId) => {
const device = nodeSurfaceDevice(pairedByDeviceId, claim.nodeId);
const pending = device?.pendingNodeSurface;
if (!device || !pending) {
return { value: [], persist: false };
}
const observed = claim.observed.find(
(entry) => entry.requestId === pending.requestId && entry.revision === pending.revision,
);
const rejected = Object.values(state.pendingById)
.filter((pending) => {
const observed = observedById.get(pending.requestId);
return observed !== undefined && observed.revision === pending.revision;
})
.toSorted((left, right) => right.ts - left.ts);
if (rejected.length === 0) {
return [];
if (!observed) {
return { value: [], persist: false };
}
for (const pending of rejected) {
delete state.pendingById[pending.requestId];
}
await persistState(state, claim.baseDir);
return rejected.map((pending) => ({
requestId: pending.requestId,
nodeId: pending.nodeId,
}));
} finally {
removeCleanupClaim(claim);
}
});
delete device.pendingNodeSurface;
return {
value: [{ requestId: pending.requestId, nodeId: device.deviceId }],
persist: true,
};
});
} finally {
removeCleanupClaim(claim);
}
}
/** Create or refresh a pending node pairing request for operator approval. */
/** Create or refresh the pending node-surface request for operator approval. */
export async function requestNodePairing(
req: NodePairingRequestInput,
baseDir?: string,
): Promise<RequestNodePairingResult> {
return await withLock(async () => {
const state = await loadState(baseDir);
const nodeId = normalizeNodeId(req.nodeId);
if (!nodeId) {
throw new Error("nodeId required");
const nodeId = normalizeNodeId(req.nodeId);
if (!nodeId) {
throw new Error("nodeId required");
}
return await withPairedDeviceRecords(baseDir, (pairedByDeviceId) => {
const device = nodeSurfaceDevice(pairedByDeviceId, nodeId);
if (!device) {
// Node surface approvals attach to paired devices; connect paths always
// complete device pairing before requesting a surface, so a missing
// record means the caller skipped the auth handshake.
throw new Error("node pairing requires a paired device");
}
const pendingForNode = Object.values(state.pendingById)
.filter((pending) => pending.nodeId === nodeId)
.toSorted((left, right) => right.ts - left.ts);
const result = await reconcilePendingPairingRequests({
pendingById: state.pendingById,
existing: pendingForNode,
incoming: {
...req,
nodeId,
},
canRefreshSingle: (existing, incoming) => samePendingApprovalSurface(existing, incoming),
refreshSingle: (existing, incoming) => refreshPendingNodePairingRequest(existing, incoming),
buildReplacement: ({ existing, incoming }) =>
buildPendingNodePairingRequest({
req: mergeNodePairingReplacementInput({ existing, incoming }),
}),
persist: async () => await persistState(state, baseDir),
});
const superseded = result.created
? pendingForNode
.filter((pending) => pending.requestId !== result.request.requestId)
.map((pending) => ({ requestId: pending.requestId, nodeId: pending.nodeId }))
: [];
const publicResult = {
...result,
request: toPublicPendingNodePairingRequest(result.request),
const existing = device.pendingNodeSurface;
if (existing && samePendingApprovalSurface(existing, { ...req, nodeId })) {
const refreshed = refreshPendingNodeSurface(existing, req);
device.pendingNodeSurface = refreshed;
return {
value: {
status: "pending" as const,
request: toPublicPendingRequest(device, refreshed),
created: false,
},
persist: true,
};
}
const replacement = buildPendingNodeSurface({ req: { ...req, nodeId } });
device.pendingNodeSurface = replacement;
const superseded = existing ? [{ requestId: existing.requestId, nodeId }] : [];
const result: RequestNodePairingResult = {
status: "pending",
request: toPublicPendingRequest(device, replacement),
created: true,
...(superseded.length > 0 ? { superseded } : {}),
};
return superseded.length > 0 ? { ...publicResult, superseded } : publicResult;
return { value: result, persist: true };
});
}
@@ -487,86 +463,88 @@ export async function reusePendingNodePairingForReconnect(
cleanupClaim: NodePairingCleanupClaim | undefined,
baseDir?: string,
): Promise<RequestNodePairingResult | null> {
return await withLock(async () => {
const state = await loadState(baseDir);
const nodeId = normalizeNodeId(req.nodeId);
const pendingForNode = Object.values(state.pendingById)
.filter((pending) => pending.nodeId === nodeId)
.toSorted((left, right) => right.ts - left.ts);
const nodeId = normalizeNodeId(req.nodeId);
return await withPairedDeviceRecords(baseDir, (pairedByDeviceId) => {
const device = nodeSurfaceDevice(pairedByDeviceId, nodeId);
const pending = device?.pendingNodeSurface;
if (
pendingForNode.length === 1 &&
samePendingApprovalSurface(pendingForNode[0], { ...req, nodeId }) &&
samePendingReconnectMetadata(pendingForNode[0], req)
device &&
pending &&
samePendingApprovalSurface(pending, { ...req, nodeId }) &&
samePendingReconnectMetadata(pending, req)
) {
const pending = pendingForNode[0];
// The unchanged reconnect supersedes older cleanup ownership without
// refreshing the request or writing pairing state.
if (cleanupClaim) {
invalidateCleanupClaimsThrough(cleanupClaim, pending, baseDir);
invalidateCleanupClaimsThrough(cleanupClaim, device, pending);
}
return {
status: "pending",
request: toPublicPendingNodePairingRequest(pending),
created: false,
value: {
status: "pending" as const,
request: toPublicPendingRequest(device, pending),
created: false,
},
persist: false,
};
}
return null;
return { value: null, persist: false };
});
}
type ApprovedNodePairingResult = { requestId: string; node: NodePairingPairedNode };
type ForbiddenNodePairingResult = { status: "forbidden"; missingScope: string };
type ApproveNodePairingResult = ApprovedNodePairingResult | ForbiddenNodePairingResult | null;
/** Approve a pending node request when caller scopes cover the requested command surface. */
export async function approveNodePairing(
requestId: string,
options: { callerScopes?: readonly string[] },
baseDir?: string,
): Promise<ApproveNodePairingResult> {
return await withLock(async () => {
const state = await loadState(baseDir);
const pending = state.pendingById[requestId];
if (!pending) {
return null;
return await withPairedDeviceRecords<ApproveNodePairingResult>(baseDir, (pairedByDeviceId) => {
const device = Object.values(pairedByDeviceId).find(
(entry) => entry.pendingNodeSurface?.requestId === requestId,
);
const pending = device?.pendingNodeSurface;
if (!device || !pending) {
return { value: null, persist: false };
}
// A paired reconnect has atomically observed this revision as stale.
// Approval can resume if the handshake fails and releases its claim.
if (pendingHasActiveCleanupClaim(pending, baseDir)) {
return null;
if (pendingHasActiveCleanupClaim(baseDir, device, pending)) {
return { value: null, persist: false };
}
const requiredScopes = resolveNodeApprovalRequiredScopes(pending);
const requiredScopes = resolveNodePairApprovalScopes(pending.commands ?? []);
const missingScope = resolveMissingRequestedScope({
role: OPERATOR_ROLE,
requestedScopes: requiredScopes,
allowedScopes: options.callerScopes ?? [],
});
if (missingScope) {
return { status: "forbidden", missingScope };
return { value: { status: "forbidden" as const, missingScope }, persist: false };
}
const now = Date.now();
const existing = state.pairedByNodeId[pending.nodeId];
const node: NodePairingPairedNode = {
nodeId: pending.nodeId,
token: newToken(),
clientId: pending.clientId,
clientMode: pending.clientMode,
device.nodeSurface = {
displayName: pending.displayName,
platform: pending.platform,
version: pending.version,
coreVersion: pending.coreVersion,
uiVersion: pending.uiVersion,
deviceFamily: pending.deviceFamily,
modelIdentifier: pending.modelIdentifier,
caps: pending.caps,
commands: pending.commands,
permissions: pending.permissions,
remoteIp: pending.remoteIp,
createdAtMs: existing?.createdAtMs ?? now,
bins: device.nodeSurface?.bins,
createdAtMs: device.nodeSurface?.createdAtMs ?? now,
approvedAtMs: now,
lastConnectedAtMs: device.nodeSurface?.lastConnectedAtMs,
};
delete state.pendingById[requestId];
state.pairedByNodeId[pending.nodeId] = node;
await persistState(state, baseDir);
return { requestId, node };
delete device.pendingNodeSurface;
const node = toPairedNode(device);
if (!node) {
return { value: null, persist: false };
}
return { value: { requestId, node }, persist: true };
});
}
@@ -575,114 +553,56 @@ export async function rejectNodePairing(
requestId: string,
baseDir?: string,
): Promise<{ requestId: string; nodeId: string } | null> {
return await withLock(async () => {
return await rejectPendingPairingRequest<
NodePairingPendingRequest,
NodePairingStateFile,
"nodeId"
>({
requestId,
idKey: "nodeId",
loadState: () => loadState(baseDir),
persistState: (state) => persistState(state, baseDir),
getId: (pending: NodePairingPendingRequest) => pending.nodeId,
});
});
}
/** Remove a paired node without disturbing unrelated pending requests. */
export async function removePairedNode(
nodeId: string,
baseDir?: string,
): Promise<{ nodeId: string } | null> {
return await withLock(async () => {
const state = await loadState(baseDir);
const normalized = normalizeNodeId(nodeId);
if (!normalized || !state.pairedByNodeId[normalized]) {
return null;
return await withPairedDeviceRecords(baseDir, (pairedByDeviceId) => {
const device = Object.values(pairedByDeviceId).find(
(entry) => entry.pendingNodeSurface?.requestId === requestId,
);
if (!device) {
return { value: null, persist: false };
}
delete state.pairedByNodeId[normalized];
await persistState(state, baseDir);
return { nodeId: normalized };
delete device.pendingNodeSurface;
return { value: { requestId, nodeId: device.deviceId }, persist: true };
});
}
/** Verify a paired node token and return the approved node record on success. */
export async function verifyNodeToken(
nodeId: string,
token: string,
baseDir?: string,
): Promise<{ ok: boolean; node?: NodePairingPairedNode }> {
const state = await loadState(baseDir);
const normalized = normalizeNodeId(nodeId);
const node = state.pairedByNodeId[normalized];
if (!node) {
return { ok: false };
}
return verifyPairingToken(token, node.token) ? { ok: true, node } : { ok: false };
}
/** Update non-auth metadata for a paired node heartbeat/status refresh. */
/** Update runtime node-surface metadata (connect stamps, remote skill bins). */
export async function updatePairedNodeMetadata(
nodeId: string,
patch: Partial<Omit<NodePairingPairedNode, "nodeId" | "token" | "createdAtMs" | "approvedAtMs">>,
patch: { lastConnectedAtMs?: number; bins?: string[] },
baseDir?: string,
): Promise<boolean> {
return await withLock(async () => {
const state = await loadState(baseDir);
const normalized = normalizeNodeId(nodeId);
const existing = state.pairedByNodeId[normalized];
if (!existing) {
return false;
return await withPairedDeviceRecords(baseDir, (pairedByDeviceId) => {
const device = nodeSurfaceDevice(pairedByDeviceId, nodeId);
if (!device?.nodeSurface) {
return { value: false, persist: false };
}
const next: NodePairingPairedNode = {
...existing,
clientId: patch.clientId ?? existing.clientId,
clientMode: patch.clientMode ?? existing.clientMode,
displayName: patch.displayName ?? existing.displayName,
platform: patch.platform ?? existing.platform,
version: patch.version ?? existing.version,
coreVersion: patch.coreVersion ?? existing.coreVersion,
uiVersion: patch.uiVersion ?? existing.uiVersion,
deviceFamily: patch.deviceFamily ?? existing.deviceFamily,
modelIdentifier: patch.modelIdentifier ?? existing.modelIdentifier,
remoteIp: patch.remoteIp ?? existing.remoteIp,
caps: patch.caps ?? existing.caps,
commands: patch.commands ?? existing.commands,
bins: patch.bins ?? existing.bins,
permissions: patch.permissions ?? existing.permissions,
lastConnectedAtMs: patch.lastConnectedAtMs ?? existing.lastConnectedAtMs,
lastSeenAtMs: patch.lastSeenAtMs ?? existing.lastSeenAtMs,
lastSeenReason: patch.lastSeenReason ?? existing.lastSeenReason,
device.nodeSurface = {
...device.nodeSurface,
...(patch.lastConnectedAtMs !== undefined
? { lastConnectedAtMs: patch.lastConnectedAtMs }
: {}),
...(patch.bins !== undefined ? { bins: patch.bins } : {}),
};
state.pairedByNodeId[normalized] = next;
await persistState(state, baseDir);
return true;
return { value: true, persist: true };
});
}
/** Rename a paired node display name while preserving token and approval metadata. */
/** Rename a paired node display name while preserving approval metadata. */
export async function renamePairedNode(
nodeId: string,
displayName: string,
baseDir?: string,
): Promise<NodePairingPairedNode | null> {
return await withLock(async () => {
const state = await loadState(baseDir);
const normalized = normalizeNodeId(nodeId);
const existing = state.pairedByNodeId[normalized];
if (!existing) {
return null;
const trimmed = displayName.trim();
if (!trimmed) {
throw new Error("displayName required");
}
return await withPairedDeviceRecords(baseDir, (pairedByDeviceId) => {
const device = nodeSurfaceDevice(pairedByDeviceId, nodeId);
if (!device?.nodeSurface) {
return { value: null, persist: false };
}
const trimmed = displayName.trim();
if (!trimmed) {
throw new Error("displayName required");
}
const next: NodePairingPairedNode = { ...existing, displayName: trimmed };
state.pairedByNodeId[normalized] = next;
await persistState(state, baseDir);
return next;
device.nodeSurface = { ...device.nodeSurface, displayName: trimmed };
return { value: toPairedNode(device), persist: true };
});
}
-49
View File
@@ -1,49 +0,0 @@
// Covers pending pairing rejection helper behavior.
import { describe, expect, it, vi } from "vitest";
import { rejectPendingPairingRequest } from "./pairing-pending.js";
describe("rejectPendingPairingRequest", () => {
it("returns null and skips persistence when the request is missing", async () => {
const persistState = vi.fn();
await expect(
rejectPendingPairingRequest({
requestId: "missing",
idKey: "deviceId",
loadState: async () => ({ pendingById: {} }),
persistState,
getId: (pending: { id: string }) => pending.id,
}),
).resolves.toBeNull();
expect(persistState).not.toHaveBeenCalled();
});
it("removes the request, persists, and returns the dynamic id key", async () => {
const state: { pendingById: Record<string, { accountId: string }> } = {
pendingById: {
keep: { accountId: "keep-me" },
reject: { accountId: "acct-42" },
},
};
const persistState = vi.fn(async () => undefined);
await expect(
rejectPendingPairingRequest({
requestId: "reject",
idKey: "accountId",
loadState: async () => state,
persistState,
getId: (pending: { accountId: string }) => pending.accountId,
}),
).resolves.toEqual({
requestId: "reject",
accountId: "acct-42",
});
expect(state.pendingById).toEqual({
keep: { accountId: "keep-me" },
});
expect(persistState).toHaveBeenCalledWith(state);
});
});
-29
View File
@@ -1,29 +0,0 @@
// Shared helpers for mutating pending pairing request state.
type PendingState<TPending> = {
pendingById: Record<string, TPending>;
};
/** Reject one pending pairing request and return the caller-selected id field. */
export async function rejectPendingPairingRequest<
TPending,
TState extends PendingState<TPending>,
TIdKey extends string,
>(params: {
requestId: string;
idKey: TIdKey;
loadState: () => Promise<TState>;
persistState: (state: TState) => Promise<void>;
getId: (pending: TPending) => string;
}): Promise<({ requestId: string } & Record<TIdKey, string>) | null> {
const state = await params.loadState();
const pending = state.pendingById[params.requestId];
if (!pending) {
return null;
}
delete state.pendingById[params.requestId];
await params.persistState(state);
return {
requestId: params.requestId,
[params.idKey]: params.getId(pending),
} as { requestId: string } & Record<TIdKey, string>;
}
-1
View File
@@ -37,7 +37,6 @@ function normalizePairedNode(row: PairedNode): PairedNode | null {
return {
...row,
nodeId,
token: normalizeOptionalString(row.token),
displayName: normalizeOptionalString(row.displayName),
platform: normalizeOptionalString(row.platform),
version: normalizeOptionalString(row.version),
+1 -2
View File
@@ -43,10 +43,9 @@ export type PendingRequest = {
requiredApproveScopes?: Array<"operator.pairing" | "operator.write" | "operator.admin">;
};
/** Persisted paired node entry with optional token and permission metadata. */
/** Persisted paired node entry with permission metadata. */
export type PairedNode = {
nodeId: string;
token?: string;
displayName?: string;
platform?: string;
version?: string;