@mmmbuto/nexuscrew 0.9.77 → 0.9.78

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.
@@ -61,9 +61,18 @@ function fleetRoutes(fleetP, cfg = {}) {
61
61
  res.json(result);
62
62
  } catch (e) {
63
63
  if (opts.action) {
64
+ // Esito incerto della rotazione di sessione: un enum chiuso, mai un
65
+ // testo libero. Vale solo se la richiesta e' stata CONSUMATA e il
66
+ // lancio e' fallito dopo: il fatto che il marcatore e' consumato
67
+ // senza che l'esito sia tracciato, dichiarato dove si legge. Il
68
+ // marker e' lo STESSO in ogni ramo diagnostico di un lancio
69
+ // fallito: un fallimento che nasce nello spawn non e' un caso in
70
+ // cui il consumo diventa invisibile.
71
+ const consumption = e && e.vlNewSessionOutcome ? { newSession: e.vlNewSessionOutcome } : {};
64
72
  const match = String(e && e.message || '').match(/cell spawn failed:\s+([A-Z][A-Z0-9_]{0,31})\s+([A-Za-z0-9._+-]{1,128})/);
65
73
  if (match) emit('error', 'CELL_SPAWN_FAILED', 'Cell client spawn failed', {
66
74
  action: opts.action, cell: safeCell(req.body), errno: match[1], client: match[2], status: e.status || 500,
75
+ ...consumption,
67
76
  });
68
77
  else emit('warn', 'FLEET_ACTION_FAILED', 'Fleet action failed', {
69
78
  action: opts.action, cell: safeCell(req.body), state: 'failed',
@@ -73,6 +82,7 @@ function fleetRoutes(fleetP, cfg = {}) {
73
82
  // argv, env, prompt, token or credential ever enters here. Untagged /
74
83
  // legacy errors degrade to the bounded UNKNOWN fallback.
75
84
  code: codeOf(e.fleetCode), phase: phaseOf(e.fleetPhase),
85
+ ...consumption,
76
86
  });
77
87
  }
78
88
  res.status(e.status || 500).json({
@@ -138,6 +148,35 @@ function fleetRoutes(fleetP, cfg = {}) {
138
148
  return f.down(cell, { boot: !!b.boot });
139
149
  }, { mutate: true, action: 'down' }));
140
150
  r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true, action: 'restart' }));
151
+ // Sessione nuova esplicita (vl): crea la richiesta persistente consumabile
152
+ // UNA volta dal prossimo lancio. Azione separata dal ciclo di lancio (nessun
153
+ // avvio implicito, nessun flag dell'engine): muta lo stato delle richieste,
154
+ // quindi e' gated da READONLY come le altre mutazioni. Senza `action:` perche'
155
+ // non avvia né ferma nulla — i diagnostic FLEET_ACTION_* descrivono
156
+ // transizioni di stato della cella, e qui la cella non cambia stato.
157
+ r.post('/vl-new-session', guard((f, b) => {
158
+ if (typeof f.vlNewSession !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
159
+ return f.vlNewSession(String(b.cell || ''));
160
+ }, { mutate: true }));
161
+ // Stato della richiesta di sessione nuova (read-only, per cella): il posto
162
+ // dove si vede che una richiesta e' stata CONSUMATA e l'esito non e'
163
+ // tracciato — senza creare una nuova richiesta, che sarebbe un'altra
164
+ // mutazione e un'altra rotazione possibile. Non e' gated da READONLY perche'
165
+ // non muta nulla, e il bersaglio e' dichiarato (scope-guard), quindi la
166
+ // risposta non porta lo stato di una cella fuori scope.
167
+ r.get('/vl-new-session-state', guard((f, _b, req) => {
168
+ if (typeof f.vlNewSessionState !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
169
+ const q = (req && req.query) || {};
170
+ return f.vlNewSessionState(String(q.cell || ''));
171
+ }));
172
+ // Inventario legacy vl (read-only): elenca le cartelle di stato condivise
173
+ // pre-contratto con le sessioni trovate. Diagnosi locale: niente migrazione,
174
+ // niente spostamenti, niente cancellazioni — mai gated da READONLY perche'
175
+ // non muta nulla.
176
+ r.get('/vl-legacy-inventory', guard((f) => {
177
+ if (typeof f.vlLegacyInventory !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
178
+ return f.vlLegacyInventory();
179
+ }));
141
180
  r.post('/engine', guard((f, b) => {
142
181
  const opts = { model: typeof b.model === 'string' ? b.model : '' };
143
182
  if (typeof b.permissionPolicy === 'string') opts.permissionPolicy = b.permissionPolicy;
@@ -31,6 +31,7 @@ const {
31
31
  redactSecrets, sanitizeEarlyDiagnostic,
32
32
  } = require('./launch.js');
33
33
  const { waitDeliveryReport, actionRequiredFor, vlPaneReadiness } = require('./prompt-delivery.js');
34
+ const { consumeVlNewSession, vlNewSessionStatus } = require('./vl-session.js');
34
35
 
35
36
  // Insieme MCP MATERIALIZZATO della cella — le chiavi del file che
36
37
  // writeCellMcpConfig (managed.js) scrive PRIMA dello spawn. Un server non
@@ -84,6 +85,19 @@ function buildMcpReadinessPayload(cell, realCwd, home, cfg) {
84
85
  // TTL della cache status (ms): scaduto, status rilegge tmux + defs da disco.
85
86
  const STATUS_TTL_MS = 2000;
86
87
 
88
+ // Pausa minima fra due lanci della STESSA cella vl (ms). Il default è i 10 s
89
+ // del contratto; configurabile via cfg.vlLaunchCooldownMs (0 lo disattiva,
90
+ // il tetto alto è 1 h). Il clock è il MONOTONO (seam cfg.monotonicNow nei test):
91
+ // un balzo dell'orologio di parete non accorcia la pausa minima.
92
+ const VL_LAUNCH_COOLDOWN_DEFAULT_MS = 10000;
93
+ const VL_LAUNCH_COOLDOWN_MAX_MS = 3600000;
94
+
95
+ function clampCooldown(ms) {
96
+ const n = Number(ms);
97
+ if (!Number.isFinite(n)) return VL_LAUNCH_COOLDOWN_DEFAULT_MS;
98
+ return Math.max(0, Math.min(VL_LAUNCH_COOLDOWN_MAX_MS, Math.floor(n)));
99
+ }
100
+
87
101
  function findCell(defs, id) { return defs.cells.find((c) => c.id === id) || null; }
88
102
  function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
89
103
 
@@ -118,6 +132,28 @@ function createBuiltinRuntime(ctx) {
118
132
  const identityDaemonBootId = cfg.identityDaemonBootId || crypto.randomBytes(16).toString('hex');
119
133
  let cache = { at: 0, defs: boot, sessions: new Set() };
120
134
 
135
+ // --- Serializzazione dei lanci per cella (contratto vl, generale) ----------
136
+ // Due up() concorrenti della STESSA cella si serializzano su una catena di
137
+ // promise: il secondo vede lo stato lasciato dal primo (sessione viva ->
138
+ // 409 di preflight) invece di correre contro di lui sulla new-session. Il
139
+ // 409 resta la rete finale; il lock toglie la corsa. Celle diverse non si
140
+ // toccano. La mappa vive nel runtime (per processo): i nodi non condividono
141
+ // lanci, quindi non c'è niente da coordinare fuori processo.
142
+ const launchLocks = new Map();
143
+ // Timestamp (seam cfg.now) dell'ultimo lancio PER CELLA vl: base della
144
+ // pausa minima fra due lanci della stessa cella.
145
+ const vlLastLaunchAt = new Map();
146
+ const nowMs = () => (typeof cfg.now === 'function' ? cfg.now() : Date.now());
147
+
148
+ // Il clock del cooldown e' MONOTONO, mai l'orologio di parete: un balzo
149
+ // dell'ora (NTP, correzione manuale) non puo' accorciare la pausa minima
150
+ // fra due lanci della stessa cella, e non puo' nemmeno allungarla. Il
151
+ // default e' il clock monotono del processo; il seam `cfg.monotonicNow`
152
+ // serve ai test, che cosi' non dormono mai.
153
+ const cooldownNow = () => (typeof cfg.monotonicNow === 'function'
154
+ ? cfg.monotonicNow()
155
+ : Number(process.hrtime.bigint() / 1000000n));
156
+
121
157
  function reloadDefs() {
122
158
  const d = loadDefinitions(defsPath);
123
159
  if (d) cache = { ...cache, at: 0, defs: d };
@@ -199,6 +235,11 @@ function createBuiltinRuntime(ctx) {
199
235
  // if nel frontend. Solo la chiave esplicita comanda: qui non si
200
236
  // interroga mai docker (percorso caldo a polling).
201
237
  const panelUrl = aiDesktop.pubblicaPanelUrlCaldo(c.panelUrl || engineDef?.panelUrl || '');
238
+ // Le celle vl portano con se' lo stato della richiesta di sessione nuova:
239
+ // e' l'unico posto dove l'esito di un consumo si vede senza creare una
240
+ // NUOVA richiesta (che sarebbe un'altra mutazione). Nomi e timestamp
241
+ // del marcatore: nessun contenuto di sessione, mai.
242
+ const isVl = !!(engineDef && engineDef.managed && engineDef.managed.client === 'vl');
202
243
  return {
203
244
  // `cell` resta l'id: e' la chiave di indirizzamento. `label` e' il nome
204
245
  // leggibile e viaggia accanto, senza mai sostituirlo.
@@ -224,6 +265,7 @@ function createBuiltinRuntime(ctx) {
224
265
  : 'unavailable',
225
266
  rc: '', key: '', degraded: false, // supervisor vivo <=> sessione tmux viva
226
267
  panelUrl,
268
+ ...(isVl ? { newSession: vlNewSessionStatus({ home, cellId: c.id }) } : {}),
227
269
  };
228
270
  });
229
271
  return {
@@ -302,8 +344,53 @@ function createBuiltinRuntime(ctx) {
302
344
  // Le override {engine,boot} del contratto route sono ignorate: il builtin e'
303
345
  // definitions-driven (l'engine della cella e' quello dichiarato; boot e' uno
304
346
  // stato persistente gestito da boot()). Lancia SENZA shell.
347
+ //
348
+ // Il corpo vero vive in upSerializzato; up() e' il lock per cella: due
349
+ // richieste concorrenti della stessa cella si mettono in fila (la seconda
350
+ // parte DOPO la prima, non accanto) e la coda si svuota da se'.
305
351
  async function up(cellId /* , { engine, boot } = {} */) {
306
352
  if (readonly()) throw httpError(403, 'READONLY: up bloccato');
353
+ const previous = launchLocks.get(cellId) || Promise.resolve();
354
+ let release;
355
+ const gate = new Promise((resolve) => { release = resolve; });
356
+ const chained = previous.then(() => gate);
357
+ launchLocks.set(cellId, chained);
358
+ try {
359
+ await previous;
360
+ return await upSerializzato(cellId);
361
+ } catch (e) {
362
+ throw annotaEsitoSessioneNuova(e, cellId);
363
+ } finally {
364
+ release();
365
+ if (launchLocks.get(cellId) === chained) launchLocks.delete(cellId);
366
+ }
367
+ }
368
+
369
+ // Il consumo del marcatore «sessione nuova» e' un FATTO che il solo
370
+ // errore di lancio non racconta: se il fallimento arriva DOPO il consumo,
371
+ // la richiesta e' stata consumata e l'esito vero (la sessione e' ruotata o
372
+ // no) NON e' tracciato da nessuna parte. Il rilievo e' che per sapere questo
373
+ // si doveva creare una NUOVA richiesta — cioe' un'altra mutazione, e una
374
+ // rotazione in piu'. Qui l'errore dichiara l'esito incerto: si sa che il
375
+ // consumo c'e' stato, e non si finge che la rotazione sia avvenuta. La
376
+ // richiesta consumata non viene MAI ripetuta in automatico.
377
+ function annotaEsitoSessioneNuova(e, cellId) {
378
+ try {
379
+ const st = vlNewSessionStatus({ home, cellId });
380
+ if (!st || st.state !== 'consumed') return e;
381
+ if (e && typeof e === 'object') {
382
+ e.data = { ...(e.data || {}), vlNewSession: st };
383
+ // Marker BOUNDED per i diagnostici: un enum, mai un testo libero.
384
+ e.vlNewSessionOutcome = 'consumed-unknown';
385
+ }
386
+ } catch (_) {
387
+ // Lo stato del marcatore non e' un errore: l'errore vero resta quello
388
+ // del lancio, e non si maschera con una lettura fallita.
389
+ }
390
+ return e;
391
+ }
392
+
393
+ async function upSerializzato(cellId /* , { engine, boot } = {} */) {
307
394
  if (typeof ensureProtection === 'function') await ensureProtection();
308
395
  const defs = reloadDefs();
309
396
  const cell = findCell(defs, cellId);
@@ -329,6 +416,21 @@ function createBuiltinRuntime(ctx) {
329
416
  throw httpError(409, 'sessione già in esecuzione', null,
330
417
  { phase: 'preflight', code: 'SESSION_DUPLICATE' });
331
418
  }
419
+ // Pausa minima fra due lanci della STESSA cella vl (contratto vl): il
420
+ // cooldown deduplica doppio click/retry e mette un passo massimo alla
421
+ // ripetizione dei lanci. Il rifiuto è dichiarato (429), mai un'attesa
422
+ // silenziosa dentro la route. Le celle non-vl non sono toccate; il clock
423
+ // è il seam cfg.now, i test non dormono.
424
+ if (engine.managed && engine.managed.client === 'vl') {
425
+ const cooldownMs = clampCooldown(cfg.vlLaunchCooldownMs !== undefined
426
+ ? cfg.vlLaunchCooldownMs : VL_LAUNCH_COOLDOWN_DEFAULT_MS);
427
+ const last = vlLastLaunchAt.get(cellId);
428
+ if (cooldownMs > 0 && typeof last === 'number' && cooldownNow() - last < cooldownMs) {
429
+ throw httpError(429,
430
+ `pausa minima fra due lanci della stessa cella vl non trascorsa (${cooldownMs} ms)`,
431
+ null, { phase: 'preflight', code: 'VL_LAUNCH_COOLDOWN' });
432
+ }
433
+ }
332
434
  let launchEngine = engine;
333
435
  // Canale di attivita' della cella: `resolved` vive solo dentro il ramo
334
436
  // managed, e il payload del broker si costruisce FUORI. Portarlo fuori con
@@ -336,6 +438,15 @@ function createBuiltinRuntime(ctx) {
336
438
  // dichiarato in un altro: un errore che in produzione fa fallire il lancio
337
439
  // di ogni cella, non una sola asserzione.
338
440
  let activityCanale = null;
441
+ // Sessione NUOVA esplicita (contratto vl): il marcatore persistente si
442
+ // consuma qui, atomicamente (rename), PRIMA dello spawn — e una volta
443
+ // sola. Il payload porta la decisione gia' presa (vlNewSession): il
444
+ // supervisore applica --new SOLO alla generazione 0, quindi un suo retry
445
+ // non ripete la rotazione; un restart ordinario non consuma nulla perche'
446
+ // il marcatore non c'e' piu'. Se il lancio fallisce dopo il consumo, il
447
+ // record .consumed resta a dichiarare l'esito incerto: mai un secondo
448
+ // --new automatico.
449
+ let vlNewSessionApplied = false;
339
450
  if (engine.managed) {
340
451
  // Generazione del lancio: identifica QUESTA partenza della cella. Uno
341
452
  // stato di attivita' che ne porta un'altra appartiene a un lancio
@@ -346,6 +457,10 @@ function createBuiltinRuntime(ctx) {
346
457
  ...cfg, home, extraModels: extraModelsFrom(defs),
347
458
  capabilityProfiles: defs.capabilityProfiles || null,
348
459
  activityGeneration: generazione,
460
+ // Stable owner identity for per-cell vl directories: resolved on THIS
461
+ // node (executor), never from a federated viewer.
462
+ vlOwnerInstanceId: typeof identityOwnerInstanceId === 'function'
463
+ ? identityOwnerInstanceId() : identityOwnerInstanceId,
349
464
  // La generazione NON si scrive qui: viaggia negli argv degli hook e nel
350
465
  // payload del broker, e a scriverla su disco e' `cell-exec`, dentro la
351
466
  // sessione che tmux ha appena creato — quindi solo per chi vince la
@@ -386,6 +501,20 @@ function createBuiltinRuntime(ctx) {
386
501
  throw httpError(refusal.status, refusal.message, null,
387
502
  { phase: 'preflight', code: refusal.code });
388
503
  }
504
+ // Consumo del marcatore «sessione nuova» (solo celle vl): superati i
505
+ // gate di risoluzione, prima del ticket e dello spawn. Un fallimento a
506
+ // valle lascia il record .consumed: l'esito incerto si RIFERISCE, non
507
+ // si ripete. Un albero di stato ostile e' un rifiuto dichiarato, non un
508
+ // lancio senza rotazione richiesta.
509
+ if (engine.managed.client === 'vl') {
510
+ try {
511
+ const consumed = consumeVlNewSession({ home, cellId: cell.id, now: nowMs() });
512
+ if (consumed.consumed) vlNewSessionApplied = true;
513
+ } catch (e) {
514
+ throw httpError(500, `richiesta sessione nuova non consumabile in sicurezza: ${e.message}`,
515
+ null, { phase: 'preflight', code: 'VL_NEW_SESSION_UNSAFE' });
516
+ }
517
+ }
389
518
  }
390
519
 
391
520
  // (2) trust del command PRIMA di lanciare
@@ -404,6 +533,13 @@ function createBuiltinRuntime(ctx) {
404
533
  const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
405
534
  const child = composeClientInvocation(launchEngine, cell);
406
535
  let ticket;
536
+ // Punto di impegno del lancio (celle vl): da qui in poi il lancio parte,
537
+ // quindi la pausa minima conta DA ORA. Un fallimento a valle (broker,
538
+ // tmux, readiness) lascia il timestamp: il retry riparte al passo del
539
+ // cooldown, non a raffica — backoff limitato, mai attese arbitrarie.
540
+ if (engine.managed && engine.managed.client === 'vl') {
541
+ vlLastLaunchAt.set(cellId, cooldownNow());
542
+ }
407
543
  try {
408
544
  let leaseInfo = null;
409
545
  if (leaseManager) {
@@ -448,6 +584,12 @@ function createBuiltinRuntime(ctx) {
448
584
  // iniettati (`activityDir` lo dice): senza canale non c'e' niente da
449
585
  // invalidare.
450
586
  ...(activityCanale ? { activity: activityCanale } : {}),
587
+ // Sessione nuova esplicita (vl): la decisione viaggia GIÀ PRESA nel
588
+ // payload, il supervisore non la ricalcola e non rilegge mai il
589
+ // marcatore. cell-exec applica --new SOLO alla generazione 0: i suoi
590
+ // restart usano argv senza --new, quindi la rotazione avviene una
591
+ // volta sola per richiesta.
592
+ ...(vlNewSessionApplied ? { vlNewSession: true } : {}),
451
593
  supervise: {
452
594
  enabled: !launchEngine.shellOneShot,
453
595
  initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
@@ -759,6 +901,8 @@ function createBuiltinRuntime(ctx) {
759
901
  // unwritable file). Strict boolean like readinessDegraded: the
760
902
  // payload may come from a federated node and the text is local i18n.
761
903
  ...(launchEngine.vlPromptDegraded ? { vlPromptDegraded: true } : {}),
904
+ // Sessione nuova esplicita applicata a QUESTO lancio (generazione 0).
905
+ ...(vlNewSessionApplied ? { vlNewSession: true } : {}),
762
906
  };
763
907
  }
764
908
 
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+ // Explicit "new session" requests for vl cells, plus the read-only legacy
3
+ // state inventory. Two separate contracts live here because both are pure
4
+ // filesystem concerns of the vl engine family and neither belongs to the
5
+ // managed-spec resolution (managed.js) or the launch cycle (runtime.js):
6
+ //
7
+ // - NEW SESSION: an explicit operator action writes a PERSISTENT marker
8
+ // under the NexusCrew state tree. The launch cycle (runtime.up) consumes
9
+ // it ATOMICALLY (rename) before the spawn, exactly once: an ordinary
10
+ // start/restart never rotates the session, and a supervisor retry cannot
11
+ // repeat a rotation that already happened. If the outcome is uncertain
12
+ // (crash between consumption and spawn), the consumed record stays on
13
+ // disk and reports its state — a second automatic rotation never happens.
14
+ //
15
+ // - LEGACY INVENTORY: the pre-contract vl cells shared a handful of
16
+ // state directories (~/.local/state/vl*). Migration is an explicit,
17
+ // operator-driven procedure (nothing moves or gets deleted here, ever):
18
+ // this only LISTS what exists so the operator can decide with data.
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const crypto = require('node:crypto');
23
+ const { safePrivateDir } = require('./credentials.js');
24
+
25
+ // Same self-defense as writeVlCellPrompt (managed.js): the cell id is already
26
+ // validated upstream (CELL_ID_RE, definitions.js), but '.' and '..' pass that
27
+ // RE and, as a FILE NAME, they are traversal. The refusal happens before any
28
+ // disk access.
29
+ function assertSafeCellId(cellId) {
30
+ const id = typeof cellId === 'string' ? cellId : '';
31
+ if (!id || id === '.' || id === '..' || id.includes('/') || /[\x00-\x1f\x7f]/.test(id)) {
32
+ throw new Error('unsafe vl cell id');
33
+ }
34
+ return id;
35
+ }
36
+
37
+ function vlNewSessionDir(home) {
38
+ const nexus = path.join(home, '.nexuscrew');
39
+ safePrivateDir(nexus, { create: true });
40
+ const dir = path.join(nexus, 'vl-new-session');
41
+ safePrivateDir(dir, { create: true });
42
+ return dir;
43
+ }
44
+
45
+ function markerPaths(home, cellId) {
46
+ const id = assertSafeCellId(cellId);
47
+ const dir = vlNewSessionDir(home);
48
+ return { dir, requested: path.join(dir, `${id}.requested`), consumed: path.join(dir, `${id}.consumed`) };
49
+ }
50
+
51
+ // I percorsi dei marcatori per una LETTURA: nessuna directory viene creata.
52
+ // La vista dello stato e' read-only per contratto, e una lettura che lascia
53
+ // una directory sul disco non e' una lettura. La richiesta e il consumo
54
+ // scrivono, e quelli passano da `markerPaths`.
55
+ function markerPathsReadOnly(home, cellId) {
56
+ const id = assertSafeCellId(cellId);
57
+ const dir = path.join(home, '.nexuscrew', 'vl-new-session');
58
+ return { dir, requested: path.join(dir, `${id}.requested`), consumed: path.join(dir, `${id}.consumed`) };
59
+ }
60
+
61
+ // Atomic JSON write (tmp + fsync + rename, mode 0600) — same shape as the vl
62
+ // catalog copy. Never a secret: the record carries ids and timestamps only.
63
+ function writeRecordAtomic(target, record) {
64
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
65
+ let fd;
66
+ try {
67
+ fd = fs.openSync(tmp, 'wx', 0o600);
68
+ fs.writeFileSync(fd, JSON.stringify(record));
69
+ fs.fsyncSync(fd);
70
+ fs.closeSync(fd); fd = undefined;
71
+ fs.renameSync(tmp, target);
72
+ } catch (e) {
73
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch (_) {} }
74
+ try { fs.unlinkSync(tmp); } catch (_) {}
75
+ throw e;
76
+ }
77
+ }
78
+
79
+ function readRecord(file) {
80
+ try {
81
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
82
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
83
+ } catch (_) { return null; }
84
+ }
85
+
86
+ // Request a fresh session for a vl cell (explicit operator action, owner
87
+ // node). Idempotent on a pending request: a double click or a UI retry finds
88
+ // the SAME pending marker instead of stacking a second one — the request is
89
+ // consumable once, so there is nothing a second click could add.
90
+ function requestVlNewSession({ home, cellId, now = Date.now() }) {
91
+ const { requested, consumed } = markerPaths(home, cellId);
92
+ const pending = readRecord(requested);
93
+ if (pending) {
94
+ return { state: 'pending', requestedAt: pending.requestedAt || 0, alreadyPending: true };
95
+ }
96
+ const previous = readRecord(consumed);
97
+ writeRecordAtomic(requested, { cell: cellId, requestedAt: now });
98
+ return {
99
+ state: 'pending',
100
+ requestedAt: now,
101
+ alreadyPending: false,
102
+ // The last consumption, if any: with a pending request on top of it this
103
+ // is also how an "outcome unknown" previous rotation surfaces.
104
+ ...(previous ? { lastConsumedAt: previous.consumedAt || 0 } : {}),
105
+ };
106
+ }
107
+
108
+ // Consume the pending request ATOMICALLY, before the spawn. The rename IS the
109
+ // consumption: two consumers cannot both win, and a crash before the rename
110
+ // leaves the request pending (the launch never happened, the rotation never
111
+ // happened — the next launch may consume it). `consumedAt` is stamped just
112
+ // before the rename: a crash between the stamp and the rename keeps the
113
+ // request pending with a slightly early timestamp, which is harmless — the
114
+ // record still tells the truth about WHAT happened, never invents a rotation.
115
+ function consumeVlNewSession({ home, cellId, now = Date.now() }) {
116
+ const { requested, consumed } = markerPaths(home, cellId);
117
+ const pending = readRecord(requested);
118
+ if (!pending) return { consumed: false };
119
+ writeRecordAtomic(requested, { cell: cellId, requestedAt: pending.requestedAt || 0, consumedAt: now });
120
+ fs.renameSync(requested, consumed);
121
+ return { consumed: true, requestedAt: pending.requestedAt || 0, consumedAt: now };
122
+ }
123
+
124
+ // Current state of the request, for diagnostics and for reporting an
125
+ // uncertain outcome: 'pending' means the next launch will rotate; 'consumed'
126
+ // means a launch consumed the request — whether the runtime actually rotated
127
+ // is NOT tracked here (NexusCrew never reads vl session content), and that is
128
+ // exactly what the state says, instead of guessing.
129
+ function vlNewSessionStatus({ home, cellId }) {
130
+ const { requested, consumed } = markerPathsReadOnly(home, cellId);
131
+ const pending = readRecord(requested);
132
+ if (pending) {
133
+ const last = readRecord(consumed);
134
+ return {
135
+ state: 'pending',
136
+ requestedAt: pending.requestedAt || 0,
137
+ ...(last ? { lastConsumedAt: last.consumedAt || 0 } : {}),
138
+ };
139
+ }
140
+ const done = readRecord(consumed);
141
+ if (done) {
142
+ return {
143
+ state: 'consumed',
144
+ requestedAt: done.requestedAt || 0,
145
+ consumedAt: done.consumedAt || 0,
146
+ outcome: 'not-tracked',
147
+ };
148
+ }
149
+ return { state: 'none' };
150
+ }
151
+
152
+ // --- Legacy inventory (read-only) -------------------------------------------
153
+ //
154
+ // The shared pre-contract state directories: `~/.local/state/vl` and every
155
+ // `vl-*` sibling directory. Sessions live at
156
+ // `<dir>/vl/host-v1/sessions/<name>` (measured on the live tree). The new
157
+ // per-cell layout lives under ~/.nexuscrew/vl-cells and never matches this
158
+ // scan, so what this lists is legacy shared state by construction.
159
+ // Names only, never content: a session transcript stays with vl.
160
+ const LEGACY_SESSIONS_REL = path.join('vl', 'host-v1', 'sessions');
161
+ const LEGACY_SESSIONS_CAP = 64;
162
+
163
+ // True se un componente del percorso (a partire da `base`) e' un symlink.
164
+ // L'inventario legacy deve restare dentro l'albero dello stato: un symlink
165
+ // INTERMEDIO (per esempio `vl/host-v1` che punta altrove) porterebbe la
166
+ // scansione a elencare le sessioni di un albero esterno. Il controllo
167
+ // guardava solo la directory finale e il path terminale: i componenti in
168
+ // mezzo restavano fuori dal controllo.
169
+ function hasSymlinkComponent(base, target) {
170
+ const rel = path.relative(base, target);
171
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return true;
172
+ let cur = base;
173
+ for (const part of rel.split(path.sep).filter(Boolean)) {
174
+ cur = path.join(cur, part);
175
+ let st;
176
+ try { st = fs.lstatSync(cur); } catch (_) { return false; }
177
+ if (st.isSymbolicLink()) return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ function vlLegacyInventory(home) {
183
+ const stateRoot = path.join(home, '.local', 'state');
184
+ let names = [];
185
+ try { names = fs.readdirSync(stateRoot); } catch (_) { names = []; }
186
+ const entries = [];
187
+ for (const name of names.sort()) {
188
+ if (name !== 'vl' && !name.startsWith('vl-')) continue;
189
+ const dir = path.join(stateRoot, name);
190
+ let st;
191
+ try { st = fs.lstatSync(dir); } catch (_) { continue; }
192
+ if (!st.isDirectory() || st.isSymbolicLink()) continue;
193
+ const sessionsDir = path.join(dir, LEGACY_SESSIONS_REL);
194
+ // Nessun componente del percorso puo' essere un symlink: un symlink
195
+ // intermedio porterebbe l'inventario a elencare un albero esterno, che
196
+ // e' esattamente cio' che l'inventario legacy non deve fare.
197
+ if (hasSymlinkComponent(home, sessionsDir)) continue;
198
+ const sessions = [];
199
+ let hasSessionsDir = false;
200
+ try {
201
+ const sst = fs.lstatSync(sessionsDir);
202
+ if (sst.isDirectory() && !sst.isSymbolicLink()) {
203
+ hasSessionsDir = true;
204
+ for (const s of fs.readdirSync(sessionsDir).sort()) sessions.push(s);
205
+ }
206
+ } catch (_) { /* unreadable or absent: listed as found, sessions empty */ }
207
+ entries.push({
208
+ name,
209
+ sessionsDir: hasSessionsDir,
210
+ sessionCount: sessions.length,
211
+ ...(sessions.length > LEGACY_SESSIONS_CAP
212
+ ? { sessions: sessions.slice(0, LEGACY_SESSIONS_CAP), truncated: true }
213
+ : { sessions }),
214
+ });
215
+ }
216
+ return {
217
+ scanned: '.local/state',
218
+ entries,
219
+ moved: 0,
220
+ deleted: 0,
221
+ note: 'inventario read-only: nessuna migrazione, spostamento o cancellazione',
222
+ };
223
+ }
224
+
225
+ module.exports = {
226
+ requestVlNewSession,
227
+ consumeVlNewSession,
228
+ vlNewSessionStatus,
229
+ vlLegacyInventory,
230
+ };
package/lib/mcp/tools.js CHANGED
@@ -587,8 +587,8 @@ const TOOLS = [
587
587
  type: 'object',
588
588
  properties: {
589
589
  target: { type: 'string', description: 'id owner-qualified esatto restituito da nc_vl_nodes' },
590
- kind: { type: 'string', enum: ['status', 'health', 'start', 'stop', 'restart', 'version', 'capabilities', 'logs', 'update_candidate', 'unpair'] },
591
- args: { type: 'object', description: 'argomenti exact del comando bounded' },
590
+ kind: { type: 'string', description: 'Comando bounded. `prompt` porta testo dell utente (args.text, stringa <= 4 KiB) fino alla sessione del device; conferma solo via lastAck.', enum: ['status', 'health', 'start', 'stop', 'restart', 'version', 'capabilities', 'logs', 'update_candidate', 'unpair', 'prompt'] },
591
+ args: { type: 'object', description: 'argomenti exact del comando bounded; per `prompt` usare {text: string <= 4 KiB}' },
592
592
  },
593
593
  required: ['target', 'kind'],
594
594
  },
@@ -597,8 +597,24 @@ const TOOLS = [
597
597
  const target = parseVlTarget(argString(args, 'target', { required: true, max: 128 }));
598
598
  const kind = argString(args, 'kind', { required: true, max: 32 });
599
599
  const commandArgs = args.args === undefined ? {} : args.args;
600
- if (!target || !commandArgs || typeof commandArgs !== 'object' || Array.isArray(commandArgs)
601
- || JSON.stringify(commandArgs).length > 4096) throw new Error('target o args VL non validi');
600
+ if (!target || !commandArgs || typeof commandArgs !== 'object' || Array.isArray(commandArgs)) {
601
+ throw new Error('target o args VL non validi');
602
+ }
603
+ // `prompt` e' l'unico verbo che porta testo dell'utente fino alla sessione
604
+ // del device. Il tetto di 4 KiB vale sul testo (byte UTF-8), non sul JSON
605
+ // che lo impacchetta: un overflow va detto qui con un messaggio leggibile,
606
+ // non partire come `submitted` per farsi rifiutare dal device.
607
+ if (kind === 'prompt') {
608
+ const text = commandArgs.text;
609
+ if (typeof text !== 'string' || text.length < 1) {
610
+ throw new Error('nc_vl_command prompt: args.text deve essere una stringa non vuota');
611
+ }
612
+ if (Buffer.byteLength(text, 'utf8') > 4096) {
613
+ throw new Error('nc_vl_command prompt: args.text oltre 4 KiB (4096 byte UTF-8); accorciare il testo');
614
+ }
615
+ } else if (JSON.stringify(commandArgs).length > 4096) {
616
+ throw new Error('target o args VL non validi');
617
+ }
602
618
  const directory = await readVlDirectory(ctx);
603
619
  const node = directory.nodes.find((item) => item.id === target.id);
604
620
  if (!node) throw new Error(vlLookupFailure(directory, target));
@@ -428,7 +428,7 @@ function knownResource(resource) {
428
428
  || /^\/event-feed\/notices\/[0-9a-f-]{36}$/.test(resource)
429
429
  || resource === '/event-feed/notices/dismiss-all'
430
430
  || isPanelResource(resource)
431
- || /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
431
+ || /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines|vl-new-session|vl-new-session-state|vl-legacy-inventory)$/.test(resource);
432
432
  }
433
433
 
434
434
  // Il pannello di una cella e' l'unica risorsa federata con un gate PER-PEER a
@@ -511,13 +511,16 @@ function allowedResource(resource, method = 'GET') {
511
511
  if (resource === '/diagnostics/logs') return method === 'GET' || method === 'DELETE';
512
512
  if (resource === '/diagnostics/verbose') return method === 'PATCH';
513
513
  if (resource === '/ws') return method === 'GET';
514
- if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
514
+ // La famiglia vl del fleet: le letture sono GET, le scritture POST. Lo
515
+ // stato della richiesta di sessione nuova sta con le altre: e' una GET
516
+ // read-only, e il suo bersaglio (la cella) e' dichiarato nello scope-guard.
517
+ if (/^\/fleet\/(status|schema|definitions|credentials\/status|vl-new-session-state|vl-legacy-inventory)$/.test(resource)) return method === 'GET';
515
518
  // `model-test` e' un POST che non muta niente: interroga il CATALOGO del
516
519
  // fornitore (`GET .../models`) con la credenziale di quel nodo e ne ricava un
517
520
  // enum. Non genera testo, quindi non consuma token. Resta comunque una
518
521
  // mutazione ai fini di READONLY (sotto): un nodo dichiarato di sola lettura
519
522
  // non emette richieste autenticate su comando di un peer.
520
- if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
523
+ if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines|vl-new-session)$/.test(resource)) return method === 'POST';
521
524
  // Notify: solo POST. `/events` e `/push/*` restano NON federati — SSE non
522
525
  // puo' autenticarsi col Bearer.
523
526
  //
@@ -20,6 +20,15 @@ const CLASSES = Object.freeze({
20
20
  // authorized inventory is projected, and a peer with no visible cell sees none
21
21
  // of it.
22
22
  inventory: Object.freeze({ grants: [], needsVisibility: true }),
23
+ // The vl legacy inventory: a photo of the NODE state (the shared
24
+ // pre-contract directories), not a list of cells. Those directories have no
25
+ // reliable cell->session mapping, so the inventory cannot be projected onto
26
+ // a partial scope without inventing one: it is whole or nothing. The peer
27
+ // needs the operator grant AND a full scope. The authoritative check is the
28
+ // scope RESOLVED on the whole chain (lib/cells/scope-guard.js): this gate
29
+ // sees only the last hop, and a restricted origin must not inherit the
30
+ // promise of a wide deliverer.
31
+ 'node-inventory': Object.freeze({ grants: ['peerOperatorAccess'], needsFullScope: true }),
23
32
  // Reading shared files: list and download, filtered by the owning cell.
24
33
  'files-read': Object.freeze({ grants: ['filesReadAccess'] }),
25
34
  // Choosing which cell of this node hosts the Live session.
@@ -94,10 +103,13 @@ function classifyResource(resource, method = 'GET') {
94
103
  // Fleet: the allowlist splits the family in two — the read alternatives are
95
104
  // GET, the rest are POST. A peer that is not an operator is refused by the
96
105
  // class gate; an operator no longer gets "resource-not-classified".
97
- if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) {
106
+ if (/^\/fleet\/(status|schema|definitions|credentials\/status|vl-new-session-state)$/.test(resource)) {
98
107
  return m === 'GET' ? 'operator' : null;
99
108
  }
100
- if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) {
109
+ // L'inventario legacy vl e' lo stato del NODO, non un elenco di celle: e'
110
+ // intero o niente (vedi la classe 'node-inventory' qui sopra).
111
+ if (resource === '/fleet/vl-legacy-inventory') return m === 'GET' ? 'node-inventory' : null;
112
+ if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines|vl-new-session)$/.test(resource)) {
101
113
  return m === 'POST' ? 'operator' : null;
102
114
  }
103
115
  if (resource === '/decks') return m === 'GET' || m === 'POST' ? 'operator' : null;
@@ -140,6 +152,9 @@ function allowedByClass(className, ingress) {
140
152
  if (ingress[grant] !== true) return false;
141
153
  }
142
154
  if (cls.needsVisibility === true && ingress.cellVisibility === 'none') return false;
155
+ // Una classe che si legge per intero non si presta a uno scope parziale:
156
+ // il peer deve vedere tutto quello che la risposta contiene.
157
+ if (cls.needsFullScope === true && ingress.cellVisibility !== 'all') return false;
143
158
  return true;
144
159
  }
145
160