From a8280058db67d64b7347313ce350dbda36041a1c Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 2 Sep 2026 01:13:03 -0700 Subject: [PATCH 01/40] test(cloud): machine-list 500 must not be labeled unreachable (#11597) A signed-in nightly hitting a real HTTP 500 on GET /api/vm was classified .unreachable, so the Cloud tab showed "Cloud is unreachable - it retries on its own" for a persistent server-side error. classifyListFailure maps every non-401/402 failure (500s, malformed bodies, transport failures) to the same .unreachable bucket. This test asserts a server response is not labeled unreachable and fails until the classification is corrected. Co-Authored-By: Claude Fable 5 --- cmuxTests/MachinesPanelModelTests.swift | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index a15abdaf25b7..66e1739e7872 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -135,6 +135,36 @@ final class MachinesPanelModelTests: XCTestCase { ) } + /// Regression for #11597: a machine-list failure must be classified by + /// what actually happened. A server response — any non-401/402 HTTP status + /// (a 500 from `GET /api/vm`, a 4xx, a body this client can't read) — is a + /// Cloud *service* error, not a transport failure, so it must NOT surface as + /// "Cloud is unreachable — it retries on its own". Only a genuinely absent + /// response (transport failure, transient session refresh) is `.unreachable`. + func testListFailureClassificationTellsTheTruth() { + typealias Problem = MachinesPanelViewModel.CloudListProblem + // Auth and plan gates keep their own fixes. + XCTAssertEqual(MachinesPanelViewModel.classifyListFailure(.httpStatus(401, "")), .sessionRejected) + XCTAssertEqual(MachinesPanelViewModel.classifyListFailure(.httpStatus(402, "")), .requiresPro) + + // A server error is a response from the Cloud service, so it must never + // be labeled "unreachable". This is the exact #11597 mislabel: the + // signed-in nightly hit a real HTTP 500 on `GET /api/vm` and the panel + // showed "Cloud is unreachable" as if the network were down. + XCTAssertNotEqual(MachinesPanelViewModel.classifyListFailure(.httpStatus(500, "")), .unreachable) + XCTAssertNotEqual(MachinesPanelViewModel.classifyListFailure(.httpStatus(503, "")), .unreachable) + XCTAssertNotEqual(MachinesPanelViewModel.classifyListFailure(.httpStatus(400, "")), .unreachable) + XCTAssertNotEqual(MachinesPanelViewModel.classifyListFailure(.malformedResponse("bad")), .unreachable) + + // Only a genuinely absent server response keeps the transport-first + // "unreachable" copy. + XCTAssertEqual( + MachinesPanelViewModel.classifyListFailure(.backendUnreachable(url: "https://cmux.com", detail: "offline")), + .unreachable + ) + _ = Problem.unreachable + } + func testCloudMachinesNeverExposeFleetWhileSignedOut() { XCTAssertEqual( CloudVMPanelAuthState.resolve(isAuthenticated: false, isWorkingOnAuth: true), From 05a97af3b67c20db4d4a7c10ff948a592387dcac Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 2 Sep 2026 01:15:03 -0700 Subject: [PATCH 02/40] fix(cloud): classify a machine-list server error truthfully, not as unreachable (#11597) GET /api/vm can fail three ways the panel must tell apart. classifyListFailure mapped every failure that was not 401/402 - real HTTP 500s, other statuses, unreadable bodies, and true transport failures - to a single .unreachable bucket, so a signed-in nightly that hit a persistent server-side 500 showed "Cloud is unreachable - it retries on its own", as if the network were down. A response from the Cloud service is a server-side failure, not a transport one. Add a .serverError case for any non-401/402 HTTP status and for an unreadable body; keep .unreachable strictly for a genuinely absent response (transport failure or a transient session-refresh miss). The panel gets a matching state whose copy says the error is on cmux's side, not the user's connection. The server side of this outage (one cloud_vms row naming a retired provider 500ing the whole list) is fixed separately in #11587; this makes the Mac app tell the truth about any such failure instead of hiding it behind a network message. Co-Authored-By: Claude Fable 5 --- Resources/Localizable.xcstrings | 51 +++++++++++++++++++++ Sources/Cloud/MachinesPanelView.swift | 32 +++++++++++++ Sources/Cloud/MachinesPanelViewModel.swift | 23 +++++++++- cmuxTests/MachinesPanelModelTests.swift | 53 ++++++---------------- 4 files changed, 118 insertions(+), 41 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d35a2ca953d1..fbfba3902517 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -127833,6 +127833,57 @@ } } }, + "machines.serverError.retry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再試行" + } + } + } + }, + "machines.serverError.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your machines are still there. The Cloud service ran into an error loading them — this is on cmux’s side, not your connection." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マシンはそのまま残っています。クラウドサービスが読み込み中にエラーになりました — これは接続の問題ではなく、cmux 側の問題です。" + } + } + } + }, + "machines.serverError.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud service error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウドサービスのエラー" + } + } + } + }, "machines.unavailable.retry": { "extractionState": "manual", "localizations": { diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index 93f6f3939ae3..5f96fcb99adf 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -282,6 +282,36 @@ struct MachinesPanelView: View { .padding(.top, 2) } + /// The Cloud service answered with an error (a 5xx, another non-401/402 + /// status, or an unreadable body). The request reached the service, so this + /// is not a network problem — say so, and let the user retry. Conflating + /// this with the transport-failure copy is exactly what made #11597's real + /// HTTP 500 read as "Cloud is unreachable — it retries on its own". + @ViewBuilder + private var serverErrorState: some View { + Image(systemName: "exclamationmark.icloud") + .font(.system(size: 26, weight: .light)) + .foregroundColor(.secondary.opacity(0.55)) + Text(String(localized: "machines.serverError.title", defaultValue: "Cloud service error")) + .cmuxFont(size: 13) + .foregroundColor(.primary.opacity(0.85)) + Text(String( + localized: "machines.serverError.subtitle", + defaultValue: "Your machines are still there. The Cloud service ran into an error loading them \u{2014} this is on cmux\u{2019}s side, not your connection." + )) + .cmuxFont(size: 12) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + Button { + viewModel.refresh() + } label: { + Text(String(localized: "machines.serverError.retry", defaultValue: "Retry")) + .cmuxFont(size: 12) + } + .padding(.top, 2) + } + /// HTTP 401 from the Cloud service while the app still holds a session: /// retrying can never fix it, so route straight to a fresh sign-in. @ViewBuilder @@ -466,6 +496,8 @@ struct MachinesPanelView: View { sessionRejectedState case .requiresPro: requiresProState + case .serverError: + serverErrorState case .unreachable: unreachableState } diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index b04d83179271..f715d4516ae7 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -346,19 +346,38 @@ final class MachinesPanelViewModel: ObservableObject { case sessionRejected /// HTTP 402: the plan gates Cloud access. case requiresPro - /// Everything else — retrying may help. + /// The Cloud service answered, but with an error (any non-401/402 HTTP + /// status, or a body this client could not read). The request reached + /// the service, so this is a server-side problem, not a network one — + /// it must never wear the "Cloud is unreachable" copy. + case serverError + /// No usable response came back: a transport failure, or a transient + /// session-refresh failure. This is the only truly "unreachable" case. case unreachable } /// Classify a list failure for ``listProblem``. Pure so tests can pin the /// mapping without a live client. + /// + /// The split is by what actually happened, so the empty state can tell the + /// truth (#11597): a rejected session needs a fresh sign-in, a plan gate + /// needs an upgrade, a server error is the Cloud service failing, and only a + /// genuinely absent response is "unreachable". Mapping a real HTTP 500 to + /// `.unreachable` is what made a signed-in nightly show "Cloud is + /// unreachable — it retries on its own" for a persistent server-side error. nonisolated static func classifyListFailure(_ error: VMClientError) -> CloudListProblem { switch error { case .httpStatus(401, _): return .sessionRejected case .httpStatus(402, _): return .requiresPro - case .notSignedIn, .sessionRefreshFailed, .backendUnreachable, .httpStatus, .malformedResponse: + case .httpStatus, .malformedResponse: + // The service responded (or responded unreadably): a server-side + // failure, not a network one. + return .serverError + case .notSignedIn, .sessionRefreshFailed, .backendUnreachable: + // No usable response reached us. `.notSignedIn` is handled before + // this classifier runs; it is grouped here only for exhaustiveness. return .unreachable } } diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index 66e1739e7872..20f8888478bd 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -135,36 +135,6 @@ final class MachinesPanelModelTests: XCTestCase { ) } - /// Regression for #11597: a machine-list failure must be classified by - /// what actually happened. A server response — any non-401/402 HTTP status - /// (a 500 from `GET /api/vm`, a 4xx, a body this client can't read) — is a - /// Cloud *service* error, not a transport failure, so it must NOT surface as - /// "Cloud is unreachable — it retries on its own". Only a genuinely absent - /// response (transport failure, transient session refresh) is `.unreachable`. - func testListFailureClassificationTellsTheTruth() { - typealias Problem = MachinesPanelViewModel.CloudListProblem - // Auth and plan gates keep their own fixes. - XCTAssertEqual(MachinesPanelViewModel.classifyListFailure(.httpStatus(401, "")), .sessionRejected) - XCTAssertEqual(MachinesPanelViewModel.classifyListFailure(.httpStatus(402, "")), .requiresPro) - - // A server error is a response from the Cloud service, so it must never - // be labeled "unreachable". This is the exact #11597 mislabel: the - // signed-in nightly hit a real HTTP 500 on `GET /api/vm` and the panel - // showed "Cloud is unreachable" as if the network were down. - XCTAssertNotEqual(MachinesPanelViewModel.classifyListFailure(.httpStatus(500, "")), .unreachable) - XCTAssertNotEqual(MachinesPanelViewModel.classifyListFailure(.httpStatus(503, "")), .unreachable) - XCTAssertNotEqual(MachinesPanelViewModel.classifyListFailure(.httpStatus(400, "")), .unreachable) - XCTAssertNotEqual(MachinesPanelViewModel.classifyListFailure(.malformedResponse("bad")), .unreachable) - - // Only a genuinely absent server response keeps the transport-first - // "unreachable" copy. - XCTAssertEqual( - MachinesPanelViewModel.classifyListFailure(.backendUnreachable(url: "https://cmux.com", detail: "offline")), - .unreachable - ) - _ = Problem.unreachable - } - func testCloudMachinesNeverExposeFleetWhileSignedOut() { XCTAssertEqual( CloudVMPanelAuthState.resolve(isAuthenticated: false, isWorkingOnAuth: true), @@ -971,21 +941,26 @@ struct MachinesPanelListProblemTests { ) } - @Test("Transient-shaped failures keep the retry-first unreachable state") - func transientFailuresStayUnreachable() { - #expect( - MachinesPanelViewModel.classifyListFailure(.httpStatus(503, "{}")) == .unreachable - ) + @Test("Transport failures keep the retry-first unreachable state") + func transportFailuresStayUnreachable() { #expect( MachinesPanelViewModel.classifyListFailure( .backendUnreachable(url: "https://cmux.com", detail: "offline") ) == .unreachable ) + #expect(MachinesPanelViewModel.classifyListFailure(.sessionRefreshFailed) == .unreachable) + } + + @Test("Service responses use the server-error state") + func serviceResponsesStayOutOfUnreachableState() { + for status in [400, 404, 409, 429, 500, 503] { + #expect( + MachinesPanelViewModel.classifyListFailure(.httpStatus(status, "{}")) == .serverError, + "HTTP \(status) is a response from the Cloud service" + ) + } #expect( - MachinesPanelViewModel.classifyListFailure(.sessionRefreshFailed) == .unreachable - ) - #expect( - MachinesPanelViewModel.classifyListFailure(.malformedResponse("bad")) == .unreachable + MachinesPanelViewModel.classifyListFailure(.malformedResponse("bad")) == .serverError ) } From 84d4fbe9e5e7b3800a1b7cce3c71e34be39e1310 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 2 Sep 2026 01:23:35 -0700 Subject: [PATCH 03/40] ci: resolve nightly auth callback plist entry semantically --- .github/workflows/ci.yml | 3 + .github/workflows/nightly.yml | 14 +- .../ci/set-nightly-auth-callback-scheme.py | 121 ++++++++++++++++++ tests/test_nightly_auth_callback_scheme.py | 105 +++++++++++++++ 4 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 scripts/ci/set-nightly-auth-callback-scheme.py create mode 100644 tests/test_nightly_auth_callback_scheme.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 593b170642f5..a57af6270d86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -293,6 +293,9 @@ jobs: - name: Validate universal nightly workflow run: bash ./tests/test_nightly_universal_build.sh + - name: Validate nightly auth callback plist rewrite + run: python3 tests/test_nightly_auth_callback_scheme.py + - name: Validate nightly notarization behavior run: ./tests/test_notarize_nightly_dmg.sh diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 8949695878be..88a77b68656e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -553,13 +553,13 @@ jobs: /usr/libexec/PlistBuddy -c "Set :CFBundleName cmux NIGHTLY" "$app_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$app_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${bundle_id}" "$app_plist" - local url_type_name - url_type_name="$(/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes:1:CFBundleURLName" "$app_plist")" - if [[ "$url_type_name" != *.auth ]]; then - echo "Expected CFBundleURLTypes[1] to be the auth URL type, found: $url_type_name" >&2 - exit 1 - fi - /usr/libexec/PlistBuddy -c "Set :CFBundleURLTypes:1:CFBundleURLSchemes:0 cmux-nightly" "$app_plist" + # Resolve the auth URL type by its semantic name/scheme. Xcode can + # reorder CFBundleURLTypes, so a numeric index silently changing + # the web or SSH scheme would strand the browser sign-in handoff. + python3 ./scripts/ci/set-nightly-auth-callback-scheme.py \ + "$app_plist" \ + cmux-nightly \ + --base-scheme cmux /usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$app_plist" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$app_plist" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$app_plist" diff --git a/scripts/ci/set-nightly-auth-callback-scheme.py b/scripts/ci/set-nightly-auth-callback-scheme.py new file mode 100644 index 000000000000..0b9e7eee5852 --- /dev/null +++ b/scripts/ci/set-nightly-auth-callback-scheme.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Replace the auth callback scheme in a built macOS app plist. + +The URL-type array is not an API: Xcode or a future plist edit may reorder it. +Resolve the auth entry by its semantic name (or the previous callback scheme) +instead of relying on a numeric index, then update only that entry. +""" + +from __future__ import annotations + +import argparse +import os +import plistlib +import stat +import sys +import tempfile +from pathlib import Path +from typing import Any, Optional + + +AUTH_NAME_SUFFIX = ".auth" + + +def _normalized_scheme(value: Any) -> str: + return value.strip().lower() if isinstance(value, str) else "" + + +def _is_auth_url_type(entry: dict[str, Any], base_scheme: str) -> bool: + name = entry.get("CFBundleURLName") + if isinstance(name, str) and name.strip().lower().endswith(AUTH_NAME_SUFFIX): + return True + + if not base_scheme: + return False + schemes = entry.get("CFBundleURLSchemes") + return isinstance(schemes, list) and any( + _normalized_scheme(scheme) == base_scheme for scheme in schemes + ) + + +def _auth_url_type_index(url_types: list[Any], base_scheme: str) -> int: + matches = [ + index + for index, value in enumerate(url_types) + if isinstance(value, dict) and _is_auth_url_type(value, base_scheme) + ] + if len(matches) != 1: + raise ValueError( + "expected exactly one auth callback URL type identified by " + f"name '*{AUTH_NAME_SUFFIX}' or scheme '{base_scheme}', found {len(matches)}" + ) + return matches[0] + + +def _write_plist_atomically(path: Path, plist: dict[str, Any], fmt: str, mode: int) -> None: + temporary_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + plistlib.dump(plist, temporary, fmt=fmt, sort_keys=False) + temporary.flush() + os.fsync(temporary.fileno()) + os.chmod(temporary_path, mode) + os.replace(temporary_path, path) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + +def set_auth_callback_scheme(path: Path, callback_scheme: str, base_scheme: str = "cmux") -> int: + normalized_callback = _normalized_scheme(callback_scheme) + if not normalized_callback: + raise ValueError("callback scheme must not be empty") + + raw = path.read_bytes() + plist = plistlib.loads(raw) + if not isinstance(plist, dict): + raise ValueError("plist root must be a dictionary") + url_types = plist.get("CFBundleURLTypes") + if not isinstance(url_types, list): + raise ValueError("plist is missing a CFBundleURLTypes array") + + index = _auth_url_type_index(url_types, _normalized_scheme(base_scheme)) + entry = url_types[index] + assert isinstance(entry, dict) + schemes = entry.get("CFBundleURLSchemes") + if not isinstance(schemes, list) or not schemes: + raise ValueError(f"auth URL type at index {index} has no URL scheme") + + schemes[0] = callback_scheme + fmt = plistlib.FMT_BINARY if raw.startswith(b"bplist") else plistlib.FMT_XML + mode = stat.S_IMODE(path.stat().st_mode) + _write_plist_atomically(path, plist, fmt, mode) + return index + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("plist", type=Path) + parser.add_argument("callback_scheme") + parser.add_argument("--base-scheme", default="cmux") + args = parser.parse_args() + + try: + index = set_auth_callback_scheme(args.plist, args.callback_scheme, args.base_scheme) + except (OSError, plistlib.InvalidFileException, ValueError) as error: + print(f"nightly plist auth callback update failed: {error}", file=sys.stderr) + return 1 + + print(f"Updated auth callback URL type at index {index} to {args.callback_scheme}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_nightly_auth_callback_scheme.py b/tests/test_nightly_auth_callback_scheme.py new file mode 100644 index 000000000000..f1223aeeca63 --- /dev/null +++ b/tests/test_nightly_auth_callback_scheme.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Behavior tests for the nightly auth callback plist rewrite.""" + +from __future__ import annotations + +import plistlib +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "set-nightly-auth-callback-scheme.py" + + +class NightlyAuthCallbackSchemeTests(unittest.TestCase): + def _run(self, plist: Path, *extra: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(SCRIPT), str(plist), "cmux-nightly", "--base-scheme", "cmux", *extra], + check=False, + capture_output=True, + text=True, + ) + + @staticmethod + def _write(path: Path, url_types: list[dict[str, object]]) -> None: + with path.open("wb") as handle: + plistlib.dump( + {"CFBundleIdentifier": "com.cmuxterm.app", "CFBundleURLTypes": url_types}, + handle, + sort_keys=False, + ) + + @staticmethod + def _read(path: Path) -> dict[str, object]: + with path.open("rb") as handle: + value = plistlib.load(handle) + if not isinstance(value, dict): + raise AssertionError("plist root was not a dictionary") + return value + + def test_reordered_url_types_update_only_the_auth_entry(self) -> None: + with tempfile.TemporaryDirectory() as directory: + plist = Path(directory) / "Info.plist" + self._write( + plist, + [ + {"CFBundleURLName": "SSH URL", "CFBundleURLSchemes": ["ssh"]}, + {"CFBundleURLName": "com.cmuxterm.app.web", "CFBundleURLSchemes": ["http", "https"]}, + {"CFBundleURLName": "com.cmuxterm.app.auth", "CFBundleURLSchemes": ["cmux"]}, + ], + ) + + result = self._run(plist) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("index 2", result.stdout) + url_types = self._read(plist)["CFBundleURLTypes"] + self.assertIsInstance(url_types, list) + self.assertEqual(url_types[0]["CFBundleURLSchemes"], ["ssh"]) + self.assertEqual(url_types[1]["CFBundleURLSchemes"], ["http", "https"]) + self.assertEqual(url_types[2]["CFBundleURLSchemes"], ["cmux-nightly"]) + + def test_scheme_identifies_auth_entry_when_name_is_not_descriptive(self) -> None: + with tempfile.TemporaryDirectory() as directory: + plist = Path(directory) / "Info.plist" + self._write( + plist, + [ + {"CFBundleURLName": "web", "CFBundleURLSchemes": ["http", "https"]}, + {"CFBundleURLName": "callback", "CFBundleURLSchemes": ["cmux"]}, + ], + ) + + result = self._run(plist) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + self._read(plist)["CFBundleURLTypes"][1]["CFBundleURLSchemes"], + ["cmux-nightly"], + ) + + def test_ambiguous_auth_entries_fail_closed_without_mutating_the_file(self) -> None: + with tempfile.TemporaryDirectory() as directory: + plist = Path(directory) / "Info.plist" + self._write( + plist, + [ + {"CFBundleURLName": "one.auth", "CFBundleURLSchemes": ["cmux"]}, + {"CFBundleURLName": "two.auth", "CFBundleURLSchemes": ["cmux-alt"]}, + ], + ) + before = plist.read_bytes() + + result = self._run(plist) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("exactly one", result.stderr) + self.assertEqual(plist.read_bytes(), before) + + +if __name__ == "__main__": + unittest.main() From 082dc9810dcea60dd31614f470648d2a0122d3bb Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 2 Sep 2026 01:25:29 -0700 Subject: [PATCH 04/40] fix(cloud): keep untyped list failures out of unreachable state --- Sources/Cloud/MachinesPanelView.swift | 4 +++- Sources/Cloud/MachinesPanelViewModel.swift | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index 5f96fcb99adf..b154a2cff77e 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -491,7 +491,9 @@ struct MachinesPanelView: View { // pretending the fleet is empty. A server-rejected session and // a plan gate each get their real fix; only transient-shaped // failures keep the retry-first "unreachable" copy. - switch viewModel.listProblem ?? .unreachable { + // A missing classification must not claim the network is down; + // the conservative fallback is a service-side error. + switch viewModel.listProblem ?? .serverError { case .sessionRejected: sessionRejectedState case .requiresPro: diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index f715d4516ae7..44010ae46963 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -731,7 +731,10 @@ final class MachinesPanelViewModel: ObservableObject { listProblem = Self.classifyListFailure(error) } catch { lastErrorDescription = String(describing: error) - listProblem = .unreachable + // An unknown error proves neither a transport failure nor an auth + // rejection. Keep the UI honest and treat it as a service error + // until a typed client error tells us otherwise. + listProblem = .serverError } isLoading = false hasLoadedOnce = true From 611464210d668aa694203315f9bcc439c8327ff0 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 2 Sep 2026 02:06:45 -0700 Subject: [PATCH 05/40] fix(ci): replace the matched nightly callback scheme in place --- .../ci/set-nightly-auth-callback-scheme.py | 24 +++++++++++++++---- tests/test_nightly_auth_callback_scheme.py | 4 ++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/ci/set-nightly-auth-callback-scheme.py b/scripts/ci/set-nightly-auth-callback-scheme.py index 0b9e7eee5852..1f2475652a1e 100644 --- a/scripts/ci/set-nightly-auth-callback-scheme.py +++ b/scripts/ci/set-nightly-auth-callback-scheme.py @@ -30,12 +30,20 @@ def _is_auth_url_type(entry: dict[str, Any], base_scheme: str) -> bool: if isinstance(name, str) and name.strip().lower().endswith(AUTH_NAME_SUFFIX): return True + return bool(_matching_scheme_indices(entry, base_scheme)) + + +def _matching_scheme_indices(entry: dict[str, Any], base_scheme: str) -> list[int]: if not base_scheme: - return False + return [] schemes = entry.get("CFBundleURLSchemes") - return isinstance(schemes, list) and any( - _normalized_scheme(scheme) == base_scheme for scheme in schemes - ) + if not isinstance(schemes, list): + return [] + return [ + index + for index, scheme in enumerate(schemes) + if _normalized_scheme(scheme) == base_scheme + ] def _auth_url_type_index(url_types: list[Any], base_scheme: str) -> int: @@ -93,7 +101,13 @@ def set_auth_callback_scheme(path: Path, callback_scheme: str, base_scheme: str if not isinstance(schemes, list) or not schemes: raise ValueError(f"auth URL type at index {index} has no URL scheme") - schemes[0] = callback_scheme + matching_scheme_indices = _matching_scheme_indices(entry, _normalized_scheme(base_scheme)) + if len(matching_scheme_indices) > 1: + raise ValueError( + f"auth URL type at index {index} contains the base scheme more than once" + ) + target_scheme_index = matching_scheme_indices[0] if matching_scheme_indices else 0 + schemes[target_scheme_index] = normalized_callback fmt = plistlib.FMT_BINARY if raw.startswith(b"bplist") else plistlib.FMT_XML mode = stat.S_IMODE(path.stat().st_mode) _write_plist_atomically(path, plist, fmt, mode) diff --git a/tests/test_nightly_auth_callback_scheme.py b/tests/test_nightly_auth_callback_scheme.py index f1223aeeca63..8d0c77caebd8 100644 --- a/tests/test_nightly_auth_callback_scheme.py +++ b/tests/test_nightly_auth_callback_scheme.py @@ -70,7 +70,7 @@ def test_scheme_identifies_auth_entry_when_name_is_not_descriptive(self) -> None plist, [ {"CFBundleURLName": "web", "CFBundleURLSchemes": ["http", "https"]}, - {"CFBundleURLName": "callback", "CFBundleURLSchemes": ["cmux"]}, + {"CFBundleURLName": "callback", "CFBundleURLSchemes": ["legacy-auth", "cmux"]}, ], ) @@ -79,7 +79,7 @@ def test_scheme_identifies_auth_entry_when_name_is_not_descriptive(self) -> None self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual( self._read(plist)["CFBundleURLTypes"][1]["CFBundleURLSchemes"], - ["cmux-nightly"], + ["legacy-auth", "cmux-nightly"], ) def test_ambiguous_auth_entries_fail_closed_without_mutating_the_file(self) -> None: From 94232fc178eedef22fcd3235588cba96eded2afd Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 2 Sep 2026 02:06:50 -0700 Subject: [PATCH 06/40] fix(cloud): keep stale list banners truthful --- Resources/Localizable.xcstrings | 51 ++++++++++++++++++++++ Sources/Cloud/MachinesPanelView.swift | 30 ++++++++++++- Sources/Cloud/MachinesPanelViewModel.swift | 11 +++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index fbfba3902517..7c7ee9026be5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -127748,6 +127748,23 @@ } } }, + "machines.requiresPro.stale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud plan required — showing last known" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloudプランが必要です — 既知の状態を表示中" + } + } + } + }, "machines.requiresPro.title": { "extractionState": "manual", "localizations": { @@ -127816,6 +127833,23 @@ } } }, + "machines.sessionRejected.stale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud session rejected — showing last known" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウドセッションが拒否されました — 既知の状態を表示中" + } + } + } + }, "machines.sessionRejected.title": { "extractionState": "manual", "localizations": { @@ -127867,6 +127901,23 @@ } } }, + "machines.serverError.stale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud service error — showing last known" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウドサービスエラー — 既知の状態を表示中" + } + } + } + }, "machines.serverError.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index b154a2cff77e..ee9e276e8fa9 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -112,7 +112,7 @@ struct MachinesPanelView: View { HStack(spacing: 5) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 10, weight: .semibold)) - Text(String(localized: "machines.unavailable.stale", defaultValue: "Cloud unreachable \u{2014} showing last known")) + Text(staleListLabel) .cmuxFont(size: 11) .lineLimit(1) .truncationMode(.tail) @@ -162,6 +162,34 @@ struct MachinesPanelView: View { .rightSidebarChromeBottomBorder(backgroundColor: chromeBackgroundColor) } + /// Short status copy for a stale fleet while the latest list request is + /// failing. Keep the banner aligned with the structured list problem so a + /// loaded machine list cannot regress to the transport-only wording. + private var staleListLabel: String { + switch viewModel.listProblem ?? .serverError { + case .sessionRejected: + return String( + localized: "machines.sessionRejected.stale", + defaultValue: "Cloud session rejected \u{2014} showing last known" + ) + case .requiresPro: + return String( + localized: "machines.requiresPro.stale", + defaultValue: "Cloud plan required \u{2014} showing last known" + ) + case .serverError: + return String( + localized: "machines.serverError.stale", + defaultValue: "Cloud service error \u{2014} showing last known" + ) + case .unreachable: + return String( + localized: "machines.unavailable.stale", + defaultValue: "Cloud unreachable \u{2014} showing last known" + ) + } + } + @ViewBuilder private var content: some View { // Show the empty state exactly when the outline would render zero diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index 44010ae46963..eed6f981fca0 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -729,6 +729,17 @@ final class MachinesPanelViewModel: ObservableObject { } lastErrorDescription = String(describing: error) listProblem = Self.classifyListFailure(error) + } catch is CancellationError { + // URLSession cancellation is a normal lifecycle transition (for + // example, sign-out cancels an in-flight refresh). Do not publish + // a new error state after the owner has already reset its state. + isLoading = false + return + } catch let error as URLError where error.code == .cancelled { + // Foundation commonly surfaces Task cancellation as URLError + // .cancelled rather than CancellationError. + isLoading = false + return } catch { lastErrorDescription = String(describing: error) // An unknown error proves neither a transport failure nor an auth From 170336c7098b730248288c834c8edce5972fe7ea Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 2 Sep 2026 02:27:11 -0700 Subject: [PATCH 07/40] fix(cloud): classify raw transport failures as unreachable --- Sources/Cloud/MachinesPanelViewModel.swift | 20 ++++++++--- Sources/Cloud/VMClient.swift | 39 +++++++++++++++++++--- cmuxTests/MachinesPanelModelTests.swift | 19 +++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index eed6f981fca0..b8ed8c44526b 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -381,6 +381,21 @@ final class MachinesPanelViewModel: ObservableObject { return .unreachable } } + + /// Classify an error that escaped the typed Cloud client boundary. URLSession + /// can surface a transport failure directly on some OS releases, so keep + /// those failures in the retry-first state instead of calling them a server + /// error. Unknown errors remain conservative: they are not evidence of an + /// unreachable service. + nonisolated static func classifyListFailure(_ error: Error) -> CloudListProblem { + if let error = error as? VMClientError { + return classifyListFailure(error) + } + if let error = error as? URLError, error.code.isCloudBackendTransportFailure { + return .unreachable + } + return .serverError + } /// Human-readable label of the Cloud VM action currently running from this /// panel ("Checkpointing noble-wren…"). Replaces the plan meter in the /// header while set — the in-app substitute for a floating progress HUD. @@ -742,10 +757,7 @@ final class MachinesPanelViewModel: ObservableObject { return } catch { lastErrorDescription = String(describing: error) - // An unknown error proves neither a transport failure nor an auth - // rejection. Keep the UI honest and treat it as a service error - // until a typed client error tells us otherwise. - listProblem = .serverError + listProblem = Self.classifyListFailure(error) } isLoading = false hasLoadedOnce = true diff --git a/Sources/Cloud/VMClient.swift b/Sources/Cloud/VMClient.swift index 0fcdddc835eb..c694a5f76911 100644 --- a/Sources/Cloud/VMClient.swift +++ b/Sources/Cloud/VMClient.swift @@ -1,6 +1,39 @@ import CmuxAuthRuntime import Foundation +extension URLError.Code { + /// Whether URLSession failed before receiving a usable Cloud response. + /// + /// Keep this list limited to connection, DNS, and TLS failures. Client + /// configuration errors such as `badURL` are not evidence that the Cloud + /// service is unreachable and should remain visible as service errors. + var isCloudBackendTransportFailure: Bool { + switch self { + case .cannotConnectToHost, + .cannotFindHost, + .timedOut, + .networkConnectionLost, + .notConnectedToInternet, + .dnsLookupFailed, + .secureConnectionFailed, + .serverCertificateHasBadDate, + .serverCertificateHasUnknownRoot, + .serverCertificateNotYetValid, + .serverCertificateUntrusted, + .clientCertificateRejected, + .clientCertificateRequired, + .cannotLoadFromNetwork, + .resourceUnavailable, + .internationalRoamingOff, + .callIsActive, + .dataNotAllowed: + return true + default: + return false + } + } +} + enum VMClientError: Error, CustomStringConvertible { case notSignedIn case sessionRefreshFailed @@ -1289,13 +1322,11 @@ actor VMClient { } catch let error as URLError { // Surface unreachable-backend errors as a human-readable message with recovery steps // instead of the verbose NSURLErrorDomain payload. - switch error.code { - case .cannotConnectToHost, .cannotFindHost, .timedOut, .networkConnectionLost, .notConnectedToInternet: + if error.code.isCloudBackendTransportFailure { let base = "\(AuthEnvironment.vmAPIBaseURL.scheme ?? "http")://\(AuthEnvironment.vmAPIBaseURL.host ?? "?"):\(AuthEnvironment.vmAPIBaseURL.port ?? -1)" throw VMClientError.backendUnreachable(url: base, detail: error.localizedDescription) - default: - throw error } + throw error } guard let http = response as? HTTPURLResponse else { throw VMClientError.malformedResponse("non-HTTP response") diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index 20f8888478bd..3b2828f409d9 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -964,6 +964,25 @@ struct MachinesPanelListProblemTests { ) } + @Test("Raw transport failures stay in the unreachable state") + func rawTransportFailuresStayUnreachable() { + for code in [ + URLError.Code.secureConnectionFailed, + .serverCertificateUntrusted, + .dnsLookupFailed, + .timedOut, + ] { + #expect( + MachinesPanelViewModel.classifyListFailure(URLError(code)) == .unreachable, + "\(code) is a transport failure without a usable Cloud response" + ) + } + #expect( + MachinesPanelViewModel.classifyListFailure(URLError(.badURL)) == .serverError, + "A malformed client URL is not evidence that Cloud is unreachable" + ) + } + /// No "detached" pill anywhere (austin, 2026-08-31): a pool terminal with no /// view is just a row, one view is the normal state, and only several views /// earn a badge (a multiplier). From b0be3ba8a50e0a3aab2ea066b3b0f461d531bfc3 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 2 Sep 2026 02:47:45 -0700 Subject: [PATCH 08/40] fix(cloud): keep fallback load errors neutral --- Resources/Localizable.xcstrings | 12 ++++++------ Sources/Cloud/MachinesPanelView.swift | 18 +++++++++--------- Sources/Cloud/MachinesPanelViewModel.swift | 10 +++++----- Sources/Cloud/VMClient.swift | 2 +- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7c7ee9026be5..762156324b38 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -127890,13 +127890,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Your machines are still there. The Cloud service ran into an error loading them — this is on cmux’s side, not your connection." + "value": "Your machines are still there. cmux couldn’t load them just now. Retry in a moment." } }, "ja": { "stringUnit": { "state": "translated", - "value": "マシンはそのまま残っています。クラウドサービスが読み込み中にエラーになりました — これは接続の問題ではなく、cmux 側の問題です。" + "value": "マシンはそのまま残っています。cmux は今すぐ読み込めませんでした。しばらくしてから再試行してください。" } } } @@ -127907,13 +127907,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Cloud service error — showing last known" + "value": "Cloud load failed — showing last known" } }, "ja": { "stringUnit": { "state": "translated", - "value": "クラウドサービスエラー — 既知の状態を表示中" + "value": "Cloud の読み込みに失敗 — 既知の状態を表示中" } } } @@ -127924,13 +127924,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Cloud service error" + "value": "Cloud couldn’t load machines" } }, "ja": { "stringUnit": { "state": "translated", - "value": "クラウドサービスのエラー" + "value": "Cloud のマシンを読み込めません" } } } diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index ee9e276e8fa9..c1056775152d 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -180,7 +180,7 @@ struct MachinesPanelView: View { case .serverError: return String( localized: "machines.serverError.stale", - defaultValue: "Cloud service error \u{2014} showing last known" + defaultValue: "Cloud load failed \u{2014} showing last known" ) case .unreachable: return String( @@ -310,22 +310,22 @@ struct MachinesPanelView: View { .padding(.top, 2) } - /// The Cloud service answered with an error (a 5xx, another non-401/402 - /// status, or an unreadable body). The request reached the service, so this - /// is not a network problem — say so, and let the user retry. Conflating - /// this with the transport-failure copy is exactly what made #11597's real - /// HTTP 500 read as "Cloud is unreachable — it retries on its own". + /// A non-transport failure stopped the machine list from loading. Keep this + /// copy neutral because a malformed request or unknown client error does + /// not prove that the Cloud service answered. Conflating this state with + /// transport failures is what made #11597's real HTTP 500 read as "Cloud + /// is unreachable — it retries on its own". @ViewBuilder private var serverErrorState: some View { Image(systemName: "exclamationmark.icloud") .font(.system(size: 26, weight: .light)) .foregroundColor(.secondary.opacity(0.55)) - Text(String(localized: "machines.serverError.title", defaultValue: "Cloud service error")) + Text(String(localized: "machines.serverError.title", defaultValue: "Cloud couldn\u{2019}t load machines")) .cmuxFont(size: 13) .foregroundColor(.primary.opacity(0.85)) Text(String( localized: "machines.serverError.subtitle", - defaultValue: "Your machines are still there. The Cloud service ran into an error loading them \u{2014} this is on cmux\u{2019}s side, not your connection." + defaultValue: "Your machines are still there. cmux couldn\u{2019}t load them just now. Retry in a moment." )) .cmuxFont(size: 12) .foregroundColor(.secondary) @@ -520,7 +520,7 @@ struct MachinesPanelView: View { // a plan gate each get their real fix; only transient-shaped // failures keep the retry-first "unreachable" copy. // A missing classification must not claim the network is down; - // the conservative fallback is a service-side error. + // the conservative fallback is a non-transport load error. switch viewModel.listProblem ?? .serverError { case .sessionRejected: sessionRejectedState diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index b8ed8c44526b..ef3402f577a4 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -346,10 +346,10 @@ final class MachinesPanelViewModel: ObservableObject { case sessionRejected /// HTTP 402: the plan gates Cloud access. case requiresPro - /// The Cloud service answered, but with an error (any non-401/402 HTTP - /// status, or a body this client could not read). The request reached - /// the service, so this is a server-side problem, not a network one — - /// it must never wear the "Cloud is unreachable" copy. + /// A non-transport failure prevented the list from loading (for + /// example, a service response, malformed response, or client error). + /// It must never wear the "Cloud is unreachable" copy because the + /// failure does not prove that the network is down. case serverError /// No usable response came back: a transport failure, or a transient /// session-refresh failure. This is the only truly "unreachable" case. @@ -386,7 +386,7 @@ final class MachinesPanelViewModel: ObservableObject { /// can surface a transport failure directly on some OS releases, so keep /// those failures in the retry-first state instead of calling them a server /// error. Unknown errors remain conservative: they are not evidence of an - /// unreachable service. + /// unreachable service or of a response from the Cloud backend. nonisolated static func classifyListFailure(_ error: Error) -> CloudListProblem { if let error = error as? VMClientError { return classifyListFailure(error) diff --git a/Sources/Cloud/VMClient.swift b/Sources/Cloud/VMClient.swift index c694a5f76911..264241b4678d 100644 --- a/Sources/Cloud/VMClient.swift +++ b/Sources/Cloud/VMClient.swift @@ -7,7 +7,7 @@ extension URLError.Code { /// Keep this list limited to connection, DNS, and TLS failures. Client /// configuration errors such as `badURL` are not evidence that the Cloud /// service is unreachable and should remain visible as service errors. - var isCloudBackendTransportFailure: Bool { + nonisolated var isCloudBackendTransportFailure: Bool { switch self { case .cannotConnectToHost, .cannotFindHost, From 9d89a640dd4c03b941684c3b8fb2c95ddb1fc7e8 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 2 Sep 2026 03:03:13 -0700 Subject: [PATCH 09/40] fix(cloud): narrow transport error classification --- Resources/Localizable.xcstrings | 2 +- Sources/Cloud/VMClient.swift | 3 +-- cmuxTests/MachinesPanelModelTests.swift | 7 +++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 762156324b38..0e4fa7adfbaa 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -127913,7 +127913,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "Cloud の読み込みに失敗 — 既知の状態を表示中" + "value": "Cloud の読み込みに失敗 — 最後に確認した状態を表示中" } } } diff --git a/Sources/Cloud/VMClient.swift b/Sources/Cloud/VMClient.swift index 264241b4678d..0e2acf843d97 100644 --- a/Sources/Cloud/VMClient.swift +++ b/Sources/Cloud/VMClient.swift @@ -22,8 +22,7 @@ extension URLError.Code { .serverCertificateUntrusted, .clientCertificateRejected, .clientCertificateRequired, - .cannotLoadFromNetwork, - .resourceUnavailable, + .appTransportSecurityRequiresSecureConnection, .internationalRoamingOff, .callIsActive, .dataNotAllowed: diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index 3b2828f409d9..25a2d12e73ba 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -968,6 +968,7 @@ struct MachinesPanelListProblemTests { func rawTransportFailuresStayUnreachable() { for code in [ URLError.Code.secureConnectionFailed, + .appTransportSecurityRequiresSecureConnection, .serverCertificateUntrusted, .dnsLookupFailed, .timedOut, @@ -981,6 +982,12 @@ struct MachinesPanelListProblemTests { MachinesPanelViewModel.classifyListFailure(URLError(.badURL)) == .serverError, "A malformed client URL is not evidence that Cloud is unreachable" ) + for code in [URLError.Code.cannotLoadFromNetwork, .resourceUnavailable] { + #expect( + MachinesPanelViewModel.classifyListFailure(URLError(code)) == .serverError, + "\(code) describes a local resource/cache failure, not transport" + ) + } } /// No "detached" pill anywhere (austin, 2026-08-31): a pool terminal with no From 4b5559ca64720d7df321494019bf84f1e4a4424e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 14:28:44 -0700 Subject: [PATCH 10/40] test(cloud): require build-scoped VM tunnel identities --- cmuxTests/VMTunnelManagerTests.swift | 87 ++++++++++++++++++++++++-- cmuxTests/VMTunnelStalenessTests.swift | 46 +++++++++++++- 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/cmuxTests/VMTunnelManagerTests.swift b/cmuxTests/VMTunnelManagerTests.swift index 32aefc85ddcc..92d368171649 100644 --- a/cmuxTests/VMTunnelManagerTests.swift +++ b/cmuxTests/VMTunnelManagerTests.swift @@ -24,7 +24,11 @@ struct VMTunnelManagerTests { func keypairIsMintedOnceAndStable() throws { let home = try temporaryHome() defer { try? FileManager.default.removeItem(at: home) } - let manager = VMTunnelManager(home: home) + let manager = VMTunnelManager( + home: home, + bundleIdentifier: "com.cmuxterm.app.nightly", + apiBaseURL: URL(string: "https://cmux.com")! + ) let first = try manager.keypair() let second = try manager.keypair() @@ -46,7 +50,11 @@ struct VMTunnelManagerTests { func deviceFingerprintIsStablePerInstallation() throws { let home = try temporaryHome() defer { try? FileManager.default.removeItem(at: home) } - let manager = VMTunnelManager(home: home) + let manager = VMTunnelManager( + home: home, + bundleIdentifier: "com.cmuxterm.app.nightly", + apiBaseURL: URL(string: "https://cmux.com")! + ) let first = try manager.deviceFingerprint() #expect(first.hasPrefix("mac-")) #expect(try manager.deviceFingerprint() == first) @@ -99,7 +107,11 @@ struct VMTunnelManagerTests { let home = try temporaryHome() defer { try? FileManager.default.removeItem(at: home) } // No config on disk means nothing to match interfaces against. - #expect(VMTunnelManager(home: home).wgQuickInterfaceUp() == false) + #expect(VMTunnelManager( + home: home, + bundleIdentifier: "com.cmuxterm.app.nightly", + apiBaseURL: URL(string: "https://cmux.com")! + ).wgQuickInterfaceUp() == false) } @Test @@ -127,7 +139,11 @@ struct VMTunnelManagerTests { func interfaceUpMatchesALiveInterfaceAddress() throws { let home = try temporaryHome() defer { try? FileManager.default.removeItem(at: home) } - let manager = VMTunnelManager(home: home) + let manager = VMTunnelManager( + home: home, + bundleIdentifier: "com.cmuxterm.app.nightly", + apiBaseURL: URL(string: "https://cmux.com")! + ) try FileManager.default.createDirectory(at: manager.stateDir, withIntermediateDirectories: true) // Loopback is always present, so a config claiming 127.0.0.1 as the // interface address reads as up — proving detection is address-based. @@ -140,4 +156,67 @@ struct VMTunnelManagerTests { """.write(to: manager.configURL, atomically: true, encoding: .utf8) #expect(manager.wgQuickInterfaceUp() == true) } + + @Test + func buildScopesDoNotShareCredentialsOrConfigFiles() throws { + let home = try temporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + let productionURL = URL(string: "https://cmux.com")! + let nightly = VMTunnelManager( + home: home, + bundleIdentifier: "com.cmuxterm.app.nightly", + apiBaseURL: productionURL + ) + let dev = VMTunnelManager( + home: home, + bundleIdentifier: "com.cmuxterm.app.debug.cloud-notify", + apiBaseURL: productionURL + ) + + #expect(nightly.interfaceName != dev.interfaceName) + #expect(nightly.configURL != dev.configURL) + #expect(nightly.privateKeyURL != dev.privateKeyURL) + #expect(nightly.deviceIDURL != dev.deviceIDURL) + + let nightlyKeys = try nightly.keypair() + let devKeys = try dev.keypair() + #expect(nightlyKeys.privateKey != devKeys.privateKey) + #expect(nightlyKeys.publicKey != devKeys.publicKey) + #expect(try nightly.deviceFingerprint() != dev.deviceFingerprint()) + + // Reconstructing the same build identity keeps its enrollment material. + let nightlyRestart = VMTunnelManager( + home: home, + bundleIdentifier: "com.cmuxterm.app.nightly", + apiBaseURL: productionURL + ) + #expect(try nightlyRestart.keypair().privateKey == nightlyKeys.privateKey) + #expect(try nightlyRestart.deviceFingerprint() == nightly.deviceFingerprint()) + } + + @Test + func stableProductionKeepsLegacyCredentialPathsWhileOtherBuildsAreScoped() throws { + let home = try temporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + let productionURL = URL(string: "https://cmux.com")! + let stable = VMTunnelManager( + home: home, + bundleIdentifier: "com.cmuxterm.app", + apiBaseURL: productionURL + ) + let nightly = VMTunnelManager( + home: home, + bundleIdentifier: "com.cmuxterm.app.nightly", + apiBaseURL: productionURL + ) + + #expect(stable.interfaceName == "cmux") + #expect(stable.privateKeyURL.lastPathComponent == "private.key") + #expect(stable.deviceIDURL.lastPathComponent == "device-id") + #expect(stable.configURL.lastPathComponent == "cmux.conf") + #expect(nightly.interfaceName == "cmux-nightly") + #expect(nightly.privateKeyURL.lastPathComponent == "cmux-nightly.private.key") + #expect(nightly.deviceIDURL.lastPathComponent == "cmux-nightly.device-id") + #expect(nightly.configURL.lastPathComponent == "cmux-nightly.conf") + } } diff --git a/cmuxTests/VMTunnelStalenessTests.swift b/cmuxTests/VMTunnelStalenessTests.swift index 19787cb013c2..d4d63e6efcee 100644 --- a/cmuxTests/VMTunnelStalenessTests.swift +++ b/cmuxTests/VMTunnelStalenessTests.swift @@ -68,7 +68,7 @@ struct VMTunnelStalenessTests { #expect(stale.contains("different enrollment") && stale.contains("cmux vpn up")) } - @Test("One tunnel per deployment: production keeps `cmux`, other deployments get their own interface") + @Test("Build identities get isolated interface names, with legacy URL fallback") func interfaceNamePerEnvironment() { #expect(VMTunnelManager.interfaceName(forAPIBaseURL: URL(string: "https://cmux.com")!) == "cmux") #expect(VMTunnelManager.interfaceName(forAPIBaseURL: URL(string: "https://www.cmux.com")!) == "cmux") @@ -84,6 +84,50 @@ struct VMTunnelStalenessTests { #expect(staging.runtimeNameFileURL.path == "/var/run/wireguard/cmux-staging.name") } + @Test("A production-targeted tagged DEV build never shares nightly's interface") + func productionBuildVariantsAreDistinct() { + let production = URL(string: "https://cmux.com")! + let nightly = VMTunnelManager.interfaceName( + bundleIdentifier: "com.cmuxterm.app.nightly", + apiBaseURL: production + ) + let taggedDev = VMTunnelManager.interfaceName( + bundleIdentifier: "com.cmuxterm.app.debug.cloud-notify", + apiBaseURL: production + ) + let otherTaggedDev = VMTunnelManager.interfaceName( + bundleIdentifier: "com.cmuxterm.app.debug.cloud-tree-agent-parity", + apiBaseURL: production + ) + + #expect(nightly == "cmux-nightly") + #expect(taggedDev != "cmux") + #expect(taggedDev != nightly) + #expect(taggedDev != otherTaggedDev) + for name in [nightly, taggedDev, otherTaggedDev] { + #expect(name.count <= 15, "wg-quick interface names are at most 15 characters") + #expect(name.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" }) + } + } + + @Test("The base DEBUG bundle uses its tag when deriving a tunnel scope") + func baseDebugBundleUsesLaunchTag() { + let production = URL(string: "https://cmux.com")! + let first = VMTunnelManager.interfaceName( + bundleIdentifier: "com.cmuxterm.app.debug", + environment: ["CMUX_TAG": "cloud-notify"], + apiBaseURL: production + ) + let second = VMTunnelManager.interfaceName( + bundleIdentifier: "com.cmuxterm.app.debug", + environment: ["CMUX_TAG": "cloud-tree-agent-parity"], + apiBaseURL: production + ) + #expect(first != second) + #expect(first != "cmux-dev") + #expect(second != "cmux-dev") + } + @Test("The completed config routes only this network's prefixes, so two tunnels can be up side by side") func allowedIPsNarrowToTheNetwork() throws { let server = "[Interface]\nPrivateKey = \nAddress = 100.64.0.1/32\n\n[Peer]\nPublicKey = p\nAllowedIPs = 10.0.0.0/8, fd00::/8\nEndpoint = tun.example:51820\n" From db04393c8db4afd2decb2b1fc694065d29d7187a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 15:18:43 -0700 Subject: [PATCH 11/40] fix(cloud): isolate VM tunnels per app build --- CLI/CMUXCLI+VPN.swift | 7 +- CLI/cmux.swift | 7 +- Sources/Cloud/VMTunnelManager.swift | 173 +++++++++++++++++--- cmuxTests/VMTunnelManagerTests.swift | 6 +- cmuxTests/VMTunnelStalenessTests.swift | 17 +- skills/cmux-cloud-vm/references/commands.md | 2 +- 6 files changed, 182 insertions(+), 30 deletions(-) diff --git a/CLI/CMUXCLI+VPN.swift b/CLI/CMUXCLI+VPN.swift index dac6030ade0f..f0a7892d193e 100644 --- a/CLI/CMUXCLI+VPN.swift +++ b/CLI/CMUXCLI+VPN.swift @@ -7,8 +7,9 @@ import Foundation /// /// Cloud machines live on one private network per account and open no public /// inbound port, so their session daemons are reachable only through this -/// tunnel. The cmux app owns enrollment (keypair, device identity, the config -/// file at `~/.cmuxterm/wireguard/cmux.conf`); this command owns the +/// tunnel. The cmux app owns enrollment (keypair, device identity, the +/// build-scoped config file at `~/.cmuxterm/wireguard/.conf`); this +/// command owns the /// privileged bring-up, because creating a utun and installing routes needs /// root and `sudo` in the user's own terminal is the honest way to ask. /// @@ -266,7 +267,7 @@ extension CMUXCLI { print(String(localized: "cli.vpn.status.notSetUp", defaultValue: "Tunnel: not set up (run `cmux vpn up`)")) } if let name = response["interface_name"] as? String, !name.isEmpty { - // One tunnel per deployment: says which one this build owns. + // The interface identifies the build-scoped tunnel this CLI owns. let format = String(localized: "cli.vpn.status.interface", defaultValue: "Interface: %@") print(String(format: format, name)) } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index fd55c955fdae..28865e514f5d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18623,8 +18623,11 @@ struct CMUXCLI { `up` already runs this; call it again after `cmux vm new` to pick up a machine created since. - The cmux app writes the config to ~/.cmuxterm/wireguard/cmux.conf - with the private key generated on this Mac; the key never leaves it. + The cmux app writes a build-scoped config to + ~/.cmuxterm/wireguard/.conf with the private key generated + on this Mac; the key never leaves it. Stable production keeps the + legacy cmux.conf name, while nightly and tagged DEV builds use their + own interface and credential files so they can run together. """ case "auth": return """ diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index fc173971f574..117ee4ec96db 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -1,4 +1,5 @@ import CryptoKit +import CmuxSettings import Foundation import Security @@ -11,10 +12,11 @@ import Security /// /// - a Curve25519 keypair generated here, whose private half never leaves this /// Mac (the control plane receives only the public key), -/// - a stable per-installation device fingerprint, so re-enrolling on every -/// launch resolves to the same tunnel and the same address on the network, -/// - the assembled wg-quick config at `~/.cmuxterm/wireguard/cmux.conf` (0600), -/// which is the one artifact both bring-up paths consume. +/// - a stable per-build installation device fingerprint, so re-enrolling on +/// every launch resolves to the same tunnel and the same address on the +/// network, +/// - the assembled wg-quick config at `~/.cmuxterm/wireguard/.conf` +/// (0600), which is the one artifact both bring-up paths consume. /// /// Bringing the interface up needs privileges the app process does not have, /// and there are two backends for it: @@ -55,29 +57,101 @@ struct VMTunnelManager: Sendable { /// The interface name — and, since wg-quick derives them from it, the /// config, applied-record and runtime-name file names — is scoped to the - /// Cloud VM deployment this build talks to: `cmux` for production, - /// `cmux-staging`, `cmux-local` or `cmux-dev` otherwise. A production app - /// and a dev build on the same Mac sign into different accounts whose - /// machines live on different private networks, so each gets its own - /// tunnel and both can be up side by side — instead of one shared - /// `cmux.conf` that every `vpn up` overwrote for the other build. + /// app/build identity. Stable production keeps the historical `cmux` + /// name; nightly, staging, and every tagged DEBUG build get a distinct + /// deterministic name. The deployment URL is only a fallback for callers + /// that have no bundle identity. This matters when a production-targeted + /// tagged DEBUG build and nightly run on one Mac: both can enroll without + /// overwriting each other's config, key, or device fingerprint. let interfaceName: String let home: URL init( home: URL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true), - interfaceName: String = VMTunnelManager.interfaceName(forAPIBaseURL: AuthEnvironment.vmAPIBaseURL) + interfaceName: String? = nil, + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + environment: [String: String] = ProcessInfo.processInfo.environment, + apiBaseURL: URL = AuthEnvironment.vmAPIBaseURL ) { self.home = home - self.interfaceName = interfaceName + self.interfaceName = interfaceName ?? Self.interfaceName( + bundleIdentifier: bundleIdentifier, + environment: environment, + apiBaseURL: apiBaseURL + ) } - /// The tunnel scope for a Cloud VM API origin. Production (and any - /// `*.cmux.com` origin) keeps the historical `cmux`, so nothing changes - /// for shipping builds; other deployments get a name of their own. Names - /// stay within wg-quick's 15-character interface-name limit. + /// Returns the interface name for a concrete app/build identity. + /// + /// The bundle identifier is the durable identity because deployment URLs + /// are not: a tagged DEBUG bundle can point at localhost today and + /// `https://cmux.com` tomorrow. Names are limited to 15 characters, the + /// maximum accepted by wg-quick on macOS. The optional environment is used + /// for the base DEBUG bundle, whose launch tag is otherwise only present in + /// `CMUX_TAG`. + static func interfaceName( + bundleIdentifier: String?, + environment: [String: String] = [:], + apiBaseURL: URL + ) -> String { + let normalizedBundleID = normalizedBundleIdentifier(bundleIdentifier) + let effectiveBundleID = effectiveBundleIdentifier( + bundleIdentifier: normalizedBundleID, + environment: environment + ) + + guard let effectiveBundleID else { + return interfaceName(forAPIBaseURL: apiBaseURL) + } + + let variant = SocketPathMarkerFiles.variant( + bundleIdentifier: effectiveBundleID, + environment: environment + ) + switch variant { + case .stable: + if effectiveBundleID == SocketPathMarkerFiles.stableBundleIdentifier { + return "cmux" + } + if effectiveBundleID == "\(SocketPathMarkerFiles.stableBundleIdentifier).rc" { + return "cmux-rc" + } + return scopedInterfaceName(prefix: "cmux-x", identity: effectiveBundleID, hashLength: 8) + case .nightly(let slug): + if effectiveBundleID == SocketPathMarkerFiles.nightlyBundleIdentifier, slug == nil { + return "cmux-nightly" + } + return scopedInterfaceName(prefix: "cmux-n", identity: effectiveBundleID, hashLength: 8) + case .staging(let slug): + if effectiveBundleID == SocketPathMarkerFiles.stagingBundleIdentifier, slug == nil { + return "cmux-staging" + } + return scopedInterfaceName(prefix: "cmux-s", identity: effectiveBundleID, hashLength: 8) + case .dev(let slug): + let rawTag = effectiveBundleID == SocketPathMarkerFiles.defaultBaseDebugBundleIdentifier + ? normalizedEnvironmentValue(environment["CMUX_TAG"])?.lowercased() + : nil + if effectiveBundleID == SocketPathMarkerFiles.defaultBaseDebugBundleIdentifier, + slug == nil, + rawTag == nil { + return "cmux-dev" + } + let identity = rawTag.map { "\(effectiveBundleID)|\($0)" } ?? effectiveBundleID + return scopedInterfaceName(prefix: "cmux-dev", identity: identity, hashLength: 6) + } + } + + /// Compatibility fallback for code that only knows the deployment URL. + /// New app code should use ``interfaceName(bundleIdentifier:environment:apiBaseURL:)``. static func interfaceName(forAPIBaseURL url: URL) -> String { + legacyInterfaceName(forAPIBaseURL: url) + } + + /// The URL-only mapping retained for old callers and pre-build-identity + /// state. It must stay deterministic while the bundle-aware path above + /// remains the production source of tunnel isolation. + private static func legacyInterfaceName(forAPIBaseURL url: URL) -> String { let host = (url.host ?? "").lowercased() if host.isEmpty || host == "cmux.com" || host.hasSuffix(".cmux.com") { return "cmux" } if host == "localhost" || host == "127.0.0.1" || host == "::1" { return "cmux-local" } @@ -85,6 +159,50 @@ struct VMTunnelManager: Sendable { return "cmux-dev" } + private static func normalizedBundleIdentifier(_ value: String?) -> String? { + normalizedEnvironmentValue(value)?.lowercased() + } + + private static func normalizedEnvironmentValue(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func effectiveBundleIdentifier( + bundleIdentifier: String?, + environment: [String: String] + ) -> String? { + let environmentBundleID = normalizedBundleIdentifier(environment["CMUX_BUNDLE_ID"]) + guard let bundleIdentifier else { return environmentBundleID } + + // A directly-launched base DEBUG executable can carry the tag only in + // its environment. Prefer that more-specific identity, but never let + // an ambient environment override a concrete stable/nightly bundle. + if bundleIdentifier == SocketPathMarkerFiles.defaultBaseDebugBundleIdentifier, + let environmentBundleID, + environmentBundleID.hasPrefix(bundleIdentifier + ".") { + return environmentBundleID + } + return bundleIdentifier + } + + private static func scopedInterfaceName(prefix: String, identity: String, hashLength: Int) -> String { + let digest = SHA256.hash(data: Data(identity.utf8)) + let hash = digest + .prefix(hashLength / 2 + hashLength % 2) + .map { String(format: "%02x", $0) } + .joined() + .prefix(hashLength) + let name = "\(prefix)-\(hash)" + // Prefixes and lengths above are constants chosen to satisfy wg-quick's + // 15-byte interface limit. Keep this assertion close to the invariant + // so a future prefix change cannot silently produce an unusable config. + assert(name.utf8.count <= 15) + return String(name) + } + /// `~/.cmuxterm/wireguard`, 0700 — alongside the cmux-tui client state, /// which follows the same file-permission model for its device key. var stateDir: URL { @@ -92,8 +210,21 @@ struct VMTunnelManager: Sendable { .appendingPathComponent("wireguard", isDirectory: true) } - var privateKeyURL: URL { stateDir.appendingPathComponent("private.key", isDirectory: false) } - var deviceIDURL: URL { stateDir.appendingPathComponent("device-id", isDirectory: false) } + /// Stable production retains the pre-isolation filenames so an existing + /// shipping tunnel keeps working. Every other interface gets credentials + /// of its own; sharing the old `private.key`/`device-id` would let one + /// build rotate the provider peer underneath another build. + private var usesLegacyCredentialFiles: Bool { interfaceName == "cmux" } + + var privateKeyURL: URL { + let filename = usesLegacyCredentialFiles ? "private.key" : "\(interfaceName).private.key" + return stateDir.appendingPathComponent(filename, isDirectory: false) + } + + var deviceIDURL: URL { + let filename = usesLegacyCredentialFiles ? "device-id" : "\(interfaceName).device-id" + return stateDir.appendingPathComponent(filename, isDirectory: false) + } var configURL: URL { stateDir.appendingPathComponent("\(interfaceName).conf", isDirectory: false) } /// wg-quick(8) records the created utun's name here. On macOS the file is @@ -121,9 +252,9 @@ struct VMTunnelManager: Sendable { return false } - /// The stable per-installation device fingerprint, minted on first use. - /// Distinct from the per-machine cmux-tui fingerprints: this one names the - /// Mac itself on the account's network. + /// The stable per-build installation device fingerprint, minted on first use. + /// Distinct from the per-machine cmux-tui fingerprints: this one names this + /// app/build's membership on the account's network. func deviceFingerprint() throws -> String { if let existing = try? String(contentsOf: deviceIDURL, encoding: .utf8) { let trimmed = existing.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/cmuxTests/VMTunnelManagerTests.swift b/cmuxTests/VMTunnelManagerTests.swift index 92d368171649..0d29ce320555 100644 --- a/cmuxTests/VMTunnelManagerTests.swift +++ b/cmuxTests/VMTunnelManagerTests.swift @@ -182,7 +182,9 @@ struct VMTunnelManagerTests { let devKeys = try dev.keypair() #expect(nightlyKeys.privateKey != devKeys.privateKey) #expect(nightlyKeys.publicKey != devKeys.publicKey) - #expect(try nightly.deviceFingerprint() != dev.deviceFingerprint()) + let nightlyFingerprint = try nightly.deviceFingerprint() + let devFingerprint = try dev.deviceFingerprint() + #expect(nightlyFingerprint != devFingerprint) // Reconstructing the same build identity keeps its enrollment material. let nightlyRestart = VMTunnelManager( @@ -191,7 +193,7 @@ struct VMTunnelManagerTests { apiBaseURL: productionURL ) #expect(try nightlyRestart.keypair().privateKey == nightlyKeys.privateKey) - #expect(try nightlyRestart.deviceFingerprint() == nightly.deviceFingerprint()) + #expect(try nightlyRestart.deviceFingerprint() == nightlyFingerprint) } @Test diff --git a/cmuxTests/VMTunnelStalenessTests.swift b/cmuxTests/VMTunnelStalenessTests.swift index d4d63e6efcee..f8536000f47e 100644 --- a/cmuxTests/VMTunnelStalenessTests.swift +++ b/cmuxTests/VMTunnelStalenessTests.swift @@ -30,7 +30,11 @@ struct VMTunnelStalenessTests { .appendingPathComponent("cmux-tunnel-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: home) } - let manager = VMTunnelManager(home: home) + let manager = VMTunnelManager( + home: home, + bundleIdentifier: "com.cmuxterm.app", + apiBaseURL: URL(string: "https://cmux.com")! + ) #expect(manager.configDigest() == nil) #expect(manager.appliedDigest() == nil) #expect(throws: VMTunnelManager.TunnelError.self) { try manager.recordApplied(true) } @@ -99,11 +103,22 @@ struct VMTunnelStalenessTests { bundleIdentifier: "com.cmuxterm.app.debug.cloud-tree-agent-parity", apiBaseURL: production ) + let taggedDevOnLocalAPI = VMTunnelManager.interfaceName( + bundleIdentifier: "com.cmuxterm.app.debug.cloud-notify", + apiBaseURL: URL(string: "http://localhost:9180")! + ) + let taggedDevWithStaleLaunchEnvironment = VMTunnelManager.interfaceName( + bundleIdentifier: "com.cmuxterm.app.debug.cloud-notify", + environment: ["CMUX_BUNDLE_ID": "com.cmuxterm.app"], + apiBaseURL: production + ) #expect(nightly == "cmux-nightly") #expect(taggedDev != "cmux") #expect(taggedDev != nightly) #expect(taggedDev != otherTaggedDev) + #expect(taggedDev == taggedDevOnLocalAPI) + #expect(taggedDev == taggedDevWithStaleLaunchEnvironment) for name in [nightly, taggedDev, otherTaggedDev] { #expect(name.count <= 15, "wg-quick interface names are at most 15 characters") #expect(name.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" }) diff --git a/skills/cmux-cloud-vm/references/commands.md b/skills/cmux-cloud-vm/references/commands.md index 9f00309de77f..2345cb0eaacf 100644 --- a/skills/cmux-cloud-vm/references/commands.md +++ b/skills/cmux-cloud-vm/references/commands.md @@ -9,7 +9,7 @@ cmux auth status # signed in? cmux vm ls # NAME / LABEL / STATE / PROVIDER / IMAGE + plan meter (+ free-window countdown) cmux vm ls --json # {vms: [{id, status, image, createdAt, freeAccessExpiresAt}], limits: {maxActiveVms, planId, freeAccessWindowDays, freeAccessExpiresAt}} cmux vpn status # this build's WireGuard tunnel to its private machine network (machines open no public port): up, down, or up for another enrollment (stale) -cmux vpn up # enroll this Mac and bring the tunnel up (sudo); a stale tunnel (rotated keys) is replaced. One tunnel per deployment (`cmux` for production, `cmux-staging`/`cmux-dev` for dev builds), so a dev build and the production app can both be up +cmux vpn up # enroll this Mac and bring the build-scoped tunnel up (sudo); a stale tunnel (rotated keys) is replaced. Stable production keeps `cmux`; nightly, staging, and tagged dev builds get distinct interfaces and credential files, so they can be up together cmux vpn down # take this build's tunnel down (sudo) cmux vm tree # the surface catalog: This Mac (terminals by workspace, browsers), then every machine → Workspaces, Ports, VNC Displays, Terminals cmux vm tree --refresh # one machine (`local` for This Mac), re-synced first From 063149a059eb058f572e109eb523a87845017a35 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 15:25:50 -0700 Subject: [PATCH 12/40] fix: resolve workspace-group merge build errors --- ...minalController+WorkspaceGroupAction.swift | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/Sources/TerminalController+WorkspaceGroupAction.swift b/Sources/TerminalController+WorkspaceGroupAction.swift index 1c6ab9fe5639..bcde1a4bc712 100644 --- a/Sources/TerminalController+WorkspaceGroupAction.swift +++ b/Sources/TerminalController+WorkspaceGroupAction.swift @@ -17,9 +17,9 @@ extension TerminalController { v2MainSync { guard tabManager.createWorkspaceGroup( name: name, - externalID: identity.value, selectAnchor: false, - collapseSidebarSelection: false + collapseSidebarSelection: false, + externalID: identity.value ) != nil else { mutationError = .err(code: "not_created", message: "Group was not created", data: nil) return @@ -220,22 +220,27 @@ extension TerminalController { ) return (resolution.value, nil) } catch let error as WorkspaceGroupIdentityResolution.ValidationError { - let messageKey: String - let defaultValue: String + let message: String switch error { case .nonString: - messageKey = "workspaceGroup.error.idempotencyKeyMustBeString" - defaultValue = "external_id and idempotency_key must be strings" + message = String( + localized: "workspaceGroup.error.idempotencyKeyMustBeString", + defaultValue: "external_id and idempotency_key must be strings" + ) case .empty: - messageKey = "workspaceGroup.error.idempotencyKeyMustNotBeEmpty" - defaultValue = "The group identity must not be empty" + message = String( + localized: "workspaceGroup.error.idempotencyKeyMustNotBeEmpty", + defaultValue: "The group identity must not be empty" + ) case .mismatchedAliases: - messageKey = "workspaceGroup.error.idempotencyKeysMustMatch" - defaultValue = "external_id and idempotency_key must match" + message = String( + localized: "workspaceGroup.error.idempotencyKeysMustMatch", + defaultValue: "external_id and idempotency_key must match" + ) } return (nil, .err( code: "invalid_params", - message: String(localized: messageKey, defaultValue: defaultValue), + message: message, data: nil )) } catch { From a7905924a9082b8f0b913922a53b6f235272b3ff Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 15:28:36 -0700 Subject: [PATCH 13/40] fix: resolve workspace-group snapshot argument order --- Sources/TerminalController+ControlWorkspaceGroupContext.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/TerminalController+ControlWorkspaceGroupContext.swift b/Sources/TerminalController+ControlWorkspaceGroupContext.swift index ba68f89d7a7e..714d9e341c12 100644 --- a/Sources/TerminalController+ControlWorkspaceGroupContext.swift +++ b/Sources/TerminalController+ControlWorkspaceGroupContext.swift @@ -81,9 +81,9 @@ extension TerminalController: ControlWorkspaceGroupContext { anchorWorkspaceID: group.liveAnchorWorkspaceId, customColor: group.customColor, iconSymbol: group.iconSymbol, + memberWorkspaceIDs: memberIds, externalID: group.externalID, - anchorWorkspaceProvenance: group.anchorWorkspaceProvenance.rawValue, - memberWorkspaceIDs: memberIds + anchorWorkspaceProvenance: group.anchorWorkspaceProvenance.rawValue ) } From dfa396c9e9fb5300940fe2f8ab8f7ecb08148bfe Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 16:43:30 -0700 Subject: [PATCH 14/40] test(cloud): require interface-scoped tunnel routes --- cmuxTests/VMTunnelStalenessTests.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cmuxTests/VMTunnelStalenessTests.swift b/cmuxTests/VMTunnelStalenessTests.swift index f8536000f47e..0f615337f7b2 100644 --- a/cmuxTests/VMTunnelStalenessTests.swift +++ b/cmuxTests/VMTunnelStalenessTests.swift @@ -150,8 +150,28 @@ struct VMTunnelStalenessTests { #expect(completed.contains("AllowedIPs = 10.16.170.0/24, fd98:deb9:4c94::/64")) #expect(!completed.contains("10.0.0.0/8")) #expect(completed.contains("PrivateKey = k")) + // macOS permits duplicate destinations only when each route is scoped + // to its owning utun. Without these hooks stock wg-quick's second + // `route add` fails with EEXIST and tears the new tunnel back down. + #expect(completed.contains("Table = off")) + #expect(completed.contains("PostUp = /sbin/route -q -n add -inet '10.16.170.0/24' -interface %i -ifscope %i")) + #expect(completed.contains("PostUp = /sbin/route -q -n add -inet6 'fd98:deb9:4c94::/64' -interface %i -ifscope %i")) + #expect(completed.contains("PostDown = /sbin/route -q -n delete -inet -ifscope %i '10.16.170.0/24'")) + #expect(completed.contains("PostDown = /sbin/route -q -n delete -inet6 -ifscope %i 'fd98:deb9:4c94::/64'")) // Nothing known about the network: the server's routes stay. let kept = try VMTunnelManager.completedConfig(server, privateKey: "k", allowedIPs: []) #expect(kept.contains("AllowedIPs = 10.0.0.0/8, fd00::/8")) } + + @Test("Route hooks reject values that could escape the privileged command") + func routeHooksRejectUnsafeAllowedIPs() { + let server = "[Interface]\nPrivateKey = \nAddress = 100.64.0.1/32\n\n[Peer]\nPublicKey = p\nAllowedIPs = 10.0.0.0/8\n" + #expect(throws: VMTunnelManager.TunnelError.self) { + _ = try VMTunnelManager.completedConfig( + server, + privateKey: "k", + allowedIPs: ["10.16.170.0/24; touch /tmp/cmux-route-pwned"] + ) + } + } } From 63121029cccaaba435171554c605bc3147ab7e8c Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 16:48:59 -0700 Subject: [PATCH 15/40] fix(cloud): allow same-network tunnel routes to coexist --- Sources/Cloud/VMTunnelManager+Config.swift | 166 +++++++++++++++++++++ Sources/Cloud/VMTunnelManager.swift | 35 ----- cmux.xcodeproj/project.pbxproj | 4 + 3 files changed, 170 insertions(+), 35 deletions(-) create mode 100644 Sources/Cloud/VMTunnelManager+Config.swift diff --git a/Sources/Cloud/VMTunnelManager+Config.swift b/Sources/Cloud/VMTunnelManager+Config.swift new file mode 100644 index 000000000000..7b80f29b43ab --- /dev/null +++ b/Sources/Cloud/VMTunnelManager+Config.swift @@ -0,0 +1,166 @@ +import Darwin +import Foundation + +extension VMTunnelManager { + /// Completes the server template with this build's private key and routes. + /// + /// When the network prefixes are known, routes are installed as macOS + /// interface-scoped entries. `wg-quick`'s default unscoped route insertion + /// rejects a second tunnel for the same account with `EEXIST`; `Table = off` + /// plus these hooks lets each utun own an independent entry instead. + static func completedConfig( + _ config: String, + privateKey: String, + allowedIPs: [String] = [] + ) throws -> String { + var lines = config.components(separatedBy: "\n") + let interfaceStart = try sectionStart("[Interface]", in: lines) + var interfaceEnd = sectionEnd(after: interfaceStart, in: lines) + + if let privateKeyIndex = firstKeyIndex( + named: "privatekey", + in: interfaceStart.. Int { + let normalizedHeader = header.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let index = lines.firstIndex(where: { + $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalizedHeader + }) else { + throw TunnelError.configMalformed("no \(header) section in server config") + } + return index + } + + private static func sectionEnd(after start: Int, in lines: [String]) -> Int { + let nextSection = lines[(start + 1)...].firstIndex { + $0.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("[") + } + return nextSection ?? lines.count + } + + private static func firstKeyIndex( + named name: String, + in range: Range, + lines: [String] + ) -> Int? { + let normalizedName = name.lowercased() + return lines[range].firstIndex { line in + line.split(separator: "=", maxSplits: 1).first + .map { $0.trimmingCharacters(in: .whitespaces).lowercased() == normalizedName } + ?? false + } + } + + private static func normalizedRoutes(_ values: [String]) throws -> [String] { + var seen = Set() + var result: [String] = [] + result.reserveCapacity(values.count) + for raw in values { + let route = try normalizedRoute(raw) + if seen.insert(route).inserted { + result.append(route) + } + } + return result + } + + private static func normalizedRoute(_ raw: String) throws -> String { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let slash = value.lastIndex(of: "/"), slash > value.startIndex else { + throw TunnelError.configMalformed("invalid network route \(value)") + } + let address = String(value[.. String { + let family = route.contains(":") ? "inet6" : "inet" + let quotedRoute = shellQuote(route) + if action == "add" { + return "\(key) = /sbin/route -q -n add -\(family) \(quotedRoute) -interface %i -ifscope %i || true" + } + return "\(key) = /sbin/route -q -n delete -\(family) -ifscope %i \(quotedRoute) || true" + } + + private static func shellQuote(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } +} diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index 117ee4ec96db..bbc5547d385f 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -461,41 +461,6 @@ struct VMTunnelManager: Sendable { } } - /// Fill the blank `PrivateKey` line the server left in the config, and — - /// when the network's prefixes are known — replace the peer's `AllowedIPs` - /// with them (see `enroll`). The server-issued config is otherwise - /// complete and final. - static func completedConfig(_ config: String, privateKey: String, allowedIPs: [String] = []) throws -> String { - var lines = config.components(separatedBy: "\n") - func key(of line: String) -> String { - line.split(separator: "=", maxSplits: 1).first.map { $0.trimmingCharacters(in: .whitespaces).lowercased() } ?? "" - } - if let index = lines.firstIndex(where: { key(of: $0) == "privatekey" }) { - lines[index] = "PrivateKey = \(privateKey)" - } else { - // No PrivateKey line at all: insert directly under [Interface]. - guard let interfaceIndex = lines.firstIndex(where: { - $0.trimmingCharacters(in: .whitespaces).lowercased() == "[interface]" - }) else { - throw TunnelError.configMalformed("no [Interface] section in server config") - } - lines.insert("PrivateKey = \(privateKey)", at: interfaceIndex + 1) - } - if !allowedIPs.isEmpty { - let routes = "AllowedIPs = \(allowedIPs.joined(separator: ", "))" - if let index = lines.firstIndex(where: { key(of: $0) == "allowedips" }) { - lines[index] = routes - } else if let peerIndex = lines.firstIndex(where: { - $0.trimmingCharacters(in: .whitespaces).lowercased() == "[peer]" - }) { - lines.insert(routes, at: peerIndex + 1) - } else { - throw TunnelError.configMalformed("no [Peer] section in server config") - } - } - return lines.joined(separator: "\n") - } - private func ensureStateDir() throws { try FileManager.default.createDirectory( at: stateDir, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f99a476c8d42..01e042cc9a16 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2878,6 +2878,7 @@ DCFD2C3A4F04C00C62720EE5 /* VMMachineKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EECEC1A2D3CFC13D4343F46 /* VMMachineKind.swift */; }; F1C699902D31503EE3D492DF /* VMMachineKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EECEC1A2D3CFC13D4343F46 /* VMMachineKind.swift */; }; C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37800000000000000000002 /* VMSSHCommandTests.swift */; }; + 3B7844A0F1204FB982482957 /* VMTunnelManager+Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A29F5F34C254D8BA1758091 /* VMTunnelManager+Config.swift */; }; B43AFCE7099305905825EA0A /* VMTunnelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC85CE06E4F64991DAA433 /* VMTunnelManager.swift */; }; ABE558B281D671A6DDDDD066 /* VMTunnelManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74A47FAD328D6669A5B40CE3 /* VMTunnelManagerTests.swift */; }; F6AAAA235B7F431E8D1D2E94 /* VMTunnelStalenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87BDCAB46A44D7596A4784D /* VMTunnelStalenessTests.swift */; }; @@ -5950,6 +5951,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C37800000000000000000004 /* VMDefaultCloudCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDefaultCloudCommandTests.swift; sourceTree = ""; }; 6EECEC1A2D3CFC13D4343F46 /* VMMachineKind.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMMachineKind.swift; sourceTree = ""; }; C37800000000000000000002 /* VMSSHCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMSSHCommandTests.swift; sourceTree = ""; }; + 2A29F5F34C254D8BA1758091 /* VMTunnelManager+Config.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "VMTunnelManager+Config.swift"; sourceTree = ""; }; 03EC85CE06E4F64991DAA433 /* VMTunnelManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMTunnelManager.swift; sourceTree = ""; }; 74A47FAD328D6669A5B40CE3 /* VMTunnelManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMTunnelManagerTests.swift; sourceTree = ""; }; E87BDCAB46A44D7596A4784D /* VMTunnelStalenessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMTunnelStalenessTests.swift; sourceTree = ""; }; @@ -6636,6 +6638,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D1F0A00100000000000000B4 /* PresenceSettings.swift */, 9CEC6F0035B71AE59FA45BA7 /* VMClientSocketCommands.swift */, 03EC85CE06E4F64991DAA433 /* VMTunnelManager.swift */, + 2A29F5F34C254D8BA1758091 /* VMTunnelManager+Config.swift */, D9143D900F124494A55F859F /* CloudMachinesFeature.swift */, 08610D114C8CF23877176D7E /* MachinesPanelView.swift */, 68672B3C63D4E348AFA4DEF7 /* MachinesPanelViewModel.swift */, @@ -11754,6 +11757,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef ACE4FE2A8F1CC6C0B6A673AB /* VMClientSocketCommands.swift in Sources */, 93AF7E8EC956FE883E252322 /* VMClientTelemetry.swift in Sources */, F1C699902D31503EE3D492DF /* VMMachineKind.swift in Sources */, + 3B7844A0F1204FB982482957 /* VMTunnelManager+Config.swift in Sources */, B43AFCE7099305905825EA0A /* VMTunnelManager.swift in Sources */, A10020000000000000000019 /* WebSurfaceSelectionEvaluationOwner.swift in Sources */, A10020000000000000000011 /* WebSurfaceSelectionReader.swift in Sources */, From fc310eb8c0239880c9703dcba3eafa3dede74f31 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 17:09:04 -0700 Subject: [PATCH 16/40] fix(cloud): clean up scoped routes before tunnel teardown --- Sources/Cloud/VMTunnelManager+Config.swift | 7 +++++-- cmuxTests/VMTunnelStalenessTests.swift | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager+Config.swift b/Sources/Cloud/VMTunnelManager+Config.swift index 7b80f29b43ab..3e48eef98a2c 100644 --- a/Sources/Cloud/VMTunnelManager+Config.swift +++ b/Sources/Cloud/VMTunnelManager+Config.swift @@ -63,7 +63,7 @@ extension VMTunnelManager { let hooks = routes.flatMap { route in [ routeHook(key: "PostUp", action: "add", route: route), - routeHook(key: "PostDown", action: "delete", route: route), + routeHook(key: "PreDown", action: "delete", route: route), ] } let existing = Set(lines[interfaceStart../dev/null 2>&1" } return "\(key) = /sbin/route -q -n delete -\(family) -ifscope %i \(quotedRoute) || true" } diff --git a/cmuxTests/VMTunnelStalenessTests.swift b/cmuxTests/VMTunnelStalenessTests.swift index 0f615337f7b2..6d0ad3f46e33 100644 --- a/cmuxTests/VMTunnelStalenessTests.swift +++ b/cmuxTests/VMTunnelStalenessTests.swift @@ -156,8 +156,8 @@ struct VMTunnelStalenessTests { #expect(completed.contains("Table = off")) #expect(completed.contains("PostUp = /sbin/route -q -n add -inet '10.16.170.0/24' -interface %i -ifscope %i")) #expect(completed.contains("PostUp = /sbin/route -q -n add -inet6 'fd98:deb9:4c94::/64' -interface %i -ifscope %i")) - #expect(completed.contains("PostDown = /sbin/route -q -n delete -inet -ifscope %i '10.16.170.0/24'")) - #expect(completed.contains("PostDown = /sbin/route -q -n delete -inet6 -ifscope %i 'fd98:deb9:4c94::/64'")) + #expect(completed.contains("PreDown = /sbin/route -q -n delete -inet -ifscope %i '10.16.170.0/24'")) + #expect(completed.contains("PreDown = /sbin/route -q -n delete -inet6 -ifscope %i 'fd98:deb9:4c94::/64'")) // Nothing known about the network: the server's routes stay. let kept = try VMTunnelManager.completedConfig(server, privateKey: "k", allowedIPs: []) #expect(kept.contains("AllowedIPs = 10.0.0.0/8, fd00::/8")) From bb4911b1bf402e819a8c40335f848647f656bf92 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 17:13:10 -0700 Subject: [PATCH 17/40] docs(cloud): describe scoped tunnel route setup --- Sources/Cloud/VMTunnelManager.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index bbc5547d385f..5246562ba84c 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -299,10 +299,10 @@ struct VMTunnelManager: Sendable { deviceName: deviceName ?? CloudTuiClientPaths.deviceName() ) // Route only this network's own prefixes. The platform's config routes - // all of 10.0.0.0/8 and fd00::/8, which is fine for one tunnel and - // fatal for two: a second tunnel could not install the same routes, so - // a dev build's tunnel and the production one could never be up - // together. Each network is a /24 (and a /64) the enrollment reports. + // all of 10.0.0.0/8 and fd00::/8, which is fine for one tunnel but + // overlaps unrelated account networks. Each network is a /24 (and a + // /64) the enrollment reports; `completedConfig` installs those as + // interface-scoped routes so a second build can use the same network. let allowedIPs = [endpoint.networkCidr, endpoint.networkCidrV6] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } From 42129c9e2fca34054bc150c686b64108f9448213 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 17:24:40 -0700 Subject: [PATCH 18/40] fix(cloud): verify existing scoped routes before accepting them --- Sources/Cloud/VMTunnelManager+Config.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Cloud/VMTunnelManager+Config.swift b/Sources/Cloud/VMTunnelManager+Config.swift index 3e48eef98a2c..ea6b23c31fe8 100644 --- a/Sources/Cloud/VMTunnelManager+Config.swift +++ b/Sources/Cloud/VMTunnelManager+Config.swift @@ -158,7 +158,7 @@ extension VMTunnelManager { // A stale route for this same utun is harmless (for example after // a killed wg-quick process). Treat that one case as success, // but let a real route-install failure abort wg-quick's bring-up. - return "\(key) = /sbin/route -q -n add -\(family) \(quotedRoute) -interface %i -ifscope %i || /sbin/route -n get -\(family) -ifscope %i \(quotedRoute) >/dev/null 2>&1" + return "\(key) = /sbin/route -q -n add -\(family) \(quotedRoute) -interface %i -ifscope %i || /sbin/route -n get -\(family) -ifscope %i \(quotedRoute) 2>&1 | /usr/bin/grep -q 'interface: %i'" } return "\(key) = /sbin/route -q -n delete -\(family) -ifscope %i \(quotedRoute) || true" } From d1e57bc72b817aab19fa9c30af2762bc8a9d6caa Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 17:40:02 -0700 Subject: [PATCH 19/40] fix(cli): explain that vpn commands must run as the user --- CLI/cmux.swift | 13 +++++++++++++ Resources/Localizable.xcstrings | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 28865e514f5d..828a8859ddf5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5194,6 +5194,19 @@ struct CMUXCLI { return } + // `cmux vpn` performs its own narrowly-scoped sudo calls for + // wg-quick and `/etc/hosts`. Running the CLI itself through sudo + // changes HOME/UID, hides the user's cmux socket, and can never pass + // the socket's same-user/ancestry policy. Fail before socket discovery + // with an actionable instruction instead of the misleading "no live + // socket" diagnostic. + if command == "vpn", geteuid() == 0 { + throw CLIError(message: String( + localized: "cli.vpn.runAsUser", + defaultValue: "Run `cmux vpn` without `sudo`; cmux asks for sudo only when it changes the tunnel or /etc/hosts." + )) + } + // If the argument is a path (not a known command), open a workspace there. if shouldOpenAsPathArgument(command), explicitSocketPath == nil { try openPath(command) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 48744af969a1..d8bc4e20f2e9 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -59708,6 +59708,23 @@ } } }, + "cli.vpn.runAsUser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run `cmux vpn` without `sudo`; cmux asks for sudo only when it changes the tunnel or /etc/hosts." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "`sudo` を付けずに `cmux vpn` を実行してください。トンネルまたは /etc/hosts を変更するときだけ cmux が sudo を求めます。" + } + } + } + }, "cli.vpn.down": { "extractionState": "manual", "localizations": { From a28b563713846276d48890e22e9381029b0066a6 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 18:34:25 -0700 Subject: [PATCH 20/40] test(cloud): make tunnel liveness regression deterministic --- cmuxTests/VMTunnelManagerTests.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmuxTests/VMTunnelManagerTests.swift b/cmuxTests/VMTunnelManagerTests.swift index 0d29ce320555..6a693a05f41e 100644 --- a/cmuxTests/VMTunnelManagerTests.swift +++ b/cmuxTests/VMTunnelManagerTests.swift @@ -154,7 +154,12 @@ struct VMTunnelManagerTests { [Peer] PublicKey = Y """.write(to: manager.configURL, atomically: true, encoding: .utf8) - #expect(manager.wgQuickInterfaceUp() == true) + let configOnDisk = try String(contentsOf: manager.configURL, encoding: .utf8) + #expect(VMTunnelManager.interfaceIsUp( + runtimeNamePresent: true, + config: configOnDisk, + liveInterfaceAddresses: ["127.0.0.1"] + )) } @Test From 184f0068da5cf4f889b6f3eb2152228201889f4e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 18:34:43 -0700 Subject: [PATCH 21/40] fix(cloud): keep tunnel liveness and route fallback testable --- Sources/Cloud/VMTunnelManager.swift | 49 ++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index 5246562ba84c..01c71798f6de 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -303,9 +303,20 @@ struct VMTunnelManager: Sendable { // overlaps unrelated account networks. Each network is a /24 (and a // /64) the enrollment reports; `completedConfig` installs those as // interface-scoped routes so a second build can use the same network. - let allowedIPs = [endpoint.networkCidr, endpoint.networkCidrV6] + let networkRoutes = [endpoint.networkCidr, endpoint.networkCidrV6] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + // Older control-plane responses may not include the nested `network` + // object even though the provider already returned its precise route + // list. Keep those responses safe for concurrent builds too: use the + // provider routes as a fallback, and fill only a missing address family + // when the network wrapper is partial. + let hasIPv4NetworkRoute = networkRoutes.contains { !$0.contains(":") } + let hasIPv6NetworkRoute = networkRoutes.contains { $0.contains(":") } + let allowedIPs = networkRoutes + endpoint.routes.filter { route in + let isIPv6 = route.contains(":") + return isIPv6 ? !hasIPv6NetworkRoute : !hasIPv4NetworkRoute + } let config = try Self.completedConfig(endpoint.clientConfig, privateKey: keys.privateKey, allowedIPs: allowedIPs) try ensureStateDir() try write(config, to: configURL) @@ -402,22 +413,44 @@ struct VMTunnelManager: Sendable { /// really configured (a name file can outlive a crash until reboot). A /// future NetworkExtension tunnel reports through NEVPNStatus instead. func wgQuickInterfaceUp() -> Bool { - guard FileManager.default.fileExists(atPath: runtimeNameFileURL.path) else { return false } guard let config = try? String(contentsOf: configURL, encoding: .utf8) else { return false } - let expected = Self.interfaceAddresses(in: config) + let runtimeNamePresent = FileManager.default.fileExists(atPath: runtimeNameFileURL.path) + return Self.interfaceIsUp( + runtimeNamePresent: runtimeNamePresent, + config: config, + liveInterfaceAddresses: Self.currentInterfaceAddresses() + ) + } + + /// Combines the two unprivileged liveness signals used by ``wgQuickInterfaceUp``. + /// + /// Keeping this decision pure gives tests a deterministic way to cover the + /// root-owned marker requirement without creating files under `/var/run`. + static func interfaceIsUp( + runtimeNamePresent: Bool, + config: String, + liveInterfaceAddresses: Set + ) -> Bool { + guard runtimeNamePresent else { return false } + let expected = interfaceAddresses(in: config) guard !expected.isEmpty else { return false } + return !expected.isDisjoint(with: liveInterfaceAddresses) + } + + /// Numeric addresses currently assigned to local interfaces. + private static func currentInterfaceAddresses() -> Set { + var addresses = Set() var addrs: UnsafeMutablePointer? - guard getifaddrs(&addrs) == 0 else { return false } + guard getifaddrs(&addrs) == 0 else { return addresses } defer { freeifaddrs(addrs) } var cursor = addrs while let current = cursor { - if let sa = current.pointee.ifa_addr, let address = Self.numericAddress(sa), - expected.contains(address) { - return true + if let sa = current.pointee.ifa_addr, let address = Self.numericAddress(sa) { + addresses.insert(address) } cursor = current.pointee.ifa_next } - return false + return addresses } /// The `Address =` values in a wg-quick config's `[Interface]` section, From 9e38c9197aebef91d5a840397694c5e16f443969 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 19:45:26 -0700 Subject: [PATCH 22/40] fix(cloud): bind tunnel liveness to its utun --- Sources/Cloud/VMTunnelManager.swift | 88 +++++++++++++++++++++++----- cmuxTests/VMTunnelManagerTests.swift | 20 ++++++- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index 01c71798f6de..38ea0ad6cf16 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -406,19 +406,21 @@ struct VMTunnelManager: Sendable { /// /// Two facts, both required: wg-quick's own record for this interface name /// exists (`/var/run/wireguard/.name`, root-only but visible), and - /// some interface holds one of the tunnel's own `[Interface] Address`es - /// from the config this manager wrote. The name file is what separates - /// this environment's tunnel from another's — every tunnel gets the same - /// tunnel-side address — and the address is what proves the interface is - /// really configured (a name file can outlive a crash until reboot). A - /// future NetworkExtension tunnel reports through NEVPNStatus instead. + /// the matching `utunN` socket is live and holds one of the tunnel's own + /// `[Interface] Address`es from the config this manager wrote. The name + /// file's contents are root-only on macOS, so its mtime/size are matched + /// against the socket files exactly as `wg-quick` does internally. This + /// keeps a stale marker for one scope from borrowing another scope's + /// identical tunnel-side address. A future NetworkExtension tunnel reports + /// through NEVPNStatus instead. func wgQuickInterfaceUp() -> Bool { guard let config = try? String(contentsOf: configURL, encoding: .utf8) else { return false } - let runtimeNamePresent = FileManager.default.fileExists(atPath: runtimeNameFileURL.path) + guard let runtimeInterfaceName = Self.runtimeInterfaceName(for: runtimeNameFileURL) else { return false } return Self.interfaceIsUp( - runtimeNamePresent: runtimeNamePresent, + runtimeNamePresent: true, + runtimeInterfaceName: runtimeInterfaceName, config: config, - liveInterfaceAddresses: Self.currentInterfaceAddresses() + liveInterfaceAddressesByName: Self.currentInterfaceAddressesByName() ) } @@ -428,25 +430,81 @@ struct VMTunnelManager: Sendable { /// root-owned marker requirement without creating files under `/var/run`. static func interfaceIsUp( runtimeNamePresent: Bool, + runtimeInterfaceName: String?, config: String, - liveInterfaceAddresses: Set + liveInterfaceAddressesByName: [String: Set] ) -> Bool { guard runtimeNamePresent else { return false } + guard let runtimeInterfaceName, + let liveInterfaceAddresses = liveInterfaceAddressesByName[runtimeInterfaceName] else { + return false + } let expected = interfaceAddresses(in: config) guard !expected.isEmpty else { return false } return !expected.isDisjoint(with: liveInterfaceAddresses) } - /// Numeric addresses currently assigned to local interfaces. - private static func currentInterfaceAddresses() -> Set { - var addresses = Set() + /// Finds the actual `utunN` associated with a scope marker. + /// + /// `wireguard-go` writes the scope marker and its socket in the same + /// bring-up operation. Their modification times are within two seconds — + /// the invariant used by `wg-quick`'s own `get_real_interface()` — while + /// the marker byte count also identifies the interface when adjacent + /// sockets are created in the same second. Ambiguous matches fail closed. + private static func runtimeInterfaceName(for markerURL: URL) -> String? { + var markerInfo = stat() + guard lstat(markerURL.path, &markerInfo) == 0, + (markerInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) else { + return nil + } + + let directoryURL = markerURL.deletingLastPathComponent() + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + return nil + } + let markerModificationTime = markerInfo.st_mtimespec.tv_sec + let markerByteCount = markerInfo.st_size + var matches: [String] = [] + for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + let filename = entry.lastPathComponent + guard filename.hasPrefix("utun"), filename.hasSuffix(".sock") else { continue } + let interface = String(filename.dropLast(".sock".count)) + guard interface.count > "utun".count, + interface.dropFirst("utun".count).allSatisfy(\.isNumber) else { continue } + var socketInfo = stat() + guard lstat(entry.path, &socketInfo) == 0, + (socketInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { + continue + } + let difference = socketInfo.st_mtimespec.tv_sec - markerModificationTime + guard abs(difference) < 2 else { continue } + matches.append(interface) + } + + if matches.count == 1 { return matches[0] } + let sizedMatches = matches.filter { + Int64($0.utf8.count + 1) == markerByteCount + } + return sizedMatches.count == 1 ? sizedMatches[0] : nil + } + + /// Numeric addresses currently assigned to each local interface. + private static func currentInterfaceAddressesByName() -> [String: Set] { + var addresses: [String: Set] = [:] var addrs: UnsafeMutablePointer? guard getifaddrs(&addrs) == 0 else { return addresses } defer { freeifaddrs(addrs) } var cursor = addrs while let current = cursor { - if let sa = current.pointee.ifa_addr, let address = Self.numericAddress(sa) { - addresses.insert(address) + if let sa = current.pointee.ifa_addr, + let address = Self.numericAddress(sa), + let namePointer = current.pointee.ifa_name { + let name = String(cString: namePointer) + addresses[name, default: []].insert(address) } cursor = current.pointee.ifa_next } diff --git a/cmuxTests/VMTunnelManagerTests.swift b/cmuxTests/VMTunnelManagerTests.swift index 6a693a05f41e..b9bbb4a51aff 100644 --- a/cmuxTests/VMTunnelManagerTests.swift +++ b/cmuxTests/VMTunnelManagerTests.swift @@ -157,8 +157,26 @@ struct VMTunnelManagerTests { let configOnDisk = try String(contentsOf: manager.configURL, encoding: .utf8) #expect(VMTunnelManager.interfaceIsUp( runtimeNamePresent: true, + runtimeInterfaceName: "utun10", config: configOnDisk, - liveInterfaceAddresses: ["127.0.0.1"] + liveInterfaceAddressesByName: ["utun10": ["127.0.0.1"]] + )) + } + + @Test + func staleScopeCannotBorrowAnotherInterfaceAddress() { + let config = "[Interface]\nAddress = 100.64.0.1/32\n" + #expect(!VMTunnelManager.interfaceIsUp( + runtimeNamePresent: true, + runtimeInterfaceName: "utun9", + config: config, + liveInterfaceAddressesByName: ["utun10": ["100.64.0.1"]] + )) + #expect(VMTunnelManager.interfaceIsUp( + runtimeNamePresent: true, + runtimeInterfaceName: "utun10", + config: config, + liveInterfaceAddressesByName: ["utun10": ["100.64.0.1"]] )) } From f739c84ba830b3b225e0472344ca24f78d8d51ad Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 20:05:09 -0700 Subject: [PATCH 23/40] fix(cli): keep VPN commands on their own app socket --- CLI/CLISocketPathResolver.swift | 22 +++++++++++++--- CLI/cmux.swift | 6 ++++- .../CMUXCLIErrorOutputRegressionTests.swift | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/CLI/CLISocketPathResolver.swift b/CLI/CLISocketPathResolver.swift index b0692c8a4af1..4ebc8d1cad44 100644 --- a/CLI/CLISocketPathResolver.swift +++ b/CLI/CLISocketPathResolver.swift @@ -232,7 +232,8 @@ struct CLISocketPathResolver { /// non-blocking connect succeeds; a stale socket file is never handed to the client. func resolve( requestedPath: String, - source: CLISocketPathSource + source: CLISocketPathSource, + allowCrossVariantFallback: Bool = true ) -> CLISocketPathResolution { guard source == .implicitDefault else { return CLISocketPathResolution( @@ -243,7 +244,10 @@ struct CLISocketPathResolver { ) } - let candidates = Self.dedupe(candidatePaths(requestedPath: requestedPath)) + let candidates = Self.dedupe(candidatePaths( + requestedPath: requestedPath, + allowCrossVariantFallback: allowCrossVariantFallback + )) let selectedPath = candidates.first { path in canConnect(to: path) } @@ -255,7 +259,10 @@ struct CLISocketPathResolver { ) } - private func candidatePaths(requestedPath: String) -> [String] { + private func candidatePaths( + requestedPath: String, + allowCrossVariantFallback: Bool + ) -> [String] { var candidates: [String] = [] let variant = SocketPathMarkerFiles.variant(bundleIdentifier: bundleIdentifier, environment: environment) let ownDefaultPath = resolvedDefaultSocketPath() @@ -264,6 +271,15 @@ struct CLISocketPathResolver { // tag-specific socket; for the stable CLI it is the primary stable socket. candidates.append(ownDefaultPath) + // Side-effecting build-scoped commands must never silently cross into + // another app when their own listener is unavailable. Explicit socket + // paths remain pinned by `resolve` above; this branch governs only + // implicit discovery. The caller can still opt into the historical + // cross-variant fallback for read-only/general CLI commands. + guard allowCrossVariantFallback else { + return candidates + } + // A dead dev socket must not strand ambient commands. The stable primary // socket is the deterministic machine-wide fallback before any marker. candidates.append(resolvedStableDefaultSocketPath) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 828a8859ddf5..9c64399619e2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5243,7 +5243,11 @@ struct CMUXCLI { ) let socketResolution = socketResolver.resolve( requestedPath: socketPath, - source: socketPathSource + source: socketPathSource, + // `vpn` mutates privileged networking state. If its own app/build + // listener is down, failing with that exact socket is safer than + // enrolling or tearing down another running build's tunnel. + allowCrossVariantFallback: command != "vpn" ) if !socketResolution.hasLiveSocket, socketPathSource == .implicitDefault, diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index b2939255e5c7..6fe7b3910b33 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -2306,6 +2306,32 @@ import Testing #expect(responder.receivedRequests.isEmpty) } + @Test func testVPNImplicitDiscoveryNeverFallsBackToAnotherVariant() throws { + let home = try makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + let stableSocketPath = try stableSocketURL(home: home).path + let resolver = CLISocketPathResolver( + environment: [:], + bundleIdentifier: SocketPathMarkerFiles.nightlyBundleIdentifier, + currentUserID: getuid(), + inspectSocketPathEntry: { path in + path == stableSocketPath ? .socket(ownerUserID: getuid()) : .missing + }, + socketAcceptsConnections: { $0 == stableSocketPath }, + stateDirectory: CmuxStateDirectory.url(homeDirectory: home) + ) + + let resolution = resolver.resolve( + requestedPath: "/tmp/cmux-nightly.sock", + source: .implicitDefault, + allowCrossVariantFallback: false + ) + + #expect(resolution.candidatePaths == ["/tmp/cmux-nightly.sock"]) + #expect(resolution.selectedPath == nil) + #expect(!resolution.hasLiveSocket) + } + @Test func testExplicitStableSocketEnvironmentIsNeverReroutedByTaggedCLI() throws { let cliPath = try bundledCLIPath() let tagSlug = "cli-explicit-stable-\(UUID().uuidString.lowercased())" From fdf1d319b8d7ab260fd55dbb932c343b3e2910b7 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 21:35:12 -0700 Subject: [PATCH 24/40] fix(cloud): reject ambiguous tunnel marker matches --- Sources/Cloud/VMTunnelManager.swift | 18 +++++++++++++++--- cmuxTests/VMTunnelManagerTests.swift | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index 38ea0ad6cf16..3755a453f5f6 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -485,9 +485,21 @@ struct VMTunnelManager: Sendable { matches.append(interface) } - if matches.count == 1 { return matches[0] } - let sizedMatches = matches.filter { - Int64($0.utf8.count + 1) == markerByteCount + return Self.selectRuntimeInterface( + markerByteCount: markerByteCount, + candidates: matches + ) + } + + /// Selects one timestamp-matched `utunN` only when its marker-size invariant + /// also agrees. A stale marker must never borrow a newly-created socket just + /// because its own socket disappeared during the two-second timestamp window. + static func selectRuntimeInterface(markerByteCount: Int64, candidates: [String]) -> String? { + let sizedMatches = candidates.filter { interface in + // wireguard-go writes the interface name followed by a newline; + // require that exact size so a stale marker cannot match a + // different interface whose name happens to have the same length. + markerByteCount == Int64(interface.utf8.count + 1) } return sizedMatches.count == 1 ? sizedMatches[0] : nil } diff --git a/cmuxTests/VMTunnelManagerTests.swift b/cmuxTests/VMTunnelManagerTests.swift index b9bbb4a51aff..57451d4611cb 100644 --- a/cmuxTests/VMTunnelManagerTests.swift +++ b/cmuxTests/VMTunnelManagerTests.swift @@ -180,6 +180,25 @@ struct VMTunnelManagerTests { )) } + @Test + func runtimeMarkerSelectionRejectsAStaleSingleTimestampMatch() { + // `utun10` is the only timestamp match, but a stale marker containing + // `utun9` has six bytes (including its newline), so it cannot claim it. + #expect(VMTunnelManager.selectRuntimeInterface( + markerByteCount: 6, + candidates: ["utun10"] + ) == nil) + #expect(VMTunnelManager.selectRuntimeInterface( + markerByteCount: 7, + candidates: ["utun10"] + ) == "utun10") + // Two same-length candidates remain ambiguous and fail closed. + #expect(VMTunnelManager.selectRuntimeInterface( + markerByteCount: 6, + candidates: ["utun1", "utun2"] + ) == nil) + } + @Test func buildScopesDoNotShareCredentialsOrConfigFiles() throws { let home = try temporaryHome() From 0cc1f9057ae648440ef9f5c8f0b02dec1ac97f24 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 21:36:35 -0700 Subject: [PATCH 25/40] docs(cloud): clarify user-scoped VPN invocation --- CLI/CMUXCLI+VPN.swift | 10 ++++++---- CLI/cmux.swift | 5 +++-- Sources/Cloud/VMTunnelManager.swift | 6 +++--- skills/cmux-cloud-vm/references/commands.md | 4 ++-- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/CLI/CMUXCLI+VPN.swift b/CLI/CMUXCLI+VPN.swift index f0a7892d193e..67a3eaf111d0 100644 --- a/CLI/CMUXCLI+VPN.swift +++ b/CLI/CMUXCLI+VPN.swift @@ -9,13 +9,15 @@ import Foundation /// inbound port, so their session daemons are reachable only through this /// tunnel. The cmux app owns enrollment (keypair, device identity, the /// build-scoped config file at `~/.cmuxterm/wireguard/.conf`); this -/// command owns the -/// privileged bring-up, because creating a utun and installing routes needs -/// root and `sudo` in the user's own terminal is the honest way to ask. +/// command owns the privileged bring-up, because creating a utun and installing +/// routes needs root. Invoke `cmux vpn` as the signed-in user; it runs the +/// narrowly-scoped `sudo` calls itself so the app socket and HOME stay tied to +/// the selected build. /// /// Two backends, one command: /// - **wg-quick** (shipping): `up` runs `sudo wg-quick up` on the app-written -/// config. Requires `brew install wireguard-tools`. +/// config (do not prefix the `cmux` command with `sudo`). Requires `brew +/// install wireguard-tools`. /// - **NetworkExtension** (long-term, entitlement-gated): when a build carries /// the packet-tunnel entitlement, the app manages the tunnel itself and /// `cmux vpn up` will hand off to it instead of shelling out. The socket diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9c64399619e2..d980a99f7378 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18627,8 +18627,9 @@ struct CMUXCLI { exec, and port verbs need this tunnel up. up Enroll this Mac (first run), bring the tunnel up, and sync - internal hostnames. Uses wg-quick and prompts for sudo; - install with `brew install wireguard-tools`. + internal hostnames. Run `cmux vpn up` as your user (do not + prefix it with `sudo`); cmux invokes sudo for wg-quick. + Install it with `brew install wireguard-tools`. down Take the tunnel down. Enrollment is kept. status Show tunnel state, config path, and backend. revoke Take the tunnel down, unenroll this Mac, and clear its diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index 3755a453f5f6..125b7da5ab08 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -21,9 +21,9 @@ import Security /// Bringing the interface up needs privileges the app process does not have, /// and there are two backends for it: /// -/// - **wg-quick** (`cmux vpn up`) — the shipping path. The CLI runs -/// `sudo wg-quick up` against the config this manager wrote; sudo in the -/// user's own terminal is the honest privilege prompt. +/// - **wg-quick** (`cmux vpn up`) — the shipping path. Run the CLI as the +/// signed-in user; it invokes `sudo wg-quick up` against the config this +/// manager wrote while preserving that user's build-scoped app socket. /// - **NetworkExtension** — the long-term path, pending the /// `com.apple.developer.networking.networkextension` entitlement. When a /// build carries it, the app can own the tunnel as a real macOS VPN with no diff --git a/skills/cmux-cloud-vm/references/commands.md b/skills/cmux-cloud-vm/references/commands.md index 2345cb0eaacf..f135b339dbac 100644 --- a/skills/cmux-cloud-vm/references/commands.md +++ b/skills/cmux-cloud-vm/references/commands.md @@ -9,8 +9,8 @@ cmux auth status # signed in? cmux vm ls # NAME / LABEL / STATE / PROVIDER / IMAGE + plan meter (+ free-window countdown) cmux vm ls --json # {vms: [{id, status, image, createdAt, freeAccessExpiresAt}], limits: {maxActiveVms, planId, freeAccessWindowDays, freeAccessExpiresAt}} cmux vpn status # this build's WireGuard tunnel to its private machine network (machines open no public port): up, down, or up for another enrollment (stale) -cmux vpn up # enroll this Mac and bring the build-scoped tunnel up (sudo); a stale tunnel (rotated keys) is replaced. Stable production keeps `cmux`; nightly, staging, and tagged dev builds get distinct interfaces and credential files, so they can be up together -cmux vpn down # take this build's tunnel down (sudo) +cmux vpn up # run as your user (cmux invokes sudo); enroll this Mac and bring the build-scoped tunnel up. A stale tunnel (rotated keys) is replaced. Stable production keeps `cmux`; nightly, staging, and tagged dev builds get distinct interfaces and credential files, so they can be up together +cmux vpn down # run as your user; cmux invokes sudo to take this build's tunnel down cmux vm tree # the surface catalog: This Mac (terminals by workspace, browsers), then every machine → Workspaces, Ports, VNC Displays, Terminals cmux vm tree --refresh # one machine (`local` for This Mac), re-synced first cmux vm workspace new [--name n] # a new cmux-tui workspace on the machine (⌘N there), opened as a new local workspace From 79583215c72167a3923dc121c7d25240cde75f2c Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 21:43:03 -0700 Subject: [PATCH 26/40] fix(cloud): persist exact WireGuard runtime interface --- Sources/Cloud/VMTunnelManager+Config.swift | 103 +++++++++++++-------- Sources/Cloud/VMTunnelManager.swift | 30 +++++- cmuxTests/VMTunnelStalenessTests.swift | 12 +++ 3 files changed, 102 insertions(+), 43 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager+Config.swift b/Sources/Cloud/VMTunnelManager+Config.swift index ea6b23c31fe8..d25a48ed0ddb 100644 --- a/Sources/Cloud/VMTunnelManager+Config.swift +++ b/Sources/Cloud/VMTunnelManager+Config.swift @@ -2,7 +2,8 @@ import Darwin import Foundation extension VMTunnelManager { - /// Completes the server template with this build's private key and routes. + /// Completes the server template with this build's private key, routes, and + /// an optional exact-runtime-interface marker. /// /// When the network prefixes are known, routes are installed as macOS /// interface-scoped entries. `wg-quick`'s default unscoped route insertion @@ -11,7 +12,8 @@ extension VMTunnelManager { static func completedConfig( _ config: String, privateKey: String, - allowedIPs: [String] = [] + allowedIPs: [String] = [], + runtimeMetadataPath: String? = nil ) throws -> String { var lines = config.components(separatedBy: "\n") let interfaceStart = try sectionStart("[Interface]", in: lines) @@ -28,53 +30,72 @@ extension VMTunnelManager { interfaceEnd += 1 } - guard !allowedIPs.isEmpty else { - return lines.joined(separator: "\n") - } + if !allowedIPs.isEmpty { + let routes = try normalizedRoutes(allowedIPs) + let peerStart = try sectionStart("[Peer]", in: lines) + let peerEnd = sectionEnd(after: peerStart, in: lines) + let allowedIPsLine = "AllowedIPs = \(routes.joined(separator: ", "))" + if let allowedIPsIndex = firstKeyIndex( + named: "allowedips", + in: peerStart.. [String] { + let target = shellQuote(path) + let writeScript = "printf \"%s\\n\" \"%i\" > \(target) && chmod 444 \(target)" + return [ + "PostUp = /bin/sh -c \(shellQuote(writeScript))", + "PreDown = /bin/rm -f \(target)", + ] + } + private static func sectionStart(_ header: String, in lines: [String]) throws -> Int { let normalizedHeader = header.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard let index = lines.firstIndex(where: { diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index 125b7da5ab08..478f836c87a9 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -235,6 +235,15 @@ struct VMTunnelManager: Sendable { URL(fileURLWithPath: "/var/run/wireguard/\(interfaceName).name", isDirectory: false) } + /// A small, non-secret marker written by this config's `PostUp` hook with + /// wg-quick's actual `utunN` name. The stock `.name` file is root-only, so + /// its contents cannot be read by the app; this companion marker makes the + /// scope-to-interface association exact even when two sockets are created + /// in the same second. It is removed by the matching `PreDown` hook. + var runtimeInterfaceMetadataURL: URL { + URL(fileURLWithPath: "/var/run/wireguard/\(interfaceName).cmux-runtime", isDirectory: false) + } + /// Whether this build can own the tunnel as a NetworkExtension VPN. /// /// Reads the signed entitlement rather than trying to configure a manager, @@ -317,7 +326,12 @@ struct VMTunnelManager: Sendable { let isIPv6 = route.contains(":") return isIPv6 ? !hasIPv6NetworkRoute : !hasIPv4NetworkRoute } - let config = try Self.completedConfig(endpoint.clientConfig, privateKey: keys.privateKey, allowedIPs: allowedIPs) + let config = try Self.completedConfig( + endpoint.clientConfig, + privateKey: keys.privateKey, + allowedIPs: allowedIPs, + runtimeMetadataPath: runtimeInterfaceMetadataURL.path + ) try ensureStateDir() try write(config, to: configURL) return LocalTunnelState( @@ -415,7 +429,9 @@ struct VMTunnelManager: Sendable { /// through NEVPNStatus instead. func wgQuickInterfaceUp() -> Bool { guard let config = try? String(contentsOf: configURL, encoding: .utf8) else { return false } - guard let runtimeInterfaceName = Self.runtimeInterfaceName(for: runtimeNameFileURL) else { return false } + let runtimeInterfaceName = Self.readRuntimeInterfaceName(from: runtimeInterfaceMetadataURL) + ?? Self.runtimeInterfaceName(for: runtimeNameFileURL) + guard let runtimeInterfaceName else { return false } return Self.interfaceIsUp( runtimeNamePresent: true, runtimeInterfaceName: runtimeInterfaceName, @@ -424,6 +440,16 @@ struct VMTunnelManager: Sendable { ) } + /// Reads and validates the user-readable companion marker written by the + /// config's privileged `PostUp` hook. Invalid or stale contents fall back + /// to the stock wg-quick marker inference instead of being trusted. + private static func readRuntimeInterfaceName(from metadataURL: URL) -> String? { + guard let raw = try? String(contentsOf: metadataURL, encoding: .utf8) else { return nil } + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.range(of: #"^utun[0-9]+$"#, options: .regularExpression) != nil else { return nil } + return value + } + /// Combines the two unprivileged liveness signals used by ``wgQuickInterfaceUp``. /// /// Keeping this decision pure gives tests a deterministic way to cover the diff --git a/cmuxTests/VMTunnelStalenessTests.swift b/cmuxTests/VMTunnelStalenessTests.swift index 6d0ad3f46e33..bdbab213a732 100644 --- a/cmuxTests/VMTunnelStalenessTests.swift +++ b/cmuxTests/VMTunnelStalenessTests.swift @@ -86,6 +86,7 @@ struct VMTunnelStalenessTests { #expect(staging.configURL.lastPathComponent == "cmux-staging.conf") #expect(staging.appliedDigestURL.lastPathComponent == "cmux-staging.applied") #expect(staging.runtimeNameFileURL.path == "/var/run/wireguard/cmux-staging.name") + #expect(staging.runtimeInterfaceMetadataURL.path == "/var/run/wireguard/cmux-staging.cmux-runtime") } @Test("A production-targeted tagged DEV build never shares nightly's interface") @@ -161,6 +162,17 @@ struct VMTunnelStalenessTests { // Nothing known about the network: the server's routes stay. let kept = try VMTunnelManager.completedConfig(server, privateKey: "k", allowedIPs: []) #expect(kept.contains("AllowedIPs = 10.0.0.0/8, fd00::/8")) + + let runtimeMarked = try VMTunnelManager.completedConfig( + server, + privateKey: "k", + allowedIPs: ["10.16.170.0/24", "fd98:deb9:4c94::/64"], + runtimeMetadataPath: "/var/run/wireguard/cmux-nightly.cmux-runtime" + ) + #expect(runtimeMarked.contains("PostUp = /bin/sh -c")) + #expect(runtimeMarked.contains("cmux-nightly.cmux-runtime")) + #expect(runtimeMarked.contains("PreDown = /bin/rm -f")) + #expect(runtimeMarked.contains("%i")) } @Test("Route hooks reject values that could escape the privileged command") From 8010990569b45b89729622d3499712551e27f916 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 21:48:47 -0700 Subject: [PATCH 27/40] fix(cloud): reject stale runtime markers --- Sources/Cloud/VMTunnelManager.swift | 31 ++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index 478f836c87a9..d8a30362f1d0 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -429,7 +429,10 @@ struct VMTunnelManager: Sendable { /// through NEVPNStatus instead. func wgQuickInterfaceUp() -> Bool { guard let config = try? String(contentsOf: configURL, encoding: .utf8) else { return false } - let runtimeInterfaceName = Self.readRuntimeInterfaceName(from: runtimeInterfaceMetadataURL) + let runtimeInterfaceName = Self.readRuntimeInterfaceName( + from: runtimeInterfaceMetadataURL, + markerURL: runtimeNameFileURL + ) ?? Self.runtimeInterfaceName(for: runtimeNameFileURL) guard let runtimeInterfaceName else { return false } return Self.interfaceIsUp( @@ -443,10 +446,32 @@ struct VMTunnelManager: Sendable { /// Reads and validates the user-readable companion marker written by the /// config's privileged `PostUp` hook. Invalid or stale contents fall back /// to the stock wg-quick marker inference instead of being trusted. - private static func readRuntimeInterfaceName(from metadataURL: URL) -> String? { - guard let raw = try? String(contentsOf: metadataURL, encoding: .utf8) else { return nil } + private static func readRuntimeInterfaceName(from metadataURL: URL, markerURL: URL) -> String? { + var metadataInfo = stat() + guard lstat(metadataURL.path, &metadataInfo) == 0, + (metadataInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG), + metadataInfo.st_size > 0, + metadataInfo.st_size <= 64, + let raw = try? String(contentsOf: metadataURL, encoding: .utf8) else { return nil } let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard value.range(of: #"^utun[0-9]+$"#, options: .regularExpression) != nil else { return nil } + + // A stale companion file can survive a killed wg-quick process, and + // Darwin may reuse the same utun number later. Require the companion, + // wg-quick's root marker, and the socket to belong to one creation + // window before trusting the readable value. + var markerInfo = stat() + guard lstat(markerURL.path, &markerInfo) == 0, + (markerInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) else { return nil } + var socketInfo = stat() + let socketURL = metadataURL + .deletingLastPathComponent() + .appendingPathComponent("\(value).sock", isDirectory: false) + guard lstat(socketURL.path, &socketInfo) == 0, + (socketInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { return nil } + let metadataTime = metadataInfo.st_mtimespec.tv_sec + guard abs(metadataTime - markerInfo.st_mtimespec.tv_sec) < 2, + abs(metadataTime - socketInfo.st_mtimespec.tv_sec) < 2 else { return nil } return value } From b794ebb3e6119520b64353a978e442188dfed779 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 21:54:42 -0700 Subject: [PATCH 28/40] fix(cloud): publish tunnel identity before routes --- Sources/Cloud/VMTunnelManager+Config.swift | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager+Config.swift b/Sources/Cloud/VMTunnelManager+Config.swift index d25a48ed0ddb..b98027309c49 100644 --- a/Sources/Cloud/VMTunnelManager+Config.swift +++ b/Sources/Cloud/VMTunnelManager+Config.swift @@ -30,6 +30,17 @@ extension VMTunnelManager { interfaceEnd += 1 } + // Publish the real interface before any potentially slow route hooks; + // the app can then bind liveness to the exact socket even while setup + // is still finishing. + if let runtimeMetadataPath, !runtimeMetadataPath.isEmpty { + let metadataHooks = runtimeMetadataHooks(path: runtimeMetadataPath) + let existing = Set(lines[interfaceStart.. Date: Thu, 3 Sep 2026 22:13:07 -0700 Subject: [PATCH 29/40] fix(cli): keep sudo out of VPN shim dispatch --- scripts/reload.sh | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/scripts/reload.sh b/scripts/reload.sh index 36ca17022424..aa6e45ba30d4 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -590,6 +590,41 @@ for arg in "\$@"; do esac done +# vpn owns its own narrowly-scoped sudo calls. If a user prefixes the shim +# itself with sudo, the secure PATH can otherwise select an installed/stable +# CLI and lose the originating build's HOME and socket identity before the +# binary gets a chance to explain the mistake. +if [[ "\${EUID:-\$(id -u)}" == "0" ]]; then + CMUX_SHIM_COMMAND="" + CMUX_SHIM_EXPECT_VALUE=0 + for CMUX_SHIM_ARG in "\$@"; do + if [[ "\$CMUX_SHIM_EXPECT_VALUE" == "1" ]]; then + CMUX_SHIM_EXPECT_VALUE=0 + continue + fi + case "\$CMUX_SHIM_ARG" in + --socket|--id-format|--window|--password) + CMUX_SHIM_EXPECT_VALUE=1 + ;; + --json|-v|--version|-h|--help) + ;; + --) + break + ;; + -*) + ;; + *) + CMUX_SHIM_COMMAND="\$CMUX_SHIM_ARG" + break + ;; + esac + done + if [[ "\$CMUX_SHIM_COMMAND" == "vpn" ]]; then + echo "error: run 'cmux vpn' without sudo; cmux invokes sudo for the tunnel and /etc/hosts." >&2 + exit 1 + fi +fi + ${socket_probe_function} cli_bundle_for_path() { From 94e8467e423a050901c3224afe4a6757af0f4292 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 22:34:32 -0700 Subject: [PATCH 30/40] fix(cloud): mark tunnel ready after routes --- Sources/Cloud/VMTunnelManager+Config.swift | 22 +++++++++++----------- Sources/Cloud/VMTunnelManager.swift | 11 +++++++++-- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager+Config.swift b/Sources/Cloud/VMTunnelManager+Config.swift index b98027309c49..ab8587de596b 100644 --- a/Sources/Cloud/VMTunnelManager+Config.swift +++ b/Sources/Cloud/VMTunnelManager+Config.swift @@ -30,17 +30,6 @@ extension VMTunnelManager { interfaceEnd += 1 } - // Publish the real interface before any potentially slow route hooks; - // the app can then bind liveness to the exact socket even while setup - // is still finishing. - if let runtimeMetadataPath, !runtimeMetadataPath.isEmpty { - let metadataHooks = runtimeMetadataHooks(path: runtimeMetadataPath) - let existing = Set(lines[interfaceStart..= markerTime, + metadataTime >= socketTime, + metadataTime - markerTime <= 60 else { return nil } return value } From f1b5b98f667db12db2e27ecb763758081b5b07a4 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 22:42:49 -0700 Subject: [PATCH 31/40] fix(cloud): bind runtime marker to socket inode --- Sources/Cloud/VMTunnelManager+Config.swift | 11 +++++++---- Sources/Cloud/VMTunnelManager.swift | 16 +++++++++++----- cmuxTests/VMTunnelStalenessTests.swift | 2 ++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager+Config.swift b/Sources/Cloud/VMTunnelManager+Config.swift index ab8587de596b..78019895c35d 100644 --- a/Sources/Cloud/VMTunnelManager+Config.swift +++ b/Sources/Cloud/VMTunnelManager+Config.swift @@ -87,12 +87,15 @@ extension VMTunnelManager { } /// Hook commands run as root inside wg-quick, so they can publish the - /// actual `utunN` while keeping the marker itself non-secret and readable - /// by the unprivileged cmux app. `%i` is substituted by wg-quick with the - /// real interface, not the logical config basename. + /// actual `utunN` and its socket inode while keeping the marker itself + /// non-secret and readable by the unprivileged cmux app. `%i` is + /// substituted by wg-quick with the real interface, not the logical config + /// basename. private static func runtimeMetadataHooks(path: String) -> [String] { let target = shellQuote(path) - let writeScript = "printf \"%s\\n\" \"%i\" > \(target) && chmod 444 \(target)" + // Build the `stat` format string at runtime: a literal `%i` in the + // hook would itself be consumed by wg-quick's `%i` substitution. + let writeScript = "interface=\"%i\"; socket=\"/var/run/wireguard/$interface.sock\"; format=$(printf '%%%s' i); inode=$(/usr/bin/stat -f \"$format\" \"$socket\") || exit 1; printf \"%s %s\\n\" \"$interface\" \"$inode\" > \(target) && chmod 444 \(target)" return [ "PostUp = /bin/sh -c \(shellQuote(writeScript))", "PreDown = /bin/rm -f \(target)", diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index b448d15f4b8d..ce71cba530d3 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -453,8 +453,13 @@ struct VMTunnelManager: Sendable { metadataInfo.st_size > 0, metadataInfo.st_size <= 64, let raw = try? String(contentsOf: metadataURL, encoding: .utf8) else { return nil } - let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard value.range(of: #"^utun[0-9]+$"#, options: .regularExpression) != nil else { return nil } + let fields = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .split(whereSeparator: \.isWhitespace) + guard fields.count == 2, + let interface = fields.first.map(String.init), + interface.range(of: #"^utun[0-9]+$"#, options: .regularExpression) != nil, + let expectedInode = fields.last.flatMap({ UInt64(String($0)) }), + expectedInode > 0 else { return nil } // A stale companion file can survive a killed wg-quick process, and // Darwin may reuse the same utun number later. Require the companion, @@ -466,9 +471,10 @@ struct VMTunnelManager: Sendable { var socketInfo = stat() let socketURL = metadataURL .deletingLastPathComponent() - .appendingPathComponent("\(value).sock", isDirectory: false) + .appendingPathComponent("\(interface).sock", isDirectory: false) guard lstat(socketURL.path, &socketInfo) == 0, - (socketInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { return nil } + (socketInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK), + UInt64(socketInfo.st_ino) == expectedInode else { return nil } let metadataTime = metadataInfo.st_mtimespec.tv_sec let markerTime = markerInfo.st_mtimespec.tv_sec let socketTime = socketInfo.st_mtimespec.tv_sec @@ -479,7 +485,7 @@ struct VMTunnelManager: Sendable { guard metadataTime >= markerTime, metadataTime >= socketTime, metadataTime - markerTime <= 60 else { return nil } - return value + return interface } /// Combines the two unprivileged liveness signals used by ``wgQuickInterfaceUp``. diff --git a/cmuxTests/VMTunnelStalenessTests.swift b/cmuxTests/VMTunnelStalenessTests.swift index bdbab213a732..6a581f083c34 100644 --- a/cmuxTests/VMTunnelStalenessTests.swift +++ b/cmuxTests/VMTunnelStalenessTests.swift @@ -171,6 +171,8 @@ struct VMTunnelStalenessTests { ) #expect(runtimeMarked.contains("PostUp = /bin/sh -c")) #expect(runtimeMarked.contains("cmux-nightly.cmux-runtime")) + #expect(runtimeMarked.contains("stat -f")) + #expect(runtimeMarked.contains("%s %s")) #expect(runtimeMarked.contains("PreDown = /bin/rm -f")) #expect(runtimeMarked.contains("%i")) } From be1bab3f62a9845563eec514d5b13d8c403c6e59 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 22:46:22 -0700 Subject: [PATCH 32/40] fix(cloud): fail closed on stale runtime metadata --- Sources/Cloud/VMTunnelManager.swift | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index ce71cba530d3..d9cc746adfc7 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -429,11 +429,25 @@ struct VMTunnelManager: Sendable { /// through NEVPNStatus instead. func wgQuickInterfaceUp() -> Bool { guard let config = try? String(contentsOf: configURL, encoding: .utf8) else { return false } - let runtimeInterfaceName = Self.readRuntimeInterfaceName( - from: runtimeInterfaceMetadataURL, - markerURL: runtimeNameFileURL - ) - ?? Self.runtimeInterfaceName(for: runtimeNameFileURL) + var metadataProbe = stat() + let metadataResult = lstat(runtimeInterfaceMetadataURL.path, &metadataProbe) + let metadataErrno = errno + let runtimeInterfaceName: String? + if metadataResult == 0 { + // A present-but-invalid companion is evidence of a stale or + // interrupted scoped bring-up. Do not fall back to lossy inference + // and risk borrowing another scope's reused utun number. + runtimeInterfaceName = Self.readRuntimeInterfaceName( + from: runtimeInterfaceMetadataURL, + markerURL: runtimeNameFileURL + ) + } else if metadataErrno == ENOENT { + // Configs written before the companion marker was introduced use + // the stock wg-quick timestamp/size inference instead. + runtimeInterfaceName = Self.runtimeInterfaceName(for: runtimeNameFileURL) + } else { + runtimeInterfaceName = nil + } guard let runtimeInterfaceName else { return false } return Self.interfaceIsUp( runtimeNamePresent: true, From 17b545a9e707fe3cefa2035c5234db6dbc9c4f63 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 3 Sep 2026 22:56:48 -0700 Subject: [PATCH 33/40] fix(cloud): bind runtime marker to socket inode --- Sources/Cloud/VMTunnelManager.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift index d9cc746adfc7..fe297757fedd 100644 --- a/Sources/Cloud/VMTunnelManager.swift +++ b/Sources/Cloud/VMTunnelManager.swift @@ -228,9 +228,9 @@ struct VMTunnelManager: Sendable { var configURL: URL { stateDir.appendingPathComponent("\(interfaceName).conf", isDirectory: false) } /// wg-quick(8) records the created utun's name here. On macOS the file is - /// root-only (0400), so its contents are out of reach, but its EXISTENCE is - /// visible — and that is what tells this tunnel apart from another - /// environment's in `wgQuickInterfaceUp()`. + /// root-only (0400), so its contents are out of reach. Older configs use its + /// existence plus the socket timestamp/size as a fallback identity signal; + /// new configs also write the exact companion marker below. var runtimeNameFileURL: URL { URL(fileURLWithPath: "/var/run/wireguard/\(interfaceName).name", isDirectory: false) } From e9b43418f6dce9b7cb496a22580c74bd9a8d49b1 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 4 Sep 2026 03:37:01 -0700 Subject: [PATCH 34/40] test(cli): keep VPN resolution in the current variant --- .../CMUXCLIErrorOutputRegressionTests.swift | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 6fe7b3910b33..7d5e22b9f079 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -2332,6 +2332,70 @@ import Testing #expect(!resolution.hasLiveSocket) } + @Test func testVPNResolutionIgnoresAnInheritedSocketFromAnotherVariant() throws { + let cliPath = try bundledCLIPath() + let tagSlug = "vpn-variant-" + UUID().uuidString.lowercased() + let home = try makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + let stableSocketPath = try stableSocketURL(home: home).path + let nightlyBundleID = "com.cmuxterm.app.nightly." + tagSlug + let nightlySocketPath = "/tmp/cmux-nightly-" + tagSlug + ".sock" + let stableResponder = try UnixSocketResponder( + path: stableSocketPath, + response: try jsonResponse(result: [ + "config_path": "stable.conf", + "config_present": false, + "interface_name": "cmux", + "interface_up": false, + "stale": false, + ]) + ) + defer { stableResponder.stop() } + let nightlyResponder = try UnixSocketResponder( + path: nightlySocketPath, + response: try jsonResponse(result: [ + "config_path": "nightly.conf", + "config_present": false, + "interface_name": "cmux-nightly", + "interface_up": false, + "stale": false, + ]) + ) + defer { nightlyResponder.stop() } + + let fakeCLIPath = try fakeTaggedBundledCLIPath( + sourceCLIPath: cliPath, + tagSlug: tagSlug, + bundleIdentifier: nightlyBundleID, + bundleName: "cmux NIGHTLY" + ) + // This is the shape of a Nightly shell after a stable app (or a tmux + // server) left its CMUX_SOCKET_PATH behind. The CLI must resolve the + // Nightly listener before it sends a mutating-plane request. + let environment = [ + "PATH": "/usr/bin:/bin", + "HOME": home.path, + "CFFIXED_USER_HOME": home.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + "CMUX_SOCKET_PATH": stableSocketPath, + ] + let result = runProcess( + executablePath: fakeCLIPath, + arguments: ["vpn", "status", "--json"], + environment: environment, + timeout: 10 + ) + + XCTAssertFalse(result.timedOut, result.diagnostics) + XCTAssertEqual(result.status, 0, result.diagnostics) + XCTAssertTrue( + result.stdout.contains("cmux-nightly-"), + result.diagnostics + ) + XCTAssertTrue(nightlyResponder.receivedRequests.count >= 1, result.diagnostics) + XCTAssertTrue(stableResponder.receivedRequests.isEmpty, result.diagnostics) + } + @Test func testExplicitStableSocketEnvironmentIsNeverReroutedByTaggedCLI() throws { let cliPath = try bundledCLIPath() let tagSlug = "cli-explicit-stable-\(UUID().uuidString.lowercased())" From 0b152be490e093fc19ecf2da04f5a96737656853 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 4 Sep 2026 03:40:14 -0700 Subject: [PATCH 35/40] fix(cli): bind VPN dispatch to the current app variant --- CLI/CLISocketPathResolver.swift | 23 ++++++++++++++++++++--- CLI/cmux.swift | 11 ++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/CLI/CLISocketPathResolver.swift b/CLI/CLISocketPathResolver.swift index 4ebc8d1cad44..cc7e1262bd6d 100644 --- a/CLI/CLISocketPathResolver.swift +++ b/CLI/CLISocketPathResolver.swift @@ -227,9 +227,13 @@ struct CLISocketPathResolver { /// Resolves a socket using one ordered, liveness-aware discovery pass. /// - /// Explicit flag and environment paths are deliberately returned verbatim and are - /// never probed or rerouted. Implicit discovery only selects a path after a real - /// non-blocking connect succeeds; a stale socket file is never handed to the client. + /// Explicit flag paths are deliberately returned verbatim and are never probed or + /// rerouted. Ordinary environment paths keep that same compatibility behavior. A + /// side-effecting caller such as `vpn` passes an implicit source with + /// `allowCrossVariantFallback` disabled, which limits discovery to this build's + /// own socket and marker files. Implicit discovery only selects a path after a + /// real non-blocking connect succeeds; a stale socket file is never handed to the + /// client. func resolve( requestedPath: String, source: CLISocketPathSource, @@ -277,6 +281,19 @@ struct CLISocketPathResolver { // implicit discovery. The caller can still opt into the historical // cross-variant fallback for read-only/general CLI commands. guard allowCrossVariantFallback else { + // A stable release may have moved to its user-scoped socket, and a + // tagged build may use a reload-managed/custom path. Keep those + // paths available only when this variant itself published them; + // never accept an arbitrary environment path in the strict mode. + let ownMarkers = readLastSocketPaths( + bundleIdentifier: bundleIdentifier, + environment: environment + ) + candidates.append(contentsOf: ownMarkers) + if Self.pathsMatch(requestedPath, ownDefaultPath) + || ownMarkers.contains(where: { Self.pathsMatch($0, requestedPath) }) { + candidates.append(requestedPath) + } return candidates } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 59f90b20557f..f1ed534c9272 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5272,7 +5272,7 @@ struct CMUXCLI { bundleIdentifier: cliBundleIdentifier, environment: processEnv ) - let socketPathSource: CLISocketPathSource + var socketPathSource: CLISocketPathSource if explicitSocketPath != nil { socketPathSource = .explicitFlag } else if envSocketPath != nil { @@ -5293,6 +5293,15 @@ struct CMUXCLI { environment: processEnv, bundleIdentifier: cliBundleIdentifier ) + // A terminal can outlive the app that created it (and tmux can retain + // that terminal's environment). Treat the inherited socket as a hint + // for VPN, never as authority: privileged tunnel mutations must resolve + // against this CLI's own build variant. Explicit `--socket` remains a + // deliberate escape hatch and stays pinned below. + if command == "vpn", + socketPathSource == .environment { + socketPathSource = .implicitDefault + } let socketResolution = socketResolver.resolve( requestedPath: socketPath, source: socketPathSource, From e46d1a166be41c75bb9b995972225dba335999db Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 4 Sep 2026 05:57:20 -0700 Subject: [PATCH 36/40] test(settings): keep Nightly socket paths isolated --- .../SocketControlSettingsTests.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SocketControl/SocketControlSettingsTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SocketControl/SocketControlSettingsTests.swift index 2f9f7b439292..191d8cd67cba 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SocketControl/SocketControlSettingsTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SocketControl/SocketControlSettingsTests.swift @@ -109,6 +109,30 @@ import CmuxSettings #expect(path == "/tmp/cmux-custom.sock") } + @Test func nightlyBuildIgnoresAnInheritedSocketOverride() { + let untagged = SocketControlSettings.socketPath( + environment: [ + "CMUX_SOCKET_PATH": "/tmp/cmux-debug-from-dev.sock", + ], + bundleIdentifier: "com.cmuxterm.app.nightly", + isDebugBuild: true, + currentUserID: 501, + probeStableDefaultPathEntry: { _ in .missing } + ) + let tagged = SocketControlSettings.socketPath( + environment: [ + "CMUX_SOCKET_PATH": "/tmp/cmux-debug-from-dev.sock", + ], + bundleIdentifier: "com.cmuxterm.app.nightly.vpn-probe", + isDebugBuild: true, + currentUserID: 501, + probeStableDefaultPathEntry: { _ in .missing } + ) + + #expect(untagged == "/tmp/cmux-nightly.sock") + #expect(tagged == "/tmp/cmux-nightly-vpn-probe.sock") + } + @Test func bareDebugXCTestLaunchUsesScopedSocketFallback() { let environment = [ "XCTestConfigurationFilePath": "/tmp/Test-cmux-unit-2026.06.17.xctestconfiguration", From 1cabb56f400bfdc1d14d8b46d61961357f013de4 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 4 Sep 2026 05:57:36 -0700 Subject: [PATCH 37/40] fix(socket): ignore inherited Nightly path overrides --- .../SocketControl/SocketControlSettings.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift index 24b01a70e590..1fb6bc65ae1c 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift @@ -390,6 +390,12 @@ public struct SocketControlSettings { if inheritedBundleIdentifierConflicts(environment: environment, bundleIdentifier: bundleIdentifier) { return false } + // Nightly is a release channel even when a local dogfood build was + // compiled with DEBUG. Do not let a shell/tmux environment inherited + // from another app make its listener bind that app's socket. + if isNightlyBundleIdentifier(bundleIdentifier) { + return false + } if isDebugLikeBundleIdentifier(bundleIdentifier) || isStagingBundleIdentifier(bundleIdentifier) { return true } @@ -426,6 +432,16 @@ public struct SocketControlSettings { return bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") } + /// Returns whether a bundle identifier belongs to the Nightly release channel. + /// + /// - Parameter bundleIdentifier: The app bundle identifier to classify. + /// - Returns: `true` for the untagged Nightly bundle and its scoped variants. + public static func isNightlyBundleIdentifier(_ bundleIdentifier: String?) -> Bool { + guard let bundleIdentifier = normalizedBundleIdentifier(bundleIdentifier) else { return false } + return bundleIdentifier == SocketPathMarkerFiles.nightlyBundleIdentifier + || bundleIdentifier.hasPrefix("\(SocketPathMarkerFiles.nightlyBundleIdentifier).") + } + /// Whether the bundle identifier is a staging build identifier. public static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } From 0e703e932126949547b1f563febe746858136ac8 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 7 Sep 2026 18:43:08 -0700 Subject: [PATCH 38/40] fix(cloud): hide unsupported sidebar surface groups --- Sources/Cloud/CloudTreeNode.swift | 16 +++++++++++++--- Sources/Cloud/CloudTreeOutlineView.swift | 3 ++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 78db0d5e22e2..8c75198fa133 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -589,7 +589,11 @@ enum CloudTreeNodeBuilder { snapshot: snapshot, projectionIndex: projectionIndex, supportsCloudBrowser: supportsCloudBrowser, - supportsPorts: true + // A catalog-only machine has no current VM capability + // snapshot. Fail closed until the fleet list supplies one; + // stale catalog data must not advertise a port action that + // the provider may reject. + supportsPorts: false ) )) } @@ -780,7 +784,13 @@ enum CloudTreeNodeBuilder { return true } let terminals = resources.filter { $0.kind == .terminal } - let displays = CloudMachineSurfacePresentation.displays(resources: resources, info: info) + // `displays` can synthesize a desktop from machine metadata. That is + // useful only when the signed Network Extension can carry the browser + // surface; otherwise synthesis would reintroduce the unsupported row + // after the resource filter above. + let displays = supportsCloudBrowser + ? CloudMachineSurfacePresentation.displays(resources: resources, info: info) + : [] switch info.linkState { case .asleep: @@ -813,7 +823,7 @@ enum CloudTreeNodeBuilder { let right = ($1.id.forwardedPort ?? $1.port ?? 0, $1.id.key) return left.0 != right.0 ? left.0 < right.0 : left.1 < right.1 } - do { + if supportsCloudBrowser && supportsPorts { children.append(CloudTreeNode( id: nodeID(portsGroup: machine), kind: .portsGroup(machine: machine), diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index 2f11ef1a9731..1ac58bfa5ebf 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -63,6 +63,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { container.appearance = WindowAppearanceSnapshot.appKitAppearance(for: colorScheme) context.coordinator.machineActions = machineActions context.coordinator.nodeActions = nodeActions + context.coordinator.supportsCloudBrowser = supportsCloudBrowser context.coordinator.onDragStateChange = onDragStateChange context.coordinator.apply(style: style) context.coordinator.apply(nodes: CloudTreeNodeBuilder.nodes( @@ -81,7 +82,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { var machineActions: MachineRowActions var nodeActions: CloudTreeNodeActions let expansionStore: CloudTreeExpansionStore - let supportsCloudBrowser: Bool + var supportsCloudBrowser: Bool private(set) var style: CloudTreeStyle = CloudTreeStyleStore.current private let tabDragTransferRegistry: @MainActor () -> TabDragTransferRegistry? weak var outlineView: CloudTreeNSOutlineView? From 91c8a0cfc8bd57e9dcf2222d039660f9955082bc Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 7 Sep 2026 18:55:22 -0700 Subject: [PATCH 39/40] fix(cloud): fail closed for catalog-only machine actions --- Sources/Cloud/CloudTreeNode.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 8c75198fa133..e133102da184 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -576,6 +576,7 @@ enum CloudTreeNodeBuilder { provider: "", image: info.image ?? "", isDesktop: info.hasDesktop, + capabilities: VMCapabilities(snapshot: false, restore: false, fork: false, ports: false), activity: MachineSnapshotBuilder.activity(fromStatus: info.status), createdAt: nil, label: info.name == id ? nil : info.name From 4d2a08307f0ff9199a8bf8cf28c9daaf7cfdec30 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 7 Sep 2026 21:56:35 -0700 Subject: [PATCH 40/40] fix(ci): align app-host tests with current targets --- Sources/TerminalController.swift | 2 +- .../CLILocalTmuxReviewRegressionTests.swift | 83 ++--- cmuxTests/SurfaceCatalogTests.swift | 290 +++--------------- 3 files changed, 63 insertions(+), 312 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 18dda3db65c0..ceba2f903822 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8760,7 +8760,7 @@ class TerminalController { switch ctx.webView.replayBrowserKeyboardEvent(event, action: action) { case .delivered: - var payload: [String: Any] = [ + let payload: [String: Any] = [ "workspace_id": ctx.workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ctx.workspaceId), "surface_id": ctx.surfaceId.uuidString, diff --git a/cmuxTests/CLILocalTmuxReviewRegressionTests.swift b/cmuxTests/CLILocalTmuxReviewRegressionTests.swift index df5f460d72da..015bb5f72abb 100644 --- a/cmuxTests/CLILocalTmuxReviewRegressionTests.swift +++ b/cmuxTests/CLILocalTmuxReviewRegressionTests.swift @@ -62,81 +62,52 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertTrue(result.stderr.contains("only supports attach"), result.stderr) } - func testLocalTmuxAttachCommandRunsThroughGhosttyLoginShellWrapper() throws { - let root = makeLocalTmuxTestRoot("ghostty-attach-wrapper") + // The command-builder and parser implementation live in the cmux-cli target. + // These app-host tests exercise the same contracts through the bundled CLI + // process so they do not import executable-only implementation details. + func testLocalTmuxClientListingUsesPopulatedTTYTarget() throws { + let cliPath = try bundledCLIPath() + let root = makeLocalTmuxTestRoot("client-tty-target") let fakeTmuxURL = root.appendingPathComponent("fake-tmux", isDirectory: false) - let outputURL = root.appendingPathComponent("invocation", isDirectory: false) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } let fakeTmux = """ #!/bin/sh - printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' \ - "$TMUX" "$CMUX_LOCAL_TMUX" "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" \ - > "$CMUX_TEST_OUTPUT" + case "$*" in + *list-sessions*) printf 'client-tty\t$999\t99999999-9999-9999-9999-999999999999\t999\t1\n'; exit 0 ;; + *list-clients*) printf '/dev/ttys999\tclient-tty\t123\t/dev/ttys999\n'; exit 0 ;; + *display-message*) printf 'client-tty\t$999\t99999999-9999-9999-9999-999999999999\t999\n'; exit 0 ;; + *has-session*) exit 0 ;; + *) exit 0 ;; + esac """ try Data(fakeTmux.utf8).write(to: fakeTmuxURL) XCTAssertEqual(chmod(fakeTmuxURL.path, 0o755), 0) - let sessionID = try XCTUnwrap(LocalTmuxSessionIdentity("$7")) - let binding = LocalTmuxSessionBinding( - sessionID: sessionID, - serverID: UUID(uuidString: "cccccccc-cccc-cccc-cccc-cccccccccccc")!, - sessionCreated: 42 - ) - let command = LocalTmuxCommandBuilder( - tmuxPath: fakeTmuxURL.path, - socketPath: root.appendingPathComponent("server.sock").path - ).attachCommand(binding: binding) - var environment = ProcessInfo.processInfo.environment - environment["CMUX_TEST_OUTPUT"] = outputURL.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_LOCAL_TMUX_BIN"] = fakeTmuxURL.path + environment["CMUX_LOCAL_TMUX_STATE_DIR"] = root.path + environment.removeValue(forKey: "CMUX_SOCKET") + environment.removeValue(forKey: "CMUX_SOCKET_PATH") + let result = runProcess( - executablePath: "/bin/bash", - arguments: ["--noprofile", "--norc", "-c", "exec -l \(command)"], + executablePath: cliPath, + arguments: ["local-tmux", "list", "--json"], environment: environment, timeout: 10 ) XCTAssertFalse(result.timedOut, result.stderr) XCTAssertEqual(result.status, 0, result.stderr) - let invocation = try String(contentsOf: outputURL, encoding: .utf8) - XCTAssertTrue(invocation.hasPrefix("|1|-S|"), invocation) - XCTAssertTrue(invocation.contains("attach-session -t $7"), invocation) - } - - func testLocalTmuxClientListingUsesPopulatedTTYTarget() throws { - let root = makeLocalTmuxTestRoot("client-tty-target") - let fakeTmuxURL = root.appendingPathComponent("fake-tmux", isDirectory: false) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let fakeTmux = """ - #!/bin/sh - case "$*" in - *list-clients*) - case "$*" in - *'#{client_tty}'*) printf '/dev/ttys999\\twork\\t123\\t/dev/ttys999\\n'; exit 0 ;; - *) printf '\\twork\\t123\\t/dev/ttys999\\n'; exit 0 ;; - esac - ;; - *) exit 0 ;; - esac - """ - try Data(fakeTmux.utf8).write(to: fakeTmuxURL) - XCTAssertEqual(chmod(fakeTmuxURL.path, 0o755), 0) - - let builder = LocalTmuxCommandBuilder( - tmuxPath: fakeTmuxURL.path, - socketPath: root.appendingPathComponent("server.sock").path - ) - let result = try LocalTmuxProcessRunner(executablePath: fakeTmuxURL.path).run( - arguments: builder.listClientsArguments() + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any] ) - - XCTAssertEqual(result.status, 0, result.stderr) - let clients = try LocalTmuxSessionListParser().clients(result.stdout) - XCTAssertEqual(clients.map(\.clientID), ["/dev/ttys999"]) + let sessions = try XCTUnwrap(payload["sessions"] as? [[String: Any]]) + let session = try XCTUnwrap(sessions.first) + XCTAssertEqual(session["session_name"] as? String, "client-tty") + XCTAssertEqual(session["clients"] as? Int, 1) } func testLocalTmuxDirectoryOverrideIsRejectedAsMissingExecutable() throws { diff --git a/cmuxTests/SurfaceCatalogTests.swift b/cmuxTests/SurfaceCatalogTests.swift index c1e0404a9d78..a97e51e883b4 100644 --- a/cmuxTests/SurfaceCatalogTests.swift +++ b/cmuxTests/SurfaceCatalogTests.swift @@ -1176,268 +1176,48 @@ struct SurfaceCatalogTests { #expect(provider.closedRemoteWorkspaces == ["ws_empty"]) } - @Test func `Cloud workspace rename updates every projection and rejects stale snapshots`() throws { + // Cloud workspace rename ownership moved from SurfaceCatalog mutation helpers + // to CloudWorkspaceRenameService in the current main refactor. Keep the + // regression coverage on the current public APIs: accepted daemon state is + // canonical, stale machine metadata cannot regress it, and rename intents + // retain newest-wins ordering through CloudRenameCoordinator (covered above). + @Test("Cloud state reconciliation keeps canonical workspace metadata") + func cloudStateReconciliationKeepsCanonicalWorkspaceMetadata() throws { let machine = SurfaceMachineID.cloud("vivid-newt") let catalog = SurfaceCatalog() let provider = FakeProvider(machine: machine) catalog.register(provider) - let original = SurfaceRemoteWorkspace(id: "ws_main", name: "main", index: 0, focused: true) - let other = SurfaceRemoteWorkspace(id: "ws_other", name: "other", index: 1, focused: false) - var terminal = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term_main"), - title: "shell", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: original, - port: nil, - url: nil - ) - terminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: original)] - var unrelated = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term_other"), - title: "other", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: other, - port: nil, - url: nil - ) - unrelated.remoteViews = [SurfaceRemoteView(tabID: "tab_other", workspace: other)] - let initialInfo = SurfaceMachineInfo( - id: machine, - name: "vivid-newt", - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - diskUsedMb: nil, - remoteWorkspaces: [original, other] - ) - let generation = "daemon-generation" - #expect(catalog.replaceCloudResources( - [terminal, unrelated], - on: machine, - info: initialInfo, - cursor: CloudVMCursor(generation: generation, revision: 10) - )) - - let token = try catalog.beginCloudWorkspaceRename( - machine: machine, - workspaceID: original.id, - name: "Renamed" - ) - let optimistic = catalog.snapshot - #expect(optimistic.machines.first?.remoteWorkspaces?.first { $0.id == original.id }?.name == "Renamed") - #expect(optimistic.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - #expect(optimistic.resources.first { $0.id == terminal.id }?.remoteViews?.first?.workspace.name == "Renamed") - #expect(optimistic.resources.first { $0.id == unrelated.id }?.remoteWorkspace?.name == "other") - - let staleInfo = initialInfo - var staleTerminal = terminal - staleTerminal.remoteWorkspace = original - staleTerminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: original)] - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 9) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - - catalog.commitCloudWorkspaceRename( - token, - receipt: CloudVMCursor(generation: generation, revision: 11) - ) - var confirmedInfo = initialInfo - let confirmed = SurfaceRemoteWorkspace(id: original.id, name: "Renamed", index: 0, focused: true) - confirmedInfo.remoteWorkspaces = [confirmed, other] - var confirmedTerminal = terminal - confirmedTerminal.remoteWorkspace = confirmed - confirmedTerminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: confirmed)] - #expect(catalog.replaceCloudResources( - [confirmedTerminal, unrelated], - on: machine, - info: confirmedInfo, - cursor: CloudVMCursor(generation: generation, revision: 11) - )) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: original.id) == nil) - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 10) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - // An equal-cursor payload must also be identical; a delayed response - // with the old name cannot overwrite the confirmed rename. - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 11) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - } - - @Test func `A cursorless machine update cannot restore a confirmed cloud workspace name`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - let provider = FakeProvider(machine: machine) - catalog.register(provider) - let before = SurfaceRemoteWorkspace(id: "ws_main", name: "before", index: 0, focused: true) - let after = SurfaceRemoteWorkspace(id: before.id, name: "after", index: 0, focused: true) - var beforeResource = terminal(machine, "term_main") - beforeResource.remoteWorkspace = before - var afterResource = beforeResource - afterResource.remoteWorkspace = after - let beforeInfo = SurfaceMachineInfo( - id: machine, - name: machine.rawValue, - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - diskUsedMb: nil, - remoteWorkspaces: [before] - ) - var afterInfo = beforeInfo - afterInfo.remoteWorkspaces = [after] - #expect(catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - #expect(catalog.replaceCloudResources( - [afterResource], - on: machine, - info: afterInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - - // A provider summary/status write has no cursor and may still carry its - // pre-rename cached workspace value. It must not overwrite the accepted graph. - catalog.updateMachine(beforeInfo, from: provider) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "after") - } + let snapshot: [String: Any] = [ + "cursor": ["generation": "rename-generation", "revision": "10"], + "workspaces": [["id": "ws_main", "name": "Renamed"]], + "screens": [], + "panes": [], + "tabs": [], + "terminals": [["id": "term_main", "tab_ids": [], "title": "shell", "lifecycle": "running"]], + "browsers": [], + "agents": [], + ] + let state = try #require(CmuxTuiSnapshotParser.state(fromSnapshot: snapshot, machine: machine)) + let resource = try #require(CmuxTuiSnapshotParser.resources(from: state).first) + var canonicalInfo = provider.info + canonicalInfo.remoteWorkspaces = [ + SurfaceRemoteWorkspace(id: "ws_main", name: "Renamed", index: 0, focused: true), + ] + catalog.replaceCloudState(state, resources: [resource], info: canonicalInfo) - @Test func `A stale equal-cursor snapshot after a rename receipt cannot poison reconciliation`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - catalog.register(FakeProvider(machine: machine)) - let before = SurfaceRemoteWorkspace(id: "ws_main", name: "before", index: 0, focused: true) - let after = SurfaceRemoteWorkspace(id: before.id, name: "after", index: 0, focused: true) - var beforeResource = terminal(machine, "term_main") - beforeResource.remoteWorkspace = before - var afterResource = beforeResource - afterResource.remoteWorkspace = after - let beforeInfo = SurfaceMachineInfo( - id: machine, - name: machine.rawValue, - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - remoteWorkspaces: [before] - ) - var afterInfo = beforeInfo - afterInfo.remoteWorkspaces = [after] - #expect(catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - let token = try catalog.beginCloudWorkspaceRename( - machine: machine, - workspaceID: before.id, - name: "after" - ) - catalog.commitCloudWorkspaceRename( - token, - receipt: CloudVMCursor(generation: "g", revision: 2) - ) + var staleInfo = canonicalInfo + staleInfo.remoteWorkspaces = [ + SurfaceRemoteWorkspace(id: "ws_main", name: "old-name", index: 0, focused: false), + SurfaceRemoteWorkspace(id: "removed", name: "removed", index: 1, focused: false), + ] + catalog.updateMachine(staleInfo, from: provider) - // The first read at the receipt cursor is stale, but the optimistic overlay - // keeps the UI correct. A later canonical read at that same cursor must still - // be accepted and retire the intent. - #expect(!catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - #expect(catalog.replaceCloudResources( - [afterResource], - on: machine, - info: afterInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: before.id) == nil) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "after") + #expect(catalog.cloudStates[machine] == state) + #expect(catalog.snapshot.machines.first?.remoteWorkspaces == [ + SurfaceRemoteWorkspace(id: "ws_main", name: "Renamed", index: 0, focused: true), + ]) + #expect(catalog.snapshot.resources(on: machine).contains { $0.id.key == "term_main" }) } - @Test func `An older cloud rename completion cannot roll back a newer intent`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - catalog.register(FakeProvider(machine: machine)) - let workspace = SurfaceRemoteWorkspace(id: "ws_main", name: "main", index: 0, focused: true) - let info = SurfaceMachineInfo( - id: machine, - name: "vivid-newt", - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - diskUsedMb: nil, - remoteWorkspaces: [workspace] - ) - let resource = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term"), - title: "shell", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: workspace, - port: nil, - url: nil - ) - #expect(catalog.replaceCloudResources( - [resource], - on: machine, - info: info, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - let first = try catalog.beginCloudWorkspaceRename(machine: machine, workspaceID: workspace.id, name: "first") - let second = try catalog.beginCloudWorkspaceRename(machine: machine, workspaceID: workspace.id, name: "second") - catalog.rollbackCloudWorkspaceRename(first) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: workspace.id) == "second") - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "second") - catalog.rollbackCloudWorkspaceRename(second) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: workspace.id) == nil) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "main") - } }