From 796d070fd74cee91cb0bd45a34df55b5bf9029b0 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:15:08 +0800 Subject: [PATCH] Refresh managed Codex credentials --- .../CodexOAuth/CodexOAuthUsageFetcher.swift | 15 +++- .../CodexPAT/CodexPATFetchStrategy.swift | 2 +- .../Codex/CodexProviderDescriptor.swift | 50 +++++++++--- Sources/CodexBarCore/UsageFetcher.swift | 65 +++++++++++++++ .../CodexOAuthExpiryPipelineTests.swift | 34 ++++---- ...exOAuthManagedWorkspaceRecoveryTests.swift | 73 +++++++++++++++-- Tests/CodexBarTests/CodexOAuthTests.swift | 4 +- .../CodexUsageFetcherFallbackTests.swift | 79 +++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 2 +- 9 files changed, 280 insertions(+), 44 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift index 09034e0af8..99474207c1 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift @@ -367,6 +367,7 @@ public struct CodexUsageResponse: Decodable, Sendable { public enum CodexOAuthFetchError: LocalizedError, Sendable { case unauthorized + case forbidden case invalidResponse case serverError(Int, String?) case networkError(Error) @@ -375,6 +376,8 @@ public enum CodexOAuthFetchError: LocalizedError, Sendable { switch self { case .unauthorized: return "Codex OAuth token expired or invalid. Run `codex login` to re-authenticate." + case .forbidden: + return "Codex account cannot access the selected workspace (HTTP 403)." case .invalidResponse: return "Invalid response from Codex usage API." case let .serverError(code, message): @@ -437,8 +440,10 @@ public enum CodexOAuthUsageFetcher { } catch { throw CodexOAuthFetchError.invalidResponse } - case 401, 403: + case 401: throw CodexOAuthFetchError.unauthorized + case 403: + throw CodexOAuthFetchError.forbidden default: let body = String(data: data, encoding: .utf8) throw CodexOAuthFetchError.serverError(response.statusCode, body) @@ -512,8 +517,10 @@ public enum CodexOAuthUsageFetcher { } catch { throw CodexOAuthFetchError.invalidResponse } - case 401, 403: + case 401: throw CodexOAuthFetchError.unauthorized + case 403: + throw CodexOAuthFetchError.forbidden default: let body = String(data: response.data, encoding: .utf8) throw CodexOAuthFetchError.serverError(response.statusCode, body) @@ -573,8 +580,10 @@ public enum CodexOAuthUsageFetcher { } catch { throw CodexOAuthFetchError.invalidResponse } - case 401, 403: + case 401: throw CodexOAuthFetchError.unauthorized + case 403: + throw CodexOAuthFetchError.forbidden default: let body = String(data: data, encoding: .utf8) throw CodexOAuthFetchError.serverError(response.statusCode, body) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATFetchStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATFetchStrategy.swift index 91f7c5f859..d6635459e5 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATFetchStrategy.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATFetchStrategy.swift @@ -27,7 +27,7 @@ struct CodexPATFetchStrategy: ProviderFetchStrategy { switch fetchError { case .unauthorized: return true - case .invalidResponse, .serverError, .networkError: + case .forbidden, .invalidResponse, .serverError, .networkError: return false } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 7cd090a5a1..7035b9a565 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -157,7 +157,7 @@ public enum CodexProviderDescriptor { let oauthWithNativeRefresh: [any ProviderFetchStrategy] = [oauth, CodexOAuthNativeRefreshCLIStrategy()] let autoStrategies: [any ProviderFetchStrategy] = context.codexWorkspaceID == nil ? [pat, oauth, cli] - : [pat, oauth] + : [pat, oauth, CodexOAuthNativeRefreshCLIStrategy()] switch context.sourceMode { case .oauth: @@ -338,38 +338,53 @@ struct CodexCLIUsageStrategy: ProviderFetchStrategy { } } -/// Explicit OAuth may recover stale native credentials through the Codex CLI, without allowing +/// OAuth may recover stale native credentials through the Codex CLI, without allowing /// missing or external credentials to silently switch sources. struct CodexOAuthNativeRefreshCLIStrategy: ProviderFetchStrategy { + typealias CredentialRefresher = @Sendable (ProviderFetchContext) async throws -> Void + let id: String = "codex.oauth-native-refresh-cli" let kind: ProviderFetchKind = .cli private let binaryResolver: @Sendable (ProviderFetchContext) -> String? + private let credentialRefresher: CredentialRefresher init( binaryResolver: @escaping @Sendable (ProviderFetchContext) -> String? = { CodexCLIUsageStrategy.resolvedBinary(env: $0.env) + }, + credentialRefresher: @escaping CredentialRefresher = { + try await $0.fetcher.refreshNativeCodexCredentials() }) { self.binaryResolver = binaryResolver + self.credentialRefresher = credentialRefresher } func isAvailable(_ context: ProviderFetchContext) async -> Bool { - // The Codex CLI app-server has no supported way to receive CodexBar's selected managed - // workspace account header. Falling back to it would therefore report the auth.json - // workspace under a different selected workspace. Keep this path unavailable until the - // owner CLI can carry that scope explicitly. - guard context.codexWorkspaceID == nil, - context.sourceMode == .oauth, + guard context.sourceMode == .auto || context.sourceMode == .oauth, self.binaryResolver(context) != nil, let credentials = try? CodexOAuthCredentialsStore.loadForUsage( env: context.env, allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) else { return false } - return credentials.source == .codexHome && credentials.needsRefresh + return credentials.source == .codexHome && !credentials.isAPIKey } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - try await CodexCLIUsageStrategy().fetch(context) + // Let the owner CLI rotate and persist its native tokens inside this exact CODEX_HOME. + // Then reload them and perform the normal OAuth request, which preserves CodexBar's + // selected managed-workspace header and account-ownership checks. + try await self.credentialRefresher(context) + let credentials = try CodexOAuthCredentialsStore.loadForUsage( + env: context.env, + allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) + guard credentials.source == .codexHome else { + throw CodexOAuthCredentialsError.readOnlySource + } + guard !credentials.isAPIKey else { + throw CodexOAuthCredentialsError.missingTokens + } + return try await CodexOAuthFetchStrategy.fetch(context: context, credentials: credentials) } func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { @@ -394,7 +409,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { return try await Self.fetch(context: context, credentials: credentials) } - private static func fetch( + fileprivate static func fetch( context: ProviderFetchContext, credentials initialCredentials: CodexOAuthCredentials) async throws -> ProviderFetchResult { @@ -477,7 +492,16 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } else { false } - guard context.sourceMode == .auto || (context.sourceMode == .oauth && isExplicitNativeRefresh) else { + let isUnauthorized = if let fetchError = error as? CodexOAuthFetchError, + case .unauthorized = fetchError + { + true + } else { + false + } + guard context.sourceMode == .auto + || (context.sourceMode == .oauth && (isExplicitNativeRefresh || isUnauthorized)) + else { return false } // Auto mode may launch the CLI as the next strategy. Keep that fallback @@ -488,7 +512,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { switch fetchError { case .unauthorized: return true - case .invalidResponse, .serverError, .networkError: + case .forbidden, .invalidResponse, .serverError, .networkError: return false } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 9618484b22..10f28e5a8c 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -867,6 +867,7 @@ private enum RPCRequestRaceResult: Sendable { private final class CodexRPCClient: @unchecked Sendable { // Provider-specific by design: Codex RPC owns its dedicated subprocess log category. private static let log = CodexBarLog.logger(LogCategories.provider(.codex, scope: "rpc")) + private static let accountRefreshTimeoutSeconds: TimeInterval = 30 private let process = Process() private let stdin = RPCChildProcessInput() private let stdoutPipe = Pipe() @@ -987,6 +988,13 @@ private final class CodexRPCClient: @unchecked Sendable { return try self.decodeResult(from: message) } + func refreshAccount() async throws { + _ = try await self.request( + method: "account/read", + params: ["refreshToken": true], + timeout: Self.accountRefreshTimeoutSeconds) + } + func fetchRateLimits() async throws -> RPCRateLimitsResponse { let message = try await self.request(method: "account/rateLimits/read") return try self.decodeResult(from: message) @@ -1135,6 +1143,45 @@ private final class CodexRPCClient: @unchecked Sendable { // MARK: - Public fetcher used by the app +private actor CodexNativeCredentialRefreshCoordinator { + private struct Entry { + let id: UUID + let task: Task + } + + static let shared = CodexNativeCredentialRefreshCoordinator() + + private var inFlightByHome: [String: Entry] = [:] + + func refresh( + home: String, + operation: @escaping @Sendable () async throws -> Void) async throws + { + if let existing = self.inFlightByHome[home] { + try await existing.task.value + return + } + + let id = UUID() + let task = Task { + try await operation() + } + self.inFlightByHome[home] = Entry(id: id, task: task) + do { + try await task.value + self.clear(home: home, id: id) + } catch { + self.clear(home: home, id: id) + throw error + } + } + + private func clear(home: String, id: UUID) { + guard self.inFlightByHome[home]?.id == id else { return } + self.inFlightByHome[home] = nil + } +} + public struct UsageFetcher: Sendable { private let environment: [String: String] private let initializeTimeoutSeconds: TimeInterval @@ -1172,6 +1219,24 @@ public struct UsageFetcher: Sendable { return usage } + /// Ask the credential-owning Codex app-server to renew the scoped native auth file. + /// This intentionally does not consume app-server usage because it cannot carry CodexBar's + /// selected managed-workspace header. + func refreshNativeCodexCredentials() async throws { + let home = CodexHomeScope.ambientHomeURL(env: self.environment).standardizedFileURL.path + try await CodexNativeCredentialRefreshCoordinator.shared.refresh(home: home) { + let rpc = try CodexRPCClient( + arguments: self.codexArguments, + environment: self.environment, + initializeTimeoutSeconds: self.initializeTimeoutSeconds, + requestTimeoutSeconds: self.requestTimeoutSeconds, + resolveExecutable: self.codexExecutableResolver) + defer { rpc.shutdown() } + try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") + try await rpc.refreshAccount() + } + } + public func loadLatestCLIAccountSnapshot() async throws -> CodexCLIAccountSnapshot { let rpc = try CodexRPCClient( arguments: self.codexArguments, diff --git a/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift b/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift index ec8ae81234..e6efd876d1 100644 --- a/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift +++ b/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift @@ -48,25 +48,18 @@ struct CodexOAuthExpiryPipelineTests { throw URLError(.cancelled) } let recovery = CodexOAuthNativeRefreshCLIStrategy(binaryResolver: { _ in "/fixture/codex" }) - #expect(await recovery.isAvailable(context) == (mode == .oauth && !managed)) + #expect(await recovery.isAvailable(context)) let outcome = await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { await Self.pipeline.fetch(context: context, provider: .codex) } - if managed { - guard case let .failure(error) = outcome.result, - case .nativeRefreshRequired = error as? CodexOAuthCredentialsError - else { - Issue.record("Managed scope must retain nativeRefreshRequired without CLI recovery") - continue - } - } else { - guard case let .failure(error) = outcome.result, error is CLISelected else { - Issue.record("Native refresh must be handed to the CLI") - continue - } - #expect(outcome.attempts.last?.strategyID - == (mode == .auto ? "codex.cli" : "codex.oauth-native-refresh-cli")) + guard case let .failure(error) = outcome.result, error is CLISelected else { + Issue.record("Native refresh must be handed to the credential-owning CLI") + continue } + let expectedRecoveryID = managed || mode == .oauth + ? "codex.oauth-native-refresh-cli" + : "codex.cli" + #expect(outcome.attempts.last?.strategyID == expectedRecoveryID) #expect(await transport.requests().isEmpty) try fixture.expectUnchanged() } @@ -90,19 +83,24 @@ struct CodexOAuthExpiryPipelineTests { let outcome = await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { await Self.pipeline.fetch(context: context, provider: .codex) } - let unauthorized = failure == "401" || failure == "403" - let expectsCLI = unauthorized && mode == .auto && !managed + let unauthorized = failure == "401" + let forbidden = failure == "403" + let expectsCLI = unauthorized guard case let .failure(error) = outcome.result else { Issue.record("An expiry hint cannot authenticate a rejected token") return } if expectsCLI { #expect(error is CLISelected) - #expect(outcome.attempts.last?.strategyID == "codex.cli") + let expectedRecoveryID = managed || mode == .oauth + ? "codex.oauth-native-refresh-cli" + : "codex.cli" + #expect(outcome.attempts.last?.strategyID == expectedRecoveryID) } else { let oauthError = try #require(error as? CodexOAuthFetchError) switch oauthError { case .unauthorized: #expect(unauthorized) + case .forbidden: #expect(forbidden) case .serverError: #expect(failure == "500") case .invalidResponse: #expect(failure == "decode") case .networkError: #expect(failure == "network") diff --git a/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift b/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift index 663089fb03..64741daa7b 100644 --- a/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift +++ b/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift @@ -5,15 +5,15 @@ import Testing @Suite(CodexCredentialFixtures()) struct CodexOAuthManagedWorkspaceRecoveryTests { @Test - func `automatic mode does not expose unscoped CLI fallback for a managed workspace`() async { + func `automatic mode exposes scoped native refresh without unscoped CLI usage fallback`() async { let context = self.makeContext(sourceMode: .auto) let strategies = await CodexProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies(context) - #expect(strategies.map(\.id) == ["codex.pat", "codex.oauth"]) + #expect(strategies.map(\.id) == ["codex.pat", "codex.oauth", "codex.oauth-native-refresh-cli"]) } @Test - func `native refresh recovery is unavailable when managed workspace scope is selected`() async throws { + func `native refresh recovery is available when managed workspace scope is selected`() async throws { let home = CodexCredentialFixtures.root .appendingPathComponent("codexbar-native-refresh-managed-workspace-\(UUID().uuidString)") try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) @@ -31,12 +31,63 @@ struct CodexOAuthManagedWorkspaceRecoveryTests { let isAvailable = await CodexOAuthNativeRefreshCLIStrategy(binaryResolver: { _ in "/usr/bin/codex" }) .isAvailable(context) - #expect(!isAvailable) + #expect(isAvailable) + } + + @Test + func `native refresh reloads scoped credentials before fetching the selected workspace`() async throws { + let home = CodexCredentialFixtures.root + .appendingPathComponent("codexbar-native-refresh-selected-workspace-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + try CodexOAuthCredentialsStore.save( + CodexOAuthCredentials( + accessToken: "stale-access-token", + refreshToken: "stale-refresh-token", + idToken: nil, + accountId: "auth-account", + lastRefresh: Date(timeIntervalSinceNow: -(9 * 24 * 60 * 60))), + env: ["CODEX_HOME": home.path]) + + let context = self.makeContext( + sourceMode: .oauth, + env: ["CODEX_HOME": home.path], + runtime: .cli) + let strategy = CodexOAuthNativeRefreshCLIStrategy( + binaryResolver: { _ in "/usr/bin/codex" }, + credentialRefresher: { context in + try CodexOAuthCredentialsStore.save( + CodexOAuthCredentials( + accessToken: "refreshed-access-token", + refreshToken: "rotated-refresh-token", + idToken: nil, + accountId: "auth-account", + lastRefresh: Date()), + env: context.env) + }) + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.path == "/backend-api/wham/usage") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer refreshed-access-token") + #expect(request.value(forHTTPHeaderField: "ChatGPT-Account-Id") == "workspace-team") + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, headerFields: nil)) + return (Data(Self.usageBody.utf8), response) + } + + let result = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await strategy.fetch(context) + } + + #expect(result.strategyID == "codex.oauth") + #expect(result.usage.primary?.usedPercent == 22) + #expect(await transport.requests().count == 1) } private func makeContext( sourceMode: ProviderSourceMode, - env: [String: String] = [:]) -> ProviderFetchContext + env: [String: String] = [:], + runtime: ProviderRuntime = .app) -> ProviderFetchContext { let browserDetection = BrowserDetection(cacheTTL: 0) let settings = ProviderSettingsSnapshot.make(codex: CodexProviderSettings( @@ -45,7 +96,7 @@ struct CodexOAuthManagedWorkspaceRecoveryTests { manualCookieHeader: nil, managedWorkspaceAccountID: "workspace-team")) return ProviderFetchContext( - runtime: .app, + runtime: runtime, sourceMode: sourceMode, includeCredits: false, webTimeout: 60, @@ -57,4 +108,14 @@ struct CodexOAuthManagedWorkspaceRecoveryTests { claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), browserDetection: browserDetection) } + + private static let usageBody = #""" + { + "plan_type":"pro", + "rate_limit":{ + "primary_window":{"used_percent":22,"reset_at":4102444800,"limit_window_seconds":18000}, + "secondary_window":{"used_percent":43,"reset_at":4102444800,"limit_window_seconds":604800} + } + } + """# } diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index 4228d131b6..c96f2c57fa 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -785,11 +785,11 @@ struct CodexOAuthTests { } @Test - func `explicit O auth mode only falls back to CLI for native refresh recovery`() { + func `explicit O auth mode falls back to CLI for rejected native credentials`() { let strategy = CodexOAuthFetchStrategy() let context = self.makeContext(sourceMode: .oauth) - #expect(!strategy.shouldFallback(on: CodexOAuthFetchError.unauthorized, context: context)) + #expect(strategy.shouldFallback(on: CodexOAuthFetchError.unauthorized, context: context)) #expect(strategy.shouldFallback(on: CodexOAuthCredentialsError.nativeRefreshRequired, context: context)) #expect(!strategy.shouldFallback(on: CodexOAuthCredentialsError.readOnlySource, context: context)) #expect(!strategy.shouldFallback(on: CodexTokenRefresher.RefreshError.expired, context: context)) diff --git a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift index ff6621338f..4f29f657cf 100644 --- a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift +++ b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift @@ -120,6 +120,45 @@ struct CodexUsageFetcherFallbackTests { #expect(snapshot.rateLimitsUnavailable(for: .codex)) } + @Test + func `native credential refresh coalesces and renews without reading CLI usage`() async throws { + let stubCLIPath = try self.makeNativeRefreshStubCodexCLI() + let requestPath = stubCLIPath + ".requests" + defer { + try? FileManager.default.removeItem(atPath: stubCLIPath) + try? FileManager.default.removeItem(atPath: requestPath) + } + + let fetcher = self.makeStubUsageFetcher(stubCLIPath) + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<2 { + group.addTask { + try await fetcher.refreshNativeCodexCredentials() + } + } + try await group.waitForAll() + } + + let messages = try String(contentsOfFile: requestPath, encoding: .utf8) + .split(whereSeparator: \.isNewline) + .map { try #require(JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any]) } + #expect(messages.count == 1) + #expect(messages.first?["method"] as? String == "account/read") + #expect((messages.first?["params"] as? [String: Any])?["refreshToken"] as? Bool == true) + } + + @Test + func `native credential refresh outlives the ordinary RPC request timeout`() async throws { + let stubCLIPath = try self.makeNativeRefreshStubCodexCLI(delaySeconds: 3.2) + defer { + try? FileManager.default.removeItem(atPath: stubCLIPath) + try? FileManager.default.removeItem(atPath: stubCLIPath + ".requests") + } + + let fetcher = self.makeStubUsageFetcher(stubCLIPath) + try await fetcher.refreshNativeCodexCredentials() + } + @Test func `CLI plan and credits response without usage windows keeps unavailable limits`() async throws { let stubCLIPath = try self.makePlanOnlyStubCodexCLI(includeCredits: true) @@ -468,6 +507,46 @@ struct CodexUsageFetcherFallbackTests { return url.path } + private func makeNativeRefreshStubCodexCLI(delaySeconds: Double = 0) throws -> String { + let script = """ + #!/usr/bin/python3 -S + import json + import os + import sys + import time + + request_path = os.environ["CODEXBAR_TEST_RPC_REQUEST_PATH"] + if "app-server" not in sys.argv[1:]: + sys.exit(92) + for line in sys.stdin: + if not line.strip(): + continue + message = json.loads(line) + method = message.get("method") + if method == "initialized": + continue + identifier = message.get("id") + if method == "initialize": + payload = {"id": identifier, "result": {}} + elif method == "account/read": + with open(request_path, "a", encoding="utf-8") as output: + output.write(json.dumps(message) + "\\n") + time.sleep(\(delaySeconds)) + payload = { + "id": identifier, + "result": {"account": {"type": "future-provider-shape"}} + } + else: + payload = {"id": identifier, "error": {"message": "unexpected method: " + str(method)}} + print(json.dumps(payload), flush=True) + """ + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-native-refresh-stub-\(UUID().uuidString)", isDirectory: false) + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + private func makeCreditsOnlyStubCodexCLI() throws -> String { let script = """ #!/usr/bin/python3 -S diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 393048a346..c4f15b49fb 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1572,7 +1572,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This tagged diagnostic payload encodes MiniMax details under the matching wire key."), SuppressedProviderReference( path: "Sources/CodexBarCore/UsageFetcher.swift", - line: 1526, + line: 1591, anchor: "providerID: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."),