diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index e1de90feed23..9f1d7bb34584 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 diff --git a/docs/docs_map.md b/docs/docs_map.md index f301bedea44c..d1c3a21b4111 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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+) diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 808e216957c5..9e5b796f324e 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -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` -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 diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 710b17d6e9a9..55421ccb0828 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -480,7 +480,7 @@ methods. Treat this as feature discovery, not a full enumeration of - - `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. diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index c07fcf0ad50e..02e2e1283648 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -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` diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index ff6a3c312914..5a90890bbec6 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -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( diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index c9eda2ceaf95..00270496f311 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -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(Artifa export const validateArtifactsDownloadParams = lazyCompile( ArtifactsDownloadParamsSchema, ); -export const validateNodePairRequestParams = lazyCompile( - NodePairRequestParamsSchema, -); export const validateNodePairListParams = lazyCompile(NodePairListParamsSchema); export const validateNodePairApproveParams = lazyCompile( NodePairApproveParamsSchema, @@ -748,9 +741,6 @@ export const validateNodePairRejectParams = lazyCompile( export const validateNodePairRemoveParams = lazyCompile( NodePairRemoveParamsSchema, ); -export const validateNodePairVerifyParams = lazyCompile( - NodePairVerifyParamsSchema, -); export const validateNodeRenameParams = lazyCompile(NodeRenameParamsSchema); export const validateNodeListParams = lazyCompile(NodeListParamsSchema); export const validateEnvironmentsListParams = lazyCompile( @@ -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, diff --git a/packages/gateway-protocol/src/schema/nodes.ts b/packages/gateway-protocol/src/schema/nodes.ts index 2ba0ddb7376c..d591ef14b519 100644 --- a/packages/gateway-protocol/src/schema/nodes.ts +++ b/packages/gateway-protocol/src/schema/nodes.ts @@ -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 }, diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 01bf28651f99..4c39912d3dc7 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -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, diff --git a/packages/gateway-protocol/src/schema/types.ts b/packages/gateway-protocol/src/schema/types.ts index 79de42d2ef58..be0031b65b34 100644 --- a/packages/gateway-protocol/src/schema/types.ts +++ b/packages/gateway-protocol/src/schema/types.ts @@ -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">; diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index e895c9ac2ac1..19e9e8f42a85 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -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 { - const copy: Record = { ...node }; - delete copy.token; - return copy as Omit; -} - /** 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; } diff --git a/src/gateway/device-pairing-prune.ts b/src/gateway/device-pairing-prune.ts index 6bf0684d47ef..1e056d5101bc 100644 --- a/src/gateway/device-pairing-prune.ts +++ b/src/gateway/device-pairing-prune.ts @@ -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() }, diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 303a67751b62..1aeeaea33755 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -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" }, diff --git a/src/gateway/node-catalog.test.ts b/src/gateway/node-catalog.test.ts index 165c1d70c850..b550969ee181 100644 --- a/src/gateway/node-catalog.test.ts +++ b/src/gateway/node-catalog.test.ts @@ -40,7 +40,6 @@ function pairedDevice(overrides: Partial = {}): TestPairedDevi function pairedNode(overrides: Partial = {}): TestPairedNode { return { nodeId: "mac-1", - token: "node-token", platform: "macos", caps: ["camera"], commands: ["system.run"], diff --git a/src/gateway/node-connect-reconcile.test.ts b/src/gateway/node-connect-reconcile.test.ts index c8c6c5480ee0..06e510f2336b 100644 --- a/src/gateway/node-connect-reconcile.test.ts +++ b/src/gateway/node-connect-reconcile.test.ts @@ -28,7 +28,6 @@ function makeNodeConnectParams(overrides?: Partial): ConnectParam function makePairedNode(overrides?: Partial): NodePairingPairedNode { return { nodeId: "openclaw-ios", - token: "token-1", createdAtMs: 1, approvedAtMs: 1, ...overrides, diff --git a/src/gateway/node-reapproval-coordinator.test.ts b/src/gateway/node-reapproval-coordinator.test.ts index 77c445870870..42abd6350304 100644 --- a/src/gateway/node-reapproval-coordinator.test.ts +++ b/src/gateway/node-reapproval-coordinator.test.ts @@ -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 { + // 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", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 1b73fd784090..e1ef92d117a3 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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", diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index fa5211d6ca3f..7bdf820d294c 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -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(); diff --git a/src/gateway/server-methods/nodes.test.ts b/src/gateway/server-methods/nodes.test.ts index 7b763dba5220..78d473c231c5 100644 --- a/src/gateway/server-methods/nodes.test.ts +++ b/src/gateway/server-methods/nodes.test.ts @@ -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 { +async function approveNodeSurface(stateDir: string, nodeId: string): Promise { 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"; diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 7bb81ba7b053..c044df0de2c9 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -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[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 }) => { diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index b6ed6e4252a6..f79e2c61ce97 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -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); }); diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index d8529358c31d..fe57f1c76eaf 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -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); diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index 4bf2b5724f3d..e4e34a4d931b 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -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); } diff --git a/src/gateway/server.node-pairing-authz.test.ts b/src/gateway/server.node-pairing-authz.test.ts index a80d1f1a4a86..c53441cc73b2 100644 --- a/src/gateway/server.node-pairing-authz.test.ts +++ b/src/gateway/server.node-pairing-authz.test.ts @@ -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 { 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 { + 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(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 { diff --git a/src/gateway/server.node-pairing-rate-limit.test.ts b/src/gateway/server.node-pairing-rate-limit.test.ts index 97af03aa87fb..3dd2f951fce9 100644 --- a/src/gateway/server.node-pairing-rate-limit.test.ts +++ b/src/gateway/server.node-pairing-rate-limit.test.ts @@ -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, diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 26eeceaa5417..105beb00c235 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -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; + 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; + 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; approvedVia?: PairedDeviceApprovalKind; + nodeSurface?: PairedDeviceNodeSurface; + pendingNodeSurface?: PairedDevicePendingNodeSurface; createdAtMs: number; approvedAtMs: number; lastSeenAtMs?: number; @@ -226,7 +277,15 @@ async function loadState(baseDir?: string): Promise { pendingById: coercePairingStateRecord(pending), pairedByDeviceId: coercePairingStateRecord(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( + baseDir: string | undefined, + operate: ( + pairedByDeviceId: Record, + ) => { value: T; persist: boolean } | Promise<{ value: T; persist: boolean }>, +): Promise { + 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 }; diff --git a/src/infra/node-pairing-migration.test.ts b/src/infra/node-pairing-migration.test.ts new file mode 100644 index 000000000000..47faae96ee79 --- /dev/null +++ b/src/infra/node-pairing-migration.test.ts @@ -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 { + 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"]); + }); +}); diff --git a/src/infra/node-pairing-migration.ts b/src/infra/node-pairing-migration.ts new file mode 100644 index 000000000000..6649de9f7bb4 --- /dev/null +++ b/src/infra/node-pairing-migration.ts @@ -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 +// /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; + bins?: string[]; + createdAtMs?: number; + approvedAtMs?: number; + lastConnectedAtMs?: number; +}; + +export type LegacyNodePairingMigrationResult = { + migrated: number; + orphaned: number; +}; + +async function archiveLegacyFile(path: string): Promise { + 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 { + const { pendingPath, pairedPath } = resolvePairingPaths(params?.baseDir, "nodes"); + const [pairedRaw, pendingRaw] = await Promise.all([ + readJsonIfExists(pairedPath), + readJsonIfExists(pendingPath), + ]); + if (pairedRaw == null && pendingRaw == null) { + return null; + } + + const legacyRows = coercePairingStateRecord(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; +} diff --git a/src/infra/node-pairing.test.ts b/src/infra/node-pairing.test.ts index 4f71a1cd1325..73d39c99fc4a 100644 --- a/src/infra/node-pairing.test.ts +++ b/src/infra/node-pairing.test.ts @@ -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 { +const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-pairing-" }); + +async function withNodePairingDir(run: (baseDir: string) => Promise): Promise { + return await run(await tempDirs.make("case")); +} + +async function seedNodeDevice(baseDir: string, nodeId: string): Promise { + 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 { + await seedNodeDevice(baseDir, "node-1"); const request = await requestNodePairing( { nodeId: "node-1", @@ -31,15 +44,7 @@ async function setupPairedNode(baseDir: string): Promise { 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(run: (baseDir: string) => Promise): Promise { - 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>( 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"]); }); }); }); diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 4fee45653441..df95ba747d02 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -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 & { 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; - pairedByNodeId: Record; -}; - -const PENDING_TTL_MS = 5 * 60 * 1000; const OPERATOR_ROLE = "operator"; -const withLock = createAsyncLock(); const activeCleanupRevisionClaims = new Map>(); let nextCleanupClaimGeneration = 0; -function buildPendingNodePairingRequest(params: { - requestId?: string; - req: NodePairingRequestInput; -}): NodePairingPendingRecord { +function normalizeNodeId(nodeId: string) { + return nodeId.trim(); +} + +function nodeSurfaceDevice( + pairedByDeviceId: Record, + 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 { - const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes"); - const [pending, paired] = await Promise.all([ - readJsonIfExists(pendingPath), - readJsonIfExists(pairedPath), - ]); - const state: NodePairingStateFile = { - pendingById: coercePairingStateRecord(pending), - pairedByNodeId: coercePairingStateRecord(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(); 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 { - 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 { - await withLock(async () => { - removeCleanupClaim(claim); - }); + removeCleanupClaim(claim); } /** Delete pending revisions claimed by a reconnect after hello succeeds. */ export async function finalizeNodePairingCleanupClaim( claim: NodePairingCleanupClaim, ): Promise { - 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 { - 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 { - 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 { - return await withLock(async () => { - const state = await loadState(baseDir); - const pending = state.pendingById[requestId]; - if (!pending) { - return null; + return await withPairedDeviceRecords(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>, + patch: { lastConnectedAtMs?: number; bins?: string[] }, baseDir?: string, ): Promise { - 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 { - 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 }; }); } diff --git a/src/infra/pairing-pending.test.ts b/src/infra/pairing-pending.test.ts deleted file mode 100644 index d3c305ee9599..000000000000 --- a/src/infra/pairing-pending.test.ts +++ /dev/null @@ -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 } = { - 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); - }); -}); diff --git a/src/infra/pairing-pending.ts b/src/infra/pairing-pending.ts deleted file mode 100644 index 4931b3edb17d..000000000000 --- a/src/infra/pairing-pending.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Shared helpers for mutating pending pairing request state. -type PendingState = { - pendingById: Record; -}; - -/** Reject one pending pairing request and return the caller-selected id field. */ -export async function rejectPendingPairingRequest< - TPending, - TState extends PendingState, - TIdKey extends string, ->(params: { - requestId: string; - idKey: TIdKey; - loadState: () => Promise; - persistState: (state: TState) => Promise; - getId: (pending: TPending) => string; -}): Promise<({ requestId: string } & Record) | 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; -} diff --git a/src/shared/node-list-parse.ts b/src/shared/node-list-parse.ts index 210bc72673af..c21e04aa5cd7 100644 --- a/src/shared/node-list-parse.ts +++ b/src/shared/node-list-parse.ts @@ -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), diff --git a/src/shared/node-list-types.ts b/src/shared/node-list-types.ts index a6aec8de8d23..1cbb2b25a540 100644 --- a/src/shared/node-list-types.ts +++ b/src/shared/node-list-types.ts @@ -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;