@mmmbuto/nexuscrew 0.8.52-rc.8 → 0.8.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,18 +28,62 @@ function ollamaContextFor(model) {
28
28
  const key = String(model || '');
29
29
  return OLLAMA_CONTEXT[key] ?? OLLAMA_CONTEXT[key.split(':')[0]];
30
30
  }
31
+ // Rinomini di modello, dichiarati in UN posto solo.
32
+ //
33
+ // Un fornitore che promuove un preview a stabile cambia l'id, e con
34
+ // `strictModels` un id non piu' in catalogo NON e' un avviso: e' una cella che
35
+ // non parte. Le due celle configurate con `qwen3.8-max-preview` si sarebbero
36
+ // fermate al primo avvio dopo l'aggiornamento, senza che nulla dicesse
37
+ // perche'.
38
+ //
39
+ // L'alias risolve il nome vecchio e restituisce quello NUOVO, quindi la
40
+ // configurazione converge da sola alla prima riscrittura invece di restare
41
+ // indietro per sempre.
42
+ const MODEL_ALIASES = Object.freeze({
43
+ 'qwen3.8-max-preview': 'qwen3.8-max',
44
+ });
45
+
46
+ // Modelli dichiarati in configurazione, indicizzati per PROFILO gestito. La
47
+ // dichiarazione e' per profilo e non per fornitore: gli elenchi di Alibaba per
48
+ // Claude e per Codex differiscono davvero, e generalizzare creerebbe
49
+ // combinazioni che non esistono.
50
+ function declaredFor(extraModels, profileId, model) {
51
+ if (!extraModels || typeof extraModels.get !== 'function') return false;
52
+ const set = extraModels.get(profileId);
53
+ return !!(set && typeof set.has === 'function' && set.has(model));
54
+ }
55
+
56
+ // Dalle definizioni alla mappa che il gate si aspetta. Sta qui e non nei
57
+ // chiamanti perche' ognuno di loro la ricostruirebbe a modo suo, e basta che
58
+ // uno la dimentichi perche' un modello dichiarato smetta di essere valido
59
+ // proprio nel punto che conta — l'avvio.
60
+ function extraModelsFrom(defs) {
61
+ const map = new Map();
62
+ for (const m of (defs && Array.isArray(defs.models) ? defs.models : [])) {
63
+ 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);
66
+ }
67
+ return map;
68
+ }
69
+
70
+ function canonicalModel(model) {
71
+ const key = String(model || '');
72
+ return Object.hasOwn(MODEL_ALIASES, key) ? MODEL_ALIASES[key] : model;
73
+ }
74
+
31
75
  const ALIBABA_TOKEN_PLAN_MODELS = Object.freeze([
32
- 'qwen3.8-max-preview', 'qwen3.7-plus', 'qwen3.7-max',
76
+ 'qwen3.8-max', 'qwen3.7-plus', 'qwen3.7-max',
33
77
  'qwen3.6-flash', 'glm-5.2', 'deepseek-v4-pro',
34
78
  ]);
35
79
  const ALIBABA_CODEX_MODELS = Object.freeze([
36
- 'qwen3.8-max-preview', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-flash',
80
+ 'qwen3.8-max', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-flash',
37
81
  ]);
38
82
  const ALIBABA_TOKEN_PLAN_CONTEXT = 983616;
39
83
  const ZERO_COST = Object.freeze({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
40
84
  const ALIBABA_PI_MODELS = Object.freeze([
41
85
  Object.freeze({
42
- id: 'qwen3.8-max-preview', name: 'qwen3.8-max-preview', api: 'openai-responses',
86
+ id: 'qwen3.8-max', name: 'qwen3.8-max', api: 'openai-responses',
43
87
  reasoning: true, thinkingLevelMap: { low: 'low', high: 'high', xhigh: 'xhigh' },
44
88
  input: ['text', 'image'], contextWindow: ALIBABA_TOKEN_PLAN_CONTEXT, maxTokens: 131072,
45
89
  cost: ZERO_COST,
@@ -89,7 +133,7 @@ function validBaseUrl(value) {
89
133
  const CATALOG = Object.freeze([
90
134
  // Claude Code
91
135
  { 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 },
92
- { 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-preview', models: ALIBABA_TOKEN_PLAN_MODELS, strictModels: true, core: true, notice: 'alibaba-token-plan' },
136
+ { 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' },
93
137
  { id: 'claude.openrouter', client: 'claude', provider: 'openrouter', label: 'OpenRouter', auth: 'OPENROUTER_API_KEY', endpoint: 'https://openrouter.ai/api', protocol: 'anthropic_messages', requiresModel: true, core: true, notice: 'claude-openrouter' },
94
138
  { 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' },
95
139
  { id: 'claude.bedrock', client: 'claude', provider: 'bedrock', label: 'Amazon Bedrock', auth: 'login', endpoint: 'AWS Bedrock', protocol: 'anthropic_messages', core: true, providerEnv: { CLAUDE_CODE_USE_BEDROCK: '1' } },
@@ -106,7 +150,7 @@ const CATALOG = Object.freeze([
106
150
  // Codex family. OpenAI Responses is the only remote custom wire API.
107
151
  { id: 'codex.native', client: 'codex', provider: 'native', label: 'OpenAI / ChatGPT account', auth: 'login', endpoint: 'OpenAI account', protocol: 'openai_responses', default: true, core: true },
108
152
  { id: 'codex-vl.native', client: 'codex-vl', provider: 'native', label: 'OpenAI / ChatGPT account', auth: 'login', endpoint: 'OpenAI account', protocol: 'openai_responses', default: true, core: true },
109
- { id: 'codex-vl.alibaba-token-plan', client: 'codex-vl', provider: 'alibaba-token-plan', label: 'Alibaba Token Plan Personal', auth: 'ALIBABA_CODE_API_KEY', endpoint: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', protocol: 'openai_responses', model: 'qwen3.8-max-preview', models: ALIBABA_CODEX_MODELS, strictModels: true, core: true, notice: 'alibaba-token-plan' },
153
+ { id: 'codex-vl.alibaba-token-plan', client: 'codex-vl', provider: 'alibaba-token-plan', label: 'Alibaba Token Plan Personal', auth: 'ALIBABA_CODE_API_KEY', endpoint: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', protocol: 'openai_responses', model: 'qwen3.8-max', models: ALIBABA_CODEX_MODELS, strictModels: true, core: true, notice: 'alibaba-token-plan' },
110
154
  { id: 'codex.openai-api', client: 'codex', provider: 'openai-api', label: 'OpenAI API', auth: 'OPENAI_API_KEY', endpoint: 'https://api.openai.com/v1', protocol: 'openai_responses', core: true },
111
155
  { id: 'codex-vl.openai-api', client: 'codex-vl', provider: 'openai-api', label: 'OpenAI API', auth: 'OPENAI_API_KEY', endpoint: 'https://api.openai.com/v1', protocol: 'openai_responses', core: true },
112
156
  { id: 'codex-vl.openrouter', client: 'codex-vl', provider: 'openrouter', label: 'OpenRouter', auth: 'OPENROUTER_API_KEY', endpoint: 'https://openrouter.ai/api/v1', protocol: 'openai_responses', requiresModel: true, core: true, notice: 'codex-openrouter' },
@@ -121,7 +165,7 @@ const CATALOG = Object.freeze([
121
165
 
122
166
  // Pi uses its real provider IDs directly. OAuth providers do not need env keys.
123
167
  { id: 'pi.native', client: 'pi', provider: 'native', label: 'Pi configured default', auth: 'login', protocol: 'pi_native', default: true, core: true },
124
- { id: 'pi.alibaba-token-plan', client: 'pi', provider: 'alibaba-token-plan', label: 'Alibaba Token Plan Personal', auth: 'ALIBABA_CODE_API_KEY', endpoint: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', protocol: 'openai-completions', model: 'qwen3.8-max-preview', models: ALIBABA_TOKEN_PLAN_MODELS, strictModels: true, piProvider: 'alibaba-token-plan', piExtension: { baseUrl: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', models: ALIBABA_PI_MODELS }, delegatePiAuth: false, core: true, notice: 'alibaba-token-plan' },
168
+ { id: 'pi.alibaba-token-plan', client: 'pi', provider: 'alibaba-token-plan', label: 'Alibaba Token Plan Personal', auth: 'ALIBABA_CODE_API_KEY', endpoint: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', protocol: 'openai-completions', model: 'qwen3.8-max', models: ALIBABA_TOKEN_PLAN_MODELS, strictModels: true, piProvider: 'alibaba-token-plan', piExtension: { baseUrl: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', models: ALIBABA_PI_MODELS }, delegatePiAuth: false, core: true, notice: 'alibaba-token-plan' },
125
169
  // Device-local shell. Kept after the established default clients so adding
126
170
  // it does not change the preselected engine for a newly-created cell.
127
171
  { id: 'shell.local', client: 'shell', provider: 'local', label: 'Shell', auth: 'none', protocol: 'shell', default: true, core: true },
@@ -168,7 +212,7 @@ function profileFor(client, provider, credentialProfile) {
168
212
  && (p.credentialProfile || '') === (credentialProfile || '')) || null;
169
213
  }
170
214
 
171
- function normalizeManagedSpec(value) {
215
+ function normalizeManagedSpec(value, { extraModels = null } = {}) {
172
216
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
173
217
  if (Object.keys(value).some((k) => !MANAGED_KEYS.has(k))) return null;
174
218
  if (!CLIENT_LABELS[value.client]) return null;
@@ -182,11 +226,23 @@ function normalizeManagedSpec(value) {
182
226
  if (typeof provider !== 'string') return null;
183
227
  const profile = profileFor(value.client, provider, credentialProfile);
184
228
  if (!profile) return null;
185
- const model = value.model === undefined ? (profile.model || '') : value.model;
186
- if (typeof model !== 'string' || model.length > 128 || /[\x00-\x1f\x7f]/.test(model)) return null;
229
+ const requested = value.model === undefined ? (profile.model || '') : value.model;
230
+ if (typeof requested !== 'string' || requested.length > 128 || /[\x00-\x1f\x7f]/.test(requested)) return null;
231
+ // L'alias si applica PRIMA del gate: un nome legacy deve passare, e deve
232
+ // passare diventando il nome nuovo.
233
+ const model = canonicalModel(requested);
187
234
  if (value.client === 'shell' && model) return null;
188
235
  if (profile.requiresModel && !model) return null;
189
- if (profile.strictModels && !(profile.models || []).includes(model)) return null;
236
+ // Il catalogo del codice e' l'elenco dei modelli NOTI, non il muro di cio' che
237
+ // esiste: un fornitore pubblica quando vuole, e senza questa via usare un
238
+ // modello nuovo richiederebbe una release di NexusCrew.
239
+ //
240
+ // Un id dichiarato nella configurazione passa perche' e' stato DICHIARATO per
241
+ // quel profilo, non perche' il controllo e' stato tolto: un id mai visto
242
+ // resta rifiutato, ed e' cio' che impedisce di far partire una cella con un
243
+ // modello inesistente e scoprirlo dal fallimento.
244
+ if (profile.strictModels && !(profile.models || []).includes(model)
245
+ && !declaredFor(extraModels, profile.id, model)) return null;
190
246
  const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
191
247
  if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
192
248
  if ((value.client === 'pi' || value.client === 'shell') && permissionPolicy !== 'standard') return null;
@@ -596,7 +652,8 @@ async function discoverPiModels(opts = {}) {
596
652
  }
597
653
 
598
654
  function describeManaged(spec, cfg = {}) {
599
- const normalized = normalizeManagedSpec(spec);
655
+ const extraModels = cfg.extraModels || null;
656
+ const normalized = normalizeManagedSpec(spec, { extraModels });
600
657
  if (!normalized) return { configured: false, reason: 'invalid managed profile' };
601
658
  const home = cfg.home || require('node:os').homedir();
602
659
  const profile = profileFor(normalized.client, normalized.provider, normalized.credentialProfile || '');
@@ -681,6 +738,134 @@ function ensureAlibabaClaudeConfig(home) {
681
738
  return ensurePrivateClaudeConfig(home, 'alibaba-token-plan', 'Alibaba Token Plan', false);
682
739
  }
683
740
 
741
+ // Un profilo Claude privato esiste per separare le CREDENZIALI, ma Claude tiene
742
+ // la configurazione MCP nello STESSO file: isolando l'una si e' isolata anche
743
+ // l'altra, e una cella su quei profili partiva senza nessuno strumento — niente
744
+ // memoria, niente `nc_notify`, niente `webfetch`. Non e' un difetto
745
+ // dell'isolamento: e' una conseguenza di dove Claude tiene le due cose.
746
+ //
747
+ // Si passa il file principale COME SORGENTE invece di copiarne il contenuto.
748
+ // Misurato: quelle definizioni portano `env` con una password Nextcloud e due
749
+ // chiavi API — copiarle avrebbe messo dei segreti in un secondo file, cioe' un
750
+ // altro posto da ruotare e da cui possono uscire, proprio dentro la directory
751
+ // che serve a tenerli separati.
752
+ //
753
+ // Niente `--strict-mcp-config`: la configurazione di progetto deve continuare a
754
+ // valere, ed e' il punto in cui si innestera' un elenco per-cella.
755
+ //
756
+ // UN SOLO TOKEN, con l'uguale. `--mcp-config` e' VARIADICO e nella forma
757
+ // spaziata consuma ogni argomento successivo che non inizi per `-` — compreso
758
+ // il prompt della cella, che viene accodato come posizionale in fondo
759
+ // all'argv. Misurato sul client installato:
760
+ // --mcp-config <file> ciao-prompt -> «MCP config file not found: .../ciao-prompt»
761
+ // --mcp-config=<file> ciao-prompt -> nessun errore, un solo file letto
762
+ // Non riportarlo alla forma spaziata «per leggibilita'»: rompe ogni cella che
763
+ // abbia un prompt, cioe' quelle vere.
764
+ //
765
+ // Fail-safe: un file assente, illeggibile o senza `mcpServers` non aggiunge il
766
+ // flag. Senza strumenti si lavora peggio; con un flag che punta al vuoto la
767
+ // cella non parte affatto.
768
+ function sharedMcpArgs(home) {
769
+ const file = path.join(home, '.claude.json');
770
+ try {
771
+ const st = fs.lstatSync(file);
772
+ if (st.isSymbolicLink() || !st.isFile() || st.size > 8 * 1024 * 1024) return [];
773
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
774
+ const servers = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed.mcpServers : null;
775
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)
776
+ || !Object.keys(servers).length) return [];
777
+ } catch (_) {
778
+ return [];
779
+ }
780
+ return [`--mcp-config=${file}`];
781
+ }
782
+
783
+ // Quali server MCP esistono, per NOME. Si guarda dove il client li prende.
784
+ // Non si legge nient'altro di quei file: servono solo i nomi.
785
+ //
786
+ // TRE SORGENTI, non due. Il local scope vive nello STESSO file della
787
+ // configurazione utente ma in un ramo diverso — `projects[<cwd>].mcpServers`,
788
+ // indicizzato sul percorso assoluto — ed e' reale: su questa installazione nove
789
+ // progetti ne hanno, fra cui la directory di lavoro delle celle. Dimenticarlo
790
+ // significava che un elenco parziale non negava server che la sessione carica
791
+ // davvero, e l'operatore avrebbe creduto di averli esclusi. Trovato dall'audit.
792
+ //
793
+ // RESTANO FUORI, e non sono enumerabili da qui: plugin, connettori claude.ai e
794
+ // la configurazione gestita di sistema. Per quelli l'unica esclusione certa e'
795
+ // `mcp: []`, che usa il jolly e non dipende da nessuna enumerazione. La finestra
796
+ // lo dice all'operatore invece di lasciarglielo credere.
797
+ function knownMcpServerNames(home, cwd) {
798
+ const nomi = new Set();
799
+ const raccogli = (servers) => {
800
+ if (servers && typeof servers === 'object' && !Array.isArray(servers)) {
801
+ for (const nome of Object.keys(servers)) nomi.add(nome);
802
+ }
803
+ };
804
+ const leggi = (file) => {
805
+ try {
806
+ const st = fs.lstatSync(file);
807
+ if (st.isSymbolicLink() || !st.isFile() || st.size > 8 * 1024 * 1024) return null;
808
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
809
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
810
+ } catch (_) { return null; /* un file assente o illeggibile non e' un errore qui */ }
811
+ };
812
+ const utente = leggi(path.join(home, '.claude.json'));
813
+ if (utente) {
814
+ raccogli(utente.mcpServers);
815
+ const progetti = utente.projects;
816
+ if (typeof cwd === 'string' && cwd && progetti && typeof progetti === 'object' && !Array.isArray(progetti)) {
817
+ const voce = progetti[cwd];
818
+ if (voce && typeof voce === 'object' && !Array.isArray(voce)) raccogli(voce.mcpServers);
819
+ }
820
+ }
821
+ if (typeof cwd === 'string' && cwd) {
822
+ const progetto = leggi(path.join(cwd, '.mcp.json'));
823
+ if (progetto) raccogli(progetto.mcpServers);
824
+ }
825
+ return nomi;
826
+ }
827
+
828
+ // Gli strumenti MCP che questa cella deve avere, per NOME.
829
+ //
830
+ // SI CONCEDE NEGANDO IL COMPLEMENTO, non permettendo l'eccezione. Misurato sul
831
+ // client installato, in tre configurazioni, perche' l'intera funzione poggia su
832
+ // questo — `mcp: []` PROMETTE che non ci sono strumenti:
833
+ // 1. deny e allow nello STESSO file -> vince il deny;
834
+ // 2. deny e allow in DUE sorgenti `--settings` distinte -> vince il deny;
835
+ // 3. un `allow` di progetto in un workspace non fidato viene ignorato dal
836
+ // client prima ancora di arrivare al confronto.
837
+ // Non e' provato il caso di un `allow` a livello utente in un workspace fidato:
838
+ // richiederebbe di modificare la configurazione reale dell'operatore. Dato il
839
+ // verso costante delle prime due, l'attesa e' che il deny vinca anche li'.
840
+ //
841
+ // `mcp: []` usa il jolly, e questo caso e' ESATTO: non dipende da quali server
842
+ // esistono ne' da dove arrivano. E' la cella di cui non ci si fida, ed e' giusto
843
+ // che sia il caso senza approssimazioni.
844
+ //
845
+ // Un elenco parziale nega il complemento di cio' che si riesce a enumerare
846
+ // (le tre sorgenti in `knownMcpServerNames`). RESIDUO DICHIARATO: plugin,
847
+ // connettori e configurazione gestita di sistema non sono enumerabili da qui, e
848
+ // un server che arrivasse da li' non verrebbe negato. Non lo nascondo dietro un
849
+ // nome rassicurante: chi vuole la garanzia certa usa `mcp: []`, e la finestra
850
+ // lo dice.
851
+ //
852
+ // Nessun file per cella: la lista viaggia in argv e contiene SOLO NOMI. Le
853
+ // definizioni — e le credenziali che alcune portano nel proprio `env` — restano
854
+ // nell'unico posto dove vivono.
855
+ //
856
+ // Forma con l'uguale, come `--mcp-config`: un solo token, cosi' nessun argomento
857
+ // successivo puo' essere inghiottito.
858
+ function cellMcpArgs(home, cell) {
859
+ const voluti = cell && Array.isArray(cell.mcp) ? cell.mcp : null;
860
+ if (!voluti) return [];
861
+ if (!voluti.length) return [`--settings=${JSON.stringify({ permissions: { deny: ['mcp__*'] } })}`];
862
+ const concessi = new Set(voluti);
863
+ const deny = [...knownMcpServerNames(home, cell.cwd)]
864
+ .filter((nome) => !concessi.has(nome)).sort().map((nome) => `mcp__${nome}`);
865
+ if (!deny.length) return [];
866
+ return [`--settings=${JSON.stringify({ permissions: { deny } })}`];
867
+ }
868
+
684
869
  function ensurePrivateClaudeConfig(home, profileId, label, penguinMode) {
685
870
  const nexusDir = path.join(home, '.nexuscrew');
686
871
  const profilesDir = path.join(nexusDir, 'claude-profiles');
@@ -763,13 +948,27 @@ function writePiProviderExtension(spec, home) {
763
948
  }
764
949
 
765
950
  function resolveManagedEngine(engine, cell, cfg = {}) {
766
- const spec = normalizeManagedSpec(engine.managed);
951
+ // `extraModels` DEVE arrivare fin qui. Threadarlo al chiamante e alla vista
952
+ // non basta: la normalizzazione che decide se la cella PARTE e' questa, e
953
+ // senza l'elenco dichiarato un modello legittimo diventa «invalid managed
954
+ // profile» — cioe' una cella che non si avvia per una ragione invisibile.
955
+ // E' il difetto che l'audit ha ripreso due volte: la vista diceva di si',
956
+ // il boot diceva di no, e nessun test guardava il boot.
957
+ const spec = normalizeManagedSpec(engine.managed, { extraModels: cfg.extraModels || null });
767
958
  const info = describeManaged(spec, cfg);
768
959
  if (!spec || !info.configured) return { ok: false, reason: info.reason, info };
769
960
  const home = cfg.home || require('node:os').homedir();
770
961
  const profile = profileFor(spec.client, spec.provider, spec.credentialProfile || '');
771
962
  const cred = credential(profile, spec, cfg, home);
772
- const env = {}; const args = []; const model = cell?.model || spec.model;
963
+ // L'override PER-CELLA va canonicalizzato come lo spec: `normalizeManagedSpec`
964
+ // applica l'alias a `spec.model`, ma `cell.model` lo scavalca DOPO e senza
965
+ // passare di li'. Senza questo, un nome legacy per-cella finisce in argv e in
966
+ // env, e i rami di trattamento (che confrontano il nome nuovo) non scattano:
967
+ // la cella parte, sembra a posto, e gira con i parametri sbagliati.
968
+ const env = {}; const args = []; const model = canonicalModel(cell?.model || spec.model);
969
+ // I due profili Claude con configurazione privata: perdono gli MCP del file
970
+ // principale e vanno ricollegati (vedi `sharedMcpArgs`).
971
+ let privateProfile = false;
773
972
  // Effective permission policy: override PER-CELL PER-ENGINE (remembered) vince sul
774
973
  // default dell'engine. Mai si mutationa engine.managed.permissionPolicy (globale).
775
974
  // Pi resta sempre 'standard' (lo spec normalized rifiuta gia' unsafe per pi).
@@ -810,6 +1009,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
810
1009
  CLAUDE_CODE_SUBAGENT_MODEL: model, API_TIMEOUT_MS: '3000000',
811
1010
  });
812
1011
  } else if (spec.provider === 'kimi-code') {
1012
+ privateProfile = true;
813
1013
  const contextWindow = model === 'k3[1m]' ? '1048576' : '262144';
814
1014
  Object.assign(env, {
815
1015
  CLAUDE_CONFIG_DIR: ensureKimiClaudeConfig(home),
@@ -826,12 +1026,13 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
826
1026
  env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT = '1';
827
1027
  }
828
1028
  } else if (spec.provider === 'alibaba-token-plan') {
829
- const qwen38 = model === 'qwen3.8-max-preview';
1029
+ privateProfile = true;
1030
+ const qwen38 = model === 'qwen3.8-max';
830
1031
  const aliases = qwen38 ? {
831
- ANTHROPIC_MODEL: 'qwen3.8-max-preview',
832
- ANTHROPIC_DEFAULT_FABLE_MODEL: 'qwen3.8-max-preview',
833
- ANTHROPIC_DEFAULT_OPUS_MODEL: 'qwen3.8-max-preview',
834
- ANTHROPIC_DEFAULT_SONNET_MODEL: 'qwen3.8-max-preview',
1032
+ ANTHROPIC_MODEL: 'qwen3.8-max',
1033
+ ANTHROPIC_DEFAULT_FABLE_MODEL: 'qwen3.8-max',
1034
+ ANTHROPIC_DEFAULT_OPUS_MODEL: 'qwen3.8-max',
1035
+ ANTHROPIC_DEFAULT_SONNET_MODEL: 'qwen3.8-max',
835
1036
  ANTHROPIC_DEFAULT_HAIKU_MODEL: 'qwen3.6-flash',
836
1037
  CLAUDE_CODE_SUBAGENT_MODEL: 'qwen3.7-max',
837
1038
  } : {
@@ -869,6 +1070,12 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
869
1070
  });
870
1071
  }
871
1072
  if (model) args.push('--model', model);
1073
+ // In coda di proposito: `--mcp-config` e' variadico e consuma ogni token
1074
+ // che non inizi per `-`. Messo qui non ha nulla da inghiottire.
1075
+ if (privateProfile) args.push(...sharedMcpArgs(home));
1076
+ // Vale per OGNI cella Claude, non solo per i profili isolati: il filtro
1077
+ // agisce sui nomi dei tool, non su dove i server sono definiti.
1078
+ args.push(...cellMcpArgs(home, cell));
872
1079
  } else if (spec.client === 'codex' || spec.client === 'codex-vl') {
873
1080
  if (profile.localProvider) args.push('--oss', '--local-provider', profile.localProvider);
874
1081
  else if (spec.provider === 'openai-api') env.OPENAI_API_KEY = cred.value;
@@ -892,7 +1099,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
892
1099
  env.ALIBABA_CODE_API_KEY = cred.value;
893
1100
  args.push(...codexProviderArgs('alibaba_token_plan', 'Alibaba Token Plan Personal', profile.endpoint, 'ALIBABA_CODE_API_KEY'));
894
1101
  args.push('-c', 'model_providers.alibaba_token_plan.stream_idle_timeout_ms=600000');
895
- if (model === 'qwen3.8-max-preview') {
1102
+ if (model === 'qwen3.8-max') {
896
1103
  const localCatalog = path.join(__dirname, 'catalogs', 'alibaba-token-plan-qwen3.8.json');
897
1104
  args.push('-c', `model_catalog_json=${JSON.stringify(localCatalog)}`);
898
1105
  args.push('-c', `model_context_window=${ALIBABA_TOKEN_PLAN_CONTEXT}`);
@@ -912,7 +1119,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
912
1119
  }, home));
913
1120
  if (spec.provider !== 'native') args.push('--provider', spec.provider === 'custom' ? spec.providerId : profile.piProvider);
914
1121
  if (model) args.push('--model', model);
915
- if (spec.provider === 'alibaba-token-plan' && model === 'qwen3.8-max-preview') args.push('--thinking', 'xhigh');
1122
+ if (spec.provider === 'alibaba-token-plan' && model === 'qwen3.8-max') args.push('--thinking', 'xhigh');
916
1123
  } else if (spec.client === 'agy') {
917
1124
  // Agy delega l'autenticazione al proprio login locale (auth: 'login'): niente
918
1125
  // env provider, niente credenziali copiate. Argv diretto: --model prima del
@@ -972,11 +1179,13 @@ function publicCatalog() {
972
1179
  }
973
1180
 
974
1181
  module.exports = {
1182
+ knownMcpServerNames,
975
1183
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
976
1184
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
977
1185
  CLIENT_LABELS, normalizeManagedSpec, profileFor,
978
1186
  defaultDefinitions, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
979
1187
  discoverPiModels, EXTERNAL_DISCOVERY_TIMEOUT_MS, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
1188
+ extraModelsFrom,
980
1189
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
981
1190
  credentialEnvNeutralizeSet, applyStoreNeutralization,
982
1191
  ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+ // lib/fleet/model-probe.js — «questo modello funziona davvero con questa
3
+ // credenziale?», chiesto all'API invece che indovinato.
4
+ //
5
+ // Oggi un id sbagliato si scopre quando la cella non parte, e il messaggio non
6
+ // dice se il problema e' il nome del modello, la chiave o la rete. Qui la
7
+ // domanda si fa prima, e la risposta e' un enum chiuso.
8
+ //
9
+ // COSTO: si interroga l'elenco dei modelli (`GET .../models`) e nient'altro.
10
+ // Non si genera testo, quindi non si consumano token. Dove il catalogo non
11
+ // esiste l'esito e' `unverified`: NON si ricade su una richiesta di
12
+ // completamento, per quanto minima. Una prova che costa e' una prova che chi
13
+ // la guarda impara a non fare — e allora tanto vale non averla.
14
+ //
15
+ // COSA NON ESCE MAI DA QUI:
16
+ // - il testo che il modello eventualmente genera: non viene letto, non viene
17
+ // registrato, non entra nell'esito. Vale la stessa disciplina dei
18
+ // diagnostici, che rifiutano contenuto grezzo;
19
+ // - la credenziale: viaggia nell'header e non compare in nessun ritorno,
20
+ // nemmeno nei dettagli di errore;
21
+ // - il corpo della risposta remota: se ne estrae al massimo un codice noto.
22
+ //
23
+ // `unverified` NON e' `ok`: una prova non ottenuta non autorizza a dichiarare
24
+ // che il modello funziona. E' la stessa regola per cui un device che ri-poll
25
+ // senza confermare produce `delivery-unknown` e non un successo.
26
+
27
+ const OUTCOMES = Object.freeze(['ok', 'unknown-model', 'auth', 'unreachable', 'unverified']);
28
+
29
+ // Un tetto basso: la prova serve a rispondere subito, non a insistere. Chi ha
30
+ // un endpoint lento lo scoprira' dal `unreachable`, che e' un'informazione.
31
+ const DEFAULT_TIMEOUT_MS = 8000;
32
+
33
+ function joinUrl(base, path) {
34
+ const b = String(base || '').replace(/\/+$/, '');
35
+ return `${b}/${String(path || '').replace(/^\/+/, '')}`;
36
+ }
37
+
38
+ // L'elenco modelli sta sotto `/models` per le API in stile OpenAI. Gli
39
+ // endpoint anthropic-compatibili spesso non lo espongono: e' il caso in cui si
40
+ // ricade sul completamento minimo.
41
+ function modelsUrl(profile) {
42
+ const endpoint = profile && profile.endpoint;
43
+ if (typeof endpoint !== 'string' || !/^https?:\/\//.test(endpoint)) return null;
44
+ return joinUrl(endpoint, 'models');
45
+ }
46
+
47
+ function authHeaders(profile, credential) {
48
+ const protocol = profile && profile.protocol;
49
+ if (protocol === 'anthropic_messages') {
50
+ return { 'x-api-key': credential, 'anthropic-version': '2023-06-01' };
51
+ }
52
+ return { authorization: `Bearer ${credential}` };
53
+ }
54
+
55
+ // Cerca l'id fra i modelli elencati. Le forme note sono `{data:[{id}]}` (OpenAI)
56
+ // e `{models:[{name|id}]}` (Ollama): non si indovina oltre — un elenco che non
57
+ // si sa leggere produce `unverified`, non un falso negativo.
58
+ function findInCatalog(payload, model) {
59
+ const rows = Array.isArray(payload && payload.data) ? payload.data
60
+ : Array.isArray(payload && payload.models) ? payload.models : null;
61
+ if (!rows) return null;
62
+ const wanted = String(model);
63
+ // Il tag fa parte dell'identita' su alcuni fornitori (`deepseek-v4-flash:0731`)
64
+ // e su altri no: si accetta la corrispondenza esatta o quella sul nome base.
65
+ const base = wanted.split(':')[0];
66
+ return rows.some((row) => {
67
+ const id = typeof row === 'string' ? row : (row && (row.id || row.name));
68
+ if (typeof id !== 'string') return false;
69
+ return id === wanted || id === base || id.split(':')[0] === wanted;
70
+ });
71
+ }
72
+
73
+ // probeModel({profile, credential, model, fetchImpl, timeoutMs})
74
+ // -> {outcome, latencyMs, detail?}
75
+ async function probeModel({
76
+ profile, credential, model, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS,
77
+ } = {}) {
78
+ if (!profile || typeof model !== 'string' || !model) {
79
+ return { outcome: 'unverified', latencyMs: 0, detail: 'parametri non validi' };
80
+ }
81
+ if (typeof credential !== 'string' || !credential) {
82
+ // Nessuna chiave: e' un esito, non un errore da nascondere. Dirlo qui
83
+ // evita una chiamata che sarebbe rifiutata comunque.
84
+ return { outcome: 'auth', latencyMs: 0, detail: 'credenziale assente' };
85
+ }
86
+ const url = modelsUrl(profile);
87
+ if (!url) {
88
+ // Endpoint non HTTP (account gestiti, provider locali senza catalogo):
89
+ // non si inventa una prova.
90
+ return { outcome: 'unverified', latencyMs: 0, detail: 'endpoint non interrogabile' };
91
+ }
92
+
93
+ const started = Date.now();
94
+ const controller = new AbortController();
95
+ const budget = Number.isInteger(timeoutMs) ? Math.max(500, Math.min(timeoutMs, 30000)) : DEFAULT_TIMEOUT_MS;
96
+ const timer = setTimeout(() => controller.abort(), budget);
97
+ try {
98
+ const res = await fetchImpl(url, {
99
+ method: 'GET', signal: controller.signal, headers: authHeaders(profile, credential),
100
+ });
101
+ const latencyMs = Date.now() - started;
102
+ const status = res && typeof res.status === 'number' ? res.status : 0;
103
+ if (status === 401 || status === 403) return { outcome: 'auth', latencyMs };
104
+ // 404/405: questo fornitore non espone il catalogo. Non e' un fallimento
105
+ // del modello, ed e' scorretto riportarlo come tale.
106
+ if (status === 404 || status === 405 || status === 501) {
107
+ return { outcome: 'unverified', latencyMs, detail: 'catalogo non esposto' };
108
+ }
109
+ if (status < 200 || status >= 300) return { outcome: 'unverified', latencyMs, detail: `http ${status}` };
110
+
111
+ let payload = null;
112
+ try { payload = typeof res.json === 'function' ? await res.json() : null; } catch (_) { payload = null; }
113
+ const found = findInCatalog(payload, model);
114
+ if (found === null) return { outcome: 'unverified', latencyMs, detail: 'catalogo non leggibile' };
115
+ return found ? { outcome: 'ok', latencyMs } : { outcome: 'unknown-model', latencyMs };
116
+ } catch (error) {
117
+ const latencyMs = Date.now() - started;
118
+ // Un abort e' il nostro timeout, non un rifiuto del fornitore.
119
+ const aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR');
120
+ return {
121
+ outcome: 'unreachable', latencyMs,
122
+ detail: aborted ? `timeout (${budget}ms)` : 'endpoint non raggiungibile',
123
+ };
124
+ } finally {
125
+ clearTimeout(timer);
126
+ }
127
+ }
128
+
129
+ module.exports = { probeModel, OUTCOMES, findInCatalog, modelsUrl };
@@ -152,6 +152,32 @@ function fleetRoutes(fleetP, cfg = {}) {
152
152
  r.post('/credentials/remove', guard((f, b) => {
153
153
  requireCap(f, 'credentials'); return f.removeLocalCredential(String(b.envKey || ''));
154
154
  }, { mutate: true }));
155
+ // Prova di un modello sull'API del suo profilo. E' nell'allowlist federata:
156
+ // chi e' accoppiato amministra il nodo remoto come farebbe in locale, e un
157
+ // modello che non si puo' provare a distanza si finisce per provarlo facendo
158
+ // partire la cella — cioe' nel modo peggiore. La credenziale si risolve dove
159
+ // la prova gira e non attraversa il router.
160
+ // Qui non e' gated da READONLY, per la stessa ragione di `nodes test`: e' una
161
+ // diagnosi, non una mutazione. Sulla via FEDERATA invece READONLY la blocca,
162
+ // perche' li' la richiesta autenticata partirebbe da un nodo dichiarato di
163
+ // sola lettura su comando di qualcun altro.
164
+ r.post('/model-test', guard((f, b) => {
165
+ requireCap(f, 'model-test');
166
+ if (typeof f.testModel !== 'function') {
167
+ const e = new Error('not supported by this fleet provider'); e.status = 501; throw e;
168
+ }
169
+ return f.testModel(String(b.engine || ''), String(b.model || ''));
170
+ }));
171
+ r.post('/define-model', guard((f, b) => {
172
+ requireCap(f, 'define');
173
+ if (typeof f.defineModel !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
174
+ return f.defineModel(b.def);
175
+ }, { mutate: true }));
176
+ r.post('/remove-model', guard((f, b) => {
177
+ requireCap(f, 'remove');
178
+ if (typeof f.removeModel !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
179
+ return f.removeModel(String(b.id || ''), String(b.engine || ''));
180
+ }, { mutate: true }));
155
181
  r.post('/define-engine', guard((f, b) => { requireCap(f, 'define'); return f.defineEngine(b.def); }, { mutate: true }));
156
182
  r.post('/edit-engine', guard((f, b) => { requireCap(f, 'edit'); return f.editEngine(b.id, b.patch, b.envChanges); }, { mutate: true }));
157
183
  r.post('/remove-engine', guard((f, b) => { requireCap(f, 'remove'); return f.removeEngine(b.id); }, { mutate: true }));
@@ -162,7 +188,7 @@ function fleetRoutes(fleetP, cfg = {}) {
162
188
  r.post('/restore-engines', guard((f, b) => {
163
189
  requireCap(f, 'restore');
164
190
  if (typeof f.restoreEngines !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
165
- return f.restoreEngines(b.engines, { overwrite: b.overwrite === true });
191
+ return f.restoreEngines(b.engines, { overwrite: b.overwrite === true, models: b.models });
166
192
  }, { mutate: true }));
167
193
  // Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
168
194
  // gestita fleet.json.
@@ -17,7 +17,7 @@ const {
17
17
  loadDefinitions, validateCommandTrust, resolveCwd,
18
18
  } = require('./definitions.js');
19
19
  const {
20
- describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
20
+ describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels, extraModelsFrom,
21
21
  } = require('./managed.js');
22
22
  const {
23
23
  httpError, minimalEnv, tmuxExec,
@@ -125,7 +125,7 @@ function createBuiltinRuntime(ctx) {
125
125
  needsPi ? discoverPiModels({ ...cfg, home }) : {},
126
126
  ]);
127
127
  const engines = cache.defs.engines.map((e) => {
128
- const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
128
+ const managed = e.managed ? describeManaged(e.managed, { ...cfg, home, extraModels: extraModelsFrom(cache.defs) }) : null;
129
129
  return {
130
130
  id: e.id, label: e.label, rc: !!e.rc,
131
131
  ...(managed ? {
@@ -164,7 +164,7 @@ function createBuiltinRuntime(ctx) {
164
164
  if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
165
165
  let launchEngine = engine;
166
166
  if (engine.managed) {
167
- const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
167
+ const resolved = resolveManagedEngine(engine, cell, { ...cfg, home, extraModels: extraModelsFrom(defs) });
168
168
  if (!resolved.ok) {
169
169
  const code = engine.managed.client === 'shell' ? 'SHELL_NOT_AVAILABLE' : 'ENGINE_UNCONFIGURED';
170
170
  throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });