@mmmbuto/nexuscrew 0.9.28 → 0.9.30

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 (81) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/LIVE_PROMPT.md +1 -1
  3. package/docs/NOTIFICATIONS.md +37 -0
  4. package/docs/OPERATIONS.md +20 -0
  5. package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
  6. package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
  7. package/frontend/dist/index.html +2 -2
  8. package/frontend/dist/sw.js +91 -10
  9. package/frontend/dist/version.json +1 -1
  10. package/lib/audio/groups.js +1 -1
  11. package/lib/auth/token.js +4 -4
  12. package/lib/cli/commands.js +70 -15
  13. package/lib/cli/doctor.js +5 -5
  14. package/lib/cli/fleet-service.js +7 -7
  15. package/lib/cli/init.js +55 -31
  16. package/lib/cli/pidfile.js +1 -1
  17. package/lib/cli/service.js +124 -8
  18. package/lib/cli/stable-alias.js +4 -4
  19. package/lib/cli/url.js +1 -1
  20. package/lib/config.js +36 -6
  21. package/lib/files/routes.js +1 -1
  22. package/lib/fleet/boot.js +2 -2
  23. package/lib/fleet/builtin.js +15 -15
  24. package/lib/fleet/causes.js +1 -1
  25. package/lib/fleet/cell-exec.js +23 -23
  26. package/lib/fleet/cell-hold.js +6 -6
  27. package/lib/fleet/cell-lease-server.js +106 -106
  28. package/lib/fleet/cell-lease.js +21 -21
  29. package/lib/fleet/definitions.js +10 -10
  30. package/lib/fleet/identity-authority.js +1 -1
  31. package/lib/fleet/launch-broker.js +13 -13
  32. package/lib/fleet/launch.js +15 -15
  33. package/lib/fleet/lease-client.js +23 -23
  34. package/lib/fleet/lease-routes.js +3 -3
  35. package/lib/fleet/lease-verifier.js +22 -22
  36. package/lib/fleet/managed.js +35 -35
  37. package/lib/fleet/prompt-delivery.js +13 -13
  38. package/lib/fleet/routes.js +15 -10
  39. package/lib/fleet/runtime.js +24 -24
  40. package/lib/live-host/bridge.js +53 -43
  41. package/lib/live-host/federation.js +97 -0
  42. package/lib/live-host/routes.js +166 -8
  43. package/lib/live-host/store.js +44 -6
  44. package/lib/mcp/server.js +14 -14
  45. package/lib/mcp/tools.js +5 -5
  46. package/lib/nodes/access-presets.js +258 -0
  47. package/lib/nodes/commands.js +198 -4
  48. package/lib/nodes/store.js +152 -9
  49. package/lib/nodes/tunnel.js +7 -7
  50. package/lib/notify/ask-answer-service.js +184 -0
  51. package/lib/notify/ask-receipts.js +197 -0
  52. package/lib/notify/ask-relay.js +152 -0
  53. package/lib/notify/asks.js +28 -3
  54. package/lib/notify/event-feed-acl.js +95 -0
  55. package/lib/notify/event-feed-asks-routes.js +144 -0
  56. package/lib/notify/event-feed-client.js +434 -0
  57. package/lib/notify/event-feed-history.js +115 -0
  58. package/lib/notify/event-feed-producers.js +175 -0
  59. package/lib/notify/event-feed-routes.js +254 -0
  60. package/lib/notify/event-feed.js +328 -0
  61. package/lib/notify/events.js +1 -1
  62. package/lib/notify/notifier.js +3 -3
  63. package/lib/notify/push-relay.js +240 -0
  64. package/lib/notify/push.js +8 -8
  65. package/lib/notify/routes.js +62 -23
  66. package/lib/proxy/federation.js +132 -12
  67. package/lib/proxy/node-proxy.js +13 -13
  68. package/lib/proxy/panel-auth.js +4 -4
  69. package/lib/proxy/panel-proxy.js +2 -2
  70. package/lib/proxy/resource-acl.js +145 -0
  71. package/lib/server.js +396 -40
  72. package/lib/settings/pairing-coordinator.js +1 -1
  73. package/lib/settings/routes.js +45 -32
  74. package/lib/update/manager.js +1 -1
  75. package/lib/update/runner.js +1 -1
  76. package/lib/voice/transcribe.js +1 -1
  77. package/lib/ws/bridge.js +3 -3
  78. package/package.json +1 -1
  79. package/skills/live/SKILL.md +12 -0
  80. package/frontend/dist/assets/index-CWra6B79.js +0 -167
  81. package/frontend/dist/assets/index-D6CIfCjv.css +0 -32
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // B4.3 — Service companion (boot) generation + migration gate. Design §4c / §9b.
2
+ // Service companion (boot) generation and migration gate.
3
3
  //
4
4
  // generateFleetService({platform, nodeBin, entryPath}) genera il contenuto del
5
5
  // service UNICO di boot (NON una unit per cella) che al boot esegue
@@ -44,7 +44,7 @@ function generateFleetService(opts) {
44
44
  function generateFleetLinux(opts) {
45
45
  const home = opts.home || os.homedir();
46
46
  const { nodeBin, entryPath } = opts;
47
- // Parita' di hardening con service.js (M3): reject char non gestibili in systemd.
47
+ // Parita' di hardening con service.js: reject char non gestibili in systemd.
48
48
  assertSystemdSafe('home', home);
49
49
  assertSystemdSafe('nodeBin', nodeBin);
50
50
  assertSystemdSafe('entryPath', entryPath);
@@ -194,7 +194,7 @@ function selectProviderModeSync(cfg = {}) {
194
194
 
195
195
  // --- Install (speculare a installService di lib/cli/service.js) ---
196
196
  // Stesso hardening di service.js: no-symlink atomic (lstat + tmp+rename), failure
197
- // collection (NON ingoiare activation — M1), execImpl iniettabile (test). Differenze
197
+ // Collection (NON ingoiare activation —), execImpl iniettabile (test). Differenze
198
198
  // companion:
199
199
  // - nomi 'nexuscrew-fleet.*' + mode termux 0o755 (boot script eseguibile)
200
200
  // - linux: daemon-reload + enable, NESSUN restart (Type=oneshot -> parte al boot)
@@ -209,7 +209,7 @@ function fleetInstallPath(platform, home) {
209
209
  }
210
210
 
211
211
  function fleetFileMode(platform) {
212
- if (platform === 'termux') return 0o755; // boot script eseguibile (design §4c: chmod 755)
212
+ if (platform === 'termux') return 0o755; // boot script must stay executable (chmod 755)
213
213
  return 0o644; // systemd unit + launchd plist (come service.js)
214
214
  }
215
215
 
@@ -237,7 +237,7 @@ function fleetInstallCommands(platform, target, ctx) {
237
237
 
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
- // e' PRESERVATO e le failure raccolte per diagnosi (M1: non si ingoia, non si rollback).
240
+ // E' PRESERVATO e le failure raccolte per diagnosi (it is never swallowed, non si rollback).
241
241
  // da revisione: `activate: false` (speculare a installService) scrive la definizione SENZA
242
242
  // toccare il service manager — per chi rigenera a meta' aggiornamento: il companion
243
243
  // e' RunAtLoad/oneshot, un bootstrap adesso eseguirebbe le celle boot:true mentre
@@ -255,7 +255,7 @@ function installFleetService(platform, content, ctx, {
255
255
  const target = ctx.installPath || fleetInstallPath(platform, home);
256
256
  const mode = fleetFileMode(platform);
257
257
 
258
- // lstat: reject symlink preesistente (no-symlink atomic, parita' service.js [M3])
258
+ // Lstat: reject symlink preesistente (no-symlink atomic, parita' service.js)
259
259
  try {
260
260
  const st = fs.lstatSync(target);
261
261
  if (st.isSymbolicLink()) {
@@ -288,7 +288,7 @@ function installFleetService(platform, content, ctx, {
288
288
  return { target, mode, written: true, failures: [], skippedActivation: skipped };
289
289
  }
290
290
 
291
- // exec service manager — raccogli failure per diagnosi (NON ingoiare, M1)
291
+ // Exec service manager — raccogli failure per diagnosi (NON ingoiare)
292
292
  const failures = [];
293
293
  if (platform === 'mac') {
294
294
  failures.push(...reinstallLaunchAgent(target, 'com.mmmbuto.nexuscrew-fleet', ctx, {
package/lib/cli/init.js CHANGED
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
- // nexuscrew init: orchestrazione setup portatile. [B2][M8][R4]
2
+ // Nexuscrew init: orchestrazione setup portatile.
3
3
  // detectPlatform -> prereq (Node>=18 abort, tmux abort-before-service) ->
4
4
  // migration rule (porta da service esistente) -> config.json (preserva) ->
5
5
  // token (preserva) -> NexusFiles -> generateService -> installService (skip dry-run) ->
6
- // [B4.3] fleet companion (solo se provider builtin + gate ok; READONLY/dry-run skip;
7
- // mai fa fallire l'init principale) -> print URL #token. Termux:boot best-effort.
6
+ // fleet companion (only when the provider is builtin and the gate passes;
7
+ // never fails the main init) -> print URL #token. Termux boot: best-effort.
8
8
  const fs = require('node:fs');
9
9
  const os = require('node:os');
10
10
  const path = require('node:path');
@@ -12,7 +12,7 @@ const crypto = require('node:crypto');
12
12
  const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
13
13
  const { loadOrCreateToken } = require('../auth/token.js');
14
14
  const { generateService, installService, fileMode, installPath: svcInstallPath } = require('./service.js');
15
- // B4.3 — companion di boot del fleet (design §4c/§9b/§9d). Seam iniettabili per test.
15
+ // Fleet boot companion. Injectable seams for tests.
16
16
  const {
17
17
  generateFleetService, installFleetService, migrationGate,
18
18
  selectProviderModeSync, fleetFileMode,
@@ -103,7 +103,7 @@ function ensureFleetDefaults(opts = {}) {
103
103
  return { path: fleetDefsPath, created: Boolean(creato), enabled: true };
104
104
  }
105
105
 
106
- // Migration rule (B2): se non c'è config.json, parse la porta dal service file esistente.
106
+ // Migration rule: se non c'è config.json, parse la porta dal service file esistente.
107
107
  function readExistingPort(platform, home, installPathOverride) {
108
108
  const p = installPathOverride || svcInstallPath(platform, home);
109
109
  let raw;
@@ -131,11 +131,15 @@ function runInit(opts = {}) {
131
131
  const tokenPath = opts.tokenPath || path.join(configDir, 'token');
132
132
  const filesRoot = opts.filesRoot || path.join(home, 'NexusFiles');
133
133
  const dryRun = !!opts.dryRun;
134
+ // Write the boot definitions without asking the service manager to load them.
135
+ // Needed by smoke runs and temporary prefixes, where the per-UID manager would
136
+ // otherwise act on the real installation (see planActivation in service.js).
137
+ const noActivate = !!opts.noActivate || opts.activate === false;
134
138
  const installBoot = opts.installBoot !== false;
135
139
  const log = opts.log || (() => {});
136
140
  const tmuxOk = opts.tmuxOk !== undefined ? opts.tmuxOk : haveTmux(opts.tmuxBin || 'tmux', opts.env || process.env);
137
141
 
138
- // prereq Node (abort before any write) [M8]
142
+ // Prereq Node (abort before any write)
139
143
  if (nodeMajor() < 18) {
140
144
  throw new Error(`Node >= 18 richiesto (attuale ${process.versions.node}). Aggiorna Node prima di init.`);
141
145
  }
@@ -144,6 +148,11 @@ function runInit(opts = {}) {
144
148
  // Structured, value-free install outcomes for callers that must fail closed
145
149
  // (notably smart-up migrations). Human-readable actions remain compatible.
146
150
  const installFailures = [];
151
+ // Outcome of the boot-definition activation: 'activated', 'skipped-by-flag'
152
+ // (--no-activate) or 'skipped-foreign-manager' (the manager loads another
153
+ // definition). Declared to callers that must not confuse a deliberate skip
154
+ // with a fresh activation.
155
+ let serviceActivation = null;
147
156
 
148
157
  // porta: opts.port > migration rule > config esistente > default 41820
149
158
  let port = opts.port;
@@ -152,7 +161,7 @@ function runInit(opts = {}) {
152
161
  if (migrated) { port = migrated; actions.push(`migration: porta ${port} letta dal service esistente`); }
153
162
  }
154
163
 
155
- // config.json (scrivi se non esiste; preserva se esiste) [B2]
164
+ // Config.json (scrivi se non esiste; preserva se esiste)
156
165
  if (!dryRun) {
157
166
  fs.mkdirSync(configDir, { recursive: true });
158
167
  if (!fs.existsSync(configPath)) {
@@ -189,7 +198,7 @@ function runInit(opts = {}) {
189
198
  }
190
199
  if (!port) port = 41820;
191
200
 
192
- // token (preserva esistente) [M4]
201
+ // Token (preserva esistente)
193
202
  let token;
194
203
  if (!dryRun) {
195
204
  token = loadOrCreateToken(tokenPath);
@@ -204,7 +213,7 @@ function runInit(opts = {}) {
204
213
  actions.push(`files root ${filesRoot}`);
205
214
  }
206
215
 
207
- // nodes.json (B0): nodeId STABILE per installazione (generato qui se manca) +
216
+ // Nodes.json: nodeId STABILE per installazione (generato qui se manca) +
208
217
  // migrazione esplicita guarded dei nodes legacy da config.json. READONLY/dry-run
209
218
  // saltano; ogni errore -> WARN, MAI far fallire l'init.
210
219
  if (!dryRun && process.env.NEXUSCREW_READONLY !== '1' && !opts.readonly) {
@@ -235,7 +244,7 @@ function runInit(opts = {}) {
235
244
  port, home, uid: uid(),
236
245
  installPath: opts.installPath,
237
246
  };
238
- // 1-bis (R23): la STESSA classe di difetto del companion di boot — nodeBin
247
+ // 1-bis: la STESSA classe di difetto del companion di boot — nodeBin
239
248
  // e entry finiscono versionati nell'unit/plist del servizio per-nodo. Si
240
249
  // risolve qui, alla scrittura, e si dichiara cio' che non ha alias stabile:
241
250
  // i due punti di scrittura non devono divergere (correggerne uno solo
@@ -253,7 +262,7 @@ function runInit(opts = {}) {
253
262
  } else if (dryRun) {
254
263
  actions.push(`DRY-RUN service (${platform}) generato, NON installato`);
255
264
  } else if (!tmuxOk) {
256
- // tmux mancante: abort before service install (config/token gia' creati) [M8]
265
+ // Tmux mancante: abort before service install (config/token gia' creati)
257
266
  // Se il chiamante ha imposto tmuxOk direttamente (test/seam), non
258
267
  // abbiamo l'informazione per distinguere assenza da verifica bloccata:
259
268
  // il messaggio resta quello storico.
@@ -264,26 +273,36 @@ function runInit(opts = {}) {
264
273
  } else {
265
274
  for (const w of svcBoot.warnings) actions.push(`WARN service: ${w}`);
266
275
  try {
267
- const r = installService(platform, content, svcCtx, { execImpl: opts.execImpl });
276
+ const r = installService(platform, content, svcCtx, {
277
+ execImpl: opts.execImpl,
278
+ activate: !noActivate,
279
+ });
280
+ serviceActivation = r.activation || null;
281
+ if (r.activation && r.activation.warning) {
282
+ const skip = r.activation.outcome === 'skipped-by-flag'
283
+ ? r.activation.warning
284
+ : `WARN: ${r.activation.warning}`;
285
+ actions.push(skip);
286
+ }
268
287
  if (r.failures && r.failures.length) {
269
- // file installato MA activation (systemctl/launchctl) fallita [M1]
288
+ // File installato MA activation (systemctl/launchctl) fallita
270
289
  installFailures.push({ component: 'service', phase: 'activation' });
271
290
  actions.push(`WARN: service file installato ${r.target} MA activation fallita: ${r.failures.map((f) => f.cmd).join('; ')} (file preservato, diagnosi)`);
272
291
  } else {
273
292
  actions.push(`service installed ${r.target} (mode 0${fileMode(platform).toString(8)})`);
274
293
  }
275
294
  } catch (e) {
276
- // failure: preserve file + diagnosi (no rollback) [M8]
295
+ // Failure: preserve file + diagnosi (no rollback)
277
296
  installFailures.push({ component: 'service', phase: 'install' });
278
297
  actions.push(`WARN: service install fallito: ${e.message} (file generati preservati)`);
279
298
  }
280
299
  }
281
300
 
282
- // --- B4.3 companion: service di boot del fleet (design §4c/§9b/§9d) ---
283
- // Il companion si installa SOLO se il provider e' builtin (§9b) e il migration
284
- // gate non rileva unit legacy cloud-cell@*.service abilitate (no doppio boot).
285
- // READONLY blocca ogni azione del companion (§9d). MAI far fallire l'init
286
- // principale: ogni errore del companion -> WARN action + return normale.
301
+ // --- Fleet boot companion service ---
302
+ // The companion installs ONLY when the provider is builtin and the migration
303
+ // gate finds no enabled legacy cloud-cell@*.service units (no double boot).
304
+ // READONLY blocks every companion action. Never let it fail the main init:
305
+ // any companion error -> WARN action + normal return.
287
306
  try {
288
307
  const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
289
308
  if (!installBoot) {
@@ -291,9 +310,9 @@ function runInit(opts = {}) {
291
310
  } else if (readonly) {
292
311
  actions.push('fleet companion: READONLY, non installato');
293
312
  } else {
294
- // provider mode: companion SOLO se builtin (§9b). selectProviderModeSync e'
295
- // il default SINCRONO (runInit e' sync: bin chiama process.exit dopo dispatch,
296
- // selectProvider async non sopravvive). Seam opts.selectProvider per i test.
313
+ // provider mode: companion ONLY when builtin. selectProviderModeSync is
314
+ // the synchronous default (runInit is sync: the bin calls process.exit after
315
+ // dispatch, an async selectProvider would not survive). Seam opts.selectProvider for tests.
297
316
  const selectProvider = opts.selectProvider || selectProviderModeSync;
298
317
  const fleetCfg = opts.fleetCfg || {
299
318
  home,
@@ -305,16 +324,16 @@ function runInit(opts = {}) {
305
324
  if (sel.mode !== 'builtin') {
306
325
  actions.push(`fleet companion: non installato (provider ${sel.mode})`);
307
326
  } else {
308
- // migration gate (§9b): seam opts.migrationGate iniettabile; default quello
309
- // di fleet-service.js (exec iniettabile via opts.execImpl). blocked -> NON
310
- // installare (mai doppio boot silenzioso).
327
+ // migration gate: seam opts.migrationGate injectable; the fleet-service.js
328
+ // one (injectable exec via opts.execImpl). Blocked -> do NOT
329
+ // install (never a silent double boot).
311
330
  const gate = (opts.migrationGate || migrationGate)({ exec: opts.execImpl, platform });
312
331
  if (gate.blocked) {
313
332
  actions.push(`WARN: fleet companion NON installato — migration gate bloccato: ${(gate.units || []).join(', ')} (${gate.remediation || 'disabilita le unit legacy cloud-cell@*.service'})`);
314
333
  } else if (dryRun) {
315
334
  actions.push('DRY-RUN fleet companion generato, NON installato');
316
335
  } else {
317
- // R23: i path del boot si risolvono alla SCRITTURA del servizio (mai
336
+ // I path del boot si risolvono alla SCRITTURA del servizio (mai
318
337
  // a ogni avvio): un alias stabile che risolva allo stesso file, se
319
338
  // c'e'; altrimenti il path attuale, DICHIARATO — un avviso adesso e'
320
339
  // meglio di un path che scade in silenzio (vedi stable-alias.js).
@@ -331,10 +350,12 @@ function runInit(opts = {}) {
331
350
  const fr = installFleetService(
332
351
  platform, fleetContent,
333
352
  { home, uid: uid(), installPath: opts.fleetInstallPath },
334
- { execImpl: opts.execImpl },
353
+ { execImpl: opts.execImpl, activate: !noActivate },
335
354
  );
336
- if (fr.failures && fr.failures.length) {
337
- // file installato MA activation (systemctl/launchctl) fallita [M1]
355
+ if (fr.skippedActivation) {
356
+ actions.push(`fleet companion ${fr.target}: activation skipped (--no-activate)`);
357
+ } else if (fr.failures && fr.failures.length) {
358
+ // File installato MA activation (systemctl/launchctl) fallita
338
359
  installFailures.push({ component: 'fleet-companion', phase: 'activation' });
339
360
  actions.push(`WARN: fleet companion file installato ${fr.target} MA activation fallita: ${fr.failures.map((f) => f.cmd).join('; ')} (file preservato, diagnosi)`);
340
361
  } else {
@@ -349,7 +370,7 @@ function runInit(opts = {}) {
349
370
  actions.push(`WARN: fleet companion fallito: ${e.message} (init principale prosegue)`);
350
371
  }
351
372
 
352
- // Termux:boot best-effort detection (R4)
373
+ // Termux:boot best-effort detection
353
374
  if (platform === 'termux' && installBoot) {
354
375
  const bootDir = path.join(home, '.termux', 'boot');
355
376
  const bootOk = fs.existsSync(bootDir);
@@ -367,7 +388,10 @@ function runInit(opts = {}) {
367
388
 
368
389
  for (const a of actions) log(a);
369
390
 
370
- return { platform, port, token, url: urlWithToken, actions, installFailures, tmuxOk, dryRun };
391
+ return {
392
+ platform, port, token, url: urlWithToken, actions, installFailures,
393
+ serviceActivation, tmuxOk, dryRun,
394
+ };
371
395
  }
372
396
 
373
397
  module.exports = { runInit, readExistingPort, haveTmux, nodeMajor, writeConfigAtomic, ensureFleetDefaults };
@@ -2,7 +2,7 @@
2
2
  // Pidfile con verified kill: metadata {pid, cmd, startTs}; kill verifica cmd+pid
3
3
  // prima di signalare (no broad match by name). Il controllo pid+nascita riduce
4
4
  // il PID reuse a una finestra residua fra verifica e segnale — non lo elimina;
5
- // dettaglio e motivo nel commento sopra il kill in killPidfile. [R1]
5
+ // Dettaglio e motivo nel commento sopra il kill in killPidfile.
6
6
  // Primario su Termux (serve --pidfile); opzionale --manual su linux/mac.
7
7
  const fs = require('node:fs');
8
8
  const os = require('node:os');
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
  // Service generation per-platform: systemd --user (linux), launchd plist (mac),
3
- // Termux:boot script. Escaping per-platform + install no-symlink atomic. [M2][M3][B1][R2][R3]
3
+ // Termux:boot script. Escaping per-platform + install no-symlink atomic.
4
4
  const fs = require('node:fs');
5
5
  const os = require('node:os');
6
6
  const path = require('node:path');
@@ -42,7 +42,7 @@ function shellQuote(s) {
42
42
  }
43
43
 
44
44
  // systemd ExecStart non gestisce bene alcuni char (", $, ;, backtick, newline, apice)
45
- // nemmeno con escaping ad hoc: reject esplicito con errore chiaro. [M3]
45
+ // Nemmeno con escaping ad hoc: reject esplicito con errore chiaro.
46
46
  // space e % sono ok (escaped). Path repo normali non hanno questi char.
47
47
  const SYSTEMD_FORBIDDEN = /[";$`\n']/;
48
48
  function assertSystemdSafe(label, p) {
@@ -54,7 +54,7 @@ function assertSystemdSafe(label, p) {
54
54
  // --- Templates ---
55
55
 
56
56
  // Entry del servizio: di norma derivato da repoRoot; i punti di scrittura
57
- // che risolvono l'alias stabile (R23/1-bis, stable-alias.js) passano
57
+ // Che risolvono l'alias stabile (/1-bis, stable-alias.js) passano
58
58
  // l'entryPath gia' risolto. Contratto vecchio intatto: senza entryPath si
59
59
  // deriva da repoRoot come prima.
60
60
  function serviceEntryPath(ctx) {
@@ -69,7 +69,7 @@ function generateService(platform, ctx) {
69
69
  }
70
70
 
71
71
  function generateLinux(ctx) {
72
- assertSystemdSafe('repoRoot', ctx.repoRoot); // [M3] reject char non gestibili
72
+ assertSystemdSafe('repoRoot', ctx.repoRoot); // Reject char non gestibili
73
73
  assertSystemdSafe('nodeBin', ctx.nodeBin);
74
74
  // HOME is stable across init/repair/update. The runtime directory may be
75
75
  // atomically replaced; keeping it as the service cwd leaves the HTTP process
@@ -304,7 +304,7 @@ function reinstallLaunchAgent(target, serviceName, ctx, opts = {}) {
304
304
  }
305
305
  }
306
306
 
307
- // Install no-symlink + atomic rename. [M3]
307
+ // Install no-symlink + atomic rename.
308
308
  // - lstat target: reject symlink (no follow)
309
309
  // - write temp file nella stessa dir -> chmod mode -> atomic rename
310
310
  // - exec service manager (systemctl/launchctl); skip in dryRun
@@ -352,15 +352,20 @@ function installService(platform, content, ctx, {
352
352
  throw e;
353
353
  }
354
354
 
355
- // exec service manager — NON ingoiare failure (M1): raccogli per diagnosi
355
+ // Exec service manager — NON ingoiare failure: raccogli per diagnosi
356
356
  const cmds = installCommands(platform, target, ctx);
357
- if (!activate) {
357
+ const activation = planActivation(platform, target, ctx, { execImpl, activate });
358
+ if (!activation.activate) {
359
+ // The manager will not run what this HOME just wrote, or the caller asked to
360
+ // write without activating: the skipped plan stays declared, and a skip for a
361
+ // foreign HOME says why instead of touching a service that is not ours.
358
362
  return {
359
363
  target,
360
364
  mode,
361
365
  written: true,
362
366
  failures: [],
363
367
  skippedActivation: cmds.map(([bin, args]) => `${bin} ${args.join(' ')}`),
368
+ activation,
364
369
  };
365
370
  }
366
371
  const failures = [];
@@ -380,7 +385,7 @@ function installService(platform, content, ctx, {
380
385
  }
381
386
  }
382
387
 
383
- return { target, mode, written: true, failures };
388
+ return { target, mode, written: true, failures, activation };
384
389
  }
385
390
 
386
391
  function installCommands(platform, target, ctx) {
@@ -407,6 +412,116 @@ function installCommands(platform, target, ctx) {
407
412
  }
408
413
 
409
414
 
415
+ // --- Activation guard -------------------------------------------------------
416
+ // The user service manager is per-UID, not per-HOME. `systemctl --user` and
417
+ // `launchctl` resolve our unit by NAME inside the namespace of the logged-in
418
+ // user, while this module writes the definition under the HOME it was given.
419
+ // A run with a temporary HOME therefore wrote a definition the manager never
420
+ // loads, and the activation commands still hit the real manager: the REAL
421
+ // service was restarted twice during a single smoke run on the live node.
422
+ //
423
+ // Decide ONCE, before the activation commands, reading back WHICH definition the
424
+ // manager has loaded. If it is not the one just written, activation is skipped
425
+ // and said out loud, instead of touching a service this HOME does not own.
426
+ const ACTIVATION_OUTCOMES = Object.freeze({
427
+ activated: 'activated',
428
+ skippedByFlag: 'skipped-by-flag',
429
+ skippedForeignManager: 'skipped-foreign-manager',
430
+ notApplicable: 'not-applicable',
431
+ });
432
+
433
+ const NO_ACTIVATE_WARNING = (target) =>
434
+ `unit installed at ${target}, activation skipped: no service manager command was run`;
435
+
436
+ function foreignManagerWarning(target, managerPath) {
437
+ return `unit installed at ${target} but the user service manager loads ${managerPath}: `
438
+ + 'activation skipped, run `nexuscrew restart` from that home or use --no-activate';
439
+ }
440
+
441
+ // Two paths name the same file when they resolve to the same inode path; the
442
+ // manager reports its own canonical form, so compare canonically on both sides.
443
+ function sameDefinitionPath(a, b) {
444
+ const canonical = (p) => {
445
+ try { return fs.realpathSync(p); } catch (_) { return path.resolve(String(p)); }
446
+ };
447
+ return canonical(a) === canonical(b);
448
+ }
449
+
450
+ // Which definition does the user service manager have loaded for our unit?
451
+ // absent nothing loaded, or no readable answer: activation stays as it was
452
+ // same it loads the definition just written
453
+ // foreign it loads a DIFFERENT definition: this HOME does not own that service
454
+ //
455
+ // The guard fires on POSITIVE evidence only. An answer we cannot read as a
456
+ // definition path is never turned into a silent behaviour change: it keeps the
457
+ // previous activation path instead of guessing.
458
+ function readLoadedDefinition(platform, target, ctx, execImpl) {
459
+ if (platform === 'linux') {
460
+ let raw;
461
+ try {
462
+ raw = execImpl('systemctl', [
463
+ '--user', 'show', 'nexuscrew.service', '--property=FragmentPath', '--value',
464
+ ], { encoding: 'utf8' });
465
+ } catch (_) {
466
+ // No probing possible (no user manager): the activation commands below fail
467
+ // the same way and are reported as failures, exactly as they were before.
468
+ return { state: 'absent', path: null };
469
+ }
470
+ const loaded = typeof raw === 'string' ? raw.trim() : '';
471
+ if (!loaded) return { state: 'absent', path: null };
472
+ return sameDefinitionPath(loaded, target)
473
+ ? { state: 'same', path: loaded }
474
+ : { state: 'foreign', path: loaded };
475
+ }
476
+ if (platform === 'mac') {
477
+ const label = `gui/${ctx.uid || uid()}/com.mmmbuto.nexuscrew`;
478
+ let raw;
479
+ try {
480
+ raw = execImpl('launchctl', ['print', label], { encoding: 'utf8' });
481
+ } catch (_) {
482
+ return { state: 'absent', path: null }; // not loaded
483
+ }
484
+ const match = typeof raw === 'string' ? raw.match(/^\s*path = (.+?)\s*$/m) : null;
485
+ if (!match) return { state: 'absent', path: null };
486
+ return sameDefinitionPath(match[1], target)
487
+ ? { state: 'same', path: match[1] }
488
+ : { state: 'foreign', path: match[1] };
489
+ }
490
+ return { state: 'absent', path: null };
491
+ }
492
+
493
+ // The single decision point for activation: callers write the definition, then
494
+ // ask this whether the manager may be touched at all.
495
+ function planActivation(platform, target, ctx, { execImpl = execFileSync, activate = true } = {}) {
496
+ if (platform === 'termux') {
497
+ return { activate: false, outcome: ACTIVATION_OUTCOMES.notApplicable, managerPath: null, warning: null };
498
+ }
499
+ if (!activate) {
500
+ return {
501
+ activate: false,
502
+ outcome: ACTIVATION_OUTCOMES.skippedByFlag,
503
+ managerPath: null,
504
+ warning: NO_ACTIVATE_WARNING(target),
505
+ };
506
+ }
507
+ const loaded = readLoadedDefinition(platform, target, ctx, execImpl);
508
+ if (loaded.state === 'foreign') {
509
+ return {
510
+ activate: false,
511
+ outcome: ACTIVATION_OUTCOMES.skippedForeignManager,
512
+ managerPath: loaded.path,
513
+ warning: foreignManagerWarning(target, loaded.path),
514
+ };
515
+ }
516
+ return {
517
+ activate: true,
518
+ outcome: ACTIVATION_OUTCOMES.activated,
519
+ managerPath: loaded.path,
520
+ warning: null,
521
+ };
522
+ }
523
+
524
+
410
525
  // --- Riconoscimento di PROPRIETA' (da revisione) -------------------------
411
526
  // La sanazione automatica riscrive una definizione solo se e' NOSTRA. La prima
412
527
  // versione cercava /nexuscrew/i nel contenuto: un unit di terzi che ci nomina
@@ -456,6 +571,7 @@ module.exports = {
456
571
  isOurService, OUR_SERVICE_ANCHOR,
457
572
  generateService, generateLinux, generateMac, generateTermux,
458
573
  installService, installPath, installCommands, fileMode,
574
+ planActivation, readLoadedDefinition, sameDefinitionPath, ACTIVATION_OUTCOMES,
459
575
  ensureLinuxTmuxSurvival, linuxTmuxSurvivalPath, LINUX_TMUX_SURVIVAL_DROPIN,
460
576
  reinstallLaunchAgent, waitForLaunchdUnload,
461
577
  escapeSystemdPath, escapeSystemdExec, escapeXml, shellQuote,
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // R23 — alias stabili per i path del servizio di boot.
2
+ // Alias stabili per i path del servizio di boot.
3
3
  //
4
4
  // Il companion di boot scrive nodeBin e entryPath LETTERALMENTE nel plist
5
5
  // launchd (ProgramArguments[0]) e nell'unit systemd (ExecStart=). Quei path
@@ -17,7 +17,7 @@
17
17
  // Se c'e', scrivere quello; se no, scrivere il path attuale e DICHIARARLO:
18
18
  // un avviso alla scrittura e' meglio di un path che scade in silenzio.
19
19
  //
20
- // Tre vincoli non negoziabili (briefing R23):
20
+ // Tre vincoli non negoziabili (briefing):
21
21
  // 1. La verifica «e' lo stesso file» si fa alla SCRITTURA del servizio, MAI a
22
22
  // ogni avvio: dopo l'upgrade il symlink DEVE puntare a un node diverso —
23
23
  // e' l'effetto voluto. Un controllo di identita' permanente si
@@ -116,7 +116,7 @@ function resolveBootPaths({ nodeBin, entryPath, env = process.env, realpath } =
116
116
  // morto: il suo criterio e' «alias che realpath allo STESSO file», e un file
117
117
  // morto non e' lo stesso file di nessuno. Il criterio qui e' diverso e
118
118
  // dichiarato: un path VIVO — l'alias stabile dello stesso file quando
119
- // l'attuale vive (R23 intatto), altrimenti il primo candidato stabile che
119
+ // L'attuale vive (intatto), altrimenti il primo candidato stabile che
120
120
  // ESISTE, con la dichiarazione; se non c'e' niente di vivo, il path resta e
121
121
  // la dichiarazione dice di rilanciare init. `env`, `realpath` e `exists`
122
122
  // iniettabili per test.
@@ -127,7 +127,7 @@ function resolveLiveBootPaths({ nodeBin, entryPath, env = process.env, realpath,
127
127
  const revive = (current, candidates, what) => {
128
128
  try {
129
129
  realpathImpl(current);
130
- return null; // vivo: decide il criterio R23 di resolveBootPaths
130
+ return null; // Vivo: decide il criterio di resolveBootPaths
131
131
  } catch (_) { /* morto su disco: cerca una alternativa viva */ }
132
132
  for (const candidate of Array.isArray(candidates) ? candidates : []) {
133
133
  if (existsImpl(candidate)) {
package/lib/cli/url.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // URL/token helpers per la CLI unificata (design §3). [A2]
2
+ // URL/token helpers for the unified CLI.
3
3
  // - resolvePaths: config/token path home-aware (seam per test con HOME temporanea).
4
4
  // - readToken: legge il token esistente SENZA crearlo (no-follow symlink), null se assente.
5
5
  // - loadPort: porta corrente (opts > env > config.json > default 41820).
package/lib/config.js CHANGED
@@ -17,7 +17,7 @@ function baseDefaults() {
17
17
  return {
18
18
  bind: '127.0.0.1',
19
19
  port: 41820,
20
- // Seconda porta loopback, SOLO per /panel/* (P0 sicurezza 2026-08-16):
20
+ // Seconda porta loopback, SOLO per /panel/* (sicurezza 2026-08-16):
21
21
  // un pannello vive su un'origin diversa dal control plane — la porta fa
22
22
  // parte dell'origin, quindi un iframe cross-porta non legge il
23
23
  // localStorage del padre, qualunque JS esegua il container. Nessuna API
@@ -57,14 +57,14 @@ function baseDefaults() {
57
57
  // Il manager aggiorna solo verso una semver superiore: mai downgrade.
58
58
  autoUpdate: true,
59
59
  sessionPresets: {},
60
- // Ponte Live (fetta 3, rev5 MC0): isolabile — a false il ponte non si
60
+ // Ponte Live (fetta 3, rev5): isolabile — a false il ponte non si
61
61
  // connette mai e ogni avvio di Live resta sul comportamento standard.
62
62
  liveBridgeEnabled: true,
63
63
  // Socket di controllo dell'app-server (misurato 2026-08-15:
64
64
  // $CODEX_HOME/app-server-control/app-server-control.sock, WebSocket sopra
65
65
  // unix socket). Non cablare mai una porta al posto di questo path.
66
66
  liveBridgeSocketPath: path.join(os.homedir(), '.codex', 'app-server-control', 'app-server-control.sock'),
67
- // MC1.5: limite dichiarato per OGNI fase del ponte (GET designazione e
67
+ // Limite dichiarato per OGNI fase del ponte (GET designazione e
68
68
  // sessione sul socket). Oltre questo la Live parte senza puntamento.
69
69
  liveBridgeTimeoutMs: 1500,
70
70
  // PTY grace is finite and configurable for mobile handovers. The caps keep
@@ -82,7 +82,37 @@ function configJsonPath() {
82
82
  return process.env.NEXUSCREW_CONFIG_FILE || path.join(os.homedir(), '.nexuscrew', 'config.json');
83
83
  }
84
84
 
85
- // Legge ~/.nexuscrew/config.json se esiste (nuovo, B2). {} se assente/malformato.
85
+ // Legge ~/.nexuscrew/config.json se esiste (nuovo). {} se assente/malformato.
86
+ // federation.events.enabled — the node-level kill switch for the event feed.
87
+ // STRICT boolean: only the literal false disables. A truthy string or any
88
+ // other type never enables anything beyond the default, and never disables by
89
+ // accident: "false" (string) is not a decision, it is noise, and the default
90
+ // holds. Without explicit grants nothing is enabled either way — the switch
91
+ // only stops what grants already allow.
92
+ function eventsEnabledFlag(raw, log = console.warn) {
93
+ // Fail-closed strictness: only the literal true/false is a decision. A
94
+ // missing value keeps the default (enabled); ANY other type — "false" as a
95
+ // string, 0, null, an object — is invalid config, is treated as DISABLED and
96
+ // is reported: silently enabling on garbage is how switches lie.
97
+ if (raw === undefined) return true;
98
+ if (raw === true) return true;
99
+ if (raw === false) return false;
100
+ try { log('config: federation.events.enabled must be a boolean; invalid value treated as disabled'); } catch (_) {}
101
+ return false;
102
+ }
103
+
104
+ function readFederationEventsEnabled(opts = {}) {
105
+ if (typeof opts.federationEventsEnabled === 'boolean') return opts.federationEventsEnabled;
106
+ let cfg = {};
107
+ try {
108
+ const raw = fs.readFileSync(opts.configJsonPath || configJsonPath(), 'utf8');
109
+ cfg = JSON.parse(raw) || {};
110
+ } catch (_) { cfg = {}; }
111
+ const fed = cfg && typeof cfg.federation === 'object' && cfg.federation ? cfg.federation : null;
112
+ const events = fed && typeof fed.events === 'object' && fed.events ? fed.events : null;
113
+ return eventsEnabledFlag(events ? events.enabled : undefined);
114
+ }
115
+
86
116
  function readConfigJson() {
87
117
  try {
88
118
  const raw = fs.readFileSync(configJsonPath(), 'utf8');
@@ -134,7 +164,7 @@ function envOverrides() {
134
164
  return e;
135
165
  }
136
166
 
137
- // Precedence: baseDefaults < config.json < env < opts. [B2]
167
+ // Precedence: baseDefaults < config.json < env < opts.
138
168
  // Le credenziali authority vivono in due file 0600 nella dir
139
169
  // dell'authority (default ~/.nexuscrew/identity-authority). Se esistono vengono
140
170
  // caricate qui, senza MAI loggare i valori: builtin costruisce l'authority
@@ -195,4 +225,4 @@ function defaults() {
195
225
  return { ...baseDefaults(), ...envOverrides() };
196
226
  }
197
227
 
198
- module.exports = { assertLoopback, baseDefaults, readConfigJson, loadConfig, defaults, LOOPBACK, configJsonPath };
228
+ module.exports = { assertLoopback, baseDefaults, readConfigJson, loadConfig, defaults, LOOPBACK, configJsonPath, eventsEnabledFlag, readFederationEventsEnabled };
@@ -55,7 +55,7 @@ function filesRoutes({
55
55
  // paste=false (tasto allegati del composer): il client appende il path al
56
56
  // testo del composer — niente scrittura PTY. Default = incolla (FilesPanel).
57
57
  const wantPaste = String((req.body && req.body.paste) || '') !== 'false';
58
- // R31-A2: paste RICHIESTO ma non riuscito non e' un 200 con pasted:false —
58
+ // : paste RICHIESTO ma non riuscito non e' un 200 con pasted:false —
59
59
  // tre cause collassavano in quell'unica etichetta muta (non richiesto /
60
60
  // tmux non ha ricevuto / testo respinto) e nessuno, risposta o UI, poteva
61
61
  // accorgersene. Il vocabolario e' quello del ramo answer degli ask
package/lib/fleet/boot.js CHANGED
@@ -1,9 +1,9 @@
1
1
  'use strict';
2
- // B4.3 — Service companion boot. bootCells(fleet, {log}) avvia tutte le celle
2
+ // Fleet boot companion: bootCells(fleet, {log}) starts the boot:true cells
3
3
  // boot:true di un fleet provider (builtin) chiamandone up(). Una cella gia' in
4
4
  // esecuzione (up -> httpError 409 / "duplicate session") = SKIP non fatale;
5
5
  // ogni altro errore viene raccolto in failed[] e NON ferma le altre celle.
6
- // Nessun side-effect all'import. Design §4c / §9b.
6
+ // No side effects on import.
7
7
  //
8
8
  // Agnostico: usa solo il contratto fleet {available, status(), up()} — non legge
9
9
  // internals delle definizioni. La lista delle celle boot:true arriva da status()