@mmmbuto/nexuscrew 0.9.23 → 0.9.25

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.
@@ -48,6 +48,9 @@ function identityErrorCode(reason) {
48
48
  switch (reason) {
49
49
  case 'expired': return 'EXPIRED';
50
50
  case 'replay': case 'challenge-replay': return 'REPLAY';
51
+ // Verify v1.1: la tupla della connessione non corrisponde -> stesso codice
52
+ // del mismatch challenge-side lato daemon (prepare: AudienceMismatch).
53
+ case 'challenge_mismatch':
51
54
  case 'audience': case 'daemonBootId': case 'connectionId': return 'AUDIENCE_MISMATCH';
52
55
  case 'lease-down': case 'revoked': case 'generation': return 'REVOKED';
53
56
  case 'timeout': case 'authority-unavailable': case 'authority': return 'AUTHORITY_UNAVAILABLE';
@@ -55,6 +58,21 @@ function identityErrorCode(reason) {
55
58
  }
56
59
  }
57
60
 
61
+ // Forma chiusa della tupla attesa (v1.1), condivisa da lease client e server:
62
+ // nonce hex64, gli altri tre stringhe non vuote limitate; nessun campo extra.
63
+ function validVerifyExpected(value) {
64
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
65
+ const keys = Object.keys(value);
66
+ if (keys.length !== 4) return false;
67
+ for (const key of ['nonce', 'connectionId', 'daemonBootId', 'audience']) {
68
+ if (!Object.hasOwn(value, key)) return false;
69
+ }
70
+ if (typeof value.nonce !== 'string' || !/^[a-f0-9]{64}$/.test(value.nonce)) return false;
71
+ return ['connectionId', 'daemonBootId', 'audience'].every((key) => (
72
+ typeof value[key] === 'string' && value[key].length > 0 && value[key].length <= 200
73
+ ));
74
+ }
75
+
58
76
  function validIdentityRequestId(value) {
59
77
  return (typeof value === 'string' && value.length > 0 && value.length <= 128)
60
78
  || Number.isSafeInteger(value);
@@ -77,6 +95,7 @@ function startLeaseClient(initialSocket, info, seams = {}) {
77
95
  // R3.2: bound di grace per i reconnect (eofAt + GRACE_MS). Oltre non si ritenta.
78
96
  let reconnectDeadline = null;
79
97
  const identityPending = new Map();
98
+ const verifyPending = new Map();
80
99
  // R1: una sola transizione di generazione in volo (il supervisore e' sequenziale).
81
100
  let generationWaiter = null;
82
101
 
@@ -233,6 +252,19 @@ function startLeaseClient(initialSocket, info, seams = {}) {
233
252
  while ((nl = buf.indexOf('\n')) !== -1) {
234
253
  const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
235
254
  let msg; try { msg = JSON.parse(line); } catch (_) { continue; }
255
+ if (msg.type === 'verifyResult' && validIdentityRequestId(msg.requestId)) {
256
+ const pending = verifyPending.get(msg.requestId);
257
+ if (pending) {
258
+ verifyPending.delete(msg.requestId);
259
+ clearTimer(pending.timer);
260
+ if (msg.ok === true && msg.claims && typeof msg.claims === 'object') {
261
+ pending.resolve({ ok: true, claims: msg.claims });
262
+ } else {
263
+ const reason = msg.ok === false && typeof msg.reason === 'string' ? msg.reason : 'identity-unverified';
264
+ pending.resolve({ ok: false, reason });
265
+ }
266
+ }
267
+ }
236
268
  if (msg.type === 'challengeProofResult' && validIdentityRequestId(msg.requestId)) {
237
269
  const pending = identityPending.get(msg.requestId);
238
270
  if (pending) {
@@ -302,6 +334,45 @@ function startLeaseClient(initialSocket, info, seams = {}) {
302
334
  });
303
335
  }
304
336
 
337
+ // verifica ONLINE di un identity proof presso l'authority. Risolve
338
+ // SEMPRE con {ok:true, claims} | {ok:false, reason}: il rifiuto e' una
339
+ // risposta, non un errore di trasporto. Solo timeout/authority giu'
340
+ // viaggiano come rejection (il relay li mappa su ok:false).
341
+ function verifyProof({ requestId, generation, proof, expected } = {}) {
342
+ if (!validIdentityRequestId(requestId) || !Number.isSafeInteger(generation) || generation < 0) {
343
+ return Promise.resolve({ ok: false, reason: 'identity-unverified' });
344
+ }
345
+ if (!proof || typeof proof !== 'object' || Array.isArray(proof)) {
346
+ return Promise.resolve({ ok: false, reason: 'malformed' });
347
+ }
348
+ // Verify v1.1: tupla attesa opzionale (retro-compat v1), schema chiuso.
349
+ if (expected !== undefined && !validVerifyExpected(expected)) {
350
+ return Promise.resolve({ ok: false, reason: 'identity-unverified' });
351
+ }
352
+ if (stopped || !current || current.destroyed || !current.writable || verifyPending.has(requestId)) {
353
+ return Promise.resolve({ ok: false, reason: 'authority-unavailable' });
354
+ }
355
+ return new Promise((resolve, reject) => {
356
+ const pending = { resolve, reject, timer: null };
357
+ pending.timer = setTimer(() => {
358
+ if (!verifyPending.has(requestId)) return;
359
+ verifyPending.delete(requestId);
360
+ reject(Object.assign(new Error('timeout'), { code: 'timeout' }));
361
+ }, IDENTITY_TIMEOUT_MS);
362
+ if (pending.timer && typeof pending.timer.unref === 'function') pending.timer.unref();
363
+ verifyPending.set(requestId, pending);
364
+ const sent = send({
365
+ type: 'verify', requestId, generation, v: 1, proof,
366
+ ...(expected ? { expected } : {}),
367
+ });
368
+ if (!sent) {
369
+ verifyPending.delete(requestId);
370
+ clearTimer(pending.timer);
371
+ reject(Object.assign(new Error('authority-unavailable'), { code: 'authority-unavailable' }));
372
+ }
373
+ });
374
+ }
375
+
305
376
  // R1: annuncia la transizione di generazione sulla connessione viva PRIMA
306
377
  // che il supervisore apra il canale identita' della generazione nuova.
307
378
  function announceGeneration(generation) {
@@ -338,12 +409,18 @@ function startLeaseClient(initialSocket, info, seams = {}) {
338
409
  clearTimer(waiter.timer);
339
410
  waiter.reject(Object.assign(new Error('lease-down'), { code: 'lease-down' }));
340
411
  }
412
+ for (const [, pending] of verifyPending) {
413
+ clearTimer(pending.timer);
414
+ pending.reject(Object.assign(new Error('lease-down'), { code: 'lease-down' }));
415
+ }
416
+ verifyPending.clear();
341
417
  clearTimer(refreshTimer);
342
418
  clearTimer(reconnectTimer);
343
419
  try { current && current.destroy(); } catch (_) {}
344
420
  current = null;
345
421
  },
346
422
  challengeProof,
423
+ verifyProof,
347
424
  announceGeneration,
348
425
  cancelIdentityChallenge: cancelIdentity,
349
426
  _isConnected: () => !!current && !current.destroyed,
@@ -353,6 +430,6 @@ function startLeaseClient(initialSocket, info, seams = {}) {
353
430
  }
354
431
 
355
432
  module.exports = {
356
- startLeaseClient, validDaemonChallenge, identityErrorCode,
433
+ startLeaseClient, validDaemonChallenge, validVerifyExpected, identityErrorCode,
357
434
  IDENTITY_FRAME_LIMIT, IDENTITY_TIMEOUT_MS,
358
435
  };
@@ -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
  };