@flame0510/project-aether 1.7.0 → 1.9.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 (49) hide show
  1. package/README.md +3 -2
  2. package/app/agents/PageClient.tsx +3 -0
  3. package/app/agents/create/loading.tsx +51 -0
  4. package/app/agents/loading.tsx +2 -11
  5. package/app/api/assistant/route.ts +9 -2
  6. package/app/api/metrics/route.ts +126 -23
  7. package/app/api/system-health/route.ts +24 -20
  8. package/app/components/Sidebar.tsx +10 -0
  9. package/app/components/Skeleton.tsx +49 -1
  10. package/app/components/SystemCockpit.tsx +82 -2
  11. package/app/components/ui/Meter.tsx +34 -0
  12. package/app/components/ui/TimeSeriesChart.tsx +226 -0
  13. package/app/components/ui/index.ts +2 -0
  14. package/app/config/loading.tsx +4 -9
  15. package/app/containers/ContainersClient.tsx +4 -2
  16. package/app/containers/loading.tsx +2 -18
  17. package/app/containers/terminal/[id]/TerminalClient.tsx +230 -276
  18. package/app/containers/terminal/[id]/loading.tsx +20 -0
  19. package/app/crons/loading.tsx +4 -9
  20. package/app/gateway/loading.tsx +3 -7
  21. package/app/globals.css +51 -0
  22. package/app/lineage/loading.tsx +4 -9
  23. package/app/memory/loading.tsx +4 -9
  24. package/app/plugins/loading.tsx +4 -9
  25. package/app/skills/loading.tsx +4 -9
  26. package/app/system/PageClient.tsx +263 -0
  27. package/app/system/SystemSkeleton.tsx +115 -0
  28. package/app/system/loading.tsx +13 -0
  29. package/app/system/page.tsx +5 -0
  30. package/app/tools/loading.tsx +4 -9
  31. package/bin/rev4a.js +8 -4
  32. package/daemon.js +271 -190
  33. package/docs/ARCHITECTURE.md +49 -46
  34. package/docs/CONTAINER-TERMINAL.md +169 -261
  35. package/docs/DESIGN-SYSTEM.md +1 -0
  36. package/docs/FRONTEND-ARCHITECTURE.md +7 -2
  37. package/docs/REV4A.md +5 -3
  38. package/docs/dev/API-REFERENCE.md +61 -16
  39. package/docs/dev/DATABASE.md +65 -25
  40. package/docs/rag/DATA-FRESHNESS.md +13 -8
  41. package/docs/rag/GLOSSARY.md +7 -4
  42. package/docs/rag/REV4A-OVERVIEW.md +5 -2
  43. package/docs/rag/WHAT-I-CAN-ANSWER.md +1 -0
  44. package/lib/db-bootstrap.mjs +0 -11
  45. package/lib/metrics-db.ts +48 -0
  46. package/package.json +5 -1
  47. package/scripts/backup.sh +3 -3
  48. package/server.js +183 -0
  49. package/terminal-ws-server.js +329 -96
package/server.js ADDED
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Rev4a web server — Next.js plus the container terminal on the same port.
4
+ *
5
+ * `rev4a serve` runs this instead of `next start`. Every HTTP request goes to Next.js
6
+ * unchanged. The one addition is the WebSocket upgrade on `/api/terminal-ws`: the browser
7
+ * opens the terminal on the dashboard's own host and port, and this server forwards it to
8
+ * the terminal server (terminal-ws-server.js, 127.0.0.1 only) — so the terminal works with
9
+ * no reverse proxy in front, locally and on a plain-IP VPS. Before forwarding it checks:
10
+ *
11
+ * 1. the session cookie (`rev4a_token`, the same JWT the dashboard pages require);
12
+ * 2. the Origin header matches the host the browser addressed — `Host`, or
13
+ * `X-Forwarded-Host` behind a proxy that rewrites Host — so a page on another site
14
+ * cannot open a shell with the user's cookie (cross-site WebSocket hijacking; a
15
+ * browser cannot set X-Forwarded-Host on a WebSocket, so trusting it opens nothing);
16
+ * 3. the container name is well formed (the terminal server checks it exists and runs).
17
+ *
18
+ * The forwarded request carries a per-boot secret (REV4A_TERMINAL_SECRET, generated by
19
+ * `rev4a serve` for both processes) and no cookie; the terminal server refuses anything
20
+ * without that secret, so reaching 127.0.0.1:3741 directly gives nothing.
21
+ */
22
+ 'use strict';
23
+
24
+ const http = require('http');
25
+ const net = require('net');
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+ const next = require('next');
30
+
31
+ const PORT = parseInt(process.env.PORT || '3740', 10);
32
+ const TERMINAL_WS_PORT = parseInt(process.env.TERMINAL_WS_PORT || '3741', 10);
33
+ const TERMINAL_SECRET = process.env.REV4A_TERMINAL_SECRET || '';
34
+ const TERMINAL_PATH = '/api/terminal-ws';
35
+ /** Docker container names and ids: what `docker exec` accepts, nothing a shell could read. */
36
+ const CONTAINER_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
37
+
38
+ // Same data directory rule as lib/rev4a-paths.ts, for the JWT secret fallback below.
39
+ const DATA_DIR = process.env.REV4A_DATA_DIR
40
+ || (process.platform === 'win32'
41
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'rev4a')
42
+ : path.join(os.homedir(), '.config', 'rev4a'));
43
+
44
+ /** REV4A_JWT_SECRET from the environment, else from .env — the rule lib/rev4a-auth.js follows. */
45
+ function jwtSecret() {
46
+ if (process.env.REV4A_JWT_SECRET) return process.env.REV4A_JWT_SECRET;
47
+ try {
48
+ const m = /^REV4A_JWT_SECRET=(.*)$/m.exec(fs.readFileSync(path.join(DATA_DIR, '.env'), 'utf8'));
49
+ return m ? m[1].trim() : '';
50
+ } catch {
51
+ return '';
52
+ }
53
+ }
54
+
55
+ function cookieValue(header, name) {
56
+ for (const part of String(header || '').split(';')) {
57
+ const i = part.indexOf('=');
58
+ if (i > 0 && part.slice(0, i).trim() === name) {
59
+ try {
60
+ return decodeURIComponent(part.slice(i + 1).trim());
61
+ } catch {
62
+ return ''; // malformed escape: treated as no cookie (401)
63
+ }
64
+ }
65
+ }
66
+ return '';
67
+ }
68
+
69
+ /** Answer an upgrade with a plain HTTP status and close — the browser sees a failed connection. */
70
+ function refuse(socket, status, reason) {
71
+ if (socket.writable) {
72
+ socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
73
+ }
74
+ socket.destroy();
75
+ }
76
+
77
+ let jwtVerify = null;
78
+
79
+ async function authorised(req) {
80
+ const token = cookieValue(req.headers.cookie, 'rev4a_token');
81
+ const secret = jwtSecret();
82
+ if (!token || !secret) return false;
83
+ try {
84
+ jwtVerify = jwtVerify || (await import('jose')).jwtVerify;
85
+ await jwtVerify(token, new TextEncoder().encode(secret));
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /** host[:port] lower-cased, without the scheme's default port (`x:80` on http is `x`). */
93
+ function normaliseHost(host, protocol) {
94
+ const h = String(host || '').trim().toLowerCase();
95
+ const port = protocol === 'https:' ? ':443' : ':80';
96
+ return h.endsWith(port) ? h.slice(0, -port.length) : h;
97
+ }
98
+
99
+ function sameOrigin(req) {
100
+ const origin = req.headers.origin;
101
+ if (!origin) return false; // browsers always send it on a WebSocket handshake
102
+ let url;
103
+ try {
104
+ url = new URL(origin);
105
+ } catch {
106
+ return false;
107
+ }
108
+ const want = normaliseHost(url.host, url.protocol);
109
+ const forwarded = String(req.headers['x-forwarded-host'] || '').split(',')[0];
110
+ return [req.headers.host, forwarded].some((h) => h && normaliseHost(h, url.protocol) === want);
111
+ }
112
+
113
+ /** Replay the handshake to the terminal server — without the cookie, with the secret — then splice the sockets. */
114
+ function forwardToTerminal(req, socket, head) {
115
+ const upstream = net.connect(TERMINAL_WS_PORT, '127.0.0.1');
116
+ let connected = false;
117
+ // Until the terminal server answers the handshake: a hung server must not hold the browser forever.
118
+ upstream.setTimeout(10000, () => (connected ? close() : refuse(socket, 504, 'Gateway Timeout')));
119
+ upstream.once('data', () => upstream.setTimeout(0));
120
+ upstream.on('connect', () => {
121
+ connected = true;
122
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
123
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
124
+ const name = req.rawHeaders[i];
125
+ const lower = name.toLowerCase();
126
+ if (lower === 'cookie' || lower === 'x-rev4a-terminal-secret') continue;
127
+ lines.push(`${name}: ${req.rawHeaders[i + 1]}`);
128
+ }
129
+ lines.push(`X-Rev4a-Terminal-Secret: ${TERMINAL_SECRET}`);
130
+ upstream.write(lines.join('\r\n') + '\r\n\r\n');
131
+ if (head && head.length) upstream.write(head);
132
+ socket.pipe(upstream).pipe(socket);
133
+ });
134
+ const close = () => { socket.destroy(); upstream.destroy(); };
135
+ // Before the splice an error is answered as HTTP; after it, the stream is WebSocket frames.
136
+ upstream.on('error', () => (connected ? close() : refuse(socket, 502, 'Bad Gateway')));
137
+ upstream.on('close', close);
138
+ socket.on('error', close);
139
+ socket.on('close', close);
140
+ }
141
+
142
+ async function onUpgrade(req, socket, head) {
143
+ socket.on('error', () => socket.destroy());
144
+ let url;
145
+ try {
146
+ url = new URL(req.url || '/', 'http://localhost');
147
+ } catch {
148
+ return refuse(socket, 400, 'Bad Request');
149
+ }
150
+ // Production Next.js takes no other upgrade (hot reload is a `next dev` feature).
151
+ if (url.pathname !== TERMINAL_PATH) return refuse(socket, 404, 'Not Found');
152
+ if (!sameOrigin(req)) return refuse(socket, 403, 'Forbidden');
153
+ if (!(await authorised(req))) return refuse(socket, 401, 'Unauthorized');
154
+ if (!CONTAINER_RE.test(url.searchParams.get('id') || '')) return refuse(socket, 400, 'Bad Request');
155
+ if (!TERMINAL_SECRET) return refuse(socket, 503, 'Service Unavailable');
156
+ forwardToTerminal(req, socket, head);
157
+ }
158
+
159
+ async function main() {
160
+ const app = next({ dev: false, dir: __dirname, port: PORT });
161
+ await app.prepare();
162
+ const handle = app.getRequestHandler();
163
+ const server = http.createServer((req, res) => handle(req, res));
164
+ server.on('upgrade', (req, socket, head) => {
165
+ onUpgrade(req, socket, head).catch(() => socket.destroy());
166
+ });
167
+ server.listen(PORT, () => {
168
+ console.log(`[rev4a] Ready on port ${PORT} (terminal on ${TERMINAL_PATH} → 127.0.0.1:${TERMINAL_WS_PORT})`);
169
+ if (!TERMINAL_SECRET) console.log('[rev4a] REV4A_TERMINAL_SECRET is not set: the terminal is disabled (start with `rev4a serve`).');
170
+ });
171
+ // Open terminal sockets keep close() waiting; give them a moment, then go.
172
+ const stop = () => {
173
+ server.close(() => process.exit(0));
174
+ setTimeout(() => process.exit(0), 3000).unref();
175
+ };
176
+ process.on('SIGTERM', stop);
177
+ process.on('SIGINT', stop);
178
+ }
179
+
180
+ main().catch((err) => {
181
+ console.error('[rev4a] Failed to start:', err);
182
+ process.exit(1);
183
+ });
@@ -1,139 +1,372 @@
1
1
  // Terminal WebSocket Server — standalone
2
- // Uses node-pty for real PTY with docker exec - fully interactive
2
+ // Uses node-pty for real PTY with docker exec - fully interactive.
3
+ //
4
+ // Reached only through server.js (the dashboard's own port), which checks the session
5
+ // cookie and the Origin and forwards the handshake with REV4A_TERMINAL_SECRET. This
6
+ // server listens on 127.0.0.1 and refuses any handshake without that secret.
7
+ //
8
+ // Sessions outlive a dropped connection: the browser names its session (`session=<uuid>`
9
+ // in the URL); when the socket drops the shell keeps running for DETACHED_GRACE_MS, its
10
+ // output queued, and a reconnect with the same id picks it up where it was. A deliberate
11
+ // close (code 1000), the shell exiting, DETACHED_GRACE_MS without a client, or
12
+ // IDLE_TIMEOUT_MS without input or output ends it — and ending it kills the shell and
13
+ // everything it started *inside the container*: killing the host's `docker exec` client
14
+ // alone leaves them running (Docker does not stop an exec when its client goes away).
3
15
  const { WebSocketServer } = require('ws');
4
16
  const http = require('http');
5
17
  const path = require('path');
18
+ const { execFile } = require('child_process');
19
+ const { createHash, randomUUID, timingSafeEqual } = require('crypto');
6
20
 
7
21
  const pty = require(path.resolve(__dirname, 'node_modules/node-pty'));
8
22
 
9
- const PORT = parseInt(process.env.TERMINAL_WS_PORT || '3741', 10);
10
- const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
11
- const IDLE_RESET_TIMEOUT_MS = 60 * 1000;
23
+ // node-pty 1.1 ships its macOS `spawn-helper` without the execute bit, and every spawn
24
+ // then fails with "posix_spawnp failed" (Linux does not use the helper). Restore it once.
25
+ if (process.platform === 'darwin') {
26
+ const fs = require('fs');
27
+ const prebuilds = path.resolve(__dirname, 'node_modules/node-pty/prebuilds');
28
+ try {
29
+ for (const arch of fs.readdirSync(prebuilds)) {
30
+ const helper = path.join(prebuilds, arch, 'spawn-helper');
31
+ if (fs.existsSync(helper) && (fs.statSync(helper).mode & 0o111) === 0) fs.chmodSync(helper, 0o755);
32
+ }
33
+ } catch (err) {
34
+ console.log(`[TerminalWS] Could not make node-pty's spawn-helper executable: ${err.message}`);
35
+ }
36
+ }
12
37
 
13
- const server = http.createServer((_req, res) => {
14
- res.writeHead(426, { 'Content-Type': 'text/plain' });
15
- res.end('Use WebSocket');
16
- });
38
+ const PORT = parseInt(process.env.TERMINAL_WS_PORT || '3741', 10);
39
+ const SECRET = process.env.REV4A_TERMINAL_SECRET || '';
40
+ const CONTAINER_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
41
+ const SESSION_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
+ /** Ping interval: keeps idle links open through NAT and finds dead peers. */
43
+ const HEARTBEAT_MS = 25 * 1000;
44
+ /** How long a shell waits for its browser to come back after the connection drops. */
45
+ const DETACHED_GRACE_MS = 2 * 60 * 1000;
46
+ /** A shell with no input and no output for this long is ended. */
47
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
48
+ /** Output kept for a detached session, replayed on reattach (the newest part, in characters). */
49
+ const DETACHED_BUFFER_CHARS = 64 * 1024;
50
+ /** Live shells at once; a runaway loop of new tabs cannot fill the host with `docker exec`. */
51
+ const MAX_SESSIONS = 20;
52
+ /** Keystrokes queued while the container is checked (before the shell exists). */
53
+ const MAX_EARLY_INPUT_CHARS = 64 * 1024;
54
+ /** Backpressure: the shell is paused while this much output waits for a slow client. */
55
+ const PAUSE_ABOVE_BYTES = 1024 * 1024;
56
+ const RESUME_BELOW_BYTES = 256 * 1024;
57
+ /** Marks the shell's processes inside the container, so they can be found and ended. */
58
+ const MARKER = 'REV4A_TERMINAL_SESSION';
17
59
 
18
- const wss = new WebSocketServer({ server });
60
+ // Close codes the client acts on: it stops retrying and says why.
61
+ const CLOSE_EXITED = 4001; // the shell exited (`exit`, Ctrl-D)
62
+ const CLOSE_ENDED = 4002; // idle too long, or a resume found the shell already gone
63
+ const CLOSE_REPLACED = 4003; // the same session was opened in another tab
64
+ const CLOSE_NOT_FOUND = 4404; // no such container
65
+ const CLOSE_NOT_RUNNING = 4409; // the container is stopped
66
+ const CLOSE_NO_SHELL = 4410; // neither bash nor sh in the container
67
+ const CLOSE_TOO_MANY = 4429; // MAX_SESSIONS shells already open
68
+ const CLOSE_SPAWN_FAILED = 4500; // the PTY could not be started on this host
19
69
 
20
- wss.on('connection', (ws, req) => {
21
- const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
22
- const containerId = url.searchParams.get('id');
70
+ function secretMatches(supplied) {
71
+ if (!SECRET || typeof supplied !== 'string') return false;
72
+ const digest = (v) => createHash('sha256').update(v, 'utf8').digest();
73
+ return timingSafeEqual(digest(supplied), digest(SECRET));
74
+ }
23
75
 
24
- if (!containerId) {
25
- ws.send(`\r\n[Error: Missing container id]\r\n`);
26
- ws.close();
27
- return;
76
+ /** A resize message from the client ({"type":"resize","cols":n,"rows":n}), or null. */
77
+ function parseResize(msg) {
78
+ if (!msg.startsWith('{"type":"resize"')) return null;
79
+ try {
80
+ const { type, cols, rows } = JSON.parse(msg);
81
+ const ok = (n) => Number.isInteger(n) && n >= 2 && n <= 1000;
82
+ return type === 'resize' && ok(cols) && ok(rows) ? { cols, rows } : null;
83
+ } catch {
84
+ return null;
28
85
  }
86
+ }
29
87
 
30
- let term = null;
31
- let idleTimer = setTimeout(() => {
32
- ws.send('\r\n[Session timeout: 30 minutes]\r\n');
33
- try { ws.close(); } catch {}
34
- }, SESSION_TIMEOUT_MS);
35
-
36
- function resetIdleTimer() {
37
- clearTimeout(idleTimer);
38
- idleTimer = setTimeout(() => {
39
- ws.send('\r\n[Session timeout: 30 minutes]\r\n');
40
- try { ws.close(); } catch {}
41
- }, SESSION_TIMEOUT_MS);
42
- }
88
+ /** 'running' | 'stopped' | 'missing' — from `docker inspect`, argv only. */
89
+ function containerState(id) {
90
+ return new Promise((resolve) => {
91
+ execFile('docker', ['inspect', '-f', '{{.State.Running}}', id], { timeout: 10000 }, (err, stdout) => {
92
+ if (err) return resolve('missing');
93
+ resolve(String(stdout).trim() === 'true' ? 'running' : 'stopped');
94
+ });
95
+ });
96
+ }
43
97
 
44
- try {
45
- term = pty.spawn('docker', ['exec', '-it', containerId, 'env', 'TERM=xterm-256color', 'bash'], {
46
- name: 'xterm-256color',
47
- cols: 80,
48
- rows: 30,
98
+ /** bash when the container has it, else sh; null when it has neither. */
99
+ function pickShell(id) {
100
+ return new Promise((resolve) => {
101
+ execFile('docker', ['exec', id, 'sh', '-c', 'command -v bash || command -v sh'], { timeout: 10000 }, (err, stdout) => {
102
+ const found = String(stdout || '').trim().split('\n')[0];
103
+ resolve(err || !found ? null : found);
49
104
  });
50
- } catch (err) {
51
- ws.send(`\r\n[Error: ${err.message}]\r\n`);
52
- ws.close();
53
- clearTimeout(idleTimer);
54
- return;
105
+ });
106
+ }
107
+
108
+ // Ends every process in the container that carries the session's marker — the shell and
109
+ // whatever it started (children inherit the environment): SIGHUP first, as a terminal
110
+ // hangup would, then SIGKILL for what ignored it. The session id is passed as an argument,
111
+ // never interpolated into the script.
112
+ const KILL_SCRIPT = [
113
+ 'm="$1"',
114
+ 'hit() { for p in /proc/[0-9]*; do tr "\\0" "\\n" < "$p/environ" 2>/dev/null | grep -qx "$m" && echo "${p#/proc/}"; done; }',
115
+ 'pids=$(hit); [ -n "$pids" ] || exit 0',
116
+ 'kill -HUP $pids 2>/dev/null; sleep 1',
117
+ 'pids=$(hit); [ -n "$pids" ] && kill -KILL $pids 2>/dev/null; exit 0',
118
+ ].join('\n');
119
+
120
+ function killInContainer(containerId, sessionId) {
121
+ execFile('docker', ['exec', containerId, 'sh', '-c', KILL_SCRIPT, 'rev4a-kill', `${MARKER}=${sessionId}`], { timeout: 15000 }, (err) => {
122
+ if (err) console.log(`[TerminalWS] Could not end session ${sessionId.slice(0, 8)} in ${containerId}: ${err.message.split('\n')[0]}`);
123
+ });
124
+ }
125
+
126
+ /** Live shells by session id. */
127
+ const sessions = new Map();
128
+ /** Shells being started, by session id: a second socket for the same id waits for it. */
129
+ const starting = new Map();
130
+
131
+ function closeSocket(ws, code, reason) {
132
+ if (ws && (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING)) {
133
+ try { ws.close(code, String(reason).slice(0, 120)); } catch {}
55
134
  }
135
+ }
136
+
137
+ function endSession(session, code, reason) {
138
+ if (!sessions.has(session.id)) return;
139
+ sessions.delete(session.id);
140
+ clearTimeout(session.detachTimer);
141
+ clearTimeout(session.flushTimer);
142
+ clearTimeout(session.drainTimer);
143
+ try { session.term.kill(); } catch {}
144
+ killInContainer(session.containerId, session.id);
145
+ closeSocket(session.ws, code, reason);
146
+ }
56
147
 
57
- // PTY output → WebSocket.
58
- // Use a queue + micro-delay to avoid flooding xterm.js with one giant chunk.
59
- let outputBuf = '';
60
- let outputTimer = null;
61
-
62
- function flushOutput() {
63
- outputTimer = null;
64
- if (!outputBuf) return;
65
- if (ws.readyState !== ws.OPEN) { outputBuf = ''; return; }
66
- ws.send(outputBuf);
67
- outputBuf = '';
148
+ /** Keep the newest DETACHED_BUFFER_CHARS, cut at a line start so the replay begins cleanly. */
149
+ function trimQueued(out) {
150
+ if (out.length <= DETACHED_BUFFER_CHARS) return out;
151
+ const tail = out.slice(-DETACHED_BUFFER_CHARS);
152
+ const nl = tail.indexOf('\n');
153
+ return nl >= 0 && nl < 4096 ? tail.slice(nl + 1) : tail;
154
+ }
155
+
156
+ /** Resume a paused shell once the client has taken most of what was sent. */
157
+ function watchDrain(session) {
158
+ clearTimeout(session.drainTimer);
159
+ session.drainTimer = setTimeout(() => {
160
+ const ws = session.ws;
161
+ if (!sessions.has(session.id)) return;
162
+ if (!ws || ws.readyState !== ws.OPEN || ws.bufferedAmount < RESUME_BELOW_BYTES) {
163
+ session.paused = false;
164
+ try { session.term.resume(); } catch {}
165
+ return;
166
+ }
167
+ watchDrain(session);
168
+ }, 50);
169
+ }
170
+
171
+ /** Send the coalesced output (at most every 10 ms: a DOM-friendly rate under a flood). */
172
+ function flush(session) {
173
+ session.flushTimer = null;
174
+ if (!session.out) return;
175
+ const ws = session.ws;
176
+ if (ws && ws.readyState === ws.OPEN) {
177
+ ws.send(session.out);
178
+ session.out = '';
179
+ if (!session.paused && ws.bufferedAmount > PAUSE_ABOVE_BYTES) {
180
+ session.paused = true;
181
+ try { session.term.pause(); } catch {}
182
+ watchDrain(session);
183
+ }
184
+ } else {
185
+ session.out = trimQueued(session.out);
68
186
  }
187
+ }
69
188
 
189
+ function startSession(id, containerId, shell, size) {
190
+ const term = pty.spawn(
191
+ 'docker',
192
+ ['exec', '-it', '-e', `${MARKER}=${id}`, containerId, 'env', 'TERM=xterm-256color', shell],
193
+ { name: 'xterm-256color', cols: size.cols, rows: size.rows },
194
+ );
195
+ const session = {
196
+ id, containerId, term, ws: null, out: '',
197
+ flushTimer: null, detachTimer: null, drainTimer: null, paused: false, lastActivity: Date.now(),
198
+ };
199
+ sessions.set(id, session);
70
200
  term.onData((data) => {
71
- if (!data) return;
72
- outputBuf += data;
73
- // Flush every 10ms max (roughly 30-40 chunks/sec — safe for DOM renderer)
74
- if (!outputTimer) {
75
- outputTimer = setTimeout(flushOutput, 10);
76
- }
77
- // If buffer grows huge, flush immediately in chunks
78
- if (outputBuf.length > 4000) {
79
- // Split into 2k chunks
80
- while (outputBuf.length > 2000) {
81
- const chunk = outputBuf.slice(0, 2000);
82
- outputBuf = outputBuf.slice(2000);
83
- if (ws.readyState === ws.OPEN) ws.send(chunk);
84
- }
201
+ session.lastActivity = Date.now();
202
+ session.out += data;
203
+ if (session.ws && session.ws.readyState === session.ws.OPEN) {
204
+ if (!session.flushTimer) session.flushTimer = setTimeout(() => flush(session), 10);
205
+ } else {
206
+ session.out = trimQueued(session.out);
85
207
  }
86
208
  });
209
+ term.onExit(({ exitCode }) => {
210
+ flush(session);
211
+ endSession(session, CLOSE_EXITED, `The shell exited (code ${exitCode})`);
212
+ });
213
+ return session;
214
+ }
87
215
 
88
- // WebSocket input → PTY
89
- ws.on('message', (data) => {
90
- resetIdleTimer();
91
- if (!term) return;
216
+ /**
217
+ * Bind a socket to a session: input and resizes go to the shell, queued output is sent.
218
+ * `size` is applied only when the client sent one — a reattach must not squeeze the shell
219
+ * to the 80×30 default and back.
220
+ */
221
+ function attach(session, ws, size, earlyInput) {
222
+ if (session.ws && session.ws !== ws) closeSocket(session.ws, CLOSE_REPLACED, 'This session was opened in another tab');
223
+ session.ws = ws;
224
+ clearTimeout(session.detachTimer);
225
+ session.detachTimer = null;
226
+ session.lastActivity = Date.now();
227
+ if (size) {
228
+ try { session.term.resize(size.cols, size.rows); } catch {}
229
+ }
230
+ for (const input of earlyInput) session.term.write(input);
231
+ flush(session);
92
232
 
233
+ ws.on('message', (data) => {
234
+ if (session.ws !== ws) return;
235
+ session.lastActivity = Date.now();
93
236
  const msg = data.toString();
94
-
95
- // Handle resize message from client
96
- if (msg.startsWith('{"type":"resize"}') || msg.startsWith('{"type":"resize"') && msg.includes('"cols"')) {
97
- try {
98
- const parsed = JSON.parse(msg);
99
- if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
100
- term.resize(parsed.cols, parsed.rows);
101
- }
102
- } catch {}
237
+ const resize = parseResize(msg);
238
+ if (resize) {
239
+ try { session.term.resize(resize.cols, resize.rows); } catch {}
103
240
  return;
104
241
  }
105
-
106
- term.write(msg);
242
+ session.term.write(msg);
107
243
  });
108
-
109
- term.onExit(({ exitCode, signal }) => {
110
- clearTimeout(idleTimer);
111
- if (ws.readyState === ws.OPEN) {
112
- ws.send(`\r\n[Process exited with code ${exitCode}]\r\n`);
113
- ws.close();
114
- }
244
+ ws.on('close', (code) => {
245
+ if (session.ws !== ws) return;
246
+ session.ws = null;
247
+ // 1000 is the page closing the terminal on purpose; anything else may come back.
248
+ if (code === 1000) return endSession(session, 1000, 'Closed');
249
+ session.detachTimer = setTimeout(() => endSession(session, CLOSE_ENDED, 'Disconnected for too long'), DETACHED_GRACE_MS);
115
250
  });
251
+ }
116
252
 
117
- ws.on('close', () => {
118
- clearTimeout(idleTimer);
119
- if (term) term.kill('SIGTERM');
120
- });
253
+ const server = http.createServer((_req, res) => {
254
+ res.writeHead(426, { 'Content-Type': 'text/plain' });
255
+ res.end('Use WebSocket');
256
+ });
257
+
258
+ const wss = new WebSocketServer({
259
+ server,
260
+ maxPayload: 1024 * 1024, // keystrokes and pastes, not files
261
+ verifyClient: ({ req }, done) => {
262
+ if (secretMatches(req.headers['x-rev4a-terminal-secret'])) return done(true);
263
+ done(false, 401, 'Unauthorized');
264
+ },
265
+ });
121
266
 
122
- ws.on('error', () => {
123
- clearTimeout(idleTimer);
124
- if (term) term.kill('SIGTERM');
267
+ // Heartbeat: every connection must answer a ping within one interval, or it is dropped
268
+ // (its session then waits DETACHED_GRACE_MS). Idle sessions end here too.
269
+ const heartbeat = setInterval(() => {
270
+ for (const ws of wss.clients) {
271
+ if (ws.isAlive === false) { ws.terminate(); continue; }
272
+ ws.isAlive = false;
273
+ try { ws.ping(); } catch {}
274
+ }
275
+ const now = Date.now();
276
+ for (const session of sessions.values()) {
277
+ if (now - session.lastActivity > IDLE_TIMEOUT_MS) endSession(session, CLOSE_ENDED, 'Ended after 30 minutes without activity');
278
+ }
279
+ }, HEARTBEAT_MS);
280
+
281
+ /** Start the shell for a new session id (container checked first), or null after closing `ws` with the reason. */
282
+ async function createSession(ws, sessionId, containerId, size) {
283
+ if (sessions.size + starting.size >= MAX_SESSIONS) {
284
+ closeSocket(ws, CLOSE_TOO_MANY, `${MAX_SESSIONS} terminals are already open — close one first`);
285
+ return null;
286
+ }
287
+ const state = await containerState(containerId);
288
+ if (state === 'missing') { closeSocket(ws, CLOSE_NOT_FOUND, `No container named ${containerId}`); return null; }
289
+ if (state !== 'running') { closeSocket(ws, CLOSE_NOT_RUNNING, `${containerId} is not running — start it first`); return null; }
290
+ const shell = await pickShell(containerId);
291
+ if (!shell) { closeSocket(ws, CLOSE_NO_SHELL, `${containerId} has no bash or sh`); return null; }
292
+ try {
293
+ return startSession(sessionId, containerId, shell, size());
294
+ } catch (err) {
295
+ closeSocket(ws, CLOSE_SPAWN_FAILED, `Could not start the shell: ${err.message}`);
296
+ return null;
297
+ }
298
+ }
299
+
300
+ wss.on('connection', async (ws, req) => {
301
+ ws.isAlive = true;
302
+ ws.on('pong', () => { ws.isAlive = true; });
303
+ ws.on('error', () => {});
304
+
305
+ // The browser sends its size (and may type) as soon as the socket opens, while the checks
306
+ // below still run: keep the last size and the input, and apply them on attach.
307
+ let attached = false;
308
+ let clientSize = null;
309
+ const earlyInput = [];
310
+ let earlyChars = 0;
311
+ ws.on('message', (data) => {
312
+ if (attached) return;
313
+ const msg = data.toString();
314
+ const resize = parseResize(msg);
315
+ if (resize) clientSize = resize;
316
+ else if (earlyChars + msg.length <= MAX_EARLY_INPUT_CHARS) { earlyInput.push(msg); earlyChars += msg.length; }
125
317
  });
126
318
 
127
- term.write('\n');
128
- ws.send(`\r\n[Connected to container: ${containerId}]\r\n`);
319
+ const url = new URL(req.url || '/', 'http://localhost');
320
+ const containerId = url.searchParams.get('id') || '';
321
+ const requested = url.searchParams.get('session') || '';
322
+ // `resume=1`: the page is reconnecting to a shell it had — if that shell is gone, say so
323
+ // instead of silently handing over a new one.
324
+ const resuming = url.searchParams.get('resume') === '1';
325
+ const sessionId = SESSION_RE.test(requested) ? requested.toLowerCase() : randomUUID();
326
+
327
+ if (!CONTAINER_RE.test(containerId)) return closeSocket(ws, CLOSE_NOT_FOUND, 'Invalid container name');
328
+
329
+ // Another socket is starting this very session (a duplicated tab while connecting): wait for it.
330
+ if (starting.has(sessionId)) await starting.get(sessionId);
331
+
332
+ const existing = sessions.get(sessionId);
333
+ if (existing && existing.containerId === containerId) {
334
+ if (ws.readyState !== ws.OPEN) return;
335
+ attached = true;
336
+ return attach(existing, ws, clientSize, earlyInput.splice(0));
337
+ }
338
+ if (resuming) {
339
+ return closeSocket(ws, CLOSE_ENDED, 'The previous shell has ended (disconnected for more than 2 minutes, or Rev4a restarted)');
340
+ }
341
+
342
+ // A session id already used for another container starts fresh under a new id.
343
+ const id = existing ? randomUUID() : sessionId;
344
+ const creation = createSession(ws, id, containerId, () => clientSize || { cols: 80, rows: 30 });
345
+ starting.set(id, creation);
346
+ const session = await creation.finally(() => starting.delete(id));
347
+ if (!session) return;
348
+ if (ws.readyState !== ws.OPEN) {
349
+ // The browser left while the shell started: keep it for a reconnect, as after a drop.
350
+ session.detachTimer = setTimeout(() => endSession(session, CLOSE_ENDED, 'Disconnected for too long'), DETACHED_GRACE_MS);
351
+ return;
352
+ }
353
+ attached = true;
354
+ attach(session, ws, null, earlyInput.splice(0));
129
355
  });
130
356
 
131
357
  server.listen(PORT, '127.0.0.1', () => {
132
358
  console.log(`[TerminalWS] Server listening on ws://127.0.0.1:${PORT}`);
133
359
  });
134
360
 
361
+ if (!SECRET) console.log('[TerminalWS] REV4A_TERMINAL_SECRET is not set: every connection will be refused (start with `rev4a serve`).');
362
+
135
363
  process.on('SIGTERM', () => {
136
364
  console.log('[TerminalWS] Shutting down...');
365
+ clearInterval(heartbeat);
366
+ for (const session of sessions.values()) endSession(session, 1001, 'Server shutting down');
367
+ for (const ws of wss.clients) ws.terminate();
137
368
  wss.close();
138
- server.close(() => process.exit(0));
369
+ // Give the in-container clean-ups (`docker exec … kill`) a moment before exiting.
370
+ setTimeout(() => server.close(() => process.exit(0)), 1500).unref();
371
+ setTimeout(() => process.exit(0), 3000).unref();
139
372
  });