@mmmbuto/nexuscrew 0.7.5 → 0.7.7

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.
@@ -0,0 +1,477 @@
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
+
32
+ const STATUS_TTL_MS = 2000;
33
+
34
+ // Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
35
+ // non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
36
+ // qui NON passiamo MAI l'env del processo per intero. engine.env va in session via -e.
37
+ // Nota: se un server tmux e' gia' in esecuzione (avviato fuori dal service), i comandi
38
+ // ereditano l'env di quel server; la garanzia dura resta: le definizioni non possono
39
+ // iniettare loader-key, e engine.env arriva al pane SOLO tramite chiavi validate.
40
+ const MINIMAL_ENV_KEYS = [
41
+ 'PATH', 'HOME', 'SHELL', 'TERM', 'LANG', 'LANGUAGE',
42
+ 'LC_ALL', 'LC_CTYPE', 'USER', 'LOGNAME',
43
+ 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS',
44
+ ];
45
+ function minimalEnv() {
46
+ const env = {};
47
+ for (const k of MINIMAL_ENV_KEYS) {
48
+ if (process.env[k] !== undefined && process.env[k] !== '') env[k] = process.env[k];
49
+ }
50
+ if (!env.PATH) env.PATH = '/usr/local/bin:/usr/bin:/bin';
51
+ if (!env.HOME) env.HOME = os.homedir();
52
+ if (!env.TERM) env.TERM = 'xterm-256color';
53
+ return env;
54
+ }
55
+
56
+ function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
57
+
58
+ // Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
59
+ // NON devono mai ecoare i segreti delle definizioni.
60
+ const REDACTED = '‹redacted›';
61
+
62
+ // redactSecrets(text, engine, cell) -> string con ogni occorrenza dei segreti
63
+ // delle definizioni sostituita da '‹redacted›'. Segreti coperti (§9h):
64
+ // - valori di engine.env (le CHIAVI restano, i VALUES vengono redatti)
65
+ // - testo del prompt della cella (cell.prompt)
66
+ // - testo del prompt dell'engine (engine.prompt) se presente
67
+ // Applicato a OGNI messaggio d'errore che incorpora stderr/stdout dei comandi
68
+ // tmux falliti (up / down / injectPrompt): tmux puo' ecoare argv/env del comando
69
+ // lanciato nei suoi log di errore. Pura + senza dipendenze: testabile direttamente.
70
+ function redactSecrets(text, engine, cell) {
71
+ if (typeof text !== 'string' || text === '') return text;
72
+ const secrets = [];
73
+ if (engine && typeof engine === 'object' && engine.env) {
74
+ for (const v of Object.values(engine.env)) {
75
+ if (typeof v === 'string' && v) secrets.push(v);
76
+ }
77
+ }
78
+ if (engine && typeof engine.prompt === 'string' && engine.prompt) secrets.push(engine.prompt);
79
+ if (cell && typeof cell.prompt === 'string' && cell.prompt) secrets.push(cell.prompt);
80
+ // Ordina per lunghezza DECRESCENTE: i segreti piu' lunghi prima, cosi' un segreto
81
+ // che e' prefisso/sottostringa di un altro non ne maschera il rimpiazzo completo.
82
+ secrets.sort((a, b) => b.length - a.length);
83
+ let out = text;
84
+ for (const s of secrets) out = out.split(s).join(REDACTED); // replace globale, regex-free
85
+ return out;
86
+ }
87
+
88
+ // Esecutore tmux: argv diretto (MAI shell). Risolve sempre {err,stdout,stderr,code}
89
+ // cosi' il chiamante distingue "sessione assente" (code!==0 atteso) da errori reali.
90
+ function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
91
+ return new Promise((resolve) => {
92
+ execFile(tmuxBin, args, { env, timeout: timeoutMs }, (err, stdout, stderr) => {
93
+ const code = err && typeof err.code === 'number' ? err.code : (err ? 1 : 0);
94
+ resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || ''), code });
95
+ });
96
+ });
97
+ }
98
+
99
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
100
+
101
+ // Policy caratteri del prompt send-keys (§9e): ammette stampabili + \t \n \r;
102
+ // rifiuta ESC(0x1b) e gli altri byte di controllo (niente marker bracketed-paste
103
+ // iniettabili). parseDefinitions caps solo la lunghezza: questo e' defense-in-depth.
104
+ function promptCharsOk(prompt) {
105
+ if (typeof prompt !== 'string') return false;
106
+ for (let i = 0; i < prompt.length; i += 1) {
107
+ const c = prompt.charCodeAt(i);
108
+ if (c === 9 || c === 10 || c === 13) continue; // \t \n \r ammessi
109
+ if (c < 32 || c === 127) return false; // ESC/null/altri control
110
+ }
111
+ return true;
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // composeLaunchArgv({tmuxSession, realCwd, engine, cell}) -> argv per new-session
116
+ // PURA + testabile. No shell: command e args sono argv diretto. model/prompt sono
117
+ // aggiunti solo se c'e' un VALORE effettivo (override cella || default engine).
118
+ // ---------------------------------------------------------------------------
119
+ function composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
120
+ const args = ['new-session', '-d', '-s', tmuxSession, '-c', realCwd];
121
+ // engine.env -> env di sessione via -e (chiavi gia' validate, no loader-key)
122
+ for (const [k, v] of Object.entries(engine.env || {})) args.push('-e', `${k}=${v}`);
123
+ // command + args: argv diretto (tmux exec, NON sh -c)
124
+ args.push(engine.command, ...(engine.args || []));
125
+ // model: flag + (override cella || valore engine), solo se c'e' un valore
126
+ if (engine.model) {
127
+ const val = (cell.model != null && cell.model !== '') ? cell.model : engine.model.value;
128
+ if (val) args.push(engine.model.flag, val);
129
+ }
130
+ // prompt flag-mode: promptFlag + prompt cella, solo se c'e' un prompt effettivo.
131
+ // SICUREZZA (design §9h): promptMode 'flag' mette il prompt in ARGV -> e' visibile
132
+ // nella process list (ps) / argv della sessione, a differenza di 'send-keys' che lo
133
+ // inietta DOPO via bracketed paste. Va quindi vincolato a prompt NON-segreti.
134
+ if (engine.promptMode === 'flag' && cell.prompt) {
135
+ args.push(engine.promptFlag, cell.prompt);
136
+ }
137
+ return args;
138
+ }
139
+
140
+ // Poll has-session entro readyMs (no delay fisso cieco). Ritorna true se la sessione
141
+ // e' viva entro la deadline, false altrimenti (command uscito / mai partita).
142
+ async function waitAlive(tmuxBin, session, { env, readyMs }) {
143
+ const deadline = Date.now() + Math.max(0, readyMs | 0);
144
+ for (;;) {
145
+ const r = await tmuxExec(tmuxBin, ['has-session', '-t', `=${session}`], { env, timeoutMs: 2000 });
146
+ if (!r.err) return true;
147
+ if (Date.now() >= deadline) return false;
148
+ await sleep(60);
149
+ }
150
+ }
151
+
152
+ // Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
153
+ // load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
154
+ // poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
155
+ // (command gia' uscito) NON digita (design §9e). Ritorna {injected, reason}.
156
+ async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, target, engine, cell } = {}) {
157
+ if (!promptCharsOk(prompt)) {
158
+ return { injected: false, reason: 'prompt contiene byte di controllo (rifiutato)' };
159
+ }
160
+ let tmp = null;
161
+ try {
162
+ tmp = path.join(os.tmpdir(), `.ncsend.${session}.${process.pid}.txt`);
163
+ fs.writeFileSync(tmp, prompt, { mode: 0o600 });
164
+ fs.chmodSync(tmp, 0o600);
165
+
166
+ const alive = await waitAlive(tmuxBin, session, { env, readyMs });
167
+ if (!alive) return { injected: false, reason: 'sessione non viva (command uscito?): nessuna digitazione' };
168
+
169
+ // Target esatto: pane id (%N) se disponibile, altrimenti '=sessione' (match
170
+ // esatto, mai prefix-match) — audit impl #5.
171
+ const to = target || `=${session}`;
172
+ await tmuxExec(tmuxBin, ['load-buffer', '-b', 'ncsend', tmp], { env });
173
+ const paste = await tmuxExec(tmuxBin, ['paste-buffer', '-p', '-t', to, '-b', 'ncsend'], { env });
174
+ if (paste.err) return { injected: false, reason: redactSecrets(`paste-buffer failed: ${paste.stderr.trim()}`, engine, cell) };
175
+ return { injected: true, reason: 'bracketed paste (load-buffer + paste-buffer -p)' };
176
+ } finally {
177
+ try { if (tmp) fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
178
+ try { await tmuxExec(tmuxBin, ['delete-buffer', '-b', 'ncsend'], { env }); } catch (_) { /* best-effort */ }
179
+ }
180
+ }
181
+
182
+ // Copia deep delle definizioni per il draft di mutazione (round-trip sicuro su disco).
183
+ function draftFrom(defs) {
184
+ return {
185
+ schemaVersion: defs.schemaVersion,
186
+ engines: defs.engines.map((e) => ({ ...e })),
187
+ cells: defs.cells.map((c) => ({ ...c })),
188
+ };
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // createBuiltinFleet(cfg) — async per parita' di contratto con createFleet.
193
+ // cfg: { fleetDefsPath?, tmuxBin?, home?, builtinEnabled?, readonlyDefault?,
194
+ // sendKeysReadyMs?, fleetProviderReason? }
195
+ // ---------------------------------------------------------------------------
196
+ async function createBuiltinFleet(cfg = {}) {
197
+ const off = {
198
+ available: false, provider: 'builtin',
199
+ isCellSession: () => false, capabilities: () => [],
200
+ };
201
+ if (cfg.builtinEnabled === false) return off;
202
+
203
+ const home = cfg.home || os.homedir();
204
+ const defsPath = cfg.fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
205
+ const tmuxBin = cfg.tmuxBin || process.env.TMUX_BIN || 'tmux';
206
+ const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
207
+
208
+ // Bootstrap: fleet.json valido? garbage -> unavailable (fail-closed, design §7)
209
+ const boot = loadDefinitions(defsPath);
210
+ if (!boot) return off;
211
+ let cache = { at: 0, defs: boot, sessions: new Set() };
212
+
213
+ function reloadDefs() {
214
+ const d = loadDefinitions(defsPath);
215
+ if (d) cache = { ...cache, at: 0, defs: d };
216
+ return cache.defs; // mai null: boot non-null e reload mantiene l'ultimo valido
217
+ }
218
+ function findCell(defs, id) { return defs.cells.find((c) => c.id === id) || null; }
219
+ function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
220
+
221
+ async function refreshSessions() {
222
+ const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}'], { env: minimalEnv() });
223
+ if (r.err) return new Set(); // nessun server / nessuna sessione
224
+ const set = new Set();
225
+ for (const line of r.stdout.split('\n')) {
226
+ const n = line.trim();
227
+ if (n) set.add(n);
228
+ }
229
+ return set;
230
+ }
231
+
232
+ async function status() {
233
+ if (Date.now() - cache.at > STATUS_TTL_MS) {
234
+ reloadDefs(); // pick-up di edit esterne/file
235
+ const sessions = await refreshSessions();
236
+ cache = { at: Date.now(), defs: cache.defs, sessions };
237
+ }
238
+ const sessions = cache.sessions;
239
+ const cells = cache.defs.cells.map((c) => {
240
+ const alive = sessions.has(c.tmuxSession);
241
+ return {
242
+ cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
243
+ active: alive, boot: c.boot, tmux: alive,
244
+ rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
245
+ };
246
+ });
247
+ const engines = cache.defs.engines.map((e) => ({ id: e.id, label: e.label, rc: !!e.rc }));
248
+ return {
249
+ available: true,
250
+ provider: 'builtin',
251
+ bootOwner: 'builtin', // §9b: la UI non puo' mentire su chi possiede il boot
252
+ reason: cfg.fleetProviderReason || 'fleet.json definitions',
253
+ cells, engines,
254
+ };
255
+ }
256
+
257
+ function isCellSession(name) {
258
+ return cache.defs.cells.some((c) => c.tmuxSession === String(name));
259
+ }
260
+
261
+ // up — ordine obbligatorio (design §9a, task B4.2).
262
+ // Le override {engine,boot} del contratto route sono ignorate: il builtin e'
263
+ // definitions-driven (l'engine della cella e' quello dichiarato; boot e' uno
264
+ // stato persistente gestito da boot()). Lancia SENZA shell.
265
+ async function up(cellId /* , { engine, boot } = {} */) {
266
+ if (readonly()) throw httpError(403, 'READONLY: up bloccato');
267
+ const defs = reloadDefs();
268
+ const cell = findCell(defs, cellId);
269
+ if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
270
+ const engine = findEngine(defs, cell.engine);
271
+ if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
272
+
273
+ // (2) trust del command PRIMA di lanciare
274
+ const trust = validateCommandTrust(engine.command);
275
+ if (!trust.ok) throw httpError(400, `command non trusted (${engine.command}): ${trust.reason}`);
276
+ // (3) cwd reale sotto la home
277
+ const realCwd = resolveCwd(cell.cwd, home);
278
+ if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
279
+
280
+ // (4)+(5) argv diretto (no shell), env minimale + engine.env via -e.
281
+ // '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
282
+ // cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
283
+ // #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
284
+ const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine, cell });
285
+ argv.splice(2, 0, '-P', '-F', '#{pane_id}');
286
+ const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
287
+ if (launch.err) {
288
+ // Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
289
+ const why = /duplicate session/i.test(launch.stderr)
290
+ ? 'sessione già in esecuzione'
291
+ : `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, engine, cell)}`;
292
+ throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
293
+ }
294
+
295
+ // (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
296
+ // (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
297
+ let prompt = null;
298
+ if (engine.promptMode === 'send-keys' && cell.prompt) {
299
+ const paneId = launch.stdout.trim().split('\n')[0] || '';
300
+ const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
301
+ prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
302
+ env: minimalEnv(),
303
+ readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : 400,
304
+ target,
305
+ engine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
306
+ });
307
+ }
308
+ cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
309
+ return { ok: true, cell: cellId, session: cell.tmuxSession, prompt };
310
+ }
311
+
312
+ async function down(cellId /* , opts */) {
313
+ if (readonly()) throw httpError(403, 'READONLY: down bloccato');
314
+ const defs = reloadDefs();
315
+ const cell = findCell(defs, cellId);
316
+ if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
317
+ const engine = findEngine(defs, cell.engine) || {};
318
+ const r = await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv() });
319
+ if (r.err && !/no server running|can't find session|not found/i.test(r.stderr)) {
320
+ throw httpError(500, `tmux kill-session failed: ${redactSecrets(r.stderr.trim(), engine, cell)}`);
321
+ }
322
+ cache = { ...cache, at: 0 };
323
+ return { ok: true, killed: !r.err };
324
+ }
325
+
326
+ // restart = down (riusa la kill esistente; sessione non viva NON e' errore,
327
+ // come down) seguito da up (rilancia secondo la definizione corrente).
328
+ // Capability del SOLO built-in: i provider legacy non la espongono (501 in route).
329
+ async function restart(cellId) {
330
+ if (readonly()) throw httpError(403, 'READONLY: restart bloccato');
331
+ await down(cellId); // idempotente: cella non viva -> nessun errore
332
+ return up(cellId);
333
+ }
334
+
335
+ async function setEngine(cellId, engId) {
336
+ if (readonly()) throw httpError(403, 'READONLY: engine bloccato');
337
+ const defs = reloadDefs();
338
+ if (!findCell(defs, cellId)) throw httpError(400, `cella sconosciuta: ${cellId}`);
339
+ if (!findEngine(defs, engId)) throw httpError(400, `engine non valido: ${engId}`);
340
+ await mutate(defs, (d) => { findCell(d, cellId).engine = engId; });
341
+ return { ok: true };
342
+ }
343
+
344
+ async function setBoot(cellId, enabled) {
345
+ if (readonly()) throw httpError(403, 'READONLY: boot bloccato');
346
+ const defs = reloadDefs();
347
+ if (!findCell(defs, cellId)) throw httpError(400, `cella sconosciuta: ${cellId}`);
348
+ await mutate(defs, (d) => { findCell(d, cellId).boot = !!enabled; });
349
+ return { ok: true };
350
+ }
351
+
352
+ // --- define / edit / remove (engine e cell) ---
353
+ // atomicWrite valida PRIMA di scrivere (fail-closed); input invalido -> backup
354
+ // predecessore + throw -> httpError(400). MAI garbage su disco.
355
+ async function defineEngine(def) {
356
+ if (readonly()) throw httpError(403, 'READONLY: define-engine bloccato');
357
+ if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione engine mancante');
358
+ if (def.id != null && findEngine(reloadDefs(), def.id)) throw httpError(400, `engine esiste già: ${def.id}`);
359
+ await mutate(reloadDefs(), (d) => { d.engines.push(def); });
360
+ return { ok: true, id: def.id };
361
+ }
362
+ async function editEngine(id, patch) {
363
+ if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
364
+ if (!id) throw httpError(400, 'id engine mancante');
365
+ const defs = reloadDefs();
366
+ if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
367
+ await mutate(defs, (d) => { Object.assign(findEngine(d, id), patch || {}); });
368
+ return { ok: true };
369
+ }
370
+ async function removeEngine(id) {
371
+ if (readonly()) throw httpError(403, 'READONLY: remove-engine bloccato');
372
+ const defs = reloadDefs();
373
+ if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
374
+ const used = defs.cells.filter((c) => c.engine === id);
375
+ if (used.length) throw httpError(400, `engine in uso da ${used.length} cella/e: rimuovi prima la cella`);
376
+ await mutate(defs, (d) => { d.engines = d.engines.filter((e) => e.id !== id); });
377
+ return { ok: true };
378
+ }
379
+
380
+ async function defineCell(def) {
381
+ if (readonly()) throw httpError(403, 'READONLY: define-cell bloccato');
382
+ if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione cell mancante');
383
+ if (def.id != null && findCell(reloadDefs(), def.id)) throw httpError(400, `cell esiste già: ${def.id}`);
384
+ await mutate(reloadDefs(), (d) => { d.cells.push(def); });
385
+ return { ok: true, id: def.id };
386
+ }
387
+ async function editCell(id, patch) {
388
+ if (readonly()) throw httpError(403, 'READONLY: edit-cell bloccato');
389
+ if (!id) throw httpError(400, 'id cell mancante');
390
+ const defs = reloadDefs();
391
+ if (!findCell(defs, id)) throw httpError(400, `cell inesistente: ${id}`);
392
+ await mutate(defs, (d) => { Object.assign(findCell(d, id), patch || {}); });
393
+ return { ok: true };
394
+ }
395
+ async function removeCell(id) {
396
+ if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
397
+ const defs = reloadDefs();
398
+ if (!findCell(defs, id)) throw httpError(400, `cell inesistente: ${id}`);
399
+ await mutate(defs, (d) => { d.cells = d.cells.filter((c) => c.id !== id); });
400
+ return { ok: true };
401
+ }
402
+
403
+ // Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
404
+ // invalido: backup predecessore + throw -> httpError(400) (mai garbage).
405
+ async function mutate(defs, mutator) {
406
+ const draft = draftFrom(defs);
407
+ mutator(draft);
408
+ let parsed;
409
+ try {
410
+ parsed = atomicWrite(defsPath, draft);
411
+ } catch (e) {
412
+ throw httpError(400, `definizioni non valide: ${e.message}`);
413
+ }
414
+ cache = { ...cache, at: 0, defs: parsed };
415
+ return parsed;
416
+ }
417
+
418
+ // schema() — descrittore campi per editor UI schema-driven (design §5/§9f).
419
+ function schema() {
420
+ return {
421
+ schemaVersion: 1,
422
+ caps: CAPS,
423
+ engine: {
424
+ id: { type: 'string', required: true, pattern: '^[a-z0-9._-]{1,32}$', max: 32 },
425
+ label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN, default: '<id>' },
426
+ rc: { type: 'boolean', required: false, default: false },
427
+ command: { type: 'string', required: true, max: CAPS.MAX_COMMAND_LEN, absolute: true },
428
+ args: { type: 'array', of: 'string', required: false, max: CAPS.MAX_ARGS, itemMax: CAPS.MAX_ARG_LEN },
429
+ env: {
430
+ type: 'object', required: false, maxKeys: CAPS.MAX_ENV_KEYS,
431
+ keyPattern: '^[A-Za-z_][A-Za-z0-9_]*$', keyMax: CAPS.MAX_ENV_KEY_LEN,
432
+ valueMax: CAPS.MAX_ENV_VAL_LEN,
433
+ denylist: ['PATH', 'SHELL', 'HOME', 'NODE_OPTIONS', 'LD_*', 'DYLD_*', 'NPM_CONFIG_*'],
434
+ },
435
+ model: {
436
+ type: 'object', required: false,
437
+ fields: {
438
+ flag: { type: 'string', required: true, singleArgv: true, max: CAPS.MAX_MODEL_FLAG_LEN },
439
+ value: { type: 'string', required: false, default: '', max: CAPS.MAX_MODEL_VAL_LEN },
440
+ },
441
+ },
442
+ promptMode: { type: 'enum', required: true, values: ['flag', 'send-keys'] },
443
+ promptFlag: { type: 'string', requiredIf: 'promptMode=flag', singleArgv: true, max: CAPS.MAX_PROMPTFLAG_LEN },
444
+ },
445
+ cell: {
446
+ id: { type: 'string', required: true, pattern: '^[A-Za-z0-9._-]{1,32}$', max: 32 },
447
+ cwd: { type: 'string', required: true, max: CAPS.MAX_CWD_LEN, underHome: true },
448
+ engine: { type: 'string', required: true, ref: 'engine.id' },
449
+ boot: { type: 'boolean', required: false, default: false },
450
+ model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
451
+ prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
452
+ },
453
+ };
454
+ }
455
+
456
+ function capabilities() {
457
+ return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema'];
458
+ }
459
+
460
+ return {
461
+ available: true,
462
+ provider: 'builtin',
463
+ status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
464
+ defineEngine, editEngine, removeEngine,
465
+ defineCell, editCell, removeCell,
466
+ schema, capabilities,
467
+ };
468
+ }
469
+
470
+ module.exports = {
471
+ createBuiltinFleet,
472
+ composeLaunchArgv,
473
+ minimalEnv,
474
+ promptCharsOk,
475
+ redactSecrets,
476
+ MINIMAL_ENV_KEYS,
477
+ };