From 898559830291d3fb8ce70e68902ec22359a6974b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 15:40:30 +0100 Subject: [PATCH] fix(gateway): stop paired-device duplication and redesign the Nodes page (#102810) * fix(gateway): prune superseded silent device pairings on auto-approve * feat(ui): unified nodes & devices inventory with dedupe and cleanup * docs: nodes page inventory + silent pairing supersede cleanup * refactor(gateway): run silent-pairing prune after approval broadcast * fix(ui): surface node list errors on the nodes inventory card * fix(gateway): restrict pairing auto-prune to same-host silent approvals * fix(gateway): report live device connections and guard pairing prune races * docs(ui): document bulk-cleanup name-collision tradeoff * fix(gateway): avoid map-spread when marking live device connections * docs: regenerate docs map --- docs/channels/pairing.md | 4 +- docs/docs_map.md | 1 + docs/gateway/pairing.md | 34 ++ docs/platforms/ios.md | 4 +- docs/web/control-ui.md | 6 +- scripts/control-ui-mock-dev.ts | 136 ++++++- src/gateway/device-pairing-prune.test.ts | 170 ++++++++ src/gateway/device-pairing-prune.ts | 59 +++ src/gateway/server-methods/devices.test.ts | 34 ++ src/gateway/server-methods/devices.ts | 12 +- src/gateway/server-methods/shared-types.ts | 1 + src/gateway/server-request-context.ts | 8 + .../server/ws-connection/message-handler.ts | 19 + src/infra/device-pairing-prune.test.ts | 275 +++++++++++++ src/infra/device-pairing.ts | 136 ++++++- ui/src/lib/nodes/index.ts | 120 ++++++ ui/src/lib/nodes/inventory.test.ts | 246 ++++++++++++ ui/src/lib/nodes/inventory.ts | 284 ++++++++++++++ ui/src/pages/nodes/nodes-page.ts | 18 +- ui/src/pages/nodes/view-inventory.ts | 362 ++++++++++++++++++ ui/src/pages/nodes/view.devices.test.ts | 147 ++++++- ui/src/pages/nodes/view.ts | 253 +----------- ui/src/pages/nodes/view.types.ts | 7 +- ui/src/styles.css | 1 + ui/src/styles/nodes.css | 54 +++ 25 files changed, 2112 insertions(+), 279 deletions(-) create mode 100644 src/gateway/device-pairing-prune.test.ts create mode 100644 src/gateway/device-pairing-prune.ts create mode 100644 src/infra/device-pairing-prune.test.ts create mode 100644 ui/src/lib/nodes/inventory.test.ts create mode 100644 ui/src/lib/nodes/inventory.ts create mode 100644 ui/src/pages/nodes/view-inventory.ts create mode 100644 ui/src/styles/nodes.css diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 1e57597200f..4d872819068 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -113,12 +113,12 @@ creates a device pairing request that must be approved. Use an already connected Control UI session with `operator.admin` access: 1. Open the Control UI and select **Nodes**. -2. In **Devices**, click **Pair mobile device**. +2. In **Nodes & devices**, click **Pair mobile device**. 3. On your phone, open the OpenClaw app → **Settings** → **Gateway**. 4. Scan the QR code or paste the setup code, then connect. Official OpenClaw iOS and Android apps are approved automatically when their -setup-code metadata matches. If **Devices** shows a pending request (for +setup-code metadata matches. If **Pending approval** shows a request (for example, for a non-official client or mismatched metadata), review its role and scopes before approving it. diff --git a/docs/docs_map.md b/docs/docs_map.md index 809ce06d2ff..ca54ed161b3 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -3583,6 +3583,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Node event trust boundaries (2026.3.31+) - H2: Auto-approval (macOS app) - H2: Trusted-CIDR device auto-approval + - H2: Silent pairing supersede cleanup - H2: Metadata-upgrade auto-approval - H2: QR pairing helpers - H2: Locality and forwarded headers diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 7cf81e87acb..808e216957c 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -176,6 +176,40 @@ Security boundary: - Same-host loopback trusted-proxy header paths are not eligible, because that path can be spoofed by local callers. +## Silent pairing supersede cleanup + +Non-interactive approvals record their provenance on the paired-device row: +same-host local policy approvals as `silent`, trusted-CIDR node approvals as +`trusted-cidr`. Clients whose state directory is ephemeral (temporary homes, +containers, per-run sandboxes) mint a fresh device keypair per run, and every +run silently re-pairs as a brand-new device — without cleanup the paired list +grows one stale row per run. + +When the Gateway silently approves a **local** device pairing, it retires +older `silent`-approved records that belong to the same client cluster +(matching `clientId`, `clientMode`, and display name) and are not currently +connected. Local clients run on the gateway host itself, so the cluster key +cannot match a different machine. Retired rows lose their tokens immediately; +any matching legacy node pairing entry is cleared and a `node.pair.resolved` +removal event is broadcast. + +Boundaries: + +- Only records whose latest approval was same-host local (`silent`) are + eligible, as trigger and as target. Trusted-CIDR pairings cross hosts where + display metadata is not a machine identity, so they are never removed + automatically — use the Control UI cleanup or `openclaw nodes remove` for + those. +- Owner-approved and QR/setup-code (bootstrap) pairings are never removed + automatically. Records approved before provenance existed stay protected, + even after a later silent re-approval of the same device id. +- Currently connected devices are skipped, so concurrent local sessions with + separate state directories keep their tokens while live. Records approved + within the last minute are also skipped, so simultaneous pairing handshakes + cannot retire each other before their connections register. +- Affected clients are local by construction, so they re-pair silently on + their next connection. + ## Metadata-upgrade auto-approval When an already-paired device reconnects with only non-sensitive metadata diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index a7dcb1847b3..7eebc7f7eed 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -42,7 +42,7 @@ Gateway has not been configured yet, run `openclaw onboard` first so setup-code creation has a token or password auth path. 2. Open the [Control UI](/web/control-ui), select **Nodes**, and click - **Pair mobile device** in the **Devices** card. + **Pair mobile device** in the **Nodes & devices** card. 3. In the iOS app, open **Settings** -> **Gateway**, scan the QR code (or paste the setup code), and connect. @@ -50,7 +50,7 @@ creation has a token or password auth path. If the setup code contains both LAN and Tailscale Serve routes, the app probes them in order and saves the first reachable endpoint. -4. The official app connects automatically. If **Devices** shows a pending +4. The official app connects automatically. If **Pending approval** shows a request, review its role and scopes before approving it. The Apple Watch companion does not have a separate OpenClaw pairing approval. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 8acc71386a9..a517329947f 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -70,13 +70,13 @@ An already paired administrator can create the iOS/Android connection QR without - Select **Nodes**, then click **Pair mobile device** in the **Devices** card. + Select **Nodes**, then click **Pair mobile device** in the **Nodes & devices** card. In the OpenClaw mobile app, open **Settings** → **Gateway** and scan the QR code. You can copy and paste the setup code instead. - The official iOS/Android app connects automatically. If **Devices** shows a pending request, review its role and scopes before approving it. + The official iOS/Android app connects automatically. If **Pending approval** shows a request, review its role and scopes before approving it. @@ -147,7 +147,7 @@ A **Search** field at the top of the sidebar opens the command palette (⌘K). T - Cron jobs: list/add/edit/run/enable/disable plus run history (`cron.*`). - Tasks: live active and recent background task ledger with linked sessions and cancellation (`tasks.*`). - Skills: status, enable/disable, install, API key updates (`skills.*`). - - Nodes: list plus caps (`node.list`), create mobile setup codes, and approve device pairing (`device.pair.*`). + - Nodes: one **Nodes & devices** inventory that joins paired device records with the node catalog (`node.list`, `device.pair.list`) — one entry per machine with roles, live link status, tokens, and capabilities. Duplicate pairings of the same client collapse into an expandable group, and **Clean up N stale** bulk-removes superseded pairings that are offline and were auto-approved (silent local or trusted-CIDR), so affected clients re-pair without user action. Entries can be removed (`node.pair.remove`, `device.pair.remove`), device pairing and node re-approvals handled inline (`device.pair.*`, `node.pair.approve`/`reject`), and mobile setup codes created from the same card. - Exec approvals: edit gateway or node allowlists and ask policy for `exec host=gateway/node` (`exec.approvals.*`). diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 14349daaff8..2057ab0d2ba 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -560,7 +560,92 @@ async function createChatPickerScenario(): Promise methodResponses: { "usage.cost": profileUsage.cost, "sessions.usage": profileUsage.sessions, - "device.pair.list": { paired: [], pending: [] }, + "device.pair.list": { + paired: [ + { + deviceId: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90", + displayName: "Mac Studio", + platform: "darwin", + clientId: "node-host", + clientMode: "node", + roles: ["operator", "node"], + scopes: ["operator.admin", "operator.read", "operator.write"], + approvedVia: "trusted-cidr", + approvedAtMs: baseTime - 3_600_000, + lastSeenAtMs: baseTime - 60_000, + tokens: [ + { role: "node", scopes: [], createdAtMs: baseTime - 3_600_000 }, + { + role: "operator", + scopes: ["operator.admin", "operator.read", "operator.write"], + createdAtMs: baseTime - 3_600_000, + }, + ], + }, + { + deviceId: "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", + displayName: "Mac Studio", + platform: "darwin", + clientId: "node-host", + clientMode: "node", + roles: ["node"], + approvedVia: "trusted-cidr", + approvedAtMs: baseTime - 86_400_000, + lastSeenAtMs: baseTime - 82_800_000, + tokens: [{ role: "node", scopes: [], createdAtMs: baseTime - 86_400_000 }], + }, + { + deviceId: "9988776655443322119988776655443322119988776655443322119988776655", + clientId: "cli", + clientMode: "cli", + platform: "darwin", + roles: ["operator"], + scopes: ["operator.admin", "operator.read", "operator.write"], + approvedVia: "silent", + approvedAtMs: baseTime - 7_200_000, + lastSeenAtMs: baseTime - 7_100_000, + tokens: [ + { + role: "operator", + scopes: ["operator.admin", "operator.read", "operator.write"], + createdAtMs: baseTime - 7_200_000, + }, + ], + }, + { + deviceId: "11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff", + displayName: "iPhone", + platform: "iOS 26.4", + clientId: "openclaw-ios", + clientMode: "ui", + roles: ["operator", "node"], + scopes: ["operator.approvals", "operator.read", "operator.write"], + approvedVia: "bootstrap", + approvedAtMs: baseTime - 172_800_000, + lastSeenAtMs: baseTime - 3_600_000, + tokens: [ + { role: "node", scopes: [], createdAtMs: baseTime - 172_800_000 }, + { + role: "operator", + scopes: ["operator.approvals", "operator.read", "operator.write"], + createdAtMs: baseTime - 172_800_000, + }, + ], + }, + ], + pending: [ + { + requestId: "mock-pending-request", + deviceId: "feedfacecafebeef0123456789abcdeffeedfacecafebeef0123456789abcdef", + displayName: "MacBook Pro", + role: "operator", + roles: ["operator"], + scopes: ["operator.read", "operator.write"], + remoteIp: "192.168.1.20", + ts: baseTime - 30_000, + }, + ], + }, "device.pair.setupCode": { auth: "token", gatewayUrl: "wss://gateway.example.test", @@ -568,7 +653,54 @@ async function createChatPickerScenario(): Promise setupCode: devicePairSetupCode, urlSource: "mock", }, - "node.list": { nodes: [] }, + "node.list": { + nodes: [ + { + nodeId: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90", + displayName: "Mac Studio", + platform: "darwin", + version: "2026.6.11", + connected: true, + paired: true, + approvalState: "approved", + connectedAtMs: baseTime - 60_000, + caps: ["canvas", "screen"], + commands: [ + "screen.snapshot", + "system.execApprovals.get", + "system.execApprovals.set", + "system.notify", + "system.run", + "system.which", + ], + }, + { + nodeId: "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", + displayName: "Mac Studio", + platform: "darwin", + version: "2026.6.10", + connected: false, + paired: true, + approvalState: "approved", + lastSeenAtMs: baseTime - 82_800_000, + caps: ["canvas", "screen"], + commands: ["screen.snapshot", "system.run"], + }, + { + nodeId: "11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff", + displayName: "iPhone", + platform: "iOS 26.4", + version: "2026.6.11", + connected: false, + paired: true, + approvalState: "pending-reapproval", + pendingRequestId: "mock-node-reapproval", + lastSeenAtMs: baseTime - 3_600_000, + caps: ["camera", "canvas", "contacts", "device", "location"], + commands: ["camera.list", "contacts.search", "device.info", "location.get"], + }, + ], + }, "agents.files.get": { cases: workspaceFileCases, }, diff --git a/src/gateway/device-pairing-prune.test.ts b/src/gateway/device-pairing-prune.test.ts new file mode 100644 index 00000000000..2ca8e128ca9 --- /dev/null +++ b/src/gateway/device-pairing-prune.test.ts @@ -0,0 +1,170 @@ +// Covers gateway-side cleanup when silent pairing supersedes stale sibling records. +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + approveDevicePairing, + listDevicePairing, + requestDevicePairing, +} from "../infra/device-pairing.js"; +import { approveNodePairing, listNodePairing, requestNodePairing } from "../infra/node-pairing.js"; +import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; +import { pruneSupersededSilentPairingsAfterApproval } from "./device-pairing-prune.js"; + +const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-gateway-pairing-prune-" }); + +type BroadcastCall = { event: string; payload: Record }; +type PruneContext = Parameters[0]["context"]; + +function createPruneContext(params?: { connectedDeviceIds?: string[] }) { + const broadcasts: BroadcastCall[] = []; + const invalidated: string[] = []; + const disconnected: string[] = []; + const logs: string[] = []; + const connected = new Set(params?.connectedDeviceIds ?? []); + const context: PruneContext = { + broadcast: (event, payload) => { + broadcasts.push({ event, payload: payload as Record }); + }, + logGateway: { + info: (message: string) => logs.push(message), + }, + hasConnectedClientsForDevice: (deviceId: string) => connected.has(deviceId), + invalidateClientsForDevice: (deviceId: string) => { + invalidated.push(deviceId); + }, + disconnectClientsForDevice: (deviceId: string) => { + disconnected.push(deviceId); + }, + }; + return { broadcasts, invalidated, disconnected, logs, context }; +} + +async function pairSilentDevice(params: { + baseDir: string; + deviceId: string; + roles: string[]; + clientId: string; + clientMode: string; + displayName?: string; +}) { + const request = await requestDevicePairing( + { + deviceId: params.deviceId, + publicKey: `pk-${params.deviceId}`, + clientId: params.clientId, + clientMode: params.clientMode, + displayName: params.displayName, + role: params.roles[0], + roles: params.roles, + scopes: [], + }, + params.baseDir, + ); + const approved = await approveDevicePairing( + request.request.requestId, + { callerScopes: [], approvedVia: "silent" }, + params.baseDir, + ); + if (approved?.status !== "approved") { + throw new Error(`expected approval for ${params.deviceId}`); + } + return approved.device; +} + +describe("pruneSupersededSilentPairingsAfterApproval", () => { + beforeAll(async () => { + await suiteRootTracker.setup(); + }); + + afterAll(async () => { + await suiteRootTracker.cleanup(); + }); + + test("retires stale node siblings across both pairing stores", async () => { + const baseDir = await suiteRootTracker.make("case"); + await pairSilentDevice({ + baseDir, + deviceId: "node-stale", + roles: ["node"], + clientId: "node-host", + clientMode: "node", + displayName: "megaclaw", + }); + const nodeRequest = await requestNodePairing( + { nodeId: "node-stale", displayName: "megaclaw" }, + baseDir, + ); + await approveNodePairing(nodeRequest.request.requestId, { callerScopes: [] }, baseDir); + const anchor = await pairSilentDevice({ + baseDir, + deviceId: "node-anchor", + roles: ["node"], + clientId: "node-host", + clientMode: "node", + displayName: "megaclaw", + }); + + const harness = createPruneContext(); + const pruned = await pruneSupersededSilentPairingsAfterApproval({ + deviceId: anchor.deviceId, + context: harness.context, + baseDir, + nowMs: Date.now() + 120_000, + }); + + expect(pruned.map((entry) => entry.deviceId)).toEqual(["node-stale"]); + const devices = await listDevicePairing(baseDir); + expect(devices.paired.map((device) => device.deviceId)).toEqual(["node-anchor"]); + const nodes = await listNodePairing(baseDir); + expect(nodes.paired).toHaveLength(0); + expect(harness.invalidated).toEqual(["node-stale"]); + expect(harness.disconnected).toEqual(["node-stale"]); + expect(harness.broadcasts).toEqual([ + { + event: "node.pair.resolved", + payload: expect.objectContaining({ nodeId: "node-stale", decision: "removed" }), + }, + ]); + }); + + test("keeps connected siblings and emits no node broadcast for operator-only prunes", async () => { + const baseDir = await suiteRootTracker.make("case"); + await pairSilentDevice({ + baseDir, + deviceId: "cli-stale", + roles: ["operator"], + clientId: "cli", + clientMode: "cli", + }); + await pairSilentDevice({ + baseDir, + deviceId: "cli-live", + roles: ["operator"], + clientId: "cli", + clientMode: "cli", + }); + const anchor = await pairSilentDevice({ + baseDir, + deviceId: "cli-anchor", + roles: ["operator"], + clientId: "cli", + clientMode: "cli", + }); + + const harness = createPruneContext({ connectedDeviceIds: ["cli-live"] }); + const pruned = await pruneSupersededSilentPairingsAfterApproval({ + deviceId: anchor.deviceId, + context: harness.context, + baseDir, + nowMs: Date.now() + 120_000, + }); + + expect(pruned.map((entry) => entry.deviceId)).toEqual(["cli-stale"]); + const devices = await listDevicePairing(baseDir); + expect(devices.paired.map((device) => device.deviceId).toSorted()).toEqual([ + "cli-anchor", + "cli-live", + ]); + expect(harness.broadcasts).toEqual([]); + expect(harness.disconnected).toEqual(["cli-stale"]); + }); +}); diff --git a/src/gateway/device-pairing-prune.ts b/src/gateway/device-pairing-prune.ts new file mode 100644 index 00000000000..6bf0684d47e --- /dev/null +++ b/src/gateway/device-pairing-prune.ts @@ -0,0 +1,59 @@ +// Prunes superseded silent device pairings after a fresh silent auto-approval. +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< + GatewayRequestContext, + | "broadcast" + | "hasConnectedClientsForDevice" + | "invalidateClientsForDevice" + | "disconnectClientsForDevice" +> & { + logGateway: Pick; +}; + +/** + * After a silent auto-approval, retire older silent pairings of the same client + * cluster. Ephemeral state dirs mint a fresh deviceId per run and every run + * re-pairs silently, so without this the paired-device list grows without bound + * (dozens of stale operator/node records per host). + */ +export async function pruneSupersededSilentPairingsAfterApproval(params: { + deviceId: string; + context: PruneContext; + baseDir?: string; + nowMs?: number; +}): Promise { + const { context } = params; + const pruned = await pruneSupersededSilentPairedDevices({ + deviceId: params.deviceId, + baseDir: params.baseDir, + nowMs: params.nowMs, + isDeviceConnected: (deviceId) => context.hasConnectedClientsForDevice?.(deviceId) ?? false, + }); + for (const entry of pruned) { + context.logGateway.info( + `device pairing pruned superseded silent pairing device=${entry.deviceId} roles=${entry.roles.join(",") || "none"}`, + ); + // 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")) { + context.broadcast( + "node.pair.resolved", + { requestId: "", nodeId: entry.deviceId, decision: "removed", ts: Date.now() }, + { dropIfSlow: true }, + ); + } + context.disconnectClientsForDevice?.(entry.deviceId); + } + return pruned; +} diff --git a/src/gateway/server-methods/devices.test.ts b/src/gateway/server-methods/devices.test.ts index caa40903f46..6a268ae3d10 100644 --- a/src/gateway/server-methods/devices.test.ts +++ b/src/gateway/server-methods/devices.test.ts @@ -718,6 +718,7 @@ describe("deviceHandlers", () => { approvedAtMs: 100, createdAtMs: 50, tokens: undefined, + connected: false, }, ], }, @@ -762,6 +763,7 @@ describe("deviceHandlers", () => { approvedAtMs: 100, createdAtMs: 50, tokens: undefined, + connected: false, }, { deviceId: "device-2", @@ -769,6 +771,7 @@ describe("deviceHandlers", () => { approvedAtMs: 200, createdAtMs: 60, tokens: undefined, + connected: false, }, ], }, @@ -802,6 +805,7 @@ describe("deviceHandlers", () => { approvedAtMs: 200, createdAtMs: 60, tokens: undefined, + connected: false, }, ], }, @@ -841,6 +845,7 @@ describe("deviceHandlers", () => { approvedAtMs: 200, createdAtMs: 60, tokens: undefined, + connected: false, }, ], }, @@ -848,6 +853,35 @@ describe("deviceHandlers", () => { ); }); + it("marks live device connections in the pairing list", async () => { + listDevicePairingMock.mockResolvedValue({ + pending: [], + paired: [ + { deviceId: "device-1", publicKey: "pk-1", approvedAtMs: 100, createdAtMs: 50 }, + { deviceId: "device-2", publicKey: "pk-2", approvedAtMs: 200, createdAtMs: 60 }, + ], + }); + const opts = createOptions( + "device.pair.list", + {}, + { client: createClient(["operator.pairing"]) }, + ); + ( + opts.context as { hasConnectedClientsForDevice?: (deviceId: string) => boolean } + ).hasConnectedClientsForDevice = (deviceId) => deviceId === "device-2"; + + await deviceHandlers["device.pair.list"](opts); + + const respond = opts.respond as ReturnType; + const payload = respond.mock.calls[0]?.[1] as { + paired: Array<{ deviceId: string; connected: boolean }>; + }; + expect(payload.paired.map((device) => [device.deviceId, device.connected])).toEqual([ + ["device-1", false], + ["device-2", true], + ]); + }); + it("rejects approving another device from a non-admin device session", async () => { getPendingDevicePairingMock.mockResolvedValue({ requestId: "req-2", diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index 63910937e01..ffc8b601d5d 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -48,12 +48,14 @@ const DEVICE_PAIR_REJECTION_DENIED_MESSAGE = "device pairing rejection denied"; function redactPairedDevice( device: { tokens?: Record } & Record, + opts?: { connected?: boolean }, ) { // Pairing lists are visible to operators; expose token lifecycle metadata // without returning raw token material or the internal approved-scope set. const { tokens, approvedScopes: _approvedScopes, ...rest } = device; return { ...rest, + ...(opts?.connected !== undefined ? { connected: opts.connected } : {}), tokens: summarizeDeviceTokens(tokens), }; } @@ -202,7 +204,7 @@ function emitDeviceTokenLifecycleSecurityEvent(params: { /** Gateway request handlers for device pair approval, removal, token rotation, and revocation. */ export const deviceHandlers: GatewayRequestHandlers = { - "device.pair.list": async ({ params, respond, client }) => { + "device.pair.list": async ({ params, respond, context, client }) => { if (!validateDevicePairListParams(params)) { respond( false, @@ -231,7 +233,13 @@ export const deviceHandlers: GatewayRequestHandlers = { true, { pending: visibleList.pending, - paired: visibleList.paired.map((device) => redactPairedDevice(device)), + // Live-connection state lets clients distinguish active pairings from + // stale ones; node-role links alone do not cover operator clients. + paired: visibleList.paired.map((device) => + redactPairedDevice(device, { + connected: context.hasConnectedClientsForDevice?.(device.deviceId.trim()) ?? false, + }), + ), }, undefined, ); diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 3cdf854cfb8..3f86482ddcd 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -103,6 +103,7 @@ export type GatewayRequestContext = { deviceId: string, opts?: { role?: string; reason?: string }, ) => void; + hasConnectedClientsForDevice?: (deviceId: string) => boolean; disconnectClientsUsingSharedGatewayAuth?: () => void; enforceSharedGatewayAuthGenerationForConfigWrite?: (nextConfig: OpenClawConfig) => void; nodeRegistry: NodeRegistry; diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 52372a4b6e3..3763b6c0914 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -143,6 +143,14 @@ export function createGatewayRequestContext( } return connIds; }, + hasConnectedClientsForDevice: (deviceId: string) => { + for (const gatewayClient of params.clients) { + if (gatewayClient.connect.device?.id === deviceId && !gatewayClient.invalidated) { + return true; + } + } + return false; + }, invalidateClientsForDevice: (deviceId: string, opts?: { role?: string; reason?: string }) => { const reason = opts?.reason ?? "device-invalidated"; for (const gatewayClient of params.clients) { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 08f41c865aa..2f0049de687 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -113,6 +113,7 @@ import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js"; import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js"; import { listControlUiPluginTabs } from "../../control-ui-plugin-tabs.js"; import { normalizeDeviceMetadataForAuth } from "../../device-auth.js"; +import { pruneSupersededSilentPairingsAfterApproval } from "../../device-pairing-prune.js"; import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../../method-scopes.js"; import type { GatewayMethodRegistry } from "../../methods/registry.js"; import { @@ -1512,6 +1513,10 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar : await approveDevicePairing(pairing.request.requestId, { callerScopes: scopes, accessMetadata: clientAccessMetadata, + // Same-host local approvals are prune-eligible "silent"; + // trusted-CIDR approvals cross hosts and must never be + // auto-pruned, so they carry their own provenance. + approvedVia: allowSilentLocalPairing ? "silent" : "trusted-cidr", }); if (approved?.status === "approved") { if (allowSetupCodeMobileBootstrapPairing && boundBootstrapProfile) { @@ -1530,6 +1535,20 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar }, { dropIfSlow: true }, ); + if (!(allowSetupCodeMobileBootstrapPairing && boundBootstrapProfile)) { + // Best-effort retirement of stale silent siblings; a prune + // failure must never fail the fresh device's handshake. + try { + await pruneSupersededSilentPairingsAfterApproval({ + deviceId: approved.device.deviceId, + context, + }); + } catch (error) { + logGateway.warn( + `device pairing prune failed device=${approved.device.deviceId} error=${String(error)}`, + ); + } + } } else { resolvedByConcurrentApproval = pairingStateAllowsRequestedAccess( await getPairedDevice(device.id), diff --git a/src/infra/device-pairing-prune.test.ts b/src/infra/device-pairing-prune.test.ts new file mode 100644 index 00000000000..deebe799a29 --- /dev/null +++ b/src/infra/device-pairing-prune.test.ts @@ -0,0 +1,275 @@ +// Covers silent-pairing approval provenance and superseded-record pruning. +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; +import { + approveBootstrapDevicePairing, + approveDevicePairing, + getPairedDevice, + listDevicePairing, + pruneSupersededSilentPairedDevices, + requestDevicePairing, + type PairedDeviceApprovalKind, +} from "./device-pairing.js"; + +const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-device-pairing-prune-" }); + +async function makeBaseDir(): Promise { + return await suiteRootTracker.make("case"); +} + +// Ages every just-approved record past the recent-approval grace window. +function agedNowMs(): number { + return Date.now() + 120_000; +} + +async function pairDevice(params: { + baseDir: string; + deviceId: string; + approvedVia?: Extract; + clientId?: string; + clientMode?: string; + displayName?: string; + roles?: string[]; +}) { + const request = await requestDevicePairing( + { + deviceId: params.deviceId, + publicKey: `pk-${params.deviceId}`, + clientId: params.clientId ?? "cli", + clientMode: params.clientMode ?? "cli", + displayName: params.displayName, + role: params.roles?.[0] ?? "operator", + roles: params.roles ?? ["operator"], + scopes: [], + }, + params.baseDir, + ); + const approved = await approveDevicePairing( + request.request.requestId, + { callerScopes: [], approvedVia: params.approvedVia }, + params.baseDir, + ); + if (approved?.status !== "approved") { + throw new Error(`expected approval for ${params.deviceId}`); + } + return approved.device; +} + +describe("device pairing approval provenance", () => { + beforeAll(async () => { + await suiteRootTracker.setup(); + }); + + afterAll(async () => { + await suiteRootTracker.cleanup(); + }); + + test("records silent, owner, and bootstrap approval kinds", async () => { + const baseDir = await makeBaseDir(); + const silent = await pairDevice({ baseDir, deviceId: "device-silent", approvedVia: "silent" }); + expect(silent.approvedVia).toBe("silent"); + + const owner = await pairDevice({ baseDir, deviceId: "device-owner" }); + expect(owner.approvedVia).toBe("owner"); + + const bootstrapRequest = await requestDevicePairing( + { + deviceId: "device-bootstrap", + publicKey: "pk-device-bootstrap", + role: "node", + roles: ["node"], + scopes: [], + }, + baseDir, + ); + const bootstrapApproved = await approveBootstrapDevicePairing( + bootstrapRequest.request.requestId, + { roles: ["node"], scopes: [] }, + baseDir, + ); + expect(bootstrapApproved?.status === "approved" && bootstrapApproved.device.approvedVia).toBe( + "bootstrap", + ); + }); + + test("owner approval stays sticky across a later silent re-approve", async () => { + const baseDir = await makeBaseDir(); + await pairDevice({ baseDir, deviceId: "device-sticky" }); + const repaired = await pairDevice({ + baseDir, + deviceId: "device-sticky", + approvedVia: "silent", + }); + expect(repaired.approvedVia).toBe("owner"); + }); + + test("pre-provenance records stay unknown across a later silent re-approve", async () => { + const baseDir = await makeBaseDir(); + const legacy = await pairDevice({ baseDir, deviceId: "device-legacy", approvedVia: "silent" }); + // Simulate a record approved before provenance existed. + const { writeJson } = await import("./json-files.js"); + const path = await import("node:path"); + const { approvedVia: _approvedVia, ...legacyShape } = legacy; + await writeJson(path.join(baseDir, "devices", "paired.json"), { + [legacy.deviceId]: legacyShape, + }); + + const repaired = await pairDevice({ + baseDir, + deviceId: "device-legacy", + approvedVia: "silent", + }); + expect(repaired.approvedVia).toBeUndefined(); + }); +}); + +describe("pruneSupersededSilentPairedDevices", () => { + beforeAll(async () => { + await suiteRootTracker.setup(); + }); + + afterAll(async () => { + await suiteRootTracker.cleanup(); + }); + + test("removes stale silent siblings from the same client cluster only", async () => { + const baseDir = await makeBaseDir(); + await pairDevice({ baseDir, deviceId: "stale-1", approvedVia: "silent" }); + await pairDevice({ baseDir, deviceId: "stale-2", approvedVia: "silent" }); + await pairDevice({ baseDir, deviceId: "owner-kept" }); + await pairDevice({ baseDir, deviceId: "cidr-kept", approvedVia: "trusted-cidr" }); + await pairDevice({ + baseDir, + deviceId: "other-cluster", + approvedVia: "silent", + clientId: "node-host", + clientMode: "node", + displayName: "megaclaw", + }); + const anchor = await pairDevice({ baseDir, deviceId: "anchor", approvedVia: "silent" }); + + const removed = await pruneSupersededSilentPairedDevices({ + deviceId: anchor.deviceId, + baseDir, + nowMs: agedNowMs(), + }); + + expect(removed.map((entry) => entry.deviceId).toSorted()).toEqual(["stale-1", "stale-2"]); + expect(removed[0]?.roles).toEqual(["operator"]); + const remaining = (await listDevicePairing(baseDir)).paired.map((device) => device.deviceId); + expect(remaining.toSorted()).toEqual(["anchor", "cidr-kept", "other-cluster", "owner-kept"]); + }); + + test("trusted-cidr approvals never anchor a prune", async () => { + const baseDir = await makeBaseDir(); + await pairDevice({ + baseDir, + deviceId: "cidr-stale", + approvedVia: "trusted-cidr", + clientId: "node-host", + clientMode: "node", + displayName: "megaclaw", + }); + const anchor = await pairDevice({ + baseDir, + deviceId: "cidr-anchor", + approvedVia: "trusted-cidr", + clientId: "node-host", + clientMode: "node", + displayName: "megaclaw", + }); + + const removed = await pruneSupersededSilentPairedDevices({ + deviceId: anchor.deviceId, + baseDir, + nowMs: agedNowMs(), + }); + + expect(removed).toEqual([]); + expect(await getPairedDevice("cidr-stale", baseDir)).not.toBeNull(); + }); + + test("skips freshly approved siblings still inside the grace window", async () => { + const baseDir = await makeBaseDir(); + await pairDevice({ baseDir, deviceId: "in-flight", approvedVia: "silent" }); + const anchor = await pairDevice({ baseDir, deviceId: "anchor", approvedVia: "silent" }); + + // Real now: the sibling was approved milliseconds ago, so a concurrent + // handshake that has not registered its connection yet must survive. + const removed = await pruneSupersededSilentPairedDevices({ + deviceId: anchor.deviceId, + baseDir, + }); + + expect(removed).toEqual([]); + expect(await getPairedDevice("in-flight", baseDir)).not.toBeNull(); + }); + + test("skips connected devices and drops pending requests for pruned ids", async () => { + const baseDir = await makeBaseDir(); + await pairDevice({ baseDir, deviceId: "stale", approvedVia: "silent" }); + await pairDevice({ baseDir, deviceId: "live", approvedVia: "silent" }); + const anchor = await pairDevice({ baseDir, deviceId: "anchor", approvedVia: "silent" }); + // A pruned device may still have a queued repair request; it must go too. + await requestDevicePairing( + { + deviceId: "stale", + publicKey: "pk-stale-repair", + clientId: "cli", + clientMode: "cli", + role: "operator", + scopes: [], + }, + baseDir, + ); + + const removed = await pruneSupersededSilentPairedDevices({ + deviceId: anchor.deviceId, + baseDir, + nowMs: agedNowMs(), + isDeviceConnected: (deviceId) => deviceId === "live", + }); + + expect(removed.map((entry) => entry.deviceId)).toEqual(["stale"]); + const list = await listDevicePairing(baseDir); + expect(list.paired.map((device) => device.deviceId).toSorted()).toEqual(["anchor", "live"]); + expect(list.pending).toHaveLength(0); + }); + + test("does not prune when the anchor was not silent-approved", async () => { + const baseDir = await makeBaseDir(); + await pairDevice({ baseDir, deviceId: "stale", approvedVia: "silent" }); + const anchor = await pairDevice({ baseDir, deviceId: "anchor-owner" }); + + const removed = await pruneSupersededSilentPairedDevices({ + deviceId: anchor.deviceId, + baseDir, + nowMs: agedNowMs(), + }); + + expect(removed).toEqual([]); + expect(await getPairedDevice("stale", baseDir)).not.toBeNull(); + }); + + test("leaves legacy records without approval provenance untouched", async () => { + const baseDir = await makeBaseDir(); + const legacy = await pairDevice({ baseDir, deviceId: "legacy", approvedVia: "silent" }); + // Simulate a pre-provenance record by re-writing it without approvedVia. + const { writeJson } = await import("./json-files.js"); + const path = await import("node:path"); + const { approvedVia: _approvedVia, ...legacyShape } = legacy; + await writeJson(path.join(baseDir, "devices", "paired.json"), { + [legacy.deviceId]: legacyShape, + }); + const anchor = await pairDevice({ baseDir, deviceId: "anchor", approvedVia: "silent" }); + + const removed = await pruneSupersededSilentPairedDevices({ + deviceId: anchor.deviceId, + baseDir, + nowMs: agedNowMs(), + }); + + expect(removed).toEqual([]); + expect(await getPairedDevice("legacy", baseDir)).not.toBeNull(); + }); +}); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 4843d529674..26eeceaa541 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -107,6 +107,16 @@ export type RevokeDeviceTokenResult = | { ok: true; entry: DeviceAuthToken } | { ok: false; reason: RevokeDeviceTokenDenyReason; scope?: string }; +/** + * How the latest pairing approval was granted. "silent" is a same-host local + * policy approval and the only prune-eligible kind: local clients re-pair + * silently and cannot collide with another machine's records. "trusted-cidr" + * is also non-interactive but crosses hosts, so it is never pruned + * automatically (display metadata is not a machine identity). "owner" and + * "bootstrap" approvals required a user action and are never pruned. + */ +export type PairedDeviceApprovalKind = "owner" | "silent" | "trusted-cidr" | "bootstrap"; + /** Persisted approved device record, including durable approval and active role tokens. */ export type PairedDevice = { deviceId: string; @@ -122,6 +132,7 @@ export type PairedDevice = { approvedScopes?: string[]; remoteIp?: string; tokens?: Record; + approvedVia?: PairedDeviceApprovalKind; createdAtMs: number; approvedAtMs: number; lastSeenAtMs?: number; @@ -537,6 +548,26 @@ function buildDeviceAuthToken(params: { }; } +// Interactive approvals must stay sticky: a later silent repair/re-approve of the +// same device id cannot downgrade an owner/bootstrap record into prune-eligible +// state. Pre-provenance records (approvedVia undefined) may have been approved by +// an owner, so a non-interactive re-approve must keep them protected (undefined). +function mergeApprovalKind( + existing: PairedDevice | undefined, + incoming: PairedDeviceApprovalKind, +): PairedDeviceApprovalKind | undefined { + if (incoming === "owner" || !existing) { + return incoming; + } + if (existing.approvedVia === undefined) { + return incoming === "bootstrap" ? "bootstrap" : undefined; + } + if (existing.approvedVia === "owner" || existing.approvedVia === "bootstrap") { + return existing.approvedVia; + } + return incoming; +} + function buildApprovedPairedDevice(params: { pending: DevicePairingPendingRequest; existing: PairedDevice | undefined; @@ -544,6 +575,7 @@ function buildApprovedPairedDevice(params: { approvedScopes: string[] | undefined; tokens: Record; now: number; + approvedVia: PairedDeviceApprovalKind; accessMetadata?: DevicePairingAccessMetadata; }): PairedDevice { return { @@ -560,6 +592,7 @@ function buildApprovedPairedDevice(params: { approvedScopes: params.approvedScopes, remoteIp: params.accessMetadata?.remoteIp ?? params.pending.remoteIp, tokens: params.tokens, + approvedVia: mergeApprovalKind(params.existing, params.approvedVia), createdAtMs: params.existing?.createdAtMs ?? params.now, approvedAtMs: params.now, lastSeenAtMs: params.accessMetadata?.lastSeenAtMs ?? params.existing?.lastSeenAtMs, @@ -746,13 +779,21 @@ export async function approveDevicePairing( ): Promise; export async function approveDevicePairing( requestId: string, - options: { callerScopes?: readonly string[]; accessMetadata?: DevicePairingAccessMetadata }, + options: { + callerScopes?: readonly string[]; + accessMetadata?: DevicePairingAccessMetadata; + approvedVia?: Extract; + }, baseDir?: string, ): Promise; export async function approveDevicePairing( requestId: string, optionsOrBaseDir?: - | { callerScopes?: readonly string[]; accessMetadata?: DevicePairingAccessMetadata } + | { + callerScopes?: readonly string[]; + accessMetadata?: DevicePairingAccessMetadata; + approvedVia?: Extract; + } | string, maybeBaseDir?: string, ): Promise { @@ -842,6 +883,7 @@ export async function approveDevicePairing( approvedScopes, tokens, now, + approvedVia: options?.approvedVia ?? "owner", accessMetadata: options?.accessMetadata, }); delete state.pendingById[requestId]; @@ -941,6 +983,7 @@ export async function approveBootstrapDevicePairing( approvedScopes: nextApprovedScopes, tokens, now, + approvedVia: "bootstrap", accessMetadata: options?.accessMetadata, }); delete state.pendingById[requestId]; @@ -994,6 +1037,95 @@ export async function removePairedDevice( }); } +// Silent pairings from the same client software on the same host mint a fresh +// deviceId whenever their state dir (and thus keypair) is ephemeral. The cluster +// key groups those records so a replacement pairing can retire its predecessors. +function silentPairingClusterKey( + device: Pick, +): string | null { + const clientId = device.clientId?.trim().toLowerCase() ?? ""; + const clientMode = device.clientMode?.trim().toLowerCase() ?? ""; + const displayName = device.displayName?.trim().toLowerCase() ?? ""; + if (!clientId && !clientMode && !displayName) { + return null; + } + return `${clientId}\0${clientMode}\0${displayName}`; +} + +/** Superseded silent pairing removed in favor of a newer record for the same client. */ +export type PrunedSupersededPairedDevice = { + deviceId: string; + roles: string[]; +}; + +// A concurrently approved sibling may still be mid-handshake and not yet visible +// to the connected-clients check; freshly approved records are never prune +// candidates so parallel silent pairings cannot delete each other's rows. +const PRUNE_RECENT_APPROVAL_GRACE_MS = 60_000; + +/** + * Remove silent-approved sibling records superseded by a newly approved silent + * pairing of the same client cluster. Only records whose latest approval was + * same-host local ("silent") are eligible, as anchor and as victim: local + * clients re-pair silently by construction and share the gateway host, so the + * metadata cluster key cannot match a different machine. Currently connected + * devices are skipped so concurrent sessions with distinct state dirs keep + * their tokens while live. + */ +export async function pruneSupersededSilentPairedDevices(params: { + deviceId: string; + baseDir?: string; + isDeviceConnected?: (deviceId: string) => boolean; + nowMs?: number; +}): Promise { + return await withLock(async () => { + const state = await loadState(params.baseDir); + const anchor = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; + if (!anchor || anchor.approvedVia !== "silent") { + return []; + } + const anchorKey = silentPairingClusterKey(anchor); + if (!anchorKey) { + return []; + } + const nowMs = params.nowMs ?? Date.now(); + const removed: PrunedSupersededPairedDevice[] = []; + for (const device of Object.values(state.pairedByDeviceId)) { + if (device.deviceId === anchor.deviceId) { + continue; + } + // Legacy records without approvedVia stay untouched (fail-safe). + if (device.approvedVia !== "silent") { + continue; + } + if (silentPairingClusterKey(device) !== anchorKey) { + continue; + } + if (nowMs - device.approvedAtMs < PRUNE_RECENT_APPROVAL_GRACE_MS) { + continue; + } + if (params.isDeviceConnected?.(device.deviceId)) { + continue; + } + delete state.pairedByDeviceId[device.deviceId]; + for (const [requestId, pending] of Object.entries(state.pendingById)) { + if (pending.deviceId === device.deviceId) { + delete state.pendingById[requestId]; + } + } + removed.push({ + deviceId: device.deviceId, + roles: listApprovedPairedDeviceRoles(device), + }); + } + if (removed.length === 0) { + return []; + } + await persistState(state, params.baseDir, "both"); + return removed; + }); +} + /** Remove one approved paired-device role while preserving unrelated role tokens. */ export async function removePairedDeviceRole(params: { deviceId: string; diff --git a/ui/src/lib/nodes/index.ts b/ui/src/lib/nodes/index.ts index 65c105a75e6..8c3f766b53f 100644 --- a/ui/src/lib/nodes/index.ts +++ b/ui/src/lib/nodes/index.ts @@ -46,12 +46,20 @@ export type PairedDevice = { deviceId: string; publicKey?: string; displayName?: string; + platform?: string; + clientId?: string; + clientMode?: string; + role?: string; roles?: string[]; scopes?: string[]; remoteIp?: string; tokens?: DeviceTokenSummary[]; + approvedVia?: "owner" | "silent" | "trusted-cidr" | "bootstrap"; + /** Server-computed: the device currently holds a live gateway connection. */ + connected?: boolean; createdAtMs?: number; approvedAtMs?: number; + lastSeenAtMs?: number; }; export type DevicePairingList = { @@ -299,6 +307,118 @@ export async function rejectDevicePairing(state: DevicesState, requestId: string } } +/** Entry removal request resolved from the unified inventory row. */ +export type InventoryRemovalRequest = { + id: string; + name: string; + removeNode: boolean; + removeDevice: boolean; +}; + +type InventoryState = NodesState & DevicesState; + +async function removeInventoryEntryRpc( + client: GatewayRequestClient, + entry: InventoryRemovalRequest, +) { + // Node removal first: it revokes the node role (deleting node-only device rows) + // and clears any legacy node pairing under the same id. A mixed-role record + // then loses its remaining roles via the device-level removal. + if (entry.removeNode) { + await client.request("node.pair.remove", { nodeId: entry.id }); + } + if (entry.removeDevice) { + await client.request("device.pair.remove", { deviceId: entry.id }); + } +} + +// Reload quietly and assign the failure afterwards: a non-quiet loadDevices +// clears devicesError first, which would erase the message before it renders. +async function reloadInventory(state: InventoryState, opts?: { error?: string }) { + const quiet = opts?.error !== undefined; + await Promise.all([loadDevices(state, { quiet }), loadNodes(state, { quiet })]); + if (opts?.error !== undefined) { + state.devicesError = opts.error; + } +} + +export async function removeInventoryEntry(state: InventoryState, entry: InventoryRemovalRequest) { + const client = state.client; + if (!client || !state.connected) { + return; + } + const confirmed = window.confirm(`Remove ${entry.name} (${entry.id.slice(0, 12)}…)?`); + if (!confirmed) { + return; + } + try { + await removeInventoryEntryRpc(client, entry); + await reloadInventory(state); + } catch (err) { + await reloadInventory(state, { error: String(err) }); + } +} + +export async function removeStaleInventoryEntries( + state: InventoryState, + entries: InventoryRemovalRequest[], +) { + const client = state.client; + if (!client || !state.connected || entries.length === 0) { + return; + } + const confirmed = window.confirm( + `Remove ${entries.length} stale pairing${entries.length === 1 ? "" : "s"}? Affected clients re-pair silently on their next connection.`, + ); + if (!confirmed) { + return; + } + const failures: string[] = []; + for (const entry of entries) { + try { + await removeInventoryEntryRpc(client, entry); + } catch (err) { + failures.push(`${entry.name}: ${String(err)}`); + } + } + await reloadInventory( + state, + failures.length > 0 + ? { + error: `Failed to remove ${failures.length} entr${failures.length === 1 ? "y" : "ies"}: ${failures[0]}`, + } + : undefined, + ); +} + +export async function approveNodePairingRequest(state: InventoryState, requestId: string) { + if (!state.client || !state.connected) { + return; + } + try { + await state.client.request("node.pair.approve", { requestId }); + await reloadInventory(state); + } catch (err) { + await reloadInventory(state, { error: String(err) }); + } +} + +export async function rejectNodePairingRequest(state: InventoryState, requestId: string) { + if (!state.client || !state.connected) { + return; + } + const confirmed = window.confirm("Reject this node pairing request?"); + if (!confirmed) { + return; + } + try { + await state.client.request("node.pair.reject", { requestId }); + await reloadInventory(state); + } catch (err) { + await reloadInventory(state, { error: String(err) }); + } +} + export async function rotateDeviceToken( state: DevicesState, params: { deviceId: string; gatewayUrl: string; role: string; scopes?: string[] }, diff --git a/ui/src/lib/nodes/inventory.test.ts b/ui/src/lib/nodes/inventory.test.ts new file mode 100644 index 00000000000..dda26874966 --- /dev/null +++ b/ui/src/lib/nodes/inventory.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest"; +import type { PairedDevice } from "./index.ts"; +import { + buildNodesInventory, + listStaleInventoryEntries, + resolveInventoryRemoval, +} from "./inventory.ts"; + +function device(overrides: Partial & { deviceId: string }): PairedDevice { + return { + publicKey: `pk-${overrides.deviceId}`, + roles: ["operator"], + ...overrides, + }; +} + +describe("buildNodesInventory", () => { + it("joins device records with node catalog rows by id", () => { + const groups = buildNodesInventory({ + paired: [ + device({ + deviceId: "node-1", + displayName: "megaclaw", + roles: ["operator", "node"], + lastSeenAtMs: 1_000, + }), + ], + nodes: [ + { + nodeId: "node-1", + displayName: "megaclaw", + connected: true, + paired: true, + caps: ["screen"], + commands: ["system.run"], + version: "2026.6.11", + }, + ], + }); + + expect(groups).toHaveLength(1); + const entry = groups[0].primary; + expect(entry.id).toBe("node-1"); + expect(entry.connected).toBe(true); + expect(entry.roles).toEqual(["operator", "node"]); + expect(entry.version).toBe("2026.6.11"); + expect(entry.node?.caps).toEqual(["screen"]); + }); + + it("groups duplicate pairings by display name with the freshest entry first", () => { + const groups = buildNodesInventory({ + paired: [ + device({ deviceId: "old-1", displayName: "MacBook", lastSeenAtMs: 1_000 }), + device({ deviceId: "new-1", displayName: "MacBook", lastSeenAtMs: 3_000 }), + device({ deviceId: "mid-1", displayName: "macbook", lastSeenAtMs: 2_000 }), + ], + nodes: [], + }); + + expect(groups).toHaveLength(1); + expect(groups[0].primary.id).toBe("new-1"); + expect(groups[0].duplicates.map((entry) => entry.id)).toEqual(["mid-1", "old-1"]); + }); + + it("prefers connected entries as group primary over fresher offline ones", () => { + const groups = buildNodesInventory({ + paired: [ + device({ deviceId: "offline-1", displayName: "megaclaw", lastSeenAtMs: 9_000 }), + device({ + deviceId: "live-1", + displayName: "megaclaw", + roles: ["node"], + lastSeenAtMs: 1_000, + }), + ], + nodes: [{ nodeId: "live-1", connected: true, paired: true }], + }); + + expect(groups[0].primary.id).toBe("live-1"); + expect(groups[0].duplicates.map((entry) => entry.id)).toEqual(["offline-1"]); + }); + + it("groups anonymous records by client identity and keeps unknown ids separate", () => { + const groups = buildNodesInventory({ + paired: [ + device({ deviceId: "cli-1", clientId: "cli", clientMode: "cli", lastSeenAtMs: 2_000 }), + device({ deviceId: "cli-2", clientId: "cli", clientMode: "cli", lastSeenAtMs: 1_000 }), + device({ deviceId: "anon-1" }), + device({ deviceId: "anon-2" }), + ], + nodes: [], + }); + + const keys = groups.map((group) => group.key); + expect(keys).toContain("client:cli:cli"); + expect(keys).toContain("id:anon-1"); + expect(keys).toContain("id:anon-2"); + const cliGroup = groups.find((group) => group.key === "client:cli:cli"); + expect(cliGroup?.primary.id).toBe("cli-1"); + expect(cliGroup?.duplicates.map((entry) => entry.id)).toEqual(["cli-2"]); + expect(cliGroup?.name).toBe("cli"); + }); + + it("keeps legacy node-only rows and marks them with the node role", () => { + const groups = buildNodesInventory({ + paired: [], + nodes: [{ nodeId: "legacy-1", displayName: "clawmac", paired: true, connected: false }], + }); + + expect(groups).toHaveLength(1); + expect(groups[0].primary.roles).toEqual(["node"]); + expect(groups[0].primary.device).toBeUndefined(); + }); + + it("flags silent and trusted-cidr pairings as auto-approved", () => { + const groups = buildNodesInventory({ + paired: [ + device({ deviceId: "cli-1", clientId: "cli", approvedVia: "silent" }), + device({ deviceId: "cidr-1", displayName: "megaclaw", approvedVia: "trusted-cidr" }), + device({ deviceId: "owner-1", displayName: "iPhone", approvedVia: "owner" }), + ], + nodes: [], + }); + const byId = new Map(groups.map((group) => [group.primary.id, group.primary])); + expect(byId.get("cli-1")?.autoApproved).toBe(true); + expect(byId.get("cidr-1")?.autoApproved).toBe(true); + expect(byId.get("owner-1")?.autoApproved).toBe(false); + }); +}); + +describe("listStaleInventoryEntries", () => { + it("treats server-reported device connectivity as live for operator-only entries", () => { + const groups = buildNodesInventory({ + paired: [ + device({ + deviceId: "cli-new", + clientId: "cli", + approvedVia: "silent", + lastSeenAtMs: 3_000, + }), + device({ + deviceId: "cli-live", + clientId: "cli", + approvedVia: "silent", + connected: true, + lastSeenAtMs: 1_000, + }), + device({ + deviceId: "cli-stale", + clientId: "cli", + approvedVia: "silent", + lastSeenAtMs: 2_000, + }), + ], + nodes: [], + }); + + expect(groups[0].primary.id).toBe("cli-live"); + expect(listStaleInventoryEntries(groups).map((entry) => entry.id)).toEqual([ + "cli-new", + "cli-stale", + ]); + }); + + it("lists offline auto-approved duplicates only", () => { + const groups = buildNodesInventory({ + paired: [ + device({ + deviceId: "new-1", + displayName: "megaclaw", + approvedVia: "silent", + lastSeenAtMs: 3_000, + }), + device({ + deviceId: "live-old", + displayName: "megaclaw", + roles: ["node"], + approvedVia: "trusted-cidr", + lastSeenAtMs: 2_000, + }), + device({ + deviceId: "old-1", + displayName: "megaclaw", + approvedVia: "trusted-cidr", + lastSeenAtMs: 1_000, + }), + // Owner-approved and pre-provenance duplicates never enter the bulk sweep. + device({ deviceId: "owner-old", displayName: "megaclaw", approvedVia: "owner" }), + device({ deviceId: "legacy-old", displayName: "megaclaw" }), + ], + nodes: [{ nodeId: "live-old", connected: true, paired: true }], + }); + + // Connected entry becomes primary; fresh offline entry stays a duplicate but + // only offline auto-approved duplicates are safe to clean up. + expect(listStaleInventoryEntries(groups).map((entry) => entry.id)).toEqual(["new-1", "old-1"]); + }); +}); + +describe("resolveInventoryRemoval", () => { + it("routes node-role entries through node removal", () => { + const groups = buildNodesInventory({ + paired: [device({ deviceId: "node-1", roles: ["node"], displayName: "megaclaw" })], + nodes: [], + }); + expect(resolveInventoryRemoval(groups[0].primary)).toEqual({ + removeNode: true, + removeDevice: false, + }); + }); + + it("routes mixed-role entries through node and device removal", () => { + const groups = buildNodesInventory({ + paired: [ + device({ deviceId: "mixed-1", roles: ["operator", "node"], displayName: "MacBook" }), + ], + nodes: [], + }); + expect(resolveInventoryRemoval(groups[0].primary)).toEqual({ + removeNode: true, + removeDevice: true, + }); + }); + + it("routes operator-only entries through device removal", () => { + const groups = buildNodesInventory({ + paired: [device({ deviceId: "op-1", roles: ["operator"] })], + nodes: [], + }); + expect(resolveInventoryRemoval(groups[0].primary)).toEqual({ + removeNode: false, + removeDevice: true, + }); + }); + + it("routes legacy node-only rows through node removal", () => { + const groups = buildNodesInventory({ + paired: [], + nodes: [{ nodeId: "legacy-1", paired: true }], + }); + expect(resolveInventoryRemoval(groups[0].primary)).toEqual({ + removeNode: true, + removeDevice: false, + }); + }); +}); diff --git a/ui/src/lib/nodes/inventory.ts b/ui/src/lib/nodes/inventory.ts new file mode 100644 index 00000000000..63e54c0f799 --- /dev/null +++ b/ui/src/lib/nodes/inventory.ts @@ -0,0 +1,284 @@ +// Builds the unified nodes/devices inventory shown on the Nodes page. +// The gateway exposes two overlapping views of the same machines: paired device +// records (roles + tokens) and the node catalog (caps + live links). This module +// joins them by id and groups duplicate pairings of the same client so the page +// renders one row per machine instead of one row per historical keypair. +import { normalizeOptionalString } from "../string-coerce.ts"; +import type { PairedDevice } from "./index.ts"; + +export type NodeApprovalState = + | "approved" + | "pending-approval" + | "pending-reapproval" + | "unapproved"; + +/** Typed projection of one raw `node.list` row. */ +export type NodeListEntry = { + nodeId: string; + displayName?: string; + platform?: string; + version?: string; + clientId?: string; + clientMode?: string; + remoteIp?: string; + caps: string[]; + commands: string[]; + approvalState?: NodeApprovalState; + pendingRequestId?: string; + connected: boolean; + paired: boolean; + connectedAtMs?: number; + lastSeenAtMs?: number; + approvedAtMs?: number; +}; + +export type NodesInventoryEntry = { + id: string; + name: string; + displayName?: string; + clientId?: string; + clientMode?: string; + platform?: string; + version?: string; + remoteIp?: string; + roles: string[]; + scopes: string[]; + connected: boolean; + autoApproved: boolean; + lastSeenAtMs?: number; + approvedAtMs?: number; + device?: PairedDevice; + node?: NodeListEntry; +}; + +/** One machine cluster: the freshest pairing plus superseded duplicates. */ +export type NodesInventoryGroup = { + key: string; + name: string; + primary: NodesInventoryEntry; + duplicates: NodesInventoryEntry[]; +}; + +const NODE_APPROVAL_STATES: ReadonlySet = new Set([ + "approved", + "pending-approval", + "pending-reapproval", + "unapproved", +]); + +function optionalNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function stringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((entry) => normalizeOptionalString(entry)) + .filter((entry): entry is string => entry !== undefined); +} + +export function parseNodeListEntry(raw: Record): NodeListEntry | null { + const nodeId = normalizeOptionalString(raw.nodeId); + if (!nodeId) { + return null; + } + const approvalState = normalizeOptionalString(raw.approvalState); + return { + nodeId, + displayName: normalizeOptionalString(raw.displayName), + platform: normalizeOptionalString(raw.platform), + version: normalizeOptionalString(raw.version), + clientId: normalizeOptionalString(raw.clientId), + clientMode: normalizeOptionalString(raw.clientMode), + remoteIp: normalizeOptionalString(raw.remoteIp), + caps: stringList(raw.caps), + commands: stringList(raw.commands), + approvalState: + approvalState && NODE_APPROVAL_STATES.has(approvalState) + ? (approvalState as NodeApprovalState) + : undefined, + pendingRequestId: normalizeOptionalString(raw.pendingRequestId), + connected: raw.connected === true, + paired: raw.paired === true, + connectedAtMs: optionalNumber(raw.connectedAtMs), + lastSeenAtMs: optionalNumber(raw.lastSeenAtMs), + approvedAtMs: optionalNumber(raw.approvedAtMs), + }; +} + +function deviceRoles(device: PairedDevice): string[] { + const roles = new Set(); + for (const role of [...(device.roles ?? []), device.role]) { + const normalized = normalizeOptionalString(role); + if (normalized) { + roles.add(normalized); + } + } + return [...roles]; +} + +function maxDefined(...values: Array): number | undefined { + let max: number | undefined; + for (const value of values) { + if (value !== undefined && (max === undefined || value > max)) { + max = value; + } + } + return max; +} + +function buildEntry(id: string, device?: PairedDevice, node?: NodeListEntry): NodesInventoryEntry { + const roles = device ? deviceRoles(device) : []; + if (node?.paired && !roles.includes("node")) { + // Legacy nodes/paired.json rows have no device record; they are still nodes. + roles.push("node"); + } + const displayName = + normalizeOptionalString(device?.displayName) ?? normalizeOptionalString(node?.displayName); + const clientId = normalizeOptionalString(device?.clientId) ?? node?.clientId; + return { + id, + name: displayName ?? clientId ?? id, + displayName, + clientId, + clientMode: normalizeOptionalString(device?.clientMode) ?? node?.clientMode, + platform: normalizeOptionalString(device?.platform) ?? node?.platform, + version: node?.version, + remoteIp: normalizeOptionalString(device?.remoteIp) ?? node?.remoteIp, + roles, + scopes: stringList(device?.scopes), + // Node catalog rows and the server-computed device connection state both + // count: operator-only clients never appear in node.list. + connected: node?.connected === true || device?.connected === true, + autoApproved: device?.approvedVia === "silent" || device?.approvedVia === "trusted-cidr", + lastSeenAtMs: maxDefined(device?.lastSeenAtMs, node?.lastSeenAtMs, node?.connectedAtMs), + approvedAtMs: maxDefined(device?.approvedAtMs, node?.approvedAtMs), + device, + node, + }; +} + +function groupKey(entry: NodesInventoryEntry): string { + const name = entry.displayName?.trim().toLowerCase(); + if (name) { + return `name:${name}`; + } + const clientId = entry.clientId?.trim().toLowerCase(); + const clientMode = entry.clientMode?.trim().toLowerCase(); + if (clientId || clientMode) { + return `client:${clientId ?? ""}:${clientMode ?? ""}`; + } + // No usable identity metadata: never merge with other anonymous records. + return `id:${entry.id}`; +} + +function entryRecency(entry: NodesInventoryEntry): number { + return entry.lastSeenAtMs ?? entry.approvedAtMs ?? 0; +} + +function compareEntries(left: NodesInventoryEntry, right: NodesInventoryEntry): number { + if (left.connected !== right.connected) { + return left.connected ? -1 : 1; + } + const recency = entryRecency(right) - entryRecency(left); + if (recency !== 0) { + return recency; + } + return left.id.localeCompare(right.id); +} + +function compareGroups(left: NodesInventoryGroup, right: NodesInventoryGroup): number { + const order = compareEntries(left.primary, right.primary); + if (order !== 0) { + return order; + } + return left.name.localeCompare(right.name); +} + +/** Joins paired devices with node catalog rows and groups duplicate pairings. */ +export function buildNodesInventory(params: { + paired: PairedDevice[]; + nodes: Array>; +}): NodesInventoryGroup[] { + const nodesById = new Map(); + for (const raw of params.nodes) { + const node = parseNodeListEntry(raw); + if (node) { + nodesById.set(node.nodeId, node); + } + } + const entries: NodesInventoryEntry[] = []; + const seen = new Set(); + for (const device of params.paired) { + const id = normalizeOptionalString(device.deviceId); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + entries.push(buildEntry(id, device, nodesById.get(id))); + } + for (const [id, node] of nodesById) { + if (!seen.has(id)) { + entries.push(buildEntry(id, undefined, node)); + } + } + + const groupsByKey = new Map(); + for (const entry of entries) { + const key = groupKey(entry); + const bucket = groupsByKey.get(key); + if (bucket) { + bucket.push(entry); + } else { + groupsByKey.set(key, [entry]); + } + } + + const groups: NodesInventoryGroup[] = []; + for (const [key, bucket] of groupsByKey) { + const sorted = bucket.toSorted(compareEntries); + const primary = sorted[0]; + groups.push({ + key, + name: primary.name, + primary, + duplicates: sorted.slice(1), + }); + } + return groups.toSorted(compareGroups); +} + +/** + * Duplicate entries safe to bulk-remove: superseded, not currently connected, + * and auto-approved (silent local / trusted-CIDR), so the client re-pairs + * without user action. Owner/QR-approved and pre-provenance duplicates keep + * their per-entry Remove button but never enter the bulk sweep. + * + * Deliberate tradeoff: groups key on display metadata because no machine + * identity survives a key rotation. Two distinct same-named trusted-CIDR + * machines can therefore land in one group and the offline one may be swept — + * accepted because the sweep is admin-confirmed and a wrongly removed client + * is re-admitted automatically by the same auto-approve policy on reconnect. + */ +export function listStaleInventoryEntries(groups: NodesInventoryGroup[]): NodesInventoryEntry[] { + return groups.flatMap((group) => + group.duplicates.filter((entry) => !entry.connected && entry.autoApproved), + ); +} + +/** Which pairing stores a removal must touch for this entry. */ +export function resolveInventoryRemoval(entry: NodesInventoryEntry): { + removeNode: boolean; + removeDevice: boolean; +} { + const hasNodeRole = entry.roles.includes("node"); + const nonNodeRoles = entry.roles.filter((role) => role !== "node"); + return { + removeNode: hasNodeRole || entry.node?.paired === true, + // node.pair.remove deletes node-only device rows itself; only records with + // other roles (or tokenless records) need the device-level removal too. + removeDevice: Boolean(entry.device) && (nonNodeRoles.length > 0 || entry.roles.length === 0), + }; +} diff --git a/ui/src/pages/nodes/nodes-page.ts b/ui/src/pages/nodes/nodes-page.ts index 6480cd36f10..255fe3dd18c 100644 --- a/ui/src/pages/nodes/nodes-page.ts +++ b/ui/src/pages/nodes/nodes-page.ts @@ -12,12 +12,16 @@ import { renderSettingsWorkspace } from "../../components/settings-workspace.ts" import { currentConfigObject } from "../../lib/config/index.ts"; import { approveDevicePairing, + approveNodePairingRequest, createInitialNodesState, loadDevices, loadExecApprovals, loadNodes, rejectDevicePairing, + rejectNodePairingRequest, removeExecApprovalsFormValue, + removeInventoryEntry, + removeStaleInventoryEntries, revokeDeviceToken, rotateDeviceToken, saveExecApprovals, @@ -103,6 +107,9 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState { if (event.event === "device.pair.requested" || event.event === "device.pair.resolved") { void loadDevices(this, { quiet: true }); } + if (event.event === "node.pair.requested" || event.event === "node.pair.resolved") { + void loadNodes(this, { quiet: true }); + } }), ); @@ -257,6 +264,7 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState { renderNodes({ loading: this.nodesLoading, nodes: this.nodes, + lastError: this.lastError, devicesLoading: this.devicesLoading, devicesError: this.devicesError, devicesList: this.devicesList, @@ -274,11 +282,17 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState { execApprovalsSelectedAgent: this.execApprovalsSelectedAgent, execApprovalsTarget: this.execApprovalsTarget, execApprovalsTargetNodeId: this.execApprovalsTargetNodeId, - onRefresh: () => void loadNodes(this), - onDevicesRefresh: () => void loadDevices(this), + onRefresh: () => { + void loadNodes(this); + void loadDevices(this); + }, onDevicePairSetupOpen: () => void this.context.overlays.openDevicePairSetup(), onDeviceApprove: (requestId) => void approveDevicePairing(this, requestId), onDeviceReject: (requestId) => void rejectDevicePairing(this, requestId), + onNodeApprove: (requestId) => void approveNodePairingRequest(this, requestId), + onNodeReject: (requestId) => void rejectNodePairingRequest(this, requestId), + onInventoryRemove: (entry) => void removeInventoryEntry(this, entry), + onInventoryCleanup: (entries) => void removeStaleInventoryEntries(this, entries), onDeviceRotate: (deviceId, role, scopes) => void rotateDeviceToken(this, { deviceId, diff --git a/ui/src/pages/nodes/view-inventory.ts b/ui/src/pages/nodes/view-inventory.ts new file mode 100644 index 00000000000..b11fcb7751b --- /dev/null +++ b/ui/src/pages/nodes/view-inventory.ts @@ -0,0 +1,362 @@ +// Nodes page renders the unified paired-device / node inventory card. +import { html, nothing, type TemplateResult } from "lit"; +import { + resolvePendingDeviceApprovalState, + type DevicePairingAccessSummary, + type PendingDeviceApprovalKind, +} from "../../../../src/shared/device-pairing-access.js"; +import { icons } from "../../components/icons.ts"; +import { t } from "../../i18n/index.ts"; +import { formatList, formatRelativeTimestamp } from "../../lib/format.ts"; +import type { + DeviceTokenSummary, + InventoryRemovalRequest, + PairedDevice, + PendingDevice, +} from "../../lib/nodes/index.ts"; +import { + buildNodesInventory, + listStaleInventoryEntries, + resolveInventoryRemoval, + type NodesInventoryEntry, + type NodesInventoryGroup, +} from "../../lib/nodes/inventory.ts"; +import { normalizeOptionalString } from "../../lib/string-coerce.ts"; +import type { NodesProps } from "./view.types.ts"; + +const MAX_CAPABILITY_CHIPS = 16; + +function toRemovalRequest(entry: NodesInventoryEntry): InventoryRemovalRequest { + const removal = resolveInventoryRemoval(entry); + return { id: entry.id, name: entry.name, ...removal }; +} + +function shortId(id: string): string { + return id.length > 14 ? `${id.slice(0, 12)}…` : id; +} + +export function renderNodesInventory(props: NodesProps) { + const list = props.devicesList ?? { pending: [], paired: [] }; + const pending = Array.isArray(list.pending) ? list.pending : []; + const paired = Array.isArray(list.paired) ? list.paired : []; + const groups = buildNodesInventory({ paired, nodes: props.nodes }); + const stale = listStaleInventoryEntries(groups); + const pairedByDeviceId = new Map( + paired + .map((device) => [normalizeOptionalString(device.deviceId), device] as const) + .filter((entry): entry is [string, PairedDevice] => Boolean(entry[0])), + ); + const loading = props.loading || props.devicesLoading; + return html` +
+
+
+
Nodes & devices
+
One entry per paired client: roles, tokens, live links.
+
+
+ ${stale.length > 0 + ? html` + + ` + : nothing} + + +
+
+ ${props.devicesError + ? html`
${props.devicesError}
` + : nothing} + ${props.lastError + ? html`
${props.lastError}
` + : nothing} +
+ ${pending.length > 0 + ? html` +
Pending approval
+ ${pending.map((req) => + renderPendingDevice(req, props, lookupPairedDevice(pairedByDeviceId, req)), + )} +
Paired
+ ` + : nothing} + ${groups.length === 0 && pending.length === 0 + ? html`
No paired nodes or devices.
` + : groups.map((group) => renderInventoryGroup(group, props))} +
+
+ `; +} + +function renderInventoryGroup(group: NodesInventoryGroup, props: NodesProps) { + if (group.duplicates.length === 0) { + return renderInventoryEntry(group.primary, props); + } + return html` +
+ ${renderInventoryEntry(group.primary, props)} +
+ + ${group.duplicates.length} older pairing${group.duplicates.length === 1 ? "" : "s"} of + ${group.name} + + ${group.duplicates.map((entry) => renderInventoryEntry(entry, props))} +
+
+ `; +} + +function entryStatusChips(entry: NodesInventoryEntry): TemplateResult[] { + const chips: TemplateResult[] = []; + // Connectivity is known for node-catalog entries and for device records with + // server-computed connection state; legacy node-only rows without either + // still report offline, which matches their live-link reality. + chips.push( + html` + ${entry.connected ? "connected" : "offline"} + `, + ); + for (const role of entry.roles) { + chips.push(html`${role}`); + } + if (entry.autoApproved) { + chips.push(html`auto-paired`); + } + const approvalState = entry.node?.approvalState; + if (approvalState === "pending-approval" || approvalState === "pending-reapproval") { + chips.push(html`approval needed`); + } + return chips; +} + +function entryMetaLine(entry: NodesInventoryEntry): string { + const parts: string[] = [shortId(entry.id)]; + if (entry.platform) { + parts.push(entry.platform); + } + if (entry.version) { + parts.push(entry.version); + } + if (entry.remoteIp) { + parts.push(entry.remoteIp); + } + if (entry.lastSeenAtMs) { + parts.push(`seen ${formatRelativeTimestamp(entry.lastSeenAtMs)}`); + } else if (entry.approvedAtMs) { + parts.push(`approved ${formatRelativeTimestamp(entry.approvedAtMs)}`); + } + return parts.join(" · "); +} + +function renderCapabilityChips(label: string, values: string[]) { + if (values.length === 0) { + return nothing; + } + const visible = values.slice(0, MAX_CAPABILITY_CHIPS); + const overflow = values.length - visible.length; + return html` +
${label}
+
+ ${visible.map((value) => html`${value}`)} + ${overflow > 0 ? html`+${overflow} more` : nothing} +
+ `; +} + +function renderEntryDetails(entry: NodesInventoryEntry, props: NodesProps) { + const tokens = entry.device?.tokens ?? []; + const caps = entry.node?.caps ?? []; + const commands = entry.node?.commands ?? []; + const scopes = entry.scopes; + if (tokens.length === 0 && caps.length === 0 && commands.length === 0 && scopes.length === 0) { + return nothing; + } + return html` +
+ Tokens & capabilities +
${entry.id}
+ ${scopes.length > 0 + ? html`
scopes: ${formatList(scopes)}
` + : nothing} + ${tokens.length > 0 + ? html` +
Tokens
+
+ ${tokens.map((token) => renderTokenRow(entry.id, token, props))} +
+ ` + : nothing} + ${renderCapabilityChips("Capabilities", caps)} ${renderCapabilityChips("Commands", commands)} +
+ `; +} + +function renderInventoryEntry(entry: NodesInventoryEntry, props: NodesProps) { + const pendingRequestId = + entry.node?.approvalState === "pending-approval" || + entry.node?.approvalState === "pending-reapproval" + ? entry.node.pendingRequestId + : undefined; + return html` +
+
+
+ ${entry.name} + ${entryStatusChips(entry)} +
+
${entryMetaLine(entry)}
+ ${renderEntryDetails(entry, props)} +
+
+
+ ${pendingRequestId + ? html` + + + ` + : nothing} + +
+
+
+ `; +} + +function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) { + const status = token.revokedAtMs ? "revoked" : "active"; + const scopes = `scopes: ${formatList(token.scopes)}`; + const when = formatRelativeTimestamp( + token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null, + ); + return html` +
+
${token.role} · ${status} · ${scopes} · ${when}
+
+ + ${token.revokedAtMs + ? nothing + : html` + + `} +
+
+ `; +} + +function lookupPairedDevice( + pairedByDeviceId: ReadonlyMap, + request: Pick, +): PairedDevice | undefined { + const deviceId = normalizeOptionalString(request.deviceId); + if (!deviceId) { + return undefined; + } + const paired = pairedByDeviceId.get(deviceId); + if (!paired) { + return undefined; + } + const requestPublicKey = normalizeOptionalString(request.publicKey); + const pairedPublicKey = normalizeOptionalString(paired.publicKey); + if (requestPublicKey && pairedPublicKey && requestPublicKey !== pairedPublicKey) { + return undefined; + } + return paired; +} + +function formatAccessSummary(access: DevicePairingAccessSummary | null): string { + if (!access) { + return "none"; + } + return `roles: ${formatList(access.roles)} · scopes: ${formatList(access.scopes)}`; +} + +function renderPendingApprovalNote(kind: PendingDeviceApprovalKind) { + switch (kind) { + case "scope-upgrade": + return "scope upgrade requires approval"; + case "role-upgrade": + return "role upgrade requires approval"; + case "re-approval": + return "reconnect details changed; approval required"; + case "new-pairing": + return "new device pairing request"; + } + const exhaustiveKind: never = kind; + void exhaustiveKind; + throw new Error("unsupported pending approval kind"); +} + +function renderPendingDevice(req: PendingDevice, props: NodesProps, paired?: PairedDevice) { + const name = normalizeOptionalString(req.displayName) || req.deviceId; + const age = typeof req.ts === "number" ? formatRelativeTimestamp(req.ts) : t("common.na"); + const approval = resolvePendingDeviceApprovalState(req, paired); + const repair = req.isRepair ? " · repair" : ""; + const ip = req.remoteIp ? ` · ${req.remoteIp}` : ""; + return html` +
+
+
${name}
+
${req.deviceId}${ip}
+
+ ${renderPendingApprovalNote(approval.kind)} · requested ${age}${repair} +
+
+ requested: ${formatAccessSummary(approval.requested)} +
+ ${approval.approved + ? html` +
+ approved now: ${formatAccessSummary(approval.approved)} +
+ ` + : nothing} +
+
+
+ + +
+
+
+ `; +} diff --git a/ui/src/pages/nodes/view.devices.test.ts b/ui/src/pages/nodes/view.devices.test.ts index d25d52ff587..902097fa54e 100644 --- a/ui/src/pages/nodes/view.devices.test.ts +++ b/ui/src/pages/nodes/view.devices.test.ts @@ -1,12 +1,14 @@ /* @vitest-environment jsdom */ import { render } from "lit"; import { describe, expect, it } from "vitest"; +import type { InventoryRemovalRequest } from "../../lib/nodes/index.ts"; import { renderNodes, type NodesProps } from "./view.ts"; function baseProps(overrides: Partial = {}): NodesProps { return { loading: false, nodes: [], + lastError: null, devicesLoading: false, devicesError: null, devicesList: { @@ -28,12 +30,15 @@ function baseProps(overrides: Partial = {}): NodesProps { execApprovalsTarget: "gateway", execApprovalsTargetNodeId: null, onRefresh: () => undefined, - onDevicesRefresh: () => undefined, onDevicePairSetupOpen: () => undefined, onDeviceApprove: () => undefined, onDeviceReject: () => undefined, onDeviceRotate: () => undefined, onDeviceRevoke: () => undefined, + onNodeApprove: () => undefined, + onNodeReject: () => undefined, + onInventoryRemove: () => undefined, + onInventoryCleanup: () => undefined, onLoadConfig: () => undefined, onLoadExecApprovals: () => undefined, onBindDefault: () => undefined, @@ -54,19 +59,20 @@ function renderNodesContainer(overrides: Partial): HTMLDivElement { return container; } -function getDevicesCard(container: Element): Element { +function getInventoryCard(container: Element): Element { const card = Array.from(container.querySelectorAll(".card")).find( - (candidate) => candidate.querySelector(".card-title")?.textContent?.trim() === "Devices", + (candidate) => + candidate.querySelector(".card-title")?.textContent?.trim() === "Nodes & devices", ); expect(card).toBeInstanceOf(Element); if (!(card instanceof Element)) { - throw new Error("Expected devices card"); + throw new Error("Expected inventory card"); } return card; } function getPendingDeviceDetails(container: Element): string[] { - const item = getDevicesCard(container).querySelector(".list-item"); + const item = getInventoryCard(container).querySelector(".list-item"); expect(item).toBeInstanceOf(Element); if (!(item instanceof Element)) { throw new Error("Expected pending device item"); @@ -76,6 +82,17 @@ function getPendingDeviceDetails(container: Element): string[] { ); } +function findButton(scope: Element, label: string): HTMLButtonElement { + const button = Array.from(scope.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === label, + ); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button ${label}`); + } + return button; +} + describe("nodes devices pending rendering", () => { it("shows requested and approved access for a scope upgrade", () => { const container = renderNodesContainer({ @@ -102,10 +119,10 @@ describe("nodes devices pending rendering", () => { }); const details = getPendingDeviceDetails(container); - expect(details[0]).toMatch(/^scope upgrade requires approval \u00b7 requested /u); + expect(details[0]).toMatch(/^scope upgrade requires approval · requested /u); expect(details.slice(1)).toEqual([ - "requested: roles: operator \u00b7 scopes: operator.admin, operator.read, operator.write", - "approved now: roles: operator \u00b7 scopes: operator.read", + "requested: roles: operator · scopes: operator.admin, operator.read, operator.write", + "approved now: roles: operator · scopes: operator.read", ]); }); @@ -134,8 +151,8 @@ describe("nodes devices pending rendering", () => { }); const details = getPendingDeviceDetails(container); - expect(details[0]).toMatch(/^scope upgrade requires approval \u00b7 requested /u); - expect(details.at(-1)).toBe("approved now: roles: operator \u00b7 scopes: operator.read"); + expect(details[0]).toMatch(/^scope upgrade requires approval · requested /u); + expect(details.at(-1)).toBe("approved now: roles: operator · scopes: operator.read"); }); it("does not show upgrade context for key-mismatched pending requests", () => { @@ -165,10 +182,10 @@ describe("nodes devices pending rendering", () => { }); const details = getPendingDeviceDetails(container); - expect(details[0]).toMatch(/^new device pairing request \u00b7 requested /u); + expect(details[0]).toMatch(/^new device pairing request · requested /u); expect(details).toEqual([ details[0] ?? "", - "requested: roles: operator \u00b7 scopes: operator.admin, operator.read, operator.write", + "requested: roles: operator · scopes: operator.admin, operator.read, operator.write", ]); }); @@ -189,7 +206,111 @@ describe("nodes devices pending rendering", () => { }); const details = getPendingDeviceDetails(container); - expect(details[1]).toBe("requested: roles: node, operator \u00b7 scopes: operator.read"); + expect(details[1]).toBe("requested: roles: node, operator · scopes: operator.read"); + }); +}); + +describe("nodes inventory rendering", () => { + it("renders one row per machine with duplicates collapsed", () => { + const container = renderNodesContainer({ + devicesList: { + pending: [], + paired: [ + { + deviceId: "mac-new", + displayName: "MacBook", + roles: ["operator", "node"], + lastSeenAtMs: 3_000, + }, + { + deviceId: "mac-old", + displayName: "MacBook", + roles: ["operator", "node"], + approvedVia: "silent", + lastSeenAtMs: 1_000, + }, + ], + }, + nodes: [{ nodeId: "mac-new", displayName: "MacBook", connected: true, paired: true }], + }); + const card = getInventoryCard(container); + + const titles = Array.from(card.querySelectorAll(".list-title")).map((title) => + title.textContent?.trim(), + ); + expect(titles).toEqual(["MacBook", "MacBook"]); + const dups = card.querySelector(".nodes-group__dups"); + expect(dups?.querySelector("summary")?.textContent).toContain("1 older pairing"); + expect(dups?.textContent).toContain("mac-old"); + expect(findButton(card, "Clean up 1 stale")).toBeInstanceOf(HTMLButtonElement); + }); + + it("wires Remove to the removal routing for the entry roles", () => { + const removed: InventoryRemovalRequest[] = []; + const container = renderNodesContainer({ + devicesList: { + pending: [], + paired: [ + { + deviceId: "op-only", + displayName: "Browser", + roles: ["operator"], + }, + ], + }, + onInventoryRemove: (entry) => removed.push(entry), + }); + + findButton(getInventoryCard(container), "Remove").click(); + + expect(removed).toEqual([ + { id: "op-only", name: "Browser", removeNode: false, removeDevice: true }, + ]); + }); + + it("renders approve and reject actions for pending node approvals", () => { + const approvals: string[] = []; + const container = renderNodesContainer({ + nodes: [ + { + nodeId: "node-pending", + displayName: "clawmac", + paired: true, + connected: true, + approvalState: "pending-reapproval", + pendingRequestId: "node-req-1", + }, + ], + onNodeApprove: (requestId) => approvals.push(requestId), + }); + const card = getInventoryCard(container); + + expect(card.textContent).toContain("approval needed"); + findButton(card, "Approve").click(); + expect(approvals).toEqual(["node-req-1"]); + }); + + it("shows token rows with rotate and revoke inside entry details", () => { + const rotations: Array<{ deviceId: string; role: string }> = []; + const container = renderNodesContainer({ + devicesList: { + pending: [], + paired: [ + { + deviceId: "device-1", + displayName: "Device One", + roles: ["operator"], + tokens: [{ role: "operator", scopes: ["operator.read"], createdAtMs: Date.now() }], + }, + ], + }, + onDeviceRotate: (deviceId, role) => rotations.push({ deviceId, role }), + }); + const card = getInventoryCard(container); + + expect(card.textContent).toContain("operator · active · scopes: operator.read"); + findButton(card, "Rotate").click(); + expect(rotations).toEqual([{ deviceId: "device-1", role: "operator" }]); }); }); diff --git a/ui/src/pages/nodes/view.ts b/ui/src/pages/nodes/view.ts index 94935e64b25..1c1db697590 100644 --- a/ui/src/pages/nodes/view.ts +++ b/ui/src/pages/nodes/view.ts @@ -1,16 +1,8 @@ // Nodes page renders its screen content. import { html, nothing } from "lit"; -import { - resolvePendingDeviceApprovalState, - type DevicePairingAccessSummary, - type PendingDeviceApprovalKind, -} from "../../../../src/shared/device-pairing-access.js"; -import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp, formatList } from "../../lib/format.ts"; -import type { DeviceTokenSummary, PairedDevice, PendingDevice } from "../../lib/nodes/index.ts"; -import { normalizeOptionalString } from "../../lib/string-coerce.ts"; import { renderExecApprovals, resolveExecApprovalsState } from "./view-exec-approvals.ts"; +import { renderNodesInventory } from "./view-inventory.ts"; import { resolveConfigAgents, resolveNodeTargets, type NodeTargetOption } from "./view-shared.ts"; export type { NodesProps } from "./view.types.ts"; import type { NodesProps } from "./view.types.ts"; @@ -19,217 +11,8 @@ export function renderNodes(props: NodesProps) { const bindingState = resolveBindingsState(props); const approvalsState = resolveExecApprovalsState(props); return html` - ${renderExecApprovals(approvalsState)} ${renderBindings(bindingState)} ${renderDevices(props)} -
-
-
-
Nodes
-
Paired devices and live links.
-
- -
-
- ${props.nodes.length === 0 - ? html`
No nodes found.
` - : props.nodes.map((n) => renderNode(n))} -
-
- `; -} - -function renderDevices(props: NodesProps) { - const list = props.devicesList ?? { pending: [], paired: [] }; - const pending = Array.isArray(list.pending) ? list.pending : []; - const paired = Array.isArray(list.paired) ? list.paired : []; - const pairedByDeviceId = new Map( - paired - .map((device) => [normalizeOptionalString(device.deviceId), device] as const) - .filter((entry): entry is [string, PairedDevice] => Boolean(entry[0])), - ); - return html` -
-
-
-
Devices
-
Pairing requests + role tokens.
-
-
- - -
-
- ${props.devicesError - ? html`
${props.devicesError}
` - : nothing} -
- ${pending.length > 0 - ? html` -
Pending
- ${pending.map((req) => - renderPendingDevice(req, props, lookupPairedDevice(pairedByDeviceId, req)), - )} - ` - : nothing} - ${paired.length > 0 - ? html` -
Paired
- ${paired.map((device) => renderPairedDevice(device, props))} - ` - : nothing} - ${pending.length === 0 && paired.length === 0 - ? html`
No paired devices.
` - : nothing} -
-
- `; -} - -function lookupPairedDevice( - pairedByDeviceId: ReadonlyMap, - request: Pick, -): PairedDevice | undefined { - const deviceId = normalizeOptionalString(request.deviceId); - if (!deviceId) { - return undefined; - } - const paired = pairedByDeviceId.get(deviceId); - if (!paired) { - return undefined; - } - const requestPublicKey = normalizeOptionalString(request.publicKey); - const pairedPublicKey = normalizeOptionalString(paired.publicKey); - if (requestPublicKey && pairedPublicKey && requestPublicKey !== pairedPublicKey) { - return undefined; - } - return paired; -} - -function formatAccessSummary(access: DevicePairingAccessSummary | null): string { - if (!access) { - return "none"; - } - return `roles: ${formatList(access.roles)} · scopes: ${formatList(access.scopes)}`; -} - -function renderPendingApprovalNote(kind: PendingDeviceApprovalKind) { - switch (kind) { - case "scope-upgrade": - return "scope upgrade requires approval"; - case "role-upgrade": - return "role upgrade requires approval"; - case "re-approval": - return "reconnect details changed; approval required"; - case "new-pairing": - return "new device pairing request"; - } - const exhaustiveKind: never = kind; - void exhaustiveKind; - throw new Error("unsupported pending approval kind"); -} - -function renderPendingDevice(req: PendingDevice, props: NodesProps, paired?: PairedDevice) { - const name = normalizeOptionalString(req.displayName) || req.deviceId; - const age = typeof req.ts === "number" ? formatRelativeTimestamp(req.ts) : t("common.na"); - const approval = resolvePendingDeviceApprovalState(req, paired); - const repair = req.isRepair ? " · repair" : ""; - const ip = req.remoteIp ? ` · ${req.remoteIp}` : ""; - return html` -
-
-
${name}
-
${req.deviceId}${ip}
-
- ${renderPendingApprovalNote(approval.kind)} · requested ${age}${repair} -
-
- requested: ${formatAccessSummary(approval.requested)} -
- ${approval.approved - ? html` -
- approved now: ${formatAccessSummary(approval.approved)} -
- ` - : nothing} -
-
-
- - -
-
-
- `; -} - -function renderPairedDevice(device: PairedDevice, props: NodesProps) { - const name = normalizeOptionalString(device.displayName) || device.deviceId; - const ip = device.remoteIp ? ` · ${device.remoteIp}` : ""; - const roles = `roles: ${formatList(device.roles)}`; - const scopes = `scopes: ${formatList(device.scopes)}`; - const tokens = Array.isArray(device.tokens) ? device.tokens : []; - return html` -
-
-
${name}
-
${device.deviceId}${ip}
-
${roles} · ${scopes}
- ${tokens.length === 0 - ? html`
Tokens: none
` - : html` -
Tokens
-
- ${tokens.map((token) => renderTokenRow(device.deviceId, token, props))} -
- `} -
-
- `; -} - -function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) { - const status = token.revokedAtMs ? "revoked" : "active"; - const scopes = `scopes: ${formatList(token.scopes)}`; - const when = formatRelativeTimestamp( - token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null, - ); - return html` -
-
${token.role} · ${status} · ${scopes} · ${when}
-
- - ${token.revokedAtMs - ? nothing - : html` - - `} -
-
+ ${renderNodesInventory(props)} ${renderExecApprovals(approvalsState)} + ${renderBindings(bindingState)} `; } @@ -447,33 +230,3 @@ function resolveAgentBindings(config: Record | null): { return { defaultBinding, agents }; } - -function renderNode(node: Record) { - const connected = Boolean(node.connected); - const paired = Boolean(node.paired); - const title = - (typeof node.displayName === "string" && node.displayName.trim()) || - (typeof node.nodeId === "string" ? node.nodeId : "unknown"); - const caps = Array.isArray(node.caps) ? (node.caps as unknown[]) : []; - const commands = Array.isArray(node.commands) ? (node.commands as unknown[]) : []; - return html` -
-
-
${title}
-
- ${typeof node.nodeId === "string" ? node.nodeId : ""} - ${typeof node.remoteIp === "string" ? ` · ${node.remoteIp}` : ""} - ${typeof node.version === "string" ? ` · ${node.version}` : ""} -
-
- ${paired ? "paired" : "unpaired"} - - ${connected ? "connected" : "offline"} - - ${caps.slice(0, 12).map((c) => html`${String(c)}`)} - ${commands.slice(0, 8).map((c) => html`${String(c)}`)} -
-
-
- `; -} diff --git a/ui/src/pages/nodes/view.types.ts b/ui/src/pages/nodes/view.types.ts index a33cb2f15b8..9234f632f05 100644 --- a/ui/src/pages/nodes/view.types.ts +++ b/ui/src/pages/nodes/view.types.ts @@ -3,11 +3,13 @@ import type { DevicePairingList, ExecApprovalsFile, ExecApprovalsSnapshot, + InventoryRemovalRequest, } from "../../lib/nodes/index.ts"; export type NodesProps = { loading: boolean; nodes: Array>; + lastError: string | null; devicesLoading: boolean; devicesError: string | null; devicesList: DevicePairingList | null; @@ -26,12 +28,15 @@ export type NodesProps = { execApprovalsTarget: "gateway" | "node"; execApprovalsTargetNodeId: string | null; onRefresh: () => void; - onDevicesRefresh: () => void; onDevicePairSetupOpen: () => void; onDeviceApprove: (requestId: string) => void; onDeviceReject: (requestId: string) => void; onDeviceRotate: (deviceId: string, role: string, scopes?: string[]) => void; onDeviceRevoke: (deviceId: string, role: string) => void; + onNodeApprove: (requestId: string) => void; + onNodeReject: (requestId: string) => void; + onInventoryRemove: (entry: InventoryRemovalRequest) => void; + onInventoryCleanup: (entries: InventoryRemovalRequest[]) => void; onLoadConfig: () => void; onLoadExecApprovals: () => void; onBindDefault: (nodeId: string | null) => void; diff --git a/ui/src/styles.css b/ui/src/styles.css index e2de8f52349..5e2dd23e45e 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -7,6 +7,7 @@ @import "./styles/config.css"; @import "./styles/config-quick.css"; @import "./styles/cron-quick-create.css"; +@import "./styles/nodes.css"; @import "./styles/sessions.css"; @import "./styles/usage.css"; @import "./styles/profile.css"; diff --git a/ui/src/styles/nodes.css b/ui/src/styles/nodes.css new file mode 100644 index 00000000000..f848c19c875 --- /dev/null +++ b/ui/src/styles/nodes.css @@ -0,0 +1,54 @@ +/* Nodes page: unified paired-device / node inventory. */ + +.nodes-entry__head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.nodes-entry__head .chip { + padding: 2px 10px; +} + +.nodes-entry__details { + margin-top: 8px; +} + +.nodes-entry__details > summary { + cursor: pointer; + font-size: 12px; + color: var(--muted); + user-select: none; +} + +.nodes-entry__details > summary:hover { + color: var(--fg); +} + +.nodes-group { + display: flex; + flex-direction: column; +} + +.nodes-group__dups { + margin: 4px 0 8px; + padding-left: 14px; + border-left: 2px solid var(--border); +} + +.nodes-group__dups > summary { + cursor: pointer; + font-size: 12px; + color: var(--muted); + padding: 4px 0; + user-select: none; +} + +.nodes-group__dups > summary:hover { + color: var(--fg); +} + +.nodes-group__dups .list-item { + opacity: 0.75; +}