@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.
- package/README.md +37 -5
- package/bin/nexuscrew.js +5 -1
- package/frontend/dist/assets/index-BWhSqR3J.css +32 -0
- package/frontend/dist/assets/index-DQMx2p4x.js +81 -0
- package/frontend/dist/index.html +3 -3
- package/frontend/index.html +1 -1
- package/lib/auth/middleware.js +17 -0
- package/lib/auth/token.js +43 -5
- package/lib/cli/commands.js +198 -0
- package/lib/cli/init.js +149 -0
- package/lib/cli/pidfile.js +100 -0
- package/lib/cli/platform.js +35 -0
- package/lib/cli/service.js +229 -0
- package/lib/config.js +59 -9
- package/lib/files/routes.js +60 -0
- package/lib/files/store.js +127 -0
- package/lib/files/watcher.js +69 -0
- package/lib/fleet/exec.js +32 -0
- package/lib/fleet/index.js +99 -0
- package/lib/fleet/routes.js +34 -0
- package/lib/server.js +90 -9
- package/lib/tmux/actions.js +65 -3
- package/lib/tmux/lifecycle.js +86 -0
- package/lib/tmux/list.js +4 -2
- package/lib/tmux/preview.js +73 -0
- package/lib/voice/transcribe.js +41 -0
- package/lib/ws/bridge.js +2 -1
- package/package.json +12 -8
- package/frontend/dist/assets/index-BZIjqGMo.css +0 -32
- package/frontend/dist/assets/index-Bl1a-2Lf.js +0 -81
|
@@ -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 =
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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 = {}) {
|
package/lib/tmux/actions.js
CHANGED
|
@@ -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
|
-
|
|
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}
|
|
51
|
+
try { execFile(tmuxBin, [...base, '-t', `=${session}:`], () => {}); return true; }
|
|
24
52
|
catch (_) { return false; }
|
|
25
53
|
}
|
|
26
54
|
|
|
27
|
-
|
|
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.
|
|
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
|
-
"
|
|
26
|
-
"
|
|
26
|
+
"multer": "^2.2.0",
|
|
27
|
+
"ws": "^8.18.0"
|
|
27
28
|
},
|
|
28
29
|
"optionalDependencies": {
|
|
29
|
-
"node-pty": "^1.
|
|
30
|
-
"
|
|
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
|
}
|