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

* 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.
This commit is contained in:
HonzysClawdbot 2026-03-14 09:38:13 +01:00 committed by GitHub
parent 2b4438b63a
commit 8706147f84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 286 additions and 35 deletions

98
.github/workflows/screenshot-drift.yml vendored Normal file
View File

@ -0,0 +1,98 @@
name: Screenshot Drift Check
on:
pull_request:
paths:
- "src/app/**"
- "src/components/**"
- "public/**"
permissions:
contents: read
pull-requests: write
jobs:
flag-drift:
name: Flag potential screenshot drift
runs-on: ubuntu-latest
steps:
- name: Check for UI changes
id: ui_changed
uses: actions/github-script@v7
with:
script: |
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
const uiPaths = files
.map(f => f.filename)
.filter(f =>
f.startsWith('src/app/') ||
f.startsWith('src/components/') ||
f.startsWith('public/')
);
core.setOutput('count', uiPaths.length);
core.setOutput('paths', uiPaths.slice(0, 10).join('\n'));
- name: Add label if UI changed
if: steps.ui_changed.outputs.count > 0
uses: actions/github-script@v7
with:
script: |
// Ensure the label exists
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'screenshot-drift',
color: 'f9d71c',
description: 'UI changed — README screenshots may need updating',
});
} catch (_) { /* label already exists */ }
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['screenshot-drift'],
});
- name: Post checklist comment
if: steps.ui_changed.outputs.count > 0
uses: actions/github-script@v7
with:
script: |
const paths = `${{ steps.ui_changed.outputs.paths }}`;
const body = [
'## 📸 Screenshot Drift Check',
'',
'This PR modifies UI source files. Please verify whether the README screenshots need refreshing:',
'',
'- [ ] `docs/mission-control-overview.png` — main dashboard',
'- [ ] `docs/mission-control-agents.png` — agents panel',
'- [ ] `docs/mission-control-memory-graph.png` — memory graph',
'',
'<details><summary>Changed UI files</summary>',
'',
'```',
paths,
'```',
'',
'</details>',
'',
'See [`docs/SCREENSHOT-GUIDE.md`](docs/SCREENSHOT-GUIDE.md) for instructions on capturing and optimising screenshots.',
'',
'_This comment is posted automatically and can be dismissed if no visual changes occurred._',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});

View File

@ -11,6 +11,15 @@ services:
required: false required: false
volumes: volumes:
- mc-data:/app/.data - mc-data:/app/.data
# Optional: mount your OpenClaw state directory read-only so Mission Control
# can read agent configs and memory. Uncomment and adjust the host path:
# - ${OPENCLAW_HOME:-~/.openclaw}:/run/openclaw:ro
# Allow the container to reach an OpenClaw gateway running on the Docker host.
# Uses the special host-gateway alias available on Docker 20.10+.
# If your gateway runs in another container on this network, remove this line
# and set OPENCLAW_GATEWAY_HOST to the container name instead.
extra_hosts:
- "host-gateway:host-gateway"
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp - /tmp

88
docs/SCREENSHOT-GUIDE.md Normal file
View File

@ -0,0 +1,88 @@
# Screenshot Guide
This document explains how to capture and update the README screenshots so they stay in sync with the UI.
## Screenshots in the README
| File | Section | Description |
|------|---------|-------------|
| `docs/mission-control-overview.png` | Dashboard Overview | Main dashboard view |
| `docs/mission-control-agents.png` | Agents Panel | Active agents list |
| `docs/mission-control-memory-graph.png` | Memory Graph | Agent memory graph |
## When to Refresh
Screenshots should be updated when:
- A new page, panel, or major UI component is added
- An existing page layout changes noticeably
- The color scheme or branding updates
- A GitHub Actions `screenshot-drift` label is applied to a PR (see [automation](#automation))
## How to Take New Screenshots
### Prerequisites
- Mission Control running locally (`pnpm dev` or Docker)
- Browser with at least 1440×900 viewport recommended
### Steps
1. **Start the app** (with some sample data for a realistic view):
```bash
pnpm dev
# or
docker compose up
```
2. **Seed sample data** (optional but recommended for non-empty screenshots):
```bash
pnpm seed # if a seed script exists, otherwise populate via UI
```
3. **Navigate to each page** and take a screenshot:
| Screenshot | URL | Notes |
|-----------|-----|-------|
| `mission-control-overview.png` | `/` | Main dashboard, full page |
| `mission-control-agents.png` | `/agents` | Agents panel open |
| `mission-control-memory-graph.png` | `/memory` | Memory graph with nodes |
4. **Crop and optimise** to reduce file size:
```bash
# macOS
pngcrush -reduce -brute input.png output.png
# Linux
optipng -o5 input.png
# or
pngquant --quality=80-95 --output output.png input.png
```
5. **Replace the files** under `docs/` and commit:
```bash
cp ~/Downloads/dashboard.png docs/mission-control-overview.png
git add docs/
git commit -m "docs: refresh README screenshots"
```
## Automation
The repository has a GitHub Actions workflow (`.github/workflows/screenshot-drift.yml`) that:
- Detects changes to files under `src/app/`, `src/components/`, and `public/`
- Adds a `screenshot-drift` label to the PR as a reminder
- Posts a checklist comment listing which screenshots may need updating
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.
## Tips
- Use a consistent browser zoom level (100%) and window size
- Hide bookmarks bar and dev tools before capturing
- Light mode and dark mode screenshots can coexist — add a `*-dark.png` variant if useful
- Prefer PNG for UI screenshots (lossless); JPEG for photos/illustrations

View File

@ -158,6 +158,44 @@ setup_env() {
fi fi
fi fi
# Auto-detect and write OpenClaw home directory into .env
local oc_home="${OPENCLAW_HOME:-$HOME/.openclaw}"
if [[ -d "$oc_home" ]]; then
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s|^OPENCLAW_HOME=.*|OPENCLAW_HOME=$oc_home|" "$INSTALL_DIR/.env"
else
sed -i "s|^OPENCLAW_HOME=.*|OPENCLAW_HOME=$oc_home|" "$INSTALL_DIR/.env"
fi
info "Set OPENCLAW_HOME=$oc_home in .env"
fi
# In Docker mode, the gateway runs on the host, not inside the container.
# Set OPENCLAW_GATEWAY_HOST to the Docker host gateway IP so the container
# can reach the gateway. Users may override this with the gateway container
# name if running OpenClaw in a container on the same network.
if [[ "$DEPLOY_MODE" == "docker" ]]; then
local gw_host="${OPENCLAW_GATEWAY_HOST:-}"
if [[ -z "$gw_host" ]]; then
# Detect Docker host IP (host-gateway alias or default bridge)
if getent hosts host-gateway &>/dev/null 2>&1; then
gw_host="host-gateway"
else
# Fallback: use the default Docker bridge gateway (172.17.0.1)
gw_host=$(ip route show default 2>/dev/null | awk '/default/ {print $3; exit}' || echo "172.17.0.1")
fi
fi
if [[ -n "$gw_host" && "$gw_host" != "127.0.0.1" ]]; then
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s|^OPENCLAW_GATEWAY_HOST=.*|OPENCLAW_GATEWAY_HOST=$gw_host|" "$INSTALL_DIR/.env"
else
sed -i "s|^OPENCLAW_GATEWAY_HOST=.*|OPENCLAW_GATEWAY_HOST=$gw_host|" "$INSTALL_DIR/.env"
fi
info "Set OPENCLAW_GATEWAY_HOST=$gw_host in .env (Docker host IP)"
info " If your gateway runs in a Docker container, update OPENCLAW_GATEWAY_HOST"
info " to the container name and add it to the mc-net network."
fi
fi
ok "Secure .env generated" ok "Secure .env generated"
} }

View File

@ -2,21 +2,22 @@ import { NextRequest, NextResponse } from 'next/server'
import { requireRole } from '@/lib/auth' import { requireRole } from '@/lib/auth'
import { config } from '@/lib/config' import { config } from '@/lib/config'
import { logger } from '@/lib/logger' import { logger } from '@/lib/logger'
import { callOpenClawGateway } from '@/lib/openclaw-gateway'
const GATEWAY_TIMEOUT = 5000 const GATEWAY_TIMEOUT = 5000
function gatewayUrl(path: string): string { /** Probe the gateway HTTP /health endpoint to check reachability. */
return `http://${config.gatewayHost}:${config.gatewayPort}${path}` async function isGatewayReachable(): Promise<boolean> {
}
async function fetchGateway(path: string, init?: RequestInit): Promise<Response> {
const controller = new AbortController() const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT) const timeout = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT)
try { try {
return await fetch(gatewayUrl(path), { const res = await fetch(
...init, `http://${config.gatewayHost}:${config.gatewayPort}/health`,
signal: controller.signal, { signal: controller.signal },
}) )
return res.ok
} catch {
return false
} finally { } finally {
clearTimeout(timeout) clearTimeout(timeout)
} }
@ -30,28 +31,44 @@ export async function GET(request: NextRequest) {
if (action === 'list') { if (action === 'list') {
try { try {
const res = await fetchGateway('/api/presence') const connected = await isGatewayReachable()
if (!res.ok) { if (!connected) {
logger.warn({ status: res.status }, 'Gateway presence endpoint returned non-OK')
return NextResponse.json({ nodes: [], connected: false }) return NextResponse.json({ nodes: [], connected: false })
} }
const data = await res.json()
return NextResponse.json(data) try {
const data = await callOpenClawGateway<{ nodes?: unknown[] }>('node.list', {}, GATEWAY_TIMEOUT)
return NextResponse.json({ nodes: data?.nodes ?? [], connected: true })
} catch (rpcErr) {
// Gateway is reachable but openclaw CLI unavailable (e.g. Docker) or
// node.list not supported — return connected=true with empty node list
logger.warn({ err: rpcErr }, 'node.list RPC failed, returning empty node list')
return NextResponse.json({ nodes: [], connected: true })
}
} catch (err) { } catch (err) {
logger.warn({ err }, 'Gateway unreachable for presence listing') logger.warn({ err }, 'Gateway unreachable for node listing')
return NextResponse.json({ nodes: [], connected: false }) return NextResponse.json({ nodes: [], connected: false })
} }
} }
if (action === 'devices') { if (action === 'devices') {
try { try {
const res = await fetchGateway('/api/devices') const connected = await isGatewayReachable()
if (!res.ok) { if (!connected) {
logger.warn({ status: res.status }, 'Gateway devices endpoint returned non-OK') return NextResponse.json({ devices: [] })
}
try {
const data = await callOpenClawGateway<{ devices?: unknown[] }>(
'device.pair.list',
{},
GATEWAY_TIMEOUT,
)
return NextResponse.json({ devices: data?.devices ?? [] })
} catch (rpcErr) {
logger.warn({ err: rpcErr }, 'device.pair.list RPC failed, returning empty device list')
return NextResponse.json({ devices: [] }) return NextResponse.json({ devices: [] })
} }
const data = await res.json()
return NextResponse.json(data)
} catch (err) { } catch (err) {
logger.warn({ err }, 'Gateway unreachable for device listing') logger.warn({ err }, 'Gateway unreachable for device listing')
return NextResponse.json({ devices: [] }) return NextResponse.json({ devices: [] })
@ -64,6 +81,7 @@ export async function GET(request: NextRequest) {
const VALID_DEVICE_ACTIONS = ['approve', 'reject', 'rotate-token', 'revoke-token'] as const const VALID_DEVICE_ACTIONS = ['approve', 'reject', 'rotate-token', 'revoke-token'] as const
type DeviceAction = (typeof VALID_DEVICE_ACTIONS)[number] type DeviceAction = (typeof VALID_DEVICE_ACTIONS)[number]
/** Map UI action names to gateway RPC method names and their required param keys. */
const ACTION_RPC_MAP: Record<DeviceAction, { method: string; paramKey: 'requestId' | 'deviceId' }> = { const ACTION_RPC_MAP: Record<DeviceAction, { method: string; paramKey: 'requestId' | 'deviceId' }> = {
'approve': { method: 'device.pair.approve', paramKey: 'requestId' }, 'approve': { method: 'device.pair.approve', paramKey: 'requestId' },
'reject': { method: 'device.pair.reject', paramKey: 'requestId' }, 'reject': { method: 'device.pair.reject', paramKey: 'requestId' },
@ -112,21 +130,10 @@ export async function POST(request: NextRequest) {
} }
try { try {
const res = await fetchGateway('/api/rpc', { const result = await callOpenClawGateway(spec.method, params, GATEWAY_TIMEOUT)
method: 'POST', return NextResponse.json(result)
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: spec.method, params }),
})
const data = await res.json()
return NextResponse.json(data, { status: res.status })
} catch (err: unknown) { } catch (err: unknown) {
const name = err instanceof Error ? err.name : ''
if (name === 'AbortError') {
logger.error('Gateway device action request timed out')
return NextResponse.json({ error: 'Gateway request timed out' }, { status: 504 })
}
logger.error({ err }, 'Gateway device action failed') logger.error({ err }, 'Gateway device action failed')
return NextResponse.json({ error: 'Gateway unreachable' }, { status: 502 }) return NextResponse.json({ error: 'Gateway device action failed' }, { status: 502 })
} }
} }

View File

@ -61,7 +61,18 @@ export function registerMcAsDashboard(mcUrl: string): { registered: boolean; alr
fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2) + '\n') fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2) + '\n')
logger.info({ origin }, 'Registered MC origin in gateway config') logger.info({ origin }, 'Registered MC origin in gateway config')
return { registered: true, alreadySet: false } return { registered: true, alreadySet: false }
} catch (err) { } catch (err: any) {
// Read-only filesystem (e.g. Docker read_only: true, or intentional mount) —
// treat as a non-fatal skip rather than an error.
if (err?.code === 'EROFS' || err?.code === 'EACCES' || err?.code === 'EPERM') {
logger.warn(
{ err, configPath },
'Gateway config is read-only — skipping MC origin registration. ' +
'To enable auto-registration, mount openclaw.json with write access or ' +
'add the MC origin to gateway.controlUi.allowedOrigins manually.',
)
return { registered: false, alreadySet: false }
}
logger.error({ err }, 'Failed to register MC in gateway config') logger.error({ err }, 'Failed to register MC in gateway config')
return { registered: false, alreadySet: false } return { registered: false, alreadySet: false }
} }