@mmmbuto/nexuscrew 0.7.6 → 0.8.0
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 +173 -18
- package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
- package/frontend/dist/assets/index-DQrDBogT.js +83 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +29 -0
- package/frontend/dist/version.json +1 -0
- package/lib/auth/middleware.js +7 -1
- package/lib/auth/token.js +31 -1
- package/lib/cli/commands.js +462 -31
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +319 -0
- package/lib/cli/init.js +107 -7
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -3
- package/lib/cli/url.js +63 -0
- package/lib/config.js +5 -0
- package/lib/decks/routes.js +81 -0
- package/lib/decks/store.js +117 -0
- package/lib/files/routes.js +59 -2
- package/lib/files/store.js +16 -1
- package/lib/fleet/boot.js +62 -0
- package/lib/fleet/builtin.js +594 -0
- package/lib/fleet/definitions.js +410 -0
- package/lib/fleet/index.js +49 -10
- package/lib/fleet/managed.js +211 -0
- package/lib/fleet/provider.js +102 -0
- package/lib/fleet/routes.js +78 -8
- package/lib/fs/routes.js +56 -0
- package/lib/mcp/server.js +362 -0
- package/lib/nodes/commands.js +396 -0
- package/lib/nodes/store.js +358 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +300 -0
- package/lib/notify/asks.js +150 -0
- package/lib/notify/events.js +59 -0
- package/lib/notify/notifier.js +37 -0
- package/lib/notify/persist.js +73 -0
- package/lib/notify/push.js +289 -0
- package/lib/notify/routes.js +260 -0
- package/lib/proxy/node-proxy.js +292 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +214 -15
- package/lib/settings/routes.js +473 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +8 -2
- package/skills/nexuscrew-agent/SKILL.md +83 -0
- package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
- package/skills/nexuscrew-agent/bin/nc-send +48 -0
- package/frontend/dist/assets/index-BWhSqR3J.css +0 -32
- package/frontend/dist/assets/index-Bx8vdLZY.css +0 -32
- package/frontend/dist/assets/index-CLb2xmyu.js +0 -81
- package/frontend/dist/assets/index-CgxfkmRo.js +0 -81
- package/frontend/dist/assets/index-DE67kR0M.js +0 -81
- package/frontend/dist/assets/index-DQMx2p4x.js +0 -81
- package/frontend/dist/assets/index-DWhfVwKW.css +0 -32
- package/frontend/dist/assets/index-DoPZ_3-h.js +0 -81
- package/frontend/dist/assets/index-o9l7pPAf.css +0 -32
- package/frontend/dist/assets/index-q9PUrGO0.js +0 -81
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// B4.2 — Fleet built-in. Implementa lo STESSO contratto di lib/fleet/index.js
|
|
3
|
+
// (createFleet: available / status / up / down / engine / boot / isCellSession)
|
|
4
|
+
// leggendo le definizioni da ~/.nexuscrew/fleet.json tramite lib/fleet/definitions.js,
|
|
5
|
+
// e lo ESTENDE con define*/edit*/remove* + schema + capabilities (design §4b/§9c).
|
|
6
|
+
//
|
|
7
|
+
// Agnostico: non conosce "claude"/"glm"/"codex" — lancia solo command+args dichiarati.
|
|
8
|
+
//
|
|
9
|
+
// Sicurezza (design §6 / §9a / §9d / §9e):
|
|
10
|
+
// - up esegue validateCommandTrust(command) e resolveCwd(cwd) PRIMA di lanciare;
|
|
11
|
+
// una cella/engine mancante o non trusted -> httpError(400), NON lancia nulla.
|
|
12
|
+
// - command/args/env NON passano per una shell: execFile + argv diretto (tmux fa
|
|
13
|
+
// exec del comando, NON sh -c — verificato: ';','|','$' passano verbatim).
|
|
14
|
+
// - env: il builtin lancia con un env MINIMALE controllato dal service (allowlist
|
|
15
|
+
// dura); engine.env — gia' ripulito dalle loader-key da parseDefinitions —
|
|
16
|
+
// raggiunge la sessione SOLO via `tmux -e` (chiavi validate). PATH lo controlla
|
|
17
|
+
// il service, mai la definizione.
|
|
18
|
+
// - READONLY (cfg.readonlyDefault===true | NEXUSCREW_READONLY=1) blocca ogni
|
|
19
|
+
// mutazione fleet e ogni up (§9d): passano solo status/schema/capabilities.
|
|
20
|
+
// - promptMode 'send-keys' inietta via `tmux load-buffer` + `paste-buffer -p`
|
|
21
|
+
// (bracketed paste), NON send-keys grezzo; se il command e' gia' uscito
|
|
22
|
+
// (sessione morta) NON digita (§9e).
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const os = require('node:os');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const { execFile } = require('node:child_process');
|
|
27
|
+
const {
|
|
28
|
+
parseDefinitions, validateCommandTrust, resolveCwd,
|
|
29
|
+
loadDefinitions, atomicWrite, CAPS,
|
|
30
|
+
} = require('./definitions.js');
|
|
31
|
+
const {
|
|
32
|
+
CATALOG: MANAGED_CATALOG, describeManaged, resolveManagedEngine, discoverOllamaModels,
|
|
33
|
+
} = require('./managed.js');
|
|
34
|
+
|
|
35
|
+
const STATUS_TTL_MS = 2000;
|
|
36
|
+
|
|
37
|
+
// Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
|
|
38
|
+
// non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
|
|
39
|
+
// qui NON passiamo MAI l'env del processo per intero. engine.env va in session via -e.
|
|
40
|
+
// Nota: se un server tmux e' gia' in esecuzione (avviato fuori dal service), i comandi
|
|
41
|
+
// ereditano l'env di quel server; la garanzia dura resta: le definizioni non possono
|
|
42
|
+
// iniettare loader-key, e engine.env arriva al pane SOLO tramite chiavi validate.
|
|
43
|
+
const MINIMAL_ENV_KEYS = [
|
|
44
|
+
'PATH', 'HOME', 'SHELL', 'TERM', 'LANG', 'LANGUAGE',
|
|
45
|
+
'LC_ALL', 'LC_CTYPE', 'USER', 'LOGNAME',
|
|
46
|
+
'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS',
|
|
47
|
+
];
|
|
48
|
+
function minimalEnv() {
|
|
49
|
+
const env = {};
|
|
50
|
+
for (const k of MINIMAL_ENV_KEYS) {
|
|
51
|
+
if (process.env[k] !== undefined && process.env[k] !== '') env[k] = process.env[k];
|
|
52
|
+
}
|
|
53
|
+
if (!env.PATH) env.PATH = '/usr/local/bin:/usr/bin:/bin';
|
|
54
|
+
if (!env.HOME) env.HOME = os.homedir();
|
|
55
|
+
if (!env.TERM) env.TERM = 'xterm-256color';
|
|
56
|
+
return env;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
|
|
60
|
+
|
|
61
|
+
// Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
|
|
62
|
+
// NON devono mai ecoare i segreti delle definizioni.
|
|
63
|
+
const REDACTED = '‹redacted›';
|
|
64
|
+
|
|
65
|
+
// redactSecrets(text, engine, cell) -> string con ogni occorrenza dei segreti
|
|
66
|
+
// delle definizioni sostituita da '‹redacted›'. Segreti coperti (§9h):
|
|
67
|
+
// - valori di engine.env (le CHIAVI restano, i VALUES vengono redatti)
|
|
68
|
+
// - testo del prompt della cella (cell.prompt)
|
|
69
|
+
// - testo del prompt dell'engine (engine.prompt) se presente
|
|
70
|
+
// Applicato a OGNI messaggio d'errore che incorpora stderr/stdout dei comandi
|
|
71
|
+
// tmux falliti (up / down / injectPrompt): tmux puo' ecoare argv/env del comando
|
|
72
|
+
// lanciato nei suoi log di errore. Pura + senza dipendenze: testabile direttamente.
|
|
73
|
+
function redactSecrets(text, engine, cell) {
|
|
74
|
+
if (typeof text !== 'string' || text === '') return text;
|
|
75
|
+
const secrets = [];
|
|
76
|
+
if (engine && typeof engine === 'object' && engine.env) {
|
|
77
|
+
for (const v of Object.values(engine.env)) {
|
|
78
|
+
if (typeof v === 'string' && v) secrets.push(v);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (engine && typeof engine.prompt === 'string' && engine.prompt) secrets.push(engine.prompt);
|
|
82
|
+
if (cell && typeof cell.prompt === 'string' && cell.prompt) secrets.push(cell.prompt);
|
|
83
|
+
// Ordina per lunghezza DECRESCENTE: i segreti piu' lunghi prima, cosi' un segreto
|
|
84
|
+
// che e' prefisso/sottostringa di un altro non ne maschera il rimpiazzo completo.
|
|
85
|
+
secrets.sort((a, b) => b.length - a.length);
|
|
86
|
+
let out = text;
|
|
87
|
+
for (const s of secrets) out = out.split(s).join(REDACTED); // replace globale, regex-free
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Esecutore tmux: argv diretto (MAI shell). Risolve sempre {err,stdout,stderr,code}
|
|
92
|
+
// cosi' il chiamante distingue "sessione assente" (code!==0 atteso) da errori reali.
|
|
93
|
+
function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
execFile(tmuxBin, args, { env, timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
96
|
+
const code = err && typeof err.code === 'number' ? err.code : (err ? 1 : 0);
|
|
97
|
+
resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || ''), code });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
103
|
+
|
|
104
|
+
// Policy caratteri del prompt send-keys (§9e): ammette stampabili + \t \n \r;
|
|
105
|
+
// rifiuta ESC(0x1b) e gli altri byte di controllo (niente marker bracketed-paste
|
|
106
|
+
// iniettabili). parseDefinitions caps solo la lunghezza: questo e' defense-in-depth.
|
|
107
|
+
function promptCharsOk(prompt) {
|
|
108
|
+
if (typeof prompt !== 'string') return false;
|
|
109
|
+
for (let i = 0; i < prompt.length; i += 1) {
|
|
110
|
+
const c = prompt.charCodeAt(i);
|
|
111
|
+
if (c === 9 || c === 10 || c === 13) continue; // \t \n \r ammessi
|
|
112
|
+
if (c < 32 || c === 127) return false; // ESC/null/altri control
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// composeLaunchArgv({tmuxSession, realCwd, engine, cell}) -> argv per new-session
|
|
119
|
+
// PURA + testabile. No shell: command e args sono argv diretto. model/prompt sono
|
|
120
|
+
// aggiunti solo se c'e' un VALORE effettivo (override cella || default engine).
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
function composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
|
|
123
|
+
const args = ['new-session', '-d', '-s', tmuxSession, '-c', realCwd];
|
|
124
|
+
// engine.env -> env di sessione via -e (chiavi gia' validate, no loader-key)
|
|
125
|
+
for (const [k, v] of Object.entries(engine.env || {})) args.push('-e', `${k}=${v}`);
|
|
126
|
+
// command + args: argv diretto (tmux exec, NON sh -c)
|
|
127
|
+
args.push(engine.command, ...(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 args;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Poll has-session entro readyMs (no delay fisso cieco). Ritorna true se la sessione
|
|
144
|
+
// e' viva entro la deadline, false altrimenti (command uscito / mai partita).
|
|
145
|
+
async function waitAlive(tmuxBin, session, { env, readyMs }) {
|
|
146
|
+
const deadline = Date.now() + Math.max(0, readyMs | 0);
|
|
147
|
+
for (;;) {
|
|
148
|
+
const r = await tmuxExec(tmuxBin, ['has-session', '-t', `=${session}`], { env, timeoutMs: 2000 });
|
|
149
|
+
if (!r.err) return true;
|
|
150
|
+
if (Date.now() >= deadline) return false;
|
|
151
|
+
await sleep(60);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
|
|
156
|
+
// load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
|
|
157
|
+
// poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
|
|
158
|
+
// (command gia' uscito) NON digita (design §9e). Ritorna {injected, reason}.
|
|
159
|
+
async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, target, engine, cell } = {}) {
|
|
160
|
+
if (!promptCharsOk(prompt)) {
|
|
161
|
+
return { injected: false, reason: 'prompt contiene byte di controllo (rifiutato)' };
|
|
162
|
+
}
|
|
163
|
+
let tmp = null;
|
|
164
|
+
try {
|
|
165
|
+
tmp = path.join(os.tmpdir(), `.ncsend.${session}.${process.pid}.txt`);
|
|
166
|
+
fs.writeFileSync(tmp, prompt, { mode: 0o600 });
|
|
167
|
+
fs.chmodSync(tmp, 0o600);
|
|
168
|
+
|
|
169
|
+
const alive = await waitAlive(tmuxBin, session, { env, readyMs });
|
|
170
|
+
if (!alive) return { injected: false, reason: 'sessione non viva (command uscito?): nessuna digitazione' };
|
|
171
|
+
|
|
172
|
+
// Target esatto: pane id (%N) se disponibile, altrimenti '=sessione' (match
|
|
173
|
+
// esatto, mai prefix-match) — audit impl #5.
|
|
174
|
+
const to = target || `=${session}`;
|
|
175
|
+
await tmuxExec(tmuxBin, ['load-buffer', '-b', 'ncsend', tmp], { env });
|
|
176
|
+
const paste = await tmuxExec(tmuxBin, ['paste-buffer', '-p', '-t', to, '-b', 'ncsend'], { env });
|
|
177
|
+
if (paste.err) return { injected: false, reason: redactSecrets(`paste-buffer failed: ${paste.stderr.trim()}`, engine, cell) };
|
|
178
|
+
return { injected: true, reason: 'bracketed paste (load-buffer + paste-buffer -p)' };
|
|
179
|
+
} finally {
|
|
180
|
+
try { if (tmp) fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
|
|
181
|
+
try { await tmuxExec(tmuxBin, ['delete-buffer', '-b', 'ncsend'], { env }); } catch (_) { /* best-effort */ }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Copia deep delle definizioni per il draft di mutazione (round-trip sicuro su disco).
|
|
186
|
+
function draftFrom(defs) {
|
|
187
|
+
return {
|
|
188
|
+
schemaVersion: defs.schemaVersion,
|
|
189
|
+
engines: defs.engines.map((e) => ({
|
|
190
|
+
...e,
|
|
191
|
+
...(e.managed ? { managed: { ...e.managed } } : {}),
|
|
192
|
+
...(e.args ? { args: [...e.args] } : {}),
|
|
193
|
+
...(e.env ? { env: { ...e.env } } : {}),
|
|
194
|
+
...(e.model ? { model: { ...e.model } } : {}),
|
|
195
|
+
})),
|
|
196
|
+
cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Applica engine + modello come un'unica transizione. Ogni engine ricorda il
|
|
201
|
+
// proprio ultimo modello; passando a un altro engine il modello vecchio non
|
|
202
|
+
// attraversa mai il confine.
|
|
203
|
+
function applyCellEngineModel(target, engineId, model, hasModel) {
|
|
204
|
+
const remembered = { ...(target.models || {}) };
|
|
205
|
+
target.engine = engineId;
|
|
206
|
+
if (hasModel) {
|
|
207
|
+
if (typeof model === 'string' && model) remembered[engineId] = model;
|
|
208
|
+
else delete remembered[engineId];
|
|
209
|
+
}
|
|
210
|
+
const selected = hasModel
|
|
211
|
+
? (typeof model === 'string' ? model : '')
|
|
212
|
+
: (remembered[engineId] || '');
|
|
213
|
+
if (Object.keys(remembered).length) target.models = remembered; else delete target.models;
|
|
214
|
+
if (selected) target.model = selected; else delete target.model;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// createBuiltinFleet(cfg) — async per parita' di contratto con createFleet.
|
|
219
|
+
// cfg: { fleetDefsPath?, tmuxBin?, home?, builtinEnabled?, readonlyDefault?,
|
|
220
|
+
// sendKeysReadyMs?, fleetProviderReason? }
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
async function createBuiltinFleet(cfg = {}) {
|
|
223
|
+
const off = {
|
|
224
|
+
available: false, provider: 'builtin',
|
|
225
|
+
isCellSession: () => false, capabilities: () => [],
|
|
226
|
+
};
|
|
227
|
+
if (cfg.builtinEnabled === false) return off;
|
|
228
|
+
|
|
229
|
+
const home = cfg.home || os.homedir();
|
|
230
|
+
const defsPath = cfg.fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
|
|
231
|
+
const tmuxBin = cfg.tmuxBin || process.env.TMUX_BIN || 'tmux';
|
|
232
|
+
const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
233
|
+
|
|
234
|
+
// Bootstrap: fleet.json valido? garbage -> unavailable (fail-closed, design §7)
|
|
235
|
+
const boot = loadDefinitions(defsPath);
|
|
236
|
+
if (!boot) return off;
|
|
237
|
+
let cache = { at: 0, defs: boot, sessions: new Set() };
|
|
238
|
+
|
|
239
|
+
function reloadDefs() {
|
|
240
|
+
const d = loadDefinitions(defsPath);
|
|
241
|
+
if (d) cache = { ...cache, at: 0, defs: d };
|
|
242
|
+
return cache.defs; // mai null: boot non-null e reload mantiene l'ultimo valido
|
|
243
|
+
}
|
|
244
|
+
function findCell(defs, id) { return defs.cells.find((c) => c.id === id) || null; }
|
|
245
|
+
function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
|
|
246
|
+
|
|
247
|
+
async function refreshSessions() {
|
|
248
|
+
const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}'], { env: minimalEnv() });
|
|
249
|
+
if (r.err) return new Set(); // nessun server / nessuna sessione
|
|
250
|
+
const set = new Set();
|
|
251
|
+
for (const line of r.stdout.split('\n')) {
|
|
252
|
+
const n = line.trim();
|
|
253
|
+
if (n) set.add(n);
|
|
254
|
+
}
|
|
255
|
+
return set;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function status() {
|
|
259
|
+
if (Date.now() - cache.at > STATUS_TTL_MS) {
|
|
260
|
+
reloadDefs(); // pick-up di edit esterne/file
|
|
261
|
+
const sessions = await refreshSessions();
|
|
262
|
+
cache = { at: Date.now(), defs: cache.defs, sessions };
|
|
263
|
+
}
|
|
264
|
+
const sessions = cache.sessions;
|
|
265
|
+
const cells = cache.defs.cells.map((c) => {
|
|
266
|
+
const alive = sessions.has(c.tmuxSession);
|
|
267
|
+
return {
|
|
268
|
+
cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
|
|
269
|
+
model: c.model || '', models: { ...(c.models || {}) },
|
|
270
|
+
active: alive, boot: c.boot, tmux: alive,
|
|
271
|
+
rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
|
|
275
|
+
const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
|
|
276
|
+
const engines = cache.defs.engines.map((e) => {
|
|
277
|
+
const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
|
|
278
|
+
return {
|
|
279
|
+
id: e.id, label: e.label, rc: !!e.rc,
|
|
280
|
+
...(managed ? {
|
|
281
|
+
kind: 'managed', client: managed.client, provider: managed.provider,
|
|
282
|
+
model: e.managed.model || managed.defaultModel || '',
|
|
283
|
+
models: managed.provider === 'ollama-cloud' ? ollamaModels : managed.models,
|
|
284
|
+
configured: managed.configured, reason: managed.reason,
|
|
285
|
+
} : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
return {
|
|
289
|
+
available: true,
|
|
290
|
+
provider: 'builtin',
|
|
291
|
+
bootOwner: 'builtin', // §9b: la UI non puo' mentire su chi possiede il boot
|
|
292
|
+
reason: cfg.fleetProviderReason || 'fleet.json definitions',
|
|
293
|
+
cells, engines,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isCellSession(name) {
|
|
298
|
+
return cache.defs.cells.some((c) => c.tmuxSession === String(name));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// up — ordine obbligatorio (design §9a, task B4.2).
|
|
302
|
+
// Le override {engine,boot} del contratto route sono ignorate: il builtin e'
|
|
303
|
+
// definitions-driven (l'engine della cella e' quello dichiarato; boot e' uno
|
|
304
|
+
// stato persistente gestito da boot()). Lancia SENZA shell.
|
|
305
|
+
async function up(cellId /* , { engine, boot } = {} */) {
|
|
306
|
+
if (readonly()) throw httpError(403, 'READONLY: up bloccato');
|
|
307
|
+
const defs = reloadDefs();
|
|
308
|
+
const cell = findCell(defs, cellId);
|
|
309
|
+
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
310
|
+
const engine = findEngine(defs, cell.engine);
|
|
311
|
+
if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
|
|
312
|
+
let launchEngine = engine;
|
|
313
|
+
if (engine.managed) {
|
|
314
|
+
const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
|
|
315
|
+
if (!resolved.ok) throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`);
|
|
316
|
+
launchEngine = resolved.engine;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// (2) trust del command PRIMA di lanciare
|
|
320
|
+
const trust = validateCommandTrust(launchEngine.command);
|
|
321
|
+
if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`);
|
|
322
|
+
// (3) cwd reale sotto la home
|
|
323
|
+
const realCwd = resolveCwd(cell.cwd, home);
|
|
324
|
+
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
|
|
325
|
+
|
|
326
|
+
// (4)+(5) argv diretto (no shell), env minimale + engine.env via -e.
|
|
327
|
+
// '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
|
|
328
|
+
// cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
|
|
329
|
+
// #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
|
|
330
|
+
const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: launchEngine, cell });
|
|
331
|
+
argv.splice(2, 0, '-P', '-F', '#{pane_id}');
|
|
332
|
+
const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
|
|
333
|
+
if (launch.err) {
|
|
334
|
+
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
335
|
+
const why = /duplicate session/i.test(launch.stderr)
|
|
336
|
+
? 'sessione già in esecuzione'
|
|
337
|
+
: `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
|
|
338
|
+
throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
|
|
342
|
+
// (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
|
|
343
|
+
let prompt = null;
|
|
344
|
+
if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
|
|
345
|
+
const paneId = launch.stdout.trim().split('\n')[0] || '';
|
|
346
|
+
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
347
|
+
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
348
|
+
env: minimalEnv(),
|
|
349
|
+
readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : 400,
|
|
350
|
+
target,
|
|
351
|
+
engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
|
|
355
|
+
return { ok: true, cell: cellId, session: cell.tmuxSession, prompt };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function down(cellId /* , opts */) {
|
|
359
|
+
if (readonly()) throw httpError(403, 'READONLY: down bloccato');
|
|
360
|
+
const defs = reloadDefs();
|
|
361
|
+
const cell = findCell(defs, cellId);
|
|
362
|
+
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
363
|
+
const engine = findEngine(defs, cell.engine) || {};
|
|
364
|
+
const r = await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv() });
|
|
365
|
+
if (r.err && !/no server running|can't find session|not found/i.test(r.stderr)) {
|
|
366
|
+
throw httpError(500, `tmux kill-session failed: ${redactSecrets(r.stderr.trim(), engine, cell)}`);
|
|
367
|
+
}
|
|
368
|
+
cache = { ...cache, at: 0 };
|
|
369
|
+
return { ok: true, killed: !r.err };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// restart = down (riusa la kill esistente; sessione non viva NON e' errore,
|
|
373
|
+
// come down) seguito da up (rilancia secondo la definizione corrente).
|
|
374
|
+
// Capability del SOLO built-in: i provider legacy non la espongono (501 in route).
|
|
375
|
+
async function restart(cellId) {
|
|
376
|
+
if (readonly()) throw httpError(403, 'READONLY: restart bloccato');
|
|
377
|
+
await down(cellId); // idempotente: cella non viva -> nessun errore
|
|
378
|
+
return up(cellId);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function setEngine(cellId, engId, opts = {}) {
|
|
382
|
+
if (readonly()) throw httpError(403, 'READONLY: engine bloccato');
|
|
383
|
+
const defs = reloadDefs();
|
|
384
|
+
if (!findCell(defs, cellId)) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
385
|
+
if (!findEngine(defs, engId)) throw httpError(400, `engine non valido: ${engId}`);
|
|
386
|
+
await mutate(defs, (d) => applyCellEngineModel(findCell(d, cellId), engId, opts.model, typeof opts.model === 'string' && opts.model !== ''));
|
|
387
|
+
const saved = reloadDefs(); const target = findCell(saved, cellId);
|
|
388
|
+
return { ok: true, engine: engId, model: target.model || '' };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function setBoot(cellId, enabled) {
|
|
392
|
+
if (readonly()) throw httpError(403, 'READONLY: boot bloccato');
|
|
393
|
+
const defs = reloadDefs();
|
|
394
|
+
if (!findCell(defs, cellId)) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
395
|
+
await mutate(defs, (d) => { findCell(d, cellId).boot = !!enabled; });
|
|
396
|
+
return { ok: true };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --- define / edit / remove (engine e cell) ---
|
|
400
|
+
// atomicWrite valida PRIMA di scrivere (fail-closed); input invalido -> backup
|
|
401
|
+
// predecessore + throw -> httpError(400). MAI garbage su disco.
|
|
402
|
+
async function defineEngine(def) {
|
|
403
|
+
if (readonly()) throw httpError(403, 'READONLY: define-engine bloccato');
|
|
404
|
+
if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione engine mancante');
|
|
405
|
+
if (def.id != null && findEngine(reloadDefs(), def.id)) throw httpError(400, `engine esiste già: ${def.id}`);
|
|
406
|
+
await mutate(reloadDefs(), (d) => { d.engines.push(def); });
|
|
407
|
+
return { ok: true, id: def.id };
|
|
408
|
+
}
|
|
409
|
+
async function editEngine(id, patch, envChanges) {
|
|
410
|
+
if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
|
|
411
|
+
if (!id) throw httpError(400, 'id engine mancante');
|
|
412
|
+
const defs = reloadDefs();
|
|
413
|
+
if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
|
|
414
|
+
if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'env'))) {
|
|
415
|
+
throw httpError(400, 'id ed env non sono modificabili tramite patch generica');
|
|
416
|
+
}
|
|
417
|
+
await mutate(defs, (d) => {
|
|
418
|
+
const target = findEngine(d, id);
|
|
419
|
+
for (const [key, value] of Object.entries(patch || {})) {
|
|
420
|
+
if (value === null) delete target[key]; else target[key] = value;
|
|
421
|
+
}
|
|
422
|
+
if (envChanges !== undefined) {
|
|
423
|
+
if (!envChanges || typeof envChanges !== 'object' || Array.isArray(envChanges)) throw httpError(400, 'envChanges non valido');
|
|
424
|
+
const next = { ...(target.env || {}) };
|
|
425
|
+
for (const key of Array.isArray(envChanges.remove) ? envChanges.remove : []) delete next[key];
|
|
426
|
+
if (envChanges.set && typeof envChanges.set === 'object' && !Array.isArray(envChanges.set)) {
|
|
427
|
+
for (const [key, value] of Object.entries(envChanges.set)) next[key] = value;
|
|
428
|
+
}
|
|
429
|
+
target.env = next;
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
const sessions = await refreshSessions();
|
|
433
|
+
return { ok: true, activeCells: defs.cells.filter((c) => c.engine === id && sessions.has(c.tmuxSession)).map((c) => c.id) };
|
|
434
|
+
}
|
|
435
|
+
async function removeEngine(id) {
|
|
436
|
+
if (readonly()) throw httpError(403, 'READONLY: remove-engine bloccato');
|
|
437
|
+
const defs = reloadDefs();
|
|
438
|
+
if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
|
|
439
|
+
const used = defs.cells.filter((c) => c.engine === id);
|
|
440
|
+
if (used.length) throw httpError(400, `engine in uso da ${used.length} cella/e: rimuovi prima la cella`);
|
|
441
|
+
await mutate(defs, (d) => {
|
|
442
|
+
d.engines = d.engines.filter((e) => e.id !== id);
|
|
443
|
+
for (const cell of d.cells) {
|
|
444
|
+
if (!cell.models || !Object.prototype.hasOwnProperty.call(cell.models, id)) continue;
|
|
445
|
+
delete cell.models[id];
|
|
446
|
+
if (!Object.keys(cell.models).length) delete cell.models;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
return { ok: true };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function defineCell(def) {
|
|
453
|
+
if (readonly()) throw httpError(403, 'READONLY: define-cell bloccato');
|
|
454
|
+
if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione cell mancante');
|
|
455
|
+
if (def.id != null && findCell(reloadDefs(), def.id)) throw httpError(400, `cell esiste già: ${def.id}`);
|
|
456
|
+
await mutate(reloadDefs(), (d) => { d.cells.push(def); });
|
|
457
|
+
return { ok: true, id: def.id };
|
|
458
|
+
}
|
|
459
|
+
async function editCell(id, patch) {
|
|
460
|
+
if (readonly()) throw httpError(403, 'READONLY: edit-cell bloccato');
|
|
461
|
+
if (!id) throw httpError(400, 'id cell mancante');
|
|
462
|
+
const defs = reloadDefs();
|
|
463
|
+
if (!findCell(defs, id)) throw httpError(400, `cell inesistente: ${id}`);
|
|
464
|
+
if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'tmuxSession'))) {
|
|
465
|
+
throw httpError(400, 'id e tmuxSession sono immutabili');
|
|
466
|
+
}
|
|
467
|
+
await mutate(defs, (d) => {
|
|
468
|
+
const target = findCell(d, id);
|
|
469
|
+
const hasEngine = typeof patch?.engine === 'string';
|
|
470
|
+
const hasModel = Object.prototype.hasOwnProperty.call(patch || {}, 'model');
|
|
471
|
+
for (const [key, value] of Object.entries(patch || {})) {
|
|
472
|
+
if (key === 'engine' || key === 'model') continue;
|
|
473
|
+
if (value === null) delete target[key]; else target[key] = value;
|
|
474
|
+
}
|
|
475
|
+
if (hasEngine || hasModel) applyCellEngineModel(target, hasEngine ? patch.engine : target.engine, patch?.model, hasModel);
|
|
476
|
+
});
|
|
477
|
+
const sessions = await refreshSessions();
|
|
478
|
+
return { ok: true, active: sessions.has(findCell(defs, id).tmuxSession) };
|
|
479
|
+
}
|
|
480
|
+
async function removeCell(id, opts = {}) {
|
|
481
|
+
if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
|
|
482
|
+
let defs = reloadDefs();
|
|
483
|
+
const cell = findCell(defs, id);
|
|
484
|
+
if (!cell) throw httpError(400, `cell inesistente: ${id}`);
|
|
485
|
+
const sessions = await refreshSessions();
|
|
486
|
+
if (sessions.has(cell.tmuxSession)) {
|
|
487
|
+
if (opts.stop !== true) throw httpError(409, 'cell attiva: conferma stop e rimozione');
|
|
488
|
+
await down(id);
|
|
489
|
+
defs = reloadDefs();
|
|
490
|
+
}
|
|
491
|
+
await mutate(defs, (d) => { d.cells = d.cells.filter((c) => c.id !== id); });
|
|
492
|
+
return { ok: true };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Vista editabile ma secret-safe: gli env values restano write-only.
|
|
496
|
+
function definitions() {
|
|
497
|
+
const defs = reloadDefs();
|
|
498
|
+
return {
|
|
499
|
+
schemaVersion: defs.schemaVersion,
|
|
500
|
+
engines: defs.engines.map((e) => {
|
|
501
|
+
const out = { ...e, envKeys: Object.keys(e.env || {}).sort() };
|
|
502
|
+
delete out.env;
|
|
503
|
+
if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home });
|
|
504
|
+
return out;
|
|
505
|
+
}),
|
|
506
|
+
cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
|
|
507
|
+
managedCatalog: MANAGED_CATALOG.map((p) => ({ ...p })),
|
|
508
|
+
managedConfig: { providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env') },
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
|
|
513
|
+
// invalido: backup predecessore + throw -> httpError(400) (mai garbage).
|
|
514
|
+
async function mutate(defs, mutator) {
|
|
515
|
+
const draft = draftFrom(defs);
|
|
516
|
+
mutator(draft);
|
|
517
|
+
let parsed;
|
|
518
|
+
try {
|
|
519
|
+
parsed = atomicWrite(defsPath, draft);
|
|
520
|
+
} catch (e) {
|
|
521
|
+
throw httpError(400, `definizioni non valide: ${e.message}`);
|
|
522
|
+
}
|
|
523
|
+
cache = { ...cache, at: 0, defs: parsed };
|
|
524
|
+
return parsed;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// schema() — descrittore campi per editor UI schema-driven (design §5/§9f).
|
|
528
|
+
function schema() {
|
|
529
|
+
return {
|
|
530
|
+
schemaVersion: 1,
|
|
531
|
+
caps: CAPS,
|
|
532
|
+
engine: {
|
|
533
|
+
kind: { type: 'enum', values: ['managed', 'custom'], default: 'managed' },
|
|
534
|
+
id: { type: 'string', required: true, pattern: '^[a-z0-9._-]{1,32}$', max: 32 },
|
|
535
|
+
label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN, default: '<id>' },
|
|
536
|
+
rc: { type: 'boolean', required: false, default: false },
|
|
537
|
+
managed: {
|
|
538
|
+
type: 'object', requiredFor: 'managed',
|
|
539
|
+
client: { type: 'enum', values: ['claude', 'codex-vl'] },
|
|
540
|
+
provider: { type: 'enum', values: ['native', 'ollama-cloud', 'zai-a', 'zai-p'] },
|
|
541
|
+
model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
|
|
542
|
+
},
|
|
543
|
+
command: { type: 'string', requiredFor: 'custom', max: CAPS.MAX_COMMAND_LEN, absolute: true },
|
|
544
|
+
args: { type: 'array', of: 'string', required: false, max: CAPS.MAX_ARGS, itemMax: CAPS.MAX_ARG_LEN },
|
|
545
|
+
env: {
|
|
546
|
+
type: 'object', required: false, maxKeys: CAPS.MAX_ENV_KEYS,
|
|
547
|
+
keyPattern: '^[A-Za-z_][A-Za-z0-9_]*$', keyMax: CAPS.MAX_ENV_KEY_LEN,
|
|
548
|
+
valueMax: CAPS.MAX_ENV_VAL_LEN,
|
|
549
|
+
denylist: ['PATH', 'SHELL', 'HOME', 'NODE_OPTIONS', 'LD_*', 'DYLD_*', 'NPM_CONFIG_*'],
|
|
550
|
+
},
|
|
551
|
+
model: {
|
|
552
|
+
type: 'object', required: false,
|
|
553
|
+
fields: {
|
|
554
|
+
flag: { type: 'string', required: true, singleArgv: true, max: CAPS.MAX_MODEL_FLAG_LEN },
|
|
555
|
+
value: { type: 'string', required: false, default: '', max: CAPS.MAX_MODEL_VAL_LEN },
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
promptMode: { type: 'enum', required: true, values: ['flag', 'send-keys'] },
|
|
559
|
+
promptFlag: { type: 'string', requiredIf: 'promptMode=flag', singleArgv: true, max: CAPS.MAX_PROMPTFLAG_LEN },
|
|
560
|
+
},
|
|
561
|
+
cell: {
|
|
562
|
+
id: { type: 'string', required: true, pattern: '^[A-Za-z0-9._-]{1,32}$', max: 32 },
|
|
563
|
+
cwd: { type: 'string', required: true, max: CAPS.MAX_CWD_LEN, underHome: true },
|
|
564
|
+
engine: { type: 'string', required: true, ref: 'engine.id' },
|
|
565
|
+
boot: { type: 'boolean', required: false, default: false },
|
|
566
|
+
model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
|
|
567
|
+
models: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_MODEL_VAL_LEN },
|
|
568
|
+
prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function capabilities() {
|
|
574
|
+
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema', 'definitions'];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
available: true,
|
|
579
|
+
provider: 'builtin',
|
|
580
|
+
status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
|
|
581
|
+
defineEngine, editEngine, removeEngine,
|
|
582
|
+
defineCell, editCell, removeCell,
|
|
583
|
+
schema, definitions, capabilities,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
module.exports = {
|
|
588
|
+
createBuiltinFleet,
|
|
589
|
+
composeLaunchArgv,
|
|
590
|
+
minimalEnv,
|
|
591
|
+
promptCharsOk,
|
|
592
|
+
redactSecrets,
|
|
593
|
+
MINIMAL_ENV_KEYS,
|
|
594
|
+
};
|