@mmmbuto/nexuscrew 0.8.47 → 0.8.48

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-C_SyIZ78.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.48"}
@@ -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
@@ -227,15 +227,19 @@ function createServer(opts = {}) {
227
227
  }
228
228
  const selected = [...entries.values()].filter((entry) => (remotePort === null || entry.remotePort === remotePort)
229
229
  && (generation === null || entry.generation === generation));
230
- let stopped = false;
230
+ // Il valore di ritorno significa "TUTTE le entry selezionate sono spente in
231
+ // modo dimostrabile". Durante la grace di una rotazione ne coesistono due:
232
+ // dichiarare successo perche' UNA e' stata chiusa lascerebbe l'altra viva
233
+ // mentre chi chiama registra il canale come privato.
234
+ const outcomes = [];
231
235
  for (const existing of selected) {
232
236
  const result = nodesTunnel.stopTunnel({ home: cfg.home || os.homedir(), name: nodesTunnel.reverseTunnelName(name, existing.remotePort, existing.generation) });
237
+ outcomes.push(result);
233
238
  // If we cannot prove ownership we neither signal nor close the listener:
234
239
  // the channel is quarantined for diagnostics rather than broken by us.
235
- if (!result.stopped && !['no pidfile', 'stale (pid dead)'].includes(result.reason)) continue;
240
+ if (!reverseRotation.stopWasDemonstrated(result)) continue;
236
241
  entries.delete(reverseKey(existing.remotePort, existing.generation));
237
242
  await reverseSlotListeners?.closePort(existing.localPort);
238
- stopped = true;
239
243
  }
240
244
  if (!entries.size) rotatableReverse.delete(name);
241
245
  if (remotePort === null && generation === null) {
@@ -243,7 +247,11 @@ function createServer(opts = {}) {
243
247
  if (watcher) clearInterval(watcher);
244
248
  reverseWatchers.delete(name);
245
249
  }
246
- return stopped;
250
+ // La decisione vive in `summarizeStops` (funzione pura, testabile senza
251
+ // processi): successo solo se qualcosa e' stato spento E niente e' rimasto
252
+ // in quarantena. Il caso "nessuna entry" resta false come prima: non si
253
+ // puo' dimostrare nulla.
254
+ return reverseRotation.summarizeStops(outcomes).allClosed;
247
255
  }
248
256
  async function verifyRotatablePool(node) {
249
257
  const pool = node?.reversePool;
@@ -473,7 +481,12 @@ function createServer(opts = {}) {
473
481
  }
474
482
 
475
483
  const app = express();
476
- reverseSlotListeners = createReverseSlotListeners({ app, diagnostics, createServerImpl: cfg.reverseSlotCreateServerImpl });
484
+ reverseSlotListeners = createReverseSlotListeners({
485
+ app, diagnostics, createServerImpl: cfg.reverseSlotCreateServerImpl,
486
+ // `routeUpgrade` e' hoisted: qui si cattura solo il riferimento, la
487
+ // chiamata avviene quando arriva un upgrade, a server avviato.
488
+ attachUpgrade: (slotServer) => slotServer.on('upgrade', routeUpgrade),
489
+ });
477
490
  const distDir = path.join(__dirname, '..', 'frontend', 'dist');
478
491
  // no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
479
492
  app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
@@ -795,7 +808,13 @@ function createServer(opts = {}) {
795
808
  }, opts.wsHeartbeatMs || 30000);
796
809
  if (typeof heartbeat.unref === 'function') heartbeat.unref();
797
810
  server.on('close', () => clearInterval(heartbeat));
798
- server.on('upgrade', (req, socket, head) => {
811
+ // Routing dell'upgrade WS. Dichiarata come funzione (hoisted nello scope di
812
+ // createServer) perche' i listener per-slot nascono PRIMA di questo punto e
813
+ // devono ricevere lo STESSO routing: un listener che serve `app` senza
814
+ // handler di upgrade fa cadere l'upgrade su Express, che non ha una rotta
815
+ // HTTP `/ws` e risponde con la SPA (200) invece di 101 -> terminale nero su
816
+ // ogni peer raggiunto via reverse pool.
817
+ function routeUpgrade(req, socket, head) {
799
818
  let pathname;
800
819
  try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
801
820
  catch (_) { try { socket.destroy(); } catch (_e) {} return; }
@@ -826,7 +845,8 @@ function createServer(opts = {}) {
826
845
  return;
827
846
  }
828
847
  try { socket.destroy(); } catch (_) {}
829
- });
848
+ }
849
+ server.on('upgrade', routeUpgrade);
830
850
  wss.on('connection', (ws, req) => {
831
851
  ws.isAlive = true;
832
852
  ws.on('pong', () => { ws.isAlive = true; });
@@ -52,7 +52,9 @@ const audioGroups = require('../audio/groups.js');
52
52
  const nodesCmds = require('../nodes/commands.js');
53
53
  const nodesTunnel = require('../nodes/tunnel.js');
54
54
  const peering = require('../nodes/peering.js');
55
- const { waitForHealthyPeer, preflightHubReverse, notifyHubShare, reconcilePeerShare } = require('../proxy/federation.js');
55
+ const {
56
+ waitForHealthyPeer, preflightHubReverse, notifyHubShare, reconcilePeerShare, SHARE_NOT_READY_CODE,
57
+ } = require('../proxy/federation.js');
56
58
  const { rotateToken } = require('../auth/token.js');
57
59
  const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
58
60
  const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
@@ -597,8 +599,45 @@ function settingsRoutes(deps = {}) {
597
599
  const wasShared = node.shared === true;
598
600
  let persistedDesired = wasShared;
599
601
  let shareOnAttempted = false;
602
+ // Traccia se il rollback ha davvero spento il canale reverse. Resta true
603
+ // finche' non si tenta una chiusura che non riesce a dimostrarsi.
604
+ let reverseChannelClosed = true;
605
+ // `close` restituisce false quando non riesce a DIMOSTRARE la proprieta'
606
+ // del supervisor: il canale resta in quarantena, vivo. Una chiusura che
607
+ // LANCIA non e' una chiusura riuscita: stesso esito, altrimenti l'unico
608
+ // caso in cui non sappiamo nulla sarebbe anche l'unico che tace.
609
+ const closeReverseChannel = async () => {
610
+ if (!node.reversePool || typeof reverseSlots?.close !== 'function') return;
611
+ try {
612
+ reverseChannelClosed = (await reverseSlots.close(name)) !== false;
613
+ } catch (_) {
614
+ reverseChannelClosed = false;
615
+ }
616
+ };
600
617
  const fetchImpl = seams.fetchImpl || fetch;
601
- const notifyHub = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
618
+ const notifyHubOnce = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
619
+ // Share ON stabilisce il reverse channel un istante prima di annunciarlo:
620
+ // subito dopo un pairing il bind sul hub non e' ancora pronto e l'hub
621
+ // risponde con un codice tipizzato ritentabile. Senza questo ritentativo
622
+ // limitato l'attesa diventava un fallimento definitivo, con rollback
623
+ // dell'intera transazione e Share che "non funziona" finche' l'operatore
624
+ // non riprova a mano. Qualunque altro errore resta definitivo.
625
+ const NOTIFY_RETRY_DELAYS_MS = [1000, 2000, 3000];
626
+ const notifyDelay = typeof seams.pairDelay === 'function'
627
+ ? seams.pairDelay
628
+ : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
629
+ const notifyHub = async (shared) => {
630
+ if (!shared) return notifyHubOnce(false);
631
+ let last;
632
+ for (let attempt = 0; attempt <= NOTIFY_RETRY_DELAYS_MS.length; attempt += 1) {
633
+ try { return await notifyHubOnce(true); } catch (e) {
634
+ last = e;
635
+ if (!e || e.code !== SHARE_NOT_READY_CODE) throw e;
636
+ if (attempt < NOTIFY_RETRY_DELAYS_MS.length) await notifyDelay(NOTIFY_RETRY_DELAYS_MS[attempt]);
637
+ }
638
+ }
639
+ throw last;
640
+ };
602
641
  const ensureLocal = async ({ restart = false } = {}) => {
603
642
  const stopForward = seams.stopTunnelImpl || nodesTunnel.stopTunnel;
604
643
  const startForward = seams.startForwardImpl || nodesTunnel.startForward;
@@ -639,7 +678,9 @@ function settingsRoutes(deps = {}) {
639
678
  nodesStore.atomicWriteStore(nodesPath, st);
640
679
  persistedDesired = shared;
641
680
  node = nodesStore.getNode(st, name);
642
- if (!shared && node.reversePool && typeof reverseSlots?.close === 'function') await reverseSlots.close(name);
681
+ // Lo stato persistito direbbe "privato" mentre il reverse pool e' ancora
682
+ // su: l'esito della chiusura va riportato a chi chiama, non ignorato.
683
+ if (!shared) await closeReverseChannel();
643
684
  await ensureLocal({ restart: true });
644
685
  };
645
686
  const revokeHub = () => reconcilePeerShare({
@@ -659,7 +700,10 @@ function settingsRoutes(deps = {}) {
659
700
  detail: scrubError(revokeErr),
660
701
  });
661
702
  }
662
- if (node.reversePool && typeof reverseSlots?.close === 'function') await reverseSlots.close(name);
703
+ // Stessa regola del rollback: una chiusura non dimostrabile lascia il
704
+ // canale in quarantena, vivo. Qui finisce in una risposta di SUCCESSO,
705
+ // quindi tacerlo e' peggio: dichiarerebbe revocato cio' che e' sospeso.
706
+ await closeReverseChannel();
663
707
  try {
664
708
  // A changed ON->OFF spec is restarted explicitly; same-state OFF uses
665
709
  // the idempotent spec-aware path, which only replaces a stale -R.
@@ -673,6 +717,7 @@ function settingsRoutes(deps = {}) {
673
717
  }
674
718
  return send(res, 200, {
675
719
  name, shared: false, revoked: true,
720
+ ...(reverseChannelClosed ? {} : { reversePoolPending: true }),
676
721
  ...(unchanged ? { unchanged: true, reconciled: true } : {}),
677
722
  });
678
723
  };
@@ -742,7 +787,11 @@ function settingsRoutes(deps = {}) {
742
787
  // Share-on is transactional: a failed hub acknowledgement returns to the
743
788
  // safe private -L-only state. Never include remote response bodies/tokens.
744
789
  if (body.shared && !wasShared && shareOnAttempted) {
745
- try { await applyLocal(false); } catch (_) { /* best-effort safe rollback */ }
790
+ // Il rollback resta best-effort non si puo' fallire due volte ma il
791
+ // suo esito NON si perde: se il canale reverse non e' stato spento in
792
+ // modo dimostrabile, chi chiama deve saperlo invece di leggere solo
793
+ // "Share non attivato" e credere che tutto sia tornato privato.
794
+ try { await applyLocal(false); } catch (_) { reverseChannelClosed = false; }
746
795
  }
747
796
  const offPersisted = body.shared === false && persistedDesired === false;
748
797
  const redact = (value) => String(value || '').replace(/Bearer\s+\S+/gi, 'Bearer ***');
@@ -753,6 +802,10 @@ function settingsRoutes(deps = {}) {
753
802
  : 'Share non attivato')
754
803
  : offPersisted ? 'Share disattivato localmente; hub non riconciliato' : 'Share non disattivato',
755
804
  ...(offPersisted ? { shared: false, reconcilePending: true } : {}),
805
+ // Segnale esplicito quando lo store dice privato ma il canale reverse
806
+ // non risulta spento in modo dimostrabile: e' una quarantena, non una
807
+ // chiusura, e va diagnosticata invece che dedotta.
808
+ ...(reverseChannelClosed ? {} : { reversePoolPending: true }),
756
809
  ...(e && typeof e.code === 'string' ? { code: e.code } : {}),
757
810
  detail: redact(e && e.message || e),
758
811
  ...(e && typeof e.hint === 'string' ? { hint: redact(e.hint) } : {}),