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..faabd6e5f --- /dev/null +++ b/desktop/src/shared/utils/node-record-key.ts @@ -0,0 +1,46 @@ +// 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') + ) +} + +/** + * 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:') + ? 'Engine only — added by address' + : 'Not in your cluster' +} 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 e5b957909..657479558 100644 --- a/desktop/src/ui/components/ClusterSettings/AvailableNode.tsx +++ b/desktop/src/ui/components/ClusterSettings/AvailableNode.tsx @@ -4,10 +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 { 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 = canPairByAddress(node) const showPairing = pairing.invite !== null || pairing.error !== null const inviteInFlight = pairing.submitting || pairing.invite?.state === 'pending' @@ -27,22 +29,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..255162f48 --- /dev/null +++ b/desktop/tests/modular/peer-verification-rendering.test.ts @@ -0,0 +1,233 @@ +// 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 { 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' +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() + // 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() +}) + +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('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') + }) + + 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) + } + }) + } +}