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:
parent
4c733d4e8e
commit
18550e5d1f
6 changed files with 521 additions and 43 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue