@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
@@ -72,7 +72,7 @@
72
72
 
73
73
  **Credentials vault:** The `/credentials` page stores third-party service tokens in `credentials.db` and installs them into containers on explicit sync. Provider API keys live separately in `provider-keys.json`, managed from the Gateway UI.
74
74
 
75
- **Container management UI:** The `/containers` page lists all running Docker containers with resource usage and quick links, and opens a web terminal into any of them. Fully functional.
75
+ **Container management UI:** The `/containers` page lists all Docker containers, running or stopped, with status, image, IP and ports, and opens a web terminal into any running one. Fully functional. (Machine CPU, RAM and disk are on `/system`.)
76
76
 
77
77
  **Workspace API:** Lazy-loaded file tree explorer with real-time reads (no caching), supports both host and container workspaces via `docker exec`.
78
78
 
@@ -81,7 +81,7 @@
81
81
  | Feature | Status | Notes |
82
82
  |---|---|---|
83
83
  | Credentials (UI + API) | 🟢 Implemented | SQLite, no encryption yet |
84
- | Container page | 🟢 Functional | Lists all containers, resources, web terminal |
84
+ | Container page | 🟢 Functional | Lists all containers, web terminal (xterm.js, no proxy needed) |
85
85
  | Agent creation wizard | 🟢 Implemented | One-click create with model/template selection |
86
86
  | Provider proxy | 🟢 Implemented | Agents route through Rev4a provider gateway |
87
87
  | Shared volumes | 🟢 Implemented | Skills + repos mounted on all agents |
@@ -575,15 +575,16 @@ The Rev4a daemon (`daemon.js`) is a standalone Node.js process that bridges the
575
575
  1. Poll `openclaw sessions --json --all-agents` every 30 s
576
576
  2. Upsert session rows into `events.db`
577
577
  3. Emit `spawn` / `complete` / `fail` / `spawn_timeout` events into the `events` table
578
- 4. Collect system metrics (CPU, RAM, disk) after every completed poll
579
- 5. Detect anomalies (CPU > 85%, RAM > 90%) and record them
580
- 6. Manage DB lifecycle (WAL mode, checkpoint after each cycle)
578
+ 4. Sample the host machine (CPU, RAM, swap, storage) every 30 s into `metrics.db`, on a
579
+ timer of its own
580
+ 5. Detect anomalies (CPU > 85%, RAM > 90%, a disk > 90%) and record them as events
581
+ 6. Manage DB lifecycle (WAL mode; events.db checkpointed after each poll cycle)
581
582
 
582
583
  ### Poll Interval
583
584
 
584
585
  A fixed 30 s timer, whether or not sessions are working. When the OpenClaw CLI is not on
585
- the host, the daemon logs it once and stays idle — and then records no system metrics
586
- either, since they are collected at the end of a poll.
586
+ the host, the session poll logs it once and stays idle; the machine metrics keep being
587
+ sampled, since they have their own timer.
587
588
 
588
589
  ### Cost Estimation
589
590
 
@@ -625,29 +626,41 @@ The daemon uses `INSERT … ON CONFLICT DO UPDATE` with these rules:
625
626
 
626
627
  ### System Metrics
627
628
 
628
- After every completed poll the daemon records a `system_metrics` row. Data sources:
629
+ Every 30 s (`METRICS_INTERVAL_MS`) the daemon samples the machine it runs on and writes a
630
+ `system_metrics` row plus one `system_disks` row per filesystem into `metrics.db` — its
631
+ own database next to `events.db` (schema in `docs/dev/DATABASE.md`). Machine-wide sources:
632
+
633
+ - **CPU**: busy share of all cores since the previous sample, from host ticks
634
+ (`os.cpus()`, i.e. `/proc/stat` on Linux) — the whole machine, not Rev4a's cgroup
635
+ - **RAM / swap**: `/proc/meminfo` on Linux, used = `MemTotal āˆ’ MemAvailable` (what `free`
636
+ shows); on macOS (development) active + wired + compressed pages from `vm_stat` and
637
+ `vm.swapusage`; elsewhere `os.totalmem()` / `os.freemem()` and no swap
638
+ - **Storage**: `fs.statfsSync()` on `/`, Docker's data root (`docker info`, asked once and
639
+ retried every 10 min while Docker is down) and Rev4a's data directory, each device once;
640
+ a path that cannot be read (Docker Desktop's root inside its VM) is skipped
641
+ - **Load**: `os.loadavg()` 1 / 5 / 15 min
642
+
643
+ The first sample comes about 5 s after start (the CPU figure needs a delta from the baseline
644
+ taken at load), then one every 30 s. Samples older than 30 days are pruned at every sample.
645
+ `metrics.db` is opened in a try: if it cannot be (corrupt, unwritable), sampling is off —
646
+ `[METRICS] disabled` in the log — and the session poll keeps running. The API side reads
647
+ it through `lib/metrics-db.ts` (read-only, `null` when there is nothing to read);
648
+ `GET /api/metrics` serves the System page (`/system`) and the dashboard's Machine card.
629
649
 
630
- - **CPU**: cgroup v2 usage delta (`/sys/fs/cgroup/cpu.stat`) when available, falls back to host ticks from `os.cpus()`
631
- - **RAM**: `os.totalmem()` / `os.freemem()`
632
- - **Disk**: `fs.statfsSync()` on the directory holding the events database — the
633
- filesystem Rev4a's own data lives on; works the same on Linux and macOS
634
- - **Load**: `os.loadavg()[0]`
650
+ ### Anomaly Detection
635
651
 
636
- Metrics older than 30 days are pruned automatically each cycle.
652
+ After each sample the daemon inserts a `system_anomaly` event into `events.db`
653
+ (`{ metric, values, threshold, message }`) and logs `[ANOMALY] <message>` when:
637
654
 
638
- ### Anomaly Detection
655
+ | Metric | Condition | Repeats |
656
+ |---|---|---|
657
+ | CPU | > 85% on two consecutive samples | at most once per 5 min |
658
+ | RAM | > 90% on two consecutive samples | at most once per 5 min |
659
+ | Disk (each filesystem) | ≄ 90% | once when it reaches it, no cooldown — again only after it drops back under, or after the daemon restarts |
639
660
 
640
- The daemon compares the last two metric samples. When both exceed a threshold and the
641
- cooldown (5 min per metric) has passed, it inserts a `system_anomaly` event
642
- (`{ metric, values, threshold, message }`) and logs `[ANOMALY] CPU high: N%` or
643
- `[ANOMALY] RAM high: N%`. No feature consumes `system_anomaly` events yet; they only pass through the generic
661
+ No feature consumes `system_anomaly` events yet; they only pass through the generic
644
662
  event feeds.
645
663
 
646
- | Metric | Threshold (two consecutive samples) |
647
- |---|---|
648
- | CPU | > 85% |
649
- | RAM | > 90% |
650
-
651
664
  ### DB Safety
652
665
 
653
666
  - WAL mode + `PRAGMA synchronous = NORMAL` for concurrent read safety
@@ -736,39 +749,29 @@ ALTER TABLE sessions ADD COLUMN ended_at INTEGER;
736
749
 
737
750
  ## 11. Container Terminal
738
751
 
739
- Rev4a provides a real, interactive terminal for any agent container via
740
- a WebSocket-connected PTY. The implementation is documented in detail in
752
+ Rev4a provides a real, interactive terminal (xterm.js in the browser, a PTY via
753
+ `node-pty` on the host) for any running container. Details in
741
754
  [docs/CONTAINER-TERMINAL.md](CONTAINER-TERMINAL.md).
742
755
 
743
- ### Two-process architecture
744
-
745
756
  | Process | Port | Role |
746
757
  |---|---|---|
747
- | Next.js (`next start`) | 3740 | Serves the terminal page, auth, API |
748
- | `terminal-ws-server.js` | 3741 (127.0.0.1) | WebSocket PTY server via `node-pty` |
749
-
750
- Both are started by `rev4a serve`; the terminal server is a process of its own to avoid
751
- event-loop contention with Next.js during high-throughput I/O. It has no authentication
752
- of its own and is reachable only through a reverse proxy route (see
753
- [CONTAINER-TERMINAL.md](CONTAINER-TERMINAL.md#routing)).
754
-
755
- ### Why custom DOM over xterm.js
756
-
757
- The initial implementation used xterm.js (both canvas and DOM renderers).
758
- Both suffered from black-screen-on-large-output and broken scrolling due to
759
- CSS conflicts. The current implementation uses a plain `<div>` with native
760
- browser scrolling and a hidden `<textarea>` for input — stable under any
761
- output volume.
758
+ | `server.js` | 3740 | Next.js (pages, API) plus the WebSocket upgrade on `/api/terminal-ws` |
759
+ | `terminal-ws-server.js` | 3741 (127.0.0.1) | The PTYs and their sessions |
762
760
 
763
- See [CONTAINER-TERMINAL.md](CONTAINER-TERMINAL.md#terminal-client-browser)
764
- for the full rationale.
761
+ Both are started by `rev4a serve`. The browser opens the terminal on the dashboard's own
762
+ port, so no reverse proxy is needed. `server.js` checks the session cookie, the Origin and
763
+ the container name, then forwards the handshake with a per-boot secret the terminal server
764
+ requires; the terminal server is a process of its own so heavy shell output never stalls
765
+ the dashboard. A shell survives a dropped connection for 2 minutes and is resumed by the
766
+ same session id.
765
767
 
766
768
  ## 12. Technology Stack
767
769
 
768
770
  | Layer | Technology | Notes |
769
771
  |---|---|---|
770
772
  | Orchestrator | Docker / Docker Compose | Already in use |
771
- | Dashboard | Next.js 16 + React 19 | Current Rev4a |
773
+ | Dashboard | Next.js 16 + React 19 | Current Rev4a, served by `server.js` (Next's programmatic API) |
774
+ | Container terminal | xterm.js 6 (browser) + node-pty + `ws` | `docker exec` PTYs, sessions resumable for 2 minutes — `docs/CONTAINER-TERMINAL.md` |
772
775
  | Provider Gateway | Express/Fastify (Node.js) | To implement in Rev4a |
773
776
  | Database | SQLite (WAL mode) | Current `events.db`, may need to scale |
774
777
  | Memory | Per-agent SQLite + central index | New |
@@ -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
@@ -82,6 +82,7 @@ then use it — a one-off copy of markup is the thing this section exists to pre
82
82
  5. **CSS vars over hardcoded colors.** Never inline `#22c55e`, `#ef4444`, `#888` — use the CSS token.
83
83
  6. **Loading feedback.** Use `loading` prop on `Button` (shows inline spinner). Do not render separate loader elements.
84
84
  7. **Minimal animation.** Transitions on hover/active (0.1s–0.12s), spin on loading spinner. No bounce, shake, or decorative animations.
85
+ 8. **Data display: violet for the data, status colours for levels.** A chart series is `var(--violet)` (2px line, 10% wash), grid and axis text are `var(--border)` / `var(--text-dim)`, never the series colour. A `Meter` fill is violet when fine and `var(--yellow)` / `var(--red)` at its warning / critical level, with the level also written out — status never rests on colour alone. Focus on a chart is a 1px `outline` in `var(--violet-dim)`, not a shadow. See `TimeSeriesChart` and `Meter` in `docs/FRONTEND-ARCHITECTURE.md`.
85
86
 
86
87
  ### React hook
87
88