Merge pull request #299 from manaflow-ai/issue-296-access-control-modes
Add socket access control modes including password + full open access
This commit is contained in:
commit
4ca14ea028
7 changed files with 889 additions and 79 deletions
105
CLI/cmux.swift
105
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 == "-v" || arg == "--version" {
|
||||
print(versionSummary())
|
||||
return
|
||||
|
|
@ -471,6 +521,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.
|
||||
|
|
@ -481,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":
|
||||
|
|
@ -524,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
|
||||
|
|
@ -543,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 {
|
||||
|
|
@ -551,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":
|
||||
|
|
@ -627,7 +685,7 @@ struct CMUXCLI {
|
|||
if let unknown = remaining.first(where: { $0.hasPrefix("--") }) {
|
||||
throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --command <text>")
|
||||
}
|
||||
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 {
|
||||
|
|
@ -772,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":
|
||||
|
|
@ -892,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 {
|
||||
|
|
@ -1016,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
|
||||
|
|
@ -1041,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":
|
||||
|
|
@ -1049,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",
|
||||
|
|
@ -1133,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) {
|
||||
|
|
@ -4028,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")
|
||||
|
|
@ -4068,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,
|
||||
|
|
@ -4599,13 +4665,16 @@ struct CMUXCLI {
|
|||
cmux - control cmux via Unix socket
|
||||
|
||||
Usage:
|
||||
cmux [--socket PATH] [--window WINDOW] [--json] [--id-format refs|uuids|both] [--version] <command> [options]
|
||||
cmux [--socket PATH] [--window WINDOW] [--password PASSWORD] [--json] [--id-format refs|uuids|both] [--version] <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:
|
||||
version
|
||||
ping
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ import subprocess
|
|||
import sys
|
||||
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
|
||||
|
|
@ -68,34 +71,169 @@ 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():
|
||||
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
|
||||
|
||||
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"
|
||||
)))
|
||||
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]
|
||||
|
||||
|
||||
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}"]
|
||||
subprocess.Popen(["open", "-a", app_path] + env_args)
|
||||
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", "-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)
|
||||
|
|
@ -249,8 +387,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 +443,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 +459,178 @@ 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
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -337,7 +647,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 +670,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 +694,19 @@ 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)
|
||||
run_test(test_password_mode_cli_exit_code, 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue