Cloud Ports and Desktop: reach VM ports over the user-space hub; keep the VPN extension explicit (#12093)
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):CloudHubPortForwarderowns one127.0.0.1listener 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 subsetcmux-tui wg hubspeaks), 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 (solocalPortURL(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 loadshttp://127.0.0.1:<local port>with the original path and query (noVNC keepsvnc.html?...).VMClientloses the private-network gate;openPortis a plain HTTPS call again, socmux vm open --printcannot trigger the extension either.CloudPrivateNetworkNoopGateis deleted.- Sidebar: the explicit VPN's state (starting / waiting for approval / connected / failed) is a banner under the Machines control bar (
MachinesTunnelBanner, fed byCloudTunnelStatusModelfromCloudTunnelCoordinator.stateUpdates()), with an Open System Settings button that lands on Login Items & Extensions (macOS 15+) or Privacy & Security (macOS 14). The previously deadCloudTunnelStatus.privateRouteBlockercopy is what it shows;.offnow 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 rancmux vpn up. vm.port_openreturns the loopbackurl(what the pane loads and what any app on the Mac can open) plusprivate_url.- CLI:
cmux vpn upprints 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)
| Feature | Route on main | Needs 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) | No | User-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 → NETunnelProviderManager | No. 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 Ports | No. Same forward; the WebSocket rides the TCP relay. | Same reason. |
cmux vm open <id> <port> --print, vm.open_port | VMClient.openPort (a plain HTTPS control-plane call), gated on the extension on main | No. The gate was incidental: the request goes to cmux.com, not into the private network. | |
| Published ports (#11986) | https://<vm>--<org>--<port>.cmux.sh | No | Public 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 extension | Yes. 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.awaitingApprovalwith 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
/Applicationsrequirement, 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>to127.0.0.1:<local port>. Dev servers with DNS-rebinding protection (Vite's defaultserver.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'surlchanged 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_urlcarries the old value, and a machine with neither route is an error rather than an emptyurl. 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_openstill reports the private noVNC URL.- The coordinator's on-demand API stays.
CloudTunnelCoordinator.requirePrivateNetworkUse/prepareForPrivateNetworkUseand 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 upremains 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
stringsas 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 -parseon every changed Swift file;xcodebuild -listreads the project.- Localization audit: every key added or changed carries
enandja; the catalog's stray top-level entries are insidestringsnow. - The repo's
ci.ymlis 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 (HEAD8defff594e: 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 thecopyPortLinkclosure added to main's newCloudTreeMachineMenuTestsfixture, which thesemantic-delivery-integrationcheck caught on the merge commit). From the second main merge on,swift-package-testsfails andrelease-buildis skipped for a reason that is main's, not this branch's: #12161 (FakeTerminalEngine.swiftin the CmuxTerminal package tests usesUUIDwithoutimport 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 coversc42b50afd7(after the second origin/main merge, which also carries main's fix for theweb-typecheckgate); run 34208136921 coversec55ca1b34:release-buildandswift-package-testspass, everyapp-host unit testsshard,tests, andtests-build-and-lagfail exactly as onmain, see #12128). On the previous dispatch (9866564c27, run 34191563050)release-buildandswift-package-testspassed; theapp-host unit testsshards fail before any test runs because thecmuxTestsbundle does not compile onmainitself (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-typecheckfailure on the first merge came frommain(#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 testsshards 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 inClaudeHookLifecycleCleanupTests(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-testsis #12161;tests-build-and-lagis main's warning-budget debt (below);release-buildis skipped because it depends on the package tests. A freshmainbaseline (https://github.com/manaflow-ai/cmux/actions/runs/34239314105) cannot even reach those jobs: main'sproject.pbxprojhas been un-normalized since #12145 (filed as #12172); this branch normalizes it in827440c466. - Required checks on the PR (
ci-status,CLA Assistant,CLA policy guard,Web complexity) and the PR-triggeredsemantic-delivery-integration / tests,agent-notification-tests, Cursor Bugbot, CodeRabbit, and Socket checks are all green on8defff594e. tests-build-and-lagfails on this branch and onmain(run 34221588627) in the same step,Validate Swift warning budget, with byte-identical output: six over-budget buckets inAppDelegate+PaneMemoryGuardrail.swift,SessionIndexTableController.swift,CmuxTuiSnapshotParser.swift,SurfaceCatalogModel.swift, andTerminalController.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 withxcodebuild -scheme cmux-unit … testoncmux-austin-mini-1in the reload-cloud checkout at50a99ebd86(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 noportsGroupnode for a machine without ports, which #12051 changed to always render (this PR's diff contains no change toportsGroupor the…/browser/port:node ids), andCloudTreeNativeDragOwnershipTests"An abandoned Cloud writer revokes its provisional capability on deallocation" is deallocation-timing (its fixture only gained thecopyPortLink: { _ in }line here). Every suite this PR adds passes. An earlier pass of the same run caught a real gap, fixed in50a99ebd86: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 HEADd04f073e17(oncmux12s-mac-mini; earlier builds of75cd8e92b1and86dd80b4f3oncmux-austin-mini-1). The live run below was repeated on thed04f073e17build:cmux vm open <vm> 8000 --json→url: http://127.0.0.1:64094,curl→HTTP 200with the VM's directory listing,vm open <vm>:desktop→ the noVNC pane,cmux vpn statusunchanged. - Live run on a real Cloud machine (staging backend, unentitled dev build), through the tagged app's debug socket, account
austin@manaflow.ai, machinevm-cae9aec4f6a442d68651be20f2cf2980(private address10.16.170.2), withpython3 -m http.server 8000 --bind 0.0.0.0started 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'sbrowser screenshotshows the same listing. That is the Ports transport end to end over the real user-space WireGuard hub, with the system extension reportedunavailable(entitlement-missing) and no macOS dialog.cmux vm open <vm>:desktop→ the pane loadshttp://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 upfails closed with the same explanation and asks macOS for nothing;systemextensionsctl listunchanged.- 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_openshareSurfaceCatalog.openCloudPort, which the run above went through), and the Machines-panel banner (a dev build has no Network Extension, so the coordinator never leavesoff).
- Unit coverage of the transport, independent of any machine:
CloudLoopbackPortForwardTestsagainst 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 realcmux vpn upapproval wait, and that opening a Ports/Desktop row on the nightly no longer triggers the extension dialog.
Regression commits
CloudTunnelStatusBlockerTests(red on main): an off system-wide VPN must not be reported as blocking the user.- 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.