Cloud Ports and Desktop: reach VM ports over the user-space hub; keep the VPN extension explicit (#12093)

Merged
AU
2mo ago
+2618-259

Fixes https://github.com/manaflow-ai/cmux/issues/12093
Fixes https://github.com/manaflow-ai/cmux/issues/12097 (every entry of Resources/Localizable.xcstrings that sat outside the strings object now lives inside it, so its Japanese copy is actually used)

What changed

Opening a Ports or Desktop row no longer depends on the packet-tunnel system extension, on any build.

  • Sources/Cloud/PortForward/ (new): CloudHubPortForwarder owns one 127.0.0.1 listener per (machine, VM port). Each accepted connection claims the user-space WireGuard hub (CloudWireGuardHub, the same one every Cloud terminal uses), runs the SOCKS5 CONNECT to <private address>:<port> over the hub's unix socket (SocksV5Client, the exact subset cmux-tui wg hub speaks), relays bytes both ways (CloudPortForwardRelay, NWConnection+Async), and releases the claim when either side closes. Idle forwards hold no hub lease. A machine's forwards close when it leaves the fleet, on sign-out, and with the process.
  • CloudPortRoutePlan (pure): a machine with a private address → hub forward; no address but a provider preview capability → the control plane's tokened URL; otherwise a named, localized refusal. The plan never consults the tunnel backend.
  • CmuxTuiSurfaceProvider+PortForward: the pane is created with the connecting placeholder, the forward is started (so localPortURL(port:) answers at once), a paused machine is woken through open-port as before, the hub is warmed once so a hub that cannot start is explained in the pane, then the pane loads http://127.0.0.1:<local port> with the original path and query (noVNC keeps vnc.html?...).
  • VMClient loses the private-network gate; openPort is a plain HTTPS call again, so cmux vm open --print cannot trigger the extension either. CloudPrivateNetworkNoopGate is deleted.
  • Sidebar: the explicit VPN's state (starting / waiting for approval / connected / failed) is a banner under the Machines control bar (MachinesTunnelBanner, fed by CloudTunnelStatusModel from CloudTunnelCoordinator.stateUpdates()), with an Open System Settings button that lands on Login Items & Extensions (macOS 15+) or Privacy & Security (macOS 14). The previously dead CloudTunnelStatus.privateRouteBlocker copy is what it shows; .off now reports no blocker.
  • Ports row menu: "Copy Link" hands out the loopback URL (creating the forward if needed); "Copy Private Address URL" keeps the raw http://<private ip>:<port> for a Mac that ran cmux vpn up.
  • vm.port_open returns the loopback url (what the pane loads and what any app on the Mac can open) plus private_url.
  • CLI: cmux vpn up prints what it is about to install (extension + "cmux Cloud" VPN configuration, and that cmux itself does not need it) before the OS dialog; status/down/help copy stops claiming the app starts the tunnel when a machine is opened.
  • Docs: docs/cloud-userspace-wireguard.md, docs/cloud-cmux-tui-daemon.md, docs/cli-contract.md.

Determination: what needs the system extension, and what does not

Short answer. The "cmux would like to use a new network extension" dialog is expected only for an explicit system-wide VPN (cmux vpn up). It is not necessary for Ports, and it was never necessary for Desktop either: both only need one TCP connection per pane into the VM's private network, and the app already owns such a route, the user-space WireGuard hub that every Cloud terminal uses. The nightly prompted because #11789 routed browser panes through the packet-tunnel extension anyway, and Ports "failed closed" because that path has no bounded wait while macOS waits for the approval.

Evidence from the code (main at 676b8c8)

FeatureRoute on mainNeeds a macOS VPN?Why
Terminals, workspace tree, port discovery (ss -ltn over the link)cmux-tui wg hub → SOCKS5 on an owner-only unix socket → remote connect --wireguard-hub (CloudWireGuardHub, CloudMachineLinkManager)NoUser-space WireGuard (BoringTun + smoltcp) inside the bundled cmux-tui; no interface, no privilege.
Ports rows (http://<private ip>:<port>)CmuxTuiSurfaceProvider.materialize → VMClient.requireCloudBrowserAccess → CloudTunnelCoordinator.requirePrivateNetworkUse → OSSystemExtensionRequest → NETunnelProviderManagerNo. A loopback listener that relays each connection through the hub's SOCKS5 CONNECT reaches the same address; the hub already allows any target inside the tunnel's routes (cmux-remote/src/wireguard_hub.rs).The only thing a VPN adds is that the kernel routes the address, which matters for processes the app does not control. A WKWebView pane is the app's own connection.
Desktop row (noVNC on 6901)Same as PortsNo. Same forward; the WebSocket rides the TCP relay.Same reason.
cmux vm open <id> <port> --print, vm.open_portVMClient.openPort (a plain HTTPS control-plane call), gated on the extension on mainNo. The gate was incidental: the request goes to cmux.com, not into the private network.
Published ports (#11986)https://<vm>--<org>--<port>.cmux.shNoPublic HTTPS.
Other apps on the Mac reaching 10.x.x.x (Safari, ssh from another terminal, cmux vpn hosts .internal names)Kernel route through the packet-tunnel extensionYes. This is the one thing only a real VPN can do.A third-party process cannot be pointed at the app's unix-socket SOCKS hub.

Two more facts from the code:

  • CloudTunnelStatus.privateRouteBlocker, the copy that says "macOS is waiting for you to allow the cmux Cloud Tunnel extension…", had zero call sites; nothing showed it (that is the silent failure the issue describes).
  • CloudTunnelCoordinator.requirePrivateNetworkUse → ensureUp() loops on .awaitingApproval with no deadline (CloudTunnelCoordinator.swift:169-176), so a pane opened while the extension is "[activated waiting for user]" spins on "Connecting…" until the user finds System Settings on their own.

Why the loopback-forward design, and the trade-offs

The default the issue names (a local userspace listener forwarding to the VM port over the existing hub) is the right one, and I could not name a reason a macOS networking engineer would reject it:

  • Same trust model as ssh -L / kubectl port-forward. Any process on this Mac can connect to the loopback port while cmux runs; the hub's unix socket is owner-only but WebKit (and the user's own browser) can only speak TCP, so a loopback listener is the shape every port-forwarding tool takes. Forwards exist only for machines in the fleet and close on sign-out and quit.
  • Host header becomes 127.0.0.1:<local port>, which is what dev servers expect: Vite's DNS-rebinding protection (server.allowedHosts) rejects a raw private IP host by default but allows loopback, so the forward works for more servers than the VPN path did.
  • Ports work on every build, including unentitled dev builds and a nightly whose extension the user never approved; the extension approval, the /Applications requirement, and the signing profile stop being prerequisites for the sidebar.
  • What gets worse: the private IP shown on the row is not the URL the pane loads (the row is the service's identity, the pane shows the local forward; VS Code's forwarded-ports panel does the same). "Copy Link" now hands out the loopback URL, and the private address moved to "Copy Private Address URL" for people who run cmux vpn up.

Rejected alternatives: teaching the Rust hub to expose TCP forwards (a cmux-tui release for something the app can do with the SOCKS client it already has, and the hub is deliberately a narrow carrier), and a per-pane WKWebsiteDataStore SOCKS proxy (per-data-store scope, unix-socket SOCKS endpoints undocumented, and no URL usable outside the pane).

Trade-offs taken

  • Loopback listener instead of a VPN. Any process on this Mac can connect to the forwarded port while cmux runs (the same model as ssh -L); the hub's unix socket stays owner-only. Forwards exist only for machines in the fleet and close on sign-out and quit.
  • Host header changes from <private ip>:<port> to 127.0.0.1:<local port>. Dev servers with DNS-rebinding protection (Vite's default server.allowedHosts) accept loopback and reject a raw private IP, so this is a net gain; a server that whitelists the VM's private IP specifically would need the VPN path.
  • Row title vs pane URL. The Ports row keeps showing the service's identity (10.x.x.x:3000) while the pane loads the local forward, like VS Code's forwarded-ports panel. "Copy Link" gives the working loopback URL; the private address moved to "Copy Private Address URL".
  • vm.port_open's url changed meaning: the link the pane loads (the loopback forward, or the control plane's preview URL for a machine without a private address) instead of the private address; private_url carries the old value, and a machine with neither route is an error rather than an empty url. The pane, Copy Link, and the socket verb share one resolver (CmuxTuiSurfaceProvider.portLinkURL), so on a machine without a private address the verb mints a second preview URL after the pane's own. vm.desktop_open still reports the private noVNC URL.
  • The coordinator's on-demand API stays. CloudTunnelCoordinator.requirePrivateNetworkUse / prepareForPrivateNetworkUse and the idle policy are no longer wired to any app path but remain (tested) for a future explicit consumer; pruning them is a follow-up so this PR stays scoped to #12093.
  • No in-app "start VPN" button was added; cmux vpn up remains the only trigger and prints the explanation itself. If a button lands later it must show the same explanation before the request.
  • The string catalog fix is broader than this issue. CodeRabbit flagged the entries outside strings as invalid; moving all 132 of them (not just the cloud/vpn families) is mechanical and makes their Japanese copy effective, at the cost of a wider diff in that one file.

Verification

Verified so far (this section is updated as the remaining runs finish):

  • scripts/check-pbxproj.sh, scripts/lint-pbxproj-test-wiring.sh, check-workspace-package-groups.py --check, lint-feature-flags.py, check-package-resolved-policy.py, git diff --check: all pass locally.
  • xcrun swiftc -parse on every changed Swift file; xcodebuild -list reads the project.
  • Localization audit: every key added or changed carries en and ja; the catalog's stray top-level entries are inside strings now.
  • The repo's ci.yml is path-gated and only ran its status fallback for this PR, so the full macOS suite was dispatched explicitly on the branch; latest: https://github.com/manaflow-ai/cmux/actions/runs/34235402180 (HEAD 8defff594e: after the fourth origin/main merge, which brought main's #12163 removal of the Increase Disk action, a string-catalog conflict resolved by keeping both sides, a pbxproj re-normalization, and the copyPortLink closure added to main's new CloudTreeMachineMenuTests fixture, which the semantic-delivery-integration check caught on the merge commit). From the second main merge on, swift-package-tests fails and release-build is skipped for a reason that is main's, not this branch's: #12161 (FakeTerminalEngine.swift in the CmuxTerminal package tests uses UUID without import Foundation, since #10564; main's own run 34221588627 fails the same way, and this branch's run 34208136921 from before that merge passed both); run 34224101125 covers c42b50afd7 (after the second origin/main merge, which also carries main's fix for the web-typecheck gate); run 34208136921 covers ec55ca1b34: release-build and swift-package-tests pass, every app-host unit tests shard, tests, and tests-build-and-lag fail exactly as on main, see #12128). On the previous dispatch (9866564c27, run 34191563050) release-build and swift-package-tests passed; the app-host unit tests shards fail before any test runs because the cmuxTests bundle does not compile on main itself (two pre-existing files reference symbols that do not exist in the app target; filed as #12128 with main's own failing runs as evidence). The suites added here cannot run through that lane until #12128 is fixed.
  • The earlier web-typecheck failure on the first merge came from main (#12147, fixed there by #12154 and merged in since).
  • Final dispatched run on the merge HEAD 8defff594e (https://github.com/manaflow-ai/cmux/actions/runs/34235402180): every gate and web job green; app-host unit tests shards 3 and 5 pass (the bundle compiles again now that #12113 is merged in, so this PR's suites ran through CI); shard 6 fails two tests in ClaudeHookLifecycleCleanupTests (hook lifecycle, reworked by #11976 on main today; nothing in this diff touches it); shards 1, 2, 4 were cancelled by the workflow's time limit, as on main's last run (34221588627: shards 2 and 4 cancelled); swift-package-tests is #12161; tests-build-and-lag is main's warning-budget debt (below); release-build is skipped because it depends on the package tests. A fresh main baseline (https://github.com/manaflow-ai/cmux/actions/runs/34239314105) cannot even reach those jobs: main's project.pbxproj has been un-normalized since #12145 (filed as #12172); this branch normalizes it in 827440c466.
  • Required checks on the PR (ci-status, CLA Assistant, CLA policy guard, Web complexity) and the PR-triggered semantic-delivery-integration / tests, agent-notification-tests, Cursor Bugbot, CodeRabbit, and Socket checks are all green on 8defff594e.
  • tests-build-and-lag fails on this branch and on main (run 34221588627) in the same step, Validate Swift warning budget, with byte-identical output: six over-budget buckets in AppDelegate+PaneMemoryGuardrail.swift, SessionIndexTableController.swift, CmuxTuiSnapshotParser.swift, SurfaceCatalogModel.swift, and TerminalController.swift, none of which this PR touches. This PR adds no Swift warning; the budget TSV is left alone on purpose.
  • Focused suites (SocksV5ClientTests, CloudLoopbackPortForwardTests, CloudPortRoutePlanTests, CloudTunnelBannerTests, CloudTunnelStatusBlockerTests, CloudTunnelCoordinatorTests, CloudPortOpenRegressionTests, CloudTreeNativeDragOwnershipTests), run with xcodebuild -scheme cmux-unit … test on cmux-austin-mini-1 in the reload-cloud checkout at 50a99ebd86 (the later commits touch a doc comment, the registry's teardown ordering, which has no test harness, and docs), with the two pre-existing files that do not compile (#12128) neutralized for that run only: 70 tests in 8 suites, 67 passed. The 3 failures are pre-existing and not touched by this PR: CloudPortOpenRegressionTests "A discovered port has one stable machine identity…" and "A localhost browser view is folded…" expect no portsGroup node for a machine without ports, which #12051 changed to always render (this PR's diff contains no change to portsGroup or the …/browser/port: node ids), and CloudTreeNativeDragOwnershipTests "An abandoned Cloud writer revokes its provisional capability on deallocation" is deallocation-timing (its fixture only gained the copyPortLink: { _ in } line here). Every suite this PR adds passes. An earlier pass of the same run caught a real gap, fixed in 50a99ebd86: CloudLoopbackPortForward.stop() returned before the listener released its port, so the "closing frees it" case could still connect for an instant.
  • Fleet build of the tagged app (reload-cloud.sh --tag issue-12093-cloud-tunnel-ports): succeeded on the pushed HEAD d04f073e17 (on cmux12s-mac-mini; earlier builds of 75cd8e92b1 and 86dd80b4f3 on cmux-austin-mini-1). The live run below was repeated on the d04f073e17 build: cmux vm open <vm> 8000 --json → url: http://127.0.0.1:64094, curl → HTTP 200 with the VM's directory listing, vm open <vm>:desktop → the noVNC pane, cmux vpn status unchanged.
  • Live run on a real Cloud machine (staging backend, unentitled dev build), through the tagged app's debug socket, account austin@manaflow.ai, machine vm-cae9aec4f6a442d68651be20f2cf2980 (private address 10.16.170.2), with python3 -m http.server 8000 --bind 0.0.0.0 started on the VM:
    • cmux vm open <vm> 8000 --json → url: http://127.0.0.1:64092, private_url: http://10.16.170.2:8000, a new browser pane.
    • curl http://127.0.0.1:64092/ from the Mac → HTTP/1.0 200 OK, Server: SimpleHTTP/0.6 Python/3.12.3, the VM's directory listing; the pane's browser screenshot shows the same listing. That is the Ports transport end to end over the real user-space WireGuard hub, with the system extension reported unavailable (entitlement-missing) and no macOS dialog.
    • cmux vm open <vm>:desktop → the pane loads http://127.0.0.1:64093/vnc.html?path=websockify&autoconnect=1… and shows the live noVNC desktop (the WebSocket rides the same forward).
    • cmux vpn status → "Terminal tunnel: up (user-space WireGuard)", "System-wide tunnel: unavailable in this build (cmux's own terminals, Ports, and Desktop do not need it)"; cmux vpn up fails closed with the same explanation and asks macOS for nothing; systemextensionsctl list unchanged.
    • Not exercised live: the Ports discovery row on that machine (its cmux-tui daemon was not up on staging, so the headless link stayed "connecting"; the sidebar row and vm.port_open share SurfaceCatalog.openCloudPort, which the run above went through), and the Machines-panel banner (a dev build has no Network Extension, so the coordinator never leaves off).
  • Unit coverage of the transport, independent of any machine: CloudLoopbackPortForwardTests against a fake SOCKS5 hub over both TCP and a unix socket (relay both ways, IPv6, refused CONNECT with and without a bound address, stalled hub cut off by the handshake deadline under a virtual clock, hub unavailable, stable local port across a private-address change, concurrent first uses, dead-listener replacement, provider local-URL contract). What only a signed nightly can show: the Machines-panel banner during a real cmux vpn up approval wait, and that opening a Ports/Desktop row on the nightly no longer triggers the extension dialog.

Regression commits

  1. CloudTunnelStatusBlockerTests (red on main): an off system-wide VPN must not be reported as blocking the user.
  2. The fix plus the new behavior suites; review rounds follow as their own commits.

🤖 Generated with Claude Code


[!NOTE]
Medium Risk
Changes core Cloud connectivity (port opens, VPN lifecycle, and socket vm.port_open response shape) and adds concurrent loopback forwarding; mistakes could break Ports/Desktop or leak wrong URLs to users.

Overview
Ports and Desktop panes no longer start or wait on the macOS Network Extension. Opening those rows now routes traffic through the existing user-space WireGuard hub: a per-(machine, port) loopback listener relays TCP via SOCKS5 into the VM private address, with routing decided by CloudPortRoutePlan (hub forward vs control-plane preview).

The system VPN is reframed as opt-in for other apps, not for cmux itself. CloudTunnelCoordinator policy and CLI/Machines copy say the extension is only for cmux vpn up (browsers, ssh, .internal hostnames). VMClient drops the private-network gate and requireCloudBrowserAccess on openPort; CloudPrivateNetworkNoopGate is removed. The Machines panel shows a tunnel status banner (including System Settings during extension approval).

Cloud UX and APIs align on the loopback URL. Sidebar Copy Link uses portLinkURL (loopback forward); Copy Private Address URL keeps the raw private IP URL. vm.port_open returns url as the shareable link plus private_url. Registry teardown closes hub forwards when machines leave the fleet or on sign-out.

Supporting changes: prompt helpers split to CloudTreeNodeActions+Prompts.swift, new localized strings (en/ja), and Localizable.xcstrings entries moved inside the strings object so translations apply.

Reviewed by Cursor Bugbot for commit 8defff594eee8b8aa99616522f041eafd07aa9b0. Bugbot is set up for automated code reviews on this repo. Configure here.