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: [