@mmmbuto/nexuscrew 0.8.52-rc.9 → 0.8.52

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.
@@ -35,7 +35,9 @@ const {
35
35
  } = require('./definitions.js');
36
36
  const {
37
37
  publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine, defaultAgyEngine, defaultKimiEngine,
38
+ CATALOG, credential: resolveProfileCredential, extraModelsFrom, knownMcpServerNames,
38
39
  } = require('./managed.js');
40
+ const { probeModel } = require('./model-probe.js');
39
41
  const { validEnvKey } = require('./env-key.js');
40
42
  const { setCredential, removeCredential } = require('./credentials.js');
41
43
  const { createLaunchBroker } = require('./launch-broker.js');
@@ -55,6 +57,14 @@ const {
55
57
  function draftFrom(defs) {
56
58
  return {
57
59
  schemaVersion: defs.schemaVersion,
60
+ // I modelli dichiarati vanno RIPORTATI nella bozza: ogni mutazione
61
+ // ricostruisce il file da qui, e un campo non copiato sparisce senza che
62
+ // nessuno lo chieda. Trovato da un test — dichiaravo un modello, salvavo
63
+ // un engine che lo usava, e il salvataggio falliva perche' la
64
+ // dichiarazione non c'era piu'.
65
+ ...(Array.isArray(defs.models) && defs.models.length
66
+ ? { models: defs.models.map((m) => ({ ...m })) }
67
+ : {}),
58
68
  engines: defs.engines.map((e) => ({
59
69
  ...e,
60
70
  ...(e.managed ? { managed: { ...e.managed } } : {}),
@@ -371,6 +381,50 @@ async function createBuiltinFleet(cfg = {}) {
371
381
  await mutate(reloadDefs(), (d) => { d.engines.push(def); });
372
382
  return { ok: true, id: def.id };
373
383
  }
384
+ // --- modelli dichiarati ---------------------------------------------------
385
+ // Vivono in fleet.json accanto agli engine: stesso file, stesso backup,
386
+ // stessa esportazione. La validazione e' quella del parser (schema chiuso):
387
+ // qui si aggiunge e si toglie, non si reinterpreta.
388
+ async function defineModel(def) {
389
+ if (readonly()) throw httpError(403, 'READONLY: define-model bloccato');
390
+ if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione modello mancante');
391
+ const id = String(def.id || '').trim();
392
+ const engine = String(def.engine || '').trim();
393
+ if (!id || !engine) throw httpError(400, 'id e engine sono obbligatori');
394
+ const defs = reloadDefs();
395
+ const esistente = (defs.models || []).some((m) => m.id === id && m.engine === engine);
396
+ if (esistente) throw httpError(400, `modello gia' dichiarato: ${id} su ${engine}`);
397
+ // La scrittura passa da `mutate`, che rilegge e RIVALIDA l'intero file: un
398
+ // campo fuori schema non entra, e non serve duplicare qui le regole.
399
+ await mutate(defs, (d) => { d.models = [...(d.models || []), def]; });
400
+ return { ok: true, id, engine };
401
+ }
402
+
403
+ async function removeModel(id, engine) {
404
+ if (readonly()) throw httpError(403, 'READONLY: remove-model bloccato');
405
+ const wantedId = String(id || '').trim();
406
+ const wantedEngine = String(engine || '').trim();
407
+ if (!wantedId || !wantedEngine) throw httpError(400, 'id e engine sono obbligatori');
408
+ const defs = reloadDefs();
409
+ if (!(defs.models || []).some((m) => m.id === wantedId && m.engine === wantedEngine)) {
410
+ throw httpError(404, `modello non dichiarato: ${wantedId} su ${wantedEngine}`);
411
+ }
412
+ // Se un engine sta USANDO questo modello, toglierlo lo renderebbe invalido
413
+ // al prossimo avvio — e il messaggio arriverebbe a chi non sa cosa e'
414
+ // cambiato. Meglio rifiutare adesso, dicendo chi lo usa.
415
+ const usato = (defs.engines || [])
416
+ .filter((e) => e.managed && e.managed.model === wantedId)
417
+ .map((e) => e.id);
418
+ if (usato.length) {
419
+ throw httpError(409, `modello in uso: ${usato.join(', ')}`);
420
+ }
421
+ await mutate(defs, (d) => {
422
+ d.models = (d.models || []).filter((m) => !(m.id === wantedId && m.engine === wantedEngine));
423
+ if (!d.models.length) delete d.models;
424
+ });
425
+ return { ok: true, id: wantedId, engine: wantedEngine };
426
+ }
427
+
374
428
  async function editEngine(id, patch, envChanges) {
375
429
  if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
376
430
  if (!id) throw httpError(400, 'id engine mancante');
@@ -566,6 +620,18 @@ async function createBuiltinFleet(cfg = {}) {
566
620
  // conserva gli eventuali valori write-only già configurati sul target.
567
621
  async function restoreEngines(engines, opts = {}) {
568
622
  if (readonly()) throw httpError(403, 'READONLY: restore-engines bloccato');
623
+ // I modelli dichiarati arrivano INSIEME agli engine e vanno scritti PRIMA,
624
+ // nella stessa mutazione: un engine che usa un modello dichiarato viene
625
+ // rifiutato se la dichiarazione non c'e' ancora — fail-closed corretto, ma
626
+ // che al ripristino si legge come «il backup era rotto».
627
+ const models = Array.isArray(opts.models) ? opts.models : [];
628
+ if (models.length > 64) throw httpError(400, 'troppi modelli nel backup');
629
+ for (const m of models) {
630
+ if (!m || typeof m !== 'object' || Array.isArray(m)) throw httpError(400, 'definizione modello non valida');
631
+ if (typeof m.id !== 'string' || !m.id.trim() || typeof m.engine !== 'string' || !m.engine.trim()) {
632
+ throw httpError(400, 'modello senza id o engine');
633
+ }
634
+ }
569
635
  if (!Array.isArray(engines) || engines.length < 1 || engines.length > 24) {
570
636
  throw httpError(400, 'engines deve contenere 1..24 definizioni');
571
637
  }
@@ -601,6 +667,10 @@ async function createBuiltinFleet(cfg = {}) {
601
667
  const sessions = await refreshSessions();
602
668
  const affected = defs.cells.filter((cell) => conflicts.includes(cell.engine) && sessions.has(cell.tmuxSession)).map((cell) => cell.id);
603
669
  await mutate(defs, (draft) => {
670
+ for (const portable of models) {
671
+ const esiste = (draft.models || []).some((m) => m.id === portable.id && m.engine === portable.engine);
672
+ if (!esiste) draft.models = [...(draft.models || []), portable];
673
+ }
604
674
  for (const portable of engines) {
605
675
  const index = draft.engines.findIndex((current) => current.id === portable.id);
606
676
  const current = index >= 0 ? draft.engines[index] : null;
@@ -725,7 +795,7 @@ async function createBuiltinFleet(cfg = {}) {
725
795
  engines: defs.engines.map((e) => {
726
796
  const out = { ...e, envKeys: Object.keys(e.env || {}).sort() };
727
797
  delete out.env;
728
- if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home });
798
+ if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home, extraModels: extraModelsFrom(defs) });
729
799
  return out;
730
800
  }),
731
801
  cells: defs.cells.map((c) => {
@@ -756,7 +826,16 @@ async function createBuiltinFleet(cfg = {}) {
756
826
  }
757
827
  return out;
758
828
  }),
829
+ // I modelli dichiarati devono uscire dalla VISTA, non solo stare nel file:
830
+ // la finestra li legge da qui, e senza questa riga l'elenco era sempre
831
+ // vuoto — si poteva dichiarare un modello e non vederlo mai, ne' ritirarlo.
832
+ // I test CRUD non se ne accorgevano perche' leggevano fleet.json diretto.
833
+ ...(Array.isArray(defs.models) && defs.models.length ? { models: defs.models } : {}),
759
834
  managedCatalog,
835
+ // I NOMI dei server MCP disponibili, per la finestra che sceglie quali
836
+ // strumenti dare a una cella. Solo nomi: le definizioni non escono di qui,
837
+ // e non devono — alcune portano credenziali nel proprio `env`.
838
+ mcpServers: [...knownMcpServerNames(home)].sort(),
760
839
  managedConfig: {
761
840
  providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env'),
762
841
  providerShellPath: cfg.providerShellPath || path.join(home, '.config', 'ai-shell', 'providers.zsh'),
@@ -771,7 +850,7 @@ async function createBuiltinFleet(cfg = {}) {
771
850
  const defs = existingDefs || reloadDefs(); const map = new Map();
772
851
  for (const engine of defs.engines) {
773
852
  if (!engine.managed) continue;
774
- const info = describeManaged(engine.managed, { ...cfg, home });
853
+ const info = describeManaged(engine.managed, { ...cfg, home, extraModels: extraModelsFrom(reloadDefs()) });
775
854
  const envKey = info.auth;
776
855
  if (!validEnvKey(envKey) || envKey === 'login' || envKey === 'none') continue;
777
856
  const current = map.get(envKey) || {
@@ -856,6 +935,20 @@ async function createBuiltinFleet(cfg = {}) {
856
935
  promptMode: { type: 'enum', required: true, values: ['flag', 'send-keys'] },
857
936
  promptFlag: { type: 'string', requiredIf: 'promptMode=flag', singleArgv: true, max: CAPS.MAX_PROMPTFLAG_LEN },
858
937
  },
938
+ // Modello dichiarato: estende il catalogo interno per UN profilo. Vive
939
+ // nello schema perche' `define-model` e' federato — un client che
940
+ // amministra un nodo remoto ricava di qui la forma da compilare, invece
941
+ // di indovinarla e scoprire il vincolo da un rifiuto.
942
+ model: {
943
+ id: { type: 'string', required: true, max: CAPS.MAX_MODEL_VAL_LEN, noControlChars: true },
944
+ engine: { type: 'catalog', required: true, source: 'managedCatalog', max: 64 },
945
+ label: { type: 'string', required: false, max: 64 },
946
+ // Fuori da questa scala e' quasi sempre un errore di battitura, e
947
+ // arriverebbe al client come comportamento inspiegabile.
948
+ contextWindow: { type: 'integer', required: false, min: 1024, max: 100000000 },
949
+ maxTokens: { type: 'integer', required: false, min: 1024, max: 100000000 },
950
+ reasoning: { type: 'boolean', required: false },
951
+ },
859
952
  cell: {
860
953
  id: { type: 'string', required: true, pattern: '^[A-Za-z0-9._-]{1,32}$', max: 32 },
861
954
  cwd: { type: 'string', required: true, max: CAPS.MAX_CWD_LEN, underHome: true },
@@ -867,6 +960,9 @@ async function createBuiltinFleet(cfg = {}) {
867
960
  permissionPolicies: { type: 'object', required: false, keyRef: 'engine.id', valueEnum: ['standard', 'unsafe'] },
868
961
  commands: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_CELL_COMMAND_LEN, managedClient: 'shell' },
869
962
  prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
963
+ // Quali strumenti MCP ha la cella, per NOME. Assente = tutti (come
964
+ // sempre); `[]` = nessuno. Solo nomi: le definizioni restano dove sono.
965
+ mcp: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$' },
870
966
  // Nome leggibile, distinto dall'id: quest'ultimo resta la chiave di
871
967
  // indirizzamento e l'unica origine della sessione tmux.
872
968
  label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN },
@@ -875,7 +971,35 @@ async function createBuiltinFleet(cfg = {}) {
875
971
  }
876
972
 
877
973
  function capabilities() {
878
- return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials'];
974
+ return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test'];
975
+ }
976
+
977
+ // «questo modello funziona con questa credenziale?», chiesto all'API invece
978
+ // che scoperto quando la cella non parte. Vive qui e non nelle route perche'
979
+ // la credenziale si risolve dove gia' si risolve per l'avvio: la chiave non
980
+ // attraversa il router e non compare in nessuna risposta.
981
+ async function testModel(engineId, model, opts = {}) {
982
+ const profile = CATALOG.find((p) => p.id === String(engineId || ''));
983
+ if (!profile) {
984
+ const e = new Error(`profilo sconosciuto "${engineId}"`); e.status = 404; throw e;
985
+ }
986
+ const wanted = typeof model === 'string' ? model.trim() : '';
987
+ if (!wanted || wanted.length > 128 || /[\x00-\x1f\x7f]/.test(wanted)) {
988
+ const e = new Error('modello non valido'); e.status = 400; throw e;
989
+ }
990
+ const spec = {
991
+ client: profile.client,
992
+ provider: profile.provider,
993
+ ...(profile.credentialProfile ? { credentialProfile: profile.credentialProfile } : {}),
994
+ };
995
+ let value = '';
996
+ try { value = (resolveProfileCredential(profile, spec, cfg, home) || {}).value || ''; } catch (_) { value = ''; }
997
+ const out = await probeModel({
998
+ profile, credential: value, model: wanted, fetchImpl: opts.fetchImpl,
999
+ });
1000
+ // Si riporta anche COSA si e' provato, perche' un esito senza il suo
1001
+ // soggetto e' inservibile in una schermata con piu' modelli.
1002
+ return { engine: profile.id, model: wanted, ...out };
879
1003
  }
880
1004
 
881
1005
  async function close() { await launchBroker.close(); }
@@ -887,7 +1011,8 @@ async function createBuiltinFleet(cfg = {}) {
887
1011
  defineEngine, editEngine, removeEngine,
888
1012
  defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
889
1013
  schema, definitions, capabilities,
890
- credentialStatus, setLocalCredential, removeLocalCredential, close,
1014
+ credentialStatus, setLocalCredential, removeLocalCredential, testModel,
1015
+ defineModel, removeModel, close,
891
1016
  };
892
1017
  }
893
1018
 
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "models": [
3
3
  {
4
- "slug": "qwen3.8-max-preview",
5
- "display_name": "qwen3.8-max-preview",
6
- "description": "Alibaba Token Plan Personal: qwen3.8-max-preview",
4
+ "slug": "qwen3.8-max",
5
+ "display_name": "qwen3.8-max",
6
+ "description": "Alibaba Token Plan Personal: qwen3.8-max",
7
7
  "default_reasoning_level": "xhigh",
8
8
  "supported_reasoning_levels": [
9
9
  {
@@ -29,6 +29,11 @@ const MAX_MODEL_FLAG_LEN = 32;
29
29
  const MAX_MODEL_VAL_LEN = 128;
30
30
  const MAX_PROMPTFLAG_LEN = 32;
31
31
  const MAX_PROMPT_LEN = 8192; // 8 KB
32
+ // Nomi di server MCP dichiarabili per cella. Il tetto e' generoso rispetto agli
33
+ // otto configurati qui, e la forma e' quella di un identificatore perche' il
34
+ // nome finisce in un prefisso di tool passato al client.
35
+ const MAX_MCP_NAMES = 64;
36
+ const MCP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
32
37
  const MAX_CELL_COMMAND_LEN = 4096;
33
38
  const MAX_TMUXSESSION_LEN = 64;
34
39
 
@@ -131,7 +136,49 @@ function cellIdFromTmuxSession(session) {
131
136
  }
132
137
 
133
138
  // ---------------------------------------------------------------------------
134
- // parseDefinitions(raw) -> {schemaVersion, engines, cells} | null
139
+ // Modelli dichiarati dall'operatore, accanto agli engine e persistiti con loro.
140
+ //
141
+ // Perche' esistono: il catalogo dei modelli vive nel pacchetto, e un fornitore
142
+ // che pubblica un id nuovo lo rende inutilizzabile fino alla release
143
+ // successiva. Qui si dichiara l'id per un profilo gestito, con i dati che il
144
+ // client si aspetta — gli stessi campi che il catalogo interno gia' porta.
145
+ //
146
+ // Un modello dichiarato NON aggira il controllo: lo estende in modo esplicito.
147
+ // Un id mai dichiarato resta rifiutato.
148
+ const MAX_MODELS = 64;
149
+ const MODEL_ID_MAX = 128;
150
+
151
+ function parseModel(m) {
152
+ if (!m || typeof m !== 'object' || Array.isArray(m)) return null;
153
+ const allowed = new Set(['id', 'engine', 'label', 'contextWindow', 'maxTokens', 'reasoning']);
154
+ if (Object.keys(m).some((k) => !allowed.has(k))) return null;
155
+ const id = typeof m.id === 'string' ? m.id.trim() : '';
156
+ // Stessa forma accettata dal gate dei modelli: nessun byte di controllo, un
157
+ // limite di lunghezza, e nulla di piu' — l'id lo decide il fornitore, non noi.
158
+ if (!id || id.length > MODEL_ID_MAX || /[\x00-\x1f\x7f]/.test(id)) return null;
159
+ const engine = typeof m.engine === 'string' ? m.engine.trim() : '';
160
+ if (!engine || engine.length > 64 || !/^[a-z0-9][a-z0-9._-]*$/.test(engine)) return null;
161
+ const out = { id, engine };
162
+ if (m.label !== undefined) {
163
+ if (typeof m.label !== 'string' || m.label.length > 64) return null;
164
+ if (m.label.trim()) out.label = m.label.trim();
165
+ }
166
+ for (const key of ['contextWindow', 'maxTokens']) {
167
+ if (m[key] === undefined) continue;
168
+ // Una finestra plausibile: un numero fuori scala e' quasi sempre un errore
169
+ // di battitura, e passerebbe fino al client sotto forma di comportamento
170
+ // inspiegabile.
171
+ if (!Number.isSafeInteger(m[key]) || m[key] < 1024 || m[key] > 100000000) return null;
172
+ out[key] = m[key];
173
+ }
174
+ if (m.reasoning !== undefined) {
175
+ if (typeof m.reasoning !== 'boolean') return null;
176
+ out.reasoning = m.reasoning;
177
+ }
178
+ return out;
179
+ }
180
+
181
+ // parseDefinitions(raw) -> {schemaVersion, engines, cells, models} | null
135
182
  // Accetta stringa JSON o oggetto gia' parsato. Strict + fail-closed.
136
183
  // ---------------------------------------------------------------------------
137
184
  function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
@@ -151,11 +198,32 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
151
198
  if (!Array.isArray(d.cells)) return null; // cells obbligatorio (array)
152
199
  if (d.cells.length > MAX_CELLS) return null;
153
200
 
201
+ // I modelli si leggono PRIMA degli engine: un engine puo' riferirsi a un id
202
+ // dichiarato qui, e leggerli dopo lo farebbe rifiutare per un dato che in
203
+ // realta' c'e'. `models` e' opzionale: una configurazione che non lo ha
204
+ // resta valida esattamente com'era.
205
+ const models = [];
206
+ const extraModels = new Map();
207
+ if (d.models !== undefined) {
208
+ if (!Array.isArray(d.models) || d.models.length > MAX_MODELS) return null;
209
+ const seen = new Set();
210
+ for (const m of d.models) {
211
+ const model = parseModel(m);
212
+ if (!model) return null;
213
+ const key = `${model.engine}::${model.id}`;
214
+ if (seen.has(key)) return null; // stesso id due volte per lo stesso profilo
215
+ seen.add(key);
216
+ if (!extraModels.has(model.engine)) extraModels.set(model.engine, new Set());
217
+ extraModels.get(model.engine).add(model.id);
218
+ models.push(model);
219
+ }
220
+ }
221
+
154
222
  const engineIds = new Set();
155
223
  const engineMap = new Map();
156
224
  const engines = [];
157
225
  for (const e of d.engines) {
158
- const eng = parseEngine(e);
226
+ const eng = parseEngine(e, { extraModels });
159
227
  if (!eng) return null;
160
228
  if (engineIds.has(eng.id)) return null; // id engine univoco
161
229
  engineIds.add(eng.id);
@@ -169,7 +237,7 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
169
237
  const cellIds = new Set();
170
238
  const cells = [];
171
239
  for (const c of d.cells) {
172
- const cell = parseCell(c, engineIds, engineMap, { allowLegacyTmuxNames });
240
+ const cell = parseCell(c, engineIds, engineMap, { allowLegacyTmuxNames, extraModels });
173
241
  if (!cell) return null;
174
242
  if (cellIds.has(cell.id)) return null; // id cell univoco
175
243
  cellIds.add(cell.id);
@@ -183,7 +251,11 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
183
251
  cells.push(cell);
184
252
  }
185
253
 
186
- const parsed = { schemaVersion: SCHEMA_VERSION, engines, cells };
254
+ // `models` compare solo se dichiarato: una configurazione senza modelli
255
+ // resta byte-identica a prima, e l'aggiornamento non riscrive nulla.
256
+ const parsed = models.length
257
+ ? { schemaVersion: SCHEMA_VERSION, engines, cells, models }
258
+ : { schemaVersion: SCHEMA_VERSION, engines, cells };
187
259
  // Metadato solo in memoria: serve al bootstrap per rinominare una sessione
188
260
  // legacy PRIMA di persistere il nome safe. Non entra in JSON, draft o API.
189
261
  Object.defineProperty(parsed, 'legacyTmuxSessions', {
@@ -195,7 +267,7 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
195
267
  }
196
268
  }
197
269
 
198
- function parseEngine(e) {
270
+ function parseEngine(e, { extraModels = null } = {}) {
199
271
  if (!e || typeof e !== 'object' || Array.isArray(e)) return null;
200
272
 
201
273
  // id
@@ -219,7 +291,7 @@ function parseEngine(e) {
219
291
  // Managed: NexusCrew conosce client/provider e compone internamente il
220
292
  // processo. Nessun command/env/argv o segreto vive nella definizione.
221
293
  if (e.managed !== undefined) {
222
- const managed = normalizeManagedSpec(e.managed);
294
+ const managed = normalizeManagedSpec(e.managed, { extraModels });
223
295
  if (!managed) return null;
224
296
  for (const key of ['command', 'args', 'env', 'promptMode', 'promptFlag', 'model']) {
225
297
  if (e[key] !== undefined) return null;
@@ -285,7 +357,25 @@ function parseEngine(e) {
285
357
  return out;
286
358
  }
287
359
 
288
- function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames = true } = {}) {
360
+ // Un modello scelto PER CELLA deve superare lo stesso controllo di un modello
361
+ // scelto per l'engine. Senza, il gate `strictModels` — che vale su
362
+ // define-engine, edit-engine, restore-engines e sulla scrittura diretta —
363
+ // resta aggirabile da questa via, che per giunta e' FEDERATA (`/fleet/engine`
364
+ // e' nell'allowlist): un peer poteva mettere un id arbitrario in una cella di
365
+ // questa installazione, e il boot lo usava senza ricontrollarlo.
366
+ //
367
+ // Rilievo dell'audit indipendente, provato con probe su due profili distinti.
368
+ // Il controllo e' quello vero, non una copia: si passa dalla stessa
369
+ // `normalizeManagedSpec` con le stesse dichiarazioni.
370
+ function cellModelAllowed(engineId, model, engineMap, extraModels) {
371
+ const engine = engineMap.get(engineId);
372
+ // Engine custom (non gestito): il modello e' un argomento del comando, non un
373
+ // id di catalogo, e qui non c'e' nulla da validare.
374
+ if (!engine || !engine.managed) return true;
375
+ return !!normalizeManagedSpec({ ...engine.managed, model }, { extraModels });
376
+ }
377
+
378
+ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames = true, extraModels = null } = {}) {
289
379
  if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
290
380
 
291
381
  // id
@@ -320,6 +410,7 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
320
410
  let model;
321
411
  if (c.model !== undefined) {
322
412
  if (typeof c.model !== 'string' || c.model.length > MAX_MODEL_VAL_LEN) return null;
413
+ if (!cellModelAllowed(c.engine, c.model, engineMap, extraModels)) return null;
323
414
  model = c.model;
324
415
  }
325
416
 
@@ -332,6 +423,9 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
332
423
  if (entries.length > MAX_ENGINES) return null;
333
424
  for (const [engineId, value] of entries) {
334
425
  if (!engineIds.has(engineId) || typeof value !== 'string' || !value || value.length > MAX_MODEL_VAL_LEN) return null;
426
+ // Anche la memoria per-engine passa dal gate: e' un valore che tornera'
427
+ // in uso appena si ritorna su quell'engine.
428
+ if (!cellModelAllowed(engineId, value, engineMap, extraModels)) return null;
335
429
  models[engineId] = value;
336
430
  }
337
431
  }
@@ -378,6 +472,32 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
378
472
  prompt = c.prompt;
379
473
  }
380
474
 
475
+ // mcp (opzionale): QUALI strumenti MCP ha questa cella, per NOME.
476
+ //
477
+ // Solo nomi, mai definizioni. Le definizioni vivono in un posto solo — la
478
+ // configurazione del client — e li' restano: duplicarle per cella
479
+ // significherebbe moltiplicare le credenziali che alcuni server portano nel
480
+ // proprio `env` (una password, due chiavi API su questa installazione), in un
481
+ // file per cella invece che in uno.
482
+ //
483
+ // ASSENTE non e' come VUOTO. Assente = la cella eredita tutti gli strumenti,
484
+ // che e' il comportamento di sempre. `[]` = nessuno strumento, ed e'
485
+ // esattamente cio' che serve per una cella di cui non ci si fida: oggi quel
486
+ // risultato si ottiene solo per caso, come effetto collaterale
487
+ // dell'isolamento per credenziale.
488
+ let mcp;
489
+ if (c.mcp !== undefined) {
490
+ if (!Array.isArray(c.mcp) || c.mcp.length > MAX_MCP_NAMES) return null;
491
+ const visti = new Set();
492
+ for (const nome of c.mcp) {
493
+ // Il nome finisce in un prefisso di tool (`mcp__<nome>`) passato al
494
+ // client: si delimita come un identificatore, non come testo libero.
495
+ if (typeof nome !== 'string' || !MCP_NAME_RE.test(nome) || visti.has(nome)) return null;
496
+ visti.add(nome);
497
+ }
498
+ mcp = [...visti];
499
+ }
500
+
381
501
  // label (opzionale): nome LEGGIBILE della cella, distinto dall'id.
382
502
  // L'id resta la chiave stabile con cui si indirizza una cella e con cui si
383
503
  // deriva la sessione tmux; la label e' solo cio' che un umano legge. Senza
@@ -437,6 +557,7 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
437
557
  if (permissionPolicies) out.permissionPolicies = permissionPolicies;
438
558
  if (commands && Object.keys(commands).length) out.commands = commands;
439
559
  if (prompt !== undefined) out.prompt = prompt;
560
+ if (mcp !== undefined) out.mcp = mcp;
440
561
  if (label !== undefined) out.label = label;
441
562
  if (legacyTmuxSession) {
442
563
  Object.defineProperty(out, 'legacyTmuxSession', {