@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.
- package/CHANGELOG.md +14 -0
- package/frontend/dist/assets/index-VtTd56fc.js +169 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/sw.js +18 -4
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +5 -0
- package/lib/fleet/builtin.js +12 -6
- package/lib/fleet/definitions.js +15 -0
- package/lib/fleet/managed.js +76 -47
- package/lib/fleet/runtime.js +15 -1
- package/lib/mcp/tools.js +1 -0
- package/lib/notify/ask-alert-identity.js +24 -0
- package/lib/notify/ask-alert-registry.js +96 -0
- package/lib/notify/asks.js +8 -2
- package/lib/notify/closure-retry.js +5 -4
- package/lib/notify/event-feed-client.js +140 -15
- package/lib/notify/event-feed-producers.js +4 -0
- package/lib/notify/event-feed-routes.js +2 -1
- package/lib/notify/notifier.js +19 -7
- package/lib/notify/owner-ask-scope.js +34 -0
- package/lib/notify/push-relay.js +10 -1
- package/lib/notify/routes.js +26 -16
- package/lib/server.js +70 -20
- package/lib/tmux/list.js +45 -0
- package/lib/tmux-reads.js +44 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-CZFJnks_.js +0 -169
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-VtTd56fc.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-fCIoVxeq.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
package/frontend/dist/sw.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
1
|
+
{"version":"0.9.61"}
|
package/lib/audio/dispatch.js
CHANGED
|
@@ -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
|
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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) || {
|
package/lib/fleet/definitions.js
CHANGED
|
@@ -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,
|
package/lib/fleet/managed.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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;
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -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
|
@@ -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 };
|
package/lib/notify/asks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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);
|