From 2bb42fc3af9b7315b50a59cccfecd8fc77c2b08c Mon Sep 17 00:00:00 2001 From: Jared Ping <13906915+JaredP94@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:34:14 +0200 Subject: [PATCH 1/3] fix(desktop): preserve canonical peer record identities Mirror DirectoryNode.RecordKey exactly in one shared helper and a fixture consumed by TypeScript and Go tests. Drop invalid records, isolate previews and manual configurations from claimed host UUIDs, and let broker discovery own record lifetime instead of collapsing by address, name or age. Proxy presence can enrich established peers but cannot create or resurrect identities. Label unverified machines and address-added engines, start existing PIN pairing by address, and keep peer controls and telemetry out of preview and manual cards. Normalize renderer collections and preserve peer-keyed membership and workload attribution. Align TUI wording and document mixed-version behavior without guessing an upgrade diagnosis. Shared type additions are optional and no Go wire payload changes. Keep the already-unreleased TUI 1.2.1 patch bump relative to v0.8.0. Signed-off-by: Jared Ping <13906915+JaredP94@users.noreply.github.com> --- desktop/README.md | 21 ++ desktop/docs/services-parity.md | 48 +-- .../electron/service-bridge/modular-state.ts | 172 +++------- .../src/shared/constants/modular-runtime.ts | 16 - .../src/shared/fixtures/node-record-keys.json | 120 +++++++ desktop/src/shared/types/cluster.ts | 10 +- desktop/src/shared/types/nodes.ts | 4 +- desktop/src/shared/utils/node-record-key.ts | 36 ++ .../ClusterSettings/AvailableNode.tsx | 34 +- .../components/NodeList/NodeCardDetails.tsx | 6 + .../src/ui/components/NodeList/NodeList.tsx | 3 +- .../ui/components/NodeList/UnverifiedNode.tsx | 14 + .../src/ui/components/TrayApp/TrayNodeRow.tsx | 18 +- desktop/src/ui/hooks/useInvitablePeers.ts | 19 +- .../src/ui/stores/discovered-nodes.store.ts | 14 +- desktop/src/ui/stores/nodes.store.ts | 16 +- desktop/src/ui/utils/overview-nodes.ts | 28 +- ...NodePerformance-radial-TrayNodeRow.test.ts | 2 + .../modular/engine-capability-gates.test.ts | 42 +++ desktop/tests/modular/epoch-resync.test.ts | 2 + desktop/tests/modular/first-launch.test.ts | 2 + .../tests/modular/ghost-node-dedupe.test.ts | 317 ++++-------------- .../inference-metrics-rendering.test.ts | 4 + .../modular/lmstudio-stale-model.test.ts | 16 + .../modular/modular-state-gpu-metrics.test.ts | 11 +- .../modular/modular-state-sensors.test.ts | 24 +- .../tests/modular/node-address-rerank.test.ts | 6 + .../tests/modular/node-info-self-poll.test.ts | 4 + desktop/tests/modular/node-record-key.test.ts | 12 + desktop/tests/modular/node-sensors.test.ts | 2 + .../modular/peer-verification-keying.test.ts | 89 +++++ .../peer-verification-rendering.test.ts | 195 +++++++++++ .../remote-engine-install-state.test.ts | 24 ++ .../modular/remote-engine-status.test.ts | 14 + .../tests/modular/uuid-node-keying.test.ts | 13 +- services/nvpair-tui/README.md | 5 + .../nvpair-tui/ui/node_provenance_test.go | 7 +- services/nvpair-tui/ui/nodes.go | 13 +- .../shared/noderec/record_key_fixture_test.go | 44 +++ 39 files changed, 954 insertions(+), 473 deletions(-) create mode 100644 desktop/src/shared/fixtures/node-record-keys.json create mode 100644 desktop/src/shared/utils/node-record-key.ts create mode 100644 desktop/src/ui/components/NodeList/UnverifiedNode.tsx create mode 100644 desktop/tests/modular/node-record-key.test.ts create mode 100644 desktop/tests/modular/peer-verification-keying.test.ts create mode 100644 desktop/tests/modular/peer-verification-rendering.test.ts create mode 100644 services/shared/noderec/record_key_fixture_test.go diff --git a/desktop/README.md b/desktop/README.md index 91fd18046..d745f53a5 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -32,6 +32,27 @@ supervises up to 12 workers. The service bundle contains 14 executables, includi broker, scanner, Ollama proxy, node-info, and node-settings; standalone broker requirements are narrower. +## Node identity and verification + +Desktop node ids mirror Go's `DirectoryNode.RecordKey()`: local and verified +records use `hostUuid` (reserved `preview:` / `manual:` principals are invalid); +all other records need a namespaced `peerRecordId`. Invalid records are dropped. +The shared TypeScript helper and Go test consume the same record-key fixture. +A verified `peerRecordId` is a proof binding, not a host id. + +Broker discovery owns identity and lifetime. Proxy events only enrich established +peer records; matching names, addresses, timestamps, or claimed UUIDs never merge +records. Membership, workload attribution, metrics, and engine actions use the +canonical record key. Priorities and routing selection remain service-owned; +the desktop exposes no scheduler-priority or proxy-selection action. + +Previews show **Not in your cluster** and offer **Pair** by address through the +existing PIN exchange. Manual engines show **Engine only — added by address**, +without PAIR identity, peer telemetry, or peer controls. Local and verified peer +cards retain their existing controls. Older peers remain unverified until the +services can verify them; the payload does not identify why proof is unavailable, +so the UI does not guess that an update is needed. + ## Develop ```bash diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index ad6652fc3..de5aa0986 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -59,20 +59,18 @@ allows the user to restart the service. Personal AI Router consumes `discovery:get-nodes` and `discovery:nodes-changed` from the broker. The bridge: -- keys every node by the backend's stable per-host UUID (`AvailableNode.hostUuid`), - the same identity workloads (`originatedFrom`/`scheduledOn`), errors (`nodeId`), - proxy routing (`Node.ID`), `/v1/node-info` (`hostUuid`), and cluster membership - (`ClusterNode.nodeUuid`) all use; the hostname is display only; +- mirrors Go `DirectoryNode.RecordKey()`: local/verified records use `hostUuid` + unless reserved; all others require a `preview:` / `manual:` `peerRecordId`. + Invalid keys are dropped. Proof bindings are not host ids, and unverified + claimed UUIDs and display names never correlate with peer records; - preserves the backend's canonical reachable address; -- reflects the discovery `trusted` (locally pinned peer) and `clustered` (belongs - to some cluster) flags, and uses `clustered` to mark an already-clustered - discovered node non-invitable. `trusted` is a display annotation the scanner - re-derives whenever the cluster-manager reports a pin-set change, and again on - its periodic sweep of each peer's reported membership; it is not the routing - gate. Each proxy answers "do I hold a pin for this peer?" from its own - mesh when it resolves candidates, so PAIR must never treat `trusted` as - authoritative for whether work can reach a node; -- merges per-engine proxy presence; +- reflects broker-reported `identityVerification`, `peerRecordId`, `trusted`, + and `clustered`, without establishing trust in TypeScript. Previews show + **Not in your cluster** with **Pair** by address using the existing PIN flow; + public cluster claims do not suppress that action. Manual engines show + **Engine only — added by address** and no Pair action; +- merges per-engine proxy presence only into broker-established peer identities. + Proxy events cannot create or resurrect records after broker withdrawal; - carries flat and per-engine remote model inventories; - emits node and discovery pushes to the renderer. @@ -81,7 +79,12 @@ at startup and resolved as soon as the broker is ready, so self is identified by the same UUID key rather than a hostname guess. The broker discovery surface does not include full dynamic telemetry, so -Electron polls `/v1/node-info` for CPU, system memory, and per-adapter GPU values. +Electron polls `/v1/node-info` for CPU, system memory, and per-adapter GPU values +only for local/verified records. Preview/manual cards never mount peer telemetry, +engine controls, or workload-targeting UI. Older peers are unverified until the +services can prove their identity; no update diagnosis is inferred from missing +metadata. Missing/invalid record keys are dropped rather than shown under a +claimed UUID. Membership and workload UUIDs only resolve peer-keyed records. Go classifies each GPU's `memory_kind`; the bridge preserves it as `memoryKind` without name-based inference. Dedicated, unified, shared, and unavailable memory @@ -156,9 +159,10 @@ Personal AI Router consequences (all reflection, no security implementation): - The proxy `node/*` presence port is the peer's promoted proxy port. Personal AI Router surfaces it as `EngineStatusData.proxyPort` and never labels it as the engine port; a remote engine's port comes only from `ec` facts. -- Because both peers must speak the mTLS channel, mixed-version clusters cannot - run inference across the version boundary. Local use and the shared - nearby-model list are unaffected. +- Older peers without authenticated service metadata stay unverified previews, + not routing/model-inventory peers. Local engine use is unaffected. The UI + cannot distinguish an old peer from another unverified PAIR machine and does + not infer an upgrade recommendation from that state alone. ## Engine lifecycle @@ -458,16 +462,14 @@ provide an equivalent client-facing contract: | Serve the model hub (Ollama committed list, LM Studio live) | `src/electron/model-hub/` | | Accumulate and reconcile receiver-side pending invites | `modular-state.ts`, `modular-supervisor.ts` | | Mirror backend-coupled runtime defaults not yet reported | `modular-runtime.ts` | -| Collapse a superseded node row before the scanner proves it | `modular-state.ts`, `modular-runtime.ts` | These are current integration boundaries. Do not add compatibility shims or duplicate backend services around them. -The last row is a display-side collapse, not a directory decision. The scanner -treats a matching address, hostname, and `lastSeen` gap as grounds to ask the -machine who it is, and evicts only on a node-info identity mismatch; the bridge -has no such proof and hides the row on the suspicion alone. It must never be -extended into anything a consumer reads as an eviction. +Node identity and lifetime belong to broker discovery. The desktop does not +collapse rows by address, name, or age; independent records stay visible until +the broker withdraws them. The shared TypeScript key helper and Go +`DirectoryNode.RecordKey()` consume one table-driven fixture. ## Unused backend methods diff --git a/desktop/src/electron/service-bridge/modular-state.ts b/desktop/src/electron/service-bridge/modular-state.ts index 10b7b0145..70ee008e9 100644 --- a/desktop/src/electron/service-bridge/modular-state.ts +++ b/desktop/src/electron/service-bridge/modular-state.ts @@ -33,6 +33,7 @@ import type { } from '@/shared/types/metrics' import type { GpuMemoryKind } from '@/shared/types/hardware' import type { NodeItem } from '@/shared/types/nodes' +import { isPairPeer, nodeRecordKey, type NodeRecordIdentity } from '@/shared/utils/node-record-key' import type { ServiceError, ServiceErrorAction, ServiceErrorSeverity } from '@/shared/types/errors' import type { Workload, @@ -44,10 +45,7 @@ import type { import type { WorkerState, WorkerStatus } from '@/shared/types/workers' import type { ClusterNode, Invite } from '@/shared/types/cluster' import type { WsInvokeResponse } from '@/shared/types/ws-channels' -import { - MODULAR_NODE_INFO_SELF_HOST, - MODULAR_SUPERSEDE_MIN_AGE_MS -} from '@/shared/constants/modular-runtime' +import { MODULAR_NODE_INFO_SELF_HOST } from '@/shared/constants/modular-runtime' import { WorkloadStates } from '@/shared/constants/workloads' import { isEngineType, @@ -131,13 +129,10 @@ interface RemoteEngineFacts { capabilities: EngineCapabilityFlags } -interface ModularNode { - // Canonical cross-domain node key = the backend's stable per-host UUID - // (`shared/nodeid`). The broker carries it on `AvailableNode.hostUuid`, the - // proxies key `Node.ID` by it, and `/v1/node-info` reports it, so every - // domain now agrees on this id: workload `originatedFrom`/`scheduledOn`, - // error `nodeId`, and proxy routing all resolve against it. Never key, - // dedupe, or attribute by hostname — that is display only (`name`). +interface ModularNode extends NodeRecordIdentity { + // Canonical Go RecordKey: hostUuid for local/verified peers, opaque + // preview:/manual: key otherwise. Claimed UUIDs and names never correlate + // unverified records with membership, workloads, metrics or proxy presence. id: string sources: BrokerNodeSource[] // Display hostname (broker `AvailableNode.name` / proxy `Node.Host`). Shown @@ -1165,6 +1160,9 @@ function toNodeItem(node: ModularNode, selfId: string | null): NodeItem { const port = primaryServicePort(node) return { id: node.id, + hostUuid: node.hostUuid, + peerRecordId: node.peerRecordId, + identityVerification: node.identityVerification, // Display label: the hostname, else the reachable address; never the raw // UUID key (falls back to it only if a node somehow has neither). name: node.name || ipAddress || node.id, @@ -1277,8 +1275,8 @@ function toEngineStatus( function parseProxyNode(params: JsonValue | undefined, engine: ProxyEngine): ModularNode | null { const obj = objectValue(params) if (!obj) return null - // The proxy keys `Node.ID` by the stable per-host UUID (and rejects empty), - // so this is already the canonical node key — no TXT parsing needed. + // This feed carries presence, not provenance. Its id may only look up an + // identity already established by broker discovery; never parse TXT as proof. const id = stringValue(obj.id) if (!id) return null const engines = emptyEngines() @@ -1334,11 +1332,13 @@ function parseProxyNode(params: JsonValue | undefined, engine: ProxyEngine): Mod function parseBrokerNode(params: JsonValue | undefined): ModularNode | null { const obj = objectValue(params) if (!obj) return null - // `AvailableNode.hostUuid` is the canonical node key. The broker's discovery - // store rejects an empty UUID, so every node it reports carries one; a - // payload without it is malformed and dropped rather than keyed by hostname. - const hostUuid = stringValue(obj.hostUuid) - if (!hostUuid) return null + const identity = { + hostUuid: stringValue(obj.hostUuid), + peerRecordId: stringValue(obj.peerRecordId), + identityVerification: stringValue(obj.identityVerification) + } + const id = nodeRecordKey(identity) + if (!id) return null // `AvailableNode.id`/`.name` are the mDNS instance hostname (display only). const hostname = stringValue(obj.name) || stringValue(obj.id) const ipAddress = stringValue(obj.ipAddress) || stringValue(obj.ip_address) @@ -1348,7 +1348,8 @@ function parseBrokerNode(params: JsonValue | undefined): ModularNode | null { const ipAddresses = stringArrayValue(obj.ipAddresses) const brokerAddresses = ipAddresses.length > 0 ? ipAddresses : ipAddress ? [ipAddress] : [] return { - id: hostUuid, + id, + ...identity, sources: ['broker'], name: hostname, host: ipAddress, @@ -1422,6 +1423,9 @@ function sameEngines( function sameNode(left: ModularNode, right: ModularNode): boolean { return ( left.id === right.id && + left.hostUuid === right.hostUuid && + left.peerRecordId === right.peerRecordId && + left.identityVerification === right.identityVerification && sameStringList(left.sources, right.sources) && left.name === right.name && left.host === right.host && @@ -1526,6 +1530,8 @@ class ModularBridgeState { } private nodes = new Map() + // Proxy events carry no provenance. Cache presence only; they cannot create identities. + private proxyNodes = new Map>() private brokerNodeIds = new Set() private errors: ServiceError[] = [] private workers: WorkerStatus[] = [] @@ -1768,7 +1774,7 @@ class ModularBridgeState { // over the inferred presence, and is omitted entirely when neither is // known — see {@link resolveRemoteEngineStatus}. for (const node of Array.from(this.nodes.values())) { - if (node.id === selfId) continue + if (node.id === selfId || !isPairPeer(node)) continue for (const engine of PROXY_ENGINES) { const status = this.resolveRemoteEngineStatus(node.id, engine) if (status) statuses.push(status) @@ -1789,6 +1795,9 @@ class ModularBridgeState { const addresses = nodeAddresses(node) return { id: node.id, + hostUuid: node.hostUuid, + peerRecordId: node.peerRecordId, + identityVerification: node.identityVerification, // Display label: hostname, else reachable address; never the raw UUID. name: node.name || primaryNodeAddress(node) || node.id, ipAddress: primaryNodeAddress(node), @@ -1808,7 +1817,7 @@ class ModularBridgeState { getNodeInfoPollTargets(): { id: string; hosts: string[]; port: number }[] { const targets: { id: string; hosts: string[]; port: number }[] = [] for (const node of Array.from(this.nodes.values())) { - if (!node.nodeInfoUp || node.nodeInfoPort <= 0) continue + if (!isPairPeer(node) || !node.nodeInfoUp || node.nodeInfoPort <= 0) continue // This machine appears in its own discovery snapshot, but its own // node-info is on this machine: loopback reaches it without depending // on the host's inbound path. Loopback alone, with no failover — the @@ -1836,7 +1845,7 @@ class ModularBridgeState { mergeNodeInfoResponse(nodeId: string, response: JsonValue): void { const node = this.nodes.get(nodeId) - if (!node) return + if (!node || !isPairPeer(node)) return const obj = objectValue(response) if (!obj) return @@ -3070,6 +3079,7 @@ class ModularBridgeState { if (notification.method === 'node/removed') { const node = parseProxyNode(notification.params, engine) if (!node) return + this.proxyNodes.get(engine)?.delete(node.id) this.clearNodeEngine(node.id, engine) return } @@ -3077,7 +3087,17 @@ class ModularBridgeState { if (notification.method === 'node/discovered' || notification.method === 'node/updated') { const node = parseProxyNode(notification.params, engine) if (!node) return - this.upsertNode(node, proxyEngineToSource(engine)) + let entries = this.proxyNodes.get(engine) + if (!entries) { + entries = new Map() + this.proxyNodes.set(engine, entries) + } + entries.set(node.id, node) + // Only the broker establishes record identity, including after withdrawal. + const existing = this.nodes.get(node.id) + if (existing && isPairPeer(existing)) { + this.upsertNode(node, proxyEngineToSource(engine)) + } } } @@ -3131,33 +3151,7 @@ class ModularBridgeState { const existing = this.nodes.get(nodeId) if (!existing) return - const remainingSources = removeSource(existing.sources, 'broker') - if (remainingSources.length === 0) { - this.removeNodeEntry(nodeId) - return - } - - // A proxy source still reports the node; drop only the node-info snapshot - // (the broker is the sole node-info / telemetry source now). - const next: ModularNode = { - ...existing, - sources: remainingSources, - nodeInfoPort: 0, - nodeInfoUp: false, - // Clear the broker's now-stale canonical address and ranking; a - // still-present proxy source repopulates `reachableAddress` from its - // `ip` field on the next node/* event, so we never keep a dead broker - // IP — nor project addresses no live source claims any more. - reachableAddress: '', - brokerAddresses: [], - gpus: [], - cpu: null, - memory: null, - sensors: null, - lastSeen: Date.now() - } - this.nodes.set(next.id, next) - this.emitNodeChanged(next) + this.removeNodeEntry(nodeId) } private upsertNode(node: ModularNode, source?: BrokerNodeSource): void { @@ -3165,12 +3159,12 @@ class ModularBridgeState { // (`cluster:get-node-id`), which equals the discovery/proxy hostUuid key, // so there is no hostname guessing here — `setSelfId` is the sole source. const existing = this.nodes.get(node.id) - const merged = this.mergeNode(existing, node, source) - // Collapse a wiped-and-rejoined host onto its new UUID before storing, - // so the pre-wipe record never reaches the map (see resolveAddressCollision). - if (this.resolveAddressCollision(merged)) { - this.removeNodeEntry(merged.id) - return + let merged = this.mergeNode(existing, node, source) + if (source === 'broker' && isPairPeer(merged)) { + for (const engine of PROXY_ENGINES) { + const presence = this.proxyNodes.get(engine)?.get(merged.id) + if (presence) merged = this.mergeNode(merged, presence, proxyEngineToSource(engine)) + } } if (existing && sameNode(existing, merged)) return this.nodes.set(merged.id, merged) @@ -3206,66 +3200,6 @@ class ModularBridgeState { } } - /** - * Collapse two UUIDs that are really one machine. - * - * A node whose appdata is wiped mints a fresh hostUuid, so peers advertise - * the same machine under a new key while the pre-wipe key survives — the - * fleet sees a duplicate "ghost" row. Nothing on the wire marks the two as - * the same host, so this infers it from the three signals the node-scanner - * uses to nominate a superseded record (`directory.supersedeCandidates`): - * same address, same hostname, and a `lastSeen` gap of at least - * MODULAR_SUPERSEDE_MIN_AGE_MS. - * - * It stops there, and the scanner does not. Those signals come from an - * unauthenticated mDNS advertisement, and `lastSeen` is not a liveness - * clock — the backend re-stamps a node only when its record CHANGES, so a - * healthy peer's `lastSeen` freezes exactly like a ghost's. The scanner - * therefore treats them as a suspicion and deletes only once node-info - * names a different host. This has no such proof available, so it must stay - * a display-side collapse: it hides a row until the next snapshot, and must - * never be read as the directory having evicted anything. - * - * Two caveats on how faithfully this can mirror the backend, both stemming - * from a proxy-sourced node carrying less than a discovery record does: - * `parseProxyNode` stamps `lastSeen` locally rather than echoing the - * backend's, and `primaryNodeAddress` falls back to `host` (a hostname for a - * proxy node), which can collapse the address and name conditions into one. - * Both only ever make this rule fire LESS often than the scanner's, so the - * node list stays a subset of the directory rather than contradicting it. - * - * Returns true when `node` is the STALE side and must not be stored — a - * backend that has not yet evicted the ghost keeps re-publishing it in every - * `discovery:nodes-changed` snapshot, so refusing it on arrival is what - * makes the eviction stick. Otherwise the losing entries are evicted here. - */ - private resolveAddressCollision(node: ModularNode): boolean { - const address = primaryNodeAddress(node) - if (!address || !node.name) return false - const rivals: ModularNode[] = [] - for (const other of this.nodes.values()) { - if (other.id === node.id || other.id === this.selfId) continue - if (primaryNodeAddress(other) !== address || other.name !== node.name) continue - rivals.push(other) - } - // Self is pinned by the cluster-manager's node UUID, so it is always the - // live identity for this machine's address: skipped above so it can - // never be evicted, and skipped as a claimant below so it never evicts. - // The backend applies the same asymmetry (directory.supersedeCandidates). - if (node.id === this.selfId) return false - // Decide before mutating. Returning mid-loop would strand entries - // already marked stale, leaving a partially applied collapse. - if (rivals.some(other => other.lastSeen - node.lastSeen >= MODULAR_SUPERSEDE_MIN_AGE_MS)) { - return true - } - for (const other of rivals) { - if (node.lastSeen - other.lastSeen >= MODULAR_SUPERSEDE_MIN_AGE_MS) { - this.removeNodeEntry(other.id) - } - } - return false - } - /** Delete a node and clean every index that referenced it. */ private removeNodeEntry(nodeId: string): void { if (!this.nodes.delete(nodeId)) return @@ -3277,13 +3211,14 @@ class ModularBridgeState { private emitNodeChanged(merged: ModularNode): void { emitBridgePush('nodes:upsert', toNodeItem(merged, this.selfId)) - emitBridgePush('metrics:update', toMetrics(merged)) + if (isPairPeer(merged)) emitBridgePush('metrics:update', toMetrics(merged)) emitBridgePush('discovery:nodes-changed', this.getAvailableNodes()) // For the local node, engine-manager is the source of truth for // install/running state; discovery only fills in models. A remote node // has no local engine-manager, so its status comes from authoritative // peer facts or its advertisement, and is omitted when neither is known. // Push per proxy-engine (Ollama + LM Studio) so both light up per node. + if (!isPairPeer(merged)) return const isSelf = merged.id === this.selfId for (const engine of PROXY_ENGINES) { if (!isSelf) { @@ -3348,6 +3283,9 @@ class ModularBridgeState { // The proxy feed carries no hostname/trust/cluster facts; keep the // broker-reported values rather than overwriting them with the proxy // node's empty name and default-false flags. + hostUuid: existing.hostUuid, + peerRecordId: existing.peerRecordId, + identityVerification: existing.identityVerification, name: existing.name || next.name, trusted: existing.trusted, clustered: existing.clustered, diff --git a/desktop/src/shared/constants/modular-runtime.ts b/desktop/src/shared/constants/modular-runtime.ts index 7705728bd..248dc532a 100644 --- a/desktop/src/shared/constants/modular-runtime.ts +++ b/desktop/src/shared/constants/modular-runtime.ts @@ -84,19 +84,3 @@ export const MODULAR_MODEL_ACTION_TIMEOUT_MS = 120_000 // Also drives the pending-invite reconciliation sweep, the backstop for a missed // receiver-side `cluster:invite-canceled` / `cluster:invite-expired` push. export const MODULAR_INVITE_STATUS_POLL_INTERVAL_MS = 1_500 - -// Minimum `lastSeen` gap before one node record is treated as having superseded -// another that shares its address and hostname — a machine whose appdata was -// wiped rejoins under a fresh hostUuid and would otherwise show twice. -// -// Matches `supersedeMinAge` in `services/nvpair-node-scanner/directory.go` -// (300 seconds), but note the two are no longer the same rule. There, address, -// hostname and age only NOMINATE a record: they come from an unauthenticated -// mDNS advertisement, and a healthy peer's `lastSeen` freezes exactly as a -// ghost's does, so the scanner deletes nothing until node-info names a -// different host. The bridge collapses on the nomination alone, which is why -// this stays a display-side concern — it hides a row, it does not evict from -// the directory the TUI, the CLI, and every other consumer read. Hardcoded -// pending backend exposure — see -// docs/services-parity.md#current-client-side-responsibilities -export const MODULAR_SUPERSEDE_MIN_AGE_MS = 300_000 diff --git a/desktop/src/shared/fixtures/node-record-keys.json b/desktop/src/shared/fixtures/node-record-keys.json new file mode 100644 index 000000000..3b08a8345 --- /dev/null +++ b/desktop/src/shared/fixtures/node-record-keys.json @@ -0,0 +1,120 @@ +{ + "_copyright": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.", + "_license": "SPDX-License-Identifier: Apache-2.0", + "cases": [ + { + "name": "local without proof binding", + "hostUuid": "self", + "identityVerification": "local", + "want": "self" + }, + { + "name": "verified binding is not the key", + "hostUuid": "peer", + "identityVerification": "verified", + "peerRecordId": "proof:binding", + "want": "peer" + }, + { + "name": "verified without binding still has a record key", + "hostUuid": "peer", + "identityVerification": "verified", + "want": "peer" + }, + { + "name": "local reserved preview principal", + "hostUuid": "preview:claim", + "identityVerification": "local", + "want": "" + }, + { + "name": "local reserved manual principal", + "hostUuid": "manual:claim", + "identityVerification": "local", + "want": "" + }, + { + "name": "verified reserved preview principal", + "hostUuid": "preview:claim", + "identityVerification": "verified", + "peerRecordId": "proof", + "want": "" + }, + { + "name": "verified reserved manual principal", + "hostUuid": "manual:claim", + "identityVerification": "verified", + "peerRecordId": "proof", + "want": "" + }, + { + "name": "empty proven principal", + "hostUuid": "", + "identityVerification": "verified", + "peerRecordId": "preview:claim", + "want": "" + }, + { + "name": "preview claiming peer", + "hostUuid": "peer", + "identityVerification": "unverified", + "peerRecordId": "preview:claim", + "want": "preview:claim" + }, + { + "name": "manual claiming peer", + "hostUuid": "peer", + "identityVerification": "manual", + "peerRecordId": "manual:config", + "want": "manual:config" + }, + { + "name": "missing metadata", + "hostUuid": "peer", + "want": "" + }, + { + "name": "old peer preview", + "hostUuid": "peer", + "peerRecordId": "preview:old", + "want": "preview:old" + }, + { + "name": "future state with preview key", + "hostUuid": "peer", + "identityVerification": "future", + "peerRecordId": "preview:future", + "want": "preview:future" + }, + { + "name": "unverified proof is invalid", + "hostUuid": "peer", + "identityVerification": "unverified", + "peerRecordId": "proof:binding", + "want": "" + }, + { + "name": "manual without opaque key", + "hostUuid": "peer", + "identityVerification": "manual", + "want": "" + }, + { + "name": "case sensitive prefixes", + "hostUuid": "peer", + "identityVerification": "unverified", + "peerRecordId": "Preview:claim", + "want": "" + }, + { + "name": "bare reserved preview namespace", + "peerRecordId": "preview:", + "want": "preview:" + }, + { + "name": "bare reserved manual namespace", + "peerRecordId": "manual:", + "want": "manual:" + } + ] +} diff --git a/desktop/src/shared/types/cluster.ts b/desktop/src/shared/types/cluster.ts index e02daa241..7aa73fd20 100644 --- a/desktop/src/shared/types/cluster.ts +++ b/desktop/src/shared/types/cluster.ts @@ -10,8 +10,10 @@ * interactive PIN-pairing invite handshake. */ -export interface AvailableNode { - /** Canonical node key = the backend's stable per-host UUID (matches `NodeItem.id`). */ +import type { NodeRecordIdentity } from '@/shared/utils/node-record-key' + +export interface AvailableNode extends NodeRecordIdentity { + /** Canonical Go record key (matches NodeItem.id); never an unverified claimed UUID. */ id: string /** Display hostname. */ name: string @@ -29,7 +31,7 @@ export interface AvailableNode { ipAddresses?: string[] port: number lastSeen: number - /** Whether this node is already a locally-trusted (pinned) peer. */ + /** Broker-reported identity proof over current pinned cluster mTLS. */ trusted: boolean /** * Whether this node already belongs to a cluster. An already-clustered node @@ -53,7 +55,7 @@ export interface ClusterNode { /** * Stable cryptographic identity; the trusted-node-store key and cert subject. * This is the cross-domain correlation key — it equals a discovered node's - * hostUuid / `NodeItem.id` / `selfId`, so member↔node matching keys on this, + * hostUuid / `NodeItem.id` for proven peers / `selfId`, so matching keys on this, * never `id` (hostname). */ nodeUuid: string diff --git a/desktop/src/shared/types/nodes.ts b/desktop/src/shared/types/nodes.ts index 551c11adb..638ae1b17 100644 --- a/desktop/src/shared/types/nodes.ts +++ b/desktop/src/shared/types/nodes.ts @@ -6,9 +6,11 @@ import { SystemTopology } from '@/shared/types/hardware' import type { EngineNativeView } from '@/shared/types/metrics' import { PlatformDisplayName } from '@/shared/types/platform' +import type { NodeRecordIdentity } from '@/shared/utils/node-record-key' + export type NodeStatus = (typeof NodeStatuses)[number] -export interface NodeItem { +export interface NodeItem extends NodeRecordIdentity { id: string name: string status: NodeStatus diff --git a/desktop/src/shared/utils/node-record-key.ts b/desktop/src/shared/utils/node-record-key.ts new file mode 100644 index 000000000..61029b366 --- /dev/null +++ b/desktop/src/shared/utils/node-record-key.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Broker-owned provenance. A host UUID in an unverified record is only a claim. */ +export interface NodeRecordIdentity { + hostUuid?: string + peerRecordId?: string + identityVerification?: string +} + +/** Mirrors services/shared/noderec/noderec.go DirectoryNode.RecordKey exactly. */ +export function nodeRecordKey(record: NodeRecordIdentity): string { + if (record.identityVerification === 'local' || record.identityVerification === 'verified') { + const hostUuid = record.hostUuid ?? '' + return hostUuid.startsWith('preview:') || hostUuid.startsWith('manual:') ? '' : hostUuid + } + const peerRecordId = record.peerRecordId ?? '' + return peerRecordId.startsWith('preview:') || peerRecordId.startsWith('manual:') + ? peerRecordId + : '' +} + +/** Peer-only presentation/actions; this renders provenance, it never establishes trust. */ +export function isPairPeer(record: NodeRecordIdentity): boolean { + return ( + nodeRecordKey(record) !== '' && + (record.identityVerification === 'local' || record.identityVerification === 'verified') + ) +} + +export function nodeVerificationLabel(record: NodeRecordIdentity): string { + if (isPairPeer(record)) return '' + return record.identityVerification === 'manual' || nodeRecordKey(record).startsWith('manual:') + ? 'Engine only — added by address' + : 'Not in your cluster' +} diff --git a/desktop/src/ui/components/ClusterSettings/AvailableNode.tsx b/desktop/src/ui/components/ClusterSettings/AvailableNode.tsx index e5b957909..f123aacaa 100644 --- a/desktop/src/ui/components/ClusterSettings/AvailableNode.tsx +++ b/desktop/src/ui/components/ClusterSettings/AvailableNode.tsx @@ -4,10 +4,13 @@ import { Button, Flex, Stack, Text } from '@nvidia/foundations-react-core' import type { AvailableNode as AvailableNodeType } from '@/shared/types/cluster' import { InvitePairingPanel } from '@/ui/components/InvitePairingPanel' +import { nodeRecordKey, nodeVerificationLabel } from '@/shared/utils/node-record-key' import { useInvitePairing } from '@/ui/hooks/useInvitePairing' export default function AvailableNode({ node }: { node: AvailableNodeType }) { const pairing = useInvitePairing() + const canPair = + node.identityVerification !== 'manual' && nodeRecordKey(node).startsWith('preview:') const showPairing = pairing.invite !== null || pairing.error !== null const inviteInFlight = pairing.submitting || pairing.invite?.state === 'pending' @@ -27,22 +30,25 @@ export default function AvailableNode({ node }: { node: AvailableNodeType }) { {(node.name || node.ipAddress).toUpperCase()} - {node.clustered - ? 'In another cluster' - : `${node.ipAddress}:${node.port}`} + {nodeVerificationLabel(node)} + + + {node.ipAddress} - + {canPair && ( + + )} )} diff --git a/desktop/src/ui/components/NodeList/NodeCardDetails.tsx b/desktop/src/ui/components/NodeList/NodeCardDetails.tsx index 9793dcea0..431a4f91c 100644 --- a/desktop/src/ui/components/NodeList/NodeCardDetails.tsx +++ b/desktop/src/ui/components/NodeList/NodeCardDetails.tsx @@ -5,6 +5,8 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, Flex, Stack } from '@nvidia/foundations-react-core' import { BarChartOutlined, SettingsOutlined, Thermometer } from '@/ui/components/icons' import { DismissibleTooltip } from '@/ui/components/DismissibleTooltip/DismissibleTooltip' +import { isPairPeer } from '@/shared/utils/node-record-key' +import UnverifiedNode from './UnverifiedNode' import type { NodeItem } from '@/shared/types/nodes' import { useMetricsStore } from '@/ui/stores/metrics.store' import { useConnectionStore } from '@/ui/stores/connection.store' @@ -35,6 +37,10 @@ interface NodeCardDetailsProps { } function NodeCardDetails({ node }: NodeCardDetailsProps) { + return isPairPeer(node) ? : +} + +function PeerNodeCardDetails({ node }: NodeCardDetailsProps) { const nodeMetrics = useMetricsStore(state => state.nodeMetrics.get(node.id)) const sensors = nodeMetrics?.sensors ?? null const [isSensorsExpanded, setIsSensorsExpanded] = useState(false) diff --git a/desktop/src/ui/components/NodeList/NodeList.tsx b/desktop/src/ui/components/NodeList/NodeList.tsx index ccaeb8c05..43cbfca7b 100644 --- a/desktop/src/ui/components/NodeList/NodeList.tsx +++ b/desktop/src/ui/components/NodeList/NodeList.tsx @@ -6,6 +6,7 @@ import { memo, useMemo } from 'react' import { Stack } from '@nvidia/foundations-react-core' import { OverlayScrollbarsComponent } from 'overlayscrollbars-react' import type { PartialOptions } from 'overlayscrollbars' +import { isPairPeer } from '@/shared/utils/node-record-key' import type { NodeItem } from '@/shared/types/nodes' import { useOverviewNodes } from '@/ui/hooks/useOverviewNodes' import NodeCardDetails from './NodeCardDetails' @@ -23,7 +24,7 @@ function NodeList() { const on: NodeItem[] = [] const off: NodeItem[] = [] allNodes.forEach(n => { - if (n.status !== 'offline') { + if (n.status !== 'offline' || !isPairPeer(n)) { on.push(n) } else { off.push(n) diff --git a/desktop/src/ui/components/NodeList/UnverifiedNode.tsx b/desktop/src/ui/components/NodeList/UnverifiedNode.tsx new file mode 100644 index 000000000..b78d8a06c --- /dev/null +++ b/desktop/src/ui/components/NodeList/UnverifiedNode.tsx @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { NodeItem } from '@/shared/types/nodes' +import AvailableNode from '@/ui/components/ClusterSettings/AvailableNode' + +/** Preview/configuration rows never mount peer telemetry or engine controls. */ +export default function UnverifiedNode({ node }: { node: NodeItem }) { + return ( +
+ +
+ ) +} diff --git a/desktop/src/ui/components/TrayApp/TrayNodeRow.tsx b/desktop/src/ui/components/TrayApp/TrayNodeRow.tsx index 984753ca8..c7c7a8d3c 100644 --- a/desktop/src/ui/components/TrayApp/TrayNodeRow.tsx +++ b/desktop/src/ui/components/TrayApp/TrayNodeRow.tsx @@ -7,6 +7,8 @@ import { useMetricsStore } from '@/ui/stores/metrics.store' import { useConnectionStore } from '@/ui/stores/connection.store' import { useEngineStatusStore } from '@/ui/stores/engine-status.store' import { LocalBadge } from '@/ui/components/LocalBadge' +import { isPairPeer } from '@/shared/utils/node-record-key' +import UnverifiedNode from '@/ui/components/NodeList/UnverifiedNode' import type { NodeItem } from '@/shared/types/nodes' import type { Workload } from '@/shared/types/workloads' import { workloadExecutionNodeId } from '@/shared/utils/workloads' @@ -28,7 +30,13 @@ const gridStyle = { borderBottom: '1px solid var(--color-translucent-white-100)' } -function TrayNodeRow({ node, activeWorkloads }: { node: NodeItem; activeWorkloads: Workload[] }) { +function PeerTrayNodeRow({ + node, + activeWorkloads +}: { + node: NodeItem + activeWorkloads: Workload[] +}) { const nodeMetrics = useMetricsStore(state => state.nodeMetrics.get(node.id)) const isLocal = useConnectionStore(state => state.selfId === node.id) const statusByNode = useEngineStatusStore(s => s.statusByNode) @@ -152,4 +160,12 @@ function TrayNodeRow({ node, activeWorkloads }: { node: NodeItem; activeWorkload ) } +function TrayNodeRow(props: { node: NodeItem; activeWorkloads: Workload[] }) { + return isPairPeer(props.node) ? ( + + ) : ( + + ) +} + export default memo(TrayNodeRow) diff --git a/desktop/src/ui/hooks/useInvitablePeers.ts b/desktop/src/ui/hooks/useInvitablePeers.ts index 1e2fecda6..86a33bf54 100644 --- a/desktop/src/ui/hooks/useInvitablePeers.ts +++ b/desktop/src/ui/hooks/useInvitablePeers.ts @@ -3,6 +3,7 @@ import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' +import { nodeRecordKey } from '@/shared/utils/node-record-key' import type { AvailableNode } from '@/shared/types/cluster' import { useDiscoveredNodesStore } from '@/ui/stores/discovered-nodes.store' import { useClusterInvitationsStore } from '@/ui/stores/cluster-invitations.store' @@ -10,7 +11,7 @@ import { useConnectionStore } from '@/ui/stores/connection.store' /** * Discovered LAN peers that can be invited: everything the broker has discovered - * minus current cluster members minus self. + * minus manual engines, current cluster members and self (by record key). * * This is the single source for both the Add Node modal and the cluster settings * "Available Nodes to Add" list, so the two can never disagree (the modal used @@ -23,10 +24,18 @@ export function useInvitablePeers(): AvailableNode[] { const selfId = useConnectionStore(state => state.selfId) return useMemo(() => { - // Correlate on the node UUID: `AvailableNode.id` = `ClusterNode.nodeUuid` - // = `selfId`. The hostname (`ClusterNode.id`) is display only and must - // never be used to match a discovered node against a member or self. + // Only proven peer record keys correlate with member UUIDs/self. Preview + // keys remain separate even when their claimed UUID names a member. const memberUuids = new Set(members.map(m => m.nodeUuid)) - return discoveredNodes.filter(node => !memberUuids.has(node.id) && node.id !== selfId) + return discoveredNodes.filter(node => { + const key = nodeRecordKey(node) + return ( + key !== '' && + node.identityVerification !== 'manual' && + !key.startsWith('manual:') && + !memberUuids.has(key) && + key !== selfId + ) + }) }, [discoveredNodes, members, selfId]) } diff --git a/desktop/src/ui/stores/discovered-nodes.store.ts b/desktop/src/ui/stores/discovered-nodes.store.ts index 6fca39a6b..6fcfcad6e 100644 --- a/desktop/src/ui/stores/discovered-nodes.store.ts +++ b/desktop/src/ui/stores/discovered-nodes.store.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { nodeRecordKey } from '@/shared/utils/node-record-key' import { create } from 'zustand' import type { AvailableNode } from '@/shared/types/cluster' @@ -13,6 +14,15 @@ interface DiscoveredNodesStore { let unsubs: Array<() => void> = [] +function validNodes(nodes: AvailableNode[]): AvailableNode[] { + const records = new Map() + for (const node of nodes) { + const id = nodeRecordKey(node) + if (id) records.set(id, { ...node, id }) + } + return [...records.values()] +} + export const useDiscoveredNodesStore = create(set => ({ nodes: [], @@ -22,7 +32,7 @@ export const useDiscoveredNodesStore = create(set => ({ if (!window.pairApi) return unsubs.push( window.pairApi.discovery.onNodesChanged(nodes => { - set({ nodes }) + set({ nodes: validNodes(nodes) }) }) ) }, @@ -30,7 +40,7 @@ export const useDiscoveredNodesStore = create(set => ({ refresh: async () => { try { const nodes = await window.pairApi.discovery.getNodes() - set({ nodes }) + set({ nodes: validNodes(nodes) }) } catch { set({ nodes: [] }) } diff --git a/desktop/src/ui/stores/nodes.store.ts b/desktop/src/ui/stores/nodes.store.ts index e3b50f516..6de0247cd 100644 --- a/desktop/src/ui/stores/nodes.store.ts +++ b/desktop/src/ui/stores/nodes.store.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { nodeRecordKey } from '@/shared/utils/node-record-key' import { NodeItem } from '@/shared/types/nodes' import deepEqual from '@/ui/utils/deep-equal' import { create } from 'zustand' @@ -27,8 +28,9 @@ export const useNodesStore = create((set, get) => ({ const map = new Map() - for (const [id, node] of Object.entries(nodeMap)) { - map.set(id, node) + for (const node of Object.values(nodeMap)) { + const id = nodeRecordKey(node) + if (id) map.set(id, { ...node, id }) } set({ @@ -41,7 +43,10 @@ export const useNodesStore = create((set, get) => ({ if (window.pairApi) { unsubs.push( - window.pairApi.nodes.onUpsert((node: NodeItem) => { + window.pairApi.nodes.onUpsert((incoming: NodeItem) => { + const id = nodeRecordKey(incoming) + if (!id) return + const node = { ...incoming, id } const prev = get().nodes const existing = prev.get(node.id) if (existing && deepEqual(existing, node)) return @@ -67,8 +72,9 @@ export const useNodesStore = create((set, get) => ({ const map = new Map() - for (const [id, node] of Object.entries(nodeMap)) { - map.set(id, node) + for (const node of Object.values(nodeMap)) { + const id = nodeRecordKey(node) + if (id) map.set(id, { ...node, id }) } set({ diff --git a/desktop/src/ui/utils/overview-nodes.ts b/desktop/src/ui/utils/overview-nodes.ts index a28591b89..1a665158e 100644 --- a/desktop/src/ui/utils/overview-nodes.ts +++ b/desktop/src/ui/utils/overview-nodes.ts @@ -3,6 +3,7 @@ import type { ClusterNode } from '@/shared/types/cluster' import type { NodeItem } from '@/shared/types/nodes' +import { isPairPeer, nodeRecordKey } from '@/shared/utils/node-record-key' import type { PlatformDisplayName } from '@/shared/types/platform' function offlineMemberNode(member: ClusterNode): NodeItem { @@ -10,6 +11,8 @@ function offlineMemberNode(member: ClusterNode): NodeItem { // NodeItem.id is the stable node UUID (the cross-domain key); the // hostname (`member.id`/`member.name`) is display only. id: member.nodeUuid, + hostUuid: member.nodeUuid, + identityVerification: 'verified', name: member.name || member.id, status: 'offline', ipAddress: member.ipAddress, @@ -23,6 +26,8 @@ function offlineMemberNode(member: ClusterNode): NodeItem { function localPlaceholderNode(id: string, name: string, os: PlatformDisplayName): NodeItem { return { id, + hostUuid: id, + identityVerification: 'local', name: name || id, status: 'active', ipAddress: '', @@ -33,28 +38,39 @@ function localPlaceholderNode(id: string, name: string, os: PlatformDisplayName) } } -/** Build the membership-scoped overview, always including the local node. */ +/** Membership-scoped peers plus separately keyed previews/manual engines. */ export function buildOverviewNodes( nodesMap: ReadonlyMap, members: readonly ClusterNode[], selfId: string | null, platform: PlatformDisplayName ): NodeItem[] { + const records = new Map() + for (const node of nodesMap.values()) { + const key = nodeRecordKey(node) + if (key) records.set(key, node.id === key ? node : { ...node, id: key }) + } const out = new Map() // `nodesMap`, `selfId`, and every member correlate on the node UUID // (`ClusterNode.nodeUuid` = `NodeItem.id`), never the hostname (`member.id`). for (const member of members) { if (member.state !== 'member' || member.nodeUuid === selfId) continue - out.set(member.nodeUuid, nodesMap.get(member.nodeUuid) ?? offlineMemberNode(member)) + const node = records.get(member.nodeUuid) ?? offlineMemberNode(member) + const key = nodeRecordKey(node) + if (key) out.set(key, node) } if (selfId) { const selfMember = members.find(member => member.nodeUuid === selfId) - out.set( - selfId, - nodesMap.get(selfId) ?? localPlaceholderNode(selfId, selfMember?.name ?? '', platform) - ) + const node = + records.get(selfId) ?? localPlaceholderNode(selfId, selfMember?.name ?? '', platform) + const key = nodeRecordKey(node) + if (key) out.set(key, node) + } + + for (const [key, node] of records) { + if (!isPairPeer(node)) out.set(key, node) } const list = Array.from(out.values()) diff --git a/desktop/tests/modular/NodeChartLegend-NodePerformance-radial-TrayNodeRow.test.ts b/desktop/tests/modular/NodeChartLegend-NodePerformance-radial-TrayNodeRow.test.ts index 8b298cf32..490f6ef6a 100644 --- a/desktop/tests/modular/NodeChartLegend-NodePerformance-radial-TrayNodeRow.test.ts +++ b/desktop/tests/modular/NodeChartLegend-NodePerformance-radial-TrayNodeRow.test.ts @@ -34,6 +34,8 @@ function gpu(memoryKind: GpuMemoryKind, overrides: Partial = {} function node(gpus: HardwareGpuInfo[]): NodeItem { return { id: 'fixture', + hostUuid: 'fixture', + identityVerification: 'local', name: 'Fixture node', ipAddress: '192.0.2.1', port: 14318, diff --git a/desktop/tests/modular/engine-capability-gates.test.ts b/desktop/tests/modular/engine-capability-gates.test.ts index 2a441c517..e9fb855ec 100644 --- a/desktop/tests/modular/engine-capability-gates.test.ts +++ b/desktop/tests/modular/engine-capability-gates.test.ts @@ -195,6 +195,20 @@ describe("engine status carries the owning node's reported capabilities", () => it('carries them for the local node and for a remote peer, separately', () => { const state = getModularBridgeState() state.setSelfId('gates-local') + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { + hostUuid: 'gates-remote', + identityVerification: 'verified', + peerRecordId: 'proof:fixture', + name: 'gates-remote' + } + ] + } + }) state.handleNotification({ source: 'proxy', method: 'node/discovered', @@ -242,6 +256,20 @@ describe("engine status carries the owning node's reported capabilities", () => it('preserves flags during local and remote transitions and replaces them on lifecycle results', () => { const state = getModularBridgeState() state.setSelfId('gates-lifecycle') + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { + hostUuid: 'gates-remote', + identityVerification: 'verified', + peerRecordId: 'proof:fixture', + name: 'gates-remote' + } + ] + } + }) state.handleNotification({ source: 'proxy', method: 'node/discovered', @@ -309,6 +337,20 @@ describe("engine status carries the owning node's reported capabilities", () => it('treats a peer that sends no capabilities object as all-false', () => { const state = getModularBridgeState() state.setSelfId('gates-local-2') + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { + hostUuid: 'gates-remote-2', + identityVerification: 'verified', + peerRecordId: 'proof:fixture', + name: 'gates-remote-2' + } + ] + } + }) state.handleNotification({ source: 'proxy', method: 'node/discovered', diff --git a/desktop/tests/modular/epoch-resync.test.ts b/desktop/tests/modular/epoch-resync.test.ts index 8a15c0bf5..14b638d67 100644 --- a/desktop/tests/modular/epoch-resync.test.ts +++ b/desktop/tests/modular/epoch-resync.test.ts @@ -540,6 +540,8 @@ describe('broker epoch tracking and resync', () => { nodes: [ { id: 'sender-uuid', + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: 'sender-uuid', name: 'Sender', ipAddress: '10.0.0.2', diff --git a/desktop/tests/modular/first-launch.test.ts b/desktop/tests/modular/first-launch.test.ts index bd6358294..b933d8d9c 100644 --- a/desktop/tests/modular/first-launch.test.ts +++ b/desktop/tests/modular/first-launch.test.ts @@ -158,6 +158,8 @@ describe('first launch', () => { it('replaces the temporary local card with discovered data', () => { const discovered: NodeItem = { id: 'local-node', + hostUuid: 'local-node', + identityVerification: 'local', name: 'My PC', status: 'active', ipAddress: '192.168.1.2', diff --git a/desktop/tests/modular/ghost-node-dedupe.test.ts b/desktop/tests/modular/ghost-node-dedupe.test.ts index 29b8c6e08..1eee3ec70 100644 --- a/desktop/tests/modular/ghost-node-dedupe.test.ts +++ b/desktop/tests/modular/ghost-node-dedupe.test.ts @@ -2,274 +2,69 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from 'vitest' - vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) vi.mock('@/electron/window', () => ({ createOverviewWindow: vi.fn() })) - import { getModularBridgeState } from '@/electron/service-bridge/modular-state' -import { MODULAR_SUPERSEDE_MIN_AGE_MS } from '@/shared/constants/modular-runtime' - -const NOW = Date.now() -const STALE = NOW - 60 * 60 * 1000 -const RECENT = NOW - (MODULAR_SUPERSEDE_MIN_AGE_MS - 60_000) -interface BrokerNodeFixture { - hostUuid: string - name: string - ipAddress: string - lastSeen: number +const old = { + hostUuid: 'old-peer', + identityVerification: 'verified', + peerRecordId: 'proof:old', + name: 'same-name', + ipAddress: '192.0.2.10', + port: 14318, + lastSeen: Date.now() - 3_600_000 } - -function brokerSnapshot(nodes: BrokerNodeFixture[]): void { - getModularBridgeState().handleNotification({ - source: 'broker', - method: 'discovery:nodes-changed', - params: { - nodes: nodes.map(node => ({ - hostUuid: node.hostUuid, - name: node.name, - ipAddress: node.ipAddress, - port: 14318, - lastSeen: node.lastSeen, - trusted: false, - clustered: false - })) +const current = { ...old, hostUuid: 'new-peer', peerRecordId: 'proof:new', lastSeen: Date.now() } + +describe('broker-owned node lifetime', () => { + it.each([false, true])( + 'does not collapse different keys by address, name or age (%s)', + reverse => { + const state = getModularBridgeState() + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: reverse ? [current, old] : [old, current] + } + }) + expect(Object.keys(state.getNodesInitial().nodes).sort()).toEqual([ + 'new-peer', + 'old-peer' + ]) + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { nodes: [current] } + }) + expect(Object.keys(state.getNodesInitial().nodes)).toEqual(['new-peer']) } - }) -} - -function availableIds(): string[] { - return getModularBridgeState() - .getAvailableNodes() - .map(node => node.id) -} - -// A node whose appdata is wiped mints a fresh hostUuid, so the backend directory -// can carry BOTH the pre-wipe key and the new one for the same machine. The -// bridge collapses that pair on the three conditions the node-scanner uses to -// NOMINATE a superseded record (directory.supersedeCandidates) — same address, -// same hostname, and a lastSeen gap of at least MODULAR_SUPERSEDE_MIN_AGE_MS. -// -// The scanner goes on to confirm the nomination against node-info before it -// evicts anything; the bridge cannot, so what these cases pin down is a -// display-side collapse of a duplicate row, not an eviction from the directory. -describe('ghost node dedupe by address', () => { - it('drops the pre-wipe record when the same address and hostname reappear under a new UUID', () => { - brokerSnapshot([ - { - hostUuid: 'ghost-old-1', - name: 'wiped-host-1', - ipAddress: '198.51.100.10', - lastSeen: STALE - }, - { - hostUuid: 'ghost-new-1', - name: 'wiped-host-1', - ipAddress: '198.51.100.10', - lastSeen: NOW - } - ]) - - const ids = availableIds() - expect(ids).toContain('ghost-new-1') - expect(ids).not.toContain('ghost-old-1') - expect(getModularBridgeState().getNodesInitial().nodes['ghost-old-1']).toBeUndefined() - }) - - it('drops the pre-wipe record when it was already in the list first', () => { - brokerSnapshot([ - { - hostUuid: 'ghost-old-2', - name: 'wiped-host-2', - ipAddress: '198.51.100.11', - lastSeen: STALE - } - ]) - expect(availableIds()).toContain('ghost-old-2') - - brokerSnapshot([ - { - hostUuid: 'ghost-old-2', - name: 'wiped-host-2', - ipAddress: '198.51.100.11', - lastSeen: STALE - }, - { - hostUuid: 'ghost-new-2', - name: 'wiped-host-2', - ipAddress: '198.51.100.11', - lastSeen: NOW - } - ]) - - const ids = availableIds() - expect(ids).toContain('ghost-new-2') - expect(ids).not.toContain('ghost-old-2') - }) - - it('keeps the ghost suppressed across repeated snapshots that still carry it', () => { - const pair: BrokerNodeFixture[] = [ - { - hostUuid: 'ghost-old-3', - name: 'wiped-host-3', - ipAddress: '198.51.100.12', - lastSeen: STALE - }, - { - hostUuid: 'ghost-new-3', - name: 'wiped-host-3', - ipAddress: '198.51.100.12', - lastSeen: NOW - } - ] - // A backend that has not evicted the ghost yet keeps publishing it, so - // eviction only holds if the stale side is refused on every arrival, not - // just evicted once. - for (let i = 0; i < 3; i++) brokerSnapshot(pair) - - const ids = availableIds() - expect(ids.filter(id => id.startsWith('ghost-'))).toEqual(['ghost-new-3']) - }) + ) - it('collapses the pair regardless of order within a snapshot', () => { - brokerSnapshot([ - { - hostUuid: 'ghost-new-4', - name: 'wiped-host-4', - ipAddress: '198.51.100.13', - lastSeen: NOW - }, - { - hostUuid: 'ghost-old-4', - name: 'wiped-host-4', - ipAddress: '198.51.100.13', - lastSeen: STALE - } - ]) - - const ids = availableIds() - expect(ids).toContain('ghost-new-4') - expect(ids).not.toContain('ghost-old-4') - }) - - it('never collapses records whose hostnames differ, even on one address', () => { - // The hostname match is what makes this inference safe. A wipe clears - // identity but not the hostname; two records that disagree on the name - // are left for the scanner's identity probe to settle with proof. - brokerSnapshot([ - { - hostUuid: 'renamed-old-5', - name: 'old-name-5', - ipAddress: '198.51.100.14', - lastSeen: STALE - }, - { - hostUuid: 'renamed-new-5', - name: 'renamed-5', - ipAddress: '198.51.100.14', - lastSeen: NOW - } - ]) - - const ids = availableIds() - expect(ids).toContain('renamed-new-5') - expect(ids).toContain('renamed-old-5') - }) - - it('never collapses a pair whose lastSeen gap is under the minimum age', () => { - brokerSnapshot([ - { - hostUuid: 'recent-old-6', - name: 'shared-host-6', - ipAddress: '198.51.100.15', - lastSeen: RECENT - }, - { - hostUuid: 'recent-new-6', - name: 'shared-host-6', - ipAddress: '198.51.100.15', - lastSeen: NOW - } - ]) - - const ids = availableIds() - expect(ids).toContain('recent-new-6') - expect(ids).toContain('recent-old-6') - }) - - it('never collapses two distinct machines on different addresses', () => { - brokerSnapshot([ - { hostUuid: 'distinct-a', name: 'host-a', ipAddress: '198.51.100.20', lastSeen: STALE }, - { hostUuid: 'distinct-b', name: 'host-b', ipAddress: '198.51.100.21', lastSeen: NOW } - ]) - - const ids = availableIds() - expect(ids).toContain('distinct-a') - expect(ids).toContain('distinct-b') - }) - - it('never evicts the local node, and never hides a peer to protect it', () => { + it('withdraws a broker record even when a proxy still reports its engine', () => { const state = getModularBridgeState() - state.setSelfId('self-uuid-7') - brokerSnapshot([ - { - hostUuid: 'self-uuid-7', - name: 'self-host-7', - ipAddress: '198.51.100.30', - lastSeen: STALE - } - ]) - expect(availableIds()).toContain('self-uuid-7') - - // Self is pinned by the cluster-manager, so a peer claiming its address - // and hostname with a fresher timestamp must not displace it. The peer - // still shows: the backend keeps it in the directory too, and silently - // hiding it here is exactly the disagreement this rule exists to avoid. - brokerSnapshot([ - { - hostUuid: 'self-uuid-7', - name: 'self-host-7', - ipAddress: '198.51.100.30', - lastSeen: STALE - }, - { - hostUuid: 'peer-7', - name: 'self-host-7', - ipAddress: '198.51.100.30', - lastSeen: NOW - } - ]) - - const ids = availableIds() - expect(ids).toContain('self-uuid-7') - expect(ids).toContain('peer-7') - }) - - it('applies a multi-way collapse completely or not at all', () => { - // Three records on one address and hostname: the stale side must not - // strand entries it had already marked for eviction. - brokerSnapshot([ - { - hostUuid: 'multi-old-a', - name: 'shared-host-8', - ipAddress: '198.51.100.40', - lastSeen: STALE - }, - { - hostUuid: 'multi-old-b', - name: 'shared-host-8', - ipAddress: '198.51.100.40', - lastSeen: STALE - }, - { - hostUuid: 'multi-new', - name: 'shared-host-8', - ipAddress: '198.51.100.40', - lastSeen: NOW - } - ]) - - const ids = availableIds().filter(id => id.startsWith('multi-')) - expect(ids).toEqual(['multi-new']) + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { nodes: [old] } + }) + state.handleNotification({ + source: 'proxy', + method: 'node/discovered', + params: { id: 'old-peer', port: 11434 } + }) + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { nodes: [] } + }) + expect(state.getNodesInitial().nodes).toEqual({}) + state.handleNotification({ + source: 'proxy', + method: 'node/updated', + params: { id: 'old-peer', port: 11434 } + }) + expect(state.getNodesInitial().nodes).toEqual({}) }) }) diff --git a/desktop/tests/modular/inference-metrics-rendering.test.ts b/desktop/tests/modular/inference-metrics-rendering.test.ts index fab6d7dbc..9d5b5c3b7 100644 --- a/desktop/tests/modular/inference-metrics-rendering.test.ts +++ b/desktop/tests/modular/inference-metrics-rendering.test.ts @@ -134,6 +134,8 @@ describe('the node card prefill/decode section', () => { it('is rendered by the node card itself', async () => { const node: NodeItem = { id: 'node-a', + hostUuid: 'node-a', + identityVerification: 'local', name: 'Fixture node', ipAddress: '192.0.2.1', port: 14318, @@ -573,6 +575,8 @@ describe("the node card's engine-native section", () => { function nodeWithEngineNative(engineNative: EngineNativeView): NodeItem { const node: NodeItem = { id: 'node-a', + hostUuid: 'node-a', + identityVerification: 'local', name: 'Fixture node', ipAddress: '192.0.2.1', port: 14318, diff --git a/desktop/tests/modular/lmstudio-stale-model.test.ts b/desktop/tests/modular/lmstudio-stale-model.test.ts index 061f31a6a..286fb9674 100644 --- a/desktop/tests/modular/lmstudio-stale-model.test.ts +++ b/desktop/tests/modular/lmstudio-stale-model.test.ts @@ -48,6 +48,8 @@ describe('LM Studio model reconciliation', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: nodeId, name: 'lmstudio-reconciliation-host', ipAddress: '127.0.0.1', @@ -106,6 +108,8 @@ describe('LM Studio model reconciliation', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: nodeId, name: 'shared-model-host', ipAddress: '127.0.0.1', @@ -127,6 +131,8 @@ describe('LM Studio model reconciliation', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: nodeId, name: 'shared-model-host', ipAddress: '127.0.0.1', @@ -150,6 +156,8 @@ describe('LM Studio model reconciliation', () => { const supervisor = getModularSupervisor() const nodeId = 'lmstudio-proxy-down-deletion-local' const discoveryNode = (models: string[]) => ({ + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: nodeId, name: 'lmstudio-proxy-down-deletion-host', ipAddress: '127.0.0.1', @@ -217,6 +225,8 @@ describe('LM Studio model reconciliation', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: nodeId, name: 'lmstudio-restart-host', ipAddress: '127.0.0.1', @@ -279,6 +289,8 @@ describe('LM Studio model reconciliation', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: nodeId, name: 'lmstudio-last-good-empty-host', ipAddress: '127.0.0.1', @@ -338,6 +350,8 @@ describe('LM Studio model reconciliation', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: nodeId, name: 'lmstudio-malformed-restart-host', ipAddress: '127.0.0.1', @@ -409,6 +423,8 @@ describe('LM Studio model reconciliation', () => { const supervisor = getModularSupervisor() const nodeId = 'lmstudio-discovery-retry-local' const discoveryNode = (models: string[]) => ({ + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: nodeId, name: 'lmstudio-discovery-retry-host', ipAddress: '127.0.0.1', diff --git a/desktop/tests/modular/modular-state-gpu-metrics.test.ts b/desktop/tests/modular/modular-state-gpu-metrics.test.ts index 3d9126f0a..d6658ec64 100644 --- a/desktop/tests/modular/modular-state-gpu-metrics.test.ts +++ b/desktop/tests/modular/modular-state-gpu-metrics.test.ts @@ -36,7 +36,16 @@ describe('GPU memory semantics from node-info through metrics history', () => { source: 'broker', method: 'discovery:nodes-changed', params: { - nodes: [{ hostUuid: nodeId, name: 'fixture', ipAddress: '192.0.2.1', port: 14318 }] + nodes: [ + { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', + hostUuid: nodeId, + name: 'fixture', + ipAddress: '192.0.2.1', + port: 14318 + } + ] } }) updates.length = 0 diff --git a/desktop/tests/modular/modular-state-sensors.test.ts b/desktop/tests/modular/modular-state-sensors.test.ts index b99e50ac4..aeb5b3f62 100644 --- a/desktop/tests/modular/modular-state-sensors.test.ts +++ b/desktop/tests/modular/modular-state-sensors.test.ts @@ -21,7 +21,18 @@ function discover(name = 'fixture'): void { getModularBridgeState().handleNotification({ source: 'broker', method: 'discovery:nodes-changed', - params: { nodes: [{ hostUuid: nodeId, name, ipAddress: '192.0.2.1', port: 14318 }] } + params: { + nodes: [ + { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', + hostUuid: nodeId, + name, + ipAddress: '192.0.2.1', + port: 14318 + } + ] + } }) } @@ -64,7 +75,12 @@ describe('node-info sensors through metrics updates and renderer state', () => { getNodeMetrics: async () => ({ nodes: [] }), onNodeMetrics: () => () => {} }, - nodes: { onRemove: () => () => {} } + nodes: { + onRemove: (callback: (id: string) => void) => + subscribePush(event => { + if (event.channel === 'nodes:remove') callback(event.payload) + }) + } } }) useMetricsStore.getState().clearAll() @@ -314,8 +330,8 @@ describe('node-info sensors through metrics updates and renderer state', () => { method: 'discovery:nodes-changed', params: { nodes: [] } }) - expect(current().sensors).toBeNull() - expect(storedSensors()).toBeNull() + expect(getModularBridgeState().getNodesInitial().nodes[nodeId]).toBeUndefined() + expect(storedSensors()).toBeUndefined() }) it('includes current sensors when other telemetry changes and upserts', () => { diff --git a/desktop/tests/modular/node-address-rerank.test.ts b/desktop/tests/modular/node-address-rerank.test.ts index 454df5e16..ae9626491 100644 --- a/desktop/tests/modular/node-address-rerank.test.ts +++ b/desktop/tests/modular/node-address-rerank.test.ts @@ -23,6 +23,8 @@ describe('broker address re-rank', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: 'uuid-rerank', name: 'rerank-host', ipAddress: '192.0.2.101', @@ -58,6 +60,8 @@ describe('broker address re-rank', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: 'uuid-rerank', name: 'rerank-host', ipAddress: '192.0.2.102', @@ -84,6 +88,8 @@ describe('broker address re-rank', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: 'uuid-proxy-refresh', name: 'proxy-refresh-host', ipAddress: '192.0.2.201', diff --git a/desktop/tests/modular/node-info-self-poll.test.ts b/desktop/tests/modular/node-info-self-poll.test.ts index a27788054..8d346c2de 100644 --- a/desktop/tests/modular/node-info-self-poll.test.ts +++ b/desktop/tests/modular/node-info-self-poll.test.ts @@ -26,6 +26,8 @@ describe('node-info self poll', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: 'uuid-self', name: 'self-host', ipAddress: '192.0.2.51', @@ -33,6 +35,8 @@ describe('node-info self poll', () => { port: 14318 }, { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: 'uuid-peer', name: 'peer-host', ipAddress: '192.0.2.61', diff --git a/desktop/tests/modular/node-record-key.test.ts b/desktop/tests/modular/node-record-key.test.ts new file mode 100644 index 000000000..05b955b74 --- /dev/null +++ b/desktop/tests/modular/node-record-key.test.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest' +import fixture from '@/shared/fixtures/node-record-keys.json' +import { nodeRecordKey } from '@/shared/utils/node-record-key' + +describe('Go DirectoryNode.RecordKey parity', () => { + it.each(fixture.cases)('$name', record => { + expect(nodeRecordKey(record)).toBe(record.want) + }) +}) diff --git a/desktop/tests/modular/node-sensors.test.ts b/desktop/tests/modular/node-sensors.test.ts index 04106dc1b..73ca9ccaa 100644 --- a/desktop/tests/modular/node-sensors.test.ts +++ b/desktop/tests/modular/node-sensors.test.ts @@ -23,6 +23,8 @@ import { useConnectionStore } from '@/ui/stores/connection.store' const GiB = 1024 ** 3 const node: NodeItem = { id: 'fixture', + hostUuid: 'fixture', + identityVerification: 'local', name: 'Fixture Mac', ipAddress: '192.0.2.1', port: 14318, diff --git a/desktop/tests/modular/peer-verification-keying.test.ts b/desktop/tests/modular/peer-verification-keying.test.ts new file mode 100644 index 000000000..ee0e941f4 --- /dev/null +++ b/desktop/tests/modular/peer-verification-keying.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from 'vitest' +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) +vi.mock('@/electron/window', () => ({ createOverviewWindow: vi.fn() })) +import { getModularBridgeState } from '@/electron/service-bridge/modular-state' + +const peer = { + hostUuid: 'proven-peer', + peerRecordId: 'proof:binding', + identityVerification: 'verified', + name: 'same-name', + ipAddress: '192.0.2.5', + port: 14318, + trusted: true, + lastSeen: Date.now() - 3_600_000, + models: ['peer-model'] +} +const preview = { + ...peer, + peerRecordId: 'preview:claim', + identityVerification: 'unverified', + trusted: false, + lastSeen: Date.now(), + models: [] +} + +describe('independent broker record identities', () => { + it.each([false, true])('keeps preview separate regardless of snapshot order (%s)', reverse => { + const state = getModularBridgeState() + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { nodes: reverse ? [preview, peer] : [peer, preview] } + }) + const nodes = state.getNodesInitial().nodes + expect(Object.keys(nodes).sort()).toEqual(['preview:claim', 'proven-peer']) + expect(nodes['proven-peer']).toMatchObject({ + name: 'same-name', + identityVerification: 'verified' + }) + expect(nodes['preview:claim']).toMatchObject({ identityVerification: 'unverified' }) + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { nodes: [peer] } + }) + expect(Object.keys(state.getNodesInitial().nodes)).toEqual(['proven-peer']) + }) + + it('drops invalid keys, including reserved proven principals and old missing metadata', () => { + const state = getModularBridgeState() + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { ...peer, hostUuid: 'preview:claim' }, + { ...peer, hostUuid: 'manual:claim' }, + { hostUuid: 'old-peer', name: 'old' }, + { ...peer, identityVerification: 'unverified' } + ] + } + }) + expect(state.getNodesInitial().nodes).toEqual({}) + }) + + it('a proxy event cannot create or resurrect a host identity', () => { + const state = getModularBridgeState() + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { nodes: [preview] } + }) + state.handleNotification({ + source: 'proxy', + method: 'node/discovered', + params: { + id: peer.hostUuid, + host: peer.name, + ip: peer.ipAddress, + port: 11434 + } + }) + expect(Object.keys(state.getNodesInitial().nodes)).toEqual(['preview:claim']) + expect(state.getNodeInfoPollTargets()).toEqual([]) + }) +}) diff --git a/desktop/tests/modular/peer-verification-rendering.test.ts b/desktop/tests/modular/peer-verification-rendering.test.ts new file mode 100644 index 000000000..18b97d13d --- /dev/null +++ b/desktop/tests/modular/peer-verification-rendering.test.ts @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import AvailableNode from '@/ui/components/ClusterSettings/AvailableNode' +import NodeCardDetails from '@/ui/components/NodeList/NodeCardDetails' +import { useNodesStore } from '@/ui/stores/nodes.store' +import { useDiscoveredNodesStore } from '@/ui/stores/discovered-nodes.store' +import { buildOverviewNodes } from '@/ui/utils/overview-nodes' +import { nodeVerificationLabel } from '@/shared/utils/node-record-key' +import type { NodeItem } from '@/shared/types/nodes' + +let container: HTMLDivElement +let root: ReturnType +const inviteNode = vi.fn().mockResolvedValue({ state: 'paired', inviteId: 'fixture' }) +const peer: NodeItem = { + id: 'peer', + hostUuid: 'peer', + peerRecordId: 'proof', + identityVerification: 'verified', + name: 'Peer', + ipAddress: '192.0.2.10', + port: 14318, + allIpAddresses: [], + status: 'active', + os: 'Linux', + topology: { gpus: [], cpu: { model: '', cores: 0, threads: 0 }, ram: 0, storage: [] } +} +const preview: NodeItem = { + ...peer, + id: 'preview:claim', + peerRecordId: 'preview:claim', + identityVerification: 'unverified' +} +const manual: NodeItem = { + ...peer, + id: 'manual:config', + peerRecordId: 'manual:config', + identityVerification: 'manual' +} + +beforeEach(() => { + inviteNode.mockResolvedValue({ state: 'paired', inviteId: 'fixture' }) + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal( + 'ResizeObserver', + class { + observe = vi.fn() + disconnect = vi.fn() + } + ) + vi.stubGlobal('requestAnimationFrame', () => 1) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + Object.defineProperty(window, 'pairApi', { + configurable: true, + value: { cluster: { inviteNode, abandonIfSolo: vi.fn().mockResolvedValue(undefined) } } + }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +describe('node verification presentation', () => { + it.each([preview, manual])( + 'renders $identityVerification without peer controls or telemetry', + async node => { + await act(async () => root.render(createElement(NodeCardDetails, { node }))) + expect(container.textContent).toContain( + node === manual ? 'Engine only — added by address' : 'Not in your cluster' + ) + expect(container.querySelector('[aria-label="Engine settings"]')).toBeNull() + expect(container.querySelector('[aria-label="Show performance"]')).toBeNull() + expect(container.textContent).not.toContain('PAIR activity') + const pair = Array.from(container.querySelectorAll('button')).find( + button => button.textContent === 'Pair' + ) + if (node === preview) { + expect(pair).toBeDefined() + await act(async () => pair?.click()) + expect(inviteNode).toHaveBeenCalledWith('192.0.2.10') + } else expect(pair).toBeUndefined() + } + ) + + it.each(['verified', 'local'])( + 'keeps existing peer controls for %s records', + async identityVerification => { + await act(async () => + root.render( + createElement(NodeCardDetails, { node: { ...peer, identityVerification } }) + ) + ) + expect(container.querySelector('[aria-label="Engine settings"]')).not.toBeNull() + expect(container.textContent).not.toContain('Not in your cluster') + expect(container.textContent).not.toContain('Engine only') + } + ) + + it('offers Pair on an unverified list row even when its public cluster claim is set', async () => { + await act(async () => + root.render( + createElement(AvailableNode, { + node: { ...preview, trusted: false, clustered: true, lastSeen: 0 } + }) + ) + ) + expect(container.textContent).toContain('Not in your cluster') + const button = container.querySelector('button') + expect(button?.textContent).toBe('Pair') + expect(button?.disabled).toBe(false) + await act(async () => button?.click()) + expect(inviteNode).toHaveBeenCalledWith('192.0.2.10') + }) + + it('does not infer a version diagnosis from unverified metadata', () => { + expect(nodeVerificationLabel({ ...preview, identityVerification: undefined })).toBe( + 'Not in your cluster' + ) + expect(nodeVerificationLabel(peer)).toBe('') + expect(nodeVerificationLabel({ ...peer, identityVerification: 'local' })).toBe('') + }) + + it('normalizes both renderer stores on snapshots and pushes, dropping invalid records', async () => { + let upsert: ((node: NodeItem) => void) | undefined + const invalid = { ...peer, hostUuid: 'preview:reserved' } + const available = [peer, preview, invalid].map(node => ({ + ...node, + trusted: true, + clustered: true, + lastSeen: 0 + })) + Object.defineProperty(window, 'pairApi', { + configurable: true, + value: { + nodes: { + getInitial: async () => ({ + nodes: { peer, claimed: preview, invalid }, + fetchedNodes: true + }), + onUpsert: (callback: (node: NodeItem) => void) => { + upsert = callback + return () => {} + }, + onRemove: () => () => {} + }, + discovery: { getNodes: async () => available, onNodesChanged: () => () => {} } + } + }) + await useNodesStore.getState().initialize() + await useDiscoveredNodesStore.getState().initialize() + expect([...useNodesStore.getState().nodes.keys()].sort()).toEqual(['peer', 'preview:claim']) + expect(useDiscoveredNodesStore.getState().nodes.map(node => node.id)).toEqual([ + 'peer', + 'preview:claim' + ]) + upsert?.({ ...preview, id: 'peer' }) + upsert?.(invalid) + expect([...useNodesStore.getState().nodes.keys()].sort()).toEqual(['peer', 'preview:claim']) + expect(useNodesStore.getState().nodes.get('peer')?.identityVerification).toBe('verified') + useNodesStore.getState().cleanup() + useDiscoveredNodesStore.getState().cleanup() + }) + + it('the overview normalizes record keys instead of accepting claimed map keys', () => { + const nodes = new Map([ + ['peer', preview], + ['claimed-map-key', peer] + ]) + const result = buildOverviewNodes(nodes, [], 'peer', 'Linux') + expect(result.map(node => node.id)).toEqual(['peer', 'preview:claim']) + expect(result[0].identityVerification).toBe('verified') + expect(buildOverviewNodes(new Map(), [], 'manual:reserved', 'Linux')).toEqual([]) + }) + + it('keeps claimed peers separate in the overview without granting membership', () => { + const nodes = new Map([ + [peer.id, peer], + [preview.id, preview], + [manual.id, manual] + ]) + expect(buildOverviewNodes(nodes, [], 'peer', 'Linux').map(node => node.id)).toEqual([ + 'peer', + 'preview:claim', + 'manual:config' + ]) + }) +}) diff --git a/desktop/tests/modular/remote-engine-install-state.test.ts b/desktop/tests/modular/remote-engine-install-state.test.ts index eed8aa895..bac38c21f 100644 --- a/desktop/tests/modular/remote-engine-install-state.test.ts +++ b/desktop/tests/modular/remote-engine-install-state.test.ts @@ -26,6 +26,8 @@ describe('remote engine install state', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: remoteNodeId, name: 'unknown-host', ipAddress: '192.0.2.60', @@ -54,6 +56,8 @@ describe('remote engine install state', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: remoteNodeId, name: 'peer-no-lms', ipAddress: '192.0.2.61', @@ -95,6 +99,8 @@ describe('remote engine install state', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: remoteNodeId, name: 'peer-stopped-lms', ipAddress: '192.0.2.62', @@ -128,6 +134,20 @@ describe('remote engine install state', () => { it('treats proxy presence as running when facts are absent on an unclustered peer', () => { const state = getModularBridgeState() const remoteNodeId = 'remote-presence' + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { + hostUuid: 'remote-presence', + identityVerification: 'verified', + peerRecordId: 'proof:fixture', + name: 'remote-presence' + } + ] + } + }) state.setSelfId('local-self') state.handleNotification({ @@ -161,6 +181,8 @@ describe('remote engine install state', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: remoteNodeId, name: 'clustered-peer', ipAddress: '192.0.2.65', @@ -198,6 +220,8 @@ describe('remote engine install state', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: remoteNodeId, name: 'peer-stale', ipAddress: '192.0.2.64', diff --git a/desktop/tests/modular/remote-engine-status.test.ts b/desktop/tests/modular/remote-engine-status.test.ts index 463e1ad60..3d5a0d32f 100644 --- a/desktop/tests/modular/remote-engine-status.test.ts +++ b/desktop/tests/modular/remote-engine-status.test.ts @@ -14,6 +14,20 @@ describe('remote engine status', () => { const remoteNodeId = 'remote-engine-status-remote' state.setSelfId('remote-engine-status-local') + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { + hostUuid: 'remote-engine-status-remote', + identityVerification: 'verified', + peerRecordId: 'proof:fixture', + name: 'remote-engine-status-remote' + } + ] + } + }) state.handleNotification({ source: 'lmstudio-proxy', method: 'node/discovered', diff --git a/desktop/tests/modular/uuid-node-keying.test.ts b/desktop/tests/modular/uuid-node-keying.test.ts index 6b68c994e..494a1bd92 100644 --- a/desktop/tests/modular/uuid-node-keying.test.ts +++ b/desktop/tests/modular/uuid-node-keying.test.ts @@ -11,9 +11,8 @@ import { parseWorkloadsInitial } from '@/electron/service-bridge/modular-state' -// The bridge keys every node by the backend's stable per-host UUID; the hostname -// is display only. These tests lock in the two behaviours that migration exists -// for: a broker view and a proxy view of the same machine collapse to one +// Proven peer records use host UUIDs; previews use independent opaque keys. +// The hostname is display only. These tests lock in peer enrichment behaviour: a broker view and a proxy view of the same machine collapse to one // UUID-keyed entry (no hostname ghost), and cross-domain attribution refuses a // telemetry response whose reported UUID does not match the polled node. describe('UUID node keying', () => { @@ -25,6 +24,8 @@ describe('UUID node keying', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: 'uuid-merge-1', name: 'merge-host-1', ipAddress: '192.0.2.11', @@ -80,6 +81,8 @@ describe('UUID node keying', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: 'uuid-merge-2', name: 'merge-host-2', ipAddress: '192.0.2.12', @@ -108,6 +111,8 @@ describe('UUID node keying', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: 'uuid-ni-1', name: 'ni-host-1', ipAddress: '192.0.2.50', @@ -149,6 +154,8 @@ describe('UUID node keying', () => { params: { nodes: [ { + identityVerification: 'verified', + peerRecordId: 'proof:fixture', hostUuid: 'uuid-ni-failover', name: 'ni-failover-host', ipAddress: '192.0.2.80', diff --git a/services/nvpair-tui/README.md b/services/nvpair-tui/README.md index 9b2c2095c..536a4bc86 100644 --- a/services/nvpair-tui/README.md +++ b/services/nvpair-tui/README.md @@ -49,6 +49,11 @@ warns that engines may remain running or stop and inference may be interrupted. Restart initiation is not completed recovery; follow subsequent worker updates. See [broker supervision](../nvpair-ui-broker/README.md#supervision--recovery). +The Nodes status column labels unverified PAIR previews **Not in your cluster** +and explicit bare engines **Engine only — added by address**. A missing +verification state is unverified; a public claim alone is not a connected peer. +Local/verified peers retain their existing connected/cluster status. + ## Keys - `tab` / `shift+tab` (or `→` / `←`, `l` / `h`) — switch tabs diff --git a/services/nvpair-tui/ui/node_provenance_test.go b/services/nvpair-tui/ui/node_provenance_test.go index 2c1c784a4..741d10d80 100644 --- a/services/nvpair-tui/ui/node_provenance_test.go +++ b/services/nvpair-tui/ui/node_provenance_test.go @@ -20,10 +20,13 @@ func TestNodeProvenanceKeepsPreviewSeparateFromVerifiedPeer(t *testing.T) { if len(view.nodes) != 2 || view.nodes[0].PeerRecordID == view.nodes[1].PeerRecordID { t.Fatal("preview collided with verified row") } - if nodeStatus(nodes[0]) != "Unverified" || nodeStatus(nodes[1]) != "Connected" { + if nodeStatus(nodes[0]) != "Not in your cluster" || nodeStatus(nodes[1]) != "Connected" { t.Fatal("provenance labels do not distinguish trust") } - if nodeStatus(availableNode{IdentityVerification: "manual"}) != "Manual engine" { + if nodeStatus(availableNode{IdentityVerification: "manual"}) != "Engine only — added by address" { t.Fatal("manual bypass is not separately labelled") } + if nodeStatus(availableNode{Trusted: true}) != "Not in your cluster" { + t.Fatal("missing provenance was treated as verified") + } } diff --git a/services/nvpair-tui/ui/nodes.go b/services/nvpair-tui/ui/nodes.go index 74f7980eb..a42f9cd0d 100644 --- a/services/nvpair-tui/ui/nodes.go +++ b/services/nvpair-tui/ui/nodes.go @@ -86,7 +86,7 @@ func (v *nodesView) Init() tea.Cmd { func (v *nodesView) SetSize(w, h int) { v.width, v.height = w, h - const port, age, status = 7, 8, 11 + const port, age, status = 7, 8, 32 name := clampWidth((w-port-age-status-2)/2, 10) addr := clampWidth(w-port-age-status-name-2, 10) v.table.SetColumns([]table.Column{ @@ -200,15 +200,14 @@ func (v *nodesView) setNodes(nodes []availableNode) { v.table.SetRows(rows) } -// nodeStatus is the STATUS cell for a discovered node: "Connected" for a paired -// peer of ours, "In cluster" for a node clustered elsewhere (both non-invitable), -// empty for an invitable standalone node. +// nodeStatus renders provenance before public trust/cluster claims. Only local +// and verified records retain the established connected/cluster status labels. func nodeStatus(n availableNode) string { switch { - case n.IdentityVerification == "unverified": - return "Unverified" case n.IdentityVerification == "manual": - return "Manual engine" + return "Engine only — added by address" + case n.IdentityVerification != "verified" && n.IdentityVerification != "local": + return "Not in your cluster" case n.Trusted: return "Connected" case n.Clustered: diff --git a/services/shared/noderec/record_key_fixture_test.go b/services/shared/noderec/record_key_fixture_test.go new file mode 100644 index 000000000..8d4bad330 --- /dev/null +++ b/services/shared/noderec/record_key_fixture_test.go @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package noderec + +import ( + "encoding/json" + "os" + "testing" +) + +// The desktop consumes this same table: neither implementation computes expectations. +func TestRecordKeyDesktopParity(t *testing.T) { + data, err := os.ReadFile("../../../desktop/src/shared/fixtures/node-record-keys.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + Cases []struct { + Name string `json:"name"` + HostUUID string `json:"hostUuid"` + PeerRecordID string `json:"peerRecordId"` + IdentityVerification string `json:"identityVerification"` + Want string `json:"want"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if len(fixture.Cases) == 0 { + t.Fatal("empty record-key fixture") + } + for _, c := range fixture.Cases { + t.Run(c.Name, func(t *testing.T) { + node := DirectoryNode{HostUUID: c.HostUUID, PeerRecordID: c.PeerRecordID, IdentityVerification: c.IdentityVerification} + if got := node.RecordKey(); got != c.Want { + t.Fatalf("RecordKey() = %q, want %q", got, c.Want) + } + if got := RecordSelector(c.Want).RecordKey(); got != c.Want { + t.Fatalf("RecordSelector round trip = %q, want %q", got, c.Want) + } + }) + } +} From dc9ebe1424a52d0aa49c77278388a998dd472b08 Mon Sep 17 00:00:00 2001 From: Jared Ping <13906915+JaredP94@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:54:52 +0200 Subject: [PATCH 2/3] fix(desktop): offer Pair in the Add node modal on provenance, not claims Review follow-up. The Add node modal's discovered list still showed "In another cluster" and disabled Invite whenever a node reported clustered: true, so an unauthenticated preview could hide pairing with a public claim, and it keyed rows by node id rather than the record key. It now matches the cluster settings list: rows are keyed by the record key, labelled "Not in your cluster", and only previews get a Pair action, by address through the PIN exchange. The shared rule is canPairByAddress. A modal regression covers a clustered preview. Signed-off-by: Jared Ping <13906915+JaredP94@users.noreply.github.com> --- desktop/src/shared/utils/node-record-key.ts | 10 ++++ desktop/src/ui/components/AddNodeModal.tsx | 49 ++++++++++++------- .../ClusterSettings/AvailableNode.tsx | 5 +- .../peer-verification-rendering.test.ts | 38 ++++++++++++++ 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/desktop/src/shared/utils/node-record-key.ts b/desktop/src/shared/utils/node-record-key.ts index 61029b366..faabd6e5f 100644 --- a/desktop/src/shared/utils/node-record-key.ts +++ b/desktop/src/shared/utils/node-record-key.ts @@ -28,6 +28,16 @@ export function isPairPeer(record: NodeRecordIdentity): boolean { ) } +/** + * Only an unverified PAIR preview is offered pairing, by address through the + * PIN exchange. Its public cluster claim is not trusted either way: the target + * reports any real rejection. Verified peers and manual engines are never + * offered it. + */ +export function canPairByAddress(record: NodeRecordIdentity): boolean { + return record.identityVerification !== 'manual' && nodeRecordKey(record).startsWith('preview:') +} + export function nodeVerificationLabel(record: NodeRecordIdentity): string { if (isPairPeer(record)) return '' return record.identityVerification === 'manual' || nodeRecordKey(record).startsWith('manual:') diff --git a/desktop/src/ui/components/AddNodeModal.tsx b/desktop/src/ui/components/AddNodeModal.tsx index d54abde9e..c6329bbae 100644 --- a/desktop/src/ui/components/AddNodeModal.tsx +++ b/desktop/src/ui/components/AddNodeModal.tsx @@ -16,6 +16,11 @@ import { } from '@nvidia/foundations-react-core' import { DialogHeader } from './DialogHeader' import { InvitePairingPanel } from './InvitePairingPanel' +import { + canPairByAddress, + nodeRecordKey, + nodeVerificationLabel +} from '@/shared/utils/node-record-key' import { useBlurOnOpen } from '@/ui/hooks/useBlurOnOpen' import { useInvitePairing } from '@/ui/hooks/useInvitePairing' import { useInvitablePeers } from '@/ui/hooks/useInvitablePeers' @@ -101,7 +106,7 @@ export function AddNodeModal({ open, onOpenChange }: AddNodeModalProps) { {nodesThatCanBeAdded.map(node => ( - {node.clustered - ? 'In another cluster' - : `${node.ipAddress}:${node.port}`} + {nodeVerificationLabel(node)} + + + {node.ipAddress} - + {canPairByAddress(node) && ( + + )} ))} diff --git a/desktop/src/ui/components/ClusterSettings/AvailableNode.tsx b/desktop/src/ui/components/ClusterSettings/AvailableNode.tsx index f123aacaa..657479558 100644 --- a/desktop/src/ui/components/ClusterSettings/AvailableNode.tsx +++ b/desktop/src/ui/components/ClusterSettings/AvailableNode.tsx @@ -4,13 +4,12 @@ import { Button, Flex, Stack, Text } from '@nvidia/foundations-react-core' import type { AvailableNode as AvailableNodeType } from '@/shared/types/cluster' import { InvitePairingPanel } from '@/ui/components/InvitePairingPanel' -import { nodeRecordKey, nodeVerificationLabel } from '@/shared/utils/node-record-key' +import { canPairByAddress, nodeVerificationLabel } from '@/shared/utils/node-record-key' import { useInvitePairing } from '@/ui/hooks/useInvitePairing' export default function AvailableNode({ node }: { node: AvailableNodeType }) { const pairing = useInvitePairing() - const canPair = - node.identityVerification !== 'manual' && nodeRecordKey(node).startsWith('preview:') + const canPair = canPairByAddress(node) const showPairing = pairing.invite !== null || pairing.error !== null const inviteInFlight = pairing.submitting || pairing.invite?.state === 'pending' diff --git a/desktop/tests/modular/peer-verification-rendering.test.ts b/desktop/tests/modular/peer-verification-rendering.test.ts index 18b97d13d..f0ab10500 100644 --- a/desktop/tests/modular/peer-verification-rendering.test.ts +++ b/desktop/tests/modular/peer-verification-rendering.test.ts @@ -6,6 +6,7 @@ import { act, createElement } from 'react' import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import AvailableNode from '@/ui/components/ClusterSettings/AvailableNode' +import { AddNodeModal } from '@/ui/components/AddNodeModal' import NodeCardDetails from '@/ui/components/NodeList/NodeCardDetails' import { useNodesStore } from '@/ui/stores/nodes.store' import { useDiscoveredNodesStore } from '@/ui/stores/discovered-nodes.store' @@ -120,6 +121,43 @@ describe('node verification presentation', () => { expect(inviteNode).toHaveBeenCalledWith('192.0.2.10') }) + it('offers Pair in the Add node modal for a preview whose public cluster claim is set', async () => { + // jsdom has no modal API; the modal calls showModal()/close(). + Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { + configurable: true, + value(this: HTMLDialogElement) { + this.open = true + } + }) + Object.defineProperty(HTMLDialogElement.prototype, 'close', { + configurable: true, + value(this: HTMLDialogElement) { + this.open = false + } + }) + useDiscoveredNodesStore.setState({ + nodes: [ + { ...preview, trusted: false, clustered: true, lastSeen: 0 }, + { ...manual, trusted: false, clustered: false, lastSeen: 0 } + ] + }) + await act(async () => + root.render(createElement(AddNodeModal, { open: true, onOpenChange: vi.fn() })) + ) + expect(document.body.textContent).toContain('Not in your cluster') + expect(document.body.textContent).not.toContain('In another cluster') + const buttons = Array.from(document.body.querySelectorAll('button')) + const pair = buttons.filter(button => button.textContent === 'Pair') + expect(pair).toHaveLength(1) + expect(pair[0]?.disabled).toBe(false) + await act(async () => pair[0]?.click()) + expect(inviteNode).toHaveBeenCalledWith('192.0.2.10') + await act(async () => root.render(null)) + useDiscoveredNodesStore.setState({ nodes: [] }) + Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal') + Reflect.deleteProperty(HTMLDialogElement.prototype, 'close') + }) + it('does not infer a version diagnosis from unverified metadata', () => { expect(nodeVerificationLabel({ ...preview, identityVerification: undefined })).toBe( 'Not in your cluster' From ad170b87e26b72d835185dda87121bbabee683ba Mon Sep 17 00:00:00 2001 From: Jared Ping <13906915+JaredP94@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:04:28 +0200 Subject: [PATCH 3/3] test(desktop): clean up dialog stubs and discovered nodes in afterEach Review follow-up. The Add node modal regression removed its dialog stubs and cleared the discovered-node store only after its assertions passed, so a failure would leak them into later tests. The file's afterEach now does both unconditionally. Signed-off-by: Jared Ping <13906915+JaredP94@users.noreply.github.com> --- desktop/tests/modular/peer-verification-rendering.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/tests/modular/peer-verification-rendering.test.ts b/desktop/tests/modular/peer-verification-rendering.test.ts index f0ab10500..255162f48 100644 --- a/desktop/tests/modular/peer-verification-rendering.test.ts +++ b/desktop/tests/modular/peer-verification-rendering.test.ts @@ -66,6 +66,10 @@ beforeEach(() => { afterEach(async () => { await act(async () => root.unmount()) container.remove() + // Runs even when an assertion fails, so no test leaks into the next. + useDiscoveredNodesStore.setState({ nodes: [] }) + Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal') + Reflect.deleteProperty(HTMLDialogElement.prototype, 'close') vi.unstubAllGlobals() }) @@ -152,10 +156,6 @@ describe('node verification presentation', () => { expect(pair[0]?.disabled).toBe(false) await act(async () => pair[0]?.click()) expect(inviteNode).toHaveBeenCalledWith('192.0.2.10') - await act(async () => root.render(null)) - useDiscoveredNodesStore.setState({ nodes: [] }) - Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal') - Reflect.deleteProperty(HTMLDialogElement.prototype, 'close') }) it('does not infer a version diagnosis from unverified metadata', () => {