cmux/Sources/SocketControlSettings.swift
Lawrence Chen e9afc22353
Skip quit confirmation for tagged DEV builds (#2288)
* Skip quit confirmation for tagged DEV builds

Tagged DEV builds are ephemeral dev iterations, so the "Quit cmux?"
dialog just adds friction. Check SocketControlSettings.launchTag() in
both applicationShouldTerminate and handleQuitShortcutWarning to bypass
the confirmation when a tag is present.

Closes https://github.com/manaflow-ai/cmux/issues/2286

* Use bundle ID instead of env var for tagged DEV detection

CMUX_TAG env var is only set when reload.sh --launch opens the app.
When the user cmd-clicks the app path, it launches via Finder without
the env var, so launchTag() returns nil and the quit dialog still shows.

Switch to checking the bundle identifier (com.cmuxterm.app.debug.<tag>)
which is baked into the built app and available regardless of how it was
launched. Add SocketControlSettings.isTaggedDevBuild() helper.

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
2026-03-28 03:57:25 -07:00

680 lines
25 KiB
Swift

import Darwin
import Foundation
#if canImport(Security)
import Security
#endif
enum SocketControlMode: String, CaseIterable, Identifiable {
case off
case cmuxOnly
case automation
case password
/// Full open access (all local users/processes) with no ancestry or password gate.
case allowAll
var id: String { rawValue }
static var uiCases: [SocketControlMode] { [.off, .cmuxOnly, .automation, .password, .allowAll] }
var displayName: String {
switch self {
case .off:
return String(localized: "socketControl.off.name", defaultValue: "Off")
case .cmuxOnly:
return String(localized: "socketControl.cmuxOnly.name", defaultValue: "cmux processes only")
case .automation:
return String(localized: "socketControl.automation.name", defaultValue: "Automation mode")
case .password:
return String(localized: "socketControl.password.name", defaultValue: "Password mode")
case .allowAll:
return String(localized: "socketControl.allowAll.name", defaultValue: "Full open access")
}
}
var description: String {
switch self {
case .off:
return String(localized: "socketControl.off.description", defaultValue: "Disable the local control socket.")
case .cmuxOnly:
return String(localized: "socketControl.cmuxOnly.description", defaultValue: "Only processes started inside cmux terminals can send commands.")
case .automation:
return String(localized: "socketControl.automation.description", defaultValue: "Allow external local automation clients from this macOS user (no ancestry check).")
case .password:
return String(localized: "socketControl.password.description", defaultValue: "Require socket authentication with a password stored in a local file.")
case .allowAll:
return String(localized: "socketControl.allowAll.description", defaultValue: "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 directoryName = "cmux"
static let fileName = "socket-control-password"
private static let keychainMigrationDefaultsKey = "socketControlPasswordMigrationVersion"
private static let keychainMigrationVersion = 1
private static let legacyKeychainService = "com.cmuxterm.app.socket-control"
private static let legacyKeychainAccount = "local-socket-password"
private struct LazyKeychainFallbackCache {
var hasLoaded = false
var password: String?
}
private static let lazyKeychainFallbackLock = NSLock()
private static var lazyKeychainFallbackCache = LazyKeychainFallbackCache()
static func configuredPassword(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> String? {
if let envPassword = normalized(environment[SocketControlSettings.socketPasswordEnvKey]) {
return envPassword
}
let filePassword: String?
do {
filePassword = try loadPassword(fileURL: fileURL)
} catch {
filePassword = nil
}
if let filePassword {
return filePassword
}
guard allowLazyKeychainFallback else {
return nil
}
return cachedLazyKeychainFallbackPassword(loadKeychainPassword: loadKeychainPassword)
}
static func hasConfiguredPassword(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> Bool {
guard let configured = configuredPassword(
environment: environment,
fileURL: fileURL,
allowLazyKeychainFallback: allowLazyKeychainFallback,
loadKeychainPassword: loadKeychainPassword
) else { return false }
return !configured.isEmpty
}
static func verify(
password candidate: String,
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> Bool {
guard let expected = configuredPassword(
environment: environment,
fileURL: fileURL,
allowLazyKeychainFallback: allowLazyKeychainFallback,
loadKeychainPassword: loadKeychainPassword
), !expected.isEmpty else {
return false
}
return expected == candidate
}
static func migrateLegacyKeychainPasswordIfNeeded(
defaults: UserDefaults = .standard,
fileURL: URL? = nil,
loadLegacyPassword: () -> String? = { loadLegacyPasswordFromKeychain() },
deleteLegacyPassword: () -> Bool = { deleteLegacyPasswordFromKeychain() }
) {
guard defaults.integer(forKey: keychainMigrationDefaultsKey) < keychainMigrationVersion else {
return
}
guard let legacyPassword = normalized(loadLegacyPassword()) else {
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
return
}
do {
if try loadPassword(fileURL: fileURL) == nil {
try savePassword(legacyPassword, fileURL: fileURL)
}
guard deleteLegacyPassword() else {
return
}
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
} catch {
// Leave migration unset so it retries on next launch.
}
}
static func loadPassword(fileURL: URL? = nil) throws -> String? {
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
return nil
}
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return nil
}
let data = try Data(contentsOf: fileURL)
guard let password = String(data: data, encoding: .utf8) else {
return nil
}
return normalized(password)
}
static func savePassword(_ password: String, fileURL: URL? = nil) throws {
let normalized = password.trimmingCharacters(in: .newlines)
if normalized.isEmpty {
try clearPassword(fileURL: fileURL)
return
}
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
throw NSError(
domain: NSCocoaErrorDomain,
code: NSFileNoSuchFileError,
userInfo: [NSLocalizedDescriptionKey: String(localized: "socketControl.error.passwordFilePath", defaultValue: "Unable to resolve socket password file path.")]
)
}
let directory = fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
let data = Data(normalized.utf8)
try data.write(to: fileURL, options: .atomic)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
}
static func clearPassword(fileURL: URL? = nil) throws {
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
return
}
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return
}
try FileManager.default.removeItem(at: fileURL)
}
static func resetLazyKeychainFallbackCacheForTests() {
lazyKeychainFallbackLock.lock()
lazyKeychainFallbackCache = LazyKeychainFallbackCache()
lazyKeychainFallbackLock.unlock()
}
static func defaultPasswordFileURL(
appSupportDirectory: URL? = nil,
fileManager: FileManager = .default
) -> URL? {
let resolvedAppSupport: URL
if let appSupportDirectory {
resolvedAppSupport = appSupportDirectory
} else if let discovered = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
resolvedAppSupport = discovered
} else {
return nil
}
return resolvedAppSupport
.appendingPathComponent(directoryName, isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
}
private static func loadLegacyPasswordFromKeychain() -> String? {
#if canImport(Security)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: legacyKeychainService,
kSecAttrAccount: legacyKeychainAccount,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
#else
return nil
#endif
}
private static func deleteLegacyPasswordFromKeychain() -> Bool {
#if canImport(Security)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: legacyKeychainService,
kSecAttrAccount: legacyKeychainAccount,
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
#else
return false
#endif
}
private static func cachedLazyKeychainFallbackPassword(
loadKeychainPassword: () -> String?
) -> String? {
lazyKeychainFallbackLock.lock()
defer { lazyKeychainFallbackLock.unlock() }
if lazyKeychainFallbackCache.hasLoaded {
return lazyKeychainFallbackCache.password
}
lazyKeychainFallbackCache.hasLoaded = true
lazyKeychainFallbackCache.password = normalized(loadKeychainPassword())
return lazyKeychainFallbackCache.password
}
private static func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .newlines)
return trimmed.isEmpty ? nil : trimmed
}
}
struct SocketControlSettings {
static let appStorageKey = "socketControlMode"
static let legacyEnabledKey = "socketControlEnabled"
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
static let launchTagEnvKey = "CMUX_TAG"
static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug"
private static let socketDirectoryName = "cmux"
private static let stableSocketFileName = "cmux.sock"
private static let lastSocketPathFileName = "last-socket-path"
static let legacyStableDefaultSocketPath = "/tmp/cmux.sock"
static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path"
static var stableDefaultSocketPath: String {
stableSocketFileURL()?.path ?? legacyStableDefaultSocketPath
}
static var lastSocketPathFile: String {
lastSocketPathFileURL()?.path ?? legacyLastSocketPathFile
}
enum StableDefaultSocketPathEntry: Equatable {
case missing
case socket(ownerUserID: uid_t)
case other(ownerUserID: uid_t)
case inaccessible(errnoCode: Int32)
}
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
}
private static var isDebugBuild: Bool {
#if DEBUG
true
#else
false
#endif
}
static func launchTag(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> String? {
guard let raw = environment[launchTagEnvKey] else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
static func shouldBlockUntaggedDebugLaunch(
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
) -> Bool {
guard isDebugBuild else { return false }
if isRunningUnderXCTest(environment: environment) {
return false
}
// XCUITest launches the app as a separate process without XCTest env vars,
// so isRunningUnderXCTest() misses it. Check for any CMUX_UI_TEST_ env var.
if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) {
return false
}
guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty else {
return false
}
if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") {
return false
}
guard bundleIdentifier == baseDebugBundleIdentifier else {
return false
}
return launchTag(environment: environment) == nil
}
static func isRunningUnderXCTest(environment: [String: String]) -> Bool {
let indicators = [
"XCTestConfigurationFilePath",
"XCTestBundlePath",
"XCTestSessionIdentifier",
"XCInjectBundle",
"XCInjectBundleInto",
]
if indicators.contains(where: { key in
guard let value = environment[key] else { return false }
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}) {
return true
}
if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true {
return true
}
return false
}
static func socketPath(
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
isDebugBuild: Bool = SocketControlSettings.isDebugBuild,
currentUserID: uid_t = getuid(),
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
) -> String {
let fallback = defaultSocketPath(
bundleIdentifier: bundleIdentifier,
isDebugBuild: isDebugBuild,
currentUserID: currentUserID,
probeStableDefaultPathEntry: probeStableDefaultPathEntry
)
if let taggedDebugPath = taggedDebugSocketPath(
bundleIdentifier: bundleIdentifier,
environment: environment
) {
if isTruthy(environment[allowSocketPathOverrideKey]),
let override = environment["CMUX_SOCKET_PATH"],
!override.isEmpty {
return override
}
return taggedDebugPath
}
guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else {
return fallback
}
if shouldHonorSocketPathOverride(
environment: environment,
bundleIdentifier: bundleIdentifier,
isDebugBuild: isDebugBuild
) {
return override
}
return fallback
}
static func defaultSocketPath(
bundleIdentifier: String?,
isDebugBuild: Bool,
currentUserID: uid_t = getuid(),
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
) -> String {
if let taggedDebugPath = taggedDebugSocketPath(bundleIdentifier: bundleIdentifier, environment: [:]) {
return taggedDebugPath
}
if bundleIdentifier == "com.cmuxterm.app.nightly" {
return "/tmp/cmux-nightly.sock"
}
if isDebugLikeBundleIdentifier(bundleIdentifier) || isDebugBuild {
return "/tmp/cmux-debug.sock"
}
if isStagingBundleIdentifier(bundleIdentifier) {
return "/tmp/cmux-staging.sock"
}
return resolvedStableDefaultSocketPath(
currentUserID: currentUserID,
probeStableDefaultPathEntry: probeStableDefaultPathEntry
)
}
static func userScopedStableSocketPath(currentUserID: uid_t = getuid()) -> String {
stableSocketDirectoryURL()?
.appendingPathComponent("cmux-\(currentUserID).sock", isDirectory: false)
.path ?? "/tmp/cmux-\(currentUserID).sock"
}
static func resolvedStableDefaultSocketPath(
currentUserID: uid_t = getuid(),
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
) -> String {
switch probeStableDefaultPathEntry(stableDefaultSocketPath) {
case .missing:
return stableDefaultSocketPath
case .socket(let ownerUserID) where ownerUserID == currentUserID:
return stableDefaultSocketPath
case .socket, .other, .inaccessible:
return userScopedStableSocketPath(currentUserID: currentUserID)
}
}
static func recordLastSocketPath(_ path: String, filePath: String = lastSocketPathFile) {
let payload = Data((path + "\n").utf8)
writeSocketPathMarker(payload, to: filePath)
if filePath != legacyLastSocketPathFile {
writeSocketPathMarker(payload, to: legacyLastSocketPathFile)
}
}
static func shouldHonorSocketPathOverride(
environment: [String: String],
bundleIdentifier: String?,
isDebugBuild: Bool
) -> Bool {
if isTruthy(environment[allowSocketPathOverrideKey]) {
return true
}
if isDebugLikeBundleIdentifier(bundleIdentifier) || isStagingBundleIdentifier(bundleIdentifier) {
return true
}
return isDebugBuild
}
static func isDebugLikeBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
guard let bundleIdentifier else { return false }
return bundleIdentifier == "com.cmuxterm.app.debug"
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.")
}
/// Tagged DEV builds have bundle IDs like `com.cmuxterm.app.debug.<tag>`.
static func isTaggedDevBuild(bundleIdentifier: String? = Bundle.main.bundleIdentifier) -> Bool {
guard let bundleIdentifier else { return false }
return bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).")
}
static func taggedDebugSocketPath(
bundleIdentifier: String?,
environment: [String: String]
) -> String? {
let bundleId = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if bundleId.hasPrefix("\(baseDebugBundleIdentifier).") {
let suffix = String(bundleId.dropFirst(baseDebugBundleIdentifier.count + 1))
let slug = suffix
.replacingOccurrences(of: ".", with: "-")
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
if !slug.isEmpty {
return "/tmp/cmux-debug-\(slug).sock"
}
}
let tag = launchTag(environment: environment)?
.lowercased()
.replacingOccurrences(of: ".", with: "-")
.replacingOccurrences(of: "_", with: "-")
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
.joined(separator: "-")
guard bundleId == baseDebugBundleIdentifier,
let tag,
!tag.isEmpty else {
return nil
}
return "/tmp/cmux-debug-\(tag).sock"
}
static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
guard let bundleIdentifier else { return false }
return bundleIdentifier == "com.cmuxterm.app.staging"
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.")
}
static func stableSocketDirectoryURL(fileManager: FileManager = .default) -> URL? {
guard let appSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
return appSupportDirectory.appendingPathComponent(socketDirectoryName, isDirectory: true)
}
static func stableSocketFileURL(fileManager: FileManager = .default) -> URL? {
stableSocketDirectoryURL(fileManager: fileManager)?
.appendingPathComponent(stableSocketFileName, isDirectory: false)
}
static func lastSocketPathFileURL(fileManager: FileManager = .default) -> URL? {
stableSocketDirectoryURL(fileManager: fileManager)?
.appendingPathComponent(lastSocketPathFileName, isDirectory: false)
}
private static func writeSocketPathMarker(_ payload: Data, to filePath: String) {
let fileURL = URL(fileURLWithPath: filePath)
let parentURL = fileURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(
at: parentURL,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
try? payload.write(to: fileURL, options: .atomic)
}
private static func inspectStableDefaultSocketPathEntry(_ path: String) -> StableDefaultSocketPathEntry {
var st = stat()
guard lstat(path, &st) == 0 else {
let errnoCode = errno
if errnoCode == ENOENT {
return .missing
}
return .inaccessible(errnoCode: errnoCode)
}
let fileType = st.st_mode & mode_t(S_IFMT)
if fileType == mode_t(S_IFSOCK) {
return .socket(ownerUserID: st.st_uid)
}
return .other(ownerUserID: st.st_uid)
}
static func isTruthy(_ raw: String?) -> Bool {
guard let raw else { return false }
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
static func envOverrideEnabled(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Bool? {
guard let raw = environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
return nil
}
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return nil
}
}
static func envOverrideMode(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> SocketControlMode? {
guard let raw = environment["CMUX_SOCKET_MODE"], !raw.isEmpty else {
return nil
}
return parseMode(raw)
}
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(environment: environment) {
return overrideMode
}
return userMode == .off ? .cmuxOnly : userMode
}
if let overrideMode = envOverrideMode(environment: environment) {
return overrideMode
}
return userMode
}
}