From 18550e5d1fe4fb53cba72cb0a3e2b107e3a81efc Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 00:55:14 -0800 Subject: [PATCH 1/3] Add expanded socket access modes with password auth Implements https://github.com/manaflow-ai/cmux/issues/296 with new modes: off, cmuxOnly, automation, password, and allowAll. Adds keychain-backed password storage, connection-level auth gates (v1 auth + v2 auth.login), settings UX with warning confirmation, CLI --password support, and regression tests. --- CLI/cmux.swift | 63 ++++++++- Sources/AppDelegate.swift | 2 +- Sources/SocketControlSettings.swift | 210 +++++++++++++++++++++++----- Sources/TerminalController.swift | 128 ++++++++++++++++- Sources/cmuxApp.swift | 126 ++++++++++++++++- cmuxTests/GhosttyConfigTests.swift | 35 +++++ 6 files changed, 521 insertions(+), 43 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d2ea40e7..791ae8f6 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,6 @@ import Foundation import Darwin +import Security struct CLIError: Error, CustomStringConvertible { let message: String @@ -235,6 +236,46 @@ enum CLIIDFormat: String { } } +private enum SocketPasswordResolver { + private static let service = "com.cmuxterm.app.socket-control" + private static let account = "local-socket-password" + + static func resolve(explicit: String?) -> String? { + if let explicit = normalized(explicit), !explicit.isEmpty { + return explicit + } + if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]), !env.isEmpty { + return env + } + return loadFromKeychain() + } + + private static func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .newlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func loadFromKeychain() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { + return nil + } + guard let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } +} + final class SocketClient { private let path: String private var socketFD: Int32 = -1 @@ -402,6 +443,7 @@ struct CMUXCLI { var jsonOutput = false var idFormatArg: String? = nil var windowId: String? = nil + var socketPasswordArg: String? = nil var index = 1 while index < args.count { @@ -435,6 +477,14 @@ struct CMUXCLI { index += 2 continue } + if arg == "--password" { + guard index + 1 < args.count else { + throw CLIError(message: "--password requires a value") + } + socketPasswordArg = args[index + 1] + index += 2 + continue + } if arg == "-h" || arg == "--help" { print(usage()) return @@ -462,6 +512,14 @@ struct CMUXCLI { try client.connect() defer { client.close() } + if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) { + let authResponse = try client.send(command: "auth \(socketPassword)") + if authResponse.hasPrefix("ERROR:"), + !authResponse.contains("Unknown command 'auth'") { + throw CLIError(message: authResponse) + } + } + let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) // If the user explicitly targets a window, focus it first so commands route correctly. @@ -4398,13 +4456,16 @@ struct CMUXCLI { cmux - control cmux via Unix socket Usage: - cmux [--socket PATH] [--window WINDOW] [--json] [--id-format refs|uuids|both] [options] + cmux [--socket PATH] [--window WINDOW] [--password PASSWORD] [--json] [--id-format refs|uuids|both] [options] Handle Inputs: For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes. `tab-action` also accepts `tab:` in addition to `surface:`. Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs. + Socket Auth: + --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then keychain password saved in Settings. + Commands: ping capabilities diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3f71d164..23197bba 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -456,7 +456,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if isRunningUnderXCTest(env) { let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) ?? SocketControlSettings.defaultMode.rawValue - let userMode = SocketControlMode(rawValue: raw) ?? SocketControlSettings.defaultMode + let userMode = SocketControlSettings.migrateMode(raw) let mode = SocketControlSettings.effectiveMode(userMode: userMode) if mode != .off { TerminalController.shared.start( diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index c0fb35bb..a2586136 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -1,16 +1,17 @@ import Foundation +import Security enum SocketControlMode: String, CaseIterable, Identifiable { case off case cmuxOnly - /// Allow any local process to connect (no ancestry check). - /// Only accessible via CMUX_SOCKET_MODE=allowAll env var — not shown in the UI. + case automation + case password + /// Full open access (all local users/processes) with no ancestry or password gate. case allowAll var id: String { rawValue } - /// Cases shown in the Settings UI. `allowAll` is intentionally excluded. - static var uiCases: [SocketControlMode] { [.off, .cmuxOnly] } + static var uiCases: [SocketControlMode] { [.off, .cmuxOnly, .automation, .password, .allowAll] } var displayName: String { switch self { @@ -18,8 +19,12 @@ enum SocketControlMode: String, CaseIterable, Identifiable { return "Off" case .cmuxOnly: return "cmux processes only" + case .automation: + return "Automation mode" + case .password: + return "Password mode" case .allowAll: - return "Allow all processes" + return "Full open access" } } @@ -29,8 +34,126 @@ enum SocketControlMode: String, CaseIterable, Identifiable { return "Disable the local control socket." case .cmuxOnly: return "Only processes started inside cmux terminals can send commands." + case .automation: + return "Allow external local automation clients from this macOS user (no ancestry check)." + case .password: + return "Require socket authentication with a password stored in your keychain." case .allowAll: - return "Allow any local process to connect (no ancestry check)." + return "Allow any local process and user to connect with no auth. Unsafe." + } + } + + var socketFilePermissions: UInt16 { + switch self { + case .allowAll: + return 0o666 + case .off, .cmuxOnly, .automation, .password: + return 0o600 + } + } + + var requiresPasswordAuth: Bool { + self == .password + } +} + +enum SocketControlPasswordStore { + static let service = "com.cmuxterm.app.socket-control" + static let account = "local-socket-password" + + private static var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + static func configuredPassword( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String? { + if let envPassword = environment[SocketControlSettings.socketPasswordEnvKey], !envPassword.isEmpty { + return envPassword + } + return try? loadPassword() + } + + static func hasConfiguredPassword( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + guard let configured = configuredPassword(environment: environment) else { return false } + return !configured.isEmpty + } + + static func verify( + password candidate: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + guard let expected = configuredPassword(environment: environment), !expected.isEmpty else { + return false + } + return expected == candidate + } + + static func loadPassword() throws -> String? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + guard let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } + + static func savePassword(_ password: String) throws { + let normalized = password.trimmingCharacters(in: .newlines) + if normalized.isEmpty { + try clearPassword() + return + } + + let data = Data(normalized.utf8) + var lookup = baseQuery + lookup[kSecReturnData as String] = true + lookup[kSecMatchLimit as String] = kSecMatchLimitOne + + var existing: CFTypeRef? + let lookupStatus = SecItemCopyMatching(lookup as CFDictionary, &existing) + switch lookupStatus { + case errSecSuccess: + let attrsToUpdate: [String: Any] = [ + kSecValueData as String: data + ] + let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attrsToUpdate as CFDictionary) + guard updateStatus == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus)) + } + case errSecItemNotFound: + var add = baseQuery + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(addStatus)) + } + default: + throw NSError(domain: NSOSStatusErrorDomain, code: Int(lookupStatus)) + } + } + + static func clearPassword() throws { + let status = SecItemDelete(baseQuery as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) } } } @@ -39,19 +162,43 @@ struct SocketControlSettings { static let appStorageKey = "socketControlMode" static let legacyEnabledKey = "socketControlEnabled" static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE" + static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD" - /// Map old persisted rawValues to the new enum. - static func migrateMode(_ raw: String) -> SocketControlMode { - switch raw { - case "off": return .off - case "cmuxOnly": return .cmuxOnly - case "allowAll": return .allowAll - // Legacy values: - case "notifications", "full": return .cmuxOnly - default: return defaultMode + private static func normalizeMode(_ raw: String) -> String { + raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "-", with: "") + } + + private static func parseMode(_ raw: String) -> SocketControlMode? { + switch normalizeMode(raw) { + case "off": + return .off + case "cmuxonly": + return .cmuxOnly + case "automation": + return .automation + case "password": + return .password + case "allowall", "openaccess", "fullopenaccess": + return .allowAll + // Legacy values from the old socket mode model. + case "notifications": + return .automation + case "full": + return .allowAll + default: + return nil } } + /// Map persisted values to the current enum values. + static func migrateMode(_ raw: String) -> SocketControlMode { + parseMode(raw) ?? defaultMode + } + static var defaultMode: SocketControlMode { return .cmuxOnly } @@ -135,8 +282,10 @@ struct SocketControlSettings { } } - static func envOverrideEnabled() -> Bool? { - guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else { + static func envOverrideEnabled( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool? { + guard let raw = environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else { return nil } @@ -150,33 +299,30 @@ struct SocketControlSettings { } } - static func envOverrideMode() -> SocketControlMode? { - guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_MODE"], !raw.isEmpty else { + static func envOverrideMode( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> SocketControlMode? { + guard let raw = environment["CMUX_SOCKET_MODE"], !raw.isEmpty else { return nil } - let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - switch cleaned { - case "off": return .off - case "cmuxonly", "cmux_only", "cmux-only": return .cmuxOnly - case "allowall", "allow_all", "allow-all": return .allowAll - // Legacy env var values — map to allowAll so existing test scripts keep working - case "notifications", "full": return .allowAll - default: return SocketControlMode(rawValue: cleaned) - } + return parseMode(raw) } - static func effectiveMode(userMode: SocketControlMode) -> SocketControlMode { - if let overrideEnabled = envOverrideEnabled() { + static func effectiveMode( + userMode: SocketControlMode, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> SocketControlMode { + if let overrideEnabled = envOverrideEnabled(environment: environment) { if !overrideEnabled { return .off } - if let overrideMode = envOverrideMode() { + if let overrideMode = envOverrideMode(environment: environment) { return overrideMode } return userMode == .off ? .cmuxOnly : userMode } - if let overrideMode = envOverrideMode() { + if let overrideMode = envOverrideMode(environment: environment) { return overrideMode } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 5f1a3833..3f61f26b 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -312,6 +312,7 @@ class TerminalController { if isRunning { if self.socketPath == socketPath && acceptLoopAlive { self.accessMode = accessMode + applySocketPermissions() return } stop() @@ -351,8 +352,7 @@ class TerminalController { return } - // Restrict socket to owner only (0600) - chmod(socketPath, 0o600) + applySocketPermissions() // Listen guard listen(serverSocket, 5) >= 0 else { @@ -398,6 +398,104 @@ class TerminalController { unlink(socketPath) } + private func applySocketPermissions() { + let permissions = mode_t(accessMode.socketFilePermissions) + if chmod(socketPath, permissions) != 0 { + print("TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(socketPath)") + } + } + + private func writeSocketResponse(_ response: String, to socket: Int32) { + let payload = response + "\n" + payload.withCString { ptr in + _ = write(socket, ptr, strlen(ptr)) + } + } + + private func passwordAuthRequiredResponse(for command: String) -> String { + let message = "Authentication required. Send auth first." + guard command.hasPrefix("{"), + let data = command.data(using: .utf8), + let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + return "ERROR: Authentication required — send auth first" + } + let id = dict["id"] + return v2Error(id: id, code: "auth_required", message: message) + } + + private func passwordLoginV1ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? { + let lowered = command.lowercased() + guard lowered == "auth" || lowered.hasPrefix("auth ") else { + return nil + } + guard SocketControlPasswordStore.hasConfiguredPassword() else { + return "ERROR: Password mode is enabled but no socket password is configured in Settings." + } + + let provided: String + if lowered == "auth" { + provided = "" + } else { + provided = String(command.dropFirst(5)) + } + guard !provided.isEmpty else { + return "ERROR: Missing password. Usage: auth " + } + guard SocketControlPasswordStore.verify(password: provided) else { + return "ERROR: Invalid password" + } + authenticated = true + return "OK: Authenticated" + } + + private func passwordLoginV2ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? { + guard command.hasPrefix("{"), + let data = command.data(using: .utf8), + let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + return nil + } + let id = dict["id"] + let method = (dict["method"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard method == "auth.login" else { + return nil + } + + guard let params = dict["params"] as? [String: Any], + let provided = params["password"] as? String else { + return v2Error(id: id, code: "invalid_params", message: "auth.login requires params.password") + } + + guard SocketControlPasswordStore.hasConfiguredPassword() else { + return v2Error( + id: id, + code: "auth_unconfigured", + message: "Password mode is enabled but no socket password is configured in Settings." + ) + } + + guard SocketControlPasswordStore.verify(password: provided) else { + return v2Error(id: id, code: "auth_failed", message: "Invalid password") + } + authenticated = true + return v2Ok(id: id, result: ["authenticated": true]) + } + + private func authResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? { + guard accessMode.requiresPasswordAuth else { + return nil + } + if let v2Response = passwordLoginV2ResponseIfNeeded(for: command, authenticated: &authenticated) { + return v2Response + } + if let v1Response = passwordLoginV1ResponseIfNeeded(for: command, authenticated: &authenticated) { + return v1Response + } + if !authenticated { + return passwordAuthRequiredResponse(for: command) + } + return nil + } + private nonisolated func acceptLoop() { acceptLoopAlive = true defer { @@ -447,7 +545,7 @@ class TerminalController { defer { close(socket) } // In cmuxOnly mode, verify the connecting process is a descendant of cmux. - // In allowAll mode (env-var only), skip the ancestry check. + // Other modes allow external clients and apply separate auth controls. if accessMode == .cmuxOnly { // Use pre-captured peer PID if available (captured in accept loop before // the peer can disconnect), falling back to live lookup. @@ -477,6 +575,7 @@ class TerminalController { var buffer = [UInt8](repeating: 0, count: 4096) var pending = "" + var authenticated = false while isRunning { let bytesRead = read(socket, &buffer, buffer.count - 1) @@ -491,11 +590,13 @@ class TerminalController { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } - let response = processCommand(trimmed) - let payload = response + "\n" - payload.withCString { ptr in - _ = write(socket, ptr, strlen(ptr)) + if let authResponse = authResponseIfNeeded(for: trimmed, authenticated: &authenticated) { + writeSocketResponse(authResponse, to: socket) + continue } + + let response = processCommand(trimmed) + writeSocketResponse(response, to: socket) } } } @@ -524,6 +625,9 @@ class TerminalController { case "ping": return "PONG" + case "auth": + return "OK: Authentication not required" + case "list_windows": return listWindows() @@ -870,6 +974,14 @@ class TerminalController { case "system.identify": return v2Ok(id: id, result: v2Identify(params: params)) + case "auth.login": + return v2Ok( + id: id, + result: [ + "authenticated": true, + "required": accessMode.requiresPasswordAuth + ] + ) // Windows case "window.list": @@ -1220,6 +1332,7 @@ class TerminalController { "system.ping", "system.capabilities", "system.identify", + "auth.login", "window.list", "window.current", "window.focus", @@ -7719,6 +7832,7 @@ class TerminalController { Available commands: ping - Check if server is running + auth - Authenticate this connection (required in password mode) list_workspaces - List all workspaces with IDs new_workspace - Create a new workspace select_workspace - Select workspace by ID or index (0-based) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 0fa8eedb..09b18c59 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2471,8 +2471,13 @@ struct SettingsView: View { @State private var topBlurBaselineOffset: CGFloat? @State private var settingsTitleLeadingInset: CGFloat = 92 @State private var showClearBrowserHistoryConfirmation = false + @State private var showOpenAccessConfirmation = false + @State private var pendingOpenAccessMode: SocketControlMode? @State private var browserHistoryEntryCount: Int = 0 @State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText + @State private var socketPasswordDraft = "" + @State private var socketPasswordStatusMessage: String? + @State private var socketPasswordStatusIsError = false private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement @@ -2482,6 +2487,29 @@ struct SettingsView: View { SocketControlSettings.migrateMode(socketControlMode) } + private var socketModeSelection: Binding { + Binding( + get: { socketControlMode }, + set: { newValue in + let normalized = SocketControlSettings.migrateMode(newValue) + if normalized == .allowAll && selectedSocketControlMode != .allowAll { + pendingOpenAccessMode = normalized + showOpenAccessConfirmation = true + return + } + socketControlMode = normalized.rawValue + if normalized != .password { + socketPasswordStatusMessage = nil + socketPasswordStatusIsError = false + } + } + ) + } + + private var hasSocketPasswordConfigured: Bool { + SocketControlPasswordStore.hasConfiguredPassword() + } + private var browserHistorySubtitle: String { switch browserHistoryEntryCount { case 0: @@ -2503,6 +2531,37 @@ struct SettingsView: View { return Double(min(max(reveal, 0), 1)) } + private func saveSocketPassword() { + let trimmed = socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + socketPasswordStatusMessage = "Enter a password first." + socketPasswordStatusIsError = true + return + } + + do { + try SocketControlPasswordStore.savePassword(trimmed) + socketPasswordDraft = "" + socketPasswordStatusMessage = "Password saved to keychain." + socketPasswordStatusIsError = false + } catch { + socketPasswordStatusMessage = "Failed to save password (\(error.localizedDescription))." + socketPasswordStatusIsError = true + } + } + + private func clearSocketPassword() { + do { + try SocketControlPasswordStore.clearPassword() + socketPasswordDraft = "" + socketPasswordStatusMessage = "Password cleared." + socketPasswordStatusIsError = false + } catch { + socketPasswordStatusMessage = "Failed to clear password (\(error.localizedDescription))." + socketPasswordStatusIsError = true + } + } + var body: some View { ZStack(alignment: .top) { ScrollView { @@ -2594,7 +2653,7 @@ struct SettingsView: View { subtitle: selectedSocketControlMode.description, controlWidth: pickerColumnWidth ) { - Picker("", selection: $socketControlMode) { + Picker("", selection: socketModeSelection) { ForEach(SocketControlMode.uiCases) { mode in Text(mode.displayName).tag(mode.rawValue) } @@ -2606,7 +2665,50 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardNote("Controls access to the local Unix socket for programmatic control. In \"cmux processes only\" mode, only processes spawned inside cmux terminals can connect.") + SettingsCardNote("Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.") + if selectedSocketControlMode == .password { + SettingsCardDivider() + SettingsCardRow( + "Socket Password", + subtitle: hasSocketPasswordConfigured + ? "Stored in login keychain." + : "No password set. External clients will be blocked until one is configured." + ) { + HStack(spacing: 8) { + SecureField("Password", text: $socketPasswordDraft) + .textFieldStyle(.roundedBorder) + .frame(width: 170) + Button(hasSocketPasswordConfigured ? "Change" : "Set") { + saveSocketPassword() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + if hasSocketPasswordConfigured { + Button("Clear") { + clearSocketPassword() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + if let message = socketPasswordStatusMessage { + Text(message) + .font(.caption) + .foregroundStyle(socketPasswordStatusIsError ? Color.red : Color.secondary) + .padding(.horizontal, 14) + .padding(.bottom, 8) + } + } + if selectedSocketControlMode == .allowAll { + SettingsCardDivider() + Text("Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.") + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } SettingsCardNote("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds).") } @@ -2962,6 +3064,21 @@ struct SettingsView: View { } message: { Text("This removes visited-page suggestions from the browser omnibar.") } + .confirmationDialog( + "Enable full open access?", + isPresented: $showOpenAccessConfirmation, + titleVisibility: .visible + ) { + Button("Enable Full Open Access", role: .destructive) { + socketControlMode = (pendingOpenAccessMode ?? .allowAll).rawValue + pendingOpenAccessMode = nil + } + Button("Cancel", role: .cancel) { + pendingOpenAccessMode = nil + } + } message: { + Text("This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk.") + } } private func resetAllSettings() { @@ -2981,6 +3098,11 @@ struct SettingsView: View { newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout + showOpenAccessConfirmation = false + pendingOpenAccessMode = nil + socketPasswordDraft = "" + socketPasswordStatusMessage = nil + socketPasswordStatusIsError = false KeyboardShortcutSettings.resetAll() shortcutResetToken = UUID() } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 5f80a466..effff6ad 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -356,6 +356,41 @@ final class TabManagerNotificationOrderingSourceTests: XCTestCase { } final class SocketControlSettingsTests: XCTestCase { + func testMigrateModeSupportsExpandedSocketModes() { + XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off) + XCTAssertEqual(SocketControlSettings.migrateMode("cmuxOnly"), .cmuxOnly) + XCTAssertEqual(SocketControlSettings.migrateMode("automation"), .automation) + XCTAssertEqual(SocketControlSettings.migrateMode("password"), .password) + XCTAssertEqual(SocketControlSettings.migrateMode("allow-all"), .allowAll) + + // Legacy aliases + XCTAssertEqual(SocketControlSettings.migrateMode("notifications"), .automation) + XCTAssertEqual(SocketControlSettings.migrateMode("full"), .allowAll) + } + + func testSocketModePermissions() { + XCTAssertEqual(SocketControlMode.off.socketFilePermissions, 0o600) + XCTAssertEqual(SocketControlMode.cmuxOnly.socketFilePermissions, 0o600) + XCTAssertEqual(SocketControlMode.automation.socketFilePermissions, 0o600) + XCTAssertEqual(SocketControlMode.password.socketFilePermissions, 0o600) + XCTAssertEqual(SocketControlMode.allowAll.socketFilePermissions, 0o666) + } + + func testInvalidEnvSocketModeDoesNotOverrideUserMode() { + XCTAssertNil( + SocketControlSettings.envOverrideMode( + environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"] + ) + ) + XCTAssertEqual( + SocketControlSettings.effectiveMode( + userMode: .password, + environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"] + ), + .password + ) + } + func testStableReleaseIgnoresAmbientSocketOverrideByDefault() { let path = SocketControlSettings.socketPath( environment: [ From a205028b2e95f238cb5d64d4b3dc25410ce69ad2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 01:08:25 -0800 Subject: [PATCH 2/3] Strengthen socket access integration coverage Make tests/test_socket_access.py deterministic across environments and add password-mode auth integration checks (v1 and v2). --- tests/test_socket_access.py | 203 ++++++++++++++++++++++++++++++++---- 1 file changed, 185 insertions(+), 18 deletions(-) diff --git a/tests/test_socket_access.py b/tests/test_socket_access.py index ce0c3e6e..48929bd2 100644 --- a/tests/test_socket_access.py +++ b/tests/test_socket_access.py @@ -20,6 +20,8 @@ import subprocess import sys import tempfile import time +import json +import glob sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from cmux import cmux, cmuxError @@ -69,32 +71,66 @@ def _raw_send(sock, command: str, timeout: float = 3.0) -> str: def _find_app(): - r = subprocess.run( - ["find", "/Users/cmux/Library/Developer/Xcode/DerivedData", - "-path", "*/Build/Products/Debug/cmux DEV.app", "-print", "-quit"], - capture_output=True, text=True, timeout=10 - ) - return r.stdout.strip() + explicit = os.environ.get("CMUX_APP_PATH") + if explicit and os.path.exists(explicit): + return explicit + + candidates = [] + home = os.path.expanduser("~") + candidates.extend(glob.glob(os.path.join( + home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux DEV.app" + ))) + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app")) + candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app")) + + candidates = [p for p in candidates if os.path.exists(p)] + if not candidates: + return "" + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] def _wait_for_socket(socket_path: str, timeout: float = 10.0) -> bool: deadline = time.time() + timeout while time.time() < deadline: if os.path.exists(socket_path): - return True + try: + sock = _raw_connect(socket_path, timeout=0.3) + sock.close() + return True + except Exception: + pass time.sleep(0.5) return False -def _kill_cmux(): - subprocess.run(["pkill", "-x", "cmux DEV"], capture_output=True) +def _kill_cmux(app_path: str = None): + if app_path: + exe = os.path.join(app_path, "Contents/MacOS/cmux DEV") + subprocess.run(["pkill", "-f", exe], capture_output=True) + else: + subprocess.run(["pkill", "-x", "cmux DEV"], capture_output=True) time.sleep(1.5) -def _launch_cmux(app_path: str, socket_path: str, mode: str = None): +def _launch_cmux(app_path: str, socket_path: str, mode: str = None, extra_env: dict = None): + if os.path.exists(socket_path): + try: + os.unlink(socket_path) + except OSError: + pass + env_args = [] if mode: env_args = ["--env", f"CMUX_SOCKET_MODE={mode}"] + launch_env = { + "CMUX_SOCKET_PATH": socket_path, + "CMUX_ALLOW_SOCKET_OVERRIDE": "1", + } + if extra_env: + launch_env.update(extra_env) + for key, value in launch_env.items(): + env_args.extend(["--env", f"{key}={value}"]) subprocess.Popen(["open", "-a", app_path] + env_args) if not _wait_for_socket(socket_path): raise RuntimeError(f"Socket {socket_path} not created after launch") @@ -249,8 +285,8 @@ fi f.write(hook_line) # Kill existing cmux, launch in cmuxOnly mode (default) - _kill_cmux() - _launch_cmux(app_path, socket_path) + _kill_cmux(app_path) + _launch_cmux(app_path, socket_path, mode="cmuxOnly") # Wait for marker (the shell sources .zprofile on startup) for _ in range(40): @@ -305,7 +341,7 @@ def test_allowall_mode_works(socket_path: str, app_path: str) -> TestResult: """Verify CMUX_SOCKET_MODE=allowAll bypasses ancestry check.""" result = TestResult("allowAll mode allows external") try: - _kill_cmux() + _kill_cmux(app_path) _launch_cmux(app_path, socket_path, mode="allowAll") sock = _raw_connect(socket_path) @@ -321,6 +357,124 @@ def test_allowall_mode_works(socket_path: str, app_path: str) -> TestResult: return result +def test_password_mode_requires_auth(socket_path: str, app_path: str) -> TestResult: + """Verify password mode rejects unauthenticated commands.""" + result = TestResult("Password mode requires auth") + password = f"cmux-pass-{os.getpid()}" + try: + _kill_cmux(app_path) + _launch_cmux( + app_path, + socket_path, + mode="password", + extra_env={"CMUX_SOCKET_PASSWORD": password} + ) + + sock = _raw_connect(socket_path) + response = _raw_send(sock, "ping") + sock.close() + + if "Authentication required" in response: + result.success("Unauthenticated command rejected in password mode") + else: + result.failure(f"Unexpected response without auth: {response!r}") + except Exception as e: + result.failure(f"{type(e).__name__}: {e}") + return result + + +def test_password_mode_v1_auth_flow(socket_path: str, app_path: str) -> TestResult: + """Verify v1 auth command unlocks the connection only with correct password.""" + result = TestResult("Password mode v1 auth flow") + password = f"cmux-pass-{os.getpid()}" + try: + _kill_cmux(app_path) + _launch_cmux( + app_path, + socket_path, + mode="password", + extra_env={"CMUX_SOCKET_PASSWORD": password} + ) + + sock = _raw_connect(socket_path) + try: + wrong = _raw_send(sock, "auth wrong-password") + if "Invalid password" not in wrong: + result.failure(f"Expected invalid password error, got: {wrong!r}") + return result + + ok = _raw_send(sock, f"auth {password}") + if "OK: Authenticated" not in ok: + result.failure(f"Expected auth success, got: {ok!r}") + return result + + pong = _raw_send(sock, "ping") + if pong != "PONG": + result.failure(f"Expected PONG after auth, got: {pong!r}") + return result + finally: + sock.close() + + result.success("v1 auth gate works") + except Exception as e: + result.failure(f"{type(e).__name__}: {e}") + return result + + +def test_password_mode_v2_auth_flow(socket_path: str, app_path: str) -> TestResult: + """Verify v2 auth.login unlocks subsequent v2 requests.""" + result = TestResult("Password mode v2 auth flow") + password = f"cmux-pass-{os.getpid()}" + try: + _kill_cmux(app_path) + _launch_cmux( + app_path, + socket_path, + mode="password", + extra_env={"CMUX_SOCKET_PASSWORD": password} + ) + + sock = _raw_connect(socket_path) + try: + unauth = _raw_send(sock, json.dumps({ + "id": "1", + "method": "system.ping", + "params": {} + })) + unauth_obj = json.loads(unauth) + if unauth_obj.get("error", {}).get("code") != "auth_required": + result.failure(f"Expected auth_required, got: {unauth!r}") + return result + + login = _raw_send(sock, json.dumps({ + "id": "2", + "method": "auth.login", + "params": {"password": password} + })) + login_obj = json.loads(login) + if not login_obj.get("ok"): + result.failure(f"Expected auth.login success, got: {login!r}") + return result + + pong = _raw_send(sock, json.dumps({ + "id": "3", + "method": "system.ping", + "params": {} + })) + pong_obj = json.loads(pong) + pong_value = pong_obj.get("result", {}).get("pong") + if pong_value is not True: + result.failure(f"Expected pong=true after auth.login, got: {pong!r}") + return result + finally: + sock.close() + + result.success("v2 auth.login gate works") + except Exception as e: + result.failure(f"{type(e).__name__}: {e}") + return result + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -337,7 +491,11 @@ def run_tests(): return 1 print(f"App: {app_path}") - socket_path = _find_socket_path() + socket_path = f"/tmp/cmux-test-socket-access-{os.getpid()}.sock" + try: + os.unlink(socket_path) + except OSError: + pass print(f"Socket: {socket_path}") print() @@ -356,9 +514,9 @@ def run_tests(): print("-" * 50) # Ensure cmux is running in cmuxOnly mode - _kill_cmux() + _kill_cmux(app_path) print(" Launching cmux in cmuxOnly mode...") - _launch_cmux(app_path, socket_path) + _launch_cmux(app_path, socket_path, mode="cmuxOnly") run_test(test_external_rejected, socket_path) run_test(test_connection_closed_after_reject, socket_path) @@ -380,9 +538,18 @@ def run_tests(): run_test(test_allowall_mode_works, socket_path, app_path) print() + # ── Phase 4: password mode auth gate ── + print("Phase 4: password mode — auth required + login flow") + print("-" * 50) + + run_test(test_password_mode_requires_auth, socket_path, app_path) + run_test(test_password_mode_v1_auth_flow, socket_path, app_path) + run_test(test_password_mode_v2_auth_flow, socket_path, app_path) + print() + # ── Cleanup: leave cmux in cmuxOnly mode ── - _kill_cmux() - _launch_cmux(app_path, socket_path) + _kill_cmux(app_path) + _launch_cmux(app_path, socket_path, mode="cmuxOnly") # ── Summary ── print("=" * 60) From ea87076fe4450cd1fd7e72401dbaae07bce12c3a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 01:37:42 -0800 Subject: [PATCH 3/3] Fix CLI exit code on v1 auth errors --- CLI/cmux.swift | 42 +++++---- tests/test_socket_access.py | 169 ++++++++++++++++++++++++++++++++++-- 2 files changed, 188 insertions(+), 23 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d72798aa..c7d6b8d6 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -539,7 +539,7 @@ struct CMUXCLI { switch command { case "ping": - let response = try client.send(command: "ping") + let response = try sendV1Command("ping", client: client) print(response) case "capabilities": @@ -582,7 +582,7 @@ struct CMUXCLI { print(jsonString(formatIDs(response, mode: idFormat))) case "list-windows": - let response = try client.send(command: "list_windows") + let response = try sendV1Command("list_windows", client: client) if jsonOutput { let windows = parseWindows(response) let payload = windows.map { item -> [String: Any] in @@ -601,7 +601,7 @@ struct CMUXCLI { } case "current-window": - let response = try client.send(command: "current_window") + let response = try sendV1Command("current_window", client: client) if jsonOutput { print(jsonString(["window_id": response])) } else { @@ -609,21 +609,21 @@ struct CMUXCLI { } case "new-window": - let response = try client.send(command: "new_window") + let response = try sendV1Command("new_window", client: client) print(response) case "focus-window": guard let target = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "focus-window requires --window") } - let response = try client.send(command: "focus_window \(target)") + let response = try sendV1Command("focus_window \(target)", client: client) print(response) case "close-window": guard let target = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "close-window requires --window") } - let response = try client.send(command: "close_window \(target)") + let response = try sendV1Command("close_window \(target)", client: client) print(response) case "move-workspace-to-window": @@ -685,7 +685,7 @@ struct CMUXCLI { if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --command ") } - let response = try client.send(command: "new_workspace") + let response = try sendV1Command("new_workspace", client: client) print(response) if let commandText = commandOpt { guard response.hasPrefix("OK ") else { @@ -830,11 +830,11 @@ struct CMUXCLI { guard let direction = rem1.first else { throw CLIError(message: "drag-surface-to-split requires a direction") } - let response = try client.send(command: "drag_surface_to_split \(surface) \(direction)") + let response = try sendV1Command("drag_surface_to_split \(surface) \(direction)", client: client) print(response) case "refresh-surfaces": - let response = try client.send(command: "refresh_surfaces") + let response = try sendV1Command("refresh_surfaces", client: client) print(response) case "surface-health": @@ -950,7 +950,7 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "current-workspace": - let response = try client.send(command: "current_workspace") + let response = try sendV1Command("current_workspace", client: client) if jsonOutput { print(jsonString(["workspace_id": response])) } else { @@ -1074,11 +1074,11 @@ struct CMUXCLI { let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client) let payload = "\(title)|\(subtitle)|\(body)" - let response = try client.send(command: "notify_target \(targetWorkspace) \(targetSurface) \(payload)") + let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client) print(response) case "list-notifications": - let response = try client.send(command: "list_notifications") + let response = try sendV1Command("list_notifications", client: client) if jsonOutput { let notifications = parseNotifications(response) let payload = notifications.map { item in @@ -1099,7 +1099,7 @@ struct CMUXCLI { } case "clear-notifications": - let response = try client.send(command: "clear_notifications") + let response = try sendV1Command("clear_notifications", client: client) print(response) case "claude-hook": @@ -1107,11 +1107,11 @@ struct CMUXCLI { case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } - let response = try client.send(command: "set_app_focus \(value)") + let response = try sendV1Command("set_app_focus \(value)", client: client) print(response) case "simulate-app-active": - let response = try client.send(command: "simulate_app_active") + let response = try sendV1Command("simulate_app_active", client: client) print(response) case "capture-pane", @@ -1191,6 +1191,14 @@ struct CMUXCLI { } } + private func sendV1Command(_ command: String, client: SocketClient) throws -> String { + let response = try client.send(command: command) + if response.hasPrefix("ERROR:") { + throw CLIError(message: response) + } + return response + } + private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat { _ = jsonOutput if let parsed = try CLIIDFormat.parse(raw) { @@ -4086,7 +4094,7 @@ struct CMUXCLI { let subtitle = sanitizeNotificationField(completion.subtitle) let body = sanitizeNotificationField(completion.body) let payload = "\(title)|\(subtitle)|\(body)" - let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") + let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) print(response) } else { print("OK") @@ -4126,7 +4134,7 @@ struct CMUXCLI { ) } - let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") + let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) _ = try? setClaudeStatus( client: client, workspaceId: workspaceId, diff --git a/tests/test_socket_access.py b/tests/test_socket_access.py index 48929bd2..ab24627b 100644 --- a/tests/test_socket_access.py +++ b/tests/test_socket_access.py @@ -22,6 +22,7 @@ import tempfile import time import json import glob +import plistlib sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from cmux import cmux, cmuxError @@ -70,22 +71,123 @@ def _raw_send(sock, command: str, timeout: float = 3.0) -> str: return data.decode().strip() +def _preferred_worktree_slug(): + env_slug = os.environ.get("CMUX_TAG") or os.environ.get("CMUX_BRANCH_SLUG") + if env_slug: + return env_slug.strip().lower() + + cwd = os.getcwd() + marker = "/worktrees/" + if marker in cwd: + tail = cwd.split(marker, 1)[1] + slug = tail.split("/", 1)[0].strip().lower() + if slug: + return slug + return "" + + +def _derived_app_candidates_for_current_worktree(): + project_path = os.path.realpath(os.path.join(os.getcwd(), "GhosttyTabs.xcodeproj")) + info_paths = glob.glob(os.path.expanduser( + "~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*/info.plist" + )) + matches = [] + for info_path in info_paths: + try: + with open(info_path, "rb") as f: + info = plistlib.load(f) + except Exception: + continue + workspace_path = info.get("WorkspacePath") + if not workspace_path: + continue + if os.path.realpath(workspace_path) != project_path: + continue + derived_root = os.path.dirname(info_path) + app_path = os.path.join(derived_root, "Build/Products/Debug/cmux DEV.app") + if os.path.exists(app_path): + matches.append(app_path) + return matches + + def _find_app(): explicit = os.environ.get("CMUX_APP_PATH") if explicit and os.path.exists(explicit): return explicit + preferred_slug = _preferred_worktree_slug() + if preferred_slug: + preferred_tmp = [] + preferred_tmp.extend(glob.glob(f"/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app")) + preferred_tmp.extend(glob.glob(f"/private/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app")) + preferred_tmp = [p for p in preferred_tmp if os.path.exists(p)] + if preferred_tmp: + preferred_tmp.sort(key=os.path.getmtime, reverse=True) + return preferred_tmp[0] + + direct_matches = _derived_app_candidates_for_current_worktree() + if direct_matches: + direct_matches.sort(key=os.path.getmtime, reverse=True) + return direct_matches[0] + + home = os.path.expanduser("~") + derived_candidates = glob.glob(os.path.join( + home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux DEV.app" + )) + tmp_candidates = [] + tmp_candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app")) + tmp_candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app")) + + derived_candidates = [p for p in derived_candidates if os.path.exists(p)] + tmp_candidates = [p for p in tmp_candidates if os.path.exists(p)] + + if preferred_slug: + preferred_derived = [p for p in derived_candidates if preferred_slug in p.lower()] + preferred_tmp = [p for p in tmp_candidates if preferred_slug in p.lower()] + if preferred_derived: + derived_candidates = preferred_derived + if preferred_tmp: + tmp_candidates = preferred_tmp + + if derived_candidates: + derived_candidates.sort(key=os.path.getmtime, reverse=True) + return derived_candidates[0] + + if tmp_candidates: + tmp_candidates.sort(key=os.path.getmtime, reverse=True) + return tmp_candidates[0] + + return "" + + +def _find_cli(preferred_app_path: str = ""): + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + + if preferred_app_path: + debug_dir = os.path.dirname(preferred_app_path) + sibling = os.path.join(debug_dir, "cmux") + if os.path.exists(sibling) and os.access(sibling, os.X_OK): + return sibling + candidates = [] home = os.path.expanduser("~") candidates.extend(glob.glob(os.path.join( - home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux DEV.app" + home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux" ))) - candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app")) - candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app")) - - candidates = [p for p in candidates if os.path.exists(p)] + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)] if not candidates: return "" + + preferred_slug = _preferred_worktree_slug() + if preferred_slug: + preferred = [p for p in candidates if preferred_slug in p.lower()] + if preferred: + candidates = preferred + candidates.sort(key=os.path.getmtime, reverse=True) return candidates[0] @@ -131,7 +233,7 @@ def _launch_cmux(app_path: str, socket_path: str, mode: str = None, extra_env: d launch_env.update(extra_env) for key, value in launch_env.items(): env_args.extend(["--env", f"{key}={value}"]) - subprocess.Popen(["open", "-a", app_path] + env_args) + subprocess.Popen(["open", "-na", app_path] + env_args) if not _wait_for_socket(socket_path): raise RuntimeError(f"Socket {socket_path} not created after launch") time.sleep(8) @@ -475,6 +577,60 @@ def test_password_mode_v2_auth_flow(socket_path: str, app_path: str) -> TestResu return result +def test_password_mode_cli_exit_code(socket_path: str, app_path: str) -> TestResult: + """Verify CLI exits non-zero on auth-required and succeeds with --password.""" + result = TestResult("Password mode CLI exit code") + password = f"cmux-pass-{os.getpid()}" + try: + cli_path = _find_cli(preferred_app_path=app_path) + if not cli_path: + result.failure("Could not find cmux CLI binary") + return result + + _kill_cmux(app_path) + _launch_cmux( + app_path, + socket_path, + mode="password", + extra_env={"CMUX_SOCKET_PASSWORD": password} + ) + + no_auth = subprocess.run( + [cli_path, "--socket", socket_path, "ping"], + capture_output=True, + text=True, + timeout=10 + ) + combined = f"{no_auth.stdout}\n{no_auth.stderr}" + if no_auth.returncode == 0: + result.failure("CLI ping without password exited 0 in password mode") + return result + if "Authentication required" not in combined: + result.failure(f"Unexpected unauthenticated CLI output: {combined!r}") + return result + + with_auth = subprocess.run( + [cli_path, "--socket", socket_path, "--password", password, "ping"], + capture_output=True, + text=True, + timeout=10 + ) + if with_auth.returncode != 0: + result.failure( + f"CLI ping with password failed: exit={with_auth.returncode} " + f"stdout={with_auth.stdout!r} stderr={with_auth.stderr!r}" + ) + return result + if "PONG" not in with_auth.stdout: + result.failure(f"Expected PONG with password, got: {with_auth.stdout!r}") + return result + + result.success("CLI exits non-zero for auth_required and succeeds with --password") + except Exception as e: + result.failure(f"{type(e).__name__}: {e}") + return result + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -545,6 +701,7 @@ def run_tests(): run_test(test_password_mode_requires_auth, socket_path, app_path) run_test(test_password_mode_v1_auth_flow, socket_path, app_path) run_test(test_password_mode_v2_auth_flow, socket_path, app_path) + run_test(test_password_mode_cli_exit_code, socket_path, app_path) print() # ── Cleanup: leave cmux in cmuxOnly mode ──