@mmmbuto/nexuscrew 0.9.7 → 0.9.9

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-YpDRVy-I.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-DDAwkwKR.css">
14
+ <script type="module" crossorigin src="/assets/index-DlWa7GOd.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CN02GYPg.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.9.7"}
1
+ {"version":"0.9.9"}
@@ -28,6 +28,20 @@ function emptyGroups() { return { schemaVersion: SCHEMA_VERSION, groups: {} }; }
28
28
 
29
29
  function validName(name) { return typeof name === 'string' && GROUP_NAME_RE.test(name); }
30
30
 
31
+ // R31-A4: gli errori di VALIDAZIONE si distinguono per contratto (status 400 +
32
+ // code chiuso), non per testo del messaggio. Prima erano Error generici
33
+ // indistinguibili da quelli di I/O di atomicWrite (EACCES/ENOSPC/...), e la
34
+ // route poteva separarli solo indovinando con un match sulla stringa — il
35
+ // modo sbagliato, vietato qui. Gli errori fs restano nativi: portano il loro
36
+ // errno in .code e NON hanno .status, così una scrittura fallita non può mai
37
+ // essere scambiata per una validazione.
38
+ function validationError(message, code) {
39
+ const err = new Error(message);
40
+ err.status = 400;
41
+ err.code = code;
42
+ return err;
43
+ }
44
+
31
45
  function normalizeSpec(spec) {
32
46
  if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return null;
33
47
  if (Object.keys(spec).some((key) => !['targets', 'mode'].includes(key))) return null;
@@ -96,9 +110,9 @@ function getGroup(cfg = {}, name, home) {
96
110
  }
97
111
 
98
112
  function saveGroup(cfg = {}, name, spec, home) {
99
- if (!validName(name)) throw new Error('nome gruppo audio non valido');
113
+ if (!validName(name)) throw validationError('nome gruppo audio non valido', 'AUDIO_GROUP_NAME_INVALID');
100
114
  const normalized = normalizeSpec(spec);
101
- if (!normalized) throw new Error('gruppo audio non valido');
115
+ if (!normalized) throw validationError('gruppo audio non valido', 'AUDIO_GROUP_SPEC_INVALID');
102
116
  const store = readGroups(cfg, home);
103
117
  store.groups[name] = normalized;
104
118
  atomicWrite(groupsPath(cfg, home), store);
@@ -106,7 +120,7 @@ function saveGroup(cfg = {}, name, spec, home) {
106
120
  }
107
121
 
108
122
  function removeGroup(cfg = {}, name, home) {
109
- if (!validName(name)) throw new Error('nome gruppo audio non valido');
123
+ if (!validName(name)) throw validationError('nome gruppo audio non valido', 'AUDIO_GROUP_NAME_INVALID');
110
124
  const store = readGroups(cfg, home);
111
125
  if (!Object.prototype.hasOwnProperty.call(store.groups, name)) return false;
112
126
  delete store.groups[name];
@@ -973,7 +973,14 @@ function autoUpdateCommand(args, opts = {}) {
973
973
  });
974
974
  }
975
975
 
976
- // update: npm i -g @latest + restart se attivo. Fallimento npm -> messaggio chiaro, code 1.
976
+ // update: npm i -g @latest + sanazione definizioni di boot + restart se attivo.
977
+ // Fallimento npm -> messaggio chiaro, code 1.
978
+ // R28-rimedio, difetto 1 (audit): la via manuale attraversa la STESSA
979
+ // sanazione del runner (healBootDefinitions: rigenera le definizioni sul
980
+ // node vivo e APPLICA l'attivazione differita). Prima update() reinstallava
981
+ // e riavviava soltanto: chi aggiornava a mano con un node appena cambiato
982
+ // (il caso reale di chi lancia update) ripartiva su path morti. Un solo
983
+ // cervello per «reinstalla ⇒ sana le definizioni», non due.
977
984
  function update(opts = {}) {
978
985
  const execImpl = opts.execImpl || execFileSync;
979
986
  const log = opts.log || console.log;
@@ -988,18 +995,25 @@ function update(opts = {}) {
988
995
  return { updated: false, error: String(e && e.message ? e.message : e), code: 1 };
989
996
  }
990
997
  const running = isServiceRunning({ ...opts, platform });
998
+ // Best-effort come nel runner: un errore della sanazione non blocca
999
+ // l'aggiornamento, viene loggato e riportato nel risultato.
1000
+ const healed = (opts.healBootImpl || require('../update/runner.js').healBootDefinitions)({
1001
+ platform, home: opts.home, running, execImpl, log,
1002
+ ...(opts.regenSeams || {}),
1003
+ ...(opts.regenBootImpl ? { regenImpl: opts.regenBootImpl } : {}),
1004
+ });
991
1005
  if (running) {
992
1006
  const restarted = (opts.restartImpl || restart)({ ...opts, platform, log });
993
1007
  if (!restarted || restarted.restarted !== true) {
994
1008
  const reason = (restarted && restarted.reason) || 'esito restart non verificato';
995
1009
  log(`update: pacchetto installato ma restart fallito — ${reason}`);
996
- return { updated: true, running, restarted: false, reason, code: 1 };
1010
+ return { updated: true, running, restarted: false, reason, bootDefinitions: healed, code: 1 };
997
1011
  }
998
1012
  log('update: servizio riavviato sul nuovo codice');
999
1013
  } else {
1000
1014
  log('update: servizio non attivo (nessun restart)');
1001
1015
  }
1002
- return { updated: true, running, restarted: running, code: 0 };
1016
+ return { updated: true, running, restarted: running, bootDefinitions: healed, code: 0 };
1003
1017
  }
1004
1018
 
1005
1019
  // B4.3 — fleet-boot companion: avvia le celle boot:true del provider selezionato.
@@ -1288,6 +1302,21 @@ function dispatch(argv, opts = {}) {
1288
1302
  const { flags, rest } = parseFlags(argv, CLI_VALUE_FLAGS);
1289
1303
  const cmd = rest[0];
1290
1304
 
1305
+ // R29 — help come flag, deciso qui e solo qui: dopo parseFlags `--help`
1306
+ // finisce in flags e `-h` resta come posizionale dopo il sottocomando; in
1307
+ // entrambi i casi il sottocomando NON deve essere eseguito. Prima di questa
1308
+ // guardia `nexuscrew init --help` eseguiva init davvero: rigenerava i plist,
1309
+ // riavviava il servizio e stampava in chiaro l'URL autenticato del pannello.
1310
+ // Scelta dichiarata: i sottocomandi con un help dedicato (nodes/peers, la
1311
+ // stessa costante NODES_HELP della gestione posizionale in dispatchNodes)
1312
+ // mostrano quello; per tutti gli altri non esiste help per-comando, quindi
1313
+ // si stampa l'HELP generale e si esce 0. La gestione posizionale esistente
1314
+ // (`help`/`-h` come primo argomento, `nodes help`) resta dov'è.
1315
+ if (flags.help === true || rest.slice(1).includes('-h')) {
1316
+ log(cmd === 'nodes' || cmd === 'peers' ? NODES_HELP : HELP);
1317
+ return { code: 0 };
1318
+ }
1319
+
1291
1320
  // help esplicito
1292
1321
  if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
1293
1322
  log(HELP);
@@ -57,7 +57,7 @@ function generateFleetLinux(opts) {
57
57
  const nodeDir = escapeSystemdPath(path.dirname(nodeBin));
58
58
  return `# NexusCrew fleet boot companion (systemd --user) - avvia le celle boot:true
59
59
  [Unit]
60
- Description=NexusCrew fleet boot companion (avvia le celle boot:true)
60
+ ${LINUX_FLEET_DESCRIPTION}
61
61
  After=network-online.target
62
62
 
63
63
  [Service]
@@ -117,7 +117,7 @@ function generateFleetTermux(opts) {
117
117
  const nodeQ = shellQuote(opts.nodeBin);
118
118
  const entryQ = shellQuote(opts.entryPath);
119
119
  return `#!/data/data/com.termux/files/usr/bin/sh
120
- # NexusCrew fleet boot companion (Termux) - avvia le celle boot:true
120
+ ${TERMUX_FLEET_HEADER}
121
121
  export PATH=/data/data/com.termux/files/usr/bin:$PATH
122
122
  export HOME=/data/data/com.termux/files/home
123
123
  export PREFIX=/data/data/com.termux/files/usr
@@ -238,12 +238,18 @@ function fleetInstallCommands(platform, target, ctx) {
238
238
  // Install no-symlink + atomic rename (come service.js). execImpl iniettabile per test;
239
239
  // default execFileSync (argv diretto, MAI shell string). Su activation fallita il file
240
240
  // e' PRESERVATO e le failure raccolte per diagnosi (M1: non si ingoia, non si rollback).
241
+ // R28: `activate: false` (speculare a installService) scrive la definizione SENZA
242
+ // toccare il service manager — per chi rigenera a meta' aggiornamento: il companion
243
+ // e' RunAtLoad/oneshot, un bootstrap adesso eseguirebbe le celle boot:true mentre
244
+ // il servizio principale sta per essere riavviato. I comandi saltati sono dichiarati
245
+ // in skippedActivation, non taciti.
241
246
  function installFleetService(platform, content, ctx, {
242
247
  dryRun = false,
243
248
  execImpl = execFileSync,
244
249
  sleepImpl,
245
250
  launchdWaitAttempts,
246
251
  launchdWaitMs,
252
+ activate = true,
247
253
  } = {}) {
248
254
  const home = ctx.home || os.homedir();
249
255
  const target = ctx.installPath || fleetInstallPath(platform, home);
@@ -274,6 +280,14 @@ function installFleetService(platform, content, ctx, {
274
280
  throw e;
275
281
  }
276
282
 
283
+ if (!activate) {
284
+ const skipped = platform === 'mac'
285
+ ? [`launchctl bootout gui/${ctx.uid || uid()}/com.mmmbuto.nexuscrew-fleet`,
286
+ `launchctl bootstrap gui/${ctx.uid || uid()} ${target}`]
287
+ : fleetInstallCommands(platform, target, ctx).map(([bin, args]) => `${bin} ${args.join(' ')}`);
288
+ return { target, mode, written: true, failures: [], skippedActivation: skipped };
289
+ }
290
+
277
291
  // exec service manager — raccogli failure per diagnosi (NON ingoiare, M1)
278
292
  const failures = [];
279
293
  if (platform === 'mac') {
@@ -296,7 +310,35 @@ function installFleetService(platform, content, ctx, {
296
310
  return { target, mode, written: true, failures };
297
311
  }
298
312
 
313
+
314
+ // Gemello di isOurService (service.js): stessa ragione, stessa disciplina —
315
+ // l'ancora vive accanto al template della companion.
316
+ // Stessa disciplina di service.js (R28, terzo giro): la riga identificativa e'
317
+ // definita una volta e usata SIA dal template SIA dall'ancora, e l'ancora e' la
318
+ // riga ESATTA — un prefisso lascia passare il servizio di terzi che comincia la
319
+ // propria Description col nostro nome.
320
+ const LINUX_FLEET_DESCRIPTION = 'Description=NexusCrew fleet boot companion (avvia le celle boot:true)';
321
+ const TERMUX_FLEET_HEADER = '# NexusCrew fleet boot companion (Termux) - avvia le celle boot:true';
322
+
323
+ function exactLine(line) {
324
+ const escaped = line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
325
+ return new RegExp(`^${escaped}$`, 'm');
326
+ }
327
+
328
+ const OUR_FLEET_ANCHOR = Object.freeze({
329
+ linux: exactLine(LINUX_FLEET_DESCRIPTION),
330
+ mac: /^\s*<string>com\.mmmbuto\.nexuscrew-fleet<\/string>\s*$/m,
331
+ termux: exactLine(TERMUX_FLEET_HEADER),
332
+ });
333
+
334
+ function isOurFleetService(platform, content) {
335
+ const re = OUR_FLEET_ANCHOR[platform];
336
+ if (!re || typeof content !== 'string' || !content) return false;
337
+ return re.test(content);
338
+ }
339
+
299
340
  module.exports = {
341
+ isOurFleetService, OUR_FLEET_ANCHOR,
300
342
  generateFleetService,
301
343
  generateFleetLinux,
302
344
  generateFleetMac,
@@ -72,7 +72,7 @@ function readProcessStart(pid) {
72
72
  }
73
73
 
74
74
  // Campi che SOLO writePidfile puo' scrivere: mai dal chiamante via `extra`.
75
- // Misurato (Dev, 2026-08-17): con lo spread di extra per ultimo,
75
+ // Misurato (2026-08-17): con lo spread di extra per ultimo,
76
76
  // writePidfile(f, pid, cmd, {processStart:'FINTO', attestation:'unsupported'})
77
77
  // vinceva sul valore vero calcolato da probeProcessStart — un chiamante
78
78
  // poteva scrivere un'attestazione INVENTATA. safeExtra la filtra qui, e i
@@ -534,7 +534,7 @@ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
534
534
  // scrivere qui, verificato nell'istante in cui
535
535
  // conta, non dedotto da uno passato. Si rifiuta.
536
536
  //
537
- // MISURATO (Dev, 2026-08-17): trattare OGNI meta senza attestazione come
537
+ // MISURATO (2026-08-17): trattare OGNI meta senza attestazione come
538
538
  // indeterminate rompe l'aggiornamento automatico per ogni nodo il cui
539
539
  // runtime e' ancora precedente a quando processStart e' nato (8fe514f,
540
540
  // v0.9.0) — npm install sovrascrive pidfile.js PRIMA che il runner lo
@@ -82,7 +82,7 @@ function generateLinux(ctx) {
82
82
  const nodeDir = escapeSystemdPath(path.dirname(ctx.nodeBin));
83
83
  return `# NexusCrew service (systemd --user, loopback, solo tunnel SSH/VPN)
84
84
  [Unit]
85
- Description=NexusCrew - browser tmux client (loopback, solo tunnel SSH/VPN)
85
+ ${LINUX_SERVICE_DESCRIPTION}
86
86
  Wants=network-online.target
87
87
  After=network-online.target
88
88
 
@@ -156,7 +156,7 @@ function generateTermux(ctx) {
156
156
  const nodeQ = shellQuote(ctx.nodeBin);
157
157
  const repoBinQ = shellQuote(serviceEntryPath(ctx));
158
158
  return `#!/data/data/com.termux/files/usr/bin/sh
159
- # NexusCrew boot (Termux) - loopback, localhost del telefono
159
+ ${TERMUX_SERVICE_HEADER}
160
160
  export PATH=/data/data/com.termux/files/usr/bin:$PATH
161
161
  export HOME=/data/data/com.termux/files/home
162
162
  export PREFIX=/data/data/com.termux/files/usr
@@ -406,7 +406,54 @@ function installCommands(platform, target, ctx) {
406
406
  return [];
407
407
  }
408
408
 
409
+
410
+ // --- Riconoscimento di PROPRIETA' (R28, audit rev2) -------------------------
411
+ // La sanazione automatica riscrive una definizione solo se e' NOSTRA. La prima
412
+ // versione cercava /nexuscrew/i nel contenuto: un unit di terzi che ci nomina
413
+ // in un commento («Avviato dopo NexusCrew») veniva sovrascritto. La parola non
414
+ // e' un titolo di proprieta'.
415
+ //
416
+ // L'ancora e' STRUTTURALE e sta ACCANTO al template che la produce, cosi' non
417
+ // possono divergere: chi cambia il template e non l'ancora fa diventare rosso
418
+ // il test che genera e riconosce (tests/npm-updater.test.js), invece di
419
+ // scoprirlo sul campo con una sanazione che smette di funzionare.
420
+ // - systemd : il valore di Description= COMINCIA con «NexusCrew» (un terzo
421
+ // descrive se' stesso, non noi) — riga intera, non sottostringa.
422
+ // - launchd : il Label e' esattamente il nostro reverse-DNS. Usarlo sarebbe
423
+ // impersonazione, non coincidenza.
424
+ // - termux : la prima riga di commento del nostro script di boot.
425
+ // Le righe identificative sono definite QUI e usate SIA dal template SIA
426
+ // dall'ancora: cosi' non possono divergere per costruzione, e il test guardia
427
+ // diventa una seconda difesa invece dell'unica.
428
+ //
429
+ // R28, terzo giro (audit 2026-08-18): l'ancora linux era un PREFISSO
430
+ // (`/^Description=NexusCrew\b/`) e un servizio di terzi che comincia la sua
431
+ // Description con il nostro nome — «NexusCrew-compatible proxy», «NexusCrew
432
+ // fork by tizio» — veniva riconosciuto come nostro e sovrascritto. Il mac era
433
+ // gia' esatto (il Label reverse-DNS completo non e' una coincidenza, e' una
434
+ // impersonazione): stesso standard ovunque, ancora = RIGA ESATTA.
435
+ const LINUX_SERVICE_DESCRIPTION = 'Description=NexusCrew - browser tmux client (loopback, solo tunnel SSH/VPN)';
436
+ const TERMUX_SERVICE_HEADER = '# NexusCrew boot (Termux) - loopback, localhost del telefono';
437
+
438
+ function exactLine(line) {
439
+ const escaped = line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
440
+ return new RegExp(`^${escaped}$`, 'm');
441
+ }
442
+
443
+ const OUR_SERVICE_ANCHOR = Object.freeze({
444
+ linux: exactLine(LINUX_SERVICE_DESCRIPTION),
445
+ mac: /^\s*<string>com\.mmmbuto\.nexuscrew<\/string>\s*$/m,
446
+ termux: exactLine(TERMUX_SERVICE_HEADER),
447
+ });
448
+
449
+ function isOurService(platform, content) {
450
+ const re = OUR_SERVICE_ANCHOR[platform];
451
+ if (!re || typeof content !== 'string' || !content) return false;
452
+ return re.test(content);
453
+ }
454
+
409
455
  module.exports = {
456
+ isOurService, OUR_SERVICE_ANCHOR,
410
457
  generateService, generateLinux, generateMac, generateTermux,
411
458
  installService, installPath, installCommands, fileMode,
412
459
  ensureLinuxTmuxSurvival, linuxTmuxSurvivalPath, LINUX_TMUX_SURVIVAL_DROPIN,
@@ -109,9 +109,58 @@ function resolveBootPaths({ nodeBin, entryPath, env = process.env, realpath } =
109
109
  };
110
110
  }
111
111
 
112
+ // R28: il runner dell'aggiornamento puo' girare con un process.execPath gia'
113
+ // MORTO su disco — l'upgrade di node (Homebrew) unlinka il Cellar vecchio
114
+ // mentre il processo resta in memoria, e npm reinstalla il pacchetto nel
115
+ // prefix del node NUOVO. In quel caso resolveBootPaths scriverebbe il path
116
+ // morto: il suo criterio e' «alias che realpath allo STESSO file», e un file
117
+ // morto non e' lo stesso file di nessuno. Il criterio qui e' diverso e
118
+ // dichiarato: un path VIVO — l'alias stabile dello stesso file quando
119
+ // l'attuale vive (R23 intatto), altrimenti il primo candidato stabile che
120
+ // ESISTE, con la dichiarazione; se non c'e' niente di vivo, il path resta e
121
+ // la dichiarazione dice di rilanciare init. `env`, `realpath` e `exists`
122
+ // iniettabili per test.
123
+ function resolveLiveBootPaths({ nodeBin, entryPath, env = process.env, realpath, exists } = {}) {
124
+ const realpathImpl = typeof realpath === 'function' ? realpath : fs.realpathSync;
125
+ const existsImpl = typeof exists === 'function' ? exists : fs.existsSync;
126
+ const warnings = [];
127
+ const revive = (current, candidates, what) => {
128
+ try {
129
+ realpathImpl(current);
130
+ return null; // vivo: decide il criterio R23 di resolveBootPaths
131
+ } catch (_) { /* morto su disco: cerca una alternativa viva */ }
132
+ for (const candidate of Array.isArray(candidates) ? candidates : []) {
133
+ if (existsImpl(candidate)) {
134
+ warnings.push(
135
+ `${what} ${current} non esiste piu' su disco; la definizione rigenerata punta al primo percorso stabile vivo ${candidate}`,
136
+ );
137
+ return candidate;
138
+ }
139
+ }
140
+ warnings.push(
141
+ `${what} ${current} non esiste piu' su disco e nessun percorso stabile e' installato; la definizione resta puntata lì — rilancia nexuscrew init`,
142
+ );
143
+ return current;
144
+ };
145
+ const revivedNode = revive(nodeBin, nodeAliasCandidates(env), 'il node del servizio');
146
+ const revivedEntry = revive(entryPath, entryAliasCandidates(entryPath, env), "l'entry del servizio");
147
+ const boot = resolveBootPaths({
148
+ nodeBin: revivedNode || nodeBin,
149
+ entryPath: revivedEntry || entryPath,
150
+ env,
151
+ realpath: realpathImpl,
152
+ });
153
+ return {
154
+ nodeBin: boot.nodeBin,
155
+ entryPath: boot.entryPath,
156
+ warnings: [...warnings, ...boot.warnings],
157
+ };
158
+ }
159
+
112
160
  module.exports = {
113
161
  nodeAliasCandidates,
114
162
  entryAliasCandidates,
115
163
  resolveStableAlias,
116
164
  resolveBootPaths,
165
+ resolveLiveBootPaths,
117
166
  };
@@ -6,6 +6,7 @@ const express = require('express');
6
6
  const { Router } = require('express');
7
7
  const multer = require('multer');
8
8
  const store = require('./store.js');
9
+ const { pasteArgs } = require('../tmux/actions.js');
9
10
 
10
11
  // Router file-exchange. Nessuno stato: tutto deriva da cfg + filesystem.
11
12
  // notifier (opzionale, MCP bridge): emette la notify di consegna file outbox.
@@ -32,7 +33,40 @@ function filesRoutes({ cfg, sessionExists, paste, notifier, readonly = () => fal
32
33
  // paste=false (tasto allegati del composer): il client appende il path al
33
34
  // testo del composer — niente scrittura PTY. Default = incolla (FilesPanel).
34
35
  const wantPaste = String((req.body && req.body.paste) || '') !== 'false';
35
- const pasted = wantPaste ? await paste(session, saved.path) : false;
36
+ // R31-A2: paste RICHIESTO ma non riuscito non e' un 200 con pasted:false
37
+ // tre cause collassavano in quell'unica etichetta muta (non richiesto /
38
+ // tmux non ha ricevuto / testo respinto) e nessuno, risposta o UI, poteva
39
+ // accorgersene. Il vocabolario e' quello del ramo answer degli ask
40
+ // (lib/notify/routes.js): «paste fallito» si dice non-200 + error, col
41
+ // codice che nomina il soggetto giusto (502 valle raggiunto, 500 rifiuto
42
+ // nostro). Il file resta nella risposta: e' salvato in inbox, e' la
43
+ // consegna alla PTY che non e' avvenuta — la causa vera va detta, non
44
+ // nascosta dietro un OK.
45
+ if (!wantPaste) return res.json({ ...saved, pasted: false });
46
+ // Testo respinto a monte: pasteArgs e' la FONTE UNICA del rifiuto (la usa
47
+ // pasteToSession). Il nome upload e' sanitizzato: se scatta, la causa e'
48
+ // il filesRoot di configurazione (control char) o un path oltre i 4096
49
+ // char. Rifiuto noto PRIMA di tmux: la PTY non va neanche tentata.
50
+ // 500, non 502 (rilievo audit su d6c47c6): qui il valle NON e' stato
51
+ // contattato — a rifiutare siamo noi, per configurazione nostra. 502
52
+ // affermerebbe «il servizio a monte non risponde» e sveglierebbe un
53
+ // allarme su un tmux che sta benissimo. La coppia di codici e'
54
+ // informazione: 502 = raggiunto ma non ha preso; 500 = mai tentato.
55
+ if (!pasteArgs(session, saved.path)) {
56
+ return res.status(500).json({
57
+ error: 'paste fallito: path non incollabile (control char o oltre 4096 char)',
58
+ ...saved,
59
+ pasted: false,
60
+ });
61
+ }
62
+ const pasted = await paste(session, saved.path);
63
+ if (!pasted) {
64
+ return res.status(502).json({
65
+ error: `paste fallito: sessione "${session}" non raggiungibile`,
66
+ ...saved,
67
+ pasted: false,
68
+ });
69
+ }
36
70
  res.json({ ...saved, pasted });
37
71
  });
38
72
  });
@@ -264,7 +264,7 @@ function createLeaseManager(cfg = {}, seams = {}) {
264
264
  socket.on('data', onData);
265
265
  socket.once('close', onEOF);
266
266
  socket.once('end', onEOF);
267
- // Correzione (audit 2a, segnalazione precisata da Dev): questo E' il socket
267
+ // Correzione (audit 2a, segnalazione precisata in revisione): questo E' il socket
268
268
  // su cui il server SCRIVE (l'ack di refresh — proprio dove l'EPIPE nasce se
269
269
  // il peer muore mentre la write e' in volo). Un EventEmitter che emette
270
270
  // 'error' senza listener fa un throw che termina l'INTERO processo — non
@@ -325,7 +325,7 @@ const CATALOG = Object.freeze([
325
325
  // default 'standard'; unsafe -> --always-approve (flag reale di `grok`).
326
326
  { id: 'grok.native', client: 'grok', provider: 'native', label: 'Grok account (CLI login)', auth: 'login', protocol: 'grok_native', core: true },
327
327
 
328
- // VL/Vivling (~/Dev/20_ai-labs/vl): runtime TUI locale. Auth propria del
328
+ // VL/Vivling (repository `vl`): runtime TUI locale. Auth propria del
329
329
  // runtime (default local Ollama, o VL_API_KEY/--profile in config.toml):
330
330
  // NexusCrew non legge ne' copia credenziali (auth 'none'). Il CLI non ha flag
331
331
  // prompt, --model ne' di approvazione (`vl [OPTIONS]`): lancia la TUI senza
@@ -71,7 +71,7 @@ const CLIENT_NAME = 'nexuscrew-live-bridge';
71
71
  // ponte, che e' gia' stata data.
72
72
  const ORPHAN_GRACE_MS = 1500;
73
73
 
74
- // —— Prompt per-cella (rev4 LC2, nome fisso confermato da Dev 2026-08-15) ——
74
+ // —— Prompt per-cella (rev4 LC2, nome fisso confermato il 2026-08-15) ——
75
75
  // Collocazione: filesRoot/<tmuxSession>/LIVE_PROMPT.md — la sessione tmux
76
76
  // ESATTA che il roster dichiara per la cella designata, la stessa fonte gia'
77
77
  // usata per l'intestazione R2 (identityHeader). NON un prefisso ricostruito a
@@ -126,13 +126,39 @@ function readCellPrompt(filesRoot, tmuxSession) {
126
126
  // di identità — è esattamente il difetto visto sul campo (la voce andava a
127
127
  // leggere tmux per capire dove si trovava).
128
128
  //
129
+ // R30 (v2, 2026-08-19): l'intestazione dice anche COME raggiungere i tool
130
+ // NexusCrew. Il daemon app-server espone UN solo insieme di server MCP a
131
+ // tutte le Live, con l'ambiente del daemon: nexuscrew è l'unico che prende
132
+ // l'identità dall'ambiente ereditato, quindi i suoi tool con sessione
133
+ // resterebbero fail-closed (nc_identity: MISSING). La via d'uscita ce l'ha
134
+ // il ponte: il nome esatto della sessione, che il server MCP accetta da
135
+ // NEXUSCREW_MCP_SESSION via stdio. Il valore NON si mette in systemd
136
+ // Environment= (una identità statica condivisa = impersonare una cella
137
+ // fissa, demolito in audit v1): lo dice l'intestazione, per-cella.
138
+ //
129
139
  // Il fatto, niente di più: quale cella (id Fleet) e, se il roster la dichiara,
130
140
  // la sessione tmux esatta — quella con cui la voce raggiunge i canonici della
131
- // cella in ~/NexusFiles/<tmuxSession>/. Non un'instruzione di lavoro: quelle
132
- // vivono nel prompt per-cella, che questa intestazione PRECEDE sempre.
141
+ // cella in ~/NexusFiles/<tmuxSession>/ e la via ai tool per quella sessione.
142
+ // Restano qui FUORI le istruzioni di lavoro: quelle vivono nel prompt
143
+ // per-cella, che questa intestazione PRECEDE sempre.
144
+ //
145
+ // Senza tmuxSession dichiarata non c'è identità possibile: il testo lo DICE,
146
+ // non suggerisce un comando che fallirebbe comunque (e una sessione indovinata
147
+ // sarebbe l'identità di un'altra cella).
133
148
  function identityHeader(cellId, tmuxSession) {
134
- const sessione = tmuxSession ? ` (sessione tmux ${tmuxSession})` : '';
135
- return `Live NexusCrew agganciata alla cella ${cellId}${sessione}.`;
149
+ if (!tmuxSession) {
150
+ return `Live NexusCrew agganciata alla cella ${cellId}. `
151
+ + 'Il roster non dichiara una sessione tmux per questa cella: senza sessione '
152
+ + 'non c\'è identità, quindi i tool NexusCrew che la richiedono non sono '
153
+ + 'raggiungibili da questa Live.';
154
+ }
155
+ return `Live NexusCrew agganciata alla cella ${cellId} (sessione tmux ${tmuxSession}). `
156
+ + 'Questa Live eredita l\'ambiente del daemon, condiviso fra tutte le Live e senza identità: '
157
+ + 'i tool NexusCrew che richiedono la sessione restano chiusi finché non li chiami con la tua. '
158
+ + 'Per usarli avvia il server MCP NexusCrew via stdio con la sessione di questa cella '
159
+ + `nell'ambiente — NEXUSCREW_MCP_SESSION=${tmuxSession} nexuscrew mcp — e parlagli `
160
+ + 'JSON-RPC su stdin (initialize, notifications/initialized, tools/call). '
161
+ + `Il valore esatto per questa conversazione è ${tmuxSession}: mai un'altra sessione.`;
136
162
  }
137
163
 
138
164
  // —— Client on-demand del socket di controllo (sezione protocollo sopra) ——
@@ -39,7 +39,7 @@ function isActive(cell) {
39
39
  return !!(cell && cell.active === true && cell.tmux !== false);
40
40
  }
41
41
 
42
- // --- Seam lease↔designazione (2026-08-15, decisione di Dev: grace = false) -----
42
+ // --- Seam lease↔designazione (2026-08-15, decisione presa in revisione: grace = false) -----
43
43
  //
44
44
  // L'idoneita' dell'host designato non e' piu' solo «sessione tmux viva»: con
45
45
  // remain-on-exit la sessione sopravvive alla morte del supervisore, e la
@@ -186,7 +186,14 @@ function notifyRoutes({
186
186
  originNode: resolved.origin.node,
187
187
  originCell: resolved.origin.cell,
188
188
  });
189
- return res.json({ status: 'delivered', delivered });
189
+ // R31-A3: lo status e' DERIVATO dai conteggi, non dichiarato a parte.
190
+ // `emit` e' best-effort — push fallito → 0, `ui` conta i write SSE
191
+ // riusciti — e il dispatcher propaga SOLO l'etichetta (i conteggi
192
+ // muoiono in forward(), rilievo R1/rc.14): per la cella mittente e'
193
+ // tutta l'informazione. Non puo' affermare una consegna che i conteggi
194
+ // smentiscono: zero canali raggiunti → 'no-delivery'.
195
+ const status = delivered.ui + delivered.push > 0 ? 'delivered' : 'no-delivery';
196
+ return res.json({ status, delivered });
190
197
  }
191
198
 
192
199
  // --- target remoto: instrada, non consegnare qui -----------------------
@@ -316,7 +323,21 @@ function notifyRoutes({
316
323
  // Validazione del testo PRIMA del claim: nessun claim da rilasciare su 400.
317
324
  const raw = req.body && req.body.text;
318
325
  if (typeof raw !== 'string') return res.status(400).json({ error: 'text deve essere una stringa' });
319
- const text = sanitizePasteText(raw).slice(0, MAX_ANSWER);
326
+ // R27: oltre il tetto si RIFIUTA, non si tronca. I due tetti vicini in casa
327
+ // (title/body della notifica, nc_send_cell) rifiutano da sempre; qui invece
328
+ // la textarea non ha limite, la route troncava a MAX_ANSWER e rispondeva
329
+ // {answered:true}: l'operatore incollava una config e la cella riceveva la
330
+ // meta' senza marcatore. Il tetto si misura DOPO la sanificazione: conta
331
+ // il testo che arriva alla cella, non quello digitato.
332
+ const sanitized = sanitizePasteText(raw);
333
+ // La lunghezza misurata ENTRA nel messaggio: la route la conosce, e senza
334
+ // di essa chi ha incollato 12000 caratteri sa che c'e' un tetto ma non di
335
+ // quanto deve tagliare. Dirti che hai sbagliato senza dirti di quanto e'
336
+ // la stessa meta' di difetto che R27 corregge altrove.
337
+ if (sanitized.length > MAX_ANSWER) {
338
+ return res.status(400).json({ error: `text troppo lungo: ${sanitized.length} caratteri, il massimo e' ${MAX_ANSWER}` });
339
+ }
340
+ const text = sanitized;
320
341
  if (!text && asks.get(id)) return res.status(400).json({ error: 'text vuoto dopo la sanificazione' });
321
342
  const claim = asks.claim(id);
322
343
  if (!claim.ok) {
package/lib/server.js CHANGED
@@ -864,7 +864,11 @@ function createServer(opts = {}) {
864
864
  peers: federatedPeers,
865
865
  localPort: () => (server && server.address() ? server.address().port : cfg.port),
866
866
  localToken: () => tokenHolder.value,
867
- statuses: new Set(['delivered', 'refused', 'unreachable', 'unknown']),
867
+ // 'no-delivery' (R31-A3): esito legittimo del target — richiesta accettata
868
+ // ma nessun canale raggiunto (0 UI, 0 push). Senza questa voce forward()
869
+ // lo degraderebbe a 'unknown/unreadable-endpoint-result', nascondendo
870
+ // proprio il silenzio che l'esito esiste per rivelare.
871
+ statuses: new Set(['delivered', 'no-delivery', 'refused', 'unreachable', 'unknown']),
868
872
  }),
869
873
  federatedRate: createSpeakRateLimiter(),
870
874
  }));
@@ -928,13 +928,22 @@ function settingsRoutes(deps = {}) {
928
928
  return send(res, 400, { error: 'body non valido: attesi targets e mode' });
929
929
  }
930
930
  try { return send(res, 200, { group: audioGroups.saveGroup(audioCfg, String(req.params.name || ''), body, home) }); }
931
- catch (_) { return send(res, 400, { error: 'gruppo audio non valido' }); }
931
+ // R31-A4: il catch nudo raccontava QUALUNQUE errore come «gruppo non
932
+ // valido» (400): un EACCES/ENOSPC sulla scrittura di audio-groups.json
933
+ // finiva letto come colpa del nome. La validazione arriva dal modulo con
934
+ // status 400 + code chiuso; tutto il resto — scrittura fallita compresa —
935
+ // è 500 con la causa vera (scrubError ripulisce i path). Stessa formula
936
+ // delle route settings sopra.
937
+ catch (e) { return send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
932
938
  });
933
939
  r.delete('/audio/groups/:name', mutGate, (req, res) => {
934
940
  try {
935
941
  const removed = audioGroups.removeGroup(audioCfg, String(req.params.name || ''), home);
936
942
  return removed ? send(res, 200, { removed: true }) : send(res, 404, { error: 'gruppo audio non trovato' });
937
- } catch (_) { return send(res, 400, { error: 'nome gruppo audio non valido' }); }
943
+ } catch (e) {
944
+ // R31-A4: come PUT sopra — vedi il commento lì.
945
+ return send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) });
946
+ }
938
947
  });
939
948
 
940
949
  // Viewer-local aliases for routed nodes. These routes are local-only and do
@@ -100,6 +100,12 @@ function createNpmUpdater(opts = {}) {
100
100
  supported, enabled, current: currentVersion,
101
101
  phase: state.phase || 'idle', latest: state.latest || '',
102
102
  available: state.available === true && isNewer(state.latest, currentVersion),
103
+ // R31: `idle` di DEFAULT (stato mai scritto) e `idle` DOPO un check
104
+ // senza novita' coincidono — e la UI narrava «aggiornato» in entrambi.
105
+ // `checked` deriva da lastCheckedAt, che viene scritto a OGNI check
106
+ // (riuscito o fallito): chi legge distingue «mai controllato» (ignoranza)
107
+ // da «controllato, nessuna novita'» (buona notizia saputa).
108
+ checked: !!state.lastCheckedAt,
103
109
  lastCheckedAt: state.lastCheckedAt || '', lastUpdatedAt: state.lastUpdatedAt || '',
104
110
  lastError: state.lastError || '', blockedVersion: state.blockedVersion || '',
105
111
  };