From 980f255450150e4d31a16b9b88a4dd4549c47a28 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sat, 11 Apr 2026 21:20:23 -0400 Subject: [PATCH 01/21] docs: add Cloud Access section covering tunnel setup and snap settings Co-Authored-By: Claude Sonnet 4.6 --- README.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/README.md b/README.md index b3176de..c57156f 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,80 @@ whose host port is already bound, so containers can start without conflicts. Conflicting proxies are restored to the config so they activate once the port is freed. +## Cloud Access + +AI Lab Cloud lets you access your home containers from any browser, anywhere +— no VPN or port forwarding required. A lightweight tunnel client runs +alongside the web daemon, opening an outbound connection to a hub you +self-host on a VPS. + +### How it works + +``` +Browser (anywhere) ──HTTPS──► AI Lab Cloud Hub (your VPS) + │ + WebSocket tunnel + (outbound from home) + │ + AI Lab (your home machine) + │ + LXD proxy device + │ + Container: openclaw / nullclaw / etc. +``` + +The hub authenticates your browser via GitHub OAuth and routes traffic only +to the tunnel registered by the matching GitHub user. + +### Quick setup + +**1. Deploy the hub** on a VPS. Full instructions are in the +[AI Lab Cloud README](https://github.com/lemonade-sdk/ailab-cloud). + +**2. Get your tunnel token.** Log in to your hub in a browser, then visit: +``` +https://cloud.example.com/auth/tunnel-token +``` + +**3. Configure AI Lab on your home machine:** + +```bash +sudo snap set ailab cloud.enabled=true +sudo snap set ailab cloud.host=https://cloud.example.com +sudo snap set ailab cloud.user=yourname +sudo snap set ailab cloud.token= +sudo snap set ailab cloud.device-id=myhome +sudo snap restart ailab.web +``` + +**4. Visit** `https://myhome.cloud.example.com` from any browser and log +in with GitHub. The full AI Lab dashboard loads proxied through the tunnel, +including the interactive terminal and all "Open …" buttons for installed +tools. + +### Cloud settings reference + +| Setting | Description | +|---|---| +| `cloud.enabled` | Set to `true` to start the tunnel client (default: `false`) | +| `cloud.host` | Hub URL, 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; becomes part of the URL | + +```bash +snap get ailab cloud # view all cloud settings at once +``` + +Disable cloud access without losing the settings: + +```bash +sudo snap set ailab cloud.enabled=false +sudo snap restart ailab.web +``` + +--- + ## Tips **Web interface**: `ailab web` serves a React dashboard at From 55827915492f28730fc46599cc1de16b499e2c22 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sat, 11 Apr 2026 21:28:37 -0400 Subject: [PATCH 02/21] docs: note that ailab-cloud hub snap is fully self-contained Co-Authored-By: Claude Sonnet 4.6 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c57156f..7b9e499 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,8 @@ to the tunnel registered by the matching GitHub user. ### Quick setup -**1. Deploy the hub** on a VPS. Full instructions are in the +**1. Deploy the hub** on a VPS with a single snap install — Redis, Caddy +(TLS), and the hub API are all bundled. Full instructions are in the [AI Lab Cloud README](https://github.com/lemonade-sdk/ailab-cloud). **2. Get your tunnel token.** Log in to your hub in a browser, then visit: From 6b67c37fa01b9c49e6fbfd644122d81c5f2e700e Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sat, 11 Apr 2026 21:58:37 -0400 Subject: [PATCH 03/21] feat(cloud): make port URLs tunnel-aware - 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 --- ailab/web/app.py | 33 ++++++++++++++++++++--- frontend/src/api/client.ts | 5 ++++ frontend/src/components/ContainerList.tsx | 28 ++++++++++++------- 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/ailab/web/app.py b/ailab/web/app.py index e1457b5..e2de7ea 100644 --- a/ailab/web/app.py +++ b/ailab/web/app.py @@ -431,8 +431,31 @@ def _get_or_create_gateway_token(token_dir: Path) -> str: return _secrets.token_urlsafe(32) +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" + + +@app.get("/api/port-base-url") +async def api_port_base_url(request: Request): + """Return the base URL the frontend should use to construct port-specific links. + + Returns 'http://localhost' when accessed locally, or the tunnel proxy + base URL when accessed through the cloud. + """ + return {"base": _port_base_url(request)} + + @app.get("/api/containers/{name}/gateway-url") -async def api_gateway_url(name: str): +async def api_gateway_url(name: str, request: Request): """Return the openclaw dashboard URL with device token, if the container has openclaw.""" cname = _container_name(name) _, _, _, home = await asyncio.to_thread(_get_container_user, cname) @@ -440,11 +463,12 @@ async def api_gateway_url(name: str): token = _read_gateway_token(token_dir) if not token: raise HTTPException(status_code=404, detail="openclaw device token not found") - return {"url": f"http://localhost:{OPENCLAW_GATEWAY_PORT}/#token={token}"} + base = _port_base_url(request) + return {"url": f"{base}:{OPENCLAW_GATEWAY_PORT}/#token={token}"} @app.post("/api/containers/{name}/gateway-pair") -async def api_gateway_pair(name: str): +async def api_gateway_pair(name: str, request: Request): """Run openclaw onboard inside the container to pair the gateway device.""" cname = _container_name(name) status = await asyncio.to_thread(_container_status, cname) @@ -458,6 +482,7 @@ async def api_gateway_pair(name: str): raise HTTPException(status_code=409, detail="openclaw is not installed in this container") installer = OpenclawInstaller() + port_base = _port_base_url(request) def task(): gateway_token = _get_or_create_gateway_token(token_dir) @@ -486,7 +511,7 @@ def task(): token = _read_gateway_token(token_dir) if token: - print(f"Paired! Dashboard: http://localhost:{OPENCLAW_GATEWAY_PORT}/#token={token}") + print(f"Paired! Dashboard: {port_base}:{OPENCLAW_GATEWAY_PORT}/#token={token}") else: print("Warning: pairing may not have succeeded — check container logs") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 49b030c..1e88dbb 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -52,6 +52,11 @@ export async function removePort(name: string, device: string): Promise { await request(`/containers/${name}/ports/${device}`, { method: 'DELETE' }); } +export async function getPortBaseUrl(): Promise { + const { base } = await request<{ base: string }>('/port-base-url'); + return base; +} + export async function getGatewayUrl(name: string): Promise<{ url: string }> { return request<{ url: string }>(`/containers/${name}/gateway-url`); } diff --git a/frontend/src/components/ContainerList.tsx b/frontend/src/components/ContainerList.tsx index 7a699d9..229eadb 100644 --- a/frontend/src/components/ContainerList.tsx +++ b/frontend/src/components/ContainerList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Container } from '../types'; -import { startContainer, stopContainer, deleteContainer, getGatewayUrl, gatewayPairStream, getOpenclawModel } from '../api/client'; +import { startContainer, stopContainer, deleteContainer, getGatewayUrl, getPortBaseUrl, gatewayPairStream, getOpenclawModel } from '../api/client'; import { SSEEvent } from '../types'; interface Props { @@ -21,8 +21,8 @@ const GATEWAY_PORTS: Record = { 18800: 'picoclaw', }; -// Ports that use token-based auth — URL fetched from API rather than constructed client-side. -const TOKEN_AUTH_PORTS = new Set([18789]); +// Port used by openclaw — URL includes an auth token so it's always fetched from the API. +const OPENCLAW_PORT_FOR_URL = 18789; // Port used by openclaw — used to detect whether to fetch the configured model. const OPENCLAW_PORT = 18789; @@ -54,10 +54,12 @@ function PairModal({ name, onClose, onPaired }: { name: string; onClose: () => v if (cancelled) return; if (event.type === 'log') { setLogs(prev => [...prev, event.msg ?? '']); - const match = (event.msg ?? '').match(/http:\/\/localhost:\d+\/#token=\S+/); - if (match) setPairedUrl(match[0]); } else if (event.type === 'done') { setDone(true); + // Fetch the URL from the API — it will be tunnel-aware. + getGatewayUrl(name).then(({ url }) => { + if (!cancelled) setPairedUrl(url); + }).catch(() => {}); } else if (event.type === 'error') { setLogs(prev => [...prev, `Error: ${event.msg}`]); setDone(true); @@ -112,18 +114,24 @@ function GatewayButton({ name, port, label }: { name: string; port: number; labe const [loading, setLoading] = useState(false); const fetchUrl = () => { - if (!TOKEN_AUTH_PORTS.has(port)) { - setUrl(`http://localhost:${port}`); + setLoading(true); + if (port !== OPENCLAW_PORT_FOR_URL) { + // Non-token ports: ask the server for the base URL so tunnel routing works. + getPortBaseUrl() + .then((base) => setUrl(`${base}:${port}`)) + .catch(() => setUrl(`http://localhost:${port}`)) + .finally(() => setLoading(false)); return; } - setLoading(true); getGatewayUrl(name) .then(({ url }) => { setUrl(url); setNotPaired(false); }) .catch((err) => { if (String(err).includes('404')) { setNotPaired(true); } else { - setUrl(`http://localhost:${port}`); + getPortBaseUrl() + .then((base) => setUrl(`${base}:${port}`)) + .catch(() => setUrl(`http://localhost:${port}`)); } }) .finally(() => setLoading(false)); @@ -174,7 +182,7 @@ function GatewayButton({ name, port, label }: { name: string; port: number; labe return ( Date: Sat, 11 Apr 2026 22:05:15 -0400 Subject: [PATCH 04/21] fix: import Request from fastapi in web/app.py Required by _port_base_url() added in the cloud tunnel-aware URL changes. Co-Authored-By: Claude Sonnet 4.6 --- ailab/web/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ailab/web/app.py b/ailab/web/app.py index e2de7ea..ceef676 100644 --- a/ailab/web/app.py +++ b/ailab/web/app.py @@ -15,7 +15,7 @@ import aiohttp import pylxd.exceptions -from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles From 31db68a689014bf98500ebeeb8b2ccd0972faa31 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sat, 11 Apr 2026 22:40:25 -0400 Subject: [PATCH 05/21] feat(cloud): implement tunnel client and snap wiring - 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 --- ailab/cloud.py | 371 +++++++++++++++++++++++++++++++++++ ailab/web/app.py | 14 +- snap/local/ailab-web-wrapper | 18 +- snap/snapcraft.yaml | 8 + 4 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 ailab/cloud.py diff --git a/ailab/cloud.py b/ailab/cloud.py new file mode 100644 index 0000000..eba2519 --- /dev/null +++ b/ailab/cloud.py @@ -0,0 +1,371 @@ +"""Cloud tunnel client for AI Lab. + +Connects outbound to an ailab-cloud hub over a persistent WebSocket, then +forwards HTTP requests and WebSocket connections from the hub to local ports +on this machine. + +Configuration (all optional — cloud tunnel is disabled when host/token are absent): + + AILAB_CLOUD_HOST Hub hostname, e.g. cloud.example.com + AILAB_CLOUD_TOKEN Tunnel registration token from the hub dashboard + AILAB_CLOUD_USER GitHub username registered on the hub + AILAB_CLOUD_DEVICE Device ID to register (default: system hostname) + AILAB_CLOUD_PORTS Comma-separated list of ports to advertise (e.g. "11500,18789") + +Usage from ailab web app: + + from ailab.cloud import CloudTunnelManager + manager = CloudTunnelManager.from_env() + if manager: + await manager.start() + ... + await manager.stop() + +Protocol (JSON over WebSocket text frames) +------------------------------------------ +Home device → Hub: + {"type": "register", "github_user": "...", "device_id": "...", "ports": [...]} + {"type": "response", "id": "", "status": 200, "headers": {...}, "body": ""} + {"type": "ws_opened", "conn_id": ""} + {"type": "ws_error", "conn_id": "", "error": "..."} + {"type": "ws_frame", "conn_id": "", "opcode": 1|2, "data": ""} + {"type": "ws_close", "conn_id": ""} + +Hub → Home device: + {"type": "registered"} + {"type": "request", "id": "", "method": "...", "path": "...", + "port": 11500, "headers": {...}, "body": ""} + {"type": "ws_open", "conn_id": "", "port": ..., "path": "..."} + {"type": "ws_frame", "conn_id": "", "opcode": 1|2, "data": ""} + {"type": "ws_close", "conn_id": ""} +""" + +import asyncio +import base64 +import json +import logging +import os +import socket +from dataclasses import dataclass, field + +import aiohttp + +logger = logging.getLogger("ailab.cloud") + +# Reconnect delay: start at 2 s, double each attempt, cap at 60 s. +_BACKOFF_BASE = 2 +_BACKOFF_MAX = 60 + +# Hop-by-hop headers that must not be forwarded. +_HOP_BY_HOP = frozenset({ + "connection", "keep-alive", "proxy-authenticate", + "proxy-authorization", "te", "trailers", + "transfer-encoding", "upgrade", +}) + + +@dataclass +class CloudConfig: + host: str + token: str + github_user: str + device_id: str + ports: list[int] = field(default_factory=list) + + @classmethod + def from_env(cls) -> "CloudConfig | None": + host = os.environ.get("AILAB_CLOUD_HOST", "").strip() + token = os.environ.get("AILAB_CLOUD_TOKEN", "").strip() + if not host or not token: + return None + # Strip any scheme the user may have included (e.g. "https://host" → "host"). + for scheme in ("https://", "http://", "wss://", "ws://"): + if host.startswith(scheme): + host = host[len(scheme):] + break + host = host.rstrip("/") + 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() + ports = [int(p) for p in ports_raw.split(",") if p.strip().isdigit()] if ports_raw else [] + return cls( + host=host, + token=token, + github_user=github_user, + device_id=device_id, + ports=ports, + ) + + @property + def ws_url(self) -> str: + return f"wss://{self.host}/tunnel/register?token={self.token}" + + +class CloudTunnelManager: + """Manages a persistent WebSocket tunnel to the ailab-cloud hub.""" + + def __init__(self, config: CloudConfig) -> None: + self._config = config + self._task: asyncio.Task | None = None + self._stop_event = asyncio.Event() + # Active proxied WebSocket connections keyed by conn_id. + self._ws_connections: dict[str, aiohttp.ClientWebSocketResponse] = {} + self._ws_sessions: dict[str, aiohttp.ClientSession] = {} + self._tunnel_ws: aiohttp.ClientWebSocketResponse | None = None + + @classmethod + def from_env(cls) -> "CloudTunnelManager | None": + config = CloudConfig.from_env() + if config is None: + return None + return cls(config) + + async def start(self) -> None: + """Start the background reconnect loop.""" + if self._task and not self._task.done(): + return + self._stop_event.clear() + self._task = asyncio.create_task(self._run(), name="cloud-tunnel") + logger.info( + "Cloud tunnel started — hub: %s device: %s", + self._config.host, + self._config.device_id, + ) + + async def stop(self) -> None: + """Signal the reconnect loop to exit and wait for it.""" + self._stop_event.set() + if self._tunnel_ws and not self._tunnel_ws.closed: + await self._tunnel_ws.close() + if self._task: + try: + await asyncio.wait_for(self._task, timeout=5) + except (asyncio.TimeoutError, asyncio.CancelledError): + self._task.cancel() + # Close any open proxied WS sessions. + for session in list(self._ws_sessions.values()): + await session.close() + logger.info("Cloud tunnel stopped") + + # ── Internal reconnect loop ─────────────────────────────────────────────── + + async def _run(self) -> None: + delay = _BACKOFF_BASE + while not self._stop_event.is_set(): + try: + await self._connect_and_serve() + delay = _BACKOFF_BASE # reset on clean disconnect + except asyncio.CancelledError: + return + except Exception as exc: + logger.warning("Tunnel disconnected: %s — reconnecting in %ds", exc, delay) + + if self._stop_event.is_set(): + return + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=delay) + except asyncio.TimeoutError: + pass + delay = min(delay * 2, _BACKOFF_MAX) + + async def _connect_and_serve(self) -> None: + cfg = self._config + connector = aiohttp.TCPConnector(ssl=True) + async with aiohttp.ClientSession(connector=connector) as session: + logger.info("Connecting to hub at %s", cfg.ws_url) + async with session.ws_connect(cfg.ws_url) as ws: + self._tunnel_ws = ws + logger.info("Tunnel WebSocket connected") + + # Send registration message. + await ws.send_json({ + "type": "register", + "github_user": cfg.github_user, + "device_id": cfg.device_id, + "ports": cfg.ports, + }) + + async for msg in ws: + if self._stop_event.is_set(): + return + if msg.type == aiohttp.WSMsgType.TEXT: + try: + envelope = json.loads(msg.data) + except json.JSONDecodeError: + logger.warning("Received non-JSON message from hub") + continue + await self._dispatch(ws, envelope) + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + logger.info("Tunnel WS closed (type=%s)", msg.type) + return + + # ── Envelope dispatch ───────────────────────────────────────────────────── + + async def _dispatch( + self, + tunnel_ws: aiohttp.ClientWebSocketResponse, + envelope: dict, + ) -> None: + msg_type = envelope.get("type") + if msg_type == "registered": + logger.info("Registered with hub as device '%s'", self._config.device_id) + elif msg_type == "request": + asyncio.create_task(self._handle_http(tunnel_ws, envelope)) + elif msg_type == "ws_open": + asyncio.create_task(self._handle_ws_open(tunnel_ws, envelope)) + elif msg_type == "ws_frame": + await self._handle_ws_frame(envelope) + elif msg_type == "ws_close": + await self._handle_ws_close(envelope) + else: + logger.debug("Unknown envelope type: %s", msg_type) + + # ── HTTP proxy ──────────────────────────────────────────────────────────── + + async def _handle_http( + self, + tunnel_ws: aiohttp.ClientWebSocketResponse, + envelope: dict, + ) -> None: + req_id = envelope.get("id", "") + port = envelope.get("port", 80) + method = envelope.get("method", "GET").upper() + path = envelope.get("path", "/") + headers = dict(envelope.get("headers", {})) + body_b64 = envelope.get("body", "") + + body: bytes | None = base64.b64decode(body_b64) if body_b64 else None + + fwd_headers = {k: v for k, v in headers.items() if k.lower() not in _HOP_BY_HOP} + + 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: + resp_body = await resp.read() + resp_headers = { + k: v for k, v in resp.headers.items() + if k.lower() not in _HOP_BY_HOP + } + response_envelope = { + "type": "response", + "id": req_id, + "status": resp.status, + "headers": resp_headers, + "body": base64.b64encode(resp_body).decode(), + } + except Exception as exc: + logger.warning("HTTP proxy error for %s %s: %s", method, url, exc) + response_envelope = { + "type": "response", + "id": req_id, + "status": 502, + "headers": {}, + "body": "", + "error": str(exc), + } + + try: + await tunnel_ws.send_json(response_envelope) + except Exception as exc: + logger.warning("Failed to send HTTP response to hub: %s", exc) + + # ── WebSocket proxy ─────────────────────────────────────────────────────── + + async def _handle_ws_open( + self, + tunnel_ws: aiohttp.ClientWebSocketResponse, + envelope: dict, + ) -> None: + conn_id = envelope.get("conn_id", "") + port = envelope.get("port", 80) + path = envelope.get("path", "/") + url = f"ws://127.0.0.1:{port}{path}" + + try: + session = aiohttp.ClientSession() + local_ws = await session.ws_connect(url) + self._ws_connections[conn_id] = local_ws + self._ws_sessions[conn_id] = session + + # Acknowledge the open. + await tunnel_ws.send_json({"type": "ws_opened", "conn_id": conn_id}) + logger.debug("WS proxy opened conn=%s → %s", conn_id, url) + + # Start relay task: local → tunnel. + asyncio.create_task( + self._relay_local_to_tunnel(conn_id, local_ws, tunnel_ws), + name=f"ws-relay-{conn_id}", + ) + except Exception as exc: + logger.warning("WS proxy open failed conn=%s url=%s: %s", conn_id, url, exc) + await tunnel_ws.send_json({"type": "ws_error", "conn_id": conn_id, "error": str(exc)}) + + async def _relay_local_to_tunnel( + self, + conn_id: str, + local_ws: aiohttp.ClientWebSocketResponse, + tunnel_ws: aiohttp.ClientWebSocketResponse, + ) -> None: + """Forward frames from a local service WS to the hub tunnel.""" + try: + async for msg in local_ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await tunnel_ws.send_json({ + "type": "ws_frame", + "conn_id": conn_id, + "opcode": 1, + "data": base64.b64encode(msg.data.encode()).decode(), + }) + elif msg.type == aiohttp.WSMsgType.BINARY: + await tunnel_ws.send_json({ + "type": "ws_frame", + "conn_id": conn_id, + "opcode": 2, + "data": base64.b64encode(msg.data).decode(), + }) + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + break + except Exception as exc: + logger.debug("WS relay error conn=%s: %s", conn_id, exc) + finally: + self._ws_connections.pop(conn_id, None) + session = self._ws_sessions.pop(conn_id, None) + if session: + await session.close() + try: + await tunnel_ws.send_json({"type": "ws_close", "conn_id": conn_id}) + except Exception: + pass + + async def _handle_ws_frame(self, envelope: dict) -> None: + """Forward a frame from the hub to the local WebSocket connection.""" + conn_id = envelope.get("conn_id", "") + local_ws = self._ws_connections.get(conn_id) + if local_ws is None or local_ws.closed: + return + opcode = envelope.get("opcode", 1) + data = base64.b64decode(envelope.get("data", "")) + try: + if opcode == 2: + await local_ws.send_bytes(data) + else: + await local_ws.send_str(data.decode()) + except Exception as exc: + logger.debug("WS frame send error conn=%s: %s", conn_id, exc) + + async def _handle_ws_close(self, envelope: dict) -> None: + """Close a proxied local WebSocket connection.""" + conn_id = envelope.get("conn_id", "") + local_ws = self._ws_connections.pop(conn_id, None) + session = self._ws_sessions.pop(conn_id, None) + if local_ws and not local_ws.closed: + try: + await local_ws.close() + except Exception: + pass + if session: + await session.close() + logger.debug("WS proxy closed conn=%s", conn_id) diff --git a/ailab/web/app.py b/ailab/web/app.py index ceef676..081b836 100644 --- a/ailab/web/app.py +++ b/ailab/web/app.py @@ -15,6 +15,7 @@ import aiohttp import pylxd.exceptions +from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse @@ -49,6 +50,7 @@ OPENCLAW_GATEWAY_PORT, OpenclawInstaller, ) +from ailab.cloud import CloudTunnelManager # ── App setup ───────────────────────────────────────────────────────────────── @@ -75,7 +77,17 @@ def _detect_lemonade_port() -> int | None: pass return None -app = FastAPI(title="ailab web interface") +@asynccontextmanager +async def lifespan(app: FastAPI): + tunnel = CloudTunnelManager.from_env() + if tunnel: + await tunnel.start() + yield + if tunnel: + await tunnel.stop() + + +app = FastAPI(title="ailab web interface", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/snap/local/ailab-web-wrapper b/snap/local/ailab-web-wrapper index 6118d6b..7b68bf9 100755 --- a/snap/local/ailab-web-wrapper +++ b/snap/local/ailab-web-wrapper @@ -1,10 +1,26 @@ #!/bin/bash -# Wrapper for the ailab web daemon that reads snap config for host and port. +# Wrapper for the ailab web daemon that reads snap config for host, port, +# and optional cloud tunnel settings. set -euo pipefail HOST=$(snapctl get web.host) PORT=$(snapctl get web.port) +# ── Cloud tunnel settings (optional) ───────────────────────────────────────── +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) +CLOUD_PORTS=$(snapctl get cloud.ports 2>/dev/null || true) + +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 + exec "$SNAP/bin/ailab" web \ --host "${HOST:-127.0.0.1}" \ --port "${PORT:-11500}" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 1470b12..500053b 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -23,6 +23,14 @@ description: | snap set ailab web.host=0.0.0.0 snap set ailab web.port=11500 + Connect to an AI Lab Cloud hub for remote access: + + snap set ailab cloud.host=cloud.example.com + snap set ailab cloud.token= + snap set ailab cloud.user= + snap set ailab cloud.device= + snap set ailab cloud.ports=11500,18789 + license: GPL-3.0+ grade: stable confinement: strict From 0c0b74ee12b6f047d4748912222f9d84e2458825 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sat, 11 Apr 2026 22:42:44 -0400 Subject: [PATCH 06/21] fix(cloud): default to port 11500 when AILAB_CLOUD_PORTS not set 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 --- ailab/cloud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ailab/cloud.py b/ailab/cloud.py index eba2519..aa4965a 100644 --- a/ailab/cloud.py +++ b/ailab/cloud.py @@ -87,7 +87,7 @@ def from_env(cls) -> "CloudConfig | None": 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() - ports = [int(p) for p in ports_raw.split(",") if p.strip().isdigit()] if ports_raw else [] + ports = [int(p) for p in ports_raw.split(",") if p.strip().isdigit()] if ports_raw else [11500] return cls( host=host, token=token, From 2b8c5f345ce76fe8ce2f4eb23d8f83a5c76d2e09 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sat, 11 Apr 2026 23:11:51 -0400 Subject: [PATCH 07/21] fix(tunnel): use relative paths so the app works behind a sub-path proxy 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 --- frontend/src/api/client.ts | 22 +++++++++++++++++++++- frontend/src/components/LogStream.tsx | 4 ++-- frontend/src/components/Terminal.tsx | 4 ++-- frontend/src/vite-env.d.ts | 1 + frontend/vite.config.ts | 1 + 5 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 frontend/src/vite-env.d.ts diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1e88dbb..db6bdbf 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,6 +1,26 @@ import { Container, LemonadeRecipe, Package, PortProxy, SSEEvent, SystemUser } from '../types'; -const BASE = '/api'; +// Use Vite's BASE_URL so API calls resolve correctly whether the app is served +// from the root (local: '/') or from a tunnel sub-path (e.g. '/d/device:11500/'). +const BASE = `${import.meta.env.BASE_URL}api`; + +/** + * Construct an absolute WebSocket URL for `path` (e.g. '/api/ws/shell/mybox'). + * + * Local: ws://localhost:11500/api/ws/shell/mybox + * Tunnel: wss://hub.example.com/d/framework:11500/ws/api/ws/shell/mybox + * + * The hub's proxy route for path-based WebSocket is /d/{target}/ws/{path}, + * so we insert '/ws' after the device prefix when running behind the tunnel. + */ +export function wsUrl(path: string): string { + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const m = window.location.pathname.match(/^(\/d\/[^/]+)\//); + if (m) { + return `${proto}//${window.location.host}${m[1]}/ws${path}`; + } + return `${proto}//${window.location.host}${path}`; +} async function request(path: string, options?: RequestInit): Promise { const resp = await fetch(`${BASE}${path}`, options); diff --git a/frontend/src/components/LogStream.tsx b/frontend/src/components/LogStream.tsx index cc712ee..df19b3c 100644 --- a/frontend/src/components/LogStream.tsx +++ b/frontend/src/components/LogStream.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { wsUrl } from '../api/client'; interface Props { containerName: string; @@ -11,8 +12,7 @@ export function LogStream({ containerName, onClose }: Props) { const wsRef = useRef(null); useEffect(() => { - const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/logs/${containerName}`); + const ws = new WebSocket(wsUrl(`/api/ws/logs/${containerName}`)); wsRef.current = ws; ws.onmessage = (e) => { diff --git a/frontend/src/components/Terminal.tsx b/frontend/src/components/Terminal.tsx index f500adf..3ecc7e0 100644 --- a/frontend/src/components/Terminal.tsx +++ b/frontend/src/components/Terminal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react'; +import { wsUrl } from '../api/client'; import { Terminal as XTerm } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; @@ -23,8 +24,7 @@ export function Terminal({ containerName, onClose }: Props) { let ws: WebSocket | null = null; function connectWs() { - const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(`${proto}//${window.location.host}/api/ws/shell/${containerName}`); + ws = new WebSocket(wsUrl(`/api/ws/shell/${containerName}`)); ws.binaryType = 'arraybuffer'; ws.onopen = () => { diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 241ee03..6dd2c21 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], + base: './', build: { outDir: '../ailab/web/static', emptyOutDir: true, From bd874f79eb5d1629b2e607f34907e6e9690b53dc Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sat, 11 Apr 2026 23:31:02 -0400 Subject: [PATCH 08/21] fix(tunnel): align WebSocket URL with hub's /d/{target}/{path} route 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 --- frontend/src/api/client.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index db6bdbf..cf07d34 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -8,16 +8,16 @@ const BASE = `${import.meta.env.BASE_URL}api`; * Construct an absolute WebSocket URL for `path` (e.g. '/api/ws/shell/mybox'). * * Local: ws://localhost:11500/api/ws/shell/mybox - * Tunnel: wss://hub.example.com/d/framework:11500/ws/api/ws/shell/mybox + * Tunnel: wss://hub.example.com/d/framework:11500/api/ws/shell/mybox * - * The hub's proxy route for path-based WebSocket is /d/{target}/ws/{path}, - * so we insert '/ws' after the device prefix when running behind the tunnel. + * The hub's WebSocket proxy route mirrors the HTTP route: /d/{target}/{path}, + * so we just prefix with the device segment when running behind the tunnel. */ export function wsUrl(path: string): string { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const m = window.location.pathname.match(/^(\/d\/[^/]+)\//); if (m) { - return `${proto}//${window.location.host}${m[1]}/ws${path}`; + return `${proto}//${window.location.host}${m[1]}${path}`; } return `${proto}//${window.location.host}${path}`; } From 94d93fafd54f2706948f132aa9c72e3388f4449d Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sun, 12 Apr 2026 19:33:10 -0400 Subject: [PATCH 09/21] fix(tunnel): pass gatewayUrl in hash so openclaw connects via tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= Tunnel: .../d/Nova:18789/#token=&gatewayUrl=wss://.../d/Nova:18789/__openclaw__/ws Co-Authored-By: Claude Sonnet 4.6 --- ailab/installers/openclaw.py | 2 ++ ailab/web/app.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/ailab/installers/openclaw.py b/ailab/installers/openclaw.py index 7ba6438..62b0813 100644 --- a/ailab/installers/openclaw.py +++ b/ailab/installers/openclaw.py @@ -28,6 +28,8 @@ ] OPENCLAW_GATEWAY_PORT = 18789 OPENCLAW_PROXY_DEVICE = "proxy-out-openclaw" +# WebSocket path served by the openclaw gateway (used to construct tunnel URLs) +OPENCLAW_WS_PATH = "/__openclaw__/ws" class OpenclawInstaller: diff --git a/ailab/web/app.py b/ailab/web/app.py index 081b836..6f59688 100644 --- a/ailab/web/app.py +++ b/ailab/web/app.py @@ -8,6 +8,7 @@ import sys import time as _time import urllib.error as _urllib_error +import urllib.parse as _urllib_parse import urllib.request as _urllib_request from pathlib import Path from typing import Any @@ -48,6 +49,7 @@ from ailab.installers import INSTALLERS, get_installer from ailab.installers.openclaw import ( OPENCLAW_GATEWAY_PORT, + OPENCLAW_WS_PATH, OpenclawInstaller, ) from ailab.cloud import CloudTunnelManager @@ -476,7 +478,17 @@ async def api_gateway_url(name: str, request: Request): if not token: raise HTTPException(status_code=404, detail="openclaw device token not found") base = _port_base_url(request) - return {"url": f"{base}:{OPENCLAW_GATEWAY_PORT}/#token={token}"} + port_url = f"{base}:{OPENCLAW_GATEWAY_PORT}" + tunnel_base = request.headers.get("x-ailab-tunnel-base", "").strip() + if tunnel_base: + # When served through the cloud tunnel, openclaw's JS can't reach the + # stored ws://localhost:18789 gateway URL. Pass gatewayUrl in the hash + # so the control-ui connects via the tunnel instead. + ws_base = port_url.replace("https://", "wss://", 1).replace("http://", "ws://", 1) + gateway_ws = f"{ws_base}{OPENCLAW_WS_PATH}" + params = _urllib_parse.urlencode({"token": token, "gatewayUrl": gateway_ws}) + return {"url": f"{port_url}/#{params}"} + return {"url": f"{port_url}/#token={token}"} @app.post("/api/containers/{name}/gateway-pair") From 0f9fcd4e582f06f300693cdf6e9700f5c1e893fb Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sun, 12 Apr 2026 19:42:19 -0400 Subject: [PATCH 10/21] fix(tunnel): whitelist hub origin in openclaw gateway allowedOrigins 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:// 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 --- ailab/web/app.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/ailab/web/app.py b/ailab/web/app.py index 6f59688..59252f4 100644 --- a/ailab/web/app.py +++ b/ailab/web/app.py @@ -468,11 +468,55 @@ async def api_port_base_url(request: Request): return {"base": _port_base_url(request)} +def _ensure_gateway_cloud_origin_sync( + cname: str, home: str, uid: int, gid: int, hub_origin: str +) -> None: + """Add hub_origin to gateway.controlUi.allowedOrigins in openclaw.json if absent. + + The openclaw gateway rejects WebSocket connections whose Origin header is not + in allowedOrigins. When the control-ui is opened through the cloud tunnel the + browser sends Origin: https:// which is not localhost, so we must + explicitly permit it. Restarts the gateway service after patching so the + new config takes effect immediately. + """ + openclaw_json_path = f"{home}/.openclaw/openclaw.json" + try: + raw = pull_file(cname, openclaw_json_path) + config = json.loads(raw) + except Exception as exc: + logger.warning("Could not read openclaw.json in %s: %s", cname, exc) + return + + origins: list = ( + config.get("gateway", {}) + .get("controlUi", {}) + .get("allowedOrigins", []) + ) + if hub_origin in origins: + return # already configured, no restart needed + + origins = list(origins) + origins.append(hub_origin) + config.setdefault("gateway", {}).setdefault("controlUi", {})["allowedOrigins"] = origins + try: + push_file(cname, openclaw_json_path, json.dumps(config, indent=2) + "\n") + except Exception as exc: + logger.warning("Could not write openclaw.json in %s: %s", cname, exc) + return + + installer = OpenclawInstaller() + installer._restart_gateway(cname, uid, gid, home) + logger.info( + "Added %s to openclaw allowedOrigins and restarted gateway in %s", + hub_origin, cname, + ) + + @app.get("/api/containers/{name}/gateway-url") async def api_gateway_url(name: str, request: Request): """Return the openclaw dashboard URL with device token, if the container has openclaw.""" cname = _container_name(name) - _, _, _, home = await asyncio.to_thread(_get_container_user, cname) + username, uid, gid, home = await asyncio.to_thread(_get_container_user, cname) token_dir = container_config_dir(name, home) / "openclaw" token = _read_gateway_token(token_dir) if not token: @@ -487,6 +531,15 @@ async def api_gateway_url(name: str, request: Request): ws_base = port_url.replace("https://", "wss://", 1).replace("http://", "ws://", 1) gateway_ws = f"{ws_base}{OPENCLAW_WS_PATH}" params = _urllib_parse.urlencode({"token": token, "gatewayUrl": gateway_ws}) + # Ensure the hub origin is whitelisted in the gateway's CORS config. + # Runs as a background task so the URL response is not delayed. + parsed = _urllib_parse.urlparse(tunnel_base) + hub_origin = f"{parsed.scheme}://{parsed.netloc}" + asyncio.create_task( + asyncio.to_thread( + _ensure_gateway_cloud_origin_sync, cname, home, uid, gid, hub_origin + ) + ) return {"url": f"{port_url}/#{params}"} return {"url": f"{port_url}/#token={token}"} From d0eccca4f322b3174755fb106b29f3e13c1123c9 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sun, 12 Apr 2026 19:44:46 -0400 Subject: [PATCH 11/21] fix(tunnel): await origin whitelist before returning gateway URL 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 --- ailab/web/app.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/ailab/web/app.py b/ailab/web/app.py index 59252f4..5c2b64c 100644 --- a/ailab/web/app.py +++ b/ailab/web/app.py @@ -531,14 +531,13 @@ async def api_gateway_url(name: str, request: Request): ws_base = port_url.replace("https://", "wss://", 1).replace("http://", "ws://", 1) gateway_ws = f"{ws_base}{OPENCLAW_WS_PATH}" params = _urllib_parse.urlencode({"token": token, "gatewayUrl": gateway_ws}) - # Ensure the hub origin is whitelisted in the gateway's CORS config. - # Runs as a background task so the URL response is not delayed. + # Ensure the hub origin is whitelisted in the gateway's CORS config + # before returning the URL — the gateway must be ready when the browser + # opens it, so we await rather than fire-and-forget. parsed = _urllib_parse.urlparse(tunnel_base) hub_origin = f"{parsed.scheme}://{parsed.netloc}" - asyncio.create_task( - asyncio.to_thread( - _ensure_gateway_cloud_origin_sync, cname, home, uid, gid, hub_origin - ) + await asyncio.to_thread( + _ensure_gateway_cloud_origin_sync, cname, home, uid, gid, hub_origin ) return {"url": f"{port_url}/#{params}"} return {"url": f"{port_url}/#token={token}"} From 116ab8517f5f3c684a01f4e23be1beb520132268 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sun, 12 Apr 2026 20:08:03 -0400 Subject: [PATCH 12/21] fix(tunnel): pass gateway token as Authorization Bearer on WS upgrade 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 --- ailab/cloud.py | 16 ++++++++++++++-- ailab/web/app.py | 7 ++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/ailab/cloud.py b/ailab/cloud.py index aa4965a..d9b4f7a 100644 --- a/ailab/cloud.py +++ b/ailab/cloud.py @@ -47,6 +47,7 @@ import os import socket from dataclasses import dataclass, field +from urllib.parse import parse_qs, urlencode, urlparse import aiohttp @@ -282,11 +283,22 @@ async def _handle_ws_open( conn_id = envelope.get("conn_id", "") port = envelope.get("port", 80) path = envelope.get("path", "/") - url = f"ws://127.0.0.1:{port}{path}" + + # Extract a `token` query param injected by the ailab web app for + # services that require Authorization: Bearer (e.g. openclaw gateway). + # Strip it from the local URL so the service doesn't see a stray param. + parsed_path = urlparse(path) + qs = parse_qs(parsed_path.query, keep_blank_values=True) + extra_headers: dict[str, str] = {} + if "token" in qs: + extra_headers["Authorization"] = f"Bearer {qs.pop('token')[0]}" + clean_qs = urlencode({k: v[0] for k, v in qs.items()}) + local_path = parsed_path._replace(query=clean_qs).geturl() + url = f"ws://127.0.0.1:{port}{local_path}" try: session = aiohttp.ClientSession() - local_ws = await session.ws_connect(url) + local_ws = await session.ws_connect(url, headers=extra_headers or None) self._ws_connections[conn_id] = local_ws self._ws_sessions[conn_id] = session diff --git a/ailab/web/app.py b/ailab/web/app.py index 5c2b64c..294c503 100644 --- a/ailab/web/app.py +++ b/ailab/web/app.py @@ -529,7 +529,12 @@ async def api_gateway_url(name: str, request: Request): # stored ws://localhost:18789 gateway URL. Pass gatewayUrl in the hash # so the control-ui connects via the tunnel instead. ws_base = port_url.replace("https://", "wss://", 1).replace("http://", "ws://", 1) - gateway_ws = f"{ws_base}{OPENCLAW_WS_PATH}" + # Embed the token as a query param on the WS URL so cloud.py can + # forward it as Authorization: Bearer when connecting locally. + gateway_ws = ( + f"{ws_base}{OPENCLAW_WS_PATH}" + f"?token={_urllib_parse.quote(token, safe='')}" + ) params = _urllib_parse.urlencode({"token": token, "gatewayUrl": gateway_ws}) # Ensure the hub origin is whitelisted in the gateway's CORS config # before returning the URL — the gateway must be ready when the browser From c2308ce5ab4ec47ff37a67adde823d6cf0eeff46 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sun, 12 Apr 2026 20:10:33 -0400 Subject: [PATCH 13/21] fix(openclaw): reset-failed before gateway restart; clamp contextWindow 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 --- ailab/installers/openclaw.py | 1 + ailab/web/app.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ailab/installers/openclaw.py b/ailab/installers/openclaw.py index 62b0813..50d9c75 100644 --- a/ailab/installers/openclaw.py +++ b/ailab/installers/openclaw.py @@ -176,6 +176,7 @@ def _restart_gateway(self, cname: str, uid: int, gid: int, home: str): cname, ["bash", "-c", "systemctl --user daemon-reload 2>/dev/null || true" + " && systemctl --user reset-failed openclaw-gateway 2>/dev/null || true" " && systemctl --user restart openclaw-gateway 2>/dev/null || true"], uid=uid, gid=gid, env={ diff --git a/ailab/web/app.py b/ailab/web/app.py index 294c503..35efcca 100644 --- a/ailab/web/app.py +++ b/ailab/web/app.py @@ -776,7 +776,7 @@ def _stream_lemonade_pull(resp, model_name: str) -> None: def _lemonade_model_entry(m: dict) -> dict: """Build an openclaw model config dict from a lemonade /api/v1/models entry.""" labels = m.get("labels") or [] - ctx_size = (m.get("recipe_options") or {}).get("ctx_size", 32768) + ctx_size = max((m.get("recipe_options") or {}).get("ctx_size", 32768) or 32768, 1) return { "id": m["id"], "name": m["id"], @@ -809,7 +809,7 @@ def task(): model_name = recipe.get("model_name", "") recipe_label = recipe.get("_name", model_name) has_vision = "vision" in recipe.get("labels", []) - ctx_size = recipe.get("recipe_options", {}).get("ctx_size", 32768) + ctx_size = max(recipe.get("recipe_options", {}).get("ctx_size", 32768) or 32768, 1) print(f"Importing recipe: {recipe_label}") From ae9e0fe70ff461dc4ca4733059026f5285c41fa3 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sun, 12 Apr 2026 20:16:44 -0400 Subject: [PATCH 14/21] fix(tunnel): forward browser Origin header to local WS service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ailab/cloud.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ailab/cloud.py b/ailab/cloud.py index d9b4f7a..18901b2 100644 --- a/ailab/cloud.py +++ b/ailab/cloud.py @@ -296,6 +296,12 @@ async def _handle_ws_open( local_path = parsed_path._replace(query=clean_qs).geturl() url = f"ws://127.0.0.1:{port}{local_path}" + # Forward browser headers sent by the hub (e.g. Origin) so local + # services that enforce CORS on WS upgrades see the real browser origin. + fwd_headers: dict = envelope.get("headers", {}) + for k, v in fwd_headers.items(): + extra_headers.setdefault(k.capitalize(), v) + try: session = aiohttp.ClientSession() local_ws = await session.ws_connect(url, headers=extra_headers or None) From b0b9bd5c7f2472d934fdfad92871b80d9f94c2f8 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sun, 12 Apr 2026 20:24:03 -0400 Subject: [PATCH 15/21] fix(openclaw): patch openclaw.json as container user, not via push_file 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 --- ailab/web/app.py | 65 +++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/ailab/web/app.py b/ailab/web/app.py index 35efcca..82347e0 100644 --- a/ailab/web/app.py +++ b/ailab/web/app.py @@ -478,38 +478,51 @@ def _ensure_gateway_cloud_origin_sync( browser sends Origin: https:// which is not localhost, so we must explicitly permit it. Restarts the gateway service after patching so the new config takes effect immediately. - """ - openclaw_json_path = f"{home}/.openclaw/openclaw.json" - try: - raw = pull_file(cname, openclaw_json_path) - config = json.loads(raw) - except Exception as exc: - logger.warning("Could not read openclaw.json in %s: %s", cname, exc) - return - origins: list = ( - config.get("gateway", {}) - .get("controlUi", {}) - .get("allowedOrigins", []) + Uses container_exec (running as the container user) rather than push_file so + that the patched openclaw.json keeps the correct owner/permissions and the + gateway (also running as that user) can read it after restart. + """ + patch_py = ( + "import json, os, sys\n" + "p = os.path.join(os.environ['HOME'], '.openclaw', 'openclaw.json')\n" + "try:\n" + " config = json.loads(open(p).read())\n" + "except Exception as e:\n" + " print('read-error:' + str(e))\n" + " sys.exit(0)\n" + f"hub_origin = {hub_origin!r}\n" + "origins = config.get('gateway', {}).get('controlUi', {}).get('allowedOrigins', [])\n" + "if hub_origin in origins:\n" + " print('already-present')\n" + " sys.exit(0)\n" + "origins = list(origins) + [hub_origin]\n" + "config.setdefault('gateway', {}).setdefault('controlUi', {})['allowedOrigins'] = origins\n" + "open(p, 'w').write(json.dumps(config, indent=2) + '\\n')\n" + "print('patched')\n" ) - if hub_origin in origins: - return # already configured, no restart needed - - origins = list(origins) - origins.append(hub_origin) - config.setdefault("gateway", {}).setdefault("controlUi", {})["allowedOrigins"] = origins - try: - push_file(cname, openclaw_json_path, json.dumps(config, indent=2) + "\n") - except Exception as exc: - logger.warning("Could not write openclaw.json in %s: %s", cname, exc) + exit_code, stdout, stderr = container_exec( + cname, + ["python3"], + uid=uid, gid=gid, + stdin=patch_py.encode(), + env={"HOME": home}, + check=False, + ) + stdout = (stdout or "").strip() + if "already-present" in stdout: + logger.debug("openclaw allowedOrigins already has %s in %s", hub_origin, cname) + return + if "patched" not in stdout: + logger.warning( + "Could not patch openclaw.json in %s (exit=%s stdout=%r stderr=%r)", + cname, exit_code, stdout, stderr, + ) return + logger.info("Added %s to openclaw allowedOrigins in %s, restarting gateway", hub_origin, cname) installer = OpenclawInstaller() installer._restart_gateway(cname, uid, gid, home) - logger.info( - "Added %s to openclaw allowedOrigins and restarted gateway in %s", - hub_origin, cname, - ) @app.get("/api/containers/{name}/gateway-url") From a1a18eeff8e7d018e9cd489718241ae168321803 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sun, 12 Apr 2026 21:13:39 -0400 Subject: [PATCH 16/21] Harden cloud tunnel registration 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> --- README.md | 4 +-- ailab/cloud.py | 69 ++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7b9e499..fe013f2 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,7 @@ sudo snap set ailab cloud.enabled=true sudo snap set ailab cloud.host=https://cloud.example.com sudo snap set ailab cloud.user=yourname sudo snap set ailab cloud.token= -sudo snap set ailab cloud.device-id=myhome +sudo snap set ailab cloud.device-id=myhome # lowercase letters, digits, and hyphens only sudo snap restart ailab.web ``` @@ -355,7 +355,7 @@ tools. | `cloud.host` | Hub URL, 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; becomes part of the URL | +| `cloud.device-id` | Short identifier for this machine; use lowercase letters, digits, and hyphens only | ```bash snap get ailab cloud # view all cloud settings at once diff --git a/ailab/cloud.py b/ailab/cloud.py index 18901b2..a93f5c4 100644 --- a/ailab/cloud.py +++ b/ailab/cloud.py @@ -6,7 +6,7 @@ Configuration (all optional — cloud tunnel is disabled when host/token are absent): - AILAB_CLOUD_HOST Hub hostname, e.g. cloud.example.com + AILAB_CLOUD_HOST Hub hostname or URL, e.g. cloud.example.com or http://localhost:8080 AILAB_CLOUD_TOKEN Tunnel registration token from the hub dashboard AILAB_CLOUD_USER GitHub username registered on the hub AILAB_CLOUD_DEVICE Device ID to register (default: system hostname) @@ -24,7 +24,8 @@ Protocol (JSON over WebSocket text frames) ------------------------------------------ Home device → Hub: - {"type": "register", "github_user": "...", "device_id": "...", "ports": [...]} + {"type": "register", "github_user": "...", "device_id": "...", + "ports": [...], "token": "..."} {"type": "response", "id": "", "status": 200, "headers": {...}, "body": ""} {"type": "ws_opened", "conn_id": ""} {"type": "ws_error", "conn_id": "", "error": "..."} @@ -45,6 +46,7 @@ import json import logging import os +import re import socket from dataclasses import dataclass, field from urllib.parse import parse_qs, urlencode, urlparse @@ -53,6 +55,8 @@ logger = logging.getLogger("ailab.cloud") +_DEVICE_ID_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") + # Reconnect delay: start at 2 s, double each attempt, cap at 60 s. _BACKOFF_BASE = 2 _BACKOFF_MAX = 60 @@ -71,35 +75,69 @@ class CloudConfig: token: str github_user: str device_id: str + secure: bool = True ports: list[int] = field(default_factory=list) + @staticmethod + def _normalize_ports(ports_raw: str) -> list[int]: + if not ports_raw: + return [11500] + + ports: list[int] = [] + seen: set[int] = set() + for item in ports_raw.split(","): + item = item.strip() + if not item: + continue + if not item.isdigit(): + raise ValueError(f"Invalid port value: {item!r}") + port = int(item) + if not 1 <= port <= 65535: + raise ValueError(f"Port out of range: {port}") + if port not in seen: + ports.append(port) + seen.add(port) + + if not ports: + raise ValueError("At least one cloud port must be configured") + + return ports + @classmethod def from_env(cls) -> "CloudConfig | None": host = os.environ.get("AILAB_CLOUD_HOST", "").strip() token = os.environ.get("AILAB_CLOUD_TOKEN", "").strip() if not host or not token: return None + secure = True # Strip any scheme the user may have included (e.g. "https://host" → "host"). for scheme in ("https://", "http://", "wss://", "ws://"): if host.startswith(scheme): + secure = scheme in ("https://", "wss://") host = host[len(scheme):] break host = host.rstrip("/") 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() - ports = [int(p) for p in ports_raw.split(",") if p.strip().isdigit()] if ports_raw else [11500] + 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, ) @property def ws_url(self) -> str: - return f"wss://{self.host}/tunnel/register?token={self.token}" + scheme = "wss" if self.secure else "ws" + return f"{scheme}://{self.host}/tunnel/register" class CloudTunnelManager: @@ -171,7 +209,7 @@ async def _run(self) -> None: async def _connect_and_serve(self) -> None: cfg = self._config - connector = aiohttp.TCPConnector(ssl=True) + connector = aiohttp.TCPConnector(ssl=cfg.secure) async with aiohttp.ClientSession(connector=connector) as session: logger.info("Connecting to hub at %s", cfg.ws_url) async with session.ws_connect(cfg.ws_url) as ws: @@ -184,6 +222,7 @@ async def _connect_and_serve(self) -> None: "github_user": cfg.github_user, "device_id": cfg.device_id, "ports": cfg.ports, + "token": cfg.token, }) async for msg in ws: @@ -235,6 +274,18 @@ async def _handle_http( headers = dict(envelope.get("headers", {})) body_b64 = envelope.get("body", "") + if port not in self._config.ports: + response_envelope = { + "type": "response", + "id": req_id, + "status": 403, + "headers": {}, + "body": "", + "error": f"Port {port} is not exposed by this device", + } + await tunnel_ws.send_json(response_envelope) + return + body: bytes | None = base64.b64decode(body_b64) if body_b64 else None fwd_headers = {k: v for k, v in headers.items() if k.lower() not in _HOP_BY_HOP} @@ -284,6 +335,14 @@ async def _handle_ws_open( port = envelope.get("port", 80) path = envelope.get("path", "/") + if port not in self._config.ports: + await tunnel_ws.send_json({ + "type": "ws_error", + "conn_id": conn_id, + "error": f"Port {port} is not exposed by this device", + }) + return + # Extract a `token` query param injected by the ailab web app for # services that require Authorization: Bearer (e.g. openclaw gateway). # Strip it from the local URL so the service doesn't see a stray param. From c7228d47cba3e1835a163a335dfe5066328a15e3 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Sun, 12 Apr 2026 21:49:09 -0400 Subject: [PATCH 17/21] Harden cloud reconnects and clean docs 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> --- .gitignore | 6 ++ README.md | 9 +- ailab/cloud.py | 79 +++++++++++---- frontend/.gitignore | 2 + frontend/index.html | 1 + frontend/tsconfig.tsbuildinfo | 1 - snap/snapcraft.yaml | 4 +- specs/cloud.md | 176 ++++++++++++++++++++++++++++++++++ 8 files changed, 257 insertions(+), 21 deletions(-) delete mode 100644 frontend/tsconfig.tsbuildinfo create mode 100644 specs/cloud.md diff --git a/.gitignore b/.gitignore index e46a99d..690ee71 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,12 @@ __pycache__/ dist/ build/ .pybuild/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +.codex +=3.9.0 debian/.debhelper/ debian/ailab/ debian/ailab.postinst.debhelper diff --git a/README.md b/README.md index fe013f2..b797f29 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,10 @@ AI Lab Cloud lets you access your home containers from any browser, anywhere alongside the web daemon, opening an outbound connection to a hub you self-host on a VPS. +Keep `ailab` and `ailab-cloud` in step when deploying tunnel-related changes. +The client and hub are developed together, so protocol or registration changes +should be rolled out as a matched pair. + ### How it works ``` @@ -342,6 +346,9 @@ sudo snap set ailab cloud.device-id=myhome # lowercase letters, digits, and hy sudo snap restart ailab.web ``` +`cloud.host` accepts either `cloud.example.com` or `https://cloud.example.com`, +but the full URL is the recommended form. + **4. Visit** `https://myhome.cloud.example.com` from any browser and log in with GitHub. The full AI Lab dashboard loads proxied through the tunnel, including the interactive terminal and all "Open …" buttons for installed @@ -352,7 +359,7 @@ tools. | Setting | Description | |---|---| | `cloud.enabled` | Set to `true` to start the tunnel client (default: `false`) | -| `cloud.host` | Hub URL, e.g. `https://cloud.example.com` | +| `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 | diff --git a/ailab/cloud.py b/ailab/cloud.py index a93f5c4..0af2e6b 100644 --- a/ailab/cloud.py +++ b/ailab/cloud.py @@ -56,6 +56,8 @@ logger = logging.getLogger("ailab.cloud") _DEVICE_ID_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") +_HEARTBEAT_INTERVAL = 30 +_REGISTER_TIMEOUT = 15 # Reconnect delay: start at 2 s, double each attempt, cap at 60 s. _BACKOFF_BASE = 2 @@ -152,6 +154,28 @@ def __init__(self, config: CloudConfig) -> None: self._ws_sessions: dict[str, aiohttp.ClientSession] = {} self._tunnel_ws: aiohttp.ClientWebSocketResponse | None = None + async def _await_registered(self, ws: aiohttp.ClientWebSocketResponse) -> None: + msg = await ws.receive(timeout=_REGISTER_TIMEOUT) + + if msg.type != aiohttp.WSMsgType.TEXT: + if msg.type == aiohttp.WSMsgType.ERROR and ws.exception(): + raise RuntimeError(f"Tunnel registration failed: {ws.exception()}") + raise RuntimeError( + f"Tunnel registration failed before acknowledgement (type={msg.type})" + ) + + try: + envelope = json.loads(msg.data) + except json.JSONDecodeError as exc: + raise RuntimeError("Tunnel registration returned non-JSON data") from exc + + if envelope.get("type") != "registered": + raise RuntimeError( + f"Tunnel registration failed: unexpected message {envelope.get('type')!r}" + ) + + logger.info("Registered with hub as device '%s'", self._config.device_id) + @classmethod def from_env(cls) -> "CloudTunnelManager | None": config = CloudConfig.from_env() @@ -212,7 +236,10 @@ async def _connect_and_serve(self) -> None: connector = aiohttp.TCPConnector(ssl=cfg.secure) async with aiohttp.ClientSession(connector=connector) as session: logger.info("Connecting to hub at %s", cfg.ws_url) - async with session.ws_connect(cfg.ws_url) as ws: + async with session.ws_connect( + cfg.ws_url, + heartbeat=_HEARTBEAT_INTERVAL, + ) as ws: self._tunnel_ws = ws logger.info("Tunnel WebSocket connected") @@ -225,19 +252,39 @@ async def _connect_and_serve(self) -> None: "token": cfg.token, }) - async for msg in ws: - if self._stop_event.is_set(): - return - if msg.type == aiohttp.WSMsgType.TEXT: - try: - envelope = json.loads(msg.data) - except json.JSONDecodeError: - logger.warning("Received non-JSON message from hub") - continue - await self._dispatch(ws, envelope) - elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): - logger.info("Tunnel WS closed (type=%s)", msg.type) - return + await self._await_registered(ws) + + try: + async for msg in ws: + if self._stop_event.is_set(): + return + if msg.type == aiohttp.WSMsgType.TEXT: + try: + envelope = json.loads(msg.data) + except json.JSONDecodeError: + logger.warning("Received non-JSON message from hub") + continue + await self._dispatch(ws, envelope) + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.ERROR, + ): + logger.info("Tunnel WS closed (type=%s)", msg.type) + break + finally: + self._tunnel_ws = None + + if self._stop_event.is_set(): + return + + if ws.exception(): + raise RuntimeError(f"Tunnel socket error: {ws.exception()}") + + raise RuntimeError( + f"Tunnel closed by hub (code={ws.close_code})" + ) # ── Envelope dispatch ───────────────────────────────────────────────────── @@ -247,9 +294,7 @@ async def _dispatch( envelope: dict, ) -> None: msg_type = envelope.get("type") - if msg_type == "registered": - logger.info("Registered with hub as device '%s'", self._config.device_id) - elif msg_type == "request": + if msg_type == "request": asyncio.create_task(self._handle_http(tunnel_ws, envelope)) elif msg_type == "ws_open": asyncio.create_task(self._handle_ws_open(tunnel_ws, envelope)) diff --git a/frontend/.gitignore b/frontend/.gitignore index b947077..e4cd754 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,2 +1,4 @@ node_modules/ dist/ +*.tsbuildinfo +.vite/ diff --git a/frontend/index.html b/frontend/index.html index b5edcb9..acf8b62 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,7 @@ AI Lab + diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo deleted file mode 100644 index bab7dc7..0000000 --- a/frontend/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.ts","./src/components/ChangeModelModal.tsx","./src/components/ContainerList.tsx","./src/components/CreateModal.tsx","./src/components/InstallModal.tsx","./src/components/LogStream.tsx","./src/components/PortManager.tsx","./src/components/Terminal.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 500053b..ebaf739 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -25,10 +25,10 @@ description: | Connect to an AI Lab Cloud hub for remote access: - snap set ailab cloud.host=cloud.example.com + snap set ailab cloud.host=https://cloud.example.com snap set ailab cloud.token= snap set ailab cloud.user= - snap set ailab cloud.device= + snap set ailab cloud.device-id= snap set ailab cloud.ports=11500,18789 license: GPL-3.0+ diff --git a/specs/cloud.md b/specs/cloud.md new file mode 100644 index 0000000..74e42e3 --- /dev/null +++ b/specs/cloud.md @@ -0,0 +1,176 @@ +> Historical design note: this document captures an early plan for AI Lab Cloud +> and no longer matches the current implementation in every detail. Prefer the +> `ailab` and `ailab-cloud` READMEs plus the current source code for the +> supported tunnel protocol and configuration surface. + +This project specification outlines the architecture, implementation details, and project plan for **AI Lab Cloud**, a secure tunneling and identity bridge for the AI Lab ecosystem. + +### Project Overview: AI Lab Cloud +The goal is to provide a "Cloud-assisted Direct Access" model. AI Lab Cloud will serve as a rendezvous point and authenticated proxy, allowing you to access your home-based LXD agent containers from any public network without configuring complex VPNs or port forwarding. + +--- + +### 1. Architecture & Core Components + + +#### A. AI Lab Cloud (The Hub) +* **Role:** Authenticates users via GitHub OAuth, maintains active persistent connections from Home Devices, and proxies HTTP/WebSocket traffic. +* **Hosting:** Cannot be hosted on GitHub Pages (which is for static content only). It requires a long-running process to manage WebSockets. A Linode/DigitalOcean VPS is ideal. +* **Stack:** Python (FastAPI), `uvicorn`, `authlib` (GitHub OAuth), and `redis` (to track active tunnel registrations). + +#### B. The Tunnel Agent (Integrated into AI Lab) +* **Role:** A background service in the existing `ailab` codebase that initiates an outbound connection to the Cloud Hub. +* **Mechanism:** Uses **Reverse WebSockets** or **gRPC stream**. It establishes a TLS-encrypted connection to the Cloud Hub and "listens" for incoming requests to proxy locally. + +#### C. Request Flow +1. **Handshake:** Home AI Lab instance connects to `ailab.linuxgroove.com` with a signed JWT containing the GitHub Username allowed. +2. **Rendezvous:** Cloud Hub holds the connection open. +3. **Authentication:** User visits the Cloud URL, logs in via GitHub. +4. **Proxying:** The Cloud Hub matches the logged-in GitHub user to the Home Device's permitted user list and pipes the browser traffic through the established WebSocket tunnel to the local AI Lab FastAPI app. + +--- + +### 2. Technical Specification + +#### Identity & Authentication +* **Protocol:** GitHub OAuth 2.0. +* **Authorization:** The Home Device configuration will include an `allowed_github_users` list. The Cloud Hub will only route traffic if the `sub` (user ID/login) from the OAuth flow matches the registered tunnel's permitted list. + +#### Tunneling Implementation +To handle both the AI Lab Web UI and the various agent ports (OpenClaw, etc.), we will implement a **Host-Header-based Multiplexer**: +* `https://[device-id].ailab.linuxgroove.com` -> Proxies to AI Lab Web UI (Port 11500). +* `https://[device-id]-[port].ailab.linuxgroove.com` -> Proxies to specific agent ports (e.g., 18789 for OpenClaw). + +#### Changes Required in `ailab` Codebase +1. **New Module (`ailab/cloud.py`):** A background task using `aiohttp` to maintain the tunnel connection. +2. **Configuration Update:** Add `cloud_host` and `cloud_user` to the snap settings/config. +3. **Middleware modification in `ailab/web/app.py`:** Update CORS and Trusted Host settings to allow the cloud domain as a valid origin. + +--- + +### 3. Project Plan + +#### Phase 1: The Cloud Hub (MVP) +* Develop the FastAPI backend for `ailab-cloud`. +* Implement GitHub OAuth flow. +* Create a "Tunnel Registry" in Redis to map GitHub IDs to active WebSocket connections. + +#### Phase 2: The Tunnel Client +* Modify `ailab/container.py` and `ailab/web/app.py` to include a `CloudTunnelManager`. +* Implement the protocol to wrap local HTTP requests into WebSocket frames to be sent back to the Cloud Hub. +* **Security:** Ensure the Home Device verifies the Cloud Hub's TLS certificate to prevent Man-in-the-Middle attacks. + +#### Phase 3: Port Multiplexing +* Implement logic to automatically detect which ports AI Lab is currently forwarding (e.g., 7860, 8888, 18789) and register those subdomains with the Cloud Hub. +* Handle WebSocket proxying for the integrated terminal (`/api/ws/shell/{name}`) to ensure the CLI remains interactive over the cloud. + +#### Phase 4: Deployment & Branding +* Dockerize the Cloud Hub for easy deployment on Linode. +* Configure Nginx/Traefik as a reverse proxy for the Cloud Hub to handle wildcard SSL certificates (essential for the `[port].ailab...` subdomains). + +--- + +### 4. Implementation Example (Conceptual Tunnel Client) + +[Inference] This snippet demonstrates how the existing `ailab` service would initiate the connection: + +```python +# Proposed addition to ailab/cloud.py +async def maintain_tunnel(cloud_url, allowed_user): + async with aiohttp.ClientSession() as session: + # Authenticate with the cloud hub + async with session.ws_connect(f"{cloud_url}/register/{allowed_user}") as ws: + async for msg in ws: + # Receive a proxied request from the cloud + request_data = json.loads(msg.data) + # Forward to local AI Lab Web UI (Port 11500) + local_resp = await forward_to_local(request_data) + # Send response back through tunnel + await ws.send_json(local_resp) +``` + +### Sources Used: +* `README.md`: Provided details on existing ports and `ailab web` defaults. +* `ailab/web/app.py`: Detail on FastAPI implementation and WebSocket shell handling. +* `ailab/container.py`: Information on how LXD proxy devices are managed. + +######################################################## + +Building the **AI Lab Cloud** hub as a snap package for Linode deployment is a strategic choice, as it ensures the service has the same transactional updates and security confinement as the `ailab` client itself. + +### Implementation Specification: `ailab-cloud` Snap + +To host the cloud service on a Linode VPS, you will need a new project structure that packages a FastAPI-based "Hub" and an Nginx/Traefik reverse proxy to handle the wildcard subdomains for individual agent ports. + +#### 1. Snap Architecture +The snap will be configured as a `server` type application, containing the FastAPI application and necessary runtime dependencies. + + + +**Key Snapcraft Components:** +* **Base:** `core24` (matching the Ubuntu 24.04 recommendation for AI Lab). +* **Confinement:** `strict` (requires specific interfaces for network access). +* **Plugs:** `network`, `network-bind`, and `redis-support`. + +#### 2. `snapcraft.yaml` for AI Lab Cloud +```yaml +name: ailab-cloud +version: '0.1' +summary: Public hub for AI Lab remote access +description: | + Provides a secure tunneling rendezvous and GitHub OAuth gateway + for AI Lab instances. +base: core24 +confinement: strict + +apps: + hub: + command: bin/python -m uvicorn ailab_cloud.main:app --host 0.0.0.0 --port 8080 + daemon: simple + plugs: [network, network-bind] + environment: + GITHUB_CLIENT_ID: ${SNAP_COMMON}/github_id + GITHUB_CLIENT_SECRET: ${SNAP_COMMON}/github_secret + +parts: + ailab-cloud: + plugin: python + source: . + python-packages: + - fastapi + - uvicorn + - authlib + - redis + - aiohttp +``` + +### 3. Core Service Logic (The Bridge) + +The cloud service must manage two distinct types of connections: +1. **The Control Plane (WebSocket):** The home `ailab` instance connects here and maintains a persistent "Tunnel". +2. **The Data Plane (HTTP/WS):** When you browse to `ailab.linuxgroove.com`, the Hub verifies your GitHub session, looks up the active tunnel for `kenvandine`, and pipes your request through the control plane to the home device. + +**Integrated Agent Port Forwarding:** +The Cloud Hub will automatically create subdomains or path-based routing for the standard AI Lab ports: +* **18789:** OpenClaw. +* **3000:** Nullclaw. +* **11500:** AI Lab Web UI. + +### 4. Integration Plan for Existing `ailab` Codebase + +To support this Linode-hosted snap, the following modifications are needed in the files you provided: + +* **`ailab/web/app.py`:** Add a new background task that runs alongside the FastAPI app. This task will use `aiohttp` to initiate a connection to your Linode instance. +* **`snap/snapcraft.yaml`:** Add a new configuration hook (`snap/hooks/configure`) to allow you to set the cloud endpoint via the CLI: + ```bash + snap set ailab cloud.enabled=true + snap set ailab cloud.host=https://ailab.linuxgroove.com + snap set ailab cloud.user=kenvandine + ``` +* **`ailab/container.py`:** Modify the `INBOUND_PROXIES` logic to ensure that traffic coming from the cloud tunnel is treated as local traffic, bypassing standard IP-based restrictions while maintaining user-mapping security. + +### 5. Deployment on Linode +1. **Provision:** Create a standard Ubuntu 24.04 Linode. +2. **Install Snapd:** `sudo apt install snapd`. +3. **Install Hub:** `sudo snap install ailab-cloud`. +4. **DNS:** Configure a wildcard A record: `*.ailab.linuxgroove.com` pointing to the Linode IP. This is critical for routing traffic to specific containers/ports dynamically. From 5a0fe2f3eba932eca0b0ab73ee0509f7d5bde30f Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Wed, 15 Apr 2026 10:43:53 -0400 Subject: [PATCH 18/21] Use LXD socket for snap shells Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ailab/container.py | 196 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 189 insertions(+), 7 deletions(-) diff --git a/ailab/container.py b/ailab/container.py index f93aa62..82214f2 100644 --- a/ailab/container.py +++ b/ailab/container.py @@ -1,14 +1,19 @@ """LXD container management for ailab — via LXD REST API (pylxd).""" +import asyncio import os import pwd import shutil +import signal import socket import sys +import termios import textwrap import time +import tty from pathlib import Path +import aiohttp import pylxd import pylxd.exceptions @@ -427,6 +432,185 @@ def container_exec( return result.exit_code, result.stdout or "", result.stderr or "" +def _build_shell_exec_argv(post_cmds: list[str] | None = None) -> list[str]: + """Return the login-shell command argv for an interactive session.""" + if post_cmds: + chain = "; ".join(post_cmds) + return ["/bin/bash", "--login", "-c", f"{chain}; exec bash --login"] + return ["/bin/bash", "--login"] + + +def _build_shell_exec_data( + name: str, + exec_argv: list[str], + username: str, + uid: int, + gid: int, + home: str, +) -> dict: + """Return the LXD exec payload for an interactive login shell.""" + try: + welcome = build_shell_welcome(name) + except Exception: + welcome = "Welcome to your AI Lab container!" + + return { + "command": exec_argv, + "environment": { + "HOME": home, + "USER": username, + "LOGNAME": username, + "TERM": "xterm-256color", + "XDG_RUNTIME_DIR": f"/run/user/{uid}", + "DBUS_SESSION_BUS_ADDRESS": f"unix:path=/run/user/{uid}/bus", + "SHELL_WELCOME": welcome, + }, + "interactive": True, + "wait-for-websocket": True, + "cwd": home, + "user": uid, + "group": gid, + } + + +async def _run_socket_shell( + cname: str, + exec_data: dict, +): + """Run an interactive shell via the LXD exec websocket API.""" + if not sys.stdin.isatty() or not sys.stdout.isatty(): + raise RuntimeError("Interactive shell requires a TTY") + + socket_path = _find_lxd_socket() + connector = aiohttp.UnixConnector(path=socket_path) + async with aiohttp.ClientSession(connector=connector) as http: + async with http.post( + f"http://localhost/1.0/instances/{cname}/exec", + params={"project": AILAB_PROJECT}, + json=exec_data, + ) as resp: + op = await resp.json() + + if op.get("status_code") not in (100, 200): + raise RuntimeError(op.get("error", "unknown error")) + + uuid = op["operation"].split("/")[-1] + fds = op["metadata"]["metadata"]["fds"] + ws_url = f"http://localhost/1.0/operations/{uuid}/websocket" + + async with http.ws_connect(ws_url, params={"secret": fds["0"]}) as lxd_data, \ + http.ws_connect(ws_url, params={"secret": fds["control"]}) as lxd_ctrl: + loop = asyncio.get_running_loop() + stdin_fd = sys.stdin.fileno() + stdout_fd = sys.stdout.fileno() + original_tty = termios.tcgetattr(stdin_fd) + original_winch = signal.getsignal(signal.SIGWINCH) + resize_task: asyncio.Task | None = None + input_queue: asyncio.Queue[bytes | None] = asyncio.Queue() + input_closed = False + + async def send_resize(): + cols, rows = shutil.get_terminal_size(fallback=(80, 24)) + await lxd_ctrl.send_json({ + "command": "window-resize", + "args": {"width": str(cols), "height": str(rows)}, + }) + + def queue_resize(*_args): + nonlocal resize_task + if resize_task and not resize_task.done(): + resize_task.cancel() + resize_task = loop.create_task(send_resize()) + + def close_input(): + nonlocal input_closed + if input_closed: + return + input_closed = True + loop.remove_reader(stdin_fd) + input_queue.put_nowait(None) + + def on_stdin_ready(): + try: + data = os.read(stdin_fd, 4096) + except BlockingIOError: + return + except OSError: + close_input() + return + + if not data: + close_input() + return + + input_queue.put_nowait(data) + + async def stdin_to_lxd(): + while True: + data = await input_queue.get() + if data is None: + break + await lxd_data.send_bytes(data) + + async def lxd_to_stdout(): + async for msg in lxd_data: + if msg.type == aiohttp.WSMsgType.BINARY: + os.write(stdout_fd, msg.data) + elif msg.type == aiohttp.WSMsgType.TEXT: + os.write(stdout_fd, msg.data.encode()) + elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR): + break + + try: + tty.setraw(stdin_fd) + os.set_blocking(stdin_fd, False) + loop.add_reader(stdin_fd, on_stdin_ready) + signal.signal(signal.SIGWINCH, queue_resize) + queue_resize() + output_task = asyncio.create_task(lxd_to_stdout()) + input_task = asyncio.create_task(stdin_to_lxd()) + try: + await asyncio.wait( + [output_task, input_task], + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + close_input() + for task in (output_task, input_task): + task.cancel() + await asyncio.gather(output_task, input_task, return_exceptions=True) + finally: + if not input_closed: + loop.remove_reader(stdin_fd) + os.set_blocking(stdin_fd, True) + termios.tcsetattr(stdin_fd, termios.TCSADRAIN, original_tty) + signal.signal(signal.SIGWINCH, original_winch) + if resize_task: + await asyncio.gather(resize_task, return_exceptions=True) + for lxd_ws in (lxd_data, lxd_ctrl): + try: + await lxd_ws.close() + except Exception: + pass + + +def _run_container_via_socket_shell( + name: str, + cname: str, + exec_argv: list[str], + username: str, + uid: int, + gid: int, + home: str, +): + """Open an interactive shell using the LXD Unix socket instead of lxc.""" + exec_data = _build_shell_exec_data(name, exec_argv, username, uid, gid, home) + try: + asyncio.run(_run_socket_shell(cname, exec_data)) + except RuntimeError as exc: + raise RuntimeError(f"Could not open interactive shell in '{name}': {exc}") from exc + + # ── Public device API (used by installers) ─────────────────────────────────── def has_device(cname: str, device_name: str) -> bool: @@ -972,11 +1156,11 @@ def run_container(name: str, post_cmds: list[str] | None = None): print(f"Opening shell in '{name}' as {username}...") - if post_cmds: - chain = "; ".join(post_cmds) - exec_argv = ["/bin/bash", "--login", "-c", f"{chain}; exec bash --login"] - else: - exec_argv = ["/bin/bash", "--login"] + exec_argv = _build_shell_exec_argv(post_cmds) + + if os.environ.get("SNAP"): + _run_container_via_socket_shell(name, cname, exec_argv, username, uid, gid, home) + return # Use lxc exec for the interactive shell — it's the only operation that # needs a real PTY, which the REST API exec doesn't provide cleanly. @@ -1147,5 +1331,3 @@ def _safe_path(path: Path, expected_parent: Path) -> bool: pass print(f"Container '{name}' deleted.") - - From 77634614ca5d449e1c9f695dff3c743a46c439f9 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Wed, 15 Apr 2026 10:53:57 -0400 Subject: [PATCH 19/21] Fix snap shell exit handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ailab/container.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/ailab/container.py b/ailab/container.py index 82214f2..716d849 100644 --- a/ailab/container.py +++ b/ailab/container.py @@ -508,6 +508,7 @@ async def _run_socket_shell( resize_task: asyncio.Task | None = None input_queue: asyncio.Queue[bytes | None] = asyncio.Queue() input_closed = False + input_barrier_sent = False async def send_resize(): cols, rows = shutil.get_terminal_size(fallback=(80, 24)) @@ -546,9 +547,13 @@ def on_stdin_ready(): input_queue.put_nowait(data) async def stdin_to_lxd(): + nonlocal input_barrier_sent while True: data = await input_queue.get() if data is None: + if not input_barrier_sent: + input_barrier_sent = True + await lxd_data.send_str("") break await lxd_data.send_bytes(data) @@ -561,6 +566,12 @@ async def lxd_to_stdout(): elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR): break + async def watch_control(): + async for msg in lxd_ctrl: + if msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR): + break + close_input() + try: tty.setraw(stdin_fd) os.set_blocking(stdin_fd, False) @@ -569,16 +580,14 @@ async def lxd_to_stdout(): queue_resize() output_task = asyncio.create_task(lxd_to_stdout()) input_task = asyncio.create_task(stdin_to_lxd()) + control_task = asyncio.create_task(watch_control()) try: - await asyncio.wait( - [output_task, input_task], - return_when=asyncio.FIRST_COMPLETED, - ) + await output_task finally: close_input() - for task in (output_task, input_task): + for task in (output_task, input_task, control_task): task.cancel() - await asyncio.gather(output_task, input_task, return_exceptions=True) + await asyncio.gather(output_task, input_task, control_task, return_exceptions=True) finally: if not input_closed: loop.remove_reader(stdin_fd) From ee095abd9ee2bcc40dac772318a3327ce89f7e3e Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Wed, 15 Apr 2026 11:10:03 -0400 Subject: [PATCH 20/21] Address PR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ailab/cloud.py | 195 +++++++++++++--------- ailab/web/app.py | 21 ++- frontend/src/api/client.ts | 2 +- frontend/src/components/ContainerList.tsx | 5 +- snap/local/ailab-web-wrapper | 5 +- 5 files changed, 138 insertions(+), 90 deletions(-) diff --git a/ailab/cloud.py b/ailab/cloud.py index 0af2e6b..da3970f 100644 --- a/ailab/cloud.py +++ b/ailab/cloud.py @@ -43,6 +43,7 @@ import asyncio import base64 +import binascii import json import logging import os @@ -58,6 +59,7 @@ _DEVICE_ID_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") _HEARTBEAT_INTERVAL = 30 _REGISTER_TIMEOUT = 15 +_LOCAL_PROXY_TIMEOUT = aiohttp.ClientTimeout(total=60, connect=5, sock_connect=5, sock_read=60) # Reconnect delay: start at 2 s, double each attempt, cap at 60 s. _BACKOFF_BASE = 2 @@ -120,6 +122,8 @@ def from_env(cls) -> "CloudConfig | None": break host = host.rstrip("/") github_user = os.environ.get("AILAB_CLOUD_USER", "").strip() + if not github_user: + raise ValueError("AILAB_CLOUD_USER is required when cloud tunnel is enabled") 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): @@ -151,7 +155,7 @@ def __init__(self, config: CloudConfig) -> None: self._stop_event = asyncio.Event() # Active proxied WebSocket connections keyed by conn_id. self._ws_connections: dict[str, aiohttp.ClientWebSocketResponse] = {} - self._ws_sessions: dict[str, aiohttp.ClientSession] = {} + self._local_session: aiohttp.ClientSession | None = None self._tunnel_ws: aiohttp.ClientWebSocketResponse | None = None async def _await_registered(self, ws: aiohttp.ClientWebSocketResponse) -> None: @@ -205,11 +209,24 @@ async def stop(self) -> None: await asyncio.wait_for(self._task, timeout=5) except (asyncio.TimeoutError, asyncio.CancelledError): self._task.cancel() - # Close any open proxied WS sessions. - for session in list(self._ws_sessions.values()): - await session.close() + await self._close_local_proxies() logger.info("Cloud tunnel stopped") + async def _close_local_proxies(self) -> None: + """Close all local proxy sockets and the shared local client session.""" + for local_ws in list(self._ws_connections.values()): + if not local_ws.closed: + try: + await local_ws.close() + except Exception: + pass + self._ws_connections.clear() + + local_session = self._local_session + self._local_session = None + if local_session and not local_session.closed: + await local_session.close() + # ── Internal reconnect loop ─────────────────────────────────────────────── async def _run(self) -> None: @@ -235,69 +252,74 @@ async def _connect_and_serve(self) -> None: cfg = self._config connector = aiohttp.TCPConnector(ssl=cfg.secure) async with aiohttp.ClientSession(connector=connector) as session: - logger.info("Connecting to hub at %s", cfg.ws_url) - async with session.ws_connect( - cfg.ws_url, - heartbeat=_HEARTBEAT_INTERVAL, - ) as ws: - self._tunnel_ws = ws - logger.info("Tunnel WebSocket connected") - - # Send registration message. - await ws.send_json({ - "type": "register", - "github_user": cfg.github_user, - "device_id": cfg.device_id, - "ports": cfg.ports, - "token": cfg.token, - }) - - await self._await_registered(ws) + self._local_session = aiohttp.ClientSession(timeout=_LOCAL_PROXY_TIMEOUT) + try: + logger.info("Connecting to hub at %s", cfg.ws_url) + async with session.ws_connect( + cfg.ws_url, + heartbeat=_HEARTBEAT_INTERVAL, + ) as ws: + self._tunnel_ws = ws + logger.info("Tunnel WebSocket connected") + + # Send registration message. + await ws.send_json({ + "type": "register", + "github_user": cfg.github_user, + "device_id": cfg.device_id, + "ports": cfg.ports, + "token": cfg.token, + }) - try: - async for msg in ws: - if self._stop_event.is_set(): - return - if msg.type == aiohttp.WSMsgType.TEXT: - try: - envelope = json.loads(msg.data) - except json.JSONDecodeError: - logger.warning("Received non-JSON message from hub") - continue - await self._dispatch(ws, envelope) - elif msg.type in ( - aiohttp.WSMsgType.CLOSE, - aiohttp.WSMsgType.CLOSING, - aiohttp.WSMsgType.CLOSED, - aiohttp.WSMsgType.ERROR, - ): - logger.info("Tunnel WS closed (type=%s)", msg.type) - break - finally: - self._tunnel_ws = None - - if self._stop_event.is_set(): - return - - if ws.exception(): - raise RuntimeError(f"Tunnel socket error: {ws.exception()}") - - raise RuntimeError( - f"Tunnel closed by hub (code={ws.close_code})" - ) + await self._await_registered(ws) + + try: + async for msg in ws: + if self._stop_event.is_set(): + return + if msg.type == aiohttp.WSMsgType.TEXT: + try: + envelope = json.loads(msg.data) + except json.JSONDecodeError: + logger.warning("Received non-JSON message from hub") + continue + await self._dispatch(ws, self._local_session, envelope) + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.ERROR, + ): + logger.info("Tunnel WS closed (type=%s)", msg.type) + break + finally: + self._tunnel_ws = None + + if self._stop_event.is_set(): + return + + if ws.exception(): + raise RuntimeError(f"Tunnel socket error: {ws.exception()}") + + raise RuntimeError( + f"Tunnel closed by hub (code={ws.close_code})" + ) + finally: + await self._close_local_proxies() # ── Envelope dispatch ───────────────────────────────────────────────────── async def _dispatch( self, tunnel_ws: aiohttp.ClientWebSocketResponse, + local_session: aiohttp.ClientSession, envelope: dict, ) -> None: msg_type = envelope.get("type") if msg_type == "request": - asyncio.create_task(self._handle_http(tunnel_ws, envelope)) + asyncio.create_task(self._handle_http(tunnel_ws, local_session, envelope)) elif msg_type == "ws_open": - asyncio.create_task(self._handle_ws_open(tunnel_ws, envelope)) + asyncio.create_task(self._handle_ws_open(tunnel_ws, local_session, envelope)) elif msg_type == "ws_frame": await self._handle_ws_frame(envelope) elif msg_type == "ws_close": @@ -310,6 +332,7 @@ async def _dispatch( async def _handle_http( self, tunnel_ws: aiohttp.ClientWebSocketResponse, + local_session: aiohttp.ClientSession, envelope: dict, ) -> None: req_id = envelope.get("id", "") @@ -331,28 +354,36 @@ async def _handle_http( await tunnel_ws.send_json(response_envelope) return - body: bytes | None = base64.b64decode(body_b64) if body_b64 else None - fwd_headers = {k: v for k, v in headers.items() if k.lower() not in _HOP_BY_HOP} 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: - resp_body = await resp.read() - resp_headers = { - k: v for k, v in resp.headers.items() - if k.lower() not in _HOP_BY_HOP - } - response_envelope = { - "type": "response", - "id": req_id, - "status": resp.status, - "headers": resp_headers, - "body": base64.b64encode(resp_body).decode(), - } + body: bytes | None = base64.b64decode(body_b64, validate=True) if body_b64 else None + async with local_session.request( + method, url, headers=fwd_headers, data=body, allow_redirects=False + ) as resp: + resp_body = await resp.read() + resp_headers = { + k: v for k, v in resp.headers.items() + if k.lower() not in _HOP_BY_HOP + } + response_envelope = { + "type": "response", + "id": req_id, + "status": resp.status, + "headers": resp_headers, + "body": base64.b64encode(resp_body).decode(), + } + except (binascii.Error, ValueError) as exc: + logger.warning("HTTP proxy request body decode error for %s %s: %s", method, url, exc) + response_envelope = { + "type": "response", + "id": req_id, + "status": 400, + "headers": {}, + "body": "", + "error": f"Invalid base64 request body: {exc}", + } except Exception as exc: logger.warning("HTTP proxy error for %s %s: %s", method, url, exc) response_envelope = { @@ -374,6 +405,7 @@ async def _handle_http( async def _handle_ws_open( self, tunnel_ws: aiohttp.ClientWebSocketResponse, + local_session: aiohttp.ClientSession, envelope: dict, ) -> None: conn_id = envelope.get("conn_id", "") @@ -407,10 +439,8 @@ async def _handle_ws_open( extra_headers.setdefault(k.capitalize(), v) try: - session = aiohttp.ClientSession() - local_ws = await session.ws_connect(url, headers=extra_headers or None) + local_ws = await local_session.ws_connect(url, headers=extra_headers or None) self._ws_connections[conn_id] = local_ws - self._ws_sessions[conn_id] = session # Acknowledge the open. await tunnel_ws.send_json({"type": "ws_opened", "conn_id": conn_id}) @@ -454,9 +484,6 @@ async def _relay_local_to_tunnel( logger.debug("WS relay error conn=%s: %s", conn_id, exc) finally: self._ws_connections.pop(conn_id, None) - session = self._ws_sessions.pop(conn_id, None) - if session: - await session.close() try: await tunnel_ws.send_json({"type": "ws_close", "conn_id": conn_id}) except Exception: @@ -469,7 +496,12 @@ async def _handle_ws_frame(self, envelope: dict) -> None: if local_ws is None or local_ws.closed: return opcode = envelope.get("opcode", 1) - data = base64.b64decode(envelope.get("data", "")) + try: + data = base64.b64decode(envelope.get("data", ""), validate=True) + except (binascii.Error, ValueError) as exc: + logger.warning("WS frame decode error conn=%s: %s", conn_id, exc) + await self._handle_ws_close({"conn_id": conn_id}) + return try: if opcode == 2: await local_ws.send_bytes(data) @@ -482,12 +514,9 @@ async def _handle_ws_close(self, envelope: dict) -> None: """Close a proxied local WebSocket connection.""" conn_id = envelope.get("conn_id", "") local_ws = self._ws_connections.pop(conn_id, None) - session = self._ws_sessions.pop(conn_id, None) if local_ws and not local_ws.closed: try: await local_ws.close() except Exception: pass - if session: - await session.close() logger.debug("WS proxy closed conn=%s", conn_id) diff --git a/ailab/web/app.py b/ailab/web/app.py index 82347e0..7612913 100644 --- a/ailab/web/app.py +++ b/ailab/web/app.py @@ -2,6 +2,7 @@ import asyncio import io +import ipaddress as _ipaddress import json import logging import socket as _socket @@ -455,7 +456,25 @@ def _port_base_url(request: Request) -> str: '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" + if not tunnel_base: + return "http://localhost" + + client_host = request.client.host if request.client else "unknown" + try: + trusted_client = client_host == "localhost" or _ipaddress.ip_address(client_host).is_loopback + except ValueError: + trusted_client = False + + if not trusted_client: + logger.warning("Ignoring untrusted X-Ailab-Tunnel-Base header from %s", client_host) + return "http://localhost" + + parsed = _urllib_parse.urlparse(tunnel_base) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + logger.warning("Ignoring invalid X-Ailab-Tunnel-Base header: %r", tunnel_base) + return "http://localhost" + + return parsed._replace(params="", query="", fragment="").geturl().rstrip("/") @app.get("/api/port-base-url") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cf07d34..7720003 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -15,7 +15,7 @@ const BASE = `${import.meta.env.BASE_URL}api`; */ export function wsUrl(path: string): string { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const m = window.location.pathname.match(/^(\/d\/[^/]+)\//); + const m = window.location.pathname.match(/^(\/d\/[^/]+)(?:\/|$)/); if (m) { return `${proto}//${window.location.host}${m[1]}${path}`; } diff --git a/frontend/src/components/ContainerList.tsx b/frontend/src/components/ContainerList.tsx index 229eadb..6700dce 100644 --- a/frontend/src/components/ContainerList.tsx +++ b/frontend/src/components/ContainerList.tsx @@ -111,7 +111,7 @@ function GatewayButton({ name, port, label }: { name: string; port: number; labe const [url, setUrl] = useState(null); const [notPaired, setNotPaired] = useState(false); const [showPairModal, setShowPairModal] = useState(false); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const fetchUrl = () => { setLoading(true); @@ -145,7 +145,7 @@ function GatewayButton({ name, port, label }: { name: string; port: number; labe return () => clearInterval(interval); }, [notPaired, name, port]); - if (loading) { + if (loading || (!url && !notPaired)) { return ( … @@ -440,4 +440,3 @@ export function ContainerList({ containers, onShell, onLogs, onPorts, onInstall, ); } - diff --git a/snap/local/ailab-web-wrapper b/snap/local/ailab-web-wrapper index 7b68bf9..f90b361 100755 --- a/snap/local/ailab-web-wrapper +++ b/snap/local/ailab-web-wrapper @@ -7,13 +7,14 @@ HOST=$(snapctl get web.host) PORT=$(snapctl get web.port) # ── Cloud tunnel settings (optional) ───────────────────────────────────────── +CLOUD_ENABLED=$(snapctl get cloud.enabled 2>/dev/null || true) 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) +CLOUD_DEVICE=$(snapctl get cloud.device-id 2>/dev/null || true) CLOUD_PORTS=$(snapctl get cloud.ports 2>/dev/null || true) -if [ -n "$CLOUD_HOST" ] && [ -n "$CLOUD_TOKEN" ]; then +if [ "${CLOUD_ENABLED:-false}" = "true" ] && [ -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" From 5fa0c529e08a2d201f12848432dab5922a1f39a1 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Wed, 15 Apr 2026 11:54:09 -0400 Subject: [PATCH 21/21] Update cloud docs for review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + ailab/cloud.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b797f29..4461e46 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,7 @@ tools. | `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 | +| `cloud.ports` | Comma-separated local ports to expose through the tunnel (default: `11500`; add `18789` for openclaw and any other tool ports you want reachable remotely) | ```bash snap get ailab cloud # view all cloud settings at once diff --git a/ailab/cloud.py b/ailab/cloud.py index da3970f..96b90fa 100644 --- a/ailab/cloud.py +++ b/ailab/cloud.py @@ -36,7 +36,8 @@ {"type": "registered"} {"type": "request", "id": "", "method": "...", "path": "...", "port": 11500, "headers": {...}, "body": ""} - {"type": "ws_open", "conn_id": "", "port": ..., "path": "..."} + {"type": "ws_open", "conn_id": "", "port": ..., "path": "...", + "headers": {...}} {"type": "ws_frame", "conn_id": "", "opcode": 1|2, "data": ""} {"type": "ws_close", "conn_id": ""} """