@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.
- package/CHANGELOG.md +16 -0
- package/docs/FLEET.md +40 -0
- package/docs/MCP.md +4 -1
- package/docs/VL_MICRO_NODES.md +10 -3
- package/frontend/dist/assets/index-Be8hcPP6.js +169 -0
- package/frontend/dist/assets/index-BwOOF72d.css +32 -0
- package/frontend/dist/index.html +2 -3
- package/frontend/dist/version.json +1 -1
- package/lib/cells/deliver.js +93 -0
- package/lib/cells/routes.js +2 -1
- package/lib/cells/scope-guard.js +46 -1
- package/lib/diagnostics/store.js +5 -0
- package/lib/fleet/builtin.js +106 -2
- package/lib/fleet/causes.js +4 -0
- package/lib/fleet/cell-exec.js +15 -2
- package/lib/fleet/definitions.js +15 -1
- package/lib/fleet/managed.js +647 -19
- package/lib/fleet/routes.js +39 -0
- package/lib/fleet/runtime.js +144 -0
- package/lib/fleet/vl-session.js +230 -0
- package/lib/mcp/tools.js +20 -4
- package/lib/proxy/federation.js +6 -3
- package/lib/proxy/resource-acl.js +17 -2
- package/lib/server.js +9 -2
- package/lib/vl-nodes/broker.js +64 -6
- package/lib/vl-nodes/outbox.js +333 -0
- package/lib/vl-nodes/routes.js +35 -2
- package/lib/vl-nodes/store.js +127 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-B9GW2LHm.css +0 -32
- package/frontend/dist/assets/index-Cgd3OQws.js +0 -169
package/lib/fleet/routes.js
CHANGED
|
@@ -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;
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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));
|
package/lib/proxy/federation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|