@mmmbuto/nexuscrew 0.8.58 → 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 +137 -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 +50 -4
  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 +366 -48
  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-BEGNtmx2.js +0 -93
  49. package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
@@ -41,6 +41,7 @@ const { probeModel } = require('./model-probe.js');
41
41
  const { validEnvKey } = require('./env-key.js');
42
42
  const { setCredential, removeCredential } = require('./credentials.js');
43
43
  const { createLaunchBroker } = require('./launch-broker.js');
44
+ const { createLeaseManager } = require('./cell-lease-server.js');
44
45
  const { MINIMAL_ENV_KEYS, termuxRuntimePaths } = require('../runtime/env.js');
45
46
  const { requireSharedTmuxProtection } = require('../tmux/shared-server.js');
46
47
 
@@ -117,6 +118,56 @@ function backfillAgyEngine(defsPath, defs, cfg = {}) {
117
118
  try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
118
119
  }
119
120
 
121
+ // Engine desktop.local: NON managed (nessun client/provider AI, solo command+args
122
+ // come un engine custom). Entra nel container desktop via `docker exec` e
123
+ // precompila panelUrl al pannello web KasmVNC del container. `-u abc` non e' un
124
+ // dettaglio: senza, si entra come root e Chromium nel container rifiuta di
125
+ // partire ("Running as root without --no-sandbox is not supported") — un
126
+ // errore che nomina il soggetto sbagliato. panelUrl e' comodita' sopra il
127
+ // meccanismo condiviso (validPanelUrl in definitions.js): l'engine non ha una
128
+ // sua strada per imporre l'URL, dichiara solo il valore, e resta sovrascrivibile
129
+ // per-cella come qualunque altro panelUrl (vedi parseCell).
130
+ function defaultDesktopEngine() {
131
+ return {
132
+ id: 'desktop.local',
133
+ label: 'Desktop',
134
+ command: 'docker',
135
+ args: ['exec', '-it', '-u', 'abc', 'ai-desktop', 'bash'],
136
+ promptMode: 'send-keys',
137
+ panelUrl: 'https://127.0.0.1:6901',
138
+ };
139
+ }
140
+
141
+ // Backfill dell'engine desktop.local: come backfillKimiEngine, idempotente
142
+ // (gia' presente -> skip) e non distruttivo su collisione id (un 'desktop.local'
143
+ // gia' scelto dall'utente per altro non viene sovrascritto). Nessun platform
144
+ // gate: il container e' un servizio esterno raggiunto via docker, non un
145
+ // binario per-platform come agy/grok.
146
+ //
147
+ // DELIBERATAMENTE NON agganciata al bootstrap (a differenza di shell/agy/kimi/
148
+ // grok/vl, vedi piu' sotto): NON e' una dimenticanza, e' una decisione presa
149
+ // (decisione di progetto, 2026-08-14). Un backfill universale presuppone un container con un
150
+ // nome preciso — `ai-desktop` — che quasi nessuna installazione avra'. E
151
+ // desktop.local non e' managed: non ha un describeManaged che lo segnali come
152
+ // "non configurato" quando il container manca, quindi resterebbe nella lista
153
+ // engine silenziosamente rotto — esattamente la classe di difetto (un gate
154
+ // verde/una feature presente che non significa cio' che sembra) inseguita piu'
155
+ // volte in questa stessa giornata di lavoro.
156
+ // Scartata anche l'opzione apparentemente furba di un gate su un container-probe
157
+ // all'avvio: il probe e' una decisione presa UNA volta, mentre il container puo'
158
+ // nascere e morire dopo — un engine che appare/scompare a seconda di quando hai
159
+ // riavviato e' peggio di uno assente, e richiederebbe interrogare Docker a ogni
160
+ // bootstrap: una dipendenza nuova sul percorso di avvio per una comodita'.
161
+ // Resta MANUALE: chi ha il container se lo aggiunge nella propria procedura
162
+ // — e' il posto dove qualcuno sa gia' che il container esiste.
163
+ function backfillDesktopEngine(defsPath, defs) {
164
+ if (!defs || defs.engines.some((engine) => engine.id === 'desktop.local')) return defs;
165
+ if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
166
+ const draft = draftFrom(defs);
167
+ draft.engines.push(defaultDesktopEngine());
168
+ try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
169
+ }
170
+
120
171
  // Backfill dell'engine Kimi Code CLI nativo: installazioni esistenti ricevono
121
172
  // kimi.native in modo idempotente e non distruttivo. Nessun platform gate: il
122
173
  // CLI gira ovunque giri Node e su Termux il resolver applica gia' il workaround
@@ -214,24 +265,32 @@ function resolveCellCwd(cell, home) {
214
265
  relNorm = normalizeCwdRel(cell.cwdRel);
215
266
  if (relNorm === null) return { ok: false, fail: { reason: 'invalid-rel', cwdRel: cell.cwdRel } };
216
267
  }
217
- const realHome = resolveCwd(home, home);
218
- if (!realHome) return { ok: false, fail: { reason: 'home-unavailable' } };
219
- const realCwd = hasCwd ? resolveCwd(cell.cwd, home) : null;
220
- const realFromRel = hasRel ? resolveCwd(path.join(realHome, relNorm), home) : null;
268
+ // out.unverifiable distingue "non esiste / fuori home / non directory"
269
+ // (legittimo rifiuto) da "esiste ma non ho potuto verificarlo" (EACCES/ELOOP su
270
+ // realpath): stesso verdetto (ok:false, fail-closed di sicurezza), messaggio
271
+ // diverso. Vedi resolveCwd e unportableCwdError. Il discriminante e' CHI ha
272
+ // fallito, non che ci sia stata un'eccezione.
273
+ const homeOut = {};
274
+ const realHome = resolveCwd(home, home, homeOut);
275
+ if (!realHome) return { ok: false, fail: { reason: 'home-unavailable', ...(homeOut.unverifiable ? { unverifiable: homeOut.unverifiable } : {}) } };
276
+ const cwdOut = {};
277
+ const realCwd = hasCwd ? resolveCwd(cell.cwd, home, cwdOut) : null;
278
+ const relOut = {};
279
+ const realFromRel = hasRel ? resolveCwd(path.join(realHome, relNorm), home, relOut) : null;
221
280
  if (hasCwd && hasRel) {
222
- if (!realCwd) return { ok: false, fail: { reason: 'invalid-cwd', cwd: cell.cwd } };
223
- if (!realFromRel) return { ok: false, fail: { reason: 'invalid-rel', cwdRel: cell.cwdRel } };
281
+ if (!realCwd) return { ok: false, fail: { reason: 'invalid-cwd', cwd: cell.cwd, ...(cwdOut.unverifiable ? { unverifiable: cwdOut.unverifiable } : {}) } };
282
+ if (!realFromRel) return { ok: false, fail: { reason: 'invalid-rel', cwdRel: cell.cwdRel, ...(relOut.unverifiable ? { unverifiable: relOut.unverifiable } : {}) } };
224
283
  if (realCwd !== realFromRel) return { ok: false, fail: { reason: 'mismatch', cwd: cell.cwd, cwdRel: cell.cwdRel } };
225
284
  return { ok: true, cwd: realCwd, cwdRel: relNorm };
226
285
  }
227
286
  if (hasCwd) {
228
- if (!realCwd) return { ok: false, fail: { reason: 'invalid-cwd', cwd: cell.cwd } };
287
+ if (!realCwd) return { ok: false, fail: { reason: 'invalid-cwd', cwd: cell.cwd, ...(cwdOut.unverifiable ? { unverifiable: cwdOut.unverifiable } : {}) } };
229
288
  const rel = deriveCwdRel(realCwd, realHome);
230
289
  if (rel === null) return { ok: false, fail: { reason: 'not-under-home', cwd: cell.cwd } };
231
290
  return { ok: true, cwd: realCwd, cwdRel: rel };
232
291
  }
233
292
  // solo cwdRel
234
- if (!realFromRel) return { ok: false, fail: { reason: 'invalid-rel', cwdRel: cell.cwdRel } };
293
+ if (!realFromRel) return { ok: false, fail: { reason: 'invalid-rel', cwdRel: cell.cwdRel, ...(relOut.unverifiable ? { unverifiable: relOut.unverifiable } : {}) } };
235
294
  return { ok: true, cwd: realFromRel, cwdRel: relNorm };
236
295
  }
237
296
 
@@ -258,12 +317,28 @@ function unportableCwdError(failures, home) {
258
317
  const entry = { id };
259
318
  const cwd = typeof fail.cwd === 'string' ? fail.cwd : undefined;
260
319
  if (cwd !== undefined) entry.cwd = cwd;
320
+ // "non ho potuto verificare" (EACCES/ELOOP...) vs "non esiste/fuori home":
321
+ // stesso verdetto (rifiuto, fail-closed di sicurezza), ma la causa e' diversa
322
+ // e l'operatore deve saperlo: potrebbe bastare un permesso, non un path
323
+ // sbagliato. Il discriminante e' CHI ha fallito, non che ci sia stata
324
+ // un'eccezione.
325
+ if (fail.unverifiable) entry.unverifiable = fail.unverifiable;
261
326
  const suggestion = suggestCwdRel(cwd, home);
262
327
  if (suggestion) entry.suggestion = suggestion;
263
328
  return entry;
264
329
  });
330
+ const unverifiable = failures.filter((f) => f.fail.unverifiable);
331
+ const code = 'unportable-cwd';
332
+ if (unverifiable.length) {
333
+ const codes = [...new Set(unverifiable.map((f) => f.fail.unverifiable))].join(', ');
334
+ return httpError(400, `cwd non verificabile (esiste ma illeggibile: ${codes}), per: ${unverifiable.map((f) => f.id).join(', ')}: il percorso potrebbe esserci ma non si e' potuto verificare (permessi/symlink)`, {
335
+ code,
336
+ cells,
337
+ hint: 'non e\' necessariamente un path sbagliato: il percorso potrebbe esistere sotto la home ma i permessi della directory non hanno permesso di verificarlo',
338
+ });
339
+ }
265
340
  return httpError(400, `cwd non portabile (deve esistere sotto la home) per: ${failures.map((f) => f.id).join(', ')}`, {
266
- code: 'unportable-cwd',
341
+ code,
267
342
  cells,
268
343
  hint: 'il percorso appartiene a un altro dispositivo o non esiste sotto la home di questo device: scegli una cartella sotto la home',
269
344
  });
@@ -298,6 +373,9 @@ async function createBuiltinFleet(cfg = {}) {
298
373
  const off = {
299
374
  available: false, provider: 'builtin',
300
375
  isCellSession: () => false, capabilities: () => [],
376
+ // B4: close() sempre sicura su qualunque Fleet restituita, anche unavailable:
377
+ // il caller non deve distinguere `off` dalla Fleet reale.
378
+ close: async () => {},
301
379
  };
302
380
  const blocked = (reason, code) => ({ ...off, reason, ...(code ? { migrationCode: code } : {}) });
303
381
  if (cfg.builtinEnabled === false) return off;
@@ -306,16 +384,38 @@ async function createBuiltinFleet(cfg = {}) {
306
384
  const defsPath = cfg.fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
307
385
  const tmuxBin = cfg.tmuxBin || process.env.TMUX_BIN || 'tmux';
308
386
  const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
309
- const launchBroker = cfg.launchBroker || createLaunchBroker({ ...cfg, home });
387
+ // Lease opt-in: attivo solo quando cfg.cellLeaseEnabled === true. server.js lo
388
+ // passa true in start(); i test che chiamano createServer() direttamente non
389
+ // passano il flag, quindi la suite esistente non vede endpoint lease aperti da up().
390
+ const cellLeaseEnabled = cfg.cellLeaseEnabled === true;
391
+
392
+ // --- P1-2 (audit 3405df0): TUTTI i gate availability AVVENGONO PRIMA di creare
393
+ // leaseManager/launchBroker e di leaseManager.boot(). leaseManager non esiste finche'
394
+ // la Fleet non e' confermata disponibile, quindi NESSUN endpoint lease resta vivo su
395
+ // un early return (off/blocked) finche' ogni gate resta sopra questa boundary.
396
+ // P2 (audit 2a05db2): questo e' un fatto contingente del ref corrente, NON una
397
+ // garanzia anti-refactor. Un gate aggiunto DOPO la creazione del manager passerebbe
398
+ // inosservato senza un controllo statico/mutation-sensitive: il commento non impedisce
399
+ // un `return off` futuro oltre la boundary. La promessa vera e' limitata: oggi nessun
400
+ // early return segue la creazione del manager, e la creazione avviene dopo tutti i
401
+ // gate noti. Manternerla richiede che ogni nuovo gate resti qui sopra.
402
+
403
+ // Bootstrap: fleet.json valido? garbage -> unavailable (fail-closed, design §7).
404
+ // ENOENT (missing) o parse invalido -> off (unavailable legittimo, fail-closed);
405
+ // EACCES/ELOOP sul lstat (file presente ma non verificabile) -> off CON reason,
406
+ // perche' "non ho potuto guardare" e' un problema diverso da "non c'e'" o "e'
407
+ // garbage". Verdetto (available:false) invariato; il discriminante e' CHI ha
408
+ // fallito, non che ci sia stata un'eccezione.
409
+ const bootStat = {};
410
+ let boot = loadDefinitions(defsPath, bootStat);
411
+ if (!boot) return bootStat.lstatBlocked
412
+ ? { ...off, reason: `fleet.json non verificabile (${bootStat.lstatBlocked}): fail-closed, non "assente" né "invalido"` }
413
+ : off;
310
414
 
311
- // Bootstrap: fleet.json valido? garbage -> unavailable (fail-closed, design §7)
312
- let boot = loadDefinitions(defsPath);
313
- if (!boot) return off;
314
415
  if (!readonly()) {
315
- // La migrazione precede QUALUNQUE backfill/scrittura: loadDefinitions
316
- // normalizza i nomi legacy soltanto in memoria. Se il rename e' ambiguo o
317
- // fallisce, fleet.json resta byte-invariato e la Fleet non puo creare una
318
- // seconda sessione safe accanto a quella legacy.
416
+ // La migrazione precede QUALUNQUE backfill/scrittura: loadDefinitions normalizza
417
+ // i nomi legacy solo in memoria. Se il rename e' ambiguo o fallisce, fleet.json
418
+ // resta byte-invariato e la Fleet non puo creare una seconda sessione safe.
319
419
  let migration;
320
420
  try {
321
421
  migration = await migrateLegacyTmuxSessions(tmuxBin, boot, { readonly: false });
@@ -351,12 +451,46 @@ async function createBuiltinFleet(cfg = {}) {
351
451
  });
352
452
  await ensureProtection();
353
453
 
454
+ // --- Tutti i gate availability passati. Ora (e solo ora) creiamo leaseManager e
455
+ // launchBroker e riapriamo gli endpoint lease delle celle note (R3.3.1 recovery,
456
+ // rilievo 3). Non fatale: un fallimento di singolo endpoint non blocca il boot.
457
+ const leaseManager = cellLeaseEnabled ? createLeaseManager({ ...cfg, home }) : null;
458
+ const launchBroker = cfg.launchBroker || createLaunchBroker({
459
+ ...cfg, home,
460
+ // R3.1.1: la connessione broker one-shot resta APERTA dopo il payload e diviene
461
+ // il canale lease del supervisore.
462
+ ...(leaseManager ? {
463
+ onLease: (socket, lease) => {
464
+ // P1-3 (reaudit dd38c83): attachInitial restituisce false se la
465
+ // persistenza del bind fallisce. F-B (audit 2a @ 142e272): ora il
466
+ // broker chiama onLease PRIMA di scrivere il payload. Il ritorno
467
+ // ESPLICITO false (o la socket distrutta qui) comunica al broker di NON
468
+ // consegnare il payload: il supervisore riceve la chiusura prima del
469
+ // frame e non spawna MAI un child con una lease che il server non ha
470
+ // committato; e non resta un lease-client connesso a un manager vuoto
471
+ // (state=none) — l'orfano di prima nasceva cosi'.
472
+ const ok = lease && lease.cellId
473
+ ? leaseManager.attachInitial(lease.cellId, socket, { generation: 0 })
474
+ : false;
475
+ if (!ok) {
476
+ try { socket.destroy(); } catch (_) {}
477
+ }
478
+ return ok;
479
+ },
480
+ } : {}),
481
+ });
482
+ if (leaseManager) {
483
+ try { await leaseManager.boot(); } catch {
484
+ // logged dal manager; un fallimento di singolo endpoint non blocca il boot.
485
+ }
486
+ }
487
+
354
488
  // Runtime estratto (lib/fleet/runtime.js): possiede cache + definizioni e
355
489
  // espone status/up/down/restart/isCellSession + gli accessor allo store
356
490
  // (reloadDefs/findCell/findEngine/refreshSessions/commitDefs) che il CRUD
357
491
  // qui sotto riusa. status/up/down/restart sono INVARIATI.
358
492
  const rt = createBuiltinRuntime({
359
- cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot, ensureProtection,
493
+ cfg, home, defsPath, tmuxBin, readonly, launchBroker, leaseManager, boot, ensureProtection,
360
494
  });
361
495
  const {
362
496
  status, cellStatus, up, down, restart, isCellSession,
@@ -1039,7 +1173,7 @@ async function createBuiltinFleet(cfg = {}) {
1039
1173
  return { engine: profile.id, model: wanted, ...out };
1040
1174
  }
1041
1175
 
1042
- async function close() { await launchBroker.close(); }
1176
+ async function close() { await launchBroker.close(); if (leaseManager) leaseManager.close(); }
1043
1177
 
1044
1178
  return {
1045
1179
  available: true,
@@ -1050,6 +1184,10 @@ async function createBuiltinFleet(cfg = {}) {
1050
1184
  schema, definitions, capabilities,
1051
1185
  credentialStatus, setLocalCredential, removeLocalCredential, testModel,
1052
1186
  defineModel, removeModel, close,
1187
+ // Fetta 2b (D3): il lease Live esposto alle route /api/lease, che il bridge
1188
+ // MCP delle celle consuma per register/refresh/recovery (B5). Assente se il
1189
+ // lease e' disattivato: le route rispondono 501, non 500.
1190
+ ...(leaseManager ? { lease: leaseManager } : {}),
1053
1191
  };
1054
1192
  }
1055
1193
 
@@ -1060,6 +1198,10 @@ module.exports = {
1060
1198
  backfillKimiEngine,
1061
1199
  backfillGrokEngine,
1062
1200
  backfillVlEngine,
1201
+ // Esportate ma NON chiamate qui sopra (§bootstrap, righe ~354-358): scelta
1202
+ // deliberata, non una dimenticanza — vedi il commento su backfillDesktopEngine.
1203
+ backfillDesktopEngine,
1204
+ defaultDesktopEngine,
1063
1205
  resolveCellCwd,
1064
1206
  composeLaunchArgv,
1065
1207
  composeClientInvocation,
@@ -4,25 +4,71 @@
4
4
  "slug": "deepseek-v4-flash",
5
5
  "display_name": "deepseek-v4-flash",
6
6
  "description": "OpenCode Go: deepseek-v4-flash",
7
- "default_reasoning_level": "medium",
7
+ "default_reasoning_level": "high",
8
8
  "supported_reasoning_levels": [
9
9
  {
10
10
  "effort": "low",
11
11
  "description": "Fast responses with lighter reasoning"
12
12
  },
13
13
  {
14
- "effort": "medium",
15
- "description": "Balanced reasoning depth"
14
+ "effort": "high",
15
+ "description": "Greater reasoning depth for complex problems"
16
+ },
17
+ {
18
+ "effort": "max",
19
+ "description": "Maximum reasoning depth"
20
+ }
21
+ ],
22
+ "shell_type": "default",
23
+ "visibility": "list",
24
+ "supported_in_api": true,
25
+ "priority": 50,
26
+ "availability_nux": null,
27
+ "upgrade": null,
28
+ "base_instructions": "",
29
+ "supports_reasoning_summaries": true,
30
+ "default_reasoning_summary": "none",
31
+ "support_verbosity": false,
32
+ "default_verbosity": null,
33
+ "apply_patch_tool_type": null,
34
+ "web_search_tool_type": "text",
35
+ "truncation_policy": {
36
+ "mode": "tokens",
37
+ "limit": 1000000
38
+ },
39
+ "supports_parallel_tool_calls": false,
40
+ "supports_image_detail_original": false,
41
+ "context_window": 1000000,
42
+ "effective_context_window_percent": 95,
43
+ "experimental_supported_tools": [],
44
+ "input_modalities": [
45
+ "text"
46
+ ],
47
+ "supports_search_tool": false
48
+ },
49
+ {
50
+ "slug": "deepseek-v4-pro",
51
+ "display_name": "deepseek-v4-pro",
52
+ "description": "OpenCode Go: deepseek-v4-pro",
53
+ "default_reasoning_level": "high",
54
+ "supported_reasoning_levels": [
55
+ {
56
+ "effort": "low",
57
+ "description": "Fast responses with lighter reasoning"
16
58
  },
17
59
  {
18
60
  "effort": "high",
19
61
  "description": "Greater reasoning depth for complex problems"
62
+ },
63
+ {
64
+ "effort": "max",
65
+ "description": "Maximum reasoning depth"
20
66
  }
21
67
  ],
22
68
  "shell_type": "default",
23
69
  "visibility": "list",
24
70
  "supported_in_api": true,
25
- "priority": 50,
71
+ "priority": 56,
26
72
  "availability_nux": null,
27
73
  "upgrade": null,
28
74
  "base_instructions": "",
@@ -19,6 +19,11 @@ const DEFAULT_SUPERVISE = Object.freeze({
19
19
  maxRapidRestarts: 8,
20
20
  });
21
21
 
22
+ // F-B (audit 2a, correzione escalation): limite ESPLICITO fra SIGTERM e
23
+ // SIGKILL quando la lease e' persa (onLost, main()). Un figlio che ignora
24
+ // SIGTERM non deve restare appeso a tempo indeterminato.
25
+ const LEASE_LOST_KILL_ESCALATION_MS = 5000;
26
+
22
27
  function parseArgs(argv) {
23
28
  const out = {};
24
29
  for (let i = 0; i < argv.length; i += 2) {
@@ -75,24 +80,40 @@ function validRestartPrompt(value) {
75
80
  && (value.readyWaitMs === undefined || validInteger(value.readyWaitMs, 0, 120000));
76
81
  }
77
82
 
83
+ function validLease(value) {
84
+ if (value === undefined) return true;
85
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
86
+ // 2b (A2): la capability statica e' revocata e non esiste piu' nel payload.
87
+ // Il proof supervisore NON transita qui: arriva sul canale lease dal server.
88
+ if (Object.keys(value).some((k) => !['cellId', 'launchEpoch', 'stablePath'].includes(k))) return false;
89
+ return (value.cellId === undefined || (typeof value.cellId === 'string' && value.cellId.length > 0 && value.cellId.length <= 128))
90
+ && typeof value.launchEpoch === 'string' && value.launchEpoch.length > 0 && value.launchEpoch.length <= 128
91
+ && typeof value.stablePath === 'string' && value.stablePath.length > 0 && value.stablePath.length <= 4096;
92
+ }
93
+
78
94
  function validPayload(payload) {
79
95
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
80
- if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt'].includes(key))) return false;
96
+ if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease'].includes(key))) return false;
81
97
  if (typeof payload.command !== 'string' || !payload.command || !Array.isArray(payload.args)) return false;
82
98
  if (!payload.env || typeof payload.env !== 'object' || Array.isArray(payload.env)) return false;
83
99
  return payload.args.every((v) => typeof v === 'string')
84
100
  && Object.entries(payload.env).every(([k, v]) => /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k) && typeof v === 'string')
85
101
  && validSupervise(payload.supervise)
86
- && validRestartPrompt(payload.restartPrompt);
102
+ && validRestartPrompt(payload.restartPrompt)
103
+ && validLease(payload.lease);
87
104
  }
88
105
 
89
- function receivePayload(socketPath, nonce, timeoutMs = 5000) {
106
+ function receivePayload(socketPath, nonce, timeoutMs = 5000, opts = {}) {
90
107
  return new Promise((resolve, reject) => {
91
108
  const socket = net.createConnection(socketPath);
92
109
  let data = Buffer.alloc(0); let expected = null; let done = false;
93
110
  const finish = (error, payload) => {
94
- if (done) return; done = true; socket.destroy();
95
- if (error) reject(error); else resolve(payload);
111
+ if (done) return; done = true;
112
+ if (error) { socket.destroy(); reject(error); return; }
113
+ // R3.1.1 (opt-in): keepOpen restituisce il socket APERTO al caller (broker
114
+ // lease); il caller lo passa al lease-client. Default: destroy (one-shot).
115
+ if (opts.keepOpen) { resolve({ payload, socket }); return; }
116
+ socket.destroy(); resolve(payload);
96
117
  };
97
118
  socket.setTimeout(timeoutMs, () => finish(new Error('launch broker timed out')));
98
119
  socket.once('connect', () => socket.write(`${JSON.stringify({ nonce })}\n`));
@@ -256,7 +277,14 @@ function startGenerationPrompt(config, generation, childState, seams = {}) {
256
277
  async function main(argv = process.argv.slice(2), seams = {}) {
257
278
  const parsed = parseArgs(argv);
258
279
  if (!parsed) throw new Error('usage: cell-exec --socket <path> --nonce <hex>');
259
- const payload = await (seams.receivePayload || receivePayload)(parsed.socketPath, parsed.nonce);
280
+ const received = await (seams.receivePayload || receivePayload)(parsed.socketPath, parsed.nonce, 5000, { keepOpen: true });
281
+ // Compat: il seam di test puo' restituire un payload direttamente; la forma
282
+ // produttiva restituisce { payload, socket } con socket APERTO (broker lease).
283
+ const payload = received && typeof received === 'object' && received.payload ? received.payload : received;
284
+ const leaseSocket = received && typeof received === 'object' && received.socket ? received.socket : null;
285
+ // Se il payload non porta lease (cella non-ospite), rilascia subito il socket
286
+ // broker: niente canale lease, e il supervisore non deve trattenere il loop.
287
+ if (!payload.lease && leaseSocket) { try { leaseSocket.destroy(); } catch (_) {} }
260
288
  const supervise = normalizeSupervise(payload.supervise);
261
289
  const spawnImpl = seams.spawn || spawn;
262
290
  const now = seams.now || Date.now;
@@ -269,7 +297,58 @@ async function main(argv = process.argv.slice(2), seams = {}) {
269
297
  if (process.env.TMUX) childEnv.TMUX = process.env.TMUX;
270
298
  if (process.env.TMUX_PANE) childEnv.TMUX_PANE = process.env.TMUX_PANE;
271
299
 
300
+ // R3.1.2: nessun bearer di lease transita al child. childEnv deriva SOLO da
301
+ // payload.env (piu' TMUX); payload.lease (cellId/launchEpoch/stablePath) resta
302
+ // nel supervisore e alimenta il lease-client. spawnImpl passa env + stdio
303
+ // inherit. Il proof (2b) vive nel lease-client, MAI nell'env del child.
304
+ let generation = 0;
305
+ let leaseCtl = null;
306
+ // F-B (audit 2a): current/stopping vivono QUI (prima del lease-client) perche'
307
+ // la callback onLost qui sotto li riferisce dalla closure.
272
308
  let current = null; let stopping = false;
309
+ if (payload.lease && leaseSocket && !leaseSocket.destroyed) {
310
+ const { startLeaseClient } = require('./lease-client.js');
311
+ leaseCtl = startLeaseClient(leaseSocket, {
312
+ stablePath: payload.lease.stablePath,
313
+ launchEpoch: payload.lease.launchEpoch,
314
+ // R3.3.4: la generation AVANZA coi restart del supervisore (loop sotto,
315
+ // generation += 1). Passiamo un getter cosicche' il reconnect presenti
316
+ // sempre la generation corrente, non 0 fisso (riconcilia :301 con :381).
317
+ generation: () => generation,
318
+ // F-B (audit 2a @ 142e272): lease persa per tutta la grace senza un
319
+ // reconnect riuscito. Prima il lease-client desisteva in silenzio dopo
320
+ // 60s e il child restava un orfano senza lease per tutta la sua vita.
321
+ // Ora il supervisore viene avvisato: ferma il child (stopping + SIGTERM)
322
+ // e il main loop termina con lui — mai un child vivo oltre la lease.
323
+ //
324
+ // Correzione (seconda riconsegna): un SOLO SIGTERM senza escalation
325
+ // lascia appeso un child che lo ignora (misurato sul percorso reale:
326
+ // {spawned:1, kills:['SIGTERM'], state:'pending'}) — l'orfano non NASCE
327
+ // piu' (F-B originale chiuso davvero), ma un figlio gia' vivo che non
328
+ // collabora non muore. LEASE_LOST_KILL_ESCALATION_MS e' il limite
329
+ // ESPLICITO, scritto qui: se il child non e' uscito entro questa
330
+ // finestra dal SIGTERM, si passa a SIGKILL. `target` fissa il processo
331
+ // di QUESTA generazione: se nel frattempo e' gia' uscito (current
332
+ // azzerato a null dal loop principale dopo waitChild), l'escalation e'
333
+ // no-op — mai un kill fantasma su un pid riusato.
334
+ onLost: () => {
335
+ writeError('nexuscrew cell supervisor stopped: lease lost (reconnect grace expired)\n');
336
+ stopping = true;
337
+ const target = current;
338
+ try { if (target) target.kill('SIGTERM'); } catch (_) {}
339
+ if (target) {
340
+ const escalate = seams.setTimeout || setTimeout;
341
+ const timer = escalate(() => {
342
+ if (current === target) {
343
+ try { target.kill('SIGKILL'); } catch (_) {}
344
+ }
345
+ }, LEASE_LOST_KILL_ESCALATION_MS);
346
+ if (timer && typeof timer.unref === 'function') timer.unref();
347
+ }
348
+ },
349
+ }, seams);
350
+ }
351
+
273
352
  const handlers = new Map();
274
353
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
275
354
  const handler = () => {
@@ -283,7 +362,6 @@ async function main(argv = process.argv.slice(2), seams = {}) {
283
362
  for (const [signal, handler] of handlers) proc.off?.(signal, handler);
284
363
  };
285
364
 
286
- let generation = 0;
287
365
  let delayMs = supervise.restartDelayMs;
288
366
  let rapid = [];
289
367
  try {
@@ -343,7 +421,7 @@ async function main(argv = process.argv.slice(2), seams = {}) {
343
421
  delayMs = Math.min(supervise.maxRestartDelayMs, Math.max(supervise.restartDelayMs, delayMs * 2));
344
422
  generation += 1;
345
423
  }
346
- } finally { cleanup(); }
424
+ } finally { if (leaseCtl) { try { leaseCtl.stop(); } catch (_) {} } cleanup(); }
347
425
  }
348
426
 
349
427
  if (require.main === module) {
@@ -353,6 +431,6 @@ if (require.main === module) {
353
431
  }
354
432
 
355
433
  module.exports = {
356
- DEFAULT_SUPERVISE, parseArgs, validSupervise, validRestartPrompt, validPayload,
434
+ DEFAULT_SUPERVISE, LEASE_LOST_KILL_ESCALATION_MS, parseArgs, validSupervise, validRestartPrompt, validPayload, validLease,
357
435
  receivePayload, sanitizeSpawnError, normalizeSupervise, waitChild, startGenerationPrompt, main,
358
436
  };