@mmmbuto/nexuscrew 0.9.39 → 0.9.41

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.
@@ -19,6 +19,7 @@ const {
19
19
  } = require('./definitions.js');
20
20
  const {
21
21
  describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels, extraModelsFrom,
22
+ knownMcpServerNames,
22
23
  } = require('./managed.js');
23
24
  const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
24
25
  const {
@@ -28,6 +29,56 @@ const {
28
29
  redactSecrets, sanitizeEarlyDiagnostic,
29
30
  } = require('./launch.js');
30
31
  const { waitDeliveryReport, actionRequiredFor, vlPaneReadiness } = require('./prompt-delivery.js');
32
+ const { scriviGenerazione } = require('../files/activity.js');
33
+
34
+ // Insieme MCP MATERIALIZZATO della cella — le chiavi del file che
35
+ // writeCellMcpConfig (managed.js) scrive PRIMA dello spawn. Un server non
36
+ // concesso non esiste per il launch, quindi non e' atteso dal gate.
37
+ const MCP_BUDGET_DEFAULT_MS = 20000; // tetto approvato per il gate MCP
38
+
39
+ function cellMcpExpectedServers(home, cellId) {
40
+ const fs = require('node:fs');
41
+ try {
42
+ const p = path.join(home, '.nexuscrew', 'cell-mcp', `${cellId}.json`);
43
+ const st = fs.lstatSync(p);
44
+ if (st.isSymbolicLink() || !st.isFile()) return [];
45
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
46
+ const servers = parsed && typeof parsed === 'object' && parsed.mcpServers
47
+ && typeof parsed.mcpServers === 'object' && !Array.isArray(parsed.mcpServers)
48
+ ? parsed.mcpServers : null;
49
+ return servers ? Object.keys(servers).sort() : [];
50
+ } catch (_) { return []; }
51
+ }
52
+
53
+ // Celle claude LEGACY (nessun materializzato cell-mcp): l'atteso e' la config
54
+ // nota al client (le tre sorgenti di `knownMcpServerNames`) meno cio' che la
55
+ // cella nega con `cell.mcp`. RESIDUO DICHIARATO: plugin, connettori e
56
+ // configurazione gestita di sistema non sono enumerabili e restano fuori
57
+ // dall'atteso — il gate copre cio' che e' enumerabile, mai promette il totale.
58
+ function legacyMcpExpectedServers(home, cwd, cell) {
59
+ const noti = [...(knownMcpServerNames(home, cwd) || [])];
60
+ if (!noti.length) return [];
61
+ const voluti = cell && Array.isArray(cell.mcp) ? cell.mcp : null;
62
+ if (voluti === null) return [...noti].sort();
63
+ const concessi = new Set(voluti.filter((n) => typeof n === 'string'));
64
+ return [...noti].filter((n) => concessi.has(n)).sort();
65
+ }
66
+
67
+ // Payload readiness per il supervisore: cwd reale della cella + attese + tetto
68
+ // MCP. Priorita' all'insieme MATERIALIZZATO (cell-mcp); per le claude legacy
69
+ // ricade sulla config nota meno i negati. undefined quando la cella non ha
70
+ // server attesi (niente gate: consegna come kimi, che da 0.8.47 non ha gate).
71
+ function buildMcpReadinessPayload(cell, realCwd, home, cfg) {
72
+ const expectedServers = cellMcpExpectedServers(home, cell.id).length
73
+ ? cellMcpExpectedServers(home, cell.id)
74
+ : legacyMcpExpectedServers(home, realCwd, cell);
75
+ if (!expectedServers.length) return undefined;
76
+ return {
77
+ cwd: realCwd,
78
+ expectedServers,
79
+ budgetMs: Math.max(0, Math.min(120000, Number(cfg && cfg.mcpReadyBudgetMs) || MCP_BUDGET_DEFAULT_MS)),
80
+ };
81
+ }
31
82
 
32
83
  // TTL della cache status (ms): scaduto, status rilegge tmux + defs da disco.
33
84
  const STATUS_TTL_MS = 2000;
@@ -241,7 +292,16 @@ function createBuiltinRuntime(ctx) {
241
292
  if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
242
293
  let launchEngine = engine;
243
294
  if (engine.managed) {
244
- const resolved = resolveManagedEngine(engine, cell, { ...cfg, home, extraModels: extraModelsFrom(defs), capabilityProfiles: defs.capabilityProfiles || null });
295
+ // Generazione del lancio: identifica QUESTA partenza della cella. Uno
296
+ // stato di attivita' che ne porta un'altra appartiene a un lancio
297
+ // precedente e viene scartato — una cella riavviata non eredita il
298
+ // «lavora» di prima.
299
+ const generazione = crypto.randomBytes(8).toString('hex');
300
+ const resolved = resolveManagedEngine(engine, cell, {
301
+ ...cfg, home, extraModels: extraModelsFrom(defs),
302
+ capabilityProfiles: defs.capabilityProfiles || null,
303
+ activityGeneration: generazione,
304
+ });
245
305
  // Il materializzatore MCP per cella e' FAIL-CLOSED: se il file per cella
246
306
  // non si scrive, la cella non parte — mai con la superficie intera dove
247
307
  // l'operatore l'ha ristretta.
@@ -253,6 +313,11 @@ function createBuiltinRuntime(ctx) {
253
313
  throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });
254
314
  }
255
315
  launchEngine = resolved.engine;
316
+ // La generazione si scrive PRIMA che il client parta: gli hook possono
317
+ // scattare appena il processo e' vivo, e uno stato che arrivasse prima
318
+ // della generazione verrebbe scartato come se fosse di un lancio
319
+ // precedente. Solo per le celle che hanno davvero gli hook.
320
+ if (resolved.activityDir) scriviGenerazione(resolved.activityDir, generazione);
256
321
  // A vl cell whose runtime cannot hold the per-cell prompt
257
322
  // (VL_SYSTEM_APPEND_FILE, 0.3.1+) DEGRADES by declaring it — same
258
323
  // shape as readinessDegraded: the cell starts without its own identity
@@ -342,12 +407,17 @@ function createBuiltinRuntime(ctx) {
342
407
  tmuxSession: cell.tmuxSession,
343
408
  prompt: cell.prompt,
344
409
  readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
345
- // Solo gli engine managed Kimi (kimi.native / claude.kimi-code) hanno
346
- // la delivery classificata at-most-once ai restart; gli engine custom
347
- // Send-keys conservano il reinject legacy senza Enter (0.8.47).
348
- client: engine.managed && engine.managed.client === 'kimi' ? 'kimi'
349
- : (engine.managed && engine.managed.provider === 'kimi-code' ? 'claude' : ''),
410
+ // kimi.* e TUTTE le claude.* hanno la delivery classificata
411
+ // at-most-once ai restart; gli engine custom send-keys conservano
412
+ // il reinject legacy senza Enter (0.8.47).
413
+ client: engine.managed && (engine.managed.client === 'kimi' || engine.managed.client === 'claude')
414
+ ? engine.managed.client : '',
350
415
  readyWaitMs: Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000)),
416
+ // Gate readiness MCP solo per le claude.* (budget composto col
417
+ // readyWaitMs: tetto MCP dal launch, avvio degradato visibile).
418
+ ...(engine.managed && engine.managed.client === 'claude' ? {
419
+ mcpReadiness: buildMcpReadinessPayload(cell, realCwd, home, cfg),
420
+ } : {}),
351
421
  },
352
422
  } : {}),
353
423
  });
@@ -557,14 +627,20 @@ function createBuiltinRuntime(ctx) {
557
627
  // Paste SENZA Enter, contratto invariato —), target %N esatto.
558
628
  let prompt = null;
559
629
  let actionRequired = null;
630
+ let mcpDegraded = null;
560
631
  if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
561
632
  const managedClient = engine.managed && typeof engine.managed.client === 'string' ? engine.managed.client : '';
562
633
  const managedProvider = engine.managed && typeof engine.managed.provider === 'string' ? engine.managed.provider : '';
563
- const classified = managedClient === 'kimi' || managedProvider === 'kimi-code';
634
+ const classified = managedClient === 'kimi' || managedClient === 'claude';
564
635
  if (classified) {
565
636
  const readyWaitMs = Math.max(0, Math.min(120000, Number(cfg.bootstrapReadyWaitMs) || 15000));
637
+ // Budget unico composto — il report copre anche il tetto MCP del
638
+ // gate (l'attesa MCP consuma lo stesso orologio del launch).
639
+ const mcpBudgetMs = managedClient === 'claude'
640
+ ? Math.max(0, Math.min(120000, Number(cfg.mcpReadyBudgetMs) || MCP_BUDGET_DEFAULT_MS))
641
+ : 0;
566
642
  const reportWaitMs = Math.max(100, Math.min(150000,
567
- (Number(cfg.sendKeysReadyMs) || readyMs) + readyWaitMs + 2000));
643
+ (Number(cfg.sendKeysReadyMs) || readyMs) + readyWaitMs + mcpBudgetMs + 2000));
568
644
  const report = await waitDeliveryReport(tmuxBin,
569
645
  paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`,
570
646
  { env: minimalEnv(), timeoutMs: reportWaitMs });
@@ -576,8 +652,17 @@ function createBuiltinRuntime(ctx) {
576
652
  delivered: delivery.delivered,
577
653
  state: delivery.state,
578
654
  reason: delivery.reason,
655
+ ...(delivery.mcp ? { mcp: delivery.mcp } : {}),
579
656
  };
580
657
  actionRequired = actionRequiredFor(managedClient, managedProvider, delivery);
658
+ // Avvio degradato visibile: l'elenco bounded dei server MCP non pronti
659
+ // sale alla UI con il flag esistente readinessDegraded.
660
+ if (delivery.mcp && delivery.mcp.state === 'degraded') {
661
+ mcpDegraded = {
662
+ failed: Array.isArray(delivery.mcp.failed) ? delivery.mcp.failed : [],
663
+ pending: Array.isArray(delivery.mcp.pending) ? delivery.mcp.pending : [],
664
+ };
665
+ }
581
666
  } else {
582
667
  const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
583
668
  prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
@@ -593,6 +678,7 @@ function createBuiltinRuntime(ctx) {
593
678
  ok: true, cell: cellId, session: cell.tmuxSession, prompt,
594
679
  ...(actionRequired ? { actionRequired } : {}),
595
680
  ...(readinessDegraded ? { readinessDegraded: true } : {}),
681
+ ...(mcpDegraded ? { readinessDegraded: true, mcpDegraded } : {}),
596
682
  // Per-cell prompt not delivered to a vl cell (old runtime or
597
683
  // unwritable file). Strict boolean like readinessDegraded: the
598
684
  // payload may come from a federated node and the text is local i18n.
@@ -630,4 +716,4 @@ function createBuiltinRuntime(ctx) {
630
716
  };
631
717
  }
632
718
 
633
- module.exports = { createBuiltinRuntime, findCell, findEngine, identityLaunchRefusal, STATUS_TTL_MS };
719
+ module.exports = { createBuiltinRuntime, findCell, findEngine, identityLaunchRefusal, STATUS_TTL_MS, buildMcpReadinessPayload, cellMcpExpectedServers };
@@ -14,23 +14,66 @@
14
14
  // refused locally until verification resolves the attempt.
15
15
 
16
16
  const crypto = require('node:crypto');
17
+ const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
17
18
  const IDLE_MS = 15000;
18
19
 
19
- function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {} } = {}) {
20
+ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot } = {}) {
20
21
  // (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
21
22
  const attempts = new Map();
22
23
 
23
24
  function requestId() { return crypto.randomUUID(); }
24
25
 
25
- function resolveOwner(ownerId) {
26
+ // Risoluzione del produttore: peer OUTBOUND (canale proprio, come sempre)
27
+ // oppure peer INBOUND condiviso con consenso eventi+risposta e slot reverse
28
+ // attivo. Per gli inbound la porta dello slot DEVE essere verificata con la
29
+ // stessa sonda dei terminali (secret del peer + instanceId atteso): nessun
30
+ // fallback sulla localPort senza proof.
31
+ function candidateOwner(ownerId) {
26
32
  const store = loadStore();
27
33
  if (!store) return null;
28
- return (store.nodes || []).find((n) => n && n.direction === 'outbound'
29
- && n.nodeId === ownerId && n.token && n.localPort) || null;
34
+ const nodes = store.nodes || [];
35
+ const out = nodes.find((n) => n && n.direction === 'outbound'
36
+ && n.nodeId === ownerId && n.token && n.localPort);
37
+ if (out) return { node: out, forwardPort: out.localPort, forwardToken: out.token, kind: 'outbound' };
38
+ const inbound = nodes.find((n) => n && n.direction === 'inbound'
39
+ && n.shared === true && n.nodeId === ownerId
40
+ && (n.token || n.acceptToken)
41
+ && n.askReplyAccess === true && n.eventsAccess === true
42
+ && n.cellVisibility !== 'none');
43
+ if (!inbound) return null;
44
+ const forwardToken = inbound.token || inbound.acceptToken;
45
+ const pool = inbound.reversePool;
46
+ const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
47
+ if (!slot || !Number.isInteger(slot.port)) {
48
+ return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified' };
49
+ }
50
+ return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound' };
51
+ }
52
+
53
+ function ownerUrl(port, path) {
54
+ return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
30
55
  }
31
56
 
32
- function ownerUrl(peer, path) {
33
- return `http://127.0.0.1:${peer.localPort}/federation/route/_/${path.replace(/^\//, '')}`;
57
+ // Preflight del canale reverse per un peer inbound: rifiuto NETTO se lo slot
58
+ // non e' verificato (non e' un esito incerto: il forward non e' mai partito).
59
+ async function verifyInboundChannel(candidate) {
60
+ const { node, forwardPort } = candidate;
61
+ const pool = node.reversePool;
62
+ const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
63
+ const proof = await probeReverseSlotImpl({
64
+ port: forwardPort, secret: node.token,
65
+ expected: {
66
+ remotePort: forwardPort,
67
+ generation: pool && Number.isInteger(pool.activeGeneration) ? pool.activeGeneration : undefined,
68
+ instanceId: node.nodeId,
69
+ },
70
+ });
71
+ if (proof && proof.owned === true) return { ok: true };
72
+ return {
73
+ ok: false, code: 404,
74
+ error: 'canale reverse non verificato: risposta non inoltrata',
75
+ reason: 'reverse-slot-unverified',
76
+ };
34
77
  }
35
78
 
36
79
  function attemptKey(ownerId, askId, rid) { return `${ownerId}|${askId}|${rid}`; }
@@ -47,16 +90,35 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
47
90
  return statusFor(ownerId, askId).some((a) => a.state === 'uncertain');
48
91
  }
49
92
 
93
+ // Il blocco precedente rifiuta il nuovo requestId PRIMA di creare la sua
94
+ // ricevuta: la sola verifica possibile e' sull'ID originale ancora 'sent'
95
+ // (se esiste), altrimenti resta solo la riconciliazione con l'owner.
96
+ function originalSentRequestId(ownerId, askId, excludeRid) {
97
+ const sent = statusFor(ownerId, askId)
98
+ .filter((a) => (a.state === 'sent' || a.state === 'uncertain') && a.requestId !== excludeRid);
99
+ return sent.length ? sent[sent.length - 1].requestId : null;
100
+ }
101
+
50
102
  async function relayAnswer({ ownerId, askId, text, rid: providedRid } = {}) {
51
103
  if (!ownerId || !askId || typeof text !== 'string' || !text.trim()) {
52
104
  return { ok: false, code: 400, error: 'ownerId, askId e text richiesti' };
53
105
  }
54
- const peer = resolveOwner(ownerId);
55
- if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
106
+ const candidate = candidateOwner(ownerId);
107
+ if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
108
+ if (candidate.kind === 'inbound-unverified') {
109
+ return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
110
+ }
111
+ if (candidate.kind === 'inbound') {
112
+ const gate = await verifyInboundChannel(candidate);
113
+ if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
114
+ }
115
+ const peer = candidate.node;
56
116
  // Local reconciliation FIRST: an uncertain attempt on this ask must be
57
117
  // verified before any new paste leaves this node.
58
118
  if (isUncertain(ownerId, askId)) {
59
- return { ok: false, code: 409, error: 'esito incerto: verifica lo stato prima di rispondere', reason: 'uncertain', uncertain: true };
119
+ // L'ID originale e' quello verificabile: il nuovo invio non e' mai partito.
120
+ return { ok: false, code: 409, error: 'esito incerto: verifica lo stato prima di rispondere', reason: 'uncertain', uncertain: true,
121
+ originalRequestId: originalSentRequestId(ownerId, askId) };
60
122
  }
61
123
  const rid = providedRid || requestId();
62
124
  const key = attemptKey(ownerId, askId, rid);
@@ -65,10 +127,10 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
65
127
  const ctrl = new AbortController();
66
128
  const timer = setTimeout(() => ctrl.abort(), IDLE_MS);
67
129
  try {
68
- const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}/answer`), {
130
+ const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/answer`), {
69
131
  method: 'POST',
70
132
  headers: {
71
- authorization: `Bearer ${peer.token}`,
133
+ authorization: `Bearer ${candidate.forwardToken}`,
72
134
  'content-type': 'application/json',
73
135
  'x-nexuscrew-visited': store.nodeId,
74
136
  },
@@ -86,6 +148,13 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
86
148
  // could have pasted (timeout-ish) parks as uncertain, never retried.
87
149
  if (r.status === 409) {
88
150
  if (entry) entry.state = body.reason === 'delivery-unknown-block' ? 'uncertain' : 'failed';
151
+ if (body.reason === 'delivery-unknown-block') {
152
+ // Stesso contratto del ramo 502: la UI riceve 200+uncertain e non
153
+ // ripete mai il paste. La verifica possibile e' sull'ID originale.
154
+ return { ok: false, code: 200, uncertain: true, reason: body.reason,
155
+ originalRequestId: originalSentRequestId(ownerId, askId, rid), requestId: rid,
156
+ error: 'esito incerto: bloccato da un invio precedente' };
157
+ }
89
158
  return { ok: false, code: 409, error: body.error || 'conflict', reason: body.reason };
90
159
  }
91
160
  if (r.status === 502) {
@@ -108,13 +177,21 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
108
177
 
109
178
  // Remote dismiss: same resolution rules as the answer (DELETE on the owner).
110
179
  async function relayDismiss({ ownerId, askId } = {}) {
111
- const peer = resolveOwner(ownerId);
112
- if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati' };
180
+ const candidate = candidateOwner(ownerId);
181
+ if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
182
+ if (candidate.kind === 'inbound-unverified') {
183
+ return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
184
+ }
185
+ if (candidate.kind === 'inbound') {
186
+ const gate = await verifyInboundChannel(candidate);
187
+ if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
188
+ }
189
+ const peer = candidate.node;
113
190
  const store = loadStore();
114
191
  try {
115
- const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}`), {
192
+ const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}`), {
116
193
  method: 'DELETE',
117
- headers: { authorization: `Bearer ${peer.token}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
194
+ headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
118
195
  });
119
196
  const body = await r.json().catch(() => ({}));
120
197
  if (!r.ok) return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}` };
@@ -123,12 +200,20 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
123
200
  }
124
201
 
125
202
  async function verifyStatus({ ownerId, askId, requestId: rid }) {
126
- const peer = resolveOwner(ownerId);
127
- if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati' };
203
+ const candidate = candidateOwner(ownerId);
204
+ if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
205
+ if (candidate.kind === 'inbound-unverified') {
206
+ return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
207
+ }
208
+ if (candidate.kind === 'inbound') {
209
+ const gate = await verifyInboundChannel(candidate);
210
+ if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
211
+ }
212
+ const peer = candidate.node;
128
213
  const store = loadStore();
129
214
  try {
130
- const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}/requests/${rid}`), {
131
- headers: { authorization: `Bearer ${peer.token}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
215
+ const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/requests/${rid}`), {
216
+ headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
132
217
  });
133
218
  if (!r.ok) return { ok: false, code: r.status };
134
219
  const body = await r.json();