feat(api,relay): cloud workspaces reach hosts their owner opened
Stack: 2 of 3 — #9407 (DB) → this PR → desktop + CLI. Merge after #9407.
Summary
superset hosts listfrom 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
- Presence:
host.listfalls 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. - Firewall:
deriveSandboxCredentialsadds the workspace credential header for the relay host, the same value the API already gets. - Relay: with no bearer and that header, it calls
host.cloudWorkspaceAccesswith 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 returnsINTERNAL_SERVER_ERROR(retryable), notUNAUTHORIZED. - Switch:
host.setCloudWorkspaceAccessis owner-only and not onSANDBOX_ALLOWED_PROCEDURES(a test enforces this), so a box can't open a host to itself. host.listandhost.rosterreportallowCloudWorkspaces.
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:
sessionFromOAuthBeareraccepts any API-signed JWT as a full user session regardless ofscope, which includes the automation tokens minted for hosts. Worth its own ticket.
Manual QA (dev stack: local API + relay + dev desktop)
-
host.listas the box: the dev host showsonline: 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 testinpackages/shared(1547),packages/trpc(873),apps/relay(19),packages/i18n(169), all passingdecideCloudWorkspaceAccessis a pure function with tests for owner (open/closed), member, no access, cross-org and non-box callersbun run typecheckin the same four packagesbun 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.listmints a 60-second server presence token when the caller has no bearer, which also fixes presence for API-key callers.host.listandhost.rosterreportallowCloudWorkspaces; the mobile sandbox host row sets it false.host.setCloudWorkspaceAccessis 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.