@mmmbuto/nexuscrew 0.9.59 → 0.9.61

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.
@@ -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-CZFJnks_.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-VtTd56fc.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-fCIoVxeq.css">
16
16
  </head>
17
17
  <body>
@@ -41,6 +41,19 @@ function alertKey(data) {
41
41
  return eventId ? `${owner}:${eventId}` : null;
42
42
  }
43
43
 
44
+ // Canonical ASK identity takes precedence over event IDs and caller tags.
45
+ async function askAlertTag(data) {
46
+ if (typeof data.ownerId !== 'string' || typeof data.askId !== 'string'
47
+ || !/^[a-f0-9]{32}$/i.test(data.ownerId) || !/^[a-f0-9]{8}$/i.test(data.askId)) return null;
48
+ const generation = Number.isSafeInteger(data.ownerAskTs) && data.ownerAskTs > 0 ? String(data.ownerAskTs)
49
+ : typeof data.ownerAskFingerprint === 'string' && /^[a-f0-9]{64}$/.test(data.ownerAskFingerprint) ? `unknown:${data.ownerAskFingerprint}` : null;
50
+ if (!generation) return null;
51
+ const bytes = new TextEncoder().encode(JSON.stringify([data.ownerId, data.askId, generation]));
52
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
53
+ return `nc:ask:${Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('')}`;
54
+ }
55
+ let alertDelivery = Promise.resolve();
56
+
44
57
  function openAlertsDb() {
45
58
  return new Promise((resolve) => {
46
59
  try {
@@ -93,9 +106,10 @@ self.addEventListener('push', (e) => {
93
106
  const title = typeof data.title === 'string' && data.title ? data.title : 'NexusCrew';
94
107
  const body = typeof data.body === 'string' ? data.body : '';
95
108
  const url = safeLocalPath(data.url) || '/';
96
- const key = alertKey(data);
97
- const tag = typeof data.tag === 'string' && TAG_RE.test(data.tag) ? data.tag : LEGACY_TAG;
98
- e.waitUntil((async () => {
109
+ e.waitUntil(alertDelivery = alertDelivery.catch(() => {}).then(async () => {
110
+ const askTag = await askAlertTag(data);
111
+ const key = askTag || alertKey(data);
112
+ const tag = askTag || (typeof data.tag === 'string' && TAG_RE.test(data.tag) ? data.tag : LEGACY_TAG);
99
113
  if (await alreadyAlerted(key)) return; // this event already rang once
100
114
  await rememberAlerted(key); // registered BEFORE showing: no double alert
101
115
  await self.registration.showNotification(title, {
@@ -104,7 +118,7 @@ self.addEventListener('push', (e) => {
104
118
  tag,
105
119
  data: { url },
106
120
  });
107
- })());
121
+ }));
108
122
  });
109
123
 
110
124
  // Click sulla notifica: focus di una finestra gia' aperta (deep-link via
@@ -1 +1 @@
1
- {"version":"0.9.59"}
1
+ {"version":"0.9.61"}
@@ -98,6 +98,11 @@ function createDispatcher(opts = {}) {
98
98
  status: payload.status,
99
99
  ...(payload.reason ? { reason: String(payload.reason).slice(0, 64) } : {}),
100
100
  httpStatus: res.status,
101
+ ...(['delivered', 'no-delivery', 'suppressed', 'elided'].includes(payload.alertStatus) ? { alertStatus: payload.alertStatus } : {}),
102
+ ...(payload.alert && Number.isSafeInteger(payload.alert.ui) && payload.alert.ui >= 0 && Number.isSafeInteger(payload.alert.push) && payload.alert.push >= 0 ? { alert: { ui: payload.alert.ui, push: payload.alert.push, ...(typeof payload.alert.uiAttempted === 'boolean' ? { uiAttempted: payload.alert.uiAttempted } : {}), ...(typeof payload.alert.pushAttempted === 'boolean' ? { pushAttempted: payload.alert.pushAttempted } : {}), ...(typeof payload.alert.pushReason === 'string' ? { pushReason: payload.alert.pushReason.slice(0, 64) } : {}) } } : {}),
103
+ ...(payload.suppressed === true ? { suppressed: true } : {}),
104
+ ...(payload.elided === true ? { elided: true } : {}),
105
+ ...(typeof payload.deduped === 'boolean' ? { deduped: payload.deduped } : {}),
101
106
  };
102
107
  }
103
108
 
@@ -30,7 +30,7 @@ const fs = require('node:fs');
30
30
  const os = require('node:os');
31
31
  const path = require('node:path');
32
32
  const {
33
- loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName, validateCommandTrust,
33
+ loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName, validateCommandTrust, defaultDefinitionsPath,
34
34
  aggiornaDefinizioni,
35
35
  cellIdFromTmuxSession,
36
36
  resolveCwd, normalizeCwdRel, deriveCwdRel,
@@ -611,7 +611,7 @@ async function createBuiltinFleet(cfg = {}) {
611
611
  if (cfg.builtinEnabled === false) return off;
612
612
 
613
613
  const home = cfg.home || os.homedir();
614
- const defsPath = cfg.fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
614
+ const defsPath = defaultDefinitionsPath(cfg);
615
615
  const tmuxBin = cfg.tmuxBin || process.env.TMUX_BIN || 'tmux';
616
616
  const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
617
617
  // Lease opt-in: attivo solo quando cfg.cellLeaseEnabled === true. server.js lo
@@ -792,7 +792,7 @@ async function createBuiltinFleet(cfg = {}) {
792
792
  });
793
793
  const {
794
794
  status, cellStatus, up, down, restart, isCellSession,
795
- reloadDefs, findCell, findEngine, refreshSessions, commitDefs,
795
+ reloadDefs, cachedDefs, findCell, findEngine, refreshSessions, commitDefs,
796
796
  } = rt;
797
797
 
798
798
  // Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
@@ -1353,7 +1353,10 @@ async function createBuiltinFleet(cfg = {}) {
1353
1353
 
1354
1354
  // Vista editabile ma secret-safe: gli env values restano write-only.
1355
1355
  function definitions() {
1356
- const defs = reloadDefs();
1356
+ // La fotografia della finestra di TTL (runtime.cachedDefs): una lettura+parse
1357
+ // per finestra condivisa con status, non una per chiamata. Le mutazioni
1358
+ // dell'interfaccia restano immediate via commitDefs.
1359
+ const defs = cachedDefs();
1357
1360
  const { map: credentialMap } = credentialRequirements(defs);
1358
1361
  const managedCatalog = publicCatalog().map((profile) => {
1359
1362
  if (typeof profile.credentialEnv !== 'string') return profile;
@@ -1473,10 +1476,13 @@ async function createBuiltinFleet(cfg = {}) {
1473
1476
  }
1474
1477
 
1475
1478
  function credentialRequirements(existingDefs) {
1476
- const defs = existingDefs || reloadDefs(); const map = new Map();
1479
+ const defs = existingDefs || cachedDefs(); const map = new Map();
1480
+ // extraModels si calcola UNA volta per la fotografia: rileggerlo per ogni
1481
+ // engine moltiplicava le letture del file per il numero di motori.
1482
+ const extraModels = extraModelsFrom(defs);
1477
1483
  for (const engine of defs.engines) {
1478
1484
  if (!engine.managed) continue;
1479
- const info = describeManaged(engine.managed, { ...cfg, home, extraModels: extraModelsFrom(reloadDefs()) });
1485
+ const info = describeManaged(engine.managed, { ...cfg, home, extraModels });
1480
1486
  const envKey = info.auth;
1481
1487
  if (!validEnvKey(envKey) || envKey === 'login' || envKey === 'none') continue;
1482
1488
  const current = map.get(envKey) || {
@@ -10,12 +10,21 @@
10
10
  // lib/fs/routes.js and lib/tmux/lifecycle.js.
11
11
  const fs = require('node:fs');
12
12
  const path = require('node:path');
13
+ const os = require('node:os');
13
14
  const crypto = require('node:crypto');
14
15
  const { normalizeManagedSpec } = require('./managed.js');
15
16
 
16
17
  // --- Cap + identita' (dichiarati; 100 regge una flotta reale con margine;
17
18
  // il limite resta deliberato contro un file patologico) ---
18
19
  const SCHEMA_VERSION = 1;
20
+
21
+ // Un cfg.home assente e' la NORMA delle installazioni reali (bin/nexuscrew
22
+ // serve non passa home): il default segue os.homedir(), esattamente come
23
+ // ogni altro path del server (server.js) e come il runtime Fleet.
24
+ function defaultDefinitionsPath(cfg = {}) {
25
+ const home = (cfg && cfg.home) || os.homedir();
26
+ return cfg.fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
27
+ }
19
28
  const MAX_ENGINES = 100;
20
29
  const MAX_CELLS = 32;
21
30
  const MAX_ARGS = 32; // argv: array, mai stringa spezzata (no shell)
@@ -1198,6 +1207,12 @@ const CAPS = {
1198
1207
  };
1199
1208
 
1200
1209
  module.exports = {
1210
+ // Il default canonico del path di fleet.json: UNA ricetta sola per il
1211
+ // runtime Fleet (builtin.js) e per ogni lettore delle definizioni (owner
1212
+ // ASK scope incluso). Come tutto il resto del server, un cfg.home assente
1213
+ // cade su os.homedir(): due ricette divergenti qui hanno prodotto il
1214
+ // crash all'avvio delle installazioni senza home esplicita.
1215
+ defaultDefinitionsPath,
1201
1216
  parseDefinitions,
1202
1217
  effectiveCapabilities,
1203
1218
  parseCapabilitySet,
@@ -854,6 +854,38 @@ function safeAllowedRoots(roots = []) {
854
854
  // "missing" che il caller altrimenti riporterebbe per una credenziale presente ma
855
855
  // illeggibile. Il verdetto (niente valori estratti -> {}) e' invariato; il
856
856
  // discriminante e' CHI ha fallito, non che ci sia stata un'eccezione.
857
+ // --- cache dei file di chiavi (patch upstream verificata) --------------------
858
+ // credentialSources() viene risolta a ogni tick e rilegge+riparsa gli stessi
859
+ // file (ai.env, secure/.env, providers.env, providers.zsh): misurate ~78
860
+ // letture/s su questo nodo. I CONTROLLI DI SICUREZZA (symlink, permessi, uid,
861
+ // size) restano eseguiti a ogni chiamata: la cache copre solo
862
+ // readFileSync+parseAssignments ed e' chiavata sull'identita' esatta del file
863
+ // (dev/ino/size/mtime/ctime/mode/uid), quindi qualunque scrittura, replace o
864
+ // chmod la invalida da se'. Stesso pattern del resto del file: nessun valore
865
+ // entra nei log, solo la mappa restituita al chiamante.
866
+ const envFileCache = new Map();
867
+ // Tetto di vita di una voce: l'identita' al millisecondo non distingue una
868
+ // riscrittura nello STESSO millisecondo con la stessa size, e senza tetto la
869
+ // voce sopravvivrebbe finche' il file non cambia identita' di nuovo — una
870
+ // chiave ruotata resterebbe invisibile per un tempo illimitato. Un secondo
871
+ // basta al dedup del burst (le letture di un tick sono fra loro vicinissime)
872
+ // e limita lo stantio al peggio a un secondo.
873
+ const ENV_FILE_CACHE_MAX_AGE_MS = 1000;
874
+
875
+ function envFileCacheKey(st) {
876
+ return `${st.dev}:${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.mode}:${st.uid}`;
877
+ }
878
+
879
+ function readEnvFileCached(target, st) {
880
+ const key = envFileCacheKey(st);
881
+ const hit = envFileCache.get(key);
882
+ if (hit && Date.now() - hit.at <= ENV_FILE_CACHE_MAX_AGE_MS) return hit.values;
883
+ const values = parseAssignments(fs.readFileSync(target, 'utf8'));
884
+ if (envFileCache.size > 32) envFileCache.clear();
885
+ envFileCache.set(key, { values, at: Date.now() });
886
+ return values;
887
+ }
888
+
857
889
  function parseEnvFile(file, opts = {}, out) {
858
890
  const blocked = Array.isArray(out && out.blocked) ? out.blocked : null;
859
891
  try {
@@ -868,7 +900,7 @@ function parseEnvFile(file, opts = {}, out) {
868
900
  const st = fs.lstatSync(target);
869
901
  if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) || st.size > 256 * 1024) return {};
870
902
  if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
871
- const values = parseAssignments(fs.readFileSync(target, 'utf8'));
903
+ const values = readEnvFileCached(target, st);
872
904
  // Provenienza richiesta dal chiamante (vedi parseProviderKeyFiles). Si
873
905
  // registra per CHIAVE, con l'impronta e non il valore, e solo per i file che
874
906
  // il chiamante ha dichiarato di voler tracciare: gli altri restano com'e-
@@ -895,7 +927,7 @@ function parseProviderShellFile(file, out) {
895
927
  const st = fs.lstatSync(file);
896
928
  if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o022) || st.size > 256 * 1024) return {};
897
929
  if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
898
- const values = parseAssignments(fs.readFileSync(file, 'utf8'));
930
+ const values = readEnvFileCached(file, st);
899
931
  // Stessa registrazione della provenienza dei file di chiavi: la shell sta
900
932
  // PIU' IN ALTO nella precedenza, quindi una providers.zsh stantia produce
901
933
  // lo stesso guasto dei file — a parti invertite — e va vista allo stesso
@@ -2292,16 +2324,19 @@ const CODEX_DAEMON_MIN_VERSION = '0.131.0-alpha.12';
2292
2324
  const CODEX_PROBE_TIMEOUT_MS = 10000;
2293
2325
  const codexCapabilityCache = new Map();
2294
2326
  const CODEX_MCP_CONTEXT_ENV = ['MCP_DEVICE', 'NEXUSCREW_MCP_SESSION', 'TMUX', 'TMUX_PANE'];
2295
- function codexEmbeddedGate(binary, cfg) {
2327
+ function codexEmbeddedGate(binary, cfg, client) {
2296
2328
  const { spawnSync } = require('node:child_process');
2297
2329
  const { compareVersions, parseVersion } = require('../update/core.js');
2298
- const unsupported = { ok: false, reason: 'Codex Termux embedded identity isolation is unsupported: runtime capability could not be verified' };
2330
+ const pkg = client === 'codex-vl' ? '@mmmbuto/codex-vl' : '@openai/codex';
2331
+ const minimum = client === 'codex-vl' ? '0.156.1-vl.1' : '0.156.0';
2332
+ const unsupported = { ok: false, reason: `Codex embedded identity isolation is unsupported: runtime capability could not be verified. Update ${pkg} to ${minimum} or later (npm install -g ${pkg}@latest); the launched binary must return a readable --version and advertise --no-daemon in --help.` };
2299
2333
  let resolved, stat;
2300
2334
  try { resolved = fs.realpathSync(binary); stat = fs.statSync(resolved); } catch (_) { return unsupported; }
2301
- const cached = codexCapabilityCache.get(resolved);
2335
+ const cacheKey = `${client}:${resolved}`;
2336
+ const cached = codexCapabilityCache.get(cacheKey);
2302
2337
  if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.result;
2303
2338
  const remember = result => {
2304
- codexCapabilityCache.set(resolved, { mtimeMs: stat.mtimeMs, size: stat.size, result });
2339
+ codexCapabilityCache.set(cacheKey, { mtimeMs: stat.mtimeMs, size: stat.size, result });
2305
2340
  return result;
2306
2341
  };
2307
2342
  const explicitNode = needsExplicitNode(binary, cfg.platform || process.platform, cfg.env || process.env);
@@ -2547,27 +2582,6 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2547
2582
  if (attivita) args.push(`--settings=${JSON.stringify({ hooks: attivita.hooks })}`);
2548
2583
  }
2549
2584
  } else if (spec.client === 'codex' || spec.client === 'codex-vl') {
2550
- const termux = (cfg.platform || process.platform) === 'android'
2551
- || termuxRuntimePaths(cfg.env || process.env, { platform: cfg.platform || process.platform, home }) !== null;
2552
- if (spec.client === 'codex' && termux) {
2553
- const gate = codexEmbeddedGate(info.binary, cfg);
2554
- if (!gate.ok) return { ok: false, info, reason: gate.reason };
2555
- if (gate.embedded) args.push('--no-daemon');
2556
- if (gate.embedded) try {
2557
- const { servers } = codexSurfaceTables(readCodexUserToml(path.join(home, '.codex')));
2558
- const bridge = servers.get('nexuscrew');
2559
- if (bridge && bridge.enabled !== false) {
2560
- if (bridge.env && CODEX_MCP_CONTEXT_ENV.some(name => Object.prototype.hasOwnProperty.call(bridge.env, name))) {
2561
- return { ok: false, info, reason: 'Codex embedded MCP identity cannot use static context overrides: remove the NexusCrew identity keys from mcp_servers.nexuscrew.env' };
2562
- }
2563
- const names = [...new Set([...(Array.isArray(bridge.env_vars) ? bridge.env_vars : []), ...CODEX_MCP_CONTEXT_ENV])];
2564
- args.push('-c', `mcp_servers.nexuscrew.env_vars=${JSON.stringify(names)}`);
2565
- }
2566
- } catch (_) {
2567
- return { ok: false, info, reason: 'Codex embedded MCP identity requires a readable, valid MCP configuration' };
2568
- }
2569
- }
2570
-
2571
2585
  // Canale di attivita' per le celle codex: gli stessi eventi che le celle
2572
2586
  // Claude pubblicano via `--settings`, qui come hook `-c` con la fiducia che
2573
2587
  // il client pretende (senza, apre un dialogo di revisione e la cella resta
@@ -2864,33 +2878,23 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2864
2878
  const authorityUnavailable = identityMode === 'authority' && !identityAuthorityReady
2865
2879
  ? (identityAuthorityFault || 'authority credentials missing')
2866
2880
  : '';
2867
- if (hasDefinitionOverride && typeof definitionEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED] === 'string') {
2881
+ const requiredOverride = hasDefinitionOverride && typeof definitionEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED] === 'string'
2882
+ ? definitionEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED]
2883
+ : hasRuntimeOverride && typeof runtimeEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED] === 'string'
2884
+ ? runtimeEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED] : null;
2885
+ if (authorityUnavailable || (!authorityActive && requiredOverride === '1')) {
2886
+ identityChannel = false;
2887
+ identityAuthorityUnavailable = authorityUnavailable || 'required identity channel needs ready authority';
2888
+ if (logLine) logLine('fleet identity: protected cell launch refused — ' + identityAuthorityUnavailable);
2889
+ } else if (hasDefinitionOverride && typeof definitionEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED] === 'string') {
2868
2890
  env[CODEX_APP_SERVER_IDENTITY_REQUIRED] = definitionEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED];
2869
2891
  identityChannel = env[CODEX_APP_SERVER_IDENTITY_REQUIRED] === '1';
2870
- if (!authorityActive && identityChannel && logLine) {
2871
- logLine(`fleet identity: override definition force CODEX_APP_SERVER_IDENTITY_REQUIRED=1 `
2872
- + `but identity mode is '${identityMode}' without authority credentials`);
2873
- }
2874
2892
  } else if (hasRuntimeOverride && typeof runtimeEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED] === 'string') {
2875
2893
  env[CODEX_APP_SERVER_IDENTITY_REQUIRED] = runtimeEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED];
2876
2894
  identityChannel = env[CODEX_APP_SERVER_IDENTITY_REQUIRED] === '1';
2877
- if (!authorityActive && identityChannel && logLine) {
2878
- logLine(`fleet identity: override runtime force CODEX_APP_SERVER_IDENTITY_REQUIRED=1 `
2879
- + `but identity mode is '${identityMode}' without authority credentials`);
2880
- }
2881
2895
  } else if (authorityActive) {
2882
2896
  env[CODEX_APP_SERVER_IDENTITY_REQUIRED] = CODEX_APP_SERVER_IDENTITY_REQUIRED_DEFAULT;
2883
2897
  identityChannel = env[CODEX_APP_SERVER_IDENTITY_REQUIRED] === '1';
2884
- } else if (authorityUnavailable) {
2885
- // Nessuna esportazione: il lancio e' rifiutato prima dello spawn, quindi
2886
- // il valore non arriverebbe mai a un child. Un override esplicito (rami
2887
- // sopra) resta una scelta dell'operatore e non entra qui.
2888
- identityChannel = false;
2889
- identityAuthorityUnavailable = authorityUnavailable;
2890
- if (logLine) {
2891
- logLine('fleet identity: protected cell launch refused — authority configured but unavailable ('
2892
- + authorityUnavailable + ')');
2893
- }
2894
2898
  } else {
2895
2899
  env[CODEX_APP_SERVER_IDENTITY_REQUIRED] = '0';
2896
2900
  identityChannel = false;
@@ -2899,6 +2903,10 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2899
2903
  }
2900
2904
  }
2901
2905
  }
2906
+ // One resolved launch decision controls the channel and the final isolation arguments.
2907
+ const isolateCodex = (spec.client === 'codex' || spec.client === 'codex-vl')
2908
+ && !identityAuthorityUnavailable && identityChannel !== true;
2909
+ const resolvedIdentityRequired = env[CODEX_APP_SERVER_IDENTITY_REQUIRED];
2902
2910
  // Prompt su argv (0.8.47) SOLO per i client senza percorso classified
2903
2911
  // delivery. Dalla 0.9.41: TUTTE le claude.* passano a consegna
2904
2912
  // ritardata (kimi.* le avevano dal 0.8.47) — promptMode 'send-keys' con
@@ -2913,7 +2921,6 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2913
2921
  // per-cell file composed by the vl branch (VL_SYSTEM_APPEND_FILE).
2914
2922
  // send-keys does not apply: the deliverBootstrapPrompt classifier covers
2915
2923
  // only kimi/claude, and vl starts immediately without waits.
2916
- if (spec.client !== 'shell' && spec.client !== 'vl' && !promptViaDelivery && cell?.prompt) args.push(cell.prompt);
2917
2924
  // nexuscrew-store source: neutralize the profile's env set in the composed
2918
2925
  // child env (unset, never empty), so the runtime cannot leak credentials that
2919
2926
  // the local store is meant to own.
@@ -2938,6 +2945,28 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2938
2945
  env[name] = value;
2939
2946
  }
2940
2947
  }
2948
+ if (spec.client === 'codex-vl' && env[CODEX_APP_SERVER_IDENTITY_REQUIRED] !== resolvedIdentityRequired) {
2949
+ return { ok: false, info, reason: 'Fleet identity environment passthrough conflicts with the resolved launch decision' };
2950
+ }
2951
+ if (isolateCodex) {
2952
+ const gate = codexEmbeddedGate(info.binary, cfg, spec.client);
2953
+ if (!gate.ok) return { ok: false, info, reason: gate.reason };
2954
+ if (gate.embedded) args.push('--no-daemon');
2955
+ if (gate.embedded) try {
2956
+ const { servers } = codexSurfaceTables(readCodexUserToml(path.join(home, '.codex')));
2957
+ const bridge = servers.get('nexuscrew');
2958
+ if (bridge && bridge.enabled !== false) {
2959
+ if (bridge.env && CODEX_MCP_CONTEXT_ENV.some(name => Object.prototype.hasOwnProperty.call(bridge.env, name))) {
2960
+ return { ok: false, info, reason: 'Codex embedded MCP identity cannot use static context overrides: remove the NexusCrew identity keys from mcp_servers.nexuscrew.env' };
2961
+ }
2962
+ const names = [...new Set([...(Array.isArray(bridge.env_vars) ? bridge.env_vars : []), ...CODEX_MCP_CONTEXT_ENV])];
2963
+ args.push('-c', `mcp_servers.nexuscrew.env_vars=${JSON.stringify(names)}`);
2964
+ }
2965
+ } catch (_) {
2966
+ return { ok: false, info, reason: 'Codex embedded MCP identity requires a readable, valid MCP configuration' };
2967
+ }
2968
+ }
2969
+ if (spec.client !== 'shell' && spec.client !== 'vl' && !promptViaDelivery && cell?.prompt) args.push(cell.prompt);
2941
2970
  let command = info.binary;
2942
2971
  if (needsExplicitNode(info.binary, cfg.platform || process.platform, cfg.env || process.env)) {
2943
2972
  command = cfg.nodeExecPath || process.execPath;
@@ -124,6 +124,20 @@ function createBuiltinRuntime(ctx) {
124
124
  return cache.defs; // mai null: boot non-null e reload mantiene l'ultimo valido
125
125
  }
126
126
 
127
+ // La fotografia definitions per le VITE di sola lettura (definitions,
128
+ // credentialStatus): una sola lettura+parse per finestra di TTL, la stessa
129
+ // finestra di cellStatus ma con contatore proprio, per non toccare il
130
+ // fresco di sessions. Le mutazioni dell'interfaccia restano immediate:
131
+ // passano da commitDefs, che aggiorna la fotografia in cache.
132
+ let defsAt = 0;
133
+ function cachedDefs() {
134
+ if (Date.now() - defsAt > STATUS_TTL_MS) {
135
+ reloadDefs();
136
+ defsAt = Date.now();
137
+ }
138
+ return cache.defs;
139
+ }
140
+
127
141
  // Commit del defs mutato dal facade CRUD (builtin.js mutate()). invalida anche
128
142
  // la TTL dello status, esattamente come faceva builtin.js prima dell'estrazione.
129
143
  function commitDefs(defs) {
@@ -774,7 +788,7 @@ function createBuiltinRuntime(ctx) {
774
788
 
775
789
  return {
776
790
  status, cellStatus, up, down, restart, isCellSession,
777
- reloadDefs, findCell, findEngine, refreshSessions, commitDefs,
791
+ reloadDefs, cachedDefs, findCell, findEngine, refreshSessions, commitDefs,
778
792
  };
779
793
  }
780
794
 
package/lib/mcp/tools.js CHANGED
@@ -385,6 +385,7 @@ const TOOLS = [
385
385
  });
386
386
  return {
387
387
  askId: j.id,
388
+ ...(Array.isArray(j.fanout) ? { fanout: j.fanout } : {}),
388
389
  note: 'la risposta dell\'operatore arrivera\' come messaggio incollato in questa sessione tmux',
389
390
  };
390
391
  },
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+ const crypto = require('node:crypto');
3
+ // Alert identity is independent of aliases and notification emission time.
4
+ function canonicalAskAlert(frame) {
5
+ const ownerId = frame && frame.ownerId;
6
+ const askId = frame && (frame.ownerAskId || frame.askId);
7
+ if (typeof ownerId !== 'string' || !/^[a-f0-9]{32}$/i.test(ownerId)
8
+ || typeof askId !== 'string' || !/^[a-f0-9]{8}$/i.test(askId)) return null;
9
+ const ownerAskTs = frame.ownerAskTs;
10
+ const fingerprint = frame.ownerAskFingerprint;
11
+ const generation = Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? String(ownerAskTs)
12
+ : typeof fingerprint === 'string' && /^[a-f0-9]{64}$/.test(fingerprint) ? `unknown:${fingerprint}` : null;
13
+ if (!generation) return null;
14
+ const key = JSON.stringify([ownerId, askId, generation]);
15
+ return { ownerId, askId, ...(generation.startsWith('unknown:') ? { ownerAskFingerprint: fingerprint } : { ownerAskTs }),
16
+ tag: `nc:ask:${crypto.createHash('sha256').update(key).digest('hex')}`, key };
17
+ }
18
+ function alertFields(frame) {
19
+ const identity = canonicalAskAlert(frame);
20
+ if (!identity) return {};
21
+ const { key, ...fields } = identity;
22
+ return fields;
23
+ }
24
+ module.exports = { canonicalAskAlert, alertFields };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const crypto = require('node:crypto');
5
+ const { canonicalAskAlert } = require('./ask-alert-identity.js');
6
+ const { askFingerprint } = require('./asks.js');
7
+ const MAX_ENTRIES = 4096;
8
+ const MAX_BYTES = 1024 * 1024;
9
+ const TTL_MS = 24 * 60 * 60 * 1000;
10
+ // One instance is shared by both ingress paths in a receiver process. An
11
+ // unfinished durable claim is uncertain after restart, never a success.
12
+ function createAskAlertRegistry({ filePath, now = Date.now, maxEntries = MAX_ENTRIES, maxBytes = MAX_BYTES, ttlMs = TTL_MS } = {}) {
13
+ let entries = {}; let blocked = false;
14
+ try {
15
+ if (fs.statSync(filePath).size > maxBytes) throw new Error('oversized');
16
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
17
+ if (data.v !== 1 || !data.entries || typeof data.entries !== 'object' || Array.isArray(data.entries) || Object.keys(data.entries).length > maxEntries) throw new Error('shape');
18
+ for (const [key, r] of Object.entries(data.entries)) {
19
+ if (!/^nc:ask:[a-f0-9]{64}$/.test(key) || !r || !Number.isFinite(r.at)
20
+ || (r.priorTag !== undefined && !/^nc:ask:[a-f0-9]{64}$/.test(r.priorTag))
21
+ || !['ui', 'push'].every(c => r[c] === undefined || ['pending', 'delivered', 'failed', 'uncertain'].includes(r[c]))) throw new Error('shape');
22
+ }
23
+ entries = data.entries;
24
+ } catch (e) { if (e.code !== 'ENOENT') blocked = true; }
25
+ function prune() {
26
+ const cutoff = now() - ttlMs;
27
+ for (const [key, r] of Object.entries(entries)) if (r.at <= cutoff) delete entries[key];
28
+ }
29
+ function save() {
30
+ const text = JSON.stringify({ v: 1, entries });
31
+ if (Buffer.byteLength(text) > maxBytes) return false;
32
+ const temp = `${filePath}.${crypto.randomUUID()}.tmp`;
33
+ try {
34
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
35
+ fs.writeFileSync(temp, text, { mode: 0o600, flag: 'wx' }); fs.renameSync(temp, filePath); return true;
36
+ } catch (_) { try { fs.unlinkSync(temp); } catch (_) {} return false; }
37
+ }
38
+ function storedKey(tag) {
39
+ return entries[tag] ? tag : Object.keys(entries).find(key => entries[key].priorTag === tag) || tag;
40
+ }
41
+ // Called only with a complete owner-authoritative ASK and its generation.
42
+ // Rename one record rather than adding an unbounded alias collection.
43
+ function adopt(ownerId, ask, authoritativeTs) {
44
+ if (blocked || !Number.isSafeInteger(authoritativeTs) || authoritativeTs <= 0 || !ask) return false;
45
+ const askId = ask.ownerAskId || ask.id;
46
+ const old = canonicalAskAlert({ ownerId, askId, ownerAskFingerprint: askFingerprint(ask) });
47
+ const known = canonicalAskAlert({ ownerId, askId, ownerAskTs: authoritativeTs });
48
+ if (!old || !known) return false;
49
+ prune(); const source = entries[old.tag];
50
+ const existing = entries[known.tag];
51
+ if (!source && !existing) return false;
52
+ // A known fan-out can precede an older feed notification with no generation.
53
+ // Bind that historical namespace before its first claim, not after alerting.
54
+ if (!source) {
55
+ if (existing.priorTag === old.tag) return true;
56
+ const previous = existing;
57
+ entries[known.tag] = { ...existing, priorTag: old.tag };
58
+ if (!save()) { entries[known.tag] = previous; blocked = true; return false; }
59
+ return true;
60
+ }
61
+ const previous = JSON.parse(JSON.stringify(entries));
62
+ const rank = { failed: 1, pending: 2, uncertain: 3, delivered: 4 };
63
+ const target = entries[known.tag] || {};
64
+ const adopted = { at: Math.max(source.at, target.at || 0), priorTag: old.tag };
65
+ for (const c of ['ui', 'push']) {
66
+ const a = source[c], b = target[c];
67
+ if (a || b) adopted[c] = (rank[a] || 0) > (rank[b] || 0) ? a : b;
68
+ }
69
+ delete entries[old.tag]; entries[known.tag] = adopted;
70
+ if (!save()) { entries = previous; blocked = true; return false; } return true;
71
+ }
72
+ function claim(frame, channel) {
73
+ const id = canonicalAskAlert(frame);
74
+ if (!id) return { allowed: true, reason: 'generic' };
75
+ if (blocked || !['ui', 'push'].includes(channel)) return { allowed: false, reason: 'alert-registry-unavailable' };
76
+ prune(); const key = storedKey(id.tag); const prior = entries[key]; const phase = prior && prior[channel];
77
+ if (phase && phase !== 'failed') return { allowed: false, reason: phase === 'delivered' ? 'already-alerted' : 'alert-uncertain' };
78
+ if (!prior && Object.keys(entries).length >= maxEntries) {
79
+ const oldest = Object.entries(entries).filter(([, r]) => r.ui !== 'pending' && r.push !== 'pending').sort((a, b) => a[1].at - b[1].at)[0];
80
+ if (!oldest) return { allowed: false, reason: 'alert-registry-cap' };
81
+ delete entries[oldest[0]];
82
+ }
83
+ entries[key] = { ...(prior || {}), at: now(), [channel]: 'pending' };
84
+ if (!save()) { blocked = true; return { allowed: false, reason: 'alert-registry-unavailable' }; }
85
+ return { allowed: true, key };
86
+ }
87
+ function complete(frame, channel, result) {
88
+ const id = canonicalAskAlert(frame);
89
+ const key = id && storedKey(id.tag);
90
+ if (!id || blocked || !entries[key] || entries[key][channel] !== 'pending') return false;
91
+ entries[key][channel] = result === 'uncertain' ? 'uncertain' : result > 0 ? 'delivered' : 'failed';
92
+ if (!save()) { blocked = true; return false; } return true;
93
+ }
94
+ return { claim, complete, adopt, limits: { maxEntries, maxBytes, ttlMs }, filePath };
95
+ }
96
+ module.exports = { createAskAlertRegistry, MAX_ENTRIES, MAX_BYTES, TTL_MS };
@@ -148,12 +148,18 @@ function createAsksStore(opts = {}) {
148
148
  // Chiusura dell'alias locale quando l'OWNER chiude la domanda. Durevole: la
149
149
  // riga resta nello storico marcata, quindi non ricompare a un reload e non
150
150
  // torna risponibile.
151
- function closeImported({ ownerId, ownerAskId, outcome }) {
151
+ function closeImported({ ownerId, ownerAskId, ownerAskTs, outcome }) {
152
152
  const ur = unreadableGuard();
153
153
  if (ur) return { ok: false, reason: 'store-unreadable', changed: false, ask: null };
154
154
  const ask = findImported(ownerId, ownerAskId);
155
155
  const record = getImportedDismissal(ownerId, ownerAskId);
156
- if (record) {
156
+ // Known closure generations cannot mutate a newer alias or dismissal receipt.
157
+ const closureTs = Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? ownerAskTs : null;
158
+ const recordMismatch = closureTs && record && record.ownerAskTs && record.ownerAskTs !== closureTs;
159
+ if ((closureTs && ask && generationTs(ask) && generationTs(ask) !== closureTs) || (!ask && recordMismatch)) {
160
+ return { ok: true, changed: false, ask: null, generationMismatch: true };
161
+ }
162
+ if (record && !recordMismatch) {
157
163
  const synced = updateImportedDismissal(ownerId, ownerAskId, { syncState: outcome === 'answered' ? 'confirmed-answered' : 'confirmed-dismissed', ownerOutcome: outcome });
158
164
  if (!synced.ok) return { ...synced, changed: false, ask: null };
159
165
  }
@@ -93,7 +93,7 @@ function createClosureRetryQueue({
93
93
  // riceve una seconda consegna inutile.
94
94
  const targets = [...entry.targets];
95
95
  try {
96
- results = await run({ askId: entry.askId, outcome: entry.outcome, session: entry.session, targets });
96
+ results = await run({ askId: entry.askId, ...(entry.ownerAskTs ? { ownerAskTs: entry.ownerAskTs } : {}), outcome: entry.outcome, session: entry.session, targets });
97
97
  } catch (e) {
98
98
  results = targets.map((target) => ({ target, status: 'unknown', reason: 'dispatch-threw' }));
99
99
  try { log(`chiusura ask ${entry.askId}: tentativo fallito (${String(e && e.message || e)})`); } catch (_) {}
@@ -129,11 +129,12 @@ function createClosureRetryQueue({
129
129
  // stessa chiusura UNISCE i target invece di essere buttata via: e' cio' che
130
130
  // impedisce a un peer spento di sparire dalla coda quando un ALTRO peer
131
131
  // risponde, o quando la stessa chiusura viene riprovata piu' tardi.
132
- function enqueue({ askId, outcome, session, targets } = {}) {
132
+ function enqueue({ askId, ownerAskTs, outcome, session, targets } = {}) {
133
133
  if (stopped || !askId || !outcome) return { ok: false, reason: 'invalid' };
134
134
  const nuovi = [...new Set((Array.isArray(targets) ? targets : []).map((t) => String(t)).filter(Boolean))];
135
135
  if (!nuovi.length) return { ok: false, reason: 'no-targets' };
136
- const existing = entries.find((e) => e.askId === askId && e.outcome === outcome);
136
+ const generation = Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? ownerAskTs : null;
137
+ const existing = entries.find((e) => e.askId === askId && e.outcome === outcome && (e.ownerAskTs || null) === generation);
137
138
  if (existing) {
138
139
  const prima = existing.targets.size;
139
140
  for (const t of nuovi) existing.targets.add(t);
@@ -148,7 +149,7 @@ function createClosureRetryQueue({
148
149
  try { log(`coda chiusure piena (${maxEntries}): scartata la piu' vecchia (${oldest.askId})`); } catch (_) {}
149
150
  }
150
151
  const entry = {
151
- askId, outcome, session, targets: new Set(nuovi),
152
+ askId, ...(generation ? { ownerAskTs: generation } : {}), outcome, session, targets: new Set(nuovi),
152
153
  attempts: 0, createdAt: now(), nextAt: 0, timer: null, inFlight: false,
153
154
  };
154
155
  entries.push(entry);