@mmmbuto/nexuscrew 0.7.7 → 0.8.2

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 (51) hide show
  1. package/README.md +153 -25
  2. package/bin/nexuscrew.js +10 -4
  3. package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
  4. package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
  5. package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +29 -0
  8. package/frontend/dist/version.json +1 -0
  9. package/lib/auth/middleware.js +7 -1
  10. package/lib/auth/token.js +31 -1
  11. package/lib/cli/commands.js +502 -43
  12. package/lib/cli/doctor.js +160 -0
  13. package/lib/cli/fleet-service.js +16 -3
  14. package/lib/cli/init.js +69 -11
  15. package/lib/cli/path.js +24 -0
  16. package/lib/cli/service.js +14 -8
  17. package/lib/cli/url.js +63 -0
  18. package/lib/config.js +5 -0
  19. package/lib/decks/routes.js +81 -0
  20. package/lib/decks/store.js +123 -0
  21. package/lib/files/routes.js +57 -1
  22. package/lib/files/store.js +16 -1
  23. package/lib/fleet/builtin.js +151 -24
  24. package/lib/fleet/definitions.js +26 -0
  25. package/lib/fleet/managed.js +381 -0
  26. package/lib/fleet/routes.js +5 -4
  27. package/lib/mcp/server.js +381 -0
  28. package/lib/nodes/commands.js +411 -0
  29. package/lib/nodes/peering.js +116 -0
  30. package/lib/nodes/store.js +425 -0
  31. package/lib/nodes/tunnel-supervisor.js +102 -0
  32. package/lib/nodes/tunnel.js +315 -0
  33. package/lib/notify/asks.js +150 -0
  34. package/lib/notify/events.js +59 -0
  35. package/lib/notify/notifier.js +37 -0
  36. package/lib/notify/persist.js +73 -0
  37. package/lib/notify/push.js +289 -0
  38. package/lib/notify/routes.js +260 -0
  39. package/lib/proxy/federation.js +217 -0
  40. package/lib/proxy/node-proxy.js +305 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +291 -14
  44. package/lib/settings/routes.js +685 -0
  45. package/lib/ws/bridge.js +10 -1
  46. package/package.json +8 -2
  47. package/skills/nexuscrew-agent/SKILL.md +83 -0
  48. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  49. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  50. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  51. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
package/lib/server.js CHANGED
@@ -2,10 +2,12 @@
2
2
  const http = require('node:http');
3
3
  const path = require('node:path');
4
4
  const os = require('node:os');
5
+ const fs = require('node:fs');
5
6
  const { execFileSync } = require('node:child_process');
6
7
  const express = require('express');
7
8
  const { WebSocketServer } = require('ws');
8
- const { defaults, loadConfig, assertLoopback } = require('./config.js');
9
+ const { defaults, loadConfig, assertLoopback, configJsonPath } = require('./config.js');
10
+ const { writeConfigAtomic } = require('./cli/init.js');
9
11
  const { listSessions, attachedClients } = require('./tmux/list.js');
10
12
  const { runAction, pasteToSession } = require('./tmux/actions.js');
11
13
  const { createSession, killSession, isProtectedSession } = require('./tmux/lifecycle.js');
@@ -13,7 +15,7 @@ const { createPreviewSampler } = require('./tmux/preview.js');
13
15
  const { openAttach } = require('./pty/attach.js');
14
16
  const { bindWs } = require('./ws/bridge.js');
15
17
  const { loadOrCreateToken, verify } = require('./auth/token.js');
16
- const { requireToken } = require('./auth/middleware.js');
18
+ const { requireToken, bearerFrom } = require('./auth/middleware.js');
17
19
  const { filesRoutes } = require('./files/routes.js');
18
20
  const { createOutboxWatcher } = require('./files/watcher.js');
19
21
  const VERSION = require('../package.json').version;
@@ -21,6 +23,18 @@ const { transcribe } = require('./voice/transcribe.js');
21
23
  const { selectProvider } = require('./fleet/provider.js');
22
24
  const { fleetRoutes } = require('./fleet/routes.js');
23
25
  const { fsRoutes } = require('./fs/routes.js');
26
+ const nodesStore = require('./nodes/store.js');
27
+ const nodesTunnel = require('./nodes/tunnel.js');
28
+ const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
29
+ const federation = require('./proxy/federation.js');
30
+ const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
31
+ const decksStore = require('./decks/store.js');
32
+ const { decksRoutes } = require('./decks/routes.js');
33
+ const { createEventsHub } = require('./notify/events.js');
34
+ const { createPushService } = require('./notify/push.js');
35
+ const { createAsksStore } = require('./notify/asks.js');
36
+ const { createNotifier } = require('./notify/notifier.js');
37
+ const { notifyRoutes } = require('./notify/routes.js');
24
38
 
25
39
  function sessionExists(tmuxBin, name) {
26
40
  if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
@@ -28,27 +42,118 @@ function sessionExists(tmuxBin, name) {
28
42
  catch (_) { return false; }
29
43
  }
30
44
 
45
+ function uiBuildVersion(distDir) {
46
+ try {
47
+ const x = JSON.parse(require('node:fs').readFileSync(path.join(distDir, 'version.json'), 'utf8'));
48
+ return typeof x.version === 'string' ? x.version : null;
49
+ } catch (_) { return null; }
50
+ }
51
+
31
52
  function createServer(opts = {}) {
32
53
  const cfg = loadConfig(opts);
33
54
  assertLoopback(cfg.bind);
34
- const token = loadOrCreateToken(cfg.tokenPath);
55
+ // Token holder LIVE (audit F7 / §4b(3)): requireToken/verify leggono tokenStore.get()
56
+ // ad ogni richiesta, cosi' una rotazione via Settings API invalida il VECCHIO token
57
+ // (401) e attiva il NUOVO (200) SENZA restart. Prima il token era catturato una volta
58
+ // allo startup e restava valido fino al restart manuale.
59
+ const tokenHolder = { value: loadOrCreateToken(cfg.tokenPath) };
60
+ const tokenStore = {
61
+ get: () => tokenHolder.value,
62
+ reload: () => { tokenHolder.value = loadOrCreateToken(cfg.tokenPath); return tokenHolder.value; },
63
+ };
64
+ const proxySockets = new Set();
65
+ // wss viene creato piu' sotto; closeSessions lo raggiunge a request-time (mai durante
66
+ // createServer) per chiudere le sessioni WS attive sulla rotazione token (§4b(3)).
67
+ let wss = null;
68
+ const closeSessions = () => {
69
+ if (wss) {
70
+ for (const ws of wss.clients) { try { ws.close(4001, 'token rotated'); } catch (_) { /* best-effort */ } }
71
+ }
72
+ for (const socket of proxySockets) { try { socket.destroy(); } catch (_) {} }
73
+ proxySockets.clear();
74
+ };
35
75
  const watcher = createOutboxWatcher({ root: cfg.filesRoot });
36
76
  const previews = createPreviewSampler(cfg.tmuxBin);
77
+ // MCP bridge (notify/ask/push): lo stato vive accanto al token (dirname del
78
+ // tokenPath = ~/.nexuscrew di default) cosi' le istanze isolate via opts/env
79
+ // nei test NON scrivono mai nella home reale. Tutto lazy: vapid.json/asks.json
80
+ // nascono al primo uso, non allo startup.
81
+ const notifyDir = cfg.notifyDir || path.dirname(cfg.tokenPath);
82
+ // READONLY come floor anche dentro il push service (F3): niente generazione
83
+ // VAPID ne' cleanup subscription quando il server e' readonly.
84
+ const bridgeReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
85
+ const eventsHub = createEventsHub();
86
+ const pushSvc = createPushService({
87
+ dir: notifyDir, webpushImpl: cfg.webpushImpl,
88
+ readonly: bridgeReadonly, maxSubs: cfg.pushMaxSubs, lookupImpl: cfg.pushLookupImpl,
89
+ });
90
+ const asksStore = createAsksStore({ dir: notifyDir });
91
+ const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
37
92
  const attachedWs = new Map(); // ws -> session (per il push dei frame files)
38
93
  // selectProvider sceglie UNA volta (startup) il provider external|builtin|disabled
39
94
  // (design §4b/§9b/§9g) e ritorna {mode,reason,fleet}; routes consumano il .fleet,
40
95
  // quindi fleetP resta una Promise<Fleet> (createServer non diventa async).
41
96
  const fleetP = selectProvider(cfg).then((p) => p.fleet);
42
97
 
98
+ // Multi-node (B1): nodes.json e' la fonte dati (B0). Il proxy risolve <name>
99
+ // -> {localPort, token} leggendo lo store ad ogni richiesta (fresh: rotazione
100
+ // token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
101
+ // il valore che il proxy inietta upstream, non esce mai verso il browser.
102
+ const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
103
+ const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
104
+ const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
105
+ function resolveNode(name) {
106
+ const st = nodesStore.loadStore(nodesPath);
107
+ if (!st) return null;
108
+ const node = nodesStore.getNode(st, name);
109
+ if (!node) return null;
110
+ return { localPort: node.localPort, token: node.token || null };
111
+ }
112
+
113
+ // A node-role installation must republish itself after service/reboot. The
114
+ // detached supervisor is idempotent and owns retry/backoff independently.
115
+ if (cfg.roles && cfg.roles.node === true) {
116
+ const st = nodesStore.loadStore(nodesPath);
117
+ if (st && st.rendezvous) {
118
+ const tr = nodesTunnel.startReverse({
119
+ home: cfg.home || os.homedir(), rendezvous: st.rendezvous,
120
+ spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
121
+ sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
122
+ });
123
+ if (!tr.started && tr.reason !== 'already running') {
124
+ process.stderr.write(`[nexuscrew] reverse tunnel autostart failed: ${tr.reason || 'unknown'}\n`);
125
+ }
126
+ }
127
+ }
128
+
129
+ // New Hydra peers are ordinary local+remote nodes. Only the side that owns
130
+ // the outbound SSH alias dials; inbound records are reached through its -R.
131
+ {
132
+ const st = nodesStore.loadStore(nodesPath);
133
+ for (const node of (st && st.nodes) || []) {
134
+ if (node.direction !== 'inbound' && node.autostart === true) {
135
+ const tr = nodesTunnel.startForward({
136
+ home: cfg.home || os.homedir(), node, localAppPort: cfg.port,
137
+ spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
138
+ sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
139
+ });
140
+ if (!tr.started && tr.reason !== 'already running') {
141
+ process.stderr.write(`[nexuscrew] peer ${node.name} autostart failed: ${tr.reason || 'unknown'}\n`);
142
+ }
143
+ }
144
+ }
145
+ }
146
+
43
147
  const app = express();
44
148
  const distDir = path.join(__dirname, '..', 'frontend', 'dist');
45
149
  // no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
46
150
  app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
151
+ app.use('/pair', publicPeeringRoutes({ cfg, nodesPath }));
47
152
 
48
153
  // Tutte le /api dietro Bearer: sul loopback il gate vero è il tunnel,
49
154
  // ma il token chiude anche altri processi locali della stessa macchina.
50
155
  const api = express.Router();
51
- api.use(requireToken(token));
156
+ api.use(requireToken(tokenStore));
52
157
  api.get('/sessions', async (_req, res) => {
53
158
  try {
54
159
  const sessions = await listSessions(cfg.tmuxBin);
@@ -62,6 +167,7 @@ function createServer(opts = {}) {
62
167
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
63
168
  });
64
169
  api.post('/sessions', express.json({ limit: '4kb' }), async (req, res) => {
170
+ if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: creazione sessione bloccata' });
65
171
  try {
66
172
  const { name, cwd, preset } = req.body || {};
67
173
  await createSession(cfg.tmuxBin, { name, cwd, preset },
@@ -70,6 +176,7 @@ function createServer(opts = {}) {
70
176
  } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
71
177
  });
72
178
  api.delete('/sessions/:name', async (req, res) => {
179
+ if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: eliminazione sessione bloccata' });
73
180
  const name = String(req.params.name || '');
74
181
  try {
75
182
  const fleet = await fleetP;
@@ -82,17 +189,58 @@ function createServer(opts = {}) {
82
189
  } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
83
190
  });
84
191
  api.get('/config', (_req, res) => res.json({
85
- readonlyDefault: cfg.readonlyDefault, version: VERSION,
192
+ readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
86
193
  bind: cfg.bind, port: cfg.port,
194
+ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
87
195
  presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
88
196
  }));
89
197
  api.use('/files', filesRoutes({
90
198
  cfg,
91
199
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
92
200
  paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
201
+ notifier,
202
+ readonly: proxyReadonly,
203
+ }));
204
+ // MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
205
+ // del router /api; gate READONLY sui mutanti dentro notifyRoutes.
206
+ api.use(notifyRoutes({
207
+ cfg,
208
+ notifier,
209
+ push: pushSvc,
210
+ asks: asksStore,
211
+ paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
212
+ sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
93
213
  }));
94
214
  api.use('/fleet', fleetRoutes(fleetP, cfg));
215
+ api.use('/decks', decksRoutes({ cfg, decksPath }));
95
216
  api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
217
+ // /nodes (read-only, per la settings UI B2): stesso formato di `nodes list --json`
218
+ // (token SEMPRE redatti via redactStore) + stato tunnel per-nodo.
219
+ api.get('/nodes', (_req, res) => {
220
+ try {
221
+ const st = nodesStore.loadStore(nodesPath);
222
+ if (!st) return res.json({ nodeId: null, nodes: [] });
223
+ const view = nodesStore.redactStore(st);
224
+ const nodes = view.nodes.map((n) => ({
225
+ ...n,
226
+ tunnel: n.direction === 'inbound' ? { status: 'up', managed: false } : nodesTunnel.readTunnelState(os.homedir(), n.name),
227
+ }));
228
+ const out = { nodeId: view.nodeId, nodes };
229
+ if (view.rendezvous) out.rendezvous = view.rendezvous;
230
+ res.json(out);
231
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
232
+ });
233
+ // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
234
+ // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
235
+ // READONLY route-level e la redazione token vivono dentro settingsRoutes.
236
+ api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
237
+ api.get('/topology', async (_req, res) => {
238
+ try { res.json(await federation.collectTopology({ nodesPath })); }
239
+ catch (e) { res.status(500).json({ error: String(e.message || e) }); }
240
+ });
241
+ api.use('/route', federation.localRouter({
242
+ nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, readonly: proxyReadonly,
243
+ }));
96
244
  api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
97
245
  api.post('/voice/transcribe',
98
246
  express.raw({ type: () => true, limit: '25mb' }),
@@ -102,21 +250,115 @@ function createServer(opts = {}) {
102
250
  res.json({ text: out.text || '' });
103
251
  } catch (e) { res.status(e.status || 502).json({ error: e.message }); }
104
252
  });
253
+ // SSE eventi UI (notify/ask, MCP bridge §2a): EventSource non puo' settare
254
+ // header -> il token e' accettato anche in query, SOLO perche' il bind e'
255
+ // loopback-only (stesso pattern dell'upgrade WS del proxy /node). Montata
256
+ // PRIMA del router /api (che e' Bearer-only) e sempre sul token live.
257
+ app.get('/api/events', (req, res) => {
258
+ const given = bearerFrom(req) || String(req.query.token || '');
259
+ if (!verify(tokenHolder.value, given)) {
260
+ return res.status(401).json({ error: 'unauthorized' });
261
+ }
262
+ eventsHub.handle(req, res);
263
+ });
264
+
105
265
  app.use('/api', api);
266
+ app.use('/federation', federation.peerRouter({
267
+ nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, readonly: proxyReadonly,
268
+ }));
269
+
270
+ // Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
271
+ // di risolvere <name>: requireToken(token) davanti al router, nessuna route
272
+ // proxy montata prima del middleware auth. Montato PRIMA dello static/catch-all.
273
+ app.use('/node', requireToken(tokenStore), createNodeProxy({ resolveNode, readonly: proxyReadonly }));
106
274
 
107
275
  app.use(express.static(distDir));
276
+ // Deck multi-finestra (§5b): /deck/<name> serve la STESSA SPA (stesso origin,
277
+ // stesso token via fragment). <name> e' una chiave strict client-side, mai usata
278
+ // per costruire path: validazione ^[a-z0-9-]{1,32}$, nome invalido → 404 secco
279
+ // (niente traversal, niente fallback silenzioso alla SPA su nomi sporchi).
280
+ const DECK_NAME_RE = /^[a-z0-9-]{1,32}$/;
281
+ // Cattura TUTTO cio' che sta sotto /deck/ (anche slash/segmenti extra o encoded)
282
+ // e valida il remainder: qualunque cosa non sia un nome deck strict → 404,
283
+ // senza mai cadere nel catch-all SPA con un nome sporco.
284
+ app.get(/^\/deck\/(.*)$/, (req, res) => {
285
+ // parita' col client: deckFromPath accetta UN trailing slash (/deck/main/),
286
+ // il server deve fare lo stesso; piu' di uno resta 404 (regex strict).
287
+ const name = req.params[0].replace(/\/$/, '');
288
+ if (!DECK_NAME_RE.test(name)) {
289
+ return res.status(404).type('text/plain').send('invalid deck name');
290
+ }
291
+ return res.sendFile(path.join(distDir, 'index.html'));
292
+ });
108
293
  app.get('*', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
109
294
 
110
295
  const server = http.createServer(app);
111
296
  // Close the watcher when the HTTP server closes. Registered HERE (inside
112
297
  // createServer) — not in start() — so every createServer consumer is covered,
113
298
  // not only the start() path. watcher.close() is idempotent.
114
- server.on('close', () => { watcher.close(); previews.close(); });
115
- const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 << 20 });
116
- wss.on('connection', (ws) => {
299
+ server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); });
300
+ // noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
301
+ // (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
302
+ // dell'HTTP (auth locale -> name strict -> inject token) prima del piping.
303
+ wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
304
+ // Browser/mobile/tunnel possono lasciare TCP half-open senza un close event.
305
+ // Il ping applicativo fa emergere il guasto; terminate genera un close 1006
306
+ // lato browser, che il client riconnette senza richiedere refresh pagina.
307
+ const heartbeat = setInterval(() => {
308
+ for (const client of wss.clients) {
309
+ if (client.isAlive === false) { try { client.terminate(); } catch (_) {} continue; }
310
+ client.isAlive = false;
311
+ try { client.ping(); } catch (_) { try { client.terminate(); } catch (_e) {} }
312
+ }
313
+ }, opts.wsHeartbeatMs || 30000);
314
+ if (typeof heartbeat.unref === 'function') heartbeat.unref();
315
+ server.on('close', () => clearInterval(heartbeat));
316
+ server.on('upgrade', (req, socket, head) => {
317
+ let pathname;
318
+ try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
319
+ catch (_) { try { socket.destroy(); } catch (_e) {} return; }
320
+ if (pathname === '/ws') {
321
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
322
+ return;
323
+ }
324
+ if (pathname.startsWith('/api/route/')) {
325
+ let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
326
+ const given = bearerFrom(req) || u.searchParams.get('token') || '';
327
+ if (!verify(tokenHolder.value, given)) return socket.destroy();
328
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets });
329
+ return;
330
+ }
331
+ if (pathname.startsWith('/federation/route/')) {
332
+ const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
333
+ if (!ingress) return socket.destroy();
334
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets });
335
+ return;
336
+ }
337
+ if (pathname === '/node' || pathname.startsWith('/node/')) {
338
+ handleNodeUpgrade({
339
+ req, socket, head, resolveNode,
340
+ verifyToken: (t) => verify(tokenHolder.value, t),
341
+ readonly: proxyReadonly,
342
+ activeSockets: proxySockets,
343
+ });
344
+ return;
345
+ }
346
+ try { socket.destroy(); } catch (_) {}
347
+ });
348
+ wss.on('connection', (ws, req) => {
349
+ ws.isAlive = true;
350
+ ws.on('pong', () => { ws.isAlive = true; });
351
+ // Preauth via header (B2 attach remoto): quando l'upgrade arriva dal proxy
352
+ // /node/<name> di un hub, il proxy ha gia' iniettato `Authorization: Bearer
353
+ // <token di QUESTO nodo>` (§4b(2)#3) mentre il frame attach porta il token
354
+ // del hub. Un Bearer valido sull'upgrade vale come auth: e' lo STESSO
355
+ // verify dello stesso token locale, solo su un canale diverso. I browser
356
+ // non possono settare header sui WS -> il flusso locale resta identico
357
+ // (token nel frame attach, mai in URL).
358
+ const preauth = req ? verify(tokenHolder.value, bearerFrom(req)) : false;
117
359
  bindWs(ws, {
118
360
  openAttach,
119
- verifyToken: (t) => verify(token, t),
361
+ verifyToken: (t) => preauth || verify(tokenHolder.value, t),
120
362
  isValidSession: (name) => sessionExists(cfg.tmuxBin, name),
121
363
  runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
122
364
  countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
@@ -134,15 +376,50 @@ function createServer(opts = {}) {
134
376
  }
135
377
  });
136
378
 
137
- return { app, server, wss, cfg, token, watcher, fleetP };
379
+ return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP };
138
380
  }
139
381
 
140
382
  function start(opts = {}) {
141
- const { server, cfg, token } = createServer(opts);
142
- server.listen(cfg.port, cfg.bind, () => {
143
- console.log(`nexuscrew on http://${cfg.bind}:${cfg.port} (token: ${token})`);
144
- console.log('localhost-only reach it via SSH/autossh/VPN tunnel.');
383
+ const { server, cfg } = createServer(opts);
384
+ const log = opts.log || console.log;
385
+ const requestedPort = cfg.port;
386
+ const onListening = () => {
387
+ cfg.port = server.address().port;
388
+ // Il token NON si stampa allo startup: finirebbe nei log del servizio
389
+ // (journalctl/logfile). L'apertura autenticata passa da `nexuscrew show`.
390
+ log(`nexuscrew on http://${cfg.bind}:${cfg.port} (open with \`nexuscrew show\`)`);
391
+ log('localhost-only — reach it via SSH/autossh/VPN tunnel.');
392
+ };
393
+ const persistFallback = () => {
394
+ const selected = server.address().port;
395
+ const configPath = opts.configPath || configJsonPath();
396
+ let current = {};
397
+ try {
398
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
399
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) current = parsed;
400
+ } catch (_) {}
401
+ writeConfigAtomic(configPath, { ...current, port: selected });
402
+ log(`preferred port ${requestedPort} busy; selected ${selected}`);
403
+ onListening();
404
+ };
405
+ const tryFallback = (candidate, remaining) => {
406
+ server.once('error', (error) => {
407
+ if (error && error.code === 'EADDRINUSE' && remaining > 1) {
408
+ tryFallback(candidate >= 65535 ? 41820 : candidate + 1, remaining - 1);
409
+ return;
410
+ }
411
+ throw error;
412
+ });
413
+ server.listen(candidate, cfg.bind, persistFallback);
414
+ };
415
+ server.once('error', (error) => {
416
+ if (error && error.code === 'EADDRINUSE' && requestedPort !== 0 && opts.autoPort !== false) {
417
+ tryFallback(requestedPort >= 65535 ? 41820 : requestedPort + 1, 200);
418
+ return;
419
+ }
420
+ throw error;
145
421
  });
422
+ server.listen(requestedPort, cfg.bind, onListening);
146
423
  return server;
147
424
  }
148
425