@mmmbuto/nexuscrew 0.8.15 → 0.8.17
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/README.md +280 -396
- package/frontend/dist/assets/index-CffPTRyq.js +91 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +11 -263
- package/lib/cli/fleet-service.js +4 -0
- package/lib/cli/runtime-lifecycle.js +299 -0
- package/lib/cli/service.js +4 -1
- package/lib/fleet/builtin.js +56 -434
- package/lib/fleet/launch.js +227 -0
- package/lib/fleet/runtime.js +269 -0
- package/lib/mcp/cells.js +154 -0
- package/lib/mcp/server.js +11 -422
- package/lib/mcp/tools.js +300 -0
- package/lib/nodes/tunnel-supervisor.js +65 -6
- package/lib/nodes/tunnel.js +49 -6
- package/lib/runtime/env.js +38 -1
- package/lib/server.js +8 -0
- package/lib/settings/pairing-coordinator.js +325 -0
- package/lib/settings/public-peering-routes.js +126 -0
- package/lib/settings/routes.js +16 -396
- package/package.json +1 -1
- package/frontend/dist/assets/index-BD-7qEsn.js +0 -91
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Launch + readiness toolkit del fleet built-in (estratto da builtin.js in
|
|
3
|
+
// modo behavior-preserving). Tutto cio' che sta qui e' STATELESS: ogni
|
|
4
|
+
// funzione riceve le proprie dipendenze come argomento e non tocca lo stato
|
|
5
|
+
// del fleet. createBuiltinRuntime() in runtime.js ne fa uso; builtin.js e'
|
|
6
|
+
// ora un facade che re-esporta questi simboli per i test.
|
|
7
|
+
//
|
|
8
|
+
// Sicurezza (design §9a/§9e/§9h) — invariata rispetto a builtin.js:
|
|
9
|
+
// - command/args/env NON passano per una shell: execFile + argv diretto
|
|
10
|
+
// (tmux fa exec del comando, NON sh -c — verificato: ';','|','$' passano
|
|
11
|
+
// verbatim). Nessun valore passa in argv, `tmux -e`, file temporanei o
|
|
12
|
+
// ambiente globale tmux. PATH lo controlla il service, mai la definizione.
|
|
13
|
+
// - env minimale controllato dal service (allowlist dura); le definizioni non
|
|
14
|
+
// possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env).
|
|
15
|
+
// - promptMode 'send-keys' inietta via `tmux load-buffer` + `paste-buffer -p`
|
|
16
|
+
// (bracketed paste), NON send-keys grezzo; se il command e' gia' uscito
|
|
17
|
+
// (sessione morta) NON digita (§9e).
|
|
18
|
+
// - redactSecrets/sanitizeEarlyDiagnostic (§9h): stderr/stdout dei comandi
|
|
19
|
+
// tmux falliti NON devono mai ecoare i segreti delle definizioni.
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const os = require('node:os');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
const { execFile } = require('node:child_process');
|
|
24
|
+
const { minimalRuntimeEnv } = require('../runtime/env.js');
|
|
25
|
+
|
|
26
|
+
// Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
|
|
27
|
+
// non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
|
|
28
|
+
// qui NON passiamo MAI l'env del processo per intero. engine.env viene consegnato
|
|
29
|
+
// direttamente al processo figlio dal broker, senza entrare nello stato tmux.
|
|
30
|
+
// Nota: se un server tmux e' gia' in esecuzione (avviato fuori dal service), i comandi
|
|
31
|
+
// ereditano l'env di quel server; la garanzia dura resta: le definizioni non possono
|
|
32
|
+
// iniettare loader-key, e engine.env arriva al pane SOLO tramite chiavi validate.
|
|
33
|
+
function minimalEnv() {
|
|
34
|
+
return minimalRuntimeEnv(process.env, { home: os.homedir() });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
|
|
38
|
+
|
|
39
|
+
// Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
|
|
40
|
+
// NON devono mai ecoare i segreti delle definizioni.
|
|
41
|
+
const REDACTED = '‹redacted›';
|
|
42
|
+
|
|
43
|
+
// redactSecrets(text, engine, cell) -> string con ogni occorrenza dei segreti
|
|
44
|
+
// delle definizioni sostituita da '‹redacted›'. Segreti coperti (§9h):
|
|
45
|
+
// - valori di engine.env (le CHIAVI restano, i VALUES vengono redatti)
|
|
46
|
+
// - testo del prompt della cella (cell.prompt)
|
|
47
|
+
// - testo del prompt dell'engine (engine.prompt) se presente
|
|
48
|
+
// Applicato a OGNI messaggio d'errore che incorpora stderr/stdout dei comandi
|
|
49
|
+
// tmux falliti (up / down / injectPrompt): tmux puo' ecoare argv/env del comando
|
|
50
|
+
// lanciato nei suoi log di errore. Pura + senza dipendenze: testabile direttamente.
|
|
51
|
+
function redactSecrets(text, engine, cell) {
|
|
52
|
+
if (typeof text !== 'string' || text === '') return text;
|
|
53
|
+
const secrets = [];
|
|
54
|
+
if (engine && typeof engine === 'object' && engine.env) {
|
|
55
|
+
for (const v of Object.values(engine.env)) {
|
|
56
|
+
if (typeof v === 'string' && v) secrets.push(v);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (engine && typeof engine.prompt === 'string' && engine.prompt) secrets.push(engine.prompt);
|
|
60
|
+
if (cell && typeof cell.prompt === 'string' && cell.prompt) secrets.push(cell.prompt);
|
|
61
|
+
// Ordina per lunghezza DECRESCENTE: i segreti piu' lunghi prima, cosi' un segreto
|
|
62
|
+
// che e' prefisso/sottostringa di un altro non ne maschera il rimpiazzo completo.
|
|
63
|
+
secrets.sort((a, b) => b.length - a.length);
|
|
64
|
+
let out = text;
|
|
65
|
+
for (const s of secrets) out = out.split(s).join(REDACTED); // replace globale, regex-free
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const MAX_EARLY_DIAGNOSTIC = 1200;
|
|
70
|
+
|
|
71
|
+
function sanitizeEarlyDiagnostic(text, engine, cell, home) {
|
|
72
|
+
let out = redactSecrets(String(text || ''), engine, cell);
|
|
73
|
+
// ANSI CSI/OSC e byte di controllo non devono arrivare nell'errore JSON/UI.
|
|
74
|
+
out = out.replace(/\x1b\][^\x07]*(?:\x07|$)/g, '')
|
|
75
|
+
.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '');
|
|
76
|
+
let clean = '';
|
|
77
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
78
|
+
const code = out.charCodeAt(i);
|
|
79
|
+
if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)) clean += out[i];
|
|
80
|
+
}
|
|
81
|
+
out = clean;
|
|
82
|
+
if (typeof home === 'string' && home) out = out.split(home).join('~');
|
|
83
|
+
out = out
|
|
84
|
+
.replace(/\bBearer\s+\S+/gi, `Bearer ${REDACTED}`)
|
|
85
|
+
.replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|AUTH)[A-Z0-9_]*)(\s*[:=]\s*)\S+/g,
|
|
86
|
+
(_m, key, sep) => `${key}${sep}${REDACTED}`)
|
|
87
|
+
.replace(/\b(?:sk|fw|fpk|hf|zai)-[A-Za-z0-9._-]{8,}\b/gi, REDACTED);
|
|
88
|
+
const lines = out.split(/\r?\n/).map((line) => line.trimEnd())
|
|
89
|
+
.filter((line) => line.trim() && !/^Pane is dead \(status /i.test(line.trim()));
|
|
90
|
+
out = lines.join('\n').trim();
|
|
91
|
+
if (out.length > MAX_EARLY_DIAGNOSTIC) out = `…${out.slice(-(MAX_EARLY_DIAGNOSTIC - 1))}`;
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Esecutore tmux: argv diretto (MAI shell). Risolve sempre {err,stdout,stderr,code}
|
|
96
|
+
// cosi' il chiamante distingue "sessione assente" (code!==0 atteso) da errori reali.
|
|
97
|
+
function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
execFile(tmuxBin, args, { env, timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
100
|
+
const code = err && typeof err.code === 'number' ? err.code : (err ? 1 : 0);
|
|
101
|
+
resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || ''), code });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
107
|
+
|
|
108
|
+
// Policy caratteri del prompt send-keys (§9e): ammette stampabili + \t \n \r;
|
|
109
|
+
// rifiuta ESC(0x1b) e gli altri byte di controllo (niente marker bracketed-paste
|
|
110
|
+
// iniettabili). parseDefinitions caps solo la lunghezza: questo e' defense-in-depth.
|
|
111
|
+
function promptCharsOk(prompt) {
|
|
112
|
+
if (typeof prompt !== 'string') return false;
|
|
113
|
+
for (let i = 0; i < prompt.length; i += 1) {
|
|
114
|
+
const c = prompt.charCodeAt(i);
|
|
115
|
+
if (c === 9 || c === 10 || c === 13) continue; // \t \n \r ammessi
|
|
116
|
+
if (c < 32 || c === 127) return false; // ESC/null/altri control
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Build the direct child invocation separately from tmux. This lets the secure
|
|
123
|
+
// launch broker carry the complete child argv and environment in memory while
|
|
124
|
+
// tmux receives only the broker helper path and a one-time nonce.
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
function composeClientInvocation(engine, cell) {
|
|
127
|
+
const args = [...(engine.args || [])];
|
|
128
|
+
// model: flag + (override cella || valore engine), solo se c'e' un valore
|
|
129
|
+
if (engine.model) {
|
|
130
|
+
const val = (cell.model != null && cell.model !== '') ? cell.model : engine.model.value;
|
|
131
|
+
if (val) args.push(engine.model.flag, val);
|
|
132
|
+
}
|
|
133
|
+
// prompt flag-mode: promptFlag + prompt cella, solo se c'e' un prompt effettivo.
|
|
134
|
+
// SICUREZZA (design §9h): promptMode 'flag' mette il prompt in ARGV -> e' visibile
|
|
135
|
+
// nella process list (ps) / argv della sessione, a differenza di 'send-keys' che lo
|
|
136
|
+
// inietta DOPO via bracketed paste. Va quindi vincolato a prompt NON-segreti.
|
|
137
|
+
if (engine.promptMode === 'flag' && cell.prompt) {
|
|
138
|
+
args.push(engine.promptFlag, cell.prompt);
|
|
139
|
+
}
|
|
140
|
+
return { command: engine.command, args };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// composeLaunchArgv({tmuxSession, realCwd, engine, cell}) -> argv per new-session
|
|
144
|
+
// PURA + testabile. Provider values are deliberately absent: no `tmux -e`, no
|
|
145
|
+
// environment value and no broker payload ever appears in the tmux client argv.
|
|
146
|
+
function composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
|
|
147
|
+
const child = composeClientInvocation(engine, cell);
|
|
148
|
+
return ['new-session', '-d', '-s', tmuxSession, '-c', realCwd, child.command, ...child.args];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Poll has-session entro readyMs (no delay fisso cieco). Ritorna true se la sessione
|
|
152
|
+
// e' viva entro la deadline, false altrimenti (command uscito / mai partita).
|
|
153
|
+
async function waitAlive(tmuxBin, session, { env, readyMs }) {
|
|
154
|
+
const deadline = Date.now() + Math.max(0, readyMs | 0);
|
|
155
|
+
for (;;) {
|
|
156
|
+
const r = await tmuxExec(tmuxBin, ['has-session', '-t', `=${session}`], { env, timeoutMs: 2000 });
|
|
157
|
+
if (!r.err) return true;
|
|
158
|
+
if (Date.now() >= deadline) return false;
|
|
159
|
+
await sleep(60);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function waitStablePane(tmuxBin, target, { env, readyMs }) {
|
|
164
|
+
const deadline = Date.now() + Math.max(0, readyMs | 0);
|
|
165
|
+
for (;;) {
|
|
166
|
+
const state = await tmuxExec(tmuxBin,
|
|
167
|
+
['display-message', '-p', '-t', target, '#{pane_dead}\t#{pane_dead_status}\t#{pane_id}'],
|
|
168
|
+
{ env, timeoutMs: 2000 });
|
|
169
|
+
if (state.err) return { alive: false, status: null, target: null };
|
|
170
|
+
const [dead, rawStatus, paneId] = state.stdout.trim().split('\t');
|
|
171
|
+
if (!/^%[0-9]+$/.test(paneId || '')) return { alive: false, status: null, target: null };
|
|
172
|
+
if (dead === '1') {
|
|
173
|
+
const status = /^-?[0-9]+$/.test(rawStatus || '') ? Number(rawStatus) : null;
|
|
174
|
+
return { alive: false, status, target: paneId };
|
|
175
|
+
}
|
|
176
|
+
if (dead !== '0') return { alive: false, status: null, target: null };
|
|
177
|
+
if (Date.now() >= deadline) return { alive: true, status: null, target: paneId };
|
|
178
|
+
await sleep(60);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
|
|
183
|
+
// load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
|
|
184
|
+
// poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
|
|
185
|
+
// (command gia' uscito) NON digita (design §9e). Ritorna {injected, reason}.
|
|
186
|
+
async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, target, engine, cell } = {}) {
|
|
187
|
+
if (!promptCharsOk(prompt)) {
|
|
188
|
+
return { injected: false, reason: 'prompt contiene byte di controllo (rifiutato)' };
|
|
189
|
+
}
|
|
190
|
+
let tmp = null;
|
|
191
|
+
try {
|
|
192
|
+
tmp = path.join(os.tmpdir(), `.ncsend.${session}.${process.pid}.txt`);
|
|
193
|
+
fs.writeFileSync(tmp, prompt, { mode: 0o600 });
|
|
194
|
+
fs.chmodSync(tmp, 0o600);
|
|
195
|
+
|
|
196
|
+
const alive = await waitAlive(tmuxBin, session, { env, readyMs });
|
|
197
|
+
if (!alive) return { injected: false, reason: 'sessione non viva (command uscito?): nessuna digitazione' };
|
|
198
|
+
|
|
199
|
+
// Target esatto: pane id (%N) se disponibile, altrimenti '=sessione' (match
|
|
200
|
+
// esatto, mai prefix-match) — audit impl #5.
|
|
201
|
+
const to = target || `=${session}`;
|
|
202
|
+
await tmuxExec(tmuxBin, ['load-buffer', '-b', 'ncsend', tmp], { env });
|
|
203
|
+
const paste = await tmuxExec(tmuxBin, ['paste-buffer', '-p', '-t', to, '-b', 'ncsend'], { env });
|
|
204
|
+
if (paste.err) return { injected: false, reason: redactSecrets(`paste-buffer failed: ${paste.stderr.trim()}`, engine, cell) };
|
|
205
|
+
return { injected: true, reason: 'bracketed paste (load-buffer + paste-buffer -p)' };
|
|
206
|
+
} finally {
|
|
207
|
+
try { if (tmp) fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
|
|
208
|
+
try { await tmuxExec(tmuxBin, ['delete-buffer', '-b', 'ncsend'], { env }); } catch (_) { /* best-effort */ }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = {
|
|
213
|
+
REDACTED,
|
|
214
|
+
MAX_EARLY_DIAGNOSTIC,
|
|
215
|
+
httpError,
|
|
216
|
+
minimalEnv,
|
|
217
|
+
tmuxExec,
|
|
218
|
+
sleep,
|
|
219
|
+
promptCharsOk,
|
|
220
|
+
composeClientInvocation,
|
|
221
|
+
composeLaunchArgv,
|
|
222
|
+
waitAlive,
|
|
223
|
+
waitStablePane,
|
|
224
|
+
injectPrompt,
|
|
225
|
+
redactSecrets,
|
|
226
|
+
sanitizeEarlyDiagnostic,
|
|
227
|
+
};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Runtime del fleet built-in (estratto da builtin.js in modo behavior-preserving).
|
|
3
|
+
// Possiede lo stato condiviso (cache definizioni + sessioni tmux) ed espone:
|
|
4
|
+
// status / up / down / restart / isCellSession — contratto runtime
|
|
5
|
+
// reloadDefs / findCell / findEngine / refreshSessions / commitDefs
|
|
6
|
+
// — accessor allo store, riusati dal facade CRUD di builtin.js
|
|
7
|
+
//
|
|
8
|
+
// Tutto l'argv/env/readiness/redaction e' delegato al toolkit stateless
|
|
9
|
+
// launch.js. createBuiltinFleet() (builtin.js) istanzia questo runtime e
|
|
10
|
+
// costruisce sopra di esso il CRUD/schema/credentials.
|
|
11
|
+
//
|
|
12
|
+
// Il contratto pubblico, l'argv, l'env, i readiness check, la permission
|
|
13
|
+
// policy, la redazione e il testo degli errori sono INVARIATI rispetto a
|
|
14
|
+
// builtin.js prima dell'estrazione.
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const {
|
|
17
|
+
loadDefinitions, validateCommandTrust, resolveCwd,
|
|
18
|
+
} = require('./definitions.js');
|
|
19
|
+
const {
|
|
20
|
+
describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
|
|
21
|
+
} = require('./managed.js');
|
|
22
|
+
const {
|
|
23
|
+
httpError, minimalEnv, tmuxExec,
|
|
24
|
+
composeClientInvocation, composeLaunchArgv,
|
|
25
|
+
waitAlive, waitStablePane, injectPrompt,
|
|
26
|
+
redactSecrets, sanitizeEarlyDiagnostic,
|
|
27
|
+
} = require('./launch.js');
|
|
28
|
+
|
|
29
|
+
// TTL della cache status (ms): scaduto, status rilegge tmux + defs da disco.
|
|
30
|
+
const STATUS_TTL_MS = 2000;
|
|
31
|
+
|
|
32
|
+
function findCell(defs, id) { return defs.cells.find((c) => c.id === id) || null; }
|
|
33
|
+
function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// createBuiltinRuntime(ctx)
|
|
37
|
+
// ctx: { cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot }
|
|
38
|
+
// boot = definizioni iniziali (loadDefinitions, non null: il caller gia'
|
|
39
|
+
// e' tornato unavailable su garbage).
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
function createBuiltinRuntime(ctx) {
|
|
42
|
+
const { cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot } = ctx;
|
|
43
|
+
let cache = { at: 0, defs: boot, sessions: new Set() };
|
|
44
|
+
|
|
45
|
+
function reloadDefs() {
|
|
46
|
+
const d = loadDefinitions(defsPath);
|
|
47
|
+
if (d) cache = { ...cache, at: 0, defs: d };
|
|
48
|
+
return cache.defs; // mai null: boot non-null e reload mantiene l'ultimo valido
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Commit del defs mutato dal facade CRUD (builtin.js mutate()). invalida anche
|
|
52
|
+
// la TTL dello status, esattamente come faceva builtin.js prima dell'estrazione.
|
|
53
|
+
function commitDefs(defs) {
|
|
54
|
+
cache = { ...cache, at: 0, defs };
|
|
55
|
+
return defs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function refreshSessions() {
|
|
59
|
+
const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}\t#{session_windows}'], { env: minimalEnv() });
|
|
60
|
+
if (r.err) return new Set(); // nessun server / nessuna sessione
|
|
61
|
+
const set = new Set();
|
|
62
|
+
for (const line of r.stdout.split('\n')) {
|
|
63
|
+
const [rawName, rawWindows] = line.split('\t');
|
|
64
|
+
const n = String(rawName || '').trim();
|
|
65
|
+
const windows = rawWindows === undefined ? 1 : Number(rawWindows);
|
|
66
|
+
if (n && Number.isFinite(windows) && windows > 0) set.add(n);
|
|
67
|
+
}
|
|
68
|
+
return set;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function status() {
|
|
72
|
+
if (Date.now() - cache.at > STATUS_TTL_MS) {
|
|
73
|
+
reloadDefs(); // pick-up di edit esterne/file
|
|
74
|
+
const sessions = await refreshSessions();
|
|
75
|
+
cache = { at: Date.now(), defs: cache.defs, sessions };
|
|
76
|
+
}
|
|
77
|
+
const sessions = cache.sessions;
|
|
78
|
+
const cells = cache.defs.cells.map((c) => {
|
|
79
|
+
const alive = sessions.has(c.tmuxSession);
|
|
80
|
+
// Effective policy per cella: override remembered per-engine, altrimenti il
|
|
81
|
+
// default dell'engine gestito. Esposta SOLO come policy effettiva (mai segreti).
|
|
82
|
+
const engineDef = findEngine(cache.defs, c.engine);
|
|
83
|
+
const engineDefault = engineDef && engineDef.managed ? engineDef.managed.permissionPolicy : '';
|
|
84
|
+
const remembered = c.permissionPolicies
|
|
85
|
+
&& (c.permissionPolicies[c.engine] === 'standard' || c.permissionPolicies[c.engine] === 'unsafe')
|
|
86
|
+
? c.permissionPolicies[c.engine] : null;
|
|
87
|
+
const effectivePolicy = engineDef?.managed?.client === 'pi'
|
|
88
|
+
? 'standard'
|
|
89
|
+
: (remembered || engineDefault || '');
|
|
90
|
+
return {
|
|
91
|
+
cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
|
|
92
|
+
model: c.model || '', models: { ...(c.models || {}) },
|
|
93
|
+
permissionPolicy: effectivePolicy,
|
|
94
|
+
permissionPolicies: { ...(c.permissionPolicies || {}) },
|
|
95
|
+
active: alive, boot: c.boot, tmux: alive,
|
|
96
|
+
rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
|
|
100
|
+
const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
|
|
101
|
+
const needsPi = cache.defs.engines.some((e) => e.managed?.client === 'pi');
|
|
102
|
+
const piModels = needsPi ? await discoverPiModels({ ...cfg, home }) : {};
|
|
103
|
+
const engines = cache.defs.engines.map((e) => {
|
|
104
|
+
const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
|
|
105
|
+
return {
|
|
106
|
+
id: e.id, label: e.label, rc: !!e.rc,
|
|
107
|
+
...(managed ? {
|
|
108
|
+
kind: 'managed', client: managed.client, provider: managed.provider,
|
|
109
|
+
model: e.managed.model || managed.defaultModel || '',
|
|
110
|
+
models: managed.provider === 'ollama-cloud' ? ollamaModels
|
|
111
|
+
: (managed.client === 'pi'
|
|
112
|
+
? (e.managed.provider === 'custom'
|
|
113
|
+
? [e.managed.model]
|
|
114
|
+
: (e.managed.provider === 'native'
|
|
115
|
+
? [...new Set(Object.values(piModels).flat())]
|
|
116
|
+
: (piModels[e.managed.provider] || [])))
|
|
117
|
+
: managed.models),
|
|
118
|
+
configured: managed.configured, reason: managed.reason,
|
|
119
|
+
} : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
available: true,
|
|
124
|
+
provider: 'builtin',
|
|
125
|
+
bootOwner: 'builtin', // §9b: la UI non puo' mentire su chi possiede il boot
|
|
126
|
+
reason: cfg.fleetProviderReason || 'fleet.json definitions',
|
|
127
|
+
cells, engines,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isCellSession(name) {
|
|
132
|
+
return cache.defs.cells.some((c) => c.tmuxSession === String(name));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// up — ordine obbligatorio (design §9a, task B4.2).
|
|
136
|
+
// Le override {engine,boot} del contratto route sono ignorate: il builtin e'
|
|
137
|
+
// definitions-driven (l'engine della cella e' quello dichiarato; boot e' uno
|
|
138
|
+
// stato persistente gestito da boot()). Lancia SENZA shell.
|
|
139
|
+
async function up(cellId /* , { engine, boot } = {} */) {
|
|
140
|
+
if (readonly()) throw httpError(403, 'READONLY: up bloccato');
|
|
141
|
+
const defs = reloadDefs();
|
|
142
|
+
const cell = findCell(defs, cellId);
|
|
143
|
+
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
144
|
+
const engine = findEngine(defs, cell.engine);
|
|
145
|
+
if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
|
|
146
|
+
let launchEngine = engine;
|
|
147
|
+
if (engine.managed) {
|
|
148
|
+
const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
|
|
149
|
+
if (!resolved.ok) throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`);
|
|
150
|
+
launchEngine = resolved.engine;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// (2) trust del command PRIMA di lanciare
|
|
154
|
+
const trust = validateCommandTrust(launchEngine.command);
|
|
155
|
+
if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`);
|
|
156
|
+
// (3) cwd reale sotto la home
|
|
157
|
+
const realCwd = resolveCwd(cell.cwd, home);
|
|
158
|
+
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
|
|
159
|
+
|
|
160
|
+
// (4)+(5) argv diretto (no shell). Environment-bearing launches go
|
|
161
|
+
// through a private, one-shot Unix-socket broker so credentials never
|
|
162
|
+
// appear in `ps`, tmux argv, tmux global/session env or a temporary file.
|
|
163
|
+
// '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
|
|
164
|
+
// cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
|
|
165
|
+
// #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
|
|
166
|
+
let tmuxLaunchEngine = launchEngine;
|
|
167
|
+
if (Object.keys(launchEngine.env || {}).length) {
|
|
168
|
+
const child = composeClientInvocation(launchEngine, cell);
|
|
169
|
+
const ticket = await launchBroker.issue({
|
|
170
|
+
command: child.command,
|
|
171
|
+
args: child.args,
|
|
172
|
+
env: { ...minimalEnv(), ...launchEngine.env },
|
|
173
|
+
});
|
|
174
|
+
tmuxLaunchEngine = {
|
|
175
|
+
command: process.execPath,
|
|
176
|
+
args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
|
|
177
|
+
env: {}, promptMode: 'managed-argv',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: tmuxLaunchEngine, cell });
|
|
181
|
+
argv.splice(2, 0, '-P', '-F', '#{pane_id}');
|
|
182
|
+
// Mantieni il pane morto solo durante la finestra di readiness: permette di
|
|
183
|
+
// catturare un errore reale del client senza lasciare una sessione fantasma.
|
|
184
|
+
// Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
|
|
185
|
+
argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
|
|
186
|
+
const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
|
|
187
|
+
if (launch.err) {
|
|
188
|
+
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
189
|
+
const why = /duplicate session/i.test(launch.stderr)
|
|
190
|
+
? 'sessione già in esecuzione'
|
|
191
|
+
: `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
|
|
192
|
+
throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// `tmux new-session -d` can return 0 even when the launched CLI exits a
|
|
196
|
+
// moment later (missing login, bad model, incompatible provider). Without
|
|
197
|
+
// this readiness gate the PWA reported success and then showed nothing.
|
|
198
|
+
// Always verify liveness, including cells without a system prompt.
|
|
199
|
+
const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
|
|
200
|
+
const paneId = launch.stdout.trim().split('\n')[0] || '';
|
|
201
|
+
const readiness = paneId.startsWith('%')
|
|
202
|
+
? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
|
|
203
|
+
: { alive: await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs }), status: null, target: null };
|
|
204
|
+
if (!readiness.alive) {
|
|
205
|
+
let diagnostic = '';
|
|
206
|
+
if (readiness.target) {
|
|
207
|
+
const captured = await tmuxExec(tmuxBin,
|
|
208
|
+
['capture-pane', '-p', '-S', '-80', '-t', readiness.target], { env: minimalEnv(), timeoutMs: 2000 });
|
|
209
|
+
if (!captured.err) diagnostic = sanitizeEarlyDiagnostic(captured.stdout, launchEngine, cell, home);
|
|
210
|
+
}
|
|
211
|
+
// remain-on-exit era soltanto diagnostico: nessun pane morto deve restare
|
|
212
|
+
// nella Fleet o nella lista tmux dopo aver raccolto l'errore.
|
|
213
|
+
await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv(), timeoutMs: 2000 });
|
|
214
|
+
cache = { ...cache, at: 0 };
|
|
215
|
+
const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
|
|
216
|
+
const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
|
|
217
|
+
throw httpError(500, `client ${client} terminato subito${status}: ${diagnostic || 'verifica login, provider, modello e argomenti dell\'engine'}`);
|
|
218
|
+
}
|
|
219
|
+
if (readiness.target) {
|
|
220
|
+
await tmuxExec(tmuxBin,
|
|
221
|
+
['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
|
|
225
|
+
// (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
|
|
226
|
+
let prompt = null;
|
|
227
|
+
if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
|
|
228
|
+
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
229
|
+
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
230
|
+
env: minimalEnv(),
|
|
231
|
+
readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : readyMs,
|
|
232
|
+
target,
|
|
233
|
+
engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
|
|
237
|
+
return { ok: true, cell: cellId, session: cell.tmuxSession, prompt };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function down(cellId /* , opts */) {
|
|
241
|
+
if (readonly()) throw httpError(403, 'READONLY: down bloccato');
|
|
242
|
+
const defs = reloadDefs();
|
|
243
|
+
const cell = findCell(defs, cellId);
|
|
244
|
+
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
245
|
+
const engine = findEngine(defs, cell.engine) || {};
|
|
246
|
+
const r = await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv() });
|
|
247
|
+
if (r.err && !/no server running|can't find session|not found/i.test(r.stderr)) {
|
|
248
|
+
throw httpError(500, `tmux kill-session failed: ${redactSecrets(r.stderr.trim(), engine, cell)}`);
|
|
249
|
+
}
|
|
250
|
+
cache = { ...cache, at: 0 };
|
|
251
|
+
return { ok: true, killed: !r.err };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// restart = down (riusa la kill esistente; sessione non viva NON e' errore,
|
|
255
|
+
// come down) seguito da up (rilancia secondo la definizione corrente).
|
|
256
|
+
// Capability del SOLO built-in: i provider legacy non la espongono (501 in route).
|
|
257
|
+
async function restart(cellId) {
|
|
258
|
+
if (readonly()) throw httpError(403, 'READONLY: restart bloccato');
|
|
259
|
+
await down(cellId); // idempotente: cella non viva -> nessun errore
|
|
260
|
+
return up(cellId);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
status, up, down, restart, isCellSession,
|
|
265
|
+
reloadDefs, findCell, findEngine, refreshSessions, commitDefs,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = { createBuiltinRuntime, findCell, findEngine, STATUS_TTL_MS };
|
package/lib/mcp/cells.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Helper cella/deck/topologia per il server MCP (`lib/mcp/server.js`).
|
|
3
|
+
//
|
|
4
|
+
// Cohesione di tutto cio' che legge la directory delle celle Fleet e risolve
|
|
5
|
+
// owner/route/topologia: layout deck (ordine visuale), normalizzazione payload
|
|
6
|
+
// celle, parsing del target owner-qualified, costruzione della route federata
|
|
7
|
+
// e della directory aggregata locale+remota. Queste funzioni parlano SOLO via
|
|
8
|
+
// l'astrazione `ctx.api` (loopback + Bearer del bridge); ACL e identita'
|
|
9
|
+
// owner-qualified sono applicate lato server HTTP.
|
|
10
|
+
const { isValidSession } = require('../files/store.js');
|
|
11
|
+
|
|
12
|
+
const NODE_PART_RE = /^[a-z0-9-]{1,32}$/;
|
|
13
|
+
const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
14
|
+
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
15
|
+
|
|
16
|
+
// Deck layout is stored column-major, while the UI is read row-major. Preserve
|
|
17
|
+
// the visual order so an agent sees the same neighbourhood as the operator.
|
|
18
|
+
function orderedDeckMembers(deck) {
|
|
19
|
+
const columns = deck && deck.layout && Array.isArray(deck.layout.columns)
|
|
20
|
+
? deck.layout.columns : [];
|
|
21
|
+
const rows = Math.max(0, ...columns.map((column) => (
|
|
22
|
+
column && Array.isArray(column.tiles) ? column.tiles.length : 0
|
|
23
|
+
)));
|
|
24
|
+
const out = [];
|
|
25
|
+
for (let row = 0; row < rows; row += 1) {
|
|
26
|
+
for (const column of columns) {
|
|
27
|
+
const tile = column && Array.isArray(column.tiles) ? column.tiles[row] : null;
|
|
28
|
+
if (!tile || typeof tile.session !== 'string' || !tile.session) continue;
|
|
29
|
+
const member = { tmuxSession: tile.session };
|
|
30
|
+
if (typeof tile.node === 'string' && tile.node) member.node = tile.node;
|
|
31
|
+
if (typeof tile.ownerId === 'string' && NODE_ID_RE.test(tile.ownerId)) member.ownerId = tile.ownerId;
|
|
32
|
+
out.push(member);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fleetStatusPath(node) {
|
|
39
|
+
if (!node) return '/api/fleet/status';
|
|
40
|
+
const parts = String(node).split('/');
|
|
41
|
+
if (!parts.length || parts.some((part) => !NODE_PART_RE.test(part))
|
|
42
|
+
|| new Set(parts).size !== parts.length) return null;
|
|
43
|
+
return `/api/route/${parts.map(encodeURIComponent).join('/')}/_/fleet/status`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fleetCellsBySession(payload) {
|
|
47
|
+
const out = new Map();
|
|
48
|
+
if (!payload || payload.available !== true || !Array.isArray(payload.cells)) return out;
|
|
49
|
+
for (const cell of payload.cells) {
|
|
50
|
+
if (!cell || typeof cell.tmuxSession !== 'string' || !cell.tmuxSession
|
|
51
|
+
|| typeof cell.cell !== 'string' || !cell.cell) continue;
|
|
52
|
+
out.set(cell.tmuxSession, cell.cell);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function routePath(route, resource) {
|
|
58
|
+
if (!Array.isArray(route) || !route.length || route.length > 4
|
|
59
|
+
|| route.some((part) => !NODE_PART_RE.test(part)) || new Set(route).size !== route.length) return null;
|
|
60
|
+
return `/api/route/${route.map(encodeURIComponent).join('/')}/_/${resource}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function topologyOwners(payload) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const node of (payload && Array.isArray(payload.nodes) ? payload.nodes : [])) {
|
|
67
|
+
if (!node || !NODE_ID_RE.test(String(node.instanceId || '')) || seen.has(node.instanceId)
|
|
68
|
+
|| !Array.isArray(node.route) || !routePath(node.route, 'decks')) continue;
|
|
69
|
+
seen.add(node.instanceId);
|
|
70
|
+
out.push({
|
|
71
|
+
instanceId: node.instanceId,
|
|
72
|
+
route: [...node.route],
|
|
73
|
+
label: typeof node.label === 'string' && node.label ? node.label : (node.name || node.route.join(' › ')),
|
|
74
|
+
stale: node.stale === true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function memberOwnerId(member, deckOwner, ownerTopology) {
|
|
81
|
+
if (member.ownerId && NODE_ID_RE.test(member.ownerId)) return member.ownerId;
|
|
82
|
+
if (!member.node) return deckOwner.instanceId;
|
|
83
|
+
const found = ownerTopology.find((node) => Array.isArray(node.route) && node.route.join('/') === member.node);
|
|
84
|
+
return found ? found.instanceId : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseCellTarget(value) {
|
|
88
|
+
if (typeof value !== 'string') return null;
|
|
89
|
+
const split = value.indexOf(':');
|
|
90
|
+
if (split < 16) return null;
|
|
91
|
+
const instanceId = value.slice(0, split);
|
|
92
|
+
const cell = value.slice(split + 1);
|
|
93
|
+
return NODE_ID_RE.test(instanceId) && CELL_ID_RE.test(cell) ? { instanceId, cell } : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeCellPayload(payload, owner, callerSession = null) {
|
|
97
|
+
if (!payload || payload.instanceId !== owner.instanceId || !Array.isArray(payload.cells)) return [];
|
|
98
|
+
const route = owner.route.length ? owner.route.join('/') : 'local';
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const raw of payload.cells) {
|
|
102
|
+
if (!raw || raw.instanceId !== owner.instanceId || !CELL_ID_RE.test(String(raw.cell || ''))
|
|
103
|
+
|| typeof raw.tmuxSession !== 'string' || !isValidSession(raw.tmuxSession)
|
|
104
|
+
|| seen.has(raw.cell)) continue;
|
|
105
|
+
seen.add(raw.cell);
|
|
106
|
+
out.push({
|
|
107
|
+
id: `${owner.instanceId}:${raw.cell}`,
|
|
108
|
+
instanceId: owner.instanceId,
|
|
109
|
+
owner: owner.label,
|
|
110
|
+
route,
|
|
111
|
+
cell: raw.cell,
|
|
112
|
+
tmuxSession: raw.tmuxSession,
|
|
113
|
+
engine: typeof raw.engine === 'string' ? raw.engine : '',
|
|
114
|
+
model: typeof raw.model === 'string' ? raw.model : '',
|
|
115
|
+
active: raw.active === true,
|
|
116
|
+
canReceive: raw.canReceive === true,
|
|
117
|
+
lastSeen: Number.isFinite(raw.lastSeen) ? raw.lastSeen : null,
|
|
118
|
+
self: owner.route.length === 0 && callerSession === raw.tmuxSession,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function readCellDirectory(ctx, callerSession = null) {
|
|
125
|
+
const [config, topology] = await Promise.all([
|
|
126
|
+
ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'),
|
|
127
|
+
]);
|
|
128
|
+
const localId = String(config && config.instanceId || '');
|
|
129
|
+
if (!NODE_ID_RE.test(localId)) throw new Error('instanceId locale non disponibile');
|
|
130
|
+
const owners = [{ instanceId: localId, route: [], label: 'Local', stale: false },
|
|
131
|
+
...topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localId)];
|
|
132
|
+
const cells = [];
|
|
133
|
+
const unavailable = [];
|
|
134
|
+
await Promise.all(owners.map(async (owner) => {
|
|
135
|
+
const apiPath = owner.route.length ? routePath(owner.route, 'cells') : '/api/cells';
|
|
136
|
+
if (!apiPath) return;
|
|
137
|
+
try {
|
|
138
|
+
cells.push(...normalizeCellPayload(await ctx.api('GET', apiPath), owner, callerSession));
|
|
139
|
+
} catch (_) {
|
|
140
|
+
unavailable.push({ instanceId: owner.instanceId, owner: owner.label,
|
|
141
|
+
route: owner.route.length ? owner.route.join('/') : 'local' });
|
|
142
|
+
}
|
|
143
|
+
}));
|
|
144
|
+
cells.sort((a, b) => (a.route === 'local' ? -1 : b.route === 'local' ? 1
|
|
145
|
+
: a.route.localeCompare(b.route)) || a.cell.localeCompare(b.cell));
|
|
146
|
+
unavailable.sort((a, b) => a.route.localeCompare(b.route));
|
|
147
|
+
return { nodeId: localId, cells, unavailable };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
NODE_PART_RE, NODE_ID_RE, CELL_ID_RE,
|
|
152
|
+
orderedDeckMembers, fleetStatusPath, fleetCellsBySession, routePath,
|
|
153
|
+
topologyOwners, memberOwnerId, parseCellTarget, normalizeCellPayload, readCellDirectory,
|
|
154
|
+
};
|