@mmmbuto/nexuscrew 0.9.32 → 0.9.34
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 +13 -0
- package/frontend/dist/assets/{index-CH2iAu6y.js → index-5eCvm3HF.js} +1 -1
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/builtin.js +107 -3
- package/lib/fleet/causes.js +3 -0
- package/lib/fleet/cell-exec.js +12 -12
- package/lib/fleet/cell-lease-server.js +4 -4
- package/lib/fleet/definitions.js +109 -6
- package/lib/fleet/managed.js +257 -3
- package/lib/fleet/routes.js +5 -0
- package/lib/fleet/runtime.js +7 -1
- package/lib/mcp/cells.js +21 -1
- package/lib/proxy/federation.js +2 -2
- package/lib/proxy/resource-acl.js +1 -1
- package/package.json +1 -1
package/lib/fleet/managed.js
CHANGED
|
@@ -1612,6 +1612,188 @@ function cellMcpArgs(home, cell) {
|
|
|
1612
1612
|
return [`--settings=${JSON.stringify({ permissions: { deny } })}`];
|
|
1613
1613
|
}
|
|
1614
1614
|
|
|
1615
|
+
// Definizioni MCP note, per NOME -> definizione (la prima sorgente che dichiara
|
|
1616
|
+
// il nome vince). Le SORGENTI sono le stesse dei nomi in `knownMcpServerNames`
|
|
1617
|
+
// (config utente, suo ramo per-progetto, .mcp.json della cwd), precedute da
|
|
1618
|
+
// eventuali file extra — per i profili Claude isolati la propria config, che
|
|
1619
|
+
// altrimenti sparirebbe dall'enumerazione. Solo LETTURA: le definizioni non
|
|
1620
|
+
// vengono mai modificate, riprese e ridistribuite tali quali.
|
|
1621
|
+
function collectMcpServerDefs(home, cwd, extraFiles = []) {
|
|
1622
|
+
const leggi = (file) => {
|
|
1623
|
+
try {
|
|
1624
|
+
const st = fs.lstatSync(file);
|
|
1625
|
+
if (st.isSymbolicLink() || !st.isFile() || st.size > 8 * 1024 * 1024) return null;
|
|
1626
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1627
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
1628
|
+
} catch (_) { return null; /* assente o illeggibile non e' un errore qui */ }
|
|
1629
|
+
};
|
|
1630
|
+
const defs = new Map();
|
|
1631
|
+
const raccogli = (servers) => {
|
|
1632
|
+
if (servers && typeof servers === 'object' && !Array.isArray(servers)) {
|
|
1633
|
+
for (const [nome, def] of Object.entries(servers)) {
|
|
1634
|
+
if (def && typeof def === 'object' && !Array.isArray(def) && !defs.has(nome)) defs.set(nome, def);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
for (const file of extraFiles) {
|
|
1639
|
+
const parsed = typeof file === 'string' ? leggi(file) : null;
|
|
1640
|
+
if (parsed) raccogli(parsed.mcpServers);
|
|
1641
|
+
}
|
|
1642
|
+
const utente = leggi(path.join(home, '.claude.json'));
|
|
1643
|
+
if (utente) {
|
|
1644
|
+
raccogli(utente.mcpServers);
|
|
1645
|
+
const progetti = utente.projects;
|
|
1646
|
+
if (typeof cwd === 'string' && cwd && progetti && typeof progetti === 'object' && !Array.isArray(progetti)) {
|
|
1647
|
+
const voce = progetti[cwd];
|
|
1648
|
+
if (voce && typeof voce === 'object' && !Array.isArray(voce)) raccogli(voce.mcpServers);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
if (typeof cwd === 'string' && cwd) {
|
|
1652
|
+
const progetto = leggi(path.join(cwd, '.mcp.json'));
|
|
1653
|
+
if (progetto) raccogli(progetto.mcpServers);
|
|
1654
|
+
}
|
|
1655
|
+
return defs;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// Il file MCP PER CELLA del materializzatore: contiene le definizioni dei SOLO
|
|
1659
|
+
// server concessi, copiate tali quali dalle sorgenti (che portano `env` con
|
|
1660
|
+
// credenziali per alcuni server). Duplicare quelle definizioni e' il prezzo
|
|
1661
|
+
// del sottoinsieme esatto — il file per profilo unico non puo' esprimere un
|
|
1662
|
+
// sottoinsieme per cella senza un allowlist del client, e l'allowlist esclusiva
|
|
1663
|
+
// vuole proprio un file. Il prezzo si paga nel regime di permessi piu' stretto
|
|
1664
|
+
// che esiste fuori dallo store credenziali: directory 0700, file 0600, tutto
|
|
1665
|
+
// sotto ~/.nexuscrew (la stessa classe di privacy di providers.env), scritto
|
|
1666
|
+
// atomicamente e RIGENERATO a ogni up (nessun valore accumulato, nessuna
|
|
1667
|
+
// copia stantia), mai loggato: il contenuto non tocca nessun log né output.
|
|
1668
|
+
// DICHARIAZIONE DI PROVENIENZA: questo file e' DERIVATO — la fonte di verita'
|
|
1669
|
+
// restano le sorgenti originali, e la provenienza delle credenziali (doctor)
|
|
1670
|
+
// non lo elenca e non lo considerera' mai una sorgente di conflitto.
|
|
1671
|
+
function writeCellMcpConfig(cellId, concessi, home, defs) {
|
|
1672
|
+
const dir = path.join(home, '.nexuscrew', 'cell-mcp');
|
|
1673
|
+
try {
|
|
1674
|
+
const st = fs.lstatSync(dir);
|
|
1675
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw new Error('unsafe per-cell MCP directory');
|
|
1676
|
+
// Strettisce, mai allarga: permessi piu' larghi di 0700 tornano a 0700; una
|
|
1677
|
+
// directory resa NON scrivibile (operatore, manutenzione) resta tale — e
|
|
1678
|
+
// il fallimento di scrittura e' il rifiuto del launch, non un launch con
|
|
1679
|
+
// la superficie sbagliata.
|
|
1680
|
+
if (st.mode & 0o077) fs.chmodSync(dir, 0o700);
|
|
1681
|
+
} catch (e) {
|
|
1682
|
+
if (e.code === 'ENOENT') fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1683
|
+
else throw e;
|
|
1684
|
+
}
|
|
1685
|
+
const servers = {};
|
|
1686
|
+
// Il canale operatore (nexuscrew) e' CORE: entra nel file anche se la cella
|
|
1687
|
+
// non lo ha nominato — senza, la cella dichiarata perderebbe `nc_*`.
|
|
1688
|
+
const nomi = new Set(concessi);
|
|
1689
|
+
if (defs.has('nexuscrew')) nomi.add('nexuscrew');
|
|
1690
|
+
for (const nome of [...nomi].sort()) {
|
|
1691
|
+
const def = defs.get(nome);
|
|
1692
|
+
if (!def) continue; // difesa residua: il preflight ora rifiuta i nomi dichiarati sconosciuti prima di arrivare qui
|
|
1693
|
+
servers[nome] = def;
|
|
1694
|
+
}
|
|
1695
|
+
const target = path.join(dir, `${cellId}.json`);
|
|
1696
|
+
const tmp = path.join(dir, `.${cellId}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
1697
|
+
try {
|
|
1698
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`, { mode: 0o600 });
|
|
1699
|
+
fs.chmodSync(tmp, 0o600);
|
|
1700
|
+
fs.renameSync(tmp, target);
|
|
1701
|
+
} catch (e) {
|
|
1702
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
1703
|
+
throw e;
|
|
1704
|
+
}
|
|
1705
|
+
return target;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// La superficie codex NOTA alla config utente, letta con un parse TOML
|
|
1709
|
+
// minimale e DELIBERATAMENTE superficie: servono solo i NOMI dei server
|
|
1710
|
+
// (`[mcp_servers.<nome>]`) e delle skill (`[[skills.config]]` con name) da
|
|
1711
|
+
// poter spegnere nel profilo per cella. La config utente non viene mai
|
|
1712
|
+
// toccata: il profilo e' un LAYER di riduzione che la legge e basta.
|
|
1713
|
+
function codexSurfaceNames(configToml) {
|
|
1714
|
+
const testo = typeof configToml === 'string' ? configToml : '';
|
|
1715
|
+
const servers = [...testo.matchAll(/^[ \t]*\[mcp_servers\.([A-Za-z0-9_-]+)\][ \t]*$/gm)].map((m) => m[1]);
|
|
1716
|
+
const skills = [...testo.matchAll(/\[\[skills\.config\]\]([^[]*)/g)]
|
|
1717
|
+
.map((m) => (m[1].match(/^[ \t]*name[ \t]*=[ \t]*"([^"]+)"/m) || [])[1])
|
|
1718
|
+
.filter(Boolean);
|
|
1719
|
+
return { servers: [...new Set(servers)], skills: [...new Set(skills)] };
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Il profilo per cella di codex/codex-vl (profilo v2: <CODEX_HOME>/<nome>
|
|
1723
|
+
// .config.toml, lanciato con -p). STESSA SEMANTICA DEL CLAUDE, tradotta nei
|
|
1724
|
+
// meccanismi nativi del client: la chiave `mcp` spegne i server NON concessi
|
|
1725
|
+
// (enabled = false — i concessi non si ridichiarano, ereditano dal layer
|
|
1726
|
+
// base); `ondemand` mette omit_tools_from = ["direct"] (scopribili via tool
|
|
1727
|
+
// search, non attivi); `skills` spegne le skill NON concesse enumerate nella
|
|
1728
|
+
// config. Chiave assente = nessuna riduzione per quella chiave. RESIDUO
|
|
1729
|
+
// DICHIARATO: server/skill definiti fuori dalla config utente (config di
|
|
1730
|
+
// progetto, config gestita) non sono enumerabili da qui e restano — come per
|
|
1731
|
+
// il claude legacy, chi vuole il caso esatto dichiara e verifica. Il file e'
|
|
1732
|
+
// atomico, 0600, rigenerato a ogni up, mai loggato; la config utente non e'
|
|
1733
|
+
// mai modificata.
|
|
1734
|
+
// La config utente di codex letta con gli stessi criteri del materializzatore:
|
|
1735
|
+
// mai modificata, mai loggata; assente/symlink/oversize = catalogo vuoto.
|
|
1736
|
+
function readCodexUserToml(codexHome) {
|
|
1737
|
+
try {
|
|
1738
|
+
const st = fs.lstatSync(path.join(codexHome, 'config.toml'));
|
|
1739
|
+
if (!st.isSymbolicLink() && st.isFile() && st.size <= 8 * 1024 * 1024) {
|
|
1740
|
+
return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
1741
|
+
}
|
|
1742
|
+
} catch (_) { /* config assente: niente da enumerare, il profilo e' vuoto */ }
|
|
1743
|
+
return '';
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function writeCellCodexProfile(cellId, declared, codexHome) {
|
|
1747
|
+
const nome = `nexuscrew-${String(cellId).replace(/\./g, '_')}`;
|
|
1748
|
+
const target = path.join(codexHome, `${nome}.config.toml`);
|
|
1749
|
+
const configToml = readCodexUserToml(codexHome);
|
|
1750
|
+
const { servers, skills } = codexSurfaceNames(configToml);
|
|
1751
|
+
const mcpConcessi = new Set(declared.mcp || []);
|
|
1752
|
+
const ondemand = new Set(declared.ondemand || []);
|
|
1753
|
+
const skillConcesse = new Set(declared.skills || []);
|
|
1754
|
+
const righe = [
|
|
1755
|
+
'# Generated by NexusCrew for this cell: reduces (never widens) the surface',
|
|
1756
|
+
'# declared in config.toml, which NexusCrew never modifies.',
|
|
1757
|
+
];
|
|
1758
|
+
if (declared.mcp !== undefined) {
|
|
1759
|
+
for (const s of servers.filter((n) => !mcpConcessi.has(n)).sort()) {
|
|
1760
|
+
righe.push('', `[mcp_servers.${s}]`, 'enabled = false');
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
// `omit_tools_from` NON e' una chiave di root: in codex e' un campo della
|
|
1764
|
+
// tabella del SINGOLO server (codex-rs/config/src/mcp_types.rs:223/:364) —
|
|
1765
|
+
// a root viene ignorato in silenzio e l'on-demand non funzionerebbe mai.
|
|
1766
|
+
// Un server on-demand e' CONCESSO ma differito: enabled resta il default e
|
|
1767
|
+
// si scrive solo la chiave di differimento; uno spento (non concesso con
|
|
1768
|
+
// `mcp` dichiarata) non prende una seconda tabella: TOML vieta i duplicati
|
|
1769
|
+
// e enabled = false resta il volere piu' restrittivo. Nomi ondemand non
|
|
1770
|
+
// presenti nella config utente non generano tabelle (ignorati; la vista li
|
|
1771
|
+
// segnala).
|
|
1772
|
+
if (ondemand.size) {
|
|
1773
|
+
const spenti = declared.mcp !== undefined
|
|
1774
|
+
? new Set(servers.filter((n) => !mcpConcessi.has(n)))
|
|
1775
|
+
: new Set();
|
|
1776
|
+
for (const s of servers.filter((n) => ondemand.has(n) && !spenti.has(n)).sort()) {
|
|
1777
|
+
righe.push('', `[mcp_servers.${s}]`, 'omit_tools_from = ["direct"]');
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
if (declared.skills !== undefined) {
|
|
1781
|
+
for (const s of skills.filter((n) => !skillConcesse.has(n)).sort()) {
|
|
1782
|
+
righe.push('', '[[skills.config]]', `name = "${s}"`, 'enabled = false');
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
const tmp = path.join(codexHome, `.${nome}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
1786
|
+
try {
|
|
1787
|
+
fs.writeFileSync(tmp, `${righe.join('\n')}\n`, { mode: 0o600 });
|
|
1788
|
+
fs.chmodSync(tmp, 0o600);
|
|
1789
|
+
fs.renameSync(tmp, target);
|
|
1790
|
+
} catch (e) {
|
|
1791
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
1792
|
+
throw e;
|
|
1793
|
+
}
|
|
1794
|
+
return target;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1615
1797
|
function ensurePrivateClaudeConfig(home, profileId, label, penguinMode) {
|
|
1616
1798
|
const nexusDir = path.join(home, '.nexuscrew');
|
|
1617
1799
|
const profilesDir = path.join(nexusDir, 'claude-profiles');
|
|
@@ -2082,9 +2264,43 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
2082
2264
|
// In coda di proposito: `--mcp-config` e' variadico e consuma ogni token
|
|
2083
2265
|
// che non inizi per `-`. Messo qui non ha nulla da inghiottire.
|
|
2084
2266
|
if (privateProfile) args.push(...sharedMcpArgs(home));
|
|
2085
|
-
//
|
|
2086
|
-
// agisce sui nomi dei tool
|
|
2087
|
-
|
|
2267
|
+
// Tre strade. LEGACY (niente capabilities, niente profilo): il filtro per
|
|
2268
|
+
// NEGAZIONE di oggi, che agisce sui nomi dei tool e lascia le sorgenti
|
|
2269
|
+
// dove sono. CELLA DICHIARATA con `mcp` (anche vuoto): allowlist ESCLUSIVA
|
|
2270
|
+
// — un file per cella con i soli server concessi (+ il canale operatore,
|
|
2271
|
+
// sempre) e `--strict-mcp-config`, che esclude anche cio' che il deny non
|
|
2272
|
+
// sa enumerare (plugin, connettori). CHIAVE `mcp` ASSENTE ma cella
|
|
2273
|
+
// dichiarata (solo skills/ondemand): comportamento di sempre per QUESTA
|
|
2274
|
+
// chiave — l'alias `cell.mcp` resta il filtro per negazione, in linea con
|
|
2275
|
+
// l'override sostitutivo per chiave del modello.
|
|
2276
|
+
// FAIL-CLOSED: se il file per cella non si scrive la cella NON parte.
|
|
2277
|
+
// Partire senza la allowlist esporrebbe l'intera superficie proprio dove
|
|
2278
|
+
// l'operatore l'ha ristretta: peggio della legacy, e senza che nessuno
|
|
2279
|
+
// l'abbia chiesto.
|
|
2280
|
+
const { effectiveCapabilities } = require('./definitions.js');
|
|
2281
|
+
const eff = effectiveCapabilities(cell || {}, { capabilityProfiles: cfg.capabilityProfiles || null });
|
|
2282
|
+
if (eff.legacy || eff.declared.mcp === undefined) {
|
|
2283
|
+
args.push(...cellMcpArgs(home, cell));
|
|
2284
|
+
} else {
|
|
2285
|
+
let file;
|
|
2286
|
+
try {
|
|
2287
|
+
const extraFiles = typeof env.CLAUDE_CONFIG_DIR === 'string'
|
|
2288
|
+
? [path.join(env.CLAUDE_CONFIG_DIR, '.claude.json')] : [];
|
|
2289
|
+
const defs = collectMcpServerDefs(home, cell?.cwd, extraFiles);
|
|
2290
|
+
// Un nome dichiarato che il client non definisce sparisce in silenzio
|
|
2291
|
+
// dal file strict: rifiuta qui, prima di scrivere qualsiasi cosa.
|
|
2292
|
+
const sconosciute = eff.declared.mcp.filter((n) => !defs.has(n));
|
|
2293
|
+
if (sconosciute.length) {
|
|
2294
|
+
const motivo = `capability mcp sconosciuta al client: ${sconosciute.join(', ')}`;
|
|
2295
|
+
return { ok: false, reason: motivo, mcpCellRefused: motivo, mcpCellRefusedCode: 'CAPABILITY_UNKNOWN_NAME' };
|
|
2296
|
+
}
|
|
2297
|
+
file = writeCellMcpConfig(String(cell?.id || 'cella'), eff.declared.mcp, home, defs);
|
|
2298
|
+
} catch (e) {
|
|
2299
|
+
const motivo = `file MCP per cella non scrivibile: ${e.message}`;
|
|
2300
|
+
return { ok: false, reason: motivo, mcpCellRefused: motivo };
|
|
2301
|
+
}
|
|
2302
|
+
args.push('--strict-mcp-config', `--mcp-config=${file}`);
|
|
2303
|
+
}
|
|
2088
2304
|
} else if (spec.client === 'codex' || spec.client === 'codex-vl') {
|
|
2089
2305
|
if (profile.localProvider) args.push('--oss', '--local-provider', profile.localProvider);
|
|
2090
2306
|
else if (spec.provider === 'openai-api') env.OPENAI_API_KEY = cred.value;
|
|
@@ -2180,6 +2396,43 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
2180
2396
|
}
|
|
2181
2397
|
}
|
|
2182
2398
|
if (model) args.push('-m', model);
|
|
2399
|
+
// Materializzatore per cella (stesse regole del ramo claude, meccanismi
|
|
2400
|
+
// nativi del client): profilo v2 sotto CODEX_HOME lanciato con -p. Il
|
|
2401
|
+
// profilo RIDUCE la superficie della config utente (mai toccata);
|
|
2402
|
+
// FAIL-CLOSED come il claude: profilo non scrivibile = launch rifiutato,
|
|
2403
|
+
// mai una cella con la superficie intera dove l'operatore ha ristretto.
|
|
2404
|
+
{
|
|
2405
|
+
const { effectiveCapabilities } = require('./definitions.js');
|
|
2406
|
+
const eff = effectiveCapabilities(cell || {}, { capabilityProfiles: cfg.capabilityProfiles || null });
|
|
2407
|
+
const daMaterializzare = eff.declared.mcp !== undefined || eff.declared.skills !== undefined || eff.declared.ondemand !== undefined;
|
|
2408
|
+
if (!eff.legacy && daMaterializzare) {
|
|
2409
|
+
// Stessa regola del ramo claude: un nome dichiarato che la config
|
|
2410
|
+
// utente non definisce sparisce in silenzio dal profilo — rifiuta
|
|
2411
|
+
// qui, prima di scrivere qualsiasi cosa. Catalogo vuoto (config
|
|
2412
|
+
// assente/vuota) + nomi dichiarati = nomi sconosciuti; `mcp: []`
|
|
2413
|
+
// resta valido (niente nomi da confrontare).
|
|
2414
|
+
const { servers, skills } = codexSurfaceNames(readCodexUserToml(path.join(home, '.codex')));
|
|
2415
|
+
const parti = [];
|
|
2416
|
+
const sconosciuteMcp = (eff.declared.mcp || []).filter((n) => !servers.includes(n));
|
|
2417
|
+
const sconosciuteOndemand = (eff.declared.ondemand || []).filter((n) => !servers.includes(n));
|
|
2418
|
+
const sconosciuteSkills = (eff.declared.skills || []).filter((n) => !skills.includes(n));
|
|
2419
|
+
if (sconosciuteMcp.length) parti.push(`capability mcp sconosciuta al client: ${sconosciuteMcp.join(', ')}`);
|
|
2420
|
+
if (sconosciuteOndemand.length) parti.push(`capability ondemand sconosciuta al client: ${sconosciuteOndemand.join(', ')}`);
|
|
2421
|
+
if (sconosciuteSkills.length) parti.push(`capability skills sconosciuta al client: ${sconosciuteSkills.join(', ')}`);
|
|
2422
|
+
if (parti.length) {
|
|
2423
|
+
const motivo = parti.join('; ');
|
|
2424
|
+
return { ok: false, reason: motivo, mcpCellRefused: motivo, mcpCellRefusedCode: 'CAPABILITY_UNKNOWN_NAME' };
|
|
2425
|
+
}
|
|
2426
|
+
let file;
|
|
2427
|
+
try {
|
|
2428
|
+
file = writeCellCodexProfile(String(cell?.id || 'cella'), eff.declared, path.join(home, '.codex'));
|
|
2429
|
+
} catch (e) {
|
|
2430
|
+
const motivo = `profilo per cella non scrivibile: ${e.message}`;
|
|
2431
|
+
return { ok: false, reason: motivo, mcpCellRefused: motivo, mcpCellRefusedCode: 'CAPABILITY_PROFILE_UNWRITABLE' };
|
|
2432
|
+
}
|
|
2433
|
+
args.push('-p', path.basename(file).replace(/\.config\.toml$/, ''));
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2183
2436
|
} else if (spec.client === 'pi') {
|
|
2184
2437
|
if (profile.auth !== 'none' && profile.auth !== 'login' && cred.value) env[cred.envKey] = cred.value;
|
|
2185
2438
|
if (spec.provider === 'custom') args.push('--extension', writePiProviderExtension(spec, home, declaredModels));
|
|
@@ -2434,6 +2687,7 @@ module.exports = {
|
|
|
2434
2687
|
CLIENT_LABELS, normalizeManagedSpec, profileFor,
|
|
2435
2688
|
defaultDefinitions, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
2436
2689
|
discoverPiModels, EXTERNAL_DISCOVERY_TIMEOUT_MS, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension, customCatalogFor,
|
|
2690
|
+
codexSurfaceNames, writeCellCodexProfile,
|
|
2437
2691
|
writeVlCellPrompt, vlSystemAppendGate,
|
|
2438
2692
|
extraModelsFrom, declaredModelsFor, declaredFor,
|
|
2439
2693
|
providerKeyPaths, parseProviderKeyFiles, credentialSources, credential, CREDENTIAL_SOURCE_VALUES,
|
package/lib/fleet/routes.js
CHANGED
|
@@ -188,6 +188,11 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
188
188
|
if (typeof f.removeModel !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
|
|
189
189
|
return f.removeModel(String(b.id || ''), String(b.engine || ''));
|
|
190
190
|
}, { mutate: true }));
|
|
191
|
+
r.post('/edit-model', guard((f, b) => {
|
|
192
|
+
requireCap(f, 'edit-model');
|
|
193
|
+
if (typeof f.editModel !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
|
|
194
|
+
return f.editModel(String(b.id || ''), String(b.engine || ''), b.patch);
|
|
195
|
+
}, { mutate: true }));
|
|
191
196
|
r.post('/define-engine', guard((f, b) => { requireCap(f, 'define'); return f.defineEngine(b.def); }, { mutate: true }));
|
|
192
197
|
r.post('/edit-engine', guard((f, b) => { requireCap(f, 'edit'); return f.editEngine(b.id, b.patch, b.envChanges); }, { mutate: true }));
|
|
193
198
|
r.post('/remove-engine', guard((f, b) => { requireCap(f, 'remove'); return f.removeEngine(b.id); }, { mutate: true }));
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -241,7 +241,13 @@ function createBuiltinRuntime(ctx) {
|
|
|
241
241
|
if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
|
|
242
242
|
let launchEngine = engine;
|
|
243
243
|
if (engine.managed) {
|
|
244
|
-
const resolved = resolveManagedEngine(engine, cell, { ...cfg, home, extraModels: extraModelsFrom(defs) });
|
|
244
|
+
const resolved = resolveManagedEngine(engine, cell, { ...cfg, home, extraModels: extraModelsFrom(defs), capabilityProfiles: defs.capabilityProfiles || null });
|
|
245
|
+
// Il materializzatore MCP per cella e' FAIL-CLOSED: se il file per cella
|
|
246
|
+
// non si scrive, la cella non parte — mai con la superficie intera dove
|
|
247
|
+
// l'operatore l'ha ristretta.
|
|
248
|
+
if (resolved.mcpCellRefused) {
|
|
249
|
+
throw httpError(500, resolved.mcpCellRefused, null, { phase: 'preflight', code: resolved.mcpCellRefusedCode || 'MCP_CELL_FILE_UNWRITABLE' });
|
|
250
|
+
}
|
|
245
251
|
if (!resolved.ok) {
|
|
246
252
|
const code = engine.managed.client === 'shell' ? 'SHELL_NOT_AVAILABLE' : 'ENGINE_UNCONFIGURED';
|
|
247
253
|
throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });
|
package/lib/mcp/cells.js
CHANGED
|
@@ -75,6 +75,10 @@ function topologyOwners(payload) {
|
|
|
75
75
|
route: [...node.route],
|
|
76
76
|
label: typeof node.label === 'string' && node.label ? node.label : (node.name || node.route.join(' › ')),
|
|
77
77
|
stale: node.stale === true,
|
|
78
|
+
// Serve alla directory per dire QUANDO un nodo stale e' stato visto
|
|
79
|
+
// l'ultima volta: senza, la voce in unavailable parlerebbe dello stato
|
|
80
|
+
// senza dire da quanto.
|
|
81
|
+
lastSeen: Number.isFinite(node.lastSeen) ? node.lastSeen : null,
|
|
78
82
|
});
|
|
79
83
|
}
|
|
80
84
|
return out;
|
|
@@ -162,10 +166,26 @@ async function readCellDirectory(ctx, callerSession = null) {
|
|
|
162
166
|
]);
|
|
163
167
|
const localId = String(config && config.instanceId || '');
|
|
164
168
|
if (!NODE_ID_RE.test(localId)) throw new Error('instanceId locale non disponibile');
|
|
169
|
+
const candidati = topologyOwners(topology).filter((owner) => owner.instanceId !== localId);
|
|
165
170
|
const owners = [{ instanceId: localId, route: [], label: 'Local', stale: false },
|
|
166
|
-
...
|
|
171
|
+
...candidati.filter((owner) => !owner.stale)];
|
|
167
172
|
const cells = [];
|
|
168
173
|
const unavailable = [];
|
|
174
|
+
// Un owner STALE resta nel gate (non si interroga un nodo che la topologia
|
|
175
|
+
// dichiara stantio) ma non SPARISCE dalla directory: chi legge `nc_cells`
|
|
176
|
+
// deve distinguere «questo nodo non risponde da X» da «questo nodo non
|
|
177
|
+
// esiste». Stesso shape di unavailableOwner, failure 'stale' + lastSeen.
|
|
178
|
+
for (const owner of candidati) {
|
|
179
|
+
if (!owner.stale) continue;
|
|
180
|
+
unavailable.push({
|
|
181
|
+
instanceId: owner.instanceId,
|
|
182
|
+
owner: owner.label,
|
|
183
|
+
route: owner.route.length ? owner.route.join('/') : 'local',
|
|
184
|
+
...(owner.route.length === 0 ? { local: true } : {}),
|
|
185
|
+
failure: 'stale',
|
|
186
|
+
lastSeen: Number.isFinite(owner.lastSeen) ? owner.lastSeen : null,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
169
189
|
await Promise.all(owners.map(async (owner) => {
|
|
170
190
|
const apiPath = owner.route.length ? routePath(owner.route, 'cells') : '/api/cells';
|
|
171
191
|
if (!apiPath) return;
|
package/lib/proxy/federation.js
CHANGED
|
@@ -207,7 +207,7 @@ function knownResource(resource) {
|
|
|
207
207
|
|| /^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)
|
|
208
208
|
|| /^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)
|
|
209
209
|
|| isPanelResource(resource)
|
|
210
|
-
|| /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
|
|
210
|
+
|| /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
// Il pannello di una cella e' l'unica risorsa federata con un gate PER-PEER a
|
|
@@ -293,7 +293,7 @@ function allowedResource(resource, method = 'GET') {
|
|
|
293
293
|
// enum. Non genera testo, quindi non consuma token. Resta comunque una
|
|
294
294
|
// mutazione ai fini di READONLY (sotto): un nodo dichiarato di sola lettura
|
|
295
295
|
// non emette richieste autenticate su comando di un peer.
|
|
296
|
-
if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
|
|
296
|
+
if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
|
|
297
297
|
// Notify: solo POST. `/events` e `/push/*` restano NON federati — SSE non
|
|
298
298
|
// puo' autenticarsi col Bearer.
|
|
299
299
|
//
|
|
@@ -90,7 +90,7 @@ function classifyResource(resource, method = 'GET') {
|
|
|
90
90
|
if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) {
|
|
91
91
|
return m === 'GET' ? 'operator' : null;
|
|
92
92
|
}
|
|
93
|
-
if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) {
|
|
93
|
+
if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) {
|
|
94
94
|
return m === 'POST' ? 'operator' : null;
|
|
95
95
|
}
|
|
96
96
|
if (resource === '/decks') return m === 'GET' || m === 'POST' ? 'operator' : null;
|