@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,384 @@
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
+
15
+ // --- Cap + identita' (dichiarati; ragionevoli per un file di flotta locale) ---
16
+ const SCHEMA_VERSION = 1;
17
+ const MAX_ENGINES = 24;
18
+ const MAX_CELLS = 32;
19
+ const MAX_ARGS = 32; // argv: array, mai stringa spezzata (no shell)
20
+ const MAX_ARG_LEN = 1024; // 1 KB per arg
21
+ const MAX_ENV_KEYS = 32;
22
+ const MAX_ENV_KEY_LEN = 64;
23
+ const MAX_ENV_VAL_LEN = 4096; // 4 KB
24
+ const MAX_LABEL_LEN = 64;
25
+ const MAX_COMMAND_LEN = 512;
26
+ const MAX_CWD_LEN = 4096;
27
+ const MAX_MODEL_FLAG_LEN = 32;
28
+ const MAX_MODEL_VAL_LEN = 128;
29
+ const MAX_PROMPTFLAG_LEN = 32;
30
+ const MAX_PROMPT_LEN = 8192; // 8 KB
31
+ const MAX_TMUXSESSION_LEN = 64;
32
+
33
+ const ENGINE_ID_RE = /^[a-z0-9._-]{1,32}$/; // engine id: lowercase (design 4a/9f)
34
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/; // cell id: ammette maiuscole
35
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; // identificatore env POSIX-like
36
+ const TMUX_NAME_RE = /^[\w.-]{1,64}$/; // come lifecycle NAME_RE
37
+
38
+ // Denylist dura di chiavi loader/runtime (design 9a): chi le imposta altera
39
+ // l'esecuzione controllata dal service -> rifiuta l'INTERO documento.
40
+ const ENV_DENY_EXACT = new Set(['PATH', 'SHELL', 'HOME', 'NODE_OPTIONS']);
41
+ const ENV_DENY_PREFIX = ['NPM_CONFIG_', 'LD_', 'DYLD_'];
42
+
43
+ function envKeyDenied(k) {
44
+ if (ENV_DENY_EXACT.has(k)) return true;
45
+ for (const p of ENV_DENY_PREFIX) { if (k.startsWith(p)) return true; }
46
+ return false;
47
+ }
48
+
49
+ // Solo testo stampabile per le label UI (no control char 0x00-0x1f, no DEL).
50
+ function isPrintable(s) {
51
+ if (typeof s !== 'string') return false;
52
+ for (let i = 0; i < s.length; i += 1) {
53
+ const c = s.charCodeAt(i);
54
+ if (c <= 0x1f || c === 0x7f) return false;
55
+ }
56
+ return true;
57
+ }
58
+
59
+ // Singolo elemento argv: no whitespace, no control char (design 9f: niente
60
+ // spazi/shell). Vale per model.flag e promptFlag.
61
+ function isSingleArgv(s) {
62
+ if (typeof s !== 'string' || !s) return false;
63
+ for (let i = 0; i < s.length; i += 1) {
64
+ const c = s.charCodeAt(i);
65
+ if (c <= 0x20 || c === 0x7f) return false;
66
+ }
67
+ return true;
68
+ }
69
+
70
+ function validTmuxName(name) {
71
+ return typeof name === 'string'
72
+ && name.length <= MAX_TMUXSESSION_LEN
73
+ && TMUX_NAME_RE.test(name)
74
+ && !name.startsWith('-');
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // parseDefinitions(raw) -> {schemaVersion, engines, cells} | null
79
+ // Accetta stringa JSON o oggetto gia' parsato. Strict + fail-closed.
80
+ // ---------------------------------------------------------------------------
81
+ function parseDefinitions(raw) {
82
+ try {
83
+ let d;
84
+ if (typeof raw === 'string') {
85
+ try { d = JSON.parse(raw); } catch (_) { return null; }
86
+ } else if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
87
+ d = raw;
88
+ } else {
89
+ return null;
90
+ }
91
+
92
+ if (d.schemaVersion !== SCHEMA_VERSION) return null;
93
+ if (!Array.isArray(d.engines)) return null; // engines obbligatorio (array)
94
+ if (d.engines.length > MAX_ENGINES) return null;
95
+ if (!Array.isArray(d.cells)) return null; // cells obbligatorio (array)
96
+ if (d.cells.length > MAX_CELLS) return null;
97
+
98
+ const engineIds = new Set();
99
+ const engines = [];
100
+ for (const e of d.engines) {
101
+ const eng = parseEngine(e);
102
+ if (!eng) return null;
103
+ if (engineIds.has(eng.id)) return null; // id engine univoco
104
+ engineIds.add(eng.id);
105
+ engines.push(eng);
106
+ }
107
+
108
+ const tmuxSeen = new Set();
109
+ const cellIds = new Set();
110
+ const cells = [];
111
+ for (const c of d.cells) {
112
+ const cell = parseCell(c, engineIds);
113
+ if (!cell) return null;
114
+ if (cellIds.has(cell.id)) return null; // id cell univoco
115
+ cellIds.add(cell.id);
116
+ if (tmuxSeen.has(cell.tmuxSession)) return null; // tmuxSession univoco
117
+ tmuxSeen.add(cell.tmuxSession);
118
+ cells.push(cell);
119
+ }
120
+
121
+ return { schemaVersion: SCHEMA_VERSION, engines, cells };
122
+ } catch (_) {
123
+ return null; // fail-closed: qualunque eccezione inattesa -> null, MAI throw
124
+ }
125
+ }
126
+
127
+ function parseEngine(e) {
128
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return null;
129
+
130
+ // id
131
+ if (typeof e.id !== 'string' || !ENGINE_ID_RE.test(e.id)) return null;
132
+
133
+ // label (opzionale, default = id; solo stampabile)
134
+ let label = e.id;
135
+ if (e.label !== undefined) {
136
+ if (typeof e.label !== 'string' || !isPrintable(e.label) || e.label.length > MAX_LABEL_LEN) return null;
137
+ label = e.label;
138
+ }
139
+ if (!label) label = e.id; // etichetta vuota -> fallback id
140
+
141
+ // rc (opzionale, default false: remote-control e' l'eccezione)
142
+ let rc = false;
143
+ if (e.rc !== undefined) {
144
+ if (typeof e.rc !== 'boolean') return null;
145
+ rc = e.rc;
146
+ }
147
+
148
+ // command (obbligatorio, stringa non vuota; il trust si verifica a parte)
149
+ if (typeof e.command !== 'string' || !e.command || e.command.length > MAX_COMMAND_LEN) return null;
150
+
151
+ // args (opzionale, default [])
152
+ let args = [];
153
+ if (e.args !== undefined) {
154
+ if (!Array.isArray(e.args) || e.args.length > MAX_ARGS) return null;
155
+ args = [];
156
+ for (const a of e.args) {
157
+ if (typeof a !== 'string' || a.length > MAX_ARG_LEN) return null;
158
+ args.push(a);
159
+ }
160
+ }
161
+
162
+ // env (opzionale, default {}); chiavi identificadori + denylist dura
163
+ let env = {};
164
+ if (e.env !== undefined) {
165
+ if (!e.env || typeof e.env !== 'object' || Array.isArray(e.env)) return null;
166
+ const keys = Object.keys(e.env);
167
+ if (keys.length > MAX_ENV_KEYS) return null;
168
+ env = {};
169
+ for (const k of keys) {
170
+ if (k.length > MAX_ENV_KEY_LEN || !ENV_KEY_RE.test(k)) return null;
171
+ if (envKeyDenied(k)) return null; // loader/runtime key -> rifiuta tutto
172
+ const v = e.env[k];
173
+ if (typeof v !== 'string' || v.length > MAX_ENV_VAL_LEN) return null;
174
+ env[k] = v;
175
+ }
176
+ }
177
+
178
+ // promptMode (obbligatorio: l'engine dichiara come iniettare il prompt)
179
+ if (e.promptMode !== 'flag' && e.promptMode !== 'send-keys') return null;
180
+ const promptMode = e.promptMode;
181
+
182
+ // model (opzionale {flag, value}); flag = singolo argv senza spazi
183
+ let model;
184
+ if (e.model !== undefined) {
185
+ if (!e.model || typeof e.model !== 'object' || Array.isArray(e.model)) return null;
186
+ if (typeof e.model.flag !== 'string' || !isSingleArgv(e.model.flag) || e.model.flag.length > MAX_MODEL_FLAG_LEN) return null;
187
+ const value = e.model.value !== undefined ? e.model.value : '';
188
+ if (typeof value !== 'string' || value.length > MAX_MODEL_VAL_LEN) return null;
189
+ model = { flag: e.model.flag, value };
190
+ }
191
+
192
+ // promptFlag (richiesto solo se promptMode==='flag'; singolo argv)
193
+ let promptFlag;
194
+ if (promptMode === 'flag') {
195
+ if (typeof e.promptFlag !== 'string' || !isSingleArgv(e.promptFlag) || e.promptFlag.length > MAX_PROMPTFLAG_LEN) return null;
196
+ promptFlag = e.promptFlag;
197
+ }
198
+ // promptMode!=='flag' con promptFlag presente -> ignorato (campo non rilevante)
199
+
200
+ const out = { id: e.id, label, rc, command: e.command, args, env, promptMode };
201
+ if (model) out.model = model;
202
+ if (promptFlag !== undefined) out.promptFlag = promptFlag;
203
+ return out;
204
+ }
205
+
206
+ function parseCell(c, engineIds) {
207
+ if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
208
+
209
+ // id
210
+ if (typeof c.id !== 'string' || !CELL_ID_RE.test(c.id)) return null;
211
+
212
+ // cwd (obbligatorio; la risoluzione/confinamento avviene via resolveCwd a runtime)
213
+ if (typeof c.cwd !== 'string' || !c.cwd || c.cwd.length > MAX_CWD_LEN) return null;
214
+
215
+ // engine = riferimento a engines[].id esistente (dangling -> null)
216
+ if (typeof c.engine !== 'string' || !engineIds.has(c.engine)) return null;
217
+
218
+ // boot (opzionale, default false)
219
+ let boot = false;
220
+ if (c.boot !== undefined) {
221
+ if (typeof c.boot !== 'boolean') return null;
222
+ boot = c.boot;
223
+ }
224
+
225
+ // model override (opzionale, stringa = value per l'engine)
226
+ let model;
227
+ if (c.model !== undefined) {
228
+ if (typeof c.model !== 'string' || c.model.length > MAX_MODEL_VAL_LEN) return null;
229
+ model = c.model;
230
+ }
231
+
232
+ // prompt (opzionale, cap)
233
+ let prompt;
234
+ if (c.prompt !== undefined) {
235
+ if (typeof c.prompt !== 'string' || c.prompt.length > MAX_PROMPT_LEN) return null;
236
+ prompt = c.prompt;
237
+ }
238
+
239
+ // tmuxSession: campo esplicito o derivato da id. UNIVOCO (check in caller).
240
+ // Namespace cloud-* RISERVATO al fleet (coerente con lifecycle.js: le celle
241
+ // sono cloud-<Cell>): un override esplicito che usa cloud-* e' rifiutato,
242
+ // PERCHE' accettare un cloud-* diverso dal proprio aliaserebbe la sessione
243
+ // di un'altra cella fleet. Si ammette SOLO il derivato canonico cloud-<id>
244
+ // della cella stessa (forma normale, sopravvive al round-trip su disco).
245
+ const canonical = `cloud-${c.id}`;
246
+ let tmuxSession;
247
+ if (c.tmuxSession !== undefined) {
248
+ if (typeof c.tmuxSession !== 'string' || !validTmuxName(c.tmuxSession)) return null;
249
+ if (/^cloud-/i.test(c.tmuxSession) && c.tmuxSession !== canonical) return null; // alias cloud-* -> null
250
+ tmuxSession = c.tmuxSession;
251
+ } else {
252
+ tmuxSession = canonical;
253
+ }
254
+
255
+ const out = { id: c.id, cwd: c.cwd, engine: c.engine, boot, tmuxSession };
256
+ if (model !== undefined) out.model = model;
257
+ if (prompt !== undefined) out.prompt = prompt;
258
+ return out;
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // validateCommandTrust(command) -> {ok, reason}
263
+ // Path assoluto, regular file, owner-executable, NON symlink (lstat), NON
264
+ // world-writable. Riutilizza il pattern di binTrusted in lib/fleet/index.js.
265
+ // ---------------------------------------------------------------------------
266
+ function validateCommandTrust(command) {
267
+ if (typeof command !== 'string' || !command) return { ok: false, reason: 'command vuoto' };
268
+ if (!path.isAbsolute(command)) return { ok: false, reason: 'command deve essere un path assoluto' };
269
+ let st;
270
+ try { st = fs.lstatSync(command); } catch (e) { return { ok: false, reason: `non accessibile (${e.code || e.message})` }; }
271
+ if (!st.isFile()) return { ok: false, reason: 'non e\' un file regolare (symlink o speciale)' }; // lstat: symlink -> isFile()=false
272
+ if (!(st.mode & 0o100)) return { ok: false, reason: 'non eseguibile dall\'owner' };
273
+ if (st.mode & 0o002) return { ok: false, reason: 'world-writable' };
274
+ // Owner check (design §9a, audit impl #4): il command deve appartenere
275
+ // all'utente del service o a root — un owner terzo potrebbe sostituire
276
+ // l'eseguibile mantenendo il path "trusted".
277
+ if (typeof process.getuid === 'function') {
278
+ const uid = process.getuid();
279
+ if (st.uid !== uid && st.uid !== 0) return { ok: false, reason: 'owner non fidato (ne\' utente del service ne\' root)' };
280
+ }
281
+ return { ok: true, reason: 'trusted' };
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // resolveCwd(cwd, home) -> path|null
286
+ // realpath SOTTO la home (default process.env.HOME); stesso confinamento di
287
+ // lib/tmux/lifecycle.js: realpath su entrambi (symlink dentro home che punta
288
+ // fuori -> rifiutato) e deve essere una directory.
289
+ // ---------------------------------------------------------------------------
290
+ function resolveCwd(cwd, home) {
291
+ try {
292
+ const h = home || process.env.HOME;
293
+ if (typeof cwd !== 'string' || !cwd || typeof h !== 'string' || !h) return null;
294
+ if (cwd.includes('\0') || h.includes('\0')) return null;
295
+ const real = fs.realpathSync(cwd);
296
+ const realHome = fs.realpathSync(h);
297
+ if (!fs.statSync(real).isDirectory()) return null;
298
+ if (real !== realHome && !real.startsWith(realHome + path.sep)) return null;
299
+ return real;
300
+ } catch (_) { return null; }
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // loadDefinitions(p) -> parsed | null
305
+ // Legge il file rifiutando i symlink; parse strict. Mai throw.
306
+ // ---------------------------------------------------------------------------
307
+ function loadDefinitions(p) {
308
+ try {
309
+ let st;
310
+ try { st = fs.lstatSync(p); } catch (_) { return null; } // missing -> null
311
+ if (st.isSymbolicLink()) return null; // no symlink
312
+ if (!st.isFile()) return null;
313
+ const raw = fs.readFileSync(p, 'utf8');
314
+ return parseDefinitions(raw);
315
+ } catch (_) { return null; }
316
+ }
317
+
318
+ // Backup best-effort del predecessore (su fallimento di validazione, o comunque
319
+ // prima di sovrascrivere). Sempre 0600.
320
+ function backupPredecessor(p) {
321
+ try {
322
+ if (!fs.lstatSync(p).isFile()) return;
323
+ const bak = `${p}.bak`;
324
+ fs.copyFileSync(p, bak);
325
+ fs.chmodSync(bak, 0o600);
326
+ } catch (_) { /* best-effort */ }
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // atomicWrite(p, data) -> parsed
331
+ // data: oggetto definizioni OPPURE stringa JSON. Valida PRIMA di scrivere
332
+ // (fail-closed: dati invalidi -> backup del predecessore + throw, mai scritti).
333
+ // Scrittura atomica: tmp nella stessa dir + rename; file mode 0600; rifiuto
334
+ // se il target esiste ed e' un symlink.
335
+ // ---------------------------------------------------------------------------
336
+ function atomicWrite(p, data) {
337
+ // Rifiuta symlink come target: mai scrivere attraverso un link.
338
+ try {
339
+ if (fs.lstatSync(p).isSymbolicLink()) {
340
+ throw new Error('refuse to write: il target e\' un symlink');
341
+ }
342
+ } catch (e) {
343
+ if (e.code === 'ENOENT') { /* nuovo file, ok */ }
344
+ else throw e; // inclusi i nostri 'refuse to write'
345
+ }
346
+
347
+ const parsed = parseDefinitions(data);
348
+ if (!parsed) {
349
+ backupPredecessor(p); // conserva il precedente per recovery/forensics
350
+ throw new Error('definizioni fleet non valide: validazione fallita');
351
+ }
352
+
353
+ const dir = path.dirname(p);
354
+ fs.mkdirSync(dir, { recursive: true });
355
+ const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
356
+ try {
357
+ fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
358
+ fs.chmodSync(tmp, 0o600); // forza 0600 a prescindere da umask/file preesistente
359
+ fs.renameSync(tmp, p); // atomico sullo stesso filesystem (stessa dir)
360
+ } catch (e) {
361
+ try { fs.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
362
+ throw e;
363
+ }
364
+ return parsed;
365
+ }
366
+
367
+ const CAPS = {
368
+ SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
369
+ MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_LABEL_LEN,
370
+ MAX_COMMAND_LEN, MAX_CWD_LEN, MAX_MODEL_FLAG_LEN, MAX_MODEL_VAL_LEN,
371
+ MAX_PROMPTFLAG_LEN, MAX_PROMPT_LEN, MAX_TMUXSESSION_LEN,
372
+ };
373
+
374
+ module.exports = {
375
+ parseDefinitions,
376
+ validateCommandTrust,
377
+ resolveCwd,
378
+ loadDefinitions,
379
+ atomicWrite,
380
+ CAPS,
381
+ // Costanti esposte anche piatte (comode per la UI/schema e i test)
382
+ SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
383
+ MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_PROMPT_LEN,
384
+ };
@@ -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
 
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+ // B4.2 — Provider selection. Sceglie UNA volta, a startup, quale fleet governa il
3
+ // runtime (design §4b/§9b/§9g). Ritorna { mode, reason, fleet }.
4
+ //
5
+ // mode ∈ 'external' | 'builtin' | 'disabled'
6
+ //
7
+ // Regole (design §9g):
8
+ // - forced (cfg.fleetProvider | NEXUSCREW_FLEET_PROVIDER): onorato e FAIL-CLOSED
9
+ // se indisponibile. NIENTE auto-fallback silenzioso al built-in.
10
+ // - auto: 'external' vince solo se fidato (binTrusted, riusato da index.js) E
11
+ // risponde al contratto (createFleet → available). Altrimenti 'builtin' se
12
+ // abilitato e fleet.json valido. Altrimenti 'disabled'.
13
+ //
14
+ // Il drift a runtime NON si risolve qui: e' una scelta one-shot. Se l'external
15
+ // diventa invalido DOPO lo startup, lo status risultante sara' degraded/unavailable
16
+ // (mai fall-through silenzioso al built-in) — gestito dal layer di status.
17
+ const { createFleet, binTrusted } = require('./index.js');
18
+ const { createBuiltinFleet } = require('./builtin.js');
19
+
20
+ const DISABLED_FLEET = Object.freeze({
21
+ available: false, provider: 'disabled', isCellSession: () => false, capabilities: () => [],
22
+ });
23
+
24
+ function failClosed(mode) {
25
+ return {
26
+ mode: 'disabled',
27
+ reason: `fail-closed: provider forzato "${mode}" non disponibile (nessun auto-fallback, §9g)`,
28
+ fleet: DISABLED_FLEET,
29
+ };
30
+ }
31
+
32
+ // Costruisce (una volta) i candidati e ne valuta la disponibilita'.
33
+ async function selectProvider(cfg = {}) {
34
+ if (cfg.fleetEnabled === false) {
35
+ return { mode: 'disabled', reason: 'fleet disabilitato (fleetEnabled=false)', fleet: DISABLED_FLEET };
36
+ }
37
+
38
+ const forced = (cfg.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER || '').toLowerCase() || null;
39
+
40
+ // External: fidato (regular file, exec, no symlink, no world-writable) E contratto ok.
41
+ let extFleet = null;
42
+ let extReason = null;
43
+ if (cfg.fleetBin) {
44
+ if (binTrusted(cfg.fleetBin)) {
45
+ const f = await createFleet(cfg); // fa status --json al boot
46
+ if (f.available) { extFleet = f; extReason = 'fidato + risponde al contratto'; }
47
+ else extReason = 'binario fidato ma non risponde al contratto (status invalido)';
48
+ } else {
49
+ extReason = 'binario non fidato (symlink/world-writable/non exec)';
50
+ }
51
+ } else {
52
+ extReason = 'nessun fleetBin esterno configurato';
53
+ }
54
+
55
+ // Builtin: abilitato + fleet.json valido.
56
+ let biFleet = null;
57
+ let biReason = null;
58
+ if (cfg.builtinEnabled !== false) {
59
+ const f = await createBuiltinFleet({ ...cfg, fleetProviderReason: 'fleet.json definitions' });
60
+ if (f.available) { biFleet = f; biReason = 'fleet.json valido'; }
61
+ else biReason = 'fleet.json mancante o invalido (fail-closed)';
62
+ } else {
63
+ biReason = 'builtin disabilitato (builtinEnabled=false)';
64
+ }
65
+
66
+ // --- Mode FORZATO: fail-closed se il richiesto non e' disponibile ---
67
+ if (forced === 'external') {
68
+ return extFleet
69
+ ? { mode: 'external', reason: `forced external (${extReason})`, fleet: extFleet }
70
+ : failClosed('external');
71
+ }
72
+ if (forced === 'builtin') {
73
+ return biFleet
74
+ ? { mode: 'builtin', reason: `forced builtin (${biReason})`, fleet: biFleet }
75
+ : failClosed('builtin');
76
+ }
77
+ if (forced === 'disabled') {
78
+ return { mode: 'disabled', reason: 'forced disabled', fleet: DISABLED_FLEET };
79
+ }
80
+ if (forced) {
81
+ return failClosed(forced); // valore forzato non riconosciuto
82
+ }
83
+
84
+ // --- AUTO: external fidato+contratto vince, poi builtin, poi disabled ---
85
+ if (extFleet) {
86
+ return { mode: 'external', reason: `auto: external ${extReason}`, fleet: extFleet };
87
+ }
88
+ if (biFleet) {
89
+ return {
90
+ mode: 'builtin',
91
+ reason: `auto: external scartato (${extReason}) → builtin (${biReason})`,
92
+ fleet: biFleet,
93
+ };
94
+ }
95
+ return {
96
+ mode: 'disabled',
97
+ reason: `auto: nessun provider disponibile — external: ${extReason}; builtin: ${biReason}`,
98
+ fleet: DISABLED_FLEET,
99
+ };
100
+ }
101
+
102
+ module.exports = { selectProvider, DISABLED_FLEET };