@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.
- package/CHANGELOG.md +98 -0
- package/frontend/dist/assets/index-CN02GYPg.css +32 -0
- package/frontend/dist/assets/index-DlWa7GOd.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +17 -3
- package/lib/cli/commands.js +32 -3
- package/lib/cli/fleet-service.js +44 -2
- package/lib/cli/pidfile.js +2 -2
- package/lib/cli/service.js +49 -2
- package/lib/cli/stable-alias.js +49 -0
- package/lib/files/routes.js +35 -1
- package/lib/fleet/cell-lease-server.js +1 -1
- package/lib/fleet/managed.js +1 -1
- package/lib/live-host/bridge.js +31 -5
- package/lib/live-host/routes.js +1 -1
- package/lib/notify/routes.js +23 -2
- package/lib/server.js +5 -1
- package/lib/settings/routes.js +11 -2
- package/lib/update/manager.js +6 -0
- package/lib/update/runner.js +244 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-DDAwkwKR.css +0 -32
- package/frontend/dist/assets/index-YpDRVy-I.js +0 -93
package/frontend/dist/index.html
CHANGED
|
@@ -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-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
1
|
+
{"version":"0.9.9"}
|
package/lib/audio/groups.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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];
|
package/lib/cli/commands.js
CHANGED
|
@@ -973,7 +973,14 @@ function autoUpdateCommand(args, opts = {}) {
|
|
|
973
973
|
});
|
|
974
974
|
}
|
|
975
975
|
|
|
976
|
-
// update: npm i -g @latest +
|
|
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);
|
package/lib/cli/fleet-service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/lib/cli/pidfile.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
package/lib/cli/service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/lib/cli/stable-alias.js
CHANGED
|
@@ -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
|
};
|
package/lib/files/routes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
package/lib/fleet/managed.js
CHANGED
|
@@ -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 (
|
|
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
|
package/lib/live-host/bridge.js
CHANGED
|
@@ -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
|
|
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
|
|
132
|
-
//
|
|
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
|
-
|
|
135
|
-
|
|
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) ——
|
package/lib/live-host/routes.js
CHANGED
|
@@ -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
|
|
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
|
package/lib/notify/routes.js
CHANGED
|
@@ -186,7 +186,14 @@ function notifyRoutes({
|
|
|
186
186
|
originNode: resolved.origin.node,
|
|
187
187
|
originCell: resolved.origin.cell,
|
|
188
188
|
});
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}));
|
package/lib/settings/routes.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
package/lib/update/manager.js
CHANGED
|
@@ -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
|
};
|