@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/CHANGELOG.md +38 -22
- package/frontend/dist/assets/{index-ChYs1kwH.js → index-D5snoCdN.js} +1 -1
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/audio/routes.js +27 -0
- package/lib/cells/routes.js +30 -2
- package/lib/cli/commands.js +11 -1
- package/lib/files/routes.js +31 -4
- package/lib/fleet/builtin.js +44 -4
- package/lib/fleet/cell-exec.js +251 -7
- package/lib/fleet/cell-lease-server.js +161 -6
- package/lib/fleet/identity-authority.js +367 -0
- package/lib/fleet/identity-transport.js +68 -0
- package/lib/fleet/launch-broker.js +26 -1
- package/lib/fleet/lease-client.js +168 -1
- package/lib/fleet/lease-routes.js +96 -24
- package/lib/fleet/managed.js +13 -0
- package/lib/fleet/runtime.js +25 -1
- package/lib/identity/binding-guard.js +128 -0
- package/lib/live-host/bridge.js +69 -0
- package/lib/live-host/routes.js +9 -5
- package/lib/mcp/identity-provider.js +132 -0
- package/lib/mcp/identity-schema.js +168 -0
- package/lib/mcp/server.js +123 -6
- package/lib/mcp/tools.js +38 -6
- package/lib/notify/asks.js +1 -0
- package/lib/notify/routes.js +29 -1
- package/lib/server.js +24 -4
- package/package.json +1 -1
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
|
-
// `
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
{
|
package/lib/notify/asks.js
CHANGED
package/lib/notify/routes.js
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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:
|
|
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
|
})),
|