@mmmbuto/nexuscrew 0.9.76 → 0.9.78

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.
@@ -15,6 +15,7 @@ const { argomentiHookCodex, gateVersione, gatePiattaforma, EVENTI_CODEX } = requ
15
15
 
16
16
  const OLLAMA_CLOUD_MODELS = Object.freeze([
17
17
  'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
18
+ 'minimax-m3.1-flash',
18
19
  'qwen3.5:397b', 'deepseek-v4-flash', 'mistral-large-3:675b', 'gemma4:31b',
19
20
  // glm-5.3-flash: disponibile su ollama.com/library (2026-08-27; glm-5.3
20
21
  // era ancora 404). Gia` usata da celle esistenti su questo engine: senza
@@ -62,6 +63,10 @@ const OLLAMA_CONTEXT = Object.freeze({
62
63
  // deepseek-v4-pro: scheda «1M context» (era 524288: META' del reale).
63
64
  'deepseek-v4-pro': 1048576,
64
65
  'minimax-m3': 524288,
66
+ // minimax-m3.1-flash: contesto dichiarato 1M (1000000); a differenza di
67
+ // minimax-m3 (dove il campo strutturato dice 512K -> 524288) qui la scheda
68
+ // dichiara 1M, quindi 1000000.
69
+ 'minimax-m3.1-flash': 1000000,
65
70
  'qwen3.5:397b': 262144, 'deepseek-v4-flash': 1048576,
66
71
  'mistral-large-3:675b': 262144, 'gemma4:31b': 262144,
67
72
  // glm-5.3-flash: scheda «1M context» + concorde zhipuai/zai/opencode-go.
@@ -308,7 +313,7 @@ const ALIBABA_PI_MODELS = Object.freeze([
308
313
  const OPENCODE_GO_MESSAGES_MODELS = Object.freeze([
309
314
  // Misurati e2e 2026-08-11 (matrice completa nel commento sopra):
310
315
  'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro',
311
- 'glm-5.2', 'glm-5.1', 'minimax-m3', 'minimax-m2.7',
316
+ 'glm-5.2', 'glm-5.1', 'minimax-m3', 'minimax-m3.1-flash', 'minimax-m2.7',
312
317
  'qwen3.8-max', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus',
313
318
  // Signal-only 2026-09-12 (CreditsError, nessun rifiuto formato):
314
319
  'glm-5.3', 'glm-5.3-flash', 'longcat-2.0', 'deepseek-v4.1-flash',
@@ -327,7 +332,7 @@ const OPENCODE_GO_CHAT_MODELS = Object.freeze([
327
332
  // Misurati e2e 2026-08-11:
328
333
  'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro',
329
334
  'glm-5.2', 'glm-5.1', 'kimi-k3', 'kimi-k2.7-code', 'kimi-k2.6',
330
- 'minimax-m3', 'minimax-m2.7', 'qwen3.8-max', 'qwen3.7-max',
335
+ 'minimax-m3', 'minimax-m3.1-flash', 'minimax-m2.7', 'qwen3.8-max', 'qwen3.7-max',
331
336
  'qwen3.7-plus', 'qwen3.6-plus', 'mimo-v2.5', 'mimo-v2.5-pro', 'hy3',
332
337
  'gpt-5.6-luna',
333
338
  // Signal-only 2026-09-12:
@@ -359,6 +364,7 @@ const OPENCODE_GO_LIMITS = Object.freeze({
359
364
  'kimi-k2.7-code': { context: 262144, output: 262144 },
360
365
  'kimi-k2.6': { context: 262144, output: 65536 },
361
366
  'minimax-m3': { context: 1000000, output: 131072 },
367
+ 'minimax-m3.1-flash': { context: 1000000, output: 131072 },
362
368
  'minimax-m2.7': { context: 204800, output: 131072 },
363
369
  'qwen3.8-max': { context: 1000000, output: 131072 },
364
370
  'qwen3.7-max': { context: 1000000, output: 65536 },
@@ -417,7 +423,29 @@ const OPENCODE_GO_PI_MODELS = Object.freeze(OPENCODE_GO_CHAT_MODELS.map((id) =>
417
423
  })));
418
424
 
419
425
  const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
420
- const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', 'credentialSourcePolicy', 'envPassthrough', ...CUSTOM_KEYS]);
426
+ // VL-only engine fields (client 'vl'): resolved/validated at launch in the vl
427
+ // branch of resolveManagedEngine, shaped here. See the vl section of
428
+ // normalizeManagedSpec for the shape contract of each.
429
+ const VL_KEYS = ['vlBinary', 'vlProfile', 'vlConfigFile', 'effort'];
430
+ // VL_* env names that belong to the ENGINE's own choice channels (variant,
431
+ // profile, model, effort, prompt file). An envPassthrough carrying one of
432
+ // these would silently FIGHT the composed decision: the passthrough loop runs
433
+ // after the vl branch, so its value would win over the operator's explicit
434
+ // engine choice. For the client vl these names are refused in passthrough —
435
+ // the operator declares the ENGINE field, not the env name. VL_API_KEY and the
436
+ // key names read by the runtime's config pass normally, by name, as always.
437
+ const VL_OWNED_ENV = new Set(['VL_PROVIDER', 'VL_BASE_URL', 'VL_MODEL', 'VL_EFFORT', 'VL_SYSTEM_APPEND_FILE']);
438
+ // The per-cell XDG trio (XDG_STATE_HOME / XDG_DATA_HOME / XDG_CONFIG_HOME).
439
+ // For the client vl these ARE the cell identity on the runtime host: the
440
+ // runtime derives from them the server root, the control socket and the
441
+ // session directory, so a shared value means a shared state tree. NexusCrew
442
+ // composes them per cell (ensureVlCellDirs), therefore a passthrough
443
+ // carrying one of these names is a way to defeat the per-cell identity: the
444
+ // passthrough loop runs AFTER the vl branch, so its value would win in
445
+ // silence and two cells would share one directory. For the client vl these
446
+ // names are refused in passthrough, and the final assignment is protected.
447
+ const VL_CELL_DIR_ENV = new Set(['XDG_STATE_HOME', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME']);
448
+ const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', 'credentialSourcePolicy', 'envPassthrough', ...CUSTOM_KEYS, ...VL_KEYS]);
421
449
  // Massimo numero di nomi in `envPassthrough`. L'allowlist e' opt-in e per
422
450
  // nome, mai un passthrough in blocco: un tetto basso ferma una lista incontrollata.
423
451
  const MAX_ENV_PASSTHROUGH = 32;
@@ -636,7 +664,7 @@ const CATALOG = Object.freeze([
636
664
  // entrano in questo client oggi: opencode.jsonc accetta `apiKey` SOLO come
637
665
  // stringa letterale (il riferimento {env:...} e' rifiutato, misurato), e
638
666
  // scrivere una chiave su disco viola la regola del repo (mai segreti nei
639
- // file generati). Residuo dichiarato nel referto del lotto.
667
+ // file generati). Residuo dichiarato nel referto di lavorazione.
640
668
  { id: 'opencode.native', client: 'opencode', provider: 'native', label: 'OpenCode account (gateway + local login)', auth: 'login', protocol: 'opencode_native', core: true, notice: 'opencode-native' },
641
669
 
642
670
  // Agy: client gestito primario (Linux/macOS non-Termux). Auth delegata al
@@ -769,6 +797,59 @@ function normalizeManagedSpec(value, { extraModels = null, engineId = null, onIs
769
797
  if (!model || !(profile.protocols || [profile.protocol]).includes(protocol)) return issue('protocol', protocol, `protocollo non supportato dal profilo (ammessi: ${(profile.protocols || [profile.protocol]).join(', ')})`);
770
798
  Object.assign(out, { displayName, baseUrl, ...(envKey ? { envKey } : {}), protocol, providerId });
771
799
  }
800
+ // VL-only fields. Anything declared for another client is a configuration
801
+ // error, not a silent no-op: an engine that carries `effort` under a claude
802
+ // profile would look configured while doing nothing.
803
+ const vlFields = VL_KEYS.filter((k) => value[k] !== undefined);
804
+ if (vlFields.length && value.client !== 'vl') {
805
+ return issue(vlFields.join(','), value.client, 'campi vl* / effort ammessi solo sul client vl');
806
+ }
807
+ if (value.client === 'vl') {
808
+ // vlBinary/vlConfigFile: absolute paths on the EXECUTOR node, no control
809
+ // bytes, sane length. Spaces are legal and tested (argv is separated, no
810
+ // shell interpolation anywhere in the launch path). Existence, regular
811
+ // file and executability are verified at LAUNCH time (node-local), not
812
+ // here: normalize also runs on read-only views of a remote node.
813
+ for (const field of ['vlBinary', 'vlConfigFile']) {
814
+ if (value[field] === undefined) continue;
815
+ const v = value[field];
816
+ if (typeof v !== 'string' || !path.isAbsolute(v) || v.length > 4096 || /[\x00-\x1f\x7f]/.test(v)) {
817
+ return issue(field, v, 'percorso assoluto atteso (nessun byte di controllo, max 4096 caratteri)');
818
+ }
819
+ out[field] = v;
820
+ }
821
+ // vlProfile: a single `-p` token. Profile MODE means the catalog decides
822
+ // provider/base: on any other variant (vl.anthropic composes VL_PROVIDER,
823
+ // vl.custom carries its own protocol/baseUrl) the two choices would fight
824
+ // and one of them would run silently inactive — so the combination is
825
+ // rejected here, explicitly, instead of being resolved by precedence.
826
+ if (value.vlProfile !== undefined) {
827
+ const p = value.vlProfile;
828
+ if (typeof p !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(p)) {
829
+ return issue('vlProfile', p, 'nome profilo: un token [A-Za-z0-9][A-Za-z0-9._-]{0,63}');
830
+ }
831
+ if (profile.provider !== 'native') {
832
+ return issue('vlProfile', p, `incompatibile con la variante ${profile.id}: la variante compone gia' provider/base — profilo e variante si escludono`);
833
+ }
834
+ out.vlProfile = p;
835
+ }
836
+ // effort: only among the levels DECLARED for the effective model. A model
837
+ // without a declared effortLevels descriptor admits NO effort (fail-closed:
838
+ // the runtime rejects unknown levels, and inventing a default would send
839
+ // the cell up with a request its profile never made).
840
+ if (value.effort !== undefined) {
841
+ const effort = value.effort;
842
+ if (typeof effort !== 'string' || !/^[a-z0-9][a-z0-9-]{0,15}$/.test(effort)) {
843
+ return issue('effort', effort, 'livello effort: token [a-z0-9][a-z0-9-]{0,15}');
844
+ }
845
+ const descriptor = declaredModelsFor(extraModels, profile.id, engineId)
846
+ .find((m) => m && m.id === model && Array.isArray(m.effortLevels));
847
+ if (!descriptor || !descriptor.effortLevels.includes(effort)) {
848
+ return issue('effort', effort, 'livello non dichiarato per il modello (serve un modello con effortLevels che lo contenga)');
849
+ }
850
+ out.effort = effort;
851
+ }
852
+ }
772
853
  // EnvPassthrough e' un'allowlist di NOMI di variabili d'ambiente che il
773
854
  // child deve ricevere, risolti a runtime dalle credentialSources (dopo i rami
774
855
  // provider in resolveManagedEngine). E' opt-in e per nome: MAI un passthrough
@@ -789,6 +870,25 @@ function normalizeManagedSpec(value, { extraModels = null, engineId = null, onIs
789
870
  if (!names) return null;
790
871
  out.envPassthrough = names;
791
872
  }
873
+ // Client vl: i nomi VL_* delle scelte engine non passano dal passthrough.
874
+ // Si rifiuta QUI, alla validazione, perche' e' l'unico punto in cui la
875
+ // dichiarazione dell'operatore e' ancora separata dalla composizione: a
876
+ // valle il valore di passthrough vincerebbe in silenzio sulla scelta
877
+ // esplicita (il loop gira dopo il ramo vl).
878
+ if (out.client === 'vl' && out.envPassthrough && out.envPassthrough.length) {
879
+ const owned = out.envPassthrough.filter((n) => VL_OWNED_ENV.has(n));
880
+ if (owned.length) {
881
+ return issue('envPassthrough', owned.join(','), `per il client vl le variabili ${[...VL_OWNED_ENV].join('/')} esprimono scelte dell'engine (variante, profilo, modello, effort): dichiara il campo engine, non il nome env`);
882
+ }
883
+ // Il trio XDG e' l'identita' per cella nel runtime vl: lo compone
884
+ // NexusCrew, uno per cella, e un valore dichiarato in passthrough
885
+ // vincerebbe in silenzio (il loop gira dopo il ramo vl) riunendo due
886
+ // celle sulla stessa directory di stato.
887
+ const cellDirs = out.envPassthrough.filter((n) => VL_CELL_DIR_ENV.has(n));
888
+ if (cellDirs.length) {
889
+ return issue('envPassthrough', cellDirs.join(','), `per il client vl le variabili ${[...VL_CELL_DIR_ENV].join('/')} sono l'identita' per cella (stato, dati, config): NexusCrew le compone per cella, non si dichiarano in passthrough`);
890
+ }
891
+ }
792
892
  return out;
793
893
  }
794
894
 
@@ -1313,52 +1413,72 @@ const sharedModelDiscovery = createModelDiscovery();
1313
1413
  // (catalog engines) the profile id IS the engine id.
1314
1414
  function engineModelDiscoveryPlan(profile, spec, cfg, home, engineId) {
1315
1415
  if (!profile) return null;
1316
- const specBase = spec && typeof spec.baseUrl === 'string' && /^https?:\/\//i.test(spec.baseUrl) ? spec.baseUrl : '';
1317
- const profileBase = typeof profile.endpoint === 'string' && /^https?:\/\//i.test(profile.endpoint) ? profile.endpoint : '';
1416
+ const httpUrl = (value) => (typeof value === 'string' && /^https?:\/\//i.test(value) ? value : '');
1417
+ // Endpoint precedence follows the launcher: the normalized spec, the
1418
+ // generated provider extension, then the profile's own endpoint. A native
1419
+ // provider has no explicit endpoint here, and is never pointed at another
1420
+ // client's profile.
1421
+ const specBase = httpUrl(spec && spec.baseUrl);
1422
+ const extensionBase = httpUrl(profile.piExtension && profile.piExtension.baseUrl);
1423
+ const profileBase = httpUrl(profile.endpoint);
1424
+ // The wire name may come hyphenated (`openai-completions`, the Pi form) or
1425
+ // underscored (the normalized form). It is the same transport.
1426
+ const rawWire = spec && typeof spec.protocol === 'string' && spec.protocol
1427
+ ? spec.protocol : (typeof profile.protocol === 'string' ? profile.protocol : '');
1428
+ const wire = rawWire.replace(/-/g, '_');
1318
1429
  let collector = null;
1319
1430
  let baseUrl = '';
1320
1431
  if (profile.provider === 'ollama' || profile.provider === 'ollama-cloud') {
1432
+ // Ollama speaks its own listing API, whatever wire a client uses for chat.
1321
1433
  collector = 'ollama';
1322
- baseUrl = specBase || profileBase || 'http://127.0.0.1:11434';
1434
+ baseUrl = specBase || extensionBase || profileBase || 'http://127.0.0.1:11434';
1323
1435
  } else if (profile.provider === 'lmstudio') {
1324
1436
  collector = 'lmstudio';
1325
- baseUrl = specBase || 'http://127.0.0.1:1234';
1326
- } else if (profile.protocol === 'anthropic_messages') {
1437
+ baseUrl = specBase || extensionBase || 'http://127.0.0.1:1234';
1438
+ } else if (wire === 'anthropic_messages') {
1327
1439
  collector = 'anthropic';
1328
- baseUrl = specBase || profileBase;
1329
- } else if (profile.protocol === 'openai_responses' || profile.protocol === 'openai_completions') {
1440
+ baseUrl = specBase || extensionBase || profileBase;
1441
+ } else if (wire === 'openai_responses' || wire === 'openai_completions') {
1330
1442
  collector = 'openai-compat';
1331
- baseUrl = specBase || profileBase;
1443
+ baseUrl = specBase || extensionBase || profileBase;
1332
1444
  } else {
1445
+ // `pi_native`, login flows and any other client-owned wire: no explicit
1446
+ // listing contract, so no collector. An explicit endpoint alone is not
1447
+ // proof of a listing API.
1333
1448
  return null;
1334
1449
  }
1335
1450
  if (!baseUrl) return null;
1336
- const plan = { engine: engineId || profile.id, collector, baseUrl };
1337
- // The credential STATE travels with the plan, from the same resolution the
1338
- // launcher uses (the caller never re-reads the credential sources):
1339
- // none / configured / missing / unavailable, plus the env NAME when one
1340
- // exists and the editor already shows it. A file that exists but cannot be
1341
- // read is "unavailable", not "missing" — the same distinction describeManaged
1342
- // makes for launching. The value, its provenance paths/hashes and any
1343
- // exception text stay here: the plan hands the key to the collector in
1344
- // memory and nothing else leaves.
1451
+ return { engine: engineId || profile.id, collector, baseUrl, ...modelListingCredential(profile, spec, cfg, home) };
1452
+ }
1453
+
1454
+ // Credential resolution for a model listing, from the same path the launcher
1455
+ // uses (the caller never re-reads the credential sources): none / configured /
1456
+ // missing / unavailable, plus the env NAME when one exists and the editor
1457
+ // already shows it. A file that exists but cannot be read is "unavailable",
1458
+ // not "missing" — the same distinction describeManaged makes for launching.
1459
+ // The value, its provenance paths/hashes and any exception text stay here:
1460
+ // the value is handed to a collector in memory and nothing else leaves. An
1461
+ // engine with no listing still gets the STATE: the diagnosis refers to the
1462
+ // listing only, never to the client's own login.
1463
+ function modelListingCredential(profile, spec, cfg, home) {
1464
+ const out = { credentialState: 'unavailable' };
1345
1465
  try {
1346
1466
  const cred = credential(profile, spec || {}, cfg, home, { blocked: [] });
1347
- plan.credentialState = !cred ? 'unavailable'
1467
+ out.credentialState = !cred ? 'unavailable'
1348
1468
  : cred.value ? 'configured'
1349
1469
  : cred.source === 'unreadable' ? 'unavailable'
1350
1470
  : cred.source === 'missing' ? 'missing'
1351
1471
  : 'none';
1352
- if (cred && cred.value) plan.apiKey = cred.value;
1472
+ if (cred && cred.value) out.apiKey = cred.value;
1353
1473
  const envKey = cred && typeof cred.envKey === 'string' ? cred.envKey : '';
1354
1474
  // 'login'/'none' resolve to a pseudo key name: never invent one for them.
1355
- if (plan.credentialState !== 'none' && ENV_KEY_RE.test(envKey || '')) plan.envKey = envKey;
1475
+ if (out.credentialState !== 'none' && ENV_KEY_RE.test(envKey || '')) out.envKey = envKey;
1356
1476
  } catch (_) {
1357
1477
  // Resolution itself failed: the key is not known to be missing — it is
1358
1478
  // unavailable, and an anonymous rejection will say exactly that.
1359
- plan.credentialState = 'unavailable';
1479
+ out.credentialState = 'unavailable';
1360
1480
  }
1361
- return plan;
1481
+ return out;
1362
1482
  }
1363
1483
  async function discoverOllamaModels(opts = {}) {
1364
1484
  const now = Date.now(); const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
@@ -1486,9 +1606,27 @@ function describeManaged(spec, cfg = {}) {
1486
1606
  // in "not found" / "missing" (la stessa forma gia' chiusa in checkTermuxExec).
1487
1607
  const binaryBlocked = [];
1488
1608
  const credBlocked = [];
1489
- const binary = normalized.client === 'shell'
1490
- ? resolveInteractiveShell({ ...cfg, home }, { blocked: binaryBlocked })
1491
- : findBinary(normalized.client, home, { blocked: binaryBlocked });
1609
+ // vl con `vlBinary` DICHIARATO: il binario dichiarato e' l'UNICO che conta
1610
+ // e il PATH non viene neppure consultato. Dichiarare un binario e' una
1611
+ // scelta esplicita dell'operatore: consultare il PATH in parallelo
1612
+ // renderebbe la dichiarazione inaffidabile — un binario dichiarato rotto
1613
+ // che si nasconde dietro un altro e' il difetto piu' pericoloso dei due,
1614
+ // perche' l'operatore crede di avviare una cosa e ne parte un'altra.
1615
+ //
1616
+ // Qui la risoluzione e' la STESSA che fa il ramo vl di resolveManagedEngine
1617
+ // (resolveVlBinary): stesso realpath, stesso file, quindi il gate che
1618
+ // interroga la versione e lo spawn parlano con lo stesso eseguibile.
1619
+ let binary = null;
1620
+ let vlBinaryError = '';
1621
+ if (normalized.client === 'vl' && normalized.vlBinary) {
1622
+ const declared = resolveVlBinary(normalized.vlBinary);
1623
+ if (declared.ok) binary = declared.resolved;
1624
+ else vlBinaryError = declared.reason;
1625
+ } else {
1626
+ binary = normalized.client === 'shell'
1627
+ ? resolveInteractiveShell({ ...cfg, home }, { blocked: binaryBlocked })
1628
+ : findBinary(normalized.client, home, { blocked: binaryBlocked });
1629
+ }
1492
1630
  // La scatola e' tenuta: dopo la chiamata porta anche i CONFLITTI fra file di
1493
1631
  // chiavi, calcolati dove i file si leggono in ordine.
1494
1632
  const credOut = { blocked: credBlocked };
@@ -1505,7 +1643,13 @@ function describeManaged(spec, cfg = {}) {
1505
1643
  || profile.client === 'vl' || !!cred.value;
1506
1644
  let configured = !!binary && authConfigured;
1507
1645
  let reason;
1508
- if (!binary) {
1646
+ if (vlBinaryError) {
1647
+ // Il binario dichiarato non e' utilizzabile: il motivo nomina il campo e
1648
+ // dichiara che NON c'e' ripiego. Nessun `client vl not found` — quello
1649
+ // direbbe il falso (il client c'e', e' il binario dichiarato a non
1650
+ // esserlo) e lascerebbe credere a un ripiego che non c'e'.
1651
+ reason = vlBinaryError;
1652
+ } else if (!binary) {
1509
1653
  reason = binaryBlocked.length
1510
1654
  ? `client ${profile.client} not confirmed: ${binaryBlocked.length === 1 ? 'a candidate could not be verified' : 'some candidates could not be verified'} (${binaryBlocked.map((b) => `${b.path} (${b.code})`).join('; ')}) — not "absent"`
1511
1655
  : `client ${profile.client} not found`;
@@ -2359,6 +2503,351 @@ function vlSystemAppendGate(binary, cfg) {
2359
2503
  return { ok: true };
2360
2504
  }
2361
2505
 
2506
+ // ---------------------------------------------------------------------------
2507
+ // VL engine fields (vlBinary / vlProfile / vlConfigFile / effort) — the launch
2508
+ // side of the contract shaped in normalizeManagedSpec. Everything here runs on
2509
+ // the EXECUTOR node inside resolveManagedEngine: paths are node-local and are
2510
+ // never computed from a federated viewer.
2511
+ // ---------------------------------------------------------------------------
2512
+
2513
+ // First vl runtime version that reads the VL_EFFORT capability. Until that
2514
+ // release ships, the gate below refuses an engine that declares `effort` with
2515
+ // a DECLARED error instead of ignoring the request:
2516
+ // a cell that silently runs at the wrong effort is worse than one that says
2517
+ // why it will not start. Align this constant with the real release.
2518
+ const VL_EFFORT_MIN = [0, 5, 0];
2519
+
2520
+ // Field-compare version parse shared by the vl gates: '0.10.0' > '0.5.0'.
2521
+ function vlVersionFromOutput(out) {
2522
+ const m = String(out).match(/\bvl\s+(\d+)\.(\d+)\.(\d+)/i);
2523
+ if (!m) return null;
2524
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
2525
+ }
2526
+
2527
+ function vlVersionAtLeast(v, minimum) {
2528
+ for (let i = 0; i < 3; i += 1) {
2529
+ if (v[i] !== minimum[i]) return v[i] > minimum[i];
2530
+ }
2531
+ return true;
2532
+ }
2533
+
2534
+ // Effort gate: the chosen binary must advertise a runtime that reads
2535
+ // VL_EFFORT. Same shape as vlSystemAppendGate (probe + declared refusal), and
2536
+ // it receives the SAME binary that will be spawned.
2537
+ function vlEffortGate(binary, cfg) {
2538
+ const out = vlBinaryVersionOutput(binary, cfg);
2539
+ if (out === null) return { ok: false, reason: `versione di vl non determinabile (${binary} --version non risponde) — effort richiesto ma non promettibile` };
2540
+ const v = vlVersionFromOutput(out);
2541
+ if (!v) return { ok: false, reason: `versione di vl non riconosciuta da "${String(out).trim().slice(0, 60)}" — effort richiesto ma non promettibile` };
2542
+ if (!vlVersionAtLeast(v, VL_EFFORT_MIN)) {
2543
+ return { ok: false, reason: `vl ${v.join('.')} < ${VL_EFFORT_MIN.join('.')}: VL_EFFORT non letta dal runtime — effort rifiutato invece di essere ignorato` };
2544
+ }
2545
+ return { ok: true };
2546
+ }
2547
+
2548
+ // Absolute-path validation for vlBinary: no PATH fallback, ever. The resolved
2549
+ // (realpath) binary is returned so probe and spawn interrogate/exec the same
2550
+ // file even through symlinks — the gate must never ask a different executable
2551
+ // than the one the supervisor spawns.
2552
+ function resolveVlBinary(declared) {
2553
+ try {
2554
+ const resolved = fs.realpathSync(declared);
2555
+ const st = fs.statSync(resolved);
2556
+ if (!st.isFile()) return { ok: false, reason: `vlBinary non e' un file regolare: ${declared}` };
2557
+ const executable = (st.mode & 0o111) !== 0;
2558
+ if (!executable) return { ok: false, reason: `vlBinary non eseguibile: ${declared}` };
2559
+ return { ok: true, resolved };
2560
+ } catch (e) {
2561
+ return { ok: false, reason: `vlBinary non raggiungibile (${e.code || e.message}): ${declared} — nessun ripiego sul PATH` };
2562
+ }
2563
+ }
2564
+
2565
+ // Per-cell VL identity directory. Key = stable owner instanceId + cellId,
2566
+ // encoded as <slug>-<hash>: the slug keeps paths readable, the 10-hex hash of
2567
+ // `owner\0cell` makes collisions impossible — same cellId under two owners
2568
+ // lands in different directories, and two ids that slug to the same string
2569
+ // stay distinct. Independent of engine, model, display name, tmux session and
2570
+ // PID: a cell keeps its paths across restarts and engine switches.
2571
+ function vlCellBaseDir(home, ownerInstanceId, cellId) {
2572
+ const owner = typeof ownerInstanceId === 'string' && ownerInstanceId ? ownerInstanceId : 'local';
2573
+ const id = typeof cellId === 'string' ? cellId : '';
2574
+ if (!id || id === '.' || id === '..' || id.includes('/')) throw new Error('unsafe vl cell id');
2575
+ const slug = id.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) || 'cell';
2576
+ const hash = crypto.createHash('sha256').update(`${owner}\0${id}`).digest('hex').slice(0, 10);
2577
+ return path.join(home, '.nexuscrew', 'vl-cells', `${slug}-${hash}`);
2578
+ }
2579
+
2580
+ // Creates (0700, symlink-rejecting) the per-cell XDG trio and returns the env
2581
+ // values. The runtime owns one state host per directory: distinct directories
2582
+ // keep two cells isolated down to the host, not just at the env layer.
2583
+ // `selected.toml` lives inside the cell's own config tree and is NEVER touched
2584
+ // by NexusCrew (the cell's private preference).
2585
+ function ensureVlCellDirs(home, ownerInstanceId, cellId) {
2586
+ const base = vlCellBaseDir(home, ownerInstanceId, cellId);
2587
+ safePrivateDir(path.join(home, '.nexuscrew'), { create: true });
2588
+ safePrivateDir(path.join(home, '.nexuscrew', 'vl-cells'), { create: true });
2589
+ safePrivateDir(base, { create: true });
2590
+ const state = path.join(base, 'state');
2591
+ const data = path.join(base, 'data');
2592
+ const config = path.join(base, 'config');
2593
+ for (const dir of [state, data, config]) safePrivateDir(dir, { create: true });
2594
+ return {
2595
+ base,
2596
+ env: { XDG_STATE_HOME: state, XDG_DATA_HOME: data, XDG_CONFIG_HOME: config },
2597
+ configVlDir: path.join(config, 'vl'),
2598
+ };
2599
+ }
2600
+
2601
+ // Profile names declared by a vl config.toml catalog (`[profiles.<name>]`
2602
+ // tables — crates/vivling/src/config/profile.rs).
2603
+ //
2604
+ // The catalog is PARSED as TOML (smol-toml, already a dependency), not
2605
+ // scanned with a regex: a header with a quoted name (`[profiles."work"]`) or
2606
+ // a trailing comment (`[profiles.work] # il profilo di lavoro`) is valid
2607
+ // TOML, and a regex would call those profiles "inexistent" and refuse the
2608
+ // cell for a catalog that is perfectly valid.
2609
+ //
2610
+ // A catalog that is not valid TOML declares NO profile and says why: the
2611
+ // cell refuses the launch, declared, naming the file — never a guess and
2612
+ // never a silent start from the defaults.
2613
+ function vlCatalogProfileSet(text) {
2614
+ const names = new Set();
2615
+ let doc;
2616
+ try {
2617
+ doc = toml.parse(String(text));
2618
+ } catch (e) {
2619
+ return { ok: false, profiles: names, reason: `TOML non valido: ${e && e.message ? e.message : 'errore di parsing'}` };
2620
+ }
2621
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
2622
+ return { ok: false, profiles: names, reason: 'il documento TOML non e\' un catalogo' };
2623
+ }
2624
+ const profiles = doc.profiles;
2625
+ if (profiles === undefined || profiles === null) return { ok: true, profiles: names, reason: '' };
2626
+ if (typeof profiles !== 'object' || Array.isArray(profiles)) {
2627
+ return { ok: false, profiles: names, reason: 'la tabella [profiles] non e\' una tabella di profili' };
2628
+ }
2629
+ for (const [name, value] of Object.entries(profiles)) {
2630
+ // Un profilo e' una TABELLA: `[profiles.<nome>]`. Una chiave di altro
2631
+ // tipo (stringa, numero, lista) non e' un profilo e non lo si dichiara.
2632
+ if (value && typeof value === 'object' && !Array.isArray(value)) names.add(name);
2633
+ }
2634
+ return { ok: true, profiles: names, reason: '' };
2635
+ }
2636
+
2637
+ // I nomi dei profili dichiarati dal catalogo. Sotto un catalogo malformato
2638
+ // l'insieme e' vuoto: la cella non parte (vedi vlCatalogProfileSet).
2639
+ function vlCatalogProfiles(text) {
2640
+ return vlCatalogProfileSet(text).profiles;
2641
+ }
2642
+
2643
+ // Atomic copy (tmp + fsync + rename, mode 0600) of the engine-declared profile
2644
+ // catalog into the cell's private XDG_CONFIG_HOME/vl/config.toml. Written at
2645
+ // EVERY launch so the catalog follows the engine; secrets are never copied
2646
+ // because the catalog is the engine's declared vlConfigFile and vl reads keys
2647
+ // by NAME from the environment. `selected.toml` is never created nor touched.
2648
+ //
2649
+ // `text` is the ALREADY VALIDATED content of the source: the caller reads and
2650
+ // parses it first, and the copy writes exactly those bytes. Reading the file
2651
+ // again here would leave a window in which the source changes between the
2652
+ // check and the write — the cell would run a catalog nobody validated.
2653
+ function writeVlCellCatalog(configVlDir, sourcePath, text) {
2654
+ let source = text;
2655
+ if (source === undefined) {
2656
+ try { source = fs.readFileSync(sourcePath, 'utf8'); } catch (e) {
2657
+ throw new Error(`catalogo profili vl non leggibile (${sourcePath}): ${e.code || e.message}`);
2658
+ }
2659
+ }
2660
+ if (typeof source !== 'string') {
2661
+ throw new Error(`catalogo profili vl non leggibile (${sourcePath}): contenuto non testuale`);
2662
+ }
2663
+ safePrivateDir(configVlDir, { create: true });
2664
+ const target = path.join(configVlDir, 'config.toml');
2665
+ try {
2666
+ if (fs.lstatSync(target).isSymbolicLink()) throw new Error('refusing symlink vl catalog target');
2667
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
2668
+ const tmp = path.join(configVlDir, `.config.${crypto.randomBytes(6).toString('hex')}.tmp`);
2669
+ let fd;
2670
+ try {
2671
+ fd = fs.openSync(tmp, 'wx', 0o600);
2672
+ fs.writeFileSync(fd, source);
2673
+ fs.fsyncSync(fd);
2674
+ fs.closeSync(fd); fd = undefined;
2675
+ fs.renameSync(tmp, target);
2676
+ } catch (e) {
2677
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch (_) {} }
2678
+ try { fs.unlinkSync(tmp); } catch (_) {}
2679
+ throw e;
2680
+ }
2681
+ return { target, source };
2682
+ }
2683
+
2684
+ // Il tipo di un file che NON e' un catalogo leggibile. Serve a dichiarare
2685
+ // il rifiuto con il nome del tipo, non con un codice generico.
2686
+ function vlCatalogSourceType(st) {
2687
+ if (st.isFIFO()) return 'FIFO';
2688
+ if (st.isSocket()) return 'socket';
2689
+ if (st.isBlockDevice()) return 'device a blocchi';
2690
+ if (st.isCharacterDevice()) return 'device a caratteri';
2691
+ if (st.isDirectory()) return 'directory';
2692
+ return 'file non regolare';
2693
+ }
2694
+
2695
+ // Le identita' (dev+inode) dell'overlay GENERATO con cui la sorgente viene
2696
+ // confrontata: l'overlay cosi' com'e', e l'overlay risolto (se al posto del
2697
+ // file generato c'e' un link simbolico). Il confronto e' su dev+inode, quindi
2698
+ // copre il percorso diretto, un hard link e un symlink.
2699
+ function vlOverlayIdentities(configVlDir) {
2700
+ const target = path.join(configVlDir, 'config.toml');
2701
+ const ids = [];
2702
+ for (const modo of ['lstatSync', 'statSync']) {
2703
+ let st;
2704
+ try { st = fs[modo](target); } catch (_) { continue; }
2705
+ ids.push({ dev: st.dev, ino: st.ino });
2706
+ }
2707
+ return ids;
2708
+ }
2709
+
2710
+ // Legge e VERIFICA la sorgente del catalogo, una volta sola.
2711
+ //
2712
+ // * REGOLARE: la sorgente deve essere un file regolare. FIFO, socket,
2713
+ // device e directory sono rifiutati dichiarati, e il rifiuto avviene
2714
+ // PRIMA di qualsiasi lettura: aprire una FIFO in lettura blocca il
2715
+ // thread Node finche' non arriva uno scrittore, e la verifica non deve
2716
+ // mai diventare un'attesa.
2717
+ // * ESTERNA ALL'OVERLAY: la sorgente non puo' essere l'overlay generato
2718
+ // della cella ne' un suo alias. Il confronto e' su dev+inode, quindi
2719
+ // copre il percorso diretto, un hard link e un symlink (risolto prima
2720
+ // del confronto): copiare l'overlay su se stesso non e' una sorgente,
2721
+ // e' un autoriferimento.
2722
+ // * STESSI BYTES: i byte letti sono quelli verificati con fstat sul
2723
+ // descriptor, e sono gli stessi che il chiamante copia nella cella. Se
2724
+ // il file cambia tra la verifica e la lettura, il lancio e' rifiutato.
2725
+ //
2726
+ // Il risultato e' {ok:true, text} oppure {ok:false, reason} con un rifiuto
2727
+ // dichiarato: niente catalogo precedente, niente default implicito.
2728
+ function readVlCatalogSource(sourcePath, configVlDir) {
2729
+ const nonLegibile = (e) => ({
2730
+ ok: false,
2731
+ reason: `catalogo vl non leggibile (${sourcePath}): ${(e && (e.code || e.message)) || 'errore sconosciuto'} — avvio rifiutato invece di partire senza catalogo`,
2732
+ });
2733
+ // Il path risolto: un symlink verso un catalogo esterno resta una
2734
+ // sorgente valida, un symlink verso l'overlay diventa l'overlay.
2735
+ let resolved;
2736
+ try {
2737
+ resolved = fs.realpathSync(sourcePath);
2738
+ } catch (e) {
2739
+ return nonLegibile(e);
2740
+ }
2741
+ let st;
2742
+ try {
2743
+ st = fs.statSync(resolved);
2744
+ } catch (e) {
2745
+ return nonLegibile(e);
2746
+ }
2747
+ if (!st.isFile()) {
2748
+ return {
2749
+ ok: false,
2750
+ reason: `catalogo vl non regolare (${sourcePath}): ${vlCatalogSourceType(st)} — avvio rifiutato invece di leggere una sorgente non regolare`,
2751
+ };
2752
+ }
2753
+ const overlay = vlOverlayIdentities(configVlDir);
2754
+ const overlayReason = `catalogo vl non esterno alla cella (${sourcePath}): è l'overlay generato o un suo alias — avvio rifiutato invece di copiare la sorgente su sé stessa`;
2755
+ if (overlay.some((id) => id.dev === st.dev && id.ino === st.ino)) {
2756
+ return { ok: false, reason: overlayReason };
2757
+ }
2758
+ let fd;
2759
+ try {
2760
+ // O_NOFOLLOW: il path e' gia' risolto, non c'e' nessun link da seguire.
2761
+ // O_NONBLOCK: se il file e' stato sostituito da una FIFO, l'apertura
2762
+ // non si blocca e fstat lo smaschera.
2763
+ fd = fs.openSync(resolved, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
2764
+ } catch (e) {
2765
+ return nonLegibile(e);
2766
+ }
2767
+ try {
2768
+ const fst = fs.fstatSync(fd);
2769
+ if (!fst.isFile()) {
2770
+ return {
2771
+ ok: false,
2772
+ reason: `catalogo vl non regolare (${sourcePath}): ${vlCatalogSourceType(fst)} — avvio rifiutato invece di leggere una sorgente non regolare`,
2773
+ };
2774
+ }
2775
+ if (fst.dev !== st.dev || fst.ino !== st.ino) {
2776
+ return {
2777
+ ok: false,
2778
+ reason: `catalogo vl cambiato durante la verifica (${sourcePath}) — avvio rifiutato invece di partire da una sorgente non verificata`,
2779
+ };
2780
+ }
2781
+ if (overlay.some((id) => id.dev === fst.dev && id.ino === fst.ino)) {
2782
+ return { ok: false, reason: overlayReason };
2783
+ }
2784
+ const text = fs.readFileSync(fd, 'utf8');
2785
+ const after = fs.fstatSync(fd);
2786
+ if (after.dev !== fst.dev || after.ino !== fst.ino || after.size !== fst.size || after.mtimeMs !== fst.mtimeMs) {
2787
+ return {
2788
+ ok: false,
2789
+ reason: `catalogo vl modificato durante la lettura (${sourcePath}) — avvio rifiutato invece di partire da byte non verificati`,
2790
+ };
2791
+ }
2792
+ return { ok: true, text };
2793
+ } catch (e) {
2794
+ return nonLegibile(e);
2795
+ } finally {
2796
+ try { fs.closeSync(fd); } catch (_) { /* il descriptor puo' essere gia' chiuso */ }
2797
+ }
2798
+ }
2799
+
2800
+ // La sorgente del catalogo di un engine vl, e il posto in cui si legge.
2801
+ //
2802
+ // 1. `vlConfigFile` dichiarato dall'engine: la fonte esplicita.
2803
+ // 2. `vl.native` SENZA quel campo: il catalogo dell'OPERATORE sul nodo
2804
+ // esecutore. Si risolve dall'XDG_CONFIG_HOME ORIGINARIO del servizio —
2805
+ // quello di chi ha avviato NexusCrew, non quello che la cella si vede
2806
+ // assegnare un secondo dopo — con fallback su HOME/.config/vl/config.toml.
2807
+ // E' il modo in cui la promessa "usa la tua configurazione" resta vera
2808
+ // quando l'engine non dichiara un percorso: la configurazione
2809
+ // dell'operatore e' la sorgente, non un overlay dimenticato.
2810
+ // 3. qualsiasi altro engine vl: nessuna sorgente. Il suo provider e'
2811
+ // esplicito nelle credenziali, quindi non c'e' un default da ereditare —
2812
+ // ma un catalogo vecchio sarebbe comunque un'eredit' invisibile.
2813
+ function vlOperatorCatalogPath(cfg, home) {
2814
+ const serviceEnv = (cfg && cfg.env) || process.env || {};
2815
+ const declared = typeof serviceEnv.XDG_CONFIG_HOME === 'string' ? serviceEnv.XDG_CONFIG_HOME.trim() : '';
2816
+ const base = declared || path.join(home, '.config');
2817
+ return path.join(base, 'vl', 'config.toml');
2818
+ }
2819
+
2820
+ // Rimuove l'overlay GENERATO: solo `config.toml` nella directory vl della
2821
+ // cella. `selected.toml` — la preferenza privata della cella — non e' mai
2822
+ // toccato, e nessun altro file della directory viene coinvolto.
2823
+ //
2824
+ // Un symlink al posto dell'overlay non viene rimosso: non e' un file che
2825
+ // NexusCrew ha scritto, e toglierlo sarebbe mettere le mani su qualcosa che
2826
+ // non e' nostro. Il lancio che lo trova rifiuta, dichiarato.
2827
+ function removeVlCellCatalog(configVlDir) {
2828
+ const target = path.join(configVlDir, 'config.toml');
2829
+ let st;
2830
+ try { st = fs.lstatSync(target); } catch (e) {
2831
+ if (e.code === 'ENOENT') return { removed: false };
2832
+ throw new Error(`overlay vl non ispezionabile (${target}): ${e.code || e.message}`);
2833
+ }
2834
+ if (st.isSymbolicLink()) {
2835
+ throw new Error(`overlay vl generato non e' un file regolare (symlink: ${target})`);
2836
+ }
2837
+ try { fs.unlinkSync(target); } catch (e) {
2838
+ throw new Error(`overlay vl non rimuovibile (${target}): ${e.code || e.message}`);
2839
+ }
2840
+ return { removed: true };
2841
+ }
2842
+
2843
+ // Rimozione best-effort: usata sui percorsi di rifiuto, dove il lancio e'
2844
+ // gia' negato e l'errore che conta e' quello del catalogo, non quello della
2845
+ // pulizia. Se la rimozione fallisce, l'overlay resta — ma nessun lancio
2846
+ // parte con quel catalogo, quindi non e' un catalogo in uso.
2847
+ function removeVlCellCatalogQuietly(configVlDir) {
2848
+ try { return removeVlCellCatalog(configVlDir); } catch (_) { return { removed: false }; }
2849
+ }
2850
+
2362
2851
  // (fix definitivo — una revisione del pacchetto aveva bocciato la prima versione:
2363
2852
  // il test costruiva `spec.models` a mano, e in produzione quel campo non
2364
2853
  // esiste mai). `declaredModels` NON viene da `spec`: i descrittori sono
@@ -2521,6 +3010,16 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2521
3010
  // hold it, or a file that cannot be written safely).
2522
3011
  // Empty string = delivered. Surfaces with the engine resolved as vlPromptDegraded.
2523
3012
  let vlPromptDegraded = '';
3013
+ // vl profile mode: env names that must NOT reach the child even when they
3014
+ // come back through envPassthrough or ambient inheritance. The profile owns
3015
+ // provider/base (always) and model/effort (when NC has no explicit choice);
3016
+ // key names like VL_API_KEY are deliberately NOT in this list.
3017
+ let vlNeutralize = [];
3018
+ // Il trio XDG per cella (client vl): lo compone il ramo vl sotto e resta
3019
+ // l'assegnazione FINALE, cioe' quella che vince sul passthrough. Tenuto
3020
+ // qui fuori dal ramo perche' la protezione dell'assegnazione avviene DOPO
3021
+ // il loop di passthrough, che gira piu' avanti.
3022
+ let vlCellEnv = null;
2524
3023
  // I due profili Claude con configurazione privata: perdono gli MCP del file
2525
3024
  // principale e vanno ricollegati (vedi `sharedMcpArgs`).
2526
3025
  let privateProfile = false;
@@ -2927,17 +3426,116 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2927
3426
  // (config.toml / VL_API_KEY by name via envPassthrough), never
2928
3427
  // credentials on argv. The model chosen in the UI travels via VL_MODEL
2929
3428
  // and the per-cell prompt via a file on the runtime system
2930
- // (VL_SYSTEM_APPEND_FILE, gate below); `vl --profile` remains
2931
- // the operator's choice. No --model on argv: the TUI starts without arguments.
2932
- if (model) env.VL_MODEL = model;
2933
- // vl.native e' «usa la tua configurazione»: NESSUNA env provider/base_url.
2934
- // I default interni del runtime sono gia' openai-compat + localhost:11434,
2935
- // e le VL_* ambientali battono il config.toml — comporle qui non aggiunge
2936
- // nulla e cancella in silenzio default_profile. Le varianti remote invece
2937
- // compongono SEMPRE la coppia, anche senza modello: la' la variante e' la
2938
- // scelta, e un fallback silenzioso manderebbe la cella altrove mentre la
2939
- // UI dice il contrario.
2940
- if (spec.provider === 'custom') {
3429
+ // (VL_SYSTEM_APPEND_FILE, gate below). No --model on argv: the TUI starts
3430
+ // without arguments.
3431
+ //
3432
+ // Per-cell identity: every vl cell gets its OWN
3433
+ // XDG_STATE/DATA/CONFIG trio under the NexusCrew state tree, keyed by
3434
+ // owner instanceId + cellId (slug + hash: no collisions, no slugs
3435
+ // traversing out). One state host per directory means two cells on the
3436
+ // same engine are isolated down to the host — the shared-folder incident
3437
+ // cannot repeat. Paths are computed HERE, on the executor
3438
+ // node, never from a federated viewer.
3439
+ let vlDirs;
3440
+ try {
3441
+ vlDirs = ensureVlCellDirs(home, cfg.vlOwnerInstanceId || null, cell?.id);
3442
+ } catch (e) {
3443
+ // The per-cell identity directory is not an optional nicety: without it
3444
+ // the runtime would fall back to a SHARED state tree — exactly the
3445
+ // cross-cell incident this closes. Unsafe id / hostile symlinks refuse
3446
+ // the launch, declared, instead of degrading into a shared folder.
3447
+ return { ok: false, info, reason: `directory identità vl per la cella non creabile in sicurezza: ${e.message}` };
3448
+ }
3449
+ Object.assign(env, vlDirs.env);
3450
+ vlCellEnv = vlDirs.env;
3451
+ // vlBinary: declared -> validated on this node and used for
3452
+ // BOTH the version probes and the spawn (info.binary is what
3453
+ // composeClientInvocation launches). Invalid -> the cell refuses, NEVER a
3454
+ // silent PATH fallback. Absent -> the PATH binary, exactly as before.
3455
+ if (spec.vlBinary) {
3456
+ const resolvedBinary = resolveVlBinary(spec.vlBinary);
3457
+ if (!resolvedBinary.ok) return { ok: false, info, reason: resolvedBinary.reason };
3458
+ info.binary = resolvedBinary.resolved;
3459
+ }
3460
+ // Catalog copy: every launch REFRESHES the cell's private
3461
+ // config/vl/config.toml (atomic, 0600) from the current source. The
3462
+ // cell's selected.toml is never created nor touched; secrets are never
3463
+ // copied (they only travel by NAME via envPassthrough), and the operator's
3464
+ // selected.toml / secrets.env are never imported. A missing or unreadable
3465
+ // source catalog refuses the launch, declared.
3466
+ //
3467
+ // La SORGENTE del catalogo e' una cosa sola, decisa qui: esplicita
3468
+ // (vlConfigFile) o, per vl.native senza quel campo, il catalogo
3469
+ // dell'operatore sul nodo esecutore. L'overlay generato viene
3470
+ // RIGENERATO dalla sorgente corrente a ogni lancio: un cambio di engine
3471
+ // o la rimozione del campo non lasciano un catalogo vecchio nella cella.
3472
+ // Senza sorgente valida il lancio e' rifiutato, dichiarato: niente
3473
+ // catalogo precedente e niente default implicito del provider.
3474
+ const vlNativeEngine = spec.provider === 'native';
3475
+ const vlSource = spec.vlConfigFile
3476
+ ? { path: spec.vlConfigFile, kind: 'dichiarato' }
3477
+ : (vlNativeEngine ? { path: vlOperatorCatalogPath(cfg, home), kind: "dell'operatore" } : null);
3478
+ let vlCatalogSource = null;
3479
+ if (vlSource) {
3480
+ // Si VERIFICA, si LEGGE e si PARSA prima di scrivere: il contenuto
3481
+ // copiato e' esattamente quello validato, e un catalogo rotto non
3482
+ // lascia un overlay a meta'. La sorgente deve essere un file REGOLARE
3483
+ // e stare FUORI dall'overlay generato: una FIFO bloccherebbe la
3484
+ // lettura, e l'overlay (o un suo alias) copiato su se stesso non e'
3485
+ // una sorgente.
3486
+ const letto = readVlCatalogSource(vlSource.path, vlDirs.configVlDir);
3487
+ if (!letto.ok) {
3488
+ removeVlCellCatalogQuietly(vlDirs.configVlDir);
3489
+ return { ok: false, info, reason: `${letto.reason} (fonte: ${vlSource.kind})` };
3490
+ }
3491
+ const text = letto.text;
3492
+ const parsed = vlCatalogProfileSet(text);
3493
+ if (!parsed.ok) {
3494
+ removeVlCellCatalogQuietly(vlDirs.configVlDir);
3495
+ return { ok: false, info, reason: `catalogo vl non valido (${vlSource.path}): ${parsed.reason} — avvio rifiutato invece di partire da un catalogo illeggibile (fonte: ${vlSource.kind})` };
3496
+ }
3497
+ try {
3498
+ writeVlCellCatalog(vlDirs.configVlDir, vlSource.path, text);
3499
+ vlCatalogSource = text;
3500
+ } catch (e) {
3501
+ removeVlCellCatalogQuietly(vlDirs.configVlDir);
3502
+ return { ok: false, info, reason: `catalogo vl non copiabile (${vlSource.path}): ${e.message} — avvio rifiutato invece di partire senza catalogo (fonte: ${vlSource.kind})` };
3503
+ }
3504
+ } else {
3505
+ // Nessuna sorgente: l'overlay generato NON sopravvive al cambio di
3506
+ // engine. Un catalogo dell'engine precedente, rimasto nella cella, e'
3507
+ // un'eredit' invisibile — e il motore di questa riga e' proprio
3508
+ // l'overlay, non la sua copia.
3509
+ try {
3510
+ removeVlCellCatalog(vlDirs.configVlDir);
3511
+ } catch (e) {
3512
+ return { ok: false, info, reason: `overlay vl generato non rimosso (${vlDirs.configVlDir}): ${e.message} — avvio rifiutato invece di partire con un catalogo precedente` };
3513
+ }
3514
+ }
3515
+ // vlProfile: single `-p <name>` argument; the profile
3516
+ // decides provider/base/model/effort unless NC carries an explicit
3517
+ // operator choice. Precedence, tested: explicit NC choice > profile >
3518
+ // the cell's selected.toml. In profile mode the provider/base pair is
3519
+ // NEUTRALIZED (not merely not-composed): inherited or envPassthrough
3520
+ // reintroductions of VL_PROVIDER/VL_BASE_URL are dropped after this
3521
+ // branch — as are VL_MODEL/VL_EFFORT when NC has no explicit choice.
3522
+ // VL_API_KEY and the profile's key names are never touched.
3523
+ if (spec.vlProfile) {
3524
+ if (!vlCatalogSource) {
3525
+ return { ok: false, info, reason: `vlProfile "${spec.vlProfile}" senza catalogo: nessuna sorgente (vlConfigFile o catalogo dell'operatore) in cui il profilo possa esistere` };
3526
+ }
3527
+ const catalogo = vlCatalogProfileSet(vlCatalogSource);
3528
+ if (!catalogo.ok) {
3529
+ return { ok: false, info, reason: `catalogo vl non valido (${vlSource.path}): ${catalogo.reason} — avvio rifiutato invece di partire da un catalogo illeggibile` };
3530
+ }
3531
+ if (!catalogo.profiles.has(spec.vlProfile)) {
3532
+ return { ok: false, info, reason: `profilo vl "${spec.vlProfile}" non dichiarato nel catalogo (${vlSource.path}) — profilo inesistente rifiutato esplicitamente` };
3533
+ }
3534
+ args.push('-p', spec.vlProfile);
3535
+ vlNeutralize.push('VL_PROVIDER', 'VL_BASE_URL');
3536
+ if (!model) vlNeutralize.push('VL_MODEL');
3537
+ if (!spec.effort) vlNeutralize.push('VL_EFFORT');
3538
+ } else if (spec.provider === 'custom') {
2941
3539
  // il protocollo scelto nel custom E' il provider wire: il ramo custom di
2942
3540
  // normalizeManagedSpec ha gia' garantito che sia uno dei tre che il
2943
3541
  // runtime parla e che baseUrl esista (endpoint obbligatorio: un default
@@ -2960,6 +3558,28 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2960
3558
  env.VL_PROVIDER = profile.vlProvider;
2961
3559
  env.VL_BASE_URL = profile.endpoint;
2962
3560
  }
3561
+ // Explicit NC model choice beats the profile (env composed only when the
3562
+ // operator actually chose; without a choice profile/selected.toml decides
3563
+ // and any ambient VL_MODEL was neutralized above).
3564
+ if (model) env.VL_MODEL = model;
3565
+ // Effort: only declared levels (validated at normalize
3566
+ // against the model descriptor), and only if the CHOSEN binary's runtime
3567
+ // reads VL_EFFORT — version-gated like the prompt gate, fail-closed with a
3568
+ // declared refusal instead of a silently ignored request.
3569
+ if (spec.effort) {
3570
+ const effortGate = vlEffortGate(info.binary, cfg);
3571
+ if (!effortGate.ok) return { ok: false, info, reason: effortGate.reason };
3572
+ env.VL_EFFORT = spec.effort;
3573
+ }
3574
+ // Diagnostics without secrets: which binary will run, which vl profile,
3575
+ // which effort, where the cell state lives. Distinguishes engine (info
3576
+ // already carries it), vl profile and effective model for the operator.
3577
+ info.vlRuntime = {
3578
+ binary: info.binary,
3579
+ profile: spec.vlProfile || '',
3580
+ effort: spec.effort || '',
3581
+ stateDir: vlDirs.base,
3582
+ };
2963
3583
  // Per-cell prompt: file composed on the runtime system
2964
3584
  // (VL_SYSTEM_APPEND_FILE). The version gate lives in code, not in hope:
2965
3585
  // below 0.3.1 the variable is silently ignored, and a cell starting without
@@ -3087,6 +3707,22 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
3087
3707
  if (spec.envPassthrough && spec.envPassthrough.length) {
3088
3708
  const sources = credentialSources(cfg, home);
3089
3709
  for (const name of spec.envPassthrough) {
3710
+ // vl profile mode: a neutralized name is DROPPED, not resolved — the
3711
+ // profile (or the explicit NC choice) owns that field, and an ambient or
3712
+ // passthrough value would silently override the catalog the operator
3713
+ // pointed the cell at. Dropping also skips the fail-closed "name not
3714
+ // set" refusal: the value is not supposed to arrive at all.
3715
+ if (vlNeutralize.includes(name)) continue;
3716
+ // Defense in depth: normalizeManagedSpec already refuses a vl spec whose
3717
+ // passthrough carries a VL_* engine-choice name; this guard keeps the
3718
+ // promise even for callers that bypass normalization (the value would be
3719
+ // composed AFTER the vl branch and silently win over the engine choice).
3720
+ if (spec.client === 'vl' && VL_OWNED_ENV.has(name)) continue;
3721
+ // Idem per il trio XDG: sono l'identita' per cella, non una credenziale.
3722
+ // normalizeManagedSpec li rifiuta gia' alla sorgente; qui la difesa in
3723
+ // profondita' tiene la promessa anche per chi chiama questa funzione
3724
+ // senza passare dalla validazione.
3725
+ if (spec.client === 'vl' && VL_CELL_DIR_ENV.has(name)) continue;
3090
3726
  const value = sources.runtime[name] || sources.local[name] || sources.shell[name]
3091
3727
  || sources.keys[name] || sources.legacy[name];
3092
3728
  if (!value) {
@@ -3095,6 +3731,15 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
3095
3731
  env[name] = value;
3096
3732
  }
3097
3733
  }
3734
+ // Same neutralization applied to the composed env itself: defense in depth
3735
+ // for any future path that reintroduces these names after the vl branch.
3736
+ for (const name of vlNeutralize) delete env[name];
3737
+ // Assegnazione FINALE del trio XDG per cella: riassegnata qui, dopo il
3738
+ // loop di passthrough e dopo la neutralizzazione, cosi' l'ultima parola e'
3739
+ // di NexusCrew. Senza questo, un valore di passthrough (o un'eredità
3740
+ // ambientale iniettata piu' a valle) sovrascriverebbe in silenzio
3741
+ // l'identita' per cella e due celle finirebbero sulla stessa directory.
3742
+ if (vlCellEnv) Object.assign(env, vlCellEnv);
3098
3743
  if (spec.client === 'codex-vl' && env[CODEX_APP_SERVER_IDENTITY_REQUIRED] !== resolvedIdentityRequired) {
3099
3744
  return { ok: false, info, reason: 'Fleet identity environment passthrough conflicts with the resolved launch decision' };
3100
3745
  }
@@ -3165,10 +3810,13 @@ module.exports = {
3165
3810
  OPENCODE_GO_MESSAGES_MODELS, OPENCODE_GO_RESPONSES_MODELS, OPENCODE_GO_CHAT_MODELS, OPENCODE_GO_LIMITS,
3166
3811
  OPENCODE_GO_ANTHROPIC_ROOT, OPENCODE_GO_API_BASE,
3167
3812
  CLIENT_LABELS, CLIENT_ORDER, engineClient, ordinaEngine, isStandardClient, normalizeManagedSpec, profileFor,
3168
- defaultDefinitions, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, engineModelDiscoveryPlan, resolveManagedEngine, needsExplicitNode,
3813
+ defaultDefinitions, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, engineModelDiscoveryPlan, modelListingCredential, resolveManagedEngine, needsExplicitNode,
3169
3814
  discoverPiModels, EXTERNAL_DISCOVERY_TIMEOUT_MS, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension, customCatalogFor,
3170
3815
  codexSurfaceNames, writeCellCodexProfile,
3171
3816
  writeVlCellPrompt, vlSystemAppendGate,
3817
+ ensureVlCellDirs, vlCellBaseDir, writeVlCellCatalog, vlCatalogProfiles,
3818
+ vlOperatorCatalogPath, removeVlCellCatalog,
3819
+ resolveVlBinary, vlEffortGate, vlEffortMinVersion: () => [...VL_EFFORT_MIN], vlVersionAtLeast,
3172
3820
  extraModelsFrom, declaredModelsFor, declaredFor,
3173
3821
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential, CREDENTIAL_SOURCE_VALUES,
3174
3822
  credentialEnvNeutralizeSet, applyStoreNeutralization,