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