@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.
package/lib/mcp/server.js CHANGED
@@ -30,12 +30,25 @@ const VERSION = require('../../package.json').version;
30
30
  const MCP_COMPANIONS = require('../../mcp-companions.json');
31
31
  const { TOOLS, IDENTITY_CODE, IDENTITY_REMEDIATION } = require('./tools.js');
32
32
  const cells = require('./cells.js');
33
+ const {
34
+ normalizeIdentityContext, IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
35
+ } = require('./identity-schema.js');
36
+ const { persistIdentityChannel } = require('./identity-provider.js');
33
37
 
34
38
  // Versione protocollo di fallback se il client non ne dichiara una valida.
35
39
  const PROTOCOL_FALLBACK = '2025-03-26';
36
40
  const HTTP_TIMEOUT_MS = 10000;
37
41
  const HTTP_TIMEOUT_CODE = 'NEXUSCREW_HTTP_TIMEOUT';
38
42
  const HTTP_UNREACHABLE_CODE = 'NEXUSCREW_HTTP_UNREACHABLE';
43
+ const IDENTITY_CONTEXT_MISSING = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_MISSING';
44
+ const IDENTITY_CONTEXT_UNVERIFIED = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_UNVERIFIED';
45
+ const IDENTITY_CONTEXT_FROM_MISMATCH = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_FROM_MISMATCH';
46
+
47
+ function identityContextError(code, message) {
48
+ const error = new Error(message);
49
+ error.code = code;
50
+ return error;
51
+ }
39
52
 
40
53
  // Trasporta la causa in forma strutturata tra bridge e directory celle. Il
41
54
  // messaggio resta per l'operatore, ma la classificazione non dipende dalla
@@ -188,6 +201,9 @@ function resolveSession(opts) {
188
201
  return resolveIdentity(opts).then((i) => i.session);
189
202
  }
190
203
 
204
+ // Contratto del resolver online iniettato dal processo NexusCrew autenticato.
205
+ // La normalizzazione condivisa rifiuta contesto incompleto, incoerente o scaduto.
206
+
191
207
  // --- server --------------------------------------------------------------------
192
208
  function createMcpServer(opts = {}) {
193
209
  const input = opts.input || process.stdin;
@@ -202,7 +218,7 @@ function createMcpServer(opts = {}) {
202
218
  const cfg = opts.config || loadConfig();
203
219
  const baseUrl = `http://127.0.0.1:${cfg.port}`;
204
220
 
205
- // Identita' risolta una volta e cacheata — ma solo se riesce.
221
+ // Identita' locale storica: risolta una volta e cacheata — ma solo se riesce.
206
222
  //
207
223
  // Perche' il successo e il fallimento hanno vita diversa: una sessione
208
224
  // risolta non cambia per la vita del processo (cache storica, invariata);
@@ -213,7 +229,8 @@ function createMcpServer(opts = {}) {
213
229
  // fallimento viene quindi ri-tentato, con anti-hammering: al piu' una
214
230
  // risoluzione ogni IDENTITY_RETRY_MS finche' non riesce (un tmux rotto non
215
231
  // puo' trasformare ogni tool call in una execFile da 3 s).
216
- // `identity()` per la diagnostica completa (source/code/presence),
232
+ // `localIdentity()` serve alla diagnostica completa (source/code/presence),
233
+ // mentre `identity()` puo' essere il resolver online per gli handler nc_*;
217
234
  // `session()` estrae solo il nome per gli handler storici (compatibilita').
218
235
  // Nessuna API/token coinvolta qui.
219
236
  // Iniettabile nei test per non attendere 30 s reali (opts.identityRetryMs).
@@ -221,7 +238,7 @@ function createMcpServer(opts = {}) {
221
238
  let identityP = null; // promise condivisa in corso/cacheata
222
239
  let identityOk = false; // solo un esito OK resta cacheato a vita
223
240
  let identityAttemptAt = 0; // istante dell'ultimo tentativo (anti-spam)
224
- const identity = () => {
241
+ const localIdentity = () => {
225
242
  if (identityOk) return identityP;
226
243
  const now = Date.now();
227
244
  if (identityP && now - identityAttemptAt < IDENTITY_RETRY_MS) return identityP;
@@ -233,7 +250,49 @@ function createMcpServer(opts = {}) {
233
250
  });
234
251
  return identityP;
235
252
  };
236
- const session = () => identity().then((i) => i.session);
253
+ const identityContextProvider = typeof opts.identityContextProvider === 'function'
254
+ ? opts.identityContextProvider : null;
255
+
256
+ // Il percorso online non cachea mai un successo: ogni tools/call ripete il
257
+ // resolver e quindi vede revoche/rotazioni del binding. L'assenza del
258
+ // provider mantiene il comportamento embedded/legacy di D.
259
+ async function identityContext({ tool = 'unknown', sharedRequired = !!identityContextProvider } = {}) {
260
+ if (!identityContextProvider) {
261
+ if (sharedRequired) {
262
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
263
+ 'contesto identita online assente: binding condiviso richiesto');
264
+ }
265
+ const id = await localIdentity();
266
+ if (!id.session) return Object.freeze({ verified: false, mode: 'legacy', ...id, from: null });
267
+ return Object.freeze({
268
+ verified: false, mode: 'legacy', bindingId: null, ownerInstanceId: null,
269
+ cellId: null, tmuxSession: id.session, from: null, ...id,
270
+ });
271
+ }
272
+ let raw;
273
+ try {
274
+ raw = await identityContextProvider({ tool });
275
+ } catch (e) {
276
+ if (e && e.code === IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE) throw e;
277
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
278
+ 'contesto identita online non disponibile: binding condiviso rifiutato');
279
+ }
280
+ return normalizeIdentityContext(raw);
281
+ }
282
+
283
+ const identity = ({ tool = 'unknown' } = {}) => {
284
+ if (!identityContextProvider) return localIdentity();
285
+ return identityContext({ tool, sharedRequired: true }).then((context) => ({
286
+ session: context.tmuxSession,
287
+ source: 'online',
288
+ code: IDENTITY_CODE.OK,
289
+ envPresence: envPresenceOf(env),
290
+ requiredEnvVars: IDENTITY_REQUIRED_ENV_VARS,
291
+ remediation: IDENTITY_REMEDIATION,
292
+ context,
293
+ }));
294
+ };
295
+ const session = (options = {}) => identity(options).then((i) => i.session);
237
296
 
238
297
  // Token letto ad OGNI chiamata (rotazione-friendly), MAI incluso negli errori.
239
298
  function readToken() {
@@ -267,6 +326,8 @@ function createMcpServer(opts = {}) {
267
326
  throw new Error('segreto bridge audio non leggibile: il server e\' inizializzato? (nexuscrew init)');
268
327
  }
269
328
  }
329
+ const identityBinding = opts.identityBinding
330
+ ? { 'x-nexuscrew-identity-binding': JSON.stringify(opts.identityBinding) } : {};
270
331
  let r;
271
332
  try {
272
333
  r = await fetchImpl(`${baseUrl}${apiPath}`, {
@@ -274,6 +335,7 @@ function createMcpServer(opts = {}) {
274
335
  headers: {
275
336
  authorization: `Bearer ${token}`,
276
337
  ...signed,
338
+ ...identityBinding,
277
339
  ...(payload !== undefined ? { 'content-type': 'application/json' } : {}),
278
340
  },
279
341
  ...(payload !== undefined ? { body: payload } : {}),
@@ -318,10 +380,30 @@ function createMcpServer(opts = {}) {
318
380
  }
319
381
  }
320
382
 
383
+ // Persistenza del canale identity: solo una response authority con proof
384
+ // child puo aggiornarlo. Il canale resta assente nel percorso legacy.
385
+ const persistIdentity = (session, out) => {
386
+ if (!session || !out || out.identityMode !== 'authority' || !out.proof) return false;
387
+ try {
388
+ return persistIdentityChannel({
389
+ tokenPath: cfg.tokenPath,
390
+ session,
391
+ cellId: out.proof.cellId,
392
+ proof: out.proof,
393
+ expiresAt: out.proof.expiresAt,
394
+ });
395
+ } catch (_) {
396
+ return false;
397
+ }
398
+ };
399
+
321
400
  const ctx = {
322
401
  session,
323
402
  identity,
403
+ localIdentity,
404
+ identityContext,
324
405
  api,
406
+ persistIdentityChannel: persistIdentity,
325
407
  home: () => env.HOME || os.homedir(),
326
408
  fileExists: (p) => { try { return require('node:fs').statSync(p).isFile(); } catch (_) { return false; } },
327
409
  messageId: () => String(idFactory()).toLowerCase(),
@@ -351,7 +433,40 @@ function createMcpServer(opts = {}) {
351
433
  const args = (params && params.arguments && typeof params.arguments === 'object'
352
434
  && !Array.isArray(params.arguments)) ? params.arguments : {};
353
435
  try {
354
- const out = await tool.handler(args, ctx);
436
+ // Resolve once, before the handler, and reuse the immutable result for
437
+ // every helper it calls. This makes the provider mandatory for every
438
+ // shared tools/call, including read-only diagnostics.
439
+ const sharedContext = identityContextProvider
440
+ ? await identityContext({ tool: name, sharedRequired: true }) : null;
441
+ const sharedProof = sharedContext && typeof identityContextProvider.currentProof === 'function'
442
+ ? identityContextProvider.currentProof() : null;
443
+ const sharedIdentity = sharedContext ? Object.freeze({
444
+ session: sharedContext.tmuxSession,
445
+ source: 'online', code: IDENTITY_CODE.OK,
446
+ envPresence: envPresenceOf(env),
447
+ requiredEnvVars: IDENTITY_REQUIRED_ENV_VARS,
448
+ remediation: IDENTITY_REMEDIATION,
449
+ context: sharedContext,
450
+ ...(sharedProof ? { proof: sharedProof } : {}),
451
+ }) : null;
452
+ const requestCtx = {
453
+ ...ctx,
454
+ identity: sharedIdentity ? () => Promise.resolve(sharedIdentity)
455
+ : (options = {}) => ctx.identity({ ...options, tool: name }),
456
+ session: sharedIdentity ? () => Promise.resolve(sharedIdentity.session)
457
+ : (options = {}) => ctx.session({ ...options, tool: name }),
458
+ identityContext: sharedContext ? () => Promise.resolve(sharedContext)
459
+ : (options = {}) => ctx.identityContext({ ...options, tool: name }),
460
+ ...(sharedContext ? {
461
+ // Il binding verificato viaggia con ogni chiamata API della richiesta:
462
+ // il confine server lo ricontrolla prima degli effetti.
463
+ api: (method, apiPath, body, options = {}) => ctx.api(method, apiPath, body, {
464
+ ...options,
465
+ identityBinding: { context: sharedContext, ...(sharedProof ? { proof: sharedProof } : {}) },
466
+ }),
467
+ } : {}),
468
+ };
469
+ const out = await tool.handler(args, requestCtx);
355
470
  reply(id, { content: [{ type: 'text', text: JSON.stringify(out) }] });
356
471
  } catch (e) {
357
472
  // Errore di ESECUZIONE tool: per contratto MCP e' un result con isError,
@@ -461,12 +576,14 @@ function startMcp(opts = {}) {
461
576
  }
462
577
 
463
578
  module.exports = {
464
- createMcpServer, startMcp, resolveSession, resolveIdentity, TOOLS,
579
+ createMcpServer, startMcp, resolveSession, resolveIdentity, normalizeIdentityContext, TOOLS,
465
580
  // V-69: il ramo vl di resolveManagedEngine compone le istruzioni companion
466
581
  // nel file di prompt per-cella — vl non ha client MCP, questo e' l'unica
467
582
  // superficie attraverso cui il testo lo raggiunge.
468
583
  companionInstructions,
469
584
  PROTOCOL_FALLBACK, HTTP_TIMEOUT_MS, HTTP_TIMEOUT_CODE, HTTP_UNREACHABLE_CODE, transportError,
585
+ IDENTITY_CONTEXT_MISSING, IDENTITY_CONTEXT_UNVERIFIED, IDENTITY_CONTEXT_FROM_MISMATCH,
586
+ IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
470
587
  parseCellTarget: cells.parseCellTarget,
471
588
  normalizeCellPayload: cells.normalizeCellPayload,
472
589
  readCellDirectory: cells.readCellDirectory,
package/lib/mcp/tools.js CHANGED
@@ -678,6 +678,9 @@ const TOOLS = [
678
678
  async handler(args, ctx) {
679
679
  const targetRef = parseCellTarget(argString(args, 'target', { required: true, max: 128 }));
680
680
  if (!targetRef) throw new Error('target non valido: usa l\'id esatto restituito da nc_cells');
681
+ if (Object.prototype.hasOwnProperty.call(args, 'from')) {
682
+ throw new Error('nc_send_cell: from e\' derivato dal contesto identita online, non e\' un input');
683
+ }
681
684
  const message = argString(args, 'message', { required: true, max: 8000 });
682
685
  for (let i = 0; i < message.length; i += 1) {
683
686
  const code = message.charCodeAt(i);
@@ -687,7 +690,10 @@ const TOOLS = [
687
690
  const identity = await ctx.identity();
688
691
  const callerSession = requireSession(identity.session, 'nc_send_cell', identity.code);
689
692
  const directory = await readCellDirectory(ctx, callerSession);
690
- const sender = directory.cells.find((cell) => cell.self && cell.active);
693
+ const onlineFrom = identity.context && identity.context.from;
694
+ const sender = directory.cells.find((cell) => cell.self && cell.active
695
+ && (!onlineFrom || (cell.instanceId === onlineFrom.instanceId
696
+ && cell.cell === onlineFrom.cell && cell.tmuxSession === onlineFrom.tmuxSession)));
691
697
  if (!sender) throw new Error('nc_send_cell: la sessione chiamante non e\' una cella Fleet attiva locale');
692
698
  const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
693
699
  && cell.cell === targetRef.cell);
@@ -699,7 +705,10 @@ const TOOLS = [
699
705
  const id = ctx.messageId();
700
706
  const receipt = await ctx.api('POST', apiPath, {
701
707
  id,
702
- from: { instanceId: sender.instanceId, cell: sender.cell, tmuxSession: sender.tmuxSession },
708
+ // In modalita' online questo e' il valore gia' derivato e verificato
709
+ // dal server MCP; il directory lookup sopra verifica che sia ancora
710
+ // una cella locale attiva. Il fallback mantiene il sender storico.
711
+ from: onlineFrom || { instanceId: sender.instanceId, cell: sender.cell, tmuxSession: sender.tmuxSession },
703
712
  to: { instanceId: target.instanceId, cell: target.cell, tmuxSession: target.tmuxSession },
704
713
  message,
705
714
  });
@@ -730,18 +739,31 @@ const TOOLS = [
730
739
  inputSchema: { type: 'object', properties: {} },
731
740
  annotations: { readOnlyHint: true },
732
741
  async handler(_args, ctx) {
742
+ // In shared mode this is the same verified per-request context used by
743
+ // every other tool. Without a provider it remains the legacy local
744
+ // diagnostic for bootstrapping and troubleshooting.
733
745
  const id = await ctx.identity();
734
746
  // Output bounded e non sensibile: nessun valore/env, solo presence e codice.
735
747
  // `session` solo se validata; `source` sempre fra i tre valori ammessi.
736
748
  const out = {
737
749
  identified: !!id.session,
738
750
  source: id.source,
751
+ verified: !!(id.context && id.context.verified === true),
739
752
  envPresence: id.envPresence,
740
753
  requiredEnvVars: id.requiredEnvVars,
741
754
  code: id.code,
742
755
  remediation: id.remediation,
743
756
  };
744
757
  if (id.session) out.session = id.session;
758
+ if (id.context) {
759
+ out.bindingId = id.context.bindingId;
760
+ out.ownerInstanceId = id.context.ownerInstanceId;
761
+ out.cellId = id.context.cellId;
762
+ out.origin = id.context.origin;
763
+ out.kind = id.context.kind;
764
+ out.expiresAt = id.context.expiresAt;
765
+ out.threadId = id.context.kind === 'connection-v1' ? null : (id.context.threadId ?? null);
766
+ }
745
767
  return out;
746
768
  },
747
769
  },
@@ -897,11 +919,19 @@ const TOOLS = [
897
919
  {
898
920
  name: 'nc_lease_register',
899
921
  description: 'Registra questa cella al lease Live del nodo: crea una registration con incarnationId propria e consegna il primo proof child. Risponde {status:"pending"} se la cella non e\' ancora tracciata dal lease del supervisore: riprova dopo retryAfterMs.',
900
- inputSchema: { type: 'object', properties: {} },
901
- async handler(_args, ctx) {
922
+ inputSchema: { type: 'object', properties: {
923
+ proof: { type: 'object', description: 'proof identity ricevuto dal bootstrap authority, se disponibile' },
924
+ } },
925
+ async handler(args, ctx) {
902
926
  const identity = await ctx.identity();
903
927
  const session = requireSession(identity.session, 'nc_lease_register', identity.code);
904
- return ctx.api('POST', '/api/lease/register', { session });
928
+ const proof = args.proof && typeof args.proof === 'object' && !Array.isArray(args.proof)
929
+ ? args.proof : null;
930
+ const out = await ctx.api('POST', '/api/lease/register', { session, ...(proof ? { proof } : {}) });
931
+ // Una registration authority consegna il canale shared del bridge: lo si
932
+ // persiste appena ricevuto, cosi il prossimo avvio parte gia verificato.
933
+ if (typeof ctx.persistIdentityChannel === 'function') ctx.persistIdentityChannel(session, out);
934
+ return out;
905
935
  },
906
936
  },
907
937
  {
@@ -918,7 +948,9 @@ const TOOLS = [
918
948
  const proof = args.proof && typeof args.proof === 'object' && !Array.isArray(args.proof)
919
949
  ? args.proof : null;
920
950
  if (!proof) throw new Error('parametro "proof" obbligatorio (oggetto ricevuto da register/refresh)');
921
- return ctx.api('POST', '/api/lease/refresh', { session, proof });
951
+ const out = await ctx.api('POST', '/api/lease/refresh', { session, proof });
952
+ if (typeof ctx.persistIdentityChannel === 'function') ctx.persistIdentityChannel(session, out);
953
+ return out;
922
954
  },
923
955
  },
924
956
  {
@@ -87,6 +87,7 @@ function createAsksStore(opts = {}) {
87
87
  session: String(session),
88
88
  ts: now(),
89
89
  answered: false,
90
+ dismissed: false,
90
91
  };
91
92
  load().push(ask);
92
93
  save();
@@ -21,6 +21,7 @@ const express = require('express');
21
21
  const { isValidSession } = require('../files/store.js');
22
22
  const { normalizeNotificationLang } = require('./language.js');
23
23
  const { HOP_HEADER } = require('../proxy/hop-proof.js');
24
+ const { createIdentityBindingGuard, expectedFromSession } = require('../identity/binding-guard.js');
24
25
 
25
26
  const TARGET_RE = /^[a-f0-9]{32}$/i;
26
27
 
@@ -103,6 +104,7 @@ function replyLabel(cfg) {
103
104
 
104
105
  function notifyRoutes({
105
106
  cfg, notifier, push, asks, paste, sessionExists,
107
+ fleetP = null, instanceId = null, identityMode = 'legacy',
106
108
  // Federazione delle notifiche. Assenti (test unitari, montaggi parziali) la
107
109
  // route resta esattamente quella locale di prima: nessun percorso nuovo si
108
110
  // apre per omissione.
@@ -111,6 +113,22 @@ function notifyRoutes({
111
113
  }) {
112
114
  const r = express.Router();
113
115
  const json = express.json({ limit: '16kb' });
116
+ const bindingGuard = createIdentityBindingGuard({
117
+ fleetP, instanceId, now: () => Date.now(), sharedRequired: identityMode === 'authority',
118
+ });
119
+
120
+ async function guardBinding(req, session = null) {
121
+ try {
122
+ const expected = expectedFromSession(session, instanceId);
123
+ return await bindingGuard.verify(req, { expected, localOnly: true });
124
+ } catch (e) {
125
+ return e;
126
+ }
127
+ }
128
+
129
+ function bindingRejected(res, error) {
130
+ return res.status(403).json({ error: error.message, code: error.code });
131
+ }
114
132
 
115
133
  const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
116
134
  const mutGate = (_req, res, next) => {
@@ -209,6 +227,8 @@ function notifyRoutes({
209
227
  return res.json(out);
210
228
  }
211
229
 
230
+ const binding = await guardBinding(req, b.session);
231
+ if (binding instanceof Error) return bindingRejected(res, binding);
212
232
  const sender = b.session || 'unknown';
213
233
  if (!allowNotify(sender)) {
214
234
  return res.status(429).json({ error: 'rate limit notify superato (limite globale per token + per sessione)' });
@@ -267,6 +287,8 @@ function notifyRoutes({
267
287
  // non consumano budget; il rate scatta solo su richieste ben formate.
268
288
  const v = asks.validate({ question: b.question, options: b.options });
269
289
  if (!v.ok) return res.status(400).json({ error: v.error });
290
+ const binding = await guardBinding(req, b.session);
291
+ if (binding instanceof Error) return bindingRejected(res, binding);
270
292
  if (!allowAsk(b.session)) {
271
293
  return res.status(429).json({ error: 'rate limit ask superato (limite globale per token + per sessione)' });
272
294
  }
@@ -298,9 +320,12 @@ function notifyRoutes({
298
320
  // Idempotente; 404 se id inesistente; 409 se answering (claim attivo: non si
299
321
  // scarta una risposta in corso). Emette il frame per le UI aperte come fa
300
322
  // POST /asks con emitRaw: la card sparisce senza aspettare il poll.
301
- r.delete('/asks/:id', mutGate, (req, res) => {
323
+ r.delete('/asks/:id', mutGate, async (req, res) => {
302
324
  try {
303
325
  const id = String(req.params.id || '');
326
+ const ask = asks.get(id);
327
+ const binding = await guardBinding(req, ask && ask.session);
328
+ if (binding instanceof Error) return bindingRejected(res, binding);
304
329
  const out = asks.dismiss(id);
305
330
  if (!out.ok) {
306
331
  if (out.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
@@ -339,6 +364,9 @@ function notifyRoutes({
339
364
  }
340
365
  const text = sanitized;
341
366
  if (!text && asks.get(id)) return res.status(400).json({ error: 'text vuoto dopo la sanificazione' });
367
+ const ask = asks.get(id);
368
+ const binding = await guardBinding(req, ask && ask.session);
369
+ if (binding instanceof Error) return bindingRejected(res, binding);
342
370
  const claim = asks.claim(id);
343
371
  if (!claim.ok) {
344
372
  if (claim.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
package/lib/server.js CHANGED
@@ -165,13 +165,16 @@ function createServer(opts = {}) {
165
165
  // selectProvider sceglie UNA volta (startup) builtin|disabled e ritorna
166
166
  // {mode,reason,fleet}; routes consumano il .fleet,
167
167
  // quindi fleetP resta una Promise<Fleet> (createServer non diventa async).
168
- const fleetP = selectProvider({ ...cfg, ensureTmuxProtection }).then((p) => p.fleet);
168
+ // L'owner identity e' la stessa fonte autorevole del node store usata dalle
169
+ // route API: nessuna cella puo dichiararla dal body.
170
+ const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
171
+ const identityOwnerInstanceId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
172
+ const fleetP = selectProvider({ ...cfg, ensureTmuxProtection, identityOwnerInstanceId }).then((p) => p.fleet);
169
173
 
170
174
  // Multi-node (B1): nodes.json e' la fonte dati (B0). Il proxy risolve <name>
171
175
  // -> {localPort, token} leggendo lo store ad ogni richiesta (fresh: rotazione
172
176
  // token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
173
177
  // il valore che il proxy inietta upstream, non esce mai verso il browser.
174
- const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
175
178
  const vlNodesPath = cfg.vlNodesPath || vlNodeStore.defaultPath(cfg.home || os.homedir());
176
179
  const vlNodeBroker = opts.vlNodeBroker || createVlNodeBroker();
177
180
  const vlOwnerId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
@@ -830,12 +833,17 @@ function createServer(opts = {}) {
830
833
  nodePanelPorts: nodePanelPorts(),
831
834
  presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
832
835
  }));
836
+ const apiNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
837
+ const identityMode = cfg.fleetIdentityMode || cfg.fleet?.identity?.mode || 'legacy';
833
838
  api.use('/files', filesRoutes({
834
839
  cfg,
835
840
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
836
841
  paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
837
842
  notifier,
838
843
  readonly: proxyReadonly,
844
+ fleetP,
845
+ instanceId: apiNodeId,
846
+ identityMode,
839
847
  }));
840
848
  // MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
841
849
  // del router /api; gate READONLY sui mutanti dentro notifyRoutes.
@@ -859,6 +867,9 @@ function createServer(opts = {}) {
859
867
  asks: asksStore,
860
868
  paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
861
869
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
870
+ fleetP,
871
+ instanceId: apiNodeId,
872
+ identityMode,
862
873
  localNodeId: federatedNodeId,
863
874
  // requireCell resta true: una notifica federata porta sempre una cella
864
875
  // attestata, che serve ad attribuirla e a calcolarne il budget.
@@ -884,7 +895,13 @@ function createServer(opts = {}) {
884
895
  // Fetta 2b (D3): superficie child del lease Live via canale nativo del
885
896
  // bridge (HTTP loopback + Bearer). La cella e' derivata dalla sessione; il
886
897
  // proof firmato dal verifier per-installazione autorizza refresh/recovery.
887
- api.use('/lease', leaseRoutes({ fleetP, readonly: proxyReadonly }));
898
+ api.use('/lease', leaseRoutes({
899
+ fleetP, readonly: proxyReadonly,
900
+ identityMode,
901
+ // Fonte autorevole del node id: node store vivo, gia condiviso con files,
902
+ // notify e cells. Non e' un dato dichiarato dal chiamante né da cfg.
903
+ instanceId: apiNodeId,
904
+ }));
888
905
  // Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
889
906
  // store, la stessa fonte usata da /api/cells e /api/peers. Lo stato Fleet e'
890
907
  // asincrono e va atteso: leggerlo come se fosse sincrono lascerebbe la
@@ -906,6 +923,8 @@ function createServer(opts = {}) {
906
923
  api.use('/audio', audioRoutes({
907
924
  readonly: proxyReadonly,
908
925
  localNodeId: audioNodeId,
926
+ fleetP,
927
+ identityMode,
909
928
  receiptStore: audioReceipts,
910
929
  adapter: audioAdapter,
911
930
  queue: audioQueue,
@@ -946,7 +965,8 @@ function createServer(opts = {}) {
946
965
  api.use('/cells', cellsRoutes({
947
966
  fleetP,
948
967
  diagnostics,
949
- instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
968
+ instanceId: apiNodeId,
969
+ identityMode,
950
970
  submit: opts.cellSubmit || ((session, text, meta) => submitToSession(cfg.tmuxBin, session, text, {
951
971
  engine: meta && meta.engine,
952
972
  })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.19",
3
+ "version": "0.9.21",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {