feat(api,relay): cloud workspaces reach hosts their owner opened

Merged
HA
15h ago
+617-22

Stack: 2 of 3 — #9407 (DB) → this PR → desktop + CLI. Merge after #9407.

Summary

  • superset hosts list from a cloud workspace now shows real online state instead of every host offline.
  • A cloud workspace can call a host through the relay only if the host's owner turned it on (v2_hosts.allow_cloud_workspaces). Otherwise it gets a clear 403 instead of "Session expired".
  • No credential enters the box. The firewall adds the workspace credential for the relay host, and the relay asks the API on every call.

Why / Context

Reported by lucas@traceroot.ai: from a cloud workspace, his online Mac showed offline and --host calls said "Session expired" while auth whoami worked. The cause was the relay, not the API or host-service. A box holds no user token, so host.list never asked the relay for presence, and the relay answered every --host call with 401, which the CLI shows as "Session expired". Also asked for in SUPER-2661 and SUPER-2852.

How It Works

  1. Presence: host.list falls back to a 60 s server presence token (the same pattern as automation dispatch) when the caller has no bearer. This also fixes presence for API-key callers.
  2. Firewall: deriveSandboxCredentials adds the workspace credential header for the relay host, the same value the API already gets.
  3. Relay: with no bearer and that header, it calls host.cloudWorkspaceAccess with the header, on every request and uncached. The API answers yes only if the box's creator owns the host and the column is true. A host member's boxes are refused, even when the host is open. The relay then removes the credential and forwards. WebSocket upgrades from a box, and credentials that aren't the shape the API mints, are refused before any API call. The firewall can't rewrite WebSocket upgrades anyway, and a stream would outlive the per-call check. If the API is unreachable, the relay returns INTERNAL_SERVER_ERROR (retryable), not UNAUTHORIZED.
  4. Switch: host.setCloudWorkspaceAccess is owner-only and not on SANDBOX_ALLOWED_PROCEDURES (a test enforces this), so a box can't open a host to itself.
  5. host.list and host.roster report allowCloudWorkspaces.

Security

  • The API is the only lock: host-service doesn't know a call came from a box, so a wrong column value opens the host. This was chosen deliberately over a host-side second lock, so existing hosts work without a host-service release.
  • Once a host is open, anything in any box its owner created can drive it as them, including a prompt-injected agent. Off by default; the next PR adds an owner-only switch with a confirm dialog that says so.
  • Separate pre-existing issue, not changed here: sessionFromOAuthBearer accepts any API-signed JWT as a full user session regardless of scope, which includes the automation tokens minted for hosts. Worth its own ticket.

Manual QA (dev stack: local API + relay + dev desktop)

  • host.list as the box: the dev host shows online: true
  • Box call to a closed host: 403 "this host does not accept cloud workspaces – its owner can allow it in the host's settings"
  • Owner turns it on: box call returns 200 through the relay
  • Owner turns it off: the next box call is refused
  • Box calls host.setCloudWorkspaceAccess: 403
  • Forged credential: 401
  • Box WebSocket upgrade: 403
  • Not verified: the production path through the real Vercel firewall rule (needs a deploy)

Testing

  • bun test in packages/shared (1547), packages/trpc (873), apps/relay (19), packages/i18n (169), all passing
  • decideCloudWorkspaceAccess is a pure function with tests for owner (open/closed), member, no access, cross-org and non-box callers
  • bun run typecheck in the same four packages
  • bun run check:i18n (one new server error, translated in all locales)

Compatibility

Additive only: two new procedures and new output fields on host.list / host.roster. No input or error-code changes for released clients (checked against packages/trpc/AGENTS.md).

Deployment / Rollout

  • Order: #9407 (migration) → this PR (API + relay deploy) → desktop/CLI PR.
  • Existing boxes pick up the new firewall rule at their next wake or keepalive.

Rollback

Revert this PR. The relay then refuses box callers again (401), and the column goes unread.


Summary by cubic

Cloud workspaces can now reach hosts their owner opened, instead of showing every host offline and failing --host calls with "Session expired."

The relay refused a box's calls because a box holds no user token. It now recognizes the firewall-added workspace credential, asks host.cloudWorkspaceAccess on every call, and forwards only if the host's owner created the box and enabled allowCloudWorkspaces; a member's box is refused even for an open host. The credential never reaches the host, and a malformed credential or a WebSocket upgrade is refused without asking the API. If the API is unreachable during the check, the call fails with a server error so callers retry instead of treating it as an auth failure.

  • host.list mints a 60-second server presence token when the caller has no bearer, which also fixes presence for API-key callers.
  • host.list and host.roster report allowCloudWorkspaces; the mobile sandbox host row sets it false.
  • host.setCloudWorkspaceAccess is owner-only and not on the sandbox allow-list.
  • WebSocket upgrades from a box are refused; a stream would outlive the per-call check.

Security

  • The API is the only lock: host-service can't tell a call came from a box, so the column value is what opens the host.
  • Off by default. Once open, anything in any box the owner created can drive the host as them, including a prompt-injected agent; a follow-up PR adds the owner-facing switch with a confirm dialog.