@mmmbuto/nexuscrew 0.8.57 → 0.9.0
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 +164 -2
- package/README.md +1 -0
- package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
- package/frontend/dist/assets/index-zjL6kZ7J.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/adapters.js +50 -6
- package/lib/cli/commands.js +40 -2
- package/lib/cli/doctor.js +95 -12
- package/lib/cli/init.js +25 -3
- package/lib/cli/path.js +43 -10
- package/lib/cli/pidfile.js +23 -2
- package/lib/config.js +15 -0
- package/lib/fleet/builtin.js +161 -19
- package/lib/fleet/catalogs/opencode-go.json +328 -0
- package/lib/fleet/cell-exec.js +87 -9
- package/lib/fleet/cell-lease-server.js +719 -0
- package/lib/fleet/cell-lease.js +112 -0
- package/lib/fleet/definitions.js +101 -7
- package/lib/fleet/launch-broker.js +115 -3
- package/lib/fleet/lease-client.js +191 -0
- package/lib/fleet/lease-routes.js +92 -0
- package/lib/fleet/lease-verifier.js +230 -0
- package/lib/fleet/managed.js +444 -55
- package/lib/fleet/prompt-delivery.js +50 -2
- package/lib/fleet/provider.js +1 -1
- package/lib/fleet/runtime.js +53 -6
- package/lib/live-host/bridge.js +369 -0
- package/lib/live-host/routes.js +184 -0
- package/lib/live-host/store.js +96 -0
- package/lib/mcp/tools.js +51 -0
- package/lib/nodes/commands.js +9 -2
- package/lib/nodes/store.js +14 -0
- package/lib/nodes/tunnel.js +4 -1
- package/lib/proxy/federation.js +106 -9
- package/lib/proxy/node-proxy.js +33 -0
- package/lib/proxy/panel-auth.js +307 -0
- package/lib/proxy/panel-proxy.js +305 -0
- package/lib/server.js +127 -4
- package/package.json +1 -1
- package/skills/alibaba-token-media/SKILL.md +19 -0
- package/skills/crew/SKILL.md +15 -0
- package/skills/fill-forms/SKILL.md +23 -0
- package/skills/mail-assistant/SKILL.md +15 -0
- package/skills/memory/SKILL.md +15 -0
- package/skills/nexuscrew-agent/SKILL.md +18 -0
- package/skills/vl-msa/SKILL.md +15 -0
- package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
- package/frontend/dist/assets/index-_c-1_3iR.js +0 -93
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure state transitions for the supervisor lease of a Live host cell.
|
|
4
|
+
//
|
|
5
|
+
// Contratto rev12 R3 + rev13 S3 + rev22. Il canale lease e' la connessione UDS
|
|
6
|
+
// accettata col nonce one-shot del launch-broker che RESTA APERTA dopo il frame
|
|
7
|
+
// payload (R3.1.1); dopo un EOF il supervisore si riconnette a un endpoint
|
|
8
|
+
// stabile (R3.3.2). Qui vivono SOLO le transizioni di stato del lease: i side
|
|
9
|
+
// effect (socket, timer, EOF handling) stanno nel LeaseManager e nei caller.
|
|
10
|
+
// Modello speculare a lib/nodes/reverse-rotation.js (clone + arg validation),
|
|
11
|
+
// cosi' i casi crash/replay sono espliciti e testabili senza socket.
|
|
12
|
+
//
|
|
13
|
+
// Invarianti normativi ribaditi nel codice:
|
|
14
|
+
// - EOF arma UNA sola transizione monotona Live -> Grace (R3.1.3); la deadline
|
|
15
|
+
// e' non estendibile: un secondo armGrace su un lease in Grace e' no-op.
|
|
16
|
+
// - Nessuna operazione riporta in Live un lease gia' in Grace: refresh e armGrace
|
|
17
|
+
// non cambiano lo stato di un lease in Grace. Solo reattach crea un lease NUOVO.
|
|
18
|
+
// - reattach (R3.3.4) produce un lease con leaseId NUOVO e STESSA identita'
|
|
19
|
+
// cellId+launchEpoch (la generation puo' avanzare). Non e' la resurrezione del
|
|
20
|
+
// lease precedente: il vecchio lease resta nella sua grace, la monotonia
|
|
21
|
+
// per-lease resta intatta e la vecchia deadline diventa irrilevante.
|
|
22
|
+
// - Oltre la grace il reconnect e' rifiutato (R3.3.5): reattach di un lease
|
|
23
|
+
// scaduto restituisce null.
|
|
24
|
+
|
|
25
|
+
const crypto = require('node:crypto');
|
|
26
|
+
|
|
27
|
+
const GRACE_MS = 60_000; // R3.2: grace 60s dall'EOF, non estendibile
|
|
28
|
+
const REFRESH_MS = 20_000; // R3.2: cadenza refresh 20s (heartbeat lato supervisore)
|
|
29
|
+
const RECONNECT_CADENCE_MS = 20_000; // rev13 S3.3: >=2 tentativi strettamente dentro la grace
|
|
30
|
+
|
|
31
|
+
function isInt(v) { return Number.isSafeInteger(v); }
|
|
32
|
+
function validId(v, max = 128) { return typeof v === 'string' && v.length > 0 && v.length <= max; }
|
|
33
|
+
function validLeaseId(v) { return typeof v === 'string' && /^[a-f0-9]{16,64}$/.test(v); }
|
|
34
|
+
|
|
35
|
+
function newLeaseId() { return crypto.randomBytes(16).toString('hex'); }
|
|
36
|
+
|
|
37
|
+
// Apertura del lease: prima connessione (post-payload). Nasce Live, senza grace.
|
|
38
|
+
function openLease({ cellId, launchEpoch, generation, leaseId, now }) {
|
|
39
|
+
if (!validId(cellId) || !validId(launchEpoch) || !isInt(generation) || generation < 0
|
|
40
|
+
|| !validLeaseId(leaseId) || !isInt(now)) return null;
|
|
41
|
+
return {
|
|
42
|
+
cellId, launchEpoch, generation, leaseId,
|
|
43
|
+
state: 'live',
|
|
44
|
+
openedAt: now,
|
|
45
|
+
lastRefreshedAt: now,
|
|
46
|
+
eofArmedAt: null,
|
|
47
|
+
graceDeadline: null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// EOF arma UNA sola transizione monotona Live -> Grace. Su un lease gia' in Grace
|
|
52
|
+
// e' no-op (ritorna lo stesso lease): la deadline NON si estende (R3.1.3).
|
|
53
|
+
function armGrace(lease, { now } = {}) {
|
|
54
|
+
if (!lease || !isInt(now)) return null;
|
|
55
|
+
if (lease.state === 'grace') return lease;
|
|
56
|
+
const next = { ...lease };
|
|
57
|
+
next.state = 'grace';
|
|
58
|
+
next.eofArmedAt = now;
|
|
59
|
+
next.graceDeadline = now + GRACE_MS;
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Refresh: heartbeat lato supervisore (cad 20s). Aggiorna lastRefreshedAt. NON
|
|
64
|
+
// cambia state e NON riporta in Live un lease in Grace (R3.1.3). Hook per la
|
|
65
|
+
// rotazione del proof HMAC (fetta 2b); in 2a e' solo liveness applicativa + ack.
|
|
66
|
+
function refresh(lease, { now } = {}) {
|
|
67
|
+
if (!lease || !isInt(now)) return null;
|
|
68
|
+
return { ...lease, lastRefreshedAt: now };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Reconnect riuscito (R3.3.4): lease NUOVO (leaseId nuovo), STESSA identita'
|
|
72
|
+
// cellId+launchEpoch (la generation puo' avanzare), state Live. R3.3.5: oltre la
|
|
73
|
+
// grace il reconnect e' rifiutato -> null. Il vecchio lease resta nella sua grace.
|
|
74
|
+
function reattach(lease, { leaseId, generation, now } = {}) {
|
|
75
|
+
if (!lease || !validLeaseId(leaseId) || !isInt(generation) || generation < 0 || !isInt(now)) return null;
|
|
76
|
+
if (lease.state === 'grace' && now >= lease.graceDeadline) return null;
|
|
77
|
+
return {
|
|
78
|
+
cellId: lease.cellId,
|
|
79
|
+
launchEpoch: lease.launchEpoch,
|
|
80
|
+
generation,
|
|
81
|
+
leaseId,
|
|
82
|
+
state: 'live',
|
|
83
|
+
openedAt: now,
|
|
84
|
+
lastRefreshedAt: now,
|
|
85
|
+
eofArmedAt: null,
|
|
86
|
+
graceDeadline: null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isLive(lease) {
|
|
91
|
+
return !!lease && lease.state === 'live';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isGrace(lease, { now = Date.now() } = {}) {
|
|
95
|
+
return !!lease && lease.state === 'grace' && isInt(now) && lease.graceDeadline != null && now < lease.graceDeadline;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isExpired(lease, { now = Date.now() } = {}) {
|
|
99
|
+
return !!lease && lease.state === 'grace' && isInt(now) && lease.graceDeadline != null && now >= lease.graceDeadline;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Chiave composita di identita' per la map lato server (modello rotatableReverse).
|
|
103
|
+
function identityKey(cellId, launchEpoch, generation) {
|
|
104
|
+
if (!validId(cellId) || !validId(launchEpoch) || !isInt(generation) || generation < 0) return null;
|
|
105
|
+
return `${cellId}:${launchEpoch}:${generation}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
GRACE_MS, REFRESH_MS, RECONNECT_CADENCE_MS,
|
|
110
|
+
newLeaseId, openLease, armGrace, refresh, reattach,
|
|
111
|
+
isLive, isGrace, isExpired, identityKey,
|
|
112
|
+
};
|
package/lib/fleet/definitions.js
CHANGED
|
@@ -29,6 +29,7 @@ const MAX_MODEL_FLAG_LEN = 32;
|
|
|
29
29
|
const MAX_MODEL_VAL_LEN = 128;
|
|
30
30
|
const MAX_PROMPTFLAG_LEN = 32;
|
|
31
31
|
const MAX_PROMPT_LEN = 8192; // 8 KB
|
|
32
|
+
const MAX_PANELURL_LEN = 512; // stesso cap di validBaseUrl (managed.js)
|
|
32
33
|
// Nomi di server MCP dichiarabili per cella. Il tetto e' generoso rispetto agli
|
|
33
34
|
// otto configurati qui, e la forma e' quella di un identificatore perche' il
|
|
34
35
|
// nome finisce in un prefisso di tool passato al client.
|
|
@@ -75,6 +76,28 @@ function isSingleArgv(s) {
|
|
|
75
76
|
return true;
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// panelUrl: endpoint HTTP(S) di un pannello associato a una cella o a un
|
|
80
|
+
// engine (es. il pannello web di un desktop container). Fail-closed e
|
|
81
|
+
// STRICT, come il resto del modulo: solo http/https, e solo host loopback
|
|
82
|
+
// (127.0.0.1, localhost, ::1) — un URL libero in fleet.json e' una superficie
|
|
83
|
+
// che non serve a questo caso d'uso (il pannello vive sulla stessa macchina).
|
|
84
|
+
// Validatore UNICO condiviso da parseEngine e parseCell: un valore "valido"
|
|
85
|
+
// e' deciso in un solo posto, mai ricalcolato due volte con criteri diversi.
|
|
86
|
+
// Esportato: il proxy del pannello disattiva la verifica TLS SOLO verso queste
|
|
87
|
+
// destinazioni, e deve leggere la stessa lista che le autorizza — non una copia.
|
|
88
|
+
const PANELURL_LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
89
|
+
function validPanelUrl(value) {
|
|
90
|
+
if (typeof value !== 'string' || !value || value.length > MAX_PANELURL_LEN) return false;
|
|
91
|
+
if (/[\x00-\x1f\x7f]|\s/.test(value)) return false; // no control char, no whitespace
|
|
92
|
+
let parsed;
|
|
93
|
+
try { parsed = new URL(value); } catch (_) { return false; }
|
|
94
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
|
95
|
+
// new URL() racchiude un host IPv6 fra parentesi quadre (es. "[::1]"): il
|
|
96
|
+
// confronto e' contro quella stessa forma, non contro "::1" nudo.
|
|
97
|
+
if (!PANELURL_LOOPBACK_HOSTS.has(parsed.hostname)) return false;
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
78
101
|
function validTmuxName(name) {
|
|
79
102
|
return typeof name === 'string'
|
|
80
103
|
&& name.length <= MAX_TMUXSESSION_LEN
|
|
@@ -202,6 +225,15 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
|
|
|
202
225
|
// dichiarato qui, e leggerli dopo lo farebbe rifiutare per un dato che in
|
|
203
226
|
// realta' c'e'. `models` e' opzionale: una configurazione che non lo ha
|
|
204
227
|
// resta valida esattamente com'era.
|
|
228
|
+
//
|
|
229
|
+
// D2 (audit pacchetto): extraModels porta il DESCRITTORE completo (id,
|
|
230
|
+
// engine, contextWindow, maxTokens, reasoning), non solo l'id. Prima era
|
|
231
|
+
// Map<engine, Set<id>>: bastava per il gate "questo id e' ammesso?"
|
|
232
|
+
// (declaredFor), ma contextWindow/maxTokens/reasoning sparivano — e
|
|
233
|
+
// customCatalogFor/writePiProviderExtension, che ne hanno bisogno per
|
|
234
|
+
// emettere il catalogo, non li vedevano mai. Ora Map<engine, Map<id,
|
|
235
|
+
// model>>: chi ha bisogno solo degli id li ricava dalle CHIAVI della Map
|
|
236
|
+
// interna (stesso .has(id) di un Set — nessun consumatore esistente cambia).
|
|
205
237
|
const models = [];
|
|
206
238
|
const extraModels = new Map();
|
|
207
239
|
if (d.models !== undefined) {
|
|
@@ -213,8 +245,8 @@ function parseDefinitions(raw, { allowLegacyTmuxNames = true } = {}) {
|
|
|
213
245
|
const key = `${model.engine}::${model.id}`;
|
|
214
246
|
if (seen.has(key)) return null; // stesso id due volte per lo stesso profilo
|
|
215
247
|
seen.add(key);
|
|
216
|
-
if (!extraModels.has(model.engine)) extraModels.set(model.engine, new
|
|
217
|
-
extraModels.get(model.engine).
|
|
248
|
+
if (!extraModels.has(model.engine)) extraModels.set(model.engine, new Map());
|
|
249
|
+
extraModels.get(model.engine).set(model.id, model);
|
|
218
250
|
models.push(model);
|
|
219
251
|
}
|
|
220
252
|
}
|
|
@@ -296,7 +328,20 @@ function parseEngine(e, { extraModels = null } = {}) {
|
|
|
296
328
|
for (const key of ['command', 'args', 'env', 'promptMode', 'promptFlag', 'model']) {
|
|
297
329
|
if (e[key] !== undefined) return null;
|
|
298
330
|
}
|
|
299
|
-
|
|
331
|
+
// panelUrl (opzionale): STESSO validatore e STESSA conservazione del ramo
|
|
332
|
+
// custom, qui sotto. Un engine managed puo' legittimamente avere un
|
|
333
|
+
// pannello, e il fallback engine->cella di cellStatus vale per entrambi.
|
|
334
|
+
// Prima di questo blocco il campo veniva scartato in silenzio: un valore
|
|
335
|
+
// valido spariva e uno invalido veniva accettato (fail-open) — rilievo 1
|
|
336
|
+
// dell'audit D8.
|
|
337
|
+
let managedPanelUrl;
|
|
338
|
+
if (e.panelUrl !== undefined) {
|
|
339
|
+
if (!validPanelUrl(e.panelUrl)) return null;
|
|
340
|
+
managedPanelUrl = e.panelUrl;
|
|
341
|
+
}
|
|
342
|
+
const managedOut = { id: e.id, label, rc, managed };
|
|
343
|
+
if (managedPanelUrl !== undefined) managedOut.panelUrl = managedPanelUrl;
|
|
344
|
+
return managedOut;
|
|
300
345
|
}
|
|
301
346
|
|
|
302
347
|
// command (obbligatorio, stringa non vuota; il trust si verifica a parte)
|
|
@@ -351,9 +396,19 @@ function parseEngine(e, { extraModels = null } = {}) {
|
|
|
351
396
|
}
|
|
352
397
|
// promptMode!=='flag' con promptFlag presente -> ignorato (campo non rilevante)
|
|
353
398
|
|
|
399
|
+
// panelUrl (opzionale): valore precompilato dall'engine (es. il pannello di
|
|
400
|
+
// un desktop container) — una cella puo' sovrascriverlo col proprio, vedi
|
|
401
|
+
// parseCell. Stessa validazione condivisa (validPanelUrl).
|
|
402
|
+
let panelUrl;
|
|
403
|
+
if (e.panelUrl !== undefined) {
|
|
404
|
+
if (!validPanelUrl(e.panelUrl)) return null;
|
|
405
|
+
panelUrl = e.panelUrl;
|
|
406
|
+
}
|
|
407
|
+
|
|
354
408
|
const out = { id: e.id, label, rc, command: e.command, args, env, promptMode };
|
|
355
409
|
if (model) out.model = model;
|
|
356
410
|
if (promptFlag !== undefined) out.promptFlag = promptFlag;
|
|
411
|
+
if (panelUrl !== undefined) out.panelUrl = panelUrl;
|
|
357
412
|
return out;
|
|
358
413
|
}
|
|
359
414
|
|
|
@@ -472,6 +527,18 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
|
|
|
472
527
|
prompt = c.prompt;
|
|
473
528
|
}
|
|
474
529
|
|
|
530
|
+
// panelUrl (opzionale, per-cella): endpoint HTTP(S) di un pannello associato
|
|
531
|
+
// alla cella (es. desktop container), sovrascrive quello precompilato
|
|
532
|
+
// dall'engine (vedi parseEngine). Opt-in: assente -> comportamento
|
|
533
|
+
// identico a oggi. Un valore PRESENTE ma malformato fa fallire l'INTERA
|
|
534
|
+
// definizione (return null): non collassa in "assente", sono due esiti
|
|
535
|
+
// opposti che si assomigliano.
|
|
536
|
+
let panelUrl;
|
|
537
|
+
if (c.panelUrl !== undefined) {
|
|
538
|
+
if (!validPanelUrl(c.panelUrl)) return null;
|
|
539
|
+
panelUrl = c.panelUrl;
|
|
540
|
+
}
|
|
541
|
+
|
|
475
542
|
// mcp (opzionale): QUALI strumenti MCP ha questa cella, per NOME.
|
|
476
543
|
//
|
|
477
544
|
// Solo nomi, mai definizioni. Le definizioni vivono in un posto solo — la
|
|
@@ -557,6 +624,7 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
|
|
|
557
624
|
if (permissionPolicies) out.permissionPolicies = permissionPolicies;
|
|
558
625
|
if (commands && Object.keys(commands).length) out.commands = commands;
|
|
559
626
|
if (prompt !== undefined) out.prompt = prompt;
|
|
627
|
+
if (panelUrl !== undefined) out.panelUrl = panelUrl;
|
|
560
628
|
if (mcp !== undefined) out.mcp = mcp;
|
|
561
629
|
if (label !== undefined) out.label = label;
|
|
562
630
|
if (legacyTmuxSession) {
|
|
@@ -596,7 +664,7 @@ function validateCommandTrust(command) {
|
|
|
596
664
|
// lib/tmux/lifecycle.js: realpath su entrambi (symlink dentro home che punta
|
|
597
665
|
// fuori -> rifiutato) e deve essere una directory.
|
|
598
666
|
// ---------------------------------------------------------------------------
|
|
599
|
-
function resolveCwd(cwd, home) {
|
|
667
|
+
function resolveCwd(cwd, home, out) {
|
|
600
668
|
try {
|
|
601
669
|
const h = home || process.env.HOME;
|
|
602
670
|
if (typeof cwd !== 'string' || !cwd || typeof h !== 'string' || !h) return null;
|
|
@@ -606,7 +674,18 @@ function resolveCwd(cwd, home) {
|
|
|
606
674
|
if (!fs.statSync(real).isDirectory()) return null;
|
|
607
675
|
if (real !== realHome && !real.startsWith(realHome + path.sep)) return null;
|
|
608
676
|
return real;
|
|
609
|
-
} catch (
|
|
677
|
+
} catch (e) {
|
|
678
|
+
// ENOENT = la cwd (o la home) non esiste (legittimo "non c'e'" -> null ->
|
|
679
|
+
// resolveCellCwd riporta 'invalid-cwd'/'home-unavailable'); altro code
|
|
680
|
+
// (EACCES/ELOOP/ENOTDIR...) = "esiste ma non ho potuto verificarla", e va
|
|
681
|
+
// distinto dal "non esiste sotto la home" che il messaggio altrimenti direbbe.
|
|
682
|
+
// Il verdetto (null -> cwd rifiutata, fail-closed di sicurezza) e' invariato;
|
|
683
|
+
// out.unverifiable porta il "perche'" a chi costruisce il messaggio
|
|
684
|
+
// (unportableCwdError). Il discriminante e' CHI ha fallito, non che ci sia
|
|
685
|
+
// stata un'eccezione.
|
|
686
|
+
if (out && e.code !== 'ENOENT') out.unverifiable = e.code || e.constructor.name;
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
610
689
|
}
|
|
611
690
|
|
|
612
691
|
// ---------------------------------------------------------------------------
|
|
@@ -659,10 +738,22 @@ function deriveCwdRel(absCwd, home) {
|
|
|
659
738
|
// loadDefinitions(p) -> parsed | null
|
|
660
739
|
// Legge il file rifiutando i symlink; parse strict. Mai throw.
|
|
661
740
|
// ---------------------------------------------------------------------------
|
|
662
|
-
function loadDefinitions(p) {
|
|
741
|
+
function loadDefinitions(p, out) {
|
|
663
742
|
try {
|
|
664
743
|
let st;
|
|
665
|
-
try {
|
|
744
|
+
try {
|
|
745
|
+
st = fs.lstatSync(p);
|
|
746
|
+
} catch (e) {
|
|
747
|
+
// ENOENT = il file non c'e' (legittimo "missing" -> null); qualsiasi altro
|
|
748
|
+
// code (EACCES/ELOOP/ENOTDIR...) = "esiste ma non ho potuto guardarlo", e va
|
|
749
|
+
// distinto dal "missing" che il caller altrimenti riporterebbe come "fleet
|
|
750
|
+
// unavailable" senza dire perche'. Il verdetto (null -> fail-closed) e'
|
|
751
|
+
// invariato; out.lstatBlocked porta il "perche'" a chi costruisce il
|
|
752
|
+
// messaggio. Il discriminante e' CHI ha fallito, non che ci sia stata
|
|
753
|
+
// un'eccezione.
|
|
754
|
+
if (out && e.code !== 'ENOENT') out.lstatBlocked = e.code || e.constructor.name;
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
666
757
|
if (st.isSymbolicLink()) return null; // no symlink
|
|
667
758
|
if (!st.isFile()) return null;
|
|
668
759
|
const raw = fs.readFileSync(p, 'utf8');
|
|
@@ -726,11 +817,13 @@ const CAPS = {
|
|
|
726
817
|
MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_LABEL_LEN,
|
|
727
818
|
MAX_COMMAND_LEN, MAX_CWD_LEN, MAX_MODEL_FLAG_LEN, MAX_MODEL_VAL_LEN,
|
|
728
819
|
MAX_PROMPTFLAG_LEN, MAX_PROMPT_LEN, MAX_CELL_COMMAND_LEN, MAX_TMUXSESSION_LEN,
|
|
820
|
+
MAX_PANELURL_LEN,
|
|
729
821
|
};
|
|
730
822
|
|
|
731
823
|
module.exports = {
|
|
732
824
|
parseDefinitions,
|
|
733
825
|
validateCommandTrust,
|
|
826
|
+
validPanelUrl, PANELURL_LOOPBACK_HOSTS,
|
|
734
827
|
resolveCwd,
|
|
735
828
|
normalizeCwdRel,
|
|
736
829
|
deriveCwdRel,
|
|
@@ -744,4 +837,5 @@ module.exports = {
|
|
|
744
837
|
// Costanti esposte anche piatte (comode per la UI/schema e i test)
|
|
745
838
|
SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
|
|
746
839
|
MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_PROMPT_LEN, MAX_CELL_COMMAND_LEN,
|
|
840
|
+
MAX_PANELURL_LEN,
|
|
747
841
|
};
|
|
@@ -48,6 +48,19 @@ function ensureRuntimeDir(dir) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Nome leggibile del tipo di un fs.Stats, per un messaggio d'errore che
|
|
52
|
+
// dice COSA ha trovato al posto di un socket (file regolare, directory,
|
|
53
|
+
// symlink, ...) invece di limitarsi a "non e' un socket".
|
|
54
|
+
function describeStatType(stat) {
|
|
55
|
+
if (stat.isSymbolicLink()) return 'un symlink';
|
|
56
|
+
if (stat.isDirectory()) return 'una directory';
|
|
57
|
+
if (stat.isFile()) return 'un file regolare';
|
|
58
|
+
if (stat.isFIFO()) return 'una FIFO';
|
|
59
|
+
if (stat.isCharacterDevice()) return 'un character device';
|
|
60
|
+
if (stat.isBlockDevice()) return 'un block device';
|
|
61
|
+
return 'un oggetto di tipo sconosciuto';
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
function encodePayload(payload) {
|
|
52
65
|
const body = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
53
66
|
if (!body.length || body.length > MAX_PAYLOAD) throw new Error('launch payload too large');
|
|
@@ -63,6 +76,12 @@ function createLaunchBroker(cfg = {}) {
|
|
|
63
76
|
let closed = false;
|
|
64
77
|
const pending = new Map();
|
|
65
78
|
const ttlMs = Math.max(1000, Number(cfg.launchTokenTtlMs) || 15000);
|
|
79
|
+
// R3.1.1 (opt-in): se cfg.onLease e' registrato e il payload porta `lease`, la
|
|
80
|
+
// connessione accettata col nonce one-shot resta APERTA dopo il frame payload e
|
|
81
|
+
// viene consegnata a onLease(socket, lease) per divenire il canale lease. Senza
|
|
82
|
+
// onLease o senza lease il comportamento e' invariato (socket.end, one-shot):
|
|
83
|
+
// le celle non-ospite non sono toccate.
|
|
84
|
+
const onLease = typeof cfg.onLease === 'function' ? cfg.onLease : null;
|
|
66
85
|
|
|
67
86
|
function expire(nonce) {
|
|
68
87
|
const entry = pending.get(nonce);
|
|
@@ -84,6 +103,11 @@ function createLaunchBroker(cfg = {}) {
|
|
|
84
103
|
let raw = '';
|
|
85
104
|
socket.setEncoding('utf8');
|
|
86
105
|
socket.setTimeout(3000, () => socket.destroy());
|
|
106
|
+
// Correzione (audit 2a, stesso buco trovato in cell-lease-server.js):
|
|
107
|
+
// il broker scrive su questo socket (il payload, o dopo F-B onLease
|
|
108
|
+
// prima). Un peer che muore mentre quella write e' in volo emette
|
|
109
|
+
// 'error' senza listener -> throw fatale, INTERO processo giu'.
|
|
110
|
+
socket.once('error', () => { try { socket.destroy(); } catch (_) {} });
|
|
87
111
|
socket.on('data', (chunk) => {
|
|
88
112
|
raw += chunk;
|
|
89
113
|
if (raw.length > REQUEST_LIMIT) return socket.destroy();
|
|
@@ -97,14 +121,102 @@ function createLaunchBroker(cfg = {}) {
|
|
|
97
121
|
// Single-use before any bytes leave this process. A second client can
|
|
98
122
|
// never claim the same payload, even while the first socket drains.
|
|
99
123
|
pending.delete(nonce); clearTimeout(entry.timer);
|
|
100
|
-
|
|
124
|
+
if (entry.lease && onLease) {
|
|
125
|
+
// R3.1.1: la stessa connessione resta APERTA dopo il frame payload.
|
|
126
|
+
// Disarmiamo il timeout one-shot e rimuoviamo il data handler del nonce:
|
|
127
|
+
// la socket arriva pulita a onLease, che la dedica al canale lease. Cosi'
|
|
128
|
+
// un refresh successivo non riparla sul nonce esaurito (il reparse della
|
|
129
|
+
// riga nonce, con pending vuota, distruggeva la connessione) e non scatta
|
|
130
|
+
// piu' il timeout idle da 3s pensato per il one-shot.
|
|
131
|
+
socket.setTimeout(0);
|
|
132
|
+
socket.removeAllListeners('data');
|
|
133
|
+
// F-B (audit 2a @ 142e272): la lease PRIMA del payload. onLease
|
|
134
|
+
// associa la connessione al lease manager (attachInitial); solo se
|
|
135
|
+
// committa il payload viene scritto. Se onLease ritorna false, o ha
|
|
136
|
+
// gia' distrutto la socket, il payload NON parte: il client riceve
|
|
137
|
+
// la chiusura prima del frame, receivePayload rigetta ('launch
|
|
138
|
+
// broker closed early') e il child NON NASCE con una lease che il
|
|
139
|
+
// server non ha mai committato. Prima il payload era scritto prima
|
|
140
|
+
// di onLease: la cella partiva anche con attach fallito (20/20 sonda).
|
|
141
|
+
const denied = onLease(socket, entry.lease) === false || socket.destroyed;
|
|
142
|
+
if (denied) return;
|
|
143
|
+
try { socket.write(entry.encoded); } catch (_) { socket.destroy(); return; }
|
|
144
|
+
} else {
|
|
145
|
+
try { socket.end(entry.encoded); } catch (_) { socket.destroy(); }
|
|
146
|
+
}
|
|
101
147
|
});
|
|
102
148
|
});
|
|
103
149
|
server.once('error', (error) => {
|
|
104
150
|
if (!server?.listening) { server = null; starting = null; reject(error); }
|
|
105
151
|
});
|
|
106
152
|
server.listen(socketPath, () => {
|
|
107
|
-
|
|
153
|
+
// Stesso rigore gia' applicato alla directory in ensureRuntimeDir
|
|
154
|
+
// (chmod, poi VERIFICA il risultato reale, fallisci chiuso se non
|
|
155
|
+
// conforme): la dir 0700 basta da sola a impedire l'attraversamento
|
|
156
|
+
// del path a qualunque altro utente, indipendentemente dal mode del
|
|
157
|
+
// socket — verificato (attraversare una directory richiede il bit x
|
|
158
|
+
// su OGNI componente del path, il mode del file terminale non conta
|
|
159
|
+
// per chi non puo' nemmeno raggiungerlo). Ma questo file non tratta
|
|
160
|
+
// quella garanzia come l'unica: se domani ensureRuntimeDir avesse un
|
|
161
|
+
// buco, o il filesystem sottostante non applicasse i permessi POSIX
|
|
162
|
+
// come atteso, il mode del socket e' l'unica barriera indipendente
|
|
163
|
+
// rimasta.
|
|
164
|
+
//
|
|
165
|
+
// Riconsegna: la prima versione di questa guardia verificava SOLO il
|
|
166
|
+
// mode (chmod, poi statSync().mode) — il PERMESSO, non l'IDENTITA'.
|
|
167
|
+
// Misurato: sostituendo il path con un FILE REGOLARE mode 0600
|
|
168
|
+
// subito dopo il chmod (stesso trust boundary: serve comunque
|
|
169
|
+
// accesso alla dir 0700), issue() si risolveva lo stesso — la
|
|
170
|
+
// guardia non si accorgeva che l'oggetto non era piu' un socket.
|
|
171
|
+
// Ora: lstatSync (non statSync, come la dir sopra — un socket non
|
|
172
|
+
// va MAI seguito attraverso un symlink) sia prima sia dopo il
|
|
173
|
+
// chmod, isSocket() verificato in entrambi i momenti, e
|
|
174
|
+
// dev/ino confrontati fra i due lstatSync per rilevare una
|
|
175
|
+
// sostituzione avvenuta DURANTE l'intervallo del chmod stesso.
|
|
176
|
+
//
|
|
177
|
+
// LIMITE DICHIARATO, non nascosto: questo chiude la finestra fra il
|
|
178
|
+
// PRIMO lstatSync (il piu' presto possibile dopo il bind) e il
|
|
179
|
+
// SECONDO (dopo il chmod) — non la finestra fra il bind stesso e il
|
|
180
|
+
// primo lstatSync (Node non espone un modo per fstat l'fd del
|
|
181
|
+
// server UDS appena bindato prima di riosservarlo per path), ne'
|
|
182
|
+
// quella fra questa verifica e il momento in cui un client si
|
|
183
|
+
// connette davvero. Chiuderle richiederebbe verificare l'identita'
|
|
184
|
+
// a OGNI connessione accettata (fstat sul socket accettato via fd,
|
|
185
|
+
// non per path) — fuori scope qui: chi ha accesso alla dir 0700 ha
|
|
186
|
+
// gia' lo stesso UID di questo processo, e a quel punto puo' fare
|
|
187
|
+
// molto altro (killare il processo, ptrace) che nessun chmod
|
|
188
|
+
// fermerebbe — non e' un'escalation di privilegi nuova, e' il
|
|
189
|
+
// limite intrinseco del modello "stesso utente = stesso trust
|
|
190
|
+
// boundary" gia' assunto da questo file per la directory.
|
|
191
|
+
let mode = null;
|
|
192
|
+
try {
|
|
193
|
+
const pre = fs.lstatSync(socketPath);
|
|
194
|
+
if (!pre.isSocket()) {
|
|
195
|
+
throw new Error(`atteso un socket, trovato ${describeStatType(pre)}`);
|
|
196
|
+
}
|
|
197
|
+
fs.chmodSync(socketPath, 0o600);
|
|
198
|
+
const post = fs.lstatSync(socketPath);
|
|
199
|
+
if (!post.isSocket()) {
|
|
200
|
+
throw new Error(`non e' piu' un socket dopo il chmod: ${describeStatType(post)}`);
|
|
201
|
+
}
|
|
202
|
+
if (post.dev !== pre.dev || post.ino !== pre.ino) {
|
|
203
|
+
throw new Error("il file al path del socket e' stato sostituito durante la verifica (dev/ino cambiati fra i due lstatSync)");
|
|
204
|
+
}
|
|
205
|
+
mode = post.mode & 0o777;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
try { server.close(); } catch (_) {}
|
|
208
|
+
try { fs.unlinkSync(socketPath); } catch (_) {}
|
|
209
|
+
server = null; starting = null;
|
|
210
|
+
reject(new Error(`unsafe launch broker socket: ${error.message}`));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (mode !== 0o600) {
|
|
214
|
+
try { server.close(); } catch (_) {}
|
|
215
|
+
try { fs.unlinkSync(socketPath); } catch (_) {}
|
|
216
|
+
server = null; starting = null;
|
|
217
|
+
reject(new Error(`unsafe launch broker socket: mode ${mode.toString(8)} dopo chmod, atteso 0600`));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
108
220
|
server.unref(); starting = null; resolve(socketPath);
|
|
109
221
|
});
|
|
110
222
|
});
|
|
@@ -114,7 +226,7 @@ function createLaunchBroker(cfg = {}) {
|
|
|
114
226
|
async function issue(payload) {
|
|
115
227
|
const target = await start();
|
|
116
228
|
const nonce = crypto.randomBytes(32).toString('hex');
|
|
117
|
-
const entry = { encoded: encodePayload(payload), expires: Date.now() + ttlMs, timer: null };
|
|
229
|
+
const entry = { encoded: encodePayload(payload), expires: Date.now() + ttlMs, timer: null, lease: payload && payload.lease ? payload.lease : null };
|
|
118
230
|
entry.timer = setTimeout(() => expire(nonce), ttlMs);
|
|
119
231
|
entry.timer.unref?.();
|
|
120
232
|
pending.set(nonce, entry);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Lease-client lato SUPERVISORE (cell-exec.js).
|
|
4
|
+
//
|
|
5
|
+
// Mantiene la connessione lease iniziale (la stessa del broker one-shot che resta
|
|
6
|
+
// APERTA dopo il payload, R3.1.1), invia refresh a cadenza 20s (R3.2 heartbeat),
|
|
7
|
+
// e su EOF reconnecta all'endpoint stabile (R3.3.2).
|
|
8
|
+
//
|
|
9
|
+
// Fetta 2b (contratto rev1, A2/B1): l'autenticazione del reconnect e' un proof
|
|
10
|
+
// HMAC firmato dal server col verifier per-installazione — la capability statica
|
|
11
|
+
// della 2a e' revocata. Il proof NON arriva nel payload: il server lo consegna
|
|
12
|
+
// sul canale (frame lease all'attach, ack a ogni refresh) e il supervisore lo
|
|
13
|
+
// detiene in memoria, presentandolo tale e quale al reconnect. R3.1.2 invariato:
|
|
14
|
+
// niente di tutto questo transita mai nell'env del child.
|
|
15
|
+
//
|
|
16
|
+
// Side effect isolati e iniettabili (seams) per testabilita', come altrove.
|
|
17
|
+
|
|
18
|
+
const net = require('node:net');
|
|
19
|
+
const L = require('./cell-lease.js');
|
|
20
|
+
|
|
21
|
+
function startLeaseClient(initialSocket, info, seams = {}) {
|
|
22
|
+
if (!initialSocket || !info || !info.stablePath || !info.launchEpoch) return null;
|
|
23
|
+
const setTimer = seams.setTimeout || ((fn, ms) => setTimeout(fn, ms));
|
|
24
|
+
const clearTimer = seams.clearTimeout || ((t) => { if (t) clearTimeout(t); });
|
|
25
|
+
const netImpl = seams.net || net;
|
|
26
|
+
const now = seams.now || Date.now;
|
|
27
|
+
|
|
28
|
+
let stopped = false;
|
|
29
|
+
let current = initialSocket;
|
|
30
|
+
let refreshTimer = null;
|
|
31
|
+
let reconnectTimer = null;
|
|
32
|
+
// 2b: ultimo proof consegnato dal server sul canale (attach/refresh). Vive
|
|
33
|
+
// SOLO in memoria qui: e' effimero per costruzione (B8), non si persiste.
|
|
34
|
+
let heldProof = null;
|
|
35
|
+
// R3.2: bound di grace per i reconnect (eofAt + GRACE_MS). Oltre non si ritenta.
|
|
36
|
+
let reconnectDeadline = null;
|
|
37
|
+
|
|
38
|
+
function send(obj) {
|
|
39
|
+
if (!current || current.destroyed || !current.writable) return false;
|
|
40
|
+
try { current.write(`${JSON.stringify(obj)}\n`); return true; } catch (_) { return false; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function armRefresh() {
|
|
44
|
+
clearTimer(refreshTimer);
|
|
45
|
+
refreshTimer = setTimer(() => {
|
|
46
|
+
if (stopped) return;
|
|
47
|
+
send({ type: 'refresh' });
|
|
48
|
+
armRefresh();
|
|
49
|
+
}, L.REFRESH_MS);
|
|
50
|
+
if (refreshTimer && typeof refreshTimer.unref === 'function') refreshTimer.unref();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onEOF() {
|
|
54
|
+
if (stopped) return;
|
|
55
|
+
if (current) { try { current.removeAllListeners('data'); current.removeAllListeners('close'); current.removeAllListeners('end'); } catch (_) {} }
|
|
56
|
+
current = null;
|
|
57
|
+
clearTimer(refreshTimer);
|
|
58
|
+
// R3.2: la grace parte dall'EOF lato supervisore. I reconnect sono bounded da
|
|
59
|
+
// eofAt + GRACE_MS: rev13 S3.3 garantisce >=2 tentativi STRETTAMENTE dentro.
|
|
60
|
+
reconnectDeadline = now() + L.GRACE_MS;
|
|
61
|
+
armReconnect(0); // primo tentativo subito, poi a cadenza RECONNECT_CADENCE_MS
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let lostNotified = false;
|
|
65
|
+
|
|
66
|
+
function armReconnect(delay) {
|
|
67
|
+
clearTimer(reconnectTimer);
|
|
68
|
+
// R3.2: oltre la grace non si ritenta (deny o server muto non vanno in loop).
|
|
69
|
+
if (reconnectDeadline != null && now() >= reconnectDeadline) {
|
|
70
|
+
// F-B (audit 2a @ 142e272): la grace e' scaduta senza un reconnect
|
|
71
|
+
// riuscito: la lease e' PERSA per questo supervisore. Prima si desisteva
|
|
72
|
+
// in silenzio e il child restava un orfano senza lease per tutta la sua
|
|
73
|
+
// vita, senza che nessuno lo sapesse. Ora il supervisore viene avvisato
|
|
74
|
+
// (info.onLost, una volta sola): cell-exec ferma il child e muore con lui.
|
|
75
|
+
if (!lostNotified) {
|
|
76
|
+
lostNotified = true;
|
|
77
|
+
if (typeof info.onLost === 'function') { try { info.onLost(); } catch (_) {} }
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
reconnectTimer = setTimer(() => {
|
|
82
|
+
if (stopped) return;
|
|
83
|
+
attemptReconnect();
|
|
84
|
+
}, delay);
|
|
85
|
+
if (reconnectTimer && typeof reconnectTimer.unref === 'function') reconnectTimer.unref();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function attemptReconnect() {
|
|
89
|
+
let settled = false;
|
|
90
|
+
// R3.3.4: generation puo' essere un getter (cell-exec la fa avanzare coi restart)
|
|
91
|
+
// o un valore; al reconnect presentiamo sempre quella corrente.
|
|
92
|
+
const curGeneration = typeof info.generation === 'function' ? info.generation() : (info.generation || 0);
|
|
93
|
+
const sock = netImpl.createConnection(info.stablePath, () => {
|
|
94
|
+
// 2b: presentiamo il proof detenuto. Senza proof (nessun ack ricevuto, o
|
|
95
|
+
// persistenza fallita lato server) il messaggio parte comunque senza: il
|
|
96
|
+
// server nega — e il tentativo resta bounded dalla grace, come in 2a.
|
|
97
|
+
const msg = { type: 'reconnect', generation: curGeneration, ...(heldProof ? { proof: heldProof } : {}) };
|
|
98
|
+
try { sock.write(`${JSON.stringify(msg)}\n`); } catch (_) { try { sock.destroy(); } catch (e) {} }
|
|
99
|
+
});
|
|
100
|
+
// R3.2: per-attempt timeout. Se il server accetta la connessione ma non risponde
|
|
101
|
+
// (socket appesa) forziamo la chiusura e ritentiamo: senza questo il client
|
|
102
|
+
// restava con 1 solo tentativo e 0 timer successivi. Cadence come upper bound.
|
|
103
|
+
const attemptTimer = setTimer(() => {
|
|
104
|
+
if (settled) return;
|
|
105
|
+
settled = true;
|
|
106
|
+
try { sock.destroy(); } catch (_) {}
|
|
107
|
+
armReconnect(0);
|
|
108
|
+
}, L.RECONNECT_CADENCE_MS);
|
|
109
|
+
if (attemptTimer && typeof attemptTimer.unref === 'function') attemptTimer.unref();
|
|
110
|
+
let buf = '';
|
|
111
|
+
sock.setEncoding('utf8');
|
|
112
|
+
sock.on('data', (chunk) => {
|
|
113
|
+
buf += chunk;
|
|
114
|
+
const nl = buf.indexOf('\n');
|
|
115
|
+
if (nl === -1) return;
|
|
116
|
+
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
117
|
+
let msg; try { msg = JSON.parse(line); } catch (_) { return; }
|
|
118
|
+
if (settled) return;
|
|
119
|
+
if (msg.type === 'lease') {
|
|
120
|
+
settled = true;
|
|
121
|
+
clearTimer(attemptTimer);
|
|
122
|
+
// R3.3.4: reconnect riuscito, lease nuovo. Il proof consegnato col lease
|
|
123
|
+
// nuovo diventa quello detenuto; riprende il refresh loop.
|
|
124
|
+
clearTimer(reconnectTimer);
|
|
125
|
+
if (msg.proof && typeof msg.proof === 'object') heldProof = msg.proof;
|
|
126
|
+
current = sock;
|
|
127
|
+
bindLive(sock);
|
|
128
|
+
armRefresh();
|
|
129
|
+
} else if (msg.type === 'deny') {
|
|
130
|
+
settled = true;
|
|
131
|
+
clearTimer(attemptTimer);
|
|
132
|
+
// R3.3.5: rifiutato (proof rifiutato, oltre grace o identity). Riprova a
|
|
133
|
+
// cadenza: rev13 S3.3 garantisce >=2 tentativi strettamente dentro la grace.
|
|
134
|
+
try { sock.destroy(); } catch (_) {}
|
|
135
|
+
armReconnect(L.RECONNECT_CADENCE_MS);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
sock.once('close', () => {
|
|
139
|
+
if (settled) return;
|
|
140
|
+
settled = true;
|
|
141
|
+
clearTimer(attemptTimer);
|
|
142
|
+
armReconnect(L.RECONNECT_CADENCE_MS);
|
|
143
|
+
});
|
|
144
|
+
sock.once('error', () => { try { sock.destroy(); } catch (_) {} });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 2b: il canale live legge cio' che il server consegna — ack col proof a ogni
|
|
148
|
+
// refresh, frame lease (col proof) all'attach. Senza lettura il proof non
|
|
149
|
+
// arriverebbe mai al detentore.
|
|
150
|
+
function bindLive(sock) {
|
|
151
|
+
sock.removeAllListeners('close');
|
|
152
|
+
sock.removeAllListeners('end');
|
|
153
|
+
let buf = '';
|
|
154
|
+
sock.on('data', (chunk) => {
|
|
155
|
+
buf += chunk;
|
|
156
|
+
let nl;
|
|
157
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
158
|
+
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
159
|
+
let msg; try { msg = JSON.parse(line); } catch (_) { continue; }
|
|
160
|
+
if ((msg.type === 'ack' || msg.type === 'lease') && msg.proof && typeof msg.proof === 'object') {
|
|
161
|
+
heldProof = msg.proof;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
sock.once('close', onEOF);
|
|
166
|
+
sock.once('end', onEOF);
|
|
167
|
+
// Il peer puo' morire senza leggere (RST: ECONNRESET su write/read): un
|
|
168
|
+
// 'error' senza listener e' fatale per il processo supervisore. Assorbilo e
|
|
169
|
+
// lascia che la 'close' che segue armi la grace, stessa forma del server.
|
|
170
|
+
sock.once('error', () => { try { sock.destroy(); } catch (_) {} });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Avvio: la connessione iniziale (broker) e' gia' aperta. Arma refresh + EOF.
|
|
174
|
+
bindLive(current);
|
|
175
|
+
armRefresh();
|
|
176
|
+
send({ type: 'refresh' }); // primo refresh immediato
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
stop() {
|
|
180
|
+
stopped = true;
|
|
181
|
+
clearTimer(refreshTimer);
|
|
182
|
+
clearTimer(reconnectTimer);
|
|
183
|
+
try { current && current.destroy(); } catch (_) {}
|
|
184
|
+
current = null;
|
|
185
|
+
},
|
|
186
|
+
_isConnected: () => !!current && !current.destroyed,
|
|
187
|
+
_heldProof: () => heldProof,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { startLeaseClient };
|