@mmmbuto/nexuscrew 0.9.24 → 0.9.26

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.
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+ // Provisioning dell'authority mode. Genera le DUE credenziali distinte
3
+ // (daemon + launcher) nella directory dell'authority, con permessi 0700/0600,
4
+ // e attiva `fleet.identity.mode = "authority"` nella config. Idempotente:
5
+ // credenziali esistenti non vengono MAI sovrascritte senza --force.
6
+ //
7
+ // Nessun valore di credenziale torna nel ritorno e nessun valore finisce nei
8
+ // log: il ritorno descrive solo percorsi e azioni.
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const crypto = require('node:crypto');
13
+
14
+ const CREDENTIAL_FILES = Object.freeze({
15
+ identityDaemonCredential: 'daemon.credential',
16
+ identityLauncherCredential: 'launcher.credential',
17
+ });
18
+
19
+ // Le due credenziali devono essere DISTINTE (stessa regola di
20
+ // identity-authority a runtime): confronto a tempo costante sui byte.
21
+ function credentialsDistinct(daemonCredential, launcherCredential) {
22
+ if (typeof daemonCredential !== 'string' || !daemonCredential
23
+ || typeof launcherCredential !== 'string' || !launcherCredential) return false;
24
+ const daemon = Buffer.from(daemonCredential);
25
+ const launcher = Buffer.from(launcherCredential);
26
+ if (daemon.length !== launcher.length) return true;
27
+ return !crypto.timingSafeEqual(daemon, launcher);
28
+ }
29
+
30
+ function defaultAuthorityDir(home) {
31
+ return path.join(home || require('node:os').homedir(), '.nexuscrew', 'identity-authority');
32
+ }
33
+
34
+ function assertSafeMode(filePath, expectedMode) {
35
+ const st = fs.lstatSync(filePath);
36
+ if (!st.isFile()) throw new Error(`identity provision: ${filePath} non e' un file regolare`);
37
+ if ((st.mode & 0o777) !== expectedMode) {
38
+ throw new Error(`identity provision: ${filePath} ha permessi ${(st.mode & 0o777).toString(8)} (attesi ${expectedMode.toString(8)})`);
39
+ }
40
+ }
41
+
42
+ function existingCredentials(dir) {
43
+ const found = [];
44
+ for (const name of Object.values(CREDENTIAL_FILES)) {
45
+ const filePath = path.join(dir, name);
46
+ try {
47
+ const st = fs.lstatSync(filePath);
48
+ if (st.isFile() && st.size > 0) found.push(filePath);
49
+ } catch (_) { /* assente: ok */ }
50
+ }
51
+ return found;
52
+ }
53
+
54
+ function provisionIdentityAuthority({
55
+ home,
56
+ dir,
57
+ force = false,
58
+ writeConfig = true,
59
+ configPath,
60
+ randomBytes = crypto.randomBytes,
61
+ fsImpl = fs,
62
+ now = new Date,
63
+ } = {}) {
64
+ const targetDir = dir || defaultAuthorityDir(home);
65
+ fsImpl.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
66
+
67
+ const preExisting = existingCredentials(targetDir);
68
+ if (preExisting.length && !force) {
69
+ return {
70
+ ok: false, code: 'ALREADY_PROVISIONED', dir: targetDir,
71
+ existing: preExisting,
72
+ message: 'identity authority already provisioned; pass --force to regenerate',
73
+ };
74
+ }
75
+
76
+ const written = [];
77
+ let daemonValue = randomBytes(32).toString('hex');
78
+ let launcherValue = randomBytes(32).toString('hex');
79
+ // Rigenera finché le due credenziali sono distinte (confronto a tempo
80
+ // costante, stessa regola di identity-authority). Con una fonte casuale
81
+ // sana la prima iterazione passa; con randomBytes iniettati degeneri il
82
+ // provisioning viene RIFIUTATO anche con --force, senza scrivere nulla.
83
+ for (let attempt = 0; attempt < 8; attempt += 1) {
84
+ if (credentialsDistinct(daemonValue, launcherValue)) break;
85
+ launcherValue = randomBytes(32).toString('hex');
86
+ if (attempt === 7 && !credentialsDistinct(daemonValue, launcherValue)) {
87
+ return {
88
+ ok: false, code: 'IDENTICAL_CREDENTIALS', dir: targetDir,
89
+ message: 'identity provision: generated credentials are identical; nothing written',
90
+ };
91
+ }
92
+ }
93
+ for (const [name, value] of [
94
+ [CREDENTIAL_FILES.identityDaemonCredential, daemonValue],
95
+ [CREDENTIAL_FILES.identityLauncherCredential, launcherValue],
96
+ ]) {
97
+ const filePath = path.join(targetDir, name);
98
+ fsImpl.writeFileSync(filePath, `${value}\n`, { mode: 0o600 });
99
+ written.push(filePath);
100
+ }
101
+ // I permessi espliciti valgono anche su filesystem che ignorano mode in
102
+ // writeFileSync (umask larghe): rinforza dopo la scrittura.
103
+ fsImpl.chmodSync(targetDir, 0o700);
104
+ for (const filePath of written) fsImpl.chmodSync(filePath, 0o600);
105
+ for (const filePath of written) assertSafeMode(filePath, 0o600);
106
+
107
+ const resolvedConfigPath = configPath
108
+ || (process.env.NEXUSCREW_CONFIG_FILE
109
+ || path.join(home || require('node:os').homedir(), '.nexuscrew', 'config.json'));
110
+
111
+ let configUpdated = false;
112
+ let configInstructions = null;
113
+ if (writeConfig) {
114
+ let config = {};
115
+ try {
116
+ config = JSON.parse(fsImpl.readFileSync(resolvedConfigPath, 'utf8'));
117
+ if (!config || typeof config !== 'object' || Array.isArray(config)) config = {};
118
+ } catch (_) { config = {}; }
119
+ config.fleet = { ...(config.fleet || {}), identity: { ...(config.fleet?.identity || {}), mode: 'authority' } };
120
+ fsImpl.mkdirSync(path.dirname(resolvedConfigPath), { recursive: true });
121
+ fsImpl.writeFileSync(resolvedConfigPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
122
+ configUpdated = true;
123
+ } else {
124
+ configInstructions = [
125
+ 'Set the following keys in the NexusCrew config (fleet.identity.mode = "authority")',
126
+ `and keep the two credential files in place under ${targetDir}:`,
127
+ ...Object.values(CREDENTIAL_FILES).map((name) => ` ${path.join(targetDir, name)}`),
128
+ ].join('\n');
129
+ }
130
+
131
+ return {
132
+ ok: true, dir: targetDir, written, configUpdated, configInstructions,
133
+ mode: 'authority',
134
+ };
135
+ }
136
+
137
+ module.exports = {
138
+ provisionIdentityAuthority,
139
+ defaultAuthorityDir,
140
+ credentialsDistinct,
141
+ CREDENTIAL_FILES,
142
+ };
@@ -29,6 +29,14 @@ const OLLAMA_CLOUD_MODELS = Object.freeze([
29
29
  // tools/thinking/vision/cloud; 552B MoE (8B attivi).
30
30
  // DICHIARATO 2026-09-11 https://ollama.com/library/deepseek-v4.1-flash
31
31
  'deepseek-v4.1-flash',
32
+ // Forme nude gemma4 e qwen3.5: MISURATE vive sull'API cloud
33
+ // (/v1/chat/completions -> 200, official measured sample 2026-09-12: 22
34
+ // live / 8 dead variants). Le varianti con tag servite in cloud restano
35
+ // `gemma4:31b` e `qwen3.5:397b`: `gemma4:12b`/`gemma4:e4b` e
36
+ // `qwen3.5:27b`/`:35b`/`:122b` sono 404 (fuori lista di proposito).
37
+ // DICHIARATO 2026-09-12 https://ollama.com/library/gemma4
38
+ // DICHIARATO 2026-09-12 https://ollama.com/library/qwen3.5
39
+ 'gemma4', 'qwen3.5',
32
40
  ]);
33
41
  // Autorita' per OLLAMA_CONTEXT = IL CAMPO STRUTTURATO «Context» della scheda
34
42
  // canale (ollama.com/library, fetch 2026-08-27) — NON la descrizione, che
@@ -63,6 +71,12 @@ const OLLAMA_CONTEXT = Object.freeze({
63
71
  // deepseek-v4.1-flash: scheda «Context / 1M tokens» (2026-09-11); come gli
64
72
  // altri deepseek vale la convenzione binaria del repo (1M -> 1048576).
65
73
  'deepseek-v4.1-flash': 1048576,
74
+ // gemma4 (nudo, tag default `latest`): scheda «128K context window»,
75
+ // Input Text, Image (2026-09-12); convenzione binaria 128K -> 131072.
76
+ 'gemma4': 131072,
77
+ // qwen3.5 (nudo, tag default `latest`): scheda «256K context window»,
78
+ // Input Text, Image (2026-09-12); come la sorella :397b, 256K -> 262144.
79
+ 'qwen3.5': 262144,
66
80
  });
67
81
  // Capacita' per modello OSSERVATE dalla scheda canale (ollama.com/library,
68
82
  // 2026-08-27). Assenza = default conservativo (comportamento odierno, nessuna
@@ -85,6 +99,12 @@ const OLLAMA_MODEL_CAPABILITIES = Object.freeze({
85
99
  // (2026-09-11). PARALLEL NON dichiarato: default conservativo false finche'
86
100
  // non misurato su device.
87
101
  'deepseek-v4.1-flash': Object.freeze({ input: ['text', 'image'], reasoning: true, supportsParallelToolCalls: false }),
102
+ // gemma4: badge vision/tools/thinking/audio/cloud (2026-09-12); scheda Input
103
+ // Text, Image. PARALLEL NON dichiarato: default conservativo false.
104
+ 'gemma4': Object.freeze({ input: ['text', 'image'], reasoning: true, supportsParallelToolCalls: false }),
105
+ // qwen3.5: badge vision/tools/thinking/cloud (2026-09-12); scheda Input
106
+ // Text, Image. PARALLEL NON dichiarato: default conservativo false.
107
+ 'qwen3.5': Object.freeze({ input: ['text', 'image'], reasoning: true, supportsParallelToolCalls: false }),
88
108
  });
89
109
  // Descrittori dell'engine ollama-cloud per la generazione del catalogo client:
90
110
  // stessa forma dei descrittori custom/PI (id, label, contextWindow, input,
@@ -269,20 +289,47 @@ const ALIBABA_PI_MODELS = Object.freeze([
269
289
  // "ha visto l'immagine" da "ha indovinato". Formati immagine per wire:
270
290
  // Messages `source.base64`, Chat `image_url` data URI, Responses
271
291
  // `input_image`.
292
+ //
293
+ // DELTA 2026-09-12 («a segnale»): il piano Go non genera oggi
294
+ // (401 CreditsError su ogni generazione, chiave valida solo sul listing
295
+ // /models -> 200). Le aggiunte qui sotto NON sono misure e2e: sono
296
+ // SIGNAL-ONLY — nessun rifiuto di formato osservato sul gateway
297
+ // (CreditsError = wire accettata, crediti mancati dopo) e id presenti sia
298
+ // sul listing gateway (37) sia nella CLI `opencode models` 1.2.6 (27).
299
+ // eccezione: `qwen3.8-flash` rifiuta Responses con ModelError «format
300
+ // openai» (misurato 2026-09-12): entra SOLO in MESSAGES e CHAT.
301
+ // Rimossi i deprecated `glm-5`, `kimi-k2.5`, `minimax-m2.5`,
302
+ // `qwen3.5-plus`: status=deprecated upstream, assenti dalla CLI 1.2.6
303
+ // (sul gateway restano solo come listing). Storico LIMITS conservato nel
304
+ // commento della tabella.
272
305
  const OPENCODE_GO_MESSAGES_MODELS = Object.freeze([
273
- 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro', 'glm-5.2', 'glm-5.1', 'glm-5',
274
- 'minimax-m3', 'minimax-m2.7', 'minimax-m2.5',
275
- 'qwen3.8-max', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.5-plus',
306
+ // Misurati e2e 2026-08-11 (matrice completa nel commento sopra):
307
+ 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro',
308
+ 'glm-5.2', 'glm-5.1', 'minimax-m3', 'minimax-m2.7',
309
+ 'qwen3.8-max', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus',
310
+ // Signal-only 2026-09-12 (CreditsError, nessun rifiuto formato):
311
+ 'glm-5.3', 'glm-5.3-flash', 'longcat-2.0', 'deepseek-v4.1-flash',
312
+ 'hy4-preview', 'qwen3.8-flash',
276
313
  ]);
277
314
  const OPENCODE_GO_RESPONSES_MODELS = Object.freeze([
278
- 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro', 'gpt-5.6-luna', 'grok-4.6', 'glm-5.2', 'glm-5.1', 'glm-5',
315
+ // Misurati e2e 2026-08-11:
316
+ 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro',
317
+ 'gpt-5.6-luna', 'grok-4.6', 'glm-5.2', 'glm-5.1',
318
+ // Signal-only 2026-09-12. qwen3.8-flash ESCLUSO di proposito: Responses
319
+ // rifiutata (ModelError «format openai», misurato 2026-09-12).
320
+ 'glm-5.3', 'glm-5.3-flash', 'longcat-2.0', 'deepseek-v4.1-flash',
321
+ 'hy4-preview',
279
322
  ]);
280
323
  const OPENCODE_GO_CHAT_MODELS = Object.freeze([
281
- 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro', 'glm-5.2', 'glm-5.1', 'glm-5',
282
- 'kimi-k3', 'kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5',
283
- 'minimax-m3', 'minimax-m2.7', 'minimax-m2.5',
284
- 'qwen3.8-max', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.5-plus',
285
- 'mimo-v2.5', 'mimo-v2.5-pro', 'hy3', 'gpt-5.6-luna',
324
+ // Misurati e2e 2026-08-11:
325
+ 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-pro',
326
+ 'glm-5.2', 'glm-5.1', 'kimi-k3', 'kimi-k2.7-code', 'kimi-k2.6',
327
+ 'minimax-m3', 'minimax-m2.7', 'qwen3.8-max', 'qwen3.7-max',
328
+ 'qwen3.7-plus', 'qwen3.6-plus', 'mimo-v2.5', 'mimo-v2.5-pro', 'hy3',
329
+ 'gpt-5.6-luna',
330
+ // Signal-only 2026-09-12:
331
+ 'glm-5.3', 'glm-5.3-flash', 'longcat-2.0', 'deepseek-v4.1-flash',
332
+ 'hy4-preview', 'qwen3.8-flash',
286
333
  ]);
287
334
  const OPENCODE_GO_ANTHROPIC_ROOT = 'https://opencode.ai/zen/go';
288
335
  const OPENCODE_GO_API_BASE = 'https://opencode.ai/zen/go/v1';
@@ -296,32 +343,45 @@ const OPENCODE_GO_API_BASE = 'https://opencode.ai/zen/go/v1';
296
343
  //
297
344
  // Restano numeri DICHIARATI, non misurati da noi: se un modello si comporta
298
345
  // come se ne avesse meno, il sospetto va qui prima che sul client.
346
+ // Storico rimosso il 2026-09-12 (id deprecated, fuori CLI 1.2.6): glm-5
347
+ // 202752/32768 · kimi-k2.5 262144/65536 · minimax-m2.5 204800/65536 ·
348
+ // qwen3.5-plus 262144/65536. Rollback: reinserirli qui e nelle tre liste.
299
349
  const OPENCODE_GO_LIMITS = Object.freeze({
300
350
  'deepseek-v4-flash': { context: 1000000, output: 384000 },
301
351
  'deepseek-v4-flash-vision-exp': { context: 1000000, output: 384000 },
302
352
  'deepseek-v4-pro': { context: 1000000, output: 384000 },
303
353
  'glm-5.2': { context: 1000000, output: 131072 },
304
354
  'glm-5.1': { context: 202752, output: 32768 },
305
- 'glm-5': { context: 202752, output: 32768 },
306
355
  'kimi-k3': { context: 1048576, output: 131072 },
307
356
  'kimi-k2.7-code': { context: 262144, output: 262144 },
308
357
  'kimi-k2.6': { context: 262144, output: 65536 },
309
- 'kimi-k2.5': { context: 262144, output: 65536 },
310
358
  'minimax-m3': { context: 1000000, output: 131072 },
311
359
  'minimax-m2.7': { context: 204800, output: 131072 },
312
- 'minimax-m2.5': { context: 204800, output: 65536 },
313
360
  'qwen3.8-max': { context: 1000000, output: 131072 },
314
361
  'qwen3.7-max': { context: 1000000, output: 65536 },
315
362
  'qwen3.7-plus': { context: 1000000, output: 65536 },
316
363
  'qwen3.6-plus': { context: 1000000, output: 65536 },
317
- 'qwen3.5-plus': { context: 262144, output: 65536 },
318
364
  'mimo-v2.5': { context: 1000000, output: 128000 },
319
365
  'mimo-v2.5-pro': { context: 1048576, output: 128000 },
366
+ // hy3: NON allineato a models.dev (che ora dichiara output 128000):
367
+ // fonte divergente, resta il valore trascritto 2026-08-11 finche' non
368
+ // ricontrollato alla fonte. (divergenza segnalata in referto del 2026-09-12)
320
369
  hy3: { context: 256000, output: 64000 },
321
370
  'gpt-5.6-luna': { context: 1050000, output: 128000 },
322
371
  // grok-4.5 -> grok-4.6 (2026-08-27): id aggiornato al modello reale;
323
372
  // limiti confermati da models.dev (xai e opencode-go: 500000/500000).
324
373
  'grok-4.6': { context: 500000, output: 500000 },
374
+ // --- DICHIARATI models.dev 2026-09-12, NON misurati da noi: il piano Go
375
+ // rispondeva 401 CreditsError a ogni generazione il 2026-09-12, quindi
376
+ // nessun numero qui sotto e' stato verificato sul comportamento reale.
377
+ // Wire: nessun rifiuto di formato osservato (signal-only), tranne
378
+ // qwen3.8-flash su Responses (ModelError «format openai», misurato).
379
+ 'deepseek-v4.1-flash': { context: 1000000, output: 384000 },
380
+ 'glm-5.3': { context: 1000000, output: 131072 },
381
+ 'glm-5.3-flash': { context: 1000000, output: 131072 },
382
+ 'longcat-2.0': { context: 1000000, output: 131072 },
383
+ 'qwen3.8-flash': { context: 1000000, output: 131072 },
384
+ 'hy4-preview': { context: 1024000, output: 64000 },
325
385
  });
326
386
 
327
387
  function opencodeGoContextFor(model) {
@@ -1708,6 +1768,13 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1708
1768
  // env, e i rami di trattamento (che confrontano il nome nuovo) non scattano:
1709
1769
  // la cella parte, sembra a posto, e gira con i parametri sbagliati.
1710
1770
  const env = {}; const args = []; const model = canonicalModel(cell?.model || spec.model);
1771
+ // Identita' della cella protetta (solo codex-vl, l'unico client con un
1772
+ // REQUIRED). La DECISIONE sul canale nasce qui, dove nasce il valore di
1773
+ // CODEX_APP_SERVER_IDENTITY_REQUIRED, e viaggia col payload: se la derivasse
1774
+ // il supervisore (cell-exec aggiungeva sempre fd 3:4) i due potevano
1775
+ // divergere. Divergono, e una cella standalone muore sull'handshake.
1776
+ let identityChannel = null;
1777
+ let identityAuthorityUnavailable = '';
1711
1778
  // V-69: motivo per cui il prompt per-cella di vl NON e' stato composto
1712
1779
  // (versione del runtime che non regge, o file non scrivibile in sicurezza).
1713
1780
  // Stringa vuota = consegnato. Sale con l'engine risolto come vlPromptDegraded.
@@ -2044,6 +2111,24 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2044
2111
  // as the runtime compatibility override for callers that do not carry a
2045
2112
  // parsed engine definition. Other managed clients never receive this key.
2046
2113
  if (spec.client === 'codex-vl') {
2114
+ // REQUIRED=1 ha senso SOLO quando l'authority mode e' attivo e
2115
+ // l'authority e' costruibile (credenziali presenti). Fuori di quello il
2116
+ // relay non puo' emettere subject: forzare 1 produce daemon che non
2117
+ // partono (fail-closed senza canale) o attach con `revoked` sistematico.
2118
+ const identityMode = cfg.fleetIdentityMode
2119
+ || (cfg.fleet && cfg.fleet.identity && cfg.fleet.identity.mode)
2120
+ || 'legacy';
2121
+ const identityCredentialsDistinct = (a, b) => (a && b
2122
+ ? Buffer.from(String(a)).compare(Buffer.from(String(b))) !== 0
2123
+ : false);
2124
+ const identityAuthorityReady = !!(cfg.identityAuthority
2125
+ || (cfg.identityDaemonCredential && cfg.identityLauncherCredential
2126
+ && identityCredentialsDistinct(cfg.identityDaemonCredential, cfg.identityLauncherCredential)));
2127
+ const identityAuthorityFault = cfg.identityAuthorityFault
2128
+ || (cfg.identityDaemonCredential && cfg.identityLauncherCredential
2129
+ && !identityCredentialsDistinct(cfg.identityDaemonCredential, cfg.identityLauncherCredential)
2130
+ ? 'daemon and launcher credentials are identical' : null);
2131
+ const logLine = typeof cfg.log === 'function' ? cfg.log : null;
2047
2132
  const definitionEnv = engine && engine.env;
2048
2133
  const runtimeEnv = cfg && cfg.env;
2049
2134
  const hasDefinitionOverride = definitionEnv && Object.prototype.hasOwnProperty.call(
@@ -2052,12 +2137,49 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2052
2137
  const hasRuntimeOverride = runtimeEnv && Object.prototype.hasOwnProperty.call(
2053
2138
  runtimeEnv, CODEX_APP_SERVER_IDENTITY_REQUIRED,
2054
2139
  );
2140
+ const authorityActive = identityMode === 'authority' && identityAuthorityReady;
2141
+ // Authority configurata ma non costruibile (credenziali assenti, identiche
2142
+ // o con permessi sbagliati): la cella protetta NON parte. Esportare '0' con
2143
+ // una riga di log era un degrado silenzioso — il child partiva standalone e
2144
+ // il motivo viveva solo nel log. Il rifiuto lo applica il launcher, col
2145
+ // codice IDENTITY_AUTHORITY_UNAVAILABLE (runtime.js); qui si dichiara il
2146
+ // MOTIVO, senza valori.
2147
+ const authorityUnavailable = identityMode === 'authority' && !identityAuthorityReady
2148
+ ? (identityAuthorityFault || 'authority credentials missing')
2149
+ : '';
2055
2150
  if (hasDefinitionOverride && typeof definitionEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED] === 'string') {
2056
2151
  env[CODEX_APP_SERVER_IDENTITY_REQUIRED] = definitionEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED];
2152
+ identityChannel = env[CODEX_APP_SERVER_IDENTITY_REQUIRED] === '1';
2153
+ if (!authorityActive && identityChannel && logLine) {
2154
+ logLine(`fleet identity: override definition force CODEX_APP_SERVER_IDENTITY_REQUIRED=1 `
2155
+ + `but identity mode is '${identityMode}' without authority credentials`);
2156
+ }
2057
2157
  } else if (hasRuntimeOverride && typeof runtimeEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED] === 'string') {
2058
2158
  env[CODEX_APP_SERVER_IDENTITY_REQUIRED] = runtimeEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED];
2059
- } else {
2159
+ identityChannel = env[CODEX_APP_SERVER_IDENTITY_REQUIRED] === '1';
2160
+ if (!authorityActive && identityChannel && logLine) {
2161
+ logLine(`fleet identity: override runtime force CODEX_APP_SERVER_IDENTITY_REQUIRED=1 `
2162
+ + `but identity mode is '${identityMode}' without authority credentials`);
2163
+ }
2164
+ } else if (authorityActive) {
2060
2165
  env[CODEX_APP_SERVER_IDENTITY_REQUIRED] = CODEX_APP_SERVER_IDENTITY_REQUIRED_DEFAULT;
2166
+ identityChannel = env[CODEX_APP_SERVER_IDENTITY_REQUIRED] === '1';
2167
+ } else if (authorityUnavailable) {
2168
+ // Nessuna esportazione: il lancio e' rifiutato prima dello spawn, quindi
2169
+ // il valore non arriverebbe mai a un child. Un override esplicito (rami
2170
+ // sopra) resta una scelta dell'operatore e non entra qui.
2171
+ identityChannel = false;
2172
+ identityAuthorityUnavailable = authorityUnavailable;
2173
+ if (logLine) {
2174
+ logLine('fleet identity: protected cell launch refused — authority configured but unavailable ('
2175
+ + authorityUnavailable + ')');
2176
+ }
2177
+ } else {
2178
+ env[CODEX_APP_SERVER_IDENTITY_REQUIRED] = '0';
2179
+ identityChannel = false;
2180
+ if (logLine) {
2181
+ logLine(`fleet identity: CODEX_APP_SERVER_IDENTITY_REQUIRED=0 (identity mode '${identityMode}')`);
2182
+ }
2061
2183
  }
2062
2184
  }
2063
2185
  // Prompt su argv (0.8.47): SOLO i client che non hanno un percorso classified
@@ -2106,6 +2228,10 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2106
2228
  return { ok: true, info, engine: {
2107
2229
  ...engine, command, args, env,
2108
2230
  promptMode: promptViaDelivery ? 'send-keys' : 'managed-argv', clientBinary: info.binary,
2231
+ // Canale identita' (vedi sopra): assente per i client senza REQUIRED, cosi'
2232
+ // il payload resta quello di prima e il supervisore conserva il default.
2233
+ ...(identityChannel === null ? {} : { identityChannel }),
2234
+ ...(identityAuthorityUnavailable ? { identityAuthorityUnavailable } : {}),
2109
2235
  ...(vlPromptDegraded ? { vlPromptDegraded } : {}),
2110
2236
  ...(spec.client === 'shell' ? { shellOneShot } : {}),
2111
2237
  } };
@@ -41,6 +41,22 @@ function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) ||
41
41
  // boot = definizioni iniziali (loadDefinitions, non null: il caller gia'
42
42
  // e' tornato unavailable su garbage).
43
43
  // ---------------------------------------------------------------------------
44
+ // Preflight identita': un'authority configurata ma non costruibile
45
+ // REFUTA il lancio della cella protetta invece di degradarla a standalone in
46
+ // silenzio. Il motivo non contiene valori: e' la stessa stringa che il
47
+ // risolutore (lib/fleet/managed.js) mette su `identityAuthorityUnavailable`.
48
+ // Il codice e' stabile e destinato all'operatore, come gli altri IDENTITY_*.
49
+ function identityLaunchRefusal(launchEngine) {
50
+ const reason = launchEngine && launchEngine.identityAuthorityUnavailable;
51
+ if (!reason) return null;
52
+ return {
53
+ status: 500,
54
+ code: 'IDENTITY_AUTHORITY_UNAVAILABLE',
55
+ message: `fleet identity: authority configurata ma non costruibile (${reason}); `
56
+ + 'la cella protetta non parte',
57
+ };
58
+ }
59
+
44
60
  function createBuiltinRuntime(ctx) {
45
61
  const {
46
62
  cfg, home, defsPath, tmuxBin, readonly, launchBroker, leaseManager, boot, ensureProtection,
@@ -214,6 +230,15 @@ function createBuiltinRuntime(ctx) {
214
230
  const log = typeof cfg.log === 'function' ? cfg.log : console.warn;
215
231
  log(`fleet vl ${cell.id}: ${launchEngine.vlPromptDegraded}`);
216
232
  }
233
+ // L'authority e' configurata ma non e' costruibile — il child non
234
+ // partirebbe con un'identita' verificabile, quindi non parte affatto.
235
+ // Prima si esportava REQUIRED=0 con una riga di log: la cella si avviava
236
+ // standalone e il degrado si scopriva solo leggendo il log.
237
+ const refusal = identityLaunchRefusal(launchEngine);
238
+ if (refusal) {
239
+ throw httpError(refusal.status, refusal.message, null,
240
+ { phase: 'preflight', code: refusal.code });
241
+ }
217
242
  }
218
243
 
219
244
  // (2) trust del command PRIMA di lanciare
@@ -264,6 +289,10 @@ function createBuiltinRuntime(ctx) {
264
289
  NEXUSCREW_MCP_SESSION: cell.tmuxSession,
265
290
  },
266
291
  ...(identity ? { identity } : {}),
292
+ // La decisione sul canale viaggia col payload: il supervisore non la
293
+ // ricalcola (era il punto in cui i due potevano divergere).
294
+ ...(typeof launchEngine.identityChannel === 'boolean'
295
+ ? { identityChannel: launchEngine.identityChannel } : {}),
267
296
  ...(leaseInfo ? { lease: { cellId: cell.id, launchEpoch: leaseInfo.launchEpoch, stablePath: leaseInfo.stablePath } } : {}),
268
297
  supervise: {
269
298
  enabled: !launchEngine.shellOneShot,
@@ -568,4 +597,4 @@ function createBuiltinRuntime(ctx) {
568
597
  };
569
598
  }
570
599
 
571
- module.exports = { createBuiltinRuntime, findCell, findEngine, STATUS_TTL_MS };
600
+ module.exports = { createBuiltinRuntime, findCell, findEngine, identityLaunchRefusal, STATUS_TTL_MS };
@@ -15,6 +15,7 @@ const { readTokenSafe } = require('../auth/token.js');
15
15
  const { cellIdFromTmuxSession, tmuxSessionForCell } = require('../fleet/definitions.js');
16
16
  const {
17
17
  identityContextError, IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
18
+ IDENTITY_CONTEXT_VERIFIED_ENV_INVALID,
18
19
  } = require('./identity-schema.js');
19
20
 
20
21
  const INTROSPECT_TIMEOUT_MS = 8000;
@@ -27,6 +28,40 @@ function identityChannelPath({ tokenPath, session }) {
27
28
  return path.join(identityChannelDir(tokenPath), `${session}.json`);
28
29
  }
29
30
 
31
+ // i metadati verified consegnati dal launcher VL al figlio MCP
32
+ // (solo su sessione bound). La fonte e' PRESENTE se la version key esiste:
33
+ // presence parziale o valori incompleti = fonte invalida (fail-closed), mai
34
+ // assenza. Nessun valore torna al chiamante: solo coerenza booleana.
35
+ const VERIFIED_ENV_VERSION_KEY = 'NEXUSCREW_VERIFIED_ENV_VERSION';
36
+ const VERIFIED_ENV_FIELDS = Object.freeze({
37
+ ownerInstanceId: 'NEXUSCREW_VERIFIED_OWNER_INSTANCE_ID',
38
+ cellId: 'NEXUSCREW_VERIFIED_CELL_ID',
39
+ incarnationId: 'NEXUSCREW_VERIFIED_INCARNATION_ID',
40
+ bindingId: 'NEXUSCREW_VERIFIED_BINDING_ID',
41
+ origin: 'NEXUSCREW_VERIFIED_ORIGIN',
42
+ threadId: 'NEXUSCREW_VERIFIED_THREAD_ID',
43
+ });
44
+
45
+ function readVerifiedEnv(env) {
46
+ const raw = env && env[VERIFIED_ENV_VERSION_KEY];
47
+ if (typeof raw !== 'string') return null; // del tutto assente -> percorso legacy
48
+ const fields = {};
49
+ for (const [key, name] of Object.entries(VERIFIED_ENV_FIELDS)) {
50
+ const value = env[name];
51
+ fields[key] = typeof value === 'string' && value.trim() ? value.trim() : null;
52
+ }
53
+ return { present: true, version: raw.trim(), ...fields };
54
+ }
55
+
56
+ function verifiedEnvFault(verified, { localInstanceId } = {}) {
57
+ if (verified.version !== '1') return 'version';
58
+ if (!verified.ownerInstanceId) return 'owner';
59
+ if (localInstanceId && verified.ownerInstanceId !== localInstanceId) return 'owner';
60
+ if (!verified.cellId) return 'cell';
61
+ if (!verified.incarnationId) return 'incarnation';
62
+ return null;
63
+ }
64
+
30
65
  function readIdentityChannel(filePath) {
31
66
  let raw;
32
67
  try { raw = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (_) { return null; }
@@ -75,13 +110,100 @@ function createMcpIdentityProvider({ config, env = process.env, fetchImpl = fetc
75
110
  const cfg = config || require('../config.js').loadConfig();
76
111
  const tokenPath = cfg && cfg.tokenPath;
77
112
  const port = cfg && cfg.port;
78
- const session = env && env.NEXUSCREW_MCP_SESSION;
79
- if (!tokenPath || !Number.isSafeInteger(port) || !session) return null;
80
- const channelPath = identityChannelPath({ tokenPath, session });
81
- if (!readIdentityChannel(channelPath)) return null;
113
+ const verified = readVerifiedEnv(env);
114
+ const legacySession = env && env.NEXUSCREW_MCP_SESSION;
115
+
116
+ // la fonte verified ha PRECEDENZA ASSOLUTA. Se i metadati verified
117
+ // sono presenti il provider esiste SEMPRE (anche con metadati incompleti):
118
+ // la validazione completa e' per-richiesta e fallisce closed con
119
+ // VERIFIED_ENV_INVALID. Mai un ritorno a null che riaprirebbe il percorso
120
+ // legacy accanto a metadati verified di provenienza non dimostrata.
121
+ let mode = null;
122
+ let channelSession = null;
123
+ if (verified && verified.present) {
124
+ mode = 'verified-env';
125
+ // (c) cellId -> tmuxSession con la stessa mappatura deterministica del
126
+ // launcher (definitions.tmuxSessionForCell): il canale identity di quella
127
+ // sessione e' l'unico archivo condiviso della cella su questo nodo.
128
+ channelSession = verified.cellId ? tmuxSessionForCell(verified.cellId) : null;
129
+ } else if (legacySession) {
130
+ mode = 'legacy-session';
131
+ channelSession = legacySession;
132
+ }
133
+ if (!tokenPath || !Number.isSafeInteger(port) || !mode) return null;
134
+ if (mode === 'legacy-session' && !channelSession) return null;
135
+ // In verified mode un canale sessione non derivabile NON degrada a null:
136
+ // il provider resta in piedi e fallisce closed per-richiesta.
137
+ const channelPath = identityChannelPath({ tokenPath, session: channelSession || '__unverified__' });
138
+ if (mode === 'legacy-session' && !readIdentityChannel(channelPath)) return null;
82
139
  const baseUrl = `http://127.0.0.1:${port}`;
83
140
 
84
- async function introspectChannel() {
141
+ // l'ownerInstanceId dichiarato deve coincidere con l'instanceId
142
+ // locale del nodo. Fonte: config.injectabile (test) oppure nodes.json.
143
+ function localInstanceId() {
144
+ if (typeof cfg.identityOwnerInstanceId === 'string' && cfg.identityOwnerInstanceId) {
145
+ return cfg.identityOwnerInstanceId;
146
+ }
147
+ try {
148
+ const nodesStore = require('../nodes/store.js');
149
+ const nodesPath = cfg.nodesPath
150
+ || nodesStore.defaultNodesPath(cfg.home || require('node:os').homedir());
151
+ const store = nodesStore.loadStore(nodesPath);
152
+ return (store && store.nodeId) || null;
153
+ } catch (_) {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ function verifiedFault() {
159
+ const fault = verifiedEnvFault(verified, { localInstanceId: localInstanceId() });
160
+ if (fault) return fault;
161
+ if (!channelSession) return 'cell';
162
+ if (!readIdentityChannel(channelPath)) return 'channel';
163
+ return null;
164
+ }
165
+
166
+ async function introspectVerified() {
167
+ const fault = verifiedFault();
168
+ if (fault) {
169
+ throw identityContextError(IDENTITY_CONTEXT_VERIFIED_ENV_INVALID,
170
+ `NEXUSCREW_MCP_IDENTITY_VERIFIED_ENV_INVALID: verified env incoerente (${fault})`);
171
+ }
172
+ let response;
173
+ try {
174
+ const token = readTokenSafe(tokenPath);
175
+ response = await fetchImpl(`${baseUrl}/api/lease/introspect`, {
176
+ method: 'POST',
177
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
178
+ body: JSON.stringify({ proof: readIdentityChannel(channelPath).proof }),
179
+ signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS),
180
+ });
181
+ } catch (_) {
182
+ throw identityContextError(IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
183
+ 'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE: authority non raggiungibile');
184
+ }
185
+ if (response.status >= 500) {
186
+ throw identityContextError(IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
187
+ 'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE: authority non raggiungibile');
188
+ }
189
+ if (!response.ok) {
190
+ throw identityContextError(IDENTITY_CONTEXT_VERIFIED_ENV_INVALID,
191
+ 'NEXUSCREW_MCP_IDENTITY_VERIFIED_ENV_INVALID: introspezione rifiutata dall\'authority');
192
+ }
193
+ const out = await response.json().catch(() => null);
194
+ const coherent = out && out.status === 'live' && out.identityMode === 'authority'
195
+ && out.cellId === verified.cellId && out.tmuxSession === channelSession
196
+ && typeof out.instanceId === 'string' && out.instanceId === verified.ownerInstanceId
197
+ && Number.isSafeInteger(Number(out.expiresAt)) && Number(out.expiresAt) > now()
198
+ && typeof out.incarnationId === 'string' && out.incarnationId === verified.incarnationId;
199
+ if (!coherent) {
200
+ throw identityContextError(IDENTITY_CONTEXT_VERIFIED_ENV_INVALID,
201
+ 'NEXUSCREW_MCP_IDENTITY_VERIFIED_ENV_INVALID: introspezione non coerente coi metadati verified');
202
+ }
203
+ return { context: sharedContextFromIntrospection({ ...out, expiresAt: Number(out.expiresAt) }, now()), proof: readIdentityChannel(channelPath).proof };
204
+ }
205
+
206
+ async function introspectLegacy() {
85
207
  const channel = readIdentityChannel(channelPath);
86
208
  if (!channel) return { verified: false, mode: 'shared' };
87
209
  let response;
@@ -113,11 +235,16 @@ function createMcpIdentityProvider({ config, env = process.env, fetchImpl = fetc
113
235
  return { context: sharedContextFromIntrospection({ ...out, expiresAt: Number(out.expiresAt) }, now()), proof: channel.proof };
114
236
  }
115
237
 
238
+ const introspectChannel = mode === 'verified-env' ? introspectVerified : introspectLegacy;
239
+
116
240
  async function provider({ tool } = {}) {
117
241
  void tool; // il canale lease è per-connessione cella, non per-tool
118
242
  const out = await introspectChannel();
119
243
  return out.context ? out.context : out;
120
244
  }
245
+ // il server espone `source: 'verified-env'` in nc_identity quando la
246
+ // fonte dei metadati e' il launcher verified e non la sessione legacy.
247
+ provider.identitySource = mode === 'verified-env' ? 'verified-env' : 'online';
121
248
  provider.currentProof = () => {
122
249
  const channel = readIdentityChannel(channelPath);
123
250
  return channel ? channel.proof : null;
@@ -129,4 +256,5 @@ module.exports = {
129
256
  createMcpIdentityProvider,
130
257
  persistIdentityChannel,
131
258
  identityChannelPath,
259
+ readVerifiedEnv,
132
260
  };
@@ -9,6 +9,7 @@ const IDENTITY_CONTEXT_MISSING = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_MISSING';
9
9
  const IDENTITY_CONTEXT_UNVERIFIED = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_UNVERIFIED';
10
10
  const IDENTITY_CONTEXT_FROM_MISMATCH = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_FROM_MISMATCH';
11
11
  const IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE = 'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE';
12
+ const IDENTITY_CONTEXT_VERIFIED_ENV_INVALID = 'NEXUSCREW_MCP_IDENTITY_VERIFIED_ENV_INVALID';
12
13
 
13
14
  function identityContextError(code, message) {
14
15
  const error = new Error(message);
@@ -163,6 +164,7 @@ module.exports = {
163
164
  IDENTITY_CONTEXT_UNVERIFIED,
164
165
  IDENTITY_CONTEXT_FROM_MISMATCH,
165
166
  IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
167
+ IDENTITY_CONTEXT_VERIFIED_ENV_INVALID,
166
168
  identityContextError,
167
169
  normalizeIdentityContext,
168
170
  };