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