Spaces:
Runtime error
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 +98 -0
- docker-compose.yml +9 -0
- docs/SCREENSHOT-GUIDE.md +88 -0
- install.sh +38 -0
- src/app/api/nodes/route.ts +41 -34
- src/lib/gateway-runtime.ts +12 -1
|
@@ -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 |
+
});
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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 |
|
|
@@ -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 |
-
|
| 9 |
-
|
| 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 |
-
|
| 17 |
-
..
|
| 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
|
| 34 |
-
if (!
|
| 35 |
-
logger.warn({ status: res.status }, 'Gateway presence endpoint returned non-OK')
|
| 36 |
return NextResponse.json({ nodes: [], connected: false })
|
| 37 |
}
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
} catch (err) {
|
| 41 |
-
logger.warn({ err }, 'Gateway unreachable for
|
| 42 |
return NextResponse.json({ nodes: [], connected: false })
|
| 43 |
}
|
| 44 |
}
|
| 45 |
|
| 46 |
if (action === 'devices') {
|
| 47 |
try {
|
| 48 |
-
const
|
| 49 |
-
if (!
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 116 |
-
|
| 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
|
| 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 |
}
|
|
@@ -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 |
}
|