@mmmbuto/nexuscrew 0.9.63 → 0.9.65
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 +14 -0
- package/README.md +9 -21
- package/docs/NOTIFICATIONS.md +34 -0
- package/docs/SECURITY.md +13 -12
- package/frontend/dist/assets/index-DkYRC1vV.js +169 -0
- package/frontend/dist/assets/{index-D1cTtNED.css → index-Dwd37Z_R.css} +2 -2
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +37 -3
- package/lib/cli/commands.js +1 -1
- package/lib/config.js +1 -1
- package/lib/fleet/builtin.js +3 -0
- package/lib/fleet/cell-lease-server.js +1 -1
- package/lib/fleet/cell-lease.js +1 -1
- package/lib/fleet/lease-client.js +1 -1
- package/lib/fleet/lease-verifier.js +1 -1
- package/lib/live-host/attest.js +39 -0
- package/lib/live-host/bridge.js +81 -21
- package/lib/live-host/entry.js +49 -0
- package/lib/live-host/registry.js +111 -0
- package/lib/live-host/reserved.js +12 -0
- package/lib/mcp/cells.js +37 -0
- package/lib/mcp/server.js +7 -1
- package/lib/mcp/tools.js +7 -1
- package/lib/notify/ask-relay.js +36 -166
- package/lib/notify/event-feed-asks-routes.js +18 -3
- package/lib/notify/event-feed-client.js +85 -10
- package/lib/notify/event-feed-notices-routes.js +135 -0
- package/lib/notify/event-feed-producers.js +21 -0
- package/lib/notify/event-feed-routes.js +30 -11
- package/lib/notify/notice-dismissal-queue.js +336 -0
- package/lib/notify/notice-dismissals.js +178 -0
- package/lib/notify/notice-relay.js +128 -0
- package/lib/notify/owner-channel.js +172 -0
- package/lib/proxy/federation.js +9 -0
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +229 -12
- package/lib/tmux/actions.js +1 -1
- package/package.json +2 -2
- package/frontend/dist/assets/index-maPH0v_e.js +0 -169
package/lib/fleet/cell-lease.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// Pure state transitions for the supervisor lease of a Live host cell.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
5
|
+
// Il canale lease e' la connessione UDS
|
|
6
6
|
// accettata col nonce one-shot del launch-broker che RESTA APERTA dopo il frame
|
|
7
7
|
// payload; after an EOF the supervisor reconnects to a stable endpoint.
|
|
8
8
|
// Here live ONLY the lease state transitions: the side
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// stays OPEN after the payload), sends refreshes every 20s (heartbeat),
|
|
7
7
|
// and on EOF reconnects to the stable endpoint.
|
|
8
8
|
//
|
|
9
|
-
// Fetta 2b
|
|
9
|
+
// Fetta 2b: l'autenticazione del reconnect e' un proof
|
|
10
10
|
// HMAC firmato dal server col verifier per-installazione — la capability statica
|
|
11
11
|
// della 2a e' revocata. Il proof NON arriva nel payload: il server lo consegna
|
|
12
12
|
// sul canale (frame lease all'attach, ack a ogni refresh) e il supervisore lo
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Verifier per-installazione e proof HMAC del lease Live (fetta 2b, contratto
|
|
4
|
-
//
|
|
4
|
+
// The premise and the items below.
|
|
5
5
|
//
|
|
6
6
|
// Modello (PREMESSA): la 2a usava un segreto condiviso simmetrico — il
|
|
7
7
|
// supervisore presentava la capability cosi' com'e' e il server la confrontava.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/live-host/attest.js — verifica del riferimento Live dichiarato da un mittente.
|
|
3
|
+
//
|
|
4
|
+
// Il server MCP della Live dichiara il riferimento della propria thread; qui si
|
|
5
|
+
// decide se quella dichiarazione regge. Regge solo se il riferimento e' nel
|
|
6
|
+
// registro, appartiene alla cella ospite che sta mandando il messaggio e la
|
|
7
|
+
// thread e' viva adesso. Il risultato porta un motivo nominato, ma chi lo usa
|
|
8
|
+
// non rifiuta il messaggio: lo consegna senza etichetta.
|
|
9
|
+
|
|
10
|
+
const { REF_RE } = require('./registry.js');
|
|
11
|
+
|
|
12
|
+
const ALIVE = new Set(['present', 'active']);
|
|
13
|
+
|
|
14
|
+
function createLiveAttestation({ registry, bridge }) {
|
|
15
|
+
async function verify({ ref, fromCell } = {}) {
|
|
16
|
+
if (ref === undefined || ref === null || ref === '') return { ok: false, reason: 'no-ref' };
|
|
17
|
+
if (typeof ref !== 'string' || !REF_RE.test(ref)) return { ok: false, reason: 'bad-ref' };
|
|
18
|
+
const entry = registry.findByRef(ref);
|
|
19
|
+
if (!entry) return { ok: false, reason: 'unknown-ref' };
|
|
20
|
+
if (entry.cell !== fromCell) return { ok: false, reason: 'wrong-host' };
|
|
21
|
+
// La tupla (cella, thread, riferimento) e' quella letta ADESSO: e' lei che
|
|
22
|
+
// si attesta. Lo stato si chiede per quel thread, e dopo l'attesa il
|
|
23
|
+
// registro si rilegge: se nel frattempo la Live e' stata ruotata, la
|
|
24
|
+
// risposta non vale piu' per questo riferimento.
|
|
25
|
+
const captured = { cell: entry.cell, threadId: entry.threadId };
|
|
26
|
+
let status;
|
|
27
|
+
try { status = await bridge.threadStatus(captured.cell, captured.threadId); } catch (_) { status = 'unknown'; }
|
|
28
|
+
// Un thread non vivo non da mai l'etichetta (anche se il ponte lo ha appena tolto dal registro).
|
|
29
|
+
if (!ALIVE.has(status)) return { ok: false, reason: 'thread-not-alive' };
|
|
30
|
+
const now = registry.findByRef(ref);
|
|
31
|
+
if (!now || now.cell !== captured.cell || now.threadId !== captured.threadId) {
|
|
32
|
+
return { ok: false, reason: 'ref-changed' };
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, hostCell: captured.cell };
|
|
35
|
+
}
|
|
36
|
+
return { verify };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { createLiveAttestation };
|
package/lib/live-host/bridge.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// lib/live-host/bridge.js — the Live bridge (contract
|
|
2
|
+
// lib/live-host/bridge.js — the Live bridge (contract revisions).
|
|
3
3
|
//
|
|
4
4
|
// Exposure: POST /api/live-host/bridge, mounted in routes.js. Reaching
|
|
5
5
|
// /api/live-host directly stays a local-only path (LOCAL_ONLY_PREFIXES covers
|
|
@@ -28,8 +28,7 @@
|
|
|
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
|
-
// Le developer instructions della config per quella Live
|
|
32
|
-
// Emendata da rev5). La riga che decide è in codex-rs
|
|
31
|
+
// Le developer instructions della config per quella Live. La riga che decide è in codex-rs
|
|
33
32
|
// core/src/config/mod.rs: `developer_instructions.or(cfg.developer_
|
|
34
33
|
// Instructions)` — l'override Some scarta il valore di config.
|
|
35
34
|
// (verso corretto dopo revisione pre-release): l'identità della
|
|
@@ -46,10 +45,10 @@
|
|
|
46
45
|
// thread/read e' separata e sola lettura: non modifica il thread ponte ne'
|
|
47
46
|
// quello della TUI. Per questo l'aggancio funziona anche su una cella che
|
|
48
47
|
// sta già processando un turno: conversazioni separate, nessuna
|
|
49
|
-
// Interruzione
|
|
48
|
+
// Interruzione.
|
|
50
49
|
// La connessione al socket di controllo è ON-DEMAND (connect →
|
|
51
50
|
// handshake → thread/start → close), mai permanente: la fuga notifiche
|
|
52
|
-
// notata in
|
|
51
|
+
// notata in precedenza riguarda i client permanenti.
|
|
53
52
|
// Il ponte opera SOLO sulla cella designata — non accetta target
|
|
54
53
|
// Dal chiamante, la designazione è la condizione.
|
|
55
54
|
// Isolabile — cfg.liveBridgeEnabled=false e il ponte non si connette
|
|
@@ -66,6 +65,7 @@
|
|
|
66
65
|
const fs = require('node:fs');
|
|
67
66
|
const path = require('node:path');
|
|
68
67
|
const os = require('node:os');
|
|
68
|
+
const crypto = require('node:crypto');
|
|
69
69
|
|
|
70
70
|
// ws+unix:// è supportato nativamente da ws >= 8 (isIpcUrl): il path prima dei
|
|
71
71
|
// `:` è il socket, dopo è la resource. Iniettabile per i test.
|
|
@@ -174,7 +174,7 @@ function queryThreadStatusOnControlSocket({
|
|
|
174
174
|
});
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
// — Prompt per-cella (
|
|
177
|
+
// — Prompt per-cella (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
|
|
@@ -248,7 +248,7 @@ function readCellPrompt(filesRoot, tmuxSession) {
|
|
|
248
248
|
// Senza tmuxSession dichiarata non c'è identità possibile: il testo lo DICE,
|
|
249
249
|
// non suggerisce un comando che fallirebbe comunque (e una sessione indovinata
|
|
250
250
|
// sarebbe l'identità di un'altra cella).
|
|
251
|
-
function identityHeader(cellId, tmuxSession) {
|
|
251
|
+
function identityHeader(cellId, tmuxSession, liveRef = null) {
|
|
252
252
|
if (!tmuxSession) {
|
|
253
253
|
return `Live NexusCrew agganciata alla cella ${cellId}. `
|
|
254
254
|
+ 'Il roster non dichiara una sessione tmux per questa cella: senza sessione '
|
|
@@ -259,9 +259,10 @@ function identityHeader(cellId, tmuxSession) {
|
|
|
259
259
|
+ 'Questa Live eredita l\'ambiente del daemon, condiviso fra tutte le Live e senza identità: '
|
|
260
260
|
+ 'i tool NexusCrew che richiedono la sessione restano chiusi finché non li chiami con la tua. '
|
|
261
261
|
+ 'Per usarli avvia il server MCP NexusCrew via stdio con la sessione di questa cella '
|
|
262
|
-
+ `nell'ambiente — NEXUSCREW_MCP_SESSION=${tmuxSession} nexuscrew mcp — e parlagli `
|
|
262
|
+
+ `nell'ambiente — NEXUSCREW_MCP_SESSION=${tmuxSession}${liveRef ? ` NEXUSCREW_MCP_LIVE_THREAD=${liveRef}` : ''} nexuscrew mcp — e parlagli `
|
|
263
263
|
+ 'JSON-RPC su stdin (initialize, notifications/initialized, tools/call). '
|
|
264
|
-
+ `Il valore esatto per questa conversazione è ${tmuxSession}: mai un'altra sessione
|
|
264
|
+
+ `Il valore esatto per questa conversazione è ${tmuxSession}: mai un'altra sessione.`
|
|
265
|
+
+ (liveRef ? ' NEXUSCREW_MCP_LIVE_THREAD dichiara questa Live ai messaggi che mandi: tienilo uguale, senza modificarlo.' : '');
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
// —— Client on-demand del socket di controllo (sezione protocollo sopra) ——
|
|
@@ -411,6 +412,18 @@ function startThreadOnControlSocket({
|
|
|
411
412
|
});
|
|
412
413
|
}
|
|
413
414
|
|
|
415
|
+
// Registro volatile con la stessa interfaccia di quello persistito.
|
|
416
|
+
function volatileRegistry(now) {
|
|
417
|
+
const map = new Map();
|
|
418
|
+
return {
|
|
419
|
+
get: (cell) => map.get(cell) || null,
|
|
420
|
+
set: (cell, entry) => { const v = { ...entry, startedAt: now() }; map.set(cell, v); return v; },
|
|
421
|
+
remove: (cell) => map.delete(cell),
|
|
422
|
+
entries: () => [...map].map(([cell, entry]) => ({ cell, ...entry })),
|
|
423
|
+
findByRef: (ref) => [...map].map(([cell, entry]) => ({ cell, ...entry })).find((e) => e.ref === ref) || null,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
414
427
|
// —— Il ponte ——
|
|
415
428
|
function createLiveBridge({
|
|
416
429
|
cfg,
|
|
@@ -421,9 +434,12 @@ function createLiveBridge({
|
|
|
421
434
|
WebSocket,
|
|
422
435
|
now = () => Date.now(),
|
|
423
436
|
log = () => {},
|
|
437
|
+
registry = null,
|
|
424
438
|
}) {
|
|
425
439
|
const root = filesRoot || cfg.filesRoot || path.join(os.homedir(), 'NexusFiles');
|
|
426
|
-
|
|
440
|
+
// The persisted registry survives a restart; without one (tests, partial
|
|
441
|
+
// setups) the bridge keeps the old behaviour with a volatile store.
|
|
442
|
+
const reg = registry || volatileRegistry(now);
|
|
427
443
|
const threadStatusCache = new Map();
|
|
428
444
|
const threadStatusInFlight = new Map();
|
|
429
445
|
// Riserve in-process per l'avvio Live. La chiave è la cella host: una
|
|
@@ -497,15 +513,28 @@ function createLiveBridge({
|
|
|
497
513
|
return cells.find((c) => c && c.cell === cellId) || null;
|
|
498
514
|
}
|
|
499
515
|
|
|
500
|
-
|
|
501
|
-
|
|
516
|
+
// Cache e richieste in corso sono per TUPLA (cella, thread), non per sola
|
|
517
|
+
// cella: la lettura di un thread sostituito non puo' rispondere per il nuovo,
|
|
518
|
+
// ne' ripopolare la cache dopo la rotazione.
|
|
519
|
+
const statusKey = (cellId, threadId) => `${cellId}\u0000${threadId}`;
|
|
520
|
+
function dropStatusFor(cellId) {
|
|
521
|
+
const prefix = `${cellId}\u0000`;
|
|
522
|
+
for (const key of [...threadStatusCache.keys()]) if (key.startsWith(prefix)) threadStatusCache.delete(key);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// `expectedThreadId`: chi attesta un riferimento chiede lo stato del thread
|
|
526
|
+
// che ha letto nel registro, non di quello che il registro nomina dopo.
|
|
527
|
+
async function threadStatus(cellId, expectedThreadId) {
|
|
528
|
+
const known = reg.get(cellId);
|
|
529
|
+
const threadId = expectedThreadId || (known && known.threadId);
|
|
502
530
|
if (!threadId) return 'unknown';
|
|
531
|
+
const key = statusKey(cellId, threadId);
|
|
503
532
|
|
|
504
533
|
const current = now();
|
|
505
|
-
const cached = threadStatusCache.get(
|
|
534
|
+
const cached = threadStatusCache.get(key);
|
|
506
535
|
if (cached && cached.expiresAt > current) return cached.value;
|
|
507
|
-
if (cached) threadStatusCache.delete(
|
|
508
|
-
if (threadStatusInFlight.has(
|
|
536
|
+
if (cached) threadStatusCache.delete(key);
|
|
537
|
+
if (threadStatusInFlight.has(key)) return threadStatusInFlight.get(key);
|
|
509
538
|
|
|
510
539
|
const query = queryThreadStatusOnControlSocket({
|
|
511
540
|
socketPath: cfg.liveBridgeSocketPath,
|
|
@@ -513,13 +542,17 @@ function createLiveBridge({
|
|
|
513
542
|
timeoutMs: cfg.liveBridgeTimeoutMs,
|
|
514
543
|
WebSocket,
|
|
515
544
|
}).catch(() => 'unknown').then((value) => {
|
|
545
|
+
// A definite "gone" ends the entry: the reference stops resolving and the
|
|
546
|
+
// file does not keep a thread that no longer exists. Anything uncertain
|
|
547
|
+
// (error, timeout, system error) leaves it alone.
|
|
548
|
+
if (value === 'absent') reg.remove(cellId, threadId);
|
|
516
549
|
// Anche unknown e' la risposta della query corrente: non riusiamo un
|
|
517
550
|
// valore buono oltre la sua finestra, e il prossimo tick potra' misurare
|
|
518
551
|
// di nuovo il nodo.
|
|
519
|
-
threadStatusCache.set(
|
|
552
|
+
threadStatusCache.set(key, { value, expiresAt: now() + threadStatusCacheMs });
|
|
520
553
|
return value;
|
|
521
|
-
}).finally(() => { threadStatusInFlight.delete(
|
|
522
|
-
threadStatusInFlight.set(
|
|
554
|
+
}).finally(() => { threadStatusInFlight.delete(key); });
|
|
555
|
+
threadStatusInFlight.set(key, query);
|
|
523
556
|
return query;
|
|
524
557
|
}
|
|
525
558
|
|
|
@@ -578,7 +611,11 @@ function createLiveBridge({
|
|
|
578
611
|
// non è mai più assente: senza LIVE_PROMPT.md porta la sola intestazione
|
|
579
612
|
// — e poiché il campo SOSTITUISCE le developer instructions della config
|
|
580
613
|
// (see: the .or() in config/mod.rs), that cell no longer receives them.
|
|
581
|
-
|
|
614
|
+
// The reference the Live declares to NexusCrew tools: made before the
|
|
615
|
+
// thread exists (the header travels with thread/start) and registered only
|
|
616
|
+
// at commit, so a discarded start leaves nothing behind.
|
|
617
|
+
const liveRef = crypto.randomBytes(16).toString('hex');
|
|
618
|
+
const intestazione = identityHeader(snap.hostCell, cell.tmuxSession, liveRef);
|
|
582
619
|
const developerInstructions = prompt.applied
|
|
583
620
|
? `${intestazione}\n\n${prompt.text}`
|
|
584
621
|
: intestazione;
|
|
@@ -647,8 +684,10 @@ function createLiveBridge({
|
|
|
647
684
|
socketPath: cfg.liveBridgeSocketPath,
|
|
648
685
|
at: now(),
|
|
649
686
|
};
|
|
650
|
-
|
|
651
|
-
|
|
687
|
+
reg.set(snap.hostCell, {
|
|
688
|
+
threadId: started.threadId, ref: liveRef, ...(cell.tmuxSession ? { tmuxSession: cell.tmuxSession } : {}),
|
|
689
|
+
});
|
|
690
|
+
dropStatusFor(snap.hostCell);
|
|
652
691
|
// Il puntamento è visibile lato nostro — log con cella, thread e
|
|
653
692
|
// prompt applicato. È il "dirottamento dichiarato" del contratto. Il
|
|
654
693
|
// Campo SOSTITUISCE le developer instructions della config (la
|
|
@@ -658,8 +697,29 @@ function createLiveBridge({
|
|
|
658
697
|
return out;
|
|
659
698
|
}
|
|
660
699
|
|
|
700
|
+
// At startup: a registry entry whose thread the daemon reports as gone is
|
|
701
|
+
// dropped; one the daemon cannot vouch for (error, timeout, system error)
|
|
702
|
+
// stays, because absence is only ever concluded from a definite answer.
|
|
703
|
+
async function reconcile() {
|
|
704
|
+
const removed = [];
|
|
705
|
+
const kept = [];
|
|
706
|
+
for (const entry of reg.entries()) {
|
|
707
|
+
let value;
|
|
708
|
+
try {
|
|
709
|
+
value = await queryThreadStatusOnControlSocket({
|
|
710
|
+
socketPath: cfg.liveBridgeSocketPath, threadId: entry.threadId,
|
|
711
|
+
timeoutMs: cfg.liveBridgeTimeoutMs, WebSocket,
|
|
712
|
+
});
|
|
713
|
+
} catch (_) { value = 'unknown'; }
|
|
714
|
+
if (value === 'absent' && reg.remove(entry.cell, entry.threadId)) { dropStatusFor(entry.cell); removed.push(entry.cell); }
|
|
715
|
+
else kept.push(entry.cell);
|
|
716
|
+
}
|
|
717
|
+
return { removed, kept };
|
|
718
|
+
}
|
|
719
|
+
|
|
661
720
|
return {
|
|
662
721
|
resolveForLive,
|
|
722
|
+
reconcile,
|
|
663
723
|
threadStatus,
|
|
664
724
|
readCellPrompt: (tmuxSession) => { const { text, ...rest } = readCellPrompt(root, tmuxSession); return rest; },
|
|
665
725
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/live-host/entry.js — la voce `<instanceId>:Live` della directory delle celle.
|
|
3
|
+
//
|
|
4
|
+
// La Live compare solo quando c'e' qualcosa di vivo da nominare: un thread
|
|
5
|
+
// nativo presente o attivo, oppure la modalita' tmux con l'ospite attivo. Non
|
|
6
|
+
// ha una sessione tmux propria e non nomina la cella ospite: chi legge la
|
|
7
|
+
// directory non deve poter confondere le due. canReceive resta false finche'
|
|
8
|
+
// non esiste una consegna dedicata alla Live; il motivo dice perche'.
|
|
9
|
+
|
|
10
|
+
const { isReservedLiveName } = require('./reserved.js');
|
|
11
|
+
|
|
12
|
+
const NATIVE_ALIVE = new Set(['present', 'active']);
|
|
13
|
+
|
|
14
|
+
async function buildLiveEntry({ store, bridge, fleetP, instanceId, now = Date.now }) {
|
|
15
|
+
try {
|
|
16
|
+
const snap = store.snapshot();
|
|
17
|
+
if (!snap || snap.hostCell == null || snap.ownerId) return null;
|
|
18
|
+
const fleet = await fleetP;
|
|
19
|
+
const statusFn = fleet && (typeof fleet.status === 'function' ? fleet.status : fleet.cellStatus);
|
|
20
|
+
if (!fleet || fleet.available !== true || typeof statusFn !== 'function') return null;
|
|
21
|
+
const status = await statusFn.call(fleet);
|
|
22
|
+
const cells = Array.isArray(status && status.cells) ? status.cells : [];
|
|
23
|
+
const host = cells.find((c) => c && c.cell === snap.hostCell && !isReservedLiveName(c.cell));
|
|
24
|
+
if (!host) return null;
|
|
25
|
+
// The Live works through its host cell (identity, tools, files): with the
|
|
26
|
+
// host off there is no Live to name, whatever the daemon still holds.
|
|
27
|
+
if (host.active !== true || host.tmux === false) return null;
|
|
28
|
+
const base = {
|
|
29
|
+
id: `${instanceId}:Live`,
|
|
30
|
+
instanceId,
|
|
31
|
+
cell: 'Live',
|
|
32
|
+
kind: 'live',
|
|
33
|
+
label: 'Live',
|
|
34
|
+
active: true,
|
|
35
|
+
canReceive: false,
|
|
36
|
+
lastSeen: now(),
|
|
37
|
+
};
|
|
38
|
+
if (String(host.engine || '').startsWith('codex-vl')) {
|
|
39
|
+
const thread = await bridge.threadStatus(snap.hostCell);
|
|
40
|
+
if (!NATIVE_ALIVE.has(thread)) return null;
|
|
41
|
+
return { ...base, mode: 'native', state: thread, reason: 'live-not-addressable' };
|
|
42
|
+
}
|
|
43
|
+
return { ...base, mode: 'tmux', state: 'tmux', reason: 'tmux-mode' };
|
|
44
|
+
} catch (_) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { buildLiveEntry };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/live-host/registry.js — registro persistito dei thread Live.
|
|
3
|
+
//
|
|
4
|
+
// One entry per host cell: the thread the Live bridge started on the app-server
|
|
5
|
+
// and the reference that thread declares when it calls NexusCrew tools. It lives
|
|
6
|
+
// next to live-host.json so it survives a server restart: the thread itself
|
|
7
|
+
// lives in the daemon, and a restart must not make it look absent.
|
|
8
|
+
//
|
|
9
|
+
// The file is the truth. Every call reads it, so two instances over the same
|
|
10
|
+
// path agree; writes are synchronous read-modify-write (one event loop, no
|
|
11
|
+
// interleaving) and atomic on disk. A damaged file reads as empty and a damaged
|
|
12
|
+
// entry is skipped, never promoted.
|
|
13
|
+
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const { readJsonSafe, atomicWriteJson } = require('../notify/persist.js');
|
|
17
|
+
|
|
18
|
+
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
19
|
+
const REF_RE = /^[a-f0-9]{32}$/;
|
|
20
|
+
const MAX_THREAD_ID = 128;
|
|
21
|
+
// One entry per host cell, and never more than this many: the oldest go first.
|
|
22
|
+
const MAX_ENTRIES = 64;
|
|
23
|
+
|
|
24
|
+
function liveThreadsPath(cfg = {}, home = (cfg.home || os.homedir())) {
|
|
25
|
+
if (cfg.liveThreadsPath) return cfg.liveThreadsPath;
|
|
26
|
+
if (cfg.tokenPath) return path.join(path.dirname(cfg.tokenPath), 'live-threads.json');
|
|
27
|
+
return path.join(home, '.nexuscrew', 'live-threads.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validEntry(cell, raw) {
|
|
31
|
+
if (!CELL_ID_RE.test(String(cell || '')) || !raw || typeof raw !== 'object') return null;
|
|
32
|
+
if (typeof raw.threadId !== 'string' || !raw.threadId || raw.threadId.length > MAX_THREAD_ID) return null;
|
|
33
|
+
if (typeof raw.ref !== 'string' || !REF_RE.test(raw.ref)) return null;
|
|
34
|
+
const out = { threadId: raw.threadId, ref: raw.ref };
|
|
35
|
+
if (typeof raw.tmuxSession === 'string' && raw.tmuxSession) out.tmuxSession = raw.tmuxSession;
|
|
36
|
+
out.startedAt = Number.isFinite(raw.startedAt) ? raw.startedAt : 0;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createLiveThreadRegistry({ filePath, now = () => Date.now() } = {}) {
|
|
41
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
42
|
+
throw new Error('createLiveThreadRegistry: filePath richiesto');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readAll() {
|
|
46
|
+
// Fail closed: a file that cannot be read safely (symlink, permissions wider
|
|
47
|
+
// than 0600) is treated as empty, never trusted. An empty registry means no
|
|
48
|
+
// thread is vouched for, which is the safe answer.
|
|
49
|
+
let raw;
|
|
50
|
+
try { raw = readJsonSafe(filePath); } catch (_) { return {}; }
|
|
51
|
+
const threads = raw && typeof raw.threads === 'object' && raw.threads && !Array.isArray(raw.threads)
|
|
52
|
+
? raw.threads : {};
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const [cell, value] of Object.entries(threads)) {
|
|
55
|
+
const entry = validEntry(cell, value);
|
|
56
|
+
if (entry) out[cell] = entry;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeAll(threads) {
|
|
62
|
+
atomicWriteJson(filePath, { version: 1, threads });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function get(cell) {
|
|
66
|
+
return readAll()[cell] || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function set(cell, entry) {
|
|
70
|
+
const next = validEntry(cell, { ...entry, startedAt: now() });
|
|
71
|
+
if (!next) throw new Error('voce del registro Live non valida');
|
|
72
|
+
const all = readAll();
|
|
73
|
+
all[cell] = next;
|
|
74
|
+
const cells = Object.keys(all);
|
|
75
|
+
if (cells.length > MAX_ENTRIES) {
|
|
76
|
+
cells.filter((name) => name !== cell)
|
|
77
|
+
.sort((a, b) => all[a].startedAt - all[b].startedAt)
|
|
78
|
+
.slice(0, cells.length - MAX_ENTRIES)
|
|
79
|
+
.forEach((name) => { delete all[name]; });
|
|
80
|
+
}
|
|
81
|
+
writeAll(all);
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// With `expectedThreadId` the entry is removed only if it still names that
|
|
86
|
+
// thread: a slow read about an old thread must not remove a newer Live that
|
|
87
|
+
// replaced the entry in the meantime.
|
|
88
|
+
function remove(cell, expectedThreadId) {
|
|
89
|
+
const all = readAll();
|
|
90
|
+
if (!(cell in all)) return false;
|
|
91
|
+
if (expectedThreadId !== undefined && all[cell].threadId !== expectedThreadId) return false;
|
|
92
|
+
delete all[cell];
|
|
93
|
+
writeAll(all);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function entries() {
|
|
98
|
+
return Object.entries(readAll()).map(([cell, entry]) => ({ cell, ...entry }));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function findByRef(ref) {
|
|
102
|
+
if (typeof ref !== 'string' || !REF_RE.test(ref)) return null;
|
|
103
|
+
return entries().find((entry) => entry.ref === ref) || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { get, set, remove, entries, findByRef, filePath };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
createLiveThreadRegistry.MAX_ENTRIES = MAX_ENTRIES;
|
|
110
|
+
|
|
111
|
+
module.exports = { createLiveThreadRegistry, liveThreadsPath, REF_RE };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/live-host/reserved.js — il nome `Live` appartiene alla Live.
|
|
3
|
+
//
|
|
4
|
+
// Una cella con quel nome (in qualunque grafia) si scambierebbe con la voce di
|
|
5
|
+
// directory della Live. Il divieto vale alla scrittura e il nome e' filtrato
|
|
6
|
+
// in uscita, cosi' una definizione gia' presente non puo' sovrapporsi.
|
|
7
|
+
|
|
8
|
+
function isReservedLiveName(id) {
|
|
9
|
+
return typeof id === 'string' && id.toLowerCase() === 'live';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { isReservedLiveName };
|
package/lib/mcp/cells.js
CHANGED
|
@@ -100,12 +100,49 @@ function parseCellTarget(value) {
|
|
|
100
100
|
return NODE_ID_RE.test(instanceId) && CELL_ID_RE.test(cell) ? { instanceId, cell } : null;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
const LIVE_MODES = new Set(['native', 'tmux']);
|
|
104
|
+
const LIVE_STATES = new Set(['present', 'active', 'tmux']);
|
|
105
|
+
const LIVE_REASONS = new Set(['live-not-addressable', 'tmux-mode']);
|
|
106
|
+
|
|
107
|
+
function normalizeLiveEntry(raw, owner, route) {
|
|
108
|
+
if (raw.instanceId !== owner.instanceId || raw.cell !== 'Live'
|
|
109
|
+
|| !LIVE_MODES.has(raw.mode) || !LIVE_STATES.has(raw.state)) return null;
|
|
110
|
+
return {
|
|
111
|
+
id: `${owner.instanceId}:Live`,
|
|
112
|
+
instanceId: owner.instanceId,
|
|
113
|
+
owner: owner.label,
|
|
114
|
+
route,
|
|
115
|
+
cell: 'Live',
|
|
116
|
+
kind: 'live',
|
|
117
|
+
label: 'Live',
|
|
118
|
+
labelReported: false,
|
|
119
|
+
mode: raw.mode,
|
|
120
|
+
state: raw.state,
|
|
121
|
+
reason: LIVE_REASONS.has(raw.reason) ? raw.reason : 'live-not-addressable',
|
|
122
|
+
engine: '',
|
|
123
|
+
model: '',
|
|
124
|
+
active: raw.active === true,
|
|
125
|
+
// Not receivable until a dedicated delivery exists.
|
|
126
|
+
canReceive: false,
|
|
127
|
+
lastSeen: Number.isFinite(raw.lastSeen) ? raw.lastSeen : null,
|
|
128
|
+
self: false,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
103
132
|
function normalizeCellPayload(payload, owner, callerSession = null) {
|
|
104
133
|
if (!payload || payload.instanceId !== owner.instanceId || !Array.isArray(payload.cells)) return [];
|
|
105
134
|
const route = owner.route.length ? owner.route.join('/') : 'local';
|
|
106
135
|
const seen = new Set();
|
|
107
136
|
const out = [];
|
|
108
137
|
for (const raw of payload.cells) {
|
|
138
|
+
if (raw && raw.kind === 'live') {
|
|
139
|
+
// The Live is not a cell: it has no tmux session. A federated payload is
|
|
140
|
+
// untrusted, so the entry is rebuilt from known values only and is never
|
|
141
|
+
// receivable, whatever the peer says.
|
|
142
|
+
const live = normalizeLiveEntry(raw, owner, route);
|
|
143
|
+
if (live && !seen.has('\0live')) { seen.add('\0live'); out.push(live); }
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
109
146
|
if (!raw || raw.instanceId !== owner.instanceId || !CELL_ID_RE.test(String(raw.cell || ''))
|
|
110
147
|
|| typeof raw.tmuxSession !== 'string' || !isValidSession(raw.tmuxSession)
|
|
111
148
|
|| seen.has(raw.cell)) continue;
|
package/lib/mcp/server.js
CHANGED
|
@@ -255,6 +255,11 @@ function createMcpServer(opts = {}) {
|
|
|
255
255
|
// (config.json + env NEXUSCREW_CONFIG_FILE/PORT/TOKEN_FILE). opts.config per test.
|
|
256
256
|
const cfg = opts.config || loadConfig();
|
|
257
257
|
const baseUrl = `http://127.0.0.1:${cfg.port}`;
|
|
258
|
+
// The Live declares the reference of its own thread; the server decides
|
|
259
|
+
// whether the declaration holds (registry, host cell, live thread). A value
|
|
260
|
+
// that is not a well-formed reference is never sent.
|
|
261
|
+
const liveRefRaw = typeof env.NEXUSCREW_MCP_LIVE_THREAD === 'string' ? env.NEXUSCREW_MCP_LIVE_THREAD.trim() : '';
|
|
262
|
+
const liveThreadHeader = /^[a-f0-9]{32}$/.test(liveRefRaw) ? { 'x-nexuscrew-live-thread': liveRefRaw } : {};
|
|
258
263
|
|
|
259
264
|
// Identita' locale storica: risolta una volta e cacheata — ma solo se riesce.
|
|
260
265
|
//
|
|
@@ -375,6 +380,7 @@ function createMcpServer(opts = {}) {
|
|
|
375
380
|
authorization: `Bearer ${token}`,
|
|
376
381
|
...signed,
|
|
377
382
|
...identityBinding,
|
|
383
|
+
...liveThreadHeader,
|
|
378
384
|
...(payload !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
379
385
|
},
|
|
380
386
|
...(payload !== undefined ? { body: payload } : {}),
|
|
@@ -389,7 +395,7 @@ function createMcpServer(opts = {}) {
|
|
|
389
395
|
return j;
|
|
390
396
|
}
|
|
391
397
|
|
|
392
|
-
//
|
|
398
|
+
// Aggiornare NexusCrew NON cambia il codice gia' caricato dai bridge
|
|
393
399
|
// MCP delle celle attive: ogni processo resta sulla versione con cui e' stato
|
|
394
400
|
// avviato fino al riavvio della cella. Dopo una correzione installata, un
|
|
395
401
|
// bridge ancora attivo puo' quindi restituire l'errore precedente; prima di
|
package/lib/mcp/tools.js
CHANGED
|
@@ -656,6 +656,7 @@ const TOOLS = [
|
|
|
656
656
|
const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
|
|
657
657
|
&& cell.cell === targetRef.cell);
|
|
658
658
|
if (!target) throw new Error('nc_cell_diagnostics: cella locale non trovata');
|
|
659
|
+
if (target.kind === 'live') throw new Error('nc_cell_diagnostics: la Live non e\' una cella e non ha diagnostica');
|
|
659
660
|
|
|
660
661
|
const [definitions, diagnostics] = await Promise.all([
|
|
661
662
|
ctx.api('GET', '/api/fleet/definitions'), recentDiagnostics(ctx),
|
|
@@ -712,6 +713,11 @@ const TOOLS = [
|
|
|
712
713
|
const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
|
|
713
714
|
&& cell.cell === targetRef.cell);
|
|
714
715
|
if (!target) throw new Error(cellLookupFailure(directory, targetRef));
|
|
716
|
+
// The Live is addressable by id but has no delivery yet: say so, and never
|
|
717
|
+
// let the message land on its host cell or any other.
|
|
718
|
+
if (target.kind === 'live') {
|
|
719
|
+
throw new Error(`live-not-addressable: la Live non riceve ancora messaggi da nc_send_cell (${target.reason || 'live-not-addressable'}); nessun messaggio inviato`);
|
|
720
|
+
}
|
|
715
721
|
if (!target.canReceive) throw new Error('cella destinataria non attiva; nessun messaggio accodato');
|
|
716
722
|
const apiPath = target.route === 'local'
|
|
717
723
|
? '/api/cells/send' : routePath(target.route.split('/'), 'cells/send');
|
|
@@ -923,7 +929,7 @@ const TOOLS = [
|
|
|
923
929
|
return { receipt };
|
|
924
930
|
},
|
|
925
931
|
},
|
|
926
|
-
// -- lease Live del child (fetta 2b
|
|
932
|
+
// -- lease Live del child (fetta 2b) ----------------------
|
|
927
933
|
// Tre metodi DISTINTI perche' i valori di ritorno non mentono l'uno con l'altro:
|
|
928
934
|
// solo register puo' rispondere {status:'pending'} (join non ancora tracciato);
|
|
929
935
|
// refresh su registration viva risponde {status:'live', proof} e non ha stati
|