feat: forward cloud workspace ports to your computer, and list forwards with `superset ports`

Merged
HA
4d ago
+2424-38

Links

Summary

This PR bundles three related changes:

  1. Forward cloud workspace ports to this computer (desktop). Conductor-style: nothing is forwarded until someone clicks Forward. The control is in the Share popover ("Ports on this computer"), the sidebar ports card and the top-bar ports dropdown. If the local port is busy, Forward picks the next free one.
  2. superset ports (realtime + API + CLI). The desktop reports its forwards to the workspace's realtime hub. A new read-only cloudWorkspace.forwards procedure and superset ports list / superset ports port <n> show who forwarded which port to which local address. Agents inside a cloud workspace can use these.
  3. Fix: forwards now reconnect after the host refuses a session (e.g. the sandbox was asleep). This also affects remote-device forwarding.

Part 1: Forwarding cloud ports (desktop)

Why

Cloud workspace ports weren't reachable from the laptop at all. Remote-device forwarding (relay, #6858) skipped cloud workspaces, and the sidebar never even listed their ports.

How

  • Same engine as remote-device forwarding (PortForwardManager plus the /fwd mux on host-service), but routed through the sandbox gate with the workspace's ticket. SandboxAccessProvider hands the ticket to the main process. Gate hosts never fall back to the relay token.
  • Explicit forwards belong to no window. They survive navigation and end on Stop, sign-out or quit.
  • Busy local port: Forward tries the same number, then up to 20 higher, then any free port. The row says "5173 was in use on this computer, so it moved to 5174". Remote-device auto-forwarding keeps its existing "Use another port" prompt.
  • Host unreachable at Forward time (e.g. sleeping box): the forward still binds the local port and heals on the first connection that gets through.
  • Ports that stop being detected: forwards on them still show in the Share popover ("Not detected in the workspace right now"), so they can be stopped.
  • The open cloud workspace's ports now reach the sidebar. Its row comes from the cloud list, not a sidebar placement, so it was being filtered out.
  • Dev only: Node doesn't resolve the dev gate's *.localhost, so those connections go to 127.0.0.1 with the original Host header.

New components

  • CloudPortForwardActions: Forward/Stop, shared by the sidebar card, the ports dropdown and the Share section.
  • CloudWorkspaceShareButton/components/SharePortsSection (SharePortRow, ShareStaleForwardRow).
  • main/lib/port-forward/forward-reporter.ts.

Part 2: superset ports

Why

An agent inside a cloud workspace can't see which port people forwarded where. That mapping lives only on each person's laptop, the same way a Docker container can't see its published host port.

How

DecisionChoiceRationale
Where the mapping livesPer-workspace WorkspaceHub Durable ObjectNo sandbox release needed; box sleep doesn't lose it; existing access checks, revocation and silent-socket sweeping are reused
How a laptop's entries expireIts WebSocket closingNo heartbeat or TTL code. Quit, sleep or network loss drops the entries; the hub's sweep removes sockets that went silent
How the agent reads itCLI → API cloudWorkspace.forwards → hub (shared secret)The box's CLI signs in to the API, not to realtime. The API applies the same access rule as opening the workspace

Realtime

  • A new forwarder connection role. It isn't presence, isn't recorded as a visit and gets no broadcasts.
  • Reports are stored in hub SQLite, not on the socket, because a hibernating socket's state is capped at 2 KB.
  • Messages over 4 KB are refused, and unchanged reports skip the write.
  • Losing access closes forwarder sockets, the same as viewers.
  • A GET on the same path, checked with the shared secret (timing-safe compare), lists forwards for the API.

Desktop

  • The reporter opens one socket per workspace that has explicit forwards, with the token in the Authorization header.
  • The backoff only resets after a socket stays up, because the hub refuses by accepting and then closing.
  • A 4401-refused token isn't retried until it changes. 4403 only backs off, since access can be granted later.

CLI

  • ports list defaults to $SUPERSET_WORKSPACE_ID, which equals the cloud workspace id inside a sandbox (checked).
  • ports port <n> prints bare http://localhost:N addresses, one per person, and exits non-zero if nobody forwarded the port.

Part 3: Reconnect fix (mux-session.ts)

ws emits no close after a refused upgrade, so a session that got a non-101 answer was never marked dead. The transport then kept reusing it, and the local port stayed dead until Stop and Forward. This is now fixed.


Manual QA

All of this was verified by clicking through the desktop dev app on a cloud box. The box played the role of the sandbox, through a local wrangler dev gate and a pass-through shim in front of its host-service. The shim and stub are not in this PR. A fresh Vercel sandbox still runs the released host-service, so this was the only way to exercise it.

Forwarding

  • npm run dev in a cloud workspace terminal → the port appears as 5173 · cloud in the sidebar card and the Share popover
  • Forward with 5173 busy locally → it goes straight to localhost:5174 with the "moved" note; the page loads; hot reload works through the forward
  • Leave the workspace → the forward keeps serving; come back → Stop → the local port is freed
  • Sleep/wake: box down (gate answers /fwd 500) → box back → the same local port reconnects without clicks. This failed before the mux-session fix.
  • The box restarted for real during testing → the app reconnected; after restarting the dev server, Forward worked

Reporting

  • Forward → the hub lists 5173 → localhost:5174 with the person's name; Stop → the hub list is empty (read via the realtime endpoint with the shared secret)

Not verified

  • superset ports list end to end against a dev API. The dev CLI's sandbox sign-in rules got in the way. The API procedure is a thin wrapper over the hub read verified above, and the CLI commands typecheck and are covered by the suite.
  • The fixes from the review round below weren't re-run in the app. They're covered by unit tests.
  • A real Vercel sandbox through the deployed gate.

Testing

  • bunx tsc --noEmit: desktop, realtime, trpc, cli, shared all clean
  • bun test: desktop port-forward + dashboard components (264), realtime (18), trpc (834), cli (482)
  • New tests:
    • explicit forwards outlive windows;
    • an explicit forward moves to a free port when its own is busy;
    • recovery after a failed first probe;
    • stopExplicit;
    • the reporter sends once per change, uses the auth header, closes when forwards end, and doesn't hammer a refusing hub;
    • the hub forwards store (report, prune, keep since, parse limits);
    • the cloud port query target.
  • bun run check:i18n: new strings translated in all 17 locales. The port-forwarding "Forward" has its own context so it doesn't reuse the navigation "Forward" translation.
  • Biome clean

An independent review pass (state machines, security, code) found no high-severity issues. Its medium findings are fixed in this PR:

  • sign-out now tears down forwards, reporter sockets and tickets;
  • the reporter's retry loop on refusals is gone;
  • a forward no longer gets stuck in error after a failed first probe;
  • forwards whose port disappeared can now be stopped.

Known Limitations

  • Forwards on other workspaces can't be seen or stopped from where you are. They show only in their own workspace's Share popover. A global "forwards on this computer" list is the next step.
  • Background workspaces still sleep. Forwarded traffic doesn't keep a sandbox awake, and the gate doesn't wake it. After sleep the dev server has to be started again.
  • The hub has no per-connection message rate limit beyond the 4 KB cap and skipping unchanged reports. Forwarders share the per-user presence connect limit.
  • "Forward a port…" for undetected ports is not included. It needs a host-service change: /fwd only allows ports attributed to the workspace.

Compatibility / Rollout

  • The API change is additive (cloudWorkspace.forwards, new and read-only).
  • Order: the realtime worker and API deploy on merge. The CLI ports commands and the desktop changes ship in their next releases. Per packages/trpc/AGENTS.md rule 9, don't release the CLI before the API is live.
  • No sandbox release needed. The desktop uses the existing /fwd route on today's host-service.
  • A desktop released before the realtime deploy just backs off when reporting; forwarding itself works.

Rollback

  • Revert the PR. Forwards are in memory on each laptop and hub rows are ephemeral, so there's no data to clean up.

Summary by cubic

Cloud workspace ports can now be forwarded to your machine with a click, and superset ports shows who forwarded what from inside the workspace.

New Features

  • Forward cloud workspace ports from the Share popover, the sidebar ports card, or the top-bar ports dropdown; nothing forwards until you click Forward, and forwards survive navigation until Stop, sign-out, or quit.
  • If the local port is busy, Forward picks the next free port and notes the move in the row.
  • Cloud forwards go through the sandbox gate with the workspace's ticket and never fall back to the relay token, so a failed ticket hand-off fails closed.
  • The desktop reports its forwards to the workspace's realtime hub, and a new read-only cloudWorkspace.forwards API procedure exposes them.
  • New CLI commands superset ports list and superset ports port <n> list per-person http://localhost:N addresses; they default to $SUPERSET_WORKSPACE_ID inside a workspace and are limited to a workspace's own sandbox callers.
  • The open cloud workspace's ports now appear in the sidebar.

Bug Fixes

  • A refused WebSocket upgrade (for example, a sleeping sandbox answering /fwd 500) left a mux session never marked dead, so the local port stayed dead until the forward was restarted; it now reconnects on the next request.
  • Sign-out now clears every forwarding credential and evicts cached mux sessions, so a new account or a forward still starting cannot reuse the previous account's tunnel.
  • The reporter's socket now has a handshake timeout, so an upgrade a proxy never completes falls into the normal retry path.

Summary by CodeRabbit

  • New Features
    • Added controls to start and stop port forwards for cloud workspaces, view detected and active forwards, and open available local addresses.
    • Forwards can remain active until stopped, even when a port is no longer detected. If the requested local port is busy, another port may be assigned.
    • Workspace sharing displays forwarded-port details, including who is forwarding each port and when forwarding began.
    • Added CLI commands to list forwarded ports for a workspace or a specific port, with local addresses and forwarding details.
    • Added translations for port-forwarding controls and status messages in multiple languages.
  • Bug Fixes
    • Improved cloud workspace port detection and forwarding status reporting.