@mmmbuto/nexuscrew 0.9.63 → 0.9.64
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 +6 -0
- package/README.md +9 -21
- package/docs/SECURITY.md +13 -12
- package/frontend/dist/assets/{index-D1cTtNED.css → index-BC6vO7Zd.css} +1 -1
- package/frontend/dist/assets/{index-maPH0v_e.js → index-oUd5O8W8.js} +38 -38
- 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/server.js +27 -10
- package/lib/tmux/actions.js +1 -1
- package/package.json +2 -2
package/frontend/dist/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-oUd5O8W8.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BC6vO7Zd.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.9.
|
|
1
|
+
{"version":"0.9.64"}
|
package/lib/cells/routes.js
CHANGED
|
@@ -4,6 +4,7 @@ const express = require('express');
|
|
|
4
4
|
const { isValidSession } = require('../files/store.js');
|
|
5
5
|
const { submitTextOk } = require('../tmux/actions.js');
|
|
6
6
|
const { createIdentityBindingGuard } = require('../identity/binding-guard.js');
|
|
7
|
+
const { isReservedLiveName } = require('../live-host/reserved.js');
|
|
7
8
|
|
|
8
9
|
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
9
10
|
const CELL_LABEL_MAX = 64;
|
|
@@ -29,6 +30,7 @@ function publicCells(status, instanceId, now = Date.now()) {
|
|
|
29
30
|
const cells = [];
|
|
30
31
|
for (const raw of status.cells) {
|
|
31
32
|
if (!raw || !CELL_ID_RE.test(String(raw.cell || ''))
|
|
33
|
+
|| isReservedLiveName(raw.cell)
|
|
32
34
|
|| !isValidSession(raw.tmuxSession) || seen.has(raw.cell)) continue;
|
|
33
35
|
seen.add(raw.cell);
|
|
34
36
|
// Un `tmux:false` esplicito prevale su active:true: la directory globale non
|
|
@@ -69,7 +71,15 @@ function validIdentity(value) {
|
|
|
69
71
|
&& isValidSession(value.tmuxSession);
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
const LIVE_HEADER = 'x-nexuscrew-live-thread';
|
|
75
|
+
|
|
76
|
+
// `liveEntry({ instanceId, now })` -> the Live directory entry or null, and
|
|
77
|
+
// `liveAttest.verify({ ref, fromCell })` -> { ok, hostCell } are optional seams:
|
|
78
|
+
// without them the routes behave as before.
|
|
79
|
+
function cellsRoutes({
|
|
80
|
+
fleetP, instanceId, submit, readonly = () => false, now = () => Date.now(), diagnostics = null,
|
|
81
|
+
identityMode = 'legacy', liveEntry = null, liveAttest = null,
|
|
82
|
+
}) {
|
|
73
83
|
const bindingGuard = createIdentityBindingGuard({
|
|
74
84
|
fleetP, instanceId, now,
|
|
75
85
|
sharedRequired: identityMode === 'authority',
|
|
@@ -98,11 +108,16 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
|
|
|
98
108
|
try {
|
|
99
109
|
const nodeId = instanceId();
|
|
100
110
|
const st = await status();
|
|
111
|
+
// The Live is its own entry, never a cell: a failure here must not take
|
|
112
|
+
// the directory down, it only means the Live is not shown.
|
|
113
|
+
const live = liveEntry && NODE_ID_RE.test(String(nodeId || ''))
|
|
114
|
+
? await Promise.resolve(liveEntry({ instanceId: nodeId, now })).catch(() => null) : null;
|
|
115
|
+
const cells = publicCells(st, nodeId, now());
|
|
101
116
|
res.json({
|
|
102
117
|
instanceId: NODE_ID_RE.test(String(nodeId || '')) ? nodeId : null,
|
|
103
118
|
available: st.available === true,
|
|
104
119
|
at: now(),
|
|
105
|
-
cells:
|
|
120
|
+
cells: live ? [...cells, live] : cells,
|
|
106
121
|
});
|
|
107
122
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
108
123
|
});
|
|
@@ -133,6 +148,11 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
|
|
|
133
148
|
|| !submitTextOk(body.message)) {
|
|
134
149
|
return reject(400, 'messaggio cella non valido');
|
|
135
150
|
}
|
|
151
|
+
// `Live` names the Live, which has no tmux session to paste into: never
|
|
152
|
+
// fall back to a cell of the same name or to its host.
|
|
153
|
+
if (isReservedLiveName(body.to.cell)) {
|
|
154
|
+
return reject(409, 'live-not-addressable: la Live non riceve messaggi da qui; nessuna consegna');
|
|
155
|
+
}
|
|
136
156
|
const localId = instanceId();
|
|
137
157
|
if (!NODE_ID_RE.test(String(localId || '')) || body.to.instanceId !== localId) {
|
|
138
158
|
return reject(409, 'destinazione non appartiene a questo nodo');
|
|
@@ -165,7 +185,21 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
|
|
|
165
185
|
&& cell.tmuxSession === body.to.tmuxSession);
|
|
166
186
|
if (!target) return reject(404, 'cella destinataria sconosciuta');
|
|
167
187
|
if (!target.canReceive) return reject(409, 'cella destinataria non attiva');
|
|
168
|
-
|
|
188
|
+
let label = `${body.from.cell}@${body.from.instanceId.slice(0, 8)}`;
|
|
189
|
+
// The Live label is earned, not declared: only a local sender whose
|
|
190
|
+
// declared reference is registered, belongs to this host cell and has a
|
|
191
|
+
// live thread. Anything else travels as a plain cell message.
|
|
192
|
+
if (liveAttest && !visited.length) {
|
|
193
|
+
const rawRef = req.headers[LIVE_HEADER];
|
|
194
|
+
if (rawRef !== undefined) {
|
|
195
|
+
const verdict = await Promise.resolve(liveAttest.verify({
|
|
196
|
+
ref: Array.isArray(rawRef) ? rawRef[0] : rawRef, fromCell: body.from.cell,
|
|
197
|
+
})).catch(() => ({ ok: false }));
|
|
198
|
+
if (verdict && verdict.ok === true) {
|
|
199
|
+
label = `Live(via ${body.from.cell})@${body.from.instanceId.slice(0, 8)}`;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
169
203
|
// End on printable text even when the source message ends in a newline:
|
|
170
204
|
// Pi may auto-submit a bracketed paste that ends with LF. NexusCrew owns
|
|
171
205
|
// the single explicit Enter used by the transport.
|
package/lib/cli/commands.js
CHANGED
|
@@ -1287,7 +1287,7 @@ async function dispatchNodes(rest, flags, opts = {}) {
|
|
|
1287
1287
|
: String(flags.selected).split(',').map((value) => value.trim()).filter(Boolean);
|
|
1288
1288
|
return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { visibility, selected } }).code };
|
|
1289
1289
|
}
|
|
1290
|
-
// Scope celle
|
|
1290
|
+
// Scope celle: quali celle di QUESTO nodo il peer puo' vedere.
|
|
1291
1291
|
// Diverso da `visibility`, che governa il transito e non l'accesso.
|
|
1292
1292
|
if (sub === 'cells') {
|
|
1293
1293
|
const arg = rest[3];
|
package/lib/config.js
CHANGED
|
@@ -85,7 +85,7 @@ function baseDefaults() {
|
|
|
85
85
|
// con `nexuscrew autoupdate on`, e resta spento finche' non lo si accende.
|
|
86
86
|
autoUpdate: false,
|
|
87
87
|
sessionPresets: {},
|
|
88
|
-
// Ponte Live (fetta 3
|
|
88
|
+
// Ponte Live (fetta 3): isolabile — a false il ponte non si
|
|
89
89
|
// connette mai e ogni avvio di Live resta sul comportamento standard.
|
|
90
90
|
liveBridgeEnabled: true,
|
|
91
91
|
// Socket di controllo dell'app-server (misurato 2026-08-15:
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -44,6 +44,7 @@ const {
|
|
|
44
44
|
const { probeModel, probeCustomEndpoint } = require('./model-probe.js');
|
|
45
45
|
const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
|
|
46
46
|
const { validEnvKey } = require('./env-key.js');
|
|
47
|
+
const { isReservedLiveName } = require('../live-host/reserved.js');
|
|
47
48
|
const { setCredential, removeCredential } = require('./credentials.js');
|
|
48
49
|
const { createLaunchBroker } = require('./launch-broker.js');
|
|
49
50
|
const { createLeaseManager } = require('./cell-lease-server.js');
|
|
@@ -1057,6 +1058,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
1057
1058
|
async function defineCell(def) {
|
|
1058
1059
|
if (readonly()) throw httpError(403, 'READONLY: define-cell bloccato');
|
|
1059
1060
|
if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione cell mancante');
|
|
1061
|
+
if (isReservedLiveName(def.id)) throw httpError(400, 'id riservato: Live indica la Live, non una cella');
|
|
1060
1062
|
if (def.id != null && findCell(reloadDefs(), def.id)) throw httpError(400, `cell esiste già: ${def.id}`);
|
|
1061
1063
|
// cwd portabile fail-closed: risolve cwd/cwdRel in coppia coerente PRIMA di
|
|
1062
1064
|
// scrivere (nessuna scrittura parziale su input non portabile).
|
|
@@ -1338,6 +1340,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
1338
1340
|
}
|
|
1339
1341
|
let id = typeof b.id === 'string' && b.id.trim() ? b.id.trim() : deriveCellId(tmuxSession, defs.cells);
|
|
1340
1342
|
if (!CELL_ID_RE.test(id)) throw httpError(400, 'id non valido (A-Za-z0-9._- max 32)');
|
|
1343
|
+
if (isReservedLiveName(id)) throw httpError(400, 'id riservato: Live indica la Live, non una cella');
|
|
1341
1344
|
if (defs.cells.some((c) => c.id === id)) throw httpError(409, `id cella già usato: ${id}`);
|
|
1342
1345
|
const cwd = typeof b.cwd === 'string' && b.cwd.trim() ? b.cwd.trim() : home;
|
|
1343
1346
|
const cellDef = { id, cwd, engine: engineId, boot: b.boot === true, tmuxSession };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// LeaseManager lato server per il lease del supervisore di una cella Live host.
|
|
4
4
|
//
|
|
5
|
-
// Fetta 2b
|
|
5
|
+
// Fetta 2b: l'autenticazione del reconnect e' un proof HMAC
|
|
6
6
|
// firmato con un verifier PER-INSTALLAZIONE — la capability statica condivisa
|
|
7
7
|
// Della 2a e' REVOCATA, non affiancata (/). Solo il server conosce il
|
|
8
8
|
// Segreto; il supervisore presenta un proof firmato con claims ed expiry (
|
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 };
|