11// Gateway node catalog builder.
22// Merges paired devices, approved node records, and live websocket sessions.
3- import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce" ;
3+ import {
4+ normalizeLowercaseStringOrEmpty ,
5+ normalizeOptionalString ,
6+ } from "@openclaw/normalization-core/string-coerce" ;
47import { normalizeSortedUniqueTrimmedStringList } from "@openclaw/normalization-core/string-normalization" ;
58import { hasEffectivePairedDeviceRole , type PairedDevice } from "../infra/device-pairing.js" ;
69import {
@@ -77,6 +80,28 @@ function uniqueSortedStrings(...items: Array<readonly unknown[] | undefined>): s
7780 return normalizeSortedUniqueTrimmedStringList ( items . flatMap ( ( item ) => item ?? [ ] ) ) ;
7881}
7982
83+ // Catalog scalars come from blind-cast pairing records, so coerce every formatter-facing optional
84+ // string scalar to a trimmed string or undefined: a non-string would crash `nodes status`/`nodes
85+ // list` formatters (.trim(), sanitizeTerminalText/stripAnsi). Scalar analog of uniqueSortedStrings.
86+ function firstNormalizedString ( ...values : unknown [ ] ) : string | undefined {
87+ // Treat a non-string (or empty) higher-priority value as ABSENT and fall through, instead of
88+ // letting `find` pick the first non-null value and normalize it to undefined — which would
89+ // suppress a valid lower-priority string. Return the first value that yields a trimmed string.
90+ for ( const value of values ) {
91+ const normalized = normalizeOptionalString ( value ) ;
92+ if ( normalized !== undefined ) {
93+ return normalized ;
94+ }
95+ }
96+ return undefined ;
97+ }
98+
99+ // Blind-cast pairing records can carry a non-string id; a node with no addressable string
100+ // id is unusable and would crash the id-based catalog sort/format, so drop it entirely.
101+ function hasAddressableId ( value : unknown ) : boolean {
102+ return normalizeOptionalString ( value ) !== undefined ;
103+ }
104+
80105function buildDevicePairingSource ( entry : PairedDevice ) : KnownNodeDevicePairingSource {
81106 return {
82107 nodeId : entry . deviceId ,
@@ -182,7 +207,7 @@ function resolveEffectiveLastSeen(params: {
182207 }
183208 return {
184209 lastSeenAtMs : newest . atMs ,
185- lastSeenReason : newest . reason ,
210+ lastSeenReason : normalizeOptionalString ( newest . reason ) ,
186211 } ;
187212}
188213
@@ -197,30 +222,59 @@ function buildEffectiveKnownNode(entry: {
197222 const lastSeen = resolveEffectiveLastSeen ( { live, devicePairing, nodePairing } ) ;
198223 return {
199224 nodeId,
200- displayName :
201- live ?. displayName ??
202- nodePairing ?. displayName ??
203- devicePairing ?. displayName ??
225+ displayName : firstNormalizedString (
226+ live ?. displayName ,
227+ nodePairing ?. displayName ,
228+ devicePairing ?. displayName ,
204229 pendingNodePairing ?. displayName ,
205- platform :
206- live ?. platform ??
207- nodePairing ?. platform ??
208- devicePairing ?. platform ??
230+ ) ,
231+ platform : firstNormalizedString (
232+ live ?. platform ,
233+ nodePairing ?. platform ,
234+ devicePairing ?. platform ,
209235 pendingNodePairing ?. platform ,
210- version : live ?. version ?? nodePairing ?. version ?? pendingNodePairing ?. version ,
211- coreVersion : live ?. coreVersion ?? nodePairing ?. coreVersion ?? pendingNodePairing ?. coreVersion ,
212- uiVersion : live ?. uiVersion ?? nodePairing ?. uiVersion ?? pendingNodePairing ?. uiVersion ,
213- clientId : live ?. clientId ?? devicePairing ?. clientId ?? pendingNodePairing ?. clientId ,
214- clientMode : live ?. clientMode ?? devicePairing ?. clientMode ?? pendingNodePairing ?. clientMode ,
215- deviceFamily :
216- live ?. deviceFamily ?? nodePairing ?. deviceFamily ?? pendingNodePairing ?. deviceFamily ,
217- modelIdentifier :
218- live ?. modelIdentifier ?? nodePairing ?. modelIdentifier ?? pendingNodePairing ?. modelIdentifier ,
219- remoteIp :
220- live ?. remoteIp ??
221- nodePairing ?. remoteIp ??
222- devicePairing ?. remoteIp ??
236+ ) ,
237+ version : firstNormalizedString (
238+ live ?. version ,
239+ nodePairing ?. version ,
240+ pendingNodePairing ?. version ,
241+ ) ,
242+ coreVersion : firstNormalizedString (
243+ live ?. coreVersion ,
244+ nodePairing ?. coreVersion ,
245+ pendingNodePairing ?. coreVersion ,
246+ ) ,
247+ uiVersion : firstNormalizedString (
248+ live ?. uiVersion ,
249+ nodePairing ?. uiVersion ,
250+ pendingNodePairing ?. uiVersion ,
251+ ) ,
252+ clientId : firstNormalizedString (
253+ live ?. clientId ,
254+ devicePairing ?. clientId ,
255+ pendingNodePairing ?. clientId ,
256+ ) ,
257+ clientMode : firstNormalizedString (
258+ live ?. clientMode ,
259+ devicePairing ?. clientMode ,
260+ pendingNodePairing ?. clientMode ,
261+ ) ,
262+ deviceFamily : firstNormalizedString (
263+ live ?. deviceFamily ,
264+ nodePairing ?. deviceFamily ,
265+ pendingNodePairing ?. deviceFamily ,
266+ ) ,
267+ modelIdentifier : firstNormalizedString (
268+ live ?. modelIdentifier ,
269+ nodePairing ?. modelIdentifier ,
270+ pendingNodePairing ?. modelIdentifier ,
271+ ) ,
272+ remoteIp : firstNormalizedString (
273+ live ?. remoteIp ,
274+ nodePairing ?. remoteIp ,
275+ devicePairing ?. remoteIp ,
223276 pendingNodePairing ?. remoteIp ,
277+ ) ,
224278 caps : live ? uniqueSortedStrings ( live . caps ) : uniqueSortedStrings ( nodePairing ?. caps ) ,
225279 commands : live
226280 ? uniqueSortedStrings ( live . commands )
@@ -271,15 +325,22 @@ export function createKnownNodeCatalog(params: {
271325} ) : KnownNodeCatalog {
272326 const devicePairingById = new Map (
273327 params . pairedDevices
274- . filter ( ( entry ) => hasEffectivePairedDeviceRole ( entry , "node" ) )
328+ . filter (
329+ ( entry ) => hasAddressableId ( entry . deviceId ) && hasEffectivePairedDeviceRole ( entry , "node" ) ,
330+ )
275331 . map ( ( entry ) => [ entry . deviceId , buildDevicePairingSource ( entry ) ] ) ,
276332 ) ;
277333 const nodePairingById = new Map (
278- ( params . pairedNodes ?? [ ] ) . map ( ( entry ) => [ entry . nodeId , buildApprovedNodeSource ( entry ) ] ) ,
334+ ( params . pairedNodes ?? [ ] )
335+ . filter ( ( entry ) => hasAddressableId ( entry . nodeId ) )
336+ . map ( ( entry ) => [ entry . nodeId , buildApprovedNodeSource ( entry ) ] ) ,
279337 ) ;
280338 const pendingNodePairingById = new Map < string , KnownNodePendingSource > ( ) ;
281339 // listNodePairing returns newest requests first; keep the current approval action per node.
282340 for ( const entry of params . pendingNodes ?? [ ] ) {
341+ if ( ! hasAddressableId ( entry . nodeId ) ) {
342+ continue ;
343+ }
283344 if ( ! pendingNodePairingById . has ( entry . nodeId ) ) {
284345 pendingNodePairingById . set ( entry . nodeId , buildPendingNodeSource ( entry ) ) ;
285346 }
0 commit comments