@mmmbuto/nexuscrew 0.8.16 → 0.8.18
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 +285 -407
- package/frontend/dist/assets/index-BkAGUEmR.css +32 -0
- package/frontend/dist/assets/index-CyN8rOAl.js +91 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +11 -269
- 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/runtime/env.js +38 -1
- 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-B9RXe5E4.js +0 -91
- package/frontend/dist/assets/index-C-JOkuIc.css +0 -32
package/lib/fleet/builtin.js
CHANGED
|
@@ -1,229 +1,53 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// B4.2 — Fleet built-in.
|
|
3
|
-
// (createFleet: available / status / up / down / engine /
|
|
4
|
-
// leggendo le definizioni da ~/.nexuscrew/fleet.json
|
|
5
|
-
// e lo ESTENDE con define*/edit*/remove* +
|
|
2
|
+
// B4.2 — Fleet built-in. FACADE: implementa lo STESSO contratto di
|
|
3
|
+
// lib/fleet/index.js (createFleet: available / status / up / down / engine /
|
|
4
|
+
// boot / isCellSession) leggendo le definizioni da ~/.nexuscrew/fleet.json
|
|
5
|
+
// tramite lib/fleet/definitions.js, e lo ESTENDE con define*/edit*/remove* +
|
|
6
|
+
// schema + capabilities (design §4b/§9c).
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
+
// La responsabilita' runtime/launch (status/up/down/restart + launch/readiness
|
|
9
|
+
// helpers) e' stata estratta in modo behavior-preserving in:
|
|
10
|
+
// - lib/fleet/launch.js toolkit stateless (argv/env/readiness/redaction)
|
|
11
|
+
// - lib/fleet/runtime.js createBuiltinRuntime: cache + status/up/down/restart
|
|
12
|
+
// Qui resta il bootstrap del cfg, l'istanziazione del runtime e il CRUD
|
|
13
|
+
// (define/edit/remove, engine/boot, import, restore, schema, definitions,
|
|
14
|
+
// credentials) che collabora con gli accessor del runtime. Il facade re-esporta
|
|
15
|
+
// i simboli di launch.js per i test che li importano da builtin.js.
|
|
8
16
|
//
|
|
9
|
-
//
|
|
17
|
+
// Agnostico: non conosce "claude"/"glm"/"codex" — lancia solo command+args
|
|
18
|
+
// dichiarati.
|
|
19
|
+
//
|
|
20
|
+
// Sicurezza (design §6 / §9a / §9d / §9e) — invariata, vedi launch.js/runtime.js:
|
|
10
21
|
// - up esegue validateCommandTrust(command) e resolveCwd(cwd) PRIMA di lanciare;
|
|
11
22
|
// una cella/engine mancante o non trusted -> httpError(400), NON lancia nulla.
|
|
12
|
-
// - command/args/env NON passano per una shell: execFile + argv diretto
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// dura); engine.env — gia' ripulito dalle loader-key da parseDefinitions —
|
|
16
|
-
// raggiunge il client solo tramite un broker AF_UNIX privato e monouso. Nessun
|
|
17
|
-
// valore passa in argv, `tmux -e`, file temporanei o ambiente globale tmux.
|
|
18
|
-
// PATH lo controlla il service, mai la definizione.
|
|
23
|
+
// - command/args/env NON passano per una shell: execFile + argv diretto.
|
|
24
|
+
// - env: il builtin lancia con un env MINIMALE controllato dal service; engine.env
|
|
25
|
+
// raggiunge il client solo tramite un broker AF_UNIX privato e monouso.
|
|
19
26
|
// - READONLY (cfg.readonlyDefault===true | NEXUSCREW_READONLY=1) blocca ogni
|
|
20
27
|
// mutazione fleet e ogni up (§9d): passano solo status/schema/capabilities.
|
|
21
|
-
// - promptMode 'send-keys' inietta via
|
|
22
|
-
// (
|
|
23
|
-
// (sessione morta) NON digita (§9e).
|
|
24
|
-
const fs = require('node:fs');
|
|
28
|
+
// - promptMode 'send-keys' inietta via bracketed paste; se il command e' gia'
|
|
29
|
+
// uscito (sessione morta) NON digita (§9e).
|
|
25
30
|
const os = require('node:os');
|
|
26
31
|
const path = require('node:path');
|
|
27
|
-
const { execFile } = require('node:child_process');
|
|
28
32
|
const {
|
|
29
|
-
parseDefinitions, validateCommandTrust, resolveCwd,
|
|
30
33
|
loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
|
|
31
34
|
} = require('./definitions.js');
|
|
32
35
|
const {
|
|
33
|
-
publicCatalog, describeManaged,
|
|
36
|
+
publicCatalog, describeManaged,
|
|
34
37
|
} = require('./managed.js');
|
|
35
38
|
const { validEnvKey } = require('./env-key.js');
|
|
36
39
|
const { setCredential, removeCredential } = require('./credentials.js');
|
|
37
40
|
const { createLaunchBroker } = require('./launch-broker.js');
|
|
38
|
-
const { MINIMAL_ENV_KEYS
|
|
39
|
-
|
|
40
|
-
const STATUS_TTL_MS = 2000;
|
|
41
|
-
|
|
42
|
-
// Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
|
|
43
|
-
// non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
|
|
44
|
-
// qui NON passiamo MAI l'env del processo per intero. engine.env viene consegnato
|
|
45
|
-
// direttamente al processo figlio dal broker, senza entrare nello stato tmux.
|
|
46
|
-
// Nota: se un server tmux e' gia' in esecuzione (avviato fuori dal service), i comandi
|
|
47
|
-
// ereditano l'env di quel server; la garanzia dura resta: le definizioni non possono
|
|
48
|
-
// iniettare loader-key, e engine.env arriva al pane SOLO tramite chiavi validate.
|
|
49
|
-
function minimalEnv() {
|
|
50
|
-
return minimalRuntimeEnv(process.env, { home: os.homedir() });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
|
|
54
|
-
|
|
55
|
-
// Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
|
|
56
|
-
// NON devono mai ecoare i segreti delle definizioni.
|
|
57
|
-
const REDACTED = '‹redacted›';
|
|
58
|
-
|
|
59
|
-
// redactSecrets(text, engine, cell) -> string con ogni occorrenza dei segreti
|
|
60
|
-
// delle definizioni sostituita da '‹redacted›'. Segreti coperti (§9h):
|
|
61
|
-
// - valori di engine.env (le CHIAVI restano, i VALUES vengono redatti)
|
|
62
|
-
// - testo del prompt della cella (cell.prompt)
|
|
63
|
-
// - testo del prompt dell'engine (engine.prompt) se presente
|
|
64
|
-
// Applicato a OGNI messaggio d'errore che incorpora stderr/stdout dei comandi
|
|
65
|
-
// tmux falliti (up / down / injectPrompt): tmux puo' ecoare argv/env del comando
|
|
66
|
-
// lanciato nei suoi log di errore. Pura + senza dipendenze: testabile direttamente.
|
|
67
|
-
function redactSecrets(text, engine, cell) {
|
|
68
|
-
if (typeof text !== 'string' || text === '') return text;
|
|
69
|
-
const secrets = [];
|
|
70
|
-
if (engine && typeof engine === 'object' && engine.env) {
|
|
71
|
-
for (const v of Object.values(engine.env)) {
|
|
72
|
-
if (typeof v === 'string' && v) secrets.push(v);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (engine && typeof engine.prompt === 'string' && engine.prompt) secrets.push(engine.prompt);
|
|
76
|
-
if (cell && typeof cell.prompt === 'string' && cell.prompt) secrets.push(cell.prompt);
|
|
77
|
-
// Ordina per lunghezza DECRESCENTE: i segreti piu' lunghi prima, cosi' un segreto
|
|
78
|
-
// che e' prefisso/sottostringa di un altro non ne maschera il rimpiazzo completo.
|
|
79
|
-
secrets.sort((a, b) => b.length - a.length);
|
|
80
|
-
let out = text;
|
|
81
|
-
for (const s of secrets) out = out.split(s).join(REDACTED); // replace globale, regex-free
|
|
82
|
-
return out;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const MAX_EARLY_DIAGNOSTIC = 1200;
|
|
86
|
-
|
|
87
|
-
function sanitizeEarlyDiagnostic(text, engine, cell, home) {
|
|
88
|
-
let out = redactSecrets(String(text || ''), engine, cell);
|
|
89
|
-
// ANSI CSI/OSC e byte di controllo non devono arrivare nell'errore JSON/UI.
|
|
90
|
-
out = out.replace(/\x1b\][^\x07]*(?:\x07|$)/g, '')
|
|
91
|
-
.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '');
|
|
92
|
-
let clean = '';
|
|
93
|
-
for (let i = 0; i < out.length; i += 1) {
|
|
94
|
-
const code = out.charCodeAt(i);
|
|
95
|
-
if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)) clean += out[i];
|
|
96
|
-
}
|
|
97
|
-
out = clean;
|
|
98
|
-
if (typeof home === 'string' && home) out = out.split(home).join('~');
|
|
99
|
-
out = out
|
|
100
|
-
.replace(/\bBearer\s+\S+/gi, `Bearer ${REDACTED}`)
|
|
101
|
-
.replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|AUTH)[A-Z0-9_]*)(\s*[:=]\s*)\S+/g,
|
|
102
|
-
(_m, key, sep) => `${key}${sep}${REDACTED}`)
|
|
103
|
-
.replace(/\b(?:sk|fw|fpk|hf|zai)-[A-Za-z0-9._-]{8,}\b/gi, REDACTED);
|
|
104
|
-
const lines = out.split(/\r?\n/).map((line) => line.trimEnd())
|
|
105
|
-
.filter((line) => line.trim() && !/^Pane is dead \(status /i.test(line.trim()));
|
|
106
|
-
out = lines.join('\n').trim();
|
|
107
|
-
if (out.length > MAX_EARLY_DIAGNOSTIC) out = `…${out.slice(-(MAX_EARLY_DIAGNOSTIC - 1))}`;
|
|
108
|
-
return out;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Esecutore tmux: argv diretto (MAI shell). Risolve sempre {err,stdout,stderr,code}
|
|
112
|
-
// cosi' il chiamante distingue "sessione assente" (code!==0 atteso) da errori reali.
|
|
113
|
-
function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
|
|
114
|
-
return new Promise((resolve) => {
|
|
115
|
-
execFile(tmuxBin, args, { env, timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
116
|
-
const code = err && typeof err.code === 'number' ? err.code : (err ? 1 : 0);
|
|
117
|
-
resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || ''), code });
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
123
|
-
|
|
124
|
-
// Policy caratteri del prompt send-keys (§9e): ammette stampabili + \t \n \r;
|
|
125
|
-
// rifiuta ESC(0x1b) e gli altri byte di controllo (niente marker bracketed-paste
|
|
126
|
-
// iniettabili). parseDefinitions caps solo la lunghezza: questo e' defense-in-depth.
|
|
127
|
-
function promptCharsOk(prompt) {
|
|
128
|
-
if (typeof prompt !== 'string') return false;
|
|
129
|
-
for (let i = 0; i < prompt.length; i += 1) {
|
|
130
|
-
const c = prompt.charCodeAt(i);
|
|
131
|
-
if (c === 9 || c === 10 || c === 13) continue; // \t \n \r ammessi
|
|
132
|
-
if (c < 32 || c === 127) return false; // ESC/null/altri control
|
|
133
|
-
}
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// ---------------------------------------------------------------------------
|
|
138
|
-
// Build the direct child invocation separately from tmux. This lets the secure
|
|
139
|
-
// launch broker carry the complete child argv and environment in memory while
|
|
140
|
-
// tmux receives only the broker helper path and a one-time nonce.
|
|
141
|
-
// ---------------------------------------------------------------------------
|
|
142
|
-
function composeClientInvocation(engine, cell) {
|
|
143
|
-
const args = [...(engine.args || [])];
|
|
144
|
-
// model: flag + (override cella || valore engine), solo se c'e' un valore
|
|
145
|
-
if (engine.model) {
|
|
146
|
-
const val = (cell.model != null && cell.model !== '') ? cell.model : engine.model.value;
|
|
147
|
-
if (val) args.push(engine.model.flag, val);
|
|
148
|
-
}
|
|
149
|
-
// prompt flag-mode: promptFlag + prompt cella, solo se c'e' un prompt effettivo.
|
|
150
|
-
// SICUREZZA (design §9h): promptMode 'flag' mette il prompt in ARGV -> e' visibile
|
|
151
|
-
// nella process list (ps) / argv della sessione, a differenza di 'send-keys' che lo
|
|
152
|
-
// inietta DOPO via bracketed paste. Va quindi vincolato a prompt NON-segreti.
|
|
153
|
-
if (engine.promptMode === 'flag' && cell.prompt) {
|
|
154
|
-
args.push(engine.promptFlag, cell.prompt);
|
|
155
|
-
}
|
|
156
|
-
return { command: engine.command, args };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// composeLaunchArgv({tmuxSession, realCwd, engine, cell}) -> argv per new-session
|
|
160
|
-
// PURA + testabile. Provider values are deliberately absent: no `tmux -e`, no
|
|
161
|
-
// environment value and no broker payload ever appears in the tmux client argv.
|
|
162
|
-
function composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
|
|
163
|
-
const child = composeClientInvocation(engine, cell);
|
|
164
|
-
return ['new-session', '-d', '-s', tmuxSession, '-c', realCwd, child.command, ...child.args];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Poll has-session entro readyMs (no delay fisso cieco). Ritorna true se la sessione
|
|
168
|
-
// e' viva entro la deadline, false altrimenti (command uscito / mai partita).
|
|
169
|
-
async function waitAlive(tmuxBin, session, { env, readyMs }) {
|
|
170
|
-
const deadline = Date.now() + Math.max(0, readyMs | 0);
|
|
171
|
-
for (;;) {
|
|
172
|
-
const r = await tmuxExec(tmuxBin, ['has-session', '-t', `=${session}`], { env, timeoutMs: 2000 });
|
|
173
|
-
if (!r.err) return true;
|
|
174
|
-
if (Date.now() >= deadline) return false;
|
|
175
|
-
await sleep(60);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async function waitStablePane(tmuxBin, target, { env, readyMs }) {
|
|
180
|
-
const deadline = Date.now() + Math.max(0, readyMs | 0);
|
|
181
|
-
for (;;) {
|
|
182
|
-
const state = await tmuxExec(tmuxBin,
|
|
183
|
-
['display-message', '-p', '-t', target, '#{pane_dead}\t#{pane_dead_status}\t#{pane_id}'],
|
|
184
|
-
{ env, timeoutMs: 2000 });
|
|
185
|
-
if (state.err) return { alive: false, status: null, target: null };
|
|
186
|
-
const [dead, rawStatus, paneId] = state.stdout.trim().split('\t');
|
|
187
|
-
if (!/^%[0-9]+$/.test(paneId || '')) return { alive: false, status: null, target: null };
|
|
188
|
-
if (dead === '1') {
|
|
189
|
-
const status = /^-?[0-9]+$/.test(rawStatus || '') ? Number(rawStatus) : null;
|
|
190
|
-
return { alive: false, status, target: paneId };
|
|
191
|
-
}
|
|
192
|
-
if (dead !== '0') return { alive: false, status: null, target: null };
|
|
193
|
-
if (Date.now() >= deadline) return { alive: true, status: null, target: paneId };
|
|
194
|
-
await sleep(60);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
|
|
199
|
-
// load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
|
|
200
|
-
// poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
|
|
201
|
-
// (command gia' uscito) NON digita (design §9e). Ritorna {injected, reason}.
|
|
202
|
-
async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, target, engine, cell } = {}) {
|
|
203
|
-
if (!promptCharsOk(prompt)) {
|
|
204
|
-
return { injected: false, reason: 'prompt contiene byte di controllo (rifiutato)' };
|
|
205
|
-
}
|
|
206
|
-
let tmp = null;
|
|
207
|
-
try {
|
|
208
|
-
tmp = path.join(os.tmpdir(), `.ncsend.${session}.${process.pid}.txt`);
|
|
209
|
-
fs.writeFileSync(tmp, prompt, { mode: 0o600 });
|
|
210
|
-
fs.chmodSync(tmp, 0o600);
|
|
41
|
+
const { MINIMAL_ENV_KEYS } = require('../runtime/env.js');
|
|
211
42
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (paste.err) return { injected: false, reason: redactSecrets(`paste-buffer failed: ${paste.stderr.trim()}`, engine, cell) };
|
|
221
|
-
return { injected: true, reason: 'bracketed paste (load-buffer + paste-buffer -p)' };
|
|
222
|
-
} finally {
|
|
223
|
-
try { if (tmp) fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
|
|
224
|
-
try { await tmuxExec(tmuxBin, ['delete-buffer', '-b', 'ncsend'], { env }); } catch (_) { /* best-effort */ }
|
|
225
|
-
}
|
|
226
|
-
}
|
|
43
|
+
// Toolkit stateless + runtime estratti (behavior-preserving). I simboli di
|
|
44
|
+
// launch.js sono re-esportati in module.exports per i test che li importano
|
|
45
|
+
// da builtin.js; httpError e' usato anche dal CRUD del facade.
|
|
46
|
+
const { createBuiltinRuntime } = require('./runtime.js');
|
|
47
|
+
const {
|
|
48
|
+
composeLaunchArgv, composeClientInvocation, minimalEnv, promptCharsOk,
|
|
49
|
+
redactSecrets, sanitizeEarlyDiagnostic, waitStablePane, httpError,
|
|
50
|
+
} = require('./launch.js');
|
|
227
51
|
|
|
228
52
|
// Copia deep delle definizioni per il draft di mutazione (round-trip sicuro su disco).
|
|
229
53
|
function draftFrom(defs) {
|
|
@@ -272,7 +96,7 @@ function applyCellTransition(target, engineId, { model, hasModel, policy, hasPol
|
|
|
272
96
|
// ---------------------------------------------------------------------------
|
|
273
97
|
// createBuiltinFleet(cfg) — async per parita' di contratto con createFleet.
|
|
274
98
|
// cfg: { fleetDefsPath?, tmuxBin?, home?, builtinEnabled?, readonlyDefault?,
|
|
275
|
-
// sendKeysReadyMs?, fleetProviderReason? }
|
|
99
|
+
// sendKeysReadyMs?, fleetProviderReason?, launchBroker?, launchReadyMs? }
|
|
276
100
|
// ---------------------------------------------------------------------------
|
|
277
101
|
async function createBuiltinFleet(cfg = {}) {
|
|
278
102
|
const off = {
|
|
@@ -290,219 +114,32 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
290
114
|
// Bootstrap: fleet.json valido? garbage -> unavailable (fail-closed, design §7)
|
|
291
115
|
const boot = loadDefinitions(defsPath);
|
|
292
116
|
if (!boot) return off;
|
|
293
|
-
let cache = { at: 0, defs: boot, sessions: new Set() };
|
|
294
|
-
|
|
295
|
-
function reloadDefs() {
|
|
296
|
-
const d = loadDefinitions(defsPath);
|
|
297
|
-
if (d) cache = { ...cache, at: 0, defs: d };
|
|
298
|
-
return cache.defs; // mai null: boot non-null e reload mantiene l'ultimo valido
|
|
299
|
-
}
|
|
300
|
-
function findCell(defs, id) { return defs.cells.find((c) => c.id === id) || null; }
|
|
301
|
-
function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
|
|
302
|
-
|
|
303
|
-
async function refreshSessions() {
|
|
304
|
-
const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}\t#{session_windows}'], { env: minimalEnv() });
|
|
305
|
-
if (r.err) return new Set(); // nessun server / nessuna sessione
|
|
306
|
-
const set = new Set();
|
|
307
|
-
for (const line of r.stdout.split('\n')) {
|
|
308
|
-
const [rawName, rawWindows] = line.split('\t');
|
|
309
|
-
const n = String(rawName || '').trim();
|
|
310
|
-
const windows = rawWindows === undefined ? 1 : Number(rawWindows);
|
|
311
|
-
if (n && Number.isFinite(windows) && windows > 0) set.add(n);
|
|
312
|
-
}
|
|
313
|
-
return set;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
async function status() {
|
|
317
|
-
if (Date.now() - cache.at > STATUS_TTL_MS) {
|
|
318
|
-
reloadDefs(); // pick-up di edit esterne/file
|
|
319
|
-
const sessions = await refreshSessions();
|
|
320
|
-
cache = { at: Date.now(), defs: cache.defs, sessions };
|
|
321
|
-
}
|
|
322
|
-
const sessions = cache.sessions;
|
|
323
|
-
const cells = cache.defs.cells.map((c) => {
|
|
324
|
-
const alive = sessions.has(c.tmuxSession);
|
|
325
|
-
// Effective policy per cella: override remembered per-engine, altrimenti il
|
|
326
|
-
// default dell'engine gestito. Esposta SOLO come policy effettiva (mai segreti).
|
|
327
|
-
const engineDef = findEngine(cache.defs, c.engine);
|
|
328
|
-
const engineDefault = engineDef && engineDef.managed ? engineDef.managed.permissionPolicy : '';
|
|
329
|
-
const remembered = c.permissionPolicies
|
|
330
|
-
&& (c.permissionPolicies[c.engine] === 'standard' || c.permissionPolicies[c.engine] === 'unsafe')
|
|
331
|
-
? c.permissionPolicies[c.engine] : null;
|
|
332
|
-
const effectivePolicy = engineDef?.managed?.client === 'pi'
|
|
333
|
-
? 'standard'
|
|
334
|
-
: (remembered || engineDefault || '');
|
|
335
|
-
return {
|
|
336
|
-
cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
|
|
337
|
-
model: c.model || '', models: { ...(c.models || {}) },
|
|
338
|
-
permissionPolicy: effectivePolicy,
|
|
339
|
-
permissionPolicies: { ...(c.permissionPolicies || {}) },
|
|
340
|
-
active: alive, boot: c.boot, tmux: alive,
|
|
341
|
-
rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
|
|
342
|
-
};
|
|
343
|
-
});
|
|
344
|
-
const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
|
|
345
|
-
const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
|
|
346
|
-
const needsPi = cache.defs.engines.some((e) => e.managed?.client === 'pi');
|
|
347
|
-
const piModels = needsPi ? await discoverPiModels({ ...cfg, home }) : {};
|
|
348
|
-
const engines = cache.defs.engines.map((e) => {
|
|
349
|
-
const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
|
|
350
|
-
return {
|
|
351
|
-
id: e.id, label: e.label, rc: !!e.rc,
|
|
352
|
-
...(managed ? {
|
|
353
|
-
kind: 'managed', client: managed.client, provider: managed.provider,
|
|
354
|
-
model: e.managed.model || managed.defaultModel || '',
|
|
355
|
-
models: managed.provider === 'ollama-cloud' ? ollamaModels
|
|
356
|
-
: (managed.client === 'pi'
|
|
357
|
-
? (e.managed.provider === 'custom'
|
|
358
|
-
? [e.managed.model]
|
|
359
|
-
: (e.managed.provider === 'native'
|
|
360
|
-
? [...new Set(Object.values(piModels).flat())]
|
|
361
|
-
: (piModels[e.managed.provider] || [])))
|
|
362
|
-
: managed.models),
|
|
363
|
-
configured: managed.configured, reason: managed.reason,
|
|
364
|
-
} : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
|
|
365
|
-
};
|
|
366
|
-
});
|
|
367
|
-
return {
|
|
368
|
-
available: true,
|
|
369
|
-
provider: 'builtin',
|
|
370
|
-
bootOwner: 'builtin', // §9b: la UI non puo' mentire su chi possiede il boot
|
|
371
|
-
reason: cfg.fleetProviderReason || 'fleet.json definitions',
|
|
372
|
-
cells, engines,
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function isCellSession(name) {
|
|
377
|
-
return cache.defs.cells.some((c) => c.tmuxSession === String(name));
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// up — ordine obbligatorio (design §9a, task B4.2).
|
|
381
|
-
// Le override {engine,boot} del contratto route sono ignorate: il builtin e'
|
|
382
|
-
// definitions-driven (l'engine della cella e' quello dichiarato; boot e' uno
|
|
383
|
-
// stato persistente gestito da boot()). Lancia SENZA shell.
|
|
384
|
-
async function up(cellId /* , { engine, boot } = {} */) {
|
|
385
|
-
if (readonly()) throw httpError(403, 'READONLY: up bloccato');
|
|
386
|
-
const defs = reloadDefs();
|
|
387
|
-
const cell = findCell(defs, cellId);
|
|
388
|
-
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
389
|
-
const engine = findEngine(defs, cell.engine);
|
|
390
|
-
if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
|
|
391
|
-
let launchEngine = engine;
|
|
392
|
-
if (engine.managed) {
|
|
393
|
-
const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
|
|
394
|
-
if (!resolved.ok) throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`);
|
|
395
|
-
launchEngine = resolved.engine;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// (2) trust del command PRIMA di lanciare
|
|
399
|
-
const trust = validateCommandTrust(launchEngine.command);
|
|
400
|
-
if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`);
|
|
401
|
-
// (3) cwd reale sotto la home
|
|
402
|
-
const realCwd = resolveCwd(cell.cwd, home);
|
|
403
|
-
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
|
|
404
|
-
|
|
405
|
-
// (4)+(5) argv diretto (no shell). Environment-bearing launches go
|
|
406
|
-
// through a private, one-shot Unix-socket broker so credentials never
|
|
407
|
-
// appear in `ps`, tmux argv, tmux global/session env or a temporary file.
|
|
408
|
-
// '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
|
|
409
|
-
// cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
|
|
410
|
-
// #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
|
|
411
|
-
let tmuxLaunchEngine = launchEngine;
|
|
412
|
-
if (Object.keys(launchEngine.env || {}).length) {
|
|
413
|
-
const child = composeClientInvocation(launchEngine, cell);
|
|
414
|
-
const ticket = await launchBroker.issue({
|
|
415
|
-
command: child.command,
|
|
416
|
-
args: child.args,
|
|
417
|
-
env: { ...minimalEnv(), ...launchEngine.env },
|
|
418
|
-
});
|
|
419
|
-
tmuxLaunchEngine = {
|
|
420
|
-
command: process.execPath,
|
|
421
|
-
args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
|
|
422
|
-
env: {}, promptMode: 'managed-argv',
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: tmuxLaunchEngine, cell });
|
|
426
|
-
argv.splice(2, 0, '-P', '-F', '#{pane_id}');
|
|
427
|
-
// Mantieni il pane morto solo durante la finestra di readiness: permette di
|
|
428
|
-
// catturare un errore reale del client senza lasciare una sessione fantasma.
|
|
429
|
-
// Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
|
|
430
|
-
argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
|
|
431
|
-
const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
|
|
432
|
-
if (launch.err) {
|
|
433
|
-
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
434
|
-
const why = /duplicate session/i.test(launch.stderr)
|
|
435
|
-
? 'sessione già in esecuzione'
|
|
436
|
-
: `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
|
|
437
|
-
throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// `tmux new-session -d` can return 0 even when the launched CLI exits a
|
|
441
|
-
// moment later (missing login, bad model, incompatible provider). Without
|
|
442
|
-
// this readiness gate the PWA reported success and then showed nothing.
|
|
443
|
-
// Always verify liveness, including cells without a system prompt.
|
|
444
|
-
const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
|
|
445
|
-
const paneId = launch.stdout.trim().split('\n')[0] || '';
|
|
446
|
-
const readiness = paneId.startsWith('%')
|
|
447
|
-
? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
|
|
448
|
-
: { alive: await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs }), status: null, target: null };
|
|
449
|
-
if (!readiness.alive) {
|
|
450
|
-
let diagnostic = '';
|
|
451
|
-
if (readiness.target) {
|
|
452
|
-
const captured = await tmuxExec(tmuxBin,
|
|
453
|
-
['capture-pane', '-p', '-S', '-80', '-t', readiness.target], { env: minimalEnv(), timeoutMs: 2000 });
|
|
454
|
-
if (!captured.err) diagnostic = sanitizeEarlyDiagnostic(captured.stdout, launchEngine, cell, home);
|
|
455
|
-
}
|
|
456
|
-
// remain-on-exit era soltanto diagnostico: nessun pane morto deve restare
|
|
457
|
-
// nella Fleet o nella lista tmux dopo aver raccolto l'errore.
|
|
458
|
-
await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv(), timeoutMs: 2000 });
|
|
459
|
-
cache = { ...cache, at: 0 };
|
|
460
|
-
const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
|
|
461
|
-
const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
|
|
462
|
-
throw httpError(500, `client ${client} terminato subito${status}: ${diagnostic || 'verifica login, provider, modello e argomenti dell\'engine'}`);
|
|
463
|
-
}
|
|
464
|
-
if (readiness.target) {
|
|
465
|
-
await tmuxExec(tmuxBin,
|
|
466
|
-
['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
|
|
467
|
-
}
|
|
468
117
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
481
|
-
cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
|
|
482
|
-
return { ok: true, cell: cellId, session: cell.tmuxSession, prompt };
|
|
483
|
-
}
|
|
118
|
+
// Runtime estratto (lib/fleet/runtime.js): possiede cache + definizioni e
|
|
119
|
+
// espone status/up/down/restart/isCellSession + gli accessor allo store
|
|
120
|
+
// (reloadDefs/findCell/findEngine/refreshSessions/commitDefs) che il CRUD
|
|
121
|
+
// qui sotto riusa. status/up/down/restart sono INVARIATI.
|
|
122
|
+
const rt = createBuiltinRuntime({
|
|
123
|
+
cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot,
|
|
124
|
+
});
|
|
125
|
+
const {
|
|
126
|
+
status, up, down, restart, isCellSession,
|
|
127
|
+
reloadDefs, findCell, findEngine, refreshSessions, commitDefs,
|
|
128
|
+
} = rt;
|
|
484
129
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
130
|
+
// Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
|
|
131
|
+
// invalido: backup predecessore + throw -> httpError(400) (mai garbage).
|
|
132
|
+
async function mutate(defs, mutator) {
|
|
133
|
+
const draft = draftFrom(defs);
|
|
134
|
+
mutator(draft);
|
|
135
|
+
let parsed;
|
|
136
|
+
try {
|
|
137
|
+
parsed = atomicWrite(defsPath, draft);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
throw httpError(400, `definizioni non valide: ${e.message}`);
|
|
494
140
|
}
|
|
495
|
-
|
|
496
|
-
return
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// restart = down (riusa la kill esistente; sessione non viva NON e' errore,
|
|
500
|
-
// come down) seguito da up (rilancia secondo la definizione corrente).
|
|
501
|
-
// Capability del SOLO built-in: i provider legacy non la espongono (501 in route).
|
|
502
|
-
async function restart(cellId) {
|
|
503
|
-
if (readonly()) throw httpError(403, 'READONLY: restart bloccato');
|
|
504
|
-
await down(cellId); // idempotente: cella non viva -> nessun errore
|
|
505
|
-
return up(cellId);
|
|
141
|
+
commitDefs(parsed);
|
|
142
|
+
return parsed;
|
|
506
143
|
}
|
|
507
144
|
|
|
508
145
|
async function setEngine(cellId, engId, opts = {}) {
|
|
@@ -897,21 +534,6 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
897
534
|
return credentialStatus();
|
|
898
535
|
}
|
|
899
536
|
|
|
900
|
-
// Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
|
|
901
|
-
// invalido: backup predecessore + throw -> httpError(400) (mai garbage).
|
|
902
|
-
async function mutate(defs, mutator) {
|
|
903
|
-
const draft = draftFrom(defs);
|
|
904
|
-
mutator(draft);
|
|
905
|
-
let parsed;
|
|
906
|
-
try {
|
|
907
|
-
parsed = atomicWrite(defsPath, draft);
|
|
908
|
-
} catch (e) {
|
|
909
|
-
throw httpError(400, `definizioni non valide: ${e.message}`);
|
|
910
|
-
}
|
|
911
|
-
cache = { ...cache, at: 0, defs: parsed };
|
|
912
|
-
return parsed;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
537
|
// schema() — descrittore campi per editor UI schema-driven (design §5/§9f).
|
|
916
538
|
function schema() {
|
|
917
539
|
return {
|