Skip to content

Add and harden AI Lab Cloud support - #3

Merged
kenvandine merged 21 commits into
lemonade-sdk:mainfrom
kenvandine:cloud_socket_security_review
Apr 15, 2026
Merged

Add and harden AI Lab Cloud support#3
kenvandine merged 21 commits into
lemonade-sdk:mainfrom
kenvandine:cloud_socket_security_review

Conversation

@kenvandine

Copy link
Copy Markdown
Member

Summary

  • add and harden the AI Lab Cloud tunnel client and routing support in ailab
  • improve reconnect behavior with registration acknowledgement and heartbeat-driven recovery
  • clean up cloud-facing docs, favicon branding, and ignore rules, including removing tracked TypeScript build metadata

Testing

  • python3 -m compileall /home/ken/src/github/kenvandine/ailab/ailab
  • npm run build (in frontend/)

kenvandine and others added 17 commits April 11, 2026 21:20
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add _port_base_url() helper that reads X-Ailab-Tunnel-Base header
  injected by the cloud proxy, falling back to http://localhost locally
- Add GET /api/port-base-url endpoint so the frontend can discover
  the correct base URL at runtime
- api_gateway_url and api_gateway_pair now return tunnel-correct URLs
  when accessed through the cloud hub
- Frontend GatewayButton calls getPortBaseUrl() for non-openclaw ports
  instead of hardcoding localhost
- PairModal fetches the gateway URL from the API after pairing completes
  rather than parsing it from log output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Required by _port_base_url() added in the cloud tunnel-aware URL changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ailab/cloud.py: new CloudTunnelManager with reconnecting WebSocket
  tunnel to ailab-cloud hub; proxies HTTP and WebSocket frames from
  the hub to local service ports
- ailab/web/app.py: wire CloudTunnelManager into FastAPI lifespan so
  the tunnel starts/stops with the web service
- snap/local/ailab-web-wrapper: read cloud.* snap settings and export
  as AILAB_CLOUD_* env vars
- snap/snapcraft.yaml: document cloud.* settings
- Strip https:// scheme from AILAB_CLOUD_HOST if user includes it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without a default, devices register with an empty ports list and the
hub dashboard shows no Open buttons even when connected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When served at /d/device:11500/ through the cloud tunnel, absolute asset
paths (/assets/...) and API calls (/api/...) resolved to the hub root
and returned 404.

- vite.config.ts: base './' makes built assets use ./assets/... (relative)
- client.ts: API BASE uses import.meta.env.BASE_URL so fetch('./api/...')
  resolves relative to the current page URL in both local and tunnel contexts
- client.ts: add wsUrl() helper that inserts /ws prefix when running under
  a /d/{device}:{port}/ tunnel sub-path so WebSocket connections route
  correctly through the hub proxy
- LogStream, Terminal: use wsUrl() instead of hardcoded window.location.host
- vite-env.d.ts: add missing Vite type reference file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The hub's WS proxy route now mirrors the HTTP route (no /ws/ segment),
so wsUrl() no longer inserts /ws/ between the device prefix and the path.

Local:   ws://localhost:11500/api/ws/shell/mybox   (unchanged)
Tunnel:  wss://hub.example.com/d/framework:11500/api/ws/shell/mybox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the openclaw dashboard is opened through the cloud tunnel, the
control-ui JS falls back to its stored ws://127.0.0.1:18789/__openclaw__/ws
gateway URL — which is unreachable from the remote browser.

Openclaw supports a gatewayUrl hash parameter to override the stored URL.
When api_gateway_url detects it's serving through the tunnel (x-ailab-tunnel-base
header present), it now includes both token and gatewayUrl in the URL hash so
the control-ui connects to the gateway via the tunnel proxy instead.

  Local:  .../d/Nova:18789/#token=<tok>
  Tunnel: .../d/Nova:18789/#token=<tok>&gatewayUrl=wss://.../d/Nova:18789/__openclaw__/ws

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The openclaw gateway rejects WebSocket connections from browsers whose
Origin header doesn't match gateway.controlUi.allowedOrigins.  When the
control-ui is opened through the cloud tunnel the browser sends
Origin: https://<hub> which is not localhost, so the connection is refused.

When api_gateway_url is called through the tunnel it now fires a background
task that adds the hub origin to gateway.controlUi.allowedOrigins in
openclaw.json inside the container and restarts the gateway service.  The
task is a no-op if the origin is already present, so the overhead on
subsequent calls is just one pull_file read.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The background task fired after the URL was already returned and opened,
so the gateway was still running the old config when the browser connected.
Awaiting the patch+restart ensures the gateway is ready before the URL
is handed to the browser.  Subsequent calls are fast (no-op if origin
already present).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the openclaw gateway WebSocket is opened via the cloud tunnel,
cloud.py was connecting to the local gateway without any auth headers,
causing a 401 from the gateway's authorizeCanvasRequest check.

- app.py: embed the gateway token as ?token= in the gatewayUrl so it
  flows through the tunnel path to the device
- cloud.py: extract the token query param from the ws_open path and
  inject it as Authorization: Bearer when connecting locally; strip
  it from the URL so the gateway doesn't see a stray query param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two fixes for issues found during tunnel debugging:

1. _restart_gateway now calls `systemctl --user reset-failed` before
   restart so a crashed gateway that hit systemd's rate limiter ("Start
   request repeated too quickly") can be recovered without manual
   intervention.

2. contextWindow values of 0 from lemonade recipe data crash the openclaw
   gateway with a validation error ("Too small: expected number to be >0").
   Clamp ctx_size to at least 1 in both _lemonade_model_entry and the
   import-recipe handler. Uses `or 32768` to also handle explicit None.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cloud.py's aiohttp WS connection to local services had no Origin header,
so the openclaw gateway's CORS check always failed with "origin not
allowed" — even after whitelisting the hub origin in openclaw.json.

The hub now captures the browser's Origin from the WS upgrade request
and includes it in the ws_open envelope.  cloud.py merges these
forwarded headers into the aiohttp ws_connect call, so the local
service sees the real browser origin and the allowedOrigins check passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
push_file uses the LXD files API which writes as uid=0 inside the
container.  The resulting root-owned openclaw.json either can't be
read by the gateway (running as the container user) or gets overwritten
by the gateway on next startup, losing the allowedOrigins we added.

Replace the pull_file/push_file pair in _ensure_gateway_cloud_origin_sync
with a container_exec python3 script that runs as the correct uid/gid,
matching the approach used in _patch_gateway_token_in_json.  The script
prints "already-present" or "patched" so we can tell whether a gateway
restart is actually needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stop sending tunnel credentials in the WebSocket URL, validate
configured device IDs and ports, and refuse forwarding to ports the
device did not advertise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add heartbeat-driven tunnel recovery, remove tracked TypeScript
build metadata, tighten ignore rules, refresh cloud setup docs,
and align the frontend favicon with the AI Lab branding.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds AI Lab Cloud tunnel client support across the backend, snap packaging, and frontend so the web UI and tool “Open …” links work both locally and when routed through a cloud hub.

Changes:

  • Introduces a persistent WebSocket-based tunnel client (ailab/cloud.py) and starts/stops it with the FastAPI app lifecycle.
  • Updates backend endpoints + frontend URL construction for tunnel-aware HTTP/WebSocket routing (including openclaw gateway URL handling).
  • Cleans up frontend build/asset behavior (Vite base path, tsbuildinfo ignore/removal) and docs/branding updates.

Reviewed changes

Copilot reviewed 15 out of 17 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
ailab/cloud.py New tunnel client handling hub registration, HTTP proxy, and WS proxying.
ailab/web/app.py Starts tunnel manager via FastAPI lifespan; adds tunnel-aware port base + openclaw gateway URL logic.
snap/local/ailab-web-wrapper Exports cloud tunnel env vars from snap config for the web daemon.
snap/snapcraft.yaml Documents new cloud.* snap settings in snap metadata.
frontend/src/api/client.ts Makes API base path and WebSocket URL construction work behind a tunnel path.
frontend/src/components/Terminal.tsx Switches terminal WS connection to tunnel-aware URL helper.
frontend/src/components/LogStream.tsx Switches log WS connection to tunnel-aware URL helper.
frontend/src/components/ContainerList.tsx Makes “Open …” links tunnel-aware; fetches base URL / gateway URL from API.
frontend/vite.config.ts Sets Vite base for relative asset serving.
frontend/src/vite-env.d.ts Adds Vite client type reference.
frontend/index.html Adds inline SVG favicon branding.
frontend/.gitignore Ignores Vite/TS build artifacts.
frontend/tsconfig.tsbuildinfo Removes tracked TS build metadata.
README.md Adds Cloud Access documentation and configuration reference.
specs/cloud.md Adds historical design note/spec (explicitly marked as outdated).
.gitignore Adds Python tool caches (and one suspicious extra ignore entry).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ailab/web/app.py Outdated
Comment on lines +448 to +458
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"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee095ab. _port_base_url() now only trusts X-Ailab-Tunnel-Base from loopback clients and validates it as an http(s) URL before using it.

Comment on lines 183 to 187
return (
<a
href={url ?? `http://localhost:${port}`}
href={url ?? '#'}
target="_blank"
rel="noopener noreferrer"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee095ab. GatewayButton now starts in loading state and keeps the placeholder rendered until a real URL is available (or the not-paired state is known), so it no longer exposes a transient href="#" link.

Comment thread snap/local/ailab-web-wrapper Outdated
Comment on lines +16 to +22
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee095ab. The snap wrapper now gates cloud env export on cloud.enabled=true, so disabling the setting is effective even if host/token remain configured.

Comment thread ailab/cloud.py
Comment on lines +122 to +137
github_user = os.environ.get("AILAB_CLOUD_USER", "").strip()
device_id = os.environ.get("AILAB_CLOUD_DEVICE", "").strip() or socket.gethostname()
ports_raw = os.environ.get("AILAB_CLOUD_PORTS", "").strip()
if not _DEVICE_ID_RE.fullmatch(device_id):
raise ValueError(
f"Invalid AILAB_CLOUD_DEVICE {device_id!r}; use lowercase letters, digits, and hyphens"
)
ports = cls._normalize_ports(ports_raw)
return cls(
host=host,
token=token,
github_user=github_user,
device_id=device_id,
secure=secure,
ports=ports,
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee095ab. CloudConfig.from_env() now raises a clear ValueError when AILAB_CLOUD_USER is empty while the cloud tunnel is enabled.

Comment thread ailab/cloud.py Outdated
await tunnel_ws.send_json(response_envelope)
return

body: bytes | None = base64.b64decode(body_b64) if body_b64 else None

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee095ab. Request-body base64 decoding now happens inside the error-handling path, and malformed payloads return a 400 response envelope instead of crashing the task.

Comment thread ailab/cloud.py Outdated
Comment on lines +338 to +343
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:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee095ab. The tunnel now reuses a shared local aiohttp.ClientSession for proxied traffic and applies a timeout, rather than creating a fresh session per HTTP request.

Comment thread frontend/src/api/client.ts Outdated
Comment on lines +18 to +22
const m = window.location.pathname.match(/^(\/d\/[^/]+)\//);
if (m) {
return `${proto}//${window.location.host}${m[1]}${path}`;
}
return `${proto}//${window.location.host}${path}`;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee095ab. wsUrl() now matches ^(\/d\/[^/]+)(?:\/|$), so tunnel WS URLs work whether the device path has a trailing slash or not.

Comment thread snap/local/ailab-web-wrapper Outdated
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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee095ab. The wrapper now reads cloud.device-id, matching the documented snap config key.

Comment thread ailab/cloud.py Outdated
self._task.cancel()
# Close any open proxied WS sessions.
for session in list(self._ws_sessions.values()):
await session.close()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee095ab. stop() now closes local proxy sockets, clears the connection map, resets the shared session reference, and closes the shared session so stale entries do not accumulate across restarts.

Comment thread ailab/cloud.py Outdated
Comment on lines +472 to +473
data = base64.b64decode(envelope.get("data", ""))
try:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee095ab. _handle_ws_frame() now validates base64 input, logs decode failures, and closes the affected proxied websocket instead of letting malformed frames raise through the task.

kenvandine and others added 3 commits April 15, 2026 10:43
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kenvandine

Copy link
Copy Markdown
Member Author

Pushed ee095ab to address the current review feedback.

Handled in this update:

  • hardened tunnel header trust and cloud env validation
  • reused a shared local aiohttp session with timeout for tunnel proxying
  • guarded malformed base64 in HTTP and WS paths
  • aligned snap wrapper behavior with cloud.enabled and cloud.device-id
  • fixed the frontend tunnel URL and placeholder-link edge cases

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds end-to-end “AI Lab Cloud” support so the ailab web UI and forwarded tool ports can be reached through a hub/tunnel setup, including tunnel-aware URL generation and WebSocket proxying.

Changes:

  • Introduce a persistent cloud tunnel client (ailab/cloud.py) and start/stop it via the FastAPI lifespan hook.
  • Make the web UI tunnel-aware (port base URL endpoint, Openclaw gateway URL/Origin handling, frontend WS/API base fixes).
  • Cleanup/ops/docs updates (snap config docs, Vite base path, favicon, ignore rules, remove tracked TS build metadata).

Reviewed changes

Copilot reviewed 16 out of 18 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
specs/cloud.md Adds historical design/spec notes for AI Lab Cloud.
snap/snapcraft.yaml Documents new snap cloud.* configuration keys in snap metadata.
snap/local/ailab-web-wrapper Exports AILAB_CLOUD_* env vars when cloud.enabled=true and required settings are present.
frontend/vite.config.ts Sets Vite base: './' so assets resolve under tunneled sub-paths.
frontend/tsconfig.tsbuildinfo Removes tracked TS incremental build metadata.
frontend/src/vite-env.d.ts Adds Vite type reference for TS tooling.
frontend/src/components/Terminal.tsx Uses centralized wsUrl() helper for tunnel-safe terminal WebSocket URLs.
frontend/src/components/LogStream.tsx Uses wsUrl() for tunnel-safe log WebSocket URLs.
frontend/src/components/ContainerList.tsx Makes “Open …” URLs tunnel-aware via API-provided base URL and gateway URL fetching.
frontend/src/api/client.ts Uses import.meta.env.BASE_URL for API base and adds wsUrl() + getPortBaseUrl().
frontend/index.html Adds inline SVG favicon branding.
frontend/.gitignore Ignores Vite/TS build artifacts.
ailab/web/app.py Starts cloud tunnel manager, adds /api/port-base-url, and hardens tunnel-aware Openclaw gateway URL/origin handling.
ailab/installers/openclaw.py Defines Openclaw gateway WS path and improves gateway service restart robustness.
ailab/container.py Adds a snap-friendly interactive shell path using the LXD unix socket websocket API.
ailab/cloud.py Implements the outbound tunnel client (HTTP + WS proxying, registration ack, heartbeat + reconnect).
README.md Adds Cloud Access documentation and setup instructions.
.gitignore Adds python tooling caches; includes one suspicious ignore entry.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread README.md
| `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 |

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5fa0c52. I added cloud.ports to the Cloud settings table in README.md and documented the default behavior plus the common 18789 openclaw case.

Comment thread ailab/cloud.py Outdated
{"type": "registered"}
{"type": "request", "id": "<uuid>", "method": "...", "path": "...",
"port": 11500, "headers": {...}, "body": "<base64>"}
{"type": "ws_open", "conn_id": "<uuid>", "port": ..., "path": "..."}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5fa0c52. The ailab/cloud.py protocol docstring now lists the optional headers field on ws_open so the documented tunnel protocol matches _handle_ws_open().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kenvandine
kenvandine merged commit 0fc3f63 into lemonade-sdk:main Apr 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants