@mmmbuto/nexuscrew 0.8.47 → 0.8.49

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-Db2ivuxA.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-BAq6N1Md.css">
14
+ <script type="module" crossorigin src="/assets/index-BZ1dY5k6.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-43DFO1EH.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.47"}
1
+ {"version":"0.8.49"}
@@ -5,6 +5,19 @@ const { isValidSession } = require('../files/store.js');
5
5
  const { submitTextOk } = require('../tmux/actions.js');
6
6
 
7
7
  const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
8
+ const CELL_LABEL_MAX = 64;
9
+
10
+ // Una label che esce da questo nodo, o che arriva da un altro, e' testo
11
+ // AUTO-DICHIARATO: la definizione locale e' gia' validata dal parser, ma il
12
+ // payload che si espone e quello che si riceve vanno delimitati comunque.
13
+ // Senza questo un peer puo' far attraversare la directory a una stringa lunga
14
+ // e con a capo, che ogni consumatore poi renderizza.
15
+ function safeCellLabel(value) {
16
+ if (typeof value !== 'string') return '';
17
+ const trimmed = value.trim();
18
+ if (!trimmed || trimmed.length > CELL_LABEL_MAX) return '';
19
+ return /[\x00-\x1f\x7f]/.test(trimmed) ? '' : trimmed;
20
+ }
8
21
  const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
9
22
  const MESSAGE_ID_RE = /^[a-f0-9-]{16,64}$/;
10
23
 
@@ -24,6 +37,10 @@ function publicCells(status, instanceId, now = Date.now()) {
24
37
  id: `${instanceId}:${raw.cell}`,
25
38
  instanceId,
26
39
  cell: raw.cell,
40
+ // Il nome leggibile viaggia accanto all'id, mai al suo posto: chi riceve
41
+ // questa voce deve poter capire che ruolo occupa la cella senza dover
42
+ // interpretare un identificatore scelto da un altro nodo.
43
+ label: safeCellLabel(raw.label),
27
44
  tmuxSession: raw.tmuxSession,
28
45
  engine: typeof raw.engine === 'string' ? raw.engine : '',
29
46
  model: typeof raw.model === 'string' ? raw.model : '',
@@ -135,4 +152,4 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
135
152
  return r;
136
153
  }
137
154
 
138
- module.exports = { cellsRoutes, publicCells, parseVisited, validIdentity };
155
+ module.exports = { cellsRoutes, publicCells, parseVisited, validIdentity, safeCellLabel };
@@ -507,7 +507,10 @@ async function createBuiltinFleet(cfg = {}) {
507
507
  if (!Array.isArray(cells) || cells.length < 1 || cells.length > MAX_CELLS) {
508
508
  throw httpError(400, `cells deve contenere 1..${MAX_CELLS} definizioni`);
509
509
  }
510
- const allowed = new Set(['id', 'cwd', 'cwdRel', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'commands', 'prompt']);
510
+ // `label` e' parte della definizione quanto `prompt`: senza di essa qui, un
511
+ // backup che la contiene verrebbe rifiutato in restore e il round-trip si
512
+ // spezzerebbe proprio sulle celle a cui e' stato dato un nome.
513
+ const allowed = new Set(['id', 'cwd', 'cwdRel', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'commands', 'prompt', 'label']);
511
514
  const seen = new Set();
512
515
  for (const cell of cells) {
513
516
  if (!cell || typeof cell !== 'object' || Array.isArray(cell)) throw httpError(400, 'definizione cell non valida');
@@ -864,6 +867,9 @@ async function createBuiltinFleet(cfg = {}) {
864
867
  permissionPolicies: { type: 'object', required: false, keyRef: 'engine.id', valueEnum: ['standard', 'unsafe'] },
865
868
  commands: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_CELL_COMMAND_LEN, managedClient: 'shell' },
866
869
  prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
870
+ // Nome leggibile, distinto dall'id: quest'ultimo resta la chiave di
871
+ // indirizzamento e l'unica origine della sessione tmux.
872
+ label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN },
867
873
  },
868
874
  };
869
875
  }
@@ -378,6 +378,21 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
378
378
  prompt = c.prompt;
379
379
  }
380
380
 
381
+ // label (opzionale): nome LEGGIBILE della cella, distinto dall'id.
382
+ // L'id resta la chiave stabile con cui si indirizza una cella e con cui si
383
+ // deriva la sessione tmux; la label e' solo cio' che un umano legge. Senza
384
+ // questa distinzione l'id fa anche da nome, e un nodo che battezza la propria
385
+ // cella come il motore la espone cosi' a tutta la rete: chi la riceve non ha
386
+ // modo di sapere che ruolo occupa. Stessa regola gia' usata per la label
387
+ // degli engine e per quella dei nodi: stampabile, non vuota, max 64.
388
+ let label;
389
+ if (c.label !== undefined) {
390
+ if (typeof c.label !== 'string') return null;
391
+ const trimmed = c.label.trim();
392
+ if (!trimmed || trimmed.length > MAX_LABEL_LEN || !isPrintable(trimmed)) return null;
393
+ label = trimmed;
394
+ }
395
+
381
396
  // tmuxSession: campo esplicito o derivato da id. UNIVOCO (check in caller).
382
397
  // Il nome CANONICO e' tmux-safe (v2 per id puntati): tmux normalizza '.' in
383
398
  // '_' nei nomi sessione, per cui `cloud-agy.native` diverrebbe `cloud-agy_native`
@@ -422,6 +437,7 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
422
437
  if (permissionPolicies) out.permissionPolicies = permissionPolicies;
423
438
  if (commands && Object.keys(commands).length) out.commands = commands;
424
439
  if (prompt !== undefined) out.prompt = prompt;
440
+ if (label !== undefined) out.label = label;
425
441
  if (legacyTmuxSession) {
426
442
  Object.defineProperty(out, 'legacyTmuxSession', {
427
443
  value: legacyTmuxSession, enumerable: false, configurable: false,
@@ -93,7 +93,9 @@ function createBuiltinRuntime(ctx) {
93
93
  ? 'standard'
94
94
  : (remembered || engineDefault || '');
95
95
  return {
96
- cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
96
+ // `cell` resta l'id: e' la chiave di indirizzamento. `label` e' il nome
97
+ // leggibile e viaggia accanto, senza mai sostituirlo.
98
+ cell: c.id, label: c.label || '', tmuxSession: c.tmuxSession, engine: c.engine,
97
99
  model: c.model || '', models: { ...(c.models || {}) },
98
100
  permissionPolicy: effectivePolicy,
99
101
  permissionPolicies: { ...(c.permissionPolicies || {}) },
package/lib/mcp/cells.js CHANGED
@@ -8,6 +8,9 @@
8
8
  // l'astrazione `ctx.api` (loopback + Bearer del bridge); ACL e identita'
9
9
  // owner-qualified sono applicate lato server HTTP.
10
10
  const { isValidSession } = require('../files/store.js');
11
+ // Stesso validatore usato in uscita: una label federata non deve poter entrare
12
+ // qui in una forma che noi non produrremmo mai.
13
+ const { safeCellLabel } = require('../cells/routes.js');
11
14
 
12
15
  const NODE_PART_RE = /^[a-z0-9-]{1,32}$/;
13
16
  const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
@@ -109,6 +112,14 @@ function normalizeCellPayload(payload, owner, callerSession = null) {
109
112
  owner: owner.label,
110
113
  route,
111
114
  cell: raw.cell,
115
+ // `cell` indirizza, `label` si legge. Un nodo che chiama la propria cella
116
+ // come il motore la esporrebbe cosi' a tutta la rete: senza un nome
117
+ // separato chi la riceve non ha modo di sapere che ruolo occupa.
118
+ // La label di una cella REMOTA e' testo auto-dichiarato: si delimita con
119
+ // lo stesso validatore usato in uscita, e si marca come riferita perche'
120
+ // chi legge distingua cio' che sappiamo da cio' che ci e' stato detto.
121
+ label: safeCellLabel(raw.label),
122
+ labelReported: owner.route.length > 0,
112
123
  tmuxSession: raw.tmuxSession,
113
124
  engine: typeof raw.engine === 'string' ? raw.engine : '',
114
125
  model: typeof raw.model === 'string' ? raw.model : '',
package/lib/mcp/tools.js CHANGED
@@ -260,7 +260,7 @@ const TOOLS = [
260
260
  if (f && f.available) {
261
261
  fleet = {
262
262
  cells: (Array.isArray(f.cells) ? f.cells : []).map((c) => ({
263
- cell: c.cell, session: c.tmuxSession, engine: c.engine, active: !!c.active,
263
+ cell: c.cell, label: c.label || '', session: c.tmuxSession, engine: c.engine, active: !!c.active,
264
264
  })),
265
265
  };
266
266
  }
@@ -42,6 +42,11 @@ function routedPeer(entry) {
42
42
  const route = Array.isArray(entry.route) ? [...entry.route] : [];
43
43
  return {
44
44
  name: entry.name,
45
+ // Riferita dal nodo che ce l'ha inoltrata, non verificata da noi: si mostra
46
+ // perche' senza di essa un nodo in transito e' solo uno slug, ma resta un
47
+ // dato di seconda mano e non sostituisce l'instanceId.
48
+ label: typeof entry.label === 'string' ? entry.label : '',
49
+ labelReported: true,
45
50
  nodeId: entry.instanceId,
46
51
  instanceId: entry.instanceId,
47
52
  kind: 'transitive',
@@ -75,4 +75,25 @@ function quarantineSlot(pool, { slot, now = Date.now() } = {}) {
75
75
  return next;
76
76
  }
77
77
 
78
- module.exports = { LEASE_MS, GRACE_MS, nextReadySlot, prepareRotation, abortPrepared, commitRotation, settleGrace, quarantineSlot };
78
+
79
+ // Esito di uno spegnimento che puo' riguardare PIU' entry insieme: durante la
80
+ // grace di una rotazione lo slot vecchio e quello nuovo coesistono. Una
81
+ // chiusura riuscita su una sola non e' una chiusura: dichiararla tale
82
+ // lascerebbe l'altra viva mentre chi chiama registra il canale come privato.
83
+ // `no pidfile` e `stale (pid dead)` contano come spente: li' non e' rimasto
84
+ // nulla di vivo da attribuire.
85
+ const STOP_ALREADY_GONE = ['no pidfile', 'stale (pid dead)'];
86
+
87
+ function stopWasDemonstrated(result) {
88
+ if (!result) return false;
89
+ return result.stopped === true || STOP_ALREADY_GONE.includes(result.reason);
90
+ }
91
+
92
+ function summarizeStops(results) {
93
+ const list = Array.isArray(results) ? results : [];
94
+ const stoppedAny = list.some(stopWasDemonstrated);
95
+ const quarantinedAny = list.some((result) => !stopWasDemonstrated(result));
96
+ return { stoppedAny, quarantinedAny, allClosed: stoppedAny && !quarantinedAny };
97
+ }
98
+
99
+ module.exports = { LEASE_MS, GRACE_MS, nextReadySlot, prepareRotation, abortPrepared, commitRotation, settleGrace, quarantineSlot, stopWasDemonstrated, summarizeStops };
@@ -23,8 +23,16 @@ function close(server) {
23
23
  });
24
24
  }
25
25
 
26
- function createReverseSlotListeners({ app, createServerImpl = http.createServer, diagnostics } = {}) {
26
+ function createReverseSlotListeners({ app, createServerImpl = http.createServer, diagnostics, attachUpgrade } = {}) {
27
27
  if (typeof app !== 'function') throw new Error('reverse slot listeners richiede app HTTP');
28
+ // Fail-closed: servire `app` non basta: il routing degli upgrade WS vive
29
+ // sull'istanza `server`, non sull'app Express. Un listener senza handler di
30
+ // upgrade e' HTTP-only e degrada in silenzio (la SPA risponde 200 a un
31
+ // upgrade). Obbligare la dipendenza rende impossibile ricreare il difetto
32
+ // per dimenticanza, invece di affidarsi a un test che enumera gli ingressi.
33
+ if (typeof attachUpgrade !== 'function') {
34
+ throw new Error('reverse slot listeners richiede attachUpgrade');
35
+ }
28
36
  const listeners = new Map(); // local target port -> owned server + immutable expected tuple
29
37
 
30
38
  async function open({ nodeName, remotePort, generation, instanceId, secret }) {
@@ -33,6 +41,7 @@ function createReverseSlotListeners({ app, createServerImpl = http.createServer,
33
41
  }
34
42
  if (typeof secret !== 'string' || !secret) throw new Error('reverse slot listener credential mancante');
35
43
  const server = createServerImpl(app);
44
+ attachUpgrade(server);
36
45
  let address;
37
46
  try { address = await listen(server, { host: '127.0.0.1', port: 0, exclusive: true }); }
38
47
  catch (error) { try { server.close(); } catch (_) {} throw error; }
@@ -96,6 +96,16 @@ async function probeReverseSlot({ port, secret, expected, fetchImpl = fetch, tim
96
96
  const response = await fetchImpl(`http://127.0.0.1:${port}/reverse-slot-proof`, {
97
97
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(probe), signal: ctrl.signal,
98
98
  });
99
+ // Un 409 e' la RISPOSTA di un listener corretto che ha valutato la tupla e
100
+ // l'ha rifiutata: e' un esito, non un'assenza. Collassarlo in
101
+ // "non ottenuta" lo renderebbe indistinguibile da un timeout, e chi
102
+ // classifica gli errori tratterebbe come transitorio un mismatch reale.
103
+ if (response && response.status === 409) {
104
+ const failure = await response.json().catch(() => null);
105
+ const code = failure && typeof failure.code === 'string' && /^[a-z0-9-]{1,64}$/.test(failure.code)
106
+ ? failure.code : 'reverse-slot-proof-mismatch';
107
+ return { owned: false, code };
108
+ }
99
109
  if (!response || response.status !== 200) return { owned: false, code: 'reverse-slot-proof-unavailable' };
100
110
  const body = await response.json().catch(() => null);
101
111
  return verifySlotProof({ secret, expected, challenge: probe, response: body });
@@ -17,13 +17,22 @@ function defaultPath(home = os.homedir()) {
17
17
  function parseEntry(raw) {
18
18
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
19
19
  const keys = Object.keys(raw).sort();
20
- if (keys.some((k) => !['instanceId', 'lastSeen', 'name', 'route'].includes(k))) return null;
20
+ if (keys.some((k) => !['instanceId', 'lastSeen', 'name', 'route', 'label'].includes(k))) return null;
21
21
  if (!NODE_ID_RE.test(raw.instanceId) || !NODE_NAME_RE.test(raw.name)) return null;
22
22
  if (!Array.isArray(raw.route) || raw.route.length < 2 || raw.route.length > MAX_HOPS) return null;
23
23
  if (raw.route.some((x) => !NODE_NAME_RE.test(x)) || new Set(raw.route).size !== raw.route.length) return null;
24
24
  if (raw.name !== raw.route[raw.route.length - 1]) return null;
25
25
  if (!Number.isInteger(raw.lastSeen) || raw.lastSeen < 0) return null;
26
- return { instanceId: raw.instanceId, name: raw.name, route: [...raw.route], lastSeen: raw.lastSeen };
26
+ // La label persiste solo se rispetta la stessa forma accettata altrove: un
27
+ // file di cache e' un ingresso come un altro e non deve poter reintrodurre
28
+ // testo che non produrremmo mai.
29
+ const label = typeof raw.label === 'string' && raw.label.trim()
30
+ && raw.label.trim().length <= 64 && !/[\x00-\x1f\x7f]/.test(raw.label.trim())
31
+ ? raw.label.trim() : null;
32
+ return {
33
+ instanceId: raw.instanceId, name: raw.name, route: [...raw.route],
34
+ ...(label ? { label } : {}), lastSeen: raw.lastSeen,
35
+ };
27
36
  }
28
37
 
29
38
  function parseCache(raw) {
@@ -17,6 +17,48 @@ const { signHop, HOP_HEADER } = require('./hop-proof.js');
17
17
  const MAX_HOPS = 4;
18
18
  const ROUTE_DELIMITER = '_';
19
19
  const TOPOLOGY_PEER_TIMEOUT_MS = 1500;
20
+ // Finestra di attesa del reverse channel su Share ON: limitata per non
21
+ // diventare un retry storm, ma sufficiente al bind subito dopo un pairing.
22
+ const SHARE_HEALTH_ATTEMPTS = 10;
23
+ const SHARE_HEALTH_DELAY_MS = 300;
24
+ const SHARE_NOT_READY_CODE = 'share-channel-not-ready';
25
+ // 500/502/503/504 sono indisponibilita' che passano; 501 e 505 no.
26
+ const TRANSIENT_HTTP_STATUS = new Set([500, 502, 503, 504]);
27
+ // Esiti della prova di slot che significano "non ottenuta" e non "sbagliata".
28
+ const SLOT_PROOF_UNAVAILABLE = 'reverse-slot-proof-unavailable';
29
+
30
+ // Classificazione dell'esito di Share ON. La regola e' fail-closed sul
31
+ // RITENTATIVO: e' ritentabile solo cio' che e' dimostrabilmente transitorio —
32
+ // il canale non ancora salito. Tutto il resto (credenziale non valida, nodo
33
+ // sbagliato in fondo al tunnel, prova di slot non corrispondente) e' un guasto
34
+ // che il tempo non ripara, e ritentarlo nasconde all'operatore la vera causa.
35
+ function classifyShareFailure(health) {
36
+ const detail = (health && health.detail) || 'reverse SSH non pronto';
37
+ if (!health) return { code: 'share-peer-unreachable', detail };
38
+ // Credenziale non valida: serve un re-pair, il tempo non la ripara.
39
+ if (health.auth === 'failed') return { code: 'share-peer-unauthorized', detail };
40
+ if (health.slotProof === true) {
41
+ // Attenzione: `probeReverseSlot` marca "non posseduta" anche quando la
42
+ // prova non e' stata OTTENUTA — timeout, connessione rifiutata, risposta
43
+ // non 200 — che e' un canale non ancora su, non una prova sbagliata.
44
+ // Confonderli renderebbe finale proprio il caso che vogliamo attendere.
45
+ return health.code === SLOT_PROOF_UNAVAILABLE
46
+ ? { code: SHARE_NOT_READY_CODE, detail }
47
+ : { code: 'share-slot-proof-failed', detail };
48
+ }
49
+ if (health.transport === 'down') return { code: SHARE_NOT_READY_CODE, detail };
50
+ // Il peer risponde ma con un errore server: indisponibilita' temporanea, non
51
+ // il nodo sbagliato in fondo al tunnel. Allowlist esplicita invece di "ogni
52
+ // 5xx": 501 e 505 dicono che quella richiesta non sara' MAI servita, e
53
+ // ritentarle sarebbe rumore.
54
+ if (TRANSIENT_HTTP_STATUS.has(health.httpStatus)) {
55
+ return { code: SHARE_NOT_READY_CODE, detail };
56
+ }
57
+ // Qui il peer ha risposto 200 ma non e' quello atteso (instanceId o payload):
58
+ // ritentare non lo trasforma nel nodo giusto.
59
+ if (health.reachability === 'failed') return { code: 'share-peer-mismatch', detail };
60
+ return { code: 'share-peer-unreachable', detail };
61
+ }
20
62
 
21
63
  function peerFromToken(nodesPath, token) {
22
64
  const st = store.loadStore(nodesPath);
@@ -381,7 +423,18 @@ async function notifyHubShare({ node, shared, fetchImpl = fetch, timeoutMs = 500
381
423
  }, budget);
382
424
  });
383
425
  const response = await Promise.race([request, timeout]);
384
- if (!response || !response.ok) throw new Error(`hub Share HTTP ${response && response.status || 'unknown'}`);
426
+ if (!response || !response.ok) {
427
+ const failure = new Error(`hub Share HTTP ${response && response.status || 'unknown'}`);
428
+ failure.status = response && response.status;
429
+ // Si estrae SOLO il codice tipizzato: il corpo remoto non entra mai nel
430
+ // messaggio d'errore ne' nei log. Un corpo non-JSON lascia il codice
431
+ // assente e l'errore resta definitivo, che e' il default sicuro.
432
+ try {
433
+ const body = typeof response.json === 'function' ? await response.json() : null;
434
+ if (body && typeof body.code === 'string' && body.code.length <= 64) failure.code = body.code;
435
+ } catch (_) { /* corpo assente o non-JSON: nessun codice */ }
436
+ throw failure;
437
+ }
385
438
  return { shared };
386
439
  } finally { clearTimeout(timer); }
387
440
  }
@@ -593,7 +646,14 @@ async function collectTopologyDetailed({
593
646
  if (!n.nodeId || seen.has(n.nodeId)
594
647
  || (!ingress && n.direction === 'inbound' && n.shared !== true)
595
648
  || (ingress && !canTransit(ingress, n))) continue;
596
- out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
649
+ // `name` e' lo slug locale con cui si indirizza; `label` e' il nome
650
+ // leggibile. Senza quest'ultimo un nodo raggiunto in transito arriva agli
651
+ // altri come uno slug scelto da qualcun altro, e cinque installazioni
652
+ // diverse si presentano con lo stesso nome.
653
+ out.push({
654
+ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true,
655
+ label: store.validLabel(n.label) ? n.label.trim() : '',
656
+ });
597
657
  if (ttl <= 1 || !n.token) continue;
598
658
  probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs }) });
599
659
  }
@@ -611,7 +671,15 @@ async function collectTopologyDetailed({
611
671
  || new Set(child.route).size !== child.route.length
612
672
  || child.name !== child.route[child.route.length - 1]
613
673
  || child.route.includes(n.name)) continue;
614
- out.push({ instanceId: child.instanceId, name: child.name, route: [n.name, ...child.route], direct: false });
674
+ // La label di un nodo in transito e' testo AUTO-DICHIARATO da un altro
675
+ // nodo: si accetta solo se rispetta la stessa forma di una label locale,
676
+ // altrimenti si resta senza nome invece di propagare qualcosa di
677
+ // arbitrario. Non e' mai una prova di identita': quella e' l'instanceId.
678
+ out.push({
679
+ instanceId: child.instanceId, name: child.name,
680
+ route: [n.name, ...child.route], direct: false,
681
+ label: store.validLabel(child.label) ? child.label.trim() : '',
682
+ });
615
683
  }
616
684
  }
617
685
  const ids = new Set(); const routes = new Set(); const unique = [];
@@ -653,7 +721,15 @@ async function collectLocalTopology({
653
721
  next.set(entry.instanceId, entry);
654
722
  }
655
723
  for (const n of live.nodes) {
656
- if (n.route.length > 1) next.set(n.instanceId, { instanceId: n.instanceId, name: n.name, route: [...n.route], lastSeen: now });
724
+ // La cache conserva anche la label: senza, un nodo che torna stale perde il
725
+ // nome e ricompare come slug, cioe' esattamente il difetto che si voleva
726
+ // togliere. Resta un dato riferito, non una prova.
727
+ if (n.route.length > 1) {
728
+ next.set(n.instanceId, {
729
+ instanceId: n.instanceId, name: n.name, route: [...n.route],
730
+ ...(n.label ? { label: n.label } : {}), lastSeen: now,
731
+ });
732
+ }
657
733
  }
658
734
  const cached = [...next.values()].sort((a, b) => a.route.join('/').localeCompare(b.route.join('/'))).slice(0, topologyCache.MAX_ENTRIES);
659
735
  const serialized = { schemaVersion: topologyCache.SCHEMA_VERSION, nodes: cached };
@@ -895,12 +971,19 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
895
971
  }
896
972
  try {
897
973
  if (body.shared) {
974
+ // Il reverse channel viene stabilito dal peer subito prima di questa
975
+ // chiamata: appena dopo un pairing il bind non e' ancora pronto e la
976
+ // finestra breve trasformava un'attesa in un fallimento definitivo,
977
+ // con rollback dell'intera transazione lato peer. La finestra resta
978
+ // limitata (nessun retry storm) ma copre il caso reale.
898
979
  let health;
899
980
  if (req.peer.reversePool) {
900
- for (let attempt = 0; attempt < 6; attempt += 1) {
981
+ for (let attempt = 0; attempt < SHARE_HEALTH_ATTEMPTS; attempt += 1) {
901
982
  health = await probeReverseOwner(req.peer, fetchImpl || fetch);
902
983
  if (health.status === 'healthy') break;
903
- if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 200));
984
+ if (attempt < SHARE_HEALTH_ATTEMPTS - 1) {
985
+ await new Promise((resolve) => setTimeout(resolve, SHARE_HEALTH_DELAY_MS));
986
+ }
904
987
  }
905
988
  } else {
906
989
  health = await waitForHealthyPeer({
@@ -908,14 +991,19 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
908
991
  token: req.peer.token,
909
992
  expectedInstanceId: req.peer.nodeId || null,
910
993
  fetchImpl: fetchImpl || fetch,
911
- attempts: 6,
912
- delayMs: 200,
994
+ attempts: SHARE_HEALTH_ATTEMPTS,
995
+ delayMs: SHARE_HEALTH_DELAY_MS,
913
996
  });
914
997
  }
915
998
  if (health.status !== 'healthy') {
999
+ // Codice tipizzato: chi chiama deve poter distinguere un'attesa da un
1000
+ // guasto definitivo senza interpretare una stringa. Solo il canale
1001
+ // non ancora salito e' ritentabile.
1002
+ const failure = classifyShareFailure(health);
916
1003
  return res.status(409).json({
917
1004
  error: 'canale share non raggiungibile',
918
- detail: health.detail || 'reverse SSH non pronto',
1005
+ code: failure.code,
1006
+ detail: failure.detail,
919
1007
  });
920
1008
  }
921
1009
  }
@@ -980,7 +1068,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
980
1068
  function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
981
1069
 
982
1070
  module.exports = {
983
- MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
1071
+ MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure,
984
1072
  peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
985
1073
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
986
1074
  probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
package/lib/server.js CHANGED
@@ -187,9 +187,20 @@ function createServer(opts = {}) {
187
187
  const key = reverseKey(active.port, expectedGeneration);
188
188
  const existing = entries.get(key);
189
189
  if (existing) return existing;
190
+ // Il listener prova la PROPRIA identita' a chi compone la sfida, e l'altro
191
+ // capo la costruisce con l'id di QUESTA installazione (probeReverseOwner usa
192
+ // `peer.nodeId`, cioe' noi visti da lui). `node.nodeId` e' invece l'id del
193
+ // nodo REMOTO — lo store lo impone, rifiutando come self-reference una voce
194
+ // il cui nodeId eguagli il proprio. Registrarlo faceva firmare due tuple
195
+ // diverse ai due capi: la prova non tornava mai e Share si spegneva con un
196
+ // errore definitivo, non ritentabile.
197
+ const localInstanceId = (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
198
+ // Fail-closed: senza identita' locale il listener nascerebbe con una prova
199
+ // che nessuno puo' validare. Meglio non aprirlo che aprirlo indimostrabile.
200
+ if (!localInstanceId) return null;
190
201
  const listener = await reverseSlotListeners.open({
191
202
  nodeName: node.name, remotePort: active.port, generation: expectedGeneration,
192
- instanceId: node.nodeId, secret: node.acceptToken,
203
+ instanceId: localInstanceId, secret: node.acceptToken,
193
204
  });
194
205
  let launched = nodesTunnel.startReverseForward({
195
206
  home: cfg.home || os.homedir(), node, remotePort: active.port, generation: expectedGeneration,
@@ -227,15 +238,19 @@ function createServer(opts = {}) {
227
238
  }
228
239
  const selected = [...entries.values()].filter((entry) => (remotePort === null || entry.remotePort === remotePort)
229
240
  && (generation === null || entry.generation === generation));
230
- let stopped = false;
241
+ // Il valore di ritorno significa "TUTTE le entry selezionate sono spente in
242
+ // modo dimostrabile". Durante la grace di una rotazione ne coesistono due:
243
+ // dichiarare successo perche' UNA e' stata chiusa lascerebbe l'altra viva
244
+ // mentre chi chiama registra il canale come privato.
245
+ const outcomes = [];
231
246
  for (const existing of selected) {
232
247
  const result = nodesTunnel.stopTunnel({ home: cfg.home || os.homedir(), name: nodesTunnel.reverseTunnelName(name, existing.remotePort, existing.generation) });
248
+ outcomes.push(result);
233
249
  // If we cannot prove ownership we neither signal nor close the listener:
234
250
  // the channel is quarantined for diagnostics rather than broken by us.
235
- if (!result.stopped && !['no pidfile', 'stale (pid dead)'].includes(result.reason)) continue;
251
+ if (!reverseRotation.stopWasDemonstrated(result)) continue;
236
252
  entries.delete(reverseKey(existing.remotePort, existing.generation));
237
253
  await reverseSlotListeners?.closePort(existing.localPort);
238
- stopped = true;
239
254
  }
240
255
  if (!entries.size) rotatableReverse.delete(name);
241
256
  if (remotePort === null && generation === null) {
@@ -243,7 +258,11 @@ function createServer(opts = {}) {
243
258
  if (watcher) clearInterval(watcher);
244
259
  reverseWatchers.delete(name);
245
260
  }
246
- return stopped;
261
+ // La decisione vive in `summarizeStops` (funzione pura, testabile senza
262
+ // processi): successo solo se qualcosa e' stato spento E niente e' rimasto
263
+ // in quarantena. Il caso "nessuna entry" resta false come prima: non si
264
+ // puo' dimostrare nulla.
265
+ return reverseRotation.summarizeStops(outcomes).allClosed;
247
266
  }
248
267
  async function verifyRotatablePool(node) {
249
268
  const pool = node?.reversePool;
@@ -473,7 +492,12 @@ function createServer(opts = {}) {
473
492
  }
474
493
 
475
494
  const app = express();
476
- reverseSlotListeners = createReverseSlotListeners({ app, diagnostics, createServerImpl: cfg.reverseSlotCreateServerImpl });
495
+ reverseSlotListeners = createReverseSlotListeners({
496
+ app, diagnostics, createServerImpl: cfg.reverseSlotCreateServerImpl,
497
+ // `routeUpgrade` e' hoisted: qui si cattura solo il riferimento, la
498
+ // chiamata avviene quando arriva un upgrade, a server avviato.
499
+ attachUpgrade: (slotServer) => slotServer.on('upgrade', routeUpgrade),
500
+ });
477
501
  const distDir = path.join(__dirname, '..', 'frontend', 'dist');
478
502
  // no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
479
503
  app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
@@ -795,7 +819,13 @@ function createServer(opts = {}) {
795
819
  }, opts.wsHeartbeatMs || 30000);
796
820
  if (typeof heartbeat.unref === 'function') heartbeat.unref();
797
821
  server.on('close', () => clearInterval(heartbeat));
798
- server.on('upgrade', (req, socket, head) => {
822
+ // Routing dell'upgrade WS. Dichiarata come funzione (hoisted nello scope di
823
+ // createServer) perche' i listener per-slot nascono PRIMA di questo punto e
824
+ // devono ricevere lo STESSO routing: un listener che serve `app` senza
825
+ // handler di upgrade fa cadere l'upgrade su Express, che non ha una rotta
826
+ // HTTP `/ws` e risponde con la SPA (200) invece di 101 -> terminale nero su
827
+ // ogni peer raggiunto via reverse pool.
828
+ function routeUpgrade(req, socket, head) {
799
829
  let pathname;
800
830
  try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
801
831
  catch (_) { try { socket.destroy(); } catch (_e) {} return; }
@@ -826,7 +856,8 @@ function createServer(opts = {}) {
826
856
  return;
827
857
  }
828
858
  try { socket.destroy(); } catch (_) {}
829
- });
859
+ }
860
+ server.on('upgrade', routeUpgrade);
830
861
  wss.on('connection', (ws, req) => {
831
862
  ws.isAlive = true;
832
863
  ws.on('pong', () => { ws.isAlive = true; });