@mmmbuto/nexuscrew 0.8.43 → 0.8.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-DNepgdI5.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-JrKi-YpL.css">
14
+ <script type="module" crossorigin src="/assets/index-DNFEGdog.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-BAq6N1Md.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.43"}
1
+ {"version":"0.8.45"}
@@ -56,10 +56,11 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
56
56
 
57
57
  async function status() {
58
58
  const fleet = await fleetP;
59
- if (!fleet || fleet.available !== true || typeof fleet.status !== 'function') {
59
+ const statusFn = fleet && (typeof fleet.cellStatus === 'function' ? fleet.cellStatus : fleet.status);
60
+ if (!fleet || fleet.available !== true || typeof statusFn !== 'function') {
60
61
  return { available: false, cells: [] };
61
62
  }
62
- return fleet.status();
63
+ return statusFn.call(fleet);
63
64
  }
64
65
 
65
66
  r.get('/', async (_req, res) => {
@@ -264,7 +264,10 @@ function status(opts = {}) {
264
264
  remotePort: red.remotePort, localPort: red.localPort,
265
265
  direction: red.direction, shared: red.shared, hasToken: red.hasToken,
266
266
  tunnel: n.direction === 'inbound'
267
- ? { status: n.shared === true ? 'shared-peer' : 'private-peer', managed: false }
267
+ ? {
268
+ status: n.shared === true ? 'shared-peer' : 'private-peer', managed: false,
269
+ share: n.shared === true ? 'enabled' : 'disabled',
270
+ }
268
271
  : nodesTunnel.readTunnelState(home, n.name),
269
272
  };
270
273
  });
@@ -281,7 +284,7 @@ function status(opts = {}) {
281
284
  log(`port: ${out.port}`);
282
285
  log(`url: ${out.url}`);
283
286
  log(`roles: client=${out.roles.client} node=${out.roles.node}`);
284
- log(`nodes: ${out.nodes.length === 0 ? '(nessuno)' : out.nodes.map((n) => `${n.name}[${n.tunnel.status}]`).join(', ')}`);
287
+ log(`nodes: ${out.nodes.length === 0 ? '(nessuno)' : out.nodes.map((n) => `${n.name}[${n.tunnel.status}${n.tunnel.share ? `, Share ${n.tunnel.share}` : ''}]`).join(', ')}`);
285
288
  if (platform === 'termux') log(`boot: ${out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script'}`);
286
289
  }
287
290
  return out;
@@ -18,11 +18,40 @@ function readPidfile(p) {
18
18
  } catch (_) { return null; }
19
19
  }
20
20
 
21
+ function currentUid() {
22
+ try { return typeof process.getuid === 'function' ? process.getuid() : null; }
23
+ catch (_) { return null; }
24
+ }
25
+
26
+ // `/proc/<pid>/stat` field 22 is the kernel start tick. Unlike a PID or an
27
+ // argv it cannot be recreated by a later process. macOS has no /proc, so a
28
+ // conservative `ps lstart` fallback still combines with UID, argv and runId.
29
+ function readProcessStart(pid) {
30
+ try {
31
+ const raw = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').trim();
32
+ const match = raw.match(/^\d+\s+\([^)]*\)\s+(.+)$/);
33
+ const fields = match && match[1].trim().split(/\s+/);
34
+ const ticks = fields && fields[19]; // field 22, after state=field 3
35
+ if (/^\d+$/.test(String(ticks || ''))) return `linux:${ticks}`;
36
+ } catch (_) {}
37
+ try {
38
+ const text = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' }).trim();
39
+ return text ? `ps:${text}` : null;
40
+ } catch (_) { return null; }
41
+ }
42
+
21
43
  // Exclusive create (wx): fallisce se il pidfile esiste già (no overwrite silenzioso).
22
44
  function writePidfile(p, pid, cmd, extra = {}) {
23
45
  fs.mkdirSync(path.dirname(p), { recursive: true });
24
46
  const safeExtra = extra && typeof extra === 'object' && !Array.isArray(extra) ? extra : {};
25
- const meta = JSON.stringify({ pid, cmd: cmd || '', startTs: Date.now(), ...safeExtra });
47
+ const processStart = readProcessStart(pid);
48
+ const uid = currentUid();
49
+ const meta = JSON.stringify({
50
+ pid, cmd: cmd || '', startTs: Date.now(),
51
+ ...(uid === null ? {} : { uid }),
52
+ ...(processStart ? { processStart } : {}),
53
+ ...safeExtra,
54
+ });
26
55
  fs.writeFileSync(p, meta + '\n', { flag: 'wx', mode: 0o600 });
27
56
  }
28
57
 
@@ -78,6 +107,19 @@ function isAlive(meta, impl = {}) {
78
107
  return true;
79
108
  }
80
109
 
110
+ // Strong ownership used by per-slot reverse supervisors. Older generic
111
+ // pidfiles remain readable for lifecycle compatibility, but a rotatable slot
112
+ // is never stopped or adopted unless all four local facts are present.
113
+ function isAttributable(meta, impl = {}) {
114
+ if (!meta || !Number.isFinite(meta.pid) || !Number.isInteger(meta.uid)
115
+ || typeof meta.processStart !== 'string' || !meta.processStart) return false;
116
+ const uid = impl.currentUidImpl ? impl.currentUidImpl() : currentUid();
117
+ if (uid === null || uid !== meta.uid) return false;
118
+ if (!isAlive(meta, impl)) return false;
119
+ const liveStart = (impl.readProcessStartImpl || readProcessStart)(meta.pid);
120
+ return typeof liveStart === 'string' && liveStart === meta.processStart;
121
+ }
122
+
81
123
  // Rimuove pidfile stale (pid morto o non verificabile). Ritorna true se rimosso.
82
124
  function cleanStale(p, impl = {}) {
83
125
  const meta = readPidfile(p);
@@ -122,5 +164,6 @@ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
122
164
 
123
165
  module.exports = {
124
166
  defaultPidfilePath, readPidfile, writePidfile, removePidfile,
125
- pidOwnership, pidExists, readCmdline, isAlive, cleanStale, killPidfile,
167
+ currentUid, readProcessStart, pidOwnership, pidExists, readCmdline,
168
+ isAlive, isAttributable, cleanStale, killPidfile,
126
169
  };
@@ -296,7 +296,7 @@ async function createBuiltinFleet(cfg = {}) {
296
296
  cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot, ensureProtection,
297
297
  });
298
298
  const {
299
- status, up, down, restart, isCellSession,
299
+ status, cellStatus, up, down, restart, isCellSession,
300
300
  reloadDefs, findCell, findEngine, refreshSessions, commitDefs,
301
301
  } = rt;
302
302
 
@@ -861,7 +861,7 @@ async function createBuiltinFleet(cfg = {}) {
861
861
  return {
862
862
  available: true,
863
863
  provider: 'builtin',
864
- status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
864
+ status, cellStatus, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
865
865
  defineEngine, editEngine, removeEngine,
866
866
  defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
867
867
  schema, definitions, capabilities,
@@ -503,12 +503,24 @@ async function discoverOllamaModels(opts = {}) {
503
503
  }
504
504
  }
505
505
 
506
+ // Una discovery esterna non deve mai consumare l'intero budget del bridge MCP
507
+ // (10 s): il caller ha ancora margine per serializzare la directory e fallire
508
+ // in modo diagnostico. Ogni futura discovery tramite binario deve usare lo
509
+ // stesso contratto bounded + negative-cache, non una retry ad ogni richiesta.
510
+ const EXTERNAL_DISCOVERY_TIMEOUT_MS = 5000;
506
511
  let piCache = { at: 0, providers: {} };
507
512
  let piInFlight = null;
513
+ function copyPiProviders(providers) {
514
+ return Object.fromEntries(Object.entries(providers).map(([key, models]) => [key, [...models]]));
515
+ }
516
+
508
517
  async function discoverPiModels(opts = {}) {
509
518
  const now = Date.now(); const ttl = opts.ttlMs === undefined ? 300000 : opts.ttlMs;
510
- if (!opts.noCache && Object.keys(piCache.providers).length && now - piCache.at < ttl) {
511
- return Object.fromEntries(Object.entries(piCache.providers).map(([k, v]) => [k, [...v]]));
519
+ // `at`, non il contenuto, rende valida anche una failure cacheata: una lista
520
+ // vuota e' un risultato operativo, non il segnale di rilanciare un binario
521
+ // eventualmente bloccato ad ogni richiesta.
522
+ if (!opts.noCache && piCache.at > 0 && now - piCache.at < ttl) {
523
+ return copyPiProviders(piCache.providers);
512
524
  }
513
525
  const home = opts.home || require('node:os').homedir();
514
526
  const binary = opts.binary || findBinary('pi', home);
@@ -516,24 +528,38 @@ async function discoverPiModels(opts = {}) {
516
528
  if (!opts.noCache && piInFlight) return piInFlight;
517
529
  const execFileImpl = opts.execFileImpl || execFile;
518
530
  const load = async () => {
519
- const stdout = await new Promise((resolve, reject) => {
520
- execFileImpl(binary, ['--list-models'], { encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 }, (err, out) => {
521
- if (err) reject(err); else resolve(String(out || ''));
531
+ try {
532
+ const stdout = await new Promise((resolve, reject) => {
533
+ execFileImpl(binary, ['--list-models'], {
534
+ encoding: 'utf8', timeout: opts.timeoutMs === undefined ? EXTERNAL_DISCOVERY_TIMEOUT_MS : opts.timeoutMs,
535
+ maxBuffer: 1024 * 1024,
536
+ }, (err, out) => {
537
+ if (err) reject(err); else resolve(String(out || ''));
538
+ });
522
539
  });
523
- });
524
- const providers = {};
525
- for (const line of stdout.split(/\r?\n/).slice(1)) {
526
- const [provider, model] = line.trim().split(/\s+/);
527
- if (!PROVIDER_ID_RE.test(provider || '') || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(model || '')) continue;
528
- (providers[provider] ||= []).push(model);
540
+ const providers = {};
541
+ for (const line of stdout.split(/\r?\n/).slice(1)) {
542
+ const [provider, model] = line.trim().split(/\s+/);
543
+ if (!PROVIDER_ID_RE.test(provider || '') || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(model || '')) continue;
544
+ (providers[provider] ||= []).push(model);
545
+ }
546
+ for (const key of Object.keys(providers)) providers[key] = [...new Set(providers[key])];
547
+ piCache = { at: now, providers };
548
+ return copyPiProviders(providers);
549
+ } catch (_) {
550
+ // Cache negativa: una failure (timeout compreso) vale per il TTL intero.
551
+ // Questo mantiene le route Fleet disponibili anche quando un binario di
552
+ // discovery e' installato ma non risponde.
553
+ // `noCache` e' un refresh diagnostico richiesto dall'operatore: se
554
+ // fallisce non deve avvelenare una cache condivisa ancora valida.
555
+ if (!opts.noCache) piCache = { at: now, providers: {} };
556
+ return {};
529
557
  }
530
- for (const key of Object.keys(providers)) providers[key] = [...new Set(providers[key])];
531
- piCache = { at: now, providers };
532
- return Object.fromEntries(Object.entries(providers).map(([k, v]) => [k, [...v]]));
533
558
  };
534
- if (opts.noCache) {
535
- try { return await load(); } catch (_) { return {}; }
536
- }
559
+ if (opts.noCache) return load();
560
+ // load() assorbe gia' gli errori operativi. Il catch e' una cintura per una
561
+ // futura regressione: chi aspetta il single-flight non deve mai ricevere un
562
+ // rejection che renda la directory Fleet indisponibile.
537
563
  piInFlight = load().catch(() => ({})).finally(() => { piInFlight = null; });
538
564
  return piInFlight;
539
565
  }
@@ -893,7 +919,7 @@ module.exports = {
893
919
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
894
920
  CLIENT_LABELS, normalizeManagedSpec, profileFor,
895
921
  defaultDefinitions, defaultShellEngine, defaultAgyEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
896
- discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
922
+ discoverPiModels, EXTERNAL_DISCOVERY_TIMEOUT_MS, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
897
923
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
898
924
  credentialEnvNeutralizeSet, applyStoreNeutralization,
899
925
  ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
@@ -69,7 +69,10 @@ function createBuiltinRuntime(ctx) {
69
69
  return set;
70
70
  }
71
71
 
72
- async function status() {
72
+ // La directory cella e il trasporto MCP dipendono soltanto da definizioni e
73
+ // tmux. Tenerla separata dai cataloghi modello evita che un binario esterno
74
+ // lento trasformi `/api/cells` in un falso guasto della flotta.
75
+ async function cellStatus() {
73
76
  if (Date.now() - cache.at > STATUS_TTL_MS) {
74
77
  reloadDefs(); // pick-up di edit esterne/file
75
78
  const sessions = await refreshSessions();
@@ -98,10 +101,26 @@ function createBuiltinRuntime(ctx) {
98
101
  rc: '', key: '', degraded: false, // supervisor vivo <=> sessione tmux viva
99
102
  };
100
103
  });
104
+ return {
105
+ available: true,
106
+ provider: 'builtin',
107
+ bootOwner: 'builtin',
108
+ reason: cfg.fleetProviderReason || 'fleet.json definitions',
109
+ cells,
110
+ };
111
+ }
112
+
113
+ async function status() {
114
+ const base = await cellStatus();
101
115
  const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
102
- const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
103
116
  const needsPi = cache.defs.engines.some((e) => e.managed?.client === 'pi');
104
- const piModels = needsPi ? await discoverPiModels({ ...cfg, home }) : {};
117
+ // Le discovery esterne hanno budget propri. Avviarle in parallelo mantiene
118
+ // il budget dello status sotto quello del bridge invece di sommare i timeout
119
+ // di Ollama e Pi in sequenza.
120
+ const [ollamaModels, piModels] = await Promise.all([
121
+ needsOllama ? discoverOllamaModels({ ...cfg, home }) : [],
122
+ needsPi ? discoverPiModels({ ...cfg, home }) : {},
123
+ ]);
105
124
  const engines = cache.defs.engines.map((e) => {
106
125
  const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
107
126
  return {
@@ -121,13 +140,7 @@ function createBuiltinRuntime(ctx) {
121
140
  } : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
122
141
  };
123
142
  });
124
- return {
125
- available: true,
126
- provider: 'builtin',
127
- bootOwner: 'builtin', // §9b: la UI non puo' mentire su chi possiede il boot
128
- reason: cfg.fleetProviderReason || 'fleet.json definitions',
129
- cells, engines,
130
- };
143
+ return { ...base, engines };
131
144
  }
132
145
 
133
146
  function isCellSession(name) {
@@ -424,7 +437,7 @@ function createBuiltinRuntime(ctx) {
424
437
  }
425
438
 
426
439
  return {
427
- status, up, down, restart, isCellSession,
440
+ status, cellStatus, up, down, restart, isCellSession,
428
441
  reloadDefs, findCell, findEngine, refreshSessions, commitDefs,
429
442
  };
430
443
  }
package/lib/mcp/cells.js CHANGED
@@ -121,6 +121,30 @@ function normalizeCellPayload(payload, owner, callerSession = null) {
121
121
  return out;
122
122
  }
123
123
 
124
+ function unavailableOwner(owner, error) {
125
+ let current = error;
126
+ for (let depth = 0; current && depth < 4; depth += 1, current = current.cause) {
127
+ if (current.code === 'NEXUSCREW_HTTP_TIMEOUT') {
128
+ return {
129
+ instanceId: owner.instanceId,
130
+ owner: owner.label,
131
+ route: owner.route.length ? owner.route.join('/') : 'local',
132
+ ...(owner.route.length === 0 ? { local: true } : {}),
133
+ failure: 'timeout',
134
+ };
135
+ }
136
+ }
137
+ const message = String(error && error.message || error || '');
138
+ const name = String(error && error.name || '');
139
+ return {
140
+ instanceId: owner.instanceId,
141
+ owner: owner.label,
142
+ route: owner.route.length ? owner.route.join('/') : 'local',
143
+ ...(owner.route.length === 0 ? { local: true } : {}),
144
+ failure: /timeout/i.test(name) || /\btimeout\b/i.test(message) ? 'timeout' : 'unreachable',
145
+ };
146
+ }
147
+
124
148
  async function readCellDirectory(ctx, callerSession = null) {
125
149
  const [config, topology] = await Promise.all([
126
150
  ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'),
@@ -136,9 +160,8 @@ async function readCellDirectory(ctx, callerSession = null) {
136
160
  if (!apiPath) return;
137
161
  try {
138
162
  cells.push(...normalizeCellPayload(await ctx.api('GET', apiPath), owner, callerSession));
139
- } catch (_) {
140
- unavailable.push({ instanceId: owner.instanceId, owner: owner.label,
141
- route: owner.route.length ? owner.route.join('/') : 'local' });
163
+ } catch (error) {
164
+ unavailable.push(unavailableOwner(owner, error));
142
165
  }
143
166
  }));
144
167
  cells.sort((a, b) => (a.route === 'local' ? -1 : b.route === 'local' ? 1
@@ -150,5 +173,5 @@ async function readCellDirectory(ctx, callerSession = null) {
150
173
  module.exports = {
151
174
  NODE_PART_RE, NODE_ID_RE, CELL_ID_RE,
152
175
  orderedDeckMembers, fleetStatusPath, fleetCellsBySession, routePath,
153
- topologyOwners, memberOwnerId, parseCellTarget, normalizeCellPayload, readCellDirectory,
176
+ topologyOwners, memberOwnerId, parseCellTarget, normalizeCellPayload, unavailableOwner, readCellDirectory,
154
177
  };
package/lib/mcp/server.js CHANGED
@@ -34,6 +34,20 @@ const cells = require('./cells.js');
34
34
  // Versione protocollo di fallback se il client non ne dichiara una valida.
35
35
  const PROTOCOL_FALLBACK = '2025-03-26';
36
36
  const HTTP_TIMEOUT_MS = 10000;
37
+ const HTTP_TIMEOUT_CODE = 'NEXUSCREW_HTTP_TIMEOUT';
38
+ const HTTP_UNREACHABLE_CODE = 'NEXUSCREW_HTTP_UNREACHABLE';
39
+
40
+ // Trasporta la causa in forma strutturata tra bridge e directory celle. Il
41
+ // messaggio resta per l'operatore, ma la classificazione non dipende dalla
42
+ // lingua o da una regex sul testo prodotto da un altro modulo.
43
+ function transportError(baseUrl, cause) {
44
+ const timeout = !!(cause && (cause.name === 'TimeoutError' || cause.code === 'ABORT_ERR' || cause.code === 'ETIMEDOUT'));
45
+ const error = new Error(`NexusCrew non raggiungibile su ${baseUrl} (${timeout ? 'timeout' : 'server spento?'})`);
46
+ error.name = 'NexusCrewTransportError';
47
+ error.code = timeout ? HTTP_TIMEOUT_CODE : HTTP_UNREACHABLE_CODE;
48
+ error.cause = cause;
49
+ return error;
50
+ }
37
51
 
38
52
  // JSON-RPC error codes standard.
39
53
  const PARSE_ERROR = -32700;
@@ -199,9 +213,7 @@ function createMcpServer(opts = {}) {
199
213
  ...(payload !== undefined ? { body: payload } : {}),
200
214
  signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
201
215
  });
202
- } catch (e) {
203
- throw new Error(`NexusCrew non raggiungibile su ${baseUrl} (${e && e.name === 'TimeoutError' ? 'timeout' : 'server spento?'})`);
204
- }
216
+ } catch (e) { throw transportError(baseUrl, e); }
205
217
  const j = await r.json().catch(() => ({}));
206
218
  if (!r.ok) throw new Error(j.error ? `API ${r.status}: ${j.error}` : `API ${r.status}`);
207
219
  return j;
@@ -351,6 +363,7 @@ function startMcp(opts = {}) {
351
363
 
352
364
  module.exports = {
353
365
  createMcpServer, startMcp, resolveSession, resolveIdentity, TOOLS,
366
+ PROTOCOL_FALLBACK, HTTP_TIMEOUT_MS, HTTP_TIMEOUT_CODE, HTTP_UNREACHABLE_CODE, transportError,
354
367
  parseCellTarget: cells.parseCellTarget,
355
368
  normalizeCellPayload: cells.normalizeCellPayload,
356
369
  readCellDirectory: cells.readCellDirectory,
@@ -16,6 +16,7 @@ const path = require('node:path');
16
16
  const { execFileSync } = require('node:child_process');
17
17
  const store = require('./store.js');
18
18
  const tunnel = require('./tunnel.js');
19
+ const reversePool = require('./reverse-pool.js');
19
20
  const topologyCache = require('./topology-cache.js');
20
21
  const inventory = require('./inventory.js');
21
22
  const federation = require('../proxy/federation.js');
@@ -364,6 +365,22 @@ function nodesRemove(opts) {
364
365
  log(`nodes remove: impossibile fermare il tunnel (${e.message}); config preservata`);
365
366
  return { code: 1, reason: 'tunnel stop failed' };
366
367
  }
368
+ // Pools allocated to an inbound peer are monotonic even after removal: its
369
+ // old SSH key can still hold permitlisten grants, so a future peer must never
370
+ // inherit those ports. Ledger first, anchor second preserves the safe
371
+ // crash direction (an ahead ledger is reconciled by the allocator).
372
+ if (node.direction === 'inbound' && node.reversePool) {
373
+ const ledgerPath = opts.reversePoolLedgerPath || reversePool.defaultLedgerPath(home);
374
+ const ledger = reversePool.loadLedger(ledgerPath);
375
+ const checked = ledger && reversePool.validateLedgerAnchor(ledger, st.reversePoolAnchor);
376
+ if (!checked || !checked.ok) {
377
+ log('nodes remove: ledger reverse non verificabile; config preservata');
378
+ return { code: 1, reason: 'reverse pool ledger invalid' };
379
+ }
380
+ const retired = reversePool.appendLedger(ledger, { type: 'retired', base: node.reversePool.base });
381
+ reversePool.atomicWriteLedger(ledgerPath, retired);
382
+ next = { ...next, reversePoolAnchor: reversePool.ledgerHead(retired) };
383
+ }
367
384
  store.atomicWriteStore(nodesPath, next);
368
385
  log(`nodes remove: nodo "${name}" rimosso${stopped ? ' (tunnel attivo fermato)' : ''}`);
369
386
  return { code: 0, name, stopped };
@@ -47,7 +47,7 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
47
47
 
48
48
  let health;
49
49
  if (node.direction === 'inbound') {
50
- if (node.shared !== true) {
50
+ if (!node.token && node.shared !== true) {
51
51
  health = {
52
52
  transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'passive',
53
53
  detail: 'client privato collegato (Share disattivato)', expected: true, managed: false, at: now,
@@ -61,10 +61,31 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
61
61
  const probed = await probeHealth({
62
62
  port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
63
63
  });
64
+ // Un client privato non dovrebbe avere alcun -R. Se la sua porta inbound
65
+ // risponde e la federation conferma proprio quel peer, e' un reverse
66
+ // residuo (per esempio un supervisor pre-upgrade): non lo pubblichiamo e
67
+ // non lo terminiamo, ma smettiamo di dichiararlo "passive".
68
+ if (node.shared !== true) {
69
+ if (probed.transport === 'down') {
70
+ health = {
71
+ transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'passive',
72
+ detail: 'client privato offline (nessun reverse atteso)', expected: true, managed: false, at: now,
73
+ };
74
+ } else if (probed.status === 'healthy') {
75
+ health = {
76
+ ...probed, status: 'degraded', code: 'private-reverse-listener', expected: false, managed: false,
77
+ detail: 'canale reverse attivo nonostante Share disattivato: verificare e riconnettere il peer prima di riattivare Share',
78
+ };
79
+ } else {
80
+ health = {
81
+ ...probed, status: 'degraded', code: 'private-inbound-listener', expected: false, managed: false,
82
+ detail: `porta inbound privata in ascolto ma peer non verificato (${probed.detail || 'health non valida'})`,
83
+ };
84
+ }
64
85
  // The receiving side does not own an inbound client's lifecycle. A
65
86
  // client-only (or legacy unknown-role) peer being offline is expected,
66
87
  // not a broken server. Live auth/payload failures remain real failures.
67
- if (probed.transport === 'down' && (node.rolesKnown !== true || node.roles?.node !== true)) {
88
+ } else if (probed.transport === 'down' && (node.rolesKnown !== true || node.roles?.node !== true)) {
68
89
  health = {
69
90
  ...probed, status: 'passive', expected: true, managed: false,
70
91
  detail: node.rolesKnown === true ? 'client peer offline (expected)' : 'inbound peer offline',