@mmmbuto/nexuscrew 0.8.58 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +197 -2
  2. package/README.md +23 -4
  3. package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
  4. package/frontend/dist/assets/index-Bu_-2-Uu.js +93 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/dist/version.json +1 -1
  7. package/lib/audio/adapters.js +50 -6
  8. package/lib/cli/commands.js +54 -2
  9. package/lib/cli/doctor.js +95 -12
  10. package/lib/cli/init.js +25 -3
  11. package/lib/cli/path.js +43 -10
  12. package/lib/cli/pidfile.js +23 -2
  13. package/lib/config.js +24 -0
  14. package/lib/fleet/builtin.js +161 -19
  15. package/lib/fleet/catalogs/opencode-go.json +50 -4
  16. package/lib/fleet/cell-exec.js +87 -9
  17. package/lib/fleet/cell-lease-server.js +719 -0
  18. package/lib/fleet/cell-lease.js +112 -0
  19. package/lib/fleet/definitions.js +101 -7
  20. package/lib/fleet/launch-broker.js +115 -3
  21. package/lib/fleet/lease-client.js +191 -0
  22. package/lib/fleet/lease-routes.js +92 -0
  23. package/lib/fleet/lease-verifier.js +230 -0
  24. package/lib/fleet/managed.js +366 -48
  25. package/lib/fleet/prompt-delivery.js +50 -2
  26. package/lib/fleet/provider.js +1 -1
  27. package/lib/fleet/runtime.js +53 -6
  28. package/lib/live-host/bridge.js +369 -0
  29. package/lib/live-host/routes.js +190 -0
  30. package/lib/live-host/store.js +96 -0
  31. package/lib/mcp/tools.js +51 -0
  32. package/lib/nodes/commands.js +21 -3
  33. package/lib/nodes/store.js +57 -0
  34. package/lib/nodes/tunnel.js +23 -1
  35. package/lib/proxy/federation.js +130 -9
  36. package/lib/proxy/node-proxy.js +33 -0
  37. package/lib/proxy/panel-auth.js +337 -0
  38. package/lib/proxy/panel-proxy.js +336 -0
  39. package/lib/server.js +252 -7
  40. package/lib/settings/pairing-coordinator.js +32 -0
  41. package/lib/settings/public-peering-routes.js +13 -1
  42. package/package.json +1 -1
  43. package/skills/aidesktop/SKILL.md +201 -0
  44. package/skills/aidesktop/docker/Dockerfile +21 -0
  45. package/skills/aidesktop/docker/custom-cont-init.d/10-cdp-relay.sh +20 -0
  46. package/skills/aidesktop/docker/docker-compose.example.yml +75 -0
  47. package/skills/crew/SKILL.md +15 -0
  48. package/skills/live/SKILL.md +90 -0
  49. package/skills/mail-assistant/SKILL.md +15 -0
  50. package/skills/memory/SKILL.md +15 -0
  51. package/skills/nexuscrew/SKILL.md +113 -0
  52. package/skills/nexuscrew-agent/SKILL.md +18 -0
  53. package/skills/vl-msa/SKILL.md +15 -0
  54. package/frontend/dist/assets/index-BEGNtmx2.js +0 -93
  55. package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
  56. package/skills/alibaba-token-media/SKILL.md +0 -133
  57. package/skills/alibaba-token-media/agents/openai.yaml +0 -4
  58. package/skills/alibaba-token-media/references/api-contract.md +0 -97
  59. package/skills/alibaba-token-media/scripts/alibaba_token_media.py +0 -550
  60. package/skills/fill-forms/SKILL.md +0 -154
  61. package/skills/fill-forms/agents/openai.yaml +0 -4
  62. package/skills/fill-forms/references/overlay-technique.md +0 -99
  63. package/skills/fill-forms/requirements.txt +0 -4
  64. package/skills/fill-forms/scripts/dump_docx.py +0 -70
  65. package/skills/fill-forms/scripts/fill_docx.py +0 -207
  66. package/skills/fill-forms/scripts/fill_pdf.py +0 -424
  67. package/skills/fill-forms/scripts/inspect_pdf.py +0 -188
  68. package/skills/fill-forms/scripts/prepare_signature.py +0 -171
@@ -57,16 +57,33 @@ function declaredFor(extraModels, profileId, model) {
57
57
  // chiamanti perche' ognuno di loro la ricostruirebbe a modo suo, e basta che
58
58
  // uno la dimentichi perche' un modello dichiarato smetta di essere valido
59
59
  // proprio nel punto che conta — l'avvio.
60
+ //
61
+ // D2: Map<engine, Map<id, model>> — porta il descrittore intero, non solo
62
+ // l'id (stesso motivo del commento gemello in definitions.js: extraModels e'
63
+ // costruita due volte, una dentro parseDefinitions per il parsing, una qui per
64
+ // il runtime che rilegge le definizioni gia' salvate; le due DEVONO restare
65
+ // nella stessa forma).
60
66
  function extraModelsFrom(defs) {
61
67
  const map = new Map();
62
68
  for (const m of (defs && Array.isArray(defs.models) ? defs.models : [])) {
63
69
  if (!m || typeof m.engine !== 'string' || typeof m.id !== 'string') continue;
64
- if (!map.has(m.engine)) map.set(m.engine, new Set());
65
- map.get(m.engine).add(m.id);
70
+ if (!map.has(m.engine)) map.set(m.engine, new Map());
71
+ map.get(m.engine).set(m.id, m);
66
72
  }
67
73
  return map;
68
74
  }
69
75
 
76
+ // I descrittori dichiarati per UN profilo (client.provider, es.
77
+ // 'codex-vl.custom'), come array. Chi ha bisogno solo del catalogo dei
78
+ // modelli (customCatalogFor, writePiProviderExtension) chiama questa; chi ha
79
+ // bisogno solo di validare un id (declaredFor) continua a usare extraModels
80
+ // direttamente — due bisogni diversi sulla STESSA struttura, non due copie.
81
+ function declaredModelsFor(extraModels, profileId) {
82
+ if (!extraModels || typeof extraModels.get !== 'function') return [];
83
+ const byId = extraModels.get(profileId);
84
+ return byId && typeof byId.values === 'function' ? [...byId.values()] : [];
85
+ }
86
+
70
87
  function canonicalModel(model) {
71
88
  const key = String(model || '');
72
89
  return Object.hasOwn(MODEL_ALIASES, key) ? MODEL_ALIASES[key] : model;
@@ -120,22 +137,28 @@ const ALIBABA_PI_MODELS = Object.freeze([
120
137
  // per distinguere il rifiuto di wire dal payload perso in traduzione).
121
138
  //
122
139
  // Cosa NON e' in elenco, e perche' non e' una svista:
123
- // - kimi-*, deepseek-v4-pro, mimo-v2.5*, hy3 fuori da Messages/Responses: il
124
- // gateway inoltra un payload vuoto e l'upstream risponde "messages must not
125
- // be empty". E' un difetto loro, reversibile senza preavviso: se un giorno
126
- // rispondono, l'id si dichiara per quell'engine senza toccare il codice.
140
+ // - kimi-*, mimo-v2.5*, hy3 fuori da Messages/Responses: il gateway inoltra
141
+ // un payload vuoto e l'upstream risponde "messages must not be empty". E' un
142
+ // difetto loro, reversibile senza preavviso: se un giorno rispondono, l'id
143
+ // si dichiara per quell'engine senza toccare il codice.
144
+ // - deepseek-v4-pro: MISURATO 2026-08-13 sul gateway opencode.ai/zen/go ->
145
+ // 200 su /v1/responses (status=completed) e 200 su /v1/messages
146
+ // (stop_reason=end_turn), auth x-api-key. DeepSeek ha aggiunto la Responses
147
+ // API a v4-pro il 13/08 (l'11/08 era escluso: "messages must not be empty"):
148
+ // per questo ora entra in MESSAGES e RESPONSES. Se smettesse di rispondere,
149
+ // andrebbe rimosso di nuovo — e questo commento va tenuto allineato al codice.
127
150
  // - mimo-v2-pro e mimo-v2-omni: deprecati dall'upstream ("migrate to
128
151
  // xiaomi/mimo-v2.5*"). hy3-preview: "Model is unavailable".
129
152
  // Il catalogo live li pubblicizza comunque; qui non entrano.
130
153
  // - grok-4.5 solo su Responses: su Chat risponde 503 e Messages lo rifiuta
131
154
  // esplicitamente ("not supported for format anthropic").
132
155
  const OPENCODE_GO_MESSAGES_MODELS = Object.freeze([
133
- 'deepseek-v4-flash', 'glm-5.2', 'glm-5.1', 'glm-5',
156
+ 'deepseek-v4-flash', 'deepseek-v4-pro', 'glm-5.2', 'glm-5.1', 'glm-5',
134
157
  'minimax-m3', 'minimax-m2.7', 'minimax-m2.5',
135
158
  'qwen3.8-max', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.5-plus',
136
159
  ]);
137
160
  const OPENCODE_GO_RESPONSES_MODELS = Object.freeze([
138
- 'deepseek-v4-flash', 'gpt-5.6-luna', 'grok-4.5', 'glm-5.2', 'glm-5.1', 'glm-5',
161
+ 'deepseek-v4-flash', 'deepseek-v4-pro', 'gpt-5.6-luna', 'grok-4.5', 'glm-5.2', 'glm-5.1', 'glm-5',
139
162
  ]);
140
163
  const OPENCODE_GO_CHAT_MODELS = Object.freeze([
141
164
  'deepseek-v4-flash', 'deepseek-v4-pro', 'glm-5.2', 'glm-5.1', 'glm-5',
@@ -206,11 +229,26 @@ const OPENCODE_GO_PI_MODELS = Object.freeze(OPENCODE_GO_CHAT_MODELS.map((id) =>
206
229
  })));
207
230
 
208
231
  const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
209
- const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', 'credentialSourcePolicy', ...CUSTOM_KEYS]);
232
+ const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', 'credentialSourcePolicy', 'envPassthrough', ...CUSTOM_KEYS]);
233
+ // D3: massimo numero di nomi in `envPassthrough`. L'allowlist e' opt-in e per
234
+ // nome, mai un passthrough in blocco: un tetto basso ferma una lista incontrollata.
235
+ const MAX_ENV_PASSTHROUGH = 32;
210
236
  // Explicit credential source policy. Default 'auto' preserves the legacy
211
237
  // resolution order (runtime -> store -> shell -> key files -> legacy) so a
212
238
  // pre-WP1 fleet.json migrates no-op: no existing cell changes resolution.
213
239
  const CREDENTIAL_SOURCES = Object.freeze(['environment', 'nexuscrew-store', 'auto']);
240
+ // I valori che `credential().source` puo' assumere, e che escono verso la UI in
241
+ // `describeManaged().credentialSource` / `describeCatalogCredential()`. La UI li
242
+ // rende con la chiave `fleet-credential-source-<valore>`, e `t()` su chiave
243
+ // assente restituisce LA CHIAVE: un valore nuovo senza traduzione si vede a
244
+ // schermo come stringa tecnica. Questa lista e' l'ancora della sonda in
245
+ // tests/i18n.test.js — la parita' fra le tre lingue e' gia' garantita da un
246
+ // altro test, qui si garantisce la COPERTURA dei valori che il backend produce.
247
+ // Chi aggiunge un valore in credential() aggiunge una riga qui e la stringa nei
248
+ // tre dizionari, oppure il gate lo ferma.
249
+ const CREDENTIAL_SOURCE_VALUES = Object.freeze([
250
+ 'login', 'none', 'environment', 'nexuscrew-store', 'local', 'compatibility', 'missing', 'unreadable',
251
+ ]);
214
252
  const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', grok: 'Grok', vl: 'VL', pi: 'Pi', agy: 'Agy', kimi: 'Kimi Code CLI', shell: 'Shell' });
215
253
  const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
216
254
 
@@ -237,7 +275,12 @@ const CATALOG = Object.freeze([
237
275
  { id: 'claude.native', client: 'claude', provider: 'native', label: 'Anthropic / Claude account', auth: 'login', endpoint: 'Anthropic account', protocol: 'anthropic_messages', rc: true, default: true, core: true },
238
276
  { id: 'claude.alibaba-token-plan', client: 'claude', provider: 'alibaba-token-plan', label: 'Alibaba Token Plan Personal', auth: 'ALIBABA_CODE_API_KEY', endpoint: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic', protocol: 'anthropic_messages', model: 'qwen3.8-max', models: ALIBABA_TOKEN_PLAN_MODELS, strictModels: true, core: true, notice: 'alibaba-token-plan' },
239
277
  { id: 'claude.kimi-code', client: 'claude', provider: 'kimi-code', label: 'Kimi Code', auth: 'KIMI_API_KEY', endpoint: 'https://api.kimi.com/coding/', protocol: 'anthropic_messages', model: 'k3[1m]', models: ['k3', 'k3[1m]', 'kimi-for-coding', 'kimi-for-coding-highspeed'], strictModels: true, core: true, notice: 'claude-kimi-code' },
240
- { id: 'claude.zai', client: 'claude', provider: 'zai', label: 'Z.AI', auth: 'dynamic', credentialEnv: true, defaultEnvKey: 'ZAI_API_KEY', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], core: true },
278
+ // GLM-5.3 dal 2026-08-15. Sul Coding Plan lo switch e' gia' automatico
279
+ // chiedendo `glm-5.2` il server risponde `glm-5.3` — ma il nome scritto qui
280
+ // deve dire la verita' su cosa stiamo usando. Il suffisso `[1m]` resta: e' un
281
+ // flag di finestra del CLI e viene tolto prima della richiesta HTTP, dove
282
+ // `glm-5.3[1m]` letterale darebbe 400 (code 1214, misurato).
283
+ { id: 'claude.zai', client: 'claude', provider: 'zai', label: 'Z.AI', auth: 'dynamic', credentialEnv: true, defaultEnvKey: 'ZAI_API_KEY', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.3[1m]', models: ['glm-5.3[1m]', 'glm-5.2[1m]'], core: true },
241
284
  // OpenCode Go su Claude parla Anthropic Messages, e la wire accetta SOLO
242
285
  // `x-api-key`: con `Authorization: Bearer` risponde 401 AuthError. Per questo
243
286
  // l'endpoint e' la root senza `/v1` (il client aggiunge `/v1/messages`) e il
@@ -317,7 +360,7 @@ const CATALOG = Object.freeze([
317
360
  { id: 'pi.github-copilot', client: 'pi', provider: 'github-copilot', label: 'GitHub Copilot', auth: 'login', protocol: 'pi_native', piProvider: 'github-copilot', core: true },
318
361
  { id: 'pi.deepseek', client: 'pi', provider: 'deepseek', label: 'DeepSeek', auth: 'DEEPSEEK_API_KEY', protocol: 'pi_native', piProvider: 'deepseek', core: true },
319
362
  // Provider Pi NON core: restano fuori dal catalogo UI (publicCatalog filtra
320
- // core/default/custom), in attesa di decisione DAG. Vengono risolti solo da
363
+ // core/default/custom), in attesa di una decisione di progetto. Vengono risolti solo da
321
364
  // configurazione esistente via profileFor. Etichette senza prefisso "Pi · ".
322
365
  { id: 'pi.fireworks', client: 'pi', provider: 'fireworks', label: 'Fireworks AI', auth: 'FIREWORKS_API_KEY', protocol: 'pi_native', piProvider: 'fireworks' },
323
366
  { id: 'pi.huggingface', client: 'pi', provider: 'huggingface', label: 'Hugging Face', auth: 'HF_TOKEN', protocol: 'pi_native', piProvider: 'huggingface' },
@@ -356,8 +399,8 @@ const CATALOG = Object.freeze([
356
399
  // --- Sezione legacy --------------------------------------------------------
357
400
  // Compatibilita' sola lettura/launch per configurazioni 0.8.0: mai nel catalogo
358
401
  // UI (publicCatalog filtra `legacy`). Risolti solo da profileFor/normalizeManagedSpec.
359
- { id: 'claude.zai-a', client: 'claude', provider: 'zai', credentialProfile: 'a', label: 'Z.AI legacy profile', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-a', legacy: true },
360
- { id: 'claude.zai-p', client: 'claude', provider: 'zai', credentialProfile: 'p', label: 'Z.AI legacy profile', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-p', legacy: true },
402
+ { id: 'claude.zai-a', client: 'claude', provider: 'zai', credentialProfile: 'a', label: 'Z.AI legacy profile', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.3[1m]', models: ['glm-5.3[1m]', 'glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-a', legacy: true },
403
+ { id: 'claude.zai-p', client: 'claude', provider: 'zai', credentialProfile: 'p', label: 'Z.AI legacy profile', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.3[1m]', models: ['glm-5.3[1m]', 'glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-p', legacy: true },
361
404
  ]);
362
405
 
363
406
  function profileFor(client, provider, credentialProfile) {
@@ -428,6 +471,27 @@ function normalizeManagedSpec(value, { extraModels = null } = {}) {
428
471
  if (!model || !(profile.protocols || [profile.protocol]).includes(protocol)) return null;
429
472
  Object.assign(out, { displayName, baseUrl, envKey, protocol, providerId });
430
473
  }
474
+ // D3: envPassthrough e' un'allowlist di NOMI di variabili d'ambiente che il
475
+ // child deve ricevere, risolti a runtime dalle credentialSources (dopo i rami
476
+ // provider in resolveManagedEngine). E' opt-in e per nome: MAI un passthrough
477
+ // in blocco dell'ambiente, solo i nomi elencati — ciascuno un nome env valido.
478
+ // E il mezzo con cui una cella vl (auth 'none', ramo senza env provider) riceve
479
+ // le variabili che il suo runtime legge: il nome non e' fisso nel codice vl
480
+ // (vivling/src/main.rs), quindi lo dichiara l'operatore che conosce la sua config.
481
+ if (value.envPassthrough !== undefined) {
482
+ if (!Array.isArray(value.envPassthrough) || !value.envPassthrough.length
483
+ || value.envPassthrough.length > MAX_ENV_PASSTHROUGH) return null;
484
+ const seen = new Set();
485
+ const names = [];
486
+ for (const raw of value.envPassthrough) {
487
+ if (typeof raw !== 'string') return null;
488
+ const name = raw.trim();
489
+ if (!ENV_KEY_RE.test(name) || seen.has(name)) return null;
490
+ seen.add(name);
491
+ names.push(name);
492
+ }
493
+ out.envPassthrough = names;
494
+ }
431
495
  return out;
432
496
  }
433
497
 
@@ -535,7 +599,17 @@ function safeAllowedRoots(roots = []) {
535
599
  return out;
536
600
  }
537
601
 
538
- function parseEnvFile(file, opts = {}) {
602
+ // `out.blocked` (accumulatore opzionale) raccoglie i file CHE ESISTONO ma non si
603
+ // sono potuti leggere/verificare (EACCES/ELOOP/ENOTDIR/EIO...). I rifiuti
604
+ // deliberati (symlink fuori roots, mode/uid/size non validi) restano `return {}`
605
+ // espliciti — sono legittimi "questo file non e' una credenziale valida" — e NON
606
+ // finiscono nei blocked. ENOENT nel catch e' "il file non c'e'" (legittimo, niente
607
+ // valore); solo gli altri code sono "non ho potuto guardare", e vanno distinti dal
608
+ // "missing" che il caller altrimenti riporterebbe per una credenziale presente ma
609
+ // illeggibile. Il verdetto (niente valori estratti -> {}) e' invariato; il
610
+ // discriminante e' CHI ha fallito, non che ci sia stata un'eccezione.
611
+ function parseEnvFile(file, opts = {}, out) {
612
+ const blocked = Array.isArray(out && out.blocked) ? out.blocked : null;
539
613
  try {
540
614
  const lst = fs.lstatSync(file);
541
615
  let target = file;
@@ -549,16 +623,23 @@ function parseEnvFile(file, opts = {}) {
549
623
  if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) || st.size > 256 * 1024) return {};
550
624
  if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
551
625
  return parseAssignments(fs.readFileSync(target, 'utf8'));
552
- } catch (_) { return {}; }
626
+ } catch (e) {
627
+ if (blocked && e.code !== 'ENOENT') blocked.push({ path: file, code: e.code || e.constructor.name });
628
+ return {};
629
+ }
553
630
  }
554
631
 
555
- function parseProviderShellFile(file) {
632
+ function parseProviderShellFile(file, out) {
633
+ const blocked = Array.isArray(out && out.blocked) ? out.blocked : null;
556
634
  try {
557
635
  const st = fs.lstatSync(file);
558
636
  if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o022) || st.size > 256 * 1024) return {};
559
637
  if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
560
638
  return parseAssignments(fs.readFileSync(file, 'utf8'));
561
- } catch (_) { return {}; }
639
+ } catch (e) {
640
+ if (blocked && e.code !== 'ENOENT') blocked.push({ path: file, code: e.code || e.constructor.name });
641
+ return {};
642
+ }
562
643
  }
563
644
 
564
645
  function binaryCandidates(client, home) {
@@ -575,14 +656,28 @@ function binaryCandidates(client, home) {
575
656
  ].filter(Boolean))];
576
657
  }
577
658
 
578
- function findBinary(client, home) {
659
+ // `out.blocked` (accumulatore opzionale) raccoglie i candidati CHE ESISTONO come
660
+ // nome nel PATH ma che non si sono potuti VERIFICARE (EACCES/ELOOP/ENOTDIR...),
661
+ // non i candidati assenti (ENOENT = legittimo "prossimo"). Il verdetto del caller
662
+ // non cambia: findBinary torna comunque null se nessun candidato e' confermato
663
+ // (non possiamo dichiararlo "trovato"); ma chi costruisce il messaggio
664
+ // (describeManaged) puo' ora distinguere "client non trovato" da "non ho potuto
665
+ // verificare un candidato" — il discriminante e' CHI ha fallito, non che ci sia
666
+ // stata un'eccezione. Stesso principio gia' applicato in checkTermuxExec (3134d2f).
667
+ function findBinary(client, home, out) {
668
+ const blocked = Array.isArray(out && out.blocked) ? out.blocked : null;
579
669
  for (const candidate of binaryCandidates(client, home)) {
580
670
  try {
581
671
  const real = fs.realpathSync(candidate); const st = fs.lstatSync(real);
582
672
  if (!st.isFile() || !(st.mode & 0o100) || (st.mode & 0o002)) continue;
583
673
  if (typeof process.getuid === 'function' && st.uid !== process.getuid() && st.uid !== 0) continue;
584
674
  return real;
585
- } catch (_) { /* next */ }
675
+ } catch (e) {
676
+ // ENOENT = il candidato non esiste (legittimo "prossimo"); qualsiasi altro
677
+ // code (EACCES/ELOOP/ENOTDIR/EIO...) = "esiste ma non ho potuto guardarlo",
678
+ // e va distinto dal "non trovato" finale, non collassato in "prossimo".
679
+ if (blocked && e.code !== 'ENOENT') blocked.push({ path: candidate, code: e.code || e.constructor.name });
680
+ }
586
681
  }
587
682
  return null;
588
683
  }
@@ -590,7 +685,7 @@ function findBinary(client, home) {
590
685
  // Resolve a device-local interactive shell without persisting a path in
591
686
  // fleet.json. Candidates are ordered and fail closed. Symlinks are resolved
592
687
  // first, then the existing command trust policy is applied to the real file.
593
- function resolveInteractiveShell(cfg = {}) {
688
+ function resolveInteractiveShell(cfg = {}, out) {
594
689
  const env = cfg.env || process.env;
595
690
  const platform = cfg.platform || process.platform;
596
691
  const termux = termuxRuntimePaths(env, { platform, home: cfg.home });
@@ -603,11 +698,19 @@ function resolveInteractiveShell(cfg = {}) {
603
698
  candidates.push('/bin/bash', '/bin/sh');
604
699
  const validate = cfg.validateCommandTrust
605
700
  || ((command) => require('./definitions.js').validateCommandTrust(command));
701
+ const blocked = Array.isArray(out && out.blocked) ? out.blocked : null;
606
702
  for (const candidate of [...new Set(candidates)]) {
607
703
  try {
608
704
  const real = fs.realpathSync(candidate);
609
705
  if (validate(real).ok) return real;
610
- } catch (_) { /* next candidate */ }
706
+ } catch (e) {
707
+ // ENOENT = il candidato non esiste (legittimo "prossimo"); altro code
708
+ // (EACCES/ELOOP/ENOTDIR...) = "non ho potuto verificare", da distinguere
709
+ // dal "nessuna shell" finale. validate() che torna ok:false NON e' un
710
+ // throw: e' un rifiuto legittimo (candidato presente ma non fidato), resta
711
+ // "prossimo" senza finire nei blocked — il discriminante e' CHI ha fallito.
712
+ if (blocked && e.code !== 'ENOENT') blocked.push({ path: candidate, code: e.code || e.constructor.name });
713
+ }
611
714
  }
612
715
  return null;
613
716
  }
@@ -672,34 +775,40 @@ function providerKeyPaths(cfg, home) {
672
775
  return [...new Set(paths.filter((file) => typeof file === 'string' && file))];
673
776
  }
674
777
 
675
- function parseProviderKeyFiles(cfg, home) {
778
+ function parseProviderKeyFiles(cfg, home, out) {
676
779
  const values = {};
677
780
  // Match providers.zsh ordering: a later secure file may intentionally
678
781
  // override the canonical ai.env value. Files remain data-only and must be
679
782
  // private regular files owned by the NexusCrew user.
680
783
  const files = providerKeyPaths(cfg, home);
681
784
  const roots = [...new Set(files.map((file) => path.dirname(path.resolve(file))))];
682
- for (const file of files) Object.assign(values, parseEnvFile(file, { allowSymlinkRoots: roots }));
785
+ for (const file of files) Object.assign(values, parseEnvFile(file, { allowSymlinkRoots: roots }, out));
683
786
  return values;
684
787
  }
685
788
 
686
- function credentialSources(cfg, home) {
789
+ // `trackLegacy`: il file legacy viene letto sempre, ma e' una FONTE solo per i
790
+ // profili con legacySecrets. Tracciarne l'illeggibilita' anche per gli altri
791
+ // produce un messaggio che manda l'operatore a sistemare un permesso
792
+ // irrilevante — «non verificabile» su un file che per quella chiave non conta
793
+ // nulla, mentre la chiave e' davvero assente. Il tracciamento segue la fonte,
794
+ // non la lettura.
795
+ function credentialSources(cfg, home, out, { trackLegacy = true } = {}) {
687
796
  let local = {};
688
797
  try { local = readCredentialStore(cfg, home); } catch (_) { /* unsafe/corrupt store is ignored, never trusted */ }
689
798
  return {
690
799
  runtime: cfg.env || process.env,
691
800
  local,
692
- shell: parseProviderShellFile(shellProvidersPath(cfg, home)),
693
- keys: parseProviderKeyFiles(cfg, home),
694
- legacy: parseEnvFile(secretsPath(cfg, home)),
801
+ shell: parseProviderShellFile(shellProvidersPath(cfg, home), out),
802
+ keys: parseProviderKeyFiles(cfg, home, out),
803
+ legacy: parseEnvFile(secretsPath(cfg, home), {}, trackLegacy ? out : undefined),
695
804
  };
696
805
  }
697
806
 
698
- function credential(profile, spec, cfg, home) {
807
+ function credential(profile, spec, cfg, home, out) {
699
808
  if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '', source: profile.auth };
700
809
  const envKey = profile.auth === 'dynamic' ? spec.envKey : profile.auth;
701
810
  const policy = spec && CREDENTIAL_SOURCES.includes(spec.credentialSourcePolicy) ? spec.credentialSourcePolicy : 'auto';
702
- const sources = credentialSources(cfg, home);
811
+ const sources = credentialSources(cfg, home, out, { trackLegacy: !!profile.legacySecrets });
703
812
  // The fixed shell file is already the user's environment source. Values are
704
813
  // consumed only in memory and passed to the selected child; never persisted
705
814
  // in fleet.json, service files, API responses or logs.
@@ -719,7 +828,15 @@ function credential(profile, spec, cfg, home) {
719
828
  if (profile.legacySecrets && sources.legacy[envKey]) {
720
829
  return { envKey, value: sources.legacy[envKey], source: 'compatibility' };
721
830
  }
722
- return { envKey, value: '', source: 'missing' };
831
+ // auto: nessuna fonte ha la chiave. Se un file credenziale esiste ma non si
832
+ // e' potuto leggere (EACCES/ELOOP...), non possiamo dichiarare la chiave
833
+ // "missing" (che implica "mettila su questo device"): e' "unreadable", non
834
+ // verificata. Il verdetto (niente valore -> authConfigured false) e' invariato;
835
+ // il messaggio di describeManaged lo distingue. environment/nexuscrew-store
836
+ // sopra restano 'missing': le loro fonti (runtime env / local store) non
837
+ // passano per i file parseEnvFile/parseProviderShellFile che tracciamo qui.
838
+ const unreadable = out && Array.isArray(out.blocked) && out.blocked.length;
839
+ return { envKey, value: '', source: unreadable ? 'unreadable' : 'missing' };
723
840
  }
724
841
 
725
842
  // The profile's "owned" env set. When the credential source is the local store,
@@ -839,22 +956,54 @@ async function discoverPiModels(opts = {}) {
839
956
  function describeManaged(spec, cfg = {}) {
840
957
  const extraModels = cfg.extraModels || null;
841
958
  const normalized = normalizeManagedSpec(spec, { extraModels });
842
- if (!normalized) return { configured: false, reason: 'invalid managed profile' };
959
+ if (!normalized) {
960
+ // D2: il rifiuto resta (i descrittori NON appartengono al profilo di una
961
+ // cella: vengono dalla definizione dell'ENGINE, in `d.models` — due
962
+ // soggetti diversi, mescolarli renderebbe ambiguo chi dichiara cosa). Ma
963
+ // se la causa e' proprio questa, il messaggio generico "invalid managed
964
+ // profile" manda a cercare ovunque tranne che nel posto giusto: dice
965
+ // dove i descrittori vanno davvero.
966
+ if (spec && typeof spec === 'object' && !Array.isArray(spec)
967
+ && Object.prototype.hasOwnProperty.call(spec, 'models')) {
968
+ return {
969
+ configured: false,
970
+ reason: '"models" non e\' un campo del profilo managed della cella — i descrittori dei modelli si dichiarano nella definizione dell\'ENGINE, nell\'array "models" del documento (schemaVersion/engines/cells/models), non qui',
971
+ };
972
+ }
973
+ return { configured: false, reason: 'invalid managed profile' };
974
+ }
843
975
  const home = cfg.home || require('node:os').homedir();
844
976
  const profile = profileFor(normalized.client, normalized.provider, normalized.credentialProfile || '');
977
+ // Tracciamento di candidati (binary) e file credenziali che ESISTONO ma non
978
+ // si sono potuti VERIFICARE (EACCES/ELOOP/ENOTDIR...). Il verdetto
979
+ // (configured/authConfigured) non cambia: il discriminante e' CHI ha fallito,
980
+ // non che ci sia stata un'eccezione. ENOENT = legittimo "non c'e'"; altro code
981
+ // = "non ho potuto guardare", e il messaggio deve dirlo invece di collassarlo
982
+ // in "not found" / "missing" (la stessa forma gia' chiusa in checkTermuxExec).
983
+ const binaryBlocked = [];
984
+ const credBlocked = [];
845
985
  const binary = normalized.client === 'shell'
846
- ? resolveInteractiveShell({ ...cfg, home })
847
- : findBinary(normalized.client, home);
848
- const cred = credential(profile, normalized, cfg, home);
986
+ ? resolveInteractiveShell({ ...cfg, home }, { blocked: binaryBlocked })
987
+ : findBinary(normalized.client, home, { blocked: binaryBlocked });
988
+ const cred = credential(profile, normalized, cfg, home, { blocked: credBlocked });
849
989
  // Pi can resolve credentials from its own documented /login auth store. Do
850
990
  // not inspect or copy that store; delegate native-provider auth to Pi.
851
991
  const delegatedPiAuth = profile.client === 'pi' && profile.provider !== 'custom'
852
992
  && profile.delegatePiAuth !== false;
853
993
  const authConfigured = delegatedPiAuth || profile.auth === 'login' || profile.auth === 'none' || !!cred.value;
854
994
  let configured = !!binary && authConfigured;
855
- let reason = !binary ? `client ${profile.client} not found` : (!authConfigured
856
- ? `credential ${cred.envKey} missing — set it on this device`
857
- : 'ready');
995
+ let reason;
996
+ if (!binary) {
997
+ reason = binaryBlocked.length
998
+ ? `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"`
999
+ : `client ${profile.client} not found`;
1000
+ } else if (!authConfigured) {
1001
+ reason = cred.source === 'unreadable'
1002
+ ? `credential ${cred.envKey} not verifiable (file present but unreadable: ${credBlocked.map((b) => `${b.path} (${b.code})`).join('; ')}) — not "missing"`
1003
+ : `credential ${cred.envKey} missing — set it on this device`;
1004
+ } else {
1005
+ reason = 'ready';
1006
+ }
858
1007
  // Agy e grok sono client primari supportati solo su Linux/macOS non-Termux.
859
1008
  // Rilevazione Termux via termuxRuntimePaths (non solo process.platform): un
860
1009
  // Node che riporta 'linux' sotto proot/Termux viene comunque intercettato.
@@ -872,7 +1021,11 @@ function describeManaged(spec, cfg = {}) {
872
1021
  permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
873
1022
  endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
874
1023
  credentialSourcePolicy: normalized.credentialSourcePolicy || 'auto',
875
- credentialSource: authConfigured ? cred.source : 'missing',
1024
+ // cred.source e' gia' 'missing' quando non c'e' valore: non serve il
1025
+ // ternario che lo forzava. Cosi' una credenziale presente ma illeggibile
1026
+ // (source 'unreadable', authConfigured false) non viene collassata in
1027
+ // 'missing' — il discriminante e' CHI ha fallito, non il esito grezzo.
1028
+ credentialSource: cred.source,
876
1029
  configured, models: [...(profile.models || [])], defaultModel: profile.model || '',
877
1030
  binary: binary || '', displayName: normalized.displayName || profile.label,
878
1031
  reason,
@@ -887,11 +1040,14 @@ function describeCatalogCredential(client, provider, credentialProfile = '', cfg
887
1040
  if (!profile || profile.auth === 'dynamic' || profile.auth === 'login' || profile.auth === 'none'
888
1041
  || !ENV_KEY_RE.test(profile.auth || '')) return null;
889
1042
  const home = cfg.home || require('node:os').homedir();
890
- const cred = credential(profile, {}, cfg, home);
1043
+ const cred = credential(profile, {}, cfg, home, { blocked: [] });
891
1044
  return {
892
1045
  envKey: cred.envKey,
893
1046
  authConfigured: !!cred.value,
894
- credentialSource: cred.value ? cred.source : 'missing',
1047
+ // cred.source e' gia' 'missing' quando non c'e' valore: non serve il
1048
+ // ternario che lo forzava. Cosi' una credenziale presente ma illeggibile
1049
+ // (source 'unreadable') non viene collassata in 'missing'.
1050
+ credentialSource: cred.source,
895
1051
  };
896
1052
  }
897
1053
 
@@ -1091,7 +1247,41 @@ function ensurePrivateClaudeConfig(home, profileId, label, penguinMode) {
1091
1247
  return configDir;
1092
1248
  }
1093
1249
 
1094
- function writePiProviderExtension(spec, home) {
1250
+ // D2 audit: il file .ts generato NON e' il consumatore — Pi lo E'. Un test che
1251
+ // legge solo il file resta verde su un'estensione che Pi rifiuta a runtime.
1252
+ // CONTRATTO REALE che Pi (@earendil-works/pi-coding-agent 0.80.10) impone a
1253
+ // ogni modello di `pi.registerProvider(id, {models: [...]})` — fonte:
1254
+ // core/extensions/types.d.ts, interface ProviderModelConfig (JSDoc del pacchetto
1255
+ // installato, non dedotto). Campi OBBLIGATORI: id, name, reasoning, input
1256
+ // (array "text"|"image"), cost ({input,output,cacheRead,cacheWrite}),
1257
+ // contextWindow, maxTokens. `input` mancante fa THROW dentro Pi la prima volta
1258
+ // che un tool consulta le capability del modello (es. core/tools/read.js:
1259
+ // `model.input.includes("image")` — TypeError su undefined, misurato e
1260
+ // riprodotto con il modulo Pi reale). I descrittori grezzi dichiarati
1261
+ // dall'operatore in `d.models` (via parseModel: id, engine, label?,
1262
+ // contextWindow, maxTokens, reasoning) NON hanno name/input/cost — vanno
1263
+ // arricchiti PRIMA di finire nell'estensione, mai passati cosi' come sono.
1264
+ function toPiModelConfig(m) {
1265
+ return {
1266
+ id: m.id,
1267
+ name: m.label || m.id,
1268
+ reasoning: m.reasoning === true,
1269
+ input: ['text'],
1270
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1271
+ contextWindow: m.contextWindow || 128000,
1272
+ maxTokens: m.maxTokens || 16384,
1273
+ };
1274
+ }
1275
+
1276
+ // D2: `declaredModels` (opzionale) sono i descrittori dichiarati per l'ENGINE
1277
+ // (via declaredModelsFor), per il ramo Pi CUSTOM — stesso motivo/stessa fonte
1278
+ // di customCatalogFor, mai letti da `spec.models`. Il ramo Pi NON-custom
1279
+ // (profile.piExtension) continua a passare `models` dentro l'oggetto spec-like
1280
+ // come sempre: e' un catalogo STATICO cablato nel codice (es. alibaba-token-
1281
+ // plan), GIA' nella forma completa che Pi si aspetta — un caso diverso, non
1282
+ // toccato dal difetto D2. declaredModels, quando presente, ha priorita', ed e'
1283
+ // sempre passato per toPiModelConfig (mai i descrittori grezzi cosi' come sono).
1284
+ function writePiProviderExtension(spec, home, declaredModels) {
1095
1285
  const dir = path.join(home, '.nexuscrew', 'pi-providers');
1096
1286
  try {
1097
1287
  const st = fs.lstatSync(dir);
@@ -1113,7 +1303,8 @@ function writePiProviderExtension(spec, home) {
1113
1303
  apiKey: spec.apiKey || `$${spec.envKey}`,
1114
1304
  authHeader: true,
1115
1305
  api: spec.protocol,
1116
- models: Array.isArray(spec.models) && spec.models.length ? spec.models : [{
1306
+ models: (Array.isArray(declaredModels) && declaredModels.length ? declaredModels.map(toPiModelConfig)
1307
+ : (Array.isArray(spec.models) && spec.models.length ? spec.models : null)) || [{
1117
1308
  id: spec.model, name: spec.model, reasoning: false, input: ['text'],
1118
1309
  contextWindow: 128000, maxTokens: 16384,
1119
1310
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -1132,6 +1323,87 @@ function writePiProviderExtension(spec, home) {
1132
1323
  return target;
1133
1324
  }
1134
1325
 
1326
+ // D2 (fix definitivo — l'audit del pacchetto aveva bocciato la prima versione:
1327
+ // il test costruiva `spec.models` a mano, e in produzione quel campo non
1328
+ // esiste mai). `declaredModels` NON viene da `spec`: i descrittori sono
1329
+ // proprieta' della definizione dell'ENGINE (`d.models` del documento), non del
1330
+ // profilo managed della cella — due soggetti diversi. Il chiamante
1331
+ // (resolveManagedEngine) li ricava con declaredModelsFor(extraModels,
1332
+ // profile.id) e li passa qui espliciti, cosi' questa funzione non finge mai
1333
+ // che `spec` porti qualcosa che semanticamente non gli appartiene.
1334
+ // Deriva model_catalog_json e model_context_window dal descrittore, così Codex-VL
1335
+ // non ricade sul fallback 272K (-73% finestra) con parallel tool call assenti.
1336
+ // Se non ci sono descrittori dichiarati -> null (comportamento invariato,
1337
+ // NESSUNA regressione per chi non li usa). NON consacra
1338
+ // ~/.codex/custom_provider_model_catalog.json: era una patch locale di una
1339
+ // singola installazione, non un contratto; il catalog è generato dai descrittori dichiarati
1340
+ // in fleet.json. I valori enum/default rispecchiano i cataloghi spediti
1341
+ // (validati dal test fleet-catalog-schema).
1342
+ function customCatalogFor(spec, model, declaredModels, home) {
1343
+ const models = Array.isArray(declaredModels) ? declaredModels : [];
1344
+ if (!models.length) return null;
1345
+ const entry = models.find((m) => m && m.id === model) || models[0];
1346
+ const cat = {
1347
+ models: models.map((m) => {
1348
+ const reasoning = m.reasoning === true;
1349
+ return {
1350
+ slug: m.id,
1351
+ display_name: m.label || m.id,
1352
+ description: m.label || m.id,
1353
+ default_reasoning_level: reasoning ? 'high' : 'medium',
1354
+ supported_reasoning_levels: reasoning
1355
+ ? [{ effort: 'low', description: 'Fast responses with lighter reasoning' },
1356
+ { effort: 'high', description: 'Greater reasoning depth for complex problems' },
1357
+ { effort: 'max', description: 'Maximum reasoning depth' }]
1358
+ : [{ effort: 'low', description: 'Fast responses with lighter reasoning' },
1359
+ { effort: 'medium', description: 'Balanced reasoning depth' },
1360
+ { effort: 'high', description: 'Greater reasoning depth for complex problems' }],
1361
+ shell_type: 'default',
1362
+ visibility: 'list',
1363
+ supported_in_api: true,
1364
+ priority: 50,
1365
+ availability_nux: null,
1366
+ upgrade: null,
1367
+ base_instructions: '',
1368
+ supports_reasoning_summaries: true,
1369
+ default_reasoning_summary: 'none',
1370
+ support_verbosity: false,
1371
+ default_verbosity: null,
1372
+ apply_patch_tool_type: null,
1373
+ web_search_tool_type: 'text',
1374
+ truncation_policy: { mode: 'tokens', limit: m.maxTokens || m.contextWindow || 128000 },
1375
+ supports_parallel_tool_calls: false,
1376
+ supports_image_detail_original: false,
1377
+ context_window: m.contextWindow || 128000,
1378
+ effective_context_window_percent: 95,
1379
+ experimental_supported_tools: [],
1380
+ input_modalities: ['text'],
1381
+ supports_search_tool: false,
1382
+ };
1383
+ }),
1384
+ };
1385
+ const dir = path.join(home, '.nexuscrew', 'custom-catalogs');
1386
+ try {
1387
+ const st = fs.lstatSync(dir);
1388
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new Error('unsafe custom catalog directory');
1389
+ } catch (e) {
1390
+ if (e.code === 'ENOENT') fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1391
+ else throw e;
1392
+ }
1393
+ fs.chmodSync(dir, 0o700);
1394
+ const target = path.join(dir, `${spec.providerId}.json`);
1395
+ const tmp = path.join(dir, `.${spec.providerId}.${crypto.randomBytes(6).toString('hex')}.tmp`);
1396
+ try {
1397
+ fs.writeFileSync(tmp, JSON.stringify(cat), { mode: 0o600 });
1398
+ fs.chmodSync(tmp, 0o600);
1399
+ fs.renameSync(tmp, target);
1400
+ } catch (e) {
1401
+ try { fs.unlinkSync(tmp); } catch (_) {}
1402
+ throw e;
1403
+ }
1404
+ return { catalogPath: target, contextWindow: entry.contextWindow || 128000 };
1405
+ }
1406
+
1135
1407
  function resolveManagedEngine(engine, cell, cfg = {}) {
1136
1408
  // `extraModels` DEVE arrivare fin qui. Threadarlo al chiamante e alla vista
1137
1409
  // non basta: la normalizzazione che decide se la cella PARTE e' questa, e
@@ -1144,6 +1416,12 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1144
1416
  if (!spec || !info.configured) return { ok: false, reason: info.reason, info };
1145
1417
  const home = cfg.home || require('node:os').homedir();
1146
1418
  const profile = profileFor(spec.client, spec.provider, spec.credentialProfile || '');
1419
+ // D2: i descrittori dichiarati per QUESTO profilo (client.provider — la
1420
+ // stessa chiave che declaredFor usa dentro normalizeManagedSpec per
1421
+ // validare gli id). Vengono dalla definizione dell'ENGINE (extraModels),
1422
+ // MAI da spec: e' il ponte che customCatalogFor/writePiProviderExtension
1423
+ // (rami custom) usano per emettere catalogo e finestra di contesto.
1424
+ const declaredModels = declaredModelsFor(cfg.extraModels, profile.id);
1147
1425
  const cred = credential(profile, spec, cfg, home);
1148
1426
  // L'override PER-CELLA va canonicalizzato come lo spec: `normalizeManagedSpec`
1149
1427
  // applica l'alias a `spec.model`, ma `cell.model` lo scavalca DOPO e senza
@@ -1209,7 +1487,11 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1209
1487
  CLAUDE_CODE_MAX_CONTEXT_TOKENS: contextWindow,
1210
1488
  API_TIMEOUT_MS: '3000000',
1211
1489
  });
1212
- if (model === 'k3' || model === 'k3[1m]') {
1490
+ // Effort massimo di default dove il modello lo sfrutta davvero. Per
1491
+ // GLM-5.3 la misura di Z.AI dice che alzando l'effort l'accuratezza sale
1492
+ // E i token per task scendono rispetto a 5.2: con una finestra a tempo,
1493
+ // spendere di piu' per chiamata rende di piu' per finestra.
1494
+ if (model === 'k3' || model === 'k3[1m]' || model.startsWith('glm-5.3')) {
1213
1495
  env.CLAUDE_CODE_EFFORT_LEVEL = 'max';
1214
1496
  env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT = '1';
1215
1497
  }
@@ -1336,11 +1618,19 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1336
1618
  } else if (spec.provider === 'custom') {
1337
1619
  env[spec.envKey] = cred.value;
1338
1620
  args.push(...codexProviderArgs(spec.providerId, spec.displayName, spec.baseUrl, spec.envKey));
1621
+ // D2: onora `models` (engine definition, già validato) come gli altri
1622
+ // provider — deriva model_catalog_json e model_context_window, così
1623
+ // Codex-VL non ricade sul fallback 272K. Senza `models` -> null (no regressione).
1624
+ const customMeta = customCatalogFor(spec, model, declaredModels, home);
1625
+ if (customMeta) {
1626
+ args.push('-c', `model_catalog_json=${JSON.stringify(customMeta.catalogPath)}`);
1627
+ args.push('-c', `model_context_window=${customMeta.contextWindow}`);
1628
+ }
1339
1629
  }
1340
1630
  if (model) args.push('-m', model);
1341
1631
  } else if (spec.client === 'pi') {
1342
1632
  if (profile.auth !== 'none' && profile.auth !== 'login' && cred.value) env[cred.envKey] = cred.value;
1343
- if (spec.provider === 'custom') args.push('--extension', writePiProviderExtension(spec, home));
1633
+ if (spec.provider === 'custom') args.push('--extension', writePiProviderExtension(spec, home, declaredModels));
1344
1634
  else if (profile.piExtension) args.push('--extension', writePiProviderExtension({
1345
1635
  providerId: profile.piProvider, displayName: profile.label.replace(/^Pi · /, ''),
1346
1636
  baseUrl: profile.piExtension.baseUrl, apiKey: profile.piExtension.apiKey,
@@ -1394,6 +1684,26 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1394
1684
  // child env (unset, never empty), so the runtime cannot leak credentials that
1395
1685
  // the local store is meant to own.
1396
1686
  applyStoreNeutralization(env, spec, profile);
1687
+ // D3: resolve envPassthrough AFTER the provider branches. Each declared name is
1688
+ // read from the credentialSources (same order as credential() 'auto': runtime
1689
+ // -> store -> shell -> key files -> legacy) and injected into the child env.
1690
+ // Per nome, mai in blocco. A name that is declared but absent from every source
1691
+ // fails CLOSED but not obscure: the reason names it, so a misconfigured cell
1692
+ // says what is missing instead of starting silent and breaking later. (vl.auth
1693
+ // is 'none': without this, the vl branch composes no provider env at all, yet
1694
+ // the runtime needs its own config vars — whose names are not fixed in the vl
1695
+ // binary, so the operator declares the ones their config uses.)
1696
+ if (spec.envPassthrough && spec.envPassthrough.length) {
1697
+ const sources = credentialSources(cfg, home);
1698
+ for (const name of spec.envPassthrough) {
1699
+ const value = sources.runtime[name] || sources.local[name] || sources.shell[name]
1700
+ || sources.keys[name] || sources.legacy[name];
1701
+ if (!value) {
1702
+ return { ok: false, info, reason: `envPassthrough name "${name}" is not set in any credential source (environment, nexuscrew-store, ${path.basename(shellProvidersPath(cfg, home))}, key files, legacy)` };
1703
+ }
1704
+ env[name] = value;
1705
+ }
1706
+ }
1397
1707
  let command = info.binary;
1398
1708
  if (needsExplicitNode(info.binary, cfg.platform || process.platform, cfg.env || process.env)) {
1399
1709
  command = cfg.nodeExecPath || process.execPath;
@@ -1413,6 +1723,14 @@ function publicCatalog() {
1413
1723
  auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
1414
1724
  protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: !['pi', 'shell', 'vl'].includes(p.client), requiresModel: !!p.requiresModel || !!p.custom,
1415
1725
  permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
1726
+ // DEC2: solo il client claude riceve MCP gestito da NexusCrew (cellMcpArgs/
1727
+ // sharedMcpArgs nel ramo claude di resolveManagedEngine). Per ogni altro
1728
+ // client (codex, vl, kimi, pi, agy, grok) `cell.mcp` e' INERTE: la cella lo
1729
+ // accetta ma non ha effetto, perche' i server MCP li registra il client nel
1730
+ // proprio file di config nativo, non NexusCrew. La vista lo dice cosi' la
1731
+ // finestra puo' avvertire l'operatore NEL PUNTO in cui sceglie cell.mcp,
1732
+ // invece di confermare un no-op silenzioso.
1733
+ mcpManaged: p.client === 'claude',
1416
1734
  rc: !!p.rc, custom: !!p.custom, default: !!p.default, notice: p.notice || '',
1417
1735
  // 'login'/'none' non sono variabili d'ambiente: nessuna KEY section per gli
1418
1736
  // engine che delegano l'auth al login del CLI (rappresentazione onesta).
@@ -1426,13 +1744,13 @@ module.exports = {
1426
1744
  knownMcpServerNames,
1427
1745
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
1428
1746
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
1429
- OPENCODE_GO_MESSAGES_MODELS, OPENCODE_GO_RESPONSES_MODELS, OPENCODE_GO_CHAT_MODELS,
1747
+ OPENCODE_GO_MESSAGES_MODELS, OPENCODE_GO_RESPONSES_MODELS, OPENCODE_GO_CHAT_MODELS, OPENCODE_GO_LIMITS,
1430
1748
  OPENCODE_GO_ANTHROPIC_ROOT, OPENCODE_GO_API_BASE,
1431
1749
  CLIENT_LABELS, normalizeManagedSpec, profileFor,
1432
1750
  defaultDefinitions, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
1433
- discoverPiModels, EXTERNAL_DISCOVERY_TIMEOUT_MS, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
1434
- extraModelsFrom,
1435
- providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
1751
+ discoverPiModels, EXTERNAL_DISCOVERY_TIMEOUT_MS, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension, customCatalogFor,
1752
+ extraModelsFrom, declaredModelsFor,
1753
+ providerKeyPaths, parseProviderKeyFiles, credentialSources, credential, CREDENTIAL_SOURCE_VALUES,
1436
1754
  credentialEnvNeutralizeSet, applyStoreNeutralization,
1437
1755
  ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
1438
1756
  shellLoginArgs, shellConfiguredCommandArgs, ENV_KEY_RE,