344 lines
13 KiB
Swift
344 lines
13 KiB
Swift
#if os(macOS)
|
|
import AppKit
|
|
import Foundation
|
|
|
|
public struct CodexWebDashboardStrategy: ProviderFetchStrategy {
|
|
public let id: String = "codex.web.dashboard"
|
|
public let kind: ProviderFetchKind = .webDashboard
|
|
|
|
public init() {}
|
|
|
|
public func isAvailable(_ context: ProviderFetchContext) async -> Bool {
|
|
context.sourceMode.usesWeb &&
|
|
!Self.managedAccountStoreIsUnreadable(context) &&
|
|
!Self.managedAccountTargetIsUnavailable(context) &&
|
|
(!Self.profileAccountTargetIsUnavailable(context) || context.sourceMode == .web)
|
|
}
|
|
|
|
public func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
|
|
guard !Self.managedAccountStoreIsUnreadable(context) else {
|
|
// A fail-closed placeholder CODEX_HOME does not identify a target account. If the managed store
|
|
// itself is unreadable, web import must not fall back to "any signed-in browser account".
|
|
throw OpenAIDashboardFetcher.FetchError.loginRequired
|
|
}
|
|
guard !Self.managedAccountTargetIsUnavailable(context) else {
|
|
// If the selected managed account no longer exists in a readable store, web import must not
|
|
// fall back to "any signed-in browser account" for that stale selection.
|
|
throw OpenAIDashboardFetcher.FetchError.loginRequired
|
|
}
|
|
guard !Self.profileAccountTargetIsUnavailable(context) else {
|
|
// A profile without an auth-backed email cannot safely target browser cookie import.
|
|
throw OpenAIDashboardFetcher.FetchError.loginRequired
|
|
}
|
|
|
|
let options = OpenAIWebOptions(
|
|
deadline: OpenAIDashboardFetcher.deadline(startingAt: Date(), timeout: context.webTimeout),
|
|
debugDumpHTML: context.webDebugDumpHTML,
|
|
verbose: context.verbose)
|
|
|
|
// Ensure AppKit is initialized before using WebKit in a CLI.
|
|
await MainActor.run {
|
|
_ = NSApplication.shared
|
|
}
|
|
|
|
let result: OpenAIWebCodexResult
|
|
do {
|
|
result = try await Self.fetchOpenAIWebCodex(
|
|
context: context,
|
|
options: options,
|
|
browserDetection: context.browserDetection)
|
|
} catch let error as URLError where error.code == .timedOut {
|
|
throw OpenAIWebCodexError.timedOut(seconds: context.webTimeout)
|
|
}
|
|
return self.makeResult(
|
|
usage: result.usage,
|
|
credits: result.credits,
|
|
dashboard: result.dashboard,
|
|
sourceLabel: "openai-web")
|
|
}
|
|
|
|
public func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool {
|
|
_ = error
|
|
return context.sourceMode == .auto
|
|
}
|
|
|
|
private static func managedAccountStoreIsUnreadable(_ context: ProviderFetchContext) -> Bool {
|
|
context.settings?.codex?.managedAccountStoreUnreadable == true
|
|
}
|
|
|
|
private static func managedAccountTargetIsUnavailable(_ context: ProviderFetchContext) -> Bool {
|
|
context.settings?.codex?.managedAccountTargetUnavailable == true
|
|
}
|
|
|
|
private static func profileAccountTargetIsUnavailable(_ context: ProviderFetchContext) -> Bool {
|
|
context.settings?.codex?.profileAccountTargetUnavailable == true
|
|
}
|
|
}
|
|
|
|
struct OpenAIWebCodexResult {
|
|
let usage: UsageSnapshot
|
|
let credits: CreditsSnapshot?
|
|
let dashboard: OpenAIDashboardSnapshot
|
|
}
|
|
|
|
enum OpenAIWebCodexError: LocalizedError, Equatable {
|
|
case missingUsage
|
|
case policyRejected(CodexDashboardAuthorityDecision)
|
|
case timedOut(seconds: TimeInterval)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .missingUsage:
|
|
return "OpenAI web dashboard did not include usage limits."
|
|
case let .timedOut(seconds):
|
|
return "OpenAI web dashboard fetch timed out after \(seconds.formatted()) seconds."
|
|
case let .policyRejected(decision):
|
|
switch decision.reason {
|
|
case let .wrongEmail(expected, actual):
|
|
var details: [String] = []
|
|
if let expected {
|
|
details.append("expected \(expected)")
|
|
}
|
|
if let actual {
|
|
details.append("got \(actual)")
|
|
}
|
|
if details.isEmpty {
|
|
return "OpenAI web dashboard belonged to the wrong account."
|
|
}
|
|
return "OpenAI web dashboard belonged to the wrong account (\(details.joined(separator: ", ")))."
|
|
case .unresolvedWithoutTrustedEvidence:
|
|
return "Active Codex identity is unresolved and no trusted auth-backed continuity exists."
|
|
case .providerAccountMissingScopedEmail:
|
|
return "Active Codex provider account is missing its scoped email, " +
|
|
"so dashboard ownership cannot be proven."
|
|
case .providerAccountLacksExactOwnershipProof:
|
|
return "OpenAI web dashboard could not be proven to belong to the active provider account."
|
|
case .missingDashboardSignedInEmail:
|
|
return "OpenAI web dashboard did not expose a signed-in email."
|
|
case let .sameEmailAmbiguity(email):
|
|
return "OpenAI web dashboard email \(email) is ambiguous across multiple known owners."
|
|
default:
|
|
return "OpenAI web dashboard was rejected by Codex dashboard authority."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct OpenAIWebOptions {
|
|
let deadline: Date
|
|
let debugDumpHTML: Bool
|
|
let verbose: Bool
|
|
}
|
|
|
|
@MainActor
|
|
private final class WebLogBuffer {
|
|
private var lines: [String] = []
|
|
private let maxCount: Int
|
|
private let verbose: Bool
|
|
private let logger = CodexBarLog.logger(LogCategories.openAIWeb)
|
|
|
|
init(maxCount: Int = 300, verbose: Bool) {
|
|
self.maxCount = maxCount
|
|
self.verbose = verbose
|
|
}
|
|
|
|
func append(_ line: String) {
|
|
self.lines.append(line)
|
|
if self.lines.count > self.maxCount {
|
|
self.lines.removeFirst(self.lines.count - self.maxCount)
|
|
}
|
|
if self.verbose {
|
|
self.logger.verbose(line)
|
|
}
|
|
}
|
|
|
|
func snapshot() -> [String] {
|
|
self.lines
|
|
}
|
|
}
|
|
|
|
extension CodexWebDashboardStrategy {
|
|
@MainActor
|
|
fileprivate static func fetchOpenAIWebCodex(
|
|
context: ProviderFetchContext,
|
|
options: OpenAIWebOptions,
|
|
browserDetection: BrowserDetection) async throws -> OpenAIWebCodexResult
|
|
{
|
|
let logger = WebLogBuffer(verbose: options.verbose)
|
|
let log: @MainActor (String) -> Void = { line in
|
|
logger.append(line)
|
|
}
|
|
do {
|
|
let result = try await Self.fetchOpenAIWebDashboard(
|
|
context: context,
|
|
options: options,
|
|
browserDetection: browserDetection,
|
|
preferCachedCookieHeader: true,
|
|
logger: log)
|
|
return try Self.makeAuthorizedDashboardResult(
|
|
dashboard: result.dashboard,
|
|
context: context,
|
|
routingTargetEmail: result.routingTargetEmail)
|
|
} catch {
|
|
guard Self.shouldRetryWithFreshBrowserImport(after: error) else {
|
|
throw error
|
|
}
|
|
_ = try OpenAIDashboardBrowserCookieImporter.remainingTimeout(until: options.deadline)
|
|
log("Retrying OpenAI web dashboard with a fresh browser cookie import.")
|
|
let result = try await Self.fetchOpenAIWebDashboard(
|
|
context: context,
|
|
options: options,
|
|
browserDetection: browserDetection,
|
|
preferCachedCookieHeader: false,
|
|
logger: log)
|
|
return try Self.makeAuthorizedDashboardResult(
|
|
dashboard: result.dashboard,
|
|
context: context,
|
|
routingTargetEmail: result.routingTargetEmail)
|
|
}
|
|
}
|
|
|
|
nonisolated static func shouldRetryWithFreshBrowserImport(after error: Error) -> Bool {
|
|
if error is OpenAIWebCodexError {
|
|
return error as? OpenAIWebCodexError == .missingUsage
|
|
}
|
|
if case OpenAIDashboardFetcher.FetchError.noDashboardData = error {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
@MainActor
|
|
static func makeAuthorizedDashboardResultForTesting(
|
|
dashboard: OpenAIDashboardSnapshot,
|
|
context: ProviderFetchContext,
|
|
routingTargetEmail: String?)
|
|
throws -> OpenAIWebCodexResult
|
|
{
|
|
try self.makeAuthorizedDashboardResult(
|
|
dashboard: dashboard,
|
|
context: context,
|
|
routingTargetEmail: routingTargetEmail)
|
|
}
|
|
|
|
@MainActor
|
|
private static func makeAuthorizedDashboardResult(
|
|
dashboard: OpenAIDashboardSnapshot,
|
|
context: ProviderFetchContext,
|
|
routingTargetEmail: String?) throws -> OpenAIWebCodexResult
|
|
{
|
|
let input = CodexCLIDashboardAuthorityContext.makeLiveWebInput(
|
|
dashboard: dashboard,
|
|
context: context,
|
|
routingTargetEmail: routingTargetEmail)
|
|
let decision = CodexDashboardAuthority.evaluate(input)
|
|
|
|
switch decision.disposition {
|
|
case .attach:
|
|
let attachedAccountEmail = CodexCLIDashboardAuthorityContext.attachmentEmail(from: input)
|
|
let credits = dashboard.toCreditsSnapshot()
|
|
let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: attachedAccountEmail)
|
|
?? Self.makeCreditsOnlyUsageSnapshot(
|
|
dashboard: dashboard,
|
|
attachedAccountEmail: attachedAccountEmail,
|
|
credits: credits)
|
|
guard let usage else {
|
|
throw OpenAIWebCodexError.missingUsage
|
|
}
|
|
if let attachedAccountEmail {
|
|
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
|
accountEmail: attachedAccountEmail,
|
|
snapshot: dashboard))
|
|
}
|
|
return OpenAIWebCodexResult(usage: usage, credits: credits, dashboard: dashboard)
|
|
case .displayOnly:
|
|
if decision.cleanup.contains(.dashboardCache) {
|
|
OpenAIDashboardCacheStore.clear()
|
|
}
|
|
throw CodexDashboardPolicyError.displayOnly(decision)
|
|
case .failClosed:
|
|
if decision.cleanup.contains(.dashboardCache) {
|
|
OpenAIDashboardCacheStore.clear()
|
|
}
|
|
throw OpenAIWebCodexError.policyRejected(decision)
|
|
}
|
|
}
|
|
|
|
private static func makeCreditsOnlyUsageSnapshot(
|
|
dashboard: OpenAIDashboardSnapshot,
|
|
attachedAccountEmail: String?,
|
|
credits: CreditsSnapshot?) -> UsageSnapshot?
|
|
{
|
|
guard credits != nil else { return nil }
|
|
return UsageSnapshot(
|
|
primary: nil,
|
|
secondary: nil,
|
|
tertiary: nil,
|
|
updatedAt: dashboard.updatedAt,
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: .codex,
|
|
accountEmail: attachedAccountEmail ?? dashboard.signedInEmail,
|
|
accountOrganization: nil,
|
|
loginMethod: dashboard.accountPlan))
|
|
}
|
|
|
|
private struct OpenAIWebDashboardFetchResult {
|
|
let dashboard: OpenAIDashboardSnapshot
|
|
let routingTargetEmail: String?
|
|
}
|
|
|
|
@MainActor
|
|
private static func fetchOpenAIWebDashboard(
|
|
context: ProviderFetchContext,
|
|
options: OpenAIWebOptions,
|
|
browserDetection: BrowserDetection,
|
|
preferCachedCookieHeader: Bool,
|
|
logger: @MainActor @escaping (String) -> Void) async throws -> OpenAIWebDashboardFetchResult
|
|
{
|
|
let auth = context.fetcher.loadAuthBackedCodexAccount()
|
|
let routingTargetEmail = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let allowAnyAccount = routingTargetEmail == nil
|
|
|
|
let importResult = try await OpenAIDashboardBrowserCookieImporter(browserDetection: browserDetection)
|
|
.importBestCookies(
|
|
intoAccountEmail: routingTargetEmail,
|
|
allowAnyAccount: allowAnyAccount,
|
|
preferCachedCookieHeader: preferCachedCookieHeader,
|
|
cacheScope: context.settings?.codex?.openAIWebCacheScope,
|
|
deadline: options.deadline,
|
|
logger: logger)
|
|
let effectiveEmail = routingTargetEmail ?? importResult.signedInEmail?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
let dashboard = try await OpenAIDashboardFetcher().loadLatestDashboard(
|
|
accountEmail: effectiveEmail,
|
|
cacheScope: context.settings?.codex?.openAIWebCacheScope,
|
|
logger: logger,
|
|
debugDumpHTML: options.debugDumpHTML,
|
|
timeout: OpenAIDashboardBrowserCookieImporter.remainingTimeout(until: options.deadline))
|
|
return OpenAIWebDashboardFetchResult(
|
|
dashboard: dashboard,
|
|
routingTargetEmail: routingTargetEmail)
|
|
}
|
|
}
|
|
#else
|
|
public struct CodexWebDashboardStrategy: ProviderFetchStrategy {
|
|
public let id: String = "codex.web.dashboard"
|
|
public let kind: ProviderFetchKind = .webDashboard
|
|
|
|
public init() {}
|
|
|
|
public func isAvailable(_: ProviderFetchContext) async -> Bool {
|
|
false
|
|
}
|
|
|
|
public func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult {
|
|
throw ProviderFetchError.noAvailableStrategy(.codex)
|
|
}
|
|
|
|
public func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
|
|
false
|
|
}
|
|
}
|
|
#endif
|