@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,410 @@
1
+ 'use strict';
2
+ // B4.1 — Definizioni fleet editabili (~/.nexuscrew/fleet.json).
3
+ // Modulo PURO: nessun side-effect all'import. Tutto l'I/O vive in
4
+ // loadDefinitions/atomicWrite; parseDefinitions/validateCommandTrust non
5
+ // toccano il filesystem se non per le stat di trust (sincrone, come binTrusted).
6
+ //
7
+ // Principio: fail-closed. Qualunque dato malformato -> null, MAI throw non
8
+ // gestito. Le definizioni contengono comandi arbitrari (design §6), quindi la
9
+ // validazione e' STRICT (garbage -> errore, non guess). Stesso confinamento di
10
+ // lib/fs/routes.js e lib/tmux/lifecycle.js.
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const crypto = require('node:crypto');
14
+ const { normalizeManagedSpec } = require('./managed.js');
15
+
16
+ // --- Cap + identita' (dichiarati; ragionevoli per un file di flotta locale) ---
17
+ const SCHEMA_VERSION = 1;
18
+ const MAX_ENGINES = 24;
19
+ const MAX_CELLS = 32;
20
+ const MAX_ARGS = 32; // argv: array, mai stringa spezzata (no shell)
21
+ const MAX_ARG_LEN = 1024; // 1 KB per arg
22
+ const MAX_ENV_KEYS = 32;
23
+ const MAX_ENV_KEY_LEN = 64;
24
+ const MAX_ENV_VAL_LEN = 4096; // 4 KB
25
+ const MAX_LABEL_LEN = 64;
26
+ const MAX_COMMAND_LEN = 512;
27
+ const MAX_CWD_LEN = 4096;
28
+ const MAX_MODEL_FLAG_LEN = 32;
29
+ const MAX_MODEL_VAL_LEN = 128;
30
+ const MAX_PROMPTFLAG_LEN = 32;
31
+ const MAX_PROMPT_LEN = 8192; // 8 KB
32
+ const MAX_TMUXSESSION_LEN = 64;
33
+
34
+ const ENGINE_ID_RE = /^[a-z0-9._-]{1,32}$/; // engine id: lowercase (design 4a/9f)
35
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/; // cell id: ammette maiuscole
36
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; // identificatore env POSIX-like
37
+ const TMUX_NAME_RE = /^[\w.-]{1,64}$/; // come lifecycle NAME_RE
38
+
39
+ // Denylist dura di chiavi loader/runtime (design 9a): chi le imposta altera
40
+ // l'esecuzione controllata dal service -> rifiuta l'INTERO documento.
41
+ const ENV_DENY_EXACT = new Set(['PATH', 'SHELL', 'HOME', 'NODE_OPTIONS']);
42
+ const ENV_DENY_PREFIX = ['NPM_CONFIG_', 'LD_', 'DYLD_'];
43
+
44
+ function envKeyDenied(k) {
45
+ if (ENV_DENY_EXACT.has(k)) return true;
46
+ for (const p of ENV_DENY_PREFIX) { if (k.startsWith(p)) return true; }
47
+ return false;
48
+ }
49
+
50
+ // Solo testo stampabile per le label UI (no control char 0x00-0x1f, no DEL).
51
+ function isPrintable(s) {
52
+ if (typeof s !== 'string') return false;
53
+ for (let i = 0; i < s.length; i += 1) {
54
+ const c = s.charCodeAt(i);
55
+ if (c <= 0x1f || c === 0x7f) return false;
56
+ }
57
+ return true;
58
+ }
59
+
60
+ // Singolo elemento argv: no whitespace, no control char (design 9f: niente
61
+ // spazi/shell). Vale per model.flag e promptFlag.
62
+ function isSingleArgv(s) {
63
+ if (typeof s !== 'string' || !s) return false;
64
+ for (let i = 0; i < s.length; i += 1) {
65
+ const c = s.charCodeAt(i);
66
+ if (c <= 0x20 || c === 0x7f) return false;
67
+ }
68
+ return true;
69
+ }
70
+
71
+ function validTmuxName(name) {
72
+ return typeof name === 'string'
73
+ && name.length <= MAX_TMUXSESSION_LEN
74
+ && TMUX_NAME_RE.test(name)
75
+ && !name.startsWith('-');
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // parseDefinitions(raw) -> {schemaVersion, engines, cells} | null
80
+ // Accetta stringa JSON o oggetto gia' parsato. Strict + fail-closed.
81
+ // ---------------------------------------------------------------------------
82
+ function parseDefinitions(raw) {
83
+ try {
84
+ let d;
85
+ if (typeof raw === 'string') {
86
+ try { d = JSON.parse(raw); } catch (_) { return null; }
87
+ } else if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
88
+ d = raw;
89
+ } else {
90
+ return null;
91
+ }
92
+
93
+ if (d.schemaVersion !== SCHEMA_VERSION) return null;
94
+ if (!Array.isArray(d.engines)) return null; // engines obbligatorio (array)
95
+ if (d.engines.length > MAX_ENGINES) return null;
96
+ if (!Array.isArray(d.cells)) return null; // cells obbligatorio (array)
97
+ if (d.cells.length > MAX_CELLS) return null;
98
+
99
+ const engineIds = new Set();
100
+ const engines = [];
101
+ for (const e of d.engines) {
102
+ const eng = parseEngine(e);
103
+ if (!eng) return null;
104
+ if (engineIds.has(eng.id)) return null; // id engine univoco
105
+ engineIds.add(eng.id);
106
+ engines.push(eng);
107
+ }
108
+
109
+ const tmuxSeen = new Set();
110
+ const cellIds = new Set();
111
+ const cells = [];
112
+ for (const c of d.cells) {
113
+ const cell = parseCell(c, engineIds);
114
+ if (!cell) return null;
115
+ if (cellIds.has(cell.id)) return null; // id cell univoco
116
+ cellIds.add(cell.id);
117
+ if (tmuxSeen.has(cell.tmuxSession)) return null; // tmuxSession univoco
118
+ tmuxSeen.add(cell.tmuxSession);
119
+ cells.push(cell);
120
+ }
121
+
122
+ return { schemaVersion: SCHEMA_VERSION, engines, cells };
123
+ } catch (_) {
124
+ return null; // fail-closed: qualunque eccezione inattesa -> null, MAI throw
125
+ }
126
+ }
127
+
128
+ function parseEngine(e) {
129
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return null;
130
+
131
+ // id
132
+ if (typeof e.id !== 'string' || !ENGINE_ID_RE.test(e.id)) return null;
133
+
134
+ // label (opzionale, default = id; solo stampabile)
135
+ let label = e.id;
136
+ if (e.label !== undefined) {
137
+ if (typeof e.label !== 'string' || !isPrintable(e.label) || e.label.length > MAX_LABEL_LEN) return null;
138
+ label = e.label;
139
+ }
140
+ if (!label) label = e.id; // etichetta vuota -> fallback id
141
+
142
+ // rc (opzionale, default false: remote-control e' l'eccezione)
143
+ let rc = false;
144
+ if (e.rc !== undefined) {
145
+ if (typeof e.rc !== 'boolean') return null;
146
+ rc = e.rc;
147
+ }
148
+
149
+ // Managed: NexusCrew conosce client/provider e compone internamente il
150
+ // processo. Nessun command/env/argv o segreto vive nella definizione.
151
+ if (e.managed !== undefined) {
152
+ const managed = normalizeManagedSpec(e.managed);
153
+ if (!managed) return null;
154
+ for (const key of ['command', 'args', 'env', 'promptMode', 'promptFlag', 'model']) {
155
+ if (e[key] !== undefined) return null;
156
+ }
157
+ return { id: e.id, label, rc, managed };
158
+ }
159
+
160
+ // command (obbligatorio, stringa non vuota; il trust si verifica a parte)
161
+ if (typeof e.command !== 'string' || !e.command || e.command.length > MAX_COMMAND_LEN) return null;
162
+
163
+ // args (opzionale, default [])
164
+ let args = [];
165
+ if (e.args !== undefined) {
166
+ if (!Array.isArray(e.args) || e.args.length > MAX_ARGS) return null;
167
+ args = [];
168
+ for (const a of e.args) {
169
+ if (typeof a !== 'string' || a.length > MAX_ARG_LEN) return null;
170
+ args.push(a);
171
+ }
172
+ }
173
+
174
+ // env (opzionale, default {}); chiavi identificadori + denylist dura
175
+ let env = {};
176
+ if (e.env !== undefined) {
177
+ if (!e.env || typeof e.env !== 'object' || Array.isArray(e.env)) return null;
178
+ const keys = Object.keys(e.env);
179
+ if (keys.length > MAX_ENV_KEYS) return null;
180
+ env = {};
181
+ for (const k of keys) {
182
+ if (k.length > MAX_ENV_KEY_LEN || !ENV_KEY_RE.test(k)) return null;
183
+ if (envKeyDenied(k)) return null; // loader/runtime key -> rifiuta tutto
184
+ const v = e.env[k];
185
+ if (typeof v !== 'string' || v.length > MAX_ENV_VAL_LEN) return null;
186
+ env[k] = v;
187
+ }
188
+ }
189
+
190
+ // promptMode (obbligatorio: l'engine dichiara come iniettare il prompt)
191
+ if (e.promptMode !== 'flag' && e.promptMode !== 'send-keys') return null;
192
+ const promptMode = e.promptMode;
193
+
194
+ // model (opzionale {flag, value}); flag = singolo argv senza spazi
195
+ let model;
196
+ if (e.model !== undefined) {
197
+ if (!e.model || typeof e.model !== 'object' || Array.isArray(e.model)) return null;
198
+ if (typeof e.model.flag !== 'string' || !isSingleArgv(e.model.flag) || e.model.flag.length > MAX_MODEL_FLAG_LEN) return null;
199
+ const value = e.model.value !== undefined ? e.model.value : '';
200
+ if (typeof value !== 'string' || value.length > MAX_MODEL_VAL_LEN) return null;
201
+ model = { flag: e.model.flag, value };
202
+ }
203
+
204
+ // promptFlag (richiesto solo se promptMode==='flag'; singolo argv)
205
+ let promptFlag;
206
+ if (promptMode === 'flag') {
207
+ if (typeof e.promptFlag !== 'string' || !isSingleArgv(e.promptFlag) || e.promptFlag.length > MAX_PROMPTFLAG_LEN) return null;
208
+ promptFlag = e.promptFlag;
209
+ }
210
+ // promptMode!=='flag' con promptFlag presente -> ignorato (campo non rilevante)
211
+
212
+ const out = { id: e.id, label, rc, command: e.command, args, env, promptMode };
213
+ if (model) out.model = model;
214
+ if (promptFlag !== undefined) out.promptFlag = promptFlag;
215
+ return out;
216
+ }
217
+
218
+ function parseCell(c, engineIds) {
219
+ if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
220
+
221
+ // id
222
+ if (typeof c.id !== 'string' || !CELL_ID_RE.test(c.id)) return null;
223
+
224
+ // cwd (obbligatorio; la risoluzione/confinamento avviene via resolveCwd a runtime)
225
+ if (typeof c.cwd !== 'string' || !c.cwd || c.cwd.length > MAX_CWD_LEN) return null;
226
+
227
+ // engine = riferimento a engines[].id esistente (dangling -> null)
228
+ if (typeof c.engine !== 'string' || !engineIds.has(c.engine)) return null;
229
+
230
+ // boot (opzionale, default false)
231
+ let boot = false;
232
+ if (c.boot !== undefined) {
233
+ if (typeof c.boot !== 'boolean') return null;
234
+ boot = c.boot;
235
+ }
236
+
237
+ // model override (opzionale, stringa = value per l'engine)
238
+ let model;
239
+ if (c.model !== undefined) {
240
+ if (typeof c.model !== 'string' || c.model.length > MAX_MODEL_VAL_LEN) return null;
241
+ model = c.model;
242
+ }
243
+
244
+ // Ultimo modello per engine, persistito per cella. Consente di tornare a un
245
+ // provider e ritrovare la scelta precedente senza trascinarla su altri engine.
246
+ let models = {};
247
+ if (c.models !== undefined) {
248
+ if (!c.models || typeof c.models !== 'object' || Array.isArray(c.models)) return null;
249
+ const entries = Object.entries(c.models);
250
+ if (entries.length > MAX_ENGINES) return null;
251
+ for (const [engineId, value] of entries) {
252
+ if (!engineIds.has(engineId) || typeof value !== 'string' || !value || value.length > MAX_MODEL_VAL_LEN) return null;
253
+ models[engineId] = value;
254
+ }
255
+ }
256
+
257
+ // prompt (opzionale, cap)
258
+ let prompt;
259
+ if (c.prompt !== undefined) {
260
+ if (typeof c.prompt !== 'string' || c.prompt.length > MAX_PROMPT_LEN) return null;
261
+ prompt = c.prompt;
262
+ }
263
+
264
+ // tmuxSession: campo esplicito o derivato da id. UNIVOCO (check in caller).
265
+ // Namespace cloud-* RISERVATO al fleet (coerente con lifecycle.js: le celle
266
+ // sono cloud-<Cell>): un override esplicito che usa cloud-* e' rifiutato,
267
+ // PERCHE' accettare un cloud-* diverso dal proprio aliaserebbe la sessione
268
+ // di un'altra cella fleet. Si ammette SOLO il derivato canonico cloud-<id>
269
+ // della cella stessa (forma normale, sopravvive al round-trip su disco).
270
+ const canonical = `cloud-${c.id}`;
271
+ let tmuxSession;
272
+ if (c.tmuxSession !== undefined) {
273
+ if (typeof c.tmuxSession !== 'string' || !validTmuxName(c.tmuxSession)) return null;
274
+ if (/^cloud-/i.test(c.tmuxSession) && c.tmuxSession !== canonical) return null; // alias cloud-* -> null
275
+ tmuxSession = c.tmuxSession;
276
+ } else {
277
+ tmuxSession = canonical;
278
+ }
279
+
280
+ const out = { id: c.id, cwd: c.cwd, engine: c.engine, boot, tmuxSession };
281
+ if (model !== undefined) out.model = model;
282
+ if (Object.keys(models).length) out.models = models;
283
+ if (prompt !== undefined) out.prompt = prompt;
284
+ return out;
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // validateCommandTrust(command) -> {ok, reason}
289
+ // Path assoluto, regular file, owner-executable, NON symlink (lstat), NON
290
+ // world-writable. Riutilizza il pattern di binTrusted in lib/fleet/index.js.
291
+ // ---------------------------------------------------------------------------
292
+ function validateCommandTrust(command) {
293
+ if (typeof command !== 'string' || !command) return { ok: false, reason: 'command vuoto' };
294
+ if (!path.isAbsolute(command)) return { ok: false, reason: 'command deve essere un path assoluto' };
295
+ let st;
296
+ try { st = fs.lstatSync(command); } catch (e) { return { ok: false, reason: `non accessibile (${e.code || e.message})` }; }
297
+ if (!st.isFile()) return { ok: false, reason: 'non e\' un file regolare (symlink o speciale)' }; // lstat: symlink -> isFile()=false
298
+ if (!(st.mode & 0o100)) return { ok: false, reason: 'non eseguibile dall\'owner' };
299
+ if (st.mode & 0o002) return { ok: false, reason: 'world-writable' };
300
+ // Owner check (design §9a, audit impl #4): il command deve appartenere
301
+ // all'utente del service o a root — un owner terzo potrebbe sostituire
302
+ // l'eseguibile mantenendo il path "trusted".
303
+ if (typeof process.getuid === 'function') {
304
+ const uid = process.getuid();
305
+ if (st.uid !== uid && st.uid !== 0) return { ok: false, reason: 'owner non fidato (ne\' utente del service ne\' root)' };
306
+ }
307
+ return { ok: true, reason: 'trusted' };
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // resolveCwd(cwd, home) -> path|null
312
+ // realpath SOTTO la home (default process.env.HOME); stesso confinamento di
313
+ // lib/tmux/lifecycle.js: realpath su entrambi (symlink dentro home che punta
314
+ // fuori -> rifiutato) e deve essere una directory.
315
+ // ---------------------------------------------------------------------------
316
+ function resolveCwd(cwd, home) {
317
+ try {
318
+ const h = home || process.env.HOME;
319
+ if (typeof cwd !== 'string' || !cwd || typeof h !== 'string' || !h) return null;
320
+ if (cwd.includes('\0') || h.includes('\0')) return null;
321
+ const real = fs.realpathSync(cwd);
322
+ const realHome = fs.realpathSync(h);
323
+ if (!fs.statSync(real).isDirectory()) return null;
324
+ if (real !== realHome && !real.startsWith(realHome + path.sep)) return null;
325
+ return real;
326
+ } catch (_) { return null; }
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // loadDefinitions(p) -> parsed | null
331
+ // Legge il file rifiutando i symlink; parse strict. Mai throw.
332
+ // ---------------------------------------------------------------------------
333
+ function loadDefinitions(p) {
334
+ try {
335
+ let st;
336
+ try { st = fs.lstatSync(p); } catch (_) { return null; } // missing -> null
337
+ if (st.isSymbolicLink()) return null; // no symlink
338
+ if (!st.isFile()) return null;
339
+ const raw = fs.readFileSync(p, 'utf8');
340
+ return parseDefinitions(raw);
341
+ } catch (_) { return null; }
342
+ }
343
+
344
+ // Backup best-effort del predecessore (su fallimento di validazione, o comunque
345
+ // prima di sovrascrivere). Sempre 0600.
346
+ function backupPredecessor(p) {
347
+ try {
348
+ if (!fs.lstatSync(p).isFile()) return;
349
+ const bak = `${p}.bak`;
350
+ fs.copyFileSync(p, bak);
351
+ fs.chmodSync(bak, 0o600);
352
+ } catch (_) { /* best-effort */ }
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // atomicWrite(p, data) -> parsed
357
+ // data: oggetto definizioni OPPURE stringa JSON. Valida PRIMA di scrivere
358
+ // (fail-closed: dati invalidi -> backup del predecessore + throw, mai scritti).
359
+ // Scrittura atomica: tmp nella stessa dir + rename; file mode 0600; rifiuto
360
+ // se il target esiste ed e' un symlink.
361
+ // ---------------------------------------------------------------------------
362
+ function atomicWrite(p, data) {
363
+ // Rifiuta symlink come target: mai scrivere attraverso un link.
364
+ try {
365
+ if (fs.lstatSync(p).isSymbolicLink()) {
366
+ throw new Error('refuse to write: il target e\' un symlink');
367
+ }
368
+ } catch (e) {
369
+ if (e.code === 'ENOENT') { /* nuovo file, ok */ }
370
+ else throw e; // inclusi i nostri 'refuse to write'
371
+ }
372
+
373
+ const parsed = parseDefinitions(data);
374
+ if (!parsed) {
375
+ backupPredecessor(p); // conserva il precedente per recovery/forensics
376
+ throw new Error('definizioni fleet non valide: validazione fallita');
377
+ }
378
+
379
+ const dir = path.dirname(p);
380
+ fs.mkdirSync(dir, { recursive: true });
381
+ const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
382
+ try {
383
+ fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
384
+ fs.chmodSync(tmp, 0o600); // forza 0600 a prescindere da umask/file preesistente
385
+ fs.renameSync(tmp, p); // atomico sullo stesso filesystem (stessa dir)
386
+ } catch (e) {
387
+ try { fs.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
388
+ throw e;
389
+ }
390
+ return parsed;
391
+ }
392
+
393
+ const CAPS = {
394
+ SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
395
+ MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_LABEL_LEN,
396
+ MAX_COMMAND_LEN, MAX_CWD_LEN, MAX_MODEL_FLAG_LEN, MAX_MODEL_VAL_LEN,
397
+ MAX_PROMPTFLAG_LEN, MAX_PROMPT_LEN, MAX_TMUXSESSION_LEN,
398
+ };
399
+
400
+ module.exports = {
401
+ parseDefinitions,
402
+ validateCommandTrust,
403
+ resolveCwd,
404
+ loadDefinitions,
405
+ atomicWrite,
406
+ CAPS,
407
+ // Costanti esposte anche piatte (comode per la UI/schema e i test)
408
+ SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
409
+ MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_PROMPT_LEN,
410
+ };
@@ -2,8 +2,33 @@
2
2
  const fs = require('node:fs');
3
3
  const { createFleetExec } = require('./exec.js');
4
4
 
5
- const ENGINES = new Set(['native', 'glm', 'glm-a', 'glm-p', 'ollama', 'ollama-cloud', 'codex-vl']);
6
5
  const STATUS_TTL_MS = 2000;
6
+ const ENGINE_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
7
+ const MAX_ENGINES = 24;
8
+
9
+ // Engines dichiarati dal contratto fleet (opzionale, additivo al v1):
10
+ // array di stringhe o {id, label?, rc?}. id per i comandi, label per la UI,
11
+ // rc=true se l'engine supporta il remote-control (default: solo id 'native',
12
+ // compat col vincolo storico). Malformato → null (fail-closed come le celle).
13
+ function parseEngines(raw) {
14
+ if (raw == null) return [];
15
+ if (!Array.isArray(raw) || raw.length > MAX_ENGINES) return null;
16
+ const out = []; const seen = new Set();
17
+ for (const e of raw) {
18
+ let id; let label; let rc;
19
+ if (typeof e === 'string') { id = e; } else if (e && typeof e === 'object' && typeof e.id === 'string') {
20
+ id = e.id;
21
+ if (e.label != null && typeof e.label !== 'string') return null;
22
+ label = e.label;
23
+ if (e.rc != null && typeof e.rc !== 'boolean') return null;
24
+ rc = e.rc;
25
+ } else return null;
26
+ if (!ENGINE_ID_RE.test(id) || seen.has(id)) return null;
27
+ seen.add(id);
28
+ out.push({ id, label: (label || id).slice(0, 48), rc: rc != null ? rc : id === 'native' });
29
+ }
30
+ return out;
31
+ }
7
32
 
8
33
  // Trust boundary sul binario (audit F3): regular file, NO symlink,
9
34
  // eseguibile dall'owner, NON world-writable.
@@ -38,7 +63,21 @@ function parseStatus(raw) {
38
63
  degraded: c.active !== c.tmux, // unit e tmux in disaccordo
39
64
  });
40
65
  }
41
- return cells;
66
+ const engines = parseEngines(d.engines);
67
+ if (engines === null) return null; // engines malformati → fail-closed
68
+ return { cells, engines };
69
+ }
70
+
71
+ // Lista engine effettiva: dichiarata dal fleet se presente, altrimenti
72
+ // derivata dagli engine in uso nelle celle (fallback conservativo, no hardcode).
73
+ function effectiveEngines(cache) {
74
+ if (cache.engines.length) return cache.engines;
75
+ const seen = new Set();
76
+ const out = [];
77
+ for (const c of cache.cells) {
78
+ if (!seen.has(c.engine)) { seen.add(c.engine); out.push({ id: c.engine, label: c.engine, rc: c.engine === 'native' }); }
79
+ }
80
+ return out;
42
81
  }
43
82
 
44
83
  function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
@@ -50,30 +89,30 @@ async function createFleet(cfg = {}) {
50
89
  if (!bin || !binTrusted(bin)) return off;
51
90
 
52
91
  const fx = createFleetExec(bin);
53
- let cells;
54
- try { cells = parseStatus(await fx.run(['status', '--json'])); } catch (_) { return off; }
55
- if (!cells) return off; // schema estraneo → feature spenta
92
+ let parsed;
93
+ try { parsed = parseStatus(await fx.run(['status', '--json'])); } catch (_) { return off; }
94
+ if (!parsed) return off; // schema estraneo → feature spenta
56
95
 
57
- let cache = { at: Date.now(), cells };
96
+ let cache = { at: Date.now(), cells: parsed.cells, engines: parsed.engines };
58
97
  const sessions = () => new Set(cache.cells.map((c) => c.tmuxSession));
59
98
 
60
99
  async function status() {
61
100
  if (Date.now() - cache.at > STATUS_TTL_MS) {
62
101
  const fresh = parseStatus(await fx.run(['status', '--json']));
63
- if (fresh) cache = { at: Date.now(), cells: fresh };
102
+ if (fresh) cache = { at: Date.now(), cells: fresh.cells, engines: fresh.engines };
64
103
  }
65
- return { available: true, cells: cache.cells };
104
+ return { available: true, cells: cache.cells, engines: effectiveEngines(cache) };
66
105
  }
67
106
 
68
107
  function assertCell(cell) {
69
108
  if (!cache.cells.some((c) => c.cell === cell)) throw httpError(400, `cella sconosciuta: ${cell}`);
70
109
  }
71
110
  function assertEngine(eng) {
72
- if (!ENGINES.has(eng)) throw httpError(400, `engine non valido: ${eng}`);
111
+ if (!effectiveEngines(cache).some((e) => e.id === eng)) throw httpError(400, `engine non valido: ${eng}`);
73
112
  }
74
113
  async function cmd(args) {
75
114
  try { await fx.run(args); } catch (e) { throw httpError(502, e.message); }
76
- cache = { at: 0, cells: cache.cells }; // invalida: il prossimo status rilegge
115
+ cache = { ...cache, at: 0 }; // invalida: il prossimo status rilegge
77
116
  return { ok: true };
78
117
  }
79
118