@mmmbuto/nexuscrew 0.9.29 → 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 (77) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/LIVE_PROMPT.md +1 -1
  3. package/docs/NOTIFICATIONS.md +37 -0
  4. package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
  5. package/frontend/dist/assets/{index-CywSos2e.css → index-DqCfZFGi.css} +1 -1
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +91 -10
  8. package/frontend/dist/version.json +1 -1
  9. package/lib/audio/groups.js +1 -1
  10. package/lib/auth/token.js +4 -4
  11. package/lib/cli/commands.js +59 -12
  12. package/lib/cli/doctor.js +5 -5
  13. package/lib/cli/fleet-service.js +7 -7
  14. package/lib/cli/init.js +27 -27
  15. package/lib/cli/pidfile.js +1 -1
  16. package/lib/cli/service.js +6 -6
  17. package/lib/cli/stable-alias.js +4 -4
  18. package/lib/cli/url.js +1 -1
  19. package/lib/config.js +36 -6
  20. package/lib/files/routes.js +1 -1
  21. package/lib/fleet/boot.js +2 -2
  22. package/lib/fleet/builtin.js +15 -15
  23. package/lib/fleet/causes.js +1 -1
  24. package/lib/fleet/cell-exec.js +23 -23
  25. package/lib/fleet/cell-hold.js +6 -6
  26. package/lib/fleet/cell-lease-server.js +106 -106
  27. package/lib/fleet/cell-lease.js +21 -21
  28. package/lib/fleet/definitions.js +10 -10
  29. package/lib/fleet/identity-authority.js +1 -1
  30. package/lib/fleet/launch-broker.js +13 -13
  31. package/lib/fleet/launch.js +15 -15
  32. package/lib/fleet/lease-client.js +23 -23
  33. package/lib/fleet/lease-routes.js +3 -3
  34. package/lib/fleet/lease-verifier.js +22 -22
  35. package/lib/fleet/managed.js +35 -35
  36. package/lib/fleet/prompt-delivery.js +13 -13
  37. package/lib/fleet/routes.js +15 -10
  38. package/lib/fleet/runtime.js +24 -24
  39. package/lib/live-host/bridge.js +36 -36
  40. package/lib/live-host/routes.js +2 -2
  41. package/lib/live-host/store.js +4 -4
  42. package/lib/mcp/server.js +14 -14
  43. package/lib/mcp/tools.js +5 -5
  44. package/lib/nodes/access-presets.js +258 -0
  45. package/lib/nodes/commands.js +198 -4
  46. package/lib/nodes/store.js +152 -9
  47. package/lib/nodes/tunnel.js +7 -7
  48. package/lib/notify/ask-answer-service.js +184 -0
  49. package/lib/notify/ask-receipts.js +197 -0
  50. package/lib/notify/ask-relay.js +152 -0
  51. package/lib/notify/asks.js +28 -3
  52. package/lib/notify/event-feed-acl.js +95 -0
  53. package/lib/notify/event-feed-asks-routes.js +144 -0
  54. package/lib/notify/event-feed-client.js +434 -0
  55. package/lib/notify/event-feed-history.js +115 -0
  56. package/lib/notify/event-feed-producers.js +175 -0
  57. package/lib/notify/event-feed-routes.js +254 -0
  58. package/lib/notify/event-feed.js +328 -0
  59. package/lib/notify/events.js +1 -1
  60. package/lib/notify/notifier.js +3 -3
  61. package/lib/notify/push-relay.js +240 -0
  62. package/lib/notify/push.js +8 -8
  63. package/lib/notify/routes.js +62 -23
  64. package/lib/proxy/federation.js +74 -3
  65. package/lib/proxy/node-proxy.js +13 -13
  66. package/lib/proxy/panel-auth.js +4 -4
  67. package/lib/proxy/panel-proxy.js +2 -2
  68. package/lib/proxy/resource-acl.js +145 -0
  69. package/lib/server.js +372 -39
  70. package/lib/settings/pairing-coordinator.js +1 -1
  71. package/lib/settings/routes.js +45 -32
  72. package/lib/update/manager.js +1 -1
  73. package/lib/update/runner.js +1 -1
  74. package/lib/voice/transcribe.js +1 -1
  75. package/lib/ws/bridge.js +3 -3
  76. package/package.json +1 -1
  77. package/frontend/dist/assets/index-B_THfiL5.js +0 -167
@@ -19,19 +19,19 @@
19
19
  // going to operate on.
20
20
  //
21
21
  // Invarianti (contratto fetta 3):
22
- // - MC1: la designazione si legge con UNA GET su loopback verso
22
+ // La designazione si legge con UNA GET su loopback verso
23
23
  // /api/live-host, autenticata col token del nodo. Nessun accesso diretto
24
24
  // allo store: la route è l'unica verità, `eligible` non si ricalcola.
25
- // - MC1.5 / JC3.3: nessuna attesa introdotta. Ogni fase ha il limite
25
+ // Nessuna attesa introdotta. Ogni fase ha il limite
26
26
  // dichiarato cfg.liveBridgeTimeoutMs; oltre quello, o su qualunque
27
27
  // fallimento, la risposta è `none` col motivo: la Live parte senza
28
28
  // puntamento, comportamento standard. Un `none` non è un errore HTTP.
29
- // - MC2: il prompt per-cella (LIVE_PROMPT.md accanto ai canonici della
29
+ // Il prompt per-cella (LIVE_PROMPT.md accanto ai canonici della
30
30
  // cella) viaggia su developerInstructions di thread/start e SOSTITUISCE
31
- // le developer instructions della config per quella Live (rev4 LC2
32
- // emendata da rev5 MC2). La riga che decide è in codex-rs
31
+ // Le developer instructions della config per quella Live (rev4
32
+ // Emendata da rev5). La riga che decide è in codex-rs
33
33
  // core/src/config/mod.rs: `developer_instructions.or(cfg.developer_
34
- // instructions)` — l'override Some scarta il valore di config. R2
34
+ // Instructions)` — l'override Some scarta il valore di config.
35
35
  // (verso corretto dopo revisione pre-release): l'identità della
36
36
  // cella designata viaggia SEMPRE come intestazione anteposta al campo,
37
37
  // anche senza prompt. Il campo NON è additivo: una cella senza
@@ -41,18 +41,18 @@
41
41
  // il world state (fragment user, canale separato) e il prompt base.
42
42
  // La via designata per le istruzioni di lavoro della Live è il
43
43
  // LIVE_PROMPT.md della cella: viaggia nello stesso campo.
44
- // - MC3: il ponte crea le proprie conversazioni con thread/start e non
44
+ // Il ponte crea le proprie conversazioni con thread/start e non
45
45
  // tocca MAI la thread di una TUI — né turn/start né thread/resume. La sonda
46
46
  // thread/read e' separata e sola lettura: non modifica il thread ponte ne'
47
47
  // quello della TUI. Per questo l'aggancio funziona anche su una cella che
48
48
  // sta già processando un turno: conversazioni separate, nessuna
49
- // interruzione (rev1 HC2/rev2 JC4).
50
- // - MC3.3: la connessione al socket di controllo è ON-DEMAND (connect →
49
+ // Interruzione (rev1 /rev2).
50
+ // La connessione al socket di controllo è ON-DEMAND (connect →
51
51
  // handshake → thread/start → close), mai permanente: la fuga notifiche
52
52
  // notata in rev5 riguarda i client permanenti.
53
- // - MC3.4: il ponte opera SOLO sulla cella designata — non accetta target
54
- // dal chiamante, la designazione è la condizione (LC3).
55
- // - MC0: isolabile — cfg.liveBridgeEnabled=false e il ponte non si connette
53
+ // Il ponte opera SOLO sulla cella designata — non accetta target
54
+ // Dal chiamante, la designazione è la condizione.
55
+ // Isolabile — cfg.liveBridgeEnabled=false e il ponte non si connette
56
56
  // mai, non fa GET, risponde `none` senza toccare nulla.
57
57
  //
58
58
  // Protocollo del socket di controllo (misurato sul runtime 2026-08-15):
@@ -61,7 +61,7 @@
61
61
  // (senza params). Poi `thread/start` {cwd, developerInstructions?} → response
62
62
  // {thread:{id}, cwd}, oppure `thread/read` {threadId, includeTurns:false} per
63
63
  // leggere il runtime. Il socket è 0600 dell'utente: il confine è quello
64
- // (MC1.3), non c'è autenticazione applicativa.
64
+ // Non c'è autenticazione applicativa.
65
65
 
66
66
  const fs = require('node:fs');
67
67
  const path = require('node:path');
@@ -174,10 +174,10 @@ function queryThreadStatusOnControlSocket({
174
174
  });
175
175
  }
176
176
 
177
- // —— Prompt per-cella (rev4 LC2, nome fisso confermato il 2026-08-15) ——
177
+ // — Prompt per-cella (rev4, nome fisso confermato il 2026-08-15) ——
178
178
  // Collocazione: filesRoot/<tmuxSession>/LIVE_PROMPT.md — la sessione tmux
179
179
  // ESATTA che il roster dichiara per la cella designata, la stessa fonte gia'
180
- // usata per l'intestazione R2 (identityHeader). NON un prefisso ricostruito a
180
+ // Usata per l'intestazione (identityHeader). NON un prefisso ricostruito a
181
181
  // mano: fino al 2026-08-16 questa funzione anteponeva 'cloud-' come default
182
182
  // universale quando il cellId non ce l'aveva gia' — su un device che chiama
183
183
  // le proprie sessioni con un prefisso diverso il file non veniva MAI trovato,
@@ -194,8 +194,8 @@ function queryThreadStatusOnControlSocket({
194
194
  // costruito (mai un prefisso indovinato),
195
195
  // quindi non si tenta nemmeno la lettura
196
196
  // applied:false, reason missing → ENOENT sul path dichiarato: assenza
197
- // legittima (LC2.3), si procede senza
198
- // PROMPT (R2: l'intestazione identità
197
+ // Legittima, si procede senza
198
+ // PROMPT: the identity header
199
199
  // viaggia comunque). ATTENZIONE: il campo
200
200
  // developerInstructions viene comunque
201
201
  // inviato per via dell'intestazione, e il
@@ -203,7 +203,7 @@ function queryThreadStatusOnControlSocket({
203
203
  // configurazione invece di sommarlo — chi
204
204
  // non ha prompt per-cella non riceve le
205
205
  // developer instructions globali che
206
- // riceveva prima (vedi MC2, verificato
206
+ // Riceveva prima (verificato
207
207
  // sulla riga che decide)
208
208
  // applied:false, reason unreadable|empty → presente ma inutilizzabile: va
209
209
  // dichiarato, mai silenziato
@@ -222,14 +222,14 @@ function readCellPrompt(filesRoot, tmuxSession) {
222
222
  return { applied: true, source: 'LIVE_PROMPT.md', text };
223
223
  }
224
224
 
225
- // —— Identità della Live (R2, 2026-08-16): la porta il ponte, non il prompt ——
226
- // Il ponte ha la designazione IN MANO (MC3.4: è la sua condizione di
227
- // funzionamento) e un prompt può legittimamente mancare (MC2.4): se
225
+ // — Identità della Live (2026-08-16): la porta il ponte, non il prompt ——
226
+ // Il ponte ha la designazione IN MANO: è la sua condizione di
227
+ // Funzionamento) e un prompt può legittimamente mancare: se
228
228
  // l'identità dipendesse dal prompt, l'assenza del prompt diventerebbe assenza
229
229
  // di identità — è esattamente il difetto visto sul campo (la voce andava a
230
230
  // leggere tmux per capire dove si trovava).
231
231
  //
232
- // R30 (v2, 2026-08-19): l'intestazione dice anche COME raggiungere i tool
232
+ // (v2, 2026-08-19): the header also states HOW to reach the tools
233
233
  // NexusCrew. Il daemon app-server espone UN solo insieme di server MCP a
234
234
  // tutte le Live, con l'ambiente del daemon: nexuscrew è l'unico che prende
235
235
  // l'identità dall'ambiente ereditato, quindi i suoi tool con sessione
@@ -353,7 +353,7 @@ function startThreadOnControlSocket({
353
353
  title: 'NexusCrew Live Bridge',
354
354
  version: bridgeVersion(),
355
355
  // Identita della connessione: la sessione gia' risolta della cella
356
- // designata (mai env grezzi, mai guess — roster MC3.4). Se assente
356
+ // Designata (mai env grezzi, mai guess — roster). Se assente
357
357
  // il campo SI OMETTE (Option None lato protocollo): il resolver a
358
358
  // valle classifica MISSING nominando la causa; sanitizzare qui la
359
359
  // duplicherebbe senza guadagno.
@@ -400,7 +400,7 @@ function startThreadOnControlSocket({
400
400
  return;
401
401
  }
402
402
  // Notifiche broadcast e risposte non attese: ignorate (connessione
403
- // on-demand, la finestra di esposizione alla fuga MC3.3 è minima).
403
+ // On-demand, la finestra di esposizione alla fuga è minima).
404
404
  });
405
405
 
406
406
  ws.on('error', (e) => done(Object.assign(new Error(`control socket: ${e.message}`), { code: 'ESOCKET' })));
@@ -426,7 +426,7 @@ function createLiveBridge({
426
426
  const threadIdsByCell = new Map();
427
427
  const threadStatusCache = new Map();
428
428
  const threadStatusInFlight = new Map();
429
- // R4: riserve in-process per l'avvio Live. La chiave è la cella host: una
429
+ // Riserve in-process per l'avvio Live. La chiave è la cella host: una
430
430
  // sola start provvisoria per cella alla volta, e il commit ricontrolla la
431
431
  // tupla congelata in riserva prima di accettare il thread creato.
432
432
  const pendingLiveByCell = new Map();
@@ -435,7 +435,7 @@ function createLiveBridge({
435
435
 
436
436
  const none = (reason, extra) => ({ mode: 'none', reason, ...(extra || {}), at: now() });
437
437
 
438
- // MC1: la designazione si legge dalla ROUTE, con il token del nodo, entro
438
+ // La designazione si legge dalla ROUTE, con il token del nodo, entro
439
439
  // il limite dichiarato. retry no, cache no: una lettura per avvio Live.
440
440
  async function readDesignation() {
441
441
  const ctrl = new AbortController();
@@ -553,7 +553,7 @@ function createLiveBridge({
553
553
  if (cell.active !== true) return none('host-cell-inactive');
554
554
  if (typeof cell.cwd !== 'string' || !cell.cwd) return none('cell-cwd-unknown');
555
555
 
556
- // JC2: la modalità è una funzione dell'engine, non una scelta. Nativa solo
556
+ // La modalità è una funzione dell'engine, non una scelta. Nativa solo
557
557
  // su engine codex-vl (il thread ponte vive nell'app-server del fork); per
558
558
  // qualunque altro engine la Live lavora ATTRAVERSO la cella e il ponte non
559
559
  // ha nulla da creare qui.
@@ -565,7 +565,7 @@ function createLiveBridge({
565
565
  if (!engine.startsWith('codex-vl')) {
566
566
  const out = {
567
567
  mode: 'tmux', cell: snap.hostCell, engine: cell.engine || null, cwd: cell.cwd,
568
- // JC5.5: in modalità tmux le regole le applica la cella; nessuna
568
+ // In modalità tmux le regole le applica la cella; nessuna
569
569
  // iniezione da parte del ponte.
570
570
  prompt: { applied: false, reason: 'tmux-mode' },
571
571
  at: now(),
@@ -574,16 +574,16 @@ function createLiveBridge({
574
574
  return out;
575
575
  }
576
576
 
577
- // R2: l'identità viaggia SEMPRE, anteposta al prompt quando c'è. Il campo
577
+ // L'identità viaggia SEMPRE, anteposta al prompt quando c'è. Il campo
578
578
  // non è mai più assente: senza LIVE_PROMPT.md porta la sola intestazione
579
579
  // — e poiché il campo SOSTITUISCE le developer instructions della config
580
- // (vedi MC2: la .or() in config/mod.rs), quella cella non le riceve più.
580
+ // (see: the .or() in config/mod.rs), that cell no longer receives them.
581
581
  const intestazione = identityHeader(snap.hostCell, cell.tmuxSession);
582
582
  const developerInstructions = prompt.applied
583
583
  ? `${intestazione}\n\n${prompt.text}`
584
584
  : intestazione;
585
585
 
586
- // R4: riserva della tupla e start provvisorio. Finché il commit non
586
+ // Riserva della tupla e start provvisorio. Finché il commit non
587
587
  // ricontrolla la stessa tupla, il thread NON viene accettato: nessun tool
588
588
  // può attraversarlo, perché il ponte non ne registra l'id.
589
589
  if (pendingLiveByCell.has(snap.hostCell)) return none('reservation-in-flight');
@@ -598,8 +598,8 @@ function createLiveBridge({
598
598
  cwd: cell.cwd,
599
599
  developerInstructions,
600
600
  // Stessa fonte dell'intestazione e del prompt per-cella (roster:
601
- // MC3.4). Dichiarata per connessione: batte qualunque ambiente
602
- // ereditato, anche quando e' popolato ma stantio (B1-bis).
601
+ //). Dichiarata per connessione: batte qualunque ambiente
602
+ // Ereditato, anche quando e' popolato ma stantio (-bis).
603
603
  declaredSession: cell.tmuxSession,
604
604
  timeoutMs: cfg.liveBridgeTimeoutMs,
605
605
  WebSocket,
@@ -612,7 +612,7 @@ function createLiveBridge({
612
612
  return none(reason, { cell: snap.hostCell, detail: String(e.message) });
613
613
  }
614
614
 
615
- // R4: commit. La designazione e il lease vengono riletti e confrontati con
615
+ // Commit. La designazione e il lease vengono riletti e confrontati con
616
616
  // la tupla riservata: se qualcosa è cambiato durante lo start, il thread
617
617
  // resta scartato (zero dispatch) e l'esito lo dichiara con l'id scartato.
618
618
  let commitSnap;
@@ -649,9 +649,9 @@ function createLiveBridge({
649
649
  };
650
650
  threadIdsByCell.set(snap.hostCell, started.threadId);
651
651
  threadStatusCache.delete(snap.hostCell);
652
- // LC1.4: il puntamento è visibile lato nostro — log con cella, thread e
652
+ // Il puntamento è visibile lato nostro — log con cella, thread e
653
653
  // prompt applicato. È il "dirottamento dichiarato" del contratto. Il
654
- // campo SOSTITUISCE le developer instructions della config (MC2, la
654
+ // Campo SOSTITUISCE le developer instructions della config (la
655
655
  // .or() in config/mod.rs): il log lo dichiara, perché chi lo legge sappia
656
656
  // cosa quella Live NON riceve.
657
657
  log(`[live-bridge] Live puntata su ${snap.hostCell}: thread ${started.threadId} (cwd ${started.cwd}, identità nell'intestazione, prompt ${prompt.applied ? 'per-cella applicato' : `non applicato (${promptEcho.reason})`}, sostituisce le developer instructions di config)`);
@@ -10,7 +10,7 @@
10
10
  // chiede. Senza quel permesso il peer riceve un rifiuto che nomina la causa
11
11
  // (`live-host-not-granted`), non un silenzio.
12
12
  //
13
- // Invarianti (contratto rev6 §2, §9):
13
+ // Invariants (host-cell designation contract):
14
14
  // - hostCell unico per nodo, CAS su revision (due designazioni concorrenti non
15
15
  // lasciano due celle rosse).
16
16
  // - API-first: la route e' l'unica autorita'; il frontend riflette la risposta e
@@ -336,7 +336,7 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
336
336
  const result = await bridge.resolveForLive();
337
337
  res.json(result);
338
338
  } catch (e) {
339
- // Il contratto (MC1.5) vuole che un guasto del ponte non fermi la Live:
339
+ // Il contratto vuole che un guasto del ponte non fermi la Live:
340
340
  // anche l'inaspettato collassa in `none` dichiarato, mai un 500.
341
341
  // `bridge-error` e' l'ultima rete: un'eccezione che nessun ramo previsto ha
342
342
  // classificato. Va NOMINATA come le altre, non lasciata fuori dall'elenco
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
  // lib/live-host/store.js — stato della designazione "cella ospite Live" di un nodo.
3
3
  //
4
- // Un solo hostCell per nodo (contratto rev6 §2.2): chiave unica, non una convenzione
5
- // ripetuta in N punti. Lo stato vive su disco (sopravvive a riavvii di cella e di
6
- // NexusCrew) e l'aggiornamento e' un CAS su `revision`: due designazioni concorrenti
7
- // non possono lasciare due celle rosse — il perdente rilegge la revision e rinuncia.
4
+ // One hostCell per node (unique key, not a repeated convention):
5
+ // the state lives on disk (survives cell and NexusCrew restarts) and the
6
+ // update is a CAS on `revision`: two concurrent designations cannot leave
7
+ // two red cells — the loser re-reads the revision and gives up.
8
8
  //
9
9
  // La designazione e' PURAMENTE un marker di scelta del nodo. L'eligibilita' (la cella
10
10
  // e' anche attiva in questo momento?) e' derivata dal roster Fleet e non si persiste
package/lib/mcp/server.js CHANGED
@@ -81,12 +81,12 @@ function companionInstructions() {
81
81
  }
82
82
 
83
83
  // --- identita' cella mittente ------------------------------------------------
84
- // Ordine delle sorgenti (design §1, INVARIATO): $TMUX presente -> tmux
85
- // display-message (nome sessione reale); se fallisce/invalida -> fallback env
86
- // NEXUSCREW_MCP_SESSION; altrimenti null. I tool che RICHIEDONO la sessione
87
- // restano fail-closed. execFile argv diretto: mai shell.
84
+ // Source order (UNCHANGED): $TMUX present -> tmux display-message (real
85
+ // session name); if it fails/is invalid -> env fallback NEXUSCREW_MCP_SESSION;
86
+ // otherwise null. The tools that REQUIRE the session stay fail-closed.
87
+ // execFile direct argv: never a shell.
88
88
  //
89
- // P0: il nome da tmux si chiede con `-t $TMUX_PANE` — target esplicito al
89
+ // Il nome da tmux si chiede con `-t $TMUX_PANE` — target esplicito al
90
90
  // PANE del chiamante, deterministico e indipendente dall'environ ereditato.
91
91
  // Senza `-t` il CLI tmux risolve il pane dall'ENVIRON DEL PROCESSO FIGLIO:
92
92
  // se quel pane è vivo risponde correttamente, ma se è morto (environ stale,
@@ -95,7 +95,7 @@ function companionInstructions() {
95
95
  // Comportamento misurato su tmux 3.4 con `-t`: pane morto -> rc=0 e stdout
96
96
  // VUOTO (non un errore): il vuoto è il segnale dello stantio.
97
97
  //
98
- // `resolveIdentity` rende OSSERVABILE la sorgente della risoluzione (P0):
98
+ // `resolveIdentity` rende OSSERVABILE la sorgente della risoluzione:
99
99
  // ritorna { session, source, code, envPresence, requiredEnvVars, remediation }
100
100
  // senza cambiare la precedenza e senza esporre valori/segreti. `resolveSession`
101
101
  // resta il wrapper pubblico Promise<string|null> invariato (compatibilita').
@@ -174,13 +174,13 @@ function resolveIdentity({ env, tmuxBin, execFileImpl }) {
174
174
  session: null, source: 'missing', code: codeWhenMissing(),
175
175
  envPresence, requiredEnvVars: IDENTITY_ALL_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
176
176
  });
177
- // P0: pane stantio o non verificabile -> NON attribuire. source 'stale-pane'
177
+ // Pane stantio o non verificabile -> NON attribuire. source 'stale-pane'
178
178
  // nomina il problema; code STALE_PANE (tools.js).
179
179
  const stalePane = () => ({
180
180
  session: null, source: 'stale-pane', code: IDENTITY_CODE.STALE_PANE,
181
181
  envPresence, requiredEnvVars: IDENTITY_ALL_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
182
182
  });
183
- // P0/R2: tmux e fallback env dicono sessioni DIVERSE entrambe valide:
183
+ // : tmux e fallback env dicono sessioni DIVERSE entrambe valide:
184
184
  // identità ambigua -> NON attribuire (nemmeno il fallback: è parte del
185
185
  // conflitto). Il code nomina il mismatch, che INVALID non direbbe.
186
186
  const sessionMismatch = () => ({
@@ -190,7 +190,7 @@ function resolveIdentity({ env, tmuxBin, execFileImpl }) {
190
190
 
191
191
  return new Promise((resolve) => {
192
192
  // Precedenza preservata: prima il fallback env valido, poi l'esito negativo
193
- // dato (`missing` storico o `stalePane` P0).
193
+ // Dato (`missing` storico o `stalePane`).
194
194
  const settle = (otherwise) => {
195
195
  const fb = tryFallback();
196
196
  resolve(typeof fb === 'string' ? ok(fb, 'NEXUSCREW_MCP_SESSION') : otherwise());
@@ -217,13 +217,13 @@ function resolveIdentity({ env, tmuxBin, execFileImpl }) {
217
217
  // rc=0 e stdout VUOTO. Il vuoto è il segnale dello stantio.
218
218
  if (!name) return settle(stalePane);
219
219
  if (isValidSession(name)) {
220
- // R2: se il fallback env è valido ma dice un'altra sessione, le due
220
+ // Se il fallback env è valido ma dice un'altra sessione, le due
221
221
  // fonti si contraddicono -> ambiguo, non si attribuisce.
222
222
  const fb = tryFallback();
223
223
  if (typeof fb === 'string' && fb !== name) return resolve(sessionMismatch());
224
224
  return resolve(ok(name, 'tmux'));
225
225
  }
226
- // nome non vuoto ma invalido: precedenza preservata, come da design §1.
226
+ // non-empty but invalid name: precedence preserved, by design.
227
227
  settle(missing);
228
228
  });
229
229
  } catch (_) {
@@ -618,9 +618,9 @@ function startMcp(opts = {}) {
618
618
 
619
619
  module.exports = {
620
620
  createMcpServer, startMcp, resolveSession, resolveIdentity, normalizeIdentityContext, TOOLS,
621
- // V-69: il ramo vl di resolveManagedEngine compone le istruzioni companion
622
- // nel file di prompt per-cella — vl non ha client MCP, questo e' l'unica
623
- // superficie attraverso cui il testo lo raggiunge.
621
+ // The vl branch of resolveManagedEngine composes the companion instructions
622
+ // into the per-cell prompt file — vl has no MCP client, this is the only
623
+ // surface through which the text reaches it.
624
624
  companionInstructions,
625
625
  PROTOCOL_FALLBACK, HTTP_TIMEOUT_MS, HTTP_TIMEOUT_CODE, HTTP_UNREACHABLE_CODE, transportError,
626
626
  IDENTITY_CONTEXT_MISSING, IDENTITY_CONTEXT_UNVERIFIED, IDENTITY_CONTEXT_FROM_MISMATCH,
package/lib/mcp/tools.js CHANGED
@@ -30,7 +30,7 @@ function argString(args, key, { required = false, max = 4096 } = {}) {
30
30
  return v;
31
31
  }
32
32
 
33
- // Codici stabili di identita' MCP (contratto P0). Valori EXACT, non sensibili,
33
+ // Codici stabili di identita' MCP (contratto). Valori EXACT, non sensibili,
34
34
  // usati sia nella diagnostica read-only (`nc_identity`) sia nel messaggio umano
35
35
  // dei tool identity-gated (isError=true preservato dal server). Non espongono
36
36
  // valori/env/sessioni: solo la categoria del problema.
@@ -38,10 +38,10 @@ const IDENTITY_CODE = Object.freeze({
38
38
  OK: 'OK',
39
39
  MISSING: 'NEXUSCREW_MCP_IDENTITY_MISSING',
40
40
  INVALID: 'NEXUSCREW_MCP_IDENTITY_INVALID',
41
- // P0: il pane del chiamante non esiste più (o TMUX_PANE è assente/malformato):
41
+ // Il pane del chiamante non esiste più (o TMUX_PANE è assente/malformato):
42
42
  // l'identità non viene attribuita via tmux.
43
43
  STALE_PANE: 'NEXUSCREW_MCP_IDENTITY_STALE_PANE',
44
- // P0/R2: tmux e NEXUSCREW_MCP_SESSION sono entrambi validi ma dicono sessioni
44
+ // : tmux e NEXUSCREW_MCP_SESSION sono entrambi validi ma dicono sessioni
45
45
  // diverse: identità ambigua, non si attribuisce nessuna delle due.
46
46
  SESSION_MISMATCH: 'NEXUSCREW_MCP_IDENTITY_SESSION_MISMATCH',
47
47
  // una fonte verified (NEXUSCREW_VERIFIED_*) presente ma non
@@ -303,7 +303,7 @@ function vlOwnerPath(owner, leaf = '') {
303
303
  }
304
304
 
305
305
  // --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
306
- // annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
306
+ // annotations.readOnlyHint:true on the tools that mutate nothing.
307
307
  const TOOLS = [
308
308
  {
309
309
  name: 'nc_notify',
@@ -920,7 +920,7 @@ const TOOLS = [
920
920
  return { receipt };
921
921
  },
922
922
  },
923
- // --- lease Live del child (fetta 2b, contratto rev1 B5) ----------------------
923
+ // -- lease Live del child (fetta 2b, contratto rev1) ----------------------
924
924
  // Tre metodi DISTINTI perche' i valori di ritorno non mentono l'uno con l'altro:
925
925
  // solo register puo' rispondere {status:'pending'} (join non ancora tracciato);
926
926
  // refresh su registration viva risponde {status:'live', proof} e non ha stati
@@ -0,0 +1,258 @@
1
+ 'use strict';
2
+
3
+ // Access presets for paired peers.
4
+ //
5
+ // A preset is a vector of grants written in ONE validated write on the node
6
+ // that owns the resources. There is no persistent `role` field: the label
7
+ // (`admin`, `user`, `nexushost`, `custom`, `unconfigured`) is DERIVED from the
8
+ // effective grants, and request handlers check grants, never the label.
9
+ //
10
+ // The owner node decides what a peer may do with its own resources. A grant
11
+ // that is missing or malformed is denied, and a record written before these
12
+ // fields existed stays denied until an explicit migration sets them: being
13
+ // paired is not, by itself, a permission.
14
+
15
+ const MAX_CELLS = 128;
16
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
17
+
18
+ const CELL_VISIBILITY = Object.freeze(['all', 'selected', 'none']);
19
+
20
+ // Grants owned by the local node towards one peer.
21
+ const BOOLEAN_GRANTS = Object.freeze([
22
+ 'eventsAccess',
23
+ 'nodeEventsAccess',
24
+ 'askReplyAccess',
25
+ 'filesReadAccess',
26
+ 'liveHostAccess',
27
+ 'panelAccess',
28
+ 'peerOperatorAccess',
29
+ ]);
30
+
31
+ // `eventsReceive` is the receiving client's local choice, so it is excluded
32
+ // from every preset: applying a preset never toggles who receives events.
33
+ const EXCLUDED_GRANTS = Object.freeze(['eventsReceive']);
34
+
35
+ const GRANT_FIELDS = Object.freeze(['cellVisibility', 'cells', ...BOOLEAN_GRANTS]);
36
+
37
+ // Provenance marker of an explicitly configured vector. It is not a grant and
38
+ // never a role: it only separates "set on purpose" from "defaulted".
39
+ const CONFIGURED_MARKER = 'accessConfigured';
40
+
41
+ const ADMIN_GRANTS = Object.freeze({
42
+ cellVisibility: 'all',
43
+ eventsAccess: true,
44
+ nodeEventsAccess: true,
45
+ askReplyAccess: true,
46
+ filesReadAccess: true,
47
+ liveHostAccess: true,
48
+ panelAccess: true,
49
+ peerOperatorAccess: true,
50
+ });
51
+
52
+ const USER_GRANTS = Object.freeze({
53
+ cellVisibility: 'all',
54
+ eventsAccess: true,
55
+ nodeEventsAccess: true,
56
+ askReplyAccess: false,
57
+ filesReadAccess: true,
58
+ liveHostAccess: false,
59
+ panelAccess: false,
60
+ peerOperatorAccess: false,
61
+ });
62
+
63
+ const NEXUSHOST_GRANTS = Object.freeze({
64
+ cellVisibility: 'none',
65
+ eventsAccess: false,
66
+ nodeEventsAccess: false,
67
+ askReplyAccess: false,
68
+ filesReadAccess: false,
69
+ liveHostAccess: false,
70
+ panelAccess: false,
71
+ peerOperatorAccess: false,
72
+ });
73
+
74
+ const PRESETS = Object.freeze({
75
+ admin: ADMIN_GRANTS,
76
+ user: USER_GRANTS,
77
+ nexushost: NEXUSHOST_GRANTS,
78
+ });
79
+
80
+ const PRESET_NAMES = Object.freeze(Object.keys(PRESETS));
81
+
82
+ // Every grant denied: what an unknown peer gets.
83
+ const DENIED_GRANTS = Object.freeze({
84
+ cellVisibility: 'none',
85
+ ...Object.fromEntries(BOOLEAN_GRANTS.map((k) => [k, false])),
86
+ });
87
+
88
+ function isPlainObject(v) {
89
+ return !!v && typeof v === 'object' && !Array.isArray(v);
90
+ }
91
+
92
+ function isCellId(v) {
93
+ return typeof v === 'string' && CELL_ID_RE.test(v);
94
+ }
95
+
96
+ // The admin vector is all-or-nothing: live hosting is an administrative act
97
+ // on the owner's own host, so it is granted only together with the complete
98
+ // admin set. A granular edit that keeps live hosting while dropping any other
99
+ // admin grant is refused.
100
+ function isCompleteAdminVector(g) {
101
+ if (!isPlainObject(g)) return false;
102
+ if (g.cellVisibility !== 'all') return false;
103
+ if (Array.isArray(g.cells) && g.cells.length !== 0) return false;
104
+ return BOOLEAN_GRANTS.every((k) => g[k] === true);
105
+ }
106
+
107
+ // Validates a grant vector. Returns { ok, errors }: `ok` means the vector is
108
+ // complete and coherent, so a label may be derived from it. A vector that is
109
+ // not ok is DENIED by callers; it is never completed with defaults.
110
+ function validateGrants(input) {
111
+ const errors = [];
112
+ if (!isPlainObject(input)) {
113
+ return { ok: false, errors: ['grants must be an object'] };
114
+ }
115
+ for (const key of Object.keys(input)) {
116
+ if (key === 'role') {
117
+ errors.push('role is not a grant: the label is derived from the grants');
118
+ } else if (EXCLUDED_GRANTS.includes(key)) {
119
+ errors.push(`${key} is a local client choice and is not part of a preset`);
120
+ } else if (!GRANT_FIELDS.includes(key)) {
121
+ errors.push(`unknown grant field: ${key}`);
122
+ }
123
+ }
124
+
125
+ if (!CELL_VISIBILITY.includes(input.cellVisibility)) {
126
+ errors.push('cellVisibility must be one of all|selected|none');
127
+ }
128
+
129
+ let cells = [];
130
+ if (input.cells !== undefined) {
131
+ if (!Array.isArray(input.cells)) {
132
+ errors.push('cells must be an array');
133
+ } else if (input.cells.length > MAX_CELLS) {
134
+ errors.push(`cells must hold at most ${MAX_CELLS} entries`);
135
+ } else if (!input.cells.every(isCellId)) {
136
+ errors.push('cells entries must be cell names');
137
+ } else if (new Set(input.cells).size !== input.cells.length) {
138
+ errors.push('cells must not repeat an entry');
139
+ } else {
140
+ cells = input.cells;
141
+ }
142
+ }
143
+ if (input.cellVisibility === 'selected' && cells.length === 0) {
144
+ errors.push('cellVisibility selected needs at least one cell');
145
+ }
146
+ if ((input.cellVisibility === 'all' || input.cellVisibility === 'none') && cells.length > 0) {
147
+ errors.push('cells are allowed only with cellVisibility selected');
148
+ }
149
+
150
+ for (const key of BOOLEAN_GRANTS) {
151
+ if (!(key in input)) errors.push(`missing grant: ${key}`);
152
+ else if (typeof input[key] !== 'boolean') errors.push(`grant must be a boolean: ${key}`);
153
+ }
154
+
155
+ if (input.liveHostAccess === true && !isCompleteAdminVector(input)) {
156
+ errors.push('liveHostAccess requires the complete admin grant set');
157
+ }
158
+
159
+ return { ok: errors.length === 0, errors };
160
+ }
161
+
162
+ // A record is configured only when its grant vector is complete and coherent.
163
+ // Records written before these fields existed, or damaged ones, are denied.
164
+ function isConfigured(input) {
165
+ return validateGrants(input).ok;
166
+ }
167
+
168
+ function sameBooleans(a, b) {
169
+ return BOOLEAN_GRANTS.every((k) => a[k] === b[k]);
170
+ }
171
+
172
+ // Derives the label from the effective grants. `custom` means a coherent
173
+ // vector that matches no preset; `unconfigured` means the vector is not
174
+ // complete, so no preset may be inferred from it.
175
+ function deriveLabel(input) {
176
+ if (!isConfigured(input)) return 'unconfigured';
177
+ if (sameBooleans(input, ADMIN_GRANTS) && input.cellVisibility === 'all') return 'admin';
178
+ if (sameBooleans(input, NEXUSHOST_GRANTS) && input.cellVisibility === 'none') return 'nexushost';
179
+ // The owner may narrow the visible cells of a user peer without granting
180
+ // anything new, so any of the three visibility values still reads `user`.
181
+ if (sameBooleans(input, USER_GRANTS) && CELL_VISIBILITY.includes(input.cellVisibility)) return 'user';
182
+ return 'custom';
183
+ }
184
+
185
+ // Returns a copy of the peer record with the grants of the named preset.
186
+ // The input is never mutated; `role` is dropped, because it is not a field
187
+ // that this model persists.
188
+ function applyPreset(peer, name) {
189
+ if (!isPlainObject(peer)) throw new Error('peer must be an object');
190
+ if (!Object.prototype.hasOwnProperty.call(PRESETS, name)) {
191
+ throw new Error(`unknown preset: ${name}`);
192
+ }
193
+ const out = { ...peer };
194
+ delete out.role;
195
+ const preset = PRESETS[name];
196
+ // Applying a preset IS the explicit act: the record is marked as configured
197
+ // so a legacy peer with defaulted (denied) grants is never mistaken for a
198
+ // deliberate vector that denies everything.
199
+ out.accessConfigured = true;
200
+ out.cellVisibility = preset.cellVisibility;
201
+ if (preset.cellVisibility === 'selected') out.cells = [...(preset.cells || [])];
202
+ else delete out.cells;
203
+ for (const key of BOOLEAN_GRANTS) out[key] = preset[key];
204
+ return out;
205
+ }
206
+
207
+ // Extracts the grant vector from a peer record. An unconfigured record yields
208
+ // every grant denied.
209
+ function grantsOf(peer) {
210
+ if (!isPlainObject(peer)) return { configured: false, label: 'unconfigured', grants: { ...DENIED_GRANTS } };
211
+ const grants = {};
212
+ grants.cellVisibility = peer.cellVisibility;
213
+ if (peer.cells !== undefined) grants.cells = peer.cells;
214
+ for (const key of BOOLEAN_GRANTS) grants[key] = peer[key];
215
+ // A record is configured only when its vector is coherent AND the vector was
216
+ // set explicitly. Grants alone cannot tell a legacy record (everything
217
+ // defaulted to denied) from a deliberate vector that denies everything.
218
+ const configured = peer.accessConfigured === true && isConfigured(grants);
219
+ return {
220
+ configured,
221
+ label: configured ? deriveLabel(grants) : 'unconfigured',
222
+ grants: configured ? grants : { ...DENIED_GRANTS },
223
+ };
224
+ }
225
+
226
+
227
+ // Read view of ONE peer for listing endpoints and the settings UI: the
228
+ // derived label, the configured marker and the EFFECTIVE grant vector. The
229
+ // vector is flat and always complete (denied for records that are not
230
+ // configured), so a caller never has to guess defaults and no token or
231
+ // secret is ever included: grants are decisions, not credentials.
232
+ function accessView(peer) {
233
+ const view = grantsOf(peer);
234
+ const access = { cellVisibility: view.grants.cellVisibility };
235
+ for (const key of BOOLEAN_GRANTS) access[key] = view.grants[key] === true;
236
+ return { accessLabel: view.label, accessConfigured: view.configured, access };
237
+ }
238
+
239
+ module.exports = {
240
+ ADMIN_GRANTS,
241
+ BOOLEAN_GRANTS,
242
+ CELL_VISIBILITY,
243
+ CONFIGURED_MARKER,
244
+ DENIED_GRANTS,
245
+ EXCLUDED_GRANTS,
246
+ GRANT_FIELDS,
247
+ NEXUSHOST_GRANTS,
248
+ PRESETS,
249
+ PRESET_NAMES,
250
+ USER_GRANTS,
251
+ accessView,
252
+ applyPreset,
253
+ deriveLabel,
254
+ grantsOf,
255
+ isCompleteAdminVector,
256
+ isConfigured,
257
+ validateGrants,
258
+ };