@mmmbuto/nexuscrew 0.9.31 → 0.9.32
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 +10 -0
- package/docs/CONFIGURATION.md +39 -0
- package/frontend/dist/assets/{index-DaAL7P-m.js → index-CH2iAu6y.js} +13 -13
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/doctor.js +74 -2
- package/lib/fleet/builtin.js +84 -8
- package/lib/fleet/endpoint-probe.js +146 -0
- package/lib/fleet/managed.js +164 -10
- package/lib/fleet/model-probe.js +110 -6
- package/lib/fleet/runtime.js +29 -2
- package/lib/notify/ask-answer-service.js +10 -4
- package/lib/notify/asks.js +67 -7
- package/lib/notify/closure-retry.js +201 -0
- package/lib/notify/event-feed-asks-routes.js +4 -0
- package/lib/notify/event-feed-routes.js +8 -0
- package/lib/notify/routes.js +274 -12
- package/lib/proxy/federation.js +16 -3
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +132 -14
- package/package.json +1 -1
package/frontend/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-CH2iAu6y.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-DqCfZFGi.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.9.
|
|
1
|
+
{"version":"0.9.32"}
|
package/lib/cli/doctor.js
CHANGED
|
@@ -393,6 +393,24 @@ function checkTermuxExec(runtimeEnv, opts = {}) {
|
|
|
393
393
|
};
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
// «Server assente» e «non ho potuto guardare» sono due cose diverse, e vanno
|
|
397
|
+
// dette diverse. La prima e' uno stato normale — il server non c'e' ancora, la
|
|
398
|
+
// probe si rinvia al primo avvio. La seconda e' un'informazione che manca:
|
|
399
|
+
// collassarla nella prima significa dichiarare sano cio' che non si e'
|
|
400
|
+
// verificato, che e' esattamente il modo in cui un permesso sbagliato resta
|
|
401
|
+
// invisibile. E' la stessa distinzione gia' fatta fra «manca» e «non
|
|
402
|
+
// verificabile» altrove nel prodotto.
|
|
403
|
+
function classifyTmuxProbeCause(error) {
|
|
404
|
+
const code = String((error && error.code) || '');
|
|
405
|
+
const message = String((error && error.message) || error || '');
|
|
406
|
+
if (code === 'ENOENT'
|
|
407
|
+
|| /no server running|no such file or directory|connection refused|no sessions/i.test(message)) {
|
|
408
|
+
return 'absent';
|
|
409
|
+
}
|
|
410
|
+
const cause = (code || message.trim() || 'errore non nominato');
|
|
411
|
+
return cause.length > 120 ? `${cause.slice(0, 117)}...` : cause;
|
|
412
|
+
}
|
|
413
|
+
|
|
396
414
|
// Read-only probe of the long-lived tmux server cwd. A server that retained an
|
|
397
415
|
// unlinked runtime directory keeps accepting clients but makes later children
|
|
398
416
|
// fail getcwd(3). Never reports the path itself; only stable state.
|
|
@@ -403,8 +421,15 @@ function checkTmuxServerCwd(platform, execImpl, opts = {}) {
|
|
|
403
421
|
let rawPid = '';
|
|
404
422
|
try {
|
|
405
423
|
rawPid = String(execImpl(opts.tmuxBin || 'tmux', ['display-message', '-p', '#{pid}'], { encoding: 'utf8' }) || '').trim();
|
|
406
|
-
} catch (
|
|
407
|
-
|
|
424
|
+
} catch (error) {
|
|
425
|
+
const cause = classifyTmuxProbeCause(error);
|
|
426
|
+
if (cause === 'absent') {
|
|
427
|
+
return { name: 'tmux server cwd', ok: true, warn: true, detail: 'server tmux non attivo; probe rinviato al primo avvio' };
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
name: 'tmux server cwd', ok: false, warn: true,
|
|
431
|
+
detail: `probe non verificabile: ${cause}`,
|
|
432
|
+
};
|
|
408
433
|
}
|
|
409
434
|
if (!/^\d+$/.test(rawPid)) {
|
|
410
435
|
return { name: 'tmux server cwd', ok: true, warn: true, detail: 'server tmux non rilevato' };
|
|
@@ -620,6 +645,51 @@ function checkFleetDefinitions(home, fleetDefsPath, enabled = true) {
|
|
|
620
645
|
};
|
|
621
646
|
}
|
|
622
647
|
|
|
648
|
+
// Sezione «engine credentials»: per ogni engine che risolve una credenziale,
|
|
649
|
+
// DA DOVE viene. Prima non esisteva alcun controllo di questo tipo: l'unico
|
|
650
|
+
// blocco credenziali del doctor era quello dell'identita' (daemon/launcher), e
|
|
651
|
+
// la provenienza delle chiavi degli engine non era visibile da nessuna parte —
|
|
652
|
+
// una diagnosi sul campo e' arrivata a un confronto manuale di hash.
|
|
653
|
+
// Non si stampa MAI un valore: solo la sorgente, il path, l'mtime e l'impronta
|
|
654
|
+
// del valore troncata a 8. Un conflitto fra i file di chiavi su un engine che
|
|
655
|
+
// una cella USA e' un fallimento; su un engine che nessuna cella usa e' un
|
|
656
|
+
// avviso, perche' li' non c'e' ancora nulla che parta con la chiave sbagliata.
|
|
657
|
+
function checkEngineCredentials(home, fleetDefsPath, enabled = true) {
|
|
658
|
+
const name = 'engine credentials';
|
|
659
|
+
if (!enabled) return { name, ok: true, warn: true, detail: 'Fleet disabilitata' };
|
|
660
|
+
const target = fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
|
|
661
|
+
const defs = loadDefinitions(target);
|
|
662
|
+
if (!defs) return { name, ok: true, warn: true, detail: 'fleet.json non leggibile: provenienza non verificabile' };
|
|
663
|
+
const { describeManaged, extraModelsFrom } = require('../fleet/managed.js');
|
|
664
|
+
const inUso = new Set((defs.cells || []).map((c) => c && c.engine).filter(Boolean));
|
|
665
|
+
const extraModels = extraModelsFrom(defs);
|
|
666
|
+
const righe = [];
|
|
667
|
+
const conflitti = [];
|
|
668
|
+
for (const engine of defs.engines || []) {
|
|
669
|
+
if (!engine || !engine.managed) continue;
|
|
670
|
+
const info = describeManaged(engine.managed, { home, extraModels, engineId: engine.id });
|
|
671
|
+
const envKey = info.auth;
|
|
672
|
+
if (!envKey || envKey === 'login' || envKey === 'none') continue;
|
|
673
|
+
const dove = info.credentialPath
|
|
674
|
+
? `${info.credentialSource} (${info.credentialPath}, mtime ${new Date(info.credentialMtime).toISOString()}, sha256 ${info.credentialHash8})`
|
|
675
|
+
: info.credentialSource;
|
|
676
|
+
righe.push(`${engine.id}: ${envKey} from ${dove}`);
|
|
677
|
+
if (info.credentialConflict) conflitti.push({ engine: engine.id, inUso: inUso.has(engine.id), c: info.credentialConflict });
|
|
678
|
+
}
|
|
679
|
+
if (!righe.length) return { name, ok: true, detail: 'nessun engine con credenziale risolta' };
|
|
680
|
+
for (const { engine, c } of conflitti) {
|
|
681
|
+
const altri = c.others
|
|
682
|
+
.map((o) => `${o.path} (mtime ${new Date(o.mtime).toISOString()}, sha256 ${o.hash8})`)
|
|
683
|
+
.join(', ');
|
|
684
|
+
righe.push(`${engine}: ${c.envKey} e' definita anche in ${altri} con un valore DIVERSO (vince ${c.winner.path})`);
|
|
685
|
+
}
|
|
686
|
+
const grave = conflitti.some((k) => k.inUso);
|
|
687
|
+
const detail = righe.join(' · ');
|
|
688
|
+
if (grave) return { name, ok: false, detail };
|
|
689
|
+
if (conflitti.length) return { name, ok: true, warn: true, detail };
|
|
690
|
+
return { name, ok: true, detail };
|
|
691
|
+
}
|
|
692
|
+
|
|
623
693
|
// Read-only check: alternate-screen is applied per managed session, while the
|
|
624
694
|
// history limit remains an operator-owned tmux setting. Do not write ~/.tmux.conf
|
|
625
695
|
// or change a live server from doctor; only recommend a sufficient value.
|
|
@@ -682,6 +752,7 @@ function doctor(opts = {}) {
|
|
|
682
752
|
checkTmuxSurvival(platform, execImpl),
|
|
683
753
|
checkTokenPerms(tokenPath),
|
|
684
754
|
checkFleetDefinitions(home, opts.fleetDefsPath, fleetEnabled),
|
|
755
|
+
checkEngineCredentials(home, opts.fleetDefsPath, fleetEnabled),
|
|
685
756
|
checkAlternateScreenHistory(opts.alternateScreen !== undefined
|
|
686
757
|
? opts.alternateScreen : loadConfig().alternateScreen, execImpl, opts.tmuxBin),
|
|
687
758
|
checkTermuxExec(opts.env, { platform, home }),
|
|
@@ -715,4 +786,5 @@ module.exports = {
|
|
|
715
786
|
checkSshClient, checkAutossh, checkSshPermitlisten,
|
|
716
787
|
checkMcpIdentity, checkIdentityAuthorityMode, checkTermuxExec,
|
|
717
788
|
checkTmuxServerCwd, checkTmuxServerTermuxPreload,
|
|
789
|
+
checkEngineCredentials,
|
|
718
790
|
};
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -38,8 +38,10 @@ const {
|
|
|
38
38
|
const {
|
|
39
39
|
publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine,
|
|
40
40
|
CATALOG, credential: resolveProfileCredential, extraModelsFrom, knownMcpServerNames, profileFor,
|
|
41
|
+
normalizeManagedSpec,
|
|
41
42
|
} = require('./managed.js');
|
|
42
|
-
const { probeModel } = require('./model-probe.js');
|
|
43
|
+
const { probeModel, probeCustomEndpoint } = require('./model-probe.js');
|
|
44
|
+
const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
|
|
43
45
|
const { validEnvKey } = require('./env-key.js');
|
|
44
46
|
const { setCredential, removeCredential } = require('./credentials.js');
|
|
45
47
|
const { createLaunchBroker } = require('./launch-broker.js');
|
|
@@ -887,8 +889,44 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
887
889
|
return !!(profile && profile.id === wantedEngine);
|
|
888
890
|
})
|
|
889
891
|
.map((e) => e.id);
|
|
890
|
-
|
|
891
|
-
|
|
892
|
+
// E una CELLA puo' sovrascrivere il modello del proprio engine: l'engine non
|
|
893
|
+
// lo usa, la cella si'. Guardare solo gli engine lasciava passare la
|
|
894
|
+
// rimozione e lasciava una cella che punta a un modello che non c'e' piu':
|
|
895
|
+
// il danno si manifestava al primo avvio, davanti a chi non sa cosa e'
|
|
896
|
+
// cambiato. Le chiavi sono le STESSE due della risoluzione — l'id
|
|
897
|
+
// dell'engine e quello del suo profilo — piu' l'override singolo `cell.model`,
|
|
898
|
+
// che vale per l'engine della cella.
|
|
899
|
+
// Il profilo che conta e' quello dell'engine DICHIARANTE: la seconda chiave
|
|
900
|
+
// con cui una cella riferisce un engine e' l'id del suo profilo, non quello
|
|
901
|
+
// di un altro.
|
|
902
|
+
const wantedDef = findEngine(defs, wantedEngine);
|
|
903
|
+
const wantedProfile = wantedDef && wantedDef.managed
|
|
904
|
+
? profileFor(wantedDef.managed.client, wantedDef.managed.provider, wantedDef.managed.credentialProfile || '')
|
|
905
|
+
: null;
|
|
906
|
+
const wantedProfileId = wantedProfile && wantedProfile.id;
|
|
907
|
+
const celleUsano = (defs.cells || [])
|
|
908
|
+
.filter((c) => {
|
|
909
|
+
const perEngine = c.models || {};
|
|
910
|
+
if (perEngine[wantedEngine] === wantedId) return true;
|
|
911
|
+
if (wantedProfileId && perEngine[wantedProfileId] === wantedId) return true;
|
|
912
|
+
// `cell.model` vale per l'engine DELLA cella: e' un uso del modello
|
|
913
|
+
// chiesto solo se quella cella gira proprio sull'engine dichiarante.
|
|
914
|
+
if (c.model === wantedId) {
|
|
915
|
+
if (c.engine === wantedEngine) return true;
|
|
916
|
+
const cellEngine = findEngine(defs, c.engine);
|
|
917
|
+
const cellProfile = cellEngine && cellEngine.managed
|
|
918
|
+
? profileFor(cellEngine.managed.client, cellEngine.managed.provider, cellEngine.managed.credentialProfile || '')
|
|
919
|
+
: null;
|
|
920
|
+
return !!(cellProfile && cellProfile.id === wantedEngine);
|
|
921
|
+
}
|
|
922
|
+
return false;
|
|
923
|
+
})
|
|
924
|
+
.map((c) => c.id);
|
|
925
|
+
if (usato.length || celleUsano.length) {
|
|
926
|
+
const parti = [];
|
|
927
|
+
if (usato.length) parti.push(`modello in uso: ${usato.join(', ')}`);
|
|
928
|
+
if (celleUsano.length) parti.push(`celle: ${celleUsano.join(', ')}`);
|
|
929
|
+
throw httpError(409, parti.join('; '));
|
|
892
930
|
}
|
|
893
931
|
await mutate(defs, (d) => {
|
|
894
932
|
d.models = (d.models || []).filter((m) => !(m.id === wantedId && m.engine === wantedEngine));
|
|
@@ -1273,7 +1311,10 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
1273
1311
|
engines: defs.engines.map((e) => {
|
|
1274
1312
|
const out = { ...e, envKeys: Object.keys(e.env || {}).sort() };
|
|
1275
1313
|
delete out.env;
|
|
1276
|
-
if (e.managed) out.managedInfo = describeManaged(e.managed, {
|
|
1314
|
+
if (e.managed) out.managedInfo = describeManaged(e.managed, {
|
|
1315
|
+
...cfg, home, extraModels: extraModelsFrom(defs), engineId: e.id,
|
|
1316
|
+
endpointProbe: cfg.endpointProbe || defaultEndpointProbe,
|
|
1317
|
+
});
|
|
1277
1318
|
return out;
|
|
1278
1319
|
}),
|
|
1279
1320
|
cells: defs.cells.map((c) => {
|
|
@@ -1452,19 +1493,54 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
1452
1493
|
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test'];
|
|
1453
1494
|
}
|
|
1454
1495
|
|
|
1496
|
+
// Un engine del documento con un `baseUrl` dichiarato a mano: indirizzo e
|
|
1497
|
+
// credenziale risolti per la prova, e niente altro. Il valore della chiave NON
|
|
1498
|
+
// entra nel ritorno di testModel ne' in un log.
|
|
1499
|
+
function customEndpointFor(engineId) {
|
|
1500
|
+
const defsNow = (typeof reloadDefs === 'function' ? reloadDefs() : defs) || defs;
|
|
1501
|
+
const eng = findEngine(defsNow, engineId);
|
|
1502
|
+
if (!eng || !eng.managed || !eng.managed.baseUrl) return null;
|
|
1503
|
+
const spec = normalizeManagedSpec(eng.managed, {
|
|
1504
|
+
extraModels: extraModelsFrom(defsNow), engineId: eng.id,
|
|
1505
|
+
});
|
|
1506
|
+
if (!spec) return null;
|
|
1507
|
+
const prof = profileFor(spec.client, spec.provider, spec.credentialProfile || '');
|
|
1508
|
+
let credential = '';
|
|
1509
|
+
try {
|
|
1510
|
+
credential = (resolveProfileCredential(prof, {
|
|
1511
|
+
client: prof.client, provider: prof.provider,
|
|
1512
|
+
...(spec.credentialProfile ? { credentialProfile: spec.credentialProfile } : {}),
|
|
1513
|
+
}, cfg, home) || {}).value || '';
|
|
1514
|
+
} catch (_) { credential = ''; }
|
|
1515
|
+
return { endpoint: spec.baseUrl || '', credential };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1455
1518
|
// «questo modello funziona con questa credenziale?», chiesto all'API invece
|
|
1456
1519
|
// che scoperto quando la cella non parte. Vive qui e non nelle route perche'
|
|
1457
1520
|
// la credenziale si risolve dove gia' si risolve per l'avvio: la chiave non
|
|
1458
1521
|
// attraversa il router e non compare in nessuna risposta.
|
|
1459
1522
|
async function testModel(engineId, model, opts = {}) {
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
const e = new Error(`profilo sconosciuto "${engineId}"`); e.status = 404; throw e;
|
|
1463
|
-
}
|
|
1523
|
+
const id = String(engineId || '');
|
|
1524
|
+
const profile = CATALOG.find((p) => p.id === id);
|
|
1464
1525
|
const wanted = typeof model === 'string' ? model.trim() : '';
|
|
1465
1526
|
if (!wanted || wanted.length > 128 || /[\x00-\x1f\x7f]/.test(wanted)) {
|
|
1466
1527
|
const e = new Error('modello non valido'); e.status = 400; throw e;
|
|
1467
1528
|
}
|
|
1529
|
+
if (!profile) {
|
|
1530
|
+
// Un engine DICHIARATO con un endpoint proprio non sta nel catalogo
|
|
1531
|
+
// pubblico: rispondere 404 significava «non provabile» proprio dove la
|
|
1532
|
+
// prova serve di piu' — un router interno che nessuno sorveglia. Si prova
|
|
1533
|
+
// sul suo indirizzo, con le credenziali gia' risolte per quell'engine.
|
|
1534
|
+
const custom = customEndpointFor(id);
|
|
1535
|
+
if (!custom) {
|
|
1536
|
+
const e = new Error(`profilo sconosciuto "${engineId}"`); e.status = 404; throw e;
|
|
1537
|
+
}
|
|
1538
|
+
const out = await probeCustomEndpoint({
|
|
1539
|
+
endpoint: custom.endpoint, credential: custom.credential, model: wanted,
|
|
1540
|
+
fetchImpl: opts.fetchImpl,
|
|
1541
|
+
});
|
|
1542
|
+
return { engine: id, model: wanted, ...out };
|
|
1543
|
+
}
|
|
1468
1544
|
const spec = {
|
|
1469
1545
|
client: profile.client,
|
|
1470
1546
|
provider: profile.provider,
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/fleet/endpoint-probe.js — «l'endpoint risponde?», chiesto all'endpoint.
|
|
3
|
+
//
|
|
4
|
+
// Un engine managed con un `baseUrl` dichiarato a mano (un router llama.cpp, un
|
|
5
|
+
// server locale, un proxy) veniva dichiarato `ready` appena il BINARIO esisteva e
|
|
6
|
+
// la credenziale era a posto. Nessuno interrogava mai l'indirizzo: un router
|
|
7
|
+
// spento risultava pronto, e lo si scopriva facendo partire la cella. Qui la
|
|
8
|
+
// domanda si fa prima, con un GET sull'elenco dei modelli — l'unica chiamata che
|
|
9
|
+
// non consuma token e non genera testo.
|
|
10
|
+
//
|
|
11
|
+
// TRE DISCIPLINE, che valgono quanto la sonda:
|
|
12
|
+
// 1. NON BLOCCANTE OLTRE IL PROPRIO BUDGET. `refresh` e' l'unico punto che
|
|
13
|
+
// aspetta, e aspetta al massimo `timeoutMs`. Chi legge lo stato da un
|
|
14
|
+
// percorso sincrono usa `read`/`ensure`: ottiene il verdetto NOTO ADESSO e
|
|
15
|
+
// al massimo fa partire un rifornimento in sottofondo.
|
|
16
|
+
// 2. UNA PROBE OGNI `ttlMs` PER ENDPOINT. `nc_status` e la UI interrogano lo
|
|
17
|
+
// stato di continuo: senza cache si tradurrebbe in una tempesta di GET
|
|
18
|
+
// verso ogni router del parco.
|
|
19
|
+
// 3. GLI ENGINE DI CATALOGO NON SI SONDANO. La sonda si applica solo dove
|
|
20
|
+
// l'indirizzo e' dichiarato (provider pubblici: irraggiungibile da qui non
|
|
21
|
+
// significa rotto, e non e' compito di questo nodo misurarlo).
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 1500;
|
|
24
|
+
const DEFAULT_TTL_MS = 30000;
|
|
25
|
+
|
|
26
|
+
// L'elenco modelli sta sotto `/v1/models` per le API in stile OpenAI. Un
|
|
27
|
+
// `baseUrl` che dichiara gia' `/v1` non lo ripete: ripeterlo produce
|
|
28
|
+
// `/v1/v1/models`, che risponde 404 e verrebbe letto come «endpoint vivo».
|
|
29
|
+
function modelsProbeUrl(baseUrl) {
|
|
30
|
+
const b = String(baseUrl || '').trim().replace(/\/+$/, '');
|
|
31
|
+
if (!/^https?:\/\//i.test(b)) return null;
|
|
32
|
+
return /\/v1$/.test(b) ? `${b}/models` : `${b}/v1/models`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// `host:porta` per il messaggio: e' cio' che serve a chi legge per capire QUALE
|
|
36
|
+
// indirizzo non risponde, senza riversare l'URL intero nel verdetto.
|
|
37
|
+
function endpointHost(baseUrl) {
|
|
38
|
+
try {
|
|
39
|
+
const u = new URL(String(baseUrl));
|
|
40
|
+
const port = u.port || (u.protocol === 'https:' ? '443' : '80');
|
|
41
|
+
return `${u.hostname}:${port}`;
|
|
42
|
+
} catch (_) { return String(baseUrl || ''); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createEndpointProbe({
|
|
46
|
+
fetchImpl = fetch,
|
|
47
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
48
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
49
|
+
now = () => Date.now(),
|
|
50
|
+
setTimer = setTimeout,
|
|
51
|
+
clearTimer = clearTimeout,
|
|
52
|
+
log = () => {},
|
|
53
|
+
} = {}) {
|
|
54
|
+
const cache = new Map(); // baseUrl -> {state, reason, at}
|
|
55
|
+
const inFlight = new Map(); // baseUrl -> Promise
|
|
56
|
+
|
|
57
|
+
// Verdetto noto ADESSO, senza toccare la rete. `null` = mai sondato.
|
|
58
|
+
function read(baseUrl) {
|
|
59
|
+
if (!modelsProbeUrl(baseUrl)) return null;
|
|
60
|
+
const hit = cache.get(baseUrl);
|
|
61
|
+
if (!hit) return null;
|
|
62
|
+
return { state: hit.state, reason: hit.reason, at: hit.at, stale: (now() - hit.at) >= ttlMs };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function refresh(baseUrl) {
|
|
66
|
+
const url = modelsProbeUrl(baseUrl);
|
|
67
|
+
if (!url) return null;
|
|
68
|
+
if (inFlight.has(baseUrl)) return inFlight.get(baseUrl);
|
|
69
|
+
const run = (async () => {
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timer = setTimer(() => controller.abort(), timeoutMs);
|
|
72
|
+
// Un timer di sonda non tiene vivo il processo.
|
|
73
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
74
|
+
let verdict;
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetchImpl(url, { method: 'GET', signal: controller.signal });
|
|
77
|
+
const status = res && typeof res.status === 'number' ? res.status : 0;
|
|
78
|
+
if (status >= 200 && status < 300) {
|
|
79
|
+
verdict = { state: 'ready', reason: 'ready' };
|
|
80
|
+
} else if (status === 401 || status === 403) {
|
|
81
|
+
// L'endpoint ha risposto e sta chiedendo una credenziale: e' VIVO.
|
|
82
|
+
// Se la chiave sia quella giusta lo decide il client, non questa sonda.
|
|
83
|
+
verdict = { state: 'ready', reason: 'ready' };
|
|
84
|
+
} else if (status >= 500) {
|
|
85
|
+
verdict = { state: 'unreachable', reason: `endpoint unreachable: ${endpointHost(baseUrl)} (http ${status})` };
|
|
86
|
+
} else {
|
|
87
|
+
// Qualunque altra risposta (404 compreso) e' comunque una risposta:
|
|
88
|
+
// l'indirizzo c'e' ed e' in ascolto.
|
|
89
|
+
verdict = { state: 'ready', reason: 'ready' };
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR');
|
|
93
|
+
verdict = {
|
|
94
|
+
state: 'unreachable',
|
|
95
|
+
reason: `endpoint unreachable: ${endpointHost(baseUrl)} (${aborted ? `timeout (${timeoutMs}ms)` : 'connessione rifiutata'})`,
|
|
96
|
+
};
|
|
97
|
+
} finally {
|
|
98
|
+
try { clearTimer(timer); } catch (_) {}
|
|
99
|
+
}
|
|
100
|
+
cache.set(baseUrl, { ...verdict, at: now() });
|
|
101
|
+
try { log(`sonda endpoint ${endpointHost(baseUrl)}: ${verdict.state}`); } catch (_) {}
|
|
102
|
+
return verdict;
|
|
103
|
+
})();
|
|
104
|
+
inFlight.set(baseUrl, run);
|
|
105
|
+
try { return await run; } finally { inFlight.delete(baseUrl); }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Percorso SINCRONO: ritorna il verdetto noto (o `null`) e, se manca o e'
|
|
109
|
+
// scaduto, avvia il rifornimento senza attendere. Il primo giro dopo un
|
|
110
|
+
// avvio puo' quindi non sapere ancora; dal secondo in poi dice la verita'
|
|
111
|
+
// per tutta la durata del TTL.
|
|
112
|
+
function ensure(baseUrl) {
|
|
113
|
+
if (!modelsProbeUrl(baseUrl)) return null;
|
|
114
|
+
const hit = read(baseUrl);
|
|
115
|
+
if (!hit || hit.stale) { refresh(baseUrl).catch(() => {}); }
|
|
116
|
+
return hit ? { state: hit.state, reason: hit.reason } : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Percorso ASINCRONO: attende la sonda SOLO se il verdetto e' assente o
|
|
120
|
+
// scaduto. E' quello che usa lo status, dove un'attesa breve e' ammessa e
|
|
121
|
+
// un'informazione vecchia non serve a nessuno.
|
|
122
|
+
async function status(baseUrl) {
|
|
123
|
+
const hit = read(baseUrl);
|
|
124
|
+
if (hit && !hit.stale) return { state: hit.state, reason: hit.reason };
|
|
125
|
+
return refresh(baseUrl).catch(() => null);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
read, ensure, refresh, status,
|
|
130
|
+
size: () => cache.size,
|
|
131
|
+
clear: () => cache.clear(),
|
|
132
|
+
limits: { timeoutMs, ttlMs },
|
|
133
|
+
url: modelsProbeUrl,
|
|
134
|
+
host: endpointHost,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Istanza di processo: la cache deve sopravvivere alla singola chiamata, ed e'
|
|
139
|
+
// esattamente cio' che evita la tempesta di probe.
|
|
140
|
+
const sharedProbe = createEndpointProbe();
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
createEndpointProbe, sharedProbe, modelsProbeUrl, endpointHost,
|
|
144
|
+
ENDPOINT_PROBE_TIMEOUT_MS: DEFAULT_TIMEOUT_MS,
|
|
145
|
+
ENDPOINT_PROBE_TTL_MS: DEFAULT_TTL_MS,
|
|
146
|
+
};
|
package/lib/fleet/managed.js
CHANGED
|
@@ -433,8 +433,16 @@ const CREDENTIAL_SOURCES = Object.freeze(['environment', 'nexuscrew-store', 'aut
|
|
|
433
433
|
// altro test, qui si garantisce la COPERTURA dei valori che il backend produce.
|
|
434
434
|
// Chi aggiunge un valore in credential() aggiunge una riga qui e la stringa nei
|
|
435
435
|
// tre dizionari, oppure il gate lo ferma.
|
|
436
|
+
// Le origini sono DISTINTE perche' l'operatore deve poterle distinguere: prima
|
|
437
|
+
// `providers.zsh`, i due file di chiavi e il file legacy si presentavano tutti
|
|
438
|
+
// come `compatibility`, e chi guardava non poteva sapere chi avesse fornito la
|
|
439
|
+
// chiave — ne' se erano due a fornirla, con valori diversi. `compatibility`
|
|
440
|
+
// resta nell'elenco come alias di lettura: non e' piu' prodotto, ma un client
|
|
441
|
+
// vecchio puo' ancora riceverlo e deve avere la sua stringa.
|
|
436
442
|
const CREDENTIAL_SOURCE_VALUES = Object.freeze([
|
|
437
|
-
'login', 'none', 'environment', 'nexuscrew-store', 'local',
|
|
443
|
+
'login', 'none', 'environment', 'nexuscrew-store', 'local',
|
|
444
|
+
'providers-shell', 'keys-file', 'secure-file', 'legacy',
|
|
445
|
+
'compatibility', 'missing', 'unreadable',
|
|
438
446
|
]);
|
|
439
447
|
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', grok: 'Grok', vl: 'VL', pi: 'Pi', agy: 'Agy', kimi: 'Kimi Code CLI', shell: 'Shell' });
|
|
440
448
|
const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
@@ -858,7 +866,21 @@ function parseEnvFile(file, opts = {}, out) {
|
|
|
858
866
|
const st = fs.lstatSync(target);
|
|
859
867
|
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) || st.size > 256 * 1024) return {};
|
|
860
868
|
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
|
|
861
|
-
|
|
869
|
+
const values = parseAssignments(fs.readFileSync(target, 'utf8'));
|
|
870
|
+
// Provenienza richiesta dal chiamante (vedi parseProviderKeyFiles). Si
|
|
871
|
+
// registra per CHIAVE, con l'impronta e non il valore, e solo per i file che
|
|
872
|
+
// il chiamante ha dichiarato di voler tracciare: gli altri restano com'e-
|
|
873
|
+
// rano.
|
|
874
|
+
if (out && out.provenance instanceof Map) {
|
|
875
|
+
const mtime = Math.floor(st.mtimeMs);
|
|
876
|
+
const source = opts.provenanceSource || 'file';
|
|
877
|
+
for (const [key, value] of Object.entries(values)) {
|
|
878
|
+
const list = out.provenance.get(key) || [];
|
|
879
|
+
list.push({ source, path: file, mtime, hash8: hash8Of(value) });
|
|
880
|
+
out.provenance.set(key, list);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return values;
|
|
862
884
|
} catch (e) {
|
|
863
885
|
if (blocked && e.code !== 'ENOENT') blocked.push({ path: file, code: e.code || e.constructor.name });
|
|
864
886
|
return {};
|
|
@@ -871,7 +893,20 @@ function parseProviderShellFile(file, out) {
|
|
|
871
893
|
const st = fs.lstatSync(file);
|
|
872
894
|
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o022) || st.size > 256 * 1024) return {};
|
|
873
895
|
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
|
|
874
|
-
|
|
896
|
+
const values = parseAssignments(fs.readFileSync(file, 'utf8'));
|
|
897
|
+
// Stessa registrazione della provenienza dei file di chiavi: la shell sta
|
|
898
|
+
// PIU' IN ALTO nella precedenza, quindi una providers.zsh stantia produce
|
|
899
|
+
// lo stesso guasto dei file — a parti invertite — e va vista allo stesso
|
|
900
|
+
// modo. Il valore non entra qui: solo la sua impronta.
|
|
901
|
+
if (out && out.provenance instanceof Map) {
|
|
902
|
+
const mtime = Math.floor(st.mtimeMs);
|
|
903
|
+
for (const [key, value] of Object.entries(values)) {
|
|
904
|
+
const list = out.provenance.get(key) || [];
|
|
905
|
+
list.push({ source: 'providers-shell', path: file, mtime, hash8: hash8Of(value) });
|
|
906
|
+
out.provenance.set(key, list);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return values;
|
|
875
910
|
} catch (e) {
|
|
876
911
|
if (blocked && e.code !== 'ENOENT') blocked.push({ path: file, code: e.code || e.constructor.name });
|
|
877
912
|
return {};
|
|
@@ -1001,6 +1036,32 @@ function shellProvidersPath(cfg, home) {
|
|
|
1001
1036
|
|| path.join(home, '.config', 'ai-shell', 'providers.zsh');
|
|
1002
1037
|
}
|
|
1003
1038
|
|
|
1039
|
+
// L'impronta di un valore, MAI il valore. Serve a dire «due sorgenti hanno
|
|
1040
|
+
// questa variabile e il contenuto e' DIVERSO» senza dire cosa contiene: e' la
|
|
1041
|
+
// sola forma che rende visibile un conflitto restando sicura da mostrare, da
|
|
1042
|
+
// scrivere in un log e da mettere in una risposta API. Il valore entra in
|
|
1043
|
+
// questa funzione e non ne esce: qui si calcola, si prende l'impronta e si
|
|
1044
|
+
// scarta. Chi aggiungera' campi a questa struttura non aggiunga il valore.
|
|
1045
|
+
function hash8Of(text) {
|
|
1046
|
+
return crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 8);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// I conflitti del SOLO gruppo dei file di chiavi: per ogni variabile definita in
|
|
1050
|
+
// piu' di un file, l'ultimo vince (e' la precedenza dichiarata) ma se il
|
|
1051
|
+
// contenuto differisce l'operatore deve saperlo. Due file con la STESSA impronta
|
|
1052
|
+
// non sono un conflitto: e' la stessa chiave in due posti.
|
|
1053
|
+
function findCredentialConflicts(provenance) {
|
|
1054
|
+
const conflicts = [];
|
|
1055
|
+
if (!(provenance instanceof Map)) return conflicts;
|
|
1056
|
+
for (const [envKey, list] of provenance) {
|
|
1057
|
+
if (!Array.isArray(list) || list.length < 2) continue;
|
|
1058
|
+
const winner = list[list.length - 1];
|
|
1059
|
+
const others = list.slice(0, -1).filter((e) => e && e.hash8 !== winner.hash8);
|
|
1060
|
+
if (others.length) conflicts.push({ envKey, winner, others });
|
|
1061
|
+
}
|
|
1062
|
+
return conflicts;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1004
1065
|
function providerKeyPaths(cfg, home) {
|
|
1005
1066
|
const paths = [
|
|
1006
1067
|
cfg.providerKeysPath || process.env.NEXUSCREW_PROVIDER_KEYS
|
|
@@ -1018,7 +1079,14 @@ function parseProviderKeyFiles(cfg, home, out) {
|
|
|
1018
1079
|
// private regular files owned by the NexusCrew user.
|
|
1019
1080
|
const files = providerKeyPaths(cfg, home);
|
|
1020
1081
|
const roots = [...new Set(files.map((file) => path.dirname(path.resolve(file))))];
|
|
1021
|
-
|
|
1082
|
+
// La provenienza si traccia QUI, e solo per questi file: e' l'unico punto che
|
|
1083
|
+
// li vede in ordine e che sa quale ha fornito ogni chiave. L'etichetta segue
|
|
1084
|
+
// l'ORDINE: l'ultimo file e' il file di override, e con un solo path
|
|
1085
|
+
// configurato non c'e' nessun override da nominare.
|
|
1086
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
1087
|
+
const source = files.length > 1 && i === files.length - 1 ? 'secure-file' : 'keys-file';
|
|
1088
|
+
Object.assign(values, parseEnvFile(files[i], { allowSymlinkRoots: roots, provenanceSource: source }, out));
|
|
1089
|
+
}
|
|
1022
1090
|
return values;
|
|
1023
1091
|
}
|
|
1024
1092
|
|
|
@@ -1031,12 +1099,28 @@ function parseProviderKeyFiles(cfg, home, out) {
|
|
|
1031
1099
|
function credentialSources(cfg, home, out, { trackLegacy = true } = {}) {
|
|
1032
1100
|
let local = {};
|
|
1033
1101
|
try { local = readCredentialStore(cfg, home); } catch (_) { /* unsafe/corrupt store is ignored, never trusted */ }
|
|
1102
|
+
// UNA sola mappa di provenienza, riempita NELL'ORDINE DI PRECEDENZA: prima i
|
|
1103
|
+
// file di chiavi (il piu' debole, poi l'override), poi la shell, che li
|
|
1104
|
+
// scavalca. L'ultimo che ha scritto una variabile e' chi vince davvero, ed e'
|
|
1105
|
+
// quello che il conflitto deve nominare. Il `blocked` del chiamante resta
|
|
1106
|
+
// condiviso: l'illeggibilita' continua a essere tracciata come prima.
|
|
1107
|
+
const provenance = new Map();
|
|
1108
|
+
const bag = { blocked: out && Array.isArray(out.blocked) ? out.blocked : null, provenance };
|
|
1109
|
+
const keys = parseProviderKeyFiles(cfg, home, bag);
|
|
1110
|
+
const shell = parseProviderShellFile(shellProvidersPath(cfg, home), bag);
|
|
1111
|
+
const conflicts = findCredentialConflicts(provenance);
|
|
1112
|
+
if (out) { out.provenance = provenance; out.conflicts = conflicts; }
|
|
1034
1113
|
return {
|
|
1035
1114
|
runtime: cfg.env || process.env,
|
|
1036
1115
|
local,
|
|
1037
|
-
shell
|
|
1038
|
-
keys
|
|
1116
|
+
shell,
|
|
1117
|
+
keys,
|
|
1039
1118
|
legacy: parseEnvFile(secretsPath(cfg, home), {}, trackLegacy ? out : undefined),
|
|
1119
|
+
// Ordine REALE dei file di chiavi: l'ultimo vince. Serve a dire DA QUALE
|
|
1120
|
+
// file viene la chiave vincente, non solo da quale gruppo.
|
|
1121
|
+
keyPaths: providerKeyPaths(cfg, home),
|
|
1122
|
+
provenance,
|
|
1123
|
+
conflicts,
|
|
1040
1124
|
};
|
|
1041
1125
|
}
|
|
1042
1126
|
|
|
@@ -1059,10 +1143,33 @@ function credential(profile, spec, cfg, home, out) {
|
|
|
1059
1143
|
// auto: legacy resolution order (runtime -> store -> shell -> keys -> legacy).
|
|
1060
1144
|
if (sources.runtime[envKey]) return { envKey, value: sources.runtime[envKey], source: 'environment' };
|
|
1061
1145
|
if (sources.local[envKey]) return { envKey, value: sources.local[envKey], source: 'local' };
|
|
1062
|
-
if (sources.shell[envKey])
|
|
1063
|
-
|
|
1146
|
+
if (sources.shell[envKey]) {
|
|
1147
|
+
// Il file della shell: prima condivideva l'etichetta con i file di chiavi e
|
|
1148
|
+
// col legacy, quindi non si poteva sapere da dove venisse la chiave. E'
|
|
1149
|
+
// un FILE anche lui, quindi porta la sua provenienza come gli altri.
|
|
1150
|
+
const trailShell = sources.provenance && sources.provenance.get(envKey);
|
|
1151
|
+
const winShell = Array.isArray(trailShell) ? trailShell.find((e) => e.source === 'providers-shell') : null;
|
|
1152
|
+
return {
|
|
1153
|
+
envKey, value: sources.shell[envKey], source: 'providers-shell',
|
|
1154
|
+
...(winShell ? { path: winShell.path, mtime: winShell.mtime, hash8: winShell.hash8 } : {}),
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
if (sources.keys[envKey]) {
|
|
1158
|
+
// Quale DEI DUE file ha fornito la chiave: l'ultimo dell'ordine vince, e se
|
|
1159
|
+
// e' il file di override la provenienza lo dice. Con l'impronta e l'mtime,
|
|
1160
|
+
// mai col valore.
|
|
1161
|
+
const trail = sources.provenance && sources.provenance.get(envKey);
|
|
1162
|
+
const win = Array.isArray(trail) && trail.length ? trail[trail.length - 1] : null;
|
|
1163
|
+
const paths = Array.isArray(sources.keyPaths) ? sources.keyPaths : [];
|
|
1164
|
+
const secure = !!(win && paths.length > 1 && win.path === paths[paths.length - 1]);
|
|
1165
|
+
return {
|
|
1166
|
+
envKey, value: sources.keys[envKey],
|
|
1167
|
+
source: secure ? 'secure-file' : 'keys-file',
|
|
1168
|
+
...(win ? { path: win.path, mtime: win.mtime, hash8: win.hash8 } : {}),
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1064
1171
|
if (profile.legacySecrets && sources.legacy[envKey]) {
|
|
1065
|
-
return { envKey, value: sources.legacy[envKey], source: '
|
|
1172
|
+
return { envKey, value: sources.legacy[envKey], source: 'legacy' };
|
|
1066
1173
|
}
|
|
1067
1174
|
// auto: nessuna fonte ha la chiave. Se un file credenziale esiste ma non si
|
|
1068
1175
|
// e' potuto leggere (EACCES/ELOOP...), non possiamo dichiarare la chiave
|
|
@@ -1221,7 +1328,10 @@ function describeManaged(spec, cfg = {}) {
|
|
|
1221
1328
|
const binary = normalized.client === 'shell'
|
|
1222
1329
|
? resolveInteractiveShell({ ...cfg, home }, { blocked: binaryBlocked })
|
|
1223
1330
|
: findBinary(normalized.client, home, { blocked: binaryBlocked });
|
|
1224
|
-
|
|
1331
|
+
// La scatola e' tenuta: dopo la chiamata porta anche i CONFLITTI fra file di
|
|
1332
|
+
// chiavi, calcolati dove i file si leggono in ordine.
|
|
1333
|
+
const credOut = { blocked: credBlocked };
|
|
1334
|
+
const cred = credential(profile, normalized, cfg, home, credOut);
|
|
1225
1335
|
// Pi can resolve credentials from its own documented /login auth store. Do
|
|
1226
1336
|
// not inspect or copy that store; delegate native-provider auth to Pi.
|
|
1227
1337
|
const delegatedPiAuth = profile.client === 'pi' && profile.provider !== 'custom'
|
|
@@ -1256,6 +1366,38 @@ function describeManaged(spec, cfg = {}) {
|
|
|
1256
1366
|
reason = `${normalized.client} non supportato su questa piattaforma (usa shell.local con command ${normalized.client})`;
|
|
1257
1367
|
}
|
|
1258
1368
|
}
|
|
1369
|
+
// Endpoint dichiarato A MANO: l'esistenza del binario e della credenziale non
|
|
1370
|
+
// dice se l'indirizzo risponde. Un router spento risultava `ready` e lo si
|
|
1371
|
+
// scopriva facendo partire la cella. Il verdetto arriva dal chiamante quando
|
|
1372
|
+
// ha potuto attendere la sonda (`cfg.endpointVerdict`), oppure dalla cache
|
|
1373
|
+
// (lettura sincrona, mai bloccante). Gli engine di catalogo non si sondano:
|
|
1374
|
+
// li' l'indirizzo non e' una scelta di questo nodo.
|
|
1375
|
+
if (configured && normalized.baseUrl) {
|
|
1376
|
+
let verdict = cfg.endpointVerdict && typeof cfg.endpointVerdict === 'object' ? cfg.endpointVerdict : null;
|
|
1377
|
+
if (!verdict && cfg.endpointProbe && typeof cfg.endpointProbe.ensure === 'function') {
|
|
1378
|
+
try { verdict = cfg.endpointProbe.ensure(normalized.baseUrl); } catch (_) { verdict = null; }
|
|
1379
|
+
}
|
|
1380
|
+
if (verdict && verdict.state === 'unreachable') {
|
|
1381
|
+
configured = false;
|
|
1382
|
+
reason = verdict.reason || `endpoint unreachable: ${normalized.baseUrl}`;
|
|
1383
|
+
} else if (verdict && verdict.state === 'ready') {
|
|
1384
|
+
reason = 'ready';
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
// Conflitto fra i file di chiavi: l'engine RESTA configurato — una chiave
|
|
1388
|
+
// valida c'e' e il client puo' partire — ma chi guarda deve sapere che la
|
|
1389
|
+
// stessa variabile e' definita altrove con un contenuto diverso. E' il caso
|
|
1390
|
+
// che sul campo si e' presentato come «la cella usa una chiave revocata»:
|
|
1391
|
+
// senza questo, l'unico modo di accorgersene era confrontare gli hash a mano.
|
|
1392
|
+
// Le impronte, mai i valori.
|
|
1393
|
+
if (configured) {
|
|
1394
|
+
const conflitti = Array.isArray(credOut.conflicts) ? credOut.conflicts : [];
|
|
1395
|
+
const mio = conflitti.find((c) => c && c.envKey === cred.envKey);
|
|
1396
|
+
if (mio && mio.others && mio.others.length) {
|
|
1397
|
+
const altri = mio.others.map((o) => o.path).join(', ');
|
|
1398
|
+
reason = `${reason === 'ready' ? 'ready ' : reason + ' '}(credential ${cred.envKey} also defined in ${altri} with a different value)`;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1259
1401
|
return {
|
|
1260
1402
|
client: profile.client, clientLabel: CLIENT_LABELS[profile.client], provider: profile.provider,
|
|
1261
1403
|
credentialProfile: normalized.credentialProfile || '', model: normalized.model,
|
|
@@ -1267,6 +1409,18 @@ function describeManaged(spec, cfg = {}) {
|
|
|
1267
1409
|
// (source 'unreadable', authConfigured false) non viene collassata in
|
|
1268
1410
|
// 'missing' — il discriminante e' CHI ha fallito, non il esito grezzo.
|
|
1269
1411
|
credentialSource: cred.source,
|
|
1412
|
+
// Provenienza della credenziale risolta: path, mtime e impronta del valore
|
|
1413
|
+
// (MAI il valore). Presenti solo quando la sorgente e' un file.
|
|
1414
|
+
credentialPath: cred.path || '',
|
|
1415
|
+
credentialMtime: cred.mtime || 0,
|
|
1416
|
+
credentialHash8: cred.hash8 || '',
|
|
1417
|
+
// Conflitto fra i file di chiavi: la stessa variabile definita in piu' file
|
|
1418
|
+
// con contenuto diverso. `null` quando non c'e' — o quando i file dicono la
|
|
1419
|
+
// stessa cosa, che non e' un conflitto.
|
|
1420
|
+
credentialConflict: (() => {
|
|
1421
|
+
const list = Array.isArray(credOut.conflicts) ? credOut.conflicts : [];
|
|
1422
|
+
return list.find((c) => c && c.envKey === cred.envKey) || null;
|
|
1423
|
+
})(),
|
|
1270
1424
|
configured,
|
|
1271
1425
|
// Catalogo builtin del profilo PIU' i modelli dichiarati in
|
|
1272
1426
|
// `definitions.models[]` per questo engine (o, legacy, per il profilo).
|