Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/CodexAccountPromotionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
36 changes: 13 additions & 23 deletions Sources/CodexBar/CodexAccountPromotionExecution.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,7 +28,7 @@ struct CodexDisplacedLivePreservationExecutor {
func execute(
plan: CodexDisplacedLivePreservationPlan,
context: PreparedPromotionContext) throws
-> CodexDisplacedLivePreservationExecutionResult
-> CodexAccountPromotionResult.DisplacedLiveDisposition
{
/*
Safety contract:
Expand All @@ -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)
Expand All @@ -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)
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -129,7 +124,7 @@ struct CodexDisplacedLivePreservationExecutor {
private func commitImportedAccount(
_ importedAccount: CodexPreparedImportedAccount,
excludingTargetID: UUID) throws
-> CodexDisplacedLivePreservationExecutionResult
-> CodexAccountPromotionResult.DisplacedLiveDisposition
{
do {
let latestManagedAccounts = try self.store.loadAccounts()
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
Expand All @@ -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()
}
}
122 changes: 30 additions & 92 deletions Sources/CodexBar/CodexAccountPromotionPreparation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ struct PreparedAuthMaterial {
let homeURL: URL
let rawData: Data
let credentials: CodexOAuthCredentials
let runtimeAccount: CodexAuthBackedAccount
let authIdentity: PreparedIdentity
}

Expand Down Expand Up @@ -91,45 +90,31 @@ struct PreparedLiveAccount {

struct PreparedPromotionContext {
let snapshot: CodexAccountReconciliationSnapshot
let managedAccounts: ManagedCodexAccountSet
let storedManagedAccounts: [PreparedStoredManagedAccount]
let target: PreparedStoredManagedAccount
let live: PreparedLiveAccount
}

@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()
let managedAccounts = try self.store.loadAccounts()
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 {
Expand All @@ -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? {
Expand All @@ -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):
Expand All @@ -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),
Expand All @@ -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")
Expand All @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -360,9 +304,3 @@ struct PreparedPromotionContextBuilder {
return nil
}
}

private enum PreparedAuthReadState {
case missing
case unreadable
case readable(Data)
}
Loading
Loading