@mmmbuto/nexuscrew 0.9.20 → 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 +18 -0
- package/frontend/dist/assets/{index-VdGL6XJ8.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 +26 -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 +24 -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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { normalizeIdentityContext } = require('../mcp/identity-schema.js');
|
|
4
|
+
|
|
5
|
+
const IDENTITY_BINDING_HEADER = 'x-nexuscrew-identity-binding';
|
|
6
|
+
const IDENTITY_BINDING_MISSING = 'NEXUSCREW_IDENTITY_BINDING_MISSING';
|
|
7
|
+
const IDENTITY_BINDING_INVALID = 'NEXUSCREW_IDENTITY_BINDING_INVALID';
|
|
8
|
+
const IDENTITY_BINDING_MISMATCH = 'NEXUSCREW_IDENTITY_BINDING_MISMATCH';
|
|
9
|
+
|
|
10
|
+
function bindingError(code, message) {
|
|
11
|
+
const error = new Error(message);
|
|
12
|
+
error.code = code;
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveInstanceId(instanceId) {
|
|
17
|
+
return typeof instanceId === 'function' ? instanceId() : instanceId;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function expectedFromSession(session, instanceId) {
|
|
21
|
+
if (!session) return null;
|
|
22
|
+
return { instanceId: resolveInstanceId(instanceId), tmuxSession: session };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Il proof child firma i timestamp come stringhe decimali (JSON del canale
|
|
26
|
+
// persistito). Il contesto shared prodotto dal provider li converte, ma un
|
|
27
|
+
// binding che copia il proof deve restare verificabile senza allargare lo
|
|
28
|
+
// schema MCP generale: qui si accettano SOLO stringhe decimali intere.
|
|
29
|
+
function canonicalTimestamp(value) {
|
|
30
|
+
if (typeof value !== 'string' || !/^[0-9]+$/.test(value)) return value;
|
|
31
|
+
const parsed = Number(value);
|
|
32
|
+
return Number.isSafeInteger(parsed) ? parsed : value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeBindingContext(raw) {
|
|
36
|
+
return {
|
|
37
|
+
...raw,
|
|
38
|
+
issuedAt: canonicalTimestamp(raw && raw.issuedAt),
|
|
39
|
+
notBefore: canonicalTimestamp(raw && raw.notBefore),
|
|
40
|
+
expiresAt: canonicalTimestamp(raw && raw.expiresAt),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createIdentityBindingGuard({
|
|
45
|
+
fleetP = null, instanceId = null, now = Date.now, sharedRequired = false,
|
|
46
|
+
} = {}) {
|
|
47
|
+
async function verify(req, { expected = null, localOnly = true } = {}) {
|
|
48
|
+
const raw = req && req.headers && req.headers[IDENTITY_BINDING_HEADER];
|
|
49
|
+
if (raw === undefined) {
|
|
50
|
+
if (sharedRequired) {
|
|
51
|
+
throw bindingError(IDENTITY_BINDING_MISSING, 'binding identita obbligatorio');
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let parsed;
|
|
57
|
+
try { parsed = JSON.parse(String(raw)); } catch (_) {
|
|
58
|
+
throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non valido');
|
|
59
|
+
}
|
|
60
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
61
|
+
throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non valido');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let context;
|
|
65
|
+
try { context = normalizeIdentityContext(normalizeBindingContext(parsed.context), { now }); } catch (_) {
|
|
66
|
+
throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non valido');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const proof = parsed.proof;
|
|
70
|
+
if (!proof || typeof proof !== 'object' || Array.isArray(proof)) {
|
|
71
|
+
throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non valido');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const localId = resolveInstanceId(instanceId);
|
|
75
|
+
if (localOnly && localId && context.ownerInstanceId !== localId) {
|
|
76
|
+
throw bindingError(IDENTITY_BINDING_MISMATCH, 'binding identita locale atteso');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (expected) {
|
|
80
|
+
if (expected.instanceId !== undefined && context.ownerInstanceId !== expected.instanceId) {
|
|
81
|
+
throw bindingError(IDENTITY_BINDING_MISMATCH, 'binding identita nodo discordante');
|
|
82
|
+
}
|
|
83
|
+
if (expected.cell !== undefined && context.cellId !== expected.cell) {
|
|
84
|
+
throw bindingError(IDENTITY_BINDING_MISMATCH, 'binding identita cella discordante');
|
|
85
|
+
}
|
|
86
|
+
if (expected.tmuxSession !== undefined && context.tmuxSession !== expected.tmuxSession) {
|
|
87
|
+
throw bindingError(IDENTITY_BINDING_MISMATCH, 'binding identita sessione discordante');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let fleet = null;
|
|
92
|
+
try { fleet = await fleetP; } catch (_) { fleet = null; }
|
|
93
|
+
const lease = fleet && fleet.lease;
|
|
94
|
+
if (!lease || typeof lease.childIntrospect !== 'function') {
|
|
95
|
+
throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non verificabile');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const out = lease.childIntrospect(proof);
|
|
99
|
+
if (!out || out.status !== 'live' || out.cellId !== context.cellId
|
|
100
|
+
|| out.incarnationId !== context.threadId
|
|
101
|
+
|| Number(out.expiresAt) !== Number(context.expiresAt)) {
|
|
102
|
+
throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non valido');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return context;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
verify,
|
|
110
|
+
header: IDENTITY_BINDING_HEADER,
|
|
111
|
+
codes: {
|
|
112
|
+
missing: IDENTITY_BINDING_MISSING,
|
|
113
|
+
invalid: IDENTITY_BINDING_INVALID,
|
|
114
|
+
mismatch: IDENTITY_BINDING_MISMATCH,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
IDENTITY_BINDING_HEADER,
|
|
121
|
+
IDENTITY_BINDING_MISSING,
|
|
122
|
+
IDENTITY_BINDING_INVALID,
|
|
123
|
+
IDENTITY_BINDING_MISMATCH,
|
|
124
|
+
bindingError,
|
|
125
|
+
createIdentityBindingGuard,
|
|
126
|
+
expectedFromSession,
|
|
127
|
+
resolveInstanceId,
|
|
128
|
+
};
|
package/lib/live-host/bridge.js
CHANGED
|
@@ -416,6 +416,10 @@ function createLiveBridge({
|
|
|
416
416
|
const threadIdsByCell = new Map();
|
|
417
417
|
const threadStatusCache = new Map();
|
|
418
418
|
const threadStatusInFlight = new Map();
|
|
419
|
+
// R4: riserve in-process per l'avvio Live. La chiave è la cella host: una
|
|
420
|
+
// sola start provvisoria per cella alla volta, e il commit ricontrolla la
|
|
421
|
+
// tupla congelata in riserva prima di accettare il thread creato.
|
|
422
|
+
const pendingLiveByCell = new Map();
|
|
419
423
|
const threadStatusCacheMs = Number.isFinite(cfg.liveThreadStatusCacheMs)
|
|
420
424
|
? Math.max(0, cfg.liveThreadStatusCacheMs) : THREAD_STATUS_CACHE_MS;
|
|
421
425
|
|
|
@@ -442,6 +446,35 @@ function createLiveBridge({
|
|
|
442
446
|
}
|
|
443
447
|
}
|
|
444
448
|
|
|
449
|
+
// Tupla dell'avvio Live come la osserva questo ponte: designazione (cella,
|
|
450
|
+
// revision, idoneità) più stato firmato del lease dell'host. Generation ed
|
|
451
|
+
// epoch arrivano dallo stesso status quando il lease manager li espone.
|
|
452
|
+
function liveTuple(snap, leaseStatus) {
|
|
453
|
+
const lease = leaseStatus && typeof leaseStatus === 'object' ? leaseStatus : { state: leaseStatus };
|
|
454
|
+
return JSON.stringify({
|
|
455
|
+
hostCell: snap ? snap.hostCell : null,
|
|
456
|
+
revision: snap ? snap.revision : null,
|
|
457
|
+
eligible: snap ? snap.eligible === true : false,
|
|
458
|
+
lease: {
|
|
459
|
+
state: lease.state == null ? null : lease.state,
|
|
460
|
+
leaseId: lease.leaseId == null ? null : lease.leaseId,
|
|
461
|
+
generation: lease.generation == null ? null : lease.generation,
|
|
462
|
+
launchEpoch: lease.launchEpoch == null ? null : lease.launchEpoch,
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function leaseTupleFor(cellId) {
|
|
468
|
+
try {
|
|
469
|
+
const fleet = await fleetP;
|
|
470
|
+
const lease = fleet && fleet.lease;
|
|
471
|
+
if (!lease || typeof lease.status !== 'function') return { state: 'unavailable' };
|
|
472
|
+
return lease.status(cellId) || { state: 'none' };
|
|
473
|
+
} catch (_) {
|
|
474
|
+
return { state: 'unavailable' };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
445
478
|
async function rosterCell(cellId) {
|
|
446
479
|
const fleet = await fleetP;
|
|
447
480
|
if (!fleet || fleet.available !== true) return null;
|
|
@@ -540,6 +573,14 @@ function createLiveBridge({
|
|
|
540
573
|
? `${intestazione}\n\n${prompt.text}`
|
|
541
574
|
: intestazione;
|
|
542
575
|
|
|
576
|
+
// R4: riserva della tupla e start provvisorio. Finché il commit non
|
|
577
|
+
// ricontrolla la stessa tupla, il thread NON viene accettato: nessun tool
|
|
578
|
+
// può attraversarlo, perché il ponte non ne registra l'id.
|
|
579
|
+
if (pendingLiveByCell.has(snap.hostCell)) return none('reservation-in-flight');
|
|
580
|
+
const reservedLease = await leaseTupleFor(snap.hostCell);
|
|
581
|
+
const reservedTuple = liveTuple(snap, reservedLease);
|
|
582
|
+
pendingLiveByCell.set(snap.hostCell, reservedTuple);
|
|
583
|
+
|
|
543
584
|
let started;
|
|
544
585
|
try {
|
|
545
586
|
started = await startThreadOnControlSocket({
|
|
@@ -557,9 +598,37 @@ function createLiveBridge({
|
|
|
557
598
|
} catch (e) {
|
|
558
599
|
const reason = e && e.code === 'ETIMEOUT' ? 'bridge-timeout' : 'bridge-socket-failed';
|
|
559
600
|
log(`[live-bridge] thread ponte NON creata (${reason}): ${e.message}`);
|
|
601
|
+
pendingLiveByCell.delete(snap.hostCell);
|
|
560
602
|
return none(reason, { cell: snap.hostCell, detail: String(e.message) });
|
|
561
603
|
}
|
|
562
604
|
|
|
605
|
+
// R4: commit. La designazione e il lease vengono riletti e confrontati con
|
|
606
|
+
// la tupla riservata: se qualcosa è cambiato durante lo start, il thread
|
|
607
|
+
// resta scartato (zero dispatch) e l'esito lo dichiara con l'id scartato.
|
|
608
|
+
let commitSnap;
|
|
609
|
+
let commitLease;
|
|
610
|
+
try {
|
|
611
|
+
commitSnap = await readDesignation();
|
|
612
|
+
commitLease = await leaseTupleFor(snap.hostCell);
|
|
613
|
+
} catch (e) {
|
|
614
|
+
pendingLiveByCell.delete(snap.hostCell);
|
|
615
|
+
log(`[live-bridge] commit illeggibile: thread ${started.threadId} scartato (${e.message})`);
|
|
616
|
+
return none('commit-unreadable', {
|
|
617
|
+
cell: snap.hostCell, discardedThread: started.threadId, detail: String(e.message),
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
if (liveTuple(commitSnap, commitLease) !== reservedTuple) {
|
|
621
|
+
// Primato alla causa radice: se il lease è cambiato, eligible oscilla
|
|
622
|
+
// con lui, e la designazione NON è la causa.
|
|
623
|
+
const leaseOf = (value) => liveTuple({ hostCell: null, revision: null, eligible: null }, value);
|
|
624
|
+
const reason = leaseOf(commitLease) !== leaseOf(reservedLease)
|
|
625
|
+
? 'lease-changed' : 'designation-changed';
|
|
626
|
+
pendingLiveByCell.delete(snap.hostCell);
|
|
627
|
+
log(`[live-bridge] tupla cambiata in volo (${reason}): thread ${started.threadId} scartato`);
|
|
628
|
+
return none(reason, { cell: snap.hostCell, discardedThread: started.threadId });
|
|
629
|
+
}
|
|
630
|
+
pendingLiveByCell.delete(snap.hostCell);
|
|
631
|
+
|
|
563
632
|
const { text, ...promptEcho } = prompt; // il testo del prompt non viaggia in risposta
|
|
564
633
|
const out = {
|
|
565
634
|
mode: 'native', cell: snap.hostCell, engine,
|
package/lib/live-host/routes.js
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
// - Cella inattiva PRESERVA la designazione: lo store non cancella mai hostCell per
|
|
20
20
|
// inattivita; `eligible` e' derivato dal roster al momento del GET.
|
|
21
21
|
// - readonly => 403 su designate/clear.
|
|
22
|
+
//
|
|
23
|
+
// Fuori dal perimetro del binding MCP: designate/clear/bridge sono superficie di
|
|
24
|
+
// gestione locale dell'operatore, non effetti richiesti da una cella tramite il
|
|
25
|
+
// bridge. Il token del nodo resta l'autorita'; il binding shared non e' richiesto.
|
|
22
26
|
|
|
23
27
|
const express = require('express');
|
|
24
28
|
const { CELL_ID_RE } = require('./store.js');
|
|
@@ -51,10 +55,10 @@ function isActive(cell) {
|
|
|
51
55
|
// distingue «non idonea perche' morta» da «non idonea perche' in recupero».
|
|
52
56
|
// - I cinque stati (live|grace|expired|none|unavailable) restano DISTINCTI
|
|
53
57
|
// fino a chi legge: collassarli e' rifare il difetto a un piano piu' su.
|
|
54
|
-
// - FALLBACK FAIL-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
+
// - FALLBACK FAIL-CLOSED: senza fleet.lease (installazione senza lease)
|
|
59
|
+
// eligible resta false e host.lease='unavailable'. L'attach locale D resta
|
|
60
|
+
// disponibile al suo percorso, ma il dispatch Live non può diventare
|
|
61
|
+
// tmux-only senza la garanzia del lease.
|
|
58
62
|
// - hostCell resta PRESERVATO in ogni caso (invariante dello store): oscilla
|
|
59
63
|
// l'idoneita', non la scelta dell'operatore.
|
|
60
64
|
function hostLeaseState(fleet, hostCell) {
|
|
@@ -68,7 +72,7 @@ function hostLeaseState(fleet, hostCell) {
|
|
|
68
72
|
function eligibleOf(fleet, cell, hostCell) {
|
|
69
73
|
const leaseState = hostLeaseState(fleet, hostCell);
|
|
70
74
|
if (leaseState === null) return false; // senza soggetto non c'e' idoneita'
|
|
71
|
-
if (leaseState === 'unavailable') return
|
|
75
|
+
if (leaseState === 'unavailable') return false;
|
|
72
76
|
return isActive(cell) && leaseState === 'live';
|
|
73
77
|
}
|
|
74
78
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Canale produttivo del contesto shared per il bridge MCP.
|
|
3
|
+
//
|
|
4
|
+
// Il proof child authority-backed, ottenuto da register/refresh in authority
|
|
5
|
+
// mode, viene persistito accanto al token in una directory privata del nodo:
|
|
6
|
+
// quello è l'unico canale condiviso fra i processi della cella (contratto:
|
|
7
|
+
// nessun isolamento fra processi dello stesso UID). L'env seleziona il FILE
|
|
8
|
+
// del canale ma mai l'identità: questa arriva soltanto dal proof firmato,
|
|
9
|
+
// verificato online dall'hub a ogni introspezione. Senza canale non esiste
|
|
10
|
+
// provider e il bridge resta sul percorso embedded legacy.
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { readTokenSafe } = require('../auth/token.js');
|
|
15
|
+
const { cellIdFromTmuxSession, tmuxSessionForCell } = require('../fleet/definitions.js');
|
|
16
|
+
const {
|
|
17
|
+
identityContextError, IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
|
|
18
|
+
} = require('./identity-schema.js');
|
|
19
|
+
|
|
20
|
+
const INTROSPECT_TIMEOUT_MS = 8000;
|
|
21
|
+
|
|
22
|
+
function identityChannelDir(tokenPath) {
|
|
23
|
+
return path.join(path.dirname(tokenPath), 'mcp-identity');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function identityChannelPath({ tokenPath, session }) {
|
|
27
|
+
return path.join(identityChannelDir(tokenPath), `${session}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readIdentityChannel(filePath) {
|
|
31
|
+
let raw;
|
|
32
|
+
try { raw = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (_) { return null; }
|
|
33
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
34
|
+
const { cellId, proof, expiresAt } = raw;
|
|
35
|
+
if (typeof cellId !== 'string' || !cellId || !proof || typeof proof !== 'object'
|
|
36
|
+
|| Array.isArray(proof) || !Number.isSafeInteger(expiresAt)) return null;
|
|
37
|
+
return { cellId, proof, expiresAt };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function persistIdentityChannel({ tokenPath, session, cellId, proof, expiresAt }) {
|
|
41
|
+
if (!tokenPath || !session || cellIdFromTmuxSession(session) !== cellId) return false;
|
|
42
|
+
const dir = identityChannelDir(tokenPath);
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
44
|
+
const filePath = identityChannelPath({ tokenPath, session });
|
|
45
|
+
fs.writeFileSync(filePath, `${JSON.stringify({ cellId, proof, expiresAt })}\n`, { mode: 0o600 });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sharedContextFromIntrospection(out, now) {
|
|
50
|
+
const issuedAt = Number.isSafeInteger(Number(out.issuedAt)) ? Number(out.issuedAt) : now();
|
|
51
|
+
const incarnationId = typeof out.incarnationId === 'string' ? out.incarnationId : '';
|
|
52
|
+
const tmuxSession = typeof out.tmuxSession === 'string' && out.tmuxSession
|
|
53
|
+
? out.tmuxSession : tmuxSessionForCell(out.cellId);
|
|
54
|
+
return {
|
|
55
|
+
version: '1',
|
|
56
|
+
kind: 'mcp-v1',
|
|
57
|
+
verified: true,
|
|
58
|
+
mode: 'shared',
|
|
59
|
+
bindingId: `${out.cellId}:${incarnationId}`,
|
|
60
|
+
ownerInstanceId: out.instanceId,
|
|
61
|
+
cellId: out.cellId,
|
|
62
|
+
tmuxSession,
|
|
63
|
+
connectionId: `${out.cellId}:${incarnationId}`,
|
|
64
|
+
threadId: incarnationId,
|
|
65
|
+
origin: 'daemon',
|
|
66
|
+
audience: 'nexuscrew-mcp',
|
|
67
|
+
scopes: ['mcp:tools/call'],
|
|
68
|
+
issuedAt,
|
|
69
|
+
notBefore: issuedAt,
|
|
70
|
+
expiresAt: out.expiresAt,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createMcpIdentityProvider({ config, env = process.env, fetchImpl = fetch, now = Date.now } = {}) {
|
|
75
|
+
const cfg = config || require('../config.js').loadConfig();
|
|
76
|
+
const tokenPath = cfg && cfg.tokenPath;
|
|
77
|
+
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;
|
|
82
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
83
|
+
|
|
84
|
+
async function introspectChannel() {
|
|
85
|
+
const channel = readIdentityChannel(channelPath);
|
|
86
|
+
if (!channel) return { verified: false, mode: 'shared' };
|
|
87
|
+
let response;
|
|
88
|
+
try {
|
|
89
|
+
const token = readTokenSafe(tokenPath);
|
|
90
|
+
response = await fetchImpl(`${baseUrl}/api/lease/introspect`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({ proof: channel.proof }),
|
|
94
|
+
signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS),
|
|
95
|
+
});
|
|
96
|
+
} catch (_) {
|
|
97
|
+
throw identityContextError(IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
|
|
98
|
+
'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE: authority non raggiungibile');
|
|
99
|
+
}
|
|
100
|
+
if (response.status >= 500) {
|
|
101
|
+
throw identityContextError(IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
|
|
102
|
+
'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE: authority non raggiungibile');
|
|
103
|
+
}
|
|
104
|
+
if (!response.ok) return { verified: false, mode: 'shared' };
|
|
105
|
+
const out = await response.json().catch(() => null);
|
|
106
|
+
if (!out || out.status !== 'live' || out.identityMode !== 'authority'
|
|
107
|
+
|| out.cellId !== channel.cellId || typeof out.instanceId !== 'string' || !out.instanceId
|
|
108
|
+
|| !Number.isSafeInteger(Number(out.expiresAt)) || Number(out.expiresAt) <= now()
|
|
109
|
+
|| typeof out.incarnationId !== 'string' || !out.incarnationId
|
|
110
|
+
|| out.incarnationId !== channel.proof.incarnationId) {
|
|
111
|
+
return { verified: false, mode: 'shared' };
|
|
112
|
+
}
|
|
113
|
+
return { context: sharedContextFromIntrospection({ ...out, expiresAt: Number(out.expiresAt) }, now()), proof: channel.proof };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function provider({ tool } = {}) {
|
|
117
|
+
void tool; // il canale lease è per-connessione cella, non per-tool
|
|
118
|
+
const out = await introspectChannel();
|
|
119
|
+
return out.context ? out.context : out;
|
|
120
|
+
}
|
|
121
|
+
provider.currentProof = () => {
|
|
122
|
+
const channel = readIdentityChannel(channelPath);
|
|
123
|
+
return channel ? channel.proof : null;
|
|
124
|
+
};
|
|
125
|
+
return provider;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = {
|
|
129
|
+
createMcpIdentityProvider,
|
|
130
|
+
persistIdentityChannel,
|
|
131
|
+
identityChannelPath,
|
|
132
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isValidSession } = require('../files/store.js');
|
|
4
|
+
|
|
5
|
+
const IDENTITY_SCHEMA_VERSION = '1';
|
|
6
|
+
const IDENTITY_KINDS = Object.freeze(new Set(['connection-v1', 'thread-v1', 'mcp-v1']));
|
|
7
|
+
const IDENTITY_ORIGINS = Object.freeze(new Set(['local_tui', 'remote_live', 'daemon']));
|
|
8
|
+
const IDENTITY_CONTEXT_MISSING = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_MISSING';
|
|
9
|
+
const IDENTITY_CONTEXT_UNVERIFIED = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_UNVERIFIED';
|
|
10
|
+
const IDENTITY_CONTEXT_FROM_MISMATCH = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_FROM_MISMATCH';
|
|
11
|
+
const IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE = 'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE';
|
|
12
|
+
|
|
13
|
+
function identityContextError(code, message) {
|
|
14
|
+
const error = new Error(message);
|
|
15
|
+
error.code = code;
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function requiredString(value, name, { max = 128 } = {}) {
|
|
20
|
+
if (typeof value !== 'string' || !value.trim() || (max !== null && value.length > max)) {
|
|
21
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
22
|
+
`contesto identita online: ${name} non valido`);
|
|
23
|
+
}
|
|
24
|
+
return value.trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function timestamp(value, name) {
|
|
28
|
+
const parsed = Number.isSafeInteger(value) && value >= 0
|
|
29
|
+
? value
|
|
30
|
+
: (typeof value === 'string' && value.trim() ? Date.parse(value) : NaN);
|
|
31
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
32
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
33
|
+
`contesto identita online: ${name} non valido`);
|
|
34
|
+
}
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nonNegativeInteger(value, name) {
|
|
39
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
40
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
41
|
+
`contesto identita online: ${name} non valido`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeLiveHost(raw) {
|
|
47
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
48
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
49
|
+
'contesto identita online: liveHost non valido');
|
|
50
|
+
}
|
|
51
|
+
const out = {
|
|
52
|
+
ownerInstanceId: requiredString(raw.ownerInstanceId, 'liveHost.ownerInstanceId'),
|
|
53
|
+
cellId: requiredString(raw.cellId, 'liveHost.cellId'),
|
|
54
|
+
tmuxSession: requiredString(raw.tmuxSession, 'liveHost.tmuxSession'),
|
|
55
|
+
leaseId: requiredString(raw.leaseId, 'liveHost.leaseId'),
|
|
56
|
+
generation: nonNegativeInteger(raw.generation, 'liveHost.generation'),
|
|
57
|
+
epoch: nonNegativeInteger(raw.epoch, 'liveHost.epoch'),
|
|
58
|
+
designation: requiredString(raw.designation, 'liveHost.designation'),
|
|
59
|
+
pairingSessionId: requiredString(raw.pairingSessionId, 'liveHost.pairingSessionId'),
|
|
60
|
+
};
|
|
61
|
+
if (!isValidSession(out.tmuxSession) || out.cellId !== out.tmuxSession.slice(6)) {
|
|
62
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
63
|
+
'contesto identita online: liveHost cella/sessione incoerenti');
|
|
64
|
+
}
|
|
65
|
+
return Object.freeze(out);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeScopes(raw) {
|
|
69
|
+
if (!Array.isArray(raw) || raw.length < 1 || raw.length > 32) {
|
|
70
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
71
|
+
'contesto identita online: scopes non validi');
|
|
72
|
+
}
|
|
73
|
+
const scopes = raw.map((scope) => requiredString(scope, 'scope', { max: 64 }));
|
|
74
|
+
if (new Set(scopes).size !== scopes.length) {
|
|
75
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
76
|
+
'contesto identita online: scopes duplicati');
|
|
77
|
+
}
|
|
78
|
+
return Object.freeze(scopes);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeIdentityContext(raw, { now = Date.now } = {}) {
|
|
82
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
83
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING, 'contesto identita online assente');
|
|
84
|
+
}
|
|
85
|
+
if (raw.verified !== true || raw.mode !== 'shared') {
|
|
86
|
+
throw identityContextError(IDENTITY_CONTEXT_UNVERIFIED,
|
|
87
|
+
'contesto identita online non verificato: binding condiviso rifiutato');
|
|
88
|
+
}
|
|
89
|
+
if (raw.version !== IDENTITY_SCHEMA_VERSION || !IDENTITY_KINDS.has(raw.kind)) {
|
|
90
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
91
|
+
'contesto identita online: versione o kind non validi');
|
|
92
|
+
}
|
|
93
|
+
if (!IDENTITY_ORIGINS.has(raw.origin)) {
|
|
94
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
95
|
+
'contesto identita online: origin non valida');
|
|
96
|
+
}
|
|
97
|
+
const ownerInstanceId = requiredString(raw.ownerInstanceId, 'ownerInstanceId');
|
|
98
|
+
const cellId = requiredString(raw.cellId, 'cellId');
|
|
99
|
+
const tmuxSession = requiredString(raw.tmuxSession || raw.session, 'tmuxSession');
|
|
100
|
+
if (!isValidSession(tmuxSession) || cellId !== tmuxSession.slice(6)) {
|
|
101
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
102
|
+
'contesto identita online: cella/sessione incoerenti');
|
|
103
|
+
}
|
|
104
|
+
const bindingId = requiredString(raw.bindingId, 'bindingId');
|
|
105
|
+
const audience = requiredString(raw.audience, 'audience');
|
|
106
|
+
const connectionId = requiredString(raw.connectionId, 'connectionId');
|
|
107
|
+
if ((raw.kind === 'thread-v1' || raw.kind === 'mcp-v1')) {
|
|
108
|
+
requiredString(raw.threadId, 'threadId');
|
|
109
|
+
}
|
|
110
|
+
const scopes = normalizeScopes(raw.scopes);
|
|
111
|
+
const issuedAt = timestamp(raw.issuedAt, 'issuedAt');
|
|
112
|
+
const notBefore = timestamp(raw.notBefore, 'notBefore');
|
|
113
|
+
const expiresAt = timestamp(raw.expiresAt, 'expiresAt');
|
|
114
|
+
const clock = Number(now());
|
|
115
|
+
if (!Number.isFinite(clock) || notBefore > clock) {
|
|
116
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
117
|
+
'contesto identita online non ancora valido');
|
|
118
|
+
}
|
|
119
|
+
if (expiresAt <= clock || expiresAt <= issuedAt || notBefore > expiresAt) {
|
|
120
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
121
|
+
'contesto identita online scaduto o temporalmente incoerente');
|
|
122
|
+
}
|
|
123
|
+
const from = { instanceId: ownerInstanceId, cell: cellId, tmuxSession };
|
|
124
|
+
if (raw.from !== undefined && JSON.stringify(raw.from) !== JSON.stringify(from)) {
|
|
125
|
+
throw identityContextError(IDENTITY_CONTEXT_FROM_MISMATCH,
|
|
126
|
+
'contesto identita online: from discordante rifiutato');
|
|
127
|
+
}
|
|
128
|
+
const context = {
|
|
129
|
+
version: IDENTITY_SCHEMA_VERSION,
|
|
130
|
+
kind: raw.kind,
|
|
131
|
+
verified: true,
|
|
132
|
+
mode: 'shared',
|
|
133
|
+
origin: raw.origin,
|
|
134
|
+
bindingId,
|
|
135
|
+
ownerInstanceId,
|
|
136
|
+
cellId,
|
|
137
|
+
tmuxSession,
|
|
138
|
+
from,
|
|
139
|
+
audience,
|
|
140
|
+
connectionId,
|
|
141
|
+
scopes,
|
|
142
|
+
issuedAt: raw.issuedAt,
|
|
143
|
+
notBefore: raw.notBefore,
|
|
144
|
+
expiresAt: raw.expiresAt,
|
|
145
|
+
};
|
|
146
|
+
if (raw.threadId !== undefined) context.threadId = requiredString(raw.threadId, 'threadId');
|
|
147
|
+
// cwd is intentionally not subject to the short identity-field bound: it is
|
|
148
|
+
// a path claim, while the signed identity fields remain bounded above.
|
|
149
|
+
if (raw.cwd !== undefined) context.cwd = requiredString(raw.cwd, 'cwd', { max: null });
|
|
150
|
+
if (raw.liveHost !== undefined) context.liveHost = normalizeLiveHost(raw.liveHost);
|
|
151
|
+
if (raw.origin === 'remote_live' && !context.liveHost) {
|
|
152
|
+
throw identityContextError(IDENTITY_CONTEXT_MISSING,
|
|
153
|
+
'contesto identita online: liveHost obbligatorio per remote_live');
|
|
154
|
+
}
|
|
155
|
+
return Object.freeze(context);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
IDENTITY_SCHEMA_VERSION,
|
|
160
|
+
IDENTITY_KINDS,
|
|
161
|
+
IDENTITY_ORIGINS,
|
|
162
|
+
IDENTITY_CONTEXT_MISSING,
|
|
163
|
+
IDENTITY_CONTEXT_UNVERIFIED,
|
|
164
|
+
IDENTITY_CONTEXT_FROM_MISMATCH,
|
|
165
|
+
IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
|
|
166
|
+
identityContextError,
|
|
167
|
+
normalizeIdentityContext,
|
|
168
|
+
};
|