@mmmbuto/nexuscrew 0.8.57 → 0.9.0

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 (49) hide show
  1. package/CHANGELOG.md +164 -2
  2. package/README.md +1 -0
  3. package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
  4. package/frontend/dist/assets/index-zjL6kZ7J.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 +40 -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 +15 -0
  14. package/lib/fleet/builtin.js +161 -19
  15. package/lib/fleet/catalogs/opencode-go.json +328 -0
  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 +444 -55
  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 +184 -0
  30. package/lib/live-host/store.js +96 -0
  31. package/lib/mcp/tools.js +51 -0
  32. package/lib/nodes/commands.js +9 -2
  33. package/lib/nodes/store.js +14 -0
  34. package/lib/nodes/tunnel.js +4 -1
  35. package/lib/proxy/federation.js +106 -9
  36. package/lib/proxy/node-proxy.js +33 -0
  37. package/lib/proxy/panel-auth.js +307 -0
  38. package/lib/proxy/panel-proxy.js +305 -0
  39. package/lib/server.js +127 -4
  40. package/package.json +1 -1
  41. package/skills/alibaba-token-media/SKILL.md +19 -0
  42. package/skills/crew/SKILL.md +15 -0
  43. package/skills/fill-forms/SKILL.md +23 -0
  44. package/skills/mail-assistant/SKILL.md +15 -0
  45. package/skills/memory/SKILL.md +15 -0
  46. package/skills/nexuscrew-agent/SKILL.md +18 -0
  47. package/skills/vl-msa/SKILL.md +15 -0
  48. package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
  49. package/frontend/dist/assets/index-_c-1_3iR.js +0 -93
@@ -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',
@@ -147,12 +170,85 @@ const OPENCODE_GO_CHAT_MODELS = Object.freeze([
147
170
  const OPENCODE_GO_ANTHROPIC_ROOT = 'https://opencode.ai/zen/go';
148
171
  const OPENCODE_GO_API_BASE = 'https://opencode.ai/zen/go/v1';
149
172
 
173
+ // Limiti dichiarati dal catalogo models.dev per il provider `opencode-go`,
174
+ // trascritti il 2026-08-11. Sono l'unica fonte autorevole che abbiamo per il
175
+ // contesto: il preflight misura quali coppie wire/modello rispondono, non
176
+ // quanto contesto reggono. Senza questi numeri il client sceglie un default
177
+ // suo — per Codex il catalogo e' proprio cio' che glielo dice, e per Claude
178
+ // l'assenza significa compattare a una soglia che non c'entra col modello.
179
+ //
180
+ // Restano numeri DICHIARATI, non misurati da noi: se un modello si comporta
181
+ // come se ne avesse meno, il sospetto va qui prima che sul client.
182
+ const OPENCODE_GO_LIMITS = Object.freeze({
183
+ 'deepseek-v4-flash': { context: 1000000, output: 384000 },
184
+ 'deepseek-v4-pro': { context: 1000000, output: 384000 },
185
+ 'glm-5.2': { context: 1000000, output: 131072 },
186
+ 'glm-5.1': { context: 202752, output: 32768 },
187
+ 'glm-5': { context: 202752, output: 32768 },
188
+ 'kimi-k3': { context: 1048576, output: 131072 },
189
+ 'kimi-k2.7-code': { context: 262144, output: 262144 },
190
+ 'kimi-k2.6': { context: 262144, output: 65536 },
191
+ 'kimi-k2.5': { context: 262144, output: 65536 },
192
+ 'minimax-m3': { context: 1000000, output: 131072 },
193
+ 'minimax-m2.7': { context: 204800, output: 131072 },
194
+ 'minimax-m2.5': { context: 204800, output: 65536 },
195
+ 'qwen3.8-max': { context: 1000000, output: 131072 },
196
+ 'qwen3.7-max': { context: 1000000, output: 65536 },
197
+ 'qwen3.7-plus': { context: 1000000, output: 65536 },
198
+ 'qwen3.6-plus': { context: 1000000, output: 65536 },
199
+ 'qwen3.5-plus': { context: 262144, output: 65536 },
200
+ 'mimo-v2.5': { context: 1000000, output: 128000 },
201
+ 'mimo-v2.5-pro': { context: 1048576, output: 128000 },
202
+ hy3: { context: 256000, output: 64000 },
203
+ 'gpt-5.6-luna': { context: 1050000, output: 128000 },
204
+ 'grok-4.5': { context: 500000, output: 500000 },
205
+ });
206
+
207
+ function opencodeGoContextFor(model) {
208
+ return OPENCODE_GO_LIMITS[String(model || '')]?.context;
209
+ }
210
+
211
+ // Descrittori per l'estensione Pi generata. `compat` viene applicato SOLO ai
212
+ // due id per cui il repo lo dichiara gia' altrove sulla stessa wire
213
+ // (`pi.alibaba-token-plan`): sono gli stessi modelli su un gateway diverso,
214
+ // quindi e' riuso di una dichiarazione esistente. Sugli altri non c'e'
215
+ // precedente e non si estrapola.
216
+ const OPENCODE_GO_PI_COMPAT = Object.freeze(['glm-5.2', 'deepseek-v4-pro']);
217
+ const OPENCODE_GO_PI_MODELS = Object.freeze(OPENCODE_GO_CHAT_MODELS.map((id) => Object.freeze({
218
+ id,
219
+ name: id,
220
+ api: 'openai-completions',
221
+ reasoning: false,
222
+ input: ['text'],
223
+ contextWindow: OPENCODE_GO_LIMITS[id].context,
224
+ maxTokens: OPENCODE_GO_LIMITS[id].output,
225
+ cost: ZERO_COST,
226
+ ...(OPENCODE_GO_PI_COMPAT.includes(id)
227
+ ? { compat: { thinkingFormat: 'openai', requiresReasoningContentOnAssistantMessages: true } }
228
+ : {}),
229
+ })));
230
+
150
231
  const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
151
- 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;
152
236
  // Explicit credential source policy. Default 'auto' preserves the legacy
153
237
  // resolution order (runtime -> store -> shell -> key files -> legacy) so a
154
238
  // pre-WP1 fleet.json migrates no-op: no existing cell changes resolution.
155
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
+ ]);
156
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' });
157
253
  const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
158
254
 
@@ -179,7 +275,12 @@ const CATALOG = Object.freeze([
179
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 },
180
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' },
181
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' },
182
- { 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 },
183
284
  // OpenCode Go su Claude parla Anthropic Messages, e la wire accetta SOLO
184
285
  // `x-api-key`: con `Authorization: Bearer` risponde 401 AuthError. Per questo
185
286
  // l'endpoint e' la root senza `/v1` (il client aggiunge `/v1/messages`) e il
@@ -250,7 +351,7 @@ const CATALOG = Object.freeze([
250
351
  // solo come estensione generata il cui apiKey e' `$OPENCODE_API_KEY`, che
251
352
  // senza NexusCrew non e' valorizzato. Delegando, la cella risulterebbe
252
353
  // configurata senza chiave e fallirebbe al primo uso.
253
- { id: 'pi.opencode-go', client: 'pi', provider: 'opencode-go', label: 'OpenCode Go', auth: 'OPENCODE_API_KEY', endpoint: OPENCODE_GO_API_BASE, protocol: 'openai-completions', model: 'deepseek-v4-flash', models: OPENCODE_GO_CHAT_MODELS, strictModels: true, piProvider: 'opencode-go', piExtension: { baseUrl: OPENCODE_GO_API_BASE }, delegatePiAuth: false, core: true },
354
+ { id: 'pi.opencode-go', client: 'pi', provider: 'opencode-go', label: 'OpenCode Go', auth: 'OPENCODE_API_KEY', endpoint: OPENCODE_GO_API_BASE, protocol: 'openai-completions', model: 'deepseek-v4-flash', models: OPENCODE_GO_CHAT_MODELS, strictModels: true, piProvider: 'opencode-go', piExtension: { baseUrl: OPENCODE_GO_API_BASE, models: OPENCODE_GO_PI_MODELS }, delegatePiAuth: false, core: true },
254
355
  { id: 'pi.anthropic', client: 'pi', provider: 'anthropic', label: 'Anthropic', auth: 'ANTHROPIC_API_KEY', protocol: 'pi_native', piProvider: 'anthropic', core: true },
255
356
  { id: 'pi.openai', client: 'pi', provider: 'openai', label: 'OpenAI API', auth: 'OPENAI_API_KEY', protocol: 'pi_native', piProvider: 'openai', core: true },
256
357
  { id: 'pi.openai-codex', client: 'pi', provider: 'openai-codex', label: 'OpenAI Codex OAuth', auth: 'login', protocol: 'pi_native', piProvider: 'openai-codex', core: true },
@@ -259,7 +360,7 @@ const CATALOG = Object.freeze([
259
360
  { id: 'pi.github-copilot', client: 'pi', provider: 'github-copilot', label: 'GitHub Copilot', auth: 'login', protocol: 'pi_native', piProvider: 'github-copilot', core: true },
260
361
  { id: 'pi.deepseek', client: 'pi', provider: 'deepseek', label: 'DeepSeek', auth: 'DEEPSEEK_API_KEY', protocol: 'pi_native', piProvider: 'deepseek', core: true },
261
362
  // Provider Pi NON core: restano fuori dal catalogo UI (publicCatalog filtra
262
- // 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
263
364
  // configurazione esistente via profileFor. Etichette senza prefisso "Pi · ".
264
365
  { id: 'pi.fireworks', client: 'pi', provider: 'fireworks', label: 'Fireworks AI', auth: 'FIREWORKS_API_KEY', protocol: 'pi_native', piProvider: 'fireworks' },
265
366
  { id: 'pi.huggingface', client: 'pi', provider: 'huggingface', label: 'Hugging Face', auth: 'HF_TOKEN', protocol: 'pi_native', piProvider: 'huggingface' },
@@ -298,8 +399,8 @@ const CATALOG = Object.freeze([
298
399
  // --- Sezione legacy --------------------------------------------------------
299
400
  // Compatibilita' sola lettura/launch per configurazioni 0.8.0: mai nel catalogo
300
401
  // UI (publicCatalog filtra `legacy`). Risolti solo da profileFor/normalizeManagedSpec.
301
- { 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 },
302
- { 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 },
303
404
  ]);
304
405
 
305
406
  function profileFor(client, provider, credentialProfile) {
@@ -370,6 +471,27 @@ function normalizeManagedSpec(value, { extraModels = null } = {}) {
370
471
  if (!model || !(profile.protocols || [profile.protocol]).includes(protocol)) return null;
371
472
  Object.assign(out, { displayName, baseUrl, envKey, protocol, providerId });
372
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
+ }
373
495
  return out;
374
496
  }
375
497
 
@@ -477,7 +599,17 @@ function safeAllowedRoots(roots = []) {
477
599
  return out;
478
600
  }
479
601
 
480
- 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;
481
613
  try {
482
614
  const lst = fs.lstatSync(file);
483
615
  let target = file;
@@ -491,16 +623,23 @@ function parseEnvFile(file, opts = {}) {
491
623
  if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) || st.size > 256 * 1024) return {};
492
624
  if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
493
625
  return parseAssignments(fs.readFileSync(target, 'utf8'));
494
- } catch (_) { return {}; }
626
+ } catch (e) {
627
+ if (blocked && e.code !== 'ENOENT') blocked.push({ path: file, code: e.code || e.constructor.name });
628
+ return {};
629
+ }
495
630
  }
496
631
 
497
- function parseProviderShellFile(file) {
632
+ function parseProviderShellFile(file, out) {
633
+ const blocked = Array.isArray(out && out.blocked) ? out.blocked : null;
498
634
  try {
499
635
  const st = fs.lstatSync(file);
500
636
  if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o022) || st.size > 256 * 1024) return {};
501
637
  if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
502
638
  return parseAssignments(fs.readFileSync(file, 'utf8'));
503
- } catch (_) { return {}; }
639
+ } catch (e) {
640
+ if (blocked && e.code !== 'ENOENT') blocked.push({ path: file, code: e.code || e.constructor.name });
641
+ return {};
642
+ }
504
643
  }
505
644
 
506
645
  function binaryCandidates(client, home) {
@@ -517,14 +656,28 @@ function binaryCandidates(client, home) {
517
656
  ].filter(Boolean))];
518
657
  }
519
658
 
520
- 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;
521
669
  for (const candidate of binaryCandidates(client, home)) {
522
670
  try {
523
671
  const real = fs.realpathSync(candidate); const st = fs.lstatSync(real);
524
672
  if (!st.isFile() || !(st.mode & 0o100) || (st.mode & 0o002)) continue;
525
673
  if (typeof process.getuid === 'function' && st.uid !== process.getuid() && st.uid !== 0) continue;
526
674
  return real;
527
- } 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
+ }
528
681
  }
529
682
  return null;
530
683
  }
@@ -532,7 +685,7 @@ function findBinary(client, home) {
532
685
  // Resolve a device-local interactive shell without persisting a path in
533
686
  // fleet.json. Candidates are ordered and fail closed. Symlinks are resolved
534
687
  // first, then the existing command trust policy is applied to the real file.
535
- function resolveInteractiveShell(cfg = {}) {
688
+ function resolveInteractiveShell(cfg = {}, out) {
536
689
  const env = cfg.env || process.env;
537
690
  const platform = cfg.platform || process.platform;
538
691
  const termux = termuxRuntimePaths(env, { platform, home: cfg.home });
@@ -545,11 +698,19 @@ function resolveInteractiveShell(cfg = {}) {
545
698
  candidates.push('/bin/bash', '/bin/sh');
546
699
  const validate = cfg.validateCommandTrust
547
700
  || ((command) => require('./definitions.js').validateCommandTrust(command));
701
+ const blocked = Array.isArray(out && out.blocked) ? out.blocked : null;
548
702
  for (const candidate of [...new Set(candidates)]) {
549
703
  try {
550
704
  const real = fs.realpathSync(candidate);
551
705
  if (validate(real).ok) return real;
552
- } 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
+ }
553
714
  }
554
715
  return null;
555
716
  }
@@ -614,34 +775,40 @@ function providerKeyPaths(cfg, home) {
614
775
  return [...new Set(paths.filter((file) => typeof file === 'string' && file))];
615
776
  }
616
777
 
617
- function parseProviderKeyFiles(cfg, home) {
778
+ function parseProviderKeyFiles(cfg, home, out) {
618
779
  const values = {};
619
780
  // Match providers.zsh ordering: a later secure file may intentionally
620
781
  // override the canonical ai.env value. Files remain data-only and must be
621
782
  // private regular files owned by the NexusCrew user.
622
783
  const files = providerKeyPaths(cfg, home);
623
784
  const roots = [...new Set(files.map((file) => path.dirname(path.resolve(file))))];
624
- 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));
625
786
  return values;
626
787
  }
627
788
 
628
- 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 } = {}) {
629
796
  let local = {};
630
797
  try { local = readCredentialStore(cfg, home); } catch (_) { /* unsafe/corrupt store is ignored, never trusted */ }
631
798
  return {
632
799
  runtime: cfg.env || process.env,
633
800
  local,
634
- shell: parseProviderShellFile(shellProvidersPath(cfg, home)),
635
- keys: parseProviderKeyFiles(cfg, home),
636
- 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),
637
804
  };
638
805
  }
639
806
 
640
- function credential(profile, spec, cfg, home) {
807
+ function credential(profile, spec, cfg, home, out) {
641
808
  if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '', source: profile.auth };
642
809
  const envKey = profile.auth === 'dynamic' ? spec.envKey : profile.auth;
643
810
  const policy = spec && CREDENTIAL_SOURCES.includes(spec.credentialSourcePolicy) ? spec.credentialSourcePolicy : 'auto';
644
- const sources = credentialSources(cfg, home);
811
+ const sources = credentialSources(cfg, home, out, { trackLegacy: !!profile.legacySecrets });
645
812
  // The fixed shell file is already the user's environment source. Values are
646
813
  // consumed only in memory and passed to the selected child; never persisted
647
814
  // in fleet.json, service files, API responses or logs.
@@ -661,7 +828,15 @@ function credential(profile, spec, cfg, home) {
661
828
  if (profile.legacySecrets && sources.legacy[envKey]) {
662
829
  return { envKey, value: sources.legacy[envKey], source: 'compatibility' };
663
830
  }
664
- 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' };
665
840
  }
666
841
 
667
842
  // The profile's "owned" env set. When the credential source is the local store,
@@ -781,22 +956,54 @@ async function discoverPiModels(opts = {}) {
781
956
  function describeManaged(spec, cfg = {}) {
782
957
  const extraModels = cfg.extraModels || null;
783
958
  const normalized = normalizeManagedSpec(spec, { extraModels });
784
- 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
+ }
785
975
  const home = cfg.home || require('node:os').homedir();
786
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 = [];
787
985
  const binary = normalized.client === 'shell'
788
- ? resolveInteractiveShell({ ...cfg, home })
789
- : findBinary(normalized.client, home);
790
- 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 });
791
989
  // Pi can resolve credentials from its own documented /login auth store. Do
792
990
  // not inspect or copy that store; delegate native-provider auth to Pi.
793
991
  const delegatedPiAuth = profile.client === 'pi' && profile.provider !== 'custom'
794
992
  && profile.delegatePiAuth !== false;
795
993
  const authConfigured = delegatedPiAuth || profile.auth === 'login' || profile.auth === 'none' || !!cred.value;
796
994
  let configured = !!binary && authConfigured;
797
- let reason = !binary ? `client ${profile.client} not found` : (!authConfigured
798
- ? `credential ${cred.envKey} missing — set it on this device`
799
- : '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
+ }
800
1007
  // Agy e grok sono client primari supportati solo su Linux/macOS non-Termux.
801
1008
  // Rilevazione Termux via termuxRuntimePaths (non solo process.platform): un
802
1009
  // Node che riporta 'linux' sotto proot/Termux viene comunque intercettato.
@@ -814,7 +1021,11 @@ function describeManaged(spec, cfg = {}) {
814
1021
  permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
815
1022
  endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
816
1023
  credentialSourcePolicy: normalized.credentialSourcePolicy || 'auto',
817
- 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,
818
1029
  configured, models: [...(profile.models || [])], defaultModel: profile.model || '',
819
1030
  binary: binary || '', displayName: normalized.displayName || profile.label,
820
1031
  reason,
@@ -829,11 +1040,14 @@ function describeCatalogCredential(client, provider, credentialProfile = '', cfg
829
1040
  if (!profile || profile.auth === 'dynamic' || profile.auth === 'login' || profile.auth === 'none'
830
1041
  || !ENV_KEY_RE.test(profile.auth || '')) return null;
831
1042
  const home = cfg.home || require('node:os').homedir();
832
- const cred = credential(profile, {}, cfg, home);
1043
+ const cred = credential(profile, {}, cfg, home, { blocked: [] });
833
1044
  return {
834
1045
  envKey: cred.envKey,
835
1046
  authConfigured: !!cred.value,
836
- 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,
837
1051
  };
838
1052
  }
839
1053
 
@@ -1033,7 +1247,41 @@ function ensurePrivateClaudeConfig(home, profileId, label, penguinMode) {
1033
1247
  return configDir;
1034
1248
  }
1035
1249
 
1036
- 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) {
1037
1285
  const dir = path.join(home, '.nexuscrew', 'pi-providers');
1038
1286
  try {
1039
1287
  const st = fs.lstatSync(dir);
@@ -1055,7 +1303,8 @@ function writePiProviderExtension(spec, home) {
1055
1303
  apiKey: spec.apiKey || `$${spec.envKey}`,
1056
1304
  authHeader: true,
1057
1305
  api: spec.protocol,
1058
- 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)) || [{
1059
1308
  id: spec.model, name: spec.model, reasoning: false, input: ['text'],
1060
1309
  contextWindow: 128000, maxTokens: 16384,
1061
1310
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -1074,6 +1323,87 @@ function writePiProviderExtension(spec, home) {
1074
1323
  return target;
1075
1324
  }
1076
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
+
1077
1407
  function resolveManagedEngine(engine, cell, cfg = {}) {
1078
1408
  // `extraModels` DEVE arrivare fin qui. Threadarlo al chiamante e alla vista
1079
1409
  // non basta: la normalizzazione che decide se la cella PARTE e' questa, e
@@ -1086,6 +1416,12 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1086
1416
  if (!spec || !info.configured) return { ok: false, reason: info.reason, info };
1087
1417
  const home = cfg.home || require('node:os').homedir();
1088
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);
1089
1425
  const cred = credential(profile, spec, cfg, home);
1090
1426
  // L'override PER-CELLA va canonicalizzato come lo spec: `normalizeManagedSpec`
1091
1427
  // applica l'alias a `spec.model`, ma `cell.model` lo scavalca DOPO e senza
@@ -1151,7 +1487,11 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1151
1487
  CLAUDE_CODE_MAX_CONTEXT_TOKENS: contextWindow,
1152
1488
  API_TIMEOUT_MS: '3000000',
1153
1489
  });
1154
- 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')) {
1155
1495
  env.CLAUDE_CODE_EFFORT_LEVEL = 'max';
1156
1496
  env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT = '1';
1157
1497
  }
@@ -1161,9 +1501,6 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1161
1501
  // ANTHROPIC_API_KEY e' cio' che Claude Code manda come `x-api-key`, quindi
1162
1502
  // qui NON si usa la forma a token degli altri gateway.
1163
1503
  //
1164
- // Nessun CLAUDE_CODE_MAX_CONTEXT_TOKENS: il context window reale di questi
1165
- // modelli su OpenCode Go non e' misurato, e un numero inventato qui
1166
- // diventerebbe la configurazione con cui le celle compattano.
1167
1504
  Object.assign(env, {
1168
1505
  ANTHROPIC_BASE_URL: profile.endpoint,
1169
1506
  ANTHROPIC_API_KEY: cred.value,
@@ -1175,6 +1512,14 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1175
1512
  CLAUDE_CODE_SUBAGENT_MODEL: model,
1176
1513
  API_TIMEOUT_MS: '3000000',
1177
1514
  });
1515
+ // Il contesto si dichiara solo se il modello e' nella tabella: un id
1516
+ // fuori tabella non deve ereditare il numero di un altro modello, e
1517
+ // l'assenza fa ricadere il client sul proprio default.
1518
+ const opencodeContext = opencodeGoContextFor(model);
1519
+ if (opencodeContext) {
1520
+ env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(opencodeContext);
1521
+ env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(opencodeContext);
1522
+ }
1178
1523
  } else if (spec.provider === 'alibaba-token-plan') {
1179
1524
  privateProfile = true;
1180
1525
  const qwen38 = model === 'qwen3.8-max';
@@ -1256,20 +1601,36 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1256
1601
  }
1257
1602
  } else if (spec.provider === 'opencode-go') {
1258
1603
  // Wire Responses nativa: il gateway espone `/v1/responses` e Codex-VL vi
1259
- // aggiunge il path, quindi qui l'endpoint include gia' `/v1`. Nessun
1260
- // model_context_window: non e' misurato, e il default del client e' piu'
1261
- // onesto di una costante inventata.
1604
+ // aggiunge il path, quindi qui l'endpoint include gia' `/v1`.
1262
1605
  env.OPENCODE_API_KEY = cred.value;
1263
1606
  args.push(...codexProviderArgs('opencode_go', 'OpenCode Go', profile.endpoint, 'OPENCODE_API_KEY'));
1264
1607
  args.push('-c', 'model_providers.opencode_go.stream_idle_timeout_ms=600000');
1608
+ // Codex non conosce questi modelli: senza catalogo non ha i metadati di
1609
+ // contesto e ricade su un default suo. Il file copre le sole coppie
1610
+ // misurate sulla wire Responses; il context window accompagna il modello
1611
+ // selezionato ed e' omesso se l'id non e' in tabella.
1612
+ const opencodeContext = opencodeGoContextFor(model);
1613
+ if (opencodeContext) {
1614
+ const localCatalog = path.join(__dirname, 'catalogs', 'opencode-go.json');
1615
+ args.push('-c', `model_catalog_json=${JSON.stringify(localCatalog)}`);
1616
+ args.push('-c', `model_context_window=${opencodeContext}`);
1617
+ }
1265
1618
  } else if (spec.provider === 'custom') {
1266
1619
  env[spec.envKey] = cred.value;
1267
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
+ }
1268
1629
  }
1269
1630
  if (model) args.push('-m', model);
1270
1631
  } else if (spec.client === 'pi') {
1271
1632
  if (profile.auth !== 'none' && profile.auth !== 'login' && cred.value) env[cred.envKey] = cred.value;
1272
- if (spec.provider === 'custom') args.push('--extension', writePiProviderExtension(spec, home));
1633
+ if (spec.provider === 'custom') args.push('--extension', writePiProviderExtension(spec, home, declaredModels));
1273
1634
  else if (profile.piExtension) args.push('--extension', writePiProviderExtension({
1274
1635
  providerId: profile.piProvider, displayName: profile.label.replace(/^Pi · /, ''),
1275
1636
  baseUrl: profile.piExtension.baseUrl, apiKey: profile.piExtension.apiKey,
@@ -1323,6 +1684,26 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1323
1684
  // child env (unset, never empty), so the runtime cannot leak credentials that
1324
1685
  // the local store is meant to own.
1325
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
+ }
1326
1707
  let command = info.binary;
1327
1708
  if (needsExplicitNode(info.binary, cfg.platform || process.platform, cfg.env || process.env)) {
1328
1709
  command = cfg.nodeExecPath || process.execPath;
@@ -1342,6 +1723,14 @@ function publicCatalog() {
1342
1723
  auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
1343
1724
  protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: !['pi', 'shell', 'vl'].includes(p.client), requiresModel: !!p.requiresModel || !!p.custom,
1344
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',
1345
1734
  rc: !!p.rc, custom: !!p.custom, default: !!p.default, notice: p.notice || '',
1346
1735
  // 'login'/'none' non sono variabili d'ambiente: nessuna KEY section per gli
1347
1736
  // engine che delegano l'auth al login del CLI (rappresentazione onesta).
@@ -1355,13 +1744,13 @@ module.exports = {
1355
1744
  knownMcpServerNames,
1356
1745
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
1357
1746
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
1358
- 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,
1359
1748
  OPENCODE_GO_ANTHROPIC_ROOT, OPENCODE_GO_API_BASE,
1360
1749
  CLIENT_LABELS, normalizeManagedSpec, profileFor,
1361
1750
  defaultDefinitions, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
1362
- discoverPiModels, EXTERNAL_DISCOVERY_TIMEOUT_MS, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
1363
- extraModelsFrom,
1364
- 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,
1365
1754
  credentialEnvNeutralizeSet, applyStoreNeutralization,
1366
1755
  ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
1367
1756
  shellLoginArgs, shellConfiguredCommandArgs, ENV_KEY_RE,