Add and harden AI Lab Cloud support - #3
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add _port_base_url() helper that reads X-Ailab-Tunnel-Base header injected by the cloud proxy, falling back to http://localhost locally - Add GET /api/port-base-url endpoint so the frontend can discover the correct base URL at runtime - api_gateway_url and api_gateway_pair now return tunnel-correct URLs when accessed through the cloud hub - Frontend GatewayButton calls getPortBaseUrl() for non-openclaw ports instead of hardcoding localhost - PairModal fetches the gateway URL from the API after pairing completes rather than parsing it from log output Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Required by _port_base_url() added in the cloud tunnel-aware URL changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ailab/cloud.py: new CloudTunnelManager with reconnecting WebSocket tunnel to ailab-cloud hub; proxies HTTP and WebSocket frames from the hub to local service ports - ailab/web/app.py: wire CloudTunnelManager into FastAPI lifespan so the tunnel starts/stops with the web service - snap/local/ailab-web-wrapper: read cloud.* snap settings and export as AILAB_CLOUD_* env vars - snap/snapcraft.yaml: document cloud.* settings - Strip https:// scheme from AILAB_CLOUD_HOST if user includes it Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without a default, devices register with an empty ports list and the hub dashboard shows no Open buttons even when connected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When served at /d/device:11500/ through the cloud tunnel, absolute asset
paths (/assets/...) and API calls (/api/...) resolved to the hub root
and returned 404.
- vite.config.ts: base './' makes built assets use ./assets/... (relative)
- client.ts: API BASE uses import.meta.env.BASE_URL so fetch('./api/...')
resolves relative to the current page URL in both local and tunnel contexts
- client.ts: add wsUrl() helper that inserts /ws prefix when running under
a /d/{device}:{port}/ tunnel sub-path so WebSocket connections route
correctly through the hub proxy
- LogStream, Terminal: use wsUrl() instead of hardcoded window.location.host
- vite-env.d.ts: add missing Vite type reference file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The hub's WS proxy route now mirrors the HTTP route (no /ws/ segment), so wsUrl() no longer inserts /ws/ between the device prefix and the path. Local: ws://localhost:11500/api/ws/shell/mybox (unchanged) Tunnel: wss://hub.example.com/d/framework:11500/api/ws/shell/mybox Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the openclaw dashboard is opened through the cloud tunnel, the control-ui JS falls back to its stored ws://127.0.0.1:18789/__openclaw__/ws gateway URL — which is unreachable from the remote browser. Openclaw supports a gatewayUrl hash parameter to override the stored URL. When api_gateway_url detects it's serving through the tunnel (x-ailab-tunnel-base header present), it now includes both token and gatewayUrl in the URL hash so the control-ui connects to the gateway via the tunnel proxy instead. Local: .../d/Nova:18789/#token=<tok> Tunnel: .../d/Nova:18789/#token=<tok>&gatewayUrl=wss://.../d/Nova:18789/__openclaw__/ws Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The openclaw gateway rejects WebSocket connections from browsers whose Origin header doesn't match gateway.controlUi.allowedOrigins. When the control-ui is opened through the cloud tunnel the browser sends Origin: https://<hub> which is not localhost, so the connection is refused. When api_gateway_url is called through the tunnel it now fires a background task that adds the hub origin to gateway.controlUi.allowedOrigins in openclaw.json inside the container and restarts the gateway service. The task is a no-op if the origin is already present, so the overhead on subsequent calls is just one pull_file read. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The background task fired after the URL was already returned and opened, so the gateway was still running the old config when the browser connected. Awaiting the patch+restart ensures the gateway is ready before the URL is handed to the browser. Subsequent calls are fast (no-op if origin already present). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the openclaw gateway WebSocket is opened via the cloud tunnel, cloud.py was connecting to the local gateway without any auth headers, causing a 401 from the gateway's authorizeCanvasRequest check. - app.py: embed the gateway token as ?token= in the gatewayUrl so it flows through the tunnel path to the device - cloud.py: extract the token query param from the ws_open path and inject it as Authorization: Bearer when connecting locally; strip it from the URL so the gateway doesn't see a stray query param Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two fixes for issues found during tunnel debugging:
1. _restart_gateway now calls `systemctl --user reset-failed` before
restart so a crashed gateway that hit systemd's rate limiter ("Start
request repeated too quickly") can be recovered without manual
intervention.
2. contextWindow values of 0 from lemonade recipe data crash the openclaw
gateway with a validation error ("Too small: expected number to be >0").
Clamp ctx_size to at least 1 in both _lemonade_model_entry and the
import-recipe handler. Uses `or 32768` to also handle explicit None.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cloud.py's aiohttp WS connection to local services had no Origin header, so the openclaw gateway's CORS check always failed with "origin not allowed" — even after whitelisting the hub origin in openclaw.json. The hub now captures the browser's Origin from the WS upgrade request and includes it in the ws_open envelope. cloud.py merges these forwarded headers into the aiohttp ws_connect call, so the local service sees the real browser origin and the allowedOrigins check passes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
push_file uses the LXD files API which writes as uid=0 inside the container. The resulting root-owned openclaw.json either can't be read by the gateway (running as the container user) or gets overwritten by the gateway on next startup, losing the allowedOrigins we added. Replace the pull_file/push_file pair in _ensure_gateway_cloud_origin_sync with a container_exec python3 script that runs as the correct uid/gid, matching the approach used in _patch_gateway_token_in_json. The script prints "already-present" or "patched" so we can tell whether a gateway restart is actually needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stop sending tunnel credentials in the WebSocket URL, validate configured device IDs and ports, and refuse forwarding to ports the device did not advertise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add heartbeat-driven tunnel recovery, remove tracked TypeScript build metadata, tighten ignore rules, refresh cloud setup docs, and align the frontend favicon with the AI Lab branding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds AI Lab Cloud tunnel client support across the backend, snap packaging, and frontend so the web UI and tool “Open …” links work both locally and when routed through a cloud hub.
Changes:
- Introduces a persistent WebSocket-based tunnel client (
ailab/cloud.py) and starts/stops it with the FastAPI app lifecycle. - Updates backend endpoints + frontend URL construction for tunnel-aware HTTP/WebSocket routing (including openclaw gateway URL handling).
- Cleans up frontend build/asset behavior (Vite base path, tsbuildinfo ignore/removal) and docs/branding updates.
Reviewed changes
Copilot reviewed 15 out of 17 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
ailab/cloud.py |
New tunnel client handling hub registration, HTTP proxy, and WS proxying. |
ailab/web/app.py |
Starts tunnel manager via FastAPI lifespan; adds tunnel-aware port base + openclaw gateway URL logic. |
snap/local/ailab-web-wrapper |
Exports cloud tunnel env vars from snap config for the web daemon. |
snap/snapcraft.yaml |
Documents new cloud.* snap settings in snap metadata. |
frontend/src/api/client.ts |
Makes API base path and WebSocket URL construction work behind a tunnel path. |
frontend/src/components/Terminal.tsx |
Switches terminal WS connection to tunnel-aware URL helper. |
frontend/src/components/LogStream.tsx |
Switches log WS connection to tunnel-aware URL helper. |
frontend/src/components/ContainerList.tsx |
Makes “Open …” links tunnel-aware; fetches base URL / gateway URL from API. |
frontend/vite.config.ts |
Sets Vite base for relative asset serving. |
frontend/src/vite-env.d.ts |
Adds Vite client type reference. |
frontend/index.html |
Adds inline SVG favicon branding. |
frontend/.gitignore |
Ignores Vite/TS build artifacts. |
frontend/tsconfig.tsbuildinfo |
Removes tracked TS build metadata. |
README.md |
Adds Cloud Access documentation and configuration reference. |
specs/cloud.md |
Adds historical design note/spec (explicitly marked as outdated). |
.gitignore |
Adds Python tool caches (and one suspicious extra ignore entry). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def _port_base_url(request: Request) -> str: | ||
| """Return the base used to construct port-specific URLs. | ||
|
|
||
| When the request came through the cloud tunnel the proxy injects | ||
| 'X-Ailab-Tunnel-Base' (e.g. 'https://hub.example.com/d/mydevice'). | ||
| Appending ':{port}' produces the correct tunnel URL for that port. | ||
| When accessed locally the header is absent and we fall back to | ||
| 'http://localhost' so existing behaviour is unchanged. | ||
| """ | ||
| tunnel_base = request.headers.get("x-ailab-tunnel-base", "").strip() | ||
| return tunnel_base if tunnel_base else "http://localhost" |
There was a problem hiding this comment.
Fixed in ee095ab. _port_base_url() now only trusts X-Ailab-Tunnel-Base from loopback clients and validates it as an http(s) URL before using it.
| return ( | ||
| <a | ||
| href={url ?? `http://localhost:${port}`} | ||
| href={url ?? '#'} | ||
| target="_blank" | ||
| rel="noopener noreferrer" |
There was a problem hiding this comment.
Fixed in ee095ab. GatewayButton now starts in loading state and keeps the placeholder rendered until a real URL is available (or the not-paired state is known), so it no longer exposes a transient href="#" link.
| if [ -n "$CLOUD_HOST" ] && [ -n "$CLOUD_TOKEN" ]; then | ||
| export AILAB_CLOUD_HOST="$CLOUD_HOST" | ||
| export AILAB_CLOUD_TOKEN="$CLOUD_TOKEN" | ||
| [ -n "$CLOUD_USER" ] && export AILAB_CLOUD_USER="$CLOUD_USER" | ||
| [ -n "$CLOUD_DEVICE" ] && export AILAB_CLOUD_DEVICE="$CLOUD_DEVICE" | ||
| [ -n "$CLOUD_PORTS" ] && export AILAB_CLOUD_PORTS="$CLOUD_PORTS" | ||
| fi |
There was a problem hiding this comment.
Fixed in ee095ab. The snap wrapper now gates cloud env export on cloud.enabled=true, so disabling the setting is effective even if host/token remain configured.
| github_user = os.environ.get("AILAB_CLOUD_USER", "").strip() | ||
| device_id = os.environ.get("AILAB_CLOUD_DEVICE", "").strip() or socket.gethostname() | ||
| ports_raw = os.environ.get("AILAB_CLOUD_PORTS", "").strip() | ||
| if not _DEVICE_ID_RE.fullmatch(device_id): | ||
| raise ValueError( | ||
| f"Invalid AILAB_CLOUD_DEVICE {device_id!r}; use lowercase letters, digits, and hyphens" | ||
| ) | ||
| ports = cls._normalize_ports(ports_raw) | ||
| return cls( | ||
| host=host, | ||
| token=token, | ||
| github_user=github_user, | ||
| device_id=device_id, | ||
| secure=secure, | ||
| ports=ports, | ||
| ) |
There was a problem hiding this comment.
Fixed in ee095ab. CloudConfig.from_env() now raises a clear ValueError when AILAB_CLOUD_USER is empty while the cloud tunnel is enabled.
| await tunnel_ws.send_json(response_envelope) | ||
| return | ||
|
|
||
| body: bytes | None = base64.b64decode(body_b64) if body_b64 else None |
There was a problem hiding this comment.
Fixed in ee095ab. Request-body base64 decoding now happens inside the error-handling path, and malformed payloads return a 400 response envelope instead of crashing the task.
| url = f"http://127.0.0.1:{port}{path}" | ||
| try: | ||
| async with aiohttp.ClientSession() as session: | ||
| async with session.request( | ||
| method, url, headers=fwd_headers, data=body, allow_redirects=False | ||
| ) as resp: |
There was a problem hiding this comment.
Fixed in ee095ab. The tunnel now reuses a shared local aiohttp.ClientSession for proxied traffic and applies a timeout, rather than creating a fresh session per HTTP request.
| const m = window.location.pathname.match(/^(\/d\/[^/]+)\//); | ||
| if (m) { | ||
| return `${proto}//${window.location.host}${m[1]}${path}`; | ||
| } | ||
| return `${proto}//${window.location.host}${path}`; |
There was a problem hiding this comment.
Fixed in ee095ab. wsUrl() now matches ^(\/d\/[^/]+)(?:\/|$), so tunnel WS URLs work whether the device path has a trailing slash or not.
| CLOUD_HOST=$(snapctl get cloud.host 2>/dev/null || true) | ||
| CLOUD_TOKEN=$(snapctl get cloud.token 2>/dev/null || true) | ||
| CLOUD_USER=$(snapctl get cloud.user 2>/dev/null || true) | ||
| CLOUD_DEVICE=$(snapctl get cloud.device 2>/dev/null || true) |
There was a problem hiding this comment.
Fixed in ee095ab. The wrapper now reads cloud.device-id, matching the documented snap config key.
| self._task.cancel() | ||
| # Close any open proxied WS sessions. | ||
| for session in list(self._ws_sessions.values()): | ||
| await session.close() |
There was a problem hiding this comment.
Fixed in ee095ab. stop() now closes local proxy sockets, clears the connection map, resets the shared session reference, and closes the shared session so stale entries do not accumulate across restarts.
| data = base64.b64decode(envelope.get("data", "")) | ||
| try: |
There was a problem hiding this comment.
Fixed in ee095ab. _handle_ws_frame() now validates base64 input, logs decode failures, and closes the affected proxied websocket instead of letting malformed frames raise through the task.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Pushed Handled in this update:
|
There was a problem hiding this comment.
Pull request overview
Adds end-to-end “AI Lab Cloud” support so the ailab web UI and forwarded tool ports can be reached through a hub/tunnel setup, including tunnel-aware URL generation and WebSocket proxying.
Changes:
- Introduce a persistent cloud tunnel client (
ailab/cloud.py) and start/stop it via the FastAPI lifespan hook. - Make the web UI tunnel-aware (port base URL endpoint, Openclaw gateway URL/Origin handling, frontend WS/API base fixes).
- Cleanup/ops/docs updates (snap config docs, Vite base path, favicon, ignore rules, remove tracked TS build metadata).
Reviewed changes
Copilot reviewed 16 out of 18 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| specs/cloud.md | Adds historical design/spec notes for AI Lab Cloud. |
| snap/snapcraft.yaml | Documents new snap cloud.* configuration keys in snap metadata. |
| snap/local/ailab-web-wrapper | Exports AILAB_CLOUD_* env vars when cloud.enabled=true and required settings are present. |
| frontend/vite.config.ts | Sets Vite base: './' so assets resolve under tunneled sub-paths. |
| frontend/tsconfig.tsbuildinfo | Removes tracked TS incremental build metadata. |
| frontend/src/vite-env.d.ts | Adds Vite type reference for TS tooling. |
| frontend/src/components/Terminal.tsx | Uses centralized wsUrl() helper for tunnel-safe terminal WebSocket URLs. |
| frontend/src/components/LogStream.tsx | Uses wsUrl() for tunnel-safe log WebSocket URLs. |
| frontend/src/components/ContainerList.tsx | Makes “Open …” URLs tunnel-aware via API-provided base URL and gateway URL fetching. |
| frontend/src/api/client.ts | Uses import.meta.env.BASE_URL for API base and adds wsUrl() + getPortBaseUrl(). |
| frontend/index.html | Adds inline SVG favicon branding. |
| frontend/.gitignore | Ignores Vite/TS build artifacts. |
| ailab/web/app.py | Starts cloud tunnel manager, adds /api/port-base-url, and hardens tunnel-aware Openclaw gateway URL/origin handling. |
| ailab/installers/openclaw.py | Defines Openclaw gateway WS path and improves gateway service restart robustness. |
| ailab/container.py | Adds a snap-friendly interactive shell path using the LXD unix socket websocket API. |
| ailab/cloud.py | Implements the outbound tunnel client (HTTP + WS proxying, registration ack, heartbeat + reconnect). |
| README.md | Adds Cloud Access documentation and setup instructions. |
| .gitignore | Adds python tooling caches; includes one suspicious ignore entry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| | `cloud.host` | Hub URL or hostname, e.g. `https://cloud.example.com` | | ||
| | `cloud.user` | Your GitHub username (must match your hub login) | | ||
| | `cloud.token` | Tunnel token from `/auth/tunnel-token` on the hub | | ||
| | `cloud.device-id` | Short identifier for this machine; use lowercase letters, digits, and hyphens only | |
There was a problem hiding this comment.
Fixed in 5fa0c52. I added cloud.ports to the Cloud settings table in README.md and documented the default behavior plus the common 18789 openclaw case.
| {"type": "registered"} | ||
| {"type": "request", "id": "<uuid>", "method": "...", "path": "...", | ||
| "port": 11500, "headers": {...}, "body": "<base64>"} | ||
| {"type": "ws_open", "conn_id": "<uuid>", "port": ..., "path": "..."} |
There was a problem hiding this comment.
Fixed in 5fa0c52. The ailab/cloud.py protocol docstring now lists the optional headers field on ws_open so the documented tunnel protocol matches _handle_ws_open().
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
ailabTesting
frontend/)