@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
package/lib/server.js CHANGED
@@ -23,7 +23,11 @@ const VERSION = require('../package.json').version;
23
23
  const { transcribe } = require('./voice/transcribe.js');
24
24
  const { selectProvider } = require('./fleet/provider.js');
25
25
  const { fleetRoutes } = require('./fleet/routes.js');
26
+ const { leaseRoutes } = require('./fleet/lease-routes.js');
26
27
  const { cellsRoutes } = require('./cells/routes.js');
28
+ const { liveHostRoutes } = require('./live-host/routes.js');
29
+ const { createLiveHostStore, liveHostPath } = require('./live-host/store.js');
30
+ const { createLiveBridge } = require('./live-host/bridge.js');
27
31
  const { fsRoutes } = require('./fs/routes.js');
28
32
  const nodesStore = require('./nodes/store.js');
29
33
  const nodesTunnel = require('./nodes/tunnel.js');
@@ -33,6 +37,8 @@ const { createReverseSlotListeners } = require('./nodes/reverse-slot-listeners.j
33
37
  const reverseRotation = require('./nodes/reverse-rotation.js');
34
38
  const topologyCache = require('./nodes/topology-cache.js');
35
39
  const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
40
+ const { createPanelProxy, handlePanelUpgrade } = require('./proxy/panel-proxy.js');
41
+ const { createPanelAuth } = require('./proxy/panel-auth.js');
36
42
  const federation = require('./proxy/federation.js');
37
43
  const { audioRoutes } = require('./audio/routes.js');
38
44
  const { isConsent: isAudioConsent } = require('./audio/consent.js');
@@ -526,6 +532,14 @@ function createServer(opts = {}) {
526
532
  }
527
533
 
528
534
  const app = express();
535
+ // CSP frame-ancestors (P0 sicurezza 2026-08-16, rimedio 4 del report): la
536
+ // pagina dell'app non deve poter essere incorporata in un iframe altrui —
537
+ // clickjacking sul control plane. Ortogonale alla porta pannello: quello
538
+ // difende CHI E' NEL FRAME, questo difende l'app dall'ESSERE il frame.
539
+ app.use((_req, res, next) => {
540
+ res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
541
+ next();
542
+ });
529
543
  reverseSlotListeners = createReverseSlotListeners({
530
544
  app, diagnostics, createServerImpl: cfg.reverseSlotCreateServerImpl,
531
545
  // `routeUpgrade` e' hoisted: qui si cattura solo il riferimento, la
@@ -535,7 +549,19 @@ function createServer(opts = {}) {
535
549
  const distDir = path.join(__dirname, '..', 'frontend', 'dist');
536
550
  // no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
537
551
  app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
538
- app.use('/pair', publicPeeringRoutes({ cfg, nodesPath }));
552
+ // Il panelServer nasce piu' avanti nella stessa funzione: il pairing legge
553
+ // la sua porta VIVA tramite questo ref, risolto a ogni join (pattern dello
554
+ // stesso tipo dell'hoisting di routeUpgrade qui sopra).
555
+ const panelListenerRef = { server: null };
556
+ app.use('/pair', publicPeeringRoutes({
557
+ cfg, nodesPath,
558
+ panelPort: () => {
559
+ const srv = panelListenerRef.server;
560
+ if (!srv || !srv.listening) return null;
561
+ const addr = srv.address();
562
+ return addr && Number.isInteger(addr.port) && addr.port > 0 ? addr.port : null;
563
+ },
564
+ }));
539
565
  // VL micro-device nodes use a separate, scoped credential surface. These
540
566
  // routes never accept the NexusCrew UI bearer and expose only pair, poll and
541
567
  // self-unpair. Operator management remains behind /api + federation ACL.
@@ -549,9 +575,121 @@ function createServer(opts = {}) {
549
575
  if (!reverseSlotListeners.respond(req, res)) res.status(404).json({ error: 'reverse slot non disponibile' });
550
576
  });
551
577
 
552
- // Tutte le /api dietro Bearer: sul loopback il gate vero è il tunnel,
578
+ // Pannello per-cella (D8): inoltra il traffico verso il `panelUrl` di UNA cella
579
+ // LOCALE. La destinazione non arriva mai dal chiamante — si risolve dallo stato
580
+ // della cella — e il token di NexusCrew non prosegue verso il pannello, che e'
581
+ // un servizio terzo e non un nodo. Local-only come /api/live-host: il pass-through
582
+ // /node/<name>/ NON deve portarci un peer qualsiasi (vedi LOCAL_ONLY_PREFIXES in
583
+ // proxy/node-proxy.js). L'attraversamento verso i nodi del proprietario passera'
584
+ // dalla via allowlistata della federazione, dietro il gate di proprieta'.
585
+ async function resolveCellPanel(cellId) {
586
+ const fleet = await fleetP;
587
+ if (!fleet || fleet.available !== true) return null; // fleet non interrogabile
588
+ const statusFn = typeof fleet.status === 'function' ? fleet.status : fleet.cellStatus;
589
+ if (typeof statusFn !== 'function') return null;
590
+ const st = await statusFn.call(fleet);
591
+ const cells = Array.isArray(st && st.cells) ? st.cells : [];
592
+ // La chiave e' `cell`, non `id`: e' cio' che cellStatus PRODUCE
593
+ // (fleet/runtime.js scrive `cell: c.id`, dove `id` e' il nome interno della
594
+ // definizione). Cercare `c.id` qui non trovava MAI nulla, quindi ogni cella
595
+ // con un pannello configurato rispondeva «pannello non disponibile» — e la
596
+ // UI mostrava una causa col nome sbagliato. Tutti gli altri consumatori di
597
+ // cellStatus usano gia' `c.cell` (cells/routes.js, live-host/bridge.js,
598
+ // live-host/routes.js): questo era l'unico fuori posto.
599
+ const cell = cells.find((c) => c && c.cell === cellId);
600
+ if (!cell) return undefined; // cella sconosciuta
601
+ return typeof cell.panelUrl === 'string' ? cell.panelUrl.trim() : '';
602
+ }
603
+ // Tutte le altre /api dietro Bearer: sul loopback il gate vero è il tunnel,
553
604
  // ma il token chiude anche altri processi locali della stessa macchina.
554
605
  const api = express.Router();
606
+ // L'INGRESSO al pannello sta PRIMA del requireToken generale: un <iframe> e'
607
+ // una navigazione del browser e non porta header, quindi l'autenticazione
608
+ // qui e' dedicata — Bearer per la PWA, ticket monouso + cookie di visione
609
+ // per l'iframe (proxy/panel-auth.js, misura 2026-08-15). Il cookie vale
610
+ // SOLO sul path della cella che lo ha emesso: non e' e non diventa
611
+ // un'autenticazione dell'origine.
612
+ const panelAuth = createPanelAuth({
613
+ verifyToken: (t) => verify(tokenHolder.value, t),
614
+ resolveCellPanel,
615
+ // Senza questo segreto il pannello non saprebbe distinguere la PWA
616
+ // dall'ultimo hop di una route federata — che entra qui col Bearer che il
617
+ // proxy si e' iniettato da se'. E' lo stesso segreto per-processo che
618
+ // firma gli hop di sotto: due valori diversi renderebbero ogni federata
619
+ // 'sospetta', cioe' chiuderebbero il pannello remoto invece di proteggerlo.
620
+ hopSecret: () => hopSecret,
621
+ log: (entry) => diagnostics.record('panel-auth', 'info', entry.outcome, {
622
+ cell: entry.cell, reason: entry.reason, state: entry.outcome,
623
+ }),
624
+ });
625
+ api.use('/panel', panelAuth.panelAuthMiddleware, createPanelProxy({
626
+ resolveCellPanel,
627
+ log: (entry) => diagnostics.record('panel-proxy', 'info', entry.outcome, {
628
+ cell: entry.cell, reason: entry.reason, state: entry.outcome,
629
+ }),
630
+ }));
631
+ // Porta pannello dedicata (P0 sicurezza 2026-08-16): un iframe
632
+ // same-origin senza sandbox legge il localStorage del padre — il JS di un
633
+ // pannello ostile puo' prendersi il token e agire come l'operatore. La
634
+ // porta fa parte dell'origin: spostando SOLO il consumo (ticket in query,
635
+ // cookie di visione) su una porta sua, il browser vede un'origin diversa e
636
+ // non c'e' piu' nulla da rubare. Nessuna API di controllo qui, nessun
637
+ // Bearer verificato: l'emissione del ticket resta sopra, dietro
638
+ // requireToken — e' l'unica operazione che richiede provare CHI chiede.
639
+ const panelApp = express();
640
+ panelApp.use('/panel', panelAuth.consumeMiddleware, createPanelProxy({
641
+ resolveCellPanel,
642
+ log: (entry) => diagnostics.record('panel-proxy', 'info', entry.outcome, {
643
+ cell: entry.cell, reason: entry.reason, state: entry.outcome,
644
+ }),
645
+ }));
646
+ // Qualunque altra cosa (niente /api, niente SPA): 404 secco. Questa porta
647
+ // non ha nient'altro da offrire, e non deve mai imparare a offrirlo per
648
+ // errore — un catch-all che rispondesse con l'app sarebbe esattamente il
649
+ // difetto che questa porta esiste per chiudere.
650
+ panelApp.use((_req, res) => res.status(404).json({ error: 'not found' }));
651
+ const panelServer = http.createServer(panelApp);
652
+ const panelServerV6 = http.createServer(panelApp);
653
+ panelListenerRef.server = panelServer;
654
+ function routePanelUpgrade(req, socket, head) {
655
+ let pathname;
656
+ try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
657
+ catch (_) { try { socket.destroy(); } catch (_e) {} return; }
658
+ if (!pathname.startsWith('/panel/')) { try { socket.destroy(); } catch (_) {} return; }
659
+ handlePanelUpgrade({
660
+ req, socket, head, resolveCellPanel,
661
+ verifyToken: (t) => verify(tokenHolder.value, t),
662
+ authorize: panelAuth.authorizeUpgrade,
663
+ log: (entry) => diagnostics.record('panel-proxy', 'info', entry.outcome, {
664
+ cell: entry.cell, reason: entry.reason, state: entry.outcome,
665
+ }),
666
+ });
667
+ }
668
+ panelServer.on('upgrade', routePanelUpgrade);
669
+ panelServerV6.on('upgrade', routePanelUpgrade);
670
+ panelServer.on('close', () => { try { panelServerV6.close(); } catch (_) {} });
671
+ // L'ingresso ai pannelli REMOTI ha la stessa malattia dell'iframe locale: la
672
+ // via federata /api/route/<nodi>/_/panel/... sta per finire sotto requireToken
673
+ // e una navigazione del browser non porta header. QUI il ticket non si può
674
+ // validare — è del nodo che lo ha emesso — quindi per le panel-resource
675
+ // federate il requireToken NON si applica: decide il nodo proprietario (gate
676
+ // panelAccess + panelAuth col ticket/cookie, e l'ultimo hop lo riconosce
677
+ // dalla prova di hop: di là il Bearer non apre più il pannello, altrimenti
678
+ // il contenuto uscirebbe per ogni peer con panelAccess senza che nessuno
679
+ // abbia mai preso un ticket). Resta della PWA autenticata l'EMISSIONE: il POST
680
+ // .../panel/<cella>/ticket transita di sotto, col Bearer, come ogni altra /api.
681
+ const panelFederatoRouter = federation.localRouter({
682
+ nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
683
+ hopSecret: () => hopSecret,
684
+ });
685
+ api.use('/route', (req, res, next) => {
686
+ const i = req.url.indexOf('/_/');
687
+ if (i === -1) return next();
688
+ const resource = req.url.slice(i + 2);
689
+ if (!/^\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(resource)) return next();
690
+ if (req.method === 'POST' && /^\/panel\/[A-Za-z0-9._-]{1,32}\/ticket\/?$/.test(resource)) return next();
691
+ panelFederatoRouter(req, res, next);
692
+ });
555
693
  api.use(requireToken(tokenStore));
556
694
  // Origine per lo scope celle: stessa prova di hop usata da audio e notify.
557
695
  // requireCell:false perche' qui interessa QUALE NODO parla, non quale cella
@@ -656,11 +794,26 @@ function createServer(opts = {}) {
656
794
  res.json(await setSessionVisibility(cfg.tmuxBin, name, req.body?.technical === true));
657
795
  } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
658
796
  });
797
+ // Mappa nome-nodo -> porta pannello inoltrata sul NOSTRO loopback (coppia
798
+ // panel negoziata nel pairing). E' la fonte con cui il frontend mette il
799
+ // frame di una cella REMOTA su un'origin diversa: la lettura e' questa, non
800
+ // /api/nodes, perche' qui non c'e' nessun probe di health federato — la
801
+ // mappa costa quanto la lettura dello store. Un nodo assente dalla mappa e
802
+ // un peer accoppiato prima di questa negoziazione: via storica.
803
+ const nodePanelPorts = () => {
804
+ const st = nodesStore.loadStore(nodesPath);
805
+ const out = {};
806
+ if (st) for (const n of st.nodes) {
807
+ if (n.panelLocalPort !== undefined) out[n.name] = n.panelLocalPort;
808
+ }
809
+ return out;
810
+ };
659
811
  api.get('/config', (_req, res) => res.json({
660
812
  readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
661
- bind: cfg.bind, port: cfg.port,
813
+ bind: cfg.bind, port: cfg.port, panelPort: cfg.panelPort,
662
814
  protectSharedTmuxServer: cfg.protectSharedTmuxServer !== false,
663
815
  instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
816
+ nodePanelPorts: nodePanelPorts(),
664
817
  presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
665
818
  }));
666
819
  api.use('/files', filesRoutes({
@@ -710,6 +863,10 @@ function createServer(opts = {}) {
710
863
  federatedRate: createSpeakRateLimiter(),
711
864
  }));
712
865
  api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
866
+ // Fetta 2b (D3): superficie child del lease Live via canale nativo del
867
+ // bridge (HTTP loopback + Bearer). La cella e' derivata dalla sessione; il
868
+ // proof firmato dal verifier per-installazione autorizza refresh/recovery.
869
+ api.use('/lease', leaseRoutes({ fleetP, readonly: proxyReadonly }));
713
870
  // Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
714
871
  // store, la stessa fonte usata da /api/cells e /api/peers. Lo stato Fleet e'
715
872
  // asincrono e va atteso: leggerlo come se fosse sincrono lascerebbe la
@@ -776,6 +933,28 @@ function createServer(opts = {}) {
776
933
  })),
777
934
  readonly: proxyReadonly,
778
935
  }));
936
+ // Cella ospite Live (contratto rev6 §2): hostCell unico per nodo con CAS. Lo store
937
+ // vive accanto al token (stessa dir isolata nei test); il proxy nega /api/live-host
938
+ // via /node (local-only), ma la via /api/route la instrada (0.9.1) dietro un
939
+ // permesso per-peer negato di default (liveHostAccess, v. lib/proxy/federation.js).
940
+ // Fetta 3: il ponte risolve il puntamento all'avvio di una Live (POST /bridge
941
+ // nello stesso gruppo, stessa auth, stesso local-only). Isolabile da cfg
942
+ // (rev5 MC0): spento non crea connessioni e la Live resta standard.
943
+ api.use('/live-host', liveHostRoutes({
944
+ fleetP,
945
+ store: createLiveHostStore({ filePath: liveHostPath(cfg) }),
946
+ readonly: proxyReadonly,
947
+ bridge: createLiveBridge({
948
+ cfg,
949
+ fleetP,
950
+ tokenGet: () => tokenHolder.value,
951
+ filesRoot: cfg.filesRoot,
952
+ log: opts.log || console.log,
953
+ }),
954
+ }));
955
+ // Pannello per-cella: montato con il suo ingresso dedicato (ticket+cookie)
956
+ // PRIMA del requireToken generale — vedi il blocco sopra, dove nascono
957
+ // `panelAuth` e `resolveCellPanel`.
779
958
  api.use('/vl-nodes', vlNodeApiRoutes({
780
959
  storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId, readonly: proxyReadonly,
781
960
  }));
@@ -920,6 +1099,10 @@ function createServer(opts = {}) {
920
1099
  watcher.close(); previews.close(); eventsHub.closeAll(); updater.close();
921
1100
  for (const timer of reverseWatchers.values()) clearInterval(timer);
922
1101
  reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
1102
+ // Il pannello non sopravvive al control plane: senza requireToken sopra,
1103
+ // non c'e' un secondo lifecycle da tenere in vita da soli. panelServerV6
1104
+ // si chiude a cascata dal listener 'close' gia' registrato su panelServer.
1105
+ try { panelServer.close(); } catch (_) {}
923
1106
  });
924
1107
  // noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
925
1108
  // (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
@@ -954,7 +1137,14 @@ function createServer(opts = {}) {
954
1137
  if (pathname.startsWith('/api/route/')) {
955
1138
  let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
956
1139
  const given = bearerFrom(req) || u.searchParams.get('token') || '';
957
- if (!verify(tokenHolder.value, given)) return socket.destroy();
1140
+ // Le WebSocket di un pannello REMOTO partono dalla pagina nel frame e
1141
+ // portano solo il cookie di visione: il token qui non c'è e non deve
1142
+ // esserci. L'HUB non può validarle (il cookie è del nodo che lo ha
1143
+ // emesso): transita e decide il proprietario, gate panelAccess compreso
1144
+ // — stessa forma del bypass HTTP sulle panel-resource federate.
1145
+ const panelFederato = /^\/api\/route\/[^?#]*\/_\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(pathname)
1146
+ && /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''));
1147
+ if (!panelFederato && !verify(tokenHolder.value, given)) return socket.destroy();
958
1148
  federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
959
1149
  return;
960
1150
  }
@@ -964,6 +1154,19 @@ function createServer(opts = {}) {
964
1154
  federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
965
1155
  return;
966
1156
  }
1157
+ if (pathname.startsWith('/api/panel/')) {
1158
+ handlePanelUpgrade({
1159
+ req, socket, head, resolveCellPanel,
1160
+ verifyToken: (t) => verify(tokenHolder.value, t),
1161
+ // Il cookie di visione apre le WS del pannello (la pagina nel frame
1162
+ // non puo' mettere header); Bearer e ?token= restano validi come prima.
1163
+ authorize: panelAuth.authorizeUpgrade,
1164
+ log: (entry) => diagnostics.record('panel-proxy', 'info', entry.outcome, {
1165
+ cell: entry.cell, reason: entry.reason, state: entry.outcome,
1166
+ }),
1167
+ });
1168
+ return;
1169
+ }
967
1170
  if (pathname === '/node' || pathname.startsWith('/node/')) {
968
1171
  handleNodeUpgrade({
969
1172
  req, socket, head, resolveNode,
@@ -991,7 +1194,7 @@ function createServer(opts = {}) {
991
1194
  // async questo handler rischierebbe di perdere i frame che arrivano prima
992
1195
  // di bindWs. `/ws` attacca per NOME DI SESSIONE e senza questo gate ogni
993
1196
  // filtro sugli elenchi sarebbe decorativo — basterebbe indovinare
994
- // `cloud-Dev`.
1197
+ // `cloud-X`.
995
1198
  const wsScope = wsCellScope(req);
996
1199
  bindWs(ws, {
997
1200
  openAttach,
@@ -1020,11 +1223,14 @@ function createServer(opts = {}) {
1020
1223
  fleetP.then((fleet) => (typeof fleet.close === 'function' ? fleet.close() : null)).catch(() => {});
1021
1224
  });
1022
1225
 
1023
- return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics };
1226
+ return {
1227
+ app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics,
1228
+ panelApp, panelServer, panelServerV6,
1229
+ };
1024
1230
  }
1025
1231
 
1026
1232
  function start(opts = {}) {
1027
- const { server, cfg } = createServer(opts);
1233
+ const { server, cfg, panelServer, panelServerV6 } = createServer({ ...opts, cellLeaseEnabled: true });
1028
1234
  const log = opts.log || console.log;
1029
1235
  const requestedPort = cfg.port;
1030
1236
  const nodesPath = opts.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
@@ -1033,12 +1239,51 @@ function start(opts = {}) {
1033
1239
  if (typeof opts.onListenError === 'function') return opts.onListenError(error);
1034
1240
  throw error;
1035
1241
  };
1242
+ // Porta pannello (P0 sicurezza 2026-08-16): scelta libera con lo STESSO
1243
+ // meccanismo di fallback della principale, ma piu' leggero — nessun peer
1244
+ // dipende ancora da un valore stabile persistito (a differenza di cfg.port,
1245
+ // che i tunnel pairati referenziano), quindi niente scrittura su
1246
+ // config.json: il frontend la scopre da /api/config a ogni avvio. Un
1247
+ // fallimento qui non deve MAI abbattere il control plane: e' un log, non
1248
+ // un throw — il pannello resta un'estensione, non un requisito di avvio.
1249
+ const startPanelV6 = () => {
1250
+ panelServerV6.once('error', (error) => {
1251
+ log(`panel: IPv6 loopback listener not available (${(error && error.code) || error}); IPv4 still serves the panel.`);
1252
+ });
1253
+ panelServerV6.listen(cfg.panelPort, '::1');
1254
+ };
1255
+ const startPanelServer = () => {
1256
+ const requestedPanelPort = cfg.panelPort;
1257
+ const tryPanelFallback = (candidate, remaining) => {
1258
+ panelServer.once('error', (error) => {
1259
+ if (error && error.code === 'EADDRINUSE' && remaining > 1) {
1260
+ tryPanelFallback(candidate >= 65535 ? 41821 : candidate + 1, remaining - 1);
1261
+ return;
1262
+ }
1263
+ log(`panel: preferred port ${requestedPanelPort} busy and no fallback available (${(error && error.code) || error}); panel disabled this run.`);
1264
+ });
1265
+ panelServer.listen(candidate, cfg.bind, () => {
1266
+ cfg.panelPort = panelServer.address().port;
1267
+ log(`panel port ${requestedPanelPort} busy; selected ${cfg.panelPort}`);
1268
+ startPanelV6();
1269
+ });
1270
+ };
1271
+ panelServer.once('error', (error) => {
1272
+ if (error && error.code === 'EADDRINUSE') { tryPanelFallback(requestedPanelPort >= 65535 ? 41821 : requestedPanelPort + 1, 200); return; }
1273
+ log(`panel: failed to start (${(error && error.code) || error}); panel disabled this run.`);
1274
+ });
1275
+ panelServer.listen(requestedPanelPort, cfg.bind, () => {
1276
+ cfg.panelPort = panelServer.address().port;
1277
+ startPanelV6();
1278
+ });
1279
+ };
1036
1280
  const onListening = () => {
1037
1281
  cfg.port = server.address().port;
1038
1282
  // Il token NON si stampa allo startup: finirebbe nei log del servizio
1039
1283
  // (journalctl/logfile). L'apertura autenticata passa da `nexuscrew show`.
1040
1284
  log(`nexuscrew on http://${cfg.bind}:${cfg.port} (open with \`nexuscrew show\`)`);
1041
1285
  log('localhost-only — reach it via a user-controlled SSH or VPN channel.');
1286
+ startPanelServer();
1042
1287
  };
1043
1288
  const persistFallback = () => {
1044
1289
  const selected = server.address().port;
@@ -87,6 +87,7 @@ function createPairHandler(deps) {
87
87
 
88
88
  let provisionalPort = null;
89
89
  let portReservation = null;
90
+ let panelPortReservation = null;
90
91
  let rollbackCredential = null;
91
92
  let created = false;
92
93
  let rolledBack = false;
@@ -98,6 +99,10 @@ function createPairHandler(deps) {
98
99
  try { await portReservation.release(); } catch (_) { /* best-effort */ }
99
100
  portReservation = null;
100
101
  }
102
+ if (panelPortReservation) {
103
+ try { await panelPortReservation.release(); } catch (_) { /* best-effort */ }
104
+ panelPortReservation = null;
105
+ }
101
106
  if (rollbackCredential && provisionalPort) {
102
107
  try {
103
108
  await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
@@ -296,6 +301,26 @@ function createPairHandler(deps) {
296
301
  // --- tunnel-final: connessione privata, solo -L -------------------------
297
302
  // reversePort resta negoziata per un futuro Share opt-in, ma il builder
298
303
  // non emette -R finche' shared non diventa true.
304
+ //
305
+ // Porta pannello del peer (P0 stessa-origin, meta' remota): se il peer
306
+ // l'ha annunciata nel join, si riserva una controparte locale e la
307
+ // coppia entra nel record — il supervisor finale inoltrera' entrambe.
308
+ // Nessuna porta disponibile NON fa fallire il pairing: il pannello e
309
+ // un'estensione, il legame viene prima. Stesso patto di publicKey: un
310
+ // peer piu' vecchio non annuncia e si accoppia esattamente come prima.
311
+ const peerPanelPort = nodesStore.isPort(joined.panelPort) ? joined.panelPort : null;
312
+ let panelLocalPort = null;
313
+ if (peerPanelPort) {
314
+ try {
315
+ panelPortReservation = await nodesCmds.reserveLocalPort(
316
+ nodesStore.loadStoreStrict(nodesPath),
317
+ { createServerImpl: seams.createPanelPortServer || seams.createPortServer },
318
+ );
319
+ panelLocalPort = panelPortReservation.port;
320
+ } catch (_) {
321
+ panelPortReservation = null; panelLocalPort = null; // fail-open dichiarato: via storica
322
+ }
323
+ }
299
324
  st = nodesStore.loadStoreStrict(nodesPath);
300
325
  if (joinedPool && st.schemaVersion < nodesStore.SCHEMA_VERSION) {
301
326
  // This device is a client of the hub-owned pool. It persists the
@@ -313,8 +338,15 @@ function createPairHandler(deps) {
313
338
  : {}),
314
339
  ...(joinedPool ? { reversePool: joinedPool } : {}),
315
340
  ...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
341
+ ...(panelLocalPort ? { panelLocalPort, panelRemotePort: peerPanelPort } : {}),
316
342
  });
317
343
  nodesStore.atomicWriteStore(nodesPath, st);
344
+ // La riserva della porta pannello ha protetto la scelta fino alla
345
+ // scrittura: come la porta di controllo, si rilascia prima dello spawn.
346
+ if (panelPortReservation) {
347
+ try { await panelPortReservation.release(); } catch (_) { /* best-effort */ }
348
+ panelPortReservation = null;
349
+ }
318
350
  nodesTunnel.stopTunnel({ home, name: b.name });
319
351
  const finalStart = nodesTunnel.startForward({
320
352
  home, node: nodesStore.getNode(st, b.name), localAppPort: runtimePort(),
@@ -28,6 +28,12 @@ function publicPeeringRoutes(deps = {}) {
28
28
  const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
29
29
  const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
30
30
  const reversePoolLedgerPath = deps.reversePoolLedgerPath || cfg.reversePoolLedgerPath || reversePool.defaultLedgerPath(home);
31
+ // Porta pannello di QUESTA installazione, letta al momento del join: non
32
+ // cfg.panelPort (che resta al valore richiesto anche quando il listener non
33
+ // e' mai partito), ma la porta su cui il panelServer ascolta ADESSO. Null
34
+ // quando il pannello e' spento per questo run: meglio il silenzio che far
35
+ // inoltrare al client una porta che nessuno ascolta.
36
+ const panelPortLive = typeof deps.panelPort === 'function' ? deps.panelPort : () => null;
31
37
  // La pubblica di questa installazione, per il passo 1 del modello di
32
38
  // autorita'. Si legge PIGRAMENTE e non all'avvio del router: un errore sul
33
39
  // file di chiave non deve impedire di montare le route di pairing — il passo
@@ -170,13 +176,19 @@ function publicPeeringRoutes(deps = {}) {
170
176
  return res.status(410).json({ error: 'invito scaduto o gia usato' });
171
177
  }
172
178
  const mia = localPublicKey();
179
+ const pannello = panelPortLive();
173
180
  res.json({ paired: true, instanceId: poolStore.nodeId, reversePort,
174
181
  reversePool: { base: assignedPool.base, slots: assignedPool.slots.map((slot) => slot.port) },
175
182
  credential, roles: readRoles(configPath),
176
183
  // Lo scambio e' simmetrico e avviene QUI, dentro l'atto che consuma
177
184
  // l'invito monouso: e' il solo momento in cui l'operatore ha deciso,
178
185
  // su entrambe le macchine, che questi due nodi si conoscono.
179
- ...(mia ? { publicKey: mia } : {}) });
186
+ ...(mia ? { publicKey: mia } : {}),
187
+ // Stesso patto della pubblica: la porta pannello si annuncia nel
188
+ // join, e un peer piu' vecchio che non la capisce si accoppia come
189
+ // sempre. Il campo assente non e' un errore, e' una versione (o un
190
+ // pannello spento per questo run).
191
+ ...(pannello ? { panelPort: pannello } : {}) });
180
192
  } catch (e) {
181
193
  if (credential) try { peering.consumePending({ pendingPath, credential, now }); } catch (_) {}
182
194
  res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.58",
3
+ "version": "0.9.1",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {