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.
This commit is contained in:
Lawrence Chen 2026-02-22 00:55:14 -08:00
parent 4c733d4e8e
commit 18550e5d1f
6 changed files with 521 additions and 43 deletions

View file

@ -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] <command> [options]
cmux [--socket PATH] [--window WINDOW] [--password PASSWORD] [--json] [--id-format refs|uuids|both] <command> [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:<n>` in addition to `surface:<n>`.
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

View file

@ -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(

View file

@ -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
}

View file

@ -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 <password> 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 <password> 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 <password>"
}
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 <password> - Authenticate this connection (required in password mode)
list_workspaces - List all workspaces with IDs
new_workspace - Create a new workspace
select_workspace <id|index> - Select workspace by ID or index (0-based)

View file

@ -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<String> {
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()
}

View file

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