From 501ea10ddd8ff17a9516d60ba2faa570fa41650e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 25 Sep 2026 13:43:30 -0700 Subject: [PATCH] fix(codex): restart daemon after system account promotion Refresh an already-running managed app-server for the destination Codex home after replacing auth. Preserve successful promotion and show a recovery note if daemon verification or restart fails. Reuse account-promotion adapters and auth preparation to keep production code size below the baseline. Fixes #3990. Thanks @massdo for isolating the persistent daemon auth state. --- CHANGELOG.md | 1 + .../CodexAccountPromotionCoordinator.swift | 3 + .../CodexAccountPromotionExecution.swift | 36 ++--- .../CodexAccountPromotionPreparation.swift | 122 +++++------------ .../CodexAccountPromotionService.swift | 72 ++++------ Sources/CodexBar/CodexAppServerDaemon.swift | 67 ++++++++++ .../CodexBar/PreferencesProvidersPane.swift | 5 +- .../Codex/CodexProviderImplementation.swift | 3 + Sources/CodexBarCore/CodexHomeScope.swift | 17 +++ .../CodexAccountPromotionExecutionTests.swift | 6 +- .../CodexAccountPromotionPlanningTests.swift | 2 +- ...odexAccountPromotionPreparationTests.swift | 6 +- .../CodexAccountPromotionServiceTests.swift | 7 +- .../CodexAccountPromotionTestSupport.swift | 11 +- .../CodexAppServerDaemonTests.swift | 125 ++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 4 +- docs/codex.md | 8 ++ 17 files changed, 313 insertions(+), 182 deletions(-) create mode 100644 Sources/CodexBar/CodexAppServerDaemon.swift create mode 100644 Tests/CodexBarTests/CodexAppServerDaemonTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c10f5abb4..992ceff7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ### Fixed +- Codex: restart the running background app-server after switching the System Account, with a recovery note if the CLI cannot restart it. Fixes #3990. Thanks @massdo! - OpenCode Go: include recorded local token counts in daily and per-model history without inventing costs or treating missing counts as zero (#3995). Thanks @Yuxin-Qiao! - Usage & Spend: start long daily ledgers with the newest 30 rows and a Show all control, reducing initial layout work while preserving full-period totals and charts (#3998). Thanks @Yuxin-Qiao! - Security: preserve browser-cookie denial across restarts and CLI configuration, and stage credential writes privately before atomic replacement (reported in #3986). Thanks @bo-vavrik! diff --git a/Sources/CodexBar/CodexAccountPromotionCoordinator.swift b/Sources/CodexBar/CodexAccountPromotionCoordinator.swift index a3797985ca..476b4daeb7 100644 --- a/Sources/CodexBar/CodexAccountPromotionCoordinator.swift +++ b/Sources/CodexBar/CodexAccountPromotionCoordinator.swift @@ -13,6 +13,7 @@ final class CodexAccountPromotionCoordinator { weak var managedAccountCoordinator: ManagedCodexAccountCoordinator? private(set) var isAuthenticatingLiveAccount = false private(set) var isPromotingSystemAccount = false + private(set) var daemonRestartNote: String? init( service: CodexAccountPromotionService, @@ -40,10 +41,12 @@ final class CodexAccountPromotionCoordinator { } self.isPromotingSystemAccount = true + self.daemonRestartNote = nil defer { self.isPromotingSystemAccount = false } do { let result = try await self.service.promoteManagedAccount(id: managedAccountID) + self.daemonRestartNote = result.daemonRestartNote return .success(result) } catch { return .failure(Self.mapUserFacingError(error)) diff --git a/Sources/CodexBar/CodexAccountPromotionExecution.swift b/Sources/CodexBar/CodexAccountPromotionExecution.swift index a85cb7a1d1..ac42ccf4b6 100644 --- a/Sources/CodexBar/CodexAccountPromotionExecution.swift +++ b/Sources/CodexBar/CodexAccountPromotionExecution.swift @@ -6,10 +6,6 @@ private struct CodexPreparedImportedAccount { let homeURL: URL } -struct CodexDisplacedLivePreservationExecutionResult: Equatable { - let displacedLiveDisposition: CodexAccountPromotionResult.DisplacedLiveDisposition -} - @MainActor struct CodexDisplacedLivePreservationExecutor { private let store: any ManagedCodexAccountStoring @@ -32,7 +28,7 @@ struct CodexDisplacedLivePreservationExecutor { func execute( plan: CodexDisplacedLivePreservationPlan, context: PreparedPromotionContext) throws - -> CodexDisplacedLivePreservationExecutionResult + -> CodexAccountPromotionResult.DisplacedLiveDisposition { /* Safety contract: @@ -42,7 +38,7 @@ struct CodexDisplacedLivePreservationExecutor { */ switch plan { case .none: - return CodexDisplacedLivePreservationExecutionResult(displacedLiveDisposition: .none) + return .none case let .reject(reason): throw self.error(for: reason) @@ -60,8 +56,7 @@ struct CodexDisplacedLivePreservationExecutor { } let refreshed = try self.refreshExistingManagedAccount(destination, from: context) - return CodexDisplacedLivePreservationExecutionResult( - displacedLiveDisposition: .alreadyManaged(managedAccountID: refreshed.id)) + return .alreadyManaged(managedAccountID: refreshed.id) } } @@ -87,10 +82,10 @@ struct CodexDisplacedLivePreservationExecutor { } let importedHomeURL = self.homeFactory.makeHomeURL() - guard CodexCredentialFileAccess.permits(CodexAccountPromotionService.authFileURL(for: importedHomeURL)) else { + guard CodexCredentialFileAccess.permits(CodexAuthFingerprint.authFileURL(homePath: importedHomeURL.path)) else { throw CodexAccountPromotionError.displacedLiveImportFailed } - let importedAccountID = Self.accountID(for: importedHomeURL) + let importedAccountID = UUID(uuidString: importedHomeURL.lastPathComponent) ?? UUID() do { try self.fileManager.createDirectory(at: importedHomeURL, withIntermediateDirectories: true) @@ -129,7 +124,7 @@ struct CodexDisplacedLivePreservationExecutor { private func commitImportedAccount( _ importedAccount: CodexPreparedImportedAccount, excludingTargetID: UUID) throws - -> CodexDisplacedLivePreservationExecutionResult + -> CodexAccountPromotionResult.DisplacedLiveDisposition { do { let latestManagedAccounts = try self.store.loadAccounts() @@ -151,12 +146,11 @@ struct CodexDisplacedLivePreservationExecutor { private func resolveImportedAccountAfterCommit( _ importedAccount: CodexPreparedImportedAccount, excludingTargetID: UUID) throws - -> CodexDisplacedLivePreservationExecutionResult + -> CodexAccountPromotionResult.DisplacedLiveDisposition { let persistedManagedAccounts = try self.store.loadAccounts() if persistedManagedAccounts.account(id: importedAccount.account.id) != nil { - return CodexDisplacedLivePreservationExecutionResult( - displacedLiveDisposition: .imported(managedAccountID: importedAccount.account.id)) + return .imported(managedAccountID: importedAccount.account.id) } guard let existingManagedAccount = self.repairDestination( @@ -190,8 +184,7 @@ struct CodexDisplacedLivePreservationExecutor { URL(fileURLWithPath: existingManagedAccount.managedHomePath, isDirectory: true)) } - return CodexDisplacedLivePreservationExecutionResult( - displacedLiveDisposition: .alreadyManaged(managedAccountID: existingManagedAccount.id)) + return .alreadyManaged(managedAccountID: existingManagedAccount.id) } private func validateRepairDestination( @@ -279,7 +272,7 @@ struct CodexDisplacedLivePreservationExecutor { lastAuthenticatedAt: now) let refreshedHomeURL = URL(fileURLWithPath: persistedManagedAccount.managedHomePath, isDirectory: true) - guard CodexCredentialFileAccess.permits(CodexAccountPromotionService.authFileURL(for: refreshedHomeURL)) + guard CodexCredentialFileAccess.permits(CodexAuthFingerprint.authFileURL(homePath: refreshedHomeURL.path)) else { throw CodexAccountPromotionError.displacedLiveImportFailed } @@ -306,20 +299,17 @@ struct CodexDisplacedLivePreservationExecutor { } private func writeManagedAuthData(_ data: Data, to homeURL: URL) throws { - let authFileURL = CodexAccountPromotionService.authFileURL(for: homeURL) + let authFileURL = CodexAuthFingerprint.authFileURL(homePath: homeURL.path) guard CodexCredentialFileAccess.permits(authFileURL) else { throw CodexOAuthCredentialsError.notFound } try CredentialFileWriter.writePrivate(data, to: authFileURL) } private func removeManagedHomeIfSafe(_ homeURL: URL) throws { - guard CodexCredentialFileAccess.permits(CodexAccountPromotionService.authFileURL(for: homeURL)) else { return } + guard CodexCredentialFileAccess.permits(CodexAuthFingerprint.authFileURL(homePath: homeURL.path)) + else { return } try self.homeFactory.validateManagedHomeForDeletion(homeURL) if self.fileManager.fileExists(atPath: homeURL.path) { try self.fileManager.removeItem(at: homeURL) } } - - private static func accountID(for homeURL: URL) -> UUID { - UUID(uuidString: homeURL.lastPathComponent) ?? UUID() - } } diff --git a/Sources/CodexBar/CodexAccountPromotionPreparation.swift b/Sources/CodexBar/CodexAccountPromotionPreparation.swift index fe95c84534..270ef00b6c 100644 --- a/Sources/CodexBar/CodexAccountPromotionPreparation.swift +++ b/Sources/CodexBar/CodexAccountPromotionPreparation.swift @@ -13,7 +13,6 @@ struct PreparedAuthMaterial { let homeURL: URL let rawData: Data let credentials: CodexOAuthCredentials - let runtimeAccount: CodexAuthBackedAccount let authIdentity: PreparedIdentity } @@ -91,7 +90,6 @@ struct PreparedLiveAccount { struct PreparedPromotionContext { let snapshot: CodexAccountReconciliationSnapshot - let managedAccounts: ManagedCodexAccountSet let storedManagedAccounts: [PreparedStoredManagedAccount] let target: PreparedStoredManagedAccount let live: PreparedLiveAccount @@ -99,28 +97,12 @@ struct PreparedPromotionContext { @MainActor struct PreparedPromotionContextBuilder { - private let store: any ManagedCodexAccountStoring - private let workspaceResolver: any ManagedCodexWorkspaceResolving - private let snapshotLoader: any CodexAccountReconciliationSnapshotLoading - private let authMaterialReader: any CodexAuthMaterialReading - private let baseEnvironment: [String: String] - private let fileManager: FileManager - - init( - store: any ManagedCodexAccountStoring, - workspaceResolver: any ManagedCodexWorkspaceResolving, - snapshotLoader: any CodexAccountReconciliationSnapshotLoading, - authMaterialReader: any CodexAuthMaterialReading, - baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, - fileManager: FileManager = .default) - { - self.store = store - self.workspaceResolver = workspaceResolver - self.snapshotLoader = snapshotLoader - self.authMaterialReader = authMaterialReader - self.baseEnvironment = baseEnvironment - self.fileManager = fileManager - } + let store: any ManagedCodexAccountStoring + let workspaceResolver: any ManagedCodexWorkspaceResolving + let snapshotLoader: any CodexAccountReconciliationSnapshotLoading + let authMaterialReader: any CodexAuthMaterialReading + let baseEnvironment: [String: String] + let fileManager: FileManager func build(targetID: UUID) async throws -> PreparedPromotionContext { let snapshot = self.snapshotLoader.loadSnapshot() @@ -128,8 +110,11 @@ struct PreparedPromotionContextBuilder { var preparedAccounts: [PreparedStoredManagedAccount] = [] preparedAccounts.reserveCapacity(managedAccounts.accounts.count) for account in managedAccounts.accounts { - let preparedAccount = try await self.prepareStoredManagedAccount(account) - preparedAccounts.append(preparedAccount) + await preparedAccounts.append(PreparedStoredManagedAccount( + persisted: account, + persistedIdentity: Self.persistedIdentity(from: account), + homeState: self.prepareManagedHomeState( + homeURL: URL(fileURLWithPath: account.managedHomePath, isDirectory: true)))) } guard let target = preparedAccounts.first(where: { $0.persisted.id == targetID }) else { @@ -139,58 +124,38 @@ struct PreparedPromotionContextBuilder { let live = await self.prepareLiveAccount() return PreparedPromotionContext( snapshot: snapshot, - managedAccounts: managedAccounts, storedManagedAccounts: preparedAccounts, target: target, live: live) } - private func prepareStoredManagedAccount( - _ account: ManagedCodexAccount) async throws - -> PreparedStoredManagedAccount - { - let homeURL = URL(fileURLWithPath: account.managedHomePath, isDirectory: true) - let persistedIdentity = Self.persistedIdentity(from: account) - let homeState = await self.prepareManagedHomeState(homeURL: homeURL) - - return PreparedStoredManagedAccount( - persisted: account, - persistedIdentity: persistedIdentity, - homeState: homeState) - } - private func prepareManagedHomeState(homeURL: URL) async -> PreparedManagedHomeState { - let readResult = self.readAuthData(homeURL: homeURL) - switch readResult { - case .missing: - return .missing(homeURL: homeURL) - case .unreadable: - return .unreadable(homeURL: homeURL) - case let .readable(rawData): + do { + guard let rawData = try self.authMaterialReader.readAuthData(homeURL: homeURL) else { + return .missing(homeURL: homeURL) + } guard let authMaterial = await self.inspectAuthMaterial(homeURL: homeURL, rawData: rawData) else { return .unreadable(homeURL: homeURL) } return .readable(authMaterial) + } catch { + return .unreadable(homeURL: homeURL) } } private func prepareLiveAccount() async -> PreparedLiveAccount { - let liveHomeURL = self.liveHomeURL() - let readResult = self.readAuthData(homeURL: liveHomeURL) - switch readResult { - case .missing: - return PreparedLiveAccount(homeState: .missing(homeURL: liveHomeURL)) - case .unreadable: - return PreparedLiveAccount(homeState: .unreadable(homeURL: liveHomeURL)) - case let .readable(rawData): - guard let authMaterial = await self.inspectAuthMaterial(homeURL: liveHomeURL, rawData: rawData) else { - return PreparedLiveAccount(homeState: .unreadable(homeURL: liveHomeURL)) - } - if Self.isAPIKeyOnly(credentials: authMaterial.credentials, rawData: authMaterial.rawData) { - return PreparedLiveAccount(homeState: .apiKeyOnly(authMaterial)) + let liveHomeURL = CodexHomeScope.ambientHomeURL(env: self.baseEnvironment, fileManager: self.fileManager) + let homeState: PreparedLiveHomeState = switch await self.prepareManagedHomeState(homeURL: liveHomeURL) { + case .missing: .missing(homeURL: liveHomeURL) + case .unreadable: .unreadable(homeURL: liveHomeURL) + case let .readable(material): + if Self.isAPIKeyOnly(credentials: material.credentials, rawData: material.rawData) { + .apiKeyOnly(material) + } else { + .readable(material) } - return PreparedLiveAccount(homeState: .readable(authMaterial)) } + return PreparedLiveAccount(homeState: homeState) } private func inspectAuthMaterial(homeURL: URL, rawData: Data) async -> PreparedAuthMaterial? { @@ -208,12 +173,11 @@ struct PreparedPromotionContextBuilder { homeURL: homeURL, rawData: rawData, credentials: credentials, - runtimeAccount: runtimeAccount, authIdentity: authIdentity) } private func derivedIdentity(homePath: String, runtimeAccount: CodexAuthBackedAccount) async -> PreparedIdentity { - let normalizedEmail = Self.normalizeEmail(runtimeAccount.email) + let normalizedEmail = CodexIdentityResolver.normalizeEmail(runtimeAccount.email) let normalizedIdentity = Self.normalizedIdentity(runtimeAccount.identity, email: normalizedEmail) let providerAccountID: String? = switch normalizedIdentity { case let .providerAccount(id): @@ -238,7 +202,7 @@ struct PreparedPromotionContextBuilder { } private static func persistedIdentity(from account: ManagedCodexAccount) -> PreparedIdentity { - let normalizedEmail = Self.normalizeEmail(account.email) + let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) let providerAccountID = account.effectiveWorkspaceAccountID let identity = Self.normalizedIdentity( CodexIdentityResolver.resolve(accountId: providerAccountID, email: normalizedEmail), @@ -252,22 +216,6 @@ struct PreparedPromotionContextBuilder { workspaceAccountID: providerAccountID) } - private func liveHomeURL() -> URL { - CodexHomeScope.ambientHomeURL(env: self.baseEnvironment, fileManager: self.fileManager) - } - - private func readAuthData(homeURL: URL) -> PreparedAuthReadState { - do { - let rawData = try self.authMaterialReader.readAuthData(homeURL: homeURL) - guard let rawData else { - return .missing - } - return .readable(rawData) - } catch { - return .unreadable - } - } - static func runtimeAccount(from rawData: Data) throws -> CodexAuthBackedAccount { guard let json = try JSONSerialization.jsonObject(with: rawData) as? [String: Any] else { throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON") @@ -281,7 +229,7 @@ struct PreparedPromotionContextBuilder { let authDict = payload?["https://api.openai.com/auth"] as? [String: Any] let profileDict = payload?["https://api.openai.com/profile"] as? [String: Any] - let email = Self.normalizeEmail( + let email = CodexIdentityResolver.normalizeEmail( (payload?["email"] as? String) ?? (profileDict?["email"] as? String)) let plan = Self.normalizedField( (authDict?["chatgpt_plan_type"] as? String) ?? (payload?["chatgpt_plan_type"] as? String)) @@ -305,10 +253,6 @@ struct PreparedPromotionContextBuilder { return value } - private static func normalizeEmail(_ email: String?) -> String? { - CodexIdentityResolver.normalizeEmail(email) - } - private static func normalizedIdentity(_ identity: CodexIdentity, email: String?) -> CodexIdentity { guard let email else { return identity } return CodexIdentityMatcher.normalized(identity, fallbackEmail: email) @@ -360,9 +304,3 @@ struct PreparedPromotionContextBuilder { return nil } } - -private enum PreparedAuthReadState { - case missing - case unreadable - case readable(Data) -} diff --git a/Sources/CodexBar/CodexAccountPromotionService.swift b/Sources/CodexBar/CodexAccountPromotionService.swift index e7a7b51002..db7ecd96a6 100644 --- a/Sources/CodexBar/CodexAccountPromotionService.swift +++ b/Sources/CodexBar/CodexAccountPromotionService.swift @@ -24,22 +24,25 @@ protocol CodexAccountScopedRefreshing { func refreshCodexAccountScopedState(allowDisabled: Bool) async } -@MainActor -struct SettingsStoreCodexAccountReconciliationSnapshotLoader: CodexAccountReconciliationSnapshotLoading { - private let settingsStore: SettingsStore +extension SettingsStore: CodexAccountReconciliationSnapshotLoading, CodexActiveSourceWriting { + func loadSnapshot() -> CodexAccountReconciliationSnapshot { + self.codexAccountReconciliationSnapshot + } - init(settingsStore: SettingsStore) { - self.settingsStore = settingsStore + func writeCodexActiveSource(_ source: CodexActiveSource) { + self.codexActiveSource = source } +} - func loadSnapshot() -> CodexAccountReconciliationSnapshot { - self.settingsStore.codexAccountReconciliationSnapshot +extension UsageStore: CodexAccountScopedRefreshing { + func refreshCodexAccountScopedState(allowDisabled: Bool) async { + await self.refreshCodexAccountScopedState(allowDisabled: allowDisabled, phaseDidChange: nil) } } struct DefaultCodexAuthMaterialReader: CodexAuthMaterialReading { func readAuthData(homeURL: URL) throws -> Data? { - let authFileURL = CodexAccountPromotionService.authFileURL(for: homeURL) + let authFileURL = CodexAuthFingerprint.authFileURL(homePath: homeURL.path) guard CodexCredentialFileAccess.fileExists(at: authFileURL) else { return nil } @@ -49,7 +52,7 @@ struct DefaultCodexAuthMaterialReader: CodexAuthMaterialReading { struct DefaultCodexLiveAuthSwapper: CodexLiveAuthSwapping { func swapLiveAuthData(_ data: Data, liveHomeURL: URL) throws { - let liveAuthURL = CodexAccountPromotionService.authFileURL(for: liveHomeURL) + let liveAuthURL = CodexAuthFingerprint.authFileURL(homePath: liveHomeURL.path) guard CodexCredentialFileAccess.permits(liveAuthURL) else { throw CodexOAuthCredentialsError.notFound } if try CodexCredentialFileAccess.substituteWriteForTesting(at: liveAuthURL) { return @@ -60,32 +63,6 @@ struct DefaultCodexLiveAuthSwapper: CodexLiveAuthSwapping { } } -@MainActor -struct SettingsStoreCodexActiveSourceWriter: CodexActiveSourceWriting { - private let settingsStore: SettingsStore - - init(settingsStore: SettingsStore) { - self.settingsStore = settingsStore - } - - func writeCodexActiveSource(_ source: CodexActiveSource) { - self.settingsStore.codexActiveSource = source - } -} - -@MainActor -struct UsageStoreCodexAccountScopedRefresher: CodexAccountScopedRefreshing { - private let usageStore: UsageStore - - init(usageStore: UsageStore) { - self.usageStore = usageStore - } - - func refreshCodexAccountScopedState(allowDisabled: Bool) async { - await self.usageStore.refreshCodexAccountScopedState(allowDisabled: allowDisabled) - } -} - struct CodexAccountPromotionResult: Equatable { enum Outcome: Equatable { case promoted @@ -103,6 +80,7 @@ struct CodexAccountPromotionResult: Equatable { let displacedLiveDisposition: DisplacedLiveDisposition let didMutateLiveAuth: Bool let resultingActiveSource: CodexActiveSource + var daemonRestartNote: String? } enum CodexAccountPromotionError: Error, Equatable { @@ -123,38 +101,38 @@ enum CodexAccountPromotionError: Error, Equatable { final class CodexAccountPromotionService { private let store: any ManagedCodexAccountStoring private let homeFactory: any ManagedCodexHomeProducing - private let identityReader: any ManagedCodexIdentityReading private let workspaceResolver: any ManagedCodexWorkspaceResolving private let snapshotLoader: any CodexAccountReconciliationSnapshotLoading private let authMaterialReader: any CodexAuthMaterialReading private let liveAuthSwapper: any CodexLiveAuthSwapping private let activeSourceWriter: any CodexActiveSourceWriting private let accountScopedRefresher: any CodexAccountScopedRefreshing + private let daemon: CodexAppServerDaemon private let baseEnvironment: [String: String] private let fileManager: FileManager init( store: any ManagedCodexAccountStoring, homeFactory: any ManagedCodexHomeProducing, - identityReader: any ManagedCodexIdentityReading, workspaceResolver: any ManagedCodexWorkspaceResolving = DefaultManagedCodexWorkspaceResolver(), snapshotLoader: any CodexAccountReconciliationSnapshotLoading, authMaterialReader: any CodexAuthMaterialReading, liveAuthSwapper: any CodexLiveAuthSwapping, activeSourceWriter: any CodexActiveSourceWriting, accountScopedRefresher: any CodexAccountScopedRefreshing, + daemon: CodexAppServerDaemon = CodexAppServerDaemon(), baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default) { self.store = store self.homeFactory = homeFactory - self.identityReader = identityReader self.workspaceResolver = workspaceResolver self.snapshotLoader = snapshotLoader self.authMaterialReader = authMaterialReader self.liveAuthSwapper = liveAuthSwapper self.activeSourceWriter = activeSourceWriter self.accountScopedRefresher = accountScopedRefresher + self.daemon = daemon self.baseEnvironment = baseEnvironment self.fileManager = fileManager } @@ -168,13 +146,12 @@ final class CodexAccountPromotionService { self.init( store: FileManagedCodexAccountStore(fileManager: fileManager), homeFactory: ManagedCodexHomeFactory(fileManager: fileManager), - identityReader: DefaultManagedCodexIdentityReader(), workspaceResolver: DefaultManagedCodexWorkspaceResolver(), - snapshotLoader: SettingsStoreCodexAccountReconciliationSnapshotLoader(settingsStore: settingsStore), + snapshotLoader: settingsStore, authMaterialReader: DefaultCodexAuthMaterialReader(), liveAuthSwapper: DefaultCodexLiveAuthSwapper(), - activeSourceWriter: SettingsStoreCodexActiveSourceWriter(settingsStore: settingsStore), - accountScopedRefresher: UsageStoreCodexAccountScopedRefresher(usageStore: usageStore), + activeSourceWriter: settingsStore, + accountScopedRefresher: usageStore, baseEnvironment: baseEnvironment, fileManager: fileManager) } @@ -220,18 +197,17 @@ final class CodexAccountPromotionService { } self.activeSourceWriter.writeCodexActiveSource(.liveSystem) + let daemonRestartNote = await self.daemon.restartIfRunning( + homeURL: context.live.homeURL, environment: self.baseEnvironment) await self.accountScopedRefresher.refreshCodexAccountScopedState(allowDisabled: true) return CodexAccountPromotionResult( targetManagedAccountID: id, outcome: .promoted, - displacedLiveDisposition: executionResult.displacedLiveDisposition, + displacedLiveDisposition: executionResult, didMutateLiveAuth: true, - resultingActiveSource: .liveSystem) - } - - nonisolated static func authFileURL(for homeURL: URL) -> URL { - homeURL.appendingPathComponent("auth.json", isDirectory: false) + resultingActiveSource: .liveSystem, + daemonRestartNote: daemonRestartNote) } private func convergedActiveSource(for context: PreparedPromotionContext) -> CodexActiveSource? { diff --git a/Sources/CodexBar/CodexAppServerDaemon.swift b/Sources/CodexBar/CodexAppServerDaemon.swift new file mode 100644 index 0000000000..422e50fdef --- /dev/null +++ b/Sources/CodexBar/CodexAppServerDaemon.swift @@ -0,0 +1,67 @@ +import CodexBarCore +import Foundation + +@MainActor +struct CodexAppServerDaemon { + private struct PIDRecord: Decodable { let pid: Int32 } + private struct Version: Decodable { + let status: String + let backend: String? + let socketPath: String + } + + var isAppServerProcess: (Int32) -> Bool = CodexHomeScope.isAppServerProcess + var run: (String, [String: String]) async throws -> String = Self.runCommand + + func restartIfRunning(homeURL: URL, environment: [String: String]) async -> String? { + let home = homeURL.resolvingSymlinksInPath().standardizedFileURL + // Codex uses separate PID records for legacy and daemon-owned installations. + let running = ["daemon.pid", "app-server.pid"].contains { name in + let url = home.appendingPathComponent("app-server-daemon/\(name)") + guard let data = try? Data(contentsOf: url), + let record = try? JSONDecoder().decode(PIDRecord.self, from: data) + else { return false } + return self.isAppServerProcess(record.pid) + } + guard running else { return nil } + let env = CodexHomeScope.scopedEnvironment(base: environment, codexHome: home.path) + let log = CodexBarLog.logger("codex-account-promotion") + var phase = "detect" + do { + let output = try await self.run("version", env) + let version = try JSONDecoder().decode(Version.self, from: Data(output.utf8)) + // The CLI validates its PID/start-time record and probes this home's control socket. + guard version.status == "running", version.backend == "pid", + URL(fileURLWithPath: version.socketPath).resolvingSymlinksInPath().standardizedFileURL == + home.appendingPathComponent("app-server-control/app-server-control.sock") + else { return nil } + phase = "restart" + _ = try await self.run("restart", env) + log.info("Codex daemon restarted after account promotion") + return nil + } catch { + log.warning("Codex daemon refresh failed", metadata: ["phase": phase]) + return L("Account switched; restart the Codex background server manually.") + } + } + + private static func runCommand(_ command: String, environment: [String: String]) async throws -> String { + var env = environment + let loginPATH = LoginShellPathCache.shared.current + env["PATH"] = PathBuilder.effectivePATH(purposes: [.rpc, .nodeTooling], env: env, loginPATH: loginPATH) + guard let binary = BinaryLocator.resolveCodexBinary(env: env, loginPATH: loginPATH) else { + // Provider-specific by design: this operation requires the Codex CLI. + throw SubprocessRunnerError.binaryNotFound("codex") + } + let arguments = ["app-server", "daemon", command] + let result = if command == "restart" { + // Once launched, let the CLI finish its external mutation even if the menu task is cancelled. + try await SubprocessRunner.runToCompletion( + binary: binary, arguments: arguments, environment: env, label: "codex-daemon-restart") + } else { + try await SubprocessRunner.run( + binary: binary, arguments: arguments, environment: env, timeout: 10, label: "codex-daemon-version") + } + return result.stdout + } +} diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index e7ff778108..5e74a94b54 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -269,8 +269,11 @@ struct ProvidersPane: View { } let result = await self.codexAccountPromotionCoordinator.promote(managedAccountID: managedAccountID) - if case let .failure(error) = result { + switch result { + case let .failure(error): self.codexAccountsNotice = CodexAccountsSectionNotice(text: error.message, tone: .warning) + case let .success(promotion): + self.codexAccountsNotice = promotion.daemonRestartNote.map { .init(text: $0, tone: .warning) } } } diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index e4f50016ab..ab1b84f49b 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -262,6 +262,9 @@ struct CodexProviderImplementation: ProviderImplementation { if context.codexWorkspacesMenuEnabled { entries.append(.action(L("Workspaces"), .openCodexWorkspaces)) } + if let note = context.codexAccountPromotionCoordinator?.daemonRestartNote { + entries.append(.text(note, .secondary)) + } let submenuItems = Self.systemAccountMenuItems( projection: context.settings.codexVisibleAccountProjection, diff --git a/Sources/CodexBarCore/CodexHomeScope.swift b/Sources/CodexBarCore/CodexHomeScope.swift index e525b787a4..2d18c391ff 100644 --- a/Sources/CodexBarCore/CodexHomeScope.swift +++ b/Sources/CodexBarCore/CodexHomeScope.swift @@ -1,6 +1,23 @@ import Foundation public enum CodexHomeScope { + public static func isAppServerProcess(_ pid: Int32) -> Bool { + #if canImport(Darwin) + guard pid > 0, let arguments = DarwinProcessEnumerator.arguments(pid: pid) else { return false } + return self.isAppServer(arguments: arguments) + #else + return false + #endif + } + + static func isAppServer(arguments: [String]) -> Bool { + guard let executable = arguments.first else { return false } + // Provider-specific by design: match the managed Codex daemon's native command. + return URL(fileURLWithPath: executable).lastPathComponent == "codex" && + arguments.dropFirst().starts(with: ["app-server"]) && + arguments.contains("--listen") && arguments.contains("unix://") + } + public static func normalizedHomePath( _ rawPath: String?, fileManager: FileManager = .default) diff --git a/Tests/CodexBarTests/CodexAccountPromotionExecutionTests.swift b/Tests/CodexBarTests/CodexAccountPromotionExecutionTests.swift index 4063fc7951..748c257c66 100644 --- a/Tests/CodexBarTests/CodexAccountPromotionExecutionTests.swift +++ b/Tests/CodexBarTests/CodexAccountPromotionExecutionTests.swift @@ -139,7 +139,7 @@ struct CodexAccountPromotionExecutionTests { let result = try executor.execute(plan: .importNew(reason: .noExistingManagedDestination), context: context) - #expect(result.displacedLiveDisposition == .alreadyManaged(managedAccountID: concurrentManaged.id)) + #expect(result == .alreadyManaged(managedAccountID: concurrentManaged.id)) let accounts = try container.loadAccounts().accounts let repaired = try #require(accounts.first(where: { $0.id == concurrentManaged.id })) #expect(repaired.providerAccountID == "acct-alpha") @@ -180,7 +180,7 @@ struct CodexAccountPromotionExecutionTests { let result = try executor.execute(plan: .importNew(reason: .noExistingManagedDestination), context: context) - #expect(result.displacedLiveDisposition == .alreadyManaged(managedAccountID: concurrentManaged.id)) + #expect(result == .alreadyManaged(managedAccountID: concurrentManaged.id)) let accounts = try container.loadAccounts().accounts let repaired = try #require(accounts.first(where: { $0.id == concurrentManaged.id })) #expect(accounts.count == 2) @@ -357,7 +357,7 @@ struct CodexAccountPromotionExecutionTests { let builder = PreparedPromotionContextBuilder( store: container.fileStore, workspaceResolver: container.workspaceResolver, - snapshotLoader: SettingsStoreCodexAccountReconciliationSnapshotLoader(settingsStore: container.settings), + snapshotLoader: container.settings, authMaterialReader: DefaultCodexAuthMaterialReader(), baseEnvironment: container.baseEnvironment, fileManager: .default) diff --git a/Tests/CodexBarTests/CodexAccountPromotionPlanningTests.swift b/Tests/CodexBarTests/CodexAccountPromotionPlanningTests.swift index e4af7a211a..6e350a926b 100644 --- a/Tests/CodexBarTests/CodexAccountPromotionPlanningTests.swift +++ b/Tests/CodexBarTests/CodexAccountPromotionPlanningTests.swift @@ -219,7 +219,7 @@ struct CodexAccountPromotionPlanningTests { let builder = PreparedPromotionContextBuilder( store: container.fileStore, workspaceResolver: container.workspaceResolver, - snapshotLoader: SettingsStoreCodexAccountReconciliationSnapshotLoader(settingsStore: container.settings), + snapshotLoader: container.settings, authMaterialReader: DefaultCodexAuthMaterialReader(), baseEnvironment: container.baseEnvironment, fileManager: .default) diff --git a/Tests/CodexBarTests/CodexAccountPromotionPreparationTests.swift b/Tests/CodexBarTests/CodexAccountPromotionPreparationTests.swift index 3b7dc8be00..7821a7aea5 100644 --- a/Tests/CodexBarTests/CodexAccountPromotionPreparationTests.swift +++ b/Tests/CodexBarTests/CodexAccountPromotionPreparationTests.swift @@ -29,7 +29,7 @@ struct CodexAccountPromotionPreparationTests { let builder = PreparedPromotionContextBuilder( store: container.fileStore, workspaceResolver: container.workspaceResolver, - snapshotLoader: SettingsStoreCodexAccountReconciliationSnapshotLoader(settingsStore: container.settings), + snapshotLoader: container.settings, authMaterialReader: DefaultCodexAuthMaterialReader(), baseEnvironment: container.baseEnvironment, fileManager: .default) @@ -59,7 +59,7 @@ struct CodexAccountPromotionPreparationTests { let builder = PreparedPromotionContextBuilder( store: container.fileStore, workspaceResolver: container.workspaceResolver, - snapshotLoader: SettingsStoreCodexAccountReconciliationSnapshotLoader(settingsStore: container.settings), + snapshotLoader: container.settings, authMaterialReader: DefaultCodexAuthMaterialReader(), baseEnvironment: container.baseEnvironment, fileManager: .default) @@ -101,7 +101,7 @@ struct CodexAccountPromotionPreparationTests { let builder = PreparedPromotionContextBuilder( store: container.fileStore, workspaceResolver: container.workspaceResolver, - snapshotLoader: SettingsStoreCodexAccountReconciliationSnapshotLoader(settingsStore: container.settings), + snapshotLoader: container.settings, authMaterialReader: DefaultCodexAuthMaterialReader(), baseEnvironment: container.baseEnvironment, fileManager: .default) diff --git a/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift b/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift index 24b7d386f6..74c1bd8cdd 100644 --- a/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift +++ b/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift @@ -515,7 +515,6 @@ struct CodexAccountPromotionServiceTests { let service = CodexAccountPromotionService( store: container.fileStore, homeFactory: container.homeFactory, - identityReader: container.identityReader, workspaceResolver: HomePathWorkspaceResolver( byHomePath: [ container.liveHomeURL.path: CodexOpenAIWorkspaceIdentity( @@ -525,11 +524,11 @@ struct CodexAccountPromotionServiceTests { workspaceAccountID: "acct-alpha", workspaceLabel: "Stale"), ]), - snapshotLoader: SettingsStoreCodexAccountReconciliationSnapshotLoader(settingsStore: container.settings), + snapshotLoader: container.settings, authMaterialReader: DefaultCodexAuthMaterialReader(), liveAuthSwapper: DefaultCodexLiveAuthSwapper(), - activeSourceWriter: SettingsStoreCodexActiveSourceWriter(settingsStore: container.settings), - accountScopedRefresher: UsageStoreCodexAccountScopedRefresher(usageStore: container.usageStore), + activeSourceWriter: container.settings, + accountScopedRefresher: container.usageStore, baseEnvironment: container.baseEnvironment, fileManager: .default) diff --git a/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift b/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift index 8049650b11..019f114b2d 100644 --- a/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift +++ b/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift @@ -87,22 +87,23 @@ final class CodexAccountPromotionTestContainer { liveAuthSwapper: (any CodexLiveAuthSwapping)? = nil, activeSourceWriter: (any CodexActiveSourceWriting)? = nil, snapshotLoader: (any CodexAccountReconciliationSnapshotLoading)? = nil, - accountScopedRefresher: (any CodexAccountScopedRefreshing)? = nil) + accountScopedRefresher: (any CodexAccountScopedRefreshing)? = nil, + daemon: CodexAppServerDaemon = CodexAppServerDaemon()) -> CodexAccountPromotionService { CodexAccountPromotionService( store: store ?? self.fileStore, homeFactory: self.homeFactory, - identityReader: self.identityReader, workspaceResolver: self.workspaceResolver, snapshotLoader: snapshotLoader - ?? SettingsStoreCodexAccountReconciliationSnapshotLoader(settingsStore: self.settings), + ?? self.settings, authMaterialReader: DefaultCodexAuthMaterialReader(), liveAuthSwapper: liveAuthSwapper ?? DefaultCodexLiveAuthSwapper(), activeSourceWriter: activeSourceWriter - ?? SettingsStoreCodexActiveSourceWriter(settingsStore: self.settings), + ?? self.settings, accountScopedRefresher: accountScopedRefresher - ?? UsageStoreCodexAccountScopedRefresher(usageStore: self.usageStore), + ?? self.usageStore, + daemon: daemon, baseEnvironment: self.baseEnvironment, fileManager: .default) } diff --git a/Tests/CodexBarTests/CodexAppServerDaemonTests.swift b/Tests/CodexBarTests/CodexAppServerDaemonTests.swift new file mode 100644 index 0000000000..a1ad6175a6 --- /dev/null +++ b/Tests/CodexBarTests/CodexAppServerDaemonTests.swift @@ -0,0 +1,125 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite(.serialized, CodexCredentialFixtures()) +@MainActor +struct CodexAppServerDaemonTests { + @Test + func `process matching excludes stdio probes and other executables`() { + #expect(CodexHomeScope.isAppServer(arguments: ["/package/bin/codex", "app-server", "--listen", "unix://"])) + #expect(!CodexHomeScope.isAppServer(arguments: ["/package/bin/codex", "app-server"])) + #expect(!CodexHomeScope.isAppServer(arguments: ["/package/bin/node", "app-server", "--listen", "unix://"])) + #expect(!CodexHomeScope.isAppServer(arguments: ["codex", "exec", "app-server", "--listen", "unix://"])) + #expect(!CodexHomeScope.isAppServer(arguments: [])) + } + + @Test(arguments: ["daemon.pid", "app-server.pid"]) + func `promotion restarts the live home daemon once after publishing auth`(_ filename: String) async throws { + let container = try CodexAccountPromotionTestContainer(suiteName: "daemon-promotion") + defer { container.tearDown() } + let target = try container.createManagedAccount( + persistedEmail: "managed@example.com", authAccountID: "acct-managed") + try container.persistAccounts([target]) + _ = try container.writeLiveOAuthAuthFile(email: "live@example.com", accountID: "acct-live") + try Self.writePID(home: container.liveHomeURL, filename: filename) + var calls: [String] = [] + let daemon = CodexAppServerDaemon(isAppServerProcess: { $0 == 123 }, run: { command, env in + calls.append(command) + #expect(env["CODEX_HOME"] == container.liveHomeURL.resolvingSymlinksInPath().path) + let identity = try container.identityReader.loadAccountIdentity(homePath: container.liveHomeURL.path) + #expect(identity.email == "managed@example.com") + return Self.version(home: container.liveHomeURL) + }) + let result = try await container.makeService(daemon: daemon).promoteManagedAccount(id: target.id) + #expect(result.outcome == .promoted) + #expect(result.daemonRestartNote == nil) + #expect(calls == ["version", "restart"]) + } + + @Test(arguments: [false, true]) + func `absent or stale daemon does not invoke the CLI`(_ stale: Bool) async throws { + let container = try CodexAccountPromotionTestContainer(suiteName: "daemon-absent") + defer { container.tearDown() } + if stale { try Self.writePID(home: container.liveHomeURL) } + let daemon = CodexAppServerDaemon(isAppServerProcess: { _ in false }, run: { _, _ in + Issue.record("Should not invoke the CLI without a matching process") + return "" + }) + let note = await daemon.restartIfRunning(homeURL: container.liveHomeURL, environment: [:]) + #expect(note == nil) + } + + @Test(arguments: ["version", "restart"]) + func `unsupported daemon command or restart failure preserves promotion with a note`( + _ failure: String) async throws + { + let container = try CodexAccountPromotionTestContainer(suiteName: "daemon-failure") + defer { container.tearDown() } + let target = try container.createManagedAccount( + persistedEmail: "managed@example.com", authAccountID: "acct-managed") + try container.persistAccounts([target]) + try Self.writePID(home: container.liveHomeURL) + var calls: [String] = [] + let daemon = CodexAppServerDaemon(isAppServerProcess: { _ in true }, run: { command, _ in + calls.append(command) + if command == failure { + throw SubprocessRunnerError.nonZeroExit(code: 2, stderr: "synthetic command failure") + } + return Self.version(home: container.liveHomeURL) + }) + let coordinator = CodexAccountPromotionCoordinator(service: container.makeService(daemon: daemon)) + let result = try await coordinator.promote(managedAccountID: target.id).get() + #expect(result.outcome == .promoted) + #expect(result.didMutateLiveAuth) + #expect(container.settings.codexActiveSource == .liveSystem) + #expect(result.daemonRestartNote == "Account switched; restart the Codex background server manually.") + #expect(coordinator.daemonRestartNote == result.daemonRestartNote) + let menu = MenuDescriptor.build( + provider: .codex, + store: container.usageStore, + settings: container.settings, + account: AccountInfo(email: nil, plan: nil), + codexAccountPromotionCoordinator: coordinator, + updateReady: false) + #expect(menu.sections.flatMap(\.entries).contains { + if case let .text(text, _) = $0 { return text == result.daemonRestartNote } + return false + }) + #expect(calls == (failure == "version" ? ["version"] : ["version", "restart"])) + } + + @Test(arguments: ["other-home", "unmanaged", "stopped"]) + func `only a managed daemon answering for the promoted home can restart`(_ mismatch: String) async throws { + let container = try CodexAccountPromotionTestContainer(suiteName: "daemon-home-match") + defer { container.tearDown() } + try Self.writePID(home: container.liveHomeURL) + var calls: [String] = [] + let daemon = CodexAppServerDaemon(isAppServerProcess: { _ in true }, run: { command, env in + calls.append(command) + #expect(env["HOME"] == "/synthetic-user") + let home = mismatch == "other-home" ? container.managedHomesURL : container.liveHomeURL + return Self.version( + home: home, + backend: mismatch == "unmanaged" ? "" : "pid", + status: mismatch == "stopped" ? "notRunning" : "running") + }) + let note = await daemon.restartIfRunning( + homeURL: container.liveHomeURL, environment: ["HOME": "/synthetic-user", "CODEX_HOME": "/wrong-home"]) + #expect(note == nil) + #expect(calls == ["version"]) + } + + private static func writePID(home: URL, filename: String = "daemon.pid") throws { + let directory = home.appendingPathComponent("app-server-daemon") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data(#"{"pid":123,"processStartTime":"synthetic"}"#.utf8) + .write(to: directory.appendingPathComponent(filename)) + } + + private static func version(home: URL, backend: String = "pid", status: String = "running") -> String { + let socket = home.resolvingSymlinksInPath().appendingPathComponent("app-server-control/app-server-control.sock") + return "{\"status\":\"\(status)\",\"backend\":\"\(backend)\",\"socketPath\":\"\(socket.path)\"}" + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index ce254586eb..a5a9c18833 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2303,7 +2303,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact preferences test fixture seeds representative provider versions, snapshots, and accounts."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesProvidersPane.swift", - line: 279, + line: 282, anchor: "guard let state = self.codexAccountsSectionState(for: .codex), state.canAddAccount else {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2311,7 +2311,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesProvidersPane.swift", - line: 295, + line: 298, anchor: "guard let state = self.codexAccountsSectionState(for: .codex),", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, diff --git a/docs/codex.md b/docs/codex.md index c2fc1a42ef..d95117c4ca 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -81,6 +81,12 @@ Usage source picker: refresh is running discards the old workspace's result. - System Account promotion fails closed when a managed selection differs from the auth file's default workspace. CodexBar keeps that selection managed rather than silently promoting the default or rewriting Codex-owned auth. +- After a successful System Account promotion, CodexBar restarts an already-running managed `codex app-server` + daemon for the destination Codex home so it reloads the selected account. It checks the daemon PID, process command, + and home-scoped control socket before running `codex app-server daemon restart` with that home's `CODEX_HOME`. + Homes without a running daemon are left alone. If the installed CLI cannot verify or restart it (including older + CLIs without daemon commands), the account remains switched and the menu/settings show a manual-restart note. + Restarting the background server can interrupt its active work; no login flow runs. - In the segmented layout, selecting an account refreshes its card while the menu stays open. Delayed results stay scoped to that selection. An open chart submenu or highlighted menu command can defer the update until the submenu closes or the highlight clears. @@ -95,6 +101,8 @@ Usage source picker: - CodexBar reads identity from the configured home, exposes it in the Codex account switcher, and scopes remote Codex fetches with `CODEX_HOME`. - Profile homes are not copied, reauthenticated, or removed by CodexBar. +- Selecting a profile-home usage card does not promote credentials or restart its daemon. Daemon refresh belongs to + System Account promotion and targets only the home whose auth file was replaced. Example: