@flame0510/project-aether 1.8.0 → 1.9.1
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 +1 -1
- package/app/agents/PageClient.tsx +3 -0
- package/app/agents/create/loading.tsx +51 -0
- package/app/agents/loading.tsx +2 -11
- package/app/api/agents/create/route.ts +6 -0
- package/app/api/assistant/route.ts +1 -1
- package/app/components/Skeleton.tsx +45 -0
- package/app/config/loading.tsx +4 -9
- package/app/containers/ContainersClient.tsx +4 -2
- package/app/containers/loading.tsx +2 -18
- package/app/containers/terminal/[id]/TerminalClient.tsx +230 -276
- package/app/containers/terminal/[id]/loading.tsx +20 -0
- package/app/crons/loading.tsx +4 -9
- package/app/gateway/loading.tsx +3 -7
- package/app/globals.css +10 -0
- package/app/lineage/loading.tsx +4 -9
- package/app/memory/loading.tsx +4 -9
- package/app/plugins/loading.tsx +4 -9
- package/app/skills/loading.tsx +4 -9
- package/app/tools/loading.tsx +4 -9
- package/bin/rev4a.js +8 -4
- package/docs/ARCHITECTURE.md +14 -24
- package/docs/CONTAINER-TERMINAL.md +169 -261
- package/docs/FRONTEND-ARCHITECTURE.md +2 -1
- package/docs/REV4A.md +4 -2
- package/docs/dev/API-REFERENCE.md +21 -5
- package/docs/rag/GLOSSARY.md +1 -1
- package/docs/rag/REV4A-OVERVIEW.md +1 -1
- package/lib/agent-recreate.ts +3 -0
- package/package.json +5 -1
- package/server.js +183 -0
- package/terminal-ws-server.js +329 -96
|
@@ -1,289 +1,197 @@
|
|
|
1
1
|
# Container Terminal — WebSocket PTY
|
|
2
2
|
|
|
3
3
|
> **Status:** Active — `main` branch
|
|
4
|
-
> **Last updated:** 2026-
|
|
4
|
+
> **Last updated:** 2026-09-26
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
16
|
-
│
|
|
16
|
+
Browser xterm.js (@xterm/xterm)
|
|
17
|
+
│ ws(s)://<dashboard host>/api/terminal-ws?id=<container>&session=<uuid>[&resume=1]
|
|
17
18
|
▼
|
|
18
|
-
|
|
19
|
-
│
|
|
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
|
-
|
|
22
|
-
│
|
|
23
|
+
terminal-ws-server.js (127.0.0.1:3741 only)
|
|
24
|
+
│ node-pty
|
|
23
25
|
▼
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
+
## Processes
|
|
72
33
|
|
|
73
|
-
|
|
74
|
-
**DOM** renderers. Both suffered from:
|
|
34
|
+
`rev4a serve` starts three processes under one systemd unit (`rev4a.service`):
|
|
75
35
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
46
|
+
## Security
|
|
150
47
|
|
|
151
|
-
|
|
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
|
-
|
|
|
50
|
+
| Check | Refused with |
|
|
156
51
|
|---|---|
|
|
157
|
-
|
|
|
158
|
-
|
|
|
159
|
-
|
|
|
160
|
-
|
|
|
161
|
-
|
|
|
162
|
-
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
|
199
|
-
|
|
|
200
|
-
|
|
|
201
|
-
|
|
|
202
|
-
|
|
|
203
|
-
|
|
|
204
|
-
|
|
|
205
|
-
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
The
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
##
|
|
190
|
+
## Files
|
|
264
191
|
|
|
265
192
|
```
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
│
|
|
22
|
+
│ server.js :3740 │
|
|
23
|
+
│ (Next.js 16 + terminal WS) │
|
|
23
24
|
│ daemon.js │
|
|
24
|
-
│ terminal
|
|
25
|
+
│ terminal-ws :3741 (local) │
|
|
25
26
|
└──────────┬──────────────────┘
|
|
26
27
|
│
|
|
27
28
|
docker exec / Docker API
|
|
@@ -67,6 +68,7 @@ Every agent is created with:
|
|
|
67
68
|
- Control UI at `http://<host>:<port>`, opened from the Agents page through `GET /api/agents/[id]/open-control-ui`
|
|
68
69
|
- The provider gateway at `http://host.docker.internal:3740/api/provider/v1`
|
|
69
70
|
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback: true`, so the Control UI opens on whatever host the agent is reached on, and pages from other origins are refused
|
|
71
|
+
- `--init`: Docker's tini runs as PID 1, with OpenClaw as its child, and collects orphaned processes the moment they exit. Without it `openclaw-gateway` is PID 1 and never collects them: every process a tool leaves behind (Chrome, builds, scripts) stays a zombie and counts against the container's pids limit (9 365 on the VPS) until the container cannot start anything. Measured before the fix: 1 542 zombies in one agent after four days of browsing and builds. tini forwards `docker stop`'s SIGTERM, so OpenClaw still shuts down cleanly. Set at create and in `recreateAgentContainer` (recreate, update, edit); an existing agent gets it the next time it is recreated.
|
|
70
72
|
|
|
71
73
|
The port is auto-assigned starting from 3000 (or user-specified).
|
|
72
74
|
|
|
@@ -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
|
-
###
|
|
2269
|
+
### `GET /api/terminal-ws` (WebSocket)
|
|
2270
2270
|
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
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
|
|
package/docs/rag/GLOSSARY.md
CHANGED
|
@@ -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.
|
|
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/lib/agent-recreate.ts
CHANGED
|
@@ -138,6 +138,9 @@ export async function recreateAgentContainer(
|
|
|
138
138
|
|
|
139
139
|
const runArgs = (img: string): string[] => [
|
|
140
140
|
'run', '-d',
|
|
141
|
+
// tini as PID 1 collects orphaned processes (zombies) — see the create route. An
|
|
142
|
+
// existing agent gets it here, the next time it is recreated, updated or edited.
|
|
143
|
+
'--init',
|
|
141
144
|
'--name', agentId,
|
|
142
145
|
'--network', network,
|
|
143
146
|
'--restart', 'unless-stopped',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flame0510/project-aether",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
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",
|