@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.
- package/CHANGELOG.md +243 -0
- package/frontend/dist/assets/{index-_kotgT8Z.css → index-CYi_lhCg.css} +1 -1
- package/frontend/dist/assets/index-Cxwpa2Y_.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +11 -0
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +6 -1
- package/lib/cells/scope-guard.js +239 -0
- package/lib/cells/scope.js +144 -0
- package/lib/cli/commands.js +23 -1
- package/lib/fleet/builtin.js +129 -4
- package/lib/fleet/catalogs/alibaba-token-plan-qwen3.8.json +3 -3
- package/lib/fleet/definitions.js +128 -7
- package/lib/fleet/managed.js +229 -20
- package/lib/fleet/model-probe.js +129 -0
- package/lib/fleet/routes.js +27 -1
- package/lib/fleet/runtime.js +3 -3
- package/lib/mcp/tools.js +67 -4
- package/lib/nodes/commands.js +86 -3
- package/lib/nodes/identity.js +232 -0
- package/lib/nodes/store.js +89 -0
- package/lib/notify/notifier.js +7 -0
- package/lib/notify/routes.js +75 -3
- package/lib/proxy/federation.js +71 -6
- package/lib/server.js +100 -4
- package/lib/settings/pairing-coordinator.js +16 -0
- package/lib/settings/public-peering-routes.js +24 -1
- package/lib/settings/routes.js +1 -1
- package/package.json +1 -1
- package/frontend/dist/assets/index-DHmXeaJX.js +0 -93
package/lib/fleet/managed.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
186
|
-
if (typeof
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1029
|
+
privateProfile = true;
|
|
1030
|
+
const qwen38 = model === 'qwen3.8-max';
|
|
830
1031
|
const aliases = qwen38 ? {
|
|
831
|
-
ANTHROPIC_MODEL: 'qwen3.8-max
|
|
832
|
-
ANTHROPIC_DEFAULT_FABLE_MODEL: 'qwen3.8-max
|
|
833
|
-
ANTHROPIC_DEFAULT_OPUS_MODEL: 'qwen3.8-max
|
|
834
|
-
ANTHROPIC_DEFAULT_SONNET_MODEL: 'qwen3.8-max
|
|
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
|
|
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
|
|
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 };
|
package/lib/fleet/routes.js
CHANGED
|
@@ -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.
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -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 });
|