@mmmbuto/nexuscrew 0.8.58 → 0.9.1

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +197 -2
  2. package/README.md +23 -4
  3. package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
  4. package/frontend/dist/assets/index-Bu_-2-Uu.js +93 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/dist/version.json +1 -1
  7. package/lib/audio/adapters.js +50 -6
  8. package/lib/cli/commands.js +54 -2
  9. package/lib/cli/doctor.js +95 -12
  10. package/lib/cli/init.js +25 -3
  11. package/lib/cli/path.js +43 -10
  12. package/lib/cli/pidfile.js +23 -2
  13. package/lib/config.js +24 -0
  14. package/lib/fleet/builtin.js +161 -19
  15. package/lib/fleet/catalogs/opencode-go.json +50 -4
  16. package/lib/fleet/cell-exec.js +87 -9
  17. package/lib/fleet/cell-lease-server.js +719 -0
  18. package/lib/fleet/cell-lease.js +112 -0
  19. package/lib/fleet/definitions.js +101 -7
  20. package/lib/fleet/launch-broker.js +115 -3
  21. package/lib/fleet/lease-client.js +191 -0
  22. package/lib/fleet/lease-routes.js +92 -0
  23. package/lib/fleet/lease-verifier.js +230 -0
  24. package/lib/fleet/managed.js +366 -48
  25. package/lib/fleet/prompt-delivery.js +50 -2
  26. package/lib/fleet/provider.js +1 -1
  27. package/lib/fleet/runtime.js +53 -6
  28. package/lib/live-host/bridge.js +369 -0
  29. package/lib/live-host/routes.js +190 -0
  30. package/lib/live-host/store.js +96 -0
  31. package/lib/mcp/tools.js +51 -0
  32. package/lib/nodes/commands.js +21 -3
  33. package/lib/nodes/store.js +57 -0
  34. package/lib/nodes/tunnel.js +23 -1
  35. package/lib/proxy/federation.js +130 -9
  36. package/lib/proxy/node-proxy.js +33 -0
  37. package/lib/proxy/panel-auth.js +337 -0
  38. package/lib/proxy/panel-proxy.js +336 -0
  39. package/lib/server.js +252 -7
  40. package/lib/settings/pairing-coordinator.js +32 -0
  41. package/lib/settings/public-peering-routes.js +13 -1
  42. package/package.json +1 -1
  43. package/skills/aidesktop/SKILL.md +201 -0
  44. package/skills/aidesktop/docker/Dockerfile +21 -0
  45. package/skills/aidesktop/docker/custom-cont-init.d/10-cdp-relay.sh +20 -0
  46. package/skills/aidesktop/docker/docker-compose.example.yml +75 -0
  47. package/skills/crew/SKILL.md +15 -0
  48. package/skills/live/SKILL.md +90 -0
  49. package/skills/mail-assistant/SKILL.md +15 -0
  50. package/skills/memory/SKILL.md +15 -0
  51. package/skills/nexuscrew/SKILL.md +113 -0
  52. package/skills/nexuscrew-agent/SKILL.md +18 -0
  53. package/skills/vl-msa/SKILL.md +15 -0
  54. package/frontend/dist/assets/index-BEGNtmx2.js +0 -93
  55. package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
  56. package/skills/alibaba-token-media/SKILL.md +0 -133
  57. package/skills/alibaba-token-media/agents/openai.yaml +0 -4
  58. package/skills/alibaba-token-media/references/api-contract.md +0 -97
  59. package/skills/alibaba-token-media/scripts/alibaba_token_media.py +0 -550
  60. package/skills/fill-forms/SKILL.md +0 -154
  61. package/skills/fill-forms/agents/openai.yaml +0 -4
  62. package/skills/fill-forms/references/overlay-technique.md +0 -99
  63. package/skills/fill-forms/requirements.txt +0 -4
  64. package/skills/fill-forms/scripts/dump_docx.py +0 -70
  65. package/skills/fill-forms/scripts/fill_docx.py +0 -207
  66. package/skills/fill-forms/scripts/fill_pdf.py +0 -424
  67. package/skills/fill-forms/scripts/inspect_pdf.py +0 -188
  68. package/skills/fill-forms/scripts/prepare_signature.py +0 -171
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+ // lib/live-host/routes.js — route /api/live-host (control plane della designazione).
3
+ //
4
+ // Montate dietro requireToken (come /api/cells, /api/fleet): solo il token del nodo
5
+ // passa. NON sono proxabili via /node/<name>: il blocklist local-only del proxy nega
6
+ // /api/live-host li' (v. lib/proxy/node-proxy.js). Sono pero' raggiungibili via
7
+ // /api/route (0.9.1), la via allowlistata della federazione, dietro un permesso
8
+ // per-peer negato di default (liveHostAccess, v. lib/proxy/federation.js) — sullo
9
+ // stesso modello del pannello: concesso dal nodo che POSSIEDE la cella, mai da chi
10
+ // chiede. Senza quel permesso il peer riceve un rifiuto che nomina la causa
11
+ // (`live-host-not-granted`), non un silenzio.
12
+ //
13
+ // Invarianti (contratto rev6 §2, §9):
14
+ // - hostCell unico per nodo, CAS su revision (due designazioni concorrenti non
15
+ // lasciano due celle rosse).
16
+ // - API-first: la route e' l'unica autorita'; il frontend riflette la risposta e
17
+ // su errore resta sullo stato precedente (nessun ottimismo qui — la logica UI
18
+ // sta nel modulo frontend host-designation.js).
19
+ // - Cella inattiva PRESERVA la designazione: lo store non cancella mai hostCell per
20
+ // inattivita; `eligible` e' derivato dal roster al momento del GET.
21
+ // - readonly => 403 su designate/clear.
22
+
23
+ const express = require('express');
24
+ const { CELL_ID_RE } = require('./store.js');
25
+
26
+ // Ricava l'elenco celle LOCALI dal fleet (definizioni, attive e non). Una cella
27
+ // federata non compare qui: e' il check che chiude "designa solo una cella di questo
28
+ // nodo". Ritorna null se il fleet non e' interrogabile (la route decide come gestirlo).
29
+ async function localCells(fleetP) {
30
+ const fleet = await fleetP;
31
+ if (!fleet || fleet.available !== true) return null;
32
+ const statusFn = fleet && (typeof fleet.status === 'function' ? fleet.status : fleet.cellStatus);
33
+ if (typeof statusFn !== 'function') return null;
34
+ const st = await statusFn.call(fleet);
35
+ return Array.isArray(st && st.cells) ? st.cells : [];
36
+ }
37
+
38
+ function isActive(cell) {
39
+ return !!(cell && cell.active === true && cell.tmux !== false);
40
+ }
41
+
42
+ // --- Seam lease↔designazione (2026-08-15, decisione di Dev: grace = false) -----
43
+ //
44
+ // L'idoneita' dell'host designato non e' piu' solo «sessione tmux viva»: con
45
+ // remain-on-exit la sessione sopravvive alla morte del supervisore, e la
46
+ // garanzia «l'host designato e' vivo» la puo' dare SOLO il lease (fetta 2b: il
47
+ // leaseId e' FIRMATO nel proof, quindi lo stato del lease identifica senza
48
+ // ambiguita' chi lo detiene). Regole:
49
+ // - eligible = attiva AND lease 'live'. In grace NON c'e' garanzia: eligible
50
+ // false, ma host.lease='grace' dice «recupero in corso» — chi legge
51
+ // distingue «non idonea perche' morta» da «non idonea perche' in recupero».
52
+ // - I cinque stati (live|grace|expired|none|unavailable) restano DISTINCTI
53
+ // fino a chi legge: collassarli e' rifare il difetto a un piano piu' su.
54
+ // - FALLBACK FAIL-OPEN DICHIARATO: senza fleet.lease (installazione senza
55
+ // lease) eligible torna tmux-only e host.lease='unavailable'. Non e' «va
56
+ // bene lo stesso»: e' «la garanzia non e' disponibile qui», detto esplicito
57
+ // perche' un eligible=true silenzioso verrebbe letto come confermata.
58
+ // - hostCell resta PRESERVATO in ogni caso (invariante dello store): oscilla
59
+ // l'idoneita', non la scelta dell'operatore.
60
+ function hostLeaseState(fleet, hostCell) {
61
+ if (hostCell == null) return null;
62
+ const lease = fleet && fleet.lease;
63
+ if (!lease || typeof lease.status !== 'function') return 'unavailable';
64
+ const st = lease.status(hostCell);
65
+ return (st && typeof st.state === 'string') ? st.state : 'none';
66
+ }
67
+
68
+ function eligibleOf(fleet, cell, hostCell) {
69
+ const leaseState = hostLeaseState(fleet, hostCell);
70
+ if (leaseState === null) return false; // senza soggetto non c'e' idoneita'
71
+ if (leaseState === 'unavailable') return isActive(cell); // fail-open dichiarato
72
+ return isActive(cell) && leaseState === 'live';
73
+ }
74
+
75
+ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Date.now(), bridge = null }) {
76
+ const r = express.Router();
77
+
78
+ // GET /api/live-host — { hostCell, revision, eligible, host: {lease}, at }.
79
+ // hostCell e revision vengono dallo store (preservato); eligible e' la verita'
80
+ // COMPOSTA roster+lease (si veda hostLeaseState sopra); host.lease espone lo
81
+ // stato del lease della cella designata, distinto, perche' chi legge distingue.
82
+ r.get('/', async (_req, res) => {
83
+ try {
84
+ const snap = store.snapshot();
85
+ let eligible = false;
86
+ let lease = null;
87
+ if (snap.hostCell != null) {
88
+ const fleet = await fleetP.catch(() => null);
89
+ const cells = await localCells(fleetP).catch(() => []);
90
+ const cell = Array.isArray(cells) ? cells.find((c) => c && c.cell === snap.hostCell) : null;
91
+ lease = hostLeaseState(fleet, snap.hostCell);
92
+ eligible = eligibleOf(fleet, cell, snap.hostCell);
93
+ }
94
+ res.json({ hostCell: snap.hostCell, revision: snap.revision, eligible, host: { lease }, at: now() });
95
+ } catch (e) {
96
+ res.status(500).json({ error: String(e && e.message || e) });
97
+ }
98
+ });
99
+
100
+ // POST /api/live-host/designate { cellId, expectedRevision }.
101
+ // expectedRevision e' OBBLIGATORIO e integer (>=0): nessun CAS permissivo, la UI
102
+ // legge sempre la revision dal GET prima di scrivere (stato iniziale = 0).
103
+ // cellId deve appartenere al roster LOCALE di questo nodo — chiamante locale
104
+ // o peer federato con liveHostAccess, la cella designabile e' sempre e solo
105
+ // una di QUESTO nodo, mai una del chiamante.
106
+ r.post('/designate', express.json({ limit: '4kb' }), async (req, res) => {
107
+ if (readonly()) return res.status(403).json({ error: 'READONLY: designazione cella ospite bloccata' });
108
+ const body = req.body || {};
109
+ if (Object.keys(body).some((k) => !['cellId', 'expectedRevision'].includes(k))
110
+ || typeof body.cellId !== 'string' || !CELL_ID_RE.test(body.cellId)
111
+ || !(Number.isInteger(body.expectedRevision) && body.expectedRevision >= 0)) {
112
+ return res.status(400).json({ error: 'designazione non valida' });
113
+ }
114
+ try {
115
+ const fleet = await fleetP.catch(() => null);
116
+ const cells = await localCells(fleetP);
117
+ if (cells === null) return res.status(503).json({ error: 'fleet non disponibile, riprova' });
118
+ const cell = cells.find((c) => c && c.cell === body.cellId);
119
+ if (!cell) return res.status(404).json({ error: 'cella non appartiene a questo nodo' });
120
+ const result = await store.compareAndSet(body.expectedRevision, body.cellId);
121
+ if (!result.ok) return res.status(409).json({
122
+ error: 'revision superata: rileggi e riprova', revision: result.revision, hostCell: result.hostCell,
123
+ });
124
+ res.json({
125
+ hostCell: result.hostCell, revision: result.revision,
126
+ eligible: eligibleOf(fleet, cell, result.hostCell),
127
+ host: { lease: hostLeaseState(fleet, result.hostCell) },
128
+ at: now(),
129
+ });
130
+ } catch (e) {
131
+ res.status(500).json({ error: String(e && e.message || e) });
132
+ }
133
+ });
134
+
135
+ // POST /api/live-host/clear { expectedRevision } — rimuove la designazione (CAS).
136
+ r.post('/clear', express.json({ limit: '4kb' }), async (req, res) => {
137
+ if (readonly()) return res.status(403).json({ error: 'READONLY: rimozione cella ospite bloccata' });
138
+ const body = req.body || {};
139
+ if (Object.keys(body).some((k) => k !== 'expectedRevision')
140
+ || !(Number.isInteger(body.expectedRevision) && body.expectedRevision >= 0)) {
141
+ return res.status(400).json({ error: 'rimozione non valida' });
142
+ }
143
+ try {
144
+ const result = await store.compareAndSet(body.expectedRevision, null);
145
+ if (!result.ok) return res.status(409).json({
146
+ error: 'revision superata: rileggi e riprova', revision: result.revision, hostCell: result.hostCell,
147
+ });
148
+ res.json({ hostCell: null, revision: result.revision, at: now() });
149
+ } catch (e) {
150
+ res.status(500).json({ error: String(e && e.message || e) });
151
+ }
152
+ });
153
+
154
+ // POST /api/live-host/bridge — risolve il puntamento per l'avvio di una Live
155
+ // (fetta 3). La richiesta NON è parametrizzabile: la designazione è la
156
+ // condizione (LC3) e nessun chiamante può scegliere il target (MC3.4). Il
157
+ // ponte risponde sempre 200: i `none` con reason sono esiti legittimi e
158
+ // distinti (nessuna designazione / cella non idonea / fallback), non errori.
159
+ // Body opzionale e vuoto: un body con campi è un 400, non viene ignorato.
160
+ r.post('/bridge', express.json({ limit: '1kb' }), async (req, res) => {
161
+ const body = req.body || {};
162
+ if (Object.keys(body).length > 0) return res.status(400).json({ error: 'la risoluzione non accetta parametri' });
163
+ if (readonly()) return res.json({ mode: 'none', reason: 'readonly', at: now() });
164
+ if (!bridge) return res.status(503).json({ error: 'ponte Live non configurato su questo nodo' });
165
+ try {
166
+ const result = await bridge.resolveForLive();
167
+ res.json(result);
168
+ } catch (e) {
169
+ // Il contratto (MC1.5) vuole che un guasto del ponte non fermi la Live:
170
+ // anche l'inaspettato collassa in `none` dichiarato, mai un 500.
171
+ // `bridge-error` e' l'ultima rete: un'eccezione che nessun ramo previsto ha
172
+ // classificato. Va NOMINATA come le altre, non lasciata fuori dall'elenco
173
+ // — una causa non dichiarata e' una causa che nessuno cerchera'.
174
+ res.json({ mode: 'none', reason: 'bridge-error', detail: String(e && e.message || e), at: now() });
175
+ }
176
+ });
177
+
178
+ // Stesso body-error handling di cellsRoutes: payload troppo grande / JSON invalido.
179
+ r.use((err, _req, res, _next) => {
180
+ if (err && (err.type === 'entity.too.large' || err.status === 413)) {
181
+ return res.status(413).json({ error: 'body troppo grande' });
182
+ }
183
+ if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
184
+ return res.status(err.status || 400).json({ error: String(err.message || err) });
185
+ });
186
+
187
+ return r;
188
+ }
189
+
190
+ module.exports = { liveHostRoutes };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+ // lib/live-host/store.js — stato della designazione "cella ospite Live" di un nodo.
3
+ //
4
+ // Un solo hostCell per nodo (contratto rev6 §2.2): chiave unica, non una convenzione
5
+ // ripetuta in N punti. Lo stato vive su disco (sopravvive a riavvii di cella e di
6
+ // NexusCrew) e l'aggiornamento e' un CAS su `revision`: due designazioni concorrenti
7
+ // non possono lasciare due celle rosse — il perdente rilegge la revision e rinuncia.
8
+ //
9
+ // La designazione e' PURAMENTE un marker di scelta del nodo. L'eligibilita' (la cella
10
+ // e' anche attiva in questo momento?) e' derivata dal roster Fleet e non si persiste
11
+ // qui: `active` resta `sessions.has(tmuxSession)` (runtime.js), la marcatura non lo
12
+ // tocca. Una cella spenta PRESERVA la designazione — resta hostCell, semplicemente
13
+ // ineligible finche' non torna attiva.
14
+
15
+ const path = require('node:path');
16
+ const os = require('node:os');
17
+ const { readJsonSafe, atomicWriteJson } = require('../notify/persist.js');
18
+
19
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
20
+
21
+ // Path del file di stato: override esplicito -> dir del token (~/.nexuscrew) -> home.
22
+ // Stessa convenzione di consentPath/groupsPath, cosi' i test isolano lo stato via
23
+ // cfg.tokenPath senza toccare la home reale.
24
+ function liveHostPath(cfg = {}, home = (cfg.home || os.homedir())) {
25
+ if (cfg.liveHostPath) return cfg.liveHostPath;
26
+ if (cfg.tokenPath) return path.join(path.dirname(cfg.tokenPath), 'live-host.json');
27
+ return path.join(home, '.nexuscrew', 'live-host.json');
28
+ }
29
+
30
+ // Normalizza il grezzo letto da disco in {revision, hostCell}. Un file assente o
31
+ // garbage (readJsonSafe -> {}) e' lo stato iniziale legittimo {0, null}; un hostCell
32
+ // non-string o fuori formato viene chiuso a null senza promuovere garbage.
33
+ function normalize(raw) {
34
+ const revision = raw && Number.isInteger(raw.revision) && raw.revision >= 0 ? raw.revision : 0;
35
+ const hostCell = raw && typeof raw.hostCell === 'string' && CELL_ID_RE.test(raw.hostCell)
36
+ ? raw.hostCell : null;
37
+ return { revision, hostCell };
38
+ }
39
+
40
+ function readLiveHost(filePath) {
41
+ return normalize(readJsonSafe(filePath));
42
+ }
43
+
44
+ // createLiveHostStore({filePath, now}) — CAS store.
45
+ //
46
+ // read+compare+write sono sincroni, ma le route sono async e due richieste concorrenti
47
+ // possono entrambe aver letto la stessa revision prima che una delle due scriva. La
48
+ // promise-chain serializza la sezione read-modify-write: nessun await dentro la
49
+ // callback, quindi il check e la scrittura sono una sola transazione atomica rispetto
50
+ // agli altri CAS in volo. Esattamente uno dei due CAS concorrenti avanza la revision.
51
+ function createLiveHostStore({ filePath, now = () => Date.now() } = {}) {
52
+ if (!filePath || typeof filePath !== 'string') {
53
+ throw new Error('createLiveHostStore: filePath richiesto');
54
+ }
55
+ let chain = Promise.resolve();
56
+
57
+ function serialize(fn) {
58
+ const run = chain.then(fn);
59
+ // La catena non si rompe mai su un errore di un CAS: un fallimento non deve
60
+ // bloccare i successivi (un CAS che lancia risolve comunque il run).
61
+ chain = run.then(() => {}, () => {});
62
+ return run;
63
+ }
64
+
65
+ // Snapshot read-only corrente (dopo normalizzazione).
66
+ function snapshot() {
67
+ return readLiveHost(filePath);
68
+ }
69
+
70
+ // CAS: scrive nextHostCell (stringa valida | null) solo se la revision corrente
71
+ // coincide con expectedRevision. Ritorna:
72
+ // { ok:true, revision:<nuova>, hostCell:<nuovo>, at }
73
+ // { ok:false, conflict:true, revision:<corrente>, hostCell:<corrente> }
74
+ // Fail-closed: expectedRevision deve essere un integer che coincide con la
75
+ // revision corrente. Uno expected non-integer (es. undefined) NON e' un
76
+ // lasciapassare: e' un conflitto. Lo stato iniziale e' revision 0 ed e'
77
+ // conoscibile (la UI lo legge dal GET), quindi non esiste un "primo scrittore
78
+ // senza revision" legittimo: l'assenza del campo va rifiutata, non tollerata.
79
+ function compareAndSet(expectedRevision, nextHostCell) {
80
+ return serialize(() => {
81
+ const cur = readLiveHost(filePath);
82
+ if (!Number.isInteger(expectedRevision) || cur.revision !== expectedRevision) {
83
+ return { ok: false, conflict: true, revision: cur.revision, hostCell: cur.hostCell };
84
+ }
85
+ const hostCell = nextHostCell === null ? null
86
+ : (typeof nextHostCell === 'string' && CELL_ID_RE.test(nextHostCell) ? nextHostCell : null);
87
+ const revised = { revision: cur.revision + 1, hostCell, updatedAt: now() };
88
+ atomicWriteJson(filePath, revised);
89
+ return { ok: true, revision: revised.revision, hostCell: revised.hostCell, at: revised.updatedAt };
90
+ });
91
+ }
92
+
93
+ return { snapshot, compareAndSet, filePath };
94
+ }
95
+
96
+ module.exports = { createLiveHostStore, liveHostPath, readLiveHost, normalize, CELL_ID_RE };
package/lib/mcp/tools.js CHANGED
@@ -863,6 +863,57 @@ const TOOLS = [
863
863
  return { receipt };
864
864
  },
865
865
  },
866
+ // --- lease Live del child (fetta 2b, contratto rev1 B5) ----------------------
867
+ // Tre metodi DISTINTI perche' i valori di ritorno non mentono l'uno con l'altro:
868
+ // solo register puo' rispondere {status:'pending'} (join non ancora tracciato);
869
+ // refresh su registration viva risponde {status:'live', proof} e non ha stati
870
+ // pendenti; recovery riprende la STESSA incarnazione con un proof scaduto da
871
+ // poco. L'authorizer di refresh/recovery e' il proof HMAC firmato dal verifier
872
+ // per-installazione del server; la cella e' la sessione del chiamante.
873
+ {
874
+ name: 'nc_lease_register',
875
+ description: 'Registra questa cella al lease Live del nodo: crea una registration con incarnationId propria e consegna il primo proof child. Risponde {status:"pending"} se la cella non e\' ancora tracciata dal lease del supervisore: riprova dopo retryAfterMs.',
876
+ inputSchema: { type: 'object', properties: {} },
877
+ async handler(_args, ctx) {
878
+ const identity = await ctx.identity();
879
+ const session = requireSession(identity.session, 'nc_lease_register', identity.code);
880
+ return ctx.api('POST', '/api/lease/register', { session });
881
+ },
882
+ },
883
+ {
884
+ name: 'nc_lease_refresh',
885
+ description: 'Rinnova la registration child presentando l\'ultimo proof detenuto: risponde {status:"live"} con un proof nuovo. Mai pending: senza registration risponde {status:"no-registration"} (usa nc_lease_register).',
886
+ inputSchema: {
887
+ type: 'object',
888
+ properties: { proof: { type: 'object', description: 'ultimo proof ricevuto (register o refresh precedente), tale e quale' } },
889
+ required: ['proof'],
890
+ },
891
+ async handler(args, ctx) {
892
+ const identity = await ctx.identity();
893
+ const session = requireSession(identity.session, 'nc_lease_refresh', identity.code);
894
+ const proof = args.proof && typeof args.proof === 'object' && !Array.isArray(args.proof)
895
+ ? args.proof : null;
896
+ if (!proof) throw new Error('parametro "proof" obbligatorio (oggetto ricevuto da register/refresh)');
897
+ return ctx.api('POST', '/api/lease/refresh', { session, proof });
898
+ },
899
+ },
900
+ {
901
+ name: 'nc_lease_recovery',
902
+ description: 'Riprende la registration child dopo un gap: presenta l\'ultimo proof (accettato anche scaduto da poco) e riceve un proof nuovo con la STESSA incarnazione. Ogni presentazione conta come attempt: oltre il cap serve nc_lease_register.',
903
+ inputSchema: {
904
+ type: 'object',
905
+ properties: { proof: { type: 'object', description: 'ultimo proof detenuto, anche scaduto da poco' } },
906
+ required: ['proof'],
907
+ },
908
+ async handler(args, ctx) {
909
+ const identity = await ctx.identity();
910
+ const session = requireSession(identity.session, 'nc_lease_recovery', identity.code);
911
+ const proof = args.proof && typeof args.proof === 'object' && !Array.isArray(args.proof)
912
+ ? args.proof : null;
913
+ if (!proof) throw new Error('parametro "proof" obbligatorio (oggetto ricevuto in precedenza)');
914
+ return ctx.api('POST', '/api/lease/recovery', { session, proof });
915
+ },
916
+ },
866
917
  ];
867
918
 
868
919
  module.exports = {
@@ -99,8 +99,14 @@ function bindLocalPort(port, createServerImpl = net.createServer) {
99
99
  // Selezione OS-aware per il pairing: nodes.json evita collisioni logiche, il
100
100
  // bind reale evita porte gia' occupate da processi estranei. La socket resta
101
101
  // riservata fino all'avvio del supervisor SSH e viene poi rilasciata una volta.
102
+ // Ogni LISTENER locale conta: localPort di controllo e panelLocalPort inoltrata
103
+ // condividono lo stesso loopback, e la stessa regola di univocita' dello store.
102
104
  async function reserveLocalPort(st, opts = {}) {
103
- const used = new Set((st.nodes || []).map((n) => n.localPort));
105
+ const used = new Set();
106
+ for (const n of st.nodes || []) {
107
+ if (Number.isInteger(n.localPort)) used.add(n.localPort);
108
+ if (Number.isInteger(n.panelLocalPort)) used.add(n.panelLocalPort);
109
+ }
104
110
  const createServerImpl = opts.createServerImpl || net.createServer;
105
111
  for (let port = opts.start || LOCAL_PORT_BASE; port <= 65535; port += 1) {
106
112
  if (used.has(port)) continue;
@@ -279,15 +285,27 @@ function nodesEdit(opts) {
279
285
  // Lo scope celle vale per QUALUNQUE peer accoppiato, in entrambe le
280
286
  // direzioni: riguarda chi accede alle celle di questo nodo, non chi ha
281
287
  // aperto il tunnel.
288
+ // `panelAccess` segue la stessa regola dello scope celle: riguarda chi accede
289
+ // ai PANNELLI di questo nodo, non chi ha aperto il tunnel — quindi vale in
290
+ // entrambe le direzioni. `liveHostAccess` e' identica: riguarda chi puo'
291
+ // designare la cella ospite Live di QUESTO nodo, non chi ha aperto il tunnel.
282
292
  const allowed = node.direction === 'inbound'
283
- ? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells'])
284
- : new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells']);
293
+ ? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess'])
294
+ : new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess']);
285
295
  const keys = Object.keys(supplied);
286
296
  const invalid = keys.find((key) => !allowed.has(key));
287
297
  if (invalid) {
288
298
  log(`nodes edit: campo "${invalid}" non modificabile per un peer ${node.direction}`);
289
299
  return { code: 1, reason: 'field-not-editable' };
290
300
  }
301
+ if (Object.hasOwn(supplied, 'panelAccess') && typeof supplied.panelAccess !== 'boolean') {
302
+ log('nodes edit: panelAccess vuole un booleano');
303
+ return { code: 1, reason: 'invalid-panel-access' };
304
+ }
305
+ if (Object.hasOwn(supplied, 'liveHostAccess') && typeof supplied.liveHostAccess !== 'boolean') {
306
+ log('nodes edit: liveHostAccess vuole un booleano');
307
+ return { code: 1, reason: 'invalid-live-host-access' };
308
+ }
291
309
  if (keys.length === 0) { log('nodes edit: nessuna modifica richiesta'); return { code: 1, reason: 'empty-patch' }; }
292
310
  const patch = { ...supplied };
293
311
  if (Object.hasOwn(patch, 'label')) {
@@ -112,6 +112,19 @@ const NODE_KEYS = new Set([
112
112
  // Quali CELLE di questo nodo il peer puo' vedere. Distinto da `visibility`,
113
113
  // che governa il TRANSITO (attraverso chi passa il traffico) e non l'accesso.
114
114
  'cellVisibility', 'cells',
115
+ // Se questo peer puo' aprire i PANNELLI delle celle di questo nodo. Distinto
116
+ // da `cellVisibility`, che dice quali celle il peer VEDE: dietro un pannello
117
+ // c'e' un browser con sessioni gia' autenticate, e un accesso di quel tipo non
118
+ // si revoca cambiando una chiave. Il resto del modello tratta un peer pairato
119
+ // come l'operatore stesso; qui no, e di proposito: default NEGATO, si concede
120
+ // per singolo nodo.
121
+ 'panelAccess',
122
+ // Se questo peer puo' designare/leggere/rimuovere la cella ospite Live di
123
+ // questo nodo. Stessa forma di panelAccess e stessa ragione: la designazione
124
+ // e' un'azione del nodo che POSSIEDE la cella, non di chi la chiede — un peer
125
+ // pairato non la acquisisce diventando "trusted come l'operatore". Default
126
+ // NEGATO, si concede per singolo nodo.
127
+ 'liveHostAccess',
115
128
  // Identita' crittografica del peer — passo 1 del modello di autorita'. Oggi
116
129
  // NON governa niente: si osserva e si registra. `keySource` distingue una
117
130
  // chiave legata al pairing (nello stesso atto in cui si e' deciso di fidarsi)
@@ -119,6 +132,13 @@ const NODE_KEYS = new Set([
119
132
  // grant potra' pretendere la prima. `keyConflict` conserva una chiave DIVERSA
120
133
  // vista dopo il legame, senza sostituire quella legata.
121
134
  'publicKey', 'keySource', 'keyBoundAt', 'keyConflict',
135
+ // P0 stessa-origin, meta' remota: la porta pannello del peer inoltrata sul
136
+ // NOSTRO loopback (panelLocalPort) e la sua origine remota (panelRemotePort).
137
+ // COPPIA obbligatoria: un -L ha due estremita', mezza coppia e' un forward
138
+ // impossibile, non "meno pannello". Campi assenti = peer accoppiato prima di
139
+ // questa negoziazione: via storica, nessun comportamento nuovo. Le porte non
140
+ // sono segreti (come localPort/remotePort, che stanno qui accanto).
141
+ 'panelLocalPort', 'panelRemotePort',
122
142
  ]);
123
143
 
124
144
  // Nome di cella: stessa forma usata da fleet/definitions.js, cells/routes.js e
@@ -257,6 +277,14 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
257
277
  // requests the optional reverse (-R) channel. Old stores therefore migrate
258
278
  // safely to private without a schema bump.
259
279
  shared: n.shared === undefined ? false : n.shared,
280
+ // Default negato: un record esistente, scritto quando questo campo non
281
+ // c'era, NON acquisisce l'accesso ai pannelli per il fatto di essere gia'
282
+ // pairato. Migrazione silenziosa verso il permesso e' esattamente cio' che
283
+ // non deve succedere.
284
+ panelAccess: n.panelAccess === undefined ? false : n.panelAccess,
285
+ // Stessa regola, stesso motivo: un record senza il campo non acquisisce la
286
+ // designazione live per anzianita' del pairing.
287
+ liveHostAccess: n.liveHostAccess === undefined ? false : n.liveHostAccess,
260
288
  visibility: n.visibility || 'network',
261
289
  };
262
290
  if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
@@ -264,6 +292,8 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
264
292
  if (direction === 'outbound' && out.transport === 'inbound') return null;
265
293
  if (typeof out.autostart !== 'boolean') return null;
266
294
  if (typeof out.shared !== 'boolean') return null;
295
+ if (typeof out.panelAccess !== 'boolean') return null;
296
+ if (typeof out.liveHostAccess !== 'boolean') return null;
267
297
  if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
268
298
  if (identityFile) out.identityFile = identityFile;
269
299
  // Keep the old public field while reading v1 so old callers/tests and a
@@ -273,6 +303,16 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
273
303
  if (!isPort(n.reversePort)) return null;
274
304
  out.reversePort = n.reversePort;
275
305
  }
306
+ // Coppia pannello (P0 stessa-origin remota): o entrambe le porte o nessuna.
307
+ // Una sola meta' non viene indovinata — sarebbe un -L monco. E la porta
308
+ // locale del pannello non puo' stare su quella del control plane: ssh
309
+ // cadrebbe con ExitOnForwardFailure portandosi via il tunnel intero.
310
+ if (n.panelLocalPort !== undefined || n.panelRemotePort !== undefined) {
311
+ if (!isPort(n.panelLocalPort) || !isPort(n.panelRemotePort)) return null;
312
+ if (n.panelLocalPort === n.localPort) return null;
313
+ out.panelLocalPort = n.panelLocalPort;
314
+ out.panelRemotePort = n.panelRemotePort;
315
+ }
276
316
  if (n.reversePool !== undefined) {
277
317
  if (schemaVersion < SCHEMA_VERSION) return null;
278
318
  const parsedPool = parseReversePool(n.reversePool);
@@ -404,6 +444,13 @@ function parseStore(raw) {
404
444
  names.add(node.name);
405
445
  if (localPorts.has(node.localPort)) return null; // ogni listener locale ha un solo owner
406
446
  localPorts.add(node.localPort);
447
+ if (node.panelLocalPort !== undefined) {
448
+ // La porta pannello inoltrata e' un ALTRO listener locale: stessa
449
+ // regola di localPort. Due nodi sulla stessa porta farebbero fallire
450
+ // il bind del secondo ssh al prossimo riavvio.
451
+ if (localPorts.has(node.panelLocalPort)) return null;
452
+ localPorts.add(node.panelLocalPort);
453
+ }
407
454
  if (node.nodeId) {
408
455
  if (node.nodeId === d.nodeId) return null; // self-reference nei dati salvati
409
456
  if (ids.has(node.nodeId)) return null; // nodeId remoto univoco
@@ -645,6 +692,8 @@ function redactNode(n) {
645
692
  transport: n.transport || 'ssh',
646
693
  autostart: !!n.autostart,
647
694
  shared: n.shared === true,
695
+ panelAccess: n.panelAccess === true,
696
+ liveHostAccess: n.liveHostAccess === true,
648
697
  visibility: n.visibility || 'network',
649
698
  hasToken: !!n.token, // presenza, non il valore
650
699
  paired: !!(n.token && n.acceptToken),
@@ -680,6 +729,14 @@ function redactNode(n) {
680
729
  ...(n.reversePool.lastAutoRotationAt === undefined ? {} : { lastAutoRotationAt: n.reversePool.lastAutoRotationAt }),
681
730
  };
682
731
  }
732
+ // La coppia pannello non e' un segreto (sono porte, come localPort): e' cio'
733
+ // che il frontend usa per mettere il frame del pannello remoto su un'origin
734
+ // diversa. Il nodo senza coppia non mostra campi: l'assenza SI' vede, perche'
735
+ // e' la via storica.
736
+ if (n.panelLocalPort !== undefined) {
737
+ out.panelLocalPort = n.panelLocalPort;
738
+ out.panelRemotePort = n.panelRemotePort;
739
+ }
683
740
  return out;
684
741
  }
685
742
 
@@ -44,6 +44,17 @@ function assertForwardSpec(node) {
44
44
  if (node.keyPath !== undefined && !store.isAbsPath(node.keyPath)) throw new Error('tunnel: keyPath non valido');
45
45
  if (node.reversePort !== undefined && !store.isPort(node.reversePort)) throw new Error('tunnel: reversePort non valida');
46
46
  if (node.localAppPort !== undefined && !store.isPort(node.localAppPort)) throw new Error('tunnel: localAppPort non valida');
47
+ // Coppia pannello (P0 stessa-origin remota): o entrambe o nessuna, e mai
48
+ // sulla porta locale del control plane — il bind conflitto abbatterebbe il
49
+ // tunnel intero con ExitOnForwardFailure.
50
+ if (node.panelLocalPort !== undefined || node.panelRemotePort !== undefined) {
51
+ if (!store.isPort(node.panelLocalPort) || !store.isPort(node.panelRemotePort)) {
52
+ throw new Error('tunnel: coppia pannello incompleta (panelLocalPort/panelRemotePort)');
53
+ }
54
+ if (node.panelLocalPort === node.localPort) {
55
+ throw new Error('tunnel: panelLocalPort coincide con localPort (bind conflitto)');
56
+ }
57
+ }
47
58
  if (node.shared === true && node.reversePort !== undefined && !node.reversePool && !store.isPort(node.localAppPort)) {
48
59
  throw new Error('tunnel: Share richiede localAppPort esplicita');
49
60
  }
@@ -64,8 +75,16 @@ function buildForwardArgs(node) {
64
75
  const reverse = node.shared === true && node.reversePort !== undefined && !node.reversePool ? [
65
76
  '-R', `127.0.0.1:${node.reversePort}:127.0.0.1:${node.localAppPort}`,
66
77
  ] : [];
78
+ // Second forward, same supervisor: the peer's panel port lands on our
79
+ // loopback so the browser gets a DIFFERENT origin for the panel. Absent pair
80
+ // (peer paired before this negotiation) emits nothing — the tunnel stays
81
+ // byte-identical to what it was.
82
+ const panel = node.panelLocalPort !== undefined && node.panelRemotePort !== undefined ? [
83
+ '-L', `127.0.0.1:${node.panelLocalPort}:127.0.0.1:${node.panelRemotePort}`,
84
+ ] : [];
67
85
  return SSH_BASE_OPTS.concat(transport, identity ? ['-i', identity] : [], [
68
86
  '-L', `127.0.0.1:${node.localPort}:127.0.0.1:${node.remotePort}`,
87
+ ...panel,
69
88
  ...reverse,
70
89
  node.ssh,
71
90
  ]);
@@ -568,7 +587,10 @@ function startTunnel(opts) {
568
587
  let pid = child && child.pid;
569
588
  const cleanupIfOwned = () => {
570
589
  const current = pidf.readPidfile(pidPath);
571
- if (current && current.pid === pid && current.runId === runId) pidf.removePidfile(pidPath);
590
+ // allowLive: la garanzia e' il match pid+runId del NOSTRO spawn — il pidfile
591
+ // e' quello che questa via ha appena scritto per un figlio suo, che in questi
592
+ // rami d'errore e' stato appena segnato o non e' mai partito.
593
+ if (current && current.pid === pid && current.runId === runId) pidf.removePidfile(pidPath, { allowLive: true });
572
594
  removeStateIfOwned(home, name, { pid, runId });
573
595
  };
574
596
  if (child && typeof child.on === 'function') {