@mmmbuto/nexuscrew 0.9.13 → 0.9.15
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/frontend/dist/assets/index-CprIQlC5.css +32 -0
- package/frontend/dist/assets/index-ToJ5arRI.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/config.js +12 -0
- package/lib/live-host/bridge.js +156 -8
- package/lib/live-host/routes.js +10 -2
- package/lib/mcp/server.js +28 -6
- package/lib/mcp/tools.js +24 -6
- package/lib/nodes/health.js +47 -9
- package/lib/server.js +10 -2
- package/lib/ws/bridge.js +190 -24
- package/package.json +1 -1
- package/frontend/dist/assets/index-BBCrZoxR.js +0 -93
- package/frontend/dist/assets/index-keXh4CAm.css +0 -32
package/lib/ws/bridge.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const crypto = require('node:crypto');
|
|
2
3
|
// Bridges ONE WebSocket to ONE PTY attach. Dependencies are injectable for tests.
|
|
3
4
|
// Hardening: close on protocol violation, no 2nd attach, clamp cols/rows,
|
|
4
5
|
// validated session, backpressure cutoff, errors as JSON with a code.
|
|
@@ -11,19 +12,138 @@ function clamp(n, lo, hi, def) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
const ATTACH_TIMEOUT_MS = 15000;
|
|
15
|
+
// Mobile handovers and tunnel renegotiation can last longer than a LAN retry.
|
|
16
|
+
// Thirty seconds is finite but configurable, so a small host can tune the
|
|
17
|
+
// trade-off without making the suspended PTY lifetime unbounded.
|
|
18
|
+
const PTY_GRACE_MS = 30000;
|
|
19
|
+
const PTY_GRACE_MAX_SESSIONS = 8;
|
|
20
|
+
// No PTY output is buffered while detached; this bounds the retained record,
|
|
21
|
+
// rather than pretending a stale output buffer is a current terminal screen.
|
|
22
|
+
const PTY_GRACE_RECORD_BYTES = 1024;
|
|
23
|
+
const PTY_GRACE_MAX_MEMORY_BYTES = PTY_GRACE_MAX_SESSIONS * PTY_GRACE_RECORD_BYTES;
|
|
24
|
+
|
|
25
|
+
function createPtyGraceStore({
|
|
26
|
+
graceMs = PTY_GRACE_MS,
|
|
27
|
+
maxSessions = PTY_GRACE_MAX_SESSIONS,
|
|
28
|
+
maxMemoryBytes = PTY_GRACE_MAX_MEMORY_BYTES,
|
|
29
|
+
recordBytes = PTY_GRACE_RECORD_BYTES,
|
|
30
|
+
randomBytes = crypto.randomBytes,
|
|
31
|
+
} = {}) {
|
|
32
|
+
const suspended = new Map();
|
|
33
|
+
const sessionLimit = Math.max(1, Math.floor(Number(maxSessions) || PTY_GRACE_MAX_SESSIONS));
|
|
34
|
+
const memoryLimit = Math.max(1, Math.floor(Number(maxMemoryBytes) || PTY_GRACE_MAX_MEMORY_BYTES));
|
|
35
|
+
const retainedRecordBytes = Math.max(1, Math.floor(Number(recordBytes) || PTY_GRACE_RECORD_BYTES));
|
|
36
|
+
let memoryBytes = 0;
|
|
37
|
+
|
|
38
|
+
const clearRecordTimer = (record) => {
|
|
39
|
+
if (record.graceTimer) clearTimeout(record.graceTimer);
|
|
40
|
+
record.graceTimer = null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const issueToken = (record) => {
|
|
44
|
+
if (!record.resumeToken) record.resumeToken = randomBytes(32).toString('base64url');
|
|
45
|
+
return record.resumeToken;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const releaseRecord = (token, record) => {
|
|
49
|
+
if (suspended.get(token) !== record) return false;
|
|
50
|
+
suspended.delete(token);
|
|
51
|
+
memoryBytes = Math.max(0, memoryBytes - record.graceBytes);
|
|
52
|
+
return true;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const suspend = (record) => {
|
|
56
|
+
if (!record || record.ended) return null;
|
|
57
|
+
const token = issueToken(record);
|
|
58
|
+
if (!suspended.has(token)
|
|
59
|
+
&& (suspended.size >= sessionLimit || memoryBytes + retainedRecordBytes > memoryLimit)) return null;
|
|
60
|
+
clearRecordTimer(record);
|
|
61
|
+
if (!suspended.has(token)) {
|
|
62
|
+
record.graceBytes = retainedRecordBytes;
|
|
63
|
+
memoryBytes += retainedRecordBytes;
|
|
64
|
+
}
|
|
65
|
+
suspended.set(token, record);
|
|
66
|
+
record.graceTimer = setTimeout(() => {
|
|
67
|
+
if (suspended.get(token) !== record) return;
|
|
68
|
+
releaseRecord(token, record);
|
|
69
|
+
record.graceTimer = null;
|
|
70
|
+
record.graceExpired = true;
|
|
71
|
+
try { record.pty.kill(); } catch (_) {}
|
|
72
|
+
}, graceMs);
|
|
73
|
+
if (typeof record.graceTimer.unref === 'function') record.graceTimer.unref();
|
|
74
|
+
return token;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const resume = (token, session) => {
|
|
78
|
+
if (!token || typeof session !== 'string') return null;
|
|
79
|
+
const record = suspended.get(token);
|
|
80
|
+
if (!record || record.ended || record.session !== session || record.ws) return null;
|
|
81
|
+
releaseRecord(token, record);
|
|
82
|
+
clearRecordTimer(record);
|
|
83
|
+
return record;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const resumeEnded = (token, session) => {
|
|
87
|
+
if (!token || typeof session !== 'string') return null;
|
|
88
|
+
const record = suspended.get(token);
|
|
89
|
+
if (!record || !record.ended || record.session !== session || record.ws) return null;
|
|
90
|
+
releaseRecord(token, record);
|
|
91
|
+
clearRecordTimer(record);
|
|
92
|
+
return record;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const close = () => {
|
|
96
|
+
for (const record of suspended.values()) {
|
|
97
|
+
clearRecordTimer(record);
|
|
98
|
+
try { record.pty.kill(); } catch (_) {}
|
|
99
|
+
}
|
|
100
|
+
suspended.clear();
|
|
101
|
+
memoryBytes = 0;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
issueToken,
|
|
106
|
+
suspend,
|
|
107
|
+
resume,
|
|
108
|
+
resumeEnded,
|
|
109
|
+
close,
|
|
110
|
+
size: () => suspended.size,
|
|
111
|
+
memoryBytes: () => memoryBytes,
|
|
112
|
+
limits: () => ({ maxSessions: sessionLimit, maxMemoryBytes: memoryLimit }),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sendJson(ws, value) {
|
|
117
|
+
try { ws.send(JSON.stringify(value)); } catch (_) {}
|
|
118
|
+
}
|
|
14
119
|
|
|
15
120
|
function bindWs(ws, deps) {
|
|
16
|
-
const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {} } = deps;
|
|
17
|
-
let
|
|
121
|
+
const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {}, ptyGrace = null } = deps;
|
|
122
|
+
let record = null;
|
|
18
123
|
let attached = false;
|
|
19
124
|
let session = null;
|
|
20
125
|
|
|
126
|
+
const detach = () => {
|
|
127
|
+
if (!record || record.ws !== ws) return;
|
|
128
|
+
record.ws = null;
|
|
129
|
+
if (record.ended) return;
|
|
130
|
+
if (!ptyGrace) {
|
|
131
|
+
try { record.pty.kill(); } catch (_) {}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!ptyGrace.suspend(record)) {
|
|
135
|
+
// A bounded host refuses another suspended PTY instead of exceeding its
|
|
136
|
+
// explicit process/memory budget.
|
|
137
|
+
try { record.pty.kill(); } catch (_) {}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
21
141
|
function fail(code, reason) {
|
|
22
142
|
// Si spegne anche la scadenza pre-attach: un frame rifiutato (token o
|
|
23
143
|
// handshake non validi) chiude gia' il socket, e lasciare il timer vivo
|
|
24
144
|
// fino al close event tiene in piedi un handle senza scopo.
|
|
25
145
|
clearAttachTimer();
|
|
26
|
-
|
|
146
|
+
sendJson(ws, { type: 'error', reason });
|
|
27
147
|
try { ws.close(code, reason); } catch (_) {}
|
|
28
148
|
}
|
|
29
149
|
|
|
@@ -51,25 +171,53 @@ function bindWs(ws, deps) {
|
|
|
51
171
|
try { msg = JSON.parse(data.toString()); } catch (_) { return fail(1002, 'bad handshake'); }
|
|
52
172
|
if (msg.type !== 'attach') return fail(1002, 'expected attach');
|
|
53
173
|
if (!verifyToken(msg.token)) return fail(4401, 'bad token');
|
|
54
|
-
|
|
174
|
+
|
|
175
|
+
// Authorization precedes every live-PTY resume. A terminated record is
|
|
176
|
+
// different: its only remaining purpose is to deliver the late exit
|
|
177
|
+
// outcome, so it has an explicit, non-attach path below.
|
|
178
|
+
if (!isValidSession(msg.session)) {
|
|
179
|
+
const ended = ptyGrace && ptyGrace.resumeEnded(msg.reconnectToken, msg.session);
|
|
180
|
+
if (ended) {
|
|
181
|
+
clearAttachTimer();
|
|
182
|
+
sendJson(ws, { type: 'exit', code: ended.exitCode });
|
|
183
|
+
try { ws.close(1000, 'exit'); } catch (_) {}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
return fail(4404, 'no such session');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The capability is bound to the authenticated session and can resume
|
|
190
|
+
// only a still-live PTY. It is never an alternative to isValidSession.
|
|
191
|
+
const resumed = ptyGrace && ptyGrace.resume(msg.reconnectToken, msg.session);
|
|
192
|
+
if (resumed) {
|
|
193
|
+
clearAttachTimer();
|
|
194
|
+
record = resumed;
|
|
195
|
+
record.ws = ws;
|
|
196
|
+
record.readonly = record.readonly || !!msg.readonly;
|
|
197
|
+
attached = true;
|
|
198
|
+
session = record.session;
|
|
199
|
+
onAttach(session, ws);
|
|
200
|
+
sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
55
203
|
attached = true;
|
|
56
204
|
clearAttachTimer();
|
|
57
205
|
session = msg.session;
|
|
58
|
-
onAttach(session, ws);
|
|
59
206
|
// Resize default: when nobody else is attached, drive the session size so a
|
|
60
207
|
// small phone gets a usable (non-clipped) view and clean line editing. When a
|
|
61
208
|
// real terminal is already attached, default to ignore-size so we don't shrink
|
|
62
209
|
// its window. An explicit takeSize from the client always wins.
|
|
63
|
-
const takeSize = msg.takeSize !== undefined
|
|
64
|
-
? !!msg.takeSize
|
|
65
|
-
: countClients(msg.session) === 0;
|
|
66
210
|
// "Segue il focus": garantisce window-size latest sulla sessione (il
|
|
67
211
|
// client usato piu' di recente ne guida la geometria). Fire-and-forget.
|
|
68
212
|
try {
|
|
69
213
|
require('node:child_process').execFile(defaults.tmuxBin || 'tmux',
|
|
70
214
|
['set-option', '-t', `=${msg.session}:`, 'window-size', 'latest'], () => {});
|
|
71
215
|
} catch (_) {}
|
|
72
|
-
|
|
216
|
+
const takeSize = msg.takeSize !== undefined
|
|
217
|
+
? !!msg.takeSize
|
|
218
|
+
: countClients(msg.session) === 0;
|
|
219
|
+
record = {
|
|
220
|
+
pty: openAttach(msg.session, {
|
|
73
221
|
// readonlyDefault del server e' un PAVIMENTO, non un default: se il server
|
|
74
222
|
// e' READONLY nessun client puo' declassarlo (msg.readonly:false non deve
|
|
75
223
|
// vincere). Il client puo' solo AGGIUNGERE restrizione (attach read-only
|
|
@@ -80,34 +228,52 @@ function bindWs(ws, deps) {
|
|
|
80
228
|
cols: clamp(msg.cols, 20, 300, 80),
|
|
81
229
|
rows: clamp(msg.rows, 5, 120, 24),
|
|
82
230
|
tmuxBin: defaults.tmuxBin || 'tmux',
|
|
231
|
+
}),
|
|
232
|
+
ws,
|
|
233
|
+
session: msg.session,
|
|
234
|
+
readonly: defaults.readonlyDefault === true || !!msg.readonly,
|
|
235
|
+
takeSize,
|
|
236
|
+
ended: false,
|
|
237
|
+
exitCode: undefined,
|
|
238
|
+
resumeToken: null,
|
|
239
|
+
graceTimer: null,
|
|
240
|
+
graceExpired: false,
|
|
241
|
+
};
|
|
242
|
+
// The token is issued for this record, not for an arbitrary caller.
|
|
243
|
+
if (ptyGrace) record.resumeToken = ptyGrace.issueToken(record);
|
|
244
|
+
onAttach(session, ws);
|
|
245
|
+
record.pty.onData((d) => {
|
|
246
|
+
if (!record.ws) return;
|
|
247
|
+
try { record.ws.send(Buffer.from(d), { binary: true }); } catch (_) { return; }
|
|
248
|
+
if ((record.ws.bufferedAmount || 0) > MAX_BUFFERED) fail(1011, 'backpressure');
|
|
83
249
|
});
|
|
84
|
-
pty.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
try { ws.
|
|
90
|
-
try { ws.close(1000, 'exit'); } catch (_) {}
|
|
250
|
+
record.pty.onExit((info) => {
|
|
251
|
+
record.ended = true;
|
|
252
|
+
record.exitCode = info && info.exitCode;
|
|
253
|
+
if (!record.ws) return;
|
|
254
|
+
sendJson(record.ws, { type: 'exit', code: record.exitCode });
|
|
255
|
+
try { record.ws.close(1000, 'exit'); } catch (_) {}
|
|
91
256
|
});
|
|
257
|
+
sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
|
|
92
258
|
return;
|
|
93
259
|
}
|
|
94
260
|
// dopo l'attach
|
|
95
|
-
if (isBinary) { pty.write(data); return; }
|
|
261
|
+
if (isBinary) { if (!record.readonly) record.pty.write(data); return; }
|
|
96
262
|
let msg;
|
|
97
263
|
try { msg = JSON.parse(data.toString()); } catch (_) { return; }
|
|
98
264
|
if (msg.type === 'attach') return fail(1002, 'already attached'); // no 2nd attach
|
|
99
|
-
if (msg.type === 'resize') pty.resize(clamp(msg.cols, 20, 300, 80), clamp(msg.rows, 5, 120, 24));
|
|
265
|
+
if (msg.type === 'resize') record.pty.resize(clamp(msg.cols, 20, 300, 80), clamp(msg.rows, 5, 120, 24));
|
|
100
266
|
// focus: il tile che prende il focus diventa size-owner (promote); perdendolo
|
|
101
267
|
// torna ignore-size (demote). Cosi' N deck/tile sulla stessa sessione NON si
|
|
102
268
|
// contendono la geometria: comanda solo chi ha il focus (§5b size policy).
|
|
103
|
-
else if (msg.type === 'focus') { if (msg.on) pty.promote(); else pty.demote(); }
|
|
104
|
-
else if (msg.type === 'input') pty.write(typeof msg.data === 'string' ? msg.data : '');
|
|
105
|
-
else if (msg.type === 'key') pty.write(typeof msg.seq === 'string' ? msg.seq.slice(0, 64) : '');
|
|
269
|
+
else if (msg.type === 'focus') { if (msg.on) record.pty.promote(); else record.pty.demote(); }
|
|
270
|
+
else if (msg.type === 'input' && !record.readonly) record.pty.write(typeof msg.data === 'string' ? msg.data : '');
|
|
271
|
+
else if (msg.type === 'key' && !record.readonly) record.pty.write(typeof msg.seq === 'string' ? msg.seq.slice(0, 64) : '');
|
|
106
272
|
else if (msg.type === 'action') runAction(session, msg.name); // nav window/pane server-side
|
|
107
273
|
}
|
|
108
274
|
|
|
109
275
|
ws.on('message', onMessage);
|
|
110
|
-
ws.on('close', () => { clearAttachTimer();
|
|
111
|
-
ws.on('error', () => { clearAttachTimer();
|
|
276
|
+
ws.on('close', () => { clearAttachTimer(); detach(); });
|
|
277
|
+
ws.on('error', () => { clearAttachTimer(); detach(); });
|
|
112
278
|
}
|
|
113
|
-
module.exports = { bindWs, clamp };
|
|
279
|
+
module.exports = { bindWs, clamp, createPtyGraceStore, PTY_GRACE_MS };
|