XCUITest launches the app as a separate process that doesn't inherit XCTest env vars (XCTestConfigurationFilePath, etc.), so isRunningUnderXCTest() returns false. The app then hits shouldBlockUntaggedDebugLaunch() and exits with code 64, causing the test runner to hang waiting for the app to launch. Fix: detect CMUX_UI_TEST_* env vars set via XCUIApplication.launchEnvironment and skip the launch guard. Also revert the failed CMUX_TAG ci.yml workaround.
385 lines
13 KiB
Swift
385 lines
13 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
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 "Off"
|
|
case .cmuxOnly:
|
|
return "cmux processes only"
|
|
case .automation:
|
|
return "Automation mode"
|
|
case .password:
|
|
return "Password mode"
|
|
case .allowAll:
|
|
return "Full open access"
|
|
}
|
|
}
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .off:
|
|
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 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))
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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",
|
|
"XCInjectBundleInto",
|
|
]
|
|
return indicators.contains { key in
|
|
guard let value = environment[key] else { return false }
|
|
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
}
|
|
|
|
static func socketPath(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
|
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
|
|
) -> String {
|
|
let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild)
|
|
|
|
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) -> String {
|
|
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 "/tmp/cmux.sock"
|
|
}
|
|
|
|
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.")
|
|
}
|
|
|
|
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 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
|
|
}
|
|
}
|