HonzysClawdbot commited on
Commit
303c344
·
unverified ·
1 Parent(s): eb47743

fix(gateway): resolve Docker connectivity — 404 endpoints, EROFS write, missing OPENCLAW_HOME (#343)

Browse files

* docs: add screenshot drift prevention guide and CI workflow

- Add docs/SCREENSHOT-GUIDE.md with step-by-step instructions for
capturing, optimising, and committing fresh README screenshots
- Add .github/workflows/screenshot-drift.yml that detects UI file
changes in PRs, applies a 'screenshot-drift' label, and posts a
checklist comment reminding contributors to verify screenshots

Addresses: README screenshots may drift as the UI evolves quickly
(setup wizard, health history timeline, and onboarding were added
after the last screenshot refresh on 2026-03-11)

* fix(gateway): resolve Docker connectivity — 404 endpoints, EROFS write, missing OPENCLAW_HOME

Fixes #332, #333

Root causes
-----------
1. nodes/route.ts called non-existent HTTP REST endpoints on the gateway
(/api/presence, /api/devices, /api/rpc). The OpenClaw gateway only
exposes HTTP at /health; all other operations use WebSocket RPC.

2. gateway-runtime.ts attempted fs.writeFileSync on openclaw.json which
fails with EROFS when the container or mount is read-only (docker-compose
uses read_only: true by default).

3. install.sh never wrote the detected OPENCLAW_HOME or Docker host gateway
IP into .env, leaving OPENCLAW_HOME empty after a fresh install.

Fixes
-----
- nodes/route.ts: replace HTTP calls to /api/presence and /api/devices
with gateway /health reachability check + callOpenClawGateway() RPC
(node.list / device.pair.list). When the openclaw CLI is unavailable
inside Docker, the RPC falls back gracefully and returns connected=true
with an empty list rather than a misleading 'Gateway offline' error.
POST device actions likewise use callOpenClawGateway instead of /api/rpc.

- gateway-runtime.ts: catch EROFS / EACCES / EPERM on config write and
emit a warn-level log with a hint, instead of an error. This eliminates
the 'Failed to register MC in gateway config EROFS: read-only file
system' log spam on Docker installs where openclaw.json is read-only.

- install.sh: after generating .env, write the detected OPENCLAW_HOME.
In --docker mode also detect the Docker host IP (host-gateway or bridge
default) and set OPENCLAW_GATEWAY_HOST so the container can reach a
gateway running on the host out of the box.

- docker-compose.yml: add extra_hosts host-gateway so the container can
resolve the Docker host IP; add a commented volume snippet for mounting
the OpenClaw state directory read-only.

.github/workflows/screenshot-drift.yml ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Screenshot Drift Check
2
+
3
+ on:
4
+ pull_request:
5
+ paths:
6
+ - "src/app/**"
7
+ - "src/components/**"
8
+ - "public/**"
9
+
10
+ permissions:
11
+ contents: read
12
+ pull-requests: write
13
+
14
+ jobs:
15
+ flag-drift:
16
+ name: Flag potential screenshot drift
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - name: Check for UI changes
20
+ id: ui_changed
21
+ uses: actions/github-script@v7
22
+ with:
23
+ script: |
24
+ const { data: files } = await github.rest.pulls.listFiles({
25
+ owner: context.repo.owner,
26
+ repo: context.repo.repo,
27
+ pull_number: context.issue.number,
28
+ per_page: 100,
29
+ });
30
+
31
+ const uiPaths = files
32
+ .map(f => f.filename)
33
+ .filter(f =>
34
+ f.startsWith('src/app/') ||
35
+ f.startsWith('src/components/') ||
36
+ f.startsWith('public/')
37
+ );
38
+
39
+ core.setOutput('count', uiPaths.length);
40
+ core.setOutput('paths', uiPaths.slice(0, 10).join('\n'));
41
+
42
+ - name: Add label if UI changed
43
+ if: steps.ui_changed.outputs.count > 0
44
+ uses: actions/github-script@v7
45
+ with:
46
+ script: |
47
+ // Ensure the label exists
48
+ try {
49
+ await github.rest.issues.createLabel({
50
+ owner: context.repo.owner,
51
+ repo: context.repo.repo,
52
+ name: 'screenshot-drift',
53
+ color: 'f9d71c',
54
+ description: 'UI changed — README screenshots may need updating',
55
+ });
56
+ } catch (_) { /* label already exists */ }
57
+
58
+ await github.rest.issues.addLabels({
59
+ owner: context.repo.owner,
60
+ repo: context.repo.repo,
61
+ issue_number: context.issue.number,
62
+ labels: ['screenshot-drift'],
63
+ });
64
+
65
+ - name: Post checklist comment
66
+ if: steps.ui_changed.outputs.count > 0
67
+ uses: actions/github-script@v7
68
+ with:
69
+ script: |
70
+ const paths = `${{ steps.ui_changed.outputs.paths }}`;
71
+ const body = [
72
+ '## 📸 Screenshot Drift Check',
73
+ '',
74
+ 'This PR modifies UI source files. Please verify whether the README screenshots need refreshing:',
75
+ '',
76
+ '- [ ] `docs/mission-control-overview.png` — main dashboard',
77
+ '- [ ] `docs/mission-control-agents.png` — agents panel',
78
+ '- [ ] `docs/mission-control-memory-graph.png` — memory graph',
79
+ '',
80
+ '<details><summary>Changed UI files</summary>',
81
+ '',
82
+ '```',
83
+ paths,
84
+ '```',
85
+ '',
86
+ '</details>',
87
+ '',
88
+ 'See [`docs/SCREENSHOT-GUIDE.md`](docs/SCREENSHOT-GUIDE.md) for instructions on capturing and optimising screenshots.',
89
+ '',
90
+ '_This comment is posted automatically and can be dismissed if no visual changes occurred._',
91
+ ].join('\n');
92
+
93
+ await github.rest.issues.createComment({
94
+ owner: context.repo.owner,
95
+ repo: context.repo.repo,
96
+ issue_number: context.issue.number,
97
+ body,
98
+ });
docker-compose.yml CHANGED
@@ -11,6 +11,15 @@ services:
11
  required: false
12
  volumes:
13
  - mc-data:/app/.data
 
 
 
 
 
 
 
 
 
14
  read_only: true
15
  tmpfs:
16
  - /tmp
 
11
  required: false
12
  volumes:
13
  - mc-data:/app/.data
14
+ # Optional: mount your OpenClaw state directory read-only so Mission Control
15
+ # can read agent configs and memory. Uncomment and adjust the host path:
16
+ # - ${OPENCLAW_HOME:-~/.openclaw}:/run/openclaw:ro
17
+ # Allow the container to reach an OpenClaw gateway running on the Docker host.
18
+ # Uses the special host-gateway alias available on Docker 20.10+.
19
+ # If your gateway runs in another container on this network, remove this line
20
+ # and set OPENCLAW_GATEWAY_HOST to the container name instead.
21
+ extra_hosts:
22
+ - "host-gateway:host-gateway"
23
  read_only: true
24
  tmpfs:
25
  - /tmp
docs/SCREENSHOT-GUIDE.md ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Screenshot Guide
2
+
3
+ This document explains how to capture and update the README screenshots so they stay in sync with the UI.
4
+
5
+ ## Screenshots in the README
6
+
7
+ | File | Section | Description |
8
+ |------|---------|-------------|
9
+ | `docs/mission-control-overview.png` | Dashboard Overview | Main dashboard view |
10
+ | `docs/mission-control-agents.png` | Agents Panel | Active agents list |
11
+ | `docs/mission-control-memory-graph.png` | Memory Graph | Agent memory graph |
12
+
13
+ ## When to Refresh
14
+
15
+ Screenshots should be updated when:
16
+
17
+ - A new page, panel, or major UI component is added
18
+ - An existing page layout changes noticeably
19
+ - The color scheme or branding updates
20
+ - A GitHub Actions `screenshot-drift` label is applied to a PR (see [automation](#automation))
21
+
22
+ ## How to Take New Screenshots
23
+
24
+ ### Prerequisites
25
+
26
+ - Mission Control running locally (`pnpm dev` or Docker)
27
+ - Browser with at least 1440×900 viewport recommended
28
+
29
+ ### Steps
30
+
31
+ 1. **Start the app** (with some sample data for a realistic view):
32
+
33
+ ```bash
34
+ pnpm dev
35
+ # or
36
+ docker compose up
37
+ ```
38
+
39
+ 2. **Seed sample data** (optional but recommended for non-empty screenshots):
40
+
41
+ ```bash
42
+ pnpm seed # if a seed script exists, otherwise populate via UI
43
+ ```
44
+
45
+ 3. **Navigate to each page** and take a screenshot:
46
+
47
+ | Screenshot | URL | Notes |
48
+ |-----------|-----|-------|
49
+ | `mission-control-overview.png` | `/` | Main dashboard, full page |
50
+ | `mission-control-agents.png` | `/agents` | Agents panel open |
51
+ | `mission-control-memory-graph.png` | `/memory` | Memory graph with nodes |
52
+
53
+ 4. **Crop and optimise** to reduce file size:
54
+
55
+ ```bash
56
+ # macOS
57
+ pngcrush -reduce -brute input.png output.png
58
+
59
+ # Linux
60
+ optipng -o5 input.png
61
+ # or
62
+ pngquant --quality=80-95 --output output.png input.png
63
+ ```
64
+
65
+ 5. **Replace the files** under `docs/` and commit:
66
+
67
+ ```bash
68
+ cp ~/Downloads/dashboard.png docs/mission-control-overview.png
69
+ git add docs/
70
+ git commit -m "docs: refresh README screenshots"
71
+ ```
72
+
73
+ ## Automation
74
+
75
+ The repository has a GitHub Actions workflow (`.github/workflows/screenshot-drift.yml`) that:
76
+
77
+ - Detects changes to files under `src/app/`, `src/components/`, and `public/`
78
+ - Adds a `screenshot-drift` label to the PR as a reminder
79
+ - Posts a checklist comment listing which screenshots may need updating
80
+
81
+ This does **not** auto-capture screenshots — it just flags the PR so a human can decide whether the change is visually significant enough to warrant a refresh.
82
+
83
+ ## Tips
84
+
85
+ - Use a consistent browser zoom level (100%) and window size
86
+ - Hide bookmarks bar and dev tools before capturing
87
+ - Light mode and dark mode screenshots can coexist — add a `*-dark.png` variant if useful
88
+ - Prefer PNG for UI screenshots (lossless); JPEG for photos/illustrations
install.sh CHANGED
@@ -158,6 +158,44 @@ setup_env() {
158
  fi
159
  fi
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  ok "Secure .env generated"
162
  }
163
 
 
158
  fi
159
  fi
160
 
161
+ # Auto-detect and write OpenClaw home directory into .env
162
+ local oc_home="${OPENCLAW_HOME:-$HOME/.openclaw}"
163
+ if [[ -d "$oc_home" ]]; then
164
+ if [[ "$(uname)" == "Darwin" ]]; then
165
+ sed -i '' "s|^OPENCLAW_HOME=.*|OPENCLAW_HOME=$oc_home|" "$INSTALL_DIR/.env"
166
+ else
167
+ sed -i "s|^OPENCLAW_HOME=.*|OPENCLAW_HOME=$oc_home|" "$INSTALL_DIR/.env"
168
+ fi
169
+ info "Set OPENCLAW_HOME=$oc_home in .env"
170
+ fi
171
+
172
+ # In Docker mode, the gateway runs on the host, not inside the container.
173
+ # Set OPENCLAW_GATEWAY_HOST to the Docker host gateway IP so the container
174
+ # can reach the gateway. Users may override this with the gateway container
175
+ # name if running OpenClaw in a container on the same network.
176
+ if [[ "$DEPLOY_MODE" == "docker" ]]; then
177
+ local gw_host="${OPENCLAW_GATEWAY_HOST:-}"
178
+ if [[ -z "$gw_host" ]]; then
179
+ # Detect Docker host IP (host-gateway alias or default bridge)
180
+ if getent hosts host-gateway &>/dev/null 2>&1; then
181
+ gw_host="host-gateway"
182
+ else
183
+ # Fallback: use the default Docker bridge gateway (172.17.0.1)
184
+ gw_host=$(ip route show default 2>/dev/null | awk '/default/ {print $3; exit}' || echo "172.17.0.1")
185
+ fi
186
+ fi
187
+ if [[ -n "$gw_host" && "$gw_host" != "127.0.0.1" ]]; then
188
+ if [[ "$(uname)" == "Darwin" ]]; then
189
+ sed -i '' "s|^OPENCLAW_GATEWAY_HOST=.*|OPENCLAW_GATEWAY_HOST=$gw_host|" "$INSTALL_DIR/.env"
190
+ else
191
+ sed -i "s|^OPENCLAW_GATEWAY_HOST=.*|OPENCLAW_GATEWAY_HOST=$gw_host|" "$INSTALL_DIR/.env"
192
+ fi
193
+ info "Set OPENCLAW_GATEWAY_HOST=$gw_host in .env (Docker host IP)"
194
+ info " If your gateway runs in a Docker container, update OPENCLAW_GATEWAY_HOST"
195
+ info " to the container name and add it to the mc-net network."
196
+ fi
197
+ fi
198
+
199
  ok "Secure .env generated"
200
  }
201
 
src/app/api/nodes/route.ts CHANGED
@@ -2,21 +2,22 @@ import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
  import { config } from '@/lib/config'
4
  import { logger } from '@/lib/logger'
 
5
 
6
  const GATEWAY_TIMEOUT = 5000
7
 
8
- function gatewayUrl(path: string): string {
9
- return `http://${config.gatewayHost}:${config.gatewayPort}${path}`
10
- }
11
-
12
- async function fetchGateway(path: string, init?: RequestInit): Promise<Response> {
13
  const controller = new AbortController()
14
  const timeout = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT)
15
  try {
16
- return await fetch(gatewayUrl(path), {
17
- ...init,
18
- signal: controller.signal,
19
- })
 
 
 
20
  } finally {
21
  clearTimeout(timeout)
22
  }
@@ -30,28 +31,44 @@ export async function GET(request: NextRequest) {
30
 
31
  if (action === 'list') {
32
  try {
33
- const res = await fetchGateway('/api/presence')
34
- if (!res.ok) {
35
- logger.warn({ status: res.status }, 'Gateway presence endpoint returned non-OK')
36
  return NextResponse.json({ nodes: [], connected: false })
37
  }
38
- const data = await res.json()
39
- return NextResponse.json(data)
 
 
 
 
 
 
 
 
40
  } catch (err) {
41
- logger.warn({ err }, 'Gateway unreachable for presence listing')
42
  return NextResponse.json({ nodes: [], connected: false })
43
  }
44
  }
45
 
46
  if (action === 'devices') {
47
  try {
48
- const res = await fetchGateway('/api/devices')
49
- if (!res.ok) {
50
- logger.warn({ status: res.status }, 'Gateway devices endpoint returned non-OK')
 
 
 
 
 
 
 
 
 
 
 
51
  return NextResponse.json({ devices: [] })
52
  }
53
- const data = await res.json()
54
- return NextResponse.json(data)
55
  } catch (err) {
56
  logger.warn({ err }, 'Gateway unreachable for device listing')
57
  return NextResponse.json({ devices: [] })
@@ -64,6 +81,7 @@ export async function GET(request: NextRequest) {
64
  const VALID_DEVICE_ACTIONS = ['approve', 'reject', 'rotate-token', 'revoke-token'] as const
65
  type DeviceAction = (typeof VALID_DEVICE_ACTIONS)[number]
66
 
 
67
  const ACTION_RPC_MAP: Record<DeviceAction, { method: string; paramKey: 'requestId' | 'deviceId' }> = {
68
  'approve': { method: 'device.pair.approve', paramKey: 'requestId' },
69
  'reject': { method: 'device.pair.reject', paramKey: 'requestId' },
@@ -112,21 +130,10 @@ export async function POST(request: NextRequest) {
112
  }
113
 
114
  try {
115
- const res = await fetchGateway('/api/rpc', {
116
- method: 'POST',
117
- headers: { 'Content-Type': 'application/json' },
118
- body: JSON.stringify({ method: spec.method, params }),
119
- })
120
-
121
- const data = await res.json()
122
- return NextResponse.json(data, { status: res.status })
123
  } catch (err: unknown) {
124
- const name = err instanceof Error ? err.name : ''
125
- if (name === 'AbortError') {
126
- logger.error('Gateway device action request timed out')
127
- return NextResponse.json({ error: 'Gateway request timed out' }, { status: 504 })
128
- }
129
  logger.error({ err }, 'Gateway device action failed')
130
- return NextResponse.json({ error: 'Gateway unreachable' }, { status: 502 })
131
  }
132
  }
 
2
  import { requireRole } from '@/lib/auth'
3
  import { config } from '@/lib/config'
4
  import { logger } from '@/lib/logger'
5
+ import { callOpenClawGateway } from '@/lib/openclaw-gateway'
6
 
7
  const GATEWAY_TIMEOUT = 5000
8
 
9
+ /** Probe the gateway HTTP /health endpoint to check reachability. */
10
+ async function isGatewayReachable(): Promise<boolean> {
 
 
 
11
  const controller = new AbortController()
12
  const timeout = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT)
13
  try {
14
+ const res = await fetch(
15
+ `http://${config.gatewayHost}:${config.gatewayPort}/health`,
16
+ { signal: controller.signal },
17
+ )
18
+ return res.ok
19
+ } catch {
20
+ return false
21
  } finally {
22
  clearTimeout(timeout)
23
  }
 
31
 
32
  if (action === 'list') {
33
  try {
34
+ const connected = await isGatewayReachable()
35
+ if (!connected) {
 
36
  return NextResponse.json({ nodes: [], connected: false })
37
  }
38
+
39
+ try {
40
+ const data = await callOpenClawGateway<{ nodes?: unknown[] }>('node.list', {}, GATEWAY_TIMEOUT)
41
+ return NextResponse.json({ nodes: data?.nodes ?? [], connected: true })
42
+ } catch (rpcErr) {
43
+ // Gateway is reachable but openclaw CLI unavailable (e.g. Docker) or
44
+ // node.list not supported — return connected=true with empty node list
45
+ logger.warn({ err: rpcErr }, 'node.list RPC failed, returning empty node list')
46
+ return NextResponse.json({ nodes: [], connected: true })
47
+ }
48
  } catch (err) {
49
+ logger.warn({ err }, 'Gateway unreachable for node listing')
50
  return NextResponse.json({ nodes: [], connected: false })
51
  }
52
  }
53
 
54
  if (action === 'devices') {
55
  try {
56
+ const connected = await isGatewayReachable()
57
+ if (!connected) {
58
+ return NextResponse.json({ devices: [] })
59
+ }
60
+
61
+ try {
62
+ const data = await callOpenClawGateway<{ devices?: unknown[] }>(
63
+ 'device.pair.list',
64
+ {},
65
+ GATEWAY_TIMEOUT,
66
+ )
67
+ return NextResponse.json({ devices: data?.devices ?? [] })
68
+ } catch (rpcErr) {
69
+ logger.warn({ err: rpcErr }, 'device.pair.list RPC failed, returning empty device list')
70
  return NextResponse.json({ devices: [] })
71
  }
 
 
72
  } catch (err) {
73
  logger.warn({ err }, 'Gateway unreachable for device listing')
74
  return NextResponse.json({ devices: [] })
 
81
  const VALID_DEVICE_ACTIONS = ['approve', 'reject', 'rotate-token', 'revoke-token'] as const
82
  type DeviceAction = (typeof VALID_DEVICE_ACTIONS)[number]
83
 
84
+ /** Map UI action names to gateway RPC method names and their required param keys. */
85
  const ACTION_RPC_MAP: Record<DeviceAction, { method: string; paramKey: 'requestId' | 'deviceId' }> = {
86
  'approve': { method: 'device.pair.approve', paramKey: 'requestId' },
87
  'reject': { method: 'device.pair.reject', paramKey: 'requestId' },
 
130
  }
131
 
132
  try {
133
+ const result = await callOpenClawGateway(spec.method, params, GATEWAY_TIMEOUT)
134
+ return NextResponse.json(result)
 
 
 
 
 
 
135
  } catch (err: unknown) {
 
 
 
 
 
136
  logger.error({ err }, 'Gateway device action failed')
137
+ return NextResponse.json({ error: 'Gateway device action failed' }, { status: 502 })
138
  }
139
  }
src/lib/gateway-runtime.ts CHANGED
@@ -61,7 +61,18 @@ export function registerMcAsDashboard(mcUrl: string): { registered: boolean; alr
61
  fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2) + '\n')
62
  logger.info({ origin }, 'Registered MC origin in gateway config')
63
  return { registered: true, alreadySet: false }
64
- } catch (err) {
 
 
 
 
 
 
 
 
 
 
 
65
  logger.error({ err }, 'Failed to register MC in gateway config')
66
  return { registered: false, alreadySet: false }
67
  }
 
61
  fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2) + '\n')
62
  logger.info({ origin }, 'Registered MC origin in gateway config')
63
  return { registered: true, alreadySet: false }
64
+ } catch (err: any) {
65
+ // Read-only filesystem (e.g. Docker read_only: true, or intentional mount) —
66
+ // treat as a non-fatal skip rather than an error.
67
+ if (err?.code === 'EROFS' || err?.code === 'EACCES' || err?.code === 'EPERM') {
68
+ logger.warn(
69
+ { err, configPath },
70
+ 'Gateway config is read-only — skipping MC origin registration. ' +
71
+ 'To enable auto-registration, mount openclaw.json with write access or ' +
72
+ 'add the MC origin to gateway.controlUi.allowedOrigins manually.',
73
+ )
74
+ return { registered: false, alreadySet: false }
75
+ }
76
  logger.error({ err }, 'Failed to register MC in gateway config')
77
  return { registered: false, alreadySet: false }
78
  }