@mmmbuto/nexuscrew 0.9.33 → 0.9.34

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.
@@ -11,7 +11,7 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-Uco-EbNb.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-5eCvm3HF.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-DqCfZFGi.css">
16
16
  </head>
17
17
  <body>
@@ -1 +1 @@
1
- {"version":"0.9.33"}
1
+ {"version":"0.9.34"}
@@ -34,11 +34,12 @@ const {
34
34
  aggiornaDefinizioni,
35
35
  cellIdFromTmuxSession,
36
36
  resolveCwd, normalizeCwdRel, deriveCwdRel,
37
+ effectiveCapabilities,
37
38
  } = require('./definitions.js');
38
39
  const {
39
40
  publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine,
40
41
  CATALOG, credential: resolveProfileCredential, extraModelsFrom, knownMcpServerNames, profileFor,
41
- normalizeManagedSpec,
42
+ normalizeManagedSpec, codexSurfaceNames,
42
43
  } = require('./managed.js');
43
44
  const { probeModel, probeCustomEndpoint } = require('./model-probe.js');
44
45
  const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
@@ -1369,6 +1370,43 @@ async function createBuiltinFleet(cfg = {}) {
1369
1370
  ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
1370
1371
  ...(c.commands ? { commands: { ...c.commands } } : {}),
1371
1372
  };
1373
+ // Superficie dichiarata (solo vista): una cella LEGACY (niente
1374
+ // capabilities, niente profilo) e' la cella di sempre — esce con
1375
+ // `legacy: true` e nessun campo capabilities; una cella dichiarata
1376
+ // esce con la fusione profilo+override (sostitutivo per chiave) e con
1377
+ // le chiavi che il materializzatore NON copre per il suo engine
1378
+ // segnate come `unsupported` (motivo leggibile, MAI un errore). Il
1379
+ // materializzatore per engine arriva per fasi: qui la dichiarazione
1380
+ // onesta di cosa viene davvero materializzato.
1381
+ const eff = effectiveCapabilities(c, defs);
1382
+ out.legacy = eff.legacy;
1383
+ if (!eff.legacy) {
1384
+ const client = (defs.engines.find((e) => e.id === c.engine) || {}).managed?.client || null;
1385
+ const materializzaMcp = client === 'claude' || client === 'codex' || client === 'codex-vl';
1386
+ const materializzaOndemand = client === 'codex' || client === 'codex-vl';
1387
+ const nonMaterializzate = {
1388
+ mcp: `nessun materializzatore MCP per questo engine in questa fase (client: ${client || 'sconosciuto'})`,
1389
+ skills: 'le skill non sono ancora materializzate per cella (fase successiva)',
1390
+ ondemand: 'la materializzazione on-demand arriva in una fase successiva',
1391
+ };
1392
+ const unsupported = Object.keys(eff.declared)
1393
+ .filter((key) => !(key === 'mcp' && materializzaMcp) && !(key === 'ondemand' && materializzaOndemand))
1394
+ .map((key) => ({ key, reason: nonMaterializzate[key] }));
1395
+ // Per codex/codex-vl un nome ondemand che NON e' nella config utente
1396
+ // del nodo viene ignorato dal profilo (TOML non scrive tabelle per
1397
+ // server sconosciuti): la vista lo dice al punto, invece di farlo
1398
+ // scoprire all'operatore quando la capability non risponde.
1399
+ if ((client === 'codex' || client === 'codex-vl') && eff.declared.ondemand?.length) {
1400
+ try {
1401
+ const toml = fs.readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8');
1402
+ const noti = new Set(codexSurfaceNames(toml).servers);
1403
+ for (const nome of eff.declared.ondemand.filter((n) => !noti.has(n))) {
1404
+ unsupported.push({ key: 'ondemand', name: nome, reason: `il server on-demand "${nome}" non e' dichiarato nella config utente del nodo: ignorato dal profilo` });
1405
+ }
1406
+ } catch (_) { /* config utente non leggibile: nessuna nota, non e' un errore di vista */ }
1407
+ }
1408
+ out.capabilities = { declared: eff.declared, ...(unsupported.length ? { unsupported } : {}) };
1409
+ }
1372
1410
  // cwdRel portatile derivato (solo vista): per celle valide (cwd reale
1373
1411
  // sotto home) espone il rel canonico; per celle persistite ma non
1374
1412
  // valide/fuori home espone needsRepair:true. NESSUNA mutazione di
@@ -1530,6 +1568,27 @@ async function createBuiltinFleet(cfg = {}) {
1530
1568
  // Nome leggibile, distinto dall'id: quest'ultimo resta la chiave di
1531
1569
  // indirizzamento e l'unica origine della sessione tmux.
1532
1570
  label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN },
1571
+ // Superficie operativa dichiarata della cella. Assenti entrambi = cella
1572
+ // legacy (comportamento di sempre). Il profilo alimenta le chiavi che
1573
+ // la cella non copre; l'override della cella sostituisce PER CHIAVE.
1574
+ capabilities: {
1575
+ type: 'object', required: false,
1576
+ mcp: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$' },
1577
+ skills: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
1578
+ ondemand: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
1579
+ },
1580
+ capabilityProfile: { type: 'string', required: false, ref: 'capabilityProfiles.id', max: 32 },
1581
+ },
1582
+ // Profili di ruolo condivisi del documento (top-level, accanto a
1583
+ // engines/cells): stessa forma del set di cella, referenziabili per nome.
1584
+ capabilityProfiles: {
1585
+ type: 'object', required: false, maxKeys: 32,
1586
+ keyPattern: '^[A-Za-z0-9._-]{1,32}$',
1587
+ valueShape: {
1588
+ mcp: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$' },
1589
+ skills: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
1590
+ ondemand: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
1591
+ },
1533
1592
  },
1534
1593
  };
1535
1594
  }
@@ -37,6 +37,9 @@ const CODES = [
37
37
  'CWD_INVALID',
38
38
  'ENGINE_UNCONFIGURED',
39
39
  'SHELL_NOT_AVAILABLE',
40
+ 'MCP_CELL_FILE_UNWRITABLE',
41
+ 'CAPABILITY_PROFILE_UNWRITABLE',
42
+ 'CAPABILITY_UNKNOWN_NAME',
40
43
  // launch-broker
41
44
  'LAUNCH_BROKER_UNSAFE',
42
45
  'LAUNCH_BROKER_PAYLOAD',
@@ -632,10 +632,10 @@ async function main(argv = process.argv.slice(2), seams = {}) {
632
632
  ? ['inherit', 'inherit', 'inherit', 'pipe', 'pipe']
633
633
  : ['inherit', 'inherit', 'inherit'],
634
634
  });
635
- // R1a (fix5): exit/error del figlio contano SUBITO, prima di qualunque
636
- // await — se il figlio esce durante l'handshake di generazione, l'evento
637
- // non deve andare perso (prima i listener arrivavano solo con waitChild,
638
- // e il supervisore restava appeso).
635
+ // exit/error del figlio contano SUBITO, prima di qualunque await — se
636
+ // il figlio esce durante l'handshake di generazione, l'evento non deve
637
+ // andare perso (prima i listener arrivavano solo con waitChild, e il
638
+ // supervisore restava appeso).
639
639
  let childSettled = false;
640
640
  const childExit = new Promise((resolve) => {
641
641
  current.once('exit', (code, signal) => {
@@ -649,16 +649,16 @@ async function main(argv = process.argv.slice(2), seams = {}) {
649
649
  });
650
650
  // Transizione di generazione comunicata al lease PRIMA di aprire il
651
651
  // canale identita' della generazione nuova. L'exit del figlio ha la
652
- // precedenza sull'annuncio; FAIL-CLOSED (R1b): solo un annuncio
653
- // RISOLTO con ok === true E generazione corrispondente apre il canale —
654
- // ok:false (lease-down, identity-unverified), rifiuto, timeout ed exit
655
- // restano tutti negativi, con sola diagnostica: il figlio parte
656
- // comunque, senza identita'.
652
+ // precedenza sull'annuncio; fail-closed: solo un annuncio RISOLTO con
653
+ // ok === true E generazione corrispondente apre il canale — ok:false
654
+ // (lease-down, identity-unverified), rifiuto, timeout ed exit restano
655
+ // tutti negativi, con sola diagnostica: il figlio parte comunque,
656
+ // senza identita'.
657
657
  if (leaseCtl && generation > 0) {
658
658
  const raced = await Promise.race([
659
- // fix 6 (R1b-bis): il valore RISOLTO va ispezionato.
660
- // Prima: .then(() => 'ok', ...) scartava il valore e trattava un
661
- // annuncio risolto {ok:false} come successo, aprendo il canale.
659
+ // Il valore RISOLTO va ispezionato: .then(() => 'ok', ...) scartava
660
+ // il valore e trattava un annuncio risolto {ok:false} come successo,
661
+ // aprendo il canale.
662
662
  leaseCtl.announceGeneration(generation).then(
663
663
  (announced) => (announced && announced.ok === true && announced.generation === generation
664
664
  ? 'ok'
@@ -426,10 +426,10 @@ function createLeaseManager(cfg = {}, seams = {}) {
426
426
  writeSafe(socket, { type: 'generationDeny', generation: msg.generation });
427
427
  return;
428
428
  }
429
- // R1b: l'avanzamento PRIMA dell'ACK e' voluto (at-least-once): se l'ACK
430
- // si perde, il client ritenta con la STESSA generazione e qui e'
431
- // idempotente (== corrente -> ACK). L'ordine inverso lascerebbe il server
432
- // stantio con un client che crede di aver transizionato.
429
+ // L'avanzamento PRIMA dell'ACK e' voluto (at-least-once): se l'ACK si
430
+ // perde, il client ritenta con la STESSA generazione e qui e' idempotente
431
+ // (== corrente -> ACK). L'ordine inverso lascerebbe il server stantio con
432
+ // un client che crede di aver transizionato.
433
433
  entry.lease.generation = msg.generation;
434
434
  writeSafe(socket, { type: 'generationAck', generation: msg.generation });
435
435
  }
@@ -39,6 +39,18 @@ const MCP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
39
39
  const MAX_CELL_COMMAND_LEN = 4096;
40
40
  const MAX_TMUXSESSION_LEN = 64;
41
41
 
42
+ // Capacita' dichiarate di una cella, per NOME (come `mcp`, che ne e' il caso
43
+ // particolare e l'alias legacy). Tre chiavi: `mcp` (server MCP), `skills`,
44
+ // `ondemand`. Profili di ruolo condivisi in `capabilityProfiles` (top-level del
45
+ // documento); la cella riferisce un profilo e puo' sostituire SINGOLE CHIAVI
46
+ // con il proprio set: l'override e' sostitutivo, non additivo, perche' unire
47
+ // non saprebbe togliere.
48
+ const MAX_CAPABILITIES = 64; // nomi per singola chiave
49
+ const MAX_CAPABILITY_PROFILES = 32; // profili dichiarabili nel documento
50
+ const CAPABILITY_KEYS = ['mcp', 'skills', 'ondemand'];
51
+ const CAPABILITY_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/; // skill/ondemand: slug
52
+ const CAPABILITY_KEY_RES = { mcp: MCP_NAME_RE, skills: CAPABILITY_NAME_RE, ondemand: CAPABILITY_NAME_RE };
53
+
42
54
  const ENGINE_ID_RE = /^[a-z0-9._-]{1,32}$/; // engine id: lowercase (design 4a/9f)
43
55
  const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/; // cell id: ammette maiuscole (il punto e' un id umano valido)
44
56
  const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; // identificatore env POSIX-like
@@ -279,6 +291,30 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true, onReject = null, o
279
291
  engines.push(eng);
280
292
  }
281
293
 
294
+ // I profili di capability si leggono PRIMA delle celle: una cella riferisce
295
+ // un profilo per NOME, e leggerli dopo la farebbe rifiutare per un dato che
296
+ // in realta' c'e'. `capabilityProfiles` e' opzionale: senza, il documento
297
+ // resta com'era (chiave assente, non vuota).
298
+ let capabilityProfiles;
299
+ if (d.capabilityProfiles !== undefined) {
300
+ if (!d.capabilityProfiles || typeof d.capabilityProfiles !== 'object' || Array.isArray(d.capabilityProfiles)) return null;
301
+ const voci = Object.entries(d.capabilityProfiles);
302
+ if (voci.length > MAX_CAPABILITY_PROFILES) {
303
+ if (typeof onIssue === 'function') onIssue({ field: 'capabilityProfiles', value: voci.length, rule: `troppi profili di capability dichiarati, cap ${MAX_CAPABILITY_PROFILES}` });
304
+ return reject(`fleet.json rifiutato: ${voci.length} profili di capability dichiarati, cap ${MAX_CAPABILITY_PROFILES}; riduci i profili a ${MAX_CAPABILITY_PROFILES} o meno`);
305
+ }
306
+ capabilityProfiles = {};
307
+ for (const [nome, profilo] of voci) {
308
+ // Il nome e' un identificatore (finira' in riferimento per cella e, poi,
309
+ // in un nome di file generato): stessa forma chiusa dell'id cella.
310
+ if (typeof nome !== 'string' || !CELL_ID_RE.test(nome) || capabilityProfiles[nome] !== undefined) return null;
311
+ const set = parseCapabilitySet(profilo, onIssue);
312
+ if (!set) return null;
313
+ capabilityProfiles[nome] = set;
314
+ }
315
+ }
316
+ const capabilityProfileNames = capabilityProfiles ? new Set(Object.keys(capabilityProfiles)) : null;
317
+
282
318
  const tmuxSeen = new Set();
283
319
  const legacyTmuxSeen = new Set();
284
320
  const legacyTmuxSessions = new Map();
@@ -286,7 +322,7 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true, onReject = null, o
286
322
  const cells = [];
287
323
  for (const c of d.cells) {
288
324
  const cell = parseCell(c, engineIds, engineMap, {
289
- allowLegacyTmuxNames, extraModels, onReject: reject, onIssue,
325
+ allowLegacyTmuxNames, extraModels, onReject: reject, onIssue, capabilityProfileNames,
290
326
  });
291
327
  if (!cell) return null;
292
328
  if (cellIds.has(cell.id)) return null; // id cell univoco
@@ -301,11 +337,12 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true, onReject = null, o
301
337
  cells.push(cell);
302
338
  }
303
339
 
304
- // `models` compare solo se dichiarato: una configurazione senza modelli
305
- // resta byte-identica a prima, e l'aggiornamento non riscrive nulla.
306
- const parsed = models.length
307
- ? { schemaVersion: SCHEMA_VERSION, engines, cells, models }
308
- : { schemaVersion: SCHEMA_VERSION, engines, cells };
340
+ // `models` e `capabilityProfiles` compaiono solo se dichiarati: una
341
+ // configurazione senza resta byte-identica a prima, e l'aggiornamento non
342
+ // riscrive nulla.
343
+ const parsed = { schemaVersion: SCHEMA_VERSION, engines, cells };
344
+ if (models.length) parsed.models = models;
345
+ if (capabilityProfiles) parsed.capabilityProfiles = capabilityProfiles;
309
346
  // Metadato solo in memoria: serve al bootstrap per rinominare una sessione
310
347
  // legacy PRIMA di persistere il nome safe. Non entra in JSON, draft o API.
311
348
  Object.defineProperty(parsed, 'legacyTmuxSessions', {
@@ -462,8 +499,50 @@ function cellModelAllowed(engineId, model, engineMap, extraModels, onIssue) {
462
499
  return !!normalizeManagedSpec({ ...engine.managed, model }, { extraModels, engineId, onIssue });
463
500
  }
464
501
 
502
+ // Un set di capability (di profilo o di cella): oggetto le cui chiavi ammesse
503
+ // sono CAPABILITY_KEYS, ciascuna un array di nomi VALIDATI PER CHIAVE, senza
504
+ // duplicati, sotto cap. Chiave non ammessa -> rifiuto dell'intero set: come
505
+ // per panelUrl, due esiti opposti che si assomigliano non collassano in uno.
506
+ function parseCapabilitySet(v, onIssue = null) {
507
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
508
+ const out = {};
509
+ for (const key of Object.keys(v)) {
510
+ if (!CAPABILITY_KEYS.includes(key)) return null;
511
+ const arr = v[key];
512
+ if (!Array.isArray(arr) || arr.length > MAX_CAPABILITIES) {
513
+ if (typeof onIssue === 'function') onIssue({ field: `capabilities.${key}`, value: Array.isArray(arr) ? arr.length : -1, rule: `troppi nomi, cap ${MAX_CAPABILITIES}` });
514
+ return null;
515
+ }
516
+ const visti = new Set();
517
+ for (const nome of arr) {
518
+ if (typeof nome !== 'string' || !CAPABILITY_KEY_RES[key].test(nome) || visti.has(nome)) return null;
519
+ visti.add(nome);
520
+ }
521
+ out[key] = [...visti];
522
+ }
523
+ return out;
524
+ }
525
+
526
+ // La superficie effettiva di una cella: il profilo alimenta le chiavi che la
527
+ // cella non copre, e l'override della cella SOSTITUISCE per chiave (unire non
528
+ // saprebbe togliere). LEGACY = nessun campo: la cella di sempre, comportamento
529
+ // invariato. Pura: la vista e i materializzatori la usano senza toccare nulla.
530
+ function effectiveCapabilities(cell, defs) {
531
+ const profili = (defs && defs.capabilityProfiles) || {};
532
+ const profilo = cell && cell.capabilityProfile !== undefined ? profili[cell.capabilityProfile] : undefined;
533
+ const declared = {};
534
+ let any = false;
535
+ for (const key of CAPABILITY_KEYS) {
536
+ const source = (cell && cell.capabilities && cell.capabilities[key] !== undefined) ? cell.capabilities[key]
537
+ : (profilo && profilo[key] !== undefined) ? profilo[key] : undefined;
538
+ if (source !== undefined) { declared[key] = [...source]; any = true; }
539
+ }
540
+ return { legacy: cell?.capabilities === undefined && cell?.capabilityProfile === undefined, declared };
541
+ }
542
+
465
543
  function parseCell(c, engineIds, engineMap = new Map(), {
466
544
  allowLegacyTmuxNames = true, extraModels = null, onReject = null, onIssue = null,
545
+ capabilityProfileNames = null,
467
546
  } = {}) {
468
547
  const reject = (message) => {
469
548
  if (typeof onReject === 'function') onReject(message);
@@ -609,6 +688,26 @@ function parseCell(c, engineIds, engineMap = new Map(), {
609
688
  mcp = [...visti];
610
689
  }
611
690
 
691
+ // capabilities / capabilityProfile: la superficie operativa DICHIARATA della
692
+ // cella. `capabilities` e' un set per chiave (`mcp`/`skills`/`ondemand`,
693
+ // parseCapabilitySet sopra); `capabilityProfile` riferisce un profilo
694
+ // top-level del documento — DEVE esistere: un profilo dangling rifiuta la
695
+ // cella, non collassa in "niente profilo" (stesso motivo di panelUrl).
696
+ // Assenti entrambi = cella legacy: comportamento invariato (effectiveCapabilities).
697
+ let capabilities;
698
+ if (c.capabilities !== undefined) {
699
+ capabilities = parseCapabilitySet(c.capabilities, onIssue);
700
+ if (!capabilities) return null;
701
+ }
702
+ let capabilityProfile;
703
+ if (c.capabilityProfile !== undefined) {
704
+ if (typeof c.capabilityProfile !== 'string' || !CELL_ID_RE.test(c.capabilityProfile)) return null;
705
+ if (!capabilityProfileNames || !capabilityProfileNames.has(c.capabilityProfile)) {
706
+ return reject(`fleet.json rifiutato: la cella ${c.id} riferisce il profilo di capability "${c.capabilityProfile}" che non e' dichiarato in capabilityProfiles`);
707
+ }
708
+ capabilityProfile = c.capabilityProfile;
709
+ }
710
+
612
711
  // label (opzionale): nome LEGGIBILE della cella, distinto dall'id.
613
712
  // L'id resta la chiave stabile con cui si indirizza una cella e con cui si
614
713
  // deriva la sessione tmux; la label e' solo cio' che un umano legge. Senza
@@ -670,6 +769,8 @@ function parseCell(c, engineIds, engineMap = new Map(), {
670
769
  if (prompt !== undefined) out.prompt = prompt;
671
770
  if (panelUrl !== undefined) out.panelUrl = panelUrl;
672
771
  if (mcp !== undefined) out.mcp = mcp;
772
+ if (capabilities !== undefined) out.capabilities = capabilities;
773
+ if (capabilityProfile !== undefined) out.capabilityProfile = capabilityProfile;
673
774
  if (label !== undefined) out.label = label;
674
775
  if (legacyTmuxSession) {
675
776
  Object.defineProperty(out, 'legacyTmuxSession', {
@@ -1098,6 +1199,8 @@ const CAPS = {
1098
1199
 
1099
1200
  module.exports = {
1100
1201
  parseDefinitions,
1202
+ effectiveCapabilities,
1203
+ parseCapabilitySet,
1101
1204
  validateCommandTrust,
1102
1205
  aggiornaDefinizioni,
1103
1206
  proprietarioVivo,
@@ -1612,6 +1612,188 @@ function cellMcpArgs(home, cell) {
1612
1612
  return [`--settings=${JSON.stringify({ permissions: { deny } })}`];
1613
1613
  }
1614
1614
 
1615
+ // Definizioni MCP note, per NOME -> definizione (la prima sorgente che dichiara
1616
+ // il nome vince). Le SORGENTI sono le stesse dei nomi in `knownMcpServerNames`
1617
+ // (config utente, suo ramo per-progetto, .mcp.json della cwd), precedute da
1618
+ // eventuali file extra — per i profili Claude isolati la propria config, che
1619
+ // altrimenti sparirebbe dall'enumerazione. Solo LETTURA: le definizioni non
1620
+ // vengono mai modificate, riprese e ridistribuite tali quali.
1621
+ function collectMcpServerDefs(home, cwd, extraFiles = []) {
1622
+ const leggi = (file) => {
1623
+ try {
1624
+ const st = fs.lstatSync(file);
1625
+ if (st.isSymbolicLink() || !st.isFile() || st.size > 8 * 1024 * 1024) return null;
1626
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
1627
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
1628
+ } catch (_) { return null; /* assente o illeggibile non e' un errore qui */ }
1629
+ };
1630
+ const defs = new Map();
1631
+ const raccogli = (servers) => {
1632
+ if (servers && typeof servers === 'object' && !Array.isArray(servers)) {
1633
+ for (const [nome, def] of Object.entries(servers)) {
1634
+ if (def && typeof def === 'object' && !Array.isArray(def) && !defs.has(nome)) defs.set(nome, def);
1635
+ }
1636
+ }
1637
+ };
1638
+ for (const file of extraFiles) {
1639
+ const parsed = typeof file === 'string' ? leggi(file) : null;
1640
+ if (parsed) raccogli(parsed.mcpServers);
1641
+ }
1642
+ const utente = leggi(path.join(home, '.claude.json'));
1643
+ if (utente) {
1644
+ raccogli(utente.mcpServers);
1645
+ const progetti = utente.projects;
1646
+ if (typeof cwd === 'string' && cwd && progetti && typeof progetti === 'object' && !Array.isArray(progetti)) {
1647
+ const voce = progetti[cwd];
1648
+ if (voce && typeof voce === 'object' && !Array.isArray(voce)) raccogli(voce.mcpServers);
1649
+ }
1650
+ }
1651
+ if (typeof cwd === 'string' && cwd) {
1652
+ const progetto = leggi(path.join(cwd, '.mcp.json'));
1653
+ if (progetto) raccogli(progetto.mcpServers);
1654
+ }
1655
+ return defs;
1656
+ }
1657
+
1658
+ // Il file MCP PER CELLA del materializzatore: contiene le definizioni dei SOLO
1659
+ // server concessi, copiate tali quali dalle sorgenti (che portano `env` con
1660
+ // credenziali per alcuni server). Duplicare quelle definizioni e' il prezzo
1661
+ // del sottoinsieme esatto — il file per profilo unico non puo' esprimere un
1662
+ // sottoinsieme per cella senza un allowlist del client, e l'allowlist esclusiva
1663
+ // vuole proprio un file. Il prezzo si paga nel regime di permessi piu' stretto
1664
+ // che esiste fuori dallo store credenziali: directory 0700, file 0600, tutto
1665
+ // sotto ~/.nexuscrew (la stessa classe di privacy di providers.env), scritto
1666
+ // atomicamente e RIGENERATO a ogni up (nessun valore accumulato, nessuna
1667
+ // copia stantia), mai loggato: il contenuto non tocca nessun log né output.
1668
+ // DICHARIAZIONE DI PROVENIENZA: questo file e' DERIVATO — la fonte di verita'
1669
+ // restano le sorgenti originali, e la provenienza delle credenziali (doctor)
1670
+ // non lo elenca e non lo considerera' mai una sorgente di conflitto.
1671
+ function writeCellMcpConfig(cellId, concessi, home, defs) {
1672
+ const dir = path.join(home, '.nexuscrew', 'cell-mcp');
1673
+ try {
1674
+ const st = fs.lstatSync(dir);
1675
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new Error('unsafe per-cell MCP directory');
1676
+ // Strettisce, mai allarga: permessi piu' larghi di 0700 tornano a 0700; una
1677
+ // directory resa NON scrivibile (operatore, manutenzione) resta tale — e
1678
+ // il fallimento di scrittura e' il rifiuto del launch, non un launch con
1679
+ // la superficie sbagliata.
1680
+ if (st.mode & 0o077) fs.chmodSync(dir, 0o700);
1681
+ } catch (e) {
1682
+ if (e.code === 'ENOENT') fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1683
+ else throw e;
1684
+ }
1685
+ const servers = {};
1686
+ // Il canale operatore (nexuscrew) e' CORE: entra nel file anche se la cella
1687
+ // non lo ha nominato — senza, la cella dichiarata perderebbe `nc_*`.
1688
+ const nomi = new Set(concessi);
1689
+ if (defs.has('nexuscrew')) nomi.add('nexuscrew');
1690
+ for (const nome of [...nomi].sort()) {
1691
+ const def = defs.get(nome);
1692
+ if (!def) continue; // difesa residua: il preflight ora rifiuta i nomi dichiarati sconosciuti prima di arrivare qui
1693
+ servers[nome] = def;
1694
+ }
1695
+ const target = path.join(dir, `${cellId}.json`);
1696
+ const tmp = path.join(dir, `.${cellId}.${crypto.randomBytes(6).toString('hex')}.tmp`);
1697
+ try {
1698
+ fs.writeFileSync(tmp, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`, { mode: 0o600 });
1699
+ fs.chmodSync(tmp, 0o600);
1700
+ fs.renameSync(tmp, target);
1701
+ } catch (e) {
1702
+ try { fs.unlinkSync(tmp); } catch (_) {}
1703
+ throw e;
1704
+ }
1705
+ return target;
1706
+ }
1707
+
1708
+ // La superficie codex NOTA alla config utente, letta con un parse TOML
1709
+ // minimale e DELIBERATAMENTE superficie: servono solo i NOMI dei server
1710
+ // (`[mcp_servers.<nome>]`) e delle skill (`[[skills.config]]` con name) da
1711
+ // poter spegnere nel profilo per cella. La config utente non viene mai
1712
+ // toccata: il profilo e' un LAYER di riduzione che la legge e basta.
1713
+ function codexSurfaceNames(configToml) {
1714
+ const testo = typeof configToml === 'string' ? configToml : '';
1715
+ const servers = [...testo.matchAll(/^[ \t]*\[mcp_servers\.([A-Za-z0-9_-]+)\][ \t]*$/gm)].map((m) => m[1]);
1716
+ const skills = [...testo.matchAll(/\[\[skills\.config\]\]([^[]*)/g)]
1717
+ .map((m) => (m[1].match(/^[ \t]*name[ \t]*=[ \t]*"([^"]+)"/m) || [])[1])
1718
+ .filter(Boolean);
1719
+ return { servers: [...new Set(servers)], skills: [...new Set(skills)] };
1720
+ }
1721
+
1722
+ // Il profilo per cella di codex/codex-vl (profilo v2: <CODEX_HOME>/<nome>
1723
+ // .config.toml, lanciato con -p). STESSA SEMANTICA DEL CLAUDE, tradotta nei
1724
+ // meccanismi nativi del client: la chiave `mcp` spegne i server NON concessi
1725
+ // (enabled = false — i concessi non si ridichiarano, ereditano dal layer
1726
+ // base); `ondemand` mette omit_tools_from = ["direct"] (scopribili via tool
1727
+ // search, non attivi); `skills` spegne le skill NON concesse enumerate nella
1728
+ // config. Chiave assente = nessuna riduzione per quella chiave. RESIDUO
1729
+ // DICHIARATO: server/skill definiti fuori dalla config utente (config di
1730
+ // progetto, config gestita) non sono enumerabili da qui e restano — come per
1731
+ // il claude legacy, chi vuole il caso esatto dichiara e verifica. Il file e'
1732
+ // atomico, 0600, rigenerato a ogni up, mai loggato; la config utente non e'
1733
+ // mai modificata.
1734
+ // La config utente di codex letta con gli stessi criteri del materializzatore:
1735
+ // mai modificata, mai loggata; assente/symlink/oversize = catalogo vuoto.
1736
+ function readCodexUserToml(codexHome) {
1737
+ try {
1738
+ const st = fs.lstatSync(path.join(codexHome, 'config.toml'));
1739
+ if (!st.isSymbolicLink() && st.isFile() && st.size <= 8 * 1024 * 1024) {
1740
+ return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
1741
+ }
1742
+ } catch (_) { /* config assente: niente da enumerare, il profilo e' vuoto */ }
1743
+ return '';
1744
+ }
1745
+
1746
+ function writeCellCodexProfile(cellId, declared, codexHome) {
1747
+ const nome = `nexuscrew-${String(cellId).replace(/\./g, '_')}`;
1748
+ const target = path.join(codexHome, `${nome}.config.toml`);
1749
+ const configToml = readCodexUserToml(codexHome);
1750
+ const { servers, skills } = codexSurfaceNames(configToml);
1751
+ const mcpConcessi = new Set(declared.mcp || []);
1752
+ const ondemand = new Set(declared.ondemand || []);
1753
+ const skillConcesse = new Set(declared.skills || []);
1754
+ const righe = [
1755
+ '# Generated by NexusCrew for this cell: reduces (never widens) the surface',
1756
+ '# declared in config.toml, which NexusCrew never modifies.',
1757
+ ];
1758
+ if (declared.mcp !== undefined) {
1759
+ for (const s of servers.filter((n) => !mcpConcessi.has(n)).sort()) {
1760
+ righe.push('', `[mcp_servers.${s}]`, 'enabled = false');
1761
+ }
1762
+ }
1763
+ // `omit_tools_from` NON e' una chiave di root: in codex e' un campo della
1764
+ // tabella del SINGOLO server (codex-rs/config/src/mcp_types.rs:223/:364) —
1765
+ // a root viene ignorato in silenzio e l'on-demand non funzionerebbe mai.
1766
+ // Un server on-demand e' CONCESSO ma differito: enabled resta il default e
1767
+ // si scrive solo la chiave di differimento; uno spento (non concesso con
1768
+ // `mcp` dichiarata) non prende una seconda tabella: TOML vieta i duplicati
1769
+ // e enabled = false resta il volere piu' restrittivo. Nomi ondemand non
1770
+ // presenti nella config utente non generano tabelle (ignorati; la vista li
1771
+ // segnala).
1772
+ if (ondemand.size) {
1773
+ const spenti = declared.mcp !== undefined
1774
+ ? new Set(servers.filter((n) => !mcpConcessi.has(n)))
1775
+ : new Set();
1776
+ for (const s of servers.filter((n) => ondemand.has(n) && !spenti.has(n)).sort()) {
1777
+ righe.push('', `[mcp_servers.${s}]`, 'omit_tools_from = ["direct"]');
1778
+ }
1779
+ }
1780
+ if (declared.skills !== undefined) {
1781
+ for (const s of skills.filter((n) => !skillConcesse.has(n)).sort()) {
1782
+ righe.push('', '[[skills.config]]', `name = "${s}"`, 'enabled = false');
1783
+ }
1784
+ }
1785
+ const tmp = path.join(codexHome, `.${nome}.${crypto.randomBytes(6).toString('hex')}.tmp`);
1786
+ try {
1787
+ fs.writeFileSync(tmp, `${righe.join('\n')}\n`, { mode: 0o600 });
1788
+ fs.chmodSync(tmp, 0o600);
1789
+ fs.renameSync(tmp, target);
1790
+ } catch (e) {
1791
+ try { fs.unlinkSync(tmp); } catch (_) {}
1792
+ throw e;
1793
+ }
1794
+ return target;
1795
+ }
1796
+
1615
1797
  function ensurePrivateClaudeConfig(home, profileId, label, penguinMode) {
1616
1798
  const nexusDir = path.join(home, '.nexuscrew');
1617
1799
  const profilesDir = path.join(nexusDir, 'claude-profiles');
@@ -2082,9 +2264,43 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2082
2264
  // In coda di proposito: `--mcp-config` e' variadico e consuma ogni token
2083
2265
  // che non inizi per `-`. Messo qui non ha nulla da inghiottire.
2084
2266
  if (privateProfile) args.push(...sharedMcpArgs(home));
2085
- // Vale per OGNI cella Claude, non solo per i profili isolati: il filtro
2086
- // agisce sui nomi dei tool, non su dove i server sono definiti.
2087
- args.push(...cellMcpArgs(home, cell));
2267
+ // Tre strade. LEGACY (niente capabilities, niente profilo): il filtro per
2268
+ // NEGAZIONE di oggi, che agisce sui nomi dei tool e lascia le sorgenti
2269
+ // dove sono. CELLA DICHIARATA con `mcp` (anche vuoto): allowlist ESCLUSIVA
2270
+ // — un file per cella con i soli server concessi (+ il canale operatore,
2271
+ // sempre) e `--strict-mcp-config`, che esclude anche cio' che il deny non
2272
+ // sa enumerare (plugin, connettori). CHIAVE `mcp` ASSENTE ma cella
2273
+ // dichiarata (solo skills/ondemand): comportamento di sempre per QUESTA
2274
+ // chiave — l'alias `cell.mcp` resta il filtro per negazione, in linea con
2275
+ // l'override sostitutivo per chiave del modello.
2276
+ // FAIL-CLOSED: se il file per cella non si scrive la cella NON parte.
2277
+ // Partire senza la allowlist esporrebbe l'intera superficie proprio dove
2278
+ // l'operatore l'ha ristretta: peggio della legacy, e senza che nessuno
2279
+ // l'abbia chiesto.
2280
+ const { effectiveCapabilities } = require('./definitions.js');
2281
+ const eff = effectiveCapabilities(cell || {}, { capabilityProfiles: cfg.capabilityProfiles || null });
2282
+ if (eff.legacy || eff.declared.mcp === undefined) {
2283
+ args.push(...cellMcpArgs(home, cell));
2284
+ } else {
2285
+ let file;
2286
+ try {
2287
+ const extraFiles = typeof env.CLAUDE_CONFIG_DIR === 'string'
2288
+ ? [path.join(env.CLAUDE_CONFIG_DIR, '.claude.json')] : [];
2289
+ const defs = collectMcpServerDefs(home, cell?.cwd, extraFiles);
2290
+ // Un nome dichiarato che il client non definisce sparisce in silenzio
2291
+ // dal file strict: rifiuta qui, prima di scrivere qualsiasi cosa.
2292
+ const sconosciute = eff.declared.mcp.filter((n) => !defs.has(n));
2293
+ if (sconosciute.length) {
2294
+ const motivo = `capability mcp sconosciuta al client: ${sconosciute.join(', ')}`;
2295
+ return { ok: false, reason: motivo, mcpCellRefused: motivo, mcpCellRefusedCode: 'CAPABILITY_UNKNOWN_NAME' };
2296
+ }
2297
+ file = writeCellMcpConfig(String(cell?.id || 'cella'), eff.declared.mcp, home, defs);
2298
+ } catch (e) {
2299
+ const motivo = `file MCP per cella non scrivibile: ${e.message}`;
2300
+ return { ok: false, reason: motivo, mcpCellRefused: motivo };
2301
+ }
2302
+ args.push('--strict-mcp-config', `--mcp-config=${file}`);
2303
+ }
2088
2304
  } else if (spec.client === 'codex' || spec.client === 'codex-vl') {
2089
2305
  if (profile.localProvider) args.push('--oss', '--local-provider', profile.localProvider);
2090
2306
  else if (spec.provider === 'openai-api') env.OPENAI_API_KEY = cred.value;
@@ -2180,6 +2396,43 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2180
2396
  }
2181
2397
  }
2182
2398
  if (model) args.push('-m', model);
2399
+ // Materializzatore per cella (stesse regole del ramo claude, meccanismi
2400
+ // nativi del client): profilo v2 sotto CODEX_HOME lanciato con -p. Il
2401
+ // profilo RIDUCE la superficie della config utente (mai toccata);
2402
+ // FAIL-CLOSED come il claude: profilo non scrivibile = launch rifiutato,
2403
+ // mai una cella con la superficie intera dove l'operatore ha ristretto.
2404
+ {
2405
+ const { effectiveCapabilities } = require('./definitions.js');
2406
+ const eff = effectiveCapabilities(cell || {}, { capabilityProfiles: cfg.capabilityProfiles || null });
2407
+ const daMaterializzare = eff.declared.mcp !== undefined || eff.declared.skills !== undefined || eff.declared.ondemand !== undefined;
2408
+ if (!eff.legacy && daMaterializzare) {
2409
+ // Stessa regola del ramo claude: un nome dichiarato che la config
2410
+ // utente non definisce sparisce in silenzio dal profilo — rifiuta
2411
+ // qui, prima di scrivere qualsiasi cosa. Catalogo vuoto (config
2412
+ // assente/vuota) + nomi dichiarati = nomi sconosciuti; `mcp: []`
2413
+ // resta valido (niente nomi da confrontare).
2414
+ const { servers, skills } = codexSurfaceNames(readCodexUserToml(path.join(home, '.codex')));
2415
+ const parti = [];
2416
+ const sconosciuteMcp = (eff.declared.mcp || []).filter((n) => !servers.includes(n));
2417
+ const sconosciuteOndemand = (eff.declared.ondemand || []).filter((n) => !servers.includes(n));
2418
+ const sconosciuteSkills = (eff.declared.skills || []).filter((n) => !skills.includes(n));
2419
+ if (sconosciuteMcp.length) parti.push(`capability mcp sconosciuta al client: ${sconosciuteMcp.join(', ')}`);
2420
+ if (sconosciuteOndemand.length) parti.push(`capability ondemand sconosciuta al client: ${sconosciuteOndemand.join(', ')}`);
2421
+ if (sconosciuteSkills.length) parti.push(`capability skills sconosciuta al client: ${sconosciuteSkills.join(', ')}`);
2422
+ if (parti.length) {
2423
+ const motivo = parti.join('; ');
2424
+ return { ok: false, reason: motivo, mcpCellRefused: motivo, mcpCellRefusedCode: 'CAPABILITY_UNKNOWN_NAME' };
2425
+ }
2426
+ let file;
2427
+ try {
2428
+ file = writeCellCodexProfile(String(cell?.id || 'cella'), eff.declared, path.join(home, '.codex'));
2429
+ } catch (e) {
2430
+ const motivo = `profilo per cella non scrivibile: ${e.message}`;
2431
+ return { ok: false, reason: motivo, mcpCellRefused: motivo, mcpCellRefusedCode: 'CAPABILITY_PROFILE_UNWRITABLE' };
2432
+ }
2433
+ args.push('-p', path.basename(file).replace(/\.config\.toml$/, ''));
2434
+ }
2435
+ }
2183
2436
  } else if (spec.client === 'pi') {
2184
2437
  if (profile.auth !== 'none' && profile.auth !== 'login' && cred.value) env[cred.envKey] = cred.value;
2185
2438
  if (spec.provider === 'custom') args.push('--extension', writePiProviderExtension(spec, home, declaredModels));
@@ -2434,6 +2687,7 @@ module.exports = {
2434
2687
  CLIENT_LABELS, normalizeManagedSpec, profileFor,
2435
2688
  defaultDefinitions, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
2436
2689
  discoverPiModels, EXTERNAL_DISCOVERY_TIMEOUT_MS, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension, customCatalogFor,
2690
+ codexSurfaceNames, writeCellCodexProfile,
2437
2691
  writeVlCellPrompt, vlSystemAppendGate,
2438
2692
  extraModelsFrom, declaredModelsFor, declaredFor,
2439
2693
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential, CREDENTIAL_SOURCE_VALUES,
@@ -241,7 +241,13 @@ function createBuiltinRuntime(ctx) {
241
241
  if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
242
242
  let launchEngine = engine;
243
243
  if (engine.managed) {
244
- const resolved = resolveManagedEngine(engine, cell, { ...cfg, home, extraModels: extraModelsFrom(defs) });
244
+ const resolved = resolveManagedEngine(engine, cell, { ...cfg, home, extraModels: extraModelsFrom(defs), capabilityProfiles: defs.capabilityProfiles || null });
245
+ // Il materializzatore MCP per cella e' FAIL-CLOSED: se il file per cella
246
+ // non si scrive, la cella non parte — mai con la superficie intera dove
247
+ // l'operatore l'ha ristretta.
248
+ if (resolved.mcpCellRefused) {
249
+ throw httpError(500, resolved.mcpCellRefused, null, { phase: 'preflight', code: resolved.mcpCellRefusedCode || 'MCP_CELL_FILE_UNWRITABLE' });
250
+ }
245
251
  if (!resolved.ok) {
246
252
  const code = engine.managed.client === 'shell' ? 'SHELL_NOT_AVAILABLE' : 'ENGINE_UNCONFIGURED';
247
253
  throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });