Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
a828005
test(cloud): machine-list 500 must not be labeled unreachable (#11597)
austinywang Sep 2, 2026
05a97af
fix(cloud): classify a machine-list server error truthfully, not as u…
austinywang Sep 2, 2026
84d4fbe
ci: resolve nightly auth callback plist entry semantically
austinywang Sep 2, 2026
082dc98
fix(cloud): keep untyped list failures out of unreachable state
austinywang Sep 2, 2026
6114642
fix(ci): replace the matched nightly callback scheme in place
austinywang Sep 2, 2026
94232fc
fix(cloud): keep stale list banners truthful
austinywang Sep 2, 2026
170336c
fix(cloud): classify raw transport failures as unreachable
austinywang Sep 2, 2026
b0be3ba
fix(cloud): keep fallback load errors neutral
austinywang Sep 2, 2026
9d89a64
fix(cloud): narrow transport error classification
austinywang Sep 2, 2026
78ff3e3
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Sep 3, 2026
2179d6a
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Sep 3, 2026
4b5559c
test(cloud): require build-scoped VM tunnel identities
austinywang Sep 3, 2026
db04393
fix(cloud): isolate VM tunnels per app build
austinywang Sep 3, 2026
063149a
fix: resolve workspace-group merge build errors
austinywang Sep 3, 2026
a790592
fix: resolve workspace-group snapshot argument order
austinywang Sep 3, 2026
dfa396c
test(cloud): require interface-scoped tunnel routes
austinywang Sep 3, 2026
6312102
fix(cloud): allow same-network tunnel routes to coexist
austinywang Sep 3, 2026
fc310eb
fix(cloud): clean up scoped routes before tunnel teardown
austinywang Sep 4, 2026
bb4911b
docs(cloud): describe scoped tunnel route setup
austinywang Sep 4, 2026
42129c9
fix(cloud): verify existing scoped routes before accepting them
austinywang Sep 4, 2026
d1e57bc
fix(cli): explain that vpn commands must run as the user
austinywang Sep 4, 2026
93ab83c
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Sep 4, 2026
a28b563
test(cloud): make tunnel liveness regression deterministic
austinywang Sep 4, 2026
184f006
fix(cloud): keep tunnel liveness and route fallback testable
austinywang Sep 4, 2026
a023d8f
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Sep 4, 2026
9e38c91
fix(cloud): bind tunnel liveness to its utun
austinywang Sep 4, 2026
f739c84
fix(cli): keep VPN commands on their own app socket
austinywang Sep 4, 2026
fdf1d31
fix(cloud): reject ambiguous tunnel marker matches
austinywang Sep 4, 2026
0cc1f90
docs(cloud): clarify user-scoped VPN invocation
austinywang Sep 4, 2026
7958321
fix(cloud): persist exact WireGuard runtime interface
austinywang Sep 4, 2026
8010990
fix(cloud): reject stale runtime markers
austinywang Sep 4, 2026
b794ebb
fix(cloud): publish tunnel identity before routes
austinywang Sep 4, 2026
bb1dede
fix(cli): keep sudo out of VPN shim dispatch
austinywang Sep 4, 2026
94e8467
fix(cloud): mark tunnel ready after routes
austinywang Sep 4, 2026
f1b5b98
fix(cloud): bind runtime marker to socket inode
austinywang Sep 4, 2026
be1bab3
fix(cloud): fail closed on stale runtime metadata
austinywang Sep 4, 2026
17b545a
fix(cloud): bind runtime marker to socket inode
austinywang Sep 4, 2026
6b813b1
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Sep 4, 2026
c1dce46
Merge origin/main into issue-11597-nightly-cloud-unreachable
austinywang Sep 4, 2026
3e9b984
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Sep 4, 2026
e9b4341
test(cli): keep VPN resolution in the current variant
austinywang Sep 4, 2026
0b152be
fix(cli): bind VPN dispatch to the current app variant
austinywang Sep 4, 2026
5d6ad77
Merge branch 'main' of https://github.com/manaflow-ai/cmux into vpn-t…
austinywang Sep 4, 2026
e46d1a1
test(settings): keep Nightly socket paths isolated
austinywang Sep 4, 2026
1cabb56
fix(socket): ignore inherited Nightly path overrides
austinywang Sep 4, 2026
5449e17
Merge remote-tracking branch 'origin/main' into vpn-tunnel-build-isol…
austinywang Sep 4, 2026
b33866a
Merge main and hide unsupported Cloud sidebar actions
austinywang Sep 6, 2026
2bd55a9
Merge remote-tracking branch 'origin/main' into vpn-tunnel-build-isol…
austinywang Sep 8, 2026
0e703e9
fix(cloud): hide unsupported sidebar surface groups
austinywang Sep 8, 2026
91c8a0c
fix(cloud): fail closed for catalog-only machine actions
austinywang Sep 8, 2026
4d2a083
fix(ci): align app-host tests with current targets
austinywang Sep 8, 2026
f4ca854
Merge remote-tracking branch 'origin/main' into vpn-tunnel-build-isol…
austinywang Sep 8, 2026
0a735a7
Merge main and preserve Cloud list error classification
lawrencecchen Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,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 cmux-tui client commit resolution
run: ./tests/test_ci_resolve_cmux_tui_client_commit.sh

Expand Down
14 changes: 7 additions & 7 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -934,13 +934,13 @@ jobs:
/usr/libexec/PlistBuddy -c "Set :CFBundleName ${CHANNEL_APP_NAME}" "$app_plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName ${CHANNEL_APP_NAME}" "$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 ${CHANNEL_URL_SCHEME}" "$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" \
"${CHANNEL_URL_SCHEME}" \
--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"
Expand Down
45 changes: 39 additions & 6 deletions CLI/CLISocketPathResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,17 @@ 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
source: CLISocketPathSource,
allowCrossVariantFallback: Bool = true
) -> CLISocketPathResolution {
guard source == .implicitDefault else {
return CLISocketPathResolution(
Expand All @@ -242,7 +247,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)
}
Expand All @@ -254,7 +262,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()
Expand All @@ -263,6 +274,28 @@ 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 {
// 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
}

// 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)
Expand Down
25 changes: 22 additions & 3 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5140,6 +5140,16 @@ struct CMUXCLI {
return
}

// `cmux vpn` talks to the selected app to mutate tunnel state. Running
// the CLI itself through sudo changes HOME/UID and can select a
// different socket, so fail with the actionable user-scoped command.
if command == "vpn", geteuid() == 0 {
throw CLIError(message: String(
localized: "cli.vpn.runAsUser",
defaultValue: "Run `cmux vpn` without `sudo`; cmux manages the required system approval itself."
))
}

// If the argument is a path (not a known command), open a workspace there.
if shouldOpenAsPathArgument(command), explicitSocketPath == nil {
try openPath(command)
Expand All @@ -5153,7 +5163,7 @@ struct CMUXCLI {
bundleIdentifier: cliBundleIdentifier,
environment: processEnv
)
let socketPathSource: CLISocketPathSource
var socketPathSource: CLISocketPathSource
if explicitSocketPath != nil {
socketPathSource = .explicitFlag
} else if envSocketPath != nil {
Expand All @@ -5174,9 +5184,17 @@ struct CMUXCLI {
environment: processEnv,
bundleIdentifier: cliBundleIdentifier
)
// A terminal or tmux server can retain another build's socket path.
// VPN commands must resolve only this build's listener; explicit
// --socket remains an intentional escape hatch.
if command == "vpn",
socketPathSource == .environment {
socketPathSource = .implicitDefault
}
let socketResolution = socketResolver.resolve(
requestedPath: socketPath,
source: socketPathSource
source: socketPathSource,
allowCrossVariantFallback: command != "vpn"
)
if !socketResolution.hasLiveSocket,
socketPathSource == .implicitDefault,
Expand Down Expand Up @@ -5460,7 +5478,8 @@ struct CMUXCLI {
resolvePath: {
socketResolver.resolve(
requestedPath: socketPath,
source: socketPathSource
source: socketPathSource,
allowCrossVariantFallback: command != "vpn"
).selectedPath ?? socketPath
},
timeout: Self.restoreSocketStartupTimeoutSeconds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
119 changes: 119 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -115367,6 +115367,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": {
Expand Down Expand Up @@ -236847,6 +236864,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": {
Expand Down Expand Up @@ -237083,6 +237117,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": {
Expand Down Expand Up @@ -237142,6 +237193,74 @@
}
}
},
"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. cmux couldn’t load them just now. Retry in a moment."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "マシンはそのまま残っています。cmux は今すぐ読み込めませんでした。しばらくしてから再試行してください。"
}
}
}
},
"machines.serverError.stale": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Cloud load failed — showing last known"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Cloud の読み込みに失敗 — 最後に確認した状態を表示中"
}
}
}
},
"machines.serverError.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Cloud couldn’t load machines"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Cloud のマシンを読み込めません"
}
}
}
},
"machines.unavailable.retry": {
"extractionState": "manual",
"localizations": {
Expand Down
Loading
Loading