@mmmbuto/nexuscrew 0.9.19 → 0.9.21

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.
@@ -22,6 +22,7 @@ const { createReceiptStore } = require('./receipt.js');
22
22
  const { createSpeakRateLimiter } = require('./rate-limit.js');
23
23
  const { createGroupReceiptStore } = require('./group-receipt.js');
24
24
  const { createGroupSpeaker } = require('./group-speak.js');
25
+ const { createIdentityBindingGuard } = require('../identity/binding-guard.js');
25
26
 
26
27
  const UTTERANCE_ID_RE = /^[A-Za-z0-9._:-]{8,128}$/;
27
28
  const LANG_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,34})$/;
@@ -103,6 +104,24 @@ function parseGroupSpeakBody(body) {
103
104
  }
104
105
 
105
106
  function audioRoutes(deps = {}) {
107
+ const bindingGuard = createIdentityBindingGuard({
108
+ fleetP: deps.fleetP || null,
109
+ instanceId: deps.localNodeId || null,
110
+ now: deps.now || Date.now,
111
+ sharedRequired: deps.identityMode === 'authority',
112
+ });
113
+
114
+ async function guardBinding(req) {
115
+ try {
116
+ return await bindingGuard.verify(req, { localOnly: true });
117
+ } catch (e) {
118
+ return e;
119
+ }
120
+ }
121
+
122
+ function bindingRejected(res, error) {
123
+ return send(res, 403, { error: error.message, code: error.code });
124
+ }
106
125
  if (!deps.originResolver || typeof deps.originResolver.resolve !== 'function') {
107
126
  throw new Error('audioRoutes: originResolver e obbligatorio (origine verificata)');
108
127
  }
@@ -210,6 +229,8 @@ function audioRoutes(deps = {}) {
210
229
 
211
230
  r.post('/speak', async (req, res) => {
212
231
  const resolved = await originOr401(req, res); if (!resolved) return undefined;
232
+ const binding = await guardBinding(req);
233
+ if (binding instanceof Error) return bindingRejected(res, binding);
213
234
  const { origin, trust, visited } = resolved;
214
235
  const parsed = parseSpeakBody(req.body, { federated: trust === 'federated' });
215
236
  if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
@@ -256,6 +277,8 @@ function audioRoutes(deps = {}) {
256
277
 
257
278
  r.post('/stop', async (req, res) => {
258
279
  const resolved = await originOr401(req, res); if (!resolved) return undefined;
280
+ const binding = await guardBinding(req);
281
+ if (binding instanceof Error) return bindingRejected(res, binding);
259
282
  const { origin } = resolved;
260
283
  const parsed = parseStopBody(req.body, { federated: resolved.trust === 'federated' });
261
284
  if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
@@ -326,6 +349,8 @@ function audioRoutes(deps = {}) {
326
349
  r.post('/groups/speak', async (req, res) => {
327
350
  const resolved = await originOr401(req, res); if (!resolved) return undefined;
328
351
  if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
352
+ const binding = await guardBinding(req);
353
+ if (binding instanceof Error) return bindingRejected(res, binding);
329
354
  const parsed = parseGroupSpeakBody(req.body);
330
355
  if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
331
356
  if (readonly()) return send(res, 422, { status: 'refused', reason: 'readonly' });
@@ -350,6 +375,8 @@ function audioRoutes(deps = {}) {
350
375
  r.post('/groups/stop', async (req, res) => {
351
376
  const resolved = await originOr401(req, res); if (!resolved) return undefined;
352
377
  if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
378
+ const binding = await guardBinding(req);
379
+ if (binding instanceof Error) return bindingRejected(res, binding);
353
380
  if (!onlyKeys(req.body, new Set(['utteranceId'])) || typeof req.body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(req.body.utteranceId)) {
354
381
  return send(res, 400, { status: 'refused', reason: 'invalid-utterance' });
355
382
  }
@@ -3,6 +3,7 @@
3
3
  const express = require('express');
4
4
  const { isValidSession } = require('../files/store.js');
5
5
  const { submitTextOk } = require('../tmux/actions.js');
6
+ const { createIdentityBindingGuard } = require('../identity/binding-guard.js');
6
7
 
7
8
  const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
8
9
  const CELL_LABEL_MAX = 64;
@@ -68,7 +69,20 @@ function validIdentity(value) {
68
69
  && isValidSession(value.tmuxSession);
69
70
  }
70
71
 
71
- function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now = () => Date.now(), diagnostics = null }) {
72
+ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now = () => Date.now(), diagnostics = null, identityMode = 'legacy' }) {
73
+ const bindingGuard = createIdentityBindingGuard({
74
+ fleetP, instanceId, now,
75
+ sharedRequired: identityMode === 'authority',
76
+ });
77
+
78
+ async function guardBinding(req, expected) {
79
+ try {
80
+ return await bindingGuard.verify(req, { expected, localOnly: false });
81
+ } catch (e) {
82
+ return e;
83
+ }
84
+ }
85
+
72
86
  const r = express.Router();
73
87
 
74
88
  async function status() {
@@ -111,7 +125,7 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
111
125
  });
112
126
  } catch (_) {}
113
127
  };
114
- const reject = (code, reason) => { logSend('warn', 'CELL_MESSAGE_REJECTED', reason); return res.status(code).json({ error: reason }); };
128
+ const reject = (code, reason, extra = null) => { logSend('warn', 'CELL_MESSAGE_REJECTED', reason); return res.status(code).json({ error: reason, ...(extra || {}) }); };
115
129
  const keys = Object.keys(body);
116
130
  if (keys.some((key) => !['id', 'from', 'to', 'message'].includes(key))
117
131
  || !MESSAGE_ID_RE.test(String(body.id || ''))
@@ -131,6 +145,20 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
131
145
  if (!visited.length && body.from.instanceId !== localId) {
132
146
  return reject(403, 'mittente remoto senza route autenticata');
133
147
  }
148
+ // Mittente locale: la tupla dichiarata deve corrispondere a una cella della
149
+ // directory attiva. Il nome di sessione non è una verifica: la fa lo stato vivo.
150
+ if (body.from.instanceId === localId) {
151
+ const directory = publicCells(await status(), localId, now());
152
+ const sender = directory.find((cell) => cell.cell === body.from.cell
153
+ && cell.tmuxSession === body.from.tmuxSession && cell.active === true);
154
+ if (!sender) return reject(403, 'mittente locale non verificato');
155
+ }
156
+ // Binding identity shared: presentato -> verificato server-side completo,
157
+ // mai accettato per sola coerenza formale né degradato a percorso legacy.
158
+ const binding = await guardBinding(req, body.from);
159
+ if (binding instanceof Error) {
160
+ return reject(403, binding.message, { code: binding.code });
161
+ }
134
162
  try {
135
163
  const cells = publicCells(await status(), localId, now());
136
164
  const target = cells.find((cell) => cell.cell === body.to.cell
@@ -1516,8 +1516,18 @@ function dispatch(argv, opts = {}) {
1516
1516
  if (cmd === 'mcp') {
1517
1517
  // Server MCP stdio (bridge cella→operatore): stdout e' il canale JSON-RPC, quindi
1518
1518
  // NESSUN log qui. keepAlive: resta vivo finche' il client tiene aperto stdin.
1519
+ // Il provider produttivo esiste soltanto se la cella ha un canale identity
1520
+ // authority persistito: senza canale il bridge resta sul percorso embedded
1521
+ // legacy, senza mai trasformare una dichiarazione in identita verificata.
1519
1522
  const startMcpImpl = opts.startMcpImpl || require('../mcp/server.js').startMcp;
1520
- startMcpImpl();
1523
+ const cfg = opts.config || require('../config.js').loadConfig();
1524
+ const provider = opts.identityContextProvider !== undefined
1525
+ ? opts.identityContextProvider
1526
+ : require('../mcp/identity-provider.js')
1527
+ .createMcpIdentityProvider({ config: cfg, env: process.env });
1528
+ startMcpImpl(provider
1529
+ ? { ...opts, config: cfg, identityContextProvider: provider }
1530
+ : { ...opts, config: cfg });
1521
1531
  return { code: 0, keepAlive: true };
1522
1532
  }
1523
1533
  if (cmd === 'fleet-boot') {
@@ -7,10 +7,30 @@ const { Router } = require('express');
7
7
  const multer = require('multer');
8
8
  const store = require('./store.js');
9
9
  const { pasteArgs } = require('../tmux/actions.js');
10
+ const { createIdentityBindingGuard, expectedFromSession } = require('../identity/binding-guard.js');
10
11
 
11
12
  // Router file-exchange. Nessuno stato: tutto deriva da cfg + filesystem.
12
13
  // notifier (opzionale, MCP bridge): emette la notify di consegna file outbox.
13
- function filesRoutes({ cfg, sessionExists, paste, notifier, readonly = () => false }) {
14
+ function filesRoutes({
15
+ cfg, sessionExists, paste, notifier, readonly = () => false,
16
+ fleetP = null, instanceId = null, identityMode = 'legacy',
17
+ }) {
18
+ const bindingGuard = createIdentityBindingGuard({
19
+ fleetP, instanceId, now: () => Date.now(), sharedRequired: identityMode === 'authority',
20
+ });
21
+
22
+ async function guardBinding(req, session = null) {
23
+ try {
24
+ const expected = expectedFromSession(session, instanceId);
25
+ return await bindingGuard.verify(req, { expected, localOnly: true });
26
+ } catch (e) {
27
+ return e;
28
+ }
29
+ }
30
+
31
+ function bindingRejected(res, error) {
32
+ return res.status(403).json({ error: error.message, code: error.code });
33
+ }
14
34
  const router = Router();
15
35
  const upload = multer({
16
36
  storage: multer.memoryStorage(),
@@ -29,6 +49,8 @@ function filesRoutes({ cfg, sessionExists, paste, notifier, readonly = () => fal
29
49
  if (!store.isValidSession(session) || !sessionExists(session)) {
30
50
  return res.status(404).json({ error: 'sessione tmux inesistente' });
31
51
  }
52
+ const binding = await guardBinding(req, session);
53
+ if (binding instanceof Error) return bindingRejected(res, binding);
32
54
  const saved = store.saveUpload(cfg.filesRoot, session, req.file.buffer, req.file.originalname);
33
55
  // paste=false (tasto allegati del composer): il client appende il path al
34
56
  // testo del composer — niente scrittura PTY. Default = incolla (FilesPanel).
@@ -78,7 +100,7 @@ function filesRoutes({ cfg, sessionExists, paste, notifier, readonly = () => fal
78
100
  // da revisione: gated READONLY — la copia e' una scrittura su disco; il gate
79
101
  // sta PRIMA di ogni altro check (nessun probe di sessione/path in READONLY).
80
102
  const bridgeReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
81
- router.post('/outbox', express.json({ limit: '8kb' }), (req, res) => {
103
+ router.post('/outbox', express.json({ limit: '8kb' }), async (req, res) => {
82
104
  try {
83
105
  if (bridgeReadonly()) {
84
106
  return res.status(403).json({ error: 'READONLY: consegna file bloccata' });
@@ -88,6 +110,8 @@ function filesRoutes({ cfg, sessionExists, paste, notifier, readonly = () => fal
88
110
  if (!store.isValidSession(session) || !sessionExists(session)) {
89
111
  return res.status(404).json({ error: 'sessione tmux inesistente' });
90
112
  }
113
+ const binding = await guardBinding(req, session);
114
+ if (binding instanceof Error) return bindingRejected(res, binding);
91
115
  if (typeof b.path !== 'string' || !path.isAbsolute(b.path)) {
92
116
  return res.status(400).json({ error: 'path deve essere assoluto' });
93
117
  }
@@ -139,10 +163,13 @@ function filesRoutes({ cfg, sessionExists, paste, notifier, readonly = () => fal
139
163
  res.download(full);
140
164
  });
141
165
 
142
- router.delete('/', (req, res) => {
166
+ router.delete('/', async (req, res) => {
143
167
  if (readonly()) return res.status(403).json({ error: 'READONLY: eliminazione file bloccata' });
168
+ const session = String(req.query.session || '');
169
+ const binding = await guardBinding(req, session);
170
+ if (binding instanceof Error) return bindingRejected(res, binding);
144
171
  const ok = store.removeFile(
145
- cfg.filesRoot, String(req.query.session || ''), String(req.query.box || ''), String(req.query.name || ''),
172
+ cfg.filesRoot, session, String(req.query.box || ''), String(req.query.name || ''),
146
173
  );
147
174
  if (!ok) return res.status(404).json({ error: 'file non trovato' });
148
175
  res.json({ deleted: true });
@@ -44,6 +44,7 @@ const { validEnvKey } = require('./env-key.js');
44
44
  const { setCredential, removeCredential } = require('./credentials.js');
45
45
  const { createLaunchBroker } = require('./launch-broker.js');
46
46
  const { createLeaseManager } = require('./cell-lease-server.js');
47
+ const { createIdentityAuthority } = require('./identity-authority.js');
47
48
  const { MINIMAL_ENV_KEYS, termuxRuntimePaths } = require('../runtime/env.js');
48
49
  const { requireSharedTmuxProtection } = require('../tmux/shared-server.js');
49
50
 
@@ -84,6 +85,17 @@ function draftFrom(defs) {
84
85
  };
85
86
  }
86
87
 
88
+ const RESERVED_ENV_KEYS = new Set(['MCP_DEVICE']);
89
+
90
+ function rejectReservedEnvKey(source, context) {
91
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return;
92
+ for (const key of Object.keys(source)) {
93
+ if (RESERVED_ENV_KEYS.has(key)) {
94
+ throw httpError(400, `${context}: ${key} è riservato e non può essere impostato dall'engine`);
95
+ }
96
+ }
97
+ }
98
+
87
99
  function warnEngineCap(log, engineId, count) {
88
100
  const emit = typeof log === 'function' ? log : console.warn;
89
101
  emit(`WARN fleet backfill: engine ${engineId} non aggiunto: ${count} engine dichiarati, cap ${CAPS.MAX_ENGINES} raggiunto; riduci gli engine prima di riprovare`);
@@ -684,12 +696,31 @@ async function createBuiltinFleet(cfg = {}) {
684
696
  });
685
697
  await ensureProtection();
686
698
 
687
- // --- Tutti i gate availability passati. Ora (e solo ora) creiamo leaseManager e
688
- // launchBroker e riapriamo gli endpoint lease delle celle note (R3.3.1 recovery,
689
- // rilievo 3). Non fatale: un fallimento di singolo endpoint non blocca il boot.
690
- const leaseManager = cellLeaseEnabled ? createLeaseManager({ ...cfg, home }) : null;
699
+ // --- Tutti i gate availability passati. Ora (e solo ora) creiamo identity,
700
+ // leaseManager e launchBroker. L'authority resta server-side: nessuna
701
+ // credenziale o chiave HMAC attraversa il payload consegnato alla cella.
702
+ const identityMode = cfg.fleetIdentityMode || cfg.fleet?.identity?.mode || 'legacy';
703
+ const identityOwnerInstanceId = typeof cfg.identityOwnerInstanceId === 'function'
704
+ ? cfg.identityOwnerInstanceId()
705
+ : (cfg.identityOwnerInstanceId || null);
706
+ const identityAuthority = cfg.identityAuthority || (
707
+ cfg.identityDaemonCredential && cfg.identityLauncherCredential
708
+ ? createIdentityAuthority({
709
+ dir: cfg.identityAuthorityDir || path.join(home, '.nexuscrew', 'identity-authority'),
710
+ daemonCredential: cfg.identityDaemonCredential,
711
+ launcherCredential: cfg.identityLauncherCredential,
712
+ subjectResolver: cfg.identitySubjectResolver,
713
+ log: cfg.log,
714
+ })
715
+ : null
716
+ );
717
+ const leaseManager = cellLeaseEnabled ? createLeaseManager({ ...cfg, home, identityAuthority }) : null;
691
718
  const launchBroker = cfg.launchBroker || createLaunchBroker({
692
719
  ...cfg, home,
720
+ identityMode,
721
+ identityAuthority,
722
+ identityDaemonCredential: cfg.identityDaemonCredential,
723
+ identityLauncherCredential: cfg.identityLauncherCredential,
693
724
  // R3.1.1: la connessione broker one-shot resta APERTA dopo il payload e diviene
694
725
  // il canale lease del supervisore.
695
726
  ...(leaseManager ? {
@@ -724,6 +755,7 @@ async function createBuiltinFleet(cfg = {}) {
724
755
  // qui sotto riusa. status/up/down/restart sono INVARIATI.
725
756
  const rt = createBuiltinRuntime({
726
757
  cfg, home, defsPath, tmuxBin, readonly, launchBroker, leaseManager, boot, ensureProtection,
758
+ identityAuthority, identityMode, identityOwnerInstanceId,
727
759
  });
728
760
  const {
729
761
  status, cellStatus, up, down, restart, isCellSession,
@@ -796,6 +828,7 @@ async function createBuiltinFleet(cfg = {}) {
796
828
  if (readonly()) throw httpError(403, 'READONLY: define-engine bloccato');
797
829
  if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione engine mancante');
798
830
  if (def.id != null && findEngine(reloadDefs(), def.id)) throw httpError(400, `engine esiste già: ${def.id}`);
831
+ rejectReservedEnvKey(def.env, 'engine env');
799
832
  await mutate(reloadDefs(), (d) => { d.engines.push(def); });
800
833
  return { ok: true, id: def.id };
801
834
  }
@@ -851,6 +884,9 @@ async function createBuiltinFleet(cfg = {}) {
851
884
  if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'env'))) {
852
885
  throw httpError(400, 'id ed env non sono modificabili tramite patch generica');
853
886
  }
887
+ if (envChanges !== undefined) {
888
+ rejectReservedEnvKey(envChanges.set, 'envChanges.set');
889
+ }
854
890
  await mutate(defs, (d) => {
855
891
  const target = findEngine(d, id);
856
892
  for (const [key, value] of Object.entries(patch || {})) {
@@ -1066,6 +1102,9 @@ async function createBuiltinFleet(cfg = {}) {
1066
1102
  || new Set(engine.envKeys).size !== engine.envKeys.length)) {
1067
1103
  throw httpError(400, `envKeys non valido per engine: ${engine.id}`);
1068
1104
  }
1105
+ if (!engine.managed && Array.isArray(engine.envKeys) && engine.envKeys.some((key) => RESERVED_ENV_KEYS.has(key))) {
1106
+ throw httpError(400, `envKeys riservato per engine: ${engine.id}: ${engine.envKeys.filter((key) => RESERVED_ENV_KEYS.has(key)).join(', ')}`);
1107
+ }
1069
1108
  if (!engine.managed && Array.isArray(engine.args) && engine.args.some((arg) => {
1070
1109
  const text = String(arg || '');
1071
1110
  return /(?:bearer\s+|authorization\s*[:=]|(?:api[_-]?key|secret|token)\s*[:=])/i.test(text)
@@ -1435,6 +1474,7 @@ async function createBuiltinFleet(cfg = {}) {
1435
1474
  // MCP delle celle consuma per register/refresh/recovery (B5). Assente se il
1436
1475
  // lease e' disattivato: le route rispondono 501, non 500.
1437
1476
  ...(leaseManager ? { lease: leaseManager } : {}),
1477
+ ...(identityAuthority ? { identityAuthority } : {}),
1438
1478
  };
1439
1479
  }
1440
1480
 
@@ -8,6 +8,9 @@ const net = require('node:net');
8
8
  const path = require('node:path');
9
9
  const { spawn } = require('node:child_process');
10
10
  const { MAX_PAYLOAD } = require('./launch-broker.js');
11
+ const {
12
+ validDaemonChallenge, identityErrorCode, IDENTITY_FRAME_LIMIT, IDENTITY_TIMEOUT_MS,
13
+ } = require('./lease-client.js');
11
14
 
12
15
  const DEFAULT_SUPERVISE = Object.freeze({
13
16
  enabled: true,
@@ -80,6 +83,20 @@ function validRestartPrompt(value) {
80
83
  && (value.readyWaitMs === undefined || validInteger(value.readyWaitMs, 0, 120000));
81
84
  }
82
85
 
86
+ function validIdentity(value) {
87
+ if (value === undefined) return true;
88
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
89
+ const keys = new Set(['audience', 'daemonBootId', 'connectionId', 'challenge', 'grant', 'proof']);
90
+ if (Object.keys(value).some((key) => !keys.has(key))) return false;
91
+ if (typeof value.challenge !== 'string' || !/^[a-f0-9]{64}$/.test(value.challenge)) return false;
92
+ if (!value.grant || typeof value.grant !== 'object' || Array.isArray(value.grant)) return false;
93
+ if (!value.proof || typeof value.proof !== 'object' || Array.isArray(value.proof)) return false;
94
+ return value.grant.kind === 'launch-grant' && value.proof.kind === 'identity-proof'
95
+ && typeof value.audience === 'string' && value.audience.length > 0
96
+ && typeof value.daemonBootId === 'string' && value.daemonBootId.length > 0
97
+ && typeof value.connectionId === 'string' && value.connectionId.length > 0;
98
+ }
99
+
83
100
  function validLease(value) {
84
101
  if (value === undefined) return true;
85
102
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
@@ -93,14 +110,15 @@ function validLease(value) {
93
110
 
94
111
  function validPayload(payload) {
95
112
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
96
- if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease'].includes(key))) return false;
113
+ if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease', 'identity'].includes(key))) return false;
97
114
  if (typeof payload.command !== 'string' || !payload.command || !Array.isArray(payload.args)) return false;
98
115
  if (!payload.env || typeof payload.env !== 'object' || Array.isArray(payload.env)) return false;
99
116
  return payload.args.every((v) => typeof v === 'string')
100
117
  && Object.entries(payload.env).every(([k, v]) => /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k) && typeof v === 'string')
101
118
  && validSupervise(payload.supervise)
102
119
  && validRestartPrompt(payload.restartPrompt)
103
- && validLease(payload.lease);
120
+ && validLease(payload.lease)
121
+ && validIdentity(payload.identity);
104
122
  }
105
123
 
106
124
  function receivePayload(socketPath, nonce, timeoutMs = 5000, opts = {}) {
@@ -150,6 +168,149 @@ function normalizeSupervise(value = {}) {
150
168
  return { ...DEFAULT_SUPERVISE, ...(value || {}) };
151
169
  }
152
170
 
171
+ function identityErrorResponse(id, code, message) {
172
+ return {
173
+ jsonrpc: '2.0',
174
+ id,
175
+ error: { code: -32000, message, data: { code } },
176
+ };
177
+ }
178
+
179
+ function validIdentityRequest(msg) {
180
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return false;
181
+ const keys = Object.keys(msg);
182
+ if (keys.length !== 4 || !['jsonrpc', 'id', 'method', 'params'].every((key) => Object.hasOwn(msg, key))) return false;
183
+ if (msg.jsonrpc !== '2.0' || msg.method !== 'nexuscrew/identity/challengeProof') return false;
184
+ if (!Number.isSafeInteger(msg.id) || msg.id < 0) return false;
185
+ return true;
186
+ }
187
+
188
+ function createIdentityChannel({
189
+ request,
190
+ response,
191
+ generation,
192
+ relay,
193
+ cancel = () => {},
194
+ setTimeout: setTimer = (...args) => setTimeout(...args),
195
+ clearTimeout: clearTimer = (timer) => { if (timer) clearTimeout(timer); },
196
+ } = {}) {
197
+ if (!request || typeof request.on !== 'function'
198
+ || !response || typeof response.write !== 'function') return null;
199
+ let closed = false;
200
+ let nextRequestId = 1;
201
+ let pending = null;
202
+ let buffer = '';
203
+
204
+ const writeResponse = (obj, force = false) => {
205
+ if ((!force && closed) || !response.writable) return false;
206
+ try { response.write(`${JSON.stringify(obj)}\n`); return true; } catch (_) { return false; }
207
+ };
208
+
209
+ const sendError = (id, code, message, force = false) => (
210
+ writeResponse(identityErrorResponse(id, code, message), force)
211
+ );
212
+
213
+ const finishPending = () => {
214
+ if (!pending) return 0;
215
+ const { rpcId } = pending;
216
+ clearTimer(pending.timer);
217
+ try { cancel(pending.requestId); } catch (_) {}
218
+ pending = null;
219
+ return rpcId;
220
+ };
221
+
222
+ const close = (code = null, message = '', rpcId = 0, sendEofError = true) => {
223
+ if (closed) return;
224
+ closed = true;
225
+ const finishedId = finishPending() || 0;
226
+ if (code && sendEofError) sendError(rpcId || finishedId, code, message, true);
227
+ try { request.removeAllListeners('data'); request.destroy(); } catch (_) {}
228
+ try { response.end(); } catch (_) {}
229
+ };
230
+
231
+ const settle = (outcome) => {
232
+ const current = pending;
233
+ if (closed || !current || current.done) return;
234
+ current.done = true;
235
+ clearTimer(current.timer);
236
+ pending = null;
237
+ try { cancel(current.requestId); } catch (_) {}
238
+ if (outcome && outcome.ok === true && outcome.proof && typeof outcome.proof === 'object') {
239
+ writeResponse({ jsonrpc: '2.0', id: current.rpcId, result: { proof: outcome.proof } });
240
+ return;
241
+ }
242
+ const reason = outcome && typeof outcome.reason === 'string' ? outcome.reason : 'identity-unverified';
243
+ const code = identityErrorCode(reason);
244
+ sendError(current.rpcId, code, reason);
245
+ // C7-bis: il canale e' terminato su timeout OLTRE che su revoke — dopo la
246
+ // risposta di errore fd4 va in EOF (la TUI vede il canale chiuso).
247
+ if (code === 'REVOKED' || reason === 'timeout') close(code, reason, current.rpcId, false);
248
+ };
249
+
250
+ const handleLine = (line) => {
251
+ if (closed) return;
252
+ if (pending) {
253
+ let id = 0;
254
+ try {
255
+ const msg = JSON.parse(line);
256
+ id = validIdentityRequest(msg) ? msg.id : 0;
257
+ } catch (_) { id = 0; }
258
+ sendError(id, 'IDENTITY_UNVERIFIED', 'busy');
259
+ return;
260
+ }
261
+ let msg = null;
262
+ try { msg = JSON.parse(line); } catch (_) {
263
+ sendError(0, 'IDENTITY_UNVERIFIED', 'invalid request');
264
+ return;
265
+ }
266
+ if (!validIdentityRequest(msg)
267
+ || !msg.params || typeof msg.params !== 'object' || Array.isArray(msg.params)
268
+ || Object.keys(msg.params).length !== 1 || !Object.hasOwn(msg.params, 'challenge')
269
+ || !validDaemonChallenge(msg.params.challenge)) {
270
+ sendError(validIdentityRequest(msg) ? msg.id : 0, 'IDENTITY_UNVERIFIED', 'invalid request');
271
+ return;
272
+ }
273
+ const requestId = `g${generation}-r${nextRequestId}`;
274
+ nextRequestId += 1;
275
+ pending = {
276
+ requestId, rpcId: msg.id, generation, done: false, timer: null,
277
+ };
278
+ pending.timer = setTimer(() => settle({ ok: false, reason: 'timeout' }), IDENTITY_TIMEOUT_MS);
279
+ if (pending.timer && typeof pending.timer.unref === 'function') pending.timer.unref();
280
+ Promise.resolve(relay(requestId, msg.params.challenge)).then(settle, (error) => {
281
+ settle({ ok: false, reason: error && error.code ? error.code : 'authority-unavailable' });
282
+ });
283
+ };
284
+
285
+ request.on('data', (chunk) => {
286
+ if (closed) return;
287
+ buffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
288
+ if (Buffer.byteLength(buffer, 'utf8') > IDENTITY_FRAME_LIMIT) {
289
+ close();
290
+ return;
291
+ }
292
+ let nl;
293
+ while ((nl = buffer.indexOf('\n')) !== -1) {
294
+ const line = buffer.slice(0, nl);
295
+ buffer = buffer.slice(nl + 1);
296
+ if (line.trim()) handleLine(line);
297
+ if (closed || Buffer.byteLength(buffer, 'utf8') > IDENTITY_FRAME_LIMIT) {
298
+ if (buffer) close();
299
+ break;
300
+ }
301
+ }
302
+ });
303
+ request.once('end', () => close());
304
+ request.once('error', () => close());
305
+ response.once('error', () => close());
306
+
307
+ return {
308
+ close,
309
+ isClosed: () => closed,
310
+ pendingCount: () => (pending ? 1 : 0),
311
+ };
312
+ }
313
+
153
314
  function waitChild(child) {
154
315
  return new Promise((resolve) => {
155
316
  let settled = false;
@@ -291,7 +452,7 @@ async function main(argv = process.argv.slice(2), seams = {}) {
291
452
  const sleep = seams.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
292
453
  const proc = seams.process || process;
293
454
  const writeError = seams.writeError || seams.stderrWrite || ((message) => process.stderr.write(message));
294
- const childEnv = { ...payload.env };
455
+ const childEnv = { ...payload.env, NEXUSCREW_IDENTITY_FD: '3:4' };
295
456
  // tmux injects these only after the broker ticket was created. Preserve them
296
457
  // for the actual TUI and bind NexusCrew MCP callbacks to the owning session.
297
458
  if (process.env.TMUX) childEnv.TMUX = process.env.TMUX;
@@ -305,7 +466,7 @@ async function main(argv = process.argv.slice(2), seams = {}) {
305
466
  let leaseCtl = null;
306
467
  // da revisione interna: current/stopping vivono QUI (prima del lease-client) perche'
307
468
  // la callback onLost qui sotto li riferisce dalla closure.
308
- let current = null; let stopping = false;
469
+ let current = null; let stopping = false; let identityCtl = null;
309
470
  if (payload.lease && leaseSocket && !leaseSocket.destroyed) {
310
471
  const { startLeaseClient } = require('./lease-client.js');
311
472
  leaseCtl = startLeaseClient(leaseSocket, {
@@ -315,6 +476,7 @@ async function main(argv = process.argv.slice(2), seams = {}) {
315
476
  // generation += 1). Passiamo un getter cosicche' il reconnect presenti
316
477
  // sempre la generation corrente, non 0 fisso (riconcilia :301 con :381).
317
478
  generation: () => generation,
479
+ onIdentityDown: () => closeIdentityChannel('REVOKED', 'lease unavailable'),
318
480
  // da revisione interna: lease persa per tutta la grace senza un
319
481
  // reconnect riuscito. Prima il lease-client desisteva in silenzio dopo
320
482
  // 60s e il child restava un orfano senza lease per tutta la sua vita.
@@ -333,6 +495,7 @@ async function main(argv = process.argv.slice(2), seams = {}) {
333
495
  // no-op — mai un kill fantasma su un pid riusato.
334
496
  onLost: () => {
335
497
  writeError('nexuscrew cell supervisor stopped: lease lost (reconnect grace expired)\n');
498
+ closeIdentityChannel('REVOKED', 'lease lost');
336
499
  stopping = true;
337
500
  const target = current;
338
501
  try { if (target) target.kill('SIGTERM'); } catch (_) {}
@@ -349,6 +512,13 @@ async function main(argv = process.argv.slice(2), seams = {}) {
349
512
  }, seams);
350
513
  }
351
514
 
515
+ const closeIdentityChannel = (code = null, message = '') => {
516
+ if (!identityCtl) return;
517
+ const ctl = identityCtl;
518
+ identityCtl = null;
519
+ ctl.close(code, message);
520
+ };
521
+
352
522
  const handlers = new Map();
353
523
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
354
524
  const handler = () => {
@@ -370,9 +540,82 @@ async function main(argv = process.argv.slice(2), seams = {}) {
370
540
  const startedAt = now();
371
541
  const childState = { exited: false };
372
542
  const promptCtl = startGenerationPrompt(payload.restartPrompt, generation, childState, seams);
373
- current = spawnImpl(payload.command, payload.args, { env: childEnv, stdio: 'inherit' });
374
- const result = await waitChild(current);
543
+ current = spawnImpl(payload.command, payload.args, {
544
+ env: childEnv,
545
+ stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'pipe'],
546
+ });
547
+ // R1a (fix5): exit/error del figlio contano SUBITO, prima di qualunque
548
+ // await — se il figlio esce durante l'handshake di generazione, l'evento
549
+ // non deve andare perso (prima i listener arrivavano solo con waitChild,
550
+ // e il supervisore restava appeso).
551
+ let childSettled = false;
552
+ const childExit = new Promise((resolve) => {
553
+ current.once('exit', (code, signal) => {
554
+ childSettled = true;
555
+ resolve({ code: code == null ? 1 : code, signal, error: null });
556
+ });
557
+ current.once('error', (error) => {
558
+ childSettled = true;
559
+ resolve({ code: 1, signal: null, error });
560
+ });
561
+ });
562
+ // R1: transizione di generazione comunicata al lease PRIMA di aprire il
563
+ // canale identita' della generazione nuova. L'exit del figlio ha la
564
+ // precedenza sull'annuncio; FAIL-CLOSED (R1b): solo un annuncio
565
+ // RISOLTO con ok === true E generazione corrispondente apre il canale —
566
+ // ok:false (lease-down, identity-unverified), rifiuto, timeout ed exit
567
+ // restano tutti negativi, con sola diagnostica: il figlio parte
568
+ // comunque, senza identita'.
569
+ if (leaseCtl && generation > 0) {
570
+ const raced = await Promise.race([
571
+ // fix 6 (R1b-bis): il valore RISOLTO va ispezionato.
572
+ // Prima: .then(() => 'ok', ...) scartava il valore e trattava un
573
+ // annuncio risolto {ok:false} come successo, aprendo il canale.
574
+ leaseCtl.announceGeneration(generation).then(
575
+ (announced) => (announced && announced.ok === true && announced.generation === generation
576
+ ? 'ok'
577
+ : `announce-refused:${(announced && announced.reason) || 'generation-mismatch'}`),
578
+ () => 'announce-failed',
579
+ ),
580
+ childExit.then(() => 'exited'),
581
+ ]);
582
+ if (raced === 'ok' && !childSettled) {
583
+ identityCtl = createIdentityChannel({
584
+ request: current && current.stdio && current.stdio[3],
585
+ response: current && current.stdio && current.stdio[4],
586
+ generation,
587
+ relay: leaseCtl
588
+ ? (requestId, challenge) => leaseCtl.challengeProof({ requestId, generation, challenge })
589
+ : async () => ({ ok: false, reason: 'lease-down' }),
590
+ cancel: leaseCtl
591
+ ? (requestId) => leaseCtl.cancelIdentityChallenge(requestId)
592
+ : () => {},
593
+ setTimeout: seams.setTimeout,
594
+ clearTimeout: seams.clearTimeout,
595
+ });
596
+ } else if (raced === 'exited') {
597
+ writeError(`nexuscrew cell: figlio uscito durante l'handshake di generazione (gen ${generation})\n`);
598
+ } else {
599
+ writeError(`nexuscrew cell: generazione ${generation} senza identita' (${raced})\n`);
600
+ }
601
+ } else {
602
+ identityCtl = createIdentityChannel({
603
+ request: current && current.stdio && current.stdio[3],
604
+ response: current && current.stdio && current.stdio[4],
605
+ generation,
606
+ relay: leaseCtl
607
+ ? (requestId, challenge) => leaseCtl.challengeProof({ requestId, generation, challenge })
608
+ : async () => ({ ok: false, reason: 'lease-down' }),
609
+ cancel: leaseCtl
610
+ ? (requestId) => leaseCtl.cancelIdentityChallenge(requestId)
611
+ : () => {},
612
+ setTimeout: seams.setTimeout,
613
+ clearTimeout: seams.clearTimeout,
614
+ });
615
+ }
616
+ const result = await childExit;
375
617
  childState.exited = true;
618
+ closeIdentityChannel();
376
619
  current = null;
377
620
  // R2/R3: la generazione e' finita. Cancella la delivery in volo e ATTESA
378
621
  // del suo termine PRIMA di qualunque nuovo spawn. R9: se l'esito e' un
@@ -432,5 +675,6 @@ if (require.main === module) {
432
675
 
433
676
  module.exports = {
434
677
  DEFAULT_SUPERVISE, LEASE_LOST_KILL_ESCALATION_MS, parseArgs, validSupervise, validRestartPrompt, validPayload, validLease,
435
- receivePayload, sanitizeSpawnError, normalizeSupervise, waitChild, startGenerationPrompt, main,
678
+ receivePayload, sanitizeSpawnError, normalizeSupervise, waitChild, startGenerationPrompt,
679
+ createIdentityChannel, validIdentityRequest, main,
436
680
  };