@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.
Files changed (59) hide show
  1. package/README.md +173 -18
  2. package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
  3. package/frontend/dist/assets/index-DQrDBogT.js +83 -0
  4. package/frontend/dist/index.html +2 -2
  5. package/frontend/dist/sw.js +29 -0
  6. package/frontend/dist/version.json +1 -0
  7. package/lib/auth/middleware.js +7 -1
  8. package/lib/auth/token.js +31 -1
  9. package/lib/cli/commands.js +462 -31
  10. package/lib/cli/doctor.js +160 -0
  11. package/lib/cli/fleet-service.js +319 -0
  12. package/lib/cli/init.js +107 -7
  13. package/lib/cli/path.js +24 -0
  14. package/lib/cli/service.js +14 -3
  15. package/lib/cli/url.js +63 -0
  16. package/lib/config.js +5 -0
  17. package/lib/decks/routes.js +81 -0
  18. package/lib/decks/store.js +117 -0
  19. package/lib/files/routes.js +59 -2
  20. package/lib/files/store.js +16 -1
  21. package/lib/fleet/boot.js +62 -0
  22. package/lib/fleet/builtin.js +594 -0
  23. package/lib/fleet/definitions.js +410 -0
  24. package/lib/fleet/index.js +49 -10
  25. package/lib/fleet/managed.js +211 -0
  26. package/lib/fleet/provider.js +102 -0
  27. package/lib/fleet/routes.js +78 -8
  28. package/lib/fs/routes.js +56 -0
  29. package/lib/mcp/server.js +362 -0
  30. package/lib/nodes/commands.js +396 -0
  31. package/lib/nodes/store.js +358 -0
  32. package/lib/nodes/tunnel-supervisor.js +102 -0
  33. package/lib/nodes/tunnel.js +300 -0
  34. package/lib/notify/asks.js +150 -0
  35. package/lib/notify/events.js +59 -0
  36. package/lib/notify/notifier.js +37 -0
  37. package/lib/notify/persist.js +73 -0
  38. package/lib/notify/push.js +289 -0
  39. package/lib/notify/routes.js +260 -0
  40. package/lib/proxy/node-proxy.js +292 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +214 -15
  44. package/lib/settings/routes.js +473 -0
  45. package/lib/ws/bridge.js +10 -1
  46. package/package.json +8 -2
  47. package/skills/nexuscrew-agent/SKILL.md +83 -0
  48. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  49. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  50. package/frontend/dist/assets/index-BWhSqR3J.css +0 -32
  51. package/frontend/dist/assets/index-Bx8vdLZY.css +0 -32
  52. package/frontend/dist/assets/index-CLb2xmyu.js +0 -81
  53. package/frontend/dist/assets/index-CgxfkmRo.js +0 -81
  54. package/frontend/dist/assets/index-DE67kR0M.js +0 -81
  55. package/frontend/dist/assets/index-DQMx2p4x.js +0 -81
  56. package/frontend/dist/assets/index-DWhfVwKW.css +0 -32
  57. package/frontend/dist/assets/index-DoPZ_3-h.js +0 -81
  58. package/frontend/dist/assets/index-o9l7pPAf.css +0 -32
  59. 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
+ };