diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b265744c988..505179503924 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3377ea728610..9c7af4beb9f9 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -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" diff --git a/CLI/CLISocketPathResolver.swift b/CLI/CLISocketPathResolver.swift index 643376557fdc..27b1e716c876 100644 --- a/CLI/CLISocketPathResolver.swift +++ b/CLI/CLISocketPathResolver.swift @@ -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( @@ -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) } @@ -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() @@ -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) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 636e7a756849..72fe4e466ad9 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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) @@ -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 { @@ -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, @@ -5460,7 +5478,8 @@ struct CMUXCLI { resolvePath: { socketResolver.resolve( requestedPath: socketPath, - source: socketPathSource + source: socketPathSource, + allowCrossVariantFallback: command != "vpn" ).selectedPath ?? socketPath }, timeout: Self.restoreSocketStartupTimeoutSeconds 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 } 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", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index df8874fcebc8..c2b9f7a13412 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -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": { @@ -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": { @@ -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": { @@ -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": { diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 66bce3745791..299e64049804 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -544,7 +544,8 @@ enum CloudTreeNodeBuilder { snapshot: SurfaceCatalogSnapshot, localWorkspaces: [CloudTreeLocalWorkspace], unreadTerminalIDs: [String: Set] = [:], - includeLocalMachine: Bool = CloudTreeNodeBuilder.includesLocalMachine + includeLocalMachine: Bool = CloudTreeNodeBuilder.includesLocalMachine, + supportsCloudBrowser: Bool = true ) -> [CloudTreeNode] { let projectionIndex = LocalProjectionIndex(snapshot: snapshot, unreadTerminalIDs: unreadTerminalIDs) var nodes: [CloudTreeNode] = [] @@ -576,7 +577,9 @@ enum CloudTreeNodeBuilder { machine: .cloud(machine.id), info: info, snapshot: snapshot, - projectionIndex: projectionIndex + projectionIndex: projectionIndex, + supportsCloudBrowser: supportsCloudBrowser, + supportsPorts: machine.capabilities.ports ) )) } @@ -589,6 +592,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 @@ -600,7 +604,13 @@ enum CloudTreeNodeBuilder { machine: info.id, info: info, snapshot: snapshot, - projectionIndex: projectionIndex + projectionIndex: projectionIndex, + supportsCloudBrowser: supportsCloudBrowser, + // 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 ) )) } @@ -773,16 +783,32 @@ enum CloudTreeNodeBuilder { machine: SurfaceMachineID, info: SurfaceMachineInfo?, snapshot: SurfaceCatalogSnapshot, - projectionIndex: LocalProjectionIndex + projectionIndex: LocalProjectionIndex, + supportsCloudBrowser: Bool, + supportsPorts: Bool ) -> [CloudTreeNode] { // The catalog has not registered this machine yet: nothing to expand. guard let info else { return [placeholder(machine, text: String(localized: "cloudTree.placeholder.connecting", defaultValue: "Connecting…"), style: .connecting)] } var children: [CloudTreeNode] = [] - let resources = snapshot.resources(on: machine) + let resources = snapshot.resources(on: machine).filter { resource in + if resource.id.isForwardedPort { + return supportsCloudBrowser && supportsPorts + } + if resource.kind == .browser || resource.kind == .display { + return supportsCloudBrowser + } + 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: @@ -815,7 +841,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 f85a255fc001..b9f1a0b64a3a 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -20,6 +20,9 @@ struct CloudTreeOutlineView: NSViewRepresentable { let machineActions: MachineRowActions let nodeActions: CloudTreeNodeActions let expansionStore: CloudTreeExpansionStore + /// Whether this build can open browser-backed Cloud surfaces (including + /// VNC displays and forwarded-port previews). + var supportsCloudBrowser: Bool = true var organizationStore: CloudSidebarOrganizationStore? = nil var organizationState = CloudSidebarOrganizationState() /// The visual preset the rows render in (the debug gallery pins one per @@ -43,7 +46,9 @@ struct CloudTreeOutlineView: NSViewRepresentable { Coordinator( machineActions: machineActions, nodeActions: nodeActions, - expansionStore: expansionStore, organization: organizationStore, + expansionStore: expansionStore, + supportsCloudBrowser: supportsCloudBrowser, + organization: organizationStore, tabDragTransferRegistry: { [tabDragTransferRegistry] in tabDragTransferRegistry ?? AppDelegate.shared?.tabDragTransferRegistry } @@ -58,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( @@ -65,7 +71,8 @@ struct CloudTreeOutlineView: NSViewRepresentable { pendingCreates: pendingCreates, snapshot: snapshot, localWorkspaces: localWorkspaces, - unreadTerminalIDs: unreadTerminalIDs + unreadTerminalIDs: unreadTerminalIDs, + supportsCloudBrowser: supportsCloudBrowser )) } // MARK: - Coordinator @@ -74,6 +81,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { var machineActions: MachineRowActions var nodeActions: CloudTreeNodeActions let expansionStore: CloudTreeExpansionStore + var supportsCloudBrowser: Bool private(set) var style: CloudTreeStyle = CloudTreeStyleStore.current private let tabDragTransferRegistry: @MainActor () -> TabDragTransferRegistry? weak var outlineView: CloudTreeNSOutlineView? @@ -105,12 +113,14 @@ struct CloudTreeOutlineView: NSViewRepresentable { machineActions: MachineRowActions, nodeActions: CloudTreeNodeActions, expansionStore: CloudTreeExpansionStore, + supportsCloudBrowser: Bool = true, organization: CloudSidebarOrganizationStore? = nil, tabDragTransferRegistry: @escaping @MainActor () -> TabDragTransferRegistry? ) { self.machineActions = machineActions self.nodeActions = nodeActions self.expansionStore = expansionStore + self.supportsCloudBrowser = supportsCloudBrowser self.organization = organization ?? CloudSidebarOrganizationStore() self.tabDragTransferRegistry = tabDragTransferRegistry } @@ -786,7 +796,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { } items.append(item(String(localized: "machines.menu.openShell", defaultValue: "Open Shell")) { nodeActions.newTerminal(.cloud(id), nil) }) items.append(item(String(localized: "cloudTree.menu.newWorkspace", defaultValue: "New Workspace")) { nodeActions.newWorkspace(.cloud(id)) }) - if machine.isDesktop { + if machine.isDesktop && supportsCloudBrowser { items.append(item(String(localized: "machines.menu.openDesktop", defaultValue: "Open Desktop")) { nodeActions.project(SurfaceResourceID(machine: .cloud(id), kind: .display, key: SurfaceResourceID.desktopDisplayKey), .split, true) }) diff --git a/Sources/Cloud/CloudTreeStyleGalleryWindow.swift b/Sources/Cloud/CloudTreeStyleGalleryWindow.swift index 8cc91673f5ba..a95f7e0afaff 100644 --- a/Sources/Cloud/CloudTreeStyleGalleryWindow.swift +++ b/Sources/Cloud/CloudTreeStyleGalleryWindow.swift @@ -117,6 +117,7 @@ private struct CloudTreeStyleGalleryColumn: View { refresh: { Task { await CmuxTuiSurfaceProviderRegistry.shared.refresh(force: true) } } ), expansionStore: expansionStore, + supportsCloudBrowser: true, style: style ) } diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index 6f25026e9b6a..2a940a45606f 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -115,7 +115,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) @@ -170,6 +170,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 load failed \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 @@ -290,6 +318,36 @@ struct MachinesPanelView: View { .padding(.top, 2) } + /// 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 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. cmux couldn\u{2019}t load them just now. Retry in a moment." + )) + .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 @@ -459,7 +517,10 @@ struct MachinesPanelView: View { unreadTerminalIDs: viewModel.unreadTerminalIDs, machineActions: machineActions, nodeActions: nodeActions, - expansionStore: expansionStore, organizationStore: SurfaceCatalog.shared.sidebarOrganization, organizationState: SurfaceCatalog.shared.sidebarOrganization.state, + expansionStore: expansionStore, + supportsCloudBrowser: CloudTunnelBackendSelector.live().select().isNetworkExtension, + organizationStore: SurfaceCatalog.shared.sidebarOrganization, + organizationState: SurfaceCatalog.shared.sidebarOrganization.state, style: CloudTreeStyle.preset(id: cloudTreeStyleID) ?? .defaultStyle, onDragStateChange: { [weak viewModel] dragging in viewModel?.setTreeDragging(dragging) } ) @@ -475,11 +536,15 @@ 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 non-transport load error. + switch viewModel.listProblem ?? .serverError { case .sessionRejected: sessionRejectedState case .requiresPro: requiresProState + case .serverError: + serverErrorState case .unreachable: unreachableState } diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index 102d2f2c276b..ab7faadd07ef 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -201,24 +201,57 @@ final class MachinesPanelViewModel: ObservableObject { case sessionRejected /// HTTP 402: the plan gates Cloud access. case requiresPro - /// Everything else — retrying may help. + /// 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. 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, .lifecycleUnsupported, + case .httpStatus, .malformedResponse, .lifecycleUnsupported, .disabledByManagedPolicy, .cloudMachinesDisabled: - // A managed policy can race a refresh; keep the generic unreachable state. + // A response, client capability, or policy failure does not prove + // that the Cloud transport is unreachable. + 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 } } + + /// 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 or of a response from the Cloud backend. + 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. @@ -698,9 +731,20 @@ 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) - listProblem = .unreachable + listProblem = Self.classifyListFailure(error) } isLoading = false hasLoadedOnce = true 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/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index c61cf8808caf..db2a88ec621c 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -2400,6 +2400,96 @@ 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 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())" diff --git a/cmuxTests/CloudPortOpenRegressionTests.swift b/cmuxTests/CloudPortOpenRegressionTests.swift index ac379087f28a..1ac377868eae 100644 --- a/cmuxTests/CloudPortOpenRegressionTests.swift +++ b/cmuxTests/CloudPortOpenRegressionTests.swift @@ -158,7 +158,7 @@ struct CloudPortOpenRegressionTests { "resource:port-vm/browser/port:3000", "resource:port-vm/browser/port:8000", ]) - #expect(byID["machine:port-vm/ws/ws_app/resource:port-vm/browser/port:8000"] != nil) + #expect(byID["machine:port-vm/ws/ws_app/resource:port-vm/browser/port:8000/tab:tab_port_8000"] != nil) #expect(portsGroup.children.last?.dragResource?.id == port.id) #expect(SurfaceResourceID(machine: machine, kind: .browser, key: "port:08000").forwardedPort == nil) @@ -237,7 +237,7 @@ struct CloudPortOpenRegressionTests { )) let portsGroup = try #require(tree.first { $0.id == "machine:port-vm/ports" }) #expect(portsGroup.children.compactMap { $0.dragResource?.id } == [port.id]) - let workspacePort = try #require(tree.first { $0.id == "machine:port-vm/ws/ws_app/resource:port-vm/browser/port:8000" }) + let workspacePort = try #require(tree.first { $0.id == "machine:port-vm/ws/ws_app/resource:port-vm/browser/port:8000/tab:tab_port" }) guard case .port = workspacePort.kind else { Issue.record("the workspace pointer should retain the port row kind") return diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index c9537486ca51..ad0b8936e96a 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -409,6 +409,64 @@ final class MachinesPanelModelTests: XCTestCase { ) } + func testCloudTreeOmitsUnsupportedBrowserBackedSurfaces() { + let machineID = SurfaceMachineID.cloud("limited-vm") + let terminal = terminal(machineID, "term_1") + let display = SurfaceResource( + id: SurfaceResourceID(machine: machineID, kind: .display, key: "display:1"), + title: "Desktop", detail: nil, lifecycle: .running, agent: nil, + remoteWorkspace: nil, port: 6901, url: nil + ) + let port = SurfaceResource( + id: SurfaceResourceID(machine: machineID, kind: .browser, key: "port:8000"), + title: ":8000", detail: "http", lifecycle: .running, agent: nil, + remoteWorkspace: nil, port: 8000, url: nil + ) + let snapshot = SurfaceCatalogSnapshot( + machines: [machineInfo(machineID)], + resources: [terminal, display, port], + projections: [] + ) + + let nodes = CloudTreeNodeBuilder.flattened(CloudTreeNodeBuilder.nodes( + machines: [machineSnapshot(id: machineID.cloudMachineID!)], + snapshot: snapshot, + localWorkspaces: [], + supportsCloudBrowser: false + )) + + XCTAssertTrue(nodes.contains { if case .terminal = $0.kind { return true }; return false }) + XCTAssertFalse(nodes.contains { + switch $0.kind { + case .browser, .display, .port, .browsersGroup, .displaysPool, .portsGroup: return true + default: return false + } + }) + } + + func testCloudTreeOmitsProviderUnsupportedPortPreviews() { + let machineID = SurfaceMachineID.cloud("no-port-preview-vm") + let port = SurfaceResource( + id: SurfaceResourceID(machine: machineID, kind: .browser, key: "port:8000"), + title: ":8000", detail: "http", lifecycle: .running, agent: nil, + remoteWorkspace: nil, port: 8000, url: nil + ) + let snapshot = SurfaceCatalogSnapshot( + machines: [machineInfo(machineID)], + resources: [port], + projections: [] + ) + var machine = machineSnapshot(id: machineID.cloudMachineID!) + machine.capabilities = VMCapabilities(snapshot: true, restore: true, fork: true, ports: false) + + let nodes = CloudTreeNodeBuilder.flattened(CloudTreeNodeBuilder.nodes( + machines: [machine], snapshot: snapshot, localWorkspaces: [], supportsCloudBrowser: true + )) + + XCTAssertFalse(nodes.contains { if case .port = $0.kind { return true }; return false }) + XCTAssertFalse(nodes.contains { if case .portsGroup = $0.kind { return true }; return false }) + } + func testCloudTreeWorkspacesLeadThenPools() { let ws0 = SurfaceRemoteWorkspace(id: "ws_main", name: "main", index: 0, focused: true) let ws1 = SurfaceRemoteWorkspace(id: "ws_side", name: "side", index: 1, focused: false) @@ -1205,22 +1263,58 @@ 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 + MachinesPanelViewModel.classifyListFailure(.malformedResponse("bad")) == .serverError ) + for error: VMClientError in [ + .lifecycleUnsupported(action: "stop"), .disabledByManagedPolicy, .cloudMachinesDisabled, + ] { + #expect(MachinesPanelViewModel.classifyListFailure(error) == .serverError) + } + } + + @Test("Raw transport failures stay in the unreachable state") + func rawTransportFailuresStayUnreachable() { + for code in [ + URLError.Code.secureConnectionFailed, + .appTransportSecurityRequiresSecureConnection, + .serverCertificateUntrusted, + .dnsLookupFailed, + .timedOut, + ] { + #expect( + MachinesPanelViewModel.classifyListFailure(URLError(code)) == .unreachable, + "\(code) is a transport failure without a usable Cloud response" + ) + } #expect( - MachinesPanelViewModel.classifyListFailure(.malformedResponse("bad")) == .unreachable + 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 diff --git a/cmuxTests/SurfaceCatalogTests.swift b/cmuxTests/SurfaceCatalogTests.swift index 984992a3bdfb..d96487b51082 100644 --- a/cmuxTests/SurfaceCatalogTests.swift +++ b/cmuxTests/SurfaceCatalogTests.swift @@ -1306,3 +1306,50 @@ extension SurfaceCatalogTests { #expect(opened.projections.count == 1) } } + +extension SurfaceCatalogTests { + // 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 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) + + 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) + + #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" }) + } + +} 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..1f2475652a1e --- /dev/null +++ b/scripts/ci/set-nightly-auth-callback-scheme.py @@ -0,0 +1,135 @@ +#!/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 + + 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 [] + schemes = entry.get("CFBundleURLSchemes") + 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: + 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") + + 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) + 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/scripts/reload.sh b/scripts/reload.sh index 546fbb84784c..65dfb48a94d7 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -608,6 +608,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() { diff --git a/skills/cmux-cloud-vm/references/commands.md b/skills/cmux-cloud-vm/references/commands.md index 5afd5437a86e..500e457d1cfd 100644 --- a/skills/cmux-cloud-vm/references/commands.md +++ b/skills/cmux-cloud-vm/references/commands.md @@ -22,8 +22,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, capabilities: {ports, …}}], limits: {maxActiveVms, planId, memoryOptionsMb, 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 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 self # INSIDE a machine: this machine's name, id, status, team (--json: {schema, machine, team, machines}); guest `cmux vm self` lists the team's machines with this one marked * 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 diff --git a/tests/test_nightly_auth_callback_scheme.py b/tests/test_nightly_auth_callback_scheme.py new file mode 100644 index 000000000000..8d0c77caebd8 --- /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": ["legacy-auth", "cmux"]}, + ], + ) + + result = self._run(plist) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + self._read(plist)["CFBundleURLTypes"][1]["CFBundleURLSchemes"], + ["legacy-auth", "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()