@mmmbuto/nexuscrew 0.8.52-rc.9 → 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-Cefi613r.js +0 -93
package/lib/fleet/builtin.js
CHANGED
|
@@ -35,7 +35,9 @@ const {
|
|
|
35
35
|
} = require('./definitions.js');
|
|
36
36
|
const {
|
|
37
37
|
publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine, defaultAgyEngine, defaultKimiEngine,
|
|
38
|
+
CATALOG, credential: resolveProfileCredential, extraModelsFrom, knownMcpServerNames,
|
|
38
39
|
} = require('./managed.js');
|
|
40
|
+
const { probeModel } = require('./model-probe.js');
|
|
39
41
|
const { validEnvKey } = require('./env-key.js');
|
|
40
42
|
const { setCredential, removeCredential } = require('./credentials.js');
|
|
41
43
|
const { createLaunchBroker } = require('./launch-broker.js');
|
|
@@ -55,6 +57,14 @@ const {
|
|
|
55
57
|
function draftFrom(defs) {
|
|
56
58
|
return {
|
|
57
59
|
schemaVersion: defs.schemaVersion,
|
|
60
|
+
// I modelli dichiarati vanno RIPORTATI nella bozza: ogni mutazione
|
|
61
|
+
// ricostruisce il file da qui, e un campo non copiato sparisce senza che
|
|
62
|
+
// nessuno lo chieda. Trovato da un test — dichiaravo un modello, salvavo
|
|
63
|
+
// un engine che lo usava, e il salvataggio falliva perche' la
|
|
64
|
+
// dichiarazione non c'era piu'.
|
|
65
|
+
...(Array.isArray(defs.models) && defs.models.length
|
|
66
|
+
? { models: defs.models.map((m) => ({ ...m })) }
|
|
67
|
+
: {}),
|
|
58
68
|
engines: defs.engines.map((e) => ({
|
|
59
69
|
...e,
|
|
60
70
|
...(e.managed ? { managed: { ...e.managed } } : {}),
|
|
@@ -371,6 +381,50 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
371
381
|
await mutate(reloadDefs(), (d) => { d.engines.push(def); });
|
|
372
382
|
return { ok: true, id: def.id };
|
|
373
383
|
}
|
|
384
|
+
// --- modelli dichiarati ---------------------------------------------------
|
|
385
|
+
// Vivono in fleet.json accanto agli engine: stesso file, stesso backup,
|
|
386
|
+
// stessa esportazione. La validazione e' quella del parser (schema chiuso):
|
|
387
|
+
// qui si aggiunge e si toglie, non si reinterpreta.
|
|
388
|
+
async function defineModel(def) {
|
|
389
|
+
if (readonly()) throw httpError(403, 'READONLY: define-model bloccato');
|
|
390
|
+
if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione modello mancante');
|
|
391
|
+
const id = String(def.id || '').trim();
|
|
392
|
+
const engine = String(def.engine || '').trim();
|
|
393
|
+
if (!id || !engine) throw httpError(400, 'id e engine sono obbligatori');
|
|
394
|
+
const defs = reloadDefs();
|
|
395
|
+
const esistente = (defs.models || []).some((m) => m.id === id && m.engine === engine);
|
|
396
|
+
if (esistente) throw httpError(400, `modello gia' dichiarato: ${id} su ${engine}`);
|
|
397
|
+
// La scrittura passa da `mutate`, che rilegge e RIVALIDA l'intero file: un
|
|
398
|
+
// campo fuori schema non entra, e non serve duplicare qui le regole.
|
|
399
|
+
await mutate(defs, (d) => { d.models = [...(d.models || []), def]; });
|
|
400
|
+
return { ok: true, id, engine };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function removeModel(id, engine) {
|
|
404
|
+
if (readonly()) throw httpError(403, 'READONLY: remove-model bloccato');
|
|
405
|
+
const wantedId = String(id || '').trim();
|
|
406
|
+
const wantedEngine = String(engine || '').trim();
|
|
407
|
+
if (!wantedId || !wantedEngine) throw httpError(400, 'id e engine sono obbligatori');
|
|
408
|
+
const defs = reloadDefs();
|
|
409
|
+
if (!(defs.models || []).some((m) => m.id === wantedId && m.engine === wantedEngine)) {
|
|
410
|
+
throw httpError(404, `modello non dichiarato: ${wantedId} su ${wantedEngine}`);
|
|
411
|
+
}
|
|
412
|
+
// Se un engine sta USANDO questo modello, toglierlo lo renderebbe invalido
|
|
413
|
+
// al prossimo avvio — e il messaggio arriverebbe a chi non sa cosa e'
|
|
414
|
+
// cambiato. Meglio rifiutare adesso, dicendo chi lo usa.
|
|
415
|
+
const usato = (defs.engines || [])
|
|
416
|
+
.filter((e) => e.managed && e.managed.model === wantedId)
|
|
417
|
+
.map((e) => e.id);
|
|
418
|
+
if (usato.length) {
|
|
419
|
+
throw httpError(409, `modello in uso: ${usato.join(', ')}`);
|
|
420
|
+
}
|
|
421
|
+
await mutate(defs, (d) => {
|
|
422
|
+
d.models = (d.models || []).filter((m) => !(m.id === wantedId && m.engine === wantedEngine));
|
|
423
|
+
if (!d.models.length) delete d.models;
|
|
424
|
+
});
|
|
425
|
+
return { ok: true, id: wantedId, engine: wantedEngine };
|
|
426
|
+
}
|
|
427
|
+
|
|
374
428
|
async function editEngine(id, patch, envChanges) {
|
|
375
429
|
if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
|
|
376
430
|
if (!id) throw httpError(400, 'id engine mancante');
|
|
@@ -566,6 +620,18 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
566
620
|
// conserva gli eventuali valori write-only già configurati sul target.
|
|
567
621
|
async function restoreEngines(engines, opts = {}) {
|
|
568
622
|
if (readonly()) throw httpError(403, 'READONLY: restore-engines bloccato');
|
|
623
|
+
// I modelli dichiarati arrivano INSIEME agli engine e vanno scritti PRIMA,
|
|
624
|
+
// nella stessa mutazione: un engine che usa un modello dichiarato viene
|
|
625
|
+
// rifiutato se la dichiarazione non c'e' ancora — fail-closed corretto, ma
|
|
626
|
+
// che al ripristino si legge come «il backup era rotto».
|
|
627
|
+
const models = Array.isArray(opts.models) ? opts.models : [];
|
|
628
|
+
if (models.length > 64) throw httpError(400, 'troppi modelli nel backup');
|
|
629
|
+
for (const m of models) {
|
|
630
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) throw httpError(400, 'definizione modello non valida');
|
|
631
|
+
if (typeof m.id !== 'string' || !m.id.trim() || typeof m.engine !== 'string' || !m.engine.trim()) {
|
|
632
|
+
throw httpError(400, 'modello senza id o engine');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
569
635
|
if (!Array.isArray(engines) || engines.length < 1 || engines.length > 24) {
|
|
570
636
|
throw httpError(400, 'engines deve contenere 1..24 definizioni');
|
|
571
637
|
}
|
|
@@ -601,6 +667,10 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
601
667
|
const sessions = await refreshSessions();
|
|
602
668
|
const affected = defs.cells.filter((cell) => conflicts.includes(cell.engine) && sessions.has(cell.tmuxSession)).map((cell) => cell.id);
|
|
603
669
|
await mutate(defs, (draft) => {
|
|
670
|
+
for (const portable of models) {
|
|
671
|
+
const esiste = (draft.models || []).some((m) => m.id === portable.id && m.engine === portable.engine);
|
|
672
|
+
if (!esiste) draft.models = [...(draft.models || []), portable];
|
|
673
|
+
}
|
|
604
674
|
for (const portable of engines) {
|
|
605
675
|
const index = draft.engines.findIndex((current) => current.id === portable.id);
|
|
606
676
|
const current = index >= 0 ? draft.engines[index] : null;
|
|
@@ -725,7 +795,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
725
795
|
engines: defs.engines.map((e) => {
|
|
726
796
|
const out = { ...e, envKeys: Object.keys(e.env || {}).sort() };
|
|
727
797
|
delete out.env;
|
|
728
|
-
if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home });
|
|
798
|
+
if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home, extraModels: extraModelsFrom(defs) });
|
|
729
799
|
return out;
|
|
730
800
|
}),
|
|
731
801
|
cells: defs.cells.map((c) => {
|
|
@@ -756,7 +826,16 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
756
826
|
}
|
|
757
827
|
return out;
|
|
758
828
|
}),
|
|
829
|
+
// I modelli dichiarati devono uscire dalla VISTA, non solo stare nel file:
|
|
830
|
+
// la finestra li legge da qui, e senza questa riga l'elenco era sempre
|
|
831
|
+
// vuoto — si poteva dichiarare un modello e non vederlo mai, ne' ritirarlo.
|
|
832
|
+
// I test CRUD non se ne accorgevano perche' leggevano fleet.json diretto.
|
|
833
|
+
...(Array.isArray(defs.models) && defs.models.length ? { models: defs.models } : {}),
|
|
759
834
|
managedCatalog,
|
|
835
|
+
// I NOMI dei server MCP disponibili, per la finestra che sceglie quali
|
|
836
|
+
// strumenti dare a una cella. Solo nomi: le definizioni non escono di qui,
|
|
837
|
+
// e non devono — alcune portano credenziali nel proprio `env`.
|
|
838
|
+
mcpServers: [...knownMcpServerNames(home)].sort(),
|
|
760
839
|
managedConfig: {
|
|
761
840
|
providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env'),
|
|
762
841
|
providerShellPath: cfg.providerShellPath || path.join(home, '.config', 'ai-shell', 'providers.zsh'),
|
|
@@ -771,7 +850,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
771
850
|
const defs = existingDefs || reloadDefs(); const map = new Map();
|
|
772
851
|
for (const engine of defs.engines) {
|
|
773
852
|
if (!engine.managed) continue;
|
|
774
|
-
const info = describeManaged(engine.managed, { ...cfg, home });
|
|
853
|
+
const info = describeManaged(engine.managed, { ...cfg, home, extraModels: extraModelsFrom(reloadDefs()) });
|
|
775
854
|
const envKey = info.auth;
|
|
776
855
|
if (!validEnvKey(envKey) || envKey === 'login' || envKey === 'none') continue;
|
|
777
856
|
const current = map.get(envKey) || {
|
|
@@ -856,6 +935,20 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
856
935
|
promptMode: { type: 'enum', required: true, values: ['flag', 'send-keys'] },
|
|
857
936
|
promptFlag: { type: 'string', requiredIf: 'promptMode=flag', singleArgv: true, max: CAPS.MAX_PROMPTFLAG_LEN },
|
|
858
937
|
},
|
|
938
|
+
// Modello dichiarato: estende il catalogo interno per UN profilo. Vive
|
|
939
|
+
// nello schema perche' `define-model` e' federato — un client che
|
|
940
|
+
// amministra un nodo remoto ricava di qui la forma da compilare, invece
|
|
941
|
+
// di indovinarla e scoprire il vincolo da un rifiuto.
|
|
942
|
+
model: {
|
|
943
|
+
id: { type: 'string', required: true, max: CAPS.MAX_MODEL_VAL_LEN, noControlChars: true },
|
|
944
|
+
engine: { type: 'catalog', required: true, source: 'managedCatalog', max: 64 },
|
|
945
|
+
label: { type: 'string', required: false, max: 64 },
|
|
946
|
+
// Fuori da questa scala e' quasi sempre un errore di battitura, e
|
|
947
|
+
// arriverebbe al client come comportamento inspiegabile.
|
|
948
|
+
contextWindow: { type: 'integer', required: false, min: 1024, max: 100000000 },
|
|
949
|
+
maxTokens: { type: 'integer', required: false, min: 1024, max: 100000000 },
|
|
950
|
+
reasoning: { type: 'boolean', required: false },
|
|
951
|
+
},
|
|
859
952
|
cell: {
|
|
860
953
|
id: { type: 'string', required: true, pattern: '^[A-Za-z0-9._-]{1,32}$', max: 32 },
|
|
861
954
|
cwd: { type: 'string', required: true, max: CAPS.MAX_CWD_LEN, underHome: true },
|
|
@@ -867,6 +960,9 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
867
960
|
permissionPolicies: { type: 'object', required: false, keyRef: 'engine.id', valueEnum: ['standard', 'unsafe'] },
|
|
868
961
|
commands: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_CELL_COMMAND_LEN, managedClient: 'shell' },
|
|
869
962
|
prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
|
|
963
|
+
// Quali strumenti MCP ha la cella, per NOME. Assente = tutti (come
|
|
964
|
+
// sempre); `[]` = nessuno. Solo nomi: le definizioni restano dove sono.
|
|
965
|
+
mcp: { type: 'array', of: 'string', required: false, max: 64, itemPattern: '^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$' },
|
|
870
966
|
// Nome leggibile, distinto dall'id: quest'ultimo resta la chiave di
|
|
871
967
|
// indirizzamento e l'unica origine della sessione tmux.
|
|
872
968
|
label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN },
|
|
@@ -875,7 +971,35 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
875
971
|
}
|
|
876
972
|
|
|
877
973
|
function capabilities() {
|
|
878
|
-
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials'];
|
|
974
|
+
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test'];
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// «questo modello funziona con questa credenziale?», chiesto all'API invece
|
|
978
|
+
// che scoperto quando la cella non parte. Vive qui e non nelle route perche'
|
|
979
|
+
// la credenziale si risolve dove gia' si risolve per l'avvio: la chiave non
|
|
980
|
+
// attraversa il router e non compare in nessuna risposta.
|
|
981
|
+
async function testModel(engineId, model, opts = {}) {
|
|
982
|
+
const profile = CATALOG.find((p) => p.id === String(engineId || ''));
|
|
983
|
+
if (!profile) {
|
|
984
|
+
const e = new Error(`profilo sconosciuto "${engineId}"`); e.status = 404; throw e;
|
|
985
|
+
}
|
|
986
|
+
const wanted = typeof model === 'string' ? model.trim() : '';
|
|
987
|
+
if (!wanted || wanted.length > 128 || /[\x00-\x1f\x7f]/.test(wanted)) {
|
|
988
|
+
const e = new Error('modello non valido'); e.status = 400; throw e;
|
|
989
|
+
}
|
|
990
|
+
const spec = {
|
|
991
|
+
client: profile.client,
|
|
992
|
+
provider: profile.provider,
|
|
993
|
+
...(profile.credentialProfile ? { credentialProfile: profile.credentialProfile } : {}),
|
|
994
|
+
};
|
|
995
|
+
let value = '';
|
|
996
|
+
try { value = (resolveProfileCredential(profile, spec, cfg, home) || {}).value || ''; } catch (_) { value = ''; }
|
|
997
|
+
const out = await probeModel({
|
|
998
|
+
profile, credential: value, model: wanted, fetchImpl: opts.fetchImpl,
|
|
999
|
+
});
|
|
1000
|
+
// Si riporta anche COSA si e' provato, perche' un esito senza il suo
|
|
1001
|
+
// soggetto e' inservibile in una schermata con piu' modelli.
|
|
1002
|
+
return { engine: profile.id, model: wanted, ...out };
|
|
879
1003
|
}
|
|
880
1004
|
|
|
881
1005
|
async function close() { await launchBroker.close(); }
|
|
@@ -887,7 +1011,8 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
887
1011
|
defineEngine, editEngine, removeEngine,
|
|
888
1012
|
defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
|
|
889
1013
|
schema, definitions, capabilities,
|
|
890
|
-
credentialStatus, setLocalCredential, removeLocalCredential,
|
|
1014
|
+
credentialStatus, setLocalCredential, removeLocalCredential, testModel,
|
|
1015
|
+
defineModel, removeModel, close,
|
|
891
1016
|
};
|
|
892
1017
|
}
|
|
893
1018
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"models": [
|
|
3
3
|
{
|
|
4
|
-
"slug": "qwen3.8-max
|
|
5
|
-
"display_name": "qwen3.8-max
|
|
6
|
-
"description": "Alibaba Token Plan Personal: qwen3.8-max
|
|
4
|
+
"slug": "qwen3.8-max",
|
|
5
|
+
"display_name": "qwen3.8-max",
|
|
6
|
+
"description": "Alibaba Token Plan Personal: qwen3.8-max",
|
|
7
7
|
"default_reasoning_level": "xhigh",
|
|
8
8
|
"supported_reasoning_levels": [
|
|
9
9
|
{
|
package/lib/fleet/definitions.js
CHANGED
|
@@ -29,6 +29,11 @@ const MAX_MODEL_FLAG_LEN = 32;
|
|
|
29
29
|
const MAX_MODEL_VAL_LEN = 128;
|
|
30
30
|
const MAX_PROMPTFLAG_LEN = 32;
|
|
31
31
|
const MAX_PROMPT_LEN = 8192; // 8 KB
|
|
32
|
+
// Nomi di server MCP dichiarabili per cella. Il tetto e' generoso rispetto agli
|
|
33
|
+
// otto configurati qui, e la forma e' quella di un identificatore perche' il
|
|
34
|
+
// nome finisce in un prefisso di tool passato al client.
|
|
35
|
+
const MAX_MCP_NAMES = 64;
|
|
36
|
+
const MCP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
32
37
|
const MAX_CELL_COMMAND_LEN = 4096;
|
|
33
38
|
const MAX_TMUXSESSION_LEN = 64;
|
|
34
39
|
|
|
@@ -131,7 +136,49 @@ function cellIdFromTmuxSession(session) {
|
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
// ---------------------------------------------------------------------------
|
|
134
|
-
//
|
|
139
|
+
// Modelli dichiarati dall'operatore, accanto agli engine e persistiti con loro.
|
|
140
|
+
//
|
|
141
|
+
// Perche' esistono: il catalogo dei modelli vive nel pacchetto, e un fornitore
|
|
142
|
+
// che pubblica un id nuovo lo rende inutilizzabile fino alla release
|
|
143
|
+
// successiva. Qui si dichiara l'id per un profilo gestito, con i dati che il
|
|
144
|
+
// client si aspetta — gli stessi campi che il catalogo interno gia' porta.
|
|
145
|
+
//
|
|
146
|
+
// Un modello dichiarato NON aggira il controllo: lo estende in modo esplicito.
|
|
147
|
+
// Un id mai dichiarato resta rifiutato.
|
|
148
|
+
const MAX_MODELS = 64;
|
|
149
|
+
const MODEL_ID_MAX = 128;
|
|
150
|
+
|
|
151
|
+
function parseModel(m) {
|
|
152
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) return null;
|
|
153
|
+
const allowed = new Set(['id', 'engine', 'label', 'contextWindow', 'maxTokens', 'reasoning']);
|
|
154
|
+
if (Object.keys(m).some((k) => !allowed.has(k))) return null;
|
|
155
|
+
const id = typeof m.id === 'string' ? m.id.trim() : '';
|
|
156
|
+
// Stessa forma accettata dal gate dei modelli: nessun byte di controllo, un
|
|
157
|
+
// limite di lunghezza, e nulla di piu' — l'id lo decide il fornitore, non noi.
|
|
158
|
+
if (!id || id.length > MODEL_ID_MAX || /[\x00-\x1f\x7f]/.test(id)) return null;
|
|
159
|
+
const engine = typeof m.engine === 'string' ? m.engine.trim() : '';
|
|
160
|
+
if (!engine || engine.length > 64 || !/^[a-z0-9][a-z0-9._-]*$/.test(engine)) return null;
|
|
161
|
+
const out = { id, engine };
|
|
162
|
+
if (m.label !== undefined) {
|
|
163
|
+
if (typeof m.label !== 'string' || m.label.length > 64) return null;
|
|
164
|
+
if (m.label.trim()) out.label = m.label.trim();
|
|
165
|
+
}
|
|
166
|
+
for (const key of ['contextWindow', 'maxTokens']) {
|
|
167
|
+
if (m[key] === undefined) continue;
|
|
168
|
+
// Una finestra plausibile: un numero fuori scala e' quasi sempre un errore
|
|
169
|
+
// di battitura, e passerebbe fino al client sotto forma di comportamento
|
|
170
|
+
// inspiegabile.
|
|
171
|
+
if (!Number.isSafeInteger(m[key]) || m[key] < 1024 || m[key] > 100000000) return null;
|
|
172
|
+
out[key] = m[key];
|
|
173
|
+
}
|
|
174
|
+
if (m.reasoning !== undefined) {
|
|
175
|
+
if (typeof m.reasoning !== 'boolean') return null;
|
|
176
|
+
out.reasoning = m.reasoning;
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// parseDefinitions(raw) -> {schemaVersion, engines, cells, models} | null
|
|
135
182
|
// Accetta stringa JSON o oggetto gia' parsato. Strict + fail-closed.
|
|
136
183
|
// ---------------------------------------------------------------------------
|
|
137
184
|
function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
|
|
@@ -151,11 +198,32 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
|
|
|
151
198
|
if (!Array.isArray(d.cells)) return null; // cells obbligatorio (array)
|
|
152
199
|
if (d.cells.length > MAX_CELLS) return null;
|
|
153
200
|
|
|
201
|
+
// I modelli si leggono PRIMA degli engine: un engine puo' riferirsi a un id
|
|
202
|
+
// dichiarato qui, e leggerli dopo lo farebbe rifiutare per un dato che in
|
|
203
|
+
// realta' c'e'. `models` e' opzionale: una configurazione che non lo ha
|
|
204
|
+
// resta valida esattamente com'era.
|
|
205
|
+
const models = [];
|
|
206
|
+
const extraModels = new Map();
|
|
207
|
+
if (d.models !== undefined) {
|
|
208
|
+
if (!Array.isArray(d.models) || d.models.length > MAX_MODELS) return null;
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
for (const m of d.models) {
|
|
211
|
+
const model = parseModel(m);
|
|
212
|
+
if (!model) return null;
|
|
213
|
+
const key = `${model.engine}::${model.id}`;
|
|
214
|
+
if (seen.has(key)) return null; // stesso id due volte per lo stesso profilo
|
|
215
|
+
seen.add(key);
|
|
216
|
+
if (!extraModels.has(model.engine)) extraModels.set(model.engine, new Set());
|
|
217
|
+
extraModels.get(model.engine).add(model.id);
|
|
218
|
+
models.push(model);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
154
222
|
const engineIds = new Set();
|
|
155
223
|
const engineMap = new Map();
|
|
156
224
|
const engines = [];
|
|
157
225
|
for (const e of d.engines) {
|
|
158
|
-
const eng = parseEngine(e);
|
|
226
|
+
const eng = parseEngine(e, { extraModels });
|
|
159
227
|
if (!eng) return null;
|
|
160
228
|
if (engineIds.has(eng.id)) return null; // id engine univoco
|
|
161
229
|
engineIds.add(eng.id);
|
|
@@ -169,7 +237,7 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
|
|
|
169
237
|
const cellIds = new Set();
|
|
170
238
|
const cells = [];
|
|
171
239
|
for (const c of d.cells) {
|
|
172
|
-
const cell = parseCell(c, engineIds, engineMap, { allowLegacyTmuxNames });
|
|
240
|
+
const cell = parseCell(c, engineIds, engineMap, { allowLegacyTmuxNames, extraModels });
|
|
173
241
|
if (!cell) return null;
|
|
174
242
|
if (cellIds.has(cell.id)) return null; // id cell univoco
|
|
175
243
|
cellIds.add(cell.id);
|
|
@@ -183,7 +251,11 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
|
|
|
183
251
|
cells.push(cell);
|
|
184
252
|
}
|
|
185
253
|
|
|
186
|
-
|
|
254
|
+
// `models` compare solo se dichiarato: una configurazione senza modelli
|
|
255
|
+
// resta byte-identica a prima, e l'aggiornamento non riscrive nulla.
|
|
256
|
+
const parsed = models.length
|
|
257
|
+
? { schemaVersion: SCHEMA_VERSION, engines, cells, models }
|
|
258
|
+
: { schemaVersion: SCHEMA_VERSION, engines, cells };
|
|
187
259
|
// Metadato solo in memoria: serve al bootstrap per rinominare una sessione
|
|
188
260
|
// legacy PRIMA di persistere il nome safe. Non entra in JSON, draft o API.
|
|
189
261
|
Object.defineProperty(parsed, 'legacyTmuxSessions', {
|
|
@@ -195,7 +267,7 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
|
|
|
195
267
|
}
|
|
196
268
|
}
|
|
197
269
|
|
|
198
|
-
function parseEngine(e) {
|
|
270
|
+
function parseEngine(e, { extraModels = null } = {}) {
|
|
199
271
|
if (!e || typeof e !== 'object' || Array.isArray(e)) return null;
|
|
200
272
|
|
|
201
273
|
// id
|
|
@@ -219,7 +291,7 @@ function parseEngine(e) {
|
|
|
219
291
|
// Managed: NexusCrew conosce client/provider e compone internamente il
|
|
220
292
|
// processo. Nessun command/env/argv o segreto vive nella definizione.
|
|
221
293
|
if (e.managed !== undefined) {
|
|
222
|
-
const managed = normalizeManagedSpec(e.managed);
|
|
294
|
+
const managed = normalizeManagedSpec(e.managed, { extraModels });
|
|
223
295
|
if (!managed) return null;
|
|
224
296
|
for (const key of ['command', 'args', 'env', 'promptMode', 'promptFlag', 'model']) {
|
|
225
297
|
if (e[key] !== undefined) return null;
|
|
@@ -285,7 +357,25 @@ function parseEngine(e) {
|
|
|
285
357
|
return out;
|
|
286
358
|
}
|
|
287
359
|
|
|
288
|
-
|
|
360
|
+
// Un modello scelto PER CELLA deve superare lo stesso controllo di un modello
|
|
361
|
+
// scelto per l'engine. Senza, il gate `strictModels` — che vale su
|
|
362
|
+
// define-engine, edit-engine, restore-engines e sulla scrittura diretta —
|
|
363
|
+
// resta aggirabile da questa via, che per giunta e' FEDERATA (`/fleet/engine`
|
|
364
|
+
// e' nell'allowlist): un peer poteva mettere un id arbitrario in una cella di
|
|
365
|
+
// questa installazione, e il boot lo usava senza ricontrollarlo.
|
|
366
|
+
//
|
|
367
|
+
// Rilievo dell'audit indipendente, provato con probe su due profili distinti.
|
|
368
|
+
// Il controllo e' quello vero, non una copia: si passa dalla stessa
|
|
369
|
+
// `normalizeManagedSpec` con le stesse dichiarazioni.
|
|
370
|
+
function cellModelAllowed(engineId, model, engineMap, extraModels) {
|
|
371
|
+
const engine = engineMap.get(engineId);
|
|
372
|
+
// Engine custom (non gestito): il modello e' un argomento del comando, non un
|
|
373
|
+
// id di catalogo, e qui non c'e' nulla da validare.
|
|
374
|
+
if (!engine || !engine.managed) return true;
|
|
375
|
+
return !!normalizeManagedSpec({ ...engine.managed, model }, { extraModels });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames = true, extraModels = null } = {}) {
|
|
289
379
|
if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
|
|
290
380
|
|
|
291
381
|
// id
|
|
@@ -320,6 +410,7 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
|
|
|
320
410
|
let model;
|
|
321
411
|
if (c.model !== undefined) {
|
|
322
412
|
if (typeof c.model !== 'string' || c.model.length > MAX_MODEL_VAL_LEN) return null;
|
|
413
|
+
if (!cellModelAllowed(c.engine, c.model, engineMap, extraModels)) return null;
|
|
323
414
|
model = c.model;
|
|
324
415
|
}
|
|
325
416
|
|
|
@@ -332,6 +423,9 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
|
|
|
332
423
|
if (entries.length > MAX_ENGINES) return null;
|
|
333
424
|
for (const [engineId, value] of entries) {
|
|
334
425
|
if (!engineIds.has(engineId) || typeof value !== 'string' || !value || value.length > MAX_MODEL_VAL_LEN) return null;
|
|
426
|
+
// Anche la memoria per-engine passa dal gate: e' un valore che tornera'
|
|
427
|
+
// in uso appena si ritorna su quell'engine.
|
|
428
|
+
if (!cellModelAllowed(engineId, value, engineMap, extraModels)) return null;
|
|
335
429
|
models[engineId] = value;
|
|
336
430
|
}
|
|
337
431
|
}
|
|
@@ -378,6 +472,32 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
|
|
|
378
472
|
prompt = c.prompt;
|
|
379
473
|
}
|
|
380
474
|
|
|
475
|
+
// mcp (opzionale): QUALI strumenti MCP ha questa cella, per NOME.
|
|
476
|
+
//
|
|
477
|
+
// Solo nomi, mai definizioni. Le definizioni vivono in un posto solo — la
|
|
478
|
+
// configurazione del client — e li' restano: duplicarle per cella
|
|
479
|
+
// significherebbe moltiplicare le credenziali che alcuni server portano nel
|
|
480
|
+
// proprio `env` (una password, due chiavi API su questa installazione), in un
|
|
481
|
+
// file per cella invece che in uno.
|
|
482
|
+
//
|
|
483
|
+
// ASSENTE non e' come VUOTO. Assente = la cella eredita tutti gli strumenti,
|
|
484
|
+
// che e' il comportamento di sempre. `[]` = nessuno strumento, ed e'
|
|
485
|
+
// esattamente cio' che serve per una cella di cui non ci si fida: oggi quel
|
|
486
|
+
// risultato si ottiene solo per caso, come effetto collaterale
|
|
487
|
+
// dell'isolamento per credenziale.
|
|
488
|
+
let mcp;
|
|
489
|
+
if (c.mcp !== undefined) {
|
|
490
|
+
if (!Array.isArray(c.mcp) || c.mcp.length > MAX_MCP_NAMES) return null;
|
|
491
|
+
const visti = new Set();
|
|
492
|
+
for (const nome of c.mcp) {
|
|
493
|
+
// Il nome finisce in un prefisso di tool (`mcp__<nome>`) passato al
|
|
494
|
+
// client: si delimita come un identificatore, non come testo libero.
|
|
495
|
+
if (typeof nome !== 'string' || !MCP_NAME_RE.test(nome) || visti.has(nome)) return null;
|
|
496
|
+
visti.add(nome);
|
|
497
|
+
}
|
|
498
|
+
mcp = [...visti];
|
|
499
|
+
}
|
|
500
|
+
|
|
381
501
|
// label (opzionale): nome LEGGIBILE della cella, distinto dall'id.
|
|
382
502
|
// L'id resta la chiave stabile con cui si indirizza una cella e con cui si
|
|
383
503
|
// deriva la sessione tmux; la label e' solo cio' che un umano legge. Senza
|
|
@@ -437,6 +557,7 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
|
|
|
437
557
|
if (permissionPolicies) out.permissionPolicies = permissionPolicies;
|
|
438
558
|
if (commands && Object.keys(commands).length) out.commands = commands;
|
|
439
559
|
if (prompt !== undefined) out.prompt = prompt;
|
|
560
|
+
if (mcp !== undefined) out.mcp = mcp;
|
|
440
561
|
if (label !== undefined) out.label = label;
|
|
441
562
|
if (legacyTmuxSession) {
|
|
442
563
|
Object.defineProperty(out, 'legacyTmuxSession', {
|