@mmmbuto/nexuscrew 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +173 -18
  2. package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
  3. package/frontend/dist/assets/index-DQrDBogT.js +83 -0
  4. package/frontend/dist/index.html +2 -2
  5. package/frontend/dist/sw.js +29 -0
  6. package/frontend/dist/version.json +1 -0
  7. package/lib/auth/middleware.js +7 -1
  8. package/lib/auth/token.js +31 -1
  9. package/lib/cli/commands.js +462 -31
  10. package/lib/cli/doctor.js +160 -0
  11. package/lib/cli/fleet-service.js +319 -0
  12. package/lib/cli/init.js +107 -7
  13. package/lib/cli/path.js +24 -0
  14. package/lib/cli/service.js +14 -3
  15. package/lib/cli/url.js +63 -0
  16. package/lib/config.js +5 -0
  17. package/lib/decks/routes.js +81 -0
  18. package/lib/decks/store.js +117 -0
  19. package/lib/files/routes.js +59 -2
  20. package/lib/files/store.js +16 -1
  21. package/lib/fleet/boot.js +62 -0
  22. package/lib/fleet/builtin.js +594 -0
  23. package/lib/fleet/definitions.js +410 -0
  24. package/lib/fleet/index.js +49 -10
  25. package/lib/fleet/managed.js +211 -0
  26. package/lib/fleet/provider.js +102 -0
  27. package/lib/fleet/routes.js +78 -8
  28. package/lib/fs/routes.js +56 -0
  29. package/lib/mcp/server.js +362 -0
  30. package/lib/nodes/commands.js +396 -0
  31. package/lib/nodes/store.js +358 -0
  32. package/lib/nodes/tunnel-supervisor.js +102 -0
  33. package/lib/nodes/tunnel.js +300 -0
  34. package/lib/notify/asks.js +150 -0
  35. package/lib/notify/events.js +59 -0
  36. package/lib/notify/notifier.js +37 -0
  37. package/lib/notify/persist.js +73 -0
  38. package/lib/notify/push.js +289 -0
  39. package/lib/notify/routes.js +260 -0
  40. package/lib/proxy/node-proxy.js +292 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +214 -15
  44. package/lib/settings/routes.js +473 -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-BWhSqR3J.css +0 -32
  51. package/frontend/dist/assets/index-Bx8vdLZY.css +0 -32
  52. package/frontend/dist/assets/index-CLb2xmyu.js +0 -81
  53. package/frontend/dist/assets/index-CgxfkmRo.js +0 -81
  54. package/frontend/dist/assets/index-DE67kR0M.js +0 -81
  55. package/frontend/dist/assets/index-DQMx2p4x.js +0 -81
  56. package/frontend/dist/assets/index-DWhfVwKW.css +0 -32
  57. package/frontend/dist/assets/index-DoPZ_3-h.js +0 -81
  58. package/frontend/dist/assets/index-o9l7pPAf.css +0 -32
  59. package/frontend/dist/assets/index-q9PUrGO0.js +0 -81
package/lib/server.js CHANGED
@@ -13,13 +13,25 @@ const { createPreviewSampler } = require('./tmux/preview.js');
13
13
  const { openAttach } = require('./pty/attach.js');
14
14
  const { bindWs } = require('./ws/bridge.js');
15
15
  const { loadOrCreateToken, verify } = require('./auth/token.js');
16
- const { requireToken } = require('./auth/middleware.js');
16
+ const { requireToken, bearerFrom } = require('./auth/middleware.js');
17
17
  const { filesRoutes } = require('./files/routes.js');
18
18
  const { createOutboxWatcher } = require('./files/watcher.js');
19
19
  const VERSION = require('../package.json').version;
20
20
  const { transcribe } = require('./voice/transcribe.js');
21
- const { createFleet } = require('./fleet/index.js');
21
+ const { selectProvider } = require('./fleet/provider.js');
22
22
  const { fleetRoutes } = require('./fleet/routes.js');
23
+ const { fsRoutes } = require('./fs/routes.js');
24
+ const nodesStore = require('./nodes/store.js');
25
+ const nodesTunnel = require('./nodes/tunnel.js');
26
+ const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
27
+ const { settingsRoutes } = require('./settings/routes.js');
28
+ const decksStore = require('./decks/store.js');
29
+ const { decksRoutes } = require('./decks/routes.js');
30
+ const { createEventsHub } = require('./notify/events.js');
31
+ const { createPushService } = require('./notify/push.js');
32
+ const { createAsksStore } = require('./notify/asks.js');
33
+ const { createNotifier } = require('./notify/notifier.js');
34
+ const { notifyRoutes } = require('./notify/routes.js');
23
35
 
24
36
  function sessionExists(tmuxBin, name) {
25
37
  if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
@@ -27,14 +39,89 @@ function sessionExists(tmuxBin, name) {
27
39
  catch (_) { return false; }
28
40
  }
29
41
 
42
+ function uiBuildVersion(distDir) {
43
+ try {
44
+ const x = JSON.parse(require('node:fs').readFileSync(path.join(distDir, 'version.json'), 'utf8'));
45
+ return typeof x.version === 'string' ? x.version : null;
46
+ } catch (_) { return null; }
47
+ }
48
+
30
49
  function createServer(opts = {}) {
31
50
  const cfg = loadConfig(opts);
32
51
  assertLoopback(cfg.bind);
33
- const token = loadOrCreateToken(cfg.tokenPath);
52
+ // Token holder LIVE (audit F7 / §4b(3)): requireToken/verify leggono tokenStore.get()
53
+ // ad ogni richiesta, cosi' una rotazione via Settings API invalida il VECCHIO token
54
+ // (401) e attiva il NUOVO (200) SENZA restart. Prima il token era catturato una volta
55
+ // allo startup e restava valido fino al restart manuale.
56
+ const tokenHolder = { value: loadOrCreateToken(cfg.tokenPath) };
57
+ const tokenStore = {
58
+ get: () => tokenHolder.value,
59
+ reload: () => { tokenHolder.value = loadOrCreateToken(cfg.tokenPath); return tokenHolder.value; },
60
+ };
61
+ const proxySockets = new Set();
62
+ // wss viene creato piu' sotto; closeSessions lo raggiunge a request-time (mai durante
63
+ // createServer) per chiudere le sessioni WS attive sulla rotazione token (§4b(3)).
64
+ let wss = null;
65
+ const closeSessions = () => {
66
+ if (wss) {
67
+ for (const ws of wss.clients) { try { ws.close(4001, 'token rotated'); } catch (_) { /* best-effort */ } }
68
+ }
69
+ for (const socket of proxySockets) { try { socket.destroy(); } catch (_) {} }
70
+ proxySockets.clear();
71
+ };
34
72
  const watcher = createOutboxWatcher({ root: cfg.filesRoot });
35
73
  const previews = createPreviewSampler(cfg.tmuxBin);
74
+ // MCP bridge (notify/ask/push): lo stato vive accanto al token (dirname del
75
+ // tokenPath = ~/.nexuscrew di default) cosi' le istanze isolate via opts/env
76
+ // nei test NON scrivono mai nella home reale. Tutto lazy: vapid.json/asks.json
77
+ // nascono al primo uso, non allo startup.
78
+ const notifyDir = cfg.notifyDir || path.dirname(cfg.tokenPath);
79
+ // READONLY come floor anche dentro il push service (F3): niente generazione
80
+ // VAPID ne' cleanup subscription quando il server e' readonly.
81
+ const bridgeReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
82
+ const eventsHub = createEventsHub();
83
+ const pushSvc = createPushService({
84
+ dir: notifyDir, webpushImpl: cfg.webpushImpl,
85
+ readonly: bridgeReadonly, maxSubs: cfg.pushMaxSubs, lookupImpl: cfg.pushLookupImpl,
86
+ });
87
+ const asksStore = createAsksStore({ dir: notifyDir });
88
+ const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
36
89
  const attachedWs = new Map(); // ws -> session (per il push dei frame files)
37
- const fleetP = createFleet(cfg); // async, non blocca il boot
90
+ // selectProvider sceglie UNA volta (startup) il provider external|builtin|disabled
91
+ // (design §4b/§9b/§9g) e ritorna {mode,reason,fleet}; routes consumano il .fleet,
92
+ // quindi fleetP resta una Promise<Fleet> (createServer non diventa async).
93
+ const fleetP = selectProvider(cfg).then((p) => p.fleet);
94
+
95
+ // Multi-node (B1): nodes.json e' la fonte dati (B0). Il proxy risolve <name>
96
+ // -> {localPort, token} leggendo lo store ad ogni richiesta (fresh: rotazione
97
+ // token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
98
+ // il valore che il proxy inietta upstream, non esce mai verso il browser.
99
+ const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
100
+ const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
101
+ const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
102
+ function resolveNode(name) {
103
+ const st = nodesStore.loadStore(nodesPath);
104
+ if (!st) return null;
105
+ const node = nodesStore.getNode(st, name);
106
+ if (!node) return null;
107
+ return { localPort: node.localPort, token: node.token || null };
108
+ }
109
+
110
+ // A node-role installation must republish itself after service/reboot. The
111
+ // detached supervisor is idempotent and owns retry/backoff independently.
112
+ if (cfg.roles && cfg.roles.node === true) {
113
+ const st = nodesStore.loadStore(nodesPath);
114
+ if (st && st.rendezvous) {
115
+ const tr = nodesTunnel.startReverse({
116
+ home: cfg.home || os.homedir(), rendezvous: st.rendezvous,
117
+ spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
118
+ sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
119
+ });
120
+ if (!tr.started && tr.reason !== 'already running') {
121
+ process.stderr.write(`[nexuscrew] reverse tunnel autostart failed: ${tr.reason || 'unknown'}\n`);
122
+ }
123
+ }
124
+ }
38
125
 
39
126
  const app = express();
40
127
  const distDir = path.join(__dirname, '..', 'frontend', 'dist');
@@ -44,7 +131,7 @@ function createServer(opts = {}) {
44
131
  // Tutte le /api dietro Bearer: sul loopback il gate vero è il tunnel,
45
132
  // ma il token chiude anche altri processi locali della stessa macchina.
46
133
  const api = express.Router();
47
- api.use(requireToken(token));
134
+ api.use(requireToken(tokenStore));
48
135
  api.get('/sessions', async (_req, res) => {
49
136
  try {
50
137
  const sessions = await listSessions(cfg.tmuxBin);
@@ -78,7 +165,7 @@ function createServer(opts = {}) {
78
165
  } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
79
166
  });
80
167
  api.get('/config', (_req, res) => res.json({
81
- readonlyDefault: cfg.readonlyDefault, version: VERSION,
168
+ readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
82
169
  bind: cfg.bind, port: cfg.port,
83
170
  presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
84
171
  }));
@@ -86,8 +173,38 @@ function createServer(opts = {}) {
86
173
  cfg,
87
174
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
88
175
  paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
176
+ notifier,
177
+ }));
178
+ // MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
179
+ // del router /api; gate READONLY sui mutanti dentro notifyRoutes.
180
+ api.use(notifyRoutes({
181
+ cfg,
182
+ notifier,
183
+ push: pushSvc,
184
+ asks: asksStore,
185
+ paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
186
+ sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
89
187
  }));
90
- api.use('/fleet', fleetRoutes(fleetP));
188
+ api.use('/fleet', fleetRoutes(fleetP, cfg));
189
+ api.use('/decks', decksRoutes({ cfg, decksPath }));
190
+ api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
191
+ // /nodes (read-only, per la settings UI B2): stesso formato di `nodes list --json`
192
+ // (token SEMPRE redatti via redactStore) + stato tunnel per-nodo.
193
+ api.get('/nodes', (_req, res) => {
194
+ try {
195
+ const st = nodesStore.loadStore(nodesPath);
196
+ if (!st) return res.json({ nodeId: null, nodes: [] });
197
+ const view = nodesStore.redactStore(st);
198
+ const nodes = view.nodes.map((n) => ({ ...n, tunnel: nodesTunnel.readTunnelState(os.homedir(), n.name) }));
199
+ const out = { nodeId: view.nodeId, nodes };
200
+ if (view.rendezvous) out.rendezvous = view.rendezvous;
201
+ res.json(out);
202
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
203
+ });
204
+ // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
205
+ // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
206
+ // READONLY route-level e la redazione token vivono dentro settingsRoutes.
207
+ api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
91
208
  api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
92
209
  api.post('/voice/transcribe',
93
210
  express.raw({ type: () => true, limit: '25mb' }),
@@ -97,21 +214,99 @@ function createServer(opts = {}) {
97
214
  res.json({ text: out.text || '' });
98
215
  } catch (e) { res.status(e.status || 502).json({ error: e.message }); }
99
216
  });
217
+ // SSE eventi UI (notify/ask, MCP bridge §2a): EventSource non puo' settare
218
+ // header -> il token e' accettato anche in query, SOLO perche' il bind e'
219
+ // loopback-only (stesso pattern dell'upgrade WS del proxy /node). Montata
220
+ // PRIMA del router /api (che e' Bearer-only) e sempre sul token live.
221
+ app.get('/api/events', (req, res) => {
222
+ const given = bearerFrom(req) || String(req.query.token || '');
223
+ if (!verify(tokenHolder.value, given)) {
224
+ return res.status(401).json({ error: 'unauthorized' });
225
+ }
226
+ eventsHub.handle(req, res);
227
+ });
228
+
100
229
  app.use('/api', api);
101
230
 
231
+ // Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
232
+ // di risolvere <name>: requireToken(token) davanti al router, nessuna route
233
+ // proxy montata prima del middleware auth. Montato PRIMA dello static/catch-all.
234
+ app.use('/node', requireToken(tokenStore), createNodeProxy({ resolveNode, readonly: proxyReadonly }));
235
+
102
236
  app.use(express.static(distDir));
237
+ // Deck multi-finestra (§5b): /deck/<name> serve la STESSA SPA (stesso origin,
238
+ // stesso token via fragment). <name> e' una chiave strict client-side, mai usata
239
+ // per costruire path: validazione ^[a-z0-9-]{1,32}$, nome invalido → 404 secco
240
+ // (niente traversal, niente fallback silenzioso alla SPA su nomi sporchi).
241
+ const DECK_NAME_RE = /^[a-z0-9-]{1,32}$/;
242
+ // Cattura TUTTO cio' che sta sotto /deck/ (anche slash/segmenti extra o encoded)
243
+ // e valida il remainder: qualunque cosa non sia un nome deck strict → 404,
244
+ // senza mai cadere nel catch-all SPA con un nome sporco.
245
+ app.get(/^\/deck\/(.*)$/, (req, res) => {
246
+ // parita' col client: deckFromPath accetta UN trailing slash (/deck/main/),
247
+ // il server deve fare lo stesso; piu' di uno resta 404 (regex strict).
248
+ const name = req.params[0].replace(/\/$/, '');
249
+ if (!DECK_NAME_RE.test(name)) {
250
+ return res.status(404).type('text/plain').send('invalid deck name');
251
+ }
252
+ return res.sendFile(path.join(distDir, 'index.html'));
253
+ });
103
254
  app.get('*', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
104
255
 
105
256
  const server = http.createServer(app);
106
257
  // Close the watcher when the HTTP server closes. Registered HERE (inside
107
258
  // createServer) — not in start() — so every createServer consumer is covered,
108
259
  // not only the start() path. watcher.close() is idempotent.
109
- server.on('close', () => { watcher.close(); previews.close(); });
110
- const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 << 20 });
111
- wss.on('connection', (ws) => {
260
+ server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); });
261
+ // noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
262
+ // (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
263
+ // dell'HTTP (auth locale -> name strict -> inject token) prima del piping.
264
+ wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
265
+ // Browser/mobile/tunnel possono lasciare TCP half-open senza un close event.
266
+ // Il ping applicativo fa emergere il guasto; terminate genera un close 1006
267
+ // lato browser, che il client riconnette senza richiedere refresh pagina.
268
+ const heartbeat = setInterval(() => {
269
+ for (const client of wss.clients) {
270
+ if (client.isAlive === false) { try { client.terminate(); } catch (_) {} continue; }
271
+ client.isAlive = false;
272
+ try { client.ping(); } catch (_) { try { client.terminate(); } catch (_e) {} }
273
+ }
274
+ }, opts.wsHeartbeatMs || 30000);
275
+ if (typeof heartbeat.unref === 'function') heartbeat.unref();
276
+ server.on('close', () => clearInterval(heartbeat));
277
+ server.on('upgrade', (req, socket, head) => {
278
+ let pathname;
279
+ try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
280
+ catch (_) { try { socket.destroy(); } catch (_e) {} return; }
281
+ if (pathname === '/ws') {
282
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
283
+ return;
284
+ }
285
+ if (pathname === '/node' || pathname.startsWith('/node/')) {
286
+ handleNodeUpgrade({
287
+ req, socket, head, resolveNode,
288
+ verifyToken: (t) => verify(tokenHolder.value, t),
289
+ readonly: proxyReadonly,
290
+ activeSockets: proxySockets,
291
+ });
292
+ return;
293
+ }
294
+ try { socket.destroy(); } catch (_) {}
295
+ });
296
+ wss.on('connection', (ws, req) => {
297
+ ws.isAlive = true;
298
+ ws.on('pong', () => { ws.isAlive = true; });
299
+ // Preauth via header (B2 attach remoto): quando l'upgrade arriva dal proxy
300
+ // /node/<name> di un hub, il proxy ha gia' iniettato `Authorization: Bearer
301
+ // <token di QUESTO nodo>` (§4b(2)#3) mentre il frame attach porta il token
302
+ // del hub. Un Bearer valido sull'upgrade vale come auth: e' lo STESSO
303
+ // verify dello stesso token locale, solo su un canale diverso. I browser
304
+ // non possono settare header sui WS -> il flusso locale resta identico
305
+ // (token nel frame attach, mai in URL).
306
+ const preauth = req ? verify(tokenHolder.value, bearerFrom(req)) : false;
112
307
  bindWs(ws, {
113
308
  openAttach,
114
- verifyToken: (t) => verify(token, t),
309
+ verifyToken: (t) => preauth || verify(tokenHolder.value, t),
115
310
  isValidSession: (name) => sessionExists(cfg.tmuxBin, name),
116
311
  runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
117
312
  countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
@@ -129,14 +324,18 @@ function createServer(opts = {}) {
129
324
  }
130
325
  });
131
326
 
132
- return { app, server, wss, cfg, token, watcher, fleetP };
327
+ return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP };
133
328
  }
134
329
 
135
330
  function start(opts = {}) {
136
- const { server, cfg, token } = createServer(opts);
331
+ const { server, cfg } = createServer(opts);
332
+ const log = opts.log || console.log;
137
333
  server.listen(cfg.port, cfg.bind, () => {
138
- console.log(`nexuscrew on http://${cfg.bind}:${cfg.port} (token: ${token})`);
139
- console.log('localhost-only reach it via SSH/autossh/VPN tunnel.');
334
+ // Il token NON si stampa allo startup: finirebbe nei log del servizio
335
+ // (journalctl/logfile), che `nexuscrew logs` espone contratto A2
336
+ // "token mai nei log". Per l'URL completo: `nexuscrew url` (o --qr).
337
+ log(`nexuscrew on http://${cfg.bind}:${cfg.port} (token: usa \`nexuscrew url\`)`);
338
+ log('localhost-only — reach it via SSH/autossh/VPN tunnel.');
140
339
  });
141
340
  return server;
142
341
  }