@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.
- package/CHANGELOG.md +9 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
- package/frontend/dist/assets/{index-CywSos2e.css → index-DqCfZFGi.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +91 -10
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +1 -1
- package/lib/auth/token.js +4 -4
- package/lib/cli/commands.js +59 -12
- package/lib/cli/doctor.js +5 -5
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +27 -27
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +6 -6
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +15 -15
- package/lib/fleet/causes.js +1 -1
- package/lib/fleet/cell-exec.js +23 -23
- package/lib/fleet/cell-hold.js +6 -6
- package/lib/fleet/cell-lease-server.js +106 -106
- package/lib/fleet/cell-lease.js +21 -21
- package/lib/fleet/definitions.js +10 -10
- package/lib/fleet/identity-authority.js +1 -1
- package/lib/fleet/launch-broker.js +13 -13
- package/lib/fleet/launch.js +15 -15
- package/lib/fleet/lease-client.js +23 -23
- package/lib/fleet/lease-routes.js +3 -3
- package/lib/fleet/lease-verifier.js +22 -22
- package/lib/fleet/managed.js +35 -35
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +24 -24
- package/lib/live-host/bridge.js +36 -36
- package/lib/live-host/routes.js +2 -2
- package/lib/live-host/store.js +4 -4
- package/lib/mcp/server.js +14 -14
- package/lib/mcp/tools.js +5 -5
- package/lib/nodes/access-presets.js +258 -0
- package/lib/nodes/commands.js +198 -4
- package/lib/nodes/store.js +152 -9
- package/lib/nodes/tunnel.js +7 -7
- package/lib/notify/ask-answer-service.js +184 -0
- package/lib/notify/ask-receipts.js +197 -0
- package/lib/notify/ask-relay.js +152 -0
- package/lib/notify/asks.js +28 -3
- package/lib/notify/event-feed-acl.js +95 -0
- package/lib/notify/event-feed-asks-routes.js +144 -0
- package/lib/notify/event-feed-client.js +434 -0
- package/lib/notify/event-feed-history.js +115 -0
- package/lib/notify/event-feed-producers.js +175 -0
- package/lib/notify/event-feed-routes.js +254 -0
- package/lib/notify/event-feed.js +328 -0
- package/lib/notify/events.js +1 -1
- package/lib/notify/notifier.js +3 -3
- package/lib/notify/push-relay.js +240 -0
- package/lib/notify/push.js +8 -8
- package/lib/notify/routes.js +62 -23
- package/lib/proxy/federation.js +74 -3
- package/lib/proxy/node-proxy.js +13 -13
- package/lib/proxy/panel-auth.js +4 -4
- package/lib/proxy/panel-proxy.js +2 -2
- package/lib/proxy/resource-acl.js +145 -0
- package/lib/server.js +372 -39
- package/lib/settings/pairing-coordinator.js +1 -1
- package/lib/settings/routes.js +45 -32
- package/lib/update/manager.js +1 -1
- package/lib/update/runner.js +1 -1
- package/lib/voice/transcribe.js +1 -1
- package/lib/ws/bridge.js +3 -3
- package/package.json +1 -1
- package/frontend/dist/assets/index-B_THfiL5.js +0 -167
package/lib/live-host/bridge.js
CHANGED
|
@@ -19,19 +19,19 @@
|
|
|
19
19
|
// going to operate on.
|
|
20
20
|
//
|
|
21
21
|
// Invarianti (contratto fetta 3):
|
|
22
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
29
|
+
// Il prompt per-cella (LIVE_PROMPT.md accanto ai canonici della
|
|
30
30
|
// cella) viaggia su developerInstructions di thread/start e SOSTITUISCE
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
50
|
-
//
|
|
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
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
198
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
226
|
-
// Il ponte ha la designazione IN MANO
|
|
227
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
602
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
652
|
+
// Il puntamento è visibile lato nostro — log con cella, thread e
|
|
653
653
|
// prompt applicato. È il "dirottamento dichiarato" del contratto. Il
|
|
654
|
-
//
|
|
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)`);
|
package/lib/live-host/routes.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
package/lib/live-host/store.js
CHANGED
|
@@ -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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
+
};
|