Restore lazy keychain reads for socket password (#589)

Add a cached lazy keychain fallback to SocketControlPasswordStore so
that authentication paths in TerminalController can transparently read
a legacy keychain password without blocking on every request. The
keychain is read at most once and the result is cached behind an
NSLock. File-based and environment passwords still take priority.

Closes https://github.com/manaflow-ai/cmux/issues/579
This commit is contained in:
Lawrence Chen 2026-02-26 15:16:27 -08:00 committed by GitHub
parent df119c75d5
commit 847ce008ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 194 additions and 10 deletions

View file

@ -66,31 +66,65 @@ enum SocketControlPasswordStore {
private static let keychainMigrationVersion = 1
private static let legacyKeychainService = "com.cmuxterm.app.socket-control"
private static let legacyKeychainAccount = "local-socket-password"
private struct LazyKeychainFallbackCache {
var hasLoaded = false
var password: String?
}
private static let lazyKeychainFallbackLock = NSLock()
private static var lazyKeychainFallbackCache = LazyKeychainFallbackCache()
static func configuredPassword(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> String? {
if let envPassword = normalized(environment[SocketControlSettings.socketPasswordEnvKey]) {
return envPassword
}
return try? loadPassword(fileURL: fileURL)
let filePassword: String?
do {
filePassword = try loadPassword(fileURL: fileURL)
} catch {
filePassword = nil
}
if let filePassword {
return filePassword
}
guard allowLazyKeychainFallback else {
return nil
}
return cachedLazyKeychainFallbackPassword(loadKeychainPassword: loadKeychainPassword)
}
static func hasConfiguredPassword(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> Bool {
guard let configured = configuredPassword(environment: environment, fileURL: fileURL) else { return false }
guard let configured = configuredPassword(
environment: environment,
fileURL: fileURL,
allowLazyKeychainFallback: allowLazyKeychainFallback,
loadKeychainPassword: loadKeychainPassword
) else { return false }
return !configured.isEmpty
}
static func verify(
password candidate: String,
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> Bool {
guard let expected = configuredPassword(environment: environment, fileURL: fileURL), !expected.isEmpty else {
guard let expected = configuredPassword(
environment: environment,
fileURL: fileURL,
allowLazyKeychainFallback: allowLazyKeychainFallback,
loadKeychainPassword: loadKeychainPassword
), !expected.isEmpty else {
return false
}
return expected == candidate
@ -173,6 +207,12 @@ enum SocketControlPasswordStore {
try FileManager.default.removeItem(at: fileURL)
}
static func resetLazyKeychainFallbackCacheForTests() {
lazyKeychainFallbackLock.lock()
lazyKeychainFallbackCache = LazyKeychainFallbackCache()
lazyKeychainFallbackLock.unlock()
}
static func defaultPasswordFileURL(
appSupportDirectory: URL? = nil,
fileManager: FileManager = .default
@ -225,6 +265,19 @@ enum SocketControlPasswordStore {
#endif
}
private static func cachedLazyKeychainFallbackPassword(
loadKeychainPassword: () -> String?
) -> String? {
lazyKeychainFallbackLock.lock()
defer { lazyKeychainFallbackLock.unlock() }
if lazyKeychainFallbackCache.hasLoaded {
return lazyKeychainFallbackCache.password
}
lazyKeychainFallbackCache.hasLoaded = true
lazyKeychainFallbackCache.password = normalized(loadKeychainPassword())
return lazyKeychainFallbackCache.password
}
private static func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .newlines)

View file

@ -459,7 +459,7 @@ class TerminalController {
guard lowered == "auth" || lowered.hasPrefix("auth ") else {
return nil
}
guard SocketControlPasswordStore.hasConfiguredPassword() else {
guard SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) else {
return "ERROR: Password mode is enabled but no socket password is configured in Settings."
}
@ -472,7 +472,7 @@ class TerminalController {
guard !provided.isEmpty else {
return "ERROR: Missing password. Usage: auth <password>"
}
guard SocketControlPasswordStore.verify(password: provided) else {
guard SocketControlPasswordStore.verify(password: provided, allowLazyKeychainFallback: true) else {
return "ERROR: Invalid password"
}
authenticated = true
@ -496,7 +496,7 @@ class TerminalController {
return v2Error(id: id, code: "invalid_params", message: "auth.login requires params.password")
}
guard SocketControlPasswordStore.hasConfiguredPassword() else {
guard SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) else {
return v2Error(
id: id,
code: "auth_unconfigured",
@ -504,7 +504,7 @@ class TerminalController {
)
}
guard SocketControlPasswordStore.verify(password: provided) else {
guard SocketControlPasswordStore.verify(password: provided, allowLazyKeychainFallback: true) else {
return v2Error(id: id, code: "auth_failed", message: "Invalid password")
}
authenticated = true

View file

@ -7,6 +7,16 @@ import XCTest
#endif
final class SocketControlPasswordStoreTests: XCTestCase {
override func setUp() {
super.setUp()
SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests()
}
override func tearDown() {
SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests()
super.tearDown()
}
func testSaveLoadAndClearRoundTripUsesFileStorage() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
@ -43,6 +53,127 @@ final class SocketControlPasswordStoreTests: XCTestCase {
XCTAssertEqual(configured, "env-secret")
}
func testConfiguredPasswordLazyKeychainFallbackReadsOnlyOnceAndCaches() {
var readCount = 0
let withoutFallback = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: false,
loadKeychainPassword: {
readCount += 1
return "legacy-secret"
}
)
XCTAssertNil(withoutFallback)
XCTAssertEqual(readCount, 0)
let firstWithFallback = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: {
readCount += 1
return "legacy-secret"
}
)
XCTAssertEqual(firstWithFallback, "legacy-secret")
XCTAssertEqual(readCount, 1)
let secondWithFallback = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: {
readCount += 1
return "new-secret"
}
)
XCTAssertEqual(secondWithFallback, "legacy-secret")
XCTAssertEqual(readCount, 1)
}
func testConfiguredPasswordLazyKeychainFallbackCachesMissingValue() {
var readCount = 0
let first = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: {
readCount += 1
return nil
}
)
XCTAssertNil(first)
XCTAssertEqual(readCount, 1)
let second = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: {
readCount += 1
return "should-not-be-read"
}
)
XCTAssertNil(second)
XCTAssertEqual(readCount, 1)
}
func testConfiguredPasswordPrefersStoredFileOverLazyKeychainFallback() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
try SocketControlPasswordStore.savePassword("stored-secret", fileURL: fileURL)
var readCount = 0
let configured = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: fileURL,
allowLazyKeychainFallback: true,
loadKeychainPassword: {
readCount += 1
return "legacy-secret"
}
)
XCTAssertEqual(configured, "stored-secret")
XCTAssertEqual(readCount, 0)
}
func testHasConfiguredAndVerifyReuseSingleLazyKeychainRead() {
var readCount = 0
let loader = {
readCount += 1
return "legacy-secret"
}
XCTAssertTrue(
SocketControlPasswordStore.hasConfiguredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: loader
)
)
XCTAssertEqual(readCount, 1)
XCTAssertTrue(
SocketControlPasswordStore.verify(
password: "legacy-secret",
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: loader
)
)
XCTAssertEqual(readCount, 1)
}
func testDefaultPasswordFileURLUsesCmuxAppSupportPath() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)