@mmmbuto/nexuscrew 0.8.57 → 0.9.0

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +164 -2
  2. package/README.md +1 -0
  3. package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
  4. package/frontend/dist/assets/index-zjL6kZ7J.js +93 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/dist/version.json +1 -1
  7. package/lib/audio/adapters.js +50 -6
  8. package/lib/cli/commands.js +40 -2
  9. package/lib/cli/doctor.js +95 -12
  10. package/lib/cli/init.js +25 -3
  11. package/lib/cli/path.js +43 -10
  12. package/lib/cli/pidfile.js +23 -2
  13. package/lib/config.js +15 -0
  14. package/lib/fleet/builtin.js +161 -19
  15. package/lib/fleet/catalogs/opencode-go.json +328 -0
  16. package/lib/fleet/cell-exec.js +87 -9
  17. package/lib/fleet/cell-lease-server.js +719 -0
  18. package/lib/fleet/cell-lease.js +112 -0
  19. package/lib/fleet/definitions.js +101 -7
  20. package/lib/fleet/launch-broker.js +115 -3
  21. package/lib/fleet/lease-client.js +191 -0
  22. package/lib/fleet/lease-routes.js +92 -0
  23. package/lib/fleet/lease-verifier.js +230 -0
  24. package/lib/fleet/managed.js +444 -55
  25. package/lib/fleet/prompt-delivery.js +50 -2
  26. package/lib/fleet/provider.js +1 -1
  27. package/lib/fleet/runtime.js +53 -6
  28. package/lib/live-host/bridge.js +369 -0
  29. package/lib/live-host/routes.js +184 -0
  30. package/lib/live-host/store.js +96 -0
  31. package/lib/mcp/tools.js +51 -0
  32. package/lib/nodes/commands.js +9 -2
  33. package/lib/nodes/store.js +14 -0
  34. package/lib/nodes/tunnel.js +4 -1
  35. package/lib/proxy/federation.js +106 -9
  36. package/lib/proxy/node-proxy.js +33 -0
  37. package/lib/proxy/panel-auth.js +307 -0
  38. package/lib/proxy/panel-proxy.js +305 -0
  39. package/lib/server.js +127 -4
  40. package/package.json +1 -1
  41. package/skills/alibaba-token-media/SKILL.md +19 -0
  42. package/skills/crew/SKILL.md +15 -0
  43. package/skills/fill-forms/SKILL.md +23 -0
  44. package/skills/mail-assistant/SKILL.md +15 -0
  45. package/skills/memory/SKILL.md +15 -0
  46. package/skills/nexuscrew-agent/SKILL.md +18 -0
  47. package/skills/vl-msa/SKILL.md +15 -0
  48. package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
  49. package/frontend/dist/assets/index-_c-1_3iR.js +0 -93
@@ -0,0 +1,719 @@
1
+ 'use strict';
2
+
3
+ // LeaseManager lato server per il lease del supervisore di una cella Live host.
4
+ //
5
+ // Fetta 2b (contratto rev1): l'autenticazione del reconnect e' un proof HMAC
6
+ // firmato con un verifier PER-INSTALLAZIONE — la capability statica condivisa
7
+ // della 2a e' REVOCATA, non affiancata (A2/B1). Solo il server conosce il
8
+ // segreto; il supervisore presenta un proof firmato con claims ed expiry (B8:
9
+ // issuedAt+60s). Il proof supervisore arriva al detentore sul canale lease
10
+ // (all'attach e a ogni refresh) e non transita mai nel child (R3.1.2 invariato).
11
+ //
12
+ // Storage per-cella (D1/D2): un file per cella in <run>/cell-leases/. Il
13
+ // refresh di una cella rilegge e riscrive SOLO il proprio file: scompare la
14
+ // read-modify-write dell'intero store condiviso, la race su di essa e
15
+ // l'amplificazione O(N^2) misurata in 2a (N letture + N scritture complete
16
+ // per ciclo di 20s). L'invariante di recovery post-restart e' preservato ed
17
+ // affinato: un file corrotto salta solo la propria cella, non lo store intero
18
+ // (E3 chiusa con test). Il nome file usa la stessa sanitizeCell dell'endpoint
19
+ // UDS: due cellId che sanitizzano uguale condividono file ed endpoint, proprieta'
20
+ // gia' vera in 2a per il socket.
21
+ //
22
+ // Rotazione (C3, sospesa per scelta dichiarata): finche' la finestra di
23
+ // sovrapposizione non e' fissata la chiave NON ruota; la verifica interroga
24
+ // comunque la LISTA delle chiavi vive (oggi una) perche' quella e' la forma
25
+ // richiesta da C2 quando la rotazione sara' attivata. Fail-closed sulla
26
+ // verifica (C4); lo stato durevole non contiene mai il segreto, solo
27
+ // identificativo e impronta nel meta del verifier (C5); la verifica rende
28
+ // osservabile quale chiave ha firmato (C6, via log del keyId).
29
+ //
30
+ // Side effect isolati e iniettabili (seams) per testabilita', come altrove nel
31
+ // fleet (cell-exec.js, launch-broker.js). Protocollo sul socket lease:
32
+ // line-oriented JSON, un messaggio per riga.
33
+ // supervisor -> server: {"type":"refresh"}
34
+ // supervisor -> server: {"type":"reconnect","generation":..,"proof":{..}}
35
+ // server -> supervisor: {"type":"lease","leaseId":..,"proof":{..}}
36
+ // | {"type":"ack","proof":{..}} | {"type":"deny"}
37
+
38
+ const net = require('node:net');
39
+ const fs = require('node:fs');
40
+ const path = require('node:path');
41
+ const crypto = require('node:crypto');
42
+ const L = require('./cell-lease.js');
43
+ const { runtimeDir, ensureRuntimeDir } = require('./launch-broker.js');
44
+ const { loadOrCreateVerifier, signProof, verifyProof, PROOF_TTL_MS } = require('./lease-verifier.js');
45
+
46
+ function sanitizeCell(cellId) {
47
+ return String(cellId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64);
48
+ }
49
+
50
+ // cellId ammesso dalle API child: stessa forma usata dalle route fleet.
51
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
52
+ function validCellId(cellId) { return typeof cellId === 'string' && CELL_ID_RE.test(cellId); }
53
+
54
+ // F-A (audit 2a @ 142e272): il formato che il runtime produce per un'identity.
55
+ // Condiviso da loadPersisted (primo ingresso, al boot) e track (secondo
56
+ // ingresso): la validazione e' UNA, non due copie da tenere allineate.
57
+ const EPOCH_RE = /^[a-f0-9]{16}$/;
58
+
59
+ // A3 — confine del consumo: un proof e' consumato UNA volta, IN-PROCESS. Il
60
+ // registro dei jti muore col processo server: dopo un restart un proof non
61
+ // ancora scaduto puo' ripresentarsi (la firma e l'expiry restano il gate).
62
+ // Garanzia piu' forte (single-use cross-restart) NON promessa dal contratto.
63
+ const JTI_CAP = 4096;
64
+
65
+ function createLeaseManager(cfg = {}, seams = {}) {
66
+ const dir = runtimeDir(cfg);
67
+ const stateDir = cfg.leaseStateDir || path.join(dir, 'cell-leases');
68
+ const now = seams.now || Date.now;
69
+ const setTimer = seams.setTimeout || ((fn, ms) => setTimeout(fn, ms));
70
+ const clearTimer = seams.clearTimeout || ((t) => { if (t) clearTimeout(t); });
71
+ const netImpl = seams.net || net;
72
+ const fsImpl = seams.fs || fs;
73
+ const log = typeof cfg.log === 'function' ? cfg.log : () => {};
74
+
75
+ // cellId -> entry:
76
+ // { launchEpoch, stablePath, stableServer, lease, socket, graceTimer, graceDeadline }
77
+ // lease/socket vivono in memoria (persi al restart); launchEpoch/graceDeadline
78
+ // sono anche persistiti PER CELLA (D1). Nessun segreto in entry o su disco.
79
+ const cells = new Map();
80
+
81
+ // Verifier per-installazione (B7): file dedicato 0o600 nella runtime dir,
82
+ // distinto dai token di liveness e dal segreto del bridge audio. Lazy: nasce
83
+ // al primo proof, cosi' un manager che non firma mai non lascia file in giro.
84
+ let verifier = null;
85
+ function ensureVerifier() {
86
+ if (!verifier) verifier = loadOrCreateVerifier({ dir, fsImpl: fs, log });
87
+ return verifier;
88
+ }
89
+ // Le chiavi vive per la verifica (C2-ready, oggi una: C3 sospesa).
90
+ const liveKeys = () => [ensureVerifier()];
91
+
92
+ // Registro jti consumati (A3): bounded, i scaduti escono da soli.
93
+ const consumedJti = new Map(); // jti -> expiresAt
94
+ function consumeJti(jti, expiresAt) {
95
+ const t = now();
96
+ for (const [j, exp] of consumedJti) if (exp <= t) consumedJti.delete(j);
97
+ if (consumedJti.has(jti)) return false;
98
+ consumedJti.set(jti, expiresAt);
99
+ while (consumedJti.size > JTI_CAP) {
100
+ const oldest = consumedJti.keys().next();
101
+ if (oldest.done) break;
102
+ consumedJti.delete(oldest.value);
103
+ }
104
+ return true;
105
+ }
106
+
107
+ // Proof supervisore (kind 'lease'): tupla con leaseId (B6), generation
108
+ // corrente, expiry issuedAt+60s (B8). Emissione == firma: non c'e' stato di
109
+ // sessione da tenere, il detentore presenta il proof cosi' com'e'.
110
+ function issueLeaseProof(cellId, entry, lease) {
111
+ return signProof(ensureVerifier(), {
112
+ kind: 'lease',
113
+ cellId,
114
+ launchEpoch: entry.launchEpoch,
115
+ leaseId: lease.leaseId,
116
+ generation: String(lease.generation),
117
+ jti: crypto.randomBytes(8).toString('hex'),
118
+ issuedAt: now(),
119
+ }, { now });
120
+ }
121
+
122
+ function cellStatePath(cellId) { return path.join(stateDir, `${sanitizeCell(cellId)}.json`); }
123
+
124
+ function stablePathFor(cellId) { return path.join(dir, `cell-${sanitizeCell(cellId)}.sock`); }
125
+
126
+ // D1: lettura PER CELLA. ENOENT = cella sconosciuta (legittimo); qualunque
127
+ // altro errore (EIO, parse, forma) = illeggibile: propaga, perche' una
128
+ // scrittura che ignora un file illeggibile cancellerebbe lo stato di quella
129
+ // cella senza saperlo.
130
+ function readPersistedCell(cellId) {
131
+ let raw;
132
+ try {
133
+ raw = fsImpl.readFileSync(cellStatePath(cellId), 'utf8');
134
+ } catch (e) {
135
+ if (e && e.code === 'ENOENT') return null;
136
+ throw e;
137
+ }
138
+ const obj = JSON.parse(raw);
139
+ // P1-1b: la FORMA del dato. La root deve essere l'entry attesa: plain-object
140
+ // con i campi del formato per-cell. Qualunque altra cosa e' illeggibile.
141
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
142
+ throw new Error(`store lease: entry per-cella non e' un oggetto: ${sanitizeCell(cellId)}`);
143
+ }
144
+ return obj;
145
+ }
146
+
147
+ function writePersistedCell(cellId, entryData) {
148
+ // GC1.2 (rev27): l'esito NON si ingoia — ritornato perche' il refresh (e il
149
+ // proof) dipende dal commit effettivo del bound. Il log resta diagnostico.
150
+ try {
151
+ ensureRuntimeDir(dir);
152
+ fsImpl.mkdirSync(stateDir, { recursive: true });
153
+ const target = cellStatePath(cellId);
154
+ const tmp = `${target}.${process.pid}.tmp`;
155
+ fsImpl.writeFileSync(tmp, JSON.stringify(entryData), { mode: 0o600 });
156
+ fsImpl.renameSync(tmp, target);
157
+ try { fsImpl.chmodSync(target, 0o600); } catch (_) {}
158
+ return true;
159
+ } catch (e) {
160
+ log(`cell-lease: persist failed: ${e && e.message}`);
161
+ return false;
162
+ }
163
+ }
164
+
165
+ // D1: persiste il bound di UNA cella leggendo e scrivendo SOLO il suo file.
166
+ // Non esiste piu' la read-modify-write dell'intero store condiviso.
167
+ // GC1 (rev27): graceDeadline SEMPRE valorizzato (mai null) — bound durevole
168
+ // per rifiutare reconnect oltre la grace post-restart (R3.3.5). Live =
169
+ // now+GRACE_MS; in grace = bound di armGrace; refresh lo rinfresca.
170
+ function persistEntry(cellId, entry) {
171
+ return writePersistedCell(cellId, {
172
+ launchEpoch: entry.launchEpoch,
173
+ graceDeadline: entry.graceDeadline,
174
+ });
175
+ }
176
+
177
+ function clearGraceTimer(entry) {
178
+ if (entry.graceTimer) { clearTimer(entry.graceTimer); entry.graceTimer = null; }
179
+ }
180
+
181
+ function armGraceTimer(cellId, entry) {
182
+ clearGraceTimer(entry);
183
+ // R3.2: deadline non estendibile, armata una sola volta.
184
+ const ms = Math.max(0, (entry.lease.graceDeadline - now()));
185
+ entry.graceTimer = setTimer(() => {
186
+ const e = cells.get(cellId);
187
+ if (!e || !e.lease || e.lease.leaseId !== entry.lease.leaseId) return;
188
+ // Grace scaduta senza reconnect: lease terminal. L'eligibilita' (binding host)
189
+ // e' out-of-scope 2a; qui marchiamo il lease come expired.
190
+ log(`cell-lease: ${cellId} grace expired (lease ${entry.lease.leaseId})`);
191
+ }, ms);
192
+ if (entry.graceTimer && typeof entry.graceTimer.unref === 'function') entry.graceTimer.unref();
193
+ }
194
+
195
+ function detachSocket(entry) {
196
+ if (!entry.socket) return;
197
+ try { entry.socket.removeAllListeners('data'); entry.socket.removeAllListeners('close'); entry.socket.removeAllListeners('end'); entry.socket.destroy(); } catch (_) {}
198
+ entry.socket = null;
199
+ }
200
+
201
+ function bindLiveSocket(cellId, entry, socket, { lease }) {
202
+ // P1-2b (audit 2a05db2): persiste il bound PRIMA di associare il socket. Se il
203
+ // record non committa, rollback e ritorna false: il caller non dichiarera' lease.
204
+ // Diverso da EOF (onEOF): li una write fallita lascia un bound ANTERIORE (fail-closed
205
+ // anticipato, accettabile); qui il bind prometterebbe stato live senza commit durevole.
206
+ const prevGraceDeadline = entry.graceDeadline;
207
+ // GC1 (rev27): bound durevole SEMPRE valorizzato. Lease live -> now+GRACE_MS (bound
208
+ // preventivo); lease in grace -> lease.graceDeadline (bound di armGrace). Mai null.
209
+ entry.graceDeadline = lease && lease.graceDeadline != null ? lease.graceDeadline : now() + L.GRACE_MS;
210
+ if (!persistEntry(cellId, entry)) {
211
+ entry.graceDeadline = prevGraceDeadline; // rollback: socket non associato
212
+ return false;
213
+ }
214
+ detachSocket(entry);
215
+ entry.lease = lease;
216
+ entry.socket = socket;
217
+ clearGraceTimer(entry);
218
+ let buf = '';
219
+ const onLine = (line) => {
220
+ let msg; try { msg = JSON.parse(line); } catch (_) { return; }
221
+ if (!msg || typeof msg !== 'object') return;
222
+ if (msg.type === 'refresh') {
223
+ // Heartbeat lato supervisore (R3.2) + GC1.1/GC1.2 (rev27): il refresh
224
+ // rinfresca il bound durevole (now+GRACE_MS) e lo persiste PRIMA dell'ACK
225
+ // e del proof nuovo (hook 2a ora realta'): se la persistenza non committa
226
+ // il refresh NON e' un successo — nessun ACK, nessun proof (il detentore
227
+ // resta col proof vecchio, che scade: fail-closed per scadenza, non per
228
+ // silenzio).
229
+ const cur = cells.get(cellId);
230
+ if (cur && cur.lease && cur.socket === socket) {
231
+ const refreshed = L.refresh(cur.lease, { now: now() });
232
+ if (refreshed) cur.lease = refreshed;
233
+ cur.graceDeadline = now() + L.GRACE_MS; // GC1.1: bound rinfrescato
234
+ if (persistEntry(cellId, cur)) {
235
+ writeSafe(socket, { type: 'ack', proof: issueLeaseProof(cellId, cur, cur.lease) });
236
+ } else {
237
+ log(`cell-lease: ${cellId} refresh: bound non persistito, ACK+proof omessi`);
238
+ }
239
+ }
240
+ return;
241
+ }
242
+ // Messaggi non riconosciuti su una connessione gia' associata sono ignorati:
243
+ // reconnect e identita' si presentano sull'endpoint stabile, non qui.
244
+ };
245
+ const onData = (chunk) => {
246
+ buf += chunk.toString();
247
+ let nl;
248
+ while ((nl = buf.indexOf('\n')) !== -1) { onLine(buf.slice(0, nl)); buf = buf.slice(nl + 1); }
249
+ };
250
+ const onEOF = () => {
251
+ const cur = cells.get(cellId);
252
+ if (!cur || cur.socket !== socket) return; // gia' sostituita da un reconnect
253
+ // R3.1.3: EOF arma UNA sola transizione monotonica Live -> Grace; deadline
254
+ // non estendibile. armGrace e' no-op se gia' in grace.
255
+ const g = L.armGrace(cur.lease, { now: now() });
256
+ if (g) cur.lease = g;
257
+ // R3.3.5: persistiamo il nuovo bound di grace per rifiutare reconnect stale
258
+ // post-restart (il lease vivo non sopravvive, ma il bound di reject si').
259
+ cur.graceDeadline = cur.lease && cur.lease.graceDeadline != null ? cur.lease.graceDeadline : now() + L.GRACE_MS;
260
+ persistEntry(cellId, cur);
261
+ armGraceTimer(cellId, cur);
262
+ };
263
+ socket.setEncoding('utf8');
264
+ socket.on('data', onData);
265
+ socket.once('close', onEOF);
266
+ socket.once('end', onEOF);
267
+ // Correzione (audit 2a, segnalazione precisata da Dev): questo E' il socket
268
+ // su cui il server SCRIVE (l'ack di refresh — proprio dove l'EPIPE nasce se
269
+ // il peer muore mentre la write e' in volo). Un EventEmitter che emette
270
+ // 'error' senza listener fa un throw che termina l'INTERO processo — non
271
+ // muore la cella, muore NexusCrew con dentro tutte le celle. Assorbe
272
+ // l'errore, non lo propaga: la naturale 'close' che segue attiva comunque
273
+ // onEOF (grace), la stessa forma del close-handler di onStableConnection.
274
+ socket.once('error', () => { try { socket.destroy(); } catch (_) {} });
275
+ return true;
276
+ }
277
+
278
+ function writeSafe(socket, obj) {
279
+ if (!socket || socket.destroyed) return;
280
+ try { socket.write(`${JSON.stringify(obj)}\n`); } catch (_) {}
281
+ }
282
+
283
+ function onStableConnection(cellId, socket) {
284
+ // Endpoint stabile: reconnect (R3.3.2-4). Legge identita' + proof, valida.
285
+ let buf = '';
286
+ const onData = (chunk) => {
287
+ buf += chunk.toString();
288
+ const nl = buf.indexOf('\n');
289
+ if (nl === -1) return;
290
+ const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
291
+ let msg; try { msg = JSON.parse(line); } catch (_) { writeSafe(socket, { type: 'deny' }); socket.destroy(); return; }
292
+ socket.removeAllListeners('data');
293
+ handleReconnect(cellId, socket, msg);
294
+ };
295
+ socket.setEncoding('utf8');
296
+ socket.on('data', onData);
297
+ socket.once('close', () => { try { socket.destroy(); } catch (_) {} });
298
+ // Correzione (audit 2a): stesso motivo di bindLiveSocket — il server scrive
299
+ // 'deny' su questo socket (onData sopra, JSON malformato); senza un
300
+ // handler 'error' un peer che muore mentre quella write e' in volo manda
301
+ // in throw fatale l'intero processo. Se la connessione avanza a
302
+ // handleReconnect->bindLiveSocket, questo handler resta attivo insieme al
303
+ // suo (entrambi fanno solo destroy: innocuo, mai un secondo effetto).
304
+ socket.once('error', () => { try { socket.destroy(); } catch (_) {} });
305
+ }
306
+
307
+ function denyReconnect(socket) {
308
+ writeSafe(socket, { type: 'deny' });
309
+ socket.destroy();
310
+ }
311
+
312
+ function handleReconnect(cellId, socket, msg) {
313
+ if (!msg || msg.type !== 'reconnect') return denyReconnect(socket);
314
+ const entry = cells.get(cellId);
315
+ if (!entry) return denyReconnect(socket);
316
+ // A2/B1: l'autenticazione del reconnect e' il proof HMAC. La capability
317
+ // statica della 2a e' revocata: un messaggio senza proof (o col vecchio
318
+ // campo capability) e' negato qui, senza confronti verso segreti condivisi.
319
+ // C4: fail-closed — forma, firma, claims attesi, expiry: ogni difetto e' deny.
320
+ const out = verifyProof(liveKeys(), msg.proof, {
321
+ now,
322
+ expect: {
323
+ kind: 'lease',
324
+ cellId,
325
+ launchEpoch: entry.launchEpoch,
326
+ // leaseId atteso SOLO con lease vivo in memoria: post-restart il lease
327
+ // non sopravvive (R3.3.1) e il gate resta firma+expiry+bound di grace.
328
+ ...(entry.lease ? { leaseId: entry.lease.leaseId } : {}),
329
+ },
330
+ });
331
+ if (!out.ok) {
332
+ log(`cell-lease: ${cellId} reconnect denied (proof: ${out.reason})`);
333
+ return denyReconnect(socket);
334
+ }
335
+ // A3: consumo il jti DOPO la verifica e PRIMA di ogni mutazione. Un proof
336
+ // presentato e negato per altro motivo NON viene consumato: potra' ripresentarsi
337
+ // fino alla propria scadenza, e ogni replica sara' negata dallo stesso gate.
338
+ if (!consumeJti(msg.proof.jti, msg.proof.expiresAt)) {
339
+ log(`cell-lease: ${cellId} reconnect denied (jti replay in-process)`);
340
+ return denyReconnect(socket);
341
+ }
342
+ const generation = Number.isInteger(msg.generation) && msg.generation >= 0 ? msg.generation : (entry.lease ? entry.lease.generation : 0);
343
+ // R3.3.4: transizione di generation VERIFICABILE (non solo non-decreasing). Il
344
+ // supervisore onesto (cell-exec.js) fa avanzare la generation di ESATTAMENTE +1
345
+ // a ogni restart del child e al reconnect presenta sempre la propria generation
346
+ // corrente. La transizione attesa e' quindi `=== current` (stesso restart,
347
+ // retry/reattach) oppure `=== current + 1` (un restart del supervisore). Un salto
348
+ // avanti arbitrario (es. 0->99) o all'indietro NON e' una transizione che il
349
+ // client onesto produrrebbe: deny. msg.generation assente -> fallback alla
350
+ // current (compat), accettato. Post-restart (entry.lease null) non c'e' generation
351
+ // persistita da validare (contratto rev25 EC6): firma+bound di grace resta il gate.
352
+ if (entry.lease && Number.isInteger(msg.generation)) {
353
+ const cur = entry.lease.generation;
354
+ if (msg.generation !== cur && msg.generation !== cur + 1) {
355
+ return denyReconnect(socket);
356
+ }
357
+ }
358
+ // R3.3.5/GC1 (rev27): oltre la grace il reconnect e' rifiutato. Con entry.lease
359
+ // vivo ci pensa reattach (null su grace scaduta). Post-restart (lease null) usiamo
360
+ // il bound di grace persistito PER CELLA, ora SEMPRE valorizzato: se la richiesta
361
+ // arriva ALLA deadline o oltre, rifiuta (>= allineato a cell-lease.js).
362
+ // Recovery post-restart con supervisore/child vivi solo ENTRO il bound live.
363
+ if (!entry.lease && now() >= entry.graceDeadline) {
364
+ return denyReconnect(socket);
365
+ }
366
+ const base = entry.lease || L.openLease({ cellId, launchEpoch: entry.launchEpoch, generation, leaseId: L.newLeaseId(), now: now() });
367
+ const reattached = entry.lease
368
+ ? L.reattach(entry.lease, { leaseId: L.newLeaseId(), generation, now: now() })
369
+ : base;
370
+ if (!reattached) return denyReconnect(socket);
371
+ // R3.3.4: lease NUOVO (leaseId nuovo), stessa identita'. Associa la nuova connessione.
372
+ if (!bindLiveSocket(cellId, entry, socket, { lease: reattached })) {
373
+ return denyReconnect(socket);
374
+ }
375
+ log(`cell-lease: ${cellId} reconnect ok (lease ${reattached.leaseId}, verifier ${out.keyId})`); // C6: chiave osservabile
376
+ writeSafe(socket, { type: 'lease', leaseId: reattached.leaseId, proof: issueLeaseProof(cellId, entry, reattached) });
377
+ }
378
+
379
+ // --- API pubblica ---
380
+
381
+ // Apre l'endpoint stabile UDS 0o600 per una cella (R3.3.2), riusando l'identity
382
+ // e lo stablePath gia' noti nell'entry. Idempotente: se l'entry ha gia' uno
383
+ // stableServer vivo non fa nulla. Condiviso da track() (prima apertura) e da
384
+ // boot() (riapertura dopo restart per le celle persistite).
385
+ async function openEndpoint(cellId, entry) {
386
+ if (entry.stableServer) return entry.stablePath;
387
+ ensureRuntimeDir(dir);
388
+ const sp = entry.stablePath || stablePathFor(cellId);
389
+ try { fsImpl.unlinkSync(sp); } catch (_) {}
390
+ const server = netImpl.createServer((sock) => onStableConnection(cellId, sock));
391
+ await new Promise((resolve, reject) => {
392
+ server.once('error', reject);
393
+ // F-C (audit 2a @ 142e272): niente piu' set/restore di process.umask.
394
+ // process.umask e' GLOBALE e la coppia set/restore non e' atomica: sotto
395
+ // openEndpoint concorrenti il restore di uno ripristinava lo 0o177 appena
396
+ // settato dall'altro e il processo restava driftato. La protezione e'
397
+ // (1) la directory owner-only verificata a OGNI bind (ensureRuntimeDir,
398
+ // subito PRIMA del listen, eseguita non presupposta) e (2) il chmod 0o600
399
+ // forzato qui sotto, che NON ingoia il fallimento.
400
+ server.listen(sp, () => {
401
+ server.removeListener('error', reject);
402
+ server.unref();
403
+ // Il chmod forza/verifica 0o600; il suo fallimento NON va ingoiato (R3.3.2).
404
+ try {
405
+ fsImpl.chmodSync(sp, 0o600);
406
+ } catch (e) {
407
+ try { server.close(); } catch (_) {}
408
+ try { fsImpl.unlinkSync(sp); } catch (_) {}
409
+ reject(e);
410
+ return;
411
+ }
412
+ resolve();
413
+ });
414
+ });
415
+ entry.stablePath = sp;
416
+ entry.stableServer = server;
417
+ return sp;
418
+ }
419
+
420
+ // F-C (audit 2a @ 142e272): track() serializzato PER CELLA. Due track
421
+ // simultanei sulla stessa cella vedevano entrambi cells.get() vuoto, generavano
422
+ // due identity divergenti e il perdente restava con un'identity inutile. La
423
+ // catena per cellId fa attendere al secondo la fine del primo; poi
424
+ // l'idempotenza esistente fa riusare la STESSA identity.
425
+ const trackGates = new Map(); // cellId -> Promise<void> (completamento del turno)
426
+ function track(cellId) {
427
+ const prev = trackGates.get(cellId) || Promise.resolve();
428
+ const run = prev.then(() => trackSerialized(cellId));
429
+ const next = run.then(() => {}, () => {});
430
+ trackGates.set(cellId, next);
431
+ next.then(() => { if (trackGates.get(cellId) === next) trackGates.delete(cellId); });
432
+ return run;
433
+ }
434
+
435
+ // Apertura lato server in up(): genera launchEpoch, persiste PER CELLA, apre
436
+ // l'endpoint stabile UDS 0o600. Ritorna i dati da inserire nel payload.
437
+ // 2b: nessuna capability nel ritorno — il proof supervisore arriva sul canale
438
+ // lease all'attach, non nel payload (il detentore lo riceve dal server).
439
+ // Chiamata SOLO tramite track() qui sopra (serializzazione per cella).
440
+ async function trackSerialized(cellId) {
441
+ ensureRuntimeDir(dir);
442
+ // Rispetta un'identity gia' nota (in memoria o persistita): al restart del
443
+ // server NON si genera una nuova launchEpoch, perche' il supervisore vivo
444
+ // reconnecta con quella originale (e il suo proof la porta firmata).
445
+ const existing = cells.get(cellId);
446
+ // P1-2 (reaudit dd38c83): snapshot dell'entry preesistente prima di mutare
447
+ // qualunque cosa. Se persistEntry fallisce su una cella gia' viva, il cleanup
448
+ // deve ripristinare lo stato precedente, NON cancellarlo.
449
+ const hadExisting = !!existing;
450
+ const existingSnapshot = existing ? {
451
+ launchEpoch: existing.launchEpoch,
452
+ stablePath: existing.stablePath, stableServer: existing.stableServer,
453
+ lease: existing.lease, socket: existing.socket, graceTimer: existing.graceTimer,
454
+ graceDeadline: existing.graceDeadline,
455
+ } : null;
456
+ // openEndpoint puo' aver creato un nuovo stableServer se l'entry non ne aveva uno.
457
+ const serverBeforeOpen = existing ? existing.stableServer : null;
458
+ // F-A (audit 2a): il SECONDO INGRESSO valida come il primo. Un valore
459
+ // persistito e' riusato SOLO se ha il formato che il runtime produce
460
+ // (hex-16 l'epoch); malformato -> identity NUOVA generata, e la persistEntry
461
+ // qui sotto la scrive, riparando la entry corrotta sul disco.
462
+ let persistedEpoch = null;
463
+ try {
464
+ const persisted = readPersistedCell(cellId);
465
+ if (persisted && typeof persisted.launchEpoch === 'string' && EPOCH_RE.test(persisted.launchEpoch)) {
466
+ persistedEpoch = persisted.launchEpoch;
467
+ }
468
+ } catch (e) {
469
+ log(`cell-lease: read per-cell failed per ${cellId}: ${e && e.message}`);
470
+ }
471
+ const launchEpoch = (existing && existing.launchEpoch)
472
+ || persistedEpoch
473
+ || crypto.randomBytes(8).toString('hex');
474
+ const entry = existing
475
+ || { launchEpoch, stablePath: stablePathFor(cellId), stableServer: null, lease: null, socket: null, graceTimer: null, graceDeadline: now() + L.GRACE_MS };
476
+ entry.launchEpoch = launchEpoch;
477
+ if (!entry.stablePath) entry.stablePath = stablePathFor(cellId);
478
+ // Idempotente: se l'endpoint e' gia' aperto (es. boot() poi up()) non lo ricrea.
479
+ await openEndpoint(cellId, entry);
480
+ cells.set(cellId, entry);
481
+ if (!persistEntry(cellId, entry)) {
482
+ // P1-2b: il record durevole (identity) e' essenziale per la recovery post-restart.
483
+ // P1-2 (reaudit dd38c83): se la cella era GIA' viva, NON cancellarla — ripristina
484
+ // lo stato precedente. Solo le risorse create in QUESTO tentativo vengono pulite.
485
+ if (hadExisting) {
486
+ entry.launchEpoch = existingSnapshot.launchEpoch;
487
+ entry.lease = existingSnapshot.lease;
488
+ entry.socket = existingSnapshot.socket;
489
+ entry.graceTimer = existingSnapshot.graceTimer;
490
+ entry.graceDeadline = existingSnapshot.graceDeadline;
491
+ if (entry.stableServer && entry.stableServer !== serverBeforeOpen) {
492
+ try { entry.stableServer.close(); } catch (_) {}
493
+ entry.stableServer = serverBeforeOpen;
494
+ entry.stablePath = existingSnapshot.stablePath;
495
+ }
496
+ cells.set(cellId, entry);
497
+ } else {
498
+ // Cella nuova: il tentativo e' fallito, cleanup completo e' sicuro
499
+ cells.delete(cellId);
500
+ if (entry.stableServer) { try { entry.stableServer.close(); } catch (_) {} try { fsImpl.unlinkSync(entry.stablePath); } catch (_) {} }
501
+ }
502
+ throw new Error(`cell-lease: track(${cellId}) non persistito: store illeggibile`);
503
+ }
504
+ return { stablePath: entry.stablePath, launchEpoch };
505
+ }
506
+
507
+ // Recovery di produzione (R3.3.1 fail-closed): al boot del server la map e'
508
+ // vuota e nessun lease sopravvive. Ricarichiamo {launchEpoch, graceDeadline}
509
+ // persistiti PER CELLA e RIAPRIAMO l'endpoint stabile per ogni cella nota,
510
+ // cosicche' un supervisore vivo che reconnecta dopo il restart trovi
511
+ // l'endpoint. La verifica del proof non richiede stato ricostruito: la chiave
512
+ // verifier e' persistita per-installazione.
513
+ async function boot() {
514
+ loadPersisted();
515
+ for (const [cellId, entry] of cells) {
516
+ if (entry.stableServer) continue;
517
+ try {
518
+ await openEndpoint(cellId, entry);
519
+ } catch (e) {
520
+ log(`cell-lease: boot endpoint failed for ${cellId}: ${e && e.message}`);
521
+ }
522
+ }
523
+ }
524
+
525
+ // Connessione broker one-shot iniziale (R3.1.1): autenticata dal nonce del
526
+ // broker; qui associiamo la connessione al lease (primo contatto).
527
+ // 2b: attachInitial NON scrive nulla sul canale. Durante la consegna del
528
+ // payload il canale appartiene al protocollo del broker (frame length-
529
+ // prefixed u32): una riga JSON scritta prima del payload corromperebbe la
530
+ // lettura di receivePayload e il child non nascerebbe — misurato dal gate
531
+ // (lease-audit-2a-fixes, F-B positivo). Il proof supervisore arriva con
532
+ // l'ACK del primo refresh, che il lease-client invia IMMEDIATAMENTE
533
+ // all'avvio: la finestra attach->primo-ack senza proof detenuto e' fail-closed
534
+ // (reconnect negato -> grace -> onLost), coerente col modello per cui nessun
535
+ // commit persistito = nessuna recovery promessa.
536
+ function attachInitial(cellId, socket, { generation = 0 } = {}) {
537
+ const entry = cells.get(cellId);
538
+ if (!entry) return false;
539
+ const lease = L.openLease({ cellId, launchEpoch: entry.launchEpoch, generation, leaseId: L.newLeaseId(), now: now() });
540
+ if (!lease) return false;
541
+ return bindLiveSocket(cellId, entry, socket, { lease });
542
+ }
543
+
544
+ // Al boot del server (R3.3.1 fail-closed): la map e' vuota. Ricarichiamo solo
545
+ // {launchEpoch, graceDeadline} persistiti PER CELLA (D1), per riconoscere le
546
+ // identity (il proof le porta firmate) e per rifiutare reconnect oltre la
547
+ // grace (R3.3.5). Nessun lease/eligibilita' sopravvive.
548
+ // E3: un file malformato/corrotto salta la PROPRIA cella (logged); le altre
549
+ // caricano. Con lo store unico di 2a un parse error buttava tutte.
550
+ function loadPersisted() {
551
+ let files;
552
+ try {
553
+ files = fsImpl.readdirSync(stateDir);
554
+ } catch (e) {
555
+ if (e && e.code !== 'ENOENT') log(`cell-lease: loadPersisted readdir fallita, nessuna cella recuperata: ${e && e.message}`);
556
+ return;
557
+ }
558
+ for (const file of files) {
559
+ if (!file.endsWith('.json')) continue;
560
+ const cellId = file.slice(0, -'.json'.length);
561
+ if (cells.has(cellId)) continue;
562
+ let info;
563
+ try {
564
+ info = readPersistedCell(cellId);
565
+ } catch (e) {
566
+ log(`cell-lease: loadPersisted entry '${cellId}' illeggibile, saltata: ${e && e.message}`);
567
+ continue;
568
+ }
569
+ // P1-4 (reaudit dd38c83): forma <> semantica. Il runtime produce launchEpoch
570
+ // come hex di 16 char. Fail-closed: formato errato = entry saltata.
571
+ if (!info || typeof info.launchEpoch !== 'string' || !EPOCH_RE.test(info.launchEpoch)) {
572
+ log(`cell-lease: loadPersisted entry '${cellId}' malformata, saltata`);
573
+ continue;
574
+ }
575
+ // L'endpoint stabile viene (ri)aperto da boot()/track(); qui registriamo
576
+ // solo l'identita' nota + il bound di grace, cosicche' handleReconnect possa
577
+ // validarli anche quando la cella non sia ancora passata di nuovo per track().
578
+ cells.set(cellId, {
579
+ launchEpoch: info.launchEpoch,
580
+ // GC1.6: graceDeadline assente/illeggibile/non-intero = grace gia' scaduta (0 -> deny sempre).
581
+ // P1-4: forma <> semantica — un bound oltre now()+2*GRACE_MS non e' producibile
582
+ // da questa fetta: trattato come scaduto (0). Tolleranza 2*GRACE_MS per un
583
+ // restart durante la grace. Fail-closed: bound assurdo = scaduto.
584
+ graceDeadline: (Number.isInteger(info.graceDeadline) && info.graceDeadline > 0
585
+ && info.graceDeadline <= now() + 2 * L.GRACE_MS) ? info.graceDeadline : 0,
586
+ stablePath: stablePathFor(cellId), stableServer: null, lease: null, socket: null, graceTimer: null,
587
+ });
588
+ }
589
+ }
590
+
591
+ // --- superficie child (B5): register / refresh / recovery -------------------
592
+ //
593
+ // Tre metodi DISTINTI perche' i loro valori di ritorno non possono mentire
594
+ // l'uno con l'altro: register puo' rispondere 'pending' (la cella non e'
595
+ // ancora tracciata); refresh su una registration viva risponde 'live' con un
596
+ // proof nuovo e NON ha stati pendenti; recovery riprende un'incarnazione con
597
+ // un proof scaduto da poco. Un metodo unico costringerebbe 'refresh' a un
598
+ // valore di ritorno che mente in uno dei due casi.
599
+ //
600
+ // B2: incarnationId e' PER-REGISTRATION, mai globale — il register di una
601
+ // cella crea la propria incarnazione e non tocca le altre. Le registration
602
+ // vivono in memoria e NON sopravvivono al restart del processo (coerente col
603
+ // fail-closed R3.3.1 del lease): dopo un restart del server il child
604
+ // re-registra (nuova incarnazione).
605
+ //
606
+ // B3: l'unita' dell'attempt e' la PRESENTAZIONE di recovery, non la
607
+ // connessione — una connessione puo' portare piu' presentazioni, e contare
608
+ // connessioni conterebbe la cosa sbagliata. Ogni presentazione (anche negata)
609
+ // consuma un attempt della propria registration; oltre il cap la
610
+ // registration e' chiusa e serve un register nuovo.
611
+ const childRegs = new Map(); // cellId -> { incarnationId, createdAt, lastAt, recoveryAttempts }
612
+ const RECOVERY_ATTEMPT_CAP = 8;
613
+ // Finestre del child, derivate dalle misure del lease:
614
+ // - un proof vive PROOF_TTL_MS (B8: 60s) dall'ultimo refresh;
615
+ // - il recovery accetta un proof scaduto da meno di una grace (L.GRACE_MS):
616
+ // il detentore ha saltato i refresh, non e' stato sostituito;
617
+ // - quindi la registration e' viva fino a lastAt + PROOF_TTL_MS + GRACE_MS.
618
+ // Senza il margine di grace la finestra di recovery sarebbe VUOTA: la
619
+ // registration morirebbe esattamente col proof che dovrebbe riprendere.
620
+ const CHILD_REG_WINDOW_MS = PROOF_TTL_MS + L.GRACE_MS;
621
+ const CHILD_PROOF_GRACE_MS = L.GRACE_MS;
622
+
623
+ function issueChildProof(cellId, reg) {
624
+ return signProof(ensureVerifier(), {
625
+ kind: 'child',
626
+ cellId,
627
+ incarnationId: reg.incarnationId,
628
+ jti: crypto.randomBytes(8).toString('hex'),
629
+ issuedAt: now(),
630
+ }, { now });
631
+ }
632
+
633
+ function childRegister(cellId) {
634
+ if (!validCellId(cellId)) return { status: 'denied', reason: 'cellId' };
635
+ if (!cells.has(cellId)) {
636
+ // La cella non e' (ancora) tracciata dal lease del supervisore: il join e'
637
+ // pendente. Solo register puo' rispondere cosi' (B5).
638
+ return { status: 'pending', retryAfterMs: L.REFRESH_MS };
639
+ }
640
+ const reg = {
641
+ incarnationId: crypto.randomBytes(8).toString('hex'), // B2: per-registration
642
+ createdAt: now(),
643
+ lastAt: now(),
644
+ recoveryAttempts: 0,
645
+ };
646
+ childRegs.set(cellId, reg);
647
+ return { status: 'registered', incarnationId: reg.incarnationId, proof: issueChildProof(cellId, reg) };
648
+ }
649
+
650
+ function childRefresh(cellId, proof) {
651
+ const reg = childRegs.get(cellId);
652
+ if (!reg) return { status: 'no-registration' };
653
+ const out = verifyProof(liveKeys(), proof, {
654
+ now,
655
+ expect: { kind: 'child', cellId, incarnationId: reg.incarnationId },
656
+ });
657
+ if (!out.ok) return { status: 'denied', reason: out.reason };
658
+ if (now() >= reg.lastAt + CHILD_REG_WINDOW_MS) return { status: 'expired' };
659
+ reg.lastAt = now();
660
+ return { status: 'live', incarnationId: reg.incarnationId, proof: issueChildProof(cellId, reg) };
661
+ }
662
+
663
+ function childRecovery(cellId, proof) {
664
+ const reg = childRegs.get(cellId);
665
+ if (!reg) return { status: 'no-registration' };
666
+ // B3: la presentazione conta PRIMA dell'esito — anche un tentativo negato
667
+ // consuma un attempt della registration.
668
+ reg.recoveryAttempts += 1;
669
+ if (reg.recoveryAttempts > RECOVERY_ATTEMPT_CAP) {
670
+ return { status: 'denied', reason: 'attempt-bound' };
671
+ }
672
+ // Recovery (ResumeFirst): la firma dev'essere valida e i claims della
673
+ // STESSA incarnazione; la scadenza e' tollerata entro una finestra di grace
674
+ // (il proof e' morto per un gap di refresh, non per sostituzione). Oltre
675
+ // quella finestra il proof non e' piu' presentabile NEanche in recovery:
676
+ // per il child l'esito utile e' «expired, serve register», non un deny.
677
+ const out = verifyProof(liveKeys(), proof, {
678
+ now,
679
+ expect: { kind: 'child', cellId, incarnationId: reg.incarnationId },
680
+ graceMs: CHILD_PROOF_GRACE_MS,
681
+ });
682
+ if (!out.ok) {
683
+ if (out.reason === 'expired') return { status: 'expired' };
684
+ return { status: 'denied', reason: out.reason };
685
+ }
686
+ if (now() >= reg.lastAt + CHILD_REG_WINDOW_MS) return { status: 'expired' };
687
+ // Riprende la STESSA incarnazione (non e' una re-registrazione) e consegna
688
+ // un proof fresco: la registration torna viva.
689
+ reg.lastAt = now();
690
+ return { status: 'live', incarnationId: reg.incarnationId, proof: issueChildProof(cellId, reg) };
691
+ }
692
+
693
+ function status(cellId) {
694
+ const entry = cells.get(cellId);
695
+ if (!entry || !entry.lease) return { cellId, state: 'none' };
696
+ const n = now();
697
+ if (L.isLive(entry.lease)) return { cellId, state: 'live', leaseId: entry.lease.leaseId, generation: entry.lease.generation };
698
+ if (L.isGrace(entry.lease, { now: n })) return { cellId, state: 'grace', leaseId: entry.lease.leaseId, graceDeadline: entry.lease.graceDeadline };
699
+ if (L.isExpired(entry.lease, { now: n })) return { cellId, state: 'expired', leaseId: entry.lease.leaseId };
700
+ return { cellId, state: 'none' };
701
+ }
702
+
703
+ function close() {
704
+ for (const [cellId, entry] of cells) {
705
+ clearGraceTimer(entry);
706
+ detachSocket(entry);
707
+ if (entry.stableServer) { try { entry.stableServer.close(); } catch (_) {} }
708
+ try { if (entry.stablePath) fsImpl.unlinkSync(entry.stablePath); } catch (_) {}
709
+ }
710
+ cells.clear();
711
+ }
712
+
713
+ return {
714
+ boot, track, attachInitial, loadPersisted, status, close, _cells: cells,
715
+ childRegister, childRefresh, childRecovery,
716
+ };
717
+ }
718
+
719
+ module.exports = { createLeaseManager, sanitizeCell };