Skip to content

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

Merged
harshithmullapudi merged 10 commits into
mainfrom
harshith/cloud-workspaces-reach-opted-in-hosts
Nov 6, 2026
Merged

harshithmullapudi merged 10 commits into
mainfrom
harshith/cloud-workspaces-reach-opted-in-hosts

Conversation

@harshithmullapudi

@harshithmullapudi harshithmullapudi commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

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

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.

Written for commit 680819f. Summary will update on new commits.

View guided diff Turn on auto-fix

Whether a host's owner lets their cloud workspaces reach it through the
relay. Defaults to false, so every existing host stays closed. A constant
default on Postgres 11+ is metadata-only: no table rewrite.
A cloud workspace holds no user token, so `superset hosts list` from a box
showed every host offline and `--host` calls failed as "Session expired".

- host.list asks the relay for presence with a short-lived server token
  when the caller has no bearer (a box, or an API key).
- host.setCloudWorkspaceAccess: owner-only switch for
  v2_hosts.allow_cloud_workspaces. Not on the sandbox allow-list, so a box
  cannot open a host to itself.
- host.cloudWorkspaceAccess: the relay's per-call check for a box's
  credential: the creator has access to the host and the host is open.
- The sandbox firewall adds the workspace credential on requests to the
  relay host too, so nothing enters the box.
- host.list and host.roster report allowCloudWorkspaces (additive).
With no bearer and the firewall-added workspace credential, the relay asks
the API on every call whether the box may reach this host, and refuses
with a message that says so when the host is closed. A box gets HTTP only:
the firewall refuses WebSocket upgrades on hosts it rewrites, and a stream
would outlive the per-call check. The credential never reaches the host.
@coderabbitai

coderabbitai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 34 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/relay/src/access.ts">

<violation number="1" location="apps/relay/src/access.ts:132">
P2: An API outage is converted to 401 for host tRPC routes. Preserve the 500 result as `INTERNAL_SERVER_ERROR`, otherwise cloud-workspace callers refresh credentials instead of retrying the transient access-check failure.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant WS as Cloud Workspace
    participant FW as Vercel Firewall
    participant R as Relay
    participant API as API (tRPC)
    participant DB as Database
    participant H as Host Service
    
    Note over WS,H: Runtime: Cloud Workspace → Host via Relay
    
    WS->>FW: HTTP request to relay (no bearer token)
    FW->>FW: Add x-superset-sandbox-credential header
    
    WS->>R: Forward request with credential header
    R->>R: authenticateCloudWorkspace(credential, hostId)
    R->>API: host.cloudWorkspaceAccess.query({hostId}) [creates sandbox client]
    API->>DB: Check v2UsersHosts for (creator, host) membership
    API->>DB: Check v2Hosts.allowCloudWorkspaces column
    DB-->>API: Access decision
    API-->>R: Return {allowed, userId, workspaceId} or denial
    
    alt Credential valid AND host allows cloud workspaces
        R->>R: Build auth context from API response
        R->>H: Forward request (strip credential header, add user id)
        H-->>R: HTTP response
        R-->>WS: 200 OK + response body
    else Credential valid but host closed
        API-->>R: allowed=false, reason="host_closed"
        R-->>WS: 403 "host does not accept cloud workspaces"
    else Forged/invalid credential
        API-->>R: 401 UNAUTHORIZED
        R-->>WS: 401 Unauthorized
    end
    
    Note over WS,R: WebSocket Upgrade from Box
    
    WS->>R: WebSocket upgrade request with credential header
    R->>R: Detect cloudWorkspaceId + WS upgrade
    R-->>WS: 403 Forbidden (HTTP only)
    
    Note over WS,API: host.list presence fallback
    
    WS->>API: host.list (no bearer token)
    API->>API: Mint 60s "automation-presence" JWT
    API->>R: fetchRelayPresence with presence token
    R-->>API: Online/offline status per host
    API-->>WS: Host list with allowCloudWorkspaces + presence
    
    Note over WS,API: Owner controls access
    
    Owner->>API: host.setCloudWorkspaceAccess({allowed: true})
    API->>API: Check caller is host owner (not sandbox)
    API->>DB: Update allowCloudWorkspaces
    DB-->>API: Success
    API-->>Owner: {allowed: true}
Loading

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/trpc/src/router/host/host.ts Resolved
Comment thread apps/relay/src/access.ts
const code = (error as { data?: { code?: string } }).data?.code;
return {
ok: false,
reason: code === "UNAUTHORIZED" ? "unauthorized" : "error",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: An API outage is converted to 401 for host tRPC routes. Preserve the 500 result as INTERNAL_SERVER_ERROR, otherwise cloud-workspace callers refresh credentials instead of retrying the transient access-check failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/relay/src/access.ts, line 132:

<comment>An API outage is converted to 401 for host tRPC routes. Preserve the 500 result as `INTERNAL_SERVER_ERROR`, otherwise cloud-workspace callers refresh credentials instead of retrying the transient access-check failure.</comment>

<file context>
@@ -87,3 +91,45 @@ export async function checkHostAccessForRegister(
+		const code = (error as { data?: { code?: string } }).data?.code;
+		return {
+			ok: false,
+			reason: code === "UNAUTHORIZED" ? "unauthorized" : "error",
+		};
+	}
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in a65d1b4. A failed access check (status 500) now maps to INTERNAL_SERVER_ERROR on the host tRPC routes, not UNAUTHORIZED, with a test that simulates the API being down.

Comment thread packages/i18n/locales/de/messages.po Resolved

Copy link
Copy Markdown
Contributor

Review: needs changes. The design is sound, and the relay behaves as described when run locally. But CI Typecheck fails because of this PR, and the opt-in reaches further than its docs say.

  • CI Typecheck fails: apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts:122. The synthetic "Cloud" OrgHost lacks the new allowCloudWorkspaces field from host.roster. Add allowCloudWorkspaces: false.
  • Opt-in also opens the host to members' boxes: packages/trpc/src/router/host/host.ts:283-303. The v2_users_hosts lookup has no role filter, so after the owner turns it on, every host member's boxes (shared through addMember) can drive the machine. The schema comment, docs and planned label say "my/their own" cloud workspaces. Add eq(v2UsersHosts.role, "owner"), or change the naming and dialog to match.
  • No tests for the API decision: host.ts:279-406. The API is the only lock, but nothing tests cloudWorkspaceAccess or setCloudWorkspaceAccess (cross-org, closed, member versus owner). Move the decision into a pure function, as update-access.ts does.
  • Low: apps/relay/src/index.ts:98-101. Anyone can make the relay call the API, with no token, by sending the header. The WebSocket refusal (:305) also comes only after the API round trip. Check the WebSocket case and the header shape first.

Relay checked locally with wrangler dev at 5fc716a1: 401 forged, 403 closed (new message), allowed passes auth, box WebSocket 403. The relay, shared and sandbox tests pass.

Full report: https://app.superset.sh/page/pr-9408-review-cloud-workspaces-reach-opted-in-hos-zm0w3q

host.roster rows now carry allowCloudWorkspaces, and the row mobile builds
for a cloud workspace's own sandbox has to match. A sandbox is not a host
its owner opened, so false.
host.cloudWorkspaceAccess matched any v2_users_hosts row, so once an owner
opened a host, the boxes of every member they had added could drive it too.
The decision is now a pure function that requires the owner role, with tests
for owner, member, closed, cross-org and non-box callers.

Also use the catalog's existing German term for host owner.
…ges retryable

- A box's WebSocket upgrade and a credential that is not the shape the API
  mints are refused without an API round trip.
- A failed access check (API unreachable) is INTERNAL_SERVER_ERROR on the
  host tRPC routes, not UNAUTHORIZED, so callers retry instead of
  treating it as an auth failure.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 8 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

harshithmullapudi added a commit that referenced this pull request Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed:

  • Mobile typecheck: fixed in fc191ba (allowCloudWorkspaces: false on the synthetic sandbox OrgHost). CI Typecheck is green.
  • Members' boxes: only the owner's cloud workspaces pass now (5cd743c).
  • Tests: the decision is a pure decideCloudWorkspaceAccess with tests for owner open/closed, member, no access, cross-org and non-box callers. The lookup arguments are asserted too (9f81d6f).
  • Relay calls the API for anyone with the header: a credential that isn't the shape the API mints (<uuid>.<43 base64url>) and a box's WebSocket upgrade are refused before any API call. An API outage is now INTERNAL_SERVER_ERROR, not UNAUTHORIZED (a65d1b4).

@harshithmullapudi
harshithmullapudi changed the base branch from harshith/db-v2-hosts-allow-cloud-workspaces to main November 5, 2026 23:58
@expo

expo Bot commented Nov 6, 2026

Copy link
Copy Markdown

Preview update published for development build 6ded2902 (fingerprint e65ce637d4ee). Open the build on your phone and scan:

Update group 14ece5ff

@harshithmullapudi
harshithmullapudi merged commit 489c612 into main Nov 6, 2026
19 of 20 checks passed
harshithmullapudi added a commit that referenced this pull request Nov 6, 2026

This branch was previously deployed

1 inactive deployment
preview — 680819f3 Deployed Nov 5, 2026 by harshithmullapudi via Deploy Docs #13957
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