@mmmbuto/nexuscrew 0.9.29 → 0.9.30
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 +9 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
- package/frontend/dist/assets/{index-CywSos2e.css → index-DqCfZFGi.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +91 -10
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +1 -1
- package/lib/auth/token.js +4 -4
- package/lib/cli/commands.js +59 -12
- package/lib/cli/doctor.js +5 -5
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +27 -27
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +6 -6
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +15 -15
- package/lib/fleet/causes.js +1 -1
- package/lib/fleet/cell-exec.js +23 -23
- package/lib/fleet/cell-hold.js +6 -6
- package/lib/fleet/cell-lease-server.js +106 -106
- package/lib/fleet/cell-lease.js +21 -21
- package/lib/fleet/definitions.js +10 -10
- package/lib/fleet/identity-authority.js +1 -1
- package/lib/fleet/launch-broker.js +13 -13
- package/lib/fleet/launch.js +15 -15
- package/lib/fleet/lease-client.js +23 -23
- package/lib/fleet/lease-routes.js +3 -3
- package/lib/fleet/lease-verifier.js +22 -22
- package/lib/fleet/managed.js +35 -35
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +24 -24
- package/lib/live-host/bridge.js +36 -36
- package/lib/live-host/routes.js +2 -2
- package/lib/live-host/store.js +4 -4
- package/lib/mcp/server.js +14 -14
- package/lib/mcp/tools.js +5 -5
- package/lib/nodes/access-presets.js +258 -0
- package/lib/nodes/commands.js +198 -4
- package/lib/nodes/store.js +152 -9
- package/lib/nodes/tunnel.js +7 -7
- package/lib/notify/ask-answer-service.js +184 -0
- package/lib/notify/ask-receipts.js +197 -0
- package/lib/notify/ask-relay.js +152 -0
- package/lib/notify/asks.js +28 -3
- package/lib/notify/event-feed-acl.js +95 -0
- package/lib/notify/event-feed-asks-routes.js +144 -0
- package/lib/notify/event-feed-client.js +434 -0
- package/lib/notify/event-feed-history.js +115 -0
- package/lib/notify/event-feed-producers.js +175 -0
- package/lib/notify/event-feed-routes.js +254 -0
- package/lib/notify/event-feed.js +328 -0
- package/lib/notify/events.js +1 -1
- package/lib/notify/notifier.js +3 -3
- package/lib/notify/push-relay.js +240 -0
- package/lib/notify/push.js +8 -8
- package/lib/notify/routes.js +62 -23
- package/lib/proxy/federation.js +74 -3
- package/lib/proxy/node-proxy.js +13 -13
- package/lib/proxy/panel-auth.js +4 -4
- package/lib/proxy/panel-proxy.js +2 -2
- package/lib/proxy/resource-acl.js +145 -0
- package/lib/server.js +372 -39
- package/lib/settings/pairing-coordinator.js +1 -1
- package/lib/settings/routes.js +45 -32
- package/lib/update/manager.js +1 -1
- package/lib/update/runner.js +1 -1
- package/lib/voice/transcribe.js +1 -1
- package/lib/ws/bridge.js +3 -3
- package/package.json +1 -1
- package/frontend/dist/assets/index-B_THfiL5.js +0 -167
package/lib/fleet/definitions.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
2
|
+
// Editable fleet definitions (~/.nexuscrew/fleet.json).
|
|
3
3
|
// Modulo PURO: nessun side-effect all'import. Tutto l'I/O vive in
|
|
4
4
|
// loadDefinitions/atomicWrite; parseDefinitions/validateCommandTrust non
|
|
5
5
|
// toccano il filesystem se non per le stat di trust (sincrone, come binTrusted).
|
|
6
6
|
//
|
|
7
7
|
// Principio: fail-closed. Qualunque dato malformato -> null, MAI throw non
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// lib/fs/routes.js
|
|
8
|
+
// handled. The definitions carry arbitrary commands, so validation is
|
|
9
|
+
// STRICT (garbage -> error, no guessing). Same confinement as
|
|
10
|
+
// lib/fs/routes.js and lib/tmux/lifecycle.js.
|
|
11
11
|
const fs = require('node:fs');
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const crypto = require('node:crypto');
|
|
@@ -119,7 +119,7 @@ function isTmuxSafeName(name) {
|
|
|
119
119
|
// Deriva il nome sessione tmux CANONICO di una cella. Puro, iniettivo, reversibile.
|
|
120
120
|
// - ID senza punto: storico `cloud-<id>` (le sessioni esistenti non vengono
|
|
121
121
|
// rinominate; il namespace e' tmux-safe perche' l'id stesso non ha punto).
|
|
122
|
-
// -
|
|
122
|
+
// - Dotted IDs: dot-free v2 mapping:
|
|
123
123
|
// raw = base64url(UTF-8 id), senza padding '='
|
|
124
124
|
// n = lunghezza di raw su due cifre
|
|
125
125
|
// padded = raw right-padded con '-' fino a 43 caratteri
|
|
@@ -469,7 +469,7 @@ function parseCell(c, engineIds, engineMap = new Map(), {
|
|
|
469
469
|
// cwd (obbligatorio; la risoluzione/confinamento avviene via resolveCwd a runtime)
|
|
470
470
|
if (typeof c.cwd !== 'string' || !c.cwd || c.cwd.length > MAX_CWD_LEN) return null;
|
|
471
471
|
|
|
472
|
-
// cwdRel (
|
|
472
|
+
// cwdRel (optional, canonical): portable home-relative form.
|
|
473
473
|
// Qui si valida solo il FORMATO (stringa canonica): la coerenza cwd<->cwdRel
|
|
474
474
|
// e' un invariante di SCRITTURA (define/edit/restore), non di lettura — così
|
|
475
475
|
// le definizioni legacy (solo cwd) e quelle nuove (cwd+cwdRel) caricano senza
|
|
@@ -907,9 +907,9 @@ function validateCommandTrust(command) {
|
|
|
907
907
|
if (!st.isFile()) return { ok: false, reason: 'non e\' un file regolare (symlink o speciale)' }; // lstat: symlink -> isFile()=false
|
|
908
908
|
if (!(st.mode & 0o100)) return { ok: false, reason: 'non eseguibile dall\'owner' };
|
|
909
909
|
if (st.mode & 0o002) return { ok: false, reason: 'world-writable' };
|
|
910
|
-
// Owner check
|
|
911
|
-
//
|
|
912
|
-
//
|
|
910
|
+
// Owner check: the command must belong to the service user or root —
|
|
911
|
+
// a third-party owner could replace the executable while keeping the
|
|
912
|
+
// path "trusted".
|
|
913
913
|
if (typeof process.getuid === 'function') {
|
|
914
914
|
const uid = process.getuid();
|
|
915
915
|
if (st.uid !== uid && st.uid !== 0) return { ok: false, reason: 'owner non fidato (ne\' utente del service ne\' root)' };
|
|
@@ -948,7 +948,7 @@ function resolveCwd(cwd, home, out) {
|
|
|
948
948
|
}
|
|
949
949
|
|
|
950
950
|
// ---------------------------------------------------------------------------
|
|
951
|
-
// cwdRel —
|
|
951
|
+
// cwdRel — portable home-relative cwd representation.
|
|
952
952
|
// Rappresentazione canonica di una cwd come percorso relativo alla home del
|
|
953
953
|
// device target: '' == la home stessa; 'personal' == <home>/personal.
|
|
954
954
|
// Helper PURI (nessun fs): la normalizzazione e' string-only e fail-closed.
|
|
@@ -294,7 +294,7 @@ function createIdentityAuthority({
|
|
|
294
294
|
return { ok: false, reason: 'challenge' };
|
|
295
295
|
}
|
|
296
296
|
const currentTick = Number(now());
|
|
297
|
-
//
|
|
297
|
+
// Il TTL della challenge e' SERVER-OWNED (15s dall'orologio
|
|
298
298
|
// dell'authority), non del chiamante: oltre il cap viene capito, e una
|
|
299
299
|
// challenge con issuedAt nel futuro e' rifiutata (documentato in referto).
|
|
300
300
|
if (Number.isSafeInteger(challenge.issuedAt) && challenge.issuedAt > currentTick) {
|
|
@@ -77,11 +77,11 @@ function createLaunchBroker(cfg = {}) {
|
|
|
77
77
|
let closed = false;
|
|
78
78
|
const pending = new Map();
|
|
79
79
|
const ttlMs = Math.max(1000, Number(cfg.launchTokenTtlMs) || 15000);
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// onLease
|
|
84
|
-
//
|
|
80
|
+
// Opt-in: if cfg.onLease is registered and the payload carries `lease`, the
|
|
81
|
+
// connection accepted with the one-shot nonce stays OPEN after the payload
|
|
82
|
+
// frame and is handed to onLease(socket, lease) to become the lease channel.
|
|
83
|
+
// Without onLease or without a lease the behavior is unchanged (socket.end,
|
|
84
|
+
// one-shot): non-host cells are untouched.
|
|
85
85
|
const onLease = typeof cfg.onLease === 'function' ? cfg.onLease : null;
|
|
86
86
|
const identityMode = cfg.identityMode || 'legacy';
|
|
87
87
|
const identityAuthority = cfg.identityAuthority || null;
|
|
@@ -127,12 +127,12 @@ function createLaunchBroker(cfg = {}) {
|
|
|
127
127
|
// never claim the same payload, even while the first socket drains.
|
|
128
128
|
pending.delete(nonce); clearTimeout(entry.timer);
|
|
129
129
|
if (entry.lease && onLease) {
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
130
|
+
// The same connection stays OPEN after the payload frame.
|
|
131
|
+
// We disarm the one-shot timeout and remove the nonce data handler:
|
|
132
|
+
// the socket reaches onLease clean, which dedicates it to the lease
|
|
133
|
+
// channel: a later refresh never talks to the spent nonce (the reparse
|
|
134
|
+
// of the nonce line, with an empty pending map, used to destroy the
|
|
135
|
+
// connection) and the 3s idle timeout meant for the one-shot is gone.
|
|
136
136
|
socket.setTimeout(0);
|
|
137
137
|
socket.removeAllListeners('data');
|
|
138
138
|
// da revisione interna: la lease PRIMA del payload. onLease
|
|
@@ -267,8 +267,8 @@ function createLaunchBroker(cfg = {}) {
|
|
|
267
267
|
try { if (socketPath) fs.unlinkSync(socketPath); } catch (_) {}
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
270
|
+
// Explicit revocation of a pending nonce: if the respawn-pane fails after
|
|
271
|
+
// issue(), the runtime consumes/revokes the ticket immediately instead of
|
|
272
272
|
// attenderne il TTL. expire() e' gia' no-op su nonce mancante/scaduto.
|
|
273
273
|
return { issue, close, revoke: expire, pendingCount: () => pending.size };
|
|
274
274
|
}
|
package/lib/fleet/launch.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// del fleet. createBuiltinRuntime() in runtime.js ne fa uso; builtin.js e'
|
|
6
6
|
// ora un facade che re-esporta questi simboli per i test.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
8
|
+
// Security model — unchanged versus builtin.js:
|
|
9
9
|
// - command/args/env NON passano per una shell: execFile + argv diretto
|
|
10
10
|
// (tmux fa exec del comando, NON sh -c — verificato: ';','|','$' passano
|
|
11
11
|
// verbatim). Nessun valore passa in argv, `tmux -e`, file temporanei o
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
// possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env).
|
|
15
15
|
// - promptMode 'send-keys' inietta via `tmux load-buffer` + `paste-buffer -p`
|
|
16
16
|
// (bracketed paste), NON send-keys grezzo; se il command e' gia' uscito
|
|
17
|
-
// (
|
|
18
|
-
// - redactSecrets/sanitizeEarlyDiagnostic
|
|
17
|
+
// (dead session) it does not type.
|
|
18
|
+
// - redactSecrets/sanitizeEarlyDiagnostic: stderr/stdout of failed tmux
|
|
19
19
|
// tmux falliti NON devono mai ecoare i segreti delle definizioni.
|
|
20
20
|
const fs = require('node:fs');
|
|
21
21
|
const os = require('node:os');
|
|
@@ -26,7 +26,7 @@ const { minimalRuntimeEnv } = require('../runtime/env.js');
|
|
|
26
26
|
const { codeOf, phaseOf } = require('./causes.js');
|
|
27
27
|
const { isTmuxSafeName, tmuxSessionForCell } = require('./definitions.js');
|
|
28
28
|
|
|
29
|
-
//
|
|
29
|
+
// Minimal service-controlled env. HARD allowlist: the definitions
|
|
30
30
|
// non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
|
|
31
31
|
// qui NON passiamo MAI l'env del processo per intero. engine.env viene consegnato
|
|
32
32
|
// direttamente al processo figlio dal broker, senza entrare nello stato tmux.
|
|
@@ -52,7 +52,7 @@ function tmuxInventoryEnv() {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// httpError(status, msg, data?, cause?) — structured HTTP error. `data` carries
|
|
55
|
-
//
|
|
55
|
+
// Arbitrary API detail for the response body; `cause` is the OPTIONAL
|
|
56
56
|
// bounded failure triple {phase, code} of the up() boundary that failed. The
|
|
57
57
|
// cause is coerced through the closed enum in causes.js (anything not
|
|
58
58
|
// allowlisted degrades to UNKNOWN) and attached as e.fleetCode / e.fleetPhase,
|
|
@@ -70,12 +70,12 @@ function httpError(status, msg, data = null, cause = null) {
|
|
|
70
70
|
return e;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
//
|
|
73
|
+
// Redaction marker: stderr/stdout of failed tmux commands
|
|
74
74
|
// NON devono mai ecoare i segreti delle definizioni.
|
|
75
75
|
const REDACTED = '‹redacted›';
|
|
76
76
|
|
|
77
77
|
// redactSecrets(text, engine, cell) -> string con ogni occorrenza dei segreti
|
|
78
|
-
//
|
|
78
|
+
// of the definitions replaced by '‹redacted›'. Covered secrets:
|
|
79
79
|
// - valori di engine.env (le CHIAVI restano, i VALUES vengono redatti)
|
|
80
80
|
// - testo del prompt della cella (cell.prompt)
|
|
81
81
|
// - testo del prompt dell'engine (engine.prompt) se presente
|
|
@@ -156,7 +156,7 @@ function boundedTmuxFailure(result) {
|
|
|
156
156
|
.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 240) || 'errore tmux';
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
//
|
|
159
|
+
// tmux identity migration: the whole plan is built and validated first.
|
|
160
160
|
// piano: nessun rename parte se una sessione legacy normalizzata e' rivendicata
|
|
161
161
|
// da piu celle (es. a.b vs a_b), se legacy+target safe coesistono o se il target
|
|
162
162
|
// non e' tmux-safe. Solo dopo il preflight rinomina via `$N`; qualunque errore e'
|
|
@@ -267,7 +267,7 @@ async function migrateLegacyTmuxSessions(tmuxBin, defs, { readonly = false } = {
|
|
|
267
267
|
};
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
//
|
|
270
|
+
// send-keys prompt character policy: printable + \t \n \r allowed;
|
|
271
271
|
// rifiuta ESC(0x1b) e gli altri byte di controllo (niente marker bracketed-paste
|
|
272
272
|
// iniettabili). parseDefinitions caps solo la lunghezza: questo e' defense-in-depth.
|
|
273
273
|
function promptCharsOk(prompt) {
|
|
@@ -293,7 +293,7 @@ function composeClientInvocation(engine, cell) {
|
|
|
293
293
|
if (val) args.push(engine.model.flag, val);
|
|
294
294
|
}
|
|
295
295
|
// prompt flag-mode: promptFlag + prompt cella, solo se c'e' un prompt effettivo.
|
|
296
|
-
//
|
|
296
|
+
// SECURITY: promptMode 'flag' puts the prompt in ARGV -> it is visible
|
|
297
297
|
// nella process list (ps) / argv della sessione, a differenza di 'send-keys' che lo
|
|
298
298
|
// inietta DOPO via bracketed paste. Va quindi vincolato a prompt NON-segreti.
|
|
299
299
|
if (engine.promptMode === 'flag' && cell.prompt) {
|
|
@@ -357,7 +357,7 @@ async function waitStablePane(tmuxBin, target, { env, readyMs }) {
|
|
|
357
357
|
}
|
|
358
358
|
|
|
359
359
|
// Risolve il pane id (%N) esatto della sessione, o verifica un %N gia' noto.
|
|
360
|
-
// Contratto submitToSession
|
|
360
|
+
// Contratto submitToSession: output session_name + pane_dead + pane_id,
|
|
361
361
|
// parsing ESATTO per campi (mai regex %N libera), session verificata uguale
|
|
362
362
|
// a quella attesa prima di qualunque paste/Enter. Ritorna null se il pane non
|
|
363
363
|
// e' risolvibile, morto, di un'altra sessione o diverso dal %N atteso.
|
|
@@ -403,10 +403,10 @@ async function securePaste(tmuxBin, session, text, { env, exec, tmpdir, target,
|
|
|
403
403
|
const load = await run(tmuxBin, ['load-buffer', '-b', buffer, tmp], { env });
|
|
404
404
|
if (load.err) return { ok: false, stage: 'load', reason: 'buffer non disponibile' };
|
|
405
405
|
loaded = true;
|
|
406
|
-
//
|
|
406
|
+
// Da qui in poi il paste e' stato TENTATO: qualunque throw/rejection
|
|
407
407
|
// dell'executor e' post-paste (stage 'paste'), mai classificato come
|
|
408
408
|
// pre-paste 'load' — il composer potrebbe contenere testo parziale o
|
|
409
|
-
//
|
|
409
|
+
// Completo e un retry duplicherebbe il prompt.
|
|
410
410
|
try {
|
|
411
411
|
const paste = await run(tmuxBin, ['paste-buffer', '-p', '-t', paneId, '-b', buffer], { env });
|
|
412
412
|
if (paste.err) return { ok: false, stage: 'paste', reason: 'paste-buffer failed' };
|
|
@@ -423,9 +423,9 @@ async function securePaste(tmuxBin, session, text, { env, exec, tmpdir, target,
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
// Iniezione prompt send-keys via bracketed paste (contratto legacy per engine
|
|
426
|
-
//
|
|
426
|
+
// Custom promptMode 'send-keys': paste SENZA Enter, invariato — 0.8.47).
|
|
427
427
|
// Readiness best-effort: se la sessione non e' viva quando paste-iamo (command
|
|
428
|
-
//
|
|
428
|
+
// already exited) does not type. Returns {injected, reason}.
|
|
429
429
|
// Trasporto backportato su securePaste: buffer random, temp wx/0600, pane %N.
|
|
430
430
|
async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, target, engine, cell } = {}) {
|
|
431
431
|
if (!promptCharsOk(prompt)) {
|
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
// Lease-client lato SUPERVISORE (cell-exec.js).
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// Keeps the initial lease connection (the same one-shot broker connection that
|
|
6
|
+
// stays OPEN after the payload), sends refreshes every 20s (heartbeat),
|
|
7
|
+
// and on EOF reconnects to the stable endpoint.
|
|
8
8
|
//
|
|
9
|
-
// Fetta 2b (contratto rev1,
|
|
9
|
+
// Fetta 2b (contratto rev1, /): l'autenticazione del reconnect e' un proof
|
|
10
10
|
// HMAC firmato dal server col verifier per-installazione — la capability statica
|
|
11
11
|
// della 2a e' revocata. Il proof NON arriva nel payload: il server lo consegna
|
|
12
12
|
// sul canale (frame lease all'attach, ack a ogni refresh) e il supervisore lo
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
// held in memory, presented as-is on reconnect. Unchanged:
|
|
14
|
+
// none of this ever transits in the child env.
|
|
15
15
|
//
|
|
16
16
|
// Side effect isolati e iniettabili (seams) per testabilita', come altrove.
|
|
17
17
|
|
|
@@ -33,7 +33,7 @@ function validDaemonChallenge(value) {
|
|
|
33
33
|
const keys = Object.keys(value);
|
|
34
34
|
if (keys.length !== IDENTITY_CHALLENGE_KEYS.length
|
|
35
35
|
|| IDENTITY_CHALLENGE_KEYS.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) return false;
|
|
36
|
-
//
|
|
36
|
+
// Tipi JSON originari, niente coercizioni (Number/regex su non-stringhe).
|
|
37
37
|
const issuedAt = value.issuedAt;
|
|
38
38
|
const expiresAt = value.expiresAt;
|
|
39
39
|
return value.version === 1
|
|
@@ -90,13 +90,13 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
90
90
|
let refreshTimer = null;
|
|
91
91
|
let reconnectTimer = null;
|
|
92
92
|
// 2b: ultimo proof consegnato dal server sul canale (attach/refresh). Vive
|
|
93
|
-
// SOLO in memoria qui: e' effimero per costruzione
|
|
93
|
+
// SOLO in memoria qui: e' effimero per costruzione, non si persiste.
|
|
94
94
|
let heldProof = null;
|
|
95
|
-
//
|
|
95
|
+
// Grace bound for reconnects (eofAt + GRACE_MS). Past it, no more attempts.
|
|
96
96
|
let reconnectDeadline = null;
|
|
97
97
|
const identityPending = new Map();
|
|
98
98
|
const verifyPending = new Map();
|
|
99
|
-
//
|
|
99
|
+
// Una sola transizione di generazione in volo (il supervisore e' sequenziale).
|
|
100
100
|
let generationWaiter = null;
|
|
101
101
|
|
|
102
102
|
function send(obj) {
|
|
@@ -150,8 +150,8 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
150
150
|
if (current) { try { current.removeAllListeners('data'); current.removeAllListeners('close'); current.removeAllListeners('end'); } catch (_) {} }
|
|
151
151
|
current = null;
|
|
152
152
|
clearTimer(refreshTimer);
|
|
153
|
-
//
|
|
154
|
-
// eofAt + GRACE_MS:
|
|
153
|
+
// The grace starts from the supervisor-side EOF. Reconnects are bounded by
|
|
154
|
+
// eofAt + GRACE_MS: at least 2 attempts happen STRICTLY inside it.
|
|
155
155
|
reconnectDeadline = now() + L.GRACE_MS;
|
|
156
156
|
armReconnect(0); // primo tentativo subito, poi a cadenza RECONNECT_CADENCE_MS
|
|
157
157
|
}
|
|
@@ -160,7 +160,7 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
160
160
|
|
|
161
161
|
function armReconnect(delay) {
|
|
162
162
|
clearTimer(reconnectTimer);
|
|
163
|
-
//
|
|
163
|
+
// Past the grace no more attempts (deny or mute server must not loop).
|
|
164
164
|
if (reconnectDeadline != null && now() >= reconnectDeadline) {
|
|
165
165
|
// da revisione interna: la grace e' scaduta senza un reconnect
|
|
166
166
|
// riuscito: la lease e' PERSA per questo supervisore. Prima si desisteva
|
|
@@ -182,8 +182,8 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
182
182
|
|
|
183
183
|
function attemptReconnect() {
|
|
184
184
|
let settled = false;
|
|
185
|
-
//
|
|
186
|
-
//
|
|
185
|
+
// The generation may be a getter (cell-exec advances it with restarts)
|
|
186
|
+
// or a value; on reconnect we always present the current one.
|
|
187
187
|
const curGeneration = typeof info.generation === 'function' ? info.generation() : (info.generation || 0);
|
|
188
188
|
const sock = netImpl.createConnection(info.stablePath, () => {
|
|
189
189
|
// 2b: presentiamo il proof detenuto. Senza proof (nessun ack ricevuto, o
|
|
@@ -192,9 +192,9 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
192
192
|
const msg = { type: 'reconnect', generation: curGeneration, ...(heldProof ? { proof: heldProof } : {}) };
|
|
193
193
|
try { sock.write(`${JSON.stringify(msg)}\n`); } catch (_) { try { sock.destroy(); } catch (e) {} }
|
|
194
194
|
});
|
|
195
|
-
//
|
|
196
|
-
// (socket
|
|
197
|
-
//
|
|
195
|
+
// Per-attempt timeout. If the server accepts the connection but does not
|
|
196
|
+
// answer (hanging socket) we force-close and retry: without this the client
|
|
197
|
+
// stayed with 1 attempt and 0 following timers. Cadence as the upper bound.
|
|
198
198
|
const attemptTimer = setTimer(() => {
|
|
199
199
|
if (settled) return;
|
|
200
200
|
settled = true;
|
|
@@ -214,8 +214,8 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
214
214
|
if (msg.type === 'lease') {
|
|
215
215
|
settled = true;
|
|
216
216
|
clearTimer(attemptTimer);
|
|
217
|
-
//
|
|
218
|
-
//
|
|
217
|
+
// Successful reconnect, new lease. The proof delivered with the new
|
|
218
|
+
// lease becomes the held one; the refresh loop resumes.
|
|
219
219
|
clearTimer(reconnectTimer);
|
|
220
220
|
if (msg.proof && typeof msg.proof === 'object') heldProof = msg.proof;
|
|
221
221
|
current = sock;
|
|
@@ -224,8 +224,8 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
224
224
|
} else if (msg.type === 'deny') {
|
|
225
225
|
settled = true;
|
|
226
226
|
clearTimer(attemptTimer);
|
|
227
|
-
//
|
|
228
|
-
//
|
|
227
|
+
// Refused (bad proof, past grace, or identity mismatch). Retry at
|
|
228
|
+
// cadence: at least 2 attempts happen strictly inside the grace window.
|
|
229
229
|
try { sock.destroy(); } catch (_) {}
|
|
230
230
|
armReconnect(L.RECONNECT_CADENCE_MS);
|
|
231
231
|
}
|
|
@@ -373,7 +373,7 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
373
373
|
});
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
-
//
|
|
376
|
+
// Annuncia la transizione di generazione sulla connessione viva PRIMA
|
|
377
377
|
// che il supervisore apra il canale identita' della generazione nuova.
|
|
378
378
|
function announceGeneration(generation) {
|
|
379
379
|
if (!Number.isSafeInteger(generation) || generation < 0) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// lib/fleet/lease-routes.js — route /api/lease, superficie child della fetta 2b
|
|
3
|
-
// (
|
|
3
|
+
// (contract: three distinct methods + the MCP↔leaseManager link).
|
|
4
4
|
//
|
|
5
5
|
// Il bridge MCP di una cella (`nexuscrew mcp`) parla con l'HTTP API locale dietro
|
|
6
6
|
// Bearer (canale nativo del bridge): queste route sono quel collegamento.
|
|
@@ -25,7 +25,7 @@ function leaseRoutes({
|
|
|
25
25
|
fleetP, instanceId, now: () => Date.now(),
|
|
26
26
|
// refresh/recovery hanno gia il proof child come authorizer nel body. Il
|
|
27
27
|
// binding MCP, quando presentato, viene verificato fail-closed; l'assenza
|
|
28
|
-
//
|
|
28
|
+
// Preserva il canale nativo lease (D) che esisteva prima di .
|
|
29
29
|
sharedRequired: false,
|
|
30
30
|
});
|
|
31
31
|
|
|
@@ -51,7 +51,7 @@ function leaseRoutes({
|
|
|
51
51
|
try {
|
|
52
52
|
const fleet = await fleetP;
|
|
53
53
|
if (!fleet || fleet.available !== true) return res.status(404).json({ error: 'fleet non disponibile' });
|
|
54
|
-
//
|
|
54
|
+
// The link lives vive sul provider — senza leaseManager (lease
|
|
55
55
|
// disattivato) e' 501, non 500: la capability manca, non e' un guasto.
|
|
56
56
|
if (!fleet.lease || typeof fleet.lease.childRegister !== 'function') {
|
|
57
57
|
return res.status(501).json({ error: 'lease non disponibile su questo nodo' });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Verifier per-installazione e proof HMAC del lease Live (fetta 2b, contratto
|
|
4
|
-
// rev1:
|
|
4
|
+
// Contract rev1: the premise and the items below.
|
|
5
5
|
//
|
|
6
6
|
// Modello (PREMESSA): la 2a usava un segreto condiviso simmetrico — il
|
|
7
7
|
// supervisore presentava la capability cosi' com'e' e il server la confrontava.
|
|
@@ -9,25 +9,25 @@
|
|
|
9
9
|
// segreto, il supervisore/child presenta un proof firmato con claims ed expiry.
|
|
10
10
|
// Non e' la 2a con un giro in piu': e' un modello di autorizzazione diverso.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
12
|
+
// La chiave verifier vive in un file DEDICATO separato 0o600, distinto
|
|
13
13
|
// dai token di liveness per-cella e dal segreto del bridge audio. «Un solo
|
|
14
14
|
// segreto» significa una sola chiave verifier, non un solo file segreto nel
|
|
15
15
|
// sistema.
|
|
16
|
-
//
|
|
16
|
+
// Lo stato durevole contiene l'identificativo e l'impronta, MAI il
|
|
17
17
|
// segreto — forma gia' usata da vl-node (PendingEnrollment). Il keyId e'
|
|
18
18
|
// DERIVATO dall'impronta (sha256 della chiave): non esiste uno stato da
|
|
19
19
|
// tenere sincronizzato con la chiave, e il meta su disco e' diagnostica.
|
|
20
|
-
//
|
|
20
|
+
// La codifica canonica e' length-prefixed con proofKind come primo tag.
|
|
21
21
|
// La canonizzazione JSON e' fragile: due serializzatori onesti producono
|
|
22
22
|
// byte diversi. Il length-prefixing dichiara il confine di ogni campo, non
|
|
23
23
|
// lo deduce da un separatore.
|
|
24
|
-
//
|
|
24
|
+
// Expiry = issuedAt + 60s, calcolabile all'emissione. «Ultimo-live+60»
|
|
25
25
|
// e' la proprieta' che si vuole, non la formula che si scrive: non e'
|
|
26
26
|
// calcolabile nel momento in cui il proof va firmato.
|
|
27
|
-
//
|
|
27
|
+
// Fail-closed sulla verifica. La verifica prova TUTTE le chiavi vive
|
|
28
28
|
// (oggi una sola: la rotazione e' sospesa per scelta dichiarata, contratto
|
|
29
|
-
|
|
30
|
-
//
|
|
29
|
+
//; la forma e' gia' quella a due chiavi di).
|
|
30
|
+
// La verifica rende osservabile QUALE chiave ha firmato (keyId), cosi'
|
|
31
31
|
// il momento in cui una chiave subentra resta leggibile dopo il fatto.
|
|
32
32
|
//
|
|
33
33
|
// Disciplina del file segreto: stessa di lib/audio/bridge-auth.js e
|
|
@@ -38,7 +38,7 @@ const fs = require('node:fs');
|
|
|
38
38
|
const path = require('node:path');
|
|
39
39
|
const crypto = require('node:crypto');
|
|
40
40
|
|
|
41
|
-
//
|
|
41
|
+
// Vita di un proof emesso. Il refresh gira ogni 20s (REFRESH_MS): un proof
|
|
42
42
|
// da 60s lascia sempre al detentore >=2 presentazioni legittime di margine.
|
|
43
43
|
const PROOF_TTL_MS = 60_000;
|
|
44
44
|
|
|
@@ -52,16 +52,16 @@ const KEY_ID_LEN = 16;
|
|
|
52
52
|
const JTI_RE = /^[a-f0-9]{16,64}$/;
|
|
53
53
|
const SIG_RE = /^[a-f0-9]{64}$/;
|
|
54
54
|
|
|
55
|
-
// Campi firmati per proofKind, IN ORDINE, proofKind primo
|
|
55
|
+
// Campi firmati per proofKind, IN ORDINE, proofKind primo. Per kind la
|
|
56
56
|
// lista e' fissa e tutti i campi sono obbligatori e non vuoti: campo mancante e
|
|
57
57
|
// campo vuoto non sono distinguibili nella canonica, quindi non esistono campi
|
|
58
|
-
//
|
|
58
|
+
// Opzionali.: nel kind 'lease' l'identita' del lease e' 'leaseId' — non
|
|
59
59
|
// identityKey, che legherebbe il lease all'identita' della cella.
|
|
60
60
|
const KIND_FIELDS = Object.freeze({
|
|
61
61
|
// tupla del supervisore: autorizza il reconnect all'endpoint stabile.
|
|
62
62
|
lease: ['kind', 'cellId', 'launchEpoch', 'leaseId', 'generation', 'jti', 'issuedAt'],
|
|
63
|
-
//
|
|
64
|
-
//
|
|
63
|
+
// Tupla del child: autorizza register/refresh/recovery. incarnationId e'
|
|
64
|
+
// Per-registration, mai globale.
|
|
65
65
|
child: ['kind', 'cellId', 'incarnationId', 'jti', 'issuedAt'],
|
|
66
66
|
});
|
|
67
67
|
|
|
@@ -69,7 +69,7 @@ function fingerprintOf(secret) {
|
|
|
69
69
|
return crypto.createHash('sha256').update(String(secret), 'utf8').digest('hex');
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
//
|
|
72
|
+
// -: codifica canonica ----------------------------------------------------
|
|
73
73
|
|
|
74
74
|
// Ogni campo: u32 big-endian della lunghezza in byte UTF-8, poi i byte. Il
|
|
75
75
|
// proofKind e' il primo tag: chiave di dominio della firma (un proof lease non
|
|
@@ -91,7 +91,7 @@ function claimsForKind(kind) {
|
|
|
91
91
|
return fields;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
//
|
|
94
|
+
// - : chiave per-installazione ------------------------------------------
|
|
95
95
|
|
|
96
96
|
// Lettura no-follow (anti-symlink), stessa disciplina del bridge secret.
|
|
97
97
|
function readKeySafe(fsImpl, keyPath) {
|
|
@@ -129,8 +129,8 @@ function loadOrCreateVerifier({ dir, fsImpl = fs, log = () => {}, now = Date.now
|
|
|
129
129
|
try { fsImpl.chmodSync(keyPath, 0o600); } catch (_) {}
|
|
130
130
|
// keyId derivato dall'impronta: la chiave porta con se' la propria identita'.
|
|
131
131
|
const keyId = fingerprintOf(secret).slice(0, KEY_ID_LEN);
|
|
132
|
-
//
|
|
133
|
-
//
|
|
132
|
+
// Il meta persiste id + impronta (MAI il segreto) per diagnostica e
|
|
133
|
+
// Osservabilita'. Best-effort: se e' assente o divergente si riscrive;
|
|
134
134
|
// un fallimento di scrittura non invalida la chiave.
|
|
135
135
|
try {
|
|
136
136
|
const meta = { version: 1, keyId, fingerprint: fingerprintOf(secret), createdAt: now() };
|
|
@@ -172,11 +172,11 @@ function signProof(verifier, claims, { now = Date.now, jti = null } = {}) {
|
|
|
172
172
|
|
|
173
173
|
function isNonEmptyString(v) { return typeof v === 'string' && v.length > 0; }
|
|
174
174
|
|
|
175
|
-
// Fail-closed
|
|
176
|
-
//
|
|
175
|
+
// Fail-closed: ogni difetto e' un motivo, non un'eccezione. `verifiers` e'
|
|
176
|
+
// La lista delle chiavi vive (oggi una; -ready per due). `expect` porta i
|
|
177
177
|
// claims che il chiamante gia' conosce: la firma prova il resto.
|
|
178
178
|
// `graceMs` (default 0) allarga la finestra di accettazione DOPO la scadenza:
|
|
179
|
-
//
|
|
179
|
+
// E' la finestra di recovery del child — un proof la cui firma e' valida e
|
|
180
180
|
// la cui scadenza e' recente NON e' una credenziale rubata riportata in vita, e'
|
|
181
181
|
// un detentore che ha saltato i refresh. Ogni altro check resta invariato.
|
|
182
182
|
function verifyProof(verifiers, candidate, { now = Date.now, expect = {}, graceMs = 0 } = {}) {
|
|
@@ -193,7 +193,7 @@ function verifyProof(verifiers, candidate, { now = Date.now, expect = {}, graceM
|
|
|
193
193
|
const issuedAt = Number(candidate.issuedAt);
|
|
194
194
|
if (!Number.isSafeInteger(issuedAt)) return { ok: false, reason: 'malformed' };
|
|
195
195
|
const expiresAt = Number(candidate.expiresAt);
|
|
196
|
-
//
|
|
196
|
+
// ExpiresAt non e' un campo qualunque: deve essere ESATTAMENTE
|
|
197
197
|
// issuedAt + PROOF_TTL_MS. Manometterlo non estende la vita del proof.
|
|
198
198
|
if (!Number.isSafeInteger(expiresAt) || expiresAt !== issuedAt + PROOF_TTL_MS) {
|
|
199
199
|
return { ok: false, reason: 'malformed' };
|
|
@@ -210,7 +210,7 @@ function verifyProof(verifiers, candidate, { now = Date.now, expect = {}, graceM
|
|
|
210
210
|
if (expect.launchEpoch !== undefined && candidate.launchEpoch !== expect.launchEpoch) return { ok: false, reason: 'launchEpoch' };
|
|
211
211
|
if (expect.leaseId !== undefined && candidate.leaseId !== expect.leaseId) return { ok: false, reason: 'leaseId' };
|
|
212
212
|
if (expect.incarnationId !== undefined && candidate.incarnationId !== expect.incarnationId) return { ok: false, reason: 'incarnationId' };
|
|
213
|
-
// Firma contro OGNI chiave viva: la prima che passa vince (
|
|
213
|
+
// Firma contro OGNI chiave viva: la prima che passa vince (/).
|
|
214
214
|
const canonical = canonicalProofFields(fields.map((f) => candidate[f]));
|
|
215
215
|
for (const v of verifiers) {
|
|
216
216
|
if (!v || typeof v.secret !== 'string') continue;
|