@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.
@@ -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
- // Vale per OGNI cella Claude, non solo per i profili isolati: il filtro
2086
- // agisce sui nomi dei tool, non su dove i server sono definiti.
2087
- args.push(...cellMcpArgs(home, cell));
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,
@@ -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 }));
@@ -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
- ...topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localId)];
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;
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.32",
3
+ "version": "0.9.34",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {