@flame0510/project-aether 1.8.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.
@@ -1,289 +1,197 @@
1
1
  # Container Terminal — WebSocket PTY
2
2
 
3
3
  > **Status:** Active — `main` branch
4
- > **Last updated:** 2026-06-30
4
+ > **Last updated:** 2026-09-26
5
5
 
6
6
  ---
7
7
 
8
8
  ## Overview
9
9
 
10
- Rev4a provides an interactive web-based terminal for Docker containers.
11
- It connects to any container with `AGENT_ID` label, spawning a real PTY session
12
- via `node-pty` and streaming I/O over a dedicated WebSocket server.
10
+ `/containers/terminal/<name>` opens an interactive shell in a running Docker container —
11
+ any container on the host (the Containers page links every running one). It is a real
12
+ terminal: raw keystrokes, Tab completion and bash history, Ctrl-keys, cursor movement,
13
+ full-screen programs (`top`, `vi`, `less`), resize, progress bars.
13
14
 
14
15
  ```
15
- Browser (xterm-compatible DOM terminal)
16
- │ WebSocket wss://rev4a/containers/terminal/{id}
16
+ Browser xterm.js (@xterm/xterm)
17
+ │ ws(s)://<dashboard host>/api/terminal-ws?id=<container>&session=<uuid>[&resume=1]
17
18
  ▼
18
- terminal-ws-server.js (127.0.0.1:3741, reached through a reverse proxy — see Routing)
19
- │ node-pty
19
+ server.js (port 3740 — the dashboard's own port)
20
+ │ checks the Origin, the session cookie and the container name,
21
+ │ then forwards the handshake with the per-boot secret (no cookie)
20
22
  ▼
21
- docker exec -it {containerId} env TERM=xterm-256color bash
22
- │
23
+ terminal-ws-server.js (127.0.0.1:3741 only)
24
+ │ node-pty
23
25
  ▼
24
- Container Shell (bash, interactive, full PTY)
25
- ```
26
-
27
- ## Architecture
28
-
29
- ### Two-process model
30
-
31
- | Process | Port | Role |
32
- |------------------|-------|------|
33
- | Next.js (`next start`) | 3740 | Next.js app (pages, API routes, auth) |
34
- | `terminal-ws-server.js` | 3741 | Standalone WebSocket server for terminal sessions, bound to 127.0.0.1 |
35
-
36
- Both are child processes of `rev4a serve` (with `daemon.js`), under the single
37
- `rev4a.service` unit in production. The terminal server is its own process, isolated from
38
- the Next.js app to avoid event-loop contention during high-throughput I/O (fast
39
- `seq`, `cat` on large files, interactive shell sessions).
40
-
41
- ### Routing
42
-
26
+ docker exec -it -e REV4A_TERMINAL_SESSION=<uuid> <container> env TERM=xterm-256color <bash | sh>
43
27
  ```
44
- /containers/terminal/{id} → Next.js (page serving TerminalClient)
45
- /api/terminal-ws?id={containerId} → reverse proxy → ws://127.0.0.1:3741
46
- ```
47
-
48
- The browser opens the socket on the dashboard's own host (`/api/terminal-ws`). Next.js
49
- has no route for that path, so the terminal works only when a reverse proxy in front of
50
- Rev4a forwards it to port 3741; without one the socket gets a 404.
51
-
52
- **No authentication of its own.** `terminal-ws-server.js` checks neither the session
53
- cookie nor the token: whoever reaches it gets a shell in the container named by `id`.
54
- It listens on 127.0.0.1 only, so the exposure is exactly what the proxy route above
55
- opens. An open issue.
56
-
57
- ### Data flow
58
-
59
- 1. User opens `/containers/terminal/openclaw-atlas`
60
- 2. `page.tsx` renders `<TerminalClient containerId="openclaw-atlas" />`
61
- 3. `TerminalClient` opens a WebSocket to `/api/terminal-ws?id=openclaw-atlas`
62
- 4. `terminal-ws-server.js` receives connection, spawns a PTY via node-pty:
63
- ```
64
- docker exec -it openclaw-atlas env TERM=xterm-256color bash
65
- ```
66
- 5. PTY output streams → WebSocket → browser (DOM terminal)
67
- 6. User keystrokes → WebSocket → PTY stdin
68
28
 
69
- ## Terminal Client (Browser)
29
+ It needs **no reverse proxy**: the browser talks to the dashboard's own host and port,
30
+ locally or over a plain-IP VPS. Behind a proxy, see [Reverse proxies](#reverse-proxies).
70
31
 
71
- ### Why not xterm.js?
32
+ ## Processes
72
33
 
73
- The first implementation used **xterm.js** (v5.3.0) with both **canvas** and
74
- **DOM** renderers. Both suffered from:
34
+ `rev4a serve` starts three processes under one systemd unit (`rev4a.service`):
75
35
 
76
- - **Black screen on large output** — the canvas renderer went blank when
77
- hundreds of lines were received in a single burst. The DOM renderer
78
- (xterm.addons.dom) had the same issue under load.
79
- - **Broken scrolling** — xterm.js manages its own scrollback buffer internally,
80
- but the canvas never grew, so native browser scrolling didn't exist. Mouse
81
- wheel scrolling often failed or produced visual artifacts.
82
- - **CSS conflicts** — the parent app sets `html, body { overflow: hidden }`,
83
- which interfered with xterm.js viewport calculation. Multiple workarounds
84
- (`position: fixed`, `minHeight: 0`, `height: 100dvh`) didn't fully resolve
85
- layout instability.
86
- - **Selection issues** — xterm.js intercepts mouse events for its own
87
- selection system, making it hard to copy text natively.
88
-
89
- ### Custom DOM terminal (current)
90
-
91
- The current terminal replaces xterm.js entirely with a plain HTML structure:
92
-
93
- ```
94
- ┌─────────────────────────────────┐
95
- │ Top bar (44px, container info) │
96
- ├─────────────────────────────────┤
97
- │ │
98
- │ Output area (div, overflow:auto│
99
- │ white-space: pre-wrap) │
100
- │ │
101
- │ root@hostname:~# ls -la │
102
- │ total 12 │
103
- │ drwxr-xr-x ... │
104
- │ -rw-r--r-- ... │
105
- │ │
106
- │ root@hostname:~# █ │
107
- ├─────────────────────────────────┤
108
- │ (hidden <textarea> for input) │
109
- └─────────────────────────────────┘
110
- ```
111
-
112
- Key design decisions:
113
-
114
- | Decision | Rationale |
115
- |---|---|
116
- | **`<textarea>` hidden off-screen** | Captures all keystrokes including Tab, Arrows, Ctrl+letter. No focus/IME issues. Single-line only (no Enter for newlines). |
117
- | **`<div>` output area** | Native browser scrolling (`overflow: auto`). Normal text selection (copy/paste with Cmd+C). Render millions of lines without black screen. |
118
- | **`white-space: pre-wrap`** | Preserves ANSI-visible indentation and spacing while wrapping long lines. |
119
- | **Last output line + input inline** | The cursor and text being typed appear on the same line as the last prompt from the server, simulating a real terminal feel. |
120
- | **`requestAnimationFrame` auto-scroll** | After every buffer update, scrolls to bottom. Smooth, native scroll behavior. |
121
-
122
- ### Input handling
123
-
124
- - **Hidden `<textarea>`** captures all keyboard input
125
- - `value` + `onChange` → React state `currentInput`
126
- - On `Enter` → command sent to WebSocket, input cleared
127
- - `Arrows` → local command history (client-side array)
128
- - `Tab` → `\t` sent to server (bash autocomplete)
129
- - `Ctrl+C/L/D/U` → respective control characters sent to server
130
- - **Multi-line paste** → split by `\n`, each line sent as a separate command
131
-
132
- ### Pending command flash fix
133
-
134
- When the user presses Enter, `currentInput` is immediately cleared.
135
- However, the server echoes the command back with a newline + new prompt.
136
- Between the clear and the server echo, there is a visible frame where the
137
- input line appears blank.
138
-
139
- **Fix:** A `pendingCmdRef` ref holds the last submitted command string.
140
- The rendering logic uses:
141
-
142
- ```ts
143
- const showInput = pendingCmdRef.current ? pendingCmdRef.current : currentInput;
144
- ```
36
+ | Process | Listens on | Role |
37
+ |---|---|---|
38
+ | `server.js` | 3740, all interfaces | Next.js (pages, API, `proxy.ts`) through its programmatic API, plus the WebSocket upgrade on `/api/terminal-ws` |
39
+ | `daemon.js` | — | Session poll and machine metrics |
40
+ | `terminal-ws-server.js` | 127.0.0.1:3741 | The PTYs and their sessions |
145
41
 
146
- `pendingCmdRef` is reset as soon as any server output arrives (the echo),
147
- so the command text stays visible without flicker.
42
+ The terminal server is its own process so a flood of shell output never stalls the
43
+ dashboard's event loop. `server.js` replaces `next start`; every HTTP request goes to
44
+ Next.js unchanged. `npm run dev` (`next dev`) has no terminal: use `rev4a serve`.
148
45
 
149
- ### ANSI parsing (color rendering)
46
+ ## Security
150
47
 
151
- The terminal now includes a real ANSI parser (`parseAnsi()`) that converts
152
- SGR (Select Graphic Rendition) escape sequences into styled `<span>`
153
- elements:
48
+ At the upgrade, in `server.js`, in this order:
154
49
 
155
- | Feature | Supported |
50
+ | Check | Refused with |
156
51
  |---|---|
157
- | Foreground colors 30-37 (normal) | ✅ |
158
- | Foreground colors 90-97 (bright) | ✅ |
159
- | Background colors 40-47 | ✅ |
160
- | Background colors 100-107 (bright) | ✅ |
161
- | Bold (1) | ✅ `fontWeight: 700` |
162
- | Dim (2) | ✅ `opacity: 0.6` |
163
- | Italic (3) | ✅ |
164
- | Underline (4) | ✅ |
165
- | Reset (0) | ✅ clears all styles |
166
- | Bracketed paste `[?2004h/l` | 🚫 stripped |
167
- | Non-SGR CSI (cursor, erase) | 🚫 stripped |
168
- | OSC sequences (title, clipboard) | 🚫 stripped |
169
-
170
- **Implementation:**
171
-
172
- ```ts
173
- type AnsiStyle = {
174
- fg?: string; // CSS color
175
- bg?: string; // CSS background-color
176
- bold?: boolean;
177
- dim?: boolean;
178
- italic?: boolean;
179
- underline?: boolean;
180
- };
181
- type Segment = { text: string; style: AnsiStyle };
182
- ```
183
-
184
- The parser walks the raw ANSI string character by character. When it
185
- encounters `\x1b[`, it reads until `m` to get SGR parameters. Each
186
- parameter (e.g. `31` for red foreground) is translated into a color
187
- from the ANSI color map. All non-SGR escape sequences (cursor movement,
188
- erase display, bracketed paste) are silently stripped.
189
-
190
- The raw PTY output is kept in state as-is (with ANSI), split by `\n`,
191
- and each line is passed through `RenderAnsi` which uses `useMemo` to
192
- avoid re-parsing on every render.
193
-
194
- **Color palette** follows the One Dark theme used by the UI:
195
-
196
- | Code | Color | Hex |
52
+ | The path is `/api/terminal-ws` (no other path takes an upgrade) | `404` |
53
+ | `Origin` equals the host the browser addressed: `Host`, or `X-Forwarded-Host` from a proxy; case and the scheme's default port are ignored. Blocks a page on another site from opening a shell with the user's cookie (cross-site WebSocket hijacking); a browser cannot set `X-Forwarded-Host` on a WebSocket, so accepting it opens nothing | `403` |
54
+ | The `rev4a_token` cookie verifies — the JWT the dashboard pages require (`REV4A_JWT_SECRET` from the environment or `.env`); a bearer token is not accepted here | `401` |
55
+ | The container name matches Docker's pattern (`[a-zA-Z0-9][a-zA-Z0-9_.-]*`, ≤128 characters) | `400` |
56
+ | The per-boot secret is set (Rev4a started with `rev4a serve`) | `503` |
57
+ | The terminal server answers (refused → `502`, silent for 10 s → `504`) | `502` / `504` |
58
+
59
+ The handshake is then replayed to 127.0.0.1:3741 **without the cookie** and with
60
+ `X-Rev4a-Terminal-Secret` (any copy the client sent is dropped): a random value
61
+ `rev4a serve` generates at every start and passes to both processes in the environment
62
+ (`REV4A_TERMINAL_SECRET`, never in argv). The terminal server refuses any handshake
63
+ without it (`401`, constant-time comparison), so another local user who reaches the
64
+ loopback port gets nothing. Started without the variable, both sides say so in the log.
65
+
66
+ The terminal server then checks the container exists and runs (`docker inspect`) and
67
+ picks `bash`, or `sh` when the image has none. Every host-side Docker call passes argv —
68
+ no host shell. Inside the container two fixed `sh -c` scripts run (the shell lookup and
69
+ the clean-up below); the session id reaches the clean-up as an argument, never
70
+ interpolated.
71
+
72
+ What an authenticated user can do is the single-admin model: open a shell in any running
73
+ container. Sessions are not bound to the login that opened them — any valid cookie can
74
+ resume a session id it knows — and an open socket is not re-checked when the login
75
+ expires; closing the tab or **Close** ends it. The session id travels in the WebSocket
76
+ URL, so a reverse proxy's access log records it.
77
+
78
+ ## Sessions
79
+
80
+ The browser names its session: a UUID kept in `sessionStorage` per container, sent as
81
+ `&session=`. The shell belongs to that id, not to the socket:
82
+
83
+ - **Drop** (network, laptop sleep): the shell keeps running for **2 minutes**, its output
84
+ queued (the newest 64 K characters, cut at a line start). The page reconnects with
85
+ `&resume=1` and is attached to it, receiving the queued output — a command started
86
+ before the drop finishes and is seen. Past 2 minutes (or after a Rev4a restart) the
87
+ shell is gone and the resume is answered with `4002` and the reason; **Reconnect**
88
+ starts a new one.
89
+ - **Reload** resumes the same shell while it lives (same tab, same `sessionStorage`); a
90
+ reload after it ended simply starts a new one.
91
+ - **Close** (the button) ends it at once: the page sends close code `1000` — or, while it
92
+ is reconnecting, briefly attaches only to send it — the id is forgotten, and the next
93
+ visit starts a new shell.
94
+ - **The same session in two tabs** (a duplicated tab copies `sessionStorage`): the newer
95
+ takes it, the older is closed with `4003`. Two sockets starting the same new id at once
96
+ get one shell: the second waits for the first.
97
+ - **Idle**: no input and no output for 30 minutes ends the shell (`4002`).
98
+ - **Heartbeat**: the server pings every 25 s; a socket that misses a ping is dropped
99
+ (its shell then waits the 2 minutes). Keeps idle links open through NAT and finds dead peers.
100
+ - **Limits**: at most 20 shells at once (`4429`); keystrokes typed before the shell starts
101
+ are kept up to 64 K characters; a message is at most 1 MB.
102
+ - **Backpressure**: when 1 MB of output waits for a slow client the shell is paused, and
103
+ resumed below 256 KB — `yes` or a huge `cat` cannot grow the server's memory.
104
+
105
+ **Ending a session ends the shell inside the container.** Killing the host's `docker exec`
106
+ client alone does not: Docker leaves an exec running when its client goes away, so every
107
+ closed terminal used to leave a shell (and anything running in it) in the container. The
108
+ shell is started with `REV4A_TERMINAL_SESSION=<id>` in its environment, which every
109
+ process it starts inherits; ending the session runs, in the container, a scan of
110
+ `/proc/*/environ` for that marker — `SIGHUP` to what carries it (a terminal hangup), then
111
+ `SIGKILL` a second later to what ignored it. Needs `sh`, `tr` and `grep` in the image
112
+ (BusyBox and Debian/Ubuntu images have them).
113
+
114
+ Sizes: the page sends `{"type":"resize","cols":n,"rows":n}` on connect and whenever the
115
+ terminal's columns or rows change; a size or keystrokes sent while the server still
116
+ checks the container are kept and applied when the shell starts. A resume keeps the
117
+ shell's size until the page sends its own.
118
+
119
+ ### Close codes
120
+
121
+ | Code | Meaning | Client |
197
122
  |---|---|---|
198
- | 30/90 (black) | Dark gray | `#1d1d1d` / `#5c6370` |
199
- | 31/91 (red) | Soft red | `#e06c75` |
200
- | 32/92 (green) | Soft green | `#98c379` |
201
- | 33/93 (yellow) | Amber | `#d19a66` |
202
- | 34/94 (blue) | Soft blue | `#61afef` |
203
- | 35/95 (magenta) | Purple | `#c678dd` |
204
- | 36/96 (cyan) | Teal | `#56b6c2` |
205
- | 37/97 (white) | Light gray / white | `#abb2bf` / `#ffffff` |
206
-
207
- ### Legacy: ANSI stripping (pre-color support)
208
-
209
- The original implementation stripped all ANSI sequences, keeping only
210
- visible text. This was replaced by the parser above on 2026-06-25.
211
-
212
- ## Terminal Server (`terminal-ws-server.js`)
213
-
214
- ### Dependencies
215
-
216
- - **ws** (WebSocket server)
217
- - **node-pty** (pseudo-terminal for child processes)
218
-
219
- ### Session lifecycle
220
-
221
- 1. WebSocket connection established with `?id=openclaw-atlas`
222
- 2. `node-pty` spawns `docker exec -it {containerId} bash` with:
223
- - `name: 'xterm-256color'`
224
- - `cols: 80, rows: 30` (initial, resized on client fit)
225
- 3. PTY `onData` → WebSocket `send`
226
- 4. WebSocket `message` → PTY `write`
227
- 5. Idle timeout: 30 minutes (reset on any client input)
228
- 6. On disconnect or `exit` command → cleanup child process, close socket
229
-
230
- ### Resize handling
231
-
232
- The client sends a JSON message when the terminal dimensions change:
233
-
234
- ```json
235
- { "type": "resize", "cols": 120, "rows": 40 }
236
- ```
237
-
238
- The server calls `term.resize(cols, rows)` to update the PTY dimensions.
239
- This ensures commands like `top`, `less`, `vim` use the correct viewport.
240
-
241
- ### Chunked output
242
-
243
- PTY output is buffered and flushed every 10ms to avoid overwhelming the
244
- browser's DOM renderer with a single giant chunk. If the buffer exceeds
245
- 2000 characters, it is flushed immediately in sub-chunks.
246
-
247
- This was added because `seq 1 10000` or `cat` on a large file would
248
- send 100KB+ in one frame, causing the browser to freeze.
249
-
250
- ### Why node-pty over `spawn('script')` or `unbuffer`?
251
-
252
- Earlier attempts used:
253
-
254
- - `spawn('docker exec -i ...')` — no PTY, commands had no echo, no history,
255
- no tab completion
256
- - `spawn('script', ['-q', '-c', 'docker exec -i ...'])` — created a PTY but
257
- output was fully buffered and never appeared until the process exited
258
- - `spawn('unbuffer', ['-p', 'docker exec -i ...'])` — same buffering issue
259
-
260
- `node-pty` is the only reliable way to create a real PTY and get
261
- line-buffered or character-buffered I/O in real-time.
123
+ | `1000` | Closed on purpose by the page | Stops, shows **Reconnect** |
124
+ | `4001` | The shell exited (`exit`, Ctrl-D) | Stops, shows the reason, **Reconnect** starts a new shell |
125
+ | `4002` | Idle for 30 minutes, or a resume found the shell already gone | Same |
126
+ | `4003` | The session was opened in another tab | Same |
127
+ | `4404` | No such container / invalid name | Same |
128
+ | `4409` | The container is stopped | Same |
129
+ | `4410` | No `bash` or `sh` in the container | Same |
130
+ | `4429` | 20 shells already open | Same |
131
+ | `4500` | The PTY could not be started on this host | Same |
132
+ | other (1006…) | The connection dropped | Reconnects: 1 s, 2 s, 5 s, then every 10 s |
133
+
134
+ Six handshakes in a row that fail before opening (about 40 s: not logged in, Rev4a down
135
+ or restarting, a proxy refusing the upgrade) stop the retries with a message and the
136
+ **Reconnect** button.
137
+
138
+ ## Reverse proxies
139
+
140
+ Rev4a needs none, but works behind one that:
141
+
142
+ - forwards WebSocket upgrades on `/api/terminal-ws` **to port 3740** (the dashboard), like
143
+ every other path. A route sending `/api/terminal-ws` to **3741** — what older versions
144
+ asked for — must be removed: 3741 now answers `401` to anything but `server.js`;
145
+ - keeps the host the browser used, in `Host` or `X-Forwarded-Host`. nginx replaces `Host`
146
+ with the upstream address by default: set `proxy_set_header Host $host;` (or
147
+ `X-Forwarded-Host $host`), plus `proxy_http_version 1.1`, `Upgrade $http_upgrade` and
148
+ `Connection "upgrade"`. Otherwise the Origin check answers `403`.
149
+
150
+ ## Terminal client (browser)
151
+
152
+ `app/containers/terminal/[id]/TerminalClient.tsx`: **xterm.js 6** (`@xterm/xterm`) with
153
+ `@xterm/addon-fit` (sizes the terminal to its box, refitted by a `ResizeObserver`) and
154
+ `@xterm/addon-web-links` (http/https URLs open in a new tab). Keystrokes go to the socket
155
+ as they are typed (`onData`, and `onBinary` for legacy mouse reports); output is written
156
+ as it arrives. 5 000 lines of scrollback, its own scrolling and selection. Copy with the
157
+ system shortcut where it is not Ctrl-C (Cmd-C on macOS); in a terminal Ctrl-C belongs to
158
+ the shell. Loaded in the browser only (dynamic import). The palette comes from the design
159
+ tokens. The session id is a random UUID from `crypto.getRandomValues` —
160
+ `crypto.randomUUID()` exists only in secure contexts, and a dashboard served over plain
161
+ HTTP by IP is not one.
162
+
163
+ The page is full screen: a bar (`ui-kicker`, the container name, a status `Badge` —
164
+ Connecting, Connected, Reconnecting, Closed — and `Button`s **Reconnect** / **Close**),
165
+ a notice line when the session stopped, and the terminal. Styles are the `terminal-page`
166
+ classes in `globals.css`.
167
+
168
+ ### Why xterm.js again
169
+
170
+ The first version (June 2026) used xterm.js 5.3 and was replaced the same day by a
171
+ custom DOM renderer after a black screen on large output, broken scrolling and selection
172
+ problems — layout issues under `html, body { overflow: hidden }` with a viewport that
173
+ never sized. The custom renderer could not be a terminal: it sent whole lines on Enter
174
+ (no Tab completion, no bash history), never sent a size, and stripped `\r` and cursor
175
+ movement (no progress bars, no `top`/`vi`/`less`). xterm.js 6 now lives in a fixed-size
176
+ box sized by `addon-fit`. Before it replaced the custom renderer it was checked in a real
177
+ browser (Playwright, 2026-09-26) against those failures: 20 000 lines of `seq` render to
178
+ the last line, the alternate screen and cursor addressing work (BusyBox `top` renders),
179
+ resize reaches the PTY (`tput cols`).
180
+
181
+ ## Troubleshooting
182
+
183
+ | Symptom | Cause |
184
+ |---|---|
185
+ | "Cannot open the terminal" after about 40 s | Not logged in (session expired); Rev4a down; or a reverse proxy not forwarding the upgrade to 3740 or rewriting `Host` (see [Reverse proxies](#reverse-proxies)) |
186
+ | "No container named …" / "… is not running" | The container is gone or stopped: an agent is started from the Agents page, any other container with `docker start` |
187
+ | "The previous shell has ended" | The connection was down for more than 2 minutes, or Rev4a restarted: **Reconnect** opens a new shell |
188
+ | "Could not start the shell: posix_spawnp failed" (macOS) | node-pty's `spawn-helper` lost its execute bit; `terminal-ws-server.js` restores it at start — restart Rev4a |
262
189
 
263
- ## File Structure
190
+ ## Files
264
191
 
265
192
  ```
266
- app/containers/terminal/
267
- ├── [id]/
268
- │ ├── page.tsx # Next.js page (auth-protected)
269
- │ └── TerminalClient.tsx # Client-side terminal component
270
- terminal-ws-server.js # WebSocket PTY server, started by `rev4a serve`
193
+ server.js # HTTP + terminal upgrade on the dashboard port
194
+ terminal-ws-server.js # PTYs, sessions, clean-up, heartbeat (127.0.0.1)
195
+ app/containers/terminal/[id]/page.tsx # the page (auth-protected by proxy.ts)
196
+ app/containers/terminal/[id]/TerminalClient.tsx
271
197
  ```
272
-
273
- ## Known Limitations
274
-
275
- - **No full-screen TUI support** — `vim`, `top`, `nano`, `htop` will
276
- not render correctly because cursor positioning sequences are stripped.
277
- These tools require a proper xterm-compatible terminal.
278
- - **Single session per connection** — no multiplexing, no tabs.
279
- Each WebSocket connection spawns one PTY.
280
- - **No WebGL renderer** — DOM rendering is CPU-bound for very fast output.
281
- In practice, up to ~500 lines/sec is smooth.
282
-
283
- ## Future Improvements
284
-
285
- - [ ] Detect TUI applications and fall back to xterm.js WebGL renderer
286
- - [ ] Terminal multiplexer (multiple tabs, split panes)
287
- - [ ] Download session log as text file
288
- - [ ] Paste confirmation dialog for multi-line pastes
289
- - [ ] Dark/light theme toggle
@@ -49,6 +49,7 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
49
49
  | `RecreateSection` | inline in `app/agents/PageClient.tsx` | RECREATE section of the agent detail panel. **Recreate Container** starts `POST /api/agents/[id]/recreate` (202) after a confirm; a banner then shows the phase — *Backing up … nn%* while the cold backup runs, *Recreating container…* while the container is rebuilt and the gateway starts. The section polls `GET /recreate` every 2 s, and on mount picks up a recreate that is already running, so a reload or navigation never loses it; it refetches the agent once the job reports `done`. |
50
50
  | System page | `app/system/PageClient.tsx` | `/system`: CPU, memory and storage of the host, from `GET /api/metrics`. Three cards (CPU with cores and load; memory with available and swap; one `Meter` per filesystem with its roles) — thresholds 85/95 % for CPU and memory, 80/90 % for storage — then a range switch (`Tabs`: 1h · 24h · 7d · 30d) scoping the charts below it: CPU and memory average with peak, one chart per filesystem. Polls every 30 s with an `AbortController` ref (a range change aborts the previous fetch). Times in the configured Rev4a timezone (`useRev4aTimezone`). Before the first answer the cards and charts are skeletons shaped like them (`app/system/SystemSkeleton.tsx`, also the route's `loading.tsx`), so nothing jumps; the hostname line is cut with an ellipsis and never widens the page. Says *No samples yet* before the daemon's first sample and *Not collecting* when the latest sample is older than three intervals. Two columns of charts from lg (992px) up, one below. |
51
51
  | Machine card | `app/components/SystemCockpit.tsx` | On the dashboard, next to Active sessions: CPU, RAM and the fullest disk now, each figure coloured only when past its threshold (same thresholds as the System page), the issues named in words, and a link to `/system`. It says *No samples yet* before the first sample, *Not collecting* when the newest is stale, and *Metrics unavailable* when a refresh fails (keeping the last figures). A `Surface`, not a `Metric`: a danger `Metric` paints its whole value red. |
52
+ | Container terminal | `app/containers/terminal/[id]/TerminalClient.tsx` | Full-screen shell into a container: xterm.js 6 (`@xterm/xterm` + `addon-fit` refitted by a `ResizeObserver`, `addon-web-links`), loaded client-side only, palette from the design tokens. Keystrokes are sent raw (`onData`, `onBinary`), the size on connect and whenever the columns or rows change. A bar with `ui-kicker`, the container name, a status `Badge` (Connecting / Connected / Reconnecting / Closed) and `Button`s **Reconnect** / **Close**; styles are the `terminal-page` classes. Session id per container in `sessionStorage` (a UUID from `getRandomValues`: `randomUUID` is missing over plain HTTP), so a reload resumes the shell; after a drop it reconnects with `resume=1` and is told when the shell is gone; retries with backoff (six failed opens, about 40 s, then a message) and stops on the server's final close codes with the reason shown. **Close** while reconnecting still ends the waiting shell. Protocol and sessions: `docs/CONTAINER-TERMINAL.md`. |
52
53
  | `ImageDownloadBanner` | `app/agents/ImageDownloadBanner.tsx` | Agent image banner on the Agents page. Polls `/api/agents/image-status` every 2 s; offers **Download Image** when no supported version is downloaded, **Download <version>** when the registry publishes a newer one, and while a download runs shows the percent of layers finished and the latest line of docker's output (the bar is indeterminate until a percent can be computed). **Cancel** (`DELETE /api/agents/download-image`, own loading state) appears once `image-status` reports the download running; before that the button reads *Starting…* and is disabled. Every outcome — ready and cancelled for 3 s, a failure until the next action — comes from the server's `lastResult`, so one that ended while the page was closed or reloading is still reported if it is less than 30 s old (aged with `serverTime`). Each outcome is announced once per page load, although the Agents page mounts the banner in three places (mobile list, mobile detail, desktop). Downloading changes no agent. |
53
54
  | `VersionBanner` | `app/components/VersionBanner.tsx` | "Update available" banner for Rev4a itself, when `GET /api/update-check?check=1` reports a newer published version (dismissable per version, remembered in `localStorage`). **Update now** starts `POST /api/update-check`; because that update restarts the server, the banner cannot be told the outcome by the response: it records what it asked for in `sessionStorage`, polls `/api/update-check` until the installed version moves (two minutes at most) and reloads, then on the next mount either confirms "Updated to vX" or reports that the update did not complete and points at `update.log` (the update's own output). It never reloads blindly onto the same version. |
54
55
  | `BrowserAccessSection` / `OpenControlUiButton` | `app/agents/BrowserAccessSection.tsx` | Browser access to one agent's Control UI, in its detail panel: requests waiting for approval (Approve / Reject) and approved browsers (Rename / Revoke), refreshed every 5 s while mounted. A successful approve, reject, rename or revoke updates the list at once, since the refresh behind it runs the OpenClaw CLI and takes seconds; a read started before the mutation is discarded. On agents that require approval, "Invite link" fetches `/api/agents/[id]/invite-link` and shows the link in a read-only field with Copy, which uses the Clipboard API in a secure context and the field's selection over plain HTTP, plus a warning when the link uses localhost. `OpenControlUiButton` opens `/api/agents/[id]/open-control-ui` in a new tab inside the click; that route redirects to a one-time link that pairs the browser with no approval, or to the plain token link when none can be issued. Used on the agent cards and in the panel. |
@@ -62,7 +63,7 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
62
63
  | Wizard icons | `app/wizard/icons.tsx` | Shared SVG icons (flyweight pattern): `ArrowRightIcon`, `CheckIcon`, `CheckCircleIcon`, `DockerIcon`, `GatewayIcon`, `AgentIcon`, `TemplateIcon`, `LinkIcon`, `ConfigIcon`, `InformationIcon`. |
63
64
  | `tokens` | `tokens.ts` | TypeScript types for `Tone` and related token values. |
64
65
  | `Badge` | `Badge.tsx` | Inline status tag with tone variants (success, danger, warning, neutral). Used for channel chips, pairing labels, error/success messages. |
65
- | `Skeleton` + shape helpers | `app/components/Skeleton.tsx` | Shimmer placeholders. `Skeleton` is the primitive; the rest mirror one specific layout each: `TreeSkeleton`, `CodeSkeleton`, `CronJobsSkeleton`, `CronRunsSkeleton`, `CredentialCardsSkeleton`, `AgentSyncRowsSkeleton`, `SkeletonLines`, `SkeletonMetric`, `CardRowSkeleton`. A shape helper must match the real markup it stands in for — same row structure, same paddings, same element count where the count is known — so nothing reflows when data replaces it. Inside text (a `<p>`, a `<span>`) pass `as="span"`: a `<div>` there is invalid HTML, and the parser splits the paragraph in the server-rendered page. The System page keeps its own shapes in `app/system/SystemSkeleton.tsx`, each shimmer inside the class of the text it replaces so the line boxes are the real ones — measured to 0 px of movement on desktop and mobile. |
66
+ | `Skeleton` + shape helpers | `app/components/Skeleton.tsx` | Shimmer placeholders. `Skeleton` is the primitive; the rest mirror one specific layout each: `TreeSkeleton`, `CodeSkeleton`, `CronJobsSkeleton`, `CronRunsSkeleton`, `CredentialCardsSkeleton`, `AgentSyncRowsSkeleton`, `SkeletonLines`, `SkeletonMetric`, `CardRowSkeleton`. A route's `loading.tsx` sits in `RouteLoadingShell` (full width up to `maxWidth`, centred): the app shell's content area is a column flexbox, and a `margin: 0 auto` child without `width: 100%` shrinks to its content — every loader did, and its rows came out as wide as its 120 px title (measured: 120 px instead of 1040). `ListPageSkeleton` (a title over rows) is the list pages' loader. Create Agent (`app/agents/create/loading.tsx`) and the container terminal (`app/containers/terminal/[id]/loading.tsx`) have their own, shaped like the page, instead of inheriting the Agents / Containers rows; the Agents page prefetches `/agents/create` (its **+ New** uses `router.push`, which prefetches nothing) and the Containers **Terminal** is a `Link`, so both open without a full reload. A shape helper must match the real markup it stands in for — same row structure, same paddings, same element count where the count is known — so nothing reflows when data replaces it. Inside text (a `<p>`, a `<span>`) pass `as="span"`: a `<div>` there is invalid HTML, and the parser splits the paragraph in the server-rendered page. The System page keeps its own shapes in `app/system/SystemSkeleton.tsx`, each shimmer inside the class of the text it replaces so the line boxes are the real ones — measured to 0 px of movement on desktop and mobile. |
66
67
 
67
68
  ### Rules
68
69
 
package/docs/REV4A.md CHANGED
@@ -19,9 +19,10 @@ A Next.js 16 dashboard for monitoring and managing the OpenClaw ecosystem.
19
19
  ┌──────────┴──────────────────┐
20
20
  │ rev4a serve │
21
21
  │ (systemd: rev4a.service) │
22
- │ Next.js 16 :3740 │
22
+ │ server.js :3740 │
23
+ │ (Next.js 16 + terminal WS) │
23
24
  │ daemon.js │
24
- │ terminal WS :3741 │
25
+ │ terminal-ws :3741 (local) │
25
26
  └──────────┬──────────────────┘
26
27
  │
27
28
  docker exec / Docker API
@@ -2266,12 +2266,28 @@ Return the installed Rev4a version, read from `package.json`.
2266
2266
 
2267
2267
  **Fallback:** `{ "version": "0.0.0" }` if `package.json` cannot be read.
2268
2268
 
2269
- ### Terminal WebSocket
2269
+ ### `GET /api/terminal-ws` (WebSocket)
2270
2270
 
2271
- Not an `/api/` route and not on the dashboard port. `terminal-ws-server.js`
2272
- listens on **`ws://127.0.0.1:3741`** (`TERMINAL_WS_PORT`), bound to the loopback
2273
- interface only, and speaks the PTY protocol described in
2274
- [CONTAINER-TERMINAL.md](../CONTAINER-TERMINAL.md).
2271
+ The container terminal. A WebSocket upgrade on the dashboard's own port, handled by
2272
+ `server.js` (not a Next.js route) and forwarded to `terminal-ws-server.js` on
2273
+ 127.0.0.1:3741 (`TERMINAL_WS_PORT`).
2274
+
2275
+ **Auth:** browser cookie only (`rev4a_token`), and `Origin` must equal the host — no bearer.
2276
+
2277
+ **Query:** `id` — the container name or id; `session` — a UUID naming the shell, so a
2278
+ reconnect with the same value resumes it (up to 2 minutes after a drop); `resume=1` — the
2279
+ page is reconnecting to a shell it had: if that shell is gone the server closes with
2280
+ `4002` instead of starting a new one.
2281
+
2282
+ **Refused at the handshake**, in this order: `404` any other upgrade path, `403` Origin
2283
+ not the addressed host (`Host` or `X-Forwarded-Host`), `401` no or bad cookie, `400` bad
2284
+ container name, `503` Rev4a not started with `rev4a serve` (no terminal secret),
2285
+ `502`/`504` the terminal server refused or did not answer.
2286
+
2287
+ **Messages:** client → server: keystrokes as text, or `{"type":"resize","cols":n,"rows":n}`;
2288
+ server → client: terminal output. Close codes (`4001` shell exited, `4002` idle, or a resume
2289
+ found the shell gone, `4003` opened in another tab, `4404` no container, `4409` stopped,
2290
+ `4410` no shell, `4429` too many shells, `4500` PTY failed) are listed in [CONTAINER-TERMINAL.md](../CONTAINER-TERMINAL.md#close-codes).
2275
2291
 
2276
2292
  ---
2277
2293
 
@@ -22,7 +22,7 @@ A compressed archive (`.tar.gz`) of an agent's persistent volume (`/root/`). Bac
22
22
  Which browsers may open an agent's Control UI. On OpenClaw 9.x every new browser must be approved once; the approval is remembered per browser. Managed in the "BROWSER ACCESS" section of the agent detail panel: approve or reject waiting browsers, rename or revoke approved ones. The Open button uses a one-time link that skips the approval. The Invite link button gives a link for someone else: their browser still waits for approval.
23
23
 
24
24
  ## Container
25
- A Docker container running on the server. Each agent runs in its own container. The Containers page shows all containers, including infrastructure ones such as databases and reverse proxies, with a web terminal link. It shows no CPU or memory metrics.
25
+ A Docker container running on the server. Each agent runs in its own container. The Containers page shows all containers, including infrastructure ones such as databases and reverse proxies, with a web terminal link (a real shell in the browser; a dropped connection is resumed for 2 minutes). It shows no CPU or memory metrics.
26
26
 
27
27
  ## Channel
28
28
  A communication channel (Telegram) configured on an agent. Channels allow users to send DMs to the agent via messaging apps. The Channel Manager modal lets you connect/disconnect Telegram and manage pairings (approve/reject senders).
@@ -85,7 +85,7 @@ The page shows no CPU or memory figures and has no start, stop, or restart butto
85
85
  The machine Rev4a runs on. Three cards — CPU (percent, cores, load average), memory (used, available, swap) and storage (each disk with its used and free space, and whether it is the system disk, where Docker keeps agents, or where Rev4a keeps its data) — then charts of CPU, memory and each disk over 1 hour, 24 hours, 7 days or 30 days (for CPU and memory the average line with the peak shaded). Hover a chart (or focus it and use the arrow keys) to read a point; each chart has a Table view. Bars turn yellow and red when they reach their thresholds. Figures refresh every 30 seconds; history is kept 30 days.
86
86
 
87
87
  ### Container Terminal (`/containers/terminal/[id]`)
88
- Live web terminal into a Docker container. Run commands, inspect files, debug issues — like SSH but in the browser.
88
+ Live web terminal into a running Docker container — like SSH in the browser: Tab completion, command history, `top`, `vi`, resizing with the window. If the connection drops (network, laptop sleep) the shell keeps running for 2 minutes and reconnecting picks it up; reloading the page resumes it too. **Close** ends the shell. If the container is stopped or missing, the page says so and offers **Reconnect**. Only a logged-in user can open it.
89
89
 
90
90
  ### Workspace (`/workspace`)
91
91
  File explorer for the OpenClaw workspace. Browse, view, and edit files across workspaces: the VPS host workspace and every agent container workspace. Switch between workspaces via a dropdown. Supports binary file preview (images) and text editing with syntax awareness.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flame0510/project-aether",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Rev4a — Revolution for Agents. OpenClaw agent fleet orchestrator.",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -37,6 +37,7 @@
37
37
  "daemon.js",
38
38
  "lineage.js",
39
39
  "terminal-ws-server.js",
40
+ "server.js",
40
41
  "package.json",
41
42
  "proxy.ts",
42
43
  "instrumentation.ts",
@@ -63,6 +64,9 @@
63
64
  },
64
65
  "dependencies": {
65
66
  "@types/better-sqlite3": "^7.6.13",
67
+ "@xterm/addon-fit": "^0.11.0",
68
+ "@xterm/addon-web-links": "^0.12.0",
69
+ "@xterm/xterm": "^6.0.0",
66
70
  "better-sqlite3": "^13.0.2",
67
71
  "cron-parser": "^5.5.0",
68
72
  "d3": "^7.9.0",