@mmmbuto/nexuscrew 0.4.2 → 0.7.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.
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const { createFleetExec } = require('./exec.js');
4
+
5
+ const ENGINES = new Set(['native', 'glm', 'glm-a', 'glm-p', 'ollama', 'ollama-cloud', 'codex-vl']);
6
+ const STATUS_TTL_MS = 2000;
7
+
8
+ // Trust boundary sul binario (audit F3): regular file, NO symlink,
9
+ // eseguibile dall'owner, NON world-writable.
10
+ function binTrusted(bin) {
11
+ try {
12
+ const st = fs.lstatSync(bin);
13
+ if (!st.isFile()) return false; // lstat: un symlink NON è file
14
+ if (!(st.mode & 0o100)) return false; // owner-executable
15
+ if (st.mode & 0o002) return false; // world-writable
16
+ return true;
17
+ } catch (_) { return false; }
18
+ }
19
+
20
+ function parseStatus(raw) {
21
+ let d;
22
+ try { d = JSON.parse(raw); } catch (_) { return null; }
23
+ if (!d || d.kind !== 'ai-fleet' || d.schemaVersion !== 1 || !Array.isArray(d.cells)) return null;
24
+ // Strict per cella (audit finale #2): campi obbligatori e tipizzati; una sola
25
+ // cella malformata invalida l'intero status (fail-closed, feature spenta).
26
+ const cells = [];
27
+ for (const c of d.cells) {
28
+ if (!c || typeof c !== 'object'
29
+ || typeof c.cell !== 'string' || !c.cell
30
+ || typeof c.tmuxSession !== 'string' || !c.tmuxSession
31
+ || typeof c.engine !== 'string' || !c.engine
32
+ || typeof c.active !== 'boolean' || typeof c.boot !== 'boolean'
33
+ || typeof c.tmux !== 'boolean'
34
+ || typeof c.rc !== 'string' || typeof c.key !== 'string') return null;
35
+ cells.push({
36
+ cell: c.cell, tmuxSession: c.tmuxSession, engine: c.engine,
37
+ active: c.active, boot: c.boot, tmux: c.tmux, rc: c.rc, key: c.key,
38
+ degraded: c.active !== c.tmux, // unit e tmux in disaccordo
39
+ });
40
+ }
41
+ return cells;
42
+ }
43
+
44
+ function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
45
+
46
+ async function createFleet(cfg = {}) {
47
+ const off = { available: false, isCellSession: () => false };
48
+ if (cfg.fleetEnabled === false) return off;
49
+ const bin = cfg.fleetBin;
50
+ if (!bin || !binTrusted(bin)) return off;
51
+
52
+ const fx = createFleetExec(bin);
53
+ let cells;
54
+ try { cells = parseStatus(await fx.run(['status', '--json'])); } catch (_) { return off; }
55
+ if (!cells) return off; // schema estraneo → feature spenta
56
+
57
+ let cache = { at: Date.now(), cells };
58
+ const sessions = () => new Set(cache.cells.map((c) => c.tmuxSession));
59
+
60
+ async function status() {
61
+ if (Date.now() - cache.at > STATUS_TTL_MS) {
62
+ const fresh = parseStatus(await fx.run(['status', '--json']));
63
+ if (fresh) cache = { at: Date.now(), cells: fresh };
64
+ }
65
+ return { available: true, cells: cache.cells };
66
+ }
67
+
68
+ function assertCell(cell) {
69
+ if (!cache.cells.some((c) => c.cell === cell)) throw httpError(400, `cella sconosciuta: ${cell}`);
70
+ }
71
+ function assertEngine(eng) {
72
+ if (!ENGINES.has(eng)) throw httpError(400, `engine non valido: ${eng}`);
73
+ }
74
+ async function cmd(args) {
75
+ try { await fx.run(args); } catch (e) { throw httpError(502, e.message); }
76
+ cache = { at: 0, cells: cache.cells }; // invalida: il prossimo status rilegge
77
+ return { ok: true };
78
+ }
79
+
80
+ return {
81
+ available: true,
82
+ status,
83
+ up: async (cell, { engine, boot } = {}) => {
84
+ assertCell(cell); if (engine != null) assertEngine(engine);
85
+ const a = ['up', cell]; if (engine) a.push('--engine', engine); if (boot) a.push('--boot');
86
+ return cmd(a);
87
+ },
88
+ down: async (cell, { boot } = {}) => {
89
+ assertCell(cell);
90
+ const a = ['down', cell]; if (boot) a.push('--boot');
91
+ return cmd(a);
92
+ },
93
+ engine: async (cell, eng) => { assertCell(cell); assertEngine(eng); return cmd(['engine', cell, eng]); },
94
+ boot: async (cell, enabled) => { assertCell(cell); return cmd([enabled ? 'boot' : 'noboot', cell]); },
95
+ isCellSession: (name) => sessions().has(name),
96
+ };
97
+ }
98
+
99
+ module.exports = { createFleet, parseStatus, binTrusted };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ const express = require('express');
3
+
4
+ // Router /api/fleet — fleetP è una Promise<Fleet> (createServer non diventa
5
+ // async): ogni handler attende la resolve; unavailable → 404 sui comandi.
6
+ function fleetRoutes(fleetP) {
7
+ const r = express.Router();
8
+ r.use(express.json({ limit: '4kb' }));
9
+
10
+ const guard = (fn) => async (req, res) => {
11
+ try {
12
+ const fleet = await fleetP;
13
+ if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
14
+ res.json(await fn(fleet, req.body || {}));
15
+ } catch (e) {
16
+ res.status(e.status || 500).json({ error: String(e.message || e) });
17
+ }
18
+ };
19
+
20
+ r.get('/status', async (_req, res) => {
21
+ try {
22
+ const fleet = await fleetP;
23
+ if (!fleet.available) return res.json({ available: false });
24
+ res.json(await fleet.status());
25
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
26
+ });
27
+ r.post('/up', guard((f, b) => f.up(String(b.cell || ''), { engine: b.engine, boot: !!b.boot })));
28
+ r.post('/down', guard((f, b) => f.down(String(b.cell || ''), { boot: !!b.boot })));
29
+ r.post('/engine', guard((f, b) => f.engine(String(b.cell || ''), String(b.engine || ''))));
30
+ r.post('/boot', guard((f, b) => f.boot(String(b.cell || ''), b.enabled === true)));
31
+ return r;
32
+ }
33
+
34
+ module.exports = { fleetRoutes };
package/lib/server.js CHANGED
@@ -1,15 +1,25 @@
1
1
  'use strict';
2
2
  const http = require('node:http');
3
3
  const path = require('node:path');
4
+ const os = require('node:os');
4
5
  const { execFileSync } = require('node:child_process');
5
6
  const express = require('express');
6
7
  const { WebSocketServer } = require('ws');
7
- const { defaults, assertLoopback } = require('./config.js');
8
+ const { defaults, loadConfig, assertLoopback } = require('./config.js');
8
9
  const { listSessions, attachedClients } = require('./tmux/list.js');
9
- const { runAction } = require('./tmux/actions.js');
10
+ const { runAction, pasteToSession } = require('./tmux/actions.js');
11
+ const { createSession, killSession, isProtectedSession } = require('./tmux/lifecycle.js');
12
+ const { createPreviewSampler } = require('./tmux/preview.js');
10
13
  const { openAttach } = require('./pty/attach.js');
11
14
  const { bindWs } = require('./ws/bridge.js');
12
15
  const { loadOrCreateToken, verify } = require('./auth/token.js');
16
+ const { requireToken } = require('./auth/middleware.js');
17
+ const { filesRoutes } = require('./files/routes.js');
18
+ const { createOutboxWatcher } = require('./files/watcher.js');
19
+ const VERSION = require('../package.json').version;
20
+ const { transcribe } = require('./voice/transcribe.js');
21
+ const { createFleet } = require('./fleet/index.js');
22
+ const { fleetRoutes } = require('./fleet/routes.js');
13
23
 
14
24
  function sessionExists(tmuxBin, name) {
15
25
  if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
@@ -18,24 +28,85 @@ function sessionExists(tmuxBin, name) {
18
28
  }
19
29
 
20
30
  function createServer(opts = {}) {
21
- const cfg = { ...defaults(), ...opts };
31
+ const cfg = loadConfig(opts);
22
32
  assertLoopback(cfg.bind);
23
33
  const token = loadOrCreateToken(cfg.tokenPath);
34
+ const watcher = createOutboxWatcher({ root: cfg.filesRoot });
35
+ const previews = createPreviewSampler(cfg.tmuxBin);
36
+ const attachedWs = new Map(); // ws -> session (per il push dei frame files)
37
+ const fleetP = createFleet(cfg); // async, non blocca il boot
24
38
 
25
39
  const app = express();
26
40
  const distDir = path.join(__dirname, '..', 'frontend', 'dist');
27
41
  // no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
28
42
  app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
29
- app.get('/api/sessions', async (_req, res) => {
30
- try { res.json({ sessions: await listSessions(cfg.tmuxBin) }); }
31
- catch (e) { res.status(500).json({ error: String(e.message || e) }); }
43
+
44
+ // Tutte le /api dietro Bearer: sul loopback il gate vero è il tunnel,
45
+ // ma il token chiude anche altri processi locali della stessa macchina.
46
+ const api = express.Router();
47
+ api.use(requireToken(token));
48
+ api.get('/sessions', async (_req, res) => {
49
+ try {
50
+ const sessions = await listSessions(cfg.tmuxBin);
51
+ const sum = watcher.getSummary();
52
+ const enriched = await Promise.all(sessions.map(async (s) => ({
53
+ ...s,
54
+ outbox: sum[s.name] || { count: 0, latest: 0 },
55
+ preview: await previews.get(s.name),
56
+ })));
57
+ res.json({ sessions: enriched });
58
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
59
+ });
60
+ api.post('/sessions', express.json({ limit: '4kb' }), async (req, res) => {
61
+ try {
62
+ const { name, cwd, preset } = req.body || {};
63
+ await createSession(cfg.tmuxBin, { name, cwd, preset },
64
+ { home: os.homedir(), presets: cfg.sessionPresets });
65
+ res.status(201).json({ created: true, name });
66
+ } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
32
67
  });
33
- // Note: the token must never be exposed here.
34
- app.get('/api/config', (_req, res) => res.json({ readonlyDefault: cfg.readonlyDefault }));
68
+ api.delete('/sessions/:name', async (req, res) => {
69
+ const name = String(req.params.name || '');
70
+ try {
71
+ const fleet = await fleetP;
72
+ if (isProtectedSession(name, fleet.isCellSession)) {
73
+ return res.status(409).json({ error: 'sessione di cella: usa fleet down' });
74
+ }
75
+ const killed = await killSession(cfg.tmuxBin, name);
76
+ if (!killed) return res.status(404).json({ error: 'sessione inesistente' });
77
+ res.json({ killed: true });
78
+ } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
79
+ });
80
+ api.get('/config', (_req, res) => res.json({
81
+ readonlyDefault: cfg.readonlyDefault, version: VERSION,
82
+ bind: cfg.bind, port: cfg.port,
83
+ presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
84
+ }));
85
+ api.use('/files', filesRoutes({
86
+ cfg,
87
+ sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
88
+ paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
89
+ }));
90
+ api.use('/fleet', fleetRoutes(fleetP));
91
+ api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
92
+ api.post('/voice/transcribe',
93
+ express.raw({ type: () => true, limit: '25mb' }),
94
+ async (req, res) => {
95
+ try {
96
+ const out = await transcribe(cfg, req.body, { language: String(req.query.language || 'it') });
97
+ res.json({ text: out.text || '' });
98
+ } catch (e) { res.status(e.status || 502).json({ error: e.message }); }
99
+ });
100
+ app.use('/api', api);
101
+
35
102
  app.use(express.static(distDir));
36
103
  app.get('*', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
37
104
 
38
105
  const server = http.createServer(app);
106
+ // Close the watcher when the HTTP server closes. Registered HERE (inside
107
+ // createServer) — not in start() — so every createServer consumer is covered,
108
+ // not only the start() path. watcher.close() is idempotent.
109
+ server.on('close', () => { watcher.close(); previews.close(); });
39
110
  const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 << 20 });
40
111
  wss.on('connection', (ws) => {
41
112
  bindWs(ws, {
@@ -45,10 +116,20 @@ function createServer(opts = {}) {
45
116
  runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
46
117
  countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
47
118
  defaults: { readonlyDefault: cfg.readonlyDefault, tmuxBin: cfg.tmuxBin },
119
+ onAttach: (sess) => attachedWs.set(ws, sess),
48
120
  });
121
+ ws.on('close', () => attachedWs.delete(ws));
122
+ });
123
+
124
+ watcher.on('change', (session, files) => {
125
+ for (const [client, sess] of attachedWs) {
126
+ if (sess === session && client.readyState === 1) {
127
+ try { client.send(JSON.stringify({ type: 'files', session, files })); } catch (_) {}
128
+ }
129
+ }
49
130
  });
50
131
 
51
- return { app, server, wss, cfg, token };
132
+ return { app, server, wss, cfg, token, watcher, fleetP };
52
133
  }
53
134
 
54
135
  function start(opts = {}) {
@@ -16,12 +16,74 @@ function actionArgs(name) {
16
16
  return Object.prototype.hasOwnProperty.call(ACTIONS, name) ? ACTIONS[name].slice() : null;
17
17
  }
18
18
 
19
- // Fire-and-forget: esegue l'azione sulla sessione (match esatto `=name`). false se non allowlistata.
19
+ const SCROLL_LINES = 3;
20
+
21
+ // Scroll della cronologia via copy-mode: 'up' entra in copy-mode (idempotente
22
+ // se già dentro) e scorre; con -e il ritorno in fondo ESCE da copy-mode da
23
+ // solo, quindi il gesto "trascina giù fino in fondo" riporta al vivo.
24
+ function scrollArgs(session, dir) {
25
+ if (typeof session !== 'string') return null;
26
+ if (dir !== 'up' && dir !== 'down') return null;
27
+ const target = `=${session}:`;
28
+ return [
29
+ ['copy-mode', '-e', '-t', target],
30
+ ['send-keys', '-t', target, '-X', '-N', String(SCROLL_LINES), dir === 'up' ? 'scroll-up' : 'scroll-down'],
31
+ ];
32
+ }
33
+
34
+ function runScroll(tmuxBin, session, dir) {
35
+ const steps = scrollArgs(session, dir);
36
+ if (!steps) return false;
37
+ try {
38
+ // send-keys -X vale solo in copy-mode: va in sequenza, non in parallelo.
39
+ execFile(tmuxBin, steps[0], () => { execFile(tmuxBin, steps[1], () => {}); });
40
+ return true;
41
+ } catch (_) { return false; }
42
+ }
43
+
44
+ // Fire-and-forget: esegue l'azione sulla sessione. Target `=name:` (exact-match
45
+ // + colon): su tmux 3.4 il bare `=name` fallisce per i comandi pane/window-target.
20
46
  function runAction(tmuxBin, session, name) {
47
+ if (name === 'scroll-up') return runScroll(tmuxBin, session, 'up');
48
+ if (name === 'scroll-down') return runScroll(tmuxBin, session, 'down');
21
49
  const base = actionArgs(name);
22
50
  if (!base) return false;
23
- try { execFile(tmuxBin, [...base, '-t', `=${session}`], () => {}); return true; }
51
+ try { execFile(tmuxBin, [...base, '-t', `=${session}:`], () => {}); return true; }
24
52
  catch (_) { return false; }
25
53
  }
26
54
 
27
- module.exports = { actionArgs, runAction, ACTIONS };
55
+ const MAX_PASTE = 4096;
56
+
57
+ // true se text contiene control char (codici 0x00-0x1f e 0x7f). Espresso via
58
+ // charCode invece di regex perche' il write-layer corrompe gli escape backslash
59
+ // nel sorgente (v. NOTE in lib/files/store.js).
60
+ function hasControlChar(text) {
61
+ for (let i = 0; i < text.length; i += 1) {
62
+ const c = text.charCodeAt(i);
63
+ if (c <= 0x1f || c === 0x7f) return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ // Digita testo literal nella sessione, SENZA Invio: il '--' protegge testi
69
+ // che iniziano con '-'; i control char (inclusi i ritorni a capo CR e LF)
70
+ // sono rifiutati a monte così un paste non può mai submitare un prompt.
71
+ function pasteArgs(session, text) {
72
+ if (typeof session !== 'string' || typeof text !== 'string') return null;
73
+ if (!text || text.length > MAX_PASTE) return null;
74
+ if (hasControlChar(text)) return null;
75
+ return ['send-keys', '-t', `=${session}:`, '-l', '--', text];
76
+ }
77
+
78
+ // Risolve col VERO esito di tmux (l'exit code), non col solo lancio del
79
+ // processo: un target inesistente deve produrre pasted:false, non un falso ok.
80
+ function pasteToSession(tmuxBin, session, text) {
81
+ const args = pasteArgs(session, text);
82
+ if (!args) return Promise.resolve(false);
83
+ return new Promise((resolve) => {
84
+ try { execFile(tmuxBin, args, (err) => resolve(!err)); }
85
+ catch (_) { resolve(false); }
86
+ });
87
+ }
88
+
89
+ module.exports = { actionArgs, runAction, ACTIONS, pasteArgs, pasteToSession, scrollArgs };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { execFile } = require('node:child_process');
5
+
6
+ // Preset allowlistati (audit F1): il client sceglie un NOME, mai un comando.
7
+ // Estendibili da config.json `sessionPresets` (name -> array argv di stringhe).
8
+ const PRESETS = { shell: null, claude: ['claude'], 'codex-vl': ['codex-vl'], pi: ['pi'] };
9
+
10
+ const NAME_RE = /^[\w.-]{1,64}$/;
11
+ function validSessionName(name) {
12
+ return typeof name === 'string' && NAME_RE.test(name) && !name.startsWith('-');
13
+ }
14
+
15
+ // cwd reale sotto la home reale (audit F1): realpath su ENTRAMBI, così un
16
+ // symlink dentro home che punta fuori viene rifiutato.
17
+ function resolveCwd(cwd, home) {
18
+ try {
19
+ const real = fs.realpathSync(cwd);
20
+ const realHome = fs.realpathSync(home);
21
+ if (!fs.statSync(real).isDirectory()) return null;
22
+ if (real !== realHome && !real.startsWith(realHome + path.sep)) return null;
23
+ return real;
24
+ } catch (_) { return null; }
25
+ }
26
+
27
+ // Denylist kill INDIPENDENTE dal registry (audit F2): qualunque cloud-* è
28
+ // protetta anche con fleet assente/rotto; in più le tmuxSession del registry.
29
+ function isProtectedSession(name, isCellSession) {
30
+ if (/^cloud-/i.test(String(name))) return true;
31
+ try { return !!isCellSession(name); } catch (_) { return false; }
32
+ }
33
+
34
+ function presetArgv(preset, extra) {
35
+ const table = { ...PRESETS };
36
+ for (const [k, v] of Object.entries(extra || {})) {
37
+ if (NAME_RE.test(k) && Array.isArray(v) && v.every((s) => typeof s === 'string')) table[k] = v;
38
+ }
39
+ if (!Object.prototype.hasOwnProperty.call(table, preset)) return undefined;
40
+ return table[preset];
41
+ }
42
+
43
+ // Pure: argomenti tmux per la create, o null se input invalido.
44
+ function buildCreateArgs(name, realCwd, preset, extraPresets) {
45
+ if (!validSessionName(name) || typeof realCwd !== 'string' || !realCwd) return null;
46
+ const argv = presetArgv(String(preset || 'shell'), extraPresets);
47
+ if (argv === undefined) return null;
48
+ const base = ['new-session', '-d', '-s', name, '-c', realCwd];
49
+ return argv ? [...base, ...argv] : base;
50
+ }
51
+
52
+ function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
53
+
54
+ function createSession(tmuxBin, { name, cwd, preset }, { home, presets } = {}) {
55
+ return new Promise((resolve, reject) => {
56
+ if (!validSessionName(name)) return reject(httpError(400, 'nome sessione non valido'));
57
+ // Il namespace cloud-* e' delle celle fleet (audit finale #1): una generica
58
+ // con quel prefisso occuperebbe il binding per-nome e sarebbe poi 409 al kill.
59
+ if (/^cloud-/i.test(name)) return reject(httpError(409, 'namespace riservato alle celle (cloud-*): usa fleet up'));
60
+ const real = resolveCwd(String(cwd || home), home);
61
+ if (!real) return reject(httpError(400, 'cwd non valida (deve esistere sotto la home)'));
62
+ const args = buildCreateArgs(name, real, preset, presets);
63
+ if (!args) return reject(httpError(400, 'preset non in allowlist'));
64
+ execFile(tmuxBin, args, (err, _o, stderr) => {
65
+ if (err) {
66
+ if (/duplicate session/i.test(stderr || '')) return reject(httpError(409, 'sessione già esistente'));
67
+ return reject(httpError(500, `tmux new-session failed: ${String(stderr || err.message).trim()}`));
68
+ }
69
+ resolve();
70
+ });
71
+ });
72
+ }
73
+
74
+ function killSession(tmuxBin, name) {
75
+ return new Promise((resolve, reject) => {
76
+ execFile(tmuxBin, ['kill-session', '-t', `=${name}`], (err, _o, stderr) => {
77
+ if (err) {
78
+ if (/can't find session|no server running/i.test(stderr || '')) return resolve(false);
79
+ return reject(httpError(500, `tmux kill-session failed: ${String(stderr || err.message).trim()}`));
80
+ }
81
+ resolve(true);
82
+ });
83
+ });
84
+ }
85
+
86
+ module.exports = { PRESETS, validSessionName, resolveCwd, isProtectedSession, buildCreateArgs, createSession, killSession };
package/lib/tmux/list.js CHANGED
@@ -1,19 +1,21 @@
1
1
  'use strict';
2
2
  const { execFile, execFileSync } = require('node:child_process');
3
3
 
4
- const FMT = "#{session_name}\t#{session_attached}\t#{session_windows}\t#{session_created}";
4
+ const FMT = "#{session_name}\t#{session_attached}\t#{session_windows}\t#{session_created}\t#{session_activity}\t#{pane_current_command}";
5
5
 
6
6
  function parseSessions(raw) {
7
7
  return String(raw)
8
8
  .split('\n')
9
9
  .filter((l) => l.trim() !== '')
10
10
  .map((line) => {
11
- const [name, attached, windows, created] = line.split('\t');
11
+ const [name, attached, windows, created, activity, cmd] = line.split('\t');
12
12
  return {
13
13
  name,
14
14
  attached: attached === '1',
15
15
  windows: Number(windows),
16
16
  created: Number(created),
17
+ activity: Number(activity) || 0,
18
+ cmd: cmd || '',
17
19
  };
18
20
  });
19
21
  }
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ const { execFile } = require('node:child_process');
3
+
4
+ const MAX_LEN = 240;
5
+
6
+ // Strip ANSI (CSI/OSC) e control char via charCode — niente escape regex nel
7
+ // sorgente (v. NOTE in lib/files/store.js sul write-layer).
8
+ function sanitizePreview(raw) {
9
+ if (typeof raw !== 'string') return '';
10
+ let out = '';
11
+ let i = 0;
12
+ while (i < raw.length && out.length < MAX_LEN + 1) {
13
+ const c = raw.charCodeAt(i);
14
+ if (c === 0x1b) { // ESC: salta la sequenza
15
+ const n = raw.charCodeAt(i + 1);
16
+ if (n === 0x5b) { // CSI: fino a byte finale 0x40-0x7e
17
+ i += 2; while (i < raw.length && (raw.charCodeAt(i) < 0x40 || raw.charCodeAt(i) > 0x7e)) i += 1;
18
+ i += 1; continue;
19
+ }
20
+ if (n === 0x5d) { // OSC: fino a BEL o ESC\
21
+ i += 2; while (i < raw.length && raw.charCodeAt(i) !== 0x07 && raw.charCodeAt(i) !== 0x1b) i += 1;
22
+ i += (raw.charCodeAt(i) === 0x1b) ? 2 : 1; continue;
23
+ }
24
+ i += 2; continue; // altre ESC-seq corte
25
+ }
26
+ if (c <= 0x1f || c === 0x7f) { i += 1; continue; } // control char
27
+ out += raw[i]; i += 1;
28
+ }
29
+ return out.trim().slice(0, MAX_LEN);
30
+ }
31
+
32
+ // Sampler con cache per sessione, concorrenza limitata e timeout: la preview è
33
+ // best-effort — errori → null, MAI nel log (audit F7).
34
+ function createPreviewSampler(tmuxBin, { ttlMs = 3000, timeoutMs = 1500, maxConcurrent = 4 } = {}) {
35
+ const cache = new Map(); // session -> {at, value}
36
+ let inFlight = 0;
37
+ const waiters = [];
38
+
39
+ const acquire = () => new Promise((res) => {
40
+ if (inFlight < maxConcurrent) { inFlight += 1; res(); } else waiters.push(res);
41
+ });
42
+ const release = () => { inFlight -= 1; const w = waiters.shift(); if (w) { inFlight += 1; w(); } };
43
+
44
+ function capture(session) {
45
+ return new Promise((resolve) => {
46
+ execFile(tmuxBin, ['capture-pane', '-p', '-t', `=${session}:`], { timeout: timeoutMs, killSignal: 'SIGKILL' },
47
+ (err, stdout) => {
48
+ if (err) return resolve(null);
49
+ const lines = String(stdout).split('\n');
50
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
51
+ const s = sanitizePreview(lines[i]);
52
+ if (s) return resolve(s);
53
+ }
54
+ resolve('');
55
+ });
56
+ });
57
+ }
58
+
59
+ async function get(session) {
60
+ const hit = cache.get(session);
61
+ if (hit && Date.now() - hit.at < ttlMs) return hit.value;
62
+ await acquire();
63
+ try {
64
+ const value = await capture(session);
65
+ cache.set(session, { at: Date.now(), value });
66
+ return value;
67
+ } finally { release(); }
68
+ }
69
+
70
+ return { get, close: () => cache.clear() };
71
+ }
72
+
73
+ module.exports = { sanitizePreview, createPreviewSampler };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+ // Voice STT proxy (graceful). voiceUrl null => non configurato: 503 pulito, non 502.
3
+ // Web Speech nel browser resta indipendente da questo proxy (split model M5).
4
+ const fs = require('node:fs');
5
+
6
+ function loadVoiceToken(cfg) {
7
+ if (cfg.voiceToken) return cfg.voiceToken;
8
+ if (!cfg.voiceTokenFile) return null; // null = non configurato (no readFileSync)
9
+ try {
10
+ const t = fs.readFileSync(cfg.voiceTokenFile, 'utf8').trim();
11
+ return t || null;
12
+ } catch (_) { return null; }
13
+ }
14
+
15
+ function httpError(status, message) {
16
+ return Object.assign(new Error(message), { status });
17
+ }
18
+
19
+ // Config-only: true se voiceUrl valorizzato (no reachability check nel primo giro).
20
+ function serverSttConfigured(cfg) {
21
+ return !!cfg.voiceUrl;
22
+ }
23
+
24
+ async function transcribe(cfg, audioBuffer, { language = 'it', fetchImpl = fetch } = {}) {
25
+ if (!audioBuffer || audioBuffer.length === 0) throw httpError(400, 'audio mancante');
26
+ if (!cfg.voiceUrl) throw httpError(503, 'server STT not configured'); // graceful, non 502
27
+ const token = loadVoiceToken(cfg);
28
+ if (!token) throw httpError(502, 'STT non disponibile (token voice mancante)');
29
+ let r;
30
+ try {
31
+ r = await fetchImpl(`${cfg.voiceUrl}/v1/audio/transcriptions`, {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
34
+ body: JSON.stringify({ file: audioBuffer.toString('base64'), language }),
35
+ });
36
+ } catch (_) { throw httpError(502, 'STT non disponibile (mcp-voice giù)'); }
37
+ if (!r.ok) throw httpError(502, `STT errore upstream (${r.status})`);
38
+ return r.json();
39
+ }
40
+
41
+ module.exports = { loadVoiceToken, transcribe, serverSttConfigured };
package/lib/ws/bridge.js CHANGED
@@ -11,7 +11,7 @@ function clamp(n, lo, hi, def) {
11
11
  }
12
12
 
13
13
  function bindWs(ws, deps) {
14
- const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {} } = deps;
14
+ const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {} } = deps;
15
15
  let pty = null;
16
16
  let attached = false;
17
17
  let session = null;
@@ -31,6 +31,7 @@ function bindWs(ws, deps) {
31
31
  if (!isValidSession(msg.session)) return fail(4404, 'no such session');
32
32
  attached = true;
33
33
  session = msg.session;
34
+ onAttach(session, ws);
34
35
  // Resize default: when nobody else is attached, drive the session size so a
35
36
  // small phone gets a usable (non-clipped) view and clean line editing. When a
36
37
  // real terminal is already attached, default to ignore-size so we don't shrink
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.4.2",
3
+ "version": "0.7.0",
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": {
@@ -15,19 +15,20 @@
15
15
  "README.md"
16
16
  ],
17
17
  "scripts": {
18
- "start": "node bin/nexuscrew.js",
19
- "dev": "node bin/nexuscrew.js",
18
+ "start": "node bin/nexuscrew.js serve",
19
+ "dev": "node bin/nexuscrew.js serve",
20
20
  "build": "cd frontend && npm install && npm run build && cd ..",
21
21
  "test": "node --test tests/*.test.js"
22
22
  },
23
23
  "dependencies": {
24
+ "@mmmbuto/pty-termux-utils": "^1.1.4",
24
25
  "express": "^4.21.0",
25
- "ws": "^8.18.0",
26
- "@mmmbuto/pty-termux-utils": "^1.1.4"
26
+ "multer": "^2.2.0",
27
+ "ws": "^8.18.0"
27
28
  },
28
29
  "optionalDependencies": {
29
- "node-pty": "^1.1.0",
30
- "@lydell/node-pty-linux-x64": "^1.2.0-beta.12"
30
+ "@lydell/node-pty-linux-x64": "^1.2.0-beta.12",
31
+ "node-pty": "^1.1.0"
31
32
  },
32
33
  "engines": {
33
34
  "node": ">=18"
@@ -56,5 +57,8 @@
56
57
  "url": "https://github.com/DioNanos/nexuscrew"
57
58
  },
58
59
  "author": "DioNanos",
59
- "license": "MIT"
60
+ "license": "MIT",
61
+ "allowScripts": {
62
+ "@mmmbuto/node-pty-android-arm64@1.1.0": true
63
+ }
60
64
  }