@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
package/lib/audio/routes.js
CHANGED
|
@@ -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
|
}
|
package/lib/cells/routes.js
CHANGED
|
@@ -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
|
package/lib/cli/commands.js
CHANGED
|
@@ -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
|
-
|
|
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') {
|
package/lib/files/routes.js
CHANGED
|
@@ -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({
|
|
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,
|
|
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 });
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -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
|
|
|
@@ -695,12 +696,31 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
695
696
|
});
|
|
696
697
|
await ensureProtection();
|
|
697
698
|
|
|
698
|
-
// --- Tutti i gate availability passati. Ora (e solo ora) creiamo
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
const
|
|
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;
|
|
702
718
|
const launchBroker = cfg.launchBroker || createLaunchBroker({
|
|
703
719
|
...cfg, home,
|
|
720
|
+
identityMode,
|
|
721
|
+
identityAuthority,
|
|
722
|
+
identityDaemonCredential: cfg.identityDaemonCredential,
|
|
723
|
+
identityLauncherCredential: cfg.identityLauncherCredential,
|
|
704
724
|
// R3.1.1: la connessione broker one-shot resta APERTA dopo il payload e diviene
|
|
705
725
|
// il canale lease del supervisore.
|
|
706
726
|
...(leaseManager ? {
|
|
@@ -735,6 +755,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
735
755
|
// qui sotto riusa. status/up/down/restart sono INVARIATI.
|
|
736
756
|
const rt = createBuiltinRuntime({
|
|
737
757
|
cfg, home, defsPath, tmuxBin, readonly, launchBroker, leaseManager, boot, ensureProtection,
|
|
758
|
+
identityAuthority, identityMode, identityOwnerInstanceId,
|
|
738
759
|
});
|
|
739
760
|
const {
|
|
740
761
|
status, cellStatus, up, down, restart, isCellSession,
|
|
@@ -1453,6 +1474,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
1453
1474
|
// MCP delle celle consuma per register/refresh/recovery (B5). Assente se il
|
|
1454
1475
|
// lease e' disattivato: le route rispondono 501, non 500.
|
|
1455
1476
|
...(leaseManager ? { lease: leaseManager } : {}),
|
|
1477
|
+
...(identityAuthority ? { identityAuthority } : {}),
|
|
1456
1478
|
};
|
|
1457
1479
|
}
|
|
1458
1480
|
|
package/lib/fleet/cell-exec.js
CHANGED
|
@@ -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, {
|
|
374
|
-
|
|
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,
|
|
678
|
+
receivePayload, sanitizeSpawnError, normalizeSupervise, waitChild, startGenerationPrompt,
|
|
679
|
+
createIdentityChannel, validIdentityRequest, main,
|
|
436
680
|
};
|