From 6b706a67d024de4d4f30df9d2e4e66c48e014ef5 Mon Sep 17 00:00:00 2001 From: Sogl Date: Wed, 23 Sep 2026 16:43:22 +0300 Subject: [PATCH 001/122] feat(antigravity): scope agy print reports to the selected account (#3662) In Auto mode a selected or injected Google account suppresses the identity-free ambient agy report, which drops model-scoped quota detail that only the print command exposes. Run the same print scoped instead: stage the account's OAuth tokens as agy's file-token payload in a fresh private HOME under the per-user temp dir, verify the staged id_token claim against the selected account before launch, and spawn agy with an allowlist environment plus SSH_TTY so it uses file storage and never touches the OS keyring. Scoped runs are macOS-only, fail closed to the existing account-scoped OAuth strategy, preserve the original ambient error, and never substitute an ambient report for a selected account. --- .../antigravity-scoped-fetch/README.md | 41 ++ .../AntigravityProviderDescriptor.swift | 28 +- .../AntigravityScopedPrintFetch.swift | 252 ++++++++++++ .../AntigravityScopedPrintFetchTests.swift | 373 ++++++++++++++++++ docs/antigravity.md | 11 + 5 files changed, 702 insertions(+), 3 deletions(-) create mode 100644 .github/pr-proof/antigravity-scoped-fetch/README.md create mode 100644 Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift create mode 100644 Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift diff --git a/.github/pr-proof/antigravity-scoped-fetch/README.md b/.github/pr-proof/antigravity-scoped-fetch/README.md new file mode 100644 index 0000000000..d2a9ad2d0c --- /dev/null +++ b/.github/pr-proof/antigravity-scoped-fetch/README.md @@ -0,0 +1,41 @@ +# Antigravity account-scoped print fetch proof + +Covers #3662: when a Google account is selected or injected in Auto mode and the +ambient `agy` paths cannot prove that account, CodexBar runs `agy -p /usage` +scoped to the account's staged file-token credentials instead of substituting an +identity-free ambient report. + +What the tests prove on macOS: + +- `scoped print runs agy against the staged private home` spawns a real child + process (a stub `agy` shell script) and asserts from inside the child that + `HOME` is the staged private directory, the allowlist environment carries no + `ANTIGRAVITY_OAUTH_CREDENTIALS_JSON` or unrelated parent secrets, `SSH_TTY` is + set (file-token storage, no OS keyring access), and the staged token file at + `.gemini/antigravity-cli/antigravity-oauth-token` contains the account token. + After the run the staging directory is gone. +- `staging rejects a token whose identity does not match the account` and + `staging rejects credentials without an identity claim` prove the fail-closed + boundary: the staged `id_token` claim is re-read from disk and verified + against the selected account before `agy` is ever launched. +- `scoped failure preserves the original error and never runs ambient print` + proves the fallback contract: a scoped failure rethrows the original + ambient-path error and never substitutes an identity-free report. +- `explicit cli mode never reaches the scoped fetch` and + `unselected auto fetch still uses the ambient print report` pin the unchanged + ambient behavior. + +No real accounts, credentials, Keychain items, or provider requests are used; +the stub `agy` writes a marker file to prove whether it was spawned. + +Reproduce from the repository root (macOS): + +```sh +swift test --filter AntigravityScopedPrintFetchTests +``` + +Regression surface: + +```sh +swift test --filter Antigravity +``` diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index 7f0e1c38d2..ac49466d83 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -523,6 +523,13 @@ struct AntigravityCLIHTTPSFetchStrategy: ProviderFetchStrategy { } else { nil } + #if os(macOS) + let scopedReportFetch: (@Sendable () async throws -> ProviderFetchResult)? = { + try await self.fetchScopedPrintUsage(binary: binary, environment: context.env) + } + #else + let scopedReportFetch: (@Sendable () async throws -> ProviderFetchResult)? = nil + #endif return try await Self.fetchWithReportFallback( context: context, legacyFetch: { @@ -539,13 +546,16 @@ struct AntigravityCLIHTTPSFetchStrategy: ProviderFetchStrategy { } }) }, - reportFetch: { try await self.fetchPrintUsage(binary: binary, environment: context.env) }) + reportFetch: { try await self.fetchPrintUsage(binary: binary, environment: context.env) }, + scopedReportFetch: scopedReportFetch) } static func fetchWithReportFallback( context: ProviderFetchContext, legacyFetch: @Sendable () async throws -> ProviderFetchResult, - reportFetch: @Sendable () async throws -> ProviderFetchResult) async throws -> ProviderFetchResult + reportFetch: @Sendable () async throws -> ProviderFetchResult, + scopedReportFetch: (@Sendable () async throws -> ProviderFetchResult)? = nil) + async throws -> ProviderFetchResult { do { let result = try await legacyFetch() @@ -557,7 +567,19 @@ struct AntigravityCLIHTTPSFetchStrategy: ProviderFetchStrategy { // Identity-free reports must not replace a selected or injected OAuth account's fallback. guard context.sourceMode != .auto || (context.selectedTokenAccountID == nil && context.env[AntigravityOAuthCredentialsStore.environmentCredentialsKey] == nil) - else { throw error } + else { + // An ambient report cannot prove account identity, but a scoped run can: + // the staged token's verified `id_token` claim binds the report to the + // selected account. A scoped failure preserves the original error so the + // ambient-path diagnostic is not masked by secondary staging failures. + guard let scopedReportFetch else { throw error } + do { + return try await scopedReportFetch() + } catch let scopedError { + if scopedError is CancellationError { throw scopedError } + throw error + } + } } return try await reportFetch() } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift new file mode 100644 index 0000000000..18e692a2c8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift @@ -0,0 +1,252 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif +import Foundation + +// MARK: - agy file token storage payload + +/// JSON payload of the `fileTokenStorage` fallback file that `agy` maintains at +/// `/.gemini/antigravity-cli/antigravity-oauth-token`. `agy`'s composite token +/// storage reads this file whenever the OS keyring is unavailable, so a staged `HOME` +/// scopes the account without touching the user's Keychain item. +struct AntigravityAgyFileTokenPayload: Codable, Equatable, Sendable { + struct Token: Codable, Equatable, Sendable { + let accessToken: String + let tokenType: String + let refreshToken: String + let expiry: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case refreshToken = "refresh_token" + case expiry + } + } + + let token: Token + let authMethod: String + let idToken: String? + + enum CodingKeys: String, CodingKey { + case token + case authMethod = "auth_method" + case idToken = "id_token" + } +} + +enum AntigravityAgyFileTokenEncoder { + static func encode(credentials: AntigravityOAuthCredentials) -> Data? { + guard let accessToken = credentials.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines), + !accessToken.isEmpty, + let refreshToken = credentials.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines), + !refreshToken.isEmpty, + let expiryDate = credentials.expiryDate + else { + return nil + } + + let payload = AntigravityAgyFileTokenPayload( + token: .init( + accessToken: accessToken, + tokenType: "Bearer", + refreshToken: refreshToken, + expiry: expiryString(for: expiryDate)), + authMethod: "consumer", + idToken: credentials.idToken) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try? encoder.encode(payload) + } + + static func decode(data: Data) -> AntigravityAgyFileTokenPayload? { + try? JSONDecoder().decode(AntigravityAgyFileTokenPayload.self, from: data) + } + + private static func expiryString(for date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter.string(from: date) + } +} + +// MARK: - Scoped staging + +enum AntigravityScopedStagingError: LocalizedError, Sendable, Equatable { + case credentialsMissingRequiredFields + case identityUnverifiable + + var errorDescription: String? { + switch self { + case .credentialsMissingRequiredFields: + "Antigravity account credentials lack required token or expiry fields." + case .identityUnverifiable: + "Antigravity scoped credentials could not be verified against the selected account." + } + } +} + +/// Stages the selected account's credentials into a fresh private `HOME` for a +/// single `agy` print invocation. The directory is deleted by the caller's `defer`, +/// so no account lifecycle tracking, locking, or persistent credential copies exist. +enum AntigravityScopedAgyStaging { + // Provider-specific by design: agy's file token storage path is a fixed external contract. + static let tokenRelativePath = [".gemini", "antigravity-cli", "antigravity-oauth-token"] + + /// Allowlist environment for the scoped child. Nothing else is inherited: + /// injected credentials, other providers' tokens, and ambient tool settings + /// cannot leak into the `agy` process. A non-empty `SSH_TTY` makes `agy` + /// select file-based token storage outright, so it never consults the OS keyring. + static func childEnvironment( + from environment: [String: String], + home: URL) -> [String: String] + { + var child: [String: String] = [:] + for key in [ + "PATH", + "TMPDIR", + "LANG", + "LC_ALL", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + ] { + if let value = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty { + child[key] = value + } + } + child["PATH"] = PathBuilder.effectivePATH( + purposes: [.tty], env: child, loginPATH: LoginShellPathCache.shared.current) + child["HOME"] = home.path + child["PWD"] = home.path + child["SSH_TTY"] = "codexbar-scoped" + return child + } + + /// Creates a fresh 0700 staging directory, writes the token file, then + /// re-reads and verifies that the staged `id_token` claim belongs to the + /// expected account — the identity `agy` will act as is proven from the + /// bytes it will read, not from the caller's label. + static func stage( + credentials: AntigravityOAuthCredentials, + expectedAccountEmail: String, + fileManager: FileManager = .default) throws -> (stagingRoot: URL, home: URL) + { + guard let tokenData = AntigravityAgyFileTokenEncoder.encode(credentials: credentials) else { + throw AntigravityScopedStagingError.credentialsMissingRequiredFields + } + let root = fileManager.temporaryDirectory + .appendingPathComponent("codexbar-agy-scoped-" + UUID().uuidString, isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + do { + try fileManager.createDirectory( + at: home, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + var tokenURL = home + for component in Self.tokenRelativePath.dropLast() { + tokenURL.appendPathComponent(component, isDirectory: true) + } + try fileManager.createDirectory( + at: tokenURL, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + tokenURL.appendPathComponent(Self.tokenRelativePath.last!) + try tokenData.write(to: tokenURL, options: [.atomic]) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tokenURL.path) + + guard let staged = try? Data(contentsOf: tokenURL), + let payload = AntigravityAgyFileTokenEncoder.decode(data: staged), + Self.normalizedEmail( + AntigravityOAuthCredentials.email(fromIDToken: payload.idToken)) == + Self.normalizedEmail(expectedAccountEmail) + else { + throw AntigravityScopedStagingError.identityUnverifiable + } + return (root, home) + } catch { + try? fileManager.removeItem(at: root) + throw error + } + } + + static func normalizedEmail(_ email: String?) -> String? { + guard let trimmed = email?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed.lowercased() + } +} + +// MARK: - Scoped print fetch + +#if os(macOS) +extension AntigravityCLIHTTPSFetchStrategy { + /// Runs `agy -p /usage` scoped to the injected token account's credentials: + /// the account's OAuth tokens are staged into a private per-run `HOME`, the + /// child receives an allowlist environment, and the staged token's `id_token` + /// claim is verified against the selected account before launch, so the + /// identity-free report can be attributed to that account. Fails closed: + /// any error propagates so the pipeline falls through to the account-scoped + /// OAuth strategy; ambient reports are never substituted for a selected + /// account. + func fetchScopedPrintUsage( + binary: String, + environment: [String: String], + timeout: TimeInterval = 90) async throws -> ProviderFetchResult + { + guard let value = environment[AntigravityOAuthCredentialsStore.environmentCredentialsKey], + let credentials = AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: value), + let expectedAccountEmail = credentials.resolvedAccountEmail + else { + throw AntigravityScopedStagingError.credentialsMissingRequiredFields + } + + let staged = try AntigravityScopedAgyStaging.stage( + credentials: credentials, + expectedAccountEmail: expectedAccountEmail) + defer { try? FileManager.default.removeItem(at: staged.stagingRoot) } + let scopedEnvironment = AntigravityScopedAgyStaging.childEnvironment( + from: environment, home: staged.home) + + let result: SubprocessResult + do { + let version = try await Self.agyVersion(binary: binary, environment: scopedEnvironment) + guard let version, version >= (1, 1, 11) + else { throw AntigravityStatusProbeError.parseFailed("CLI usage reports require agy 1.1.11 or later") } + result = try await SubprocessRunner.run( + binary: binary, + arguments: ["-p", "/usage", "--output-format", "json", "--print-timeout", "90s"], + environment: scopedEnvironment, + timeout: timeout, + maxOutputBytes: 1_048_576, + standardInput: FileHandle.nullDevice, + currentDirectoryURL: staged.home, + label: "antigravity-cli-scoped-usage") + } catch let error as SubprocessRunnerError { + try Task.checkCancellation() + // Subprocess errors may contain raw stderr; classify them into safe, + // fixed diagnostics instead of surfacing the process output. + throw AntigravityCLIPrintFailure.error(for: error) + } + + let parsed = try AntigravityStatusProbe.parseCLIUsageReport(Data(result.stdout.utf8)) + if let reportedEmail = AntigravityScopedAgyStaging.normalizedEmail(parsed.accountEmail), + reportedEmail != AntigravityScopedAgyStaging.normalizedEmail(expectedAccountEmail) + { + throw AntigravityStatusProbeError.accountMismatch( + expected: expectedAccountEmail, found: parsed.accountEmail) + } + let snapshot = parsed.withIdentity(from: AntigravityStatusSnapshot( + modelQuotas: [], accountEmail: expectedAccountEmail, accountPlan: nil, source: parsed.source)) + return try self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: Self.sourceLabel) + } +} +#endif diff --git a/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift b/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift new file mode 100644 index 0000000000..35671528c9 --- /dev/null +++ b/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift @@ -0,0 +1,373 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct AntigravityScopedPrintFetchTests { + // MARK: - Token payload + + @Test + func `file token payload encodes the agy storage format`() throws { + let credentials = AntigravityOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + expiryDate: Date(timeIntervalSince1970: 1_800_000_000), + idToken: "header.payload.signature", + email: "user@example.com") + let data = try #require(AntigravityAgyFileTokenEncoder.encode(credentials: credentials)) + let payload = try #require(AntigravityAgyFileTokenEncoder.decode(data: data)) + #expect(payload.token.accessToken == "access") + #expect(payload.token.tokenType == "Bearer") + #expect(payload.token.refreshToken == "refresh") + #expect(payload.token.expiry == "2027-01-15T08:00:00Z") + #expect(payload.authMethod == "consumer") + #expect(payload.idToken == "header.payload.signature") + } + + @Test + func `file token payload refuses credentials without refresh token or expiry`() { + let noRefresh = AntigravityOAuthCredentials( + accessToken: "access", refreshToken: nil, expiryDate: Date(), email: "a@b.c") + let noExpiry = AntigravityOAuthCredentials( + accessToken: "access", refreshToken: "refresh", expiryDate: nil, email: "a@b.c") + #expect(AntigravityAgyFileTokenEncoder.encode(credentials: noRefresh) == nil) + #expect(AntigravityAgyFileTokenEncoder.encode(credentials: noExpiry) == nil) + } + + // MARK: - Child environment + + @Test + func `scoped child environment inherits only allowlisted keys`() { + let parent = [ + "HOME": "/users/ambient", + "PATH": "/ambient/bin", + "TMPDIR": "/tmp/ambient", + "LANG": "en_US.UTF-8", + "HTTPS_PROXY": "http://proxy:8080", + "GEMINI_API_KEY": "ambient-secret", + "ANTHROPIC_API_KEY": "ambient-secret", + AntigravityOAuthCredentialsStore.environmentCredentialsKey: "injected-creds", + "AWS_PROFILE": "ambient-profile", + ] + let home = URL(fileURLWithPath: "/scoped/home", isDirectory: true) + let child = AntigravityScopedAgyStaging.childEnvironment(from: parent, home: home) + + #expect(child["HOME"] == "/scoped/home") + #expect(child["PWD"] == "/scoped/home") + #expect(child["SSH_TTY"] == "codexbar-scoped") + #expect(child["TMPDIR"] == "/tmp/ambient") + #expect(child["LANG"] == "en_US.UTF-8") + #expect(child["HTTPS_PROXY"] == "http://proxy:8080") + #expect(child["PATH"]?.isEmpty == false) + #expect(child["GEMINI_API_KEY"] == nil) + #expect(child["ANTHROPIC_API_KEY"] == nil) + #expect(child["AWS_PROFILE"] == nil) + #expect(child[AntigravityOAuthCredentialsStore.environmentCredentialsKey] == nil) + } + + // MARK: - Staging and identity verification + + @Test + func `staging writes a private token file verified against the account claim`() throws { + let credentials = self.credentials(email: "scoped@example.com") + let staged = try AntigravityScopedAgyStaging.stage( + credentials: credentials, expectedAccountEmail: "scoped@example.com") + defer { try? FileManager.default.removeItem(at: staged.stagingRoot) } + + let tokenURL = staged.home + .appendingPathComponent(".gemini/antigravity-cli/antigravity-oauth-token") + let attrs = try FileManager.default.attributesOfItem(atPath: tokenURL.path) + #expect((attrs[.posixPermissions] as? Int) == 0o600) + let homeAttrs = try FileManager.default.attributesOfItem(atPath: staged.home.path) + #expect((homeAttrs[.posixPermissions] as? Int) == 0o700) + } + + @Test + func `staging rejects a token whose identity does not match the account`() throws { + let credentials = self.credentials(email: "other@example.com") + do { + _ = try AntigravityScopedAgyStaging.stage( + credentials: credentials, expectedAccountEmail: "scoped@example.com") + Issue.record("A token for a different account must not be staged") + } catch AntigravityScopedStagingError.identityUnverifiable {} + #expect(self.scopedStagingDirectories().isEmpty) + } + + @Test + func `staging rejects credentials without an identity claim`() throws { + let credentials = AntigravityOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + expiryDate: Date().addingTimeInterval(3600)) + do { + _ = try AntigravityScopedAgyStaging.stage( + credentials: credentials, expectedAccountEmail: "scoped@example.com") + Issue.record("Unverifiable staged identity must fail closed") + } catch AntigravityScopedStagingError.identityUnverifiable {} + #expect(self.scopedStagingDirectories().isEmpty) + } + + // MARK: - Fallback wiring (platform-independent) + + @Test + func `selected auto account uses the scoped report when legacy fails`() async throws { + let strategy = AntigravityCLIHTTPSFetchStrategy() + let expected = strategy.makeResult( + usage: self.makeUsage(email: "scoped@example.com"), sourceLabel: "cli") + let result = try await AntigravityCLIHTTPSFetchStrategy.fetchWithReportFallback( + context: self.makeContext(selected: true, env: self.accountEnv(email: "scoped@example.com")), + legacyFetch: { throw AntigravityStatusProbeError.timedOut }, + reportFetch: { + Issue.record("Ambient identity-free print stays suppressed for selected accounts") + throw AntigravityStatusProbeError.notRunning + }, + scopedReportFetch: { expected }) + #expect(result.usage.identity?.accountEmail == "scoped@example.com") + } + + @Test + func `scoped failure preserves the original error and never runs ambient print`() async { + await #expect(throws: AntigravityStatusProbeError.timedOut) { + try await AntigravityCLIHTTPSFetchStrategy.fetchWithReportFallback( + context: self.makeContext(selected: true, env: self.accountEnv(email: "scoped@example.com")), + legacyFetch: { throw AntigravityStatusProbeError.timedOut }, + reportFetch: { + Issue.record("Ambient identity-free print stays suppressed for selected accounts") + throw AntigravityStatusProbeError.notRunning + }, + scopedReportFetch: { + throw AntigravityStatusProbeError.cliReportFailed(.executableNotFound) + }) + } + } + + @Test + func `cancellation stops the pipeline before the scoped fetch`() async { + await #expect(throws: CancellationError.self) { + try await AntigravityCLIHTTPSFetchStrategy.fetchWithReportFallback( + context: self.makeContext(selected: true, env: self.accountEnv(email: "scoped@example.com")), + legacyFetch: { throw CancellationError() }, + reportFetch: { + Issue.record("Cancellation must stop the provider pipeline") + throw AntigravityStatusProbeError.notRunning + }, + scopedReportFetch: { + Issue.record("Cancellation must not start a scoped subprocess") + throw AntigravityStatusProbeError.notRunning + }) + } + } + + @Test + func `unselected auto fetch still uses the ambient print report`() async throws { + let strategy = AntigravityCLIHTTPSFetchStrategy() + let expected = strategy.makeResult(usage: self.makeUsage(email: nil), sourceLabel: "cli") + let result = try await AntigravityCLIHTTPSFetchStrategy.fetchWithReportFallback( + context: self.makeContext(), + legacyFetch: { throw AntigravityStatusProbeError.timedOut }, + reportFetch: { expected }, + scopedReportFetch: { + Issue.record("Scoped fetch is reserved for selected or injected accounts") + throw AntigravityStatusProbeError.notRunning + }) + #expect(result.usage.identity?.accountEmail == nil) + } + + @Test + func `explicit cli mode never reaches the scoped fetch`() async throws { + let strategy = AntigravityCLIHTTPSFetchStrategy() + let expected = strategy.makeResult(usage: self.makeUsage(email: nil), sourceLabel: "cli") + let result = try await AntigravityCLIHTTPSFetchStrategy.fetchWithReportFallback( + context: self.makeContext( + sourceMode: .cli, selected: true, env: self.accountEnv(email: "scoped@example.com")), + legacyFetch: { throw AntigravityStatusProbeError.timedOut }, + reportFetch: { expected }, + scopedReportFetch: { + Issue.record("Explicit cli mode stays bound to the ambient login") + throw AntigravityStatusProbeError.notRunning + }) + #expect(result.usage.identity?.accountEmail == nil) + } + + // MARK: - Scoped subprocess (macOS only) + + #if os(macOS) + @Test + func `scoped print runs agy against the staged private home`() async throws { + let report = try self.reportJSON() + let fixture = try self.scopedPrintFixture(body: """ + [ -n "${SSH_TTY:-}" ] || exit 21 + [ -z "${ANTIGRAVITY_OAUTH_CREDENTIALS_JSON+x}" ] || exit 22 + [ -z "${LEAKED_PARENT_SECRET+x}" ] || exit 23 + [ "$HOME" != "/Users/ambient" ] || exit 26 + [ -f "$HOME/.gemini/antigravity-cli/antigravity-oauth-token" ] || exit 24 + /usr/bin/grep -q 'scoped-access-token' "$HOME/.gemini/antigravity-cli/antigravity-oauth-token" || exit 25 + /bin/cat <<'REPORT' + \(report) + REPORT + """) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + var environment = self.accountEnv(email: "scoped@example.com") + environment.merge(fixture.environment) { _, new in new } + environment["HOME"] = "/Users/ambient" + environment["LEAKED_PARENT_SECRET"] = "must-not-reach-child" + + let preexisting = Set(self.scopedStagingDirectories()) + let result = try await AntigravityCLIHTTPSFetchStrategy().fetchScopedPrintUsage( + binary: fixture.binary.path, environment: environment) + + #expect(result.usage.identity?.accountEmail == "scoped@example.com") + #expect(abs((result.usage.primary?.usedPercent ?? -1) - 40) < 0.001) + #expect(Set(self.scopedStagingDirectories()) == preexisting) + } + + @Test + func `scoped print refuses undecodable injected credentials without spawning`() async throws { + let fixture = try self.scopedPrintFixture(body: "echo invoked > \"$(dirname \"$0\")/invoked\"; exit 19") + defer { try? FileManager.default.removeItem(at: fixture.directory) } + var environment = fixture.environment + environment[AntigravityOAuthCredentialsStore.environmentCredentialsKey] = "malformed" + + await #expect(throws: AntigravityScopedStagingError.credentialsMissingRequiredFields) { + try await AntigravityCLIHTTPSFetchStrategy().fetchScopedPrintUsage( + binary: fixture.binary.path, environment: environment) + } + #expect(!FileManager.default.fileExists( + atPath: fixture.directory.appendingPathComponent("invoked").path)) + } + + @Test + func `scoped print maps stderr to a classified failure`() async throws { + let fixture = try self.scopedPrintFixture(body: """ + /bin/cat >&2 <<'STDERR' + Eligibility check failed: account does not support Google ToS + STDERR + exit 1 + """) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + var environment = self.accountEnv(email: "scoped@example.com") + environment.merge(fixture.environment) { _, new in new } + + await #expect(throws: AntigravityStatusProbeError.cliReportFailed( + .exited(code: 1, reason: .ineligible))) + { + try await AntigravityCLIHTTPSFetchStrategy().fetchScopedPrintUsage( + binary: fixture.binary.path, environment: environment) + } + } + #endif + + // MARK: - Helpers + + private func credentials(email: String) -> AntigravityOAuthCredentials { + AntigravityOAuthCredentials( + accessToken: "scoped-access-token", + refreshToken: "refresh", + expiryDate: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: email), + email: email) + } + + private func accountEnv(email: String) -> [String: String] { + guard let value = try? AntigravityOAuthCredentialsStore.tokenAccountValue( + for: self.credentials(email: email)) + else { return [:] } + return [AntigravityOAuthCredentialsStore.environmentCredentialsKey: value] + } + + private func makeContext( + sourceMode: ProviderSourceMode = .auto, + selected: Bool = false, + env: [String: String] = [:]) -> ProviderFetchContext + { + var effectiveEnv = env + effectiveEnv["HOME"] = effectiveEnv["HOME"] ?? FileManager.default.temporaryDirectory.path + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: effectiveEnv, + settings: nil, + fetcher: UsageFetcher(environment: effectiveEnv), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + selectedTokenAccountID: selected ? UUID() : nil, + persistsCLISessions: false) + } + + private func makeUsage(email: String?) -> UsageSnapshot { + UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: email, + accountOrganization: nil, + loginMethod: nil)) + } + + private func scopedStagingDirectories() -> [String] { + (try? FileManager.default.contentsOfDirectory( + atPath: FileManager.default.temporaryDirectory.path))? + .filter { $0.hasPrefix("codexbar-agy-scoped-") } ?? [] + } + + private func reportJSON() throws -> String { + let report: [String: Any] = [ + "status": "SUCCESS", + "response": "Synthetic quota report", + "command": [ + "name": "usage", + "data": [ + "groups": [[ + "name": "Gemini Models", + "buckets": [[ + "id": "gemini-5h", + "name": "Five Hour Limit Remaining", + "window": "5h", + "remaining_fraction": 0.6, + ]], + ]], + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: report) + return try #require(String(bytes: data, encoding: .utf8)) + } + + #if os(macOS) + private func scopedPrintFixture(body: String, version: String? = "1.2.7") throws + -> (directory: URL, binary: URL, environment: [String: String]) + { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let binary = directory.appendingPathComponent("agy") + var script = "#!/bin/sh\nset -eu\n" + if let version { + script += "if [ \"${1:-}\" = --version ]; then echo \"\(version)\"; exit 0; fi\n" + } + try (script + body + "\n").write(to: binary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: binary.path) + return (directory, binary, ["PATH": "/usr/bin:/bin"]) + } + #endif + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } +} diff --git a/docs/antigravity.md b/docs/antigravity.md index ba6b825f93..92c45c2304 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -45,6 +45,17 @@ The report contains no account or plan identity: explicit CLI mode remains autho this fallback only without a selected token account or explicitly injected OAuth credentials. Successful HTTPS results retain their verified identity. Failed command diagnostics do not include raw stderr. +On macOS, when a Google account is selected or injected in Auto mode and the ambient paths cannot prove +that account, CodexBar instead runs the same print command scoped to the account: its OAuth credentials +are written as `agy`'s file-token payload into a fresh private `HOME` under the per-user temporary +directory, the staged `id_token` claim is re-read from disk and verified against the selected account +before launch, and the child process receives an allowlist environment (login `PATH`, locale, proxy +variables) without `ANTIGRAVITY_OAUTH_CREDENTIALS_JSON` or any ambient provider credentials. A non-empty +`SSH_TTY` forces `agy` onto file-token storage so the scoped run never touches the OS keyring. The +staging directory is deleted after the run, a report carrying conflicting identity is rejected, and any +scoped failure preserves the original ambient error — an ambient report is never substituted for a +selected account, so the pipeline falls through to the account-scoped OAuth fetch exactly as before. + If live sources fail and local conversation history is available, CodexBar labels the result as offline and shows a safe explanation of the live failure in settings and CLI usage output. CLI failures distinguish sign-in, eligibility, and network problems without exposing stderr, URLs, or account emails. Offline conversation counts From e0172ea2efe543f5db5600207452593938ab458d Mon Sep 17 00:00:00 2001 From: Sogl Date: Wed, 23 Sep 2026 17:03:15 +0300 Subject: [PATCH 002/122] docs(antigravity): add live scoped-fetch proof artifacts Real agy 1.2.9 runs against a staged private HOME authenticate as the selected saved account while the ambient CLI login is a different account, leave the ambient ~/.gemini tree and OAuth token untouched, reject revoked staged credentials with UNAUTHENTICATED instead of using the ambient login, and refresh expired staged grants in place. --- .../antigravity-scoped-fetch/README.md | 61 ++++++------ .../live-evidence.log | 93 +++++++++++++++++++ 2 files changed, 124 insertions(+), 30 deletions(-) create mode 100644 .github/pr-proof/antigravity-scoped-fetch/live-evidence.log diff --git a/.github/pr-proof/antigravity-scoped-fetch/README.md b/.github/pr-proof/antigravity-scoped-fetch/README.md index d2a9ad2d0c..4d3939cdbd 100644 --- a/.github/pr-proof/antigravity-scoped-fetch/README.md +++ b/.github/pr-proof/antigravity-scoped-fetch/README.md @@ -5,37 +5,38 @@ ambient `agy` paths cannot prove that account, CodexBar runs `agy -p /usage` scoped to the account's staged file-token credentials instead of substituting an identity-free ambient report. -What the tests prove on macOS: - -- `scoped print runs agy against the staged private home` spawns a real child - process (a stub `agy` shell script) and asserts from inside the child that - `HOME` is the staged private directory, the allowlist environment carries no - `ANTIGRAVITY_OAUTH_CREDENTIALS_JSON` or unrelated parent secrets, `SSH_TTY` is - set (file-token storage, no OS keyring access), and the staged token file at - `.gemini/antigravity-cli/antigravity-oauth-token` contains the account token. - After the run the staging directory is gone. -- `staging rejects a token whose identity does not match the account` and - `staging rejects credentials without an identity claim` prove the fail-closed - boundary: the staged `id_token` claim is re-read from disk and verified - against the selected account before `agy` is ever launched. -- `scoped failure preserves the original error and never runs ambient print` - proves the fallback contract: a scoped failure rethrows the original - ambient-path error and never substitutes an identity-free report. -- `explicit cli mode never reaches the scoped fetch` and - `unselected auto fetch still uses the ambient print report` pin the unchanged - ambient behavior. - -No real accounts, credentials, Keychain items, or provider requests are used; -the stub `agy` writes a marker file to prove whether it was spawned. - -Reproduce from the repository root (macOS): +`live-evidence.log` — macOS, agy 1.2.9, real accounts (emails redacted): + +1. `CodexBarCLI usage --account A` runs the scoped `antigravity-cli-scoped-usage` + subprocess and returns A's quota labeled A while ambient agy is logged in as + a different account B. +2. Auto mode with the app-selected account takes the same scoped path. +3. A revoked-credential account preserves the original ambient-path error, falls + back to the account-scoped OAuth strategy, and never displays B's quota. +4. Manually staged runs confirm the process boundary: agy authenticates as A in + its own log, the ambient `~/.gemini` tree (614 files) is untouched, revoked + staged credentials get UNAUTHENTICATED (401), and an expired staged grant + refreshes in place. +5. Provenance: the saved grants carry the OAuth client ID that CodexBar's + Add Account flow discovers from the installed Antigravity.app — they are + CodexBar-minted under Antigravity's own client. +6. Production expired-grant run: with `expiry_date` forced to the past the + scoped `agy` attempt fails closed (90s bound), the original ambient-path + error is preserved, and the account-scoped OAuth strategy recovers with + A's data labeled A. + +Reproduce the scoped run from a shell (requires a real `agy` and a saved +Antigravity token account): ```sh -swift test --filter AntigravityScopedPrintFetchTests +# Stage /.gemini/antigravity-cli/antigravity-oauth-token in agy's +# file-token format, then: +env -i HOME= PWD= SSH_TTY=codexbar-scoped \ + PATH="$PATH" TMPDIR="$TMPDIR" LANG=en_US.UTF-8 \ + agy -p /usage --output-format json --print-timeout 90s ``` -Regression surface: - -```sh -swift test --filter Antigravity -``` +Automated coverage: `swift test --filter AntigravityScopedPrintFetchTests` +(staging format, env allowlist, identity verification, fail-closed wiring, +spawned stub-`agy` end-to-end) and `swift test --filter Antigravity` for the +regression surface. diff --git a/.github/pr-proof/antigravity-scoped-fetch/live-evidence.log b/.github/pr-proof/antigravity-scoped-fetch/live-evidence.log new file mode 100644 index 0000000000..c3c880c01b --- /dev/null +++ b/.github/pr-proof/antigravity-scoped-fetch/live-evidence.log @@ -0,0 +1,93 @@ +# Live macOS proof — agy 1.2.9, CodexBarCLI built from this branch. +# Ambient agy CLI login = account B (a***@gmail.com). +# Scoped account A = p***@gmail.com, a different saved Google account. +# Emails redacted; no credentials appear below. + +## 1. Production path: selected account A gets A's quota via the scoped spawn + +$ CodexBarCLI usage --provider antigravity --account p***@gmail.com --format json -v + + debug subprocess: binary=agy label=antigravity-cli-version status=0 + debug antigravity: Antigravity CLI HTTPS spawn skipped; agy local server requires a CSRF token + debug subprocess: binary=agy label=antigravity-cli-version status=0 + debug subprocess: binary=agy label=antigravity-cli-scoped-usage status=0 + + Result: source=cli, accountEmail=p***@gmail.com + Gemini weekly usedPercent=0 resetsAt=2026-09-30T13:07:31Z + Claude/GPT weekly usedPercent=100 resetsAt=2026-09-25T08:15:28Z + + Ambient B's windows for contrast (raw `agy -p /usage` under real ~/.gemini): + Gemini weekly usedPercent=0 resetsAt=2026-09-30T10:04:56Z + Claude/GPT weekly usedPercent=100 resetsAt=2026-09-23T14:58:41Z + +## 2. Auto mode without --account takes the same scoped path + +$ CodexBarCLI usage --provider antigravity --format json -v + (app-selected account = p***@gmail.com) + + debug subprocess: binary=agy label=antigravity-cli-scoped-usage status=0 + Result: identical A windows, accountEmail=p***@gmail.com + No ambient report substituted although agy's ambient login is B. + +## 3. Revoked scoped credentials cannot substitute ambient B + +$ CodexBarCLI usage --account revoked-proof@example.com ... (temporary fake entry, removed after) + + warning subprocess: binary=agy label=antigravity-cli-scoped-usage status=1 + strategies: antigravity.cli-https error=agy 1.2.2 or later requires a local CSRF token + antigravity.oauth error=Antigravity Google auth not found + Result: source=offline, labeled revoked-proof@example.com. + The original ambient-path error is preserved; B's quota never appears. + +## 4. Manual staging boundary (what the scoped spawn does internally) + +Staged /.gemini/antigravity-cli/antigravity-oauth-token for A, then: + +$ env -i HOME= PWD= SSH_TTY=codexbar-scoped PATH=... TMPDIR=... LANG=... \ + agy -p /usage --output-format json --print-timeout 90s + + /.gemini/antigravity-cli/log/cli-*.log: + server_oauth.go:196] applyAuthResult: email=p***@gmail.com, authMethod=consumer + server_oauth.go:201] OAuth: authenticated successfully as p***@gmail.com + exit=0, report = A's windows above. + + Ambient ~/.gemini/antigravity-cli (614 files) snapshotted before/after: + files touched: NONE | ambient oauth-token touched: False + + Same run with garbage access_token/refresh_token staged: + exit=1, stderr: Eligibility check failed: UNAUTHENTICATED (code 401) + Ambient B is logged in and healthy; no ambient fallback occurred. + + Same run with token expiry forced to 2020-01-01: + exit=0, A's report returned; agy refreshed the grant and rewrote only the + staged token file (new access_token, expiry +1h, id_token preserved). + +## 5. Grant provenance: the tested account came from CodexBar "Add Account" + +All 4 saved token accounts in ~/.codexbar/config.json carry OAuth client_id + 1071006060591-***.apps.googleusercontent.com +CodexBar's Add Account flow resolves its OAuth client by discovering it from +the installed /Applications/Antigravity.app +(AntigravityOAuthConfig.discoverClientFromInstalledApp). That app binary +contains exactly two OAuth client IDs and the saved grants use the primary +one — i.e. these are CodexBar-minted grants under Antigravity's own client, +not tokens copied out of agy's state. + +## 6. Production expired-grant run: scoped attempt fails closed, OAuth recovers + +$ CodexBarCLI usage --provider antigravity --account p***@gmail.com --format json -v + (saved expiry_date forced to the past for this run; restored after) + + Run 1: antigravity-cli-scoped-usage hit the 90s timeout while agy attempted + to refresh the expired grant + Run 2: antigravity-cli-scoped-usage status=1 in ~17s + strategies: antigravity.cli-https error=agy 1.2.2 or later requires a local CSRF token + Result: source=oauth, accountEmail=p***@gmail.com, A's quota windows + Gemini weekly resetsAt=2026-09-30T13:07:31Z + + The scoped attempt fails closed within the timeout bound, the original + ambient-path error is preserved in the diagnostics, and the account-scoped + OAuth strategy recovers with A's data labeled A. Ambient B never appears. + (In-run token refresh via agy for this grant was unreliable in the staged + environment — observed once as a stall, once as an exit-1 — which is why the + account-scoped OAuth fallback exists.) From 7a279fa06eabc8c232ebb4cc5f37755810459f8c Mon Sep 17 00:00:00 2001 From: Dohyeon Park Date: Mon, 28 Sep 2026 01:30:23 +0900 Subject: [PATCH 003/122] Make active quota depletion visible in the menu bar Reuse CodexBar account-scoped history so chart points retain their provider, account, and reset-window identity. Constraint: Preserve existing provider fetching and the localized Plan Usage menu. Rejected: Join history to a live snapshot by window duration | account switches and same-duration Claude lanes can mix data. Confidence: high Scope-risk: narrow Tested: swift build; make check; 66 focused tests; synthetic chart render. Not-tested: live provider polling or signed app launch. --- README.md | 1 + .../CodexBar/QuotaBurndownChartMenuView.swift | 145 +++++++++++++++ Sources/CodexBar/QuotaBurndownModel.swift | 78 ++++++++ ...tatusItemController+UsageHistoryMenu.swift | 21 +++ .../QuotaBurndownChartMenuViewTests.swift | 56 ++++++ .../QuotaBurndownModelTests.swift | 173 ++++++++++++++++++ .../QuotaBurndownRenderProofTests.swift | 46 +++++ docs/widgets/burndown-menu-synthetic.png | Bin 0 -> 31329 bytes 8 files changed, 520 insertions(+) create mode 100644 Sources/CodexBar/QuotaBurndownChartMenuView.swift create mode 100644 Sources/CodexBar/QuotaBurndownModel.swift create mode 100644 Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift create mode 100644 Tests/CodexBarTests/QuotaBurndownModelTests.swift create mode 100644 Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift create mode 100644 docs/widgets/burndown-menu-synthetic.png diff --git a/README.md b/README.md index 32a9bcdf1a..9c76a58e22 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ show an incident indicator. - Provider-specific usage meters with reset countdowns. - Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history). - Inline spend and usage charts for API-backed providers such as OpenAI, Claude Admin API, OpenRouter, LiteLLM, z.ai, MiniMax, Mistral, and AWS Bedrock. +- Codex and Claude Plan Usage menus show a current-window quota burndown from recorded snapshots, alongside an even-use guide. The line fills in as the app collects samples. - Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories. Codex history uses a WAL-enabled SQLite store capped at 25,000 retained session entries and 256 MiB. - A persistent Settings → Usage & Spend view for local estimates, grouped by native currency and provider. Each provider shows its accounts or history sources alongside its model breakdown; project/session views and daily/hourly trends share compact selectors. Incomplete history stays labeled, and source, privacy, export, and sharing controls remain available. - Provider status polling with incident badges in the menu and icon overlay. diff --git a/Sources/CodexBar/QuotaBurndownChartMenuView.swift b/Sources/CodexBar/QuotaBurndownChartMenuView.swift new file mode 100644 index 0000000000..23edc93c60 --- /dev/null +++ b/Sources/CodexBar/QuotaBurndownChartMenuView.swift @@ -0,0 +1,145 @@ +import Charts +import CodexBarCore +import SwiftUI + +@MainActor +struct QuotaBurndownChartMenuView: View { + private struct Series: Identifiable { + let id: String + let title: String + let model: QuotaBurndownModel + } + + private let series: [Series] + private let width: CGFloat + private let color: Color + + @State private var selectedSeriesID: String? + + init( + provider: UsageProvider, + histories: [PlanUtilizationSeriesHistory], + width: CGFloat, + referenceDate: Date = Date()) + { + self.series = histories.compactMap { history in + guard let latest = history.entries.last, + let reset = latest.resetsAt, + latest.capturedAt <= referenceDate, + reset > referenceDate + else { return nil } + let window = RateWindow( + usedPercent: latest.usedPercent, + windowMinutes: history.windowMinutes, + resetsAt: reset, + resetDescription: nil) + guard let model = QuotaBurndownModel(history: history, window: window, now: latest.capturedAt) + else { return nil } + let title: String + switch history.name { + case .session: title = L("Session") + case .weekly: title = L("Weekly") + case .monthly: title = L("Monthly") + case .opus: title = L("Opus") + default: return nil + } + return Series(id: "\(history.name.rawValue):\(history.windowMinutes)", title: title, model: model) + } + self.width = width + let accent = ProviderAccentPalette.color(for: provider) + self.color = Color(red: accent.red, green: accent.green, blue: accent.blue) + } + + var body: some View { + let selected = self.series.first(where: { $0.id == self.selectedSeriesID }) ?? self.series.first + + VStack(alignment: .leading, spacing: 10) { + if self.series.count > 1 { + Picker(selection: Binding( + get: { selected?.id ?? "" }, + set: { self.selectedSeriesID = $0 })) + { + ForEach(self.series) { series in + Text(series.title).tag(series.id) + } + } label: { + EmptyView() + } + .labelsHidden() + .pickerStyle(.segmented) + } + + if let selected { + Chart { + ForEach(selected.model.ideal, id: \.date) { point in + LineMark( + x: .value("Time", point.date), + y: .value(L("Usage remaining"), point.remainingPercent), + series: .value("Series", "Ideal")) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + } + ForEach(selected.model.samples, id: \.date) { point in + LineMark( + x: .value("Time", point.date), + y: .value(L("Usage remaining"), point.remainingPercent), + series: .value("Series", "Observed")) + .foregroundStyle(self.color) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + if let current = selected.model.samples.last { + PointMark( + x: .value("Time", current.date), + y: .value(L("Usage remaining"), current.remainingPercent)) + .foregroundStyle(self.color) + .symbolSize(35) + } + } + .chartXScale(domain: selected.model.start...selected.model.reset) + .chartYScale(domain: 0...100) + .chartYAxis { + AxisMarks(values: [0, 50, 100]) + } + .chartXAxis(.hidden) + .chartLegend(.hidden) + .frame(height: 130) + .accessibilityLabel(L("Usage remaining")) + + HStack { + Text(selected.model.start.formatted(.dateTime.hour().minute())) + Spacer() + Text(selected.model.reset.formatted(.dateTime.hour().minute())) + } + .font(.caption) + .foregroundStyle(.secondary) + + if let remaining = selected.model.samples.last?.remainingPercent { + Text("\(remaining.formatted(.number.precision(.fractionLength(0))))% \(L("Usage remaining"))") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text(L("No data")) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .frame(height: 146) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(minWidth: self.width, maxWidth: .infinity, alignment: .topLeading) + } + + var hasSeries: Bool { + !self.series.isEmpty + } + + #if DEBUG + var _seriesRemainingForTesting: [String: Double] { + Dictionary(uniqueKeysWithValues: self.series.compactMap { series in + series.model.samples.last.map { (series.id, $0.remainingPercent) } + }) + } + #endif +} diff --git a/Sources/CodexBar/QuotaBurndownModel.swift b/Sources/CodexBar/QuotaBurndownModel.swift new file mode 100644 index 0000000000..a36ab6acbe --- /dev/null +++ b/Sources/CodexBar/QuotaBurndownModel.swift @@ -0,0 +1,78 @@ +import CodexBarCore +import Foundation + +struct QuotaBurndownModel: Equatable, Sendable { + private static let resetEquivalenceTolerance: TimeInterval = 2 * 60 + + struct Sample: Equatable, Sendable { + let date: Date + let remainingPercent: Double + } + + let start: Date + let reset: Date + let samples: [Sample] + let ideal: [Sample] + + init?(history: PlanUtilizationSeriesHistory, window: RateWindow, now: Date) { + guard window.usedPercent.isFinite, + !window.isSyntheticPlaceholder, + let windowMinutes = window.windowMinutes, + windowMinutes > 0, + let reset = window.resetsAt, + reset.timeIntervalSinceReferenceDate.isFinite, + now.timeIntervalSinceReferenceDate.isFinite + else { return nil } + + let duration = Double(windowMinutes) * 60 + guard duration.isFinite, duration > 0 else { return nil } + + let start = reset.addingTimeInterval(-duration) + guard start.timeIntervalSinceReferenceDate.isFinite, + start <= now, + now < reset + else { return nil } + + let historicalSamples = history.entries.enumerated().compactMap { index, entry -> (Int, Date, Double)? in + guard entry.capturedAt >= start, + entry.capturedAt <= now, + entry.capturedAt.timeIntervalSinceReferenceDate.isFinite, + entry.usedPercent.isFinite, + entry.resetsAt.map({ + abs($0.timeIntervalSince(reset)) <= Self.resetEquivalenceTolerance + }) ?? true + else { return nil } + return (index, entry.capturedAt, entry.usedPercent) + } + .sorted { lhs, rhs in + lhs.1 == rhs.1 ? lhs.0 < rhs.0 : lhs.1 < rhs.1 + } + .map { ($0.1, $0.2) } + + var currentSegment: [(Date, Double)] = [] + for sample in historicalSamples + [(now, window.usedPercent)] { + if let last = currentSegment.last { + if sample.0 == last.0 { + currentSegment[currentSegment.count - 1] = sample + continue + } + if sample.1 < last.1 { + currentSegment.removeAll(keepingCapacity: true) + } + } + currentSegment.append(sample) + } + + self.start = start + self.reset = reset + self.samples = currentSegment.map { date, usedPercent in + Sample( + date: date, + remainingPercent: (100 - usedPercent).clamped(to: 0...100)) + } + self.ideal = [ + Sample(date: start, remainingPercent: 100), + Sample(date: reset, remainingPercent: 0), + ] + } +} diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index de3639d8fa..1b2c135fda 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -49,6 +49,27 @@ extension StatusItemController { return true } + // Provider-specific by design: this menu burndown currently targets Codex and Claude quota histories. + if provider == .codex || provider == .claude { + let burndownView = QuotaBurndownChartMenuView( + provider: provider, + histories: histories, + width: width) + if burndownView.hasSeries { + let hosting = UsageHistoryMenuHostingView(rootView: burndownView) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = true + chartItem.representedObject = Self.usageHistoryChartID + chartItem.toolTip = provider.rawValue + submenu.addItem(chartItem) + return true + } + } + let chartView = PlanUtilizationHistoryChartMenuView( provider: provider, histories: histories, diff --git a/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift b/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift new file mode 100644 index 0000000000..abd324691a --- /dev/null +++ b/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct QuotaBurndownChartMenuViewTests { + @Test + func `keeps same duration quota lanes separate`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let histories = [ + PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 10080, + entries: [ + .init(capturedAt: now, usedPercent: 20, resetsAt: now.addingTimeInterval(3600)), + ]), + PlanUtilizationSeriesHistory( + name: .opus, + windowMinutes: 10080, + entries: [ + .init(capturedAt: now, usedPercent: 70, resetsAt: now.addingTimeInterval(7200)), + ]), + ] + + let view = QuotaBurndownChartMenuView( + provider: .claude, + histories: histories, + width: 400, + referenceDate: now) + + #expect(view._seriesRemainingForTesting["weekly:10080"] == 80) + #expect(view._seriesRemainingForTesting["opus:10080"] == 30) + } + + @Test + func `hides a completed reset window`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let history = PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [ + .init( + capturedAt: now.addingTimeInterval(-3600), + usedPercent: 40, + resetsAt: now.addingTimeInterval(-1)), + ]) + + let view = QuotaBurndownChartMenuView( + provider: .codex, + histories: [history], + width: 400, + referenceDate: now) + + #expect(!view.hasSeries) + } +} diff --git a/Tests/CodexBarTests/QuotaBurndownModelTests.swift b/Tests/CodexBarTests/QuotaBurndownModelTests.swift new file mode 100644 index 0000000000..bdc6a4d51c --- /dev/null +++ b/Tests/CodexBarTests/QuotaBurndownModelTests.swift @@ -0,0 +1,173 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct QuotaBurndownModelTests { + @Test + func `builds current window samples and ideal line`() throws { + let reset = Self.now.addingTimeInterval(2 * 3600) + let history = Self.history(entries: [ + Self.entry(hoursBeforeNow: 2, usedPercent: 20, reset: reset), + Self.entry(hoursBeforeNow: 1, usedPercent: 45, reset: reset), + ]) + + let model = try #require(QuotaBurndownModel( + history: history, + window: Self.window(usedPercent: 60, reset: reset), + now: Self.now)) + + #expect(model.start == reset.addingTimeInterval(-5 * 3600)) + #expect(model.reset == reset) + #expect(model.samples == [ + .init(date: Self.now.addingTimeInterval(-2 * 3600), remainingPercent: 80), + .init(date: Self.now.addingTimeInterval(-3600), remainingPercent: 55), + .init(date: Self.now, remainingPercent: 40), + ]) + #expect(model.ideal == [ + .init(date: reset.addingTimeInterval(-5 * 3600), remainingPercent: 100), + .init(date: reset, remainingPercent: 0), + ]) + } + + @Test + func `isolates the current reset and keeps only the newest segment after a usage drop`() throws { + let reset = Self.now.addingTimeInterval(2 * 3600) + let priorReset = reset.addingTimeInterval(-5 * 3600) + let history = Self.history(entries: [ + Self.entry(hoursBeforeNow: 6, usedPercent: 90, reset: priorReset), + Self.entry(hoursBeforeNow: 4, usedPercent: 70, reset: reset), + Self.entry(hoursBeforeNow: 2, usedPercent: 80, reset: reset), + Self.entry(hoursBeforeNow: 1.5, usedPercent: 85, reset: priorReset), + Self.entry(hoursBeforeNow: 1, usedPercent: 10, reset: reset), + Self.entry(hoursBeforeNow: 0.5, usedPercent: 25, reset: reset), + ]) + + let model = try #require(QuotaBurndownModel( + history: history, + window: Self.window(usedPercent: 30, reset: reset), + now: Self.now)) + + #expect(model.samples == [ + .init(date: Self.now.addingTimeInterval(-3600), remainingPercent: 90), + .init(date: Self.now.addingTimeInterval(-1800), remainingPercent: 75), + .init(date: Self.now, remainingPercent: 70), + ]) + } + + @Test + func `accepts small reset timestamp drift while excluding another cycle`() throws { + let reset = Self.now.addingTimeInterval(2 * 3600) + let history = Self.history(entries: [ + Self.entry(hoursBeforeNow: 2, usedPercent: 20, reset: reset.addingTimeInterval(90)), + Self.entry(hoursBeforeNow: 1, usedPercent: 40, reset: reset.addingTimeInterval(-5 * 3600)), + ]) + + let model = try #require(QuotaBurndownModel( + history: history, + window: Self.window(usedPercent: 50, reset: reset), + now: Self.now)) + + #expect(model.samples == [ + .init(date: Self.now.addingTimeInterval(-2 * 3600), remainingPercent: 80), + .init(date: Self.now, remainingPercent: 50), + ]) + } + + @Test + func `requires a valid current reset and duration`() { + let history = Self.history(entries: []) + + #expect(QuotaBurndownModel( + history: history, + window: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + now: Self.now) == nil) + #expect(QuotaBurndownModel( + history: history, + window: RateWindow( + usedPercent: 10, + windowMinutes: 0, + resetsAt: Self.now.addingTimeInterval(3600), + resetDescription: nil), + now: Self.now) == nil) + #expect(QuotaBurndownModel( + history: history, + window: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: Self.now.addingTimeInterval(3600), + resetDescription: nil, + isSyntheticPlaceholder: true), + now: Self.now) == nil) + } + + @Test + func `uses the live window when history has no current samples`() throws { + let reset = Self.now.addingTimeInterval(2 * 3600) + let history = Self.history(entries: [ + Self.entry(hoursBeforeNow: 6, usedPercent: 80, reset: reset.addingTimeInterval(-5 * 3600)), + ]) + + let model = try #require(QuotaBurndownModel( + history: history, + window: Self.window(usedPercent: 125, reset: reset), + now: Self.now)) + + #expect(model.samples == [.init(date: Self.now, remainingPercent: 0)]) + } + + @Test + func `deduplicates timestamps skips nonfinite history and rejects nonfinite live usage`() throws { + let reset = Self.now.addingTimeInterval(2 * 3600) + let duplicateDate = Self.now.addingTimeInterval(-3600) + let history = Self.history(entries: [ + .init(capturedAt: Self.now.addingTimeInterval(-2 * 3600), usedPercent: .nan, resetsAt: reset), + .init(capturedAt: duplicateDate, usedPercent: 20, resetsAt: reset), + .init(capturedAt: duplicateDate, usedPercent: 30, resetsAt: reset), + .init(capturedAt: Self.now, usedPercent: 40, resetsAt: reset), + ]) + + let model = try #require(QuotaBurndownModel( + history: history, + window: Self.window(usedPercent: 150, reset: reset), + now: Self.now)) + + #expect(model.samples == [ + .init(date: duplicateDate, remainingPercent: 70), + .init(date: Self.now, remainingPercent: 0), + ]) + #expect(QuotaBurndownModel( + history: history, + window: Self.window(usedPercent: .infinity, reset: reset), + now: Self.now) == nil) + } + + private static let now = Date(timeIntervalSince1970: 1_700_000_000) + + private static func history(entries: [PlanUtilizationHistoryEntry]) -> PlanUtilizationSeriesHistory { + PlanUtilizationSeriesHistory(name: .session, windowMinutes: 300, entries: entries) + } + + private static func entry( + hoursBeforeNow: Double, + usedPercent: Double, + reset: Date?) -> PlanUtilizationHistoryEntry + { + PlanUtilizationHistoryEntry( + capturedAt: self.now.addingTimeInterval(-hoursBeforeNow * 3600), + usedPercent: usedPercent, + resetsAt: reset) + } + + private static func window(usedPercent: Double, reset: Date) -> RateWindow { + RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil) + } +} diff --git a/Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift b/Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift new file mode 100644 index 0000000000..09994a7d2c --- /dev/null +++ b/Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift @@ -0,0 +1,46 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +struct QuotaBurndownRenderProofTests { + @Test + func `render current window from synthetic quota samples`() throws { + guard let path = ProcessInfo.processInfo.environment["CODEXBAR_BURNDOWN_PROOF_PATH"] else { return } + let now = Date() + let reset = now.addingTimeInterval(2 * 3600) + let history = PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [ + .init(capturedAt: now.addingTimeInterval(-2 * 3600), usedPercent: 10, resetsAt: reset), + .init(capturedAt: now.addingTimeInterval(-3600), usedPercent: 35, resetsAt: reset), + .init(capturedAt: now.addingTimeInterval(-1800), usedPercent: 48, resetsAt: reset), + ]) + let current = PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: history.entries + [ + .init(capturedAt: now, usedPercent: 60, resetsAt: reset), + ]) + let view = QuotaBurndownChartMenuView( + provider: .codex, + histories: [current], + width: 400, + referenceDate: now) + .frame(width: 400) + .padding(12) + .background(Color.white) + .environment(\.colorScheme, .light) + let hosting = NSHostingView(rootView: view) + hosting.appearance = NSAppearance(named: .aqua) + hosting.frame = CGRect(origin: .zero, size: hosting.fittingSize) + hosting.layoutSubtreeIfNeeded() + let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])) + .write(to: URL(fileURLWithPath: path), options: .atomic) + } +} diff --git a/docs/widgets/burndown-menu-synthetic.png b/docs/widgets/burndown-menu-synthetic.png new file mode 100644 index 0000000000000000000000000000000000000000..c23c2afeb87a2733ae5f8d96ac5149cf18e8a46f GIT binary patch literal 31329 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�oV^@#m?&Kc1D26i*8B>wN3aHv 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 Date: Sun, 27 Sep 2026 13:46:12 -0400 Subject: [PATCH 004/122] Improve spend dashboard header layout --- .../PreferencesSpendDashboardPane.swift | 72 +++++++++++-------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 1792069a7c..bc43fbcb54 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -232,41 +232,53 @@ struct SpendDashboardPane: View { } private var header: some View { - HStack(alignment: .top, spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text(L("Usage & Spend")) - .font(.title2.weight(.semibold)) - Text(L("Local estimated cost history across supported providers.")) - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - Picker(L("Time range"), selection: self.periodBinding) { - Text(spendDashboardDayRangeText(7)).tag(CostReportingPeriod.rolling(days: 7)) - Text(spendDashboardDayRangeText(30)).tag(CostReportingPeriod.rolling(days: 30)) - Text(spendDashboardDayRangeText(90)).tag(CostReportingPeriod.rolling(days: 90)) - Text(L("Month to date")).tag(CostReportingPeriod.monthToDate) - Text(L("All")).tag(CostReportingPeriod.allTime) - if case let .rolling(days) = self.controller.selectedPeriod, ![7, 30, 90].contains(days) { - Text(spendDashboardDayRangeText(days)).tag(self.controller.selectedPeriod) + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(L("Usage & Spend")) + .font(.title2.weight(.semibold)) + .lineLimit(1) + Text(L("Local estimated cost history across supported providers.")) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } + .layoutPriority(1) + Spacer(minLength: 0) + self.refreshButton } - .labelsHidden() - .pickerStyle(.segmented) - .frame(width: 360) - .accessibilityIdentifier("spend-dashboard-range-picker") + self.rangePicker + } + } - Button { - self.store.refreshSpendDashboard(accounts: self.codexSpendScanRequests) - } label: { - if self.controller.isRefreshing { - ProgressView().controlSize(.small) - } else { - Label(L("Refresh"), systemImage: "arrow.clockwise") - } + private var rangePicker: some View { + Picker(L("Time range"), selection: self.periodBinding) { + Text(spendDashboardDayRangeText(7)).tag(CostReportingPeriod.rolling(days: 7)) + Text(spendDashboardDayRangeText(30)).tag(CostReportingPeriod.rolling(days: 30)) + Text(spendDashboardDayRangeText(90)).tag(CostReportingPeriod.rolling(days: 90)) + Text(L("Month to date")).tag(CostReportingPeriod.monthToDate) + Text(L("All")).tag(CostReportingPeriod.allTime) + if case let .rolling(days) = self.controller.selectedPeriod, ![7, 30, 90].contains(days) { + Text(spendDashboardDayRangeText(days)).tag(self.controller.selectedPeriod) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: 480, alignment: .leading) + .accessibilityIdentifier("spend-dashboard-range-picker") + } + + private var refreshButton: some View { + Button { + self.store.refreshSpendDashboard(accounts: self.codexSpendScanRequests) + } label: { + if self.controller.isRefreshing { + ProgressView().controlSize(.small) + } else { + Label(L("Refresh"), systemImage: "arrow.clockwise") } - .disabled(self.controller.isRefreshing || !self.settings.costUsageEnabled) } + .disabled(self.controller.isRefreshing || !self.settings.costUsageEnabled) } @ViewBuilder From 1a765e0787014d153c7f5e94f74605d1b514437d Mon Sep 17 00:00:00 2001 From: Sogl Date: Sun, 27 Sep 2026 21:28:59 +0300 Subject: [PATCH 005/122] fix(pi): preserve directory marker when canonicalizing missing roots resolvingSymlinksInPath() drops the directory marker for paths that do not exist on disk, so a root canonical URL depended on whether the directory was already created. Restore the marker so root identity is stable before and after the directory appears. --- .../PiFamilySessionRootResolver.swift | 6 +++- .../CodexBarCore/PiFamilySessionScanner.swift | 6 +++- .../PiSharedRootMergeTests.swift | 33 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/PiFamilySessionRootResolver.swift b/Sources/CodexBarCore/PiFamilySessionRootResolver.swift index 8b83992657..5b7370f220 100644 --- a/Sources/CodexBarCore/PiFamilySessionRootResolver.swift +++ b/Sources/CodexBarCore/PiFamilySessionRootResolver.swift @@ -428,7 +428,11 @@ struct OMPSessionRootResolver: Sendable { } private static func canonicalURL(_ url: URL) -> URL { - url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL + let resolved = url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL + // resolvingSymlinksInPath drops the directory marker for paths that do not exist yet, + // which would make a root's canonical URL depend on whether the directory is on disk. + guard url.hasDirectoryPath, !resolved.hasDirectoryPath else { return resolved } + return URL(fileURLWithPath: resolved.path, isDirectory: true) } private static func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { diff --git a/Sources/CodexBarCore/PiFamilySessionScanner.swift b/Sources/CodexBarCore/PiFamilySessionScanner.swift index 47dfc85c12..fd2059d5e5 100644 --- a/Sources/CodexBarCore/PiFamilySessionScanner.swift +++ b/Sources/CodexBarCore/PiFamilySessionScanner.swift @@ -1330,7 +1330,11 @@ struct PiFamilySessionScanner: Sendable { } private static func canonicalURL(_ url: URL) -> URL { - url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL + let resolved = url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL + // resolvingSymlinksInPath drops the directory marker for paths that do not exist yet, + // which would make a root's canonical URL depend on whether the directory is on disk. + guard url.hasDirectoryPath, !resolved.hasDirectoryPath else { return resolved } + return URL(fileURLWithPath: resolved.path, isDirectory: true) } private static func isDirectFile(in file: URL, projectDirectory: URL) -> Bool { diff --git a/Tests/CodexBarTests/PiSharedRootMergeTests.swift b/Tests/CodexBarTests/PiSharedRootMergeTests.swift index e50ea87a3a..01d2070f4f 100644 --- a/Tests/CodexBarTests/PiSharedRootMergeTests.swift +++ b/Tests/CodexBarTests/PiSharedRootMergeTests.swift @@ -71,6 +71,39 @@ struct PiSharedRootMergeTests { #expect(root.retentionKeys == ["process:pi:session-dir:\(sharedRoot.standardizedFileURL.path)"]) } + @Test + func `custom agent dir root identity does not depend on directory existence`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let agentDir = env.root.appendingPathComponent("custom-pi-agent", isDirectory: true) + let sessionsRoot = agentDir.appendingPathComponent("sessions", isDirectory: true) + let environment = [ + "HOME": env.root.path, + "PI_CODING_AGENT_DIR": agentDir.path, + ] + + // The directory is absent on disk: canonicalization must keep the + // directory marker so the resolved root stays identical once it appears. + let missing = OMPSessionRootResolver.sessionRoots( + environment: environment, + baseDirectory: env.root) + let missingRoot = try #require(missing.first { $0.path.hasSuffix("custom-pi-agent/sessions") }) + #expect(missingRoot.hasDirectoryPath) + + let missingCostRoot = try #require(PiFamilySessionScanner.costSessionRoots( + environment: environment, + baseDirectories: [env.root]).first { $0.url.path == missingRoot.path }) + #expect(missingCostRoot.url.hasDirectoryPath) + + try FileManager.default.createDirectory( + at: sessionsRoot, + withIntermediateDirectories: true) + #expect(OMPSessionRootResolver.sessionRoots( + environment: environment, + baseDirectory: env.root) == missing) + } + @Test func `shared pi and omp root keeps required process provenance`() throws { let env = try CostUsageTestEnvironment() From ac80ba8f0c75dc7cf06872545b484d8123f60f97 Mon Sep 17 00:00:00 2001 From: Sogl Date: Sun, 27 Sep 2026 21:28:33 +0300 Subject: [PATCH 006/122] fix(app): drop isolated deinit from AgentSessionsStore AgentSessionsStore lives in an Objective-C ivar of StatusItemController, so its deinit runs inside __ivar_destroyer on whichever thread performs the release. On macOS < 26 the back-deployed runtime cannot hop to the main actor from that context and aborts in swift_task_deinitOnExecutorMainActorBackDeploy (SIGABRT in StatusMenuTokenAccountSwitcherTests on macOS 15). --- Sources/CodexBar/AgentSessionsStore.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/AgentSessionsStore.swift b/Sources/CodexBar/AgentSessionsStore.swift index f1fac70bd3..f59812ffb4 100644 --- a/Sources/CodexBar/AgentSessionsStore.swift +++ b/Sources/CodexBar/AgentSessionsStore.swift @@ -53,12 +53,12 @@ final class AgentSessionsStore { private let remoteFetch: RemoteFetch private let remoteFetcher: RemoteSessionFetcher private let powerAssertion: AgentSessionPowerAssertion - private var powerAssertionID: UInt32? + private nonisolated(unsafe) var powerAssertionID: UInt32? private let periodicSleep: PeriodicSleep - @ObservationIgnored private var localPeriodicTask: Task? - @ObservationIgnored private var remotePeriodicTask: Task? - @ObservationIgnored private var localImmediateTask: Task? - @ObservationIgnored private var remoteImmediateTask: Task? + @ObservationIgnored private nonisolated(unsafe) var localPeriodicTask: Task? + @ObservationIgnored private nonisolated(unsafe) var remotePeriodicTask: Task? + @ObservationIgnored private nonisolated(unsafe) var localImmediateTask: Task? + @ObservationIgnored private nonisolated(unsafe) var remoteImmediateTask: Task? @ObservationIgnored private var localRefreshGate = AgentSessionRefreshGate() @ObservationIgnored private var remoteRefreshGate = AgentSessionRemoteRefreshGate() @ObservationIgnored var onUpdate: (@MainActor () -> Void)? @@ -113,7 +113,7 @@ final class AgentSessionsStore { self.periodicSleep = periodicSleep } - isolated deinit { + deinit { if let powerAssertionID { self.powerAssertion.release(powerAssertionID) } self.localPeriodicTask?.cancel() self.remotePeriodicTask?.cancel() From 6cfaad64409bd4861cf1e60f7b454ebd4461560c Mon Sep 17 00:00:00 2001 From: Sogl Date: Sun, 27 Sep 2026 21:36:01 +0300 Subject: [PATCH 007/122] fix(antigravity): keep model quotas when quota verification is denied retrieveUserQuota answers 403 for accounts without quota-API access; treat a denied verification as unverifiable and keep fetchAvailableModels quotas instead of mapping to an empty result. Verified-empty responses still resolve to an empty result. --- .../AntigravityRemoteUsageFetcher.swift | 5 +- .../AntigravityRemoteUsageFetcherTests.swift | 72 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift index 1e8272323b..43ee0bd7d1 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift @@ -216,7 +216,10 @@ public struct AntigravityRemoteUsageFetcher: Sendable { projectId: projectId, timeout: timeout, dataLoader: dataLoader) - guard let quotaBuckets, Self.hasQuotaFractionData(quotaBuckets) else { + guard let quotaBuckets else { + return modelQuotas + } + guard Self.hasQuotaFractionData(quotaBuckets) else { return [] } return Self.mergeVerifiedQuotas(modelQuotas: modelQuotas, verifiedQuotas: quotaBuckets) diff --git a/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift b/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift index 82b1557bba..9d876dc075 100644 --- a/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift @@ -519,6 +519,78 @@ struct AntigravityRemoteUsageFetcherTests { #expect(snapshot.accountEmail == "user@example.com") } + @Test + func `remote fetch keeps model quotas when quota verification is forbidden`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + email: "user@example.com") + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistResponse( + tierId: "standard-tier", + projectId: "managed-project-123")) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "models": [ + "claude-sonnet-4": [ + "displayName": "Claude Sonnet 4", + "quotaInfo": ["remainingFraction": 1], + ], + "gemini-2.5-pro": [ + "displayName": "Gemini 2.5 Pro", + "quotaInfo": ["remainingFraction": 1], + ], + ], + ])) + } + if url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 403, + body: GeminiAPITestHelpers.jsonData([ + "error": [ + "code": 403, + "message": "You do not have a valid license of this product", + "status": "PERMISSION_DENIED", + ], + ])) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let snapshot = try await AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + dataLoader: dataLoader) + .fetch() + + #expect(snapshot.modelQuotas.map(\.modelId).sorted() == ["claude-sonnet-4", "gemini-2.5-pro"]) + #expect(snapshot.modelQuotas.map(\.remainingFraction) == [1, 1]) + #expect(snapshot.accountEmail == "user@example.com") + } + @Test func `remote fetch propagates quota verification server errors`() async throws { let env = try GeminiTestEnvironment() From 8053da9e37b7f7a743b35d49e7190cbd03b07666 Mon Sep 17 00:00:00 2001 From: Sogl Date: Sun, 27 Sep 2026 21:36:01 +0300 Subject: [PATCH 008/122] refactor(antigravity): stage scoped credentials via CredentialFileWriter --- .../Providers/Antigravity/AntigravityScopedPrintFetch.swift | 5 ++--- .../Providers/TypeSafe/TypeSafeWebFetchStrategy.swift | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift index 18e692a2c8..70c70eb34a 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift @@ -96,7 +96,7 @@ enum AntigravityScopedStagingError: LocalizedError, Sendable, Equatable { /// single `agy` print invocation. The directory is deleted by the caller's `defer`, /// so no account lifecycle tracking, locking, or persistent credential copies exist. enum AntigravityScopedAgyStaging { - // Provider-specific by design: agy's file token storage path is a fixed external contract. + /// Provider-specific by design: agy's file token storage path is a fixed external contract. static let tokenRelativePath = [".gemini", "antigravity-cli", "antigravity-oauth-token"] /// Allowlist environment for the scoped child. Nothing else is inherited: @@ -159,8 +159,7 @@ enum AntigravityScopedAgyStaging { try fileManager.createDirectory( at: tokenURL, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) tokenURL.appendPathComponent(Self.tokenRelativePath.last!) - try tokenData.write(to: tokenURL, options: [.atomic]) - try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tokenURL.path) + try CredentialFileWriter.writePrivate(tokenData, to: tokenURL) guard let staged = try? Data(contentsOf: tokenURL), let payload = AntigravityAgyFileTokenEncoder.decode(data: staged), diff --git a/Sources/CodexBarCore/Providers/TypeSafe/TypeSafeWebFetchStrategy.swift b/Sources/CodexBarCore/Providers/TypeSafe/TypeSafeWebFetchStrategy.swift index 26511010a3..572d704675 100644 --- a/Sources/CodexBarCore/Providers/TypeSafe/TypeSafeWebFetchStrategy.swift +++ b/Sources/CodexBarCore/Providers/TypeSafe/TypeSafeWebFetchStrategy.swift @@ -172,7 +172,8 @@ enum TypeSafeCredentialError: LocalizedError, Equatable { var errorDescription: String? { switch self { case .missingCookie: - "No TypeSafe session cookies found. Sign in at console.typesafe.ai/settings/billing or paste a Cookie header." + "No TypeSafe session cookies found. Sign in at console.typesafe.ai/settings/billing " + + "or paste a Cookie header." case .invalidCookie: "TypeSafe needs a nonempty Cookie header from the billing page." case .disabled: From 70be213f976d7b87ab6b87bcdead772d6cdf31bc Mon Sep 17 00:00:00 2001 From: Sogl Date: Sun, 27 Sep 2026 21:36:02 +0300 Subject: [PATCH 009/122] docs(antigravity): move live proof artifacts to PR description --- .../antigravity-scoped-fetch/README.md | 42 --------- .../live-evidence.log | 93 ------------------- 2 files changed, 135 deletions(-) delete mode 100644 .github/pr-proof/antigravity-scoped-fetch/README.md delete mode 100644 .github/pr-proof/antigravity-scoped-fetch/live-evidence.log diff --git a/.github/pr-proof/antigravity-scoped-fetch/README.md b/.github/pr-proof/antigravity-scoped-fetch/README.md deleted file mode 100644 index 4d3939cdbd..0000000000 --- a/.github/pr-proof/antigravity-scoped-fetch/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Antigravity account-scoped print fetch proof - -Covers #3662: when a Google account is selected or injected in Auto mode and the -ambient `agy` paths cannot prove that account, CodexBar runs `agy -p /usage` -scoped to the account's staged file-token credentials instead of substituting an -identity-free ambient report. - -`live-evidence.log` — macOS, agy 1.2.9, real accounts (emails redacted): - -1. `CodexBarCLI usage --account A` runs the scoped `antigravity-cli-scoped-usage` - subprocess and returns A's quota labeled A while ambient agy is logged in as - a different account B. -2. Auto mode with the app-selected account takes the same scoped path. -3. A revoked-credential account preserves the original ambient-path error, falls - back to the account-scoped OAuth strategy, and never displays B's quota. -4. Manually staged runs confirm the process boundary: agy authenticates as A in - its own log, the ambient `~/.gemini` tree (614 files) is untouched, revoked - staged credentials get UNAUTHENTICATED (401), and an expired staged grant - refreshes in place. -5. Provenance: the saved grants carry the OAuth client ID that CodexBar's - Add Account flow discovers from the installed Antigravity.app — they are - CodexBar-minted under Antigravity's own client. -6. Production expired-grant run: with `expiry_date` forced to the past the - scoped `agy` attempt fails closed (90s bound), the original ambient-path - error is preserved, and the account-scoped OAuth strategy recovers with - A's data labeled A. - -Reproduce the scoped run from a shell (requires a real `agy` and a saved -Antigravity token account): - -```sh -# Stage /.gemini/antigravity-cli/antigravity-oauth-token in agy's -# file-token format, then: -env -i HOME= PWD= SSH_TTY=codexbar-scoped \ - PATH="$PATH" TMPDIR="$TMPDIR" LANG=en_US.UTF-8 \ - agy -p /usage --output-format json --print-timeout 90s -``` - -Automated coverage: `swift test --filter AntigravityScopedPrintFetchTests` -(staging format, env allowlist, identity verification, fail-closed wiring, -spawned stub-`agy` end-to-end) and `swift test --filter Antigravity` for the -regression surface. diff --git a/.github/pr-proof/antigravity-scoped-fetch/live-evidence.log b/.github/pr-proof/antigravity-scoped-fetch/live-evidence.log deleted file mode 100644 index c3c880c01b..0000000000 --- a/.github/pr-proof/antigravity-scoped-fetch/live-evidence.log +++ /dev/null @@ -1,93 +0,0 @@ -# Live macOS proof — agy 1.2.9, CodexBarCLI built from this branch. -# Ambient agy CLI login = account B (a***@gmail.com). -# Scoped account A = p***@gmail.com, a different saved Google account. -# Emails redacted; no credentials appear below. - -## 1. Production path: selected account A gets A's quota via the scoped spawn - -$ CodexBarCLI usage --provider antigravity --account p***@gmail.com --format json -v - - debug subprocess: binary=agy label=antigravity-cli-version status=0 - debug antigravity: Antigravity CLI HTTPS spawn skipped; agy local server requires a CSRF token - debug subprocess: binary=agy label=antigravity-cli-version status=0 - debug subprocess: binary=agy label=antigravity-cli-scoped-usage status=0 - - Result: source=cli, accountEmail=p***@gmail.com - Gemini weekly usedPercent=0 resetsAt=2026-09-30T13:07:31Z - Claude/GPT weekly usedPercent=100 resetsAt=2026-09-25T08:15:28Z - - Ambient B's windows for contrast (raw `agy -p /usage` under real ~/.gemini): - Gemini weekly usedPercent=0 resetsAt=2026-09-30T10:04:56Z - Claude/GPT weekly usedPercent=100 resetsAt=2026-09-23T14:58:41Z - -## 2. Auto mode without --account takes the same scoped path - -$ CodexBarCLI usage --provider antigravity --format json -v - (app-selected account = p***@gmail.com) - - debug subprocess: binary=agy label=antigravity-cli-scoped-usage status=0 - Result: identical A windows, accountEmail=p***@gmail.com - No ambient report substituted although agy's ambient login is B. - -## 3. Revoked scoped credentials cannot substitute ambient B - -$ CodexBarCLI usage --account revoked-proof@example.com ... (temporary fake entry, removed after) - - warning subprocess: binary=agy label=antigravity-cli-scoped-usage status=1 - strategies: antigravity.cli-https error=agy 1.2.2 or later requires a local CSRF token - antigravity.oauth error=Antigravity Google auth not found - Result: source=offline, labeled revoked-proof@example.com. - The original ambient-path error is preserved; B's quota never appears. - -## 4. Manual staging boundary (what the scoped spawn does internally) - -Staged /.gemini/antigravity-cli/antigravity-oauth-token for A, then: - -$ env -i HOME= PWD= SSH_TTY=codexbar-scoped PATH=... TMPDIR=... LANG=... \ - agy -p /usage --output-format json --print-timeout 90s - - /.gemini/antigravity-cli/log/cli-*.log: - server_oauth.go:196] applyAuthResult: email=p***@gmail.com, authMethod=consumer - server_oauth.go:201] OAuth: authenticated successfully as p***@gmail.com - exit=0, report = A's windows above. - - Ambient ~/.gemini/antigravity-cli (614 files) snapshotted before/after: - files touched: NONE | ambient oauth-token touched: False - - Same run with garbage access_token/refresh_token staged: - exit=1, stderr: Eligibility check failed: UNAUTHENTICATED (code 401) - Ambient B is logged in and healthy; no ambient fallback occurred. - - Same run with token expiry forced to 2020-01-01: - exit=0, A's report returned; agy refreshed the grant and rewrote only the - staged token file (new access_token, expiry +1h, id_token preserved). - -## 5. Grant provenance: the tested account came from CodexBar "Add Account" - -All 4 saved token accounts in ~/.codexbar/config.json carry OAuth client_id - 1071006060591-***.apps.googleusercontent.com -CodexBar's Add Account flow resolves its OAuth client by discovering it from -the installed /Applications/Antigravity.app -(AntigravityOAuthConfig.discoverClientFromInstalledApp). That app binary -contains exactly two OAuth client IDs and the saved grants use the primary -one — i.e. these are CodexBar-minted grants under Antigravity's own client, -not tokens copied out of agy's state. - -## 6. Production expired-grant run: scoped attempt fails closed, OAuth recovers - -$ CodexBarCLI usage --provider antigravity --account p***@gmail.com --format json -v - (saved expiry_date forced to the past for this run; restored after) - - Run 1: antigravity-cli-scoped-usage hit the 90s timeout while agy attempted - to refresh the expired grant - Run 2: antigravity-cli-scoped-usage status=1 in ~17s - strategies: antigravity.cli-https error=agy 1.2.2 or later requires a local CSRF token - Result: source=oauth, accountEmail=p***@gmail.com, A's quota windows - Gemini weekly resetsAt=2026-09-30T13:07:31Z - - The scoped attempt fails closed within the timeout bound, the original - ambient-path error is preserved in the diagnostics, and the account-scoped - OAuth strategy recovers with A's data labeled A. Ambient B never appears. - (In-run token refresh via agy for this grant was unreliable in the staged - environment — observed once as a stall, once as an exit-1 — which is why the - account-scoped OAuth fallback exists.) From 47c383face627b849bb7bb78548b86a0b408288d Mon Sep 17 00:00:00 2001 From: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:21:19 +0200 Subject: [PATCH 010/122] fix(mistral): offer Monthly Plan in the menu bar metric picker --- CHANGELOG.md | 1 + .../MenuBarPercentWindowPreference.swift | 19 +++++++++- ...iderMenuBarPercentWindowSettingsView.swift | 15 +++++++- .../MenuBarPercentWindowPreferenceTests.swift | 6 +-- .../MistralMonthlyPlanPickerTests.swift | 38 +++++++++++++++++++ docs/mistral.md | 3 +- 6 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 04cf9084a5..9601f3e763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Mistral: offer Monthly Plan in the provider's Menu bar metric picker, so the menu bar and widgets can show the Vibe allowance without a `defaults write` (#PR). Thanks @T0mSIlver! - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! ### Changed diff --git a/Sources/CodexBar/MenuBarPercentWindowPreference.swift b/Sources/CodexBar/MenuBarPercentWindowPreference.swift index d78c54a991..05f156d18c 100644 --- a/Sources/CodexBar/MenuBarPercentWindowPreference.swift +++ b/Sources/CodexBar/MenuBarPercentWindowPreference.swift @@ -8,6 +8,7 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl case session case weekly case tertiary + case monthlyPlan var id: String { self.rawValue @@ -18,7 +19,7 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl case .automatic: .automatic case .session: .session case .weekly: .weekly - case .tertiary: nil + case .tertiary, .monthlyPlan: nil } } @@ -28,11 +29,20 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl case .session: .percent(window: .session) case .weekly: .percent(window: .weekly) case .tertiary: .lanePercent(lane: .tertiary) + // Mistral's automatic percent reads the per-provider metric, which the picker sets to Monthly Plan. + case .monthlyPlan: .percent(window: .automatic) } } + /// The per-provider metric this choice stores for providers that offer Monthly Plan, which the + /// automatic percent and widgets read. + var menuBarMetric: MenuBarMetricPreference { + self == .monthlyPlan ? .monthlyPlan : .automatic + } + func label(for provider: UsageProvider) -> String { guard self != .automatic else { return L("menu_bar_layout_token_auto") } + if self == .monthlyPlan { return MenuBarMetricPreference.monthlyPlan.label } if self == .tertiary { return MenuBarLayoutLaneLabels(provider: provider, snapshot: nil).label(for: .tertiary) } @@ -65,6 +75,9 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl if metrics.supported.contains(.tertiary), !metrics.tertiaryRequiresWindow { options.append(.tertiary) } + if metrics.supported.contains(.monthlyPlan) { + options.append(.monthlyPlan) + } return options } @@ -104,10 +117,12 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl /// Ordinary percentages own the choice when a custom layout also has an independent tertiary /// token. Only layouts without ordinary percentages treat tertiary tokens as the controlled group. - static func current(in layout: MenuBarLayout) -> Self? { + /// A Monthly Plan metric turns an all-automatic layout into the Monthly Plan choice. + static func current(in layout: MenuBarLayout, metric: MenuBarMetricPreference = .automatic) -> Self? { let windows = Self.percentWindows(in: layout) guard let first = windows.first else { return self.hasTertiaryPercent(in: layout) ? .tertiary : nil } guard windows.allSatisfy({ $0 == first }) else { return nil } + if first == .automatic, metric == .monthlyPlan { return .monthlyPlan } return Self.allCases.first { $0.percentWindow == first } } diff --git a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift index 600e705c43..21106dda6e 100644 --- a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift +++ b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift @@ -14,7 +14,8 @@ struct ProviderMenuBarPercentWindowSettingsView: View { ProviderMenuBarPercentWindowPicker( provider: self.provider, iconStyle: self.settings.menuBarIconStyle, - layout: self.layoutBinding) + layout: self.layoutBinding, + metric: self.metricBinding) } var layoutBinding: Binding { @@ -22,6 +23,12 @@ struct ProviderMenuBarPercentWindowSettingsView: View { get: { self.settings.menuBarLayoutResolution(for: self.provider).layout }, set: { self.settings.setMenuBarLayout($0, for: self.provider) }) } + + var metricBinding: Binding { + Binding( + get: { self.settings.menuBarMetricPreference(for: self.provider) }, + set: { self.settings.setMenuBarMetricPreference($0, for: self.provider) }) + } } @MainActor @@ -29,6 +36,7 @@ struct ProviderMenuBarPercentWindowPicker: View { let provider: UsageProvider let iconStyle: MenuBarIconStyle @Binding var layout: MenuBarLayout + var metric: Binding = .constant(.automatic) var body: some View { let layout = self.layout @@ -65,7 +73,7 @@ struct ProviderMenuBarPercentWindowPicker: View { get: { let layout = self.layout let available = MenuBarPercentWindowPreference.available(for: self.provider, layout: layout) - return MenuBarPercentWindowPreference.current(in: layout) + return MenuBarPercentWindowPreference.current(in: layout, metric: self.metric.wrappedValue) .flatMap { available.contains($0) ? $0 : nil } }, set: { preference in @@ -73,6 +81,9 @@ struct ProviderMenuBarPercentWindowPicker: View { guard let preference, MenuBarPercentWindowPreference.available(for: self.provider, layout: layout).contains(preference) else { return } + if MenuBarPercentWindowPreference.available(for: self.provider).contains(.monthlyPlan) { + self.metric.wrappedValue = preference.menuBarMetric + } self.layout = preference.applied(to: layout) }) } diff --git a/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift b/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift index 8cdadbbf11..842c704595 100644 --- a/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift +++ b/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift @@ -270,7 +270,7 @@ struct MenuBarPercentWindowPreferenceTests { func `picker hides when session and weekly cannot apply`() { let layout = MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) let automaticOnly = MenuBarPercentWindowPreference.available( - metrics: ProviderMenuBarMetricCapabilities(supported: [.automatic, .monthlyPlan])) + metrics: ProviderMenuBarMetricCapabilities(supported: [.automatic, .extraUsage])) #expect(automaticOnly == [.automatic]) #expect(MenuBarPercentWindowPreference.isVisible( @@ -286,7 +286,7 @@ struct MenuBarPercentWindowPreferenceTests { @Test func `available options follow provider percent-window capabilities`() { let mistralLike = ProviderMenuBarMetricCapabilities(supported: [.automatic, .monthlyPlan]) - #expect(MenuBarPercentWindowPreference.available(metrics: mistralLike) == [.automatic]) + #expect(MenuBarPercentWindowPreference.available(metrics: mistralLike) == [.automatic, .monthlyPlan]) let sessionOnlyPrimary = ProviderMenuBarMetricCapabilities(supported: [.automatic, .primary]) #expect(MenuBarPercentWindowPreference.available(metrics: sessionOnlyPrimary) == [.automatic, .session]) @@ -298,7 +298,7 @@ struct MenuBarPercentWindowPreferenceTests { #expect(MenuBarPercentWindowPreference.available( metrics: .standard) == [.automatic, .session, .weekly]) - #expect(MenuBarPercentWindowPreference.available(for: .mistral) == [.automatic, .session]) + #expect(MenuBarPercentWindowPreference.available(for: .mistral) == [.automatic, .session, .monthlyPlan]) #expect(MenuBarPercentWindowPreference.available(for: .openrouter) == [.automatic, .session]) #expect(MenuBarPercentWindowPreference.available(for: .codex) == [.automatic, .session, .weekly]) #expect(MenuBarPercentWindowPreference.isVisible( diff --git a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift new file mode 100644 index 0000000000..df80e11aeb --- /dev/null +++ b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift @@ -0,0 +1,38 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MistralMonthlyPlanPickerTests { + @Test + func `menu bar metric picker stores Monthly Plan for the menu bar and widgets`() { + let settings = testSettingsStore( + suiteName: "MistralMonthlyPlanPickerTests", + userDefaults: InMemoryUserDefaults()) + let view = ProviderMenuBarPercentWindowSettingsView(provider: .mistral, settings: settings) + view.layoutBinding.wrappedValue = MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) + let picker = ProviderMenuBarPercentWindowPicker( + provider: .mistral, + iconStyle: .iconAndPercent, + layout: view.layoutBinding, + metric: view.metricBinding) + #expect(MenuBarPercentWindowPreference.monthlyPlan.label(for: .mistral) == "Monthly Plan") + #expect(picker.selectionBinding.wrappedValue == .automatic) + + picker.selectionBinding.wrappedValue = .monthlyPlan + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + #expect(settings.menuBarLayout(for: .mistral).lines == [[.icon, .percent(window: .automatic)]]) + #expect(picker.selectionBinding.wrappedValue == .monthlyPlan) + + picker.selectionBinding.wrappedValue = .session + #expect(settings.menuBarMetricPreference(for: .mistral) == .automatic) + #expect(settings.menuBarLayout(for: .mistral).lines == [[.icon, .percent(window: .session)]]) + #expect(picker.selectionBinding.wrappedValue == .session) + + picker.selectionBinding.wrappedValue = .monthlyPlan + picker.selectionBinding.wrappedValue = .automatic + #expect(settings.menuBarMetricPreference(for: .mistral) == .automatic) + #expect(picker.selectionBinding.wrappedValue == .automatic) + } +} diff --git a/docs/mistral.md b/docs/mistral.md index 4b6ddb535d..92b8ca0189 100644 --- a/docs/mistral.md +++ b/docs/mistral.md @@ -65,7 +65,8 @@ For the console request, CodexBar forwards only the `csrftoken` and `ory_session ## Widgets -Usage widgets follow Mistral's menu bar metric preference: +Usage widgets follow the **Menu bar metric** picker in Mistral's provider settings, which appears when the menu bar +style is **Icon & percent**: - **Automatic** and **Included API** show only the API allowance, preserving the existing default. - **Monthly Plan** shows only the Vibe allowance, falling back to Included API when the plan is missing or unknown. From 96aff386562af275c51162a0f86a41d294ae5a2d Mon Sep 17 00:00:00 2001 From: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:59:26 +0200 Subject: [PATCH 011/122] docs(changelog): link Mistral Monthly Plan picker PR --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9601f3e763..7e167920e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Mistral: offer Monthly Plan in the provider's Menu bar metric picker, so the menu bar and widgets can show the Vibe allowance without a `defaults write` (#PR). Thanks @T0mSIlver! +- Mistral: offer Monthly Plan in the provider's Menu bar metric picker, so the menu bar and widgets can show the Vibe allowance without a `defaults write` (#4072). Thanks @T0mSIlver! - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! ### Changed From bb0ed315ca67d71415d2933bbd4d8be02fbcff63 Mon Sep 17 00:00:00 2001 From: Elijah Friedman Date: Sun, 27 Sep 2026 16:06:07 -0400 Subject: [PATCH 012/122] Update provider branding color palettes - Update provider branding color palettes - Add widget colors to provider descriptors - Update CommandCode brand color regression test --- .../Providers/Abacus/AbacusProviderDescriptor.swift | 7 ++++--- .../Providers/AiAnd/AiAndProviderDescriptor.swift | 5 +++-- .../CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift | 7 ++++--- .../Providers/Augment/AugmentProviderDescriptor.swift | 7 ++++--- .../Providers/Bedrock/BedrockProviderDescriptor.swift | 5 +++-- .../Providers/Chutes/ChutesProviderDescriptor.swift | 2 +- .../ClawRouter/ClawRouterProviderDescriptor.swift | 5 +++-- .../Providers/ClinePass/ClinePassProviderDescriptor.swift | 5 +++-- .../Providers/Codebuff/CodebuffProviderDescriptor.swift | 7 ++++--- .../CommandCode/CommandCodeProviderDescriptor.swift | 4 ++-- .../Providers/Copilot/CopilotProviderDescriptor.swift | 5 +++-- .../Providers/Cursor/CursorProviderDescriptor.swift | 6 ++++-- .../Providers/DeepSeek/DeepSeekProviderDescriptor.swift | 2 +- .../Providers/Deepgram/DeepgramProviderDescriptor.swift | 4 ++-- .../Providers/Devin/DevinProviderDescriptor.swift | 7 ++++--- .../Providers/Doubao/DoubaoProviderDescriptor.swift | 2 +- .../Providers/Fireworks/FireworksProviderDescriptor.swift | 7 ++++--- .../Providers/Groq/GroqProviderDescriptor.swift | 7 ++++--- .../Providers/JetBrains/JetBrainsProviderDescriptor.swift | 7 ++++--- .../Providers/Kilo/KiloProviderDescriptor.swift | 7 ++++--- .../Providers/Kimi/KimiProviderDescriptor.swift | 7 ++++--- .../Providers/Kiro/KiroProviderDescriptor.swift | 7 ++++--- .../Providers/LiteLLM/LiteLLMProviderDescriptor.swift | 5 +++-- .../Providers/LongCat/LongCatProviderDescriptor.swift | 7 ++++--- .../Providers/Mistral/MistralProviderDescriptor.swift | 7 ++++--- .../Providers/Moonshot/MoonshotProviderDescriptor.swift | 7 ++++--- .../NeuralWatt/NeuralWattProviderDescriptor.swift | 4 ++-- .../Providers/Notion/NotionProviderDescriptor.swift | 7 ++++--- .../Providers/OpenCode/OpenCodeProviderDescriptor.swift | 7 ++++--- .../Perplexity/PerplexityProviderDescriptor.swift | 5 +++-- .../Providers/Qoder/QoderProviderDescriptor.swift | 3 ++- .../Providers/Sakana/SakanaProviderDescriptor.swift | 4 ++-- .../Providers/Sub2API/Sub2APIProviderDescriptor.swift | 5 +++-- .../Providers/T3Chat/T3ChatProviderDescriptor.swift | 5 +++-- .../Providers/Venice/VeniceProviderDescriptor.swift | 5 +++-- .../Providers/Warp/WarpProviderDescriptor.swift | 7 ++++--- Tests/CodexBarTests/MenuCardProviderRegressionTests.swift | 2 +- 37 files changed, 116 insertions(+), 86 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift index c3db21530f..f00653caf0 100644 --- a/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift @@ -45,12 +45,13 @@ public enum AbacusProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .abacus), iconResourceName: "ProviderIcon-abacus", - color: ProviderColor(red: 56 / 255, green: 189 / 255, blue: 248 / 255), + color: ProviderColor(red: 129 / 255, green: 78 / 255, blue: 232 / 255), confettiPalette: [ - ProviderColor(hex: 0x35BEE2), + ProviderColor(hex: 0x814EE8), ProviderColor(hex: 0xC64AF9), ProviderColor(hex: 0xFFFFFF), - ]), + ], + widgetColor: ProviderColor(red: 56 / 255, green: 189 / 255, blue: 248 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Abacus AI cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/AiAnd/AiAndProviderDescriptor.swift b/Sources/CodexBarCore/Providers/AiAnd/AiAndProviderDescriptor.swift index 2ce0cdee6f..634e486ee5 100644 --- a/Sources/CodexBarCore/Providers/AiAnd/AiAndProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/AiAnd/AiAndProviderDescriptor.swift @@ -9,8 +9,9 @@ public enum AiAndProviderDescriptor { weeklyLabel: "Spend", debugLogUnavailableMessage: "ai& debug log not yet implemented", dashboardURL: "https://console.aiand.com", - color: .init(hex: 0xE25C2B), - confetti: [0xE25C2B, 0xF2A17E, 0x33231C], + color: .init(hex: 0xC70007), + confetti: [0xC70007, 0xF2A17E, 0x33231C], + widgetColor: .init(hex: 0xE25C2B), noDataMessage: "ai& spend is summed from the request logs API.", environmentKey: "AIAND_API_KEY", presentation: ProviderUsagePresentation(costPresenter: { snapshot in diff --git a/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift index 52d26614bc..633273846e 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift @@ -33,12 +33,13 @@ public enum AmpProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .amp), iconResourceName: "ProviderIcon-amp", - color: ProviderColor(red: 220 / 255, green: 38 / 255, blue: 38 / 255), + color: ProviderColor(red: 243 / 255, green: 78 / 255, blue: 63 / 255), confettiPalette: [ ProviderColor(hex: 0x091C1E), ProviderColor(hex: 0xDFDFC1), - ProviderColor(hex: 0xD97706), - ]), + ProviderColor(hex: 0xF34E3F), + ], + widgetColor: ProviderColor(red: 220 / 255, green: 38 / 255, blue: 38 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Amp cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift index 7c64334408..5247529689 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift @@ -65,12 +65,13 @@ public enum AugmentProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .augment), iconResourceName: "ProviderIcon-augment", - color: ProviderColor(red: 99 / 255, green: 102 / 255, blue: 241 / 255), + color: ProviderColor(red: 26 / 255, green: 160 / 255, blue: 73 / 255), confettiPalette: [ - ProviderColor(hex: 0xF97316), + ProviderColor(hex: 0x1AA049), ProviderColor(hex: 0x111111), ProviderColor(hex: 0xFFF7ED), - ]), + ], + widgetColor: ProviderColor(red: 99 / 255, green: 102 / 255, blue: 241 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Augment cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift index 99d5c26563..fa4ac19708 100644 --- a/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift @@ -48,12 +48,13 @@ public enum BedrockProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .bedrock), iconResourceName: "ProviderIcon-bedrock", - color: ProviderColor(red: 1, green: 0.6, blue: 0), + color: ProviderColor(red: 1 / 255, green: 168 / 255, blue: 141 / 255), confettiPalette: [ ProviderColor(hex: 0x01A88D), ProviderColor(hex: 0x232F3E), ProviderColor(hex: 0xFF9900), - ]), + ], + widgetColor: ProviderColor(red: 1, green: 0.6, blue: 0)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: { "No AWS Bedrock cost data available. Check your AWS access keys " diff --git a/Sources/CodexBarCore/Providers/Chutes/ChutesProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Chutes/ChutesProviderDescriptor.swift index be7ad71ae6..7432205a88 100644 --- a/Sources/CodexBarCore/Providers/Chutes/ChutesProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Chutes/ChutesProviderDescriptor.swift @@ -11,7 +11,7 @@ public enum ChutesProviderDescriptor { debugLogUnavailableMessage: "Chutes debug log not yet implemented", usesDetailBackedWindow: true, dashboardURL: "https://chutes.ai", - color: ProviderColor(hex: 0x3184FF), + color: ProviderColor(hex: 0x63D297), confetti: [0x121212, 0xFFFFFF, 0x63D297], widgetColor: ProviderColor(hex: 0x18A058), noDataMessage: "Chutes cost history is not available from CodexBar.", diff --git a/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift index cb03b6fcd0..ec9bbd23f1 100644 --- a/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift @@ -9,8 +9,9 @@ public enum ClawRouterProviderDescriptor { weeklyLabel: "Requests", debugLogUnavailableMessage: "ClawRouter debug log not yet implemented", dashboardURL: "https://clawrouter.openclaw.ai/dashboard/access", - color: ProviderColor(hex: 0x596EF6), - confetti: [0x332CB3, 0x456FDD, 0xFFFFFF], + color: ProviderColor(hex: 0x1F5AE0), + confetti: [0x332CB3, 0x1F5AE0, 0xFFFFFF], + widgetColor: ProviderColor(hex: 0x596EF6), noDataMessage: "ClawRouter spend is reported by its usage API.", environmentKey: ClawRouterSettingsReader.apiKeyEnvironmentKey, missingCredentialMessage: { _ in ClawRouterSettingsReader.missingCredentialsMessage }, diff --git a/Sources/CodexBarCore/Providers/ClinePass/ClinePassProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ClinePass/ClinePassProviderDescriptor.swift index 2a1753cc93..2e17f9ccd9 100644 --- a/Sources/CodexBarCore/Providers/ClinePass/ClinePassProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ClinePass/ClinePassProviderDescriptor.swift @@ -14,8 +14,9 @@ public enum ClinePassProviderDescriptor { opusLabel: "Monthly", debugLogUnavailableMessage: "ClinePass debug log not yet implemented", dashboardURL: "https://app.cline.bot/dashboard/subscription?personal=true", - color: .init(red: 0.38, green: 0.64, blue: 0.98), - confetti: [0x61A3FA, 0x111111, 0xFFFFFF], + color: .init(hex: 0x5487C8), + confetti: [0x5487C8, 0x111111, 0xFFFFFF], + widgetColor: .init(red: 0.38, green: 0.64, blue: 0.98), noDataMessage: "ClinePass cost history is not available via the usage-limits API.", environmentKey: "CLINE_API_KEY", environmentAliases: ["CLINEPASS_API_KEY"], diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift index fac83ca613..3bbced7ccf 100644 --- a/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift @@ -48,12 +48,13 @@ public enum CodebuffProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .codebuff), iconResourceName: "ProviderIcon-codebuff", - color: ProviderColor(red: 68 / 255, green: 255 / 255, blue: 0 / 255), + color: ProviderColor(red: 0 / 255, green: 255 / 255, blue: 149 / 255), confettiPalette: [ - ProviderColor(hex: 0x9EFC62), + ProviderColor(hex: 0x00FF95), ProviderColor(hex: 0xFFFFFF), ProviderColor(hex: 0x000000), - ]), + ], + widgetColor: ProviderColor(red: 68 / 255, green: 255 / 255, blue: 0 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Codebuff cost summary is not yet supported." }), diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift index c16965e594..dab8f48283 100644 --- a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift @@ -33,11 +33,11 @@ public enum CommandCodeProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .commandcode), iconResourceName: "ProviderIcon-commandcode", - color: ProviderColor(hex: 0xA04DFD), + color: ProviderColor(hex: 0x8C4EDD), confettiPalette: [ ProviderColor(hex: 0x000000), ProviderColor(hex: 0xFFFFFF), - ProviderColor(hex: 0x7B5BFF), + ProviderColor(hex: 0x8C4EDD), ], widgetColor: ProviderColor(hex: 0x000000)), tokenCost: ProviderTokenCostConfig( diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 3ee45b9abc..6e0f9c160a 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -54,12 +54,13 @@ public enum CopilotProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .copilot), iconResourceName: "ProviderIcon-copilot", - color: ProviderColor(red: 168 / 255, green: 85 / 255, blue: 247 / 255), + color: ProviderColor(red: 133 / 255, green: 52 / 255, blue: 243 / 255), confettiPalette: [ ProviderColor(hex: 0x8534F3), ProviderColor(hex: 0xF08A3A), ProviderColor(hex: 0xC898FD), - ]), + ], + widgetColor: ProviderColor(red: 168 / 255, green: 85 / 255, blue: 247 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Copilot cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index ae2fde284a..2f07af32f5 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -62,11 +62,13 @@ public enum CursorProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .cursor), iconResourceName: "ProviderIcon-cursor", - color: ProviderColor(red: 0 / 255, green: 191 / 255, blue: 165 / 255), + color: ProviderColor(red: 245 / 255, green: 78 / 255, blue: 0 / 255), confettiPalette: [ + ProviderColor(hex: 0xF54E00), ProviderColor(hex: 0x1B1913), ProviderColor(hex: 0xEDECEC), - ]), + ], + widgetColor: ProviderColor(red: 0 / 255, green: 191 / 255, blue: 165 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: { "No Cursor cost usage found. Sign in to Cursor in your browser or the Cursor app." }, diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift index 159369d935..0c81ec90fb 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift @@ -95,7 +95,7 @@ public enum DeepSeekProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .deepseek), iconResourceName: "ProviderIcon-deepseek", - color: ProviderColor(red: 0.32, green: 0.49, blue: 0.94), + color: ProviderColor(red: 77 / 255, green: 107 / 255, blue: 254 / 255), confettiPalette: [ ProviderColor(hex: 0x4D6BFE), ProviderColor(hex: 0x3982FF), diff --git a/Sources/CodexBarCore/Providers/Deepgram/DeepgramProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Deepgram/DeepgramProviderDescriptor.swift index 0afeed2b53..1ff0e3b5a9 100644 --- a/Sources/CodexBarCore/Providers/Deepgram/DeepgramProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Deepgram/DeepgramProviderDescriptor.swift @@ -11,8 +11,8 @@ public enum DeepgramProviderDescriptor { debugLogUnavailableMessage: "Deepgram debug log not yet implemented", dashboardURL: "https://console.deepgram.com/project/", statusLinkURL: "https://status.deepgram.com", - color: ProviderColor(hex: 0x6467F2), - confetti: [0x13EF95, 0x149AFB, 0x1A1A1F], + color: ProviderColor(hex: 0x13EF93), + confetti: [0x13EF93, 0x149AFB, 0x1A1A1F], widgetColor: ProviderColor(hex: 0x0A121B), noDataMessage: "Deepgram cost summary is not yet supported.", environmentKey: DeepgramSettingsReader.apiKeyEnvironmentKey, diff --git a/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift index e32dc78655..b8fec40e08 100644 --- a/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift @@ -53,12 +53,13 @@ public enum DevinProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .devin), iconResourceName: "ProviderIcon-devin", - color: ProviderColor(red: 70 / 255, green: 180 / 255, blue: 130 / 255), + color: ProviderColor(red: 49 / 255, green: 124 / 255, blue: 255 / 255), confettiPalette: [ + ProviderColor(hex: 0x317CFF), ProviderColor(hex: 0x000000), - ProviderColor(hex: 0x626870), ProviderColor(hex: 0xFFFFFF), - ]), + ], + widgetColor: ProviderColor(red: 70 / 255, green: 180 / 255, blue: 130 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Devin cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift index 145a0333b6..b584fa2a1b 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift @@ -63,7 +63,7 @@ public enum DoubaoProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .doubao), iconResourceName: "ProviderIcon-doubao", - color: ProviderColor(red: 51 / 255, green: 112 / 255, blue: 255 / 255), + color: ProviderColor(red: 0 / 255, green: 87 / 255, blue: 255 / 255), confettiPalette: [ ProviderColor(hex: 0x0057FF), ProviderColor(hex: 0xEFC5BA), diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift index c556ef088c..0e1a463e0f 100644 --- a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift @@ -43,12 +43,13 @@ public enum FireworksProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .fireworks), iconResourceName: "ProviderIcon-fireworks", - color: ProviderColor(red: 242 / 255, green: 91 / 255, blue: 28 / 255), + color: ProviderColor(red: 103 / 255, green: 32 / 255, blue: 255 / 255), confettiPalette: [ - ProviderColor(hex: 0xE65618), + ProviderColor(hex: 0x6720FF), ProviderColor(hex: 0xFF9A3C), ProviderColor(hex: 0x2B2B2E), - ]), + ], + widgetColor: ProviderColor(red: 242 / 255, green: 91 / 255, blue: 28 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Fireworks spend comes from the billing summary API; cost history is not tracked." }), diff --git a/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift index 4cd584d09e..1b03fa4763 100644 --- a/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift @@ -40,12 +40,13 @@ public enum GroqProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .groq), iconResourceName: "ProviderIcon-groq", - color: ProviderColor(red: 245 / 255, green: 104 / 255, blue: 68 / 255), + color: ProviderColor(red: 245 / 255, green: 80 / 255, blue: 54 / 255), confettiPalette: [ - ProviderColor(hex: 0xF43E01), + ProviderColor(hex: 0xF55036), ProviderColor(hex: 0xFFFFFF), ProviderColor(hex: 0x97FCA7), - ]), + ], + widgetColor: ProviderColor(red: 245 / 255, green: 104 / 255, blue: 68 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Sign in at console.groq.com to show Groq spend and token usage." }, diff --git a/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift b/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift index 9f04b24d63..e81f7e6e62 100644 --- a/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift @@ -31,12 +31,13 @@ public enum JetBrainsProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .jetbrains), iconResourceName: "ProviderIcon-jetbrains", - color: ProviderColor(red: 255 / 255, green: 51 / 255, blue: 153 / 255), + color: ProviderColor(red: 149 / 255, green: 90 / 255, blue: 224 / 255), confettiPalette: [ - ProviderColor(hex: 0x6B57FF), + ProviderColor(hex: 0x955AE0), ProviderColor(hex: 0x21D789), ProviderColor(hex: 0x000000), - ]), + ], + widgetColor: ProviderColor(red: 255 / 255, green: 51 / 255, blue: 153 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "JetBrains AI cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift index a9b1f3a150..06d71915d2 100644 --- a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift @@ -52,12 +52,13 @@ public enum KiloProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .kilo), iconResourceName: "ProviderIcon-kilo", - color: ProviderColor(red: 242 / 255, green: 112 / 255, blue: 39 / 255), + color: ProviderColor(red: 250 / 255, green: 247 / 255, blue: 79 / 255), confettiPalette: [ - ProviderColor(hex: 0xFA483A), + ProviderColor(hex: 0xFAF74F), ProviderColor(hex: 0xAC1D0E), ProviderColor(hex: 0x121212), - ]), + ], + widgetColor: ProviderColor(red: 242 / 255, green: 112 / 255, blue: 39 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Kilo cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index a4cf95d9f2..cc9615da09 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -87,12 +87,13 @@ public enum KimiProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .kimi), iconResourceName: "ProviderIcon-kimi", - color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255), + color: ProviderColor(red: 0 / 255, green: 124 / 255, blue: 255 / 255), confettiPalette: [ ProviderColor(hex: 0x000000), - ProviderColor(hex: 0x4E6EF2), + ProviderColor(hex: 0x007CFF), ProviderColor(hex: 0xFFFFFF), - ]), + ], + widgetColor: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Kimi Code cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift index 070ee2b514..30cea42827 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift @@ -28,12 +28,13 @@ public enum KiroProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .kiro), iconResourceName: "ProviderIcon-kiro", - color: ProviderColor(red: 255 / 255, green: 153 / 255, blue: 0 / 255), + color: ProviderColor(red: 144 / 255, green: 70 / 255, blue: 255 / 255), confettiPalette: [ - ProviderColor(hex: 0x8F4AFF), + ProviderColor(hex: 0x9046FF), ProviderColor(hex: 0xCAA9FF), ProviderColor(hex: 0x2B2B2B), - ]), + ], + widgetColor: ProviderColor(red: 255 / 255, green: 153 / 255, blue: 0 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Kiro cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift index 37b5930bbd..b3f08f6a67 100644 --- a/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift @@ -11,8 +11,9 @@ public enum LiteLLMProviderDescriptor { debugLogUnavailableMessage: "LiteLLM debug log not yet implemented", usesDetailBackedWindow: true, dashboardURL: nil, - color: ProviderColor(hex: 0x4C89F0), - confetti: [0x191938, 0x8258F2, 0xC5B9F6], + color: ProviderColor(hex: 0x5B3FD1), + confetti: [0x191938, 0x5B3FD1, 0xC5B9F6], + widgetColor: ProviderColor(hex: 0x4C89F0), noDataMessage: "LiteLLM spend is reported by the provider API.", environmentKey: LiteLLMSettingsReader.apiKeyEnvironmentKey, tokenAccountSupport: TokenAccountSupport( diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift index c883e60e12..b237a6e37b 100644 --- a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift @@ -43,12 +43,13 @@ public enum LongCatProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .longcat), iconResourceName: "ProviderIcon-longcat", - color: ProviderColor(red: 255 / 255, green: 209 / 255, blue: 0 / 255), + color: ProviderColor(red: 41 / 255, green: 225 / 255, blue: 84 / 255), confettiPalette: [ - ProviderColor(hex: 0xFFD100), + ProviderColor(hex: 0x29E154), ProviderColor(hex: 0x111111), ProviderColor(hex: 0xFFFFFF), - ]), + ], + widgetColor: ProviderColor(red: 255 / 255, green: 209 / 255, blue: 0 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "LongCat cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift index 828893dff6..598dc9d339 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift @@ -48,12 +48,13 @@ public enum MistralProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .mistral), iconResourceName: "ProviderIcon-mistral", - color: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255), + color: ProviderColor(red: 255 / 255, green: 82 / 255, blue: 41 / 255), confettiPalette: [ - ProviderColor(hex: 0xFA500F), + ProviderColor(hex: 0xFF5229), ProviderColor(hex: 0xFFAF01), ProviderColor(hex: 0xFFE000), - ]), + ], + widgetColor: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: { "Mistral cost history needs a billing web session." }, diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift index 46ad8f4239..08805fbf1c 100644 --- a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift @@ -61,12 +61,13 @@ public enum MoonshotProviderDescriptor { // Provider-specific by design: Moonshot's Open Platform product deliberately uses Kimi branding. iconStyle: .init(provider: .kimi), iconResourceName: "ProviderIcon-kimi", - color: ProviderColor(red: 32 / 255, green: 93 / 255, blue: 235 / 255), + color: ProviderColor(red: 0 / 255, green: 0 / 255, blue: 0 / 255), confettiPalette: [ - ProviderColor(hex: 0x121212), + ProviderColor(hex: 0x000000), ProviderColor(hex: 0x305140), ProviderColor(hex: 0x9F9F9F), - ]), + ], + widgetColor: ProviderColor(red: 32 / 255, green: 93 / 255, blue: 235 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Moonshot / Kimi Open Platform cost summary is not available." }), diff --git a/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattProviderDescriptor.swift b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattProviderDescriptor.swift index 481c4f37c2..d9567bb27a 100644 --- a/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattProviderDescriptor.swift @@ -13,8 +13,8 @@ public enum NeuralWattProviderDescriptor { usesDetailBackedWindow: true, dashboardURL: "https://portal.neuralwatt.com/dashboard", subscriptionDashboardURL: "https://portal.neuralwatt.com/dashboard", - color: ProviderColor(red: 0.22, green: 0.85, blue: 0.55), - confetti: [0x38D98C, 0x17243A, 0xFFFFFF], + color: ProviderColor(hex: 0xD55934), + confetti: [0xD55934, 0x17243A, 0xFFFFFF], widgetColor: ProviderColor(hex: 0x38D98C), noDataMessage: "Neuralwatt token cost history is not available via the quota API.", environmentKey: NeuralWattSettingsReader.apiKeyEnvironmentKey, diff --git a/Sources/CodexBarCore/Providers/Notion/NotionProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Notion/NotionProviderDescriptor.swift index 39659f5d26..d88eb26623 100644 --- a/Sources/CodexBarCore/Providers/Notion/NotionProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Notion/NotionProviderDescriptor.swift @@ -52,12 +52,13 @@ public enum NotionProviderDescriptor { iconResourceName: "ProviderIcon-notion", // Notion's UI accent blue, not its near-black brand ink: the ink is // indistinguishable from the unfilled track in a usage gauge. - color: ProviderColor(red: 51 / 255, green: 126 / 255, blue: 169 / 255), + color: ProviderColor(red: 46 / 255, green: 170 / 255, blue: 220 / 255), confettiPalette: [ - ProviderColor(hex: 0x337EA9), + ProviderColor(hex: 0x2EAADC), ProviderColor(hex: 0xE16259), ProviderColor(hex: 0x37352F), - ]), + ], + widgetColor: ProviderColor(red: 51 / 255, green: 126 / 255, blue: 169 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Notion AI cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift index 46bbd055ba..0072ea7a36 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift @@ -60,12 +60,13 @@ public enum OpenCodeProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .opencode), iconResourceName: "ProviderIcon-opencode", - color: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255), + color: ProviderColor(red: 59 / 255, green: 125 / 255, blue: 216 / 255), confettiPalette: [ ProviderColor(hex: 0x211E1E), ProviderColor(hex: 0xCFCECD), - ProviderColor(hex: 0xFAB283), - ]), + ProviderColor(hex: 0x3B7DD8), + ], + widgetColor: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "OpenCode cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift index fabfe576d1..69e6564f29 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift @@ -13,8 +13,9 @@ public enum PerplexityProviderDescriptor { usesDetailBackedWindow: true, dashboardURL: "https://www.perplexity.ai/account/usage", statusLinkURL: "https://status.perplexity.com/", - color: .init(hex: 0x20B2AA), - confetti: [0x016A71, 0x313131, 0xFDFBFA], + color: .init(hex: 0x20808D), + confetti: [0x20808D, 0x313131, 0xFDFBFA], + widgetColor: .init(hex: 0x20B2AA), noDataMessage: "Perplexity cost tracking is not supported.", menuBarMetrics: ProviderMenuBarMetricCapabilities( supported: [.automatic, .primary, .secondary, .tertiary]), diff --git a/Sources/CodexBarCore/Providers/Qoder/QoderProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Qoder/QoderProviderDescriptor.swift index c52ec10b20..0f714ba064 100644 --- a/Sources/CodexBarCore/Providers/Qoder/QoderProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Qoder/QoderProviderDescriptor.swift @@ -13,8 +13,9 @@ public enum QoderProviderDescriptor { debugLogUnavailableMessage: "Qoder debug log not yet implemented", usesDetailBackedWindow: true, dashboardURL: QoderWebSite.international.dashboardURL.absoluteString, - color: .init(hex: 0x10B981), + color: .init(hex: 0x2ADB5C), confetti: [0x2ADB5C, 0x111113, 0xFFFFFF], + widgetColor: .init(hex: 0x10B981), noDataMessage: "Qoder cost summary is not supported.", presentation: ProviderUsagePresentation( menuCard: ProviderMenuCardPresentation( diff --git a/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift index 06fd7a0f4b..325b11e4ae 100644 --- a/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift @@ -19,8 +19,8 @@ public enum SakanaProviderDescriptor { ], debugLogUnavailableMessage: "Sakana AI debug log not yet implemented", dashboardURL: "https://console.sakana.ai/billing", - color: .init(red: 0.16, green: 0.46, blue: 0.86), - confetti: [0xE10600, 0x0D0D0D, 0xFFFFFF], + color: .init(hex: 0xCC2B2B), + confetti: [0xCC2B2B, 0x0D0D0D, 0xFFFFFF], widgetColor: .init(hex: 0x2975DB), noDataMessage: "Sakana AI cost summary is not supported.", presentation: ProviderUsagePresentation( diff --git a/Sources/CodexBarCore/Providers/Sub2API/Sub2APIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Sub2API/Sub2APIProviderDescriptor.swift index b938fc4aa9..ddf654451c 100644 --- a/Sources/CodexBarCore/Providers/Sub2API/Sub2APIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Sub2API/Sub2APIProviderDescriptor.swift @@ -15,8 +15,9 @@ public enum Sub2APIProviderDescriptor { ], debugLogUnavailableMessage: "sub2api debug log not yet implemented", dashboardURL: nil, - color: ProviderColor(hex: 0x2DC6D8), - confetti: [0x1F62FF, 0x60EDF6, 0x74F9B0], + color: ProviderColor(hex: 0x14B8A6), + confetti: [0x1F62FF, 0x14B8A6, 0x74F9B0], + widgetColor: ProviderColor(hex: 0x2DC6D8), noDataMessage: "sub2api spend is reported by its usage API.", environmentKey: Sub2APISettingsReader.apiKeyEnvironmentKey, missingCredentialMessage: { environment in diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift index b93f4cf443..df050d062f 100644 --- a/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift @@ -12,8 +12,9 @@ public enum T3ChatProviderDescriptor { debugPane: ProviderDebugPaneCapabilities(errorSimulationOrder: 6), dashboardURL: "https://t3.chat/settings/customization", subscriptionDashboardURL: "https://t3.chat/settings/subscription", - color: .init(hex: 0xF56647), - confetti: [0x970B72, 0xE6229C, 0xFEA0F6], + color: .init(hex: 0xA3004C), + confetti: [0xA3004C, 0xE6229C, 0xFEA0F6], + widgetColor: .init(hex: 0xF56647), noDataMessage: "T3 Chat cost summary is not supported.", aliases: ["t3-chat", "t3"], webSource: .init( diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift index 948cfca0a9..57f4575b8f 100644 --- a/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift @@ -47,12 +47,13 @@ public enum VeniceProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .venice), iconResourceName: "ProviderIcon-venice", - color: ProviderColor(red: 0.2, green: 0.6, blue: 1.0), + color: ProviderColor(red: 60 / 255, green: 143 / 255, blue: 221 / 255), confettiPalette: [ ProviderColor(hex: 0x0E2942), ProviderColor(hex: 0xF7F5ED), ProviderColor(hex: 0x3C8FDD), - ]), + ], + widgetColor: ProviderColor(red: 0.2, green: 0.6, blue: 1.0)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Venice per-day cost history is not available via API." }), diff --git a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift index 477b893602..ec5a13a12f 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift @@ -32,12 +32,13 @@ public enum WarpProviderDescriptor { branding: ProviderBranding( iconStyle: .init(provider: .warp), iconResourceName: "ProviderIcon-warp", - color: ProviderColor(red: 147 / 255, green: 139 / 255, blue: 180 / 255), + color: ProviderColor(red: 1 / 255, green: 164 / 255, blue: 255 / 255), confettiPalette: [ - ProviderColor(hex: 0xC7AEFF), + ProviderColor(hex: 0x01A4FF), ProviderColor(hex: 0x1C1A26), ProviderColor(hex: 0xFFFFFF), - ]), + ], + widgetColor: ProviderColor(red: 147 / 255, green: 139 / 255, blue: 180 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Warp cost summary is not available." }), diff --git a/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift b/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift index 2764588773..3ca8af54f6 100644 --- a/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift +++ b/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift @@ -30,7 +30,7 @@ struct MenuCardProviderRegressionTests { @Test func `command code progress color uses its contrasting brand accent`() { let branding = ProviderDescriptorRegistry.descriptor(for: .commandcode).branding.color - let expected = ProviderColor(hex: 0xA04DFD) + let expected = ProviderColor(hex: 0x8C4EDD) #expect(branding == expected) #expect(UsageMenuCardView.Model.progressColor(for: .commandcode) == Color( From fc7efc16cf57462c321fd05ce265e465e87c707c Mon Sep 17 00:00:00 2001 From: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:25:34 +0200 Subject: [PATCH 013/122] fix(mistral): show the metric picker in every menu bar style --- .../MenuBarPercentWindowPreference.swift | 14 ++++++---- ...iderMenuBarPercentWindowSettingsView.swift | 10 +++++-- .../Resources/ar.lproj/Localizable.strings | 2 +- .../Resources/ca.lproj/Localizable.strings | 2 +- .../Resources/de.lproj/Localizable.strings | 2 +- .../Resources/es.lproj/Localizable.strings | 2 +- .../Resources/fa.lproj/Localizable.strings | 2 +- .../Resources/fr.lproj/Localizable.strings | 2 +- .../Resources/gl.lproj/Localizable.strings | 2 +- .../Resources/id.lproj/Localizable.strings | 2 +- .../Resources/it.lproj/Localizable.strings | 2 +- .../Resources/ja.lproj/Localizable.strings | 2 +- .../Resources/ko.lproj/Localizable.strings | 2 +- .../Resources/nl.lproj/Localizable.strings | 2 +- .../Resources/pl.lproj/Localizable.strings | 2 +- .../Resources/pt-BR.lproj/Localizable.strings | 2 +- .../Resources/ru.lproj/Localizable.strings | 2 +- .../Resources/sv.lproj/Localizable.strings | 2 +- .../Resources/th.lproj/Localizable.strings | 2 +- .../Resources/tr.lproj/Localizable.strings | 2 +- .../Resources/uk.lproj/Localizable.strings | 2 +- .../Resources/vi.lproj/Localizable.strings | 2 +- .../zh-Hans.lproj/Localizable.strings | 2 +- .../zh-Hant.lproj/Localizable.strings | 2 +- .../MistralMonthlyPlanPickerTests.swift | 28 +++++++++++++++++++ docs/mistral.md | 4 +-- 26 files changed, 69 insertions(+), 31 deletions(-) diff --git a/Sources/CodexBar/MenuBarPercentWindowPreference.swift b/Sources/CodexBar/MenuBarPercentWindowPreference.swift index 05f156d18c..8ee8d67c5c 100644 --- a/Sources/CodexBar/MenuBarPercentWindowPreference.swift +++ b/Sources/CodexBar/MenuBarPercentWindowPreference.swift @@ -94,14 +94,15 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl } /// The simplified picker controls percent layouts without changing the global icon style. + /// Monthly Plan also picks the widget allowance, so it stays reachable in every style and layout. static func isVisible( iconStyle: MenuBarIconStyle, layout: MenuBarLayout, available: [Self]) -> Bool { - iconStyle == .iconAndPercent - && self.hasPercentToken(in: layout) - && available.count > 1 + guard available.count > 1 else { return false } + if available.contains(.monthlyPlan) { return true } + return iconStyle == .iconAndPercent && self.hasPercentToken(in: layout) } static func isVisible( @@ -117,10 +118,13 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl /// Ordinary percentages own the choice when a custom layout also has an independent tertiary /// token. Only layouts without ordinary percentages treat tertiary tokens as the controlled group. - /// A Monthly Plan metric turns an all-automatic layout into the Monthly Plan choice. + /// A Monthly Plan metric turns an all-automatic layout, or one without percentages, into the Monthly Plan choice. static func current(in layout: MenuBarLayout, metric: MenuBarMetricPreference = .automatic) -> Self? { let windows = Self.percentWindows(in: layout) - guard let first = windows.first else { return self.hasTertiaryPercent(in: layout) ? .tertiary : nil } + guard let first = windows.first else { + if self.hasTertiaryPercent(in: layout) { return .tertiary } + return metric == .monthlyPlan ? .monthlyPlan : nil + } guard windows.allSatisfy({ $0 == first }) else { return nil } if first == .automatic, metric == .monthlyPlan { return .monthlyPlan } return Self.allCases.first { $0.percentWindow == first } diff --git a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift index 21106dda6e..51aadfa905 100644 --- a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift +++ b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift @@ -62,7 +62,9 @@ struct ProviderMenuBarPercentWindowPicker: View { .pickerStyle(.menu) .listRowSeparator(.hidden) } footer: { - SettingsSectionFooter(L("menu_bar_metric_subtitle")) + SettingsSectionFooter(available.contains(.monthlyPlan) + ? L("menu_bar_metric_subtitle_mistral") + : L("menu_bar_metric_subtitle")) } .background(FocusResigningBackground()) } @@ -81,10 +83,14 @@ struct ProviderMenuBarPercentWindowPicker: View { guard let preference, MenuBarPercentWindowPreference.available(for: self.provider, layout: layout).contains(preference) else { return } + let updated = preference.applied(to: layout) if MenuBarPercentWindowPreference.available(for: self.provider).contains(.monthlyPlan) { self.metric.wrappedValue = preference.menuBarMetric + // Without a percentage to change, only the metric is stored, so the layout keeps following + // its source instead of becoming a provider override. + guard updated != layout else { return } } - self.layout = preference.applied(to: layout) + self.layout = updated }) } } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index ea3dbae929..af9796f364 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -598,7 +598,7 @@ "menu_bar_metric_subtitle" = "اختر أي نافذة تحدد نسبة شريط القوائم."; "menu_bar_metric_subtitle_deepseek" = "يظهر توازن DeepSeek في شريط القوائم."; "menu_bar_metric_subtitle_moonshot" = "يظهر توازن Moonshot / Kimi API في شريط القوائم."; -"menu_bar_metric_subtitle_mistral" = "يعرض الإنفاق Mistral API الشهري الحالي في شريط القوائم."; +"menu_bar_metric_subtitle_mistral" = "اختر إنفاق Mistral API أو استخدام Monthly Plan لشريط القوائم والأدوات."; "automatic" = "أوتوماتيكي"; "primary_api_key_limit" = "الحد الأساسي (API المفاتيح)"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index d2877fabe8..16223a84fd 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -576,7 +576,7 @@ "menu_bar_metric_subtitle" = "Trieu quina finestra determina el percentatge de la barra de menús."; "menu_bar_metric_subtitle_deepseek" = "Mostra el saldo de DeepSeek a la barra de menús."; "menu_bar_metric_subtitle_moonshot" = "Mostra el saldo de l'API de Moonshot / Kimi a la barra de menús."; -"menu_bar_metric_subtitle_mistral" = "Trieu entre la despesa de l'API de Mistral i l'ús del Monthly Plan per a la barra de menús."; +"menu_bar_metric_subtitle_mistral" = "Trieu entre la despesa de l'API de Mistral i l'ús del Monthly Plan per a la barra de menús i els widgets."; "automatic" = "Automàtic"; "primary_api_key_limit" = "Principal (límit de la clau d'API)"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 8be276122d..f486d398e9 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -590,7 +590,7 @@ "menu_bar_metric_subtitle" = "Wählen Sie aus, welches Fenster den Prozentwert der Menüleiste steuert."; "menu_bar_metric_subtitle_deepseek" = "Zeigt das DeepSeek-Guthaben in der Menüleiste an."; "menu_bar_metric_subtitle_moonshot" = "Zeigt das Moonshot-/Kimi-API-Guthaben in der Menüleiste an."; -"menu_bar_metric_subtitle_mistral" = "Zeigt die Mistral-API-Ausgaben des aktuellen Monats in der Menüleiste an."; +"menu_bar_metric_subtitle_mistral" = "Wähle Mistral-API-Ausgaben oder die Nutzung des Monthly Plan für Menüleiste und Widgets."; "automatic" = "Automatisch"; "primary_api_key_limit" = "Primär (API-Schlüssellimit)"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index b603a722ed..5078931588 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -584,7 +584,7 @@ "menu_bar_metric_subtitle" = "Elige qué ventana determina el porcentaje de la barra de menús."; "menu_bar_metric_subtitle_deepseek" = "Muestra el saldo de DeepSeek en la barra de menús."; "menu_bar_metric_subtitle_moonshot" = "Muestra el saldo de la API de Moonshot / Kimi en la barra de menús."; -"menu_bar_metric_subtitle_mistral" = "Muestra el gasto de la API de Mistral del mes actual en la barra de menús."; +"menu_bar_metric_subtitle_mistral" = "Elige el gasto de la API de Mistral o el uso del Monthly Plan para la barra de menús y los widgets."; "automatic" = "Automático"; "primary_api_key_limit" = "Principal (límite de la clave de API)"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index af9a059ec2..fa567d106e 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -598,7 +598,7 @@ "menu_bar_metric_subtitle" = "انتخاب کنید کدام پنجره درصد نوار منو را تنظیم کند."; "menu_bar_metric_subtitle_deepseek" = "تعادل DeepSeek را در نوار منو نشان می دهد."; "menu_bar_metric_subtitle_moonshot" = "تعادل Moonshot / Kimi API را در نوار منو نشان می دهد."; -"menu_bar_metric_subtitle_mistral" = "هزینه های Mistral API ماه جاری را در نوار منو نشان می دهد."; +"menu_bar_metric_subtitle_mistral" = "هزینه Mistral API یا مصرف Monthly Plan را برای نوار منو و ویجت‌ها انتخاب کنید."; "automatic" = "اتوماتیک"; "primary_api_key_limit" = "کلید اصلی (API حد کلید)"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index c3ed832fad..1fd7a3e3b6 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -592,7 +592,7 @@ "menu_bar_metric_subtitle" = "Choisissez quelle fenêtre gère le pourcentage de la barre de menus."; "menu_bar_metric_subtitle_deepseek" = "Affiche le solde DeepSeek dans la barre de menu."; "menu_bar_metric_subtitle_moonshot" = "Affiche le solde de l'API Moonshot / Kimi dans la barre de menu."; -"menu_bar_metric_subtitle_mistral" = "Affiche les dépenses de l'API Mistral du mois en cours dans la barre de menu."; +"menu_bar_metric_subtitle_mistral" = "Choisissez les dépenses de l'API Mistral ou l'utilisation du Monthly Plan pour la barre de menus et les widgets."; "automatic" = "Automatique"; "primary_api_key_limit" = "Primaire (limite de clé API)"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 8bd432a554..e284dd46dd 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -571,7 +571,7 @@ "menu_bar_metric_subtitle" = "Escolle que xanela determina a porcentaxe da barra de menús."; "menu_bar_metric_subtitle_deepseek" = "Amosa o saldo de DeepSeek na barra de menús."; "menu_bar_metric_subtitle_moonshot" = "Amosa o saldo da API de Moonshot / Kimi na barra de menús."; -"menu_bar_metric_subtitle_mistral" = "Amosa o gasto da API de Mistral do mes actual na barra de menús."; +"menu_bar_metric_subtitle_mistral" = "Escolle o gasto da API de Mistral ou o uso do Plan mensual para a barra de menús e os widgets."; "automatic" = "Automático"; "primary_api_key_limit" = "Principal (límite da chave de API)"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 1802137505..150ea7ef1e 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -600,7 +600,7 @@ "menu_bar_metric_subtitle" = "Pilih jendela mana yang menggerakkan persentase menu bar."; "menu_bar_metric_subtitle_deepseek" = "Menampilkan saldo DeepSeek di menu bar."; "menu_bar_metric_subtitle_moonshot" = "Menampilkan saldo API Moonshot / Kimi di menu bar."; -"menu_bar_metric_subtitle_mistral" = "Menampilkan pengeluaran API Mistral bulan ini di menu bar."; +"menu_bar_metric_subtitle_mistral" = "Pilih pengeluaran API Mistral atau penggunaan Monthly Plan untuk menu bar dan widget."; "automatic" = "Otomatis"; "primary_api_key_limit" = "Utama (batas kunci API)"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 2477449503..58f67352c3 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -600,7 +600,7 @@ "menu_bar_metric_subtitle" = "Scegli quale finestra guida la percentuale nella barra menu."; "menu_bar_metric_subtitle_deepseek" = "Mostra il saldo DeepSeek nella barra menu."; "menu_bar_metric_subtitle_moonshot" = "Mostra il saldo API Moonshot / Kimi nella barra menu."; -"menu_bar_metric_subtitle_mistral" = "Mostra la spesa API Mistral del mese corrente nella barra menu."; +"menu_bar_metric_subtitle_mistral" = "Scegli la spesa API Mistral o l'utilizzo del Piano mensile per la barra menu e i widget."; "automatic" = "Automatico"; "primary_api_key_limit" = "Principale (limite chiave API)"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 1880d5bad4..668882ca5e 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -589,7 +589,7 @@ "menu_bar_metric_subtitle" = "メニューバーのパーセント表示に使用するウインドウを選択します。"; "menu_bar_metric_subtitle_deepseek" = "DeepSeek の残高をメニューバーに表示します。"; "menu_bar_metric_subtitle_moonshot" = "Moonshot / Kimi API の残高をメニューバーに表示します。"; -"menu_bar_metric_subtitle_mistral" = "今月の Mistral API 支出をメニューバーに表示します。"; +"menu_bar_metric_subtitle_mistral" = "メニューバーとウィジェットに表示する Mistral API 支出または Monthly Plan の使用量を選択します。"; "automatic" = "自動"; "primary_api_key_limit" = "プライマリ(API キー上限)"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index aaec0a3d49..7d638535ba 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -584,7 +584,7 @@ "menu_bar_metric_subtitle" = "메뉴 막대 백분율을 결정할 창을 선택하세요."; "menu_bar_metric_subtitle_deepseek" = "메뉴 막대에 DeepSeek 잔액을 표시합니다."; "menu_bar_metric_subtitle_moonshot" = "메뉴 막대에 Moonshot / Kimi API 잔액을 표시합니다."; -"menu_bar_metric_subtitle_mistral" = "메뉴 막대에 이번 달 Mistral API 지출을 표시합니다."; +"menu_bar_metric_subtitle_mistral" = "메뉴 막대와 위젯에 표시할 Mistral API 지출 또는 Monthly Plan 사용량을 선택합니다."; "automatic" = "자동"; "primary_api_key_limit" = "기본 (API 키 한도)"; "menu_bar_style_title" = "메뉴 막대 스타일"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 92bf3ccc35..29a68c851c 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -592,7 +592,7 @@ "menu_bar_metric_subtitle" = "Kies welk venster het menubalkpercentage aanstuurt."; "menu_bar_metric_subtitle_deepseek" = "Toont het DeepSeek-saldo in de menubalk."; "menu_bar_metric_subtitle_moonshot" = "Toont het Moonshot / Kimi API-saldo in de menubalk."; -"menu_bar_metric_subtitle_mistral" = "Toont de Mistral API-uitgaven van de huidige maand in de menubalk."; +"menu_bar_metric_subtitle_mistral" = "Kies Mistral API-uitgaven of Monthly Plan-gebruik voor de menubalk en widgets."; "automatic" = "Automatisch"; "primary_api_key_limit" = "Primair (API-sleutellimiet)"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 17a45ff52a..8a54edd490 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -600,7 +600,7 @@ "menu_bar_metric_subtitle" = "Wybierz metrykę pokazywaną obok ikony na pasku menu."; "menu_bar_metric_subtitle_deepseek" = "Pokazuje saldo DeepSeek na pasku menu."; "menu_bar_metric_subtitle_moonshot" = "Pokazuje saldo API Moonshot / Kimi na pasku menu."; -"menu_bar_metric_subtitle_mistral" = "Pokazuje wydatki Mistral API z bieżącego miesiąca na pasku menu."; +"menu_bar_metric_subtitle_mistral" = "Wybierz wydatki Mistral API lub użycie Monthly Plan dla paska menu i widżetów."; "automatic" = "Automatycznie"; "primary_api_key_limit" = "Główny (limit klucza API)"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 0ac9990785..770dc778ca 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -589,7 +589,7 @@ "menu_bar_metric_subtitle" = "Escolha qual janela define a porcentagem da barra de menus."; "menu_bar_metric_subtitle_deepseek" = "Mostra o saldo do DeepSeek na barra de menus."; "menu_bar_metric_subtitle_moonshot" = "Mostra o saldo da API Moonshot / Kimi na barra de menus."; -"menu_bar_metric_subtitle_mistral" = "Mostra o gasto da API Mistral no mês atual na barra de menus."; +"menu_bar_metric_subtitle_mistral" = "Escolha o gasto da API Mistral ou o uso do Monthly Plan para a barra de menus e os widgets."; "automatic" = "Automático"; "primary_api_key_limit" = "Primário (limite da chave de API)"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index cffbabb49f..11e9d48314 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -593,7 +593,7 @@ "menu_bar_metric_subtitle" = "Выберите, какое окно управляет процентами строки меню."; "menu_bar_metric_subtitle_deepseek" = "Показывает баланс DeepSeek в строке меню."; "menu_bar_metric_subtitle_moonshot" = "Показывает баланс Moonshot / Kimi API в строке меню."; -"menu_bar_metric_subtitle_mistral" = "В строке меню показаны расходы Mistral API за текущий месяц."; +"menu_bar_metric_subtitle_mistral" = "Выберите расходы Mistral API или использование ежемесячного плана для строки меню и виджетов."; "automatic" = "Автоматически"; "primary_api_key_limit" = "Основной (лимит API-ключа)"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 817607c1fb..0730a9b6a2 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -591,7 +591,7 @@ "menu_bar_metric_subtitle" = "Välj vilket fönster som styr procenttalet i menyraden."; "menu_bar_metric_subtitle_deepseek" = "Visar DeepSeek-saldot i menyraden."; "menu_bar_metric_subtitle_moonshot" = "Visar saldot för Moonshot/Kimi API i menyraden."; -"menu_bar_metric_subtitle_mistral" = "Visar den aktuella månadens Mistral API-utgift i menyraden."; +"menu_bar_metric_subtitle_mistral" = "Välj Mistral API-utgift eller Monthly Plan-användning för menyraden och widgetar."; "automatic" = "Automatiskt"; "primary_api_key_limit" = "Primär (API-nyckelgräns)"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 530d738858..6f4fdf8e2f 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -598,7 +598,7 @@ "menu_bar_metric_subtitle" = "เลือกหน้าต่างที่จะขับเคลื่อนเปอร์เซ็นต์ของแถบเมนู"; "menu_bar_metric_subtitle_deepseek" = "แสดงยอดคงเหลือ DeepSeek ในแถบเมนู"; "menu_bar_metric_subtitle_moonshot" = "แสดงยอดคงเหลือ Moonshot / Kimi API ในแถบเมนู"; -"menu_bar_metric_subtitle_mistral" = "แสดงการใช้จ่าย Mistral API เดือนปัจจุบันในแถบเมนู"; +"menu_bar_metric_subtitle_mistral" = "เลือกการใช้จ่าย Mistral API หรือการใช้งาน Monthly Plan สำหรับแถบเมนูและวิดเจ็ต"; "automatic" = "อัตโนมัติ"; "primary_api_key_limit" = "หลัก (จํากัดคีย์ API)"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 5f05a401f9..52370947e1 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -598,7 +598,7 @@ "menu_bar_metric_subtitle" = "Menü çubuğu yüzdesini hangi pencerenin belirleyeceğini seçin."; "menu_bar_metric_subtitle_deepseek" = "Menü çubuğunda DeepSeek bakiyesini gösterir."; "menu_bar_metric_subtitle_moonshot" = "Menü çubuğunda Moonshot / Kimi API bakiyesini gösterir."; -"menu_bar_metric_subtitle_mistral" = "Menü çubuğunda Mistral API'sinin geçerli ay harcamasını gösterir."; +"menu_bar_metric_subtitle_mistral" = "Menü çubuğu ve widget'lar için Mistral API harcamasını veya aylık plan kullanımını seçin."; "automatic" = "Otomatik"; "primary_api_key_limit" = "Birincil (API anahtarı limiti)"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 22a202310d..ea38103f95 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -592,7 +592,7 @@ "menu_bar_metric_subtitle" = "Виберіть, яке вікно керує відсотками панелі меню."; "menu_bar_metric_subtitle_deepseek" = "Показує баланс DeepSeek на панелі меню."; "menu_bar_metric_subtitle_moonshot" = "Показує баланс API Moonshot / Kimi на панелі меню."; -"menu_bar_metric_subtitle_mistral" = "Показує поточні витрати Mistral API на панелі меню."; +"menu_bar_metric_subtitle_mistral" = "Виберіть витрати Mistral API або використання Monthly Plan для панелі меню та віджетів."; "automatic" = "Автоматичний"; "primary_api_key_limit" = "Основний (обмеження ключа API)"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index e161baf8a7..a1e9e1d1f1 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -588,7 +588,7 @@ "menu_bar_metric_subtitle" = "Chọn cửa sổ nào thúc đẩy phần trăm thanh menu."; "menu_bar_metric_subtitle_deepseek" = "Hiển thị số dư DeepSeek trong thanh menu ."; "menu_bar_metric_subtitle_moonshot" = "Hiển thị số dư Moonshot / Kimi API trong thanh menu ."; -"menu_bar_metric_subtitle_mistral" = "Hiển thị mức chi tiêu API của Mistral trong tháng hiện tại trong thanh menu ."; +"menu_bar_metric_subtitle_mistral" = "Chọn chi tiêu API của Mistral hoặc mức sử dụng Monthly Plan cho thanh menu và tiện ích."; "automatic" = "Tự động"; "primary_api_key_limit" = "Chính ( API giới hạn khóa)"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index ebe3fb66a3..8ab07e50d2 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -576,7 +576,7 @@ "menu_bar_metric_subtitle" = "选择哪个窗口驱动菜单栏百分比。"; "menu_bar_metric_subtitle_deepseek" = "在菜单栏显示 DeepSeek 余额。"; "menu_bar_metric_subtitle_moonshot" = "在菜单栏显示 Moonshot / Kimi API 余额。"; -"menu_bar_metric_subtitle_mistral" = "在菜单栏显示 Mistral API 本月支出。"; +"menu_bar_metric_subtitle_mistral" = "为菜单栏和小组件选择 Mistral API 支出或 Monthly Plan 用量。"; "automatic" = "自动"; "primary_api_key_limit" = "主要(API 密钥限制)"; "menu_bar_style_title" = "菜单栏样式"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index b1bd36d6dc..227018ce39 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -593,7 +593,7 @@ "menu_bar_metric_subtitle" = "選擇選單列百分比要依據哪個時段。"; "menu_bar_metric_subtitle_deepseek" = "在選單列顯示 DeepSeek 餘額。"; "menu_bar_metric_subtitle_moonshot" = "在選單列顯示 Moonshot / Kimi API 餘額。"; -"menu_bar_metric_subtitle_mistral" = "在選單列顯示 Mistral API 本月支出。"; +"menu_bar_metric_subtitle_mistral" = "為選單列和小工具選擇 Mistral API 支出或月租方案用量。"; "automatic" = "自動"; "primary_api_key_limit" = "主要(API 金鑰限制)"; "menu_bar_style_title" = "選單列樣式"; diff --git a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift index df80e11aeb..e812183dcb 100644 --- a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift +++ b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift @@ -35,4 +35,32 @@ struct MistralMonthlyPlanPickerTests { #expect(settings.menuBarMetricPreference(for: .mistral) == .automatic) #expect(picker.selectionBinding.wrappedValue == .automatic) } + + @Test + func `Monthly Plan stays reachable when the menu bar hides percentages`() { + let settings = testSettingsStore( + suiteName: "MistralMonthlyPlanPickerTests-critters", + userDefaults: InMemoryUserDefaults()) + settings.menuBarIconStyle = .critters + settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon]]), for: nil) + let view = ProviderMenuBarPercentWindowSettingsView(provider: .mistral, settings: settings) + let picker = ProviderMenuBarPercentWindowPicker( + provider: .mistral, + iconStyle: settings.menuBarIconStyle, + layout: view.layoutBinding, + metric: view.metricBinding) + #expect(MenuBarPercentWindowPreference.isVisible( + iconStyle: .critters, + layout: settings.menuBarLayout(for: .mistral), + provider: .mistral)) + #expect(!MenuBarPercentWindowPreference.isVisible( + iconStyle: .critters, + layout: settings.menuBarLayout(for: .codex), + provider: .codex)) + + picker.selectionBinding.wrappedValue = .monthlyPlan + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + #expect(picker.selectionBinding.wrappedValue == .monthlyPlan) + #expect(settings.menuBarLayoutOverrides[.mistral] == nil) + } } diff --git a/docs/mistral.md b/docs/mistral.md index 92b8ca0189..22353ef16b 100644 --- a/docs/mistral.md +++ b/docs/mistral.md @@ -65,8 +65,8 @@ For the console request, CodexBar forwards only the `csrftoken` and `ory_session ## Widgets -Usage widgets follow the **Menu bar metric** picker in Mistral's provider settings, which appears when the menu bar -style is **Icon & percent**: +Usage widgets follow the **Menu bar metric** picker in Mistral's provider settings. The picker appears in every menu bar +style, so Critters and Meter bars users can still pick the widget allowance: - **Automatic** and **Included API** show only the API allowance, preserving the existing default. - **Monthly Plan** shows only the Vibe allowance, falling back to Included API when the plan is missing or unknown. From 980324d7d4f87ddd0b9639026e180fecb7e41fa6 Mon Sep 17 00:00:00 2001 From: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:26:30 +0200 Subject: [PATCH 014/122] fix(mistral): keep the shared picker footer --- .../CodexBar/ProviderMenuBarPercentWindowSettingsView.swift | 4 +--- Sources/CodexBar/Resources/ar.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/ca.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/de.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/es.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/fa.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/fr.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/gl.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/id.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/it.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/ja.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/ko.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/nl.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/pl.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/ru.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/sv.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/th.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/tr.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/uk.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/vi.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings | 2 +- Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings | 2 +- 23 files changed, 23 insertions(+), 25 deletions(-) diff --git a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift index 51aadfa905..6ba94b09d8 100644 --- a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift +++ b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift @@ -62,9 +62,7 @@ struct ProviderMenuBarPercentWindowPicker: View { .pickerStyle(.menu) .listRowSeparator(.hidden) } footer: { - SettingsSectionFooter(available.contains(.monthlyPlan) - ? L("menu_bar_metric_subtitle_mistral") - : L("menu_bar_metric_subtitle")) + SettingsSectionFooter(L("menu_bar_metric_subtitle")) } .background(FocusResigningBackground()) } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index af9796f364..ea3dbae929 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -598,7 +598,7 @@ "menu_bar_metric_subtitle" = "اختر أي نافذة تحدد نسبة شريط القوائم."; "menu_bar_metric_subtitle_deepseek" = "يظهر توازن DeepSeek في شريط القوائم."; "menu_bar_metric_subtitle_moonshot" = "يظهر توازن Moonshot / Kimi API في شريط القوائم."; -"menu_bar_metric_subtitle_mistral" = "اختر إنفاق Mistral API أو استخدام Monthly Plan لشريط القوائم والأدوات."; +"menu_bar_metric_subtitle_mistral" = "يعرض الإنفاق Mistral API الشهري الحالي في شريط القوائم."; "automatic" = "أوتوماتيكي"; "primary_api_key_limit" = "الحد الأساسي (API المفاتيح)"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 16223a84fd..d2877fabe8 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -576,7 +576,7 @@ "menu_bar_metric_subtitle" = "Trieu quina finestra determina el percentatge de la barra de menús."; "menu_bar_metric_subtitle_deepseek" = "Mostra el saldo de DeepSeek a la barra de menús."; "menu_bar_metric_subtitle_moonshot" = "Mostra el saldo de l'API de Moonshot / Kimi a la barra de menús."; -"menu_bar_metric_subtitle_mistral" = "Trieu entre la despesa de l'API de Mistral i l'ús del Monthly Plan per a la barra de menús i els widgets."; +"menu_bar_metric_subtitle_mistral" = "Trieu entre la despesa de l'API de Mistral i l'ús del Monthly Plan per a la barra de menús."; "automatic" = "Automàtic"; "primary_api_key_limit" = "Principal (límit de la clau d'API)"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index f486d398e9..8be276122d 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -590,7 +590,7 @@ "menu_bar_metric_subtitle" = "Wählen Sie aus, welches Fenster den Prozentwert der Menüleiste steuert."; "menu_bar_metric_subtitle_deepseek" = "Zeigt das DeepSeek-Guthaben in der Menüleiste an."; "menu_bar_metric_subtitle_moonshot" = "Zeigt das Moonshot-/Kimi-API-Guthaben in der Menüleiste an."; -"menu_bar_metric_subtitle_mistral" = "Wähle Mistral-API-Ausgaben oder die Nutzung des Monthly Plan für Menüleiste und Widgets."; +"menu_bar_metric_subtitle_mistral" = "Zeigt die Mistral-API-Ausgaben des aktuellen Monats in der Menüleiste an."; "automatic" = "Automatisch"; "primary_api_key_limit" = "Primär (API-Schlüssellimit)"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 5078931588..b603a722ed 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -584,7 +584,7 @@ "menu_bar_metric_subtitle" = "Elige qué ventana determina el porcentaje de la barra de menús."; "menu_bar_metric_subtitle_deepseek" = "Muestra el saldo de DeepSeek en la barra de menús."; "menu_bar_metric_subtitle_moonshot" = "Muestra el saldo de la API de Moonshot / Kimi en la barra de menús."; -"menu_bar_metric_subtitle_mistral" = "Elige el gasto de la API de Mistral o el uso del Monthly Plan para la barra de menús y los widgets."; +"menu_bar_metric_subtitle_mistral" = "Muestra el gasto de la API de Mistral del mes actual en la barra de menús."; "automatic" = "Automático"; "primary_api_key_limit" = "Principal (límite de la clave de API)"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index fa567d106e..af9a059ec2 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -598,7 +598,7 @@ "menu_bar_metric_subtitle" = "انتخاب کنید کدام پنجره درصد نوار منو را تنظیم کند."; "menu_bar_metric_subtitle_deepseek" = "تعادل DeepSeek را در نوار منو نشان می دهد."; "menu_bar_metric_subtitle_moonshot" = "تعادل Moonshot / Kimi API را در نوار منو نشان می دهد."; -"menu_bar_metric_subtitle_mistral" = "هزینه Mistral API یا مصرف Monthly Plan را برای نوار منو و ویجت‌ها انتخاب کنید."; +"menu_bar_metric_subtitle_mistral" = "هزینه های Mistral API ماه جاری را در نوار منو نشان می دهد."; "automatic" = "اتوماتیک"; "primary_api_key_limit" = "کلید اصلی (API حد کلید)"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 1fd7a3e3b6..c3ed832fad 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -592,7 +592,7 @@ "menu_bar_metric_subtitle" = "Choisissez quelle fenêtre gère le pourcentage de la barre de menus."; "menu_bar_metric_subtitle_deepseek" = "Affiche le solde DeepSeek dans la barre de menu."; "menu_bar_metric_subtitle_moonshot" = "Affiche le solde de l'API Moonshot / Kimi dans la barre de menu."; -"menu_bar_metric_subtitle_mistral" = "Choisissez les dépenses de l'API Mistral ou l'utilisation du Monthly Plan pour la barre de menus et les widgets."; +"menu_bar_metric_subtitle_mistral" = "Affiche les dépenses de l'API Mistral du mois en cours dans la barre de menu."; "automatic" = "Automatique"; "primary_api_key_limit" = "Primaire (limite de clé API)"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index e284dd46dd..8bd432a554 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -571,7 +571,7 @@ "menu_bar_metric_subtitle" = "Escolle que xanela determina a porcentaxe da barra de menús."; "menu_bar_metric_subtitle_deepseek" = "Amosa o saldo de DeepSeek na barra de menús."; "menu_bar_metric_subtitle_moonshot" = "Amosa o saldo da API de Moonshot / Kimi na barra de menús."; -"menu_bar_metric_subtitle_mistral" = "Escolle o gasto da API de Mistral ou o uso do Plan mensual para a barra de menús e os widgets."; +"menu_bar_metric_subtitle_mistral" = "Amosa o gasto da API de Mistral do mes actual na barra de menús."; "automatic" = "Automático"; "primary_api_key_limit" = "Principal (límite da chave de API)"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 150ea7ef1e..1802137505 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -600,7 +600,7 @@ "menu_bar_metric_subtitle" = "Pilih jendela mana yang menggerakkan persentase menu bar."; "menu_bar_metric_subtitle_deepseek" = "Menampilkan saldo DeepSeek di menu bar."; "menu_bar_metric_subtitle_moonshot" = "Menampilkan saldo API Moonshot / Kimi di menu bar."; -"menu_bar_metric_subtitle_mistral" = "Pilih pengeluaran API Mistral atau penggunaan Monthly Plan untuk menu bar dan widget."; +"menu_bar_metric_subtitle_mistral" = "Menampilkan pengeluaran API Mistral bulan ini di menu bar."; "automatic" = "Otomatis"; "primary_api_key_limit" = "Utama (batas kunci API)"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 58f67352c3..2477449503 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -600,7 +600,7 @@ "menu_bar_metric_subtitle" = "Scegli quale finestra guida la percentuale nella barra menu."; "menu_bar_metric_subtitle_deepseek" = "Mostra il saldo DeepSeek nella barra menu."; "menu_bar_metric_subtitle_moonshot" = "Mostra il saldo API Moonshot / Kimi nella barra menu."; -"menu_bar_metric_subtitle_mistral" = "Scegli la spesa API Mistral o l'utilizzo del Piano mensile per la barra menu e i widget."; +"menu_bar_metric_subtitle_mistral" = "Mostra la spesa API Mistral del mese corrente nella barra menu."; "automatic" = "Automatico"; "primary_api_key_limit" = "Principale (limite chiave API)"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 668882ca5e..1880d5bad4 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -589,7 +589,7 @@ "menu_bar_metric_subtitle" = "メニューバーのパーセント表示に使用するウインドウを選択します。"; "menu_bar_metric_subtitle_deepseek" = "DeepSeek の残高をメニューバーに表示します。"; "menu_bar_metric_subtitle_moonshot" = "Moonshot / Kimi API の残高をメニューバーに表示します。"; -"menu_bar_metric_subtitle_mistral" = "メニューバーとウィジェットに表示する Mistral API 支出または Monthly Plan の使用量を選択します。"; +"menu_bar_metric_subtitle_mistral" = "今月の Mistral API 支出をメニューバーに表示します。"; "automatic" = "自動"; "primary_api_key_limit" = "プライマリ(API キー上限)"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 7d638535ba..aaec0a3d49 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -584,7 +584,7 @@ "menu_bar_metric_subtitle" = "메뉴 막대 백분율을 결정할 창을 선택하세요."; "menu_bar_metric_subtitle_deepseek" = "메뉴 막대에 DeepSeek 잔액을 표시합니다."; "menu_bar_metric_subtitle_moonshot" = "메뉴 막대에 Moonshot / Kimi API 잔액을 표시합니다."; -"menu_bar_metric_subtitle_mistral" = "메뉴 막대와 위젯에 표시할 Mistral API 지출 또는 Monthly Plan 사용량을 선택합니다."; +"menu_bar_metric_subtitle_mistral" = "메뉴 막대에 이번 달 Mistral API 지출을 표시합니다."; "automatic" = "자동"; "primary_api_key_limit" = "기본 (API 키 한도)"; "menu_bar_style_title" = "메뉴 막대 스타일"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 29a68c851c..92bf3ccc35 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -592,7 +592,7 @@ "menu_bar_metric_subtitle" = "Kies welk venster het menubalkpercentage aanstuurt."; "menu_bar_metric_subtitle_deepseek" = "Toont het DeepSeek-saldo in de menubalk."; "menu_bar_metric_subtitle_moonshot" = "Toont het Moonshot / Kimi API-saldo in de menubalk."; -"menu_bar_metric_subtitle_mistral" = "Kies Mistral API-uitgaven of Monthly Plan-gebruik voor de menubalk en widgets."; +"menu_bar_metric_subtitle_mistral" = "Toont de Mistral API-uitgaven van de huidige maand in de menubalk."; "automatic" = "Automatisch"; "primary_api_key_limit" = "Primair (API-sleutellimiet)"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 8a54edd490..17a45ff52a 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -600,7 +600,7 @@ "menu_bar_metric_subtitle" = "Wybierz metrykę pokazywaną obok ikony na pasku menu."; "menu_bar_metric_subtitle_deepseek" = "Pokazuje saldo DeepSeek na pasku menu."; "menu_bar_metric_subtitle_moonshot" = "Pokazuje saldo API Moonshot / Kimi na pasku menu."; -"menu_bar_metric_subtitle_mistral" = "Wybierz wydatki Mistral API lub użycie Monthly Plan dla paska menu i widżetów."; +"menu_bar_metric_subtitle_mistral" = "Pokazuje wydatki Mistral API z bieżącego miesiąca na pasku menu."; "automatic" = "Automatycznie"; "primary_api_key_limit" = "Główny (limit klucza API)"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 770dc778ca..0ac9990785 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -589,7 +589,7 @@ "menu_bar_metric_subtitle" = "Escolha qual janela define a porcentagem da barra de menus."; "menu_bar_metric_subtitle_deepseek" = "Mostra o saldo do DeepSeek na barra de menus."; "menu_bar_metric_subtitle_moonshot" = "Mostra o saldo da API Moonshot / Kimi na barra de menus."; -"menu_bar_metric_subtitle_mistral" = "Escolha o gasto da API Mistral ou o uso do Monthly Plan para a barra de menus e os widgets."; +"menu_bar_metric_subtitle_mistral" = "Mostra o gasto da API Mistral no mês atual na barra de menus."; "automatic" = "Automático"; "primary_api_key_limit" = "Primário (limite da chave de API)"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 11e9d48314..cffbabb49f 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -593,7 +593,7 @@ "menu_bar_metric_subtitle" = "Выберите, какое окно управляет процентами строки меню."; "menu_bar_metric_subtitle_deepseek" = "Показывает баланс DeepSeek в строке меню."; "menu_bar_metric_subtitle_moonshot" = "Показывает баланс Moonshot / Kimi API в строке меню."; -"menu_bar_metric_subtitle_mistral" = "Выберите расходы Mistral API или использование ежемесячного плана для строки меню и виджетов."; +"menu_bar_metric_subtitle_mistral" = "В строке меню показаны расходы Mistral API за текущий месяц."; "automatic" = "Автоматически"; "primary_api_key_limit" = "Основной (лимит API-ключа)"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 0730a9b6a2..817607c1fb 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -591,7 +591,7 @@ "menu_bar_metric_subtitle" = "Välj vilket fönster som styr procenttalet i menyraden."; "menu_bar_metric_subtitle_deepseek" = "Visar DeepSeek-saldot i menyraden."; "menu_bar_metric_subtitle_moonshot" = "Visar saldot för Moonshot/Kimi API i menyraden."; -"menu_bar_metric_subtitle_mistral" = "Välj Mistral API-utgift eller Monthly Plan-användning för menyraden och widgetar."; +"menu_bar_metric_subtitle_mistral" = "Visar den aktuella månadens Mistral API-utgift i menyraden."; "automatic" = "Automatiskt"; "primary_api_key_limit" = "Primär (API-nyckelgräns)"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 6f4fdf8e2f..530d738858 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -598,7 +598,7 @@ "menu_bar_metric_subtitle" = "เลือกหน้าต่างที่จะขับเคลื่อนเปอร์เซ็นต์ของแถบเมนู"; "menu_bar_metric_subtitle_deepseek" = "แสดงยอดคงเหลือ DeepSeek ในแถบเมนู"; "menu_bar_metric_subtitle_moonshot" = "แสดงยอดคงเหลือ Moonshot / Kimi API ในแถบเมนู"; -"menu_bar_metric_subtitle_mistral" = "เลือกการใช้จ่าย Mistral API หรือการใช้งาน Monthly Plan สำหรับแถบเมนูและวิดเจ็ต"; +"menu_bar_metric_subtitle_mistral" = "แสดงการใช้จ่าย Mistral API เดือนปัจจุบันในแถบเมนู"; "automatic" = "อัตโนมัติ"; "primary_api_key_limit" = "หลัก (จํากัดคีย์ API)"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 52370947e1..5f05a401f9 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -598,7 +598,7 @@ "menu_bar_metric_subtitle" = "Menü çubuğu yüzdesini hangi pencerenin belirleyeceğini seçin."; "menu_bar_metric_subtitle_deepseek" = "Menü çubuğunda DeepSeek bakiyesini gösterir."; "menu_bar_metric_subtitle_moonshot" = "Menü çubuğunda Moonshot / Kimi API bakiyesini gösterir."; -"menu_bar_metric_subtitle_mistral" = "Menü çubuğu ve widget'lar için Mistral API harcamasını veya aylık plan kullanımını seçin."; +"menu_bar_metric_subtitle_mistral" = "Menü çubuğunda Mistral API'sinin geçerli ay harcamasını gösterir."; "automatic" = "Otomatik"; "primary_api_key_limit" = "Birincil (API anahtarı limiti)"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index ea38103f95..22a202310d 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -592,7 +592,7 @@ "menu_bar_metric_subtitle" = "Виберіть, яке вікно керує відсотками панелі меню."; "menu_bar_metric_subtitle_deepseek" = "Показує баланс DeepSeek на панелі меню."; "menu_bar_metric_subtitle_moonshot" = "Показує баланс API Moonshot / Kimi на панелі меню."; -"menu_bar_metric_subtitle_mistral" = "Виберіть витрати Mistral API або використання Monthly Plan для панелі меню та віджетів."; +"menu_bar_metric_subtitle_mistral" = "Показує поточні витрати Mistral API на панелі меню."; "automatic" = "Автоматичний"; "primary_api_key_limit" = "Основний (обмеження ключа API)"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index a1e9e1d1f1..e161baf8a7 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -588,7 +588,7 @@ "menu_bar_metric_subtitle" = "Chọn cửa sổ nào thúc đẩy phần trăm thanh menu."; "menu_bar_metric_subtitle_deepseek" = "Hiển thị số dư DeepSeek trong thanh menu ."; "menu_bar_metric_subtitle_moonshot" = "Hiển thị số dư Moonshot / Kimi API trong thanh menu ."; -"menu_bar_metric_subtitle_mistral" = "Chọn chi tiêu API của Mistral hoặc mức sử dụng Monthly Plan cho thanh menu và tiện ích."; +"menu_bar_metric_subtitle_mistral" = "Hiển thị mức chi tiêu API của Mistral trong tháng hiện tại trong thanh menu ."; "automatic" = "Tự động"; "primary_api_key_limit" = "Chính ( API giới hạn khóa)"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 8ab07e50d2..ebe3fb66a3 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -576,7 +576,7 @@ "menu_bar_metric_subtitle" = "选择哪个窗口驱动菜单栏百分比。"; "menu_bar_metric_subtitle_deepseek" = "在菜单栏显示 DeepSeek 余额。"; "menu_bar_metric_subtitle_moonshot" = "在菜单栏显示 Moonshot / Kimi API 余额。"; -"menu_bar_metric_subtitle_mistral" = "为菜单栏和小组件选择 Mistral API 支出或 Monthly Plan 用量。"; +"menu_bar_metric_subtitle_mistral" = "在菜单栏显示 Mistral API 本月支出。"; "automatic" = "自动"; "primary_api_key_limit" = "主要(API 密钥限制)"; "menu_bar_style_title" = "菜单栏样式"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 227018ce39..b1bd36d6dc 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -593,7 +593,7 @@ "menu_bar_metric_subtitle" = "選擇選單列百分比要依據哪個時段。"; "menu_bar_metric_subtitle_deepseek" = "在選單列顯示 DeepSeek 餘額。"; "menu_bar_metric_subtitle_moonshot" = "在選單列顯示 Moonshot / Kimi API 餘額。"; -"menu_bar_metric_subtitle_mistral" = "為選單列和小工具選擇 Mistral API 支出或月租方案用量。"; +"menu_bar_metric_subtitle_mistral" = "在選單列顯示 Mistral API 本月支出。"; "automatic" = "自動"; "primary_api_key_limit" = "主要(API 金鑰限制)"; "menu_bar_style_title" = "選單列樣式"; From d20aed7b400b73840978c1109435a07370eddfd7 Mon Sep 17 00:00:00 2001 From: Elijah Friedman Date: Sun, 27 Sep 2026 16:35:18 -0400 Subject: [PATCH 015/122] Update Kimi accent color --- .../Providers/Moonshot/MoonshotProviderDescriptor.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift index 08805fbf1c..9152a80cee 100644 --- a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift @@ -61,7 +61,9 @@ public enum MoonshotProviderDescriptor { // Provider-specific by design: Moonshot's Open Platform product deliberately uses Kimi branding. iconStyle: .init(provider: .kimi), iconResourceName: "ProviderIcon-kimi", - color: ProviderColor(red: 0 / 255, green: 0 / 255, blue: 0 / 255), + // Kimi's accent blue, not Moonshot's black brand ink: black is + // indistinguishable from the unfilled track in a dark-mode usage gauge. + color: ProviderColor(red: 0 / 255, green: 124 / 255, blue: 255 / 255), confettiPalette: [ ProviderColor(hex: 0x000000), ProviderColor(hex: 0x305140), From 0a781410a8fd0ee96e46d45a21351d0fcc535a9b Mon Sep 17 00:00:00 2001 From: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:37:19 +0200 Subject: [PATCH 016/122] fix(mistral): keep metric-only picker choices valid without a percentage --- .../CodexBar/MenuBarPercentWindowPreference.swift | 12 +++++++++--- .../ProviderMenuBarPercentWindowSettingsView.swift | 3 ++- .../MistralMonthlyPlanPickerTests.swift | 13 +++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/MenuBarPercentWindowPreference.swift b/Sources/CodexBar/MenuBarPercentWindowPreference.swift index 8ee8d67c5c..ebf202fbec 100644 --- a/Sources/CodexBar/MenuBarPercentWindowPreference.swift +++ b/Sources/CodexBar/MenuBarPercentWindowPreference.swift @@ -90,6 +90,10 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl if let layout, !self.percentWindows(in: layout).isEmpty, self.hasTertiaryPercent(in: layout) { return options.filter { $0 != .tertiary } } + // Without a percentage in the layout, only the stored metric can change. + if let layout, options.contains(.monthlyPlan), !self.hasPercentToken(in: layout) { + return [.automatic, .monthlyPlan] + } return options } @@ -118,12 +122,14 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl /// Ordinary percentages own the choice when a custom layout also has an independent tertiary /// token. Only layouts without ordinary percentages treat tertiary tokens as the controlled group. - /// A Monthly Plan metric turns an all-automatic layout, or one without percentages, into the Monthly Plan choice. - static func current(in layout: MenuBarLayout, metric: MenuBarMetricPreference = .automatic) -> Self? { + /// Pass the stored metric for providers that offer Monthly Plan: it turns an all-automatic layout into the + /// Monthly Plan choice, and alone decides the choice when the layout has no percentage. + static func current(in layout: MenuBarLayout, metric: MenuBarMetricPreference? = nil) -> Self? { let windows = Self.percentWindows(in: layout) guard let first = windows.first else { if self.hasTertiaryPercent(in: layout) { return .tertiary } - return metric == .monthlyPlan ? .monthlyPlan : nil + guard let metric else { return nil } + return metric == .monthlyPlan ? .monthlyPlan : .automatic } guard windows.allSatisfy({ $0 == first }) else { return nil } if first == .automatic, metric == .monthlyPlan { return .monthlyPlan } diff --git a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift index 6ba94b09d8..68b931412e 100644 --- a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift +++ b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift @@ -73,7 +73,8 @@ struct ProviderMenuBarPercentWindowPicker: View { get: { let layout = self.layout let available = MenuBarPercentWindowPreference.available(for: self.provider, layout: layout) - return MenuBarPercentWindowPreference.current(in: layout, metric: self.metric.wrappedValue) + let metric = available.contains(.monthlyPlan) ? self.metric.wrappedValue : nil + return MenuBarPercentWindowPreference.current(in: layout, metric: metric) .flatMap { available.contains($0) ? $0 : nil } }, set: { preference in diff --git a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift index e812183dcb..e2ce1a8578 100644 --- a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift +++ b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift @@ -58,9 +58,22 @@ struct MistralMonthlyPlanPickerTests { layout: settings.menuBarLayout(for: .codex), provider: .codex)) + // Without a percentage, only the metric-backed choices apply. + #expect(MenuBarPercentWindowPreference.available( + for: .mistral, + layout: settings.menuBarLayout(for: .mistral)) == [.automatic, .monthlyPlan]) + #expect(picker.selectionBinding.wrappedValue == .automatic) + picker.selectionBinding.wrappedValue = .monthlyPlan #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) #expect(picker.selectionBinding.wrappedValue == .monthlyPlan) + + picker.selectionBinding.wrappedValue = .session + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + + picker.selectionBinding.wrappedValue = .automatic + #expect(settings.menuBarMetricPreference(for: .mistral) == .automatic) + #expect(picker.selectionBinding.wrappedValue == .automatic) #expect(settings.menuBarLayoutOverrides[.mistral] == nil) } } From 38d47ef8cccb8339c1aebb522369119ff65c459b Mon Sep 17 00:00:00 2001 From: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:42:46 +0200 Subject: [PATCH 017/122] fix(mistral): price billing usage by event type, zone, and tier --- CHANGELOG.md | 1 + .../Providers/Mistral/MistralModels.swift | 8 ++++ .../Mistral/MistralUsageFetcher.swift | 44 +++++++++++++++---- .../MistralUsageParserTests.swift | 43 ++++++++++++++++++ docs/mistral.md | 4 +- 5 files changed, 91 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb04cdaa9..51fd1cd249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Mistral: price billing usage by event type, API zone, and service tier, so a per-second audio or priority price no longer inflates API spend and 30-day token cost. - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index 314f7548f3..eb339ec433 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -66,6 +66,8 @@ struct MistralUsageEntry: Codable { let billingMetric: String? let billingDisplayName: String? let billingGroup: String? + let apiZone: String? + let serviceTier: String? let timestamp: String? let value: Int? let valuePaid: Int? @@ -77,6 +79,8 @@ struct MistralUsageEntry: Codable { case billingMetric = "billing_metric" case billingDisplayName = "billing_display_name" case billingGroup = "billing_group" + case apiZone = "api_zone" + case serviceTier = "service_tier" case valuePaid = "value_paid" } } @@ -85,6 +89,8 @@ struct MistralPrice: Codable { let eventType: String? let billingMetric: String? let billingGroup: String? + let apiZone: String? + let serviceTier: String? let price: String? enum CodingKeys: String, CodingKey { @@ -92,6 +98,8 @@ struct MistralPrice: Codable { case eventType = "event_type" case billingMetric = "billing_metric" case billingGroup = "billing_group" + case apiZone = "api_zone" + case serviceTier = "service_tier" } } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift index 039f5ca430..6b6d13895b 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -349,8 +349,19 @@ public enum MistralUsageFetcher { // MARK: - Private Helpers - private static func buildPriceIndex(_ prices: [MistralPrice]) -> [String: Double] { - var index: [String: Double] = [:] + /// Mistral lists one price per event type, metric, group, API zone, and service tier. The same metric and + /// group can carry a far higher per-second audio price or a priority-tier price, so every dimension is part of + /// the key; a price table without zone or tier still matches entries through the zone-less key. + fileprivate struct PriceKey: Hashable { + let eventType: String? + let metric: String + let group: String + let apiZone: String? + let serviceTier: String? + } + + private static func buildPriceIndex(_ prices: [MistralPrice]) -> [PriceKey: Double] { + var index: [PriceKey: Double] = [:] for price in prices { guard let metric = price.billingMetric, let group = price.billingGroup, @@ -358,7 +369,12 @@ public enum MistralUsageFetcher { let value = Double(priceStr), value.isFinite else { continue } - let key = "\(metric)::\(group)" + let key = PriceKey( + eventType: price.eventType, + metric: metric, + group: group, + apiZone: price.apiZone, + serviceTier: price.serviceTier) index[key] = value } return index @@ -366,7 +382,7 @@ public enum MistralUsageFetcher { private static func aggregateModel( _ data: MistralModelUsageData, - prices: [String: Double], + prices: [PriceKey: Double], countsTokens: Bool) throws -> (tokens: TokenCounts, cost: Double) { var tokens = TokenCounts() @@ -387,7 +403,7 @@ public enum MistralUsageFetcher { private static func addDailyEntries( modelName: String, data: MistralModelUsageData, - prices: [String: Double], + prices: [PriceKey: Double], daily: inout [String: DailyAccumulator], countsTokens: Bool) throws { @@ -432,9 +448,21 @@ public enum MistralUsageFetcher { } } - private static func cost(for entry: MistralUsageEntry, units: Int, prices: [String: Double]) -> Double { + private static func cost(for entry: MistralUsageEntry, units: Int, prices: [PriceKey: Double]) -> Double { guard let metric = entry.billingMetric, let group = entry.billingGroup else { return 0 } - let cost = Double(units) * (prices["\(metric)::\(group)"] ?? 0) + let key = PriceKey( + eventType: entry.eventType, + metric: metric, + group: group, + apiZone: entry.apiZone, + serviceTier: entry.serviceTier) + let zonelessKey = PriceKey( + eventType: entry.eventType, + metric: metric, + group: group, + apiZone: nil, + serviceTier: nil) + let cost = Double(units) * (prices[key] ?? prices[zonelessKey] ?? 0) return cost.isFinite ? cost : 0 } @@ -468,7 +496,7 @@ public enum MistralUsageFetcher { private struct DailyEntryContext { let kind: MistralUsageFetcher.TokenKind let modelName: String - let prices: [String: Double] + let prices: [MistralUsageFetcher.PriceKey: Double] let countsTokens: Bool } diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index 1ea4478260..89b54c17dd 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -47,6 +47,49 @@ struct MistralUsageParserTests { #expect(snapshot.totalCost > 0) } + @Test + func `prices entries by event type zone and tier instead of the last matching metric`() throws { + // Trimmed from a real September 2026 response: the price table lists mistral-medium-3-5 input once per + // zone and tier and then again as a per-second audio price, which is 100x the token price. + let entry = { (group: String, value: Int) in + """ + {"usage_type":"vibe","event_type":"api_tokens","billing_metric":"mistral-medium-3-5",\ + "billing_display_name":"mistral-vibe-cli-latest","billing_group":"\(group)","timestamp":"2026-09-16",\ + "value":\(value),"value_paid":\(value),"api_zone":"global","service_tier":"standard"} + """ + } + let price = { (event: String, group: String, zone: String, tier: String, price: String) in + """ + {"event_type":"\(event)","billing_metric":"mistral-medium-3-5","billing_group":"\(group)",\ + "api_zone":"\(zone)","service_tier":"\(tier)","price":"\(price)"} + """ + } + let prices = [ + price("api_tokens", "input", "global", "standard", "0.0000012750"), + price("api_tokens", "input", "eu", "priority", "0.0000023588"), + price("api_audio_seconds", "input", "global", "standard", "0.0001416667"), + price("api_tokens", "cached", "global", "standard", "1.275E-7"), + price("api_tokens", "cached", "eu", "priority", "2.359E-7"), + price("api_tokens", "output", "global", "standard", "0.0000063750"), + price("api_tokens", "output", "eu", "priority", "0.0000117938"), + ] + let json = """ + {"vibe_code":{"completion":{"models":{"mistral-vibe-cli-latest::mistral-medium-3-5":{\ + "input":[\(entry("input", 4_375_190))],"cached":[\(entry("cached", 21_628_160))],\ + "output":[\(entry("output", 458_774))]}}}},\ + "start_date":"2026-09-01T00:00:00Z","end_date":"2026-09-30T23:59:59Z","currency":"EUR",\ + "prices":[\(prices.joined(separator: ","))]} + """ + let updatedAt = try #require(ISO8601DateParser.parse("2026-09-27T12:00:00Z")) + + let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: updatedAt) + + let expected = 4_375_190 * 0.000001275 + 21_628_160 * 1.275e-7 + 458_774 * 0.000006375 + #expect(abs(snapshot.totalCost - expected) < 1e-9) + let history = snapshot.toCostUsageTokenSnapshot(historyDays: 30) + #expect(abs((history.last30DaysCostUSD ?? 0) - expected) < 1e-9) + } + @Test(arguments: ["NaN", "Infinity", "1e308"]) func `ignores prices that produce nonfinite costs`(price: String) async throws { let json = """ diff --git a/docs/mistral.md b/docs/mistral.md index 4b6ddb535d..ddfa846777 100644 --- a/docs/mistral.md +++ b/docs/mistral.md @@ -50,7 +50,9 @@ For the console request, CodexBar forwards only the `csrftoken` and `ory_session - **Included API** shows the subscription allowance's used percentage, used / total / remaining amount, and reset time. - The optional **Monthly Plan** window shows the separate Vibe Code allowance with the same details. -- API spend is computed from billed units (`value_paid`, falling back to `value`) and the pricing table. Token totals +- API spend is computed from billed units (`value_paid`, falling back to `value`) and the pricing table. Each unit takes + the price with the same event type, metric, group, API zone, and service tier; the table lists one metric under + several of these, and audio-second and priority prices are far higher than standard token prices. Token totals and daily buckets use consumed units (`value`, falling back to `value_paid`), so plan-covered usage still counts. - Token totals include API completions, Le Chat, and Vibe Code completions from the billing usage response. - Daily usage buckets feed the inline usage dashboard. From 4b148e0602173372bf6c81b1cdd257ac8309825d Mon Sep 17 00:00:00 2001 From: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:17:26 +0200 Subject: [PATCH 018/122] test: scope the icon-style picker rule to percentage choices --- Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift b/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift index 842c704595..9fe8201f0a 100644 --- a/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift +++ b/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift @@ -246,7 +246,8 @@ struct MenuBarPercentWindowPreferenceTests { @Test func `picker stays hidden unless the global style is icon and percent`() { let layout = MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) - let options = MenuBarPercentWindowPreference.allCases + // Monthly Plan keeps the picker visible in every style; MistralMonthlyPlanPickerTests covers it. + let options = MenuBarPercentWindowPreference.allCases.filter { $0 != .monthlyPlan } #expect(MenuBarPercentWindowPreference.isVisible( iconStyle: .iconAndPercent, From 6fd10ece5cb8e11845fe0c45048959ba13d8087b Mon Sep 17 00:00:00 2001 From: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:44:26 +0200 Subject: [PATCH 019/122] docs(changelog): link Mistral pricing PR --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51fd1cd249..25e691a32b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- Mistral: price billing usage by event type, API zone, and service tier, so a per-second audio or priority price no longer inflates API spend and 30-day token cost. +- Mistral: price billing usage by event type, API zone, and service tier, so a per-second audio or priority price no longer inflates API spend and 30-day token cost (#4076). Thanks @T0mSIlver! - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! From 5becbf61e28bca75faa28ca3f824e9fec5ceaecc Mon Sep 17 00:00:00 2001 From: Sogl Date: Mon, 28 Sep 2026 00:47:28 +0300 Subject: [PATCH 020/122] Drop unverified quota fallback A denied retrieveUserQuota response must not surface the unverified all-100% availability payload as measured quota; restore the upstream empty-result contract from #1509. --- .../AntigravityRemoteUsageFetcher.swift | 5 +- .../AntigravityRemoteUsageFetcherTests.swift | 72 ------------------- 2 files changed, 1 insertion(+), 76 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift index 43ee0bd7d1..1e8272323b 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift @@ -216,10 +216,7 @@ public struct AntigravityRemoteUsageFetcher: Sendable { projectId: projectId, timeout: timeout, dataLoader: dataLoader) - guard let quotaBuckets else { - return modelQuotas - } - guard Self.hasQuotaFractionData(quotaBuckets) else { + guard let quotaBuckets, Self.hasQuotaFractionData(quotaBuckets) else { return [] } return Self.mergeVerifiedQuotas(modelQuotas: modelQuotas, verifiedQuotas: quotaBuckets) diff --git a/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift b/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift index 9d876dc075..82b1557bba 100644 --- a/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift @@ -519,78 +519,6 @@ struct AntigravityRemoteUsageFetcherTests { #expect(snapshot.accountEmail == "user@example.com") } - @Test - func `remote fetch keeps model quotas when quota verification is forbidden`() async throws { - let env = try GeminiTestEnvironment() - defer { env.cleanup() } - try env.writeAntigravityCredentials( - accessToken: "token", - refreshToken: nil, - expiry: Date().addingTimeInterval(3600), - idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), - email: "user@example.com") - - let dataLoader = GeminiAPITestHelpers.dataLoader { request in - guard let url = request.url, let host = url.host else { - throw URLError(.badURL) - } - - switch host { - case "cloudcode-pa.googleapis.com": - if url.path == "/v1internal:loadCodeAssist" { - return GeminiAPITestHelpers.response( - url: url.absoluteString, - status: 200, - body: GeminiAPITestHelpers.loadCodeAssistResponse( - tierId: "standard-tier", - projectId: "managed-project-123")) - } - if url.path == "/v1internal:fetchAvailableModels" { - return GeminiAPITestHelpers.response( - url: url.absoluteString, - status: 200, - body: GeminiAPITestHelpers.jsonData([ - "models": [ - "claude-sonnet-4": [ - "displayName": "Claude Sonnet 4", - "quotaInfo": ["remainingFraction": 1], - ], - "gemini-2.5-pro": [ - "displayName": "Gemini 2.5 Pro", - "quotaInfo": ["remainingFraction": 1], - ], - ], - ])) - } - if url.path == "/v1internal:retrieveUserQuota" { - return GeminiAPITestHelpers.response( - url: url.absoluteString, - status: 403, - body: GeminiAPITestHelpers.jsonData([ - "error": [ - "code": 403, - "message": "You do not have a valid license of this product", - "status": "PERMISSION_DENIED", - ], - ])) - } - return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) - default: - return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) - } - } - - let snapshot = try await AntigravityRemoteUsageFetcher( - timeout: 1, - homeDirectory: env.homeURL.path, - dataLoader: dataLoader) - .fetch() - - #expect(snapshot.modelQuotas.map(\.modelId).sorted() == ["claude-sonnet-4", "gemini-2.5-pro"]) - #expect(snapshot.modelQuotas.map(\.remainingFraction) == [1, 1]) - #expect(snapshot.accountEmail == "user@example.com") - } - @Test func `remote fetch propagates quota verification server errors`() async throws { let env = try GeminiTestEnvironment() From 7c4018f595d5353a6f13d4012e7e55e38ea83c52 Mon Sep 17 00:00:00 2001 From: Sogl Date: Mon, 28 Sep 2026 00:47:28 +0300 Subject: [PATCH 021/122] Verify scoped agy identity via effective access token agy logs the staged id_token claim as its authenticated identity, so the report identity check cannot rely on it. After the child exits, resolve the post-run access token through Google userinfo and reject the report unless that account matches the selected one. --- .../AntigravityProviderDescriptor.swift | 2 + .../AntigravityScopedPrintFetch.swift | 76 ++++++++++++++++-- .../AntigravityScopedPrintFetchTests.swift | 79 ++++++++++++++++++- docs/antigravity.md | 5 +- 4 files changed, 154 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index 2ab4d84e23..b4cbfa7170 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -583,6 +583,8 @@ struct AntigravityCLIHTTPSFetchStrategy: ProviderFetchStrategy { return try await scopedReportFetch() } catch let scopedError { if scopedError is CancellationError { throw scopedError } + Self.log.info( + "Scoped agy usage fetch failed; preserving ambient error (reason: \(type(of: scopedError)))") throw error } } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift index 70c70eb34a..ed6a671b95 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift @@ -182,24 +182,70 @@ enum AntigravityScopedAgyStaging { } return trimmed.lowercased() } + + /// The account whose credential `agy` actually used during a run. After the + /// child exits, its staged token file holds the access token that made the + /// API calls — refreshed in place when the staged grant was expired — so + /// resolving that token through Google's `userinfo` endpoint binds the + /// result to the effective credential, not to the `id_token` claim (which + /// could disagree with the access/refresh tokens in a corrupted file). + static func runEffectiveAccountEmail( + home: URL, + timeout: TimeInterval, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse), + fileManager: FileManager = .default) async -> String? + { + var tokenURL = home + for component in Self.tokenRelativePath { + tokenURL.appendPathComponent(component, isDirectory: false) + } + guard let data = fileManager.contents(atPath: tokenURL.path), + let payload = AntigravityAgyFileTokenEncoder.decode(data: data) + else { + return nil + } + let accessToken = payload.token.accessToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !accessToken.isEmpty, + let url = URL(string: "https://www.googleapis.com/oauth2/v3/userinfo") + else { + return nil + } + var request = URLRequest(url: url) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = min(timeout, 15) + guard let (responseData, response) = try? await dataLoader(request), + let http = response as? HTTPURLResponse, http.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] + else { + return nil + } + return self.normalizedEmail(json["email"] as? String) + } } // MARK: - Scoped print fetch #if os(macOS) extension AntigravityCLIHTTPSFetchStrategy { + private static let scopedPrintLog = CodexBarLog.logger(LogCategories.provider(.antigravity)) + /// Runs `agy -p /usage` scoped to the injected token account's credentials: /// the account's OAuth tokens are staged into a private per-run `HOME`, the /// child receives an allowlist environment, and the staged token's `id_token` - /// claim is verified against the selected account before launch, so the - /// identity-free report can be attributed to that account. Fails closed: - /// any error propagates so the pipeline falls through to the account-scoped - /// OAuth strategy; ambient reports are never substituted for a selected - /// account. + /// claim is verified against the selected account before launch. Because the + /// CLI authenticates with the staged access/refresh tokens — which could + /// disagree with the `id_token` claim — the access token `agy` actually used + /// is resolved through Google's `userinfo` endpoint after the run and must + /// match the selected account before the report is labeled with it. Fails + /// closed: any error propagates so the pipeline falls through to the + /// account-scoped OAuth strategy; ambient reports are never substituted for + /// a selected account. func fetchScopedPrintUsage( binary: String, environment: [String: String], - timeout: TimeInterval = 90) async throws -> ProviderFetchResult + timeout: TimeInterval = 90, + dataLoader: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil) async throws + -> ProviderFetchResult { guard let value = environment[AntigravityOAuthCredentialsStore.environmentCredentialsKey], let credentials = AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: value), @@ -240,9 +286,27 @@ extension AntigravityCLIHTTPSFetchStrategy { if let reportedEmail = AntigravityScopedAgyStaging.normalizedEmail(parsed.accountEmail), reportedEmail != AntigravityScopedAgyStaging.normalizedEmail(expectedAccountEmail) { + Self.scopedPrintLog.info( + "Scoped agy usage report rejected: report identity does not match the selected account") throw AntigravityStatusProbeError.accountMismatch( expected: expectedAccountEmail, found: parsed.accountEmail) } + let loader = dataLoader ?? { request in try await URLSession.shared.data(for: request) } + let effectiveEmail = await AntigravityScopedAgyStaging.runEffectiveAccountEmail( + home: staged.home, + timeout: timeout, + dataLoader: loader) + guard let effectiveEmail else { + Self.scopedPrintLog.info( + "Scoped agy usage report rejected: CLI effective account could not be verified") + throw AntigravityScopedStagingError.identityUnverifiable + } + guard effectiveEmail == AntigravityScopedAgyStaging.normalizedEmail(expectedAccountEmail) else { + Self.scopedPrintLog.info( + "Scoped agy usage report rejected: CLI effective account does not match the selected account") + throw AntigravityStatusProbeError.accountMismatch( + expected: expectedAccountEmail, found: effectiveEmail) + } let snapshot = parsed.withIdentity(from: AntigravityStatusSnapshot( modelQuotas: [], accountEmail: expectedAccountEmail, accountPlan: nil, source: parsed.source)) return try self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: Self.sourceLabel) diff --git a/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift b/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift index 35671528c9..c3ce79f9e3 100644 --- a/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift +++ b/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift @@ -215,13 +215,67 @@ struct AntigravityScopedPrintFetchTests { let preexisting = Set(self.scopedStagingDirectories()) let result = try await AntigravityCLIHTTPSFetchStrategy().fetchScopedPrintUsage( - binary: fixture.binary.path, environment: environment) + binary: fixture.binary.path, + environment: environment, + dataLoader: self.userinfoLoader(mapping: ["scoped-access-token": "scoped@example.com"])) #expect(result.usage.identity?.accountEmail == "scoped@example.com") #expect(abs((result.usage.primary?.usedPercent ?? -1) - 40) < 0.001) #expect(Set(self.scopedStagingDirectories()) == preexisting) } + @Test + func `scoped print rejects a report when the effective token belongs to another account`() async throws { + let report = try self.reportJSON() + // Simulate agy refreshing the staged grant into a different account's + // token: the id_token still claims the selected account, but the access + // token that made the API calls resolves to a donor account. + let fixture = try self.scopedPrintFixture(body: """ + /usr/bin/sed -i '' 's/scoped-access-token/donor-access-token/' \ + "$HOME/.gemini/antigravity-cli/antigravity-oauth-token" + /bin/cat <<'REPORT' + \(report) + REPORT + """) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + var environment = self.accountEnv(email: "scoped@example.com") + environment.merge(fixture.environment) { _, new in new } + + await #expect(throws: AntigravityStatusProbeError.accountMismatch( + expected: "scoped@example.com", found: "donor@example.com")) + { + try await AntigravityCLIHTTPSFetchStrategy().fetchScopedPrintUsage( + binary: fixture.binary.path, + environment: environment, + dataLoader: self.userinfoLoader(mapping: [ + "scoped-access-token": "scoped@example.com", + "donor-access-token": "donor@example.com", + ])) + } + } + + @Test + func `scoped print rejects a report when the effective account cannot be verified`() async throws { + let report = try self.reportJSON() + let fixture = try self.scopedPrintFixture(body: """ + /bin/cat <<'REPORT' + \(report) + REPORT + """) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + var environment = self.accountEnv(email: "scoped@example.com") + environment.merge(fixture.environment) { _, new in new } + + await #expect(throws: AntigravityScopedStagingError.identityUnverifiable) { + try await AntigravityCLIHTTPSFetchStrategy().fetchScopedPrintUsage( + binary: fixture.binary.path, + environment: environment, + dataLoader: self.userinfoLoader(mapping: [:])) + } + } + @Test func `scoped print refuses undecodable injected credentials without spawning`() async throws { let fixture = try self.scopedPrintFixture(body: "echo invoked > \"$(dirname \"$0\")/invoked\"; exit 19") @@ -357,6 +411,29 @@ struct AntigravityScopedPrintFetchTests { } #endif + #if os(macOS) + private func userinfoLoader( + mapping: [String: String]) -> @Sendable (URLRequest) async throws -> (Data, URLResponse) + { + { request in + let token = request.value(forHTTPHeaderField: "Authorization")? + .replacingOccurrences(of: "Bearer ", with: "") + if let token, let email = mapping[token], + let url = request.url + { + let body = try JSONSerialization.data(withJSONObject: ["email": email]) + let response = HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (body, response) + } + let url = request.url ?? URL(fileURLWithPath: "/") + let response = HTTPURLResponse( + url: url, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + } + #endif + private struct StubClaudeFetcher: ClaudeUsageFetching { func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { throw ClaudeUsageError.parseFailed("stub") diff --git a/docs/antigravity.md b/docs/antigravity.md index 10222166a9..dfce5aae10 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -52,7 +52,10 @@ directory, the staged `id_token` claim is re-read from disk and verified against before launch, and the child process receives an allowlist environment (login `PATH`, locale, proxy variables) without `ANTIGRAVITY_OAUTH_CREDENTIALS_JSON` or any ambient provider credentials. A non-empty `SSH_TTY` forces `agy` onto file-token storage so the scoped run never touches the OS keyring. The -staging directory is deleted after the run, a report carrying conflicting identity is rejected, and any +staging directory is deleted after the run, and the report is only attributed to the account after +the access token `agy` actually used — refreshed in place inside the staged file when expired — is +resolved through Google's `userinfo` endpoint and its email matches it, so an identity-free report +can never carry a label its credentials did not prove. Any scoped failure preserves the original ambient error — an ambient report is never substituted for a selected account, so the pipeline falls through to the account-scoped OAuth fetch exactly as before. From 3d0979a17ed485289c7d39ef5fb3a069e24a2f82 Mon Sep 17 00:00:00 2001 From: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:58:10 +0200 Subject: [PATCH 022/122] fix(mistral): let layout editor percentages override a stored Monthly Plan --- .../SettingsStore+MenuPreferences.swift | 10 ++++++++++ .../MistralMonthlyPlanPickerTests.swift | 17 +++++++++++++++++ docs/mistral.md | 2 ++ 3 files changed, 29 insertions(+) diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index fcbd01187f..e75bdf977e 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -303,6 +303,16 @@ extension SettingsStore { { return .automatic } + // A layout whose percentages all read another window (set in the layout editor after Monthly Plan was + // chosen) wins, so widgets follow what the menu bar and its picker show. + if preference == .monthlyPlan, + let choice = MenuBarPercentWindowPreference.current( + in: self.menuBarLayout(for: provider), + metric: preference), + choice != .monthlyPlan + { + return .automatic + } return preference } diff --git a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift index e2ce1a8578..2422b945fb 100644 --- a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift +++ b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift @@ -76,4 +76,21 @@ struct MistralMonthlyPlanPickerTests { #expect(picker.selectionBinding.wrappedValue == .automatic) #expect(settings.menuBarLayoutOverrides[.mistral] == nil) } + + @Test + func `a layout editor percentage overrides a stored Monthly Plan`() { + let settings = testSettingsStore( + suiteName: "MistralMonthlyPlanPickerTests-layout-editor", + userDefaults: InMemoryUserDefaults()) + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) + + settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]), for: .mistral) + #expect(settings.menuBarMetricPreference(for: .mistral, snapshot: nil) == .monthlyPlan) + + settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon, .percent(window: .session)]]), for: .mistral) + #expect(settings.menuBarMetricPreference(for: .mistral, snapshot: nil) == .automatic) + + settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon]]), for: .mistral) + #expect(settings.menuBarMetricPreference(for: .mistral, snapshot: nil) == .monthlyPlan) + } } diff --git a/docs/mistral.md b/docs/mistral.md index 22353ef16b..73cab8545c 100644 --- a/docs/mistral.md +++ b/docs/mistral.md @@ -70,6 +70,8 @@ style, so Critters and Meter bars users can still pick the widget allowance: - **Automatic** and **Included API** show only the API allowance, preserving the existing default. - **Monthly Plan** shows only the Vibe allowance, falling back to Included API when the plan is missing or unknown. +- If the menu bar layout editor later sets every Mistral percentage to Included API, widgets follow it and show Included + API; returning the percentage to Auto restores Monthly Plan. Automatic still shows API spend in the menu bar. Changing the metric updates widget rows from the existing snapshot, without another request. After a failed refresh, widgets reselect from the last published usage snapshot and keep its From 7555d399414d54e1d5d25a2f857bae4efd1250f0 Mon Sep 17 00:00:00 2001 From: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:14:07 +0200 Subject: [PATCH 023/122] Revert "fix(mistral): let layout editor percentages override a stored Monthly Plan" This reverts commit 3d0979a17ed485289c7d39ef5fb3a069e24a2f82. --- .../SettingsStore+MenuPreferences.swift | 10 ---------- .../MistralMonthlyPlanPickerTests.swift | 17 ----------------- docs/mistral.md | 2 -- 3 files changed, 29 deletions(-) diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index e75bdf977e..fcbd01187f 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -303,16 +303,6 @@ extension SettingsStore { { return .automatic } - // A layout whose percentages all read another window (set in the layout editor after Monthly Plan was - // chosen) wins, so widgets follow what the menu bar and its picker show. - if preference == .monthlyPlan, - let choice = MenuBarPercentWindowPreference.current( - in: self.menuBarLayout(for: provider), - metric: preference), - choice != .monthlyPlan - { - return .automatic - } return preference } diff --git a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift index 2422b945fb..e2ce1a8578 100644 --- a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift +++ b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift @@ -76,21 +76,4 @@ struct MistralMonthlyPlanPickerTests { #expect(picker.selectionBinding.wrappedValue == .automatic) #expect(settings.menuBarLayoutOverrides[.mistral] == nil) } - - @Test - func `a layout editor percentage overrides a stored Monthly Plan`() { - let settings = testSettingsStore( - suiteName: "MistralMonthlyPlanPickerTests-layout-editor", - userDefaults: InMemoryUserDefaults()) - settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) - - settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]), for: .mistral) - #expect(settings.menuBarMetricPreference(for: .mistral, snapshot: nil) == .monthlyPlan) - - settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon, .percent(window: .session)]]), for: .mistral) - #expect(settings.menuBarMetricPreference(for: .mistral, snapshot: nil) == .automatic) - - settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon]]), for: .mistral) - #expect(settings.menuBarMetricPreference(for: .mistral, snapshot: nil) == .monthlyPlan) - } } diff --git a/docs/mistral.md b/docs/mistral.md index 73cab8545c..22353ef16b 100644 --- a/docs/mistral.md +++ b/docs/mistral.md @@ -70,8 +70,6 @@ style, so Critters and Meter bars users can still pick the widget allowance: - **Automatic** and **Included API** show only the API allowance, preserving the existing default. - **Monthly Plan** shows only the Vibe allowance, falling back to Included API when the plan is missing or unknown. -- If the menu bar layout editor later sets every Mistral percentage to Included API, widgets follow it and show Included - API; returning the percentage to Auto restores Monthly Plan. Automatic still shows API spend in the menu bar. Changing the metric updates widget rows from the existing snapshot, without another request. After a failed refresh, widgets reselect from the last published usage snapshot and keep its From 231bbef9c1a070508ea57398a2ea2c5e562f0e7d Mon Sep 17 00:00:00 2001 From: Brandon Charleson Date: Sun, 27 Sep 2026 16:47:18 -0600 Subject: [PATCH 024/122] Antigravity: reap MCP servers left behind by the usage probe agy -p /usage starts the user's MCP servers in a private temp directory and then exits. Servers that call setsid are reparented to launchd and keep a core busy after every refresh. Record descendants while the probe is alive, and kill anything still using that directory once it finishes. --- CHANGELOG.md | 1 + .../ProcessWorkingDirectoryReaper.swift | 89 +++++++++++++++ .../Host/Process/SubprocessRunner.swift | 50 +++++++++ .../AntigravityProviderDescriptor.swift | 17 ++- .../CodexBarTests/SubprocessRunnerTests.swift | 101 ++++++++++++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 Sources/CodexBarCore/Host/Process/ProcessWorkingDirectoryReaper.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 87533a7201..900e38d6d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Antigravity: stop MCP servers that `agy -p /usage` leaves running after the quota probe exits, including servers that detach from the process group. They were reparented to launchd and each held a core until killed by hand. - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBarCore/Host/Process/ProcessWorkingDirectoryReaper.swift b/Sources/CodexBarCore/Host/Process/ProcessWorkingDirectoryReaper.swift new file mode 100644 index 0000000000..90f03e1fb2 --- /dev/null +++ b/Sources/CodexBarCore/Host/Process/ProcessWorkingDirectoryReaper.swift @@ -0,0 +1,89 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif +import Foundation + +/// Stops processes that a finished CLI left behind in a private working directory. +/// +/// `agy -p /usage` starts the user's MCP servers with that directory as their cwd, then +/// exits. Servers that call `setsid` are no longer children and are not in the CLI's +/// process group, so they survive and keep a core busy. Matching the directory still finds them. +enum ProcessWorkingDirectoryReaper { + static func terminateProcesses(in directory: URL) { + let target = Self.standardizedPath(directory.path) + guard !target.isEmpty, target != "/" else { return } + let matches = self.processIDs(inCurrentDirectory: target) + guard !matches.isEmpty else { return } + for pid in matches { + kill(pid, SIGTERM) + } + let deadline = Date().addingTimeInterval(0.4) + while Date() < deadline { + if matches.allSatisfy({ kill($0, 0) != 0 }) { return } + usleep(50_000) + } + for pid in matches where kill(pid, 0) == 0 { + kill(pid, SIGKILL) + } + } + + static func processIDs(inCurrentDirectory directory: String) -> [pid_t] { + let target = Self.standardizedPath(directory) + guard !target.isEmpty else { return [] } + let selfPID = pid_t(getpid()) + return self.allProcessIDs().filter { pid in + pid > 0 && pid != selfPID && self.currentDirectory(of: pid) == target + } + } + + private static func standardizedPath(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().path + } + + private static func allProcessIDs() -> [pid_t] { + #if canImport(Darwin) + let bufferCount = proc_listallpids(nil, 0) + guard bufferCount > 0 else { return [] } + var pids = [pid_t](repeating: 0, count: Int(bufferCount) + 32) + let byteCount = Int32(pids.count * MemoryLayout.stride) + let written = proc_listallpids(&pids, byteCount) + guard written > 0 else { return [] } + return Array(pids.prefix(Int(written))) + #else + guard let names = try? FileManager.default.contentsOfDirectory(atPath: "/proc") else { return [] } + return names.compactMap { pid_t($0) } + #endif + } + + private static func currentDirectory(of pid: pid_t) -> String? { + #if canImport(Darwin) + var info = proc_vnodepathinfo() + let size = proc_pidinfo( + pid, + PROC_PIDVNODEPATHINFO, + 0, + &info, + Int32(MemoryLayout.stride)) + guard size == Int32(MemoryLayout.stride) else { return nil } + var pathBuffer = info.pvi_cdir.vip_path + let pathCapacity = MemoryLayout.size(ofValue: pathBuffer) + let path = withUnsafePointer(to: &pathBuffer) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: pathCapacity) { + String(cString: $0) + } + } + guard !path.isEmpty else { return nil } + return Self.standardizedPath(path) + #else + let link = "/proc/\(pid)/cwd" + guard let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: link), + !destination.isEmpty + else { return nil } + return Self.standardizedPath(destination) + #endif + } +} diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index 26f3444240..ccfb4306bc 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -153,6 +153,9 @@ public enum SubprocessRunner { standardInput: Any? = nil, currentDirectoryURL: URL? = nil, acceptsNonZeroExit: Bool = false, + /// When set, descendants recorded while the process was alive are signaled after it + /// exits. This includes children that called `setsid` and were reparented to launchd. + reapDescendants: Bool = false, label: String) async throws -> SubprocessResult { guard FileManager.default.isExecutableFile(atPath: binary) else { @@ -203,6 +206,15 @@ public enum SubprocessRunner { let pid = process.processIdentifier let processGroup: pid_t? = setpgid(pid, pid) == 0 ? pid : nil + let descendantTracker: DescendantTracker? = reapDescendants ? DescendantTracker() : nil + descendantTracker?.record(rootPID: pid) + let descendantPoll = Task.detached { + guard let descendantTracker else { return } + while !Task.isCancelled { + descendantTracker.record(rootPID: pid) + try? await Task.sleep(nanoseconds: 20_000_000) + } + } let exitCodeTask = Task { await termination.wait() @@ -288,6 +300,9 @@ public enum SubprocessRunner { "status": "\(exitCode)", "duration_ms": "\(Int(duration * 1000))", ]) + descendantPoll.cancel() + descendantTracker?.record(rootPID: pid) + descendantTracker?.reap() return SubprocessResult(stdout: stdout, stderr: stderr) } catch { let duration = Date().timeIntervalSince(start) @@ -299,11 +314,46 @@ public enum SubprocessRunner { "duration_ms": "\(Int(duration * 1000))", ]) // Safety net: ensure the process is dead (may already be killed by timeout timer). + descendantPoll.cancel() + descendantTracker?.record(rootPID: pid) self.terminateProcess(process, processGroup: processGroup) + descendantTracker?.reap() exitCodeTask.cancel() stdoutCapture.stop() stderrCapture.stop() throw error } } + + /// Remembers child process identities observed while a root process is still alive. + private final class DescendantTracker: @unchecked Sendable { + private let lock = NSLock() + private var identities: [TTYProcessTreeTerminator.ProcessIdentity] = [] + + func record(rootPID: pid_t) { + let fresh = TTYProcessTreeTerminator.descendantPIDs(of: rootPID) + .compactMap(TTYProcessTreeTerminator.processIdentity(for:)) + self.lock.withLock { + for identity in fresh where !self.identities.contains(identity) { + self.identities.append(identity) + } + } + } + + func reap() { + let snapshot = self.lock.withLock { self.identities } + guard !snapshot.isEmpty else { return } + for identity in snapshot where TTYProcessTreeTerminator.isCurrent(identity) { + kill(identity.pid, SIGTERM) + } + let deadline = Date().addingTimeInterval(0.4) + while Date() < deadline { + if snapshot.allSatisfy({ !TTYProcessTreeTerminator.isCurrent($0) }) { return } + usleep(50_000) + } + for identity in snapshot where TTYProcessTreeTerminator.isCurrent(identity) { + kill(identity.pid, SIGKILL) + } + } + } } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index 581e12368a..3928d8fda8 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -578,8 +578,16 @@ struct AntigravityCLIHTTPSFetchStrategy: ProviderFetchStrategy { .appendingPathComponent("codexbar-agy-usage-" + UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory( at: directory, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) - defer { try? FileManager.default.removeItem(at: directory) } - func run(_ arguments: [String], timeout: TimeInterval) async throws -> SubprocessResult { + defer { + // agy loads ~/.gemini MCP servers into this directory and does not wait for them. + ProcessWorkingDirectoryReaper.terminateProcesses(in: directory) + try? FileManager.default.removeItem(at: directory) + } + func run( + _ arguments: [String], + timeout: TimeInterval, + reapDescendants: Bool = false) async throws -> SubprocessResult + { try await SubprocessRunner.run( binary: binary, arguments: arguments, @@ -588,6 +596,7 @@ struct AntigravityCLIHTTPSFetchStrategy: ProviderFetchStrategy { maxOutputBytes: 1_048_576, standardInput: FileHandle.nullDevice, currentDirectoryURL: directory, + reapDescendants: reapDescendants, label: "antigravity-cli-usage") } let result: SubprocessResult @@ -597,7 +606,9 @@ struct AntigravityCLIHTTPSFetchStrategy: ProviderFetchStrategy { guard let version, version >= (1, 1, 11) else { throw AntigravityStatusProbeError.parseFailed("CLI usage reports require agy 1.1.11 or later") } result = try await run( - ["-p", "/usage", "--output-format", "json", "--print-timeout", "90s"], timeout: timeout) + ["-p", "/usage", "--output-format", "json", "--print-timeout", "90s"], + timeout: timeout, + reapDescendants: true) } catch let error as SubprocessRunnerError { try Task.checkCancellation() // Subprocess errors may contain raw stderr; classify them into safe, diff --git a/Tests/CodexBarTests/SubprocessRunnerTests.swift b/Tests/CodexBarTests/SubprocessRunnerTests.swift index d793fc773c..a909fdc63f 100644 --- a/Tests/CodexBarTests/SubprocessRunnerTests.swift +++ b/Tests/CodexBarTests/SubprocessRunnerTests.swift @@ -369,4 +369,105 @@ struct SubprocessRunnerTests { #expect(count == 20, "All 20 concurrent calls should complete") } } + + @Test + func `reapDescendants kills a session-escaped child after the parent exits`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-reap-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + try? FileManager.default.removeItem(at: root) + } + + var environment = ProcessInfo.processInfo.environment + environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path + let script = """ + import os + import subprocess + import sys + import time + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + start_new_session=True, + ) + with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle: + handle.write(str(child.pid)) + time.sleep(0.4) + """ + + _ = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: environment, + timeout: 10, + currentDirectoryURL: root, + reapDescendants: true, + label: "reap-escaped-child") + + let text = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))) + let deadline = Date().addingTimeInterval(1.5) + while kill(childPID, 0) == 0, Date() < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(kill(childPID, 0) == -1) + } + + @Test + func `working directory reaper kills a detached process in that directory`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-cwd-reap-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + try? FileManager.default.removeItem(at: root) + } + + var environment = ProcessInfo.processInfo.environment + environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path + environment["CODEXBAR_TEST_CWD"] = root.path + let script = """ + import os + import subprocess + import sys + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + cwd=os.environ["CODEXBAR_TEST_CWD"], + start_new_session=True, + ) + with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle: + handle.write(str(child.pid)) + """ + _ = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: environment, + timeout: 10, + label: "cwd-reaper-fixture") + + let text = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))) + #expect(kill(childPID, 0) == 0) + + ProcessWorkingDirectoryReaper.terminateProcesses(in: root) + + let deadline = Date().addingTimeInterval(1.5) + while kill(childPID, 0) == 0, Date() < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(kill(childPID, 0) == -1) + } } From 190dbfcac31bcdc0b88b6ed6297408855c3bc844 Mon Sep 17 00:00:00 2001 From: Brandon Charleson Date: Sun, 27 Sep 2026 16:50:18 -0600 Subject: [PATCH 025/122] Note the Antigravity reap fix in the changelog as #4077. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 900e38d6d3..176376d8a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- Antigravity: stop MCP servers that `agy -p /usage` leaves running after the quota probe exits, including servers that detach from the process group. They were reparented to launchd and each held a core until killed by hand. +- Antigravity: stop MCP servers that `agy -p /usage` leaves running after the quota probe exits, including servers that detach from the process group. They were reparented to launchd and each held a core until killed by hand (#4077). - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! From 6ae8c7c1107112905d6fbbf5271cb76ae563c7be Mon Sep 17 00:00:00 2001 From: Brandon Charleson Date: Sun, 27 Sep 2026 20:25:30 -0600 Subject: [PATCH 026/122] Reap Antigravity leftovers only while the process identity still matches The directory sweep now stores each process start token and checks it again before SIGTERM and SIGKILL, so a reused PID is not signaled. SwiftFormat also wants the short usleep delay ungrouped and a normal comment on the runner flag. --- .../ProcessWorkingDirectoryReaper.swift | 20 ++++++++++++------- .../Host/Process/SubprocessRunner.swift | 6 +++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBarCore/Host/Process/ProcessWorkingDirectoryReaper.swift b/Sources/CodexBarCore/Host/Process/ProcessWorkingDirectoryReaper.swift index 90f03e1fb2..e6c700a2e2 100644 --- a/Sources/CodexBarCore/Host/Process/ProcessWorkingDirectoryReaper.swift +++ b/Sources/CodexBarCore/Host/Process/ProcessWorkingDirectoryReaper.swift @@ -16,21 +16,27 @@ enum ProcessWorkingDirectoryReaper { static func terminateProcesses(in directory: URL) { let target = Self.standardizedPath(directory.path) guard !target.isEmpty, target != "/" else { return } - let matches = self.processIDs(inCurrentDirectory: target) + let matches = self.processIdentities(inCurrentDirectory: target) guard !matches.isEmpty else { return } - for pid in matches { - kill(pid, SIGTERM) + for identity in matches where TTYProcessTreeTerminator.isCurrent(identity) { + kill(identity.pid, SIGTERM) } let deadline = Date().addingTimeInterval(0.4) while Date() < deadline { - if matches.allSatisfy({ kill($0, 0) != 0 }) { return } - usleep(50_000) + if matches.allSatisfy({ !TTYProcessTreeTerminator.isCurrent($0) }) { return } + usleep(50000) } - for pid in matches where kill(pid, 0) == 0 { - kill(pid, SIGKILL) + for identity in matches where TTYProcessTreeTerminator.isCurrent(identity) { + kill(identity.pid, SIGKILL) } } + static func processIdentities( + inCurrentDirectory directory: String) -> [TTYProcessTreeTerminator.ProcessIdentity] + { + self.processIDs(inCurrentDirectory: directory).compactMap(TTYProcessTreeTerminator.processIdentity(for:)) + } + static func processIDs(inCurrentDirectory directory: String) -> [pid_t] { let target = Self.standardizedPath(directory) guard !target.isEmpty else { return [] } diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index ccfb4306bc..9ef21df95a 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -153,8 +153,8 @@ public enum SubprocessRunner { standardInput: Any? = nil, currentDirectoryURL: URL? = nil, acceptsNonZeroExit: Bool = false, - /// When set, descendants recorded while the process was alive are signaled after it - /// exits. This includes children that called `setsid` and were reparented to launchd. + // When set, descendants recorded while the process was alive are signaled after it + // exits. This includes children that called `setsid` and were reparented to launchd. reapDescendants: Bool = false, label: String) async throws -> SubprocessResult { @@ -349,7 +349,7 @@ public enum SubprocessRunner { let deadline = Date().addingTimeInterval(0.4) while Date() < deadline { if snapshot.allSatisfy({ !TTYProcessTreeTerminator.isCurrent($0) }) { return } - usleep(50_000) + usleep(50000) } for identity in snapshot where TTYProcessTreeTerminator.isCurrent(identity) { kill(identity.pid, SIGKILL) From 03a97bc636f83b6556fd4dc06be5066e5c064a0b Mon Sep 17 00:00:00 2001 From: B Klug Date: Sun, 27 Sep 2026 19:23:44 -0500 Subject: [PATCH 027/122] perf(codex): memoize Gatekeeper verdicts for standalone CLI binaries Every Codex binary lookup ran `spctl --assess` on the native executable, and nothing cached the verdict. Each assessment re-hashes the whole binary in syspolicyd (~2.5 s of CPU for a 281 MB x86_64 codex), so the cost scaled with refresh cadence: ~1.7% of a core at a 5-minute refresh, ~55% when lookups ran every few seconds. Remember definitive verdicts (accepted/rejected) for regular files, keyed by path plus device, inode, size, mtime and ctime, for up to an hour. ctime is not settable from user space, so a content write, chmod or xattr change (quarantine included) always re-assesses. Timeouts and spctl errors stay retryable, concurrent callers share one in-flight assessment, and app bundles are never memoized, matching the #3838 rule that bundle metadata cannot prove sealed resources unchanged. Fixes #4078 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014giiyt6RgBarhNPpjn2Xiz --- CHANGELOG.md | 1 + .../CodexLaunchPreflight+AssessmentMemo.swift | 120 +++++++++ Sources/CodexBarCore/PathEnvironment.swift | 20 +- ...exLaunchPreflightAssessmentMemoTests.swift | 236 ++++++++++++++++++ 4 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift create mode 100644 Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f07727e09..2e38364f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Codex: remember Gatekeeper verdicts for unchanged standalone CLI binaries instead of running `spctl --assess` on every lookup, which kept `syspolicyd` busy in proportion to the refresh cadence. Fixes #4078. - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift new file mode 100644 index 0000000000..839a409a29 --- /dev/null +++ b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift @@ -0,0 +1,120 @@ +import Foundation + +#if os(macOS) +extension CodexLaunchPreflight { + /// Remembers Gatekeeper verdicts for standalone Mach-O launch candidates. + /// + /// `spctl --assess` re-hashes the whole binary on every call and nothing upstream caches a + /// `rejected (… does not seem to be an app)` verdict, so each Codex lookup cost seconds of `syspolicyd` + /// CPU and the total scaled with refresh cadence (#4078). A single-file executable carries its own + /// signature, so unlike an app bundle (see `KeychainAccessPreflight.ValidationMemo`) its stat identity + /// covers everything the assessment read. `ctime` is part of the key because user space cannot set it: + /// a content write, `chmod`, or xattr change (quarantine included) always yields a new key. The lifetime + /// bounds how long a certificate revoked in place can go unnoticed. + final class AssessmentMemo: @unchecked Sendable { + static let shared = AssessmentMemo() + static let capacity = 16 + static let lifetime: TimeInterval = 60 * 60 + + /// Assessments are synchronous. Each pending key has its own result promise, so waiting callers + /// share even a transient result without holding the dictionary lock or blocking unrelated keys. + private final class Flight { + private let condition = NSCondition() + private var completed = false + private var result: GatekeeperAssessment? + + func wait() -> GatekeeperAssessment? { + self.condition.lock() + defer { self.condition.unlock() } + while !self.completed { + self.condition.wait() + } + return self.result + } + + func complete(_ result: GatekeeperAssessment?) { + self.condition.lock() + self.result = result + self.completed = true + self.condition.broadcast() + self.condition.unlock() + } + } + + private let lock = NSLock() + private var entries: [AssessmentKey: (assessment: GatekeeperAssessment, expiresAt: TimeInterval)] = [:] + private var flights: [AssessmentKey: Flight] = [:] + private let onJoin: @Sendable () -> Void + + init(onJoin: @escaping @Sendable () -> Void = {}) { + self.onJoin = onJoin + } + + /// Returns the remembered verdict for an unchanged regular file, or runs `assess`. Only verdicts + /// `isDefinitive` accepts are kept; timeouts, launch failures, and `spctl` errors stay retryable. + /// Directories (app bundles) are never memoized. + func assessment( + path: String, + now: TimeInterval = ProcessInfo.processInfo.systemUptime, + isDefinitive: (GatekeeperAssessment) -> Bool, + assess: (String) -> GatekeeperAssessment?) -> GatekeeperAssessment? + { + guard let key = AssessmentKey(path: path) else { return assess(path) } + self.lock.lock() + if let entry = self.entries[key], now < entry.expiresAt { + self.lock.unlock() + return entry.assessment + } + if let flight = self.flights[key] { + self.lock.unlock() + self.onJoin() + return flight.wait() + } + let flight = Flight() + self.flights[key] = flight + self.lock.unlock() + + let result = assess(path) + self.lock.withLock { + self.entries = self.entries.filter { now < $0.value.expiresAt } + if let result, isDefinitive(result) { + if self.entries.count >= Self.capacity, + let firstToExpire = self.entries.min(by: { $0.value.expiresAt < $1.value.expiresAt })?.key + { + self.entries.removeValue(forKey: firstToExpire) + } + self.entries[key] = (result, now + Self.lifetime) + } + flight.complete(result) + self.flights.removeValue(forKey: key) + } + return result + } + } + + private struct AssessmentKey: Hashable { + let path: String + let device: dev_t + let inode: ino_t + let size: off_t + let modifiedSeconds: Int + let modifiedNanoseconds: Int + let changedSeconds: Int + let changedNanoseconds: Int + + init?(path: String) { + // `stat` follows symlinks, so a repointed `current` link or shim resolves to a new identity. + var info = stat() + guard stat(path, &info) == 0, info.st_mode & S_IFMT == S_IFREG else { return nil } + self.path = path + self.device = info.st_dev + self.inode = info.st_ino + self.size = info.st_size + self.modifiedSeconds = info.st_mtimespec.tv_sec + self.modifiedNanoseconds = info.st_mtimespec.tv_nsec + self.changedSeconds = info.st_ctimespec.tv_sec + self.changedNanoseconds = info.st_ctimespec.tv_nsec + } + } +} +#endif diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index f46aacc8bd..7f9ab90783 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -454,7 +454,7 @@ public enum CodexLaunchPreflight { path: path, fileManager: fileManager, hasExtendedAttribute: self.hasExtendedAttribute, - spctlAssessment: { self.spctlAssessment(path: $0) }, + spctlAssessment: { self.memoizedSpctlAssessment(path: $0) }, appSignatureIsTrusted: self.isExpectedOpenAIAppSignature, isMachOExecutable: self.isMachOExecutable) #else @@ -594,6 +594,13 @@ public enum CodexLaunchPreflight { bytes == [0xCA, 0xFE, 0xBA, 0xBF] } + private static func memoizedSpctlAssessment(path: String) -> GatekeeperAssessment? { + AssessmentMemo.shared.assessment( + path: path, + isDefinitive: { self.isDefinitiveAssessment($0.output, path: path) }, + assess: { self.spctlAssessment(path: $0) }) + } + private static func spctlAssessment(path: String, timeout: TimeInterval = 5.0) -> GatekeeperAssessment? { let spctlPath = "/usr/sbin/spctl" guard FileManager.default.isExecutableFile(atPath: spctlPath) else { return nil } @@ -661,6 +668,17 @@ public enum CodexLaunchPreflight { .localizedCaseInsensitiveCompare("accepted") == .orderedSame } + /// A verdict worth remembering; `spctl` errors (for example `syspolicyd` unavailable) are neither. + static func isDefinitiveAssessment(_ assessment: String, path: String) -> Bool { + guard let verdict = self.assessmentDiagnosticText(assessment, path: path) + .split(whereSeparator: \.isNewline) + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + else { return false } + return verdict.hasPrefix("accepted") || verdict.hasPrefix("rejected") + } + private static func isExplicitlyBlockedAssessment(_ assessment: String, path: String) -> Bool { let lower = self.assessmentDiagnosticText(assessment, path: path).lowercased() if lower.contains("denied") || diff --git a/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift new file mode 100644 index 0000000000..80a13b8f91 --- /dev/null +++ b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift @@ -0,0 +1,236 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +struct CodexLaunchPreflightAssessmentMemoTests { + private typealias Memo = CodexLaunchPreflight.AssessmentMemo + private typealias Assessment = CodexLaunchPreflight.GatekeeperAssessment + + private static let notAnApp = "rejected (the code is valid but does not seem to be an app)\n" + + "origin=Developer ID Application: Synthetic Fixture (FIXTURE01)" + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + var count: Int { + self.lock.withLock { self.value } + } + + func increment() { + self.lock.withLock { self.value += 1 } + } + } + + private struct Fixture { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + + init() throws { + try FileManager.default.createDirectory(at: self.root, withIntermediateDirectories: true) + } + + func executable(_ name: String, contents: String = "synthetic native codex") throws -> URL { + let url = self.root.appendingPathComponent(name) + try Data(contents.utf8).write(to: url) + return url + } + + func remove() { try? FileManager.default.removeItem(at: self.root) } + } + + private static func assess( + _ memo: Memo, + _ path: String, + now: TimeInterval = 0, + calls: Counter, + output: String? = Self.notAnApp) -> Assessment? + { + memo.assessment( + path: path, + now: now, + isDefinitive: { CodexLaunchPreflight.isDefinitiveAssessment($0.output, path: path) }, + assess: { _ in + calls.increment() + return output.map { Assessment(output: "\(path): \($0)", exitStatus: 3) } + }) + } + + @Test + func `an unchanged executable is assessed once`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let codex = try fixture.executable("codex") + let memo = Memo() + let calls = Counter() + + for _ in 0..<100 { + #expect(Self.assess(memo, codex.path, calls: calls)?.exitStatus == 3) + } + + #expect(calls.count == 1) + print("assessment memo serial: requests=100 assessments=\(calls.count)") + } + + @Test + func `rewriting the executable forces a fresh assessment`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let codex = try fixture.executable("codex") + let memo = Memo() + let calls = Counter() + _ = Self.assess(memo, codex.path, calls: calls) + + try Data("a codex update that is definitely not the old one".utf8).write(to: codex) + _ = Self.assess(memo, codex.path, calls: calls) + + #expect(calls.count == 2) + } + + @Test + func `an extended attribute change alone forces a fresh assessment`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let codex = try fixture.executable("codex") + let memo = Memo() + let calls = Counter() + let modifiedBefore = try FileManager.default.attributesOfItem(atPath: codex.path)[.modificationDate] as? Date + _ = Self.assess(memo, codex.path, calls: calls) + + // Quarantine arrives as an xattr: size and mtime stay put, only ctime moves. + let value = Array("0081;00000000;Synthetic;".utf8) + let status = setxattr(codex.path, "com.apple.quarantine", value, value.count, 0, 0) + #expect(status == 0) + let modifiedAfter = try FileManager.default.attributesOfItem(atPath: codex.path)[.modificationDate] as? Date + #expect(modifiedBefore == modifiedAfter) + _ = Self.assess(memo, codex.path, calls: calls) + + #expect(calls.count == 2) + } + + @Test + func `a repointed symlink gets its own verdict`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let old = try fixture.executable("codex-0.1", contents: "old release") + let new = try fixture.executable("codex-0.2", contents: "new release") + let link = fixture.root.appendingPathComponent("codex") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: old) + let memo = Memo() + let calls = Counter() + _ = Self.assess(memo, link.path, calls: calls) + + try FileManager.default.removeItem(at: link) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: new) + _ = Self.assess(memo, link.path, calls: calls) + + #expect(calls.count == 2) + } + + @Test + func `verdicts expire after the lifetime`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let codex = try fixture.executable("codex") + let memo = Memo() + let calls = Counter() + + _ = Self.assess(memo, codex.path, now: 0, calls: calls) + _ = Self.assess(memo, codex.path, now: Memo.lifetime - 1, calls: calls) + #expect(calls.count == 1) + + _ = Self.assess(memo, codex.path, now: Memo.lifetime, calls: calls) + #expect(calls.count == 2) + } + + @Test + func `timeouts and spctl errors stay retryable`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let codex = try fixture.executable("codex") + let memo = Memo() + let timeouts = Counter() + let errors = Counter() + + for _ in 0..<3 { + #expect(Self.assess(memo, codex.path, calls: timeouts, output: nil) == nil) + _ = Self.assess(memo, codex.path, calls: errors, output: "spctl: syspolicyd is unavailable") + } + + #expect(timeouts.count == 3) + #expect(errors.count == 3) + } + + @Test + func `app bundles are never memoized`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let bundle = fixture.root.appendingPathComponent("Codex.app") + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + let memo = Memo() + let calls = Counter() + + for _ in 0..<3 { + _ = Self.assess(memo, bundle.path, calls: calls, output: "accepted\nsource=Notarized Developer ID") + } + + #expect(calls.count == 3) + } + + @Test + func `concurrent callers share one assessment`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let codex = try fixture.executable("codex") + let joins = Counter() + let memo = Memo(onJoin: { joins.increment() }) + let calls = Counter() + let path = codex.path + + DispatchQueue.concurrentPerform(iterations: 20) { _ in + _ = memo.assessment( + path: path, + isDefinitive: { _ in true }, + assess: { _ in + calls.increment() + Thread.sleep(forTimeInterval: 0.2) + return Assessment(output: Self.notAnApp, exitStatus: 3) + }) + } + + #expect(calls.count == 1) + #expect(joins.count <= 19) + print("assessment memo concurrent: requests=20 assessments=\(calls.count) joined=\(joins.count)") + } + + @Test + func `capacity evicts the verdict closest to expiry`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let memo = Memo() + let calls = Counter() + let paths = try (0...Memo.capacity).map { try fixture.executable("codex-\($0)").path } + + for (offset, path) in paths.enumerated() { + _ = Self.assess(memo, path, now: TimeInterval(offset), calls: calls) + } + _ = Self.assess(memo, paths[1], now: TimeInterval(paths.count), calls: calls) + #expect(calls.count == paths.count) + + _ = Self.assess(memo, paths[0], now: TimeInterval(paths.count), calls: calls) + #expect(calls.count == paths.count + 1) + } + + @Test + func `only accepted and rejected verdicts are definitive`() { + let path = "/tools/bin/codex" + #expect(CodexLaunchPreflight.isDefinitiveAssessment("\(path): \(Self.notAnApp)", path: path)) + #expect(CodexLaunchPreflight.isDefinitiveAssessment( + "\(path): accepted\nsource=Notarized Developer ID", + path: path)) + #expect(!CodexLaunchPreflight.isDefinitiveAssessment( + "spctl: syspolicyd is unavailable", + path: path)) + #expect(!CodexLaunchPreflight.isDefinitiveAssessment("", path: path)) + } +} +#endif From f6c6b86affb750a91b1875ef95a74d9fdd9f4cd9 Mon Sep 17 00:00:00 2001 From: B Klug Date: Sun, 27 Sep 2026 20:13:27 -0500 Subject: [PATCH 028/122] fix(codex): bind memoized Gatekeeper verdicts to the executable assessed Review found that the memo read the file identity before `spctl` ran and stored the verdict under it, so a symlink swapped during the assessment and swapped back would leave another executable's verdict in the cache. The key now resolves the path itself, recording every symlink it crosses (a link cannot be retargeted in place, so a swap changes its inode or ctime even when reverted), plus the resolved file's identity. A verdict is kept only when the key read after `spctl` returns equals the one read before. Adds regressions for a leaf link and an intermediate directory link swapped during assessment, and a final-effect test through isLaunchCandidateAllowed: a replaced executable is blocked on the next lookup, and a revocation that leaves the file untouched takes effect when the verdict expires. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014giiyt6RgBarhNPpjn2Xiz --- CHANGELOG.md | 2 +- .../CodexLaunchPreflight+AssessmentMemo.swift | 92 +++++++++++++-- ...exLaunchPreflightAssessmentMemoTests.swift | 109 ++++++++++++++++++ 3 files changed, 194 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e38364f45..1414c7d45d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- Codex: remember Gatekeeper verdicts for unchanged standalone CLI binaries instead of running `spctl --assess` on every lookup, which kept `syspolicyd` busy in proportion to the refresh cadence. Fixes #4078. +- Codex: remember Gatekeeper verdicts for unchanged standalone CLI binaries, bound to the file actually assessed, instead of running `spctl --assess` on every lookup, which kept `syspolicyd` busy in proportion to the refresh cadence (#4080). Fixes #4078. - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift index 839a409a29..181c6e5d08 100644 --- a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift +++ b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift @@ -9,8 +9,11 @@ extension CodexLaunchPreflight { /// CPU and the total scaled with refresh cadence (#4078). A single-file executable carries its own /// signature, so unlike an app bundle (see `KeychainAccessPreflight.ValidationMemo`) its stat identity /// covers everything the assessment read. `ctime` is part of the key because user space cannot set it: - /// a content write, `chmod`, or xattr change (quarantine included) always yields a new key. The lifetime - /// bounds how long a certificate revoked in place can go unnoticed. + /// a content write, `chmod`, or xattr change (quarantine included) always yields a new key. The key also + /// records every symlink the path crosses, and a verdict is kept only when the key read after `spctl` + /// returns matches the one read before, so a target swapped during the assessment (even one swapped + /// back) is never remembered. The lifetime bounds how long a certificate revoked in place can go + /// unnoticed. final class AssessmentMemo: @unchecked Sendable { static let shared = AssessmentMemo() static let capacity = 16 @@ -51,8 +54,9 @@ extension CodexLaunchPreflight { } /// Returns the remembered verdict for an unchanged regular file, or runs `assess`. Only verdicts - /// `isDefinitive` accepts are kept; timeouts, launch failures, and `spctl` errors stay retryable. - /// Directories (app bundles) are never memoized. + /// `isDefinitive` accepts are kept, and only when the path resolved to the same file through the same + /// symlinks before and after the assessment; timeouts, launch failures, and `spctl` errors stay + /// retryable. Directories (app bundles) are never memoized. func assessment( path: String, now: TimeInterval = ProcessInfo.processInfo.systemUptime, @@ -75,9 +79,11 @@ extension CodexLaunchPreflight { self.lock.unlock() let result = assess(path) + // Bind the verdict to what was assessed: anything that moved while `spctl` ran is not remembered. + let unchanged = AssessmentKey(path: path) == key self.lock.withLock { self.entries = self.entries.filter { now < $0.value.expiresAt } - if let result, isDefinitive(result) { + if let result, unchanged, isDefinitive(result) { if self.entries.count >= Self.capacity, let firstToExpire = self.entries.min(by: { $0.value.expiresAt < $1.value.expiresAt })?.key { @@ -94,6 +100,68 @@ extension CodexLaunchPreflight { private struct AssessmentKey: Hashable { let path: String + let resolvedPath: String + let links: [LinkIdentity] + let target: FileIdentity + + init?(path: String) { + guard let resolution = Self.resolve(path), + let target = FileIdentity(path: resolution.path), + target.isRegularFile + else { return nil } + self.path = path + self.resolvedPath = resolution.path + self.links = resolution.links + self.target = target + } + + /// Resolves an absolute path like `realpath(3)`, recording each symlink crossed. A symlink cannot be + /// retargeted in place: replacing it creates a new inode and renaming it moves its ctime, so the chain + /// changes whenever any hop does, including a swap that is later reverted. + private static func resolve(_ path: String) -> (path: String, links: [LinkIdentity])? { + guard path.hasPrefix("/") else { return nil } + var pending = path.split(separator: "/").map(String.init) + var resolved: [String] = [] + var links: [LinkIdentity] = [] + while !pending.isEmpty { + let component = pending.removeFirst() + if component.isEmpty || component == "." { continue } + if component == ".." { + _ = resolved.popLast() + continue + } + let candidate = "/" + (resolved + [component]).joined(separator: "/") + guard let identity = FileIdentity(path: candidate) else { return nil } + guard identity.isSymbolicLink else { + resolved.append(component) + continue + } + guard links.count < 32, let destination = Self.readLink(candidate) else { return nil } + links.append(LinkIdentity(path: candidate, destination: destination, identity: identity)) + if destination.hasPrefix("/") { + resolved.removeAll() + } + pending = destination.split(separator: "/").map(String.init) + pending + } + return ("/" + resolved.joined(separator: "/"), links) + } + + private static func readLink(_ path: String) -> String? { + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX) + 1) + let count = readlink(path, &buffer, Int(PATH_MAX)) + guard count > 0 else { return nil } + return String(bytes: buffer[.. Bool in + CodexLaunchPreflight.isLaunchCandidateAllowed( + path: codex.path, + fileManager: .default, + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { path in + memo.assessment( + path: path, + now: now, + isDefinitive: { CodexLaunchPreflight.isDefinitiveAssessment($0.output, path: path) }, + assess: { path in + calls.increment() + let unsigned = (try? String(contentsOfFile: path, encoding: .utf8)) != "signed release" + let verdict = revoked + ? "rejected (CSSMERR_TP_CERT_REVOKED)" + : unsigned ? "rejected\nsource=no usable signature" : Self.notAnApp + return Assessment(output: "\(path): \(verdict)", exitStatus: 3) + }) + }, + appSignatureIsTrusted: { _ in false }, + isMachOExecutable: { _ in true }) + } + + #expect(decide(0)) + #expect(decide(1)) + #expect(calls.count == 1) + + // Replacing the executable is caught on the next lookup, not after the lifetime. + try Data("unsigned replacement".utf8).write(to: codex) + #expect(!decide(2)) + #expect(!decide(3)) + #expect(calls.count == 2) + + // A revocation that leaves the file untouched takes effect once the verdict expires. + try Data("signed release".utf8).write(to: codex) + #expect(decide(4)) + revoked = true + #expect(decide(4 + Memo.lifetime - 1)) + #expect(!decide(4 + Memo.lifetime)) + #expect(calls.count == 4) + } + @Test func `only accepted and rejected verdicts are definitive`() { let path = "/tools/bin/codex" From 4b0736a14335b339f7979a646c424a5968db6610 Mon Sep 17 00:00:00 2001 From: B Klug Date: Sun, 27 Sep 2026 20:55:02 -0500 Subject: [PATCH 029/122] fix(codex): revalidate shared Gatekeeper verdicts per caller; 15-minute lifetime Review round 2 found that a caller joining an in-flight assessment returned the leader's verdict without rechecking its own path, so a link retargeted to a forbidden executable mid-assessment could be allowed on the old target's verdict. The leader already declined to cache that verdict but still published it to waiters. Every caller now gets a verdict only when the path still names the assessed file after `spctl` returns; otherwise it runs a fresh, unshared assessment. Adds a final-decision regression through isLaunchCandidateAllowed where the link is retargeted to an unsigned binary during a shared assessment: both the leader and the waiter are blocked. Also shortens the verdict lifetime from one hour to 15 minutes, per the review's recommendation on the certificate-revocation window. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014giiyt6RgBarhNPpjn2Xiz --- .../CodexLaunchPreflight+AssessmentMemo.swift | 33 ++++--- ...exLaunchPreflightAssessmentMemoTests.swift | 97 +++++++++++++++++-- 2 files changed, 108 insertions(+), 22 deletions(-) diff --git a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift index 181c6e5d08..1c8e5dbe63 100644 --- a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift +++ b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift @@ -12,21 +12,23 @@ extension CodexLaunchPreflight { /// a content write, `chmod`, or xattr change (quarantine included) always yields a new key. The key also /// records every symlink the path crosses, and a verdict is kept only when the key read after `spctl` /// returns matches the one read before, so a target swapped during the assessment (even one swapped - /// back) is never remembered. The lifetime bounds how long a certificate revoked in place can go - /// unnoticed. + /// back) is never remembered, and no caller (the one that ran `spctl` or one that waited on it) is + /// answered with a verdict for a file its path no longer names: it gets a fresh assessment instead. The + /// lifetime bounds how long a certificate revoked in place, with the file untouched, can go unnoticed. final class AssessmentMemo: @unchecked Sendable { static let shared = AssessmentMemo() static let capacity = 16 - static let lifetime: TimeInterval = 60 * 60 + static let lifetime: TimeInterval = 15 * 60 /// Assessments are synchronous. Each pending key has its own result promise, so waiting callers /// share even a transient result without holding the dictionary lock or blocking unrelated keys. + /// `bound` records whether the path still named the assessed file when `spctl` returned. private final class Flight { private let condition = NSCondition() private var completed = false - private var result: GatekeeperAssessment? + private var result: (assessment: GatekeeperAssessment?, bound: Bool) = (nil, false) - func wait() -> GatekeeperAssessment? { + func wait() -> (assessment: GatekeeperAssessment?, bound: Bool) { self.condition.lock() defer { self.condition.unlock() } while !self.completed { @@ -35,9 +37,9 @@ extension CodexLaunchPreflight { return self.result } - func complete(_ result: GatekeeperAssessment?) { + func complete(_ assessment: GatekeeperAssessment?, bound: Bool) { self.condition.lock() - self.result = result + self.result = (assessment, bound) self.completed = true self.condition.broadcast() self.condition.unlock() @@ -56,7 +58,8 @@ extension CodexLaunchPreflight { /// Returns the remembered verdict for an unchanged regular file, or runs `assess`. Only verdicts /// `isDefinitive` accepts are kept, and only when the path resolved to the same file through the same /// symlinks before and after the assessment; timeouts, launch failures, and `spctl` errors stay - /// retryable. Directories (app bundles) are never memoized. + /// retryable. A verdict is only ever returned for the file the path names when the call returns; + /// otherwise the caller gets a fresh, unshared assessment. Directories (app bundles) are never memoized. func assessment( path: String, now: TimeInterval = ProcessInfo.processInfo.systemUptime, @@ -72,7 +75,10 @@ extension CodexLaunchPreflight { if let flight = self.flights[key] { self.lock.unlock() self.onJoin() - return flight.wait() + let shared = flight.wait() + // The leader's verdict speaks for the file it assessed; revalidate it for this caller. + if shared.bound, AssessmentKey(path: path) == key { return shared.assessment } + return assess(path) } let flight = Flight() self.flights[key] = flight @@ -80,10 +86,10 @@ extension CodexLaunchPreflight { let result = assess(path) // Bind the verdict to what was assessed: anything that moved while `spctl` ran is not remembered. - let unchanged = AssessmentKey(path: path) == key + let bound = AssessmentKey(path: path) == key self.lock.withLock { self.entries = self.entries.filter { now < $0.value.expiresAt } - if let result, unchanged, isDefinitive(result) { + if let result, bound, isDefinitive(result) { if self.entries.count >= Self.capacity, let firstToExpire = self.entries.min(by: { $0.value.expiresAt < $1.value.expiresAt })?.key { @@ -91,10 +97,11 @@ extension CodexLaunchPreflight { } self.entries[key] = (result, now + Self.lifetime) } - flight.complete(result) + flight.complete(result, bound: bound) self.flights.removeValue(forKey: key) } - return result + // A verdict for a file the path no longer names is not an answer for this lookup either. + return bound ? result : assess(path) } } diff --git a/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift index 6c00fe851e..e8a2d31d0d 100644 --- a/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift +++ b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift @@ -238,16 +238,20 @@ struct CodexLaunchPreflightAssessmentMemoTests { // spctl would have assessed `other`; by the time it returns the link names `original` again. _ = memo.assessment(path: link.path, isDefinitive: { _ in true }, assess: { _ in calls.increment() - swap(other) - swap(original) + if calls.count == 1 { + swap(other) + swap(original) + } return Assessment(output: Self.notAnApp, exitStatus: 3) }) - _ = Self.assess(memo, link.path, calls: calls) + // The disturbed verdict was neither returned nor kept: the caller got a fresh assessment. #expect(calls.count == 2) + _ = Self.assess(memo, link.path, calls: calls) + #expect(calls.count == 3) // An undisturbed assessment is remembered as usual. _ = Self.assess(memo, link.path, calls: calls) - #expect(calls.count == 2) + #expect(calls.count == 3) } @Test @@ -268,15 +272,18 @@ struct CodexLaunchPreflightAssessmentMemoTests { _ = memo.assessment(path: codex, isDefinitive: { _ in true }, assess: { _ in calls.increment() - try? manager.removeItem(at: current) - try? manager.createSymbolicLink(atPath: current.path, withDestinationPath: "release-2") - try? manager.removeItem(at: current) - try? manager.createSymbolicLink(atPath: current.path, withDestinationPath: "release-1") + if calls.count == 1 { + try? manager.removeItem(at: current) + try? manager.createSymbolicLink(atPath: current.path, withDestinationPath: "release-2") + try? manager.removeItem(at: current) + try? manager.createSymbolicLink(atPath: current.path, withDestinationPath: "release-1") + } return Assessment(output: Self.notAnApp, exitStatus: 3) }) + #expect(calls.count == 2) _ = Self.assess(memo, codex, calls: calls) - #expect(calls.count == 2) + #expect(calls.count == 3) } @Test @@ -329,6 +336,78 @@ struct CodexLaunchPreflightAssessmentMemoTests { #expect(calls.count == 4) } + private final class Decisions: @unchecked Sendable { + private let lock = NSLock() + private var values: [String: Bool] = [:] + subscript(name: String) -> Bool? { + get { self.lock.withLock { self.values[name] } } + set { self.lock.withLock { self.values[name] = newValue } } + } + } + + @Test + func `no caller inherits a verdict for a target swapped during a shared assessment`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let signed = try fixture.executable("codex-signed", contents: "signed release") + let unsigned = try fixture.executable("codex-unsigned", contents: "unsigned replacement") + let link = fixture.root.appendingPathComponent("codex") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: signed) + let started = DispatchSemaphore(value: 0) + let joined = DispatchSemaphore(value: 0) + let memo = Memo(onJoin: { joined.signal() }) + let calls = Counter() + let decisions = Decisions() + let path = link.path + + let decide: @Sendable () -> Bool = { + CodexLaunchPreflight.isLaunchCandidateAllowed( + path: path, + fileManager: .default, + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { candidate in + memo.assessment( + path: candidate, + isDefinitive: { CodexLaunchPreflight.isDefinitiveAssessment($0.output, path: candidate) }, + assess: { candidate in + calls.increment() + // Gatekeeper reads whatever the link names when the assessment starts. + let isSigned = (try? String(contentsOfFile: candidate, encoding: .utf8)) == "signed release" + if calls.count == 1 { + started.signal() + _ = joined.wait(timeout: .now() + 5) + // Retarget to the forbidden binary while the shared assessment is running. + try? FileManager.default.removeItem(at: link) + try? FileManager.default.createSymbolicLink(at: link, withDestinationURL: unsigned) + } + let verdict = isSigned ? Self.notAnApp : "rejected\nsource=no usable signature" + return Assessment(output: "\(candidate): \(verdict)", exitStatus: 3) + }) + }, + appSignatureIsTrusted: { _ in false }, + isMachOExecutable: { _ in true }) + } + + let group = DispatchGroup() + group.enter() + DispatchQueue.global().async { + decisions["leader"] = decide() + group.leave() + } + _ = started.wait(timeout: .now() + 5) + group.enter() + DispatchQueue.global().async { + decisions["waiter"] = decide() + group.leave() + } + _ = group.wait(timeout: .now() + 10) + + // Both lookups now name the unsigned binary, so neither may be allowed on the signed one's verdict. + #expect(decisions["leader"] == false) + #expect(decisions["waiter"] == false) + #expect(calls.count == 3) + } + @Test func `only accepted and rejected verdicts are definitive`() { let path = "/tools/bin/codex" From bd39efc6d7d3748c97d01208275b810256d3dc40 Mon Sep 17 00:00:00 2001 From: B Klug Date: Sun, 27 Sep 2026 21:28:36 -0500 Subject: [PATCH 030/122] fix(codex): bind memoized verdicts to the inode Gatekeeper assessed Review round 3 found that a parent directory moved aside, replaced while spctl ran, and restored could leave a different tool's verdict cached for the original, because the path spctl traversed could change under it. Instead of tracking every link and directory on the path, the memo now has Gatekeeper assess `/.vol//`, which names the resolved file directly: nothing on the path can change what was assessed. Verdicts are keyed by that file's identity (device, inode, size, mtime, ctime), kept only if it is unchanged when spctl returns, and handed to a caller only while the caller's path still names that file; otherwise the caller gets a fresh, unshared assessment. Verdicts are reported for the caller's path. Where /.vol cannot reach the file, nothing is memoized. Replaces the path-walking key from the previous revision (simpler, and it also removes the noise that directory timestamps would have added). Adds the reviewer's directory-swap scenario and link swaps during assessment as final-decision tests: the unsigned CLI stays blocked in every case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014giiyt6RgBarhNPpjn2Xiz --- .../CodexLaunchPreflight+AssessmentMemo.swift | 155 +++++++----------- ...exLaunchPreflightAssessmentMemoTests.swift | 140 ++++++++++++---- 2 files changed, 163 insertions(+), 132 deletions(-) diff --git a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift index 1c8e5dbe63..dd31f05b03 100644 --- a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift +++ b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift @@ -6,23 +6,26 @@ extension CodexLaunchPreflight { /// /// `spctl --assess` re-hashes the whole binary on every call and nothing upstream caches a /// `rejected (… does not seem to be an app)` verdict, so each Codex lookup cost seconds of `syspolicyd` - /// CPU and the total scaled with refresh cadence (#4078). A single-file executable carries its own - /// signature, so unlike an app bundle (see `KeychainAccessPreflight.ValidationMemo`) its stat identity - /// covers everything the assessment read. `ctime` is part of the key because user space cannot set it: - /// a content write, `chmod`, or xattr change (quarantine included) always yields a new key. The key also - /// records every symlink the path crosses, and a verdict is kept only when the key read after `spctl` - /// returns matches the one read before, so a target swapped during the assessment (even one swapped - /// back) is never remembered, and no caller (the one that ran `spctl` or one that waited on it) is - /// answered with a verdict for a file its path no longer names: it gets a fresh assessment instead. The - /// lifetime bounds how long a certificate revoked in place, with the file untouched, can go unnoticed. + /// CPU and the total scaled with refresh cadence (#4078). + /// + /// A verdict is bound to a file, not to a path. The memo resolves the candidate to its device and inode + /// and has Gatekeeper assess `/.vol//`, which names that file directly, so no symlink or + /// directory swapped while `spctl` runs can change what was assessed. A single-file executable carries its + /// own signature, so unlike an app bundle (see `KeychainAccessPreflight.ValidationMemo`) its identity + /// covers everything the assessment read: device, inode, size, mtime and ctime. User space cannot set + /// ctime, so a content write, `chmod`, or xattr change (quarantine included) is always a new identity. + /// A verdict is kept only if the file's identity is unchanged when `spctl` returns, and a caller receives + /// it only while its own path still names that file; otherwise the caller gets a fresh, unshared + /// assessment of its path. The lifetime bounds how long a certificate revoked in place, with the file + /// untouched, can go unnoticed. Where `/.vol` cannot reach the file, nothing is memoized. final class AssessmentMemo: @unchecked Sendable { static let shared = AssessmentMemo() static let capacity = 16 static let lifetime: TimeInterval = 15 * 60 - /// Assessments are synchronous. Each pending key has its own result promise, so waiting callers - /// share even a transient result without holding the dictionary lock or blocking unrelated keys. - /// `bound` records whether the path still named the assessed file when `spctl` returned. + /// Assessments are synchronous. Each pending file has its own result promise, so waiting callers + /// share even a transient result without holding the dictionary lock or blocking unrelated files. + /// `bound` records whether the file was unchanged when `spctl` returned. private final class Flight { private let condition = NSCondition() private var completed = false @@ -47,126 +50,83 @@ extension CodexLaunchPreflight { } private let lock = NSLock() - private var entries: [AssessmentKey: (assessment: GatekeeperAssessment, expiresAt: TimeInterval)] = [:] - private var flights: [AssessmentKey: Flight] = [:] + private var entries: [FileIdentity: (assessment: GatekeeperAssessment, expiresAt: TimeInterval)] = [:] + private var flights: [FileIdentity: Flight] = [:] private let onJoin: @Sendable () -> Void init(onJoin: @escaping @Sendable () -> Void = {}) { self.onJoin = onJoin } - /// Returns the remembered verdict for an unchanged regular file, or runs `assess`. Only verdicts - /// `isDefinitive` accepts are kept, and only when the path resolved to the same file through the same - /// symlinks before and after the assessment; timeouts, launch failures, and `spctl` errors stay - /// retryable. A verdict is only ever returned for the file the path names when the call returns; - /// otherwise the caller gets a fresh, unshared assessment. Directories (app bundles) are never memoized. + /// Returns the remembered verdict for the unchanged regular file `path` names, or assesses it. Only + /// verdicts `isDefinitive` accepts are kept; timeouts, launch failures, and `spctl` errors stay + /// retryable. Directories (app bundles) are never memoized. Verdicts are reported for `path`. func assessment( path: String, now: TimeInterval = ProcessInfo.processInfo.systemUptime, isDefinitive: (GatekeeperAssessment) -> Bool, assess: (String) -> GatekeeperAssessment?) -> GatekeeperAssessment? { - guard let key = AssessmentKey(path: path) else { return assess(path) } + guard let file = FileIdentity(path: path), file.isRegularFile, + FileIdentity(path: file.volumePath) == file + else { return assess(path) } self.lock.lock() - if let entry = self.entries[key], now < entry.expiresAt { + if let entry = self.entries[file], now < entry.expiresAt { self.lock.unlock() - return entry.assessment + return Self.attributed(entry.assessment, from: file.volumePath, to: path) } - if let flight = self.flights[key] { + if let flight = self.flights[file] { self.lock.unlock() self.onJoin() let shared = flight.wait() - // The leader's verdict speaks for the file it assessed; revalidate it for this caller. - if shared.bound, AssessmentKey(path: path) == key { return shared.assessment } + // The verdict speaks for the file that was assessed; this caller's path must still name it. + if shared.bound, FileIdentity(path: path) == file { + return Self.attributed(shared.assessment, from: file.volumePath, to: path) + } return assess(path) } let flight = Flight() - self.flights[key] = flight + self.flights[file] = flight self.lock.unlock() - let result = assess(path) - // Bind the verdict to what was assessed: anything that moved while `spctl` ran is not remembered. - let bound = AssessmentKey(path: path) == key + let result = assess(file.volumePath) + // Kept only if the file did not change while `spctl` read it. + let bound = FileIdentity(path: file.volumePath) == file self.lock.withLock { self.entries = self.entries.filter { now < $0.value.expiresAt } - if let result, bound, isDefinitive(result) { + if let result, bound, let reported = Self.attributed(result, from: file.volumePath, to: path), + isDefinitive(reported) + { if self.entries.count >= Self.capacity, let firstToExpire = self.entries.min(by: { $0.value.expiresAt < $1.value.expiresAt })?.key { self.entries.removeValue(forKey: firstToExpire) } - self.entries[key] = (result, now + Self.lifetime) + self.entries[file] = (result, now + Self.lifetime) } flight.complete(result, bound: bound) - self.flights.removeValue(forKey: key) + self.flights.removeValue(forKey: file) } - // A verdict for a file the path no longer names is not an answer for this lookup either. - return bound ? result : assess(path) - } - } - - private struct AssessmentKey: Hashable { - let path: String - let resolvedPath: String - let links: [LinkIdentity] - let target: FileIdentity - - init?(path: String) { - guard let resolution = Self.resolve(path), - let target = FileIdentity(path: resolution.path), - target.isRegularFile - else { return nil } - self.path = path - self.resolvedPath = resolution.path - self.links = resolution.links - self.target = target - } - - /// Resolves an absolute path like `realpath(3)`, recording each symlink crossed. A symlink cannot be - /// retargeted in place: replacing it creates a new inode and renaming it moves its ctime, so the chain - /// changes whenever any hop does, including a swap that is later reverted. - private static func resolve(_ path: String) -> (path: String, links: [LinkIdentity])? { - guard path.hasPrefix("/") else { return nil } - var pending = path.split(separator: "/").map(String.init) - var resolved: [String] = [] - var links: [LinkIdentity] = [] - while !pending.isEmpty { - let component = pending.removeFirst() - if component.isEmpty || component == "." { continue } - if component == ".." { - _ = resolved.popLast() - continue - } - let candidate = "/" + (resolved + [component]).joined(separator: "/") - guard let identity = FileIdentity(path: candidate) else { return nil } - guard identity.isSymbolicLink else { - resolved.append(component) - continue - } - guard links.count < 32, let destination = Self.readLink(candidate) else { return nil } - links.append(LinkIdentity(path: candidate, destination: destination, identity: identity)) - if destination.hasPrefix("/") { - resolved.removeAll() - } - pending = destination.split(separator: "/").map(String.init) + pending + // A verdict for a file the path no longer names is not an answer for this lookup. + if bound, FileIdentity(path: path) == file { + return Self.attributed(result, from: file.volumePath, to: path) } - return ("/" + resolved.joined(separator: "/"), links) + return assess(path) } - private static func readLink(_ path: String) -> String? { - var buffer = [CChar](repeating: 0, count: Int(PATH_MAX) + 1) - let count = readlink(path, &buffer, Int(PATH_MAX)) - guard count > 0 else { return nil } - return String(bytes: buffer[.. GatekeeperAssessment? + { + guard let assessment, assessment.output.hasPrefix("\(source):") else { return assessment } + return GatekeeperAssessment( + output: path + String(assessment.output.dropFirst(source.count)), + exitStatus: assessment.exitStatus) } } - private struct LinkIdentity: Hashable { - let path: String - let destination: String - let identity: FileIdentity - } - private struct FileIdentity: Hashable { let mode: mode_t let device: dev_t @@ -181,14 +141,15 @@ extension CodexLaunchPreflight { self.mode & S_IFMT == S_IFREG } - var isSymbolicLink: Bool { - self.mode & S_IFMT == S_IFLNK + /// Names this file without traversing any directory or symlink. + var volumePath: String { + "/.vol/\(self.device)/\(self.inode)" } - /// `lstat`: a symlink is described as itself, never as its target. + /// `stat`, so a symlinked candidate resolves to the file it names right now. init?(path: String) { var info = stat() - guard lstat(path, &info) == 0 else { return nil } + guard stat(path, &info) == 0 else { return nil } self.mode = info.st_mode self.device = info.st_dev self.inode = info.st_ino diff --git a/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift index e8a2d31d0d..6f7cdc7280 100644 --- a/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift +++ b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift @@ -220,14 +220,45 @@ struct CodexLaunchPreflightAssessmentMemoTests { #expect(calls.count == paths.count + 1) } + /// A final launch decision through the production preflight, with Gatekeeper faked from file contents: + /// "signed release" is a valid CLI (allowed), anything else has no usable signature (blocked). + private static func decide( + _ memo: Memo, + _ path: String, + now: TimeInterval = 0, + calls: Counter, + during: @escaping (Int) -> Void = { _ in }) -> Bool + { + CodexLaunchPreflight.isLaunchCandidateAllowed( + path: path, + fileManager: .default, + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { candidate in + memo.assessment( + path: candidate, + now: now, + isDefinitive: { CodexLaunchPreflight.isDefinitiveAssessment($0.output, path: candidate) }, + assess: { assessed in + calls.increment() + let contents = try? String(contentsOfFile: assessed, encoding: .utf8) + during(calls.count) + let verdict = contents == "signed release" + ? Self.notAnApp : "rejected\nsource=no usable signature" + return Assessment(output: "\(assessed): \(verdict)", exitStatus: 3) + }) + }, + appSignatureIsTrusted: { _ in false }, + isMachOExecutable: { _ in true }) + } + @Test - func `a link swapped during assessment and swapped back is not remembered`() throws { + func `a link swapped during assessment cannot lend its target's verdict`() throws { let fixture = try Fixture() defer { fixture.remove() } - let original = try fixture.executable("codex-original", contents: "original release") - let other = try fixture.executable("codex-other", contents: "some other executable") + let unsigned = try fixture.executable("codex-unsigned", contents: "unsigned build") + let signed = try fixture.executable("codex-signed", contents: "signed release") let link = fixture.root.appendingPathComponent("codex") - try FileManager.default.createSymbolicLink(at: link, withDestinationURL: original) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: unsigned) let memo = Memo() let calls = Counter() let swap = { (destination: URL) in @@ -235,34 +266,28 @@ struct CodexLaunchPreflightAssessmentMemoTests { try? FileManager.default.createSymbolicLink(at: link, withDestinationURL: destination) } - // spctl would have assessed `other`; by the time it returns the link names `original` again. - _ = memo.assessment(path: link.path, isDefinitive: { _ in true }, assess: { _ in - calls.increment() - if calls.count == 1 { - swap(other) - swap(original) + // The link names the signed CLI while spctl runs and the unsigned one again when it returns. + #expect(!Self.decide(memo, link.path, calls: calls, during: { call in + if call == 1 { + swap(signed) + swap(unsigned) } - return Assessment(output: Self.notAnApp, exitStatus: 3) - }) - // The disturbed verdict was neither returned nor kept: the caller got a fresh assessment. - #expect(calls.count == 2) - _ = Self.assess(memo, link.path, calls: calls) - #expect(calls.count == 3) - - // An undisturbed assessment is remembered as usual. - _ = Self.assess(memo, link.path, calls: calls) - #expect(calls.count == 3) + })) + // Gatekeeper assessed the unsigned file by inode, so the swap changed nothing and the verdict holds. + #expect(calls.count == 1) + #expect(!Self.decide(memo, link.path, calls: calls)) + #expect(calls.count == 1) } @Test - func `an intermediate directory link swapped during assessment is not remembered`() throws { + func `an intermediate directory link swapped during assessment cannot lend its verdict`() throws { let fixture = try Fixture() defer { fixture.remove() } let manager = FileManager.default - for release in ["release-1", "release-2"] { + for (release, contents) in [("release-1", "unsigned build"), ("release-2", "signed release")] { let bin = fixture.root.appendingPathComponent("\(release)/bin") try manager.createDirectory(at: bin, withIntermediateDirectories: true) - try Data(release.utf8).write(to: bin.appendingPathComponent("codex")) + try Data(contents.utf8).write(to: bin.appendingPathComponent("codex")) } let current = fixture.root.appendingPathComponent("current") try manager.createSymbolicLink(atPath: current.path, withDestinationPath: "release-1") @@ -270,20 +295,65 @@ struct CodexLaunchPreflightAssessmentMemoTests { let memo = Memo() let calls = Counter() - _ = memo.assessment(path: codex, isDefinitive: { _ in true }, assess: { _ in - calls.increment() - if calls.count == 1 { - try? manager.removeItem(at: current) - try? manager.createSymbolicLink(atPath: current.path, withDestinationPath: "release-2") - try? manager.removeItem(at: current) - try? manager.createSymbolicLink(atPath: current.path, withDestinationPath: "release-1") - } - return Assessment(output: Self.notAnApp, exitStatus: 3) + #expect(!Self.decide(memo, codex, calls: calls, during: { call in + guard call == 1 else { return } + try? manager.removeItem(at: current) + try? manager.createSymbolicLink(atPath: current.path, withDestinationPath: "release-2") + try? manager.removeItem(at: current) + try? manager.createSymbolicLink(atPath: current.path, withDestinationPath: "release-1") + })) + #expect(!Self.decide(memo, codex, calls: calls)) + #expect(calls.count == 1) + } + + @Test + func `a parent directory swapped during assessment and restored cannot lend its verdict`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let manager = FileManager.default + for (name, contents) in [("tool", "unsigned build"), ("signed", "signed release")] { + let bin = fixture.root.appendingPathComponent("\(name)/bin") + try manager.createDirectory(at: bin, withIntermediateDirectories: true) + try Data(contents.utf8).write(to: bin.appendingPathComponent("codex")) + } + let tool = fixture.root.appendingPathComponent("tool") + let signed = fixture.root.appendingPathComponent("signed") + let aside = fixture.root.appendingPathComponent("tool-aside") + let codex = tool.appendingPathComponent("bin/codex").path + let memo = Memo() + let calls = Counter() + + // The unsigned tool is moved aside, the signed one takes its pathname while spctl runs, and the + // original is restored before it returns. + #expect(!Self.decide(memo, codex, calls: calls, during: { call in + guard call == 1 else { return } + try? manager.moveItem(at: tool, to: aside) + try? manager.moveItem(at: signed, to: tool) + try? manager.moveItem(at: tool, to: signed) + try? manager.moveItem(at: aside, to: tool) + })) + #expect(!Self.decide(memo, codex, calls: calls)) + #expect(!Self.decide(memo, codex, calls: calls)) + #expect(calls.count == 1) + } + + @Test + func `verdicts are reported for the caller's path, not the inode path assessed`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let codex = try fixture.executable("codex") + let memo = Memo() + var assessedPaths: [String] = [] + let first = memo.assessment(path: codex.path, isDefinitive: { _ in true }, assess: { assessed in + assessedPaths.append(assessed) + return Assessment(output: "\(assessed): \(Self.notAnApp)", exitStatus: 3) }) - #expect(calls.count == 2) - _ = Self.assess(memo, codex, calls: calls) + let second = memo.assessment(path: codex.path, isDefinitive: { _ in true }, assess: { _ in nil }) - #expect(calls.count == 3) + #expect(assessedPaths.count == 1) + #expect(assessedPaths.first?.hasPrefix("/.vol/") == true) + #expect(first?.output.hasPrefix("\(codex.path): rejected") == true) + #expect(second?.output == first?.output) } @Test From c9b0edd56383e08456fee1f2866174277c20ef5c Mon Sep 17 00:00:00 2001 From: B Klug Date: Sun, 27 Sep 2026 22:25:30 -0500 Subject: [PATCH 031/122] fix(codex): five-minute verdict lifetime, and commit the native proof Adopt ClawSweeper's recommended five-minute bound on how long a cached Gatekeeper verdict can outlive an in-place certificate revocation. The elevated-cadence case that motivated #4078 still collapses to one assessment per five minutes. Adds .github/pr-proof/codex-gatekeeper-assessment-memo.log with the Intel Mac before/after spctl counts and the production-path harness output. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014giiyt6RgBarhNPpjn2Xiz --- .../codex-gatekeeper-assessment-memo.log | 45 +++++++++++++++++++ .../CodexLaunchPreflight+AssessmentMemo.swift | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .github/pr-proof/codex-gatekeeper-assessment-memo.log diff --git a/.github/pr-proof/codex-gatekeeper-assessment-memo.log b/.github/pr-proof/codex-gatekeeper-assessment-memo.log new file mode 100644 index 0000000000..ccf5371157 --- /dev/null +++ b/.github/pr-proof/codex-gatekeeper-assessment-memo.log @@ -0,0 +1,45 @@ +CodexBar Codex launch preflight: Gatekeeper assessment memo, native proof (#4078) +Verified 2026-09-27 on macOS 15.7.7 (24G720), Intel Core i7-4790K, x86_64. +Codex CLI 0.145.0 standalone: ~/.local/bin/codex -> ~/.codex/packages/standalone/current/bin/codex +(Developer ID Application: OpenAI OpCo, LLC (2DC432GLL2), 281 MB x86_64, no quarantine xattr). +Private paths are shown relative to ~. + +Cost of one assessment (spctl --assess --type execute --verbose=4, back to back, syspolicyd otherwise idle): + run 1 11.5 s wall 4.9 s syspolicyd CPU rejected (the code is valid but does not seem to be an app) + run 2 2.7 s wall 2.6 s syspolicyd CPU same verdict + run 3 2.6 s wall 2.5 s syspolicyd CPU same verdict +The same verdicts come back for /.vol// as for the path, for both the Developer ID codex +and an ad-hoc signed CLI ("rejected" / "source=no usable signature"). + +Before (official 0.57.0; unified log, process == "spctl", all parented by CodexBar): + 16:50-17:10 5 refreshes 10 spctl runs, a pair on every 5-minute refresh (~1.7% of a core) + 17:00-18:00 elevated lookup cadence, a pair every ~12 s: 494 spctl runs; syspolicyd 16.5 s CPU + per 30 s (~55% of a core). Quitting CodexBar: 0.00 s per 30 s. + +After (this PR, packaged 0.68.1 build 160, launched with CODEX_CLI_PATH unset): + 20:31:26, 20:31:31, 20:36:30 cold start: assessment > the existing 5 s spctl timeout, terminated, + not remembered (unchanged behaviour) + 20:36:35 completed in 2.64 s, remembered + 20:41:30, 20:46:31 refreshes codex launched, 0 spctl runs + +Production-path harness: a throwaway local executable calling the public +CodexLaunchPreflight.isLaunchCandidateAllowed(path:) with the real spctl, on a symlink retargeted +between the Developer ID codex and an ad-hoc signed CLI. + + codex (Developer ID), lookup 1 ALLOWED 2580 ms spctl on /.vol//, remembered + codex, lookup 2 ALLOWED 0 ms memo + codex, lookup 3 ALLOWED 0 ms memo + link retargeted to ad-hoc binary BLOCKED 76 ms a different file, its own assessment + ad-hoc binary, lookup 2 BLOCKED 0 ms memo + link retargeted back to codex ALLOWED 0 ms the same file as lookup 1, its verdict + codex, lookup 5 ALLOWED 0 ms memo + +Shared in-flight assessment (fresh process): the leader starts spctl on codex, a second lookup joins at +300 ms, and the link is retargeted to the ad-hoc binary at 1004 ms. + + leader (starts spctl on codex) BLOCKED returned at 2637 ms + waiter (joins the same assessment) BLOCKED returned at 2637 ms + +Unified log for that phase: one spctl on codex (2.47 s), then two short spctl runs on the ad-hoc binary, +one fresh assessment per caller. Neither caller was answered with the verdict for the file its path no +longer named. diff --git a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift index dd31f05b03..9f191bd7f9 100644 --- a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift +++ b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift @@ -21,7 +21,7 @@ extension CodexLaunchPreflight { final class AssessmentMemo: @unchecked Sendable { static let shared = AssessmentMemo() static let capacity = 16 - static let lifetime: TimeInterval = 15 * 60 + static let lifetime: TimeInterval = 5 * 60 /// Assessments are synchronous. Each pending file has its own result promise, so waiting callers /// share even a transient result without holding the dictionary lock or blocking unrelated files. From 32119dae74814f53437959a88c7646c4d0fe7b2d Mon Sep 17 00:00:00 2001 From: Dohyeon Park Date: Mon, 28 Sep 2026 12:34:05 +0900 Subject: [PATCH 032/122] Keep recorded quota context clear without hiding usage history Preserve both menu charts, label saved capture age, and give multi-day windows calendar endpoints. Include an isolated native proof fixture. Constraint: Saved account history does not prove a fresh live quota window Rejected: Bare live snapshot reconciliation | Account ownership cannot be proven for every selected series Confidence: high Scope-risk: narrow Directive: Keep the original utilization chart accessible alongside burndown Tested: 69 focused tests; make check; weekly render; native submenu accessibility Not-tested: Full suite stopped on two unchanged AdaptiveRefreshTimerTests cancellation failures after retry --- README.md | 2 +- Sources/CodexBar/CodexbarApp.swift | 3 + .../CodexBar/QuotaBurndownChartMenuView.swift | 36 ++- .../CodexBar/QuotaBurndownNativeProof.swift | 235 ++++++++++++++++++ ...tatusItemController+UsageHistoryMenu.swift | 2 +- .../QuotaBurndownChartMenuViewTests.swift | 35 ++- .../QuotaBurndownRenderProofTests.swift | 18 +- .../StatusMenuHostedSubmenuRefreshTests.swift | 50 ++++ docs/widgets/burndown-native-synthetic.png | Bin 0 -> 197106 bytes docs/widgets/burndown-proof.md | 37 +++ docs/widgets/burndown-weekly-synthetic.png | Bin 0 -> 43109 bytes 11 files changed, 404 insertions(+), 14 deletions(-) create mode 100644 Sources/CodexBar/QuotaBurndownNativeProof.swift create mode 100644 docs/widgets/burndown-native-synthetic.png create mode 100644 docs/widgets/burndown-proof.md create mode 100644 docs/widgets/burndown-weekly-synthetic.png diff --git a/README.md b/README.md index 9c76a58e22..cac4679564 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ show an incident indicator. - Provider-specific usage meters with reset countdowns. - Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history). - Inline spend and usage charts for API-backed providers such as OpenAI, Claude Admin API, OpenRouter, LiteLLM, z.ai, MiniMax, Mistral, and AWS Bedrock. -- Codex and Claude Plan Usage menus show a current-window quota burndown from recorded snapshots, alongside an even-use guide. The line fills in as the app collects samples. +- Codex and Claude Plan Usage menus show a quota burndown from recorded snapshots, with the capture time and an even-use guide. The existing utilization history remains below it, and the line fills in as the app collects samples. - Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories. Codex history uses a WAL-enabled SQLite store capped at 25,000 retained session entries and 256 MiB. - A persistent Settings → Usage & Spend view for local estimates, grouped by native currency and provider. Each provider shows its accounts or history sources alongside its model breakdown; project/session views and daily/hourly trends share compact selectors. Incomplete history stays labeled, and source, privacy, export, and sharing controls remain available. - Provider status polling with incident badges in the menu and icon overlay. diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 2ac82fb885..46e9fd4575 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -27,6 +27,9 @@ enum CodexBarEntryPoint { exit(CodexBarCoreResourceSmoke.run()) } #if DEBUG + if QuotaBurndownNativeProof.runIfRequested() { + return + } if MenuBarLayoutNativeProof.runIfRequested() { return } diff --git a/Sources/CodexBar/QuotaBurndownChartMenuView.swift b/Sources/CodexBar/QuotaBurndownChartMenuView.swift index 23edc93c60..c16688fec3 100644 --- a/Sources/CodexBar/QuotaBurndownChartMenuView.swift +++ b/Sources/CodexBar/QuotaBurndownChartMenuView.swift @@ -8,6 +8,7 @@ struct QuotaBurndownChartMenuView: View { let id: String let title: String let model: QuotaBurndownModel + let lastKnownUsageMessage: String } private let series: [Series] @@ -43,7 +44,13 @@ struct QuotaBurndownChartMenuView: View { case .opus: title = L("Opus") default: return nil } - return Series(id: "\(history.name.rawValue):\(history.windowMinutes)", title: title, model: model) + return Series( + id: "\(history.name.rawValue):\(history.windowMinutes)", + title: title, + model: model, + lastKnownUsageMessage: LastKnownUsagePresentation.message( + capturedAt: latest.capturedAt, + now: referenceDate)) } self.width = width let accent = ProviderAccentPalette.color(for: provider) @@ -106,9 +113,9 @@ struct QuotaBurndownChartMenuView: View { .accessibilityLabel(L("Usage remaining")) HStack { - Text(selected.model.start.formatted(.dateTime.hour().minute())) + self.axisLabel(for: selected.model.start, model: selected.model, alignment: .leading) Spacer() - Text(selected.model.reset.formatted(.dateTime.hour().minute())) + self.axisLabel(for: selected.model.reset, model: selected.model, alignment: .trailing) } .font(.caption) .foregroundStyle(.secondary) @@ -118,6 +125,10 @@ struct QuotaBurndownChartMenuView: View { .font(.caption) .foregroundStyle(.secondary) } + Text(selected.lastKnownUsageMessage) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } else { Text(L("No data")) .font(.footnote) @@ -135,7 +146,26 @@ struct QuotaBurndownChartMenuView: View { !self.series.isEmpty } + private func axisLabel( + for date: Date, + model: QuotaBurndownModel, + alignment: HorizontalAlignment) -> some View + { + VStack(alignment: alignment, spacing: 2) { + if model.reset.timeIntervalSince(model.start) >= 86400 { + Text(date.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day() + .locale(codexBarLocalizedLocale()))) + } + Text(date.formatted(.dateTime.hour().minute().locale(codexBarLocalizedLocale()))) + } + .accessibilityElement(children: .combine) + } + #if DEBUG + var _seriesLastKnownMessagesForTesting: [String: String] { + Dictionary(uniqueKeysWithValues: self.series.map { ($0.id, $0.lastKnownUsageMessage) }) + } + var _seriesRemainingForTesting: [String: Double] { Dictionary(uniqueKeysWithValues: self.series.compactMap { series in series.model.samples.last.map { (series.id, $0.remainingPercent) } diff --git a/Sources/CodexBar/QuotaBurndownNativeProof.swift b/Sources/CodexBar/QuotaBurndownNativeProof.swift new file mode 100644 index 0000000000..eb6ae5d70c --- /dev/null +++ b/Sources/CodexBar/QuotaBurndownNativeProof.swift @@ -0,0 +1,235 @@ +#if DEBUG +import AppKit +import CodexBarCore + +/// A separate, opt-in process exercises the production lazy submenu with synthetic captures. +@MainActor +enum QuotaBurndownNativeProof { + static func runIfRequested() -> Bool { + guard CommandLine.arguments.contains("--quota-burndown-proof") else { return false } + // SettingsStore has no injected app-group migration switch. Its existing test gate also + // disables shared defaults, login-item registration, and automatic background work. + setenv("SWIFT_TESTING_ENABLED", "1", 1) + guard TestProcessSafety.isRunning, SettingsStore.isRunningTests else { + FileHandle.standardError.write(Data("Quota proof requires isolated process safety gates.\n".utf8)) + return true + } + KeychainAccessGate.isDisabled = true + let app = NSApplication.shared + app.setActivationPolicy(.regular) + let delegate = Delegate() + app.delegate = delegate + withExtendedLifetime(delegate) { app.run() } + return true + } + + @MainActor + private final class Delegate: NSObject, NSApplicationDelegate { + private var controller: StatusItemController? + private var store: UsageStore? + private var settings: SettingsStore? + private var item: NSStatusItem? + private var directory: URL? + private var window: NSWindow? + private var stale = false + + func applicationDidFinishLaunching(_ notification: Notification) { + do { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBar-quota-proof-\(UUID().uuidString)", isDirectory: true) + self.directory = directory + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let configStore = CodexBarConfigStore(fileURL: directory.appendingPathComponent("config.json")) + try configStore.save(CodexBarConfig(providers: UsageProvider.allCases.map { + // Provider-specific by design: this native proof enables only synthetic Codex quota lanes. + ProviderConfig(id: $0.instanceID, enabled: $0 == .codex) + })) + let defaults = ProofDefaults(values: [ + "debugDisableKeychainAccess": true, + "agentSessionsEnabled": false, + "openAIWebAccessEnabled": false, + "launchAtLogin": false, + ]) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + tokenAccountStore: FileTokenAccountStore(fileURL: directory + .appendingPathComponent("accounts.json")), + antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore( + fileURL: directory.appendingPathComponent("antigravity.json")), + performInitialProviderDetection: false) + // Ownership selection otherwise consults real Codex auth state even without polling. + settings._test_codexAccountSnapshotLoader = { source in + CodexAccountReconciliationSnapshot( + storedAccounts: [], + activeStoredAccount: nil, + liveSystemAccount: nil, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: source, + hasUnreadableAddedAccountStore: false) + } + let environment = ["HOME": directory.path, "CODEX_HOME": directory.path] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + historicalUsageHistoryStore: HistoricalUsageHistoryStore( + fileURL: directory.appendingPathComponent("historical.json")), + planUtilizationHistoryStore: PlanUtilizationHistoryStore(directoryURL: nil), + startupBehavior: .testing, + environmentBase: environment, + widgetTimelineReloader: {}) + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + menuCardRenderingEnabled: true, + menuRefreshEnabled: false, + observeProviderConfigNotifications: false) + controller.statusItem.isVisible = false + self.settings = settings + self.store = store + self.controller = controller + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.title = "Quota proof" + item.button?.setAccessibilityIdentifier("codexbar-synthetic-quota-proof") + self.item = item + self.rebuildMenu() + self.showProofWindow() + } catch { + FileHandle.standardError.write(Data("Quota proof failed: \(error)\n".utf8)) + NSApplication.shared.terminate(nil) + } + } + + private func showProofWindow() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 560, height: 200), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false) + window.title = "CodexBar native menu proof" + let label = NSTextField(wrappingLabelWithString: + "Synthetic data only. Open Plan Usage, then choose Weekly in the burndown chart. " + + "The historical chart remains below. No accounts or providers are contacted.") + label.frame = NSRect(x: 24, y: 105, width: 512, height: 70) + window.contentView?.addSubview(label) + let button = NSButton(title: "Open Plan Usage", target: self, action: #selector(self.openMenu(_:))) + button.frame = NSRect(x: 24, y: 45, width: 180, height: 34) + window.contentView?.addSubview(button) + window.center() + window.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + self.window = window + } + + @objc private func openMenu(_ sender: NSButton) { + self.item?.menu?.popUp( + positioning: nil, + at: NSPoint(x: sender.frame.minX, y: sender.frame.minY), + in: sender.superview) + } + + private func rebuildMenu() { + guard let store, let controller, let item else { return } + let now = Date() + let sessionReset = now.addingTimeInterval(2 * 3600) + let weeklyReset = now.addingTimeInterval(2 * 24 * 3600) + let sessionCapture = now.addingTimeInterval(self.stale ? -3600 : -120) + let weeklyCapture = now.addingTimeInterval(self.stale ? -12 * 3600 : -3 * 3600) + var buckets = PlanUtilizationHistoryBuckets() + buckets.setHistories([ + PlanUtilizationSeriesHistory(name: .session, windowMinutes: 300, entries: [ + .init(capturedAt: now.addingTimeInterval(-2 * 3600), usedPercent: 8, resetsAt: sessionReset), + .init(capturedAt: sessionCapture, usedPercent: 42, resetsAt: sessionReset), + ]), + PlanUtilizationSeriesHistory(name: .weekly, windowMinutes: 10080, entries: [ + .init(capturedAt: now.addingTimeInterval(-4 * 24 * 3600), usedPercent: 5, resetsAt: weeklyReset), + .init(capturedAt: now.addingTimeInterval(-3 * 24 * 3600), usedPercent: 22, resetsAt: weeklyReset), + .init(capturedAt: now.addingTimeInterval(-24 * 3600), usedPercent: 48, resetsAt: weeklyReset), + .init(capturedAt: weeklyCapture, usedPercent: 68, resetsAt: weeklyReset), + ]), + ], for: nil) + // Provider-specific by design: the fixture seeds synthetic Codex history without a live snapshot. + store.planUtilizationHistory[.codex] = buckets + store.planUtilizationHistoryLoaded = true + store.planUtilizationHistoryRevision &+= 1 + // There is deliberately no live snapshot: these are explicitly saved captures. + let menu = NSMenu(title: "Synthetic quota proof") + menu.autoenablesItems = false + menu.addItem(NSMenuItem(title: "Synthetic data only", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem( + title: self.stale ? "Older captures · no live snapshot" : "Session recent · Weekly 3h old", + action: nil, + keyEquivalent: "")) + menu.addItem(.separator()) + // Provider-specific by design: exercise the production Codex Plan Usage submenu. + if let submenu = controller.makeUsageHistorySubmenu(provider: .codex, width: 400) { + let entry = NSMenuItem(title: "Plan Usage", action: nil, keyEquivalent: "") + entry.isEnabled = true + entry.submenu = submenu + menu.addItem(entry) + // Keep the placeholder untouched; the real controller hydrates on submenu open. + } + menu.addItem(.separator()) + let toggle = NSMenuItem(title: "Toggle older captures", action: #selector(self.toggle), keyEquivalent: "") + toggle.target = self + menu.addItem(toggle) + let quit = NSMenuItem(title: "Quit proof", action: #selector(self.quit), keyEquivalent: "") + quit.target = self + menu.addItem(quit) + item.menu = menu + FileHandle.standardOutput.write(Data("quota-proof ready synthetic-only stale=\(self.stale)\n".utf8)) + } + + @objc private func toggle() { + self.stale.toggle() + self.rebuildMenu() + } + + @objc private func quit() { + NSApplication.shared.terminate(nil) + } + + func applicationWillTerminate(_ notification: Notification) { + self.controller?.prepareForAppShutdown() + if let item { NSStatusBar.system.removeStatusItem(item) } + // Only the unique synthetic directory created by this process is removed. + if let directory { try? FileManager.default.removeItem(at: directory) } + } + } + + /// Absent keys cannot fall through to Foundation defaults domains; writes stay in memory. + private final class ProofDefaults: UserDefaults, @unchecked Sendable { + private let lock = NSLock() + private var values: [String: Any] + + init(values: [String: Any]) { + self.values = values + super.init(suiteName: "QuotaProof-\(UUID().uuidString)")! + } + + override func object(forKey key: String) -> Any? { self.lock.withLock { self.values[key] } } + override func set(_ value: Any?, forKey key: String) { self.lock.withLock { self.values[key] = value } } + override func removeObject(forKey key: String) { self.set(nil as Any?, forKey: key) } + override func bool(forKey key: String) -> Bool { (self.object(forKey: key) as? NSNumber)?.boolValue ?? false } + override func integer(forKey key: String) -> Int { (self.object(forKey: key) as? NSNumber)?.intValue ?? 0 } + override func float(forKey key: String) -> Float { (self.object(forKey: key) as? NSNumber)?.floatValue ?? 0 } + override func double(forKey key: String) -> Double { (self.object(forKey: key) as? NSNumber)?.doubleValue ?? 0 } + override func string(forKey key: String) -> String? { self.object(forKey: key) as? String } + override func array(forKey key: String) -> [Any]? { self.object(forKey: key) as? [Any] } + override func dictionary(forKey key: String) -> [String: Any]? { self.object(forKey: key) as? [String: Any] } + override func data(forKey key: String) -> Data? { self.object(forKey: key) as? Data } + override func stringArray(forKey key: String) -> [String]? { self.object(forKey: key) as? [String] } + override func url(forKey key: String) -> URL? { self.object(forKey: key) as? URL } + override func set(_ value: Bool, forKey key: String) { self.set(value as Any, forKey: key) } + override func set(_ value: Int, forKey key: String) { self.set(value as Any, forKey: key) } + override func set(_ value: Float, forKey key: String) { self.set(value as Any, forKey: key) } + override func set(_ value: Double, forKey key: String) { self.set(value as Any, forKey: key) } + override func set(_ value: URL?, forKey key: String) { self.set(value as Any?, forKey: key) } + override func dictionaryRepresentation() -> [String: Any] { self.lock.withLock { self.values } } + } +} +#endif diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 1b2c135fda..e27d0d62d7 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -66,7 +66,7 @@ extension StatusItemController { chartItem.representedObject = Self.usageHistoryChartID chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) - return true + submenu.addItem(.separator()) } } diff --git a/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift b/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift index abd324691a..8e6193075e 100644 --- a/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift +++ b/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift @@ -18,7 +18,10 @@ struct QuotaBurndownChartMenuViewTests { name: .opus, windowMinutes: 10080, entries: [ - .init(capturedAt: now, usedPercent: 70, resetsAt: now.addingTimeInterval(7200)), + .init( + capturedAt: now.addingTimeInterval(-7200), + usedPercent: 70, + resetsAt: now.addingTimeInterval(7200)), ]), ] @@ -30,6 +33,36 @@ struct QuotaBurndownChartMenuViewTests { #expect(view._seriesRemainingForTesting["weekly:10080"] == 80) #expect(view._seriesRemainingForTesting["opus:10080"] == 30) + #expect(view._seriesLastKnownMessagesForTesting["weekly:10080"] == LastKnownUsagePresentation.message( + capturedAt: now, + now: now)) + #expect(view._seriesLastKnownMessagesForTesting["opus:10080"] == LastKnownUsagePresentation.message( + capturedAt: now.addingTimeInterval(-7200), + now: now)) + } + + @Test(arguments: [60.0, 21600.0, 172_800.0]) + func `saved weekly usage reports actual capture time rather than current time`(age: TimeInterval) { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let capturedAt = now.addingTimeInterval(-age) + let history = PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 10080, + entries: [.init(capturedAt: capturedAt, usedPercent: 40, resetsAt: now.addingTimeInterval(86400))]) + let view = QuotaBurndownChartMenuView( + provider: .codex, + histories: [history], + width: 400, + referenceDate: now) + + #expect(view.hasSeries) + #expect(view._seriesRemainingForTesting["weekly:10080"] == 60) + #expect(view._seriesLastKnownMessagesForTesting["weekly:10080"] == LastKnownUsagePresentation.message( + capturedAt: capturedAt, + now: now)) + #expect(view._seriesLastKnownMessagesForTesting["weekly:10080"] != LastKnownUsagePresentation.message( + capturedAt: now, + now: now)) } @Test diff --git a/Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift b/Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift index 09994a7d2c..933eeb9e33 100644 --- a/Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift +++ b/Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift @@ -9,19 +9,21 @@ struct QuotaBurndownRenderProofTests { @Test func `render current window from synthetic quota samples`() throws { guard let path = ProcessInfo.processInfo.environment["CODEXBAR_BURNDOWN_PROOF_PATH"] else { return } + let weekly = ProcessInfo.processInfo.environment["CODEXBAR_BURNDOWN_PROOF_WEEKLY"] == "1" let now = Date() - let reset = now.addingTimeInterval(2 * 3600) + let reset = now.addingTimeInterval(weekly ? 2 * 86400 : 2 * 3600) + let sampleInterval: TimeInterval = weekly ? 86400 : 3600 let history = PlanUtilizationSeriesHistory( - name: .session, - windowMinutes: 300, + name: weekly ? .weekly : .session, + windowMinutes: weekly ? 10080 : 300, entries: [ - .init(capturedAt: now.addingTimeInterval(-2 * 3600), usedPercent: 10, resetsAt: reset), - .init(capturedAt: now.addingTimeInterval(-3600), usedPercent: 35, resetsAt: reset), - .init(capturedAt: now.addingTimeInterval(-1800), usedPercent: 48, resetsAt: reset), + .init(capturedAt: now.addingTimeInterval(-2 * sampleInterval), usedPercent: 10, resetsAt: reset), + .init(capturedAt: now.addingTimeInterval(-sampleInterval), usedPercent: 35, resetsAt: reset), + .init(capturedAt: now.addingTimeInterval(-sampleInterval / 2), usedPercent: 48, resetsAt: reset), ]) let current = PlanUtilizationSeriesHistory( - name: .session, - windowMinutes: 300, + name: history.name, + windowMinutes: history.windowMinutes, entries: history.entries + [ .init(capturedAt: now, usedPercent: 60, resetsAt: reset), ]) diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 18693cc4b8..01627f33ee 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -1,5 +1,6 @@ import AppKit import CodexBarCore +import SwiftUI import Testing @testable import CodexBar @@ -380,6 +381,55 @@ struct StatusMenuHostedSubmenuRefreshTests { #expect(bobView !== aliceView) } + @Test + func `active saved quota keeps utilization history accessible through submenu refresh`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + Self.enableOnlyClaude(settings) + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + Self.seedClaudeSnapshots(in: store) + let now = Date() + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [ + PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 10080, + entries: [.init( + capturedAt: now.addingTimeInterval(-21600), + usedPercent: 24, + resetsAt: now.addingTimeInterval(86400))]), + ]) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + let submenu = try #require(controller.makeUsageHistorySubmenu( + provider: .claude, + width: StatusItemController.menuCardBaseWidth)) + controller.menuWillOpen(submenu) + + #expect(submenu.items.count == 3) + #expect(submenu.items.first?.view is NSHostingView) + #expect(submenu.items[1].isSeparatorItem) + #expect(submenu.items.last?.view is NSHostingView) + + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [Self.makePlanHistory( + usedPercent: 30)]) + store.planUtilizationHistoryRevision &+= 1 + controller.refreshHostedSubviewMenu(submenu) + + #expect(submenu.items.count == 1) + #expect(submenu.items.first?.view is NSHostingView) + } + private func assertHostedChartItemHeightMatchesRefresh( chartID: String, provider: UsageProvider, diff --git a/docs/widgets/burndown-native-synthetic.png b/docs/widgets/burndown-native-synthetic.png new file mode 100644 index 0000000000000000000000000000000000000000..0f67b6131e88d35b9d23f4b7f5d021793bc99852 GIT binary patch literal 197106 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�_sxHqLe?-N0gmh+G`{ZsGhLWtl2h1B7hcNlX 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�E&` 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 literal 0 HcmV?d00001 diff --git a/docs/widgets/burndown-proof.md b/docs/widgets/burndown-proof.md new file mode 100644 index 0000000000..d3a7a028b6 --- /dev/null +++ b/docs/widgets/burndown-proof.md @@ -0,0 +1,37 @@ +# Quota burndown proof + +The debug app has an opt-in synthetic fixture for the production Plan Usage submenu: + +```sh +CODEXBAR_SIGNING=adhoc ./Scripts/package_app.sh debug +open -n CodexBar.app --args --quota-burndown-proof +``` + +Choose **Open Plan Usage**, then **Weekly** in the upper chart. The submenu uses the +same lazy hydration and hosted views as the normal menu. It shows the recorded +burndown, its capture age, and the original utilization chart below a separator. +Weekly endpoints include localized weekday, month, date, and time. Session endpoints +remain compact time labels. + +The fixture runs before normal startup, disables Keychain access and background +refresh, and stores its synthetic configuration in a unique temporary directory. +It does not contact providers or load real accounts. **Toggle older captures** +exercises the last-known label without supplying a live snapshot. + +The native submenu below was captured from the running synthetic fixture. It shows +both charts and the capture-age label, before the calendar-label follow-up. + +![Running native Plan Usage submenu](burndown-native-synthetic.png) + +A separate rendering fixture produces the calendar-label screenshot: + +```sh +CODEXBAR_BURNDOWN_PROOF_PATH=/tmp/codexbar-weekly-calendar-proof.png \ +CODEXBAR_BURNDOWN_PROOF_WEEKLY=1 \ +swift test --filter QuotaBurndownRenderProofTests +``` + +![Weekly burndown with calendar endpoints](burndown-weekly-synthetic.png) + +This image is a hosted-view render. Native submenu verification also confirmed both +charts and the weekly endpoint labels in the running debug app's accessibility tree. diff --git a/docs/widgets/burndown-weekly-synthetic.png b/docs/widgets/burndown-weekly-synthetic.png new file mode 100644 index 0000000000000000000000000000000000000000..d9f73d45dd9114fba7272a2d04c74656822407f5 GIT binary patch literal 43109 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 literal 0 HcmV?d00001 From 7ab7e566843b86090dcb27c2e3ff298a80352ed8 Mon Sep 17 00:00:00 2001 From: B Klug Date: Sun, 27 Sep 2026 23:13:03 -0500 Subject: [PATCH 033/122] fix(codex): revalidate the caller's path on a memo cache hit too Review of revision 5 found that the cache-hit branch returned a remembered verdict after the initial stat without re-checking that the caller's path still names that file, unlike the shared and fresh branches. All three branches now return through one `deliver` step that re-checks the caller's path immediately before answering, and otherwise runs a fresh, unshared assessment. Adds a final-decision regression through isLaunchCandidateAllowed with a test hook that retargets the link to an unsigned CLI inside the cache-hit window: the decision is blocked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014giiyt6RgBarhNPpjn2Xiz --- .../codex-gatekeeper-assessment-memo.log | 13 ++++++++ .../CodexLaunchPreflight+AssessmentMemo.swift | 33 ++++++++++++------- ...exLaunchPreflightAssessmentMemoTests.swift | 27 +++++++++++++++ 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/.github/pr-proof/codex-gatekeeper-assessment-memo.log b/.github/pr-proof/codex-gatekeeper-assessment-memo.log index ccf5371157..f328be9178 100644 --- a/.github/pr-proof/codex-gatekeeper-assessment-memo.log +++ b/.github/pr-proof/codex-gatekeeper-assessment-memo.log @@ -43,3 +43,16 @@ Shared in-flight assessment (fresh process): the leader starts spctl on codex, a Unified log for that phase: one spctl on codex (2.47 s), then two short spctl runs on the ad-hoc binary, one fresh assessment per caller. Neither caller was answered with the verdict for the file its path no longer named. + +Cache-hit window (review of revision 5): production decision function (the internal +isLaunchCandidateAllowed seam), production AssessmentMemo, a real spctl run with the production +arguments, and the memo's onCacheHit test hook retargeting the link to the ad-hoc binary after the +remembered entry is found and before it is returned. + + codex (Developer ID), lookup 1 ALLOWED 9479 ms spctl runs: 1 (cold) + codex, lookup 2 (cache hit) ALLOWED 0 ms spctl runs: 0 + cache hit; link -> ad-hoc inside the hit window BLOCKED 138 ms spctl runs: 1 + ad-hoc binary, next lookup BLOCKED 137 ms spctl runs: 1 + +The remembered "allowed" for codex did not reach the decision for the ad-hoc binary: the path re-check +before answering saw a different file and ran a fresh, unshared assessment instead. diff --git a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift index 9f191bd7f9..231f3d90e2 100644 --- a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift +++ b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift @@ -53,9 +53,11 @@ extension CodexLaunchPreflight { private var entries: [FileIdentity: (assessment: GatekeeperAssessment, expiresAt: TimeInterval)] = [:] private var flights: [FileIdentity: Flight] = [:] private let onJoin: @Sendable () -> Void + private let onCacheHit: @Sendable () -> Void - init(onJoin: @escaping @Sendable () -> Void = {}) { + init(onJoin: @escaping @Sendable () -> Void = {}, onCacheHit: @escaping @Sendable () -> Void = {}) { self.onJoin = onJoin + self.onCacheHit = onCacheHit } /// Returns the remembered verdict for the unchanged regular file `path` names, or assesses it. Only @@ -73,17 +75,14 @@ extension CodexLaunchPreflight { self.lock.lock() if let entry = self.entries[file], now < entry.expiresAt { self.lock.unlock() - return Self.attributed(entry.assessment, from: file.volumePath, to: path) + self.onCacheHit() + return Self.deliver(entry.assessment, bound: true, file: file, path: path, assess: assess) } if let flight = self.flights[file] { self.lock.unlock() self.onJoin() let shared = flight.wait() - // The verdict speaks for the file that was assessed; this caller's path must still name it. - if shared.bound, FileIdentity(path: path) == file { - return Self.attributed(shared.assessment, from: file.volumePath, to: path) - } - return assess(path) + return Self.deliver(shared.assessment, bound: shared.bound, file: file, path: path, assess: assess) } let flight = Flight() self.flights[file] = flight @@ -107,11 +106,21 @@ extension CodexLaunchPreflight { flight.complete(result, bound: bound) self.flights.removeValue(forKey: file) } - // A verdict for a file the path no longer names is not an answer for this lookup. - if bound, FileIdentity(path: path) == file { - return Self.attributed(result, from: file.volumePath, to: path) - } - return assess(path) + return Self.deliver(result, bound: bound, file: file, path: path, assess: assess) + } + + /// Every answer (cache hit, shared, or fresh) is checked against what the caller's path names + /// immediately before it is returned. A verdict for a file the path no longer names is not an answer + /// for this lookup, so the caller gets a fresh, unshared assessment of its path instead. + private static func deliver( + _ assessment: GatekeeperAssessment?, + bound: Bool, + file: FileIdentity, + path: String, + assess: (String) -> GatekeeperAssessment?) -> GatekeeperAssessment? + { + guard bound, FileIdentity(path: path) == file else { return assess(path) } + return self.attributed(assessment, from: file.volumePath, to: path) } /// `spctl` names the path it was given at the start of its first line; report the caller's path. diff --git a/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift index 6f7cdc7280..20227fe5c2 100644 --- a/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift +++ b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift @@ -337,6 +337,33 @@ struct CodexLaunchPreflightAssessmentMemoTests { #expect(calls.count == 1) } + @Test + func `a cache hit revalidates the caller's path before answering`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let signed = try fixture.executable("codex-signed", contents: "signed release") + let unsigned = try fixture.executable("codex-unsigned", contents: "unsigned build") + let link = fixture.root.appendingPathComponent("codex") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: signed) + let armed = Counter() + // Fires after the remembered entry is found and before it is returned: the narrowest cache-hit window. + let memo = Memo(onCacheHit: { + guard armed.count == 1 else { return } + try? FileManager.default.removeItem(at: link) + try? FileManager.default.createSymbolicLink(at: link, withDestinationURL: unsigned) + }) + let calls = Counter() + + #expect(Self.decide(memo, link.path, calls: calls)) + #expect(Self.decide(memo, link.path, calls: calls)) + #expect(calls.count == 1) + + armed.increment() + // The signed CLI's remembered "allowed" must not reach the decision for the unsigned one. + #expect(!Self.decide(memo, link.path, calls: calls)) + #expect(calls.count == 2) + } + @Test func `verdicts are reported for the caller's path, not the inode path assessed`() throws { let fixture = try Fixture() From f6d5c96efb9797967f94b3eda4250051d9170342 Mon Sep 17 00:00:00 2001 From: Dohyeon Park Date: Mon, 28 Sep 2026 15:03:30 +0900 Subject: [PATCH 034/122] Keep upgraded Codex quota windows consistent across menu charts Reuse the utilization chart's provider classification and combine legacy aliases with migrated monthly history without rewriting saved data. Constraint: Existing thirty-day Codex history can retain Session or Weekly names Rejected: A second duration classifier | Shared provider policy prevents naming drift Confidence: high Scope-risk: narrow Tested: 66 focused tests; make check; independent code review; diff check Not-tested: Unfiltered full suite on this low-power Mac; two existing timer assumptions depend on unconstrained power state --- .../PlanUtilizationHistoryChartMenuView.swift | 2 +- .../CodexBar/QuotaBurndownChartMenuView.swift | 31 ++++++++++- .../QuotaBurndownChartMenuViewTests.swift | 51 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 1faeff444b..21b782935d 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -237,7 +237,7 @@ struct PlanUtilizationHistoryChartMenuView: View { /// Histories recorded before duration-based classification stored a 43,200-minute Codex window /// under its payload slot (session for primary, weekly for secondary). Fold those into the /// monthly series so the chart does not split or hide the window's history. - private nonisolated static func effectiveSeriesName( + nonisolated static func effectiveSeriesName( provider: UsageProvider, history: PlanUtilizationSeriesHistory) -> PlanUtilizationSeriesName { diff --git a/Sources/CodexBar/QuotaBurndownChartMenuView.swift b/Sources/CodexBar/QuotaBurndownChartMenuView.swift index c16688fec3..83732d3be0 100644 --- a/Sources/CodexBar/QuotaBurndownChartMenuView.swift +++ b/Sources/CodexBar/QuotaBurndownChartMenuView.swift @@ -23,7 +23,7 @@ struct QuotaBurndownChartMenuView: View { width: CGFloat, referenceDate: Date = Date()) { - self.series = histories.compactMap { history in + self.series = Self.normalizedHistories(histories, provider: provider).compactMap { history in guard let latest = history.entries.last, let reset = latest.resetsAt, latest.capturedAt <= referenceDate, @@ -146,6 +146,27 @@ struct QuotaBurndownChartMenuView: View { !self.series.isEmpty } + private static func normalizedHistories( + _ histories: [PlanUtilizationSeriesHistory], + provider: UsageProvider) -> [PlanUtilizationSeriesHistory] + { + var orderedIDs: [String] = [] + var historiesByID: [String: PlanUtilizationSeriesHistory] = [:] + for history in histories { + guard [.session, .weekly, .monthly, .opus].contains(history.name) else { continue } + let name = PlanUtilizationHistoryChartMenuView.effectiveSeriesName(provider: provider, history: history) + let windowMinutes = name.canonicalWindowMinutes(history.windowMinutes) + let id = "\(name.rawValue):\(windowMinutes)" + if historiesByID[id] == nil { orderedIDs.append(id) } + historiesByID[id] = PlanUtilizationSeriesHistory( + name: name, + windowMinutes: windowMinutes, + entries: PlanUtilizationHistoryChartMenuView.mergedEntries( + (historiesByID[id]?.entries ?? []) + history.entries)) + } + return orderedIDs.compactMap { historiesByID[$0] } + } + private func axisLabel( for date: Date, model: QuotaBurndownModel, @@ -162,6 +183,14 @@ struct QuotaBurndownChartMenuView: View { } #if DEBUG + var _seriesTitlesForTesting: [String: String] { + Dictionary(uniqueKeysWithValues: self.series.map { ($0.id, $0.title) }) + } + + var _seriesSampleCountsForTesting: [String: Int] { + Dictionary(uniqueKeysWithValues: self.series.map { ($0.id, $0.model.samples.count) }) + } + var _seriesLastKnownMessagesForTesting: [String: String] { Dictionary(uniqueKeysWithValues: self.series.map { ($0.id, $0.lastKnownUsageMessage) }) } diff --git a/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift b/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift index 8e6193075e..a13a37bfaa 100644 --- a/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift +++ b/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift @@ -4,6 +4,57 @@ import Testing @MainActor struct QuotaBurndownChartMenuViewTests { + @Test(arguments: [PlanUtilizationSeriesName.session, .weekly]) + func `saved legacy Codex thirty day windows display as monthly`(name: PlanUtilizationSeriesName) throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let history = PlanUtilizationSeriesHistory( + name: name, + windowMinutes: 43200, + entries: [.init(capturedAt: now, usedPercent: 40, resetsAt: now.addingTimeInterval(86400))]) + let saved = try JSONDecoder().decode( + PlanUtilizationSeriesHistory.self, + from: JSONEncoder().encode(history)) + let view = QuotaBurndownChartMenuView( + provider: .codex, + histories: [saved], + width: 400, + referenceDate: now) + + #expect(saved.name == name) + #expect(view._seriesTitlesForTesting == ["monthly:43200": L("Monthly")]) + #expect(view._seriesRemainingForTesting == ["monthly:43200": 60]) + } + + @Test + func `legacy and migrated monthly captures merge without duplicate tabs or lost samples`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let reset = now.addingTimeInterval(86400) + let histories = [ + PlanUtilizationSeriesHistory(name: .session, windowMinutes: 43200, entries: [ + .init(capturedAt: now.addingTimeInterval(-7200), usedPercent: 20, resetsAt: reset), + ]), + PlanUtilizationSeriesHistory(name: .weekly, windowMinutes: 43200, entries: [ + .init(capturedAt: now.addingTimeInterval(-3600), usedPercent: 40, resetsAt: reset), + ]), + PlanUtilizationSeriesHistory(name: .monthly, windowMinutes: 43200, entries: [ + .init(capturedAt: now.addingTimeInterval(-10800), usedPercent: 10, resetsAt: reset), + .init(capturedAt: now.addingTimeInterval(-7200), usedPercent: 20, resetsAt: reset), + ]), + ] + let view = QuotaBurndownChartMenuView( + provider: .codex, + histories: histories, + width: 400, + referenceDate: now) + + #expect(view._seriesTitlesForTesting == ["monthly:43200": L("Monthly")]) + #expect(view._seriesRemainingForTesting == ["monthly:43200": 60]) + #expect(view._seriesSampleCountsForTesting == ["monthly:43200": 3]) + #expect(view._seriesLastKnownMessagesForTesting["monthly:43200"] == LastKnownUsagePresentation.message( + capturedAt: now.addingTimeInterval(-3600), + now: now)) + } + @Test func `keeps same duration quota lanes separate`() { let now = Date(timeIntervalSince1970: 1_700_000_000) From a3a6f4d1a0decc3f1ab924f39bc754fb0d709de6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 27 Sep 2026 23:24:43 -0700 Subject: [PATCH 035/122] ci: build tests with Xcode 26.3 and fix Swift 6.2 type-check timeouts (#4079) Xcode 26.3 / Swift 6.2 could not type-check two optional-cost #expect expressions in CostUsageQuotaWeekLinuxTests (built on macOS too), so the test build failed on the release toolchain; bind each cost to a Double first. CI gains a macos-15 Xcode 26.3 job running swift build --build-tests on Swift changes, wired into the aggregate gate, so release-toolchain type-check regressions are caught before tagging. Fixes #4070. Thanks @RowboTony! --- .github/workflows/ci.yml | 38 +++++++++++++- CHANGELOG.md | 1 + Scripts/ci_verify_test_jobs.sh | 15 ++++++ Scripts/test_ci_path_gate.sh | 52 ++++++++++++------- TestsLinux/CostUsageQuotaWeekLinuxTests.swift | 8 ++- docs/DEVELOPMENT.md | 13 +++++ docs/RELEASING.md | 2 +- 7 files changed, 105 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e3fdd6e8e..cde2c82e76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,6 +203,38 @@ jobs: printf '| Runs lint-macos | `%s` |\n' "$RUNS_LINT_MACOS" } >> "$GITHUB_STEP_SUMMARY" + swift-build-macos-compatibility: + needs: changes + if: ${{ needs.changes.outputs.macos-tests == 'true' }} + runs-on: macos-15 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Select Xcode 26.3 + run: | + set -euo pipefail + sudo xcode-select -s /Applications/Xcode_26.3.app/Contents/Developer + echo "DEVELOPER_DIR=/Applications/Xcode_26.3.app/Contents/Developer" >> "$GITHUB_ENV" + [[ "$(/usr/bin/xcodebuild -version)" == Xcode\ 26.3* ]] + /usr/bin/xcodebuild -version + + - name: Restore SwiftPM build cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .build + ~/Library/Caches/org.swift.swiftpm + key: swiftpm-macos15-xcode26.3-arm64-${{ hashFiles('Package.resolved', 'Package.swift') }} + restore-keys: | + swiftpm-macos15-xcode26.3-arm64- + + - name: Build app, CLI, and tests with Swift 6.2 + run: | + set -euo pipefail + swift --version + swift build --build-tests + lint-build-test: runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -210,6 +242,7 @@ jobs: - changes - lint - swift-test-macos + - swift-build-macos-compatibility - build-linux-musl-cli - build-linux-cli if: ${{ always() && !cancelled() }} @@ -226,7 +259,8 @@ jobs: "${{ needs.changes.outputs.macos-tests-deferred }}" \ "${{ needs.changes.outputs.linux-musl-build }}" \ "${{ needs.build-linux-musl-cli.result }}" \ - "${{ needs.build-linux-cli.result }}" + "${{ needs.build-linux-cli.result }}" \ + "${{ needs.swift-build-macos-compatibility.result }}" - name: Summarize aggregate CI gate if: ${{ always() }} @@ -238,6 +272,7 @@ jobs: MACOS_TESTS_DEFERRED: ${{ needs.changes.outputs.macos-tests-deferred }} MACOS_TESTS_REASON: ${{ needs.changes.outputs.macos-tests-reason }} MACOS_RESULT: ${{ needs.swift-test-macos.result }} + MACOS_COMPATIBILITY_RESULT: ${{ needs.swift-build-macos-compatibility.result }} LINUX_MUSL_BUILD: ${{ needs.changes.outputs.linux-musl-build }} LINUX_MUSL_BUILD_REASON: ${{ needs.changes.outputs.linux-musl-build-reason }} LINUX_MUSL_RESULT: ${{ needs.build-linux-musl-cli.result }} @@ -258,6 +293,7 @@ jobs: printf '| macOS Swift tests deferred | `%s` |\n' "${MACOS_TESTS_DEFERRED:-}" printf '| macOS gate reason | %s |\n' "$reason" printf '| swift-test-macos result | `%s` |\n' "$MACOS_RESULT" + printf '| Swift 6.2 build result | `%s` |\n' "$MACOS_COMPATIBILITY_RESULT" printf '| Linux musl build required | `%s` |\n' "${LINUX_MUSL_BUILD:-}" printf '| Linux musl gate reason | %s |\n' "$musl_reason" printf '| build-linux-musl-cli result | `%s` |\n' "$LINUX_MUSL_RESULT" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f07727e09..acf9be31e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Development: restore test compilation on Xcode 26.3 / Swift 6.2 and check app, CLI, and test compatibility in CI (#4070). Thanks @RowboTony! - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Scripts/ci_verify_test_jobs.sh b/Scripts/ci_verify_test_jobs.sh index 9b6e5432bc..2276288adb 100755 --- a/Scripts/ci_verify_test_jobs.sh +++ b/Scripts/ci_verify_test_jobs.sh @@ -10,6 +10,7 @@ macos_tests_deferred="${5:-}" linux_musl_build_required="${6:-}" linux_musl_build_result="${7:-}" linux_build_result="${8-}" +macos_compatibility_result="${9-}" if [[ "$lint_result" != "success" ]]; then printf 'lint job finished with %s\n' "${lint_result:-}" >&2 @@ -45,6 +46,20 @@ case "${macos_tests_required}:${macos_tests_deferred}:${macos_test_result}" in ;; esac +case "${macos_tests_required}:${macos_compatibility_result}" in + true:success) + printf 'Swift 6.2 compatibility build passed.\n' + ;; + false:skipped) + printf 'Swift 6.2 compatibility build skipped by the macOS test gate.\n' + ;; + *) + printf 'Swift 6.2 build gate/result mismatch: required=%s result=%s\n' \ + "${macos_tests_required:-}" "${macos_compatibility_result:-}" >&2 + exit 1 + ;; +esac + printf 'Linux glibc CLI matrix passed.\n' case "${linux_musl_build_required}:${linux_musl_build_result}" in diff --git a/Scripts/test_ci_path_gate.sh b/Scripts/test_ci_path_gate.sh index 90c741fab4..6993e5b25d 100755 --- a/Scripts/test_ci_path_gate.sh +++ b/Scripts/test_ci_path_gate.sh @@ -201,10 +201,10 @@ if [[ -s "$unterminated_output" ]]; then fi verify="${ROOT_DIR}/Scripts/ci_verify_test_jobs.sh" -"$verify" success success true success false true success success >/dev/null -"$verify" success success true success false false skipped success >/dev/null -"$verify" success success false skipped false true success success >/dev/null -"$verify" success success false skipped false false skipped success >/dev/null +"$verify" success success true success false true success success success >/dev/null +"$verify" success success true success false false skipped success success >/dev/null +"$verify" success success false skipped false true success success skipped >/dev/null +"$verify" success success false skipped false false skipped success skipped >/dev/null assert_verify_fails() { if "$verify" "$@" >/dev/null 2>&1; then @@ -213,17 +213,24 @@ assert_verify_fails() { fi } -assert_verify_fails success success true skipped false true success success -assert_verify_fails success success true skipped true true success success -assert_verify_fails success success false skipped true true success success -assert_verify_fails success success true success true true success success -assert_verify_fails success success false success false true success success -assert_verify_fails success success "" skipped false true success success -assert_verify_fails failure success true success false true success success -assert_verify_fails success failure true success false true success success -assert_verify_fails success success true success false true skipped success -assert_verify_fails success success true success false false success success -assert_verify_fails success success true success false "" skipped success +for compatibility_result in failure cancelled skipped '' unknown; do + assert_verify_fails success success true success false false skipped success "$compatibility_result" +done +assert_verify_fails success success true success false false skipped success +assert_verify_fails success success false skipped false false skipped success success +assert_verify_fails success success false skipped false false skipped success failure + +assert_verify_fails success success true skipped false true success success success +assert_verify_fails success success true skipped true true success success success +assert_verify_fails success success false skipped true true success success skipped +assert_verify_fails success success true success true true success success success +assert_verify_fails success success false success false true success success skipped +assert_verify_fails success success "" skipped false true success success success +assert_verify_fails failure success true success false true success success success +assert_verify_fails success failure true success false true success success success +assert_verify_fails success success true success false true skipped success success +assert_verify_fails success success true success false false success success success +assert_verify_fails success success true success false "" skipped success success assert_linux_verify_fails() { local expected="$1" @@ -254,14 +261,15 @@ for macos_required in true false; do for failed_result in failure cancelled; do assert_verify_fails "$failed_result" success "$macos_required" "$macos_result" \ - false "$musl_required" "$musl_result" success + false "$musl_required" "$musl_result" success "$macos_result" assert_verify_fails success "$failed_result" "$macos_required" "$macos_result" \ - false "$musl_required" "$musl_result" success + false "$musl_required" "$musl_result" success "$macos_result" if [[ "$macos_required" == true ]]; then - assert_verify_fails success success true "$failed_result" false "$musl_required" "$musl_result" success + assert_verify_fails success success true "$failed_result" false "$musl_required" "$musl_result" success success fi if [[ "$musl_required" == true ]]; then - assert_verify_fails success success "$macos_required" "$macos_result" false true "$failed_result" success + assert_verify_fails success success "$macos_required" "$macos_result" false true "$failed_result" \ + success "$macos_result" fi done done @@ -282,12 +290,16 @@ body = aggregate.group(1) needs = re.search(r"(?m)^ needs:\n((?: - [^\n]+\n)+)", body) if needs is None or " - build-linux-cli" not in needs.group(1).splitlines(): sys.exit("lint-build-test must need build-linux-cli") +if " - swift-build-macos-compatibility" not in needs.group(1).splitlines(): + sys.exit("lint-build-test must need swift-build-macos-compatibility") command = re.search(r"(?m)^ \./Scripts/ci_verify_test_jobs\.sh(?:[^\n]*\\\n)+[^\n]*", body) if command is None: sys.exit("missing aggregate verifier command") arguments = shlex.split(command.group(0).replace("\\\n", "")) -if len(arguments) != 9 or arguments[8] != "${{ needs.build-linux-cli.result }}": +if len(arguments) != 10 or arguments[8] != "${{ needs.build-linux-cli.result }}": sys.exit("aggregate verifier argument eight must be needs.build-linux-cli.result") +if arguments[9] != "${{ needs.swift-build-macos-compatibility.result }}": + sys.exit("aggregate verifier argument nine must be needs.swift-build-macos-compatibility.result") PY printf 'CI path gate tests passed.\n' diff --git a/TestsLinux/CostUsageQuotaWeekLinuxTests.swift b/TestsLinux/CostUsageQuotaWeekLinuxTests.swift index 19c8c267d4..cfd0c8f39d 100644 --- a/TestsLinux/CostUsageQuotaWeekLinuxTests.swift +++ b/TestsLinux/CostUsageQuotaWeekLinuxTests.swift @@ -242,7 +242,9 @@ struct CostUsageQuotaWeekLinuxTests { #expect(current?.totalTokens == 400) #expect(previous?.totalCostUSD == 2) #expect(previous?.totalTokens == 200) - #expect((current?.totalCostUSD ?? 0) + (previous?.totalCostUSD ?? 0) == 6) + let currentCost: Double = current?.totalCostUSD ?? 0 + let previousCost: Double = previous?.totalCostUSD ?? 0 + #expect(currentCost + previousCost == 6) #expect(current?.entryCount == 1) #expect(previous?.entryCount == 1) } @@ -285,7 +287,9 @@ struct CostUsageQuotaWeekLinuxTests { #expect(previous?.totalTokens == 200) #expect(current?.totalCostUSD == 4) #expect(current?.totalTokens == 400) - #expect((current?.totalCostUSD ?? 0) + (previous?.totalCostUSD ?? 0) == 6) + let currentCost: Double = current?.totalCostUSD ?? 0 + let previousCost: Double = previous?.totalCostUSD ?? 0 + #expect(currentCost + previousCost == 6) } @Test diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ac2424d371..2283719325 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -34,6 +34,19 @@ read_when: 4. **Optional file log**: enable Debug → Logging → "Enable file logging" to write `~/Library/Logs/CodexBar/CodexBar.log` (verbosity defaults to "Verbose") +## Swift Toolchain Compatibility + +The package supports Swift 6.2, including Xcode 26.3 on macOS 15. CI's +`swift-build-macos-compatibility` job builds the app, CLI, and all test targets +with that Xcode version using `swift build --build-tests`, without running them. +It uses the existing macOS path gate, including every Swift change, and runs on +draft PRs too. The aggregate `lint-build-test` gate requires a successful build +when applicable; docs-only changes may skip it. Runtime tests remain on newer Xcode. + +Keep large initializer and `#expect` expressions simple: bind intermediate values +to explicitly typed locals when the Swift 6.2 type checker struggles. Use +`ProviderColor(hex:)` for provider colors instead of arithmetic inside spec initializers. + ## Keychain Prompts (Development) ### First Launch After Fresh Clone diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 57a666ae55..7ee85e6026 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -110,7 +110,7 @@ Each Homebrew handoff uses the release tag, workflow run ID, and run attempt as - [ ] Update versions (scripts/Info.plist, CHANGELOG, About text) — changelog top section must be finalized; release script pulls notes from it automatically. - [ ] `swiftformat`, `swiftlint`, `make test` (zero warnings/errors) - [ ] `./Scripts/build_icon.sh` if icon changed -- [ ] Preflight the CLI on the release commit: `gh workflow run release-cli.yml --ref main` and wait for green. The macOS CLI jobs build with Xcode 26.3 on the macOS 15 images, older than main CI's toolchain, so type-checker regressions only show up there. +- [ ] Preflight the CLI on the release commit: `gh workflow run release-cli.yml --ref main` and wait for green. The macOS CLI jobs use Xcode 26.3 (26.2 fallback) on the macOS 15 images. Regular CI also builds the app, CLI, and tests with Xcode 26.3 to catch older-toolchain type-checker regressions; this does not replace release-mode packaging preflight. - [ ] `./Scripts/sign-and-notarize.sh` - [ ] Generate Sparkle appcast via `Scripts/release.sh` or `Scripts/make_appcast.sh`; use `SPARKLE_PRIVATE_KEY_FILE` only if overriding Keychain signing. - Upload the dSYM archive alongside the app zip on the GitHub release; the release script now automates this and will fail if it’s missing. From fb7d4a914b125139ea9d1742f41311a118908f76 Mon Sep 17 00:00:00 2001 From: Peter Urda Date: Sun, 27 Sep 2026 23:38:18 -0700 Subject: [PATCH 036/122] Price aliased Antigravity, Codex models --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Antigravity/AntigravityLocalReader.swift | 18 ++++- .../Vendored/CostUsage/CostUsagePricing.swift | 40 ++++++++--- .../AntigravityLocalReaderTests.swift | 44 +++++++++++- .../CodexBarTests/CostUsagePricingTests.swift | 67 +++++++++++++------ .../CostUsageScannerPriorityTests.swift | 2 +- docs/model-pricing.md | 2 + 7 files changed, 141 insertions(+), 34 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index c5a1d17bb1..c65242f05b 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "33d3202ea786d9ce" + static let value = "2a146b3a97e5761a" } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift index 8f89ec3989..3b7100d242 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift @@ -93,17 +93,29 @@ enum AntigravityLocalReader { } /// Antigravity records routing variants of a vendor model (`-tiered`, `-low`, `-thinking`) - /// that bill at the base model's public price. The alias stays provider-local so shared - /// Claude pricing keeps reporting unknown Claude variants as unpriced. + /// that bill at the base model's public price, and product aliases that name no catalogued + /// model at all. The alias stays provider-local so shared Claude pricing keeps reporting + /// unknown Claude variants as unpriced. static func pricingBaseModelID(for model: String) -> String? { let lowered = model.lowercased() + if let alias = self.pricingModelAliases[lowered] { return alias } guard let suffix = self.routingVariantSuffixes.first(where: lowered.hasSuffix) else { return nil } let base = String(model.dropLast(suffix.count)) - return base.isEmpty ? nil : base + return base.isEmpty ? nil : self.pricingModelAliases[base.lowercased()] ?? base } private static let routingVariantSuffixes = ["-tiered", "-low", "-thinking"] + /// Gemini 3.1 Pro is catalogued only as `gemini-3.1-pro-preview`. Antigravity records it under + /// its product aliases and effort tiers; ccusage's Antigravity adapter maps the same IDs. + private static let pricingModelAliases = [ + "gemini-pro-default": "gemini-3.1-pro-preview", + "gemini-pro-agent": "gemini-3.1-pro-preview", + "gemini-3.1-pro": "gemini-3.1-pro-preview", + "gemini-3.1-pro-high": "gemini-3.1-pro-preview", + "gemini-3.1-pro-low": "gemini-3.1-pro-preview", + ] + static func checkedAdd(_ lhs: Int, _ rhs: Int) -> Int? { let (result, overflow) = lhs.addingReportingOverflow(rhs) return overflow ? nil : result diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 714885cc1c..c169ac9289 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -1,5 +1,6 @@ import Foundation +// swiftlint:disable:next type_body_length enum CostUsagePricing { private static let codexPriorityInputTokenLimit = 272_000 static let codexUnattributedModel = "unknown" @@ -191,17 +192,18 @@ enum CostUsagePricing { // Long context: prompts with >272K input tokens are 2x input / 1.5x output for the full // request. Cache writes: 1.25x uncached input. API Fast support and multipliers are applied // separately after Standard pricing resolves from models.dev or this bundled fallback. + // Sol was repriced from $5/$30 to $4/$20 on 2026-08-22. "gpt-5.6-sol": CodexPricing( - inputCostPerToken: 5e-6, - outputCostPerToken: 3e-5, - cacheReadInputCostPerToken: 5e-7, + inputCostPerToken: 4e-6, + outputCostPerToken: 2e-5, + cacheReadInputCostPerToken: 4e-7, displayLabel: nil, - cacheWriteInputCostPerToken: 6.25e-6, + cacheWriteInputCostPerToken: 5e-6, thresholdTokens: 272_000, - inputCostPerTokenAboveThreshold: 1e-5, - outputCostPerTokenAboveThreshold: 4.5e-5, - cacheReadInputCostPerTokenAboveThreshold: 1e-6, - cacheWriteInputCostPerTokenAboveThreshold: 1.25e-5), + inputCostPerTokenAboveThreshold: 8e-6, + outputCostPerTokenAboveThreshold: 3e-5, + cacheReadInputCostPerTokenAboveThreshold: 8e-7, + cacheWriteInputCostPerTokenAboveThreshold: 1e-5), "gpt-5.6-terra": CodexPricing( inputCostPerToken: 2e-6, outputCostPerToken: 1.2e-5, @@ -224,6 +226,19 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: 1.8e-6, cacheReadInputCostPerTokenAboveThreshold: 4e-8, cacheWriteInputCostPerTokenAboveThreshold: 5e-7), + // Daybreak Cyber models (OpenAI pricing page). No long-context tier is published, and + // gpt-5.5-cyber lists no cache-write rate. + "gpt-5.6-cyber": CodexPricing( + inputCostPerToken: 1.25e-5, + outputCostPerToken: 7.5e-5, + cacheReadInputCostPerToken: 1.25e-6, + displayLabel: nil, + cacheWriteInputCostPerToken: 1.5625e-5), + "gpt-5.5-cyber": CodexPricing( + inputCostPerToken: 1.25e-5, + outputCostPerToken: 7.5e-5, + cacheReadInputCostPerToken: 1.25e-6, + displayLabel: nil), ] static func codexBuiltInPricingFingerprint() -> String { @@ -528,6 +543,15 @@ enum CostUsagePricing { return "gpt-5.6-luna" } + // OpenAI's Daybreak aliases currently point to Sol (blue) and Cyber (red). + // https://developers.openai.com/api/docs/pricing + if trimmed == "gpt-daybreak-blue-latest" { + return "gpt-5.6-sol" + } + if trimmed == "gpt-daybreak-red-latest" { + return "gpt-5.6-cyber" + } + if self.codex[trimmed] != nil { return trimmed } diff --git a/Tests/CodexBarTests/AntigravityLocalReaderTests.swift b/Tests/CodexBarTests/AntigravityLocalReaderTests.swift index 01615b0dcc..0f63438899 100644 --- a/Tests/CodexBarTests/AntigravityLocalReaderTests.swift +++ b/Tests/CodexBarTests/AntigravityLocalReaderTests.swift @@ -159,7 +159,8 @@ struct AntigravityLocalReaderTests { #expect(AntigravityLocalReader.pricingBaseModelID(for: "gemini-3.8-flash-tiered") == "gemini-3.8-flash") - #expect(AntigravityLocalReader.pricingBaseModelID(for: "gemini-3.1-pro-low") == "gemini-3.1-pro") + #expect(AntigravityLocalReader.pricingBaseModelID(for: "gemini-3.1-pro-low") + == "gemini-3.1-pro-preview") #expect(AntigravityLocalReader.pricingBaseModelID(for: "claude-opus-4-6-thinking") == "claude-opus-4-6") #expect(AntigravityLocalReader.pricingBaseModelID(for: "gemini-3.8-flash") == nil) @@ -899,3 +900,44 @@ struct AntigravityLocalReaderTests { #expect(snapshot.last30DaysTokens == nil) } } + +extension AntigravityLocalReaderTests { + @Test + func `gemini pro product aliases price from the catalogued preview model`() async throws { + let fixture = try Fixture() + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(#""" + { + "google": { + "id": "google", + "name": "Google", + "models": { + "gemini-3.1-pro-preview": { + "id": "gemini-3.1-pro-preview", + "cost": {"input": 1, "output": 2, "cache_read": 0.2} + } + } + } + } + """#.utf8)) + let cacheRoot = fixture.root.appendingPathComponent("scanner-cache") + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: Fixture.now, cacheRoot: cacheRoot)) + try fixture.database(blobs: [Fixture.blob(model: "gemini-pro-default")]) + + let snapshot = try await fixture.snapshot() + let expected = 111e-6 + 50 * 0.2e-6 + 37 * 2e-6 + #expect(snapshot.last30DaysCostUSD == expected) + // The recorded alias keeps its own identity in the breakdown; only pricing resolves. + #expect(snapshot.daily.first?.modelBreakdowns?.first?.modelName == "gemini-pro-default") + + for alias in [ + "gemini-pro-default", + "gemini-pro-agent", + "gemini-3.1-pro", + "gemini-3.1-pro-high", + "gemini-3.1-pro-thinking", + ] { + #expect(AntigravityLocalReader.pricingBaseModelID(for: alias) == "gemini-3.1-pro-preview") + } + #expect(AntigravityLocalReader.pricingBaseModelID(for: "Gemini-Pro-Default") == "gemini-3.1-pro-preview") + } +} diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index fa29825a9c..8c1b0289ec 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -21,6 +21,11 @@ struct CostUsagePricingTests { #expect(CostUsagePricing.normalizeCodexModel("gpt-5.6") == "gpt-5.6-sol") #expect(CostUsagePricing.normalizeCodexModel("gpt-reserve") == "gpt-5.6-luna") #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-reserve") == "gpt-5.6-luna") + #expect(CostUsagePricing.normalizeCodexModel("gpt-daybreak-blue-latest") == "gpt-5.6-sol") + #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-daybreak-blue-latest") == "gpt-5.6-sol") + #expect(CostUsagePricing.normalizeCodexModel("gpt-daybreak-red-latest") == "gpt-5.6-cyber") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.6-cyber") == "gpt-5.6-cyber") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.5-cyber") == "gpt-5.5-cyber") // Fictitious dated suffixes only exercise normalize stripping (not released snapshot IDs). #expect(CostUsagePricing.normalizeCodexModel("gpt-5.6-sol-2099-01-01") == "gpt-5.6-sol") #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5.6-terra-2099-01-01") == "gpt-5.6-terra") @@ -254,9 +259,9 @@ struct CostUsagePricingTests { outputTokens: 5, modelsDevCacheRoot: root) - // Rates per token: Sol $5/$30 per 1M, Terra $2/$12, Luna $0.20/$1.20; + // Rates per token: Sol $4/$20 per 1M, Terra $2/$12, Luna $0.20/$1.20; // cache read is 10% of input. Non-cached input is 90 tokens. - #expect(sol == (90.0 * 5e-6) + (10.0 * 5e-7) + (5.0 * 3e-5)) + #expect(sol == (90.0 * 4e-6) + (10.0 * 4e-7) + (5.0 * 2e-5)) #expect(terra == (90.0 * 2e-6) + (10.0 * 2e-7) + (5.0 * 1.2e-5)) #expect(luna == (90.0 * 2e-7) + (10.0 * 2e-8) + (5.0 * 1.2e-6)) #expect(reserve == luna) @@ -264,6 +269,28 @@ struct CostUsagePricingTests { #expect(alias == sol) } + @Test + func `codex cost prices daybreak aliases and cyber bundled fallback`() throws { + // Empty models.dev cache root forces the built-in table. + let root = try Self.cacheRoot() + + func cost(_ model: String) -> Double? { + CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + modelsDevCacheRoot: root) + } + + // Cyber rates per token: $12.50 input, $1.25 cached input, $75 output per 1M. + let cyber = (90.0 * 1.25e-5) + (10.0 * 1.25e-6) + (5.0 * 7.5e-5) + #expect(cost("gpt-5.6-cyber") == cyber) + #expect(cost("gpt-5.5-cyber") == cyber) + #expect(cost("gpt-daybreak-blue-latest") == cost("gpt-5.6-sol")) + #expect(cost("gpt-daybreak-red-latest") == cyber) + } + @Test func `codex models dev falls back from gpt56 alias to canonical sol pricing`() throws { let canonicalOnlyRoot = try Self.seedModelsDevCache(""" @@ -391,7 +418,7 @@ struct CostUsagePricingTests { // Long-context (>272K) rates apply to the entire request. Total input contains 10 cached, // 20 cache-write, and 271,971 ordinary input tokens. - #expect(sol == (271_971.0 * 1e-5) + (10.0 * 1e-6) + (20.0 * 1.25e-5) + (10.0 * 4.5e-5)) + #expect(sol == (271_971.0 * 8e-6) + (10.0 * 8e-7) + (20.0 * 1e-5) + (10.0 * 3e-5)) #expect(terra == (271_971.0 * 4e-6) + (10.0 * 4e-7) + (20.0 * 5e-6) + (10.0 * 1.8e-5)) #expect(luna == (271_971.0 * 4e-7) + (10.0 * 4e-8) + (20.0 * 5e-7) + (10.0 * 1.8e-6)) } @@ -408,7 +435,7 @@ struct CostUsagePricingTests { cacheWriteInputTokens: 20, modelsDevCacheRoot: root) - let expected = (70.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6) + (5.0 * 3e-5) + let expected = (70.0 * 4e-6) + (10.0 * 4e-7) + (20.0 * 5e-6) + (5.0 * 2e-5) #expect(sol == expected) } @@ -435,7 +462,7 @@ struct CostUsagePricingTests { modelsDevCacheRoot: root) // Public API Fast rates are 2x Standard for GPT-5.6. - let expectedSol = 2.02 + let expectedSol = 1.456 let expectedTerra = 0.808 let expectedLuna = 0.0808 #expect(abs((sol ?? 0) - expectedSol) < 1e-12) @@ -475,10 +502,10 @@ struct CostUsagePricingTests { outputTokens: 5, modelsDevCacheRoot: root) - let solInput = 70.0 * 5e-6 - let solCached = 10.0 * 5e-7 - let solWrite = 20.0 * 6.25e-6 - let solOutput = 5.0 * 3e-5 + let solInput = 70.0 * 4e-6 + let solCached = 10.0 * 4e-7 + let solWrite = 20.0 * 5e-6 + let solOutput = 5.0 * 2e-5 let expectedSol: Double = (solInput + solCached + solWrite + solOutput) * 2 let terraInput = 70.0 * 2e-6 let terraCached = 10.0 * 2e-7 @@ -820,8 +847,8 @@ struct CostUsagePricingTests { outputTokens: 100, modelsDevCacheRoot: catalogThresholdRoot) - #expect(bundledBelowThreshold == (200_000.0 * 5e-6) + (100.0 * 30e-6)) - #expect(bundledAtThreshold == (272_000.0 * 5e-6) + (100.0 * 30e-6)) + #expect(bundledBelowThreshold == (200_000.0 * 4e-6) + (100.0 * 20e-6)) + #expect(bundledAtThreshold == (272_000.0 * 4e-6) + (100.0 * 20e-6)) #expect(bundledAboveThreshold == nil) #expect(linear == (300_000.0 * 7.5e-7) + (100_000.0 * 7.5e-8) + (100.0 * 4.5e-6)) #expect(catalogAtThreshold == (200_000.0 * 5e-6) + (100.0 * 30e-6)) @@ -876,7 +903,7 @@ extension CostUsagePricingTests { "models": { "gpt-5.6-sol": { "id": "gpt-5.6-sol", - "cost": { "input": 5, "output": 30 } + "cost": { "input": 4, "output": 20 } } } } @@ -889,7 +916,7 @@ extension CostUsagePricingTests { "models": { "gpt-5.6-sol": { "id": "gpt-5.6-sol", - "cost": { "input": 5, "output": 30, "cache_read": 0, "cache_write": 0 } + "cost": { "input": 4, "output": 20, "cache_read": 0, "cache_write": 0 } } } } @@ -911,8 +938,8 @@ extension CostUsagePricingTests { cacheWriteInputTokens: 20, modelsDevCacheRoot: explicitZeroRoot) - #expect(missing == (70.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6)) - #expect(explicitZero == 70.0 * 5e-6) + #expect(missing == (70.0 * 4e-6) + (10.0 * 4e-7) + (20.0 * 5e-6)) + #expect(explicitZero == 70.0 * 4e-6) } @Test @@ -926,9 +953,9 @@ extension CostUsagePricingTests { "gpt-5.6-sol": { "id": "gpt-5.6-sol", "cost": { - "input": 5, - "output": 30, - "cache_read": 0.5 + "input": 4, + "output": 20, + "cache_read": 0.4 } } } @@ -943,9 +970,9 @@ extension CostUsagePricingTests { outputTokens: 10, modelsDevCacheRoot: root) - // Without bundled above-threshold fallback this would bill short rates ($5/$30) despite + // Without bundled above-threshold fallback this would bill short rates ($4/$20) despite // entering long-context mode via the bundled threshold. - #expect(cost == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) + #expect(cost == (272_001.0 * 8e-6) + (10.0 * 3e-5)) } @Test diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index b15bb3cc88..0e709b72a1 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -706,7 +706,7 @@ struct CostUsageScannerPriorityTests { until: day, now: day, options: options) - let expected = (172_001.0 * 1e-5) + (100_000.0 * 1e-6) + (5.0 * 4.5e-5) + let expected = (172_001.0 * 8e-6) + (100_000.0 * 8e-7) + (5.0 * 3e-5) #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) let breakdown = try #require(report.data.first?.modelBreakdowns?.first) diff --git a/docs/model-pricing.md b/docs/model-pricing.md index 9036ad7361..1fe30bd2e1 100644 --- a/docs/model-pricing.md +++ b/docs/model-pricing.md @@ -39,6 +39,8 @@ Local cost scanners preserve that scope when selecting a catalog: - Other bare Claude-session IDs are priced only when exactly one selected first-party catalog matches. Ambiguous cross-vendor matches remain unpriced. - Provider-qualified Claude-session IDs stay on an approved explicit route and never fall through to another vendor. - Claude's [documented `k3[1m]` alias](https://www.kimi.com/code/docs/en/third-party-tools/claude-code.html) resolves to `kimi-for-coding/k3` after exact-row lookup, including the existing `kimi-coding/` and `kimi-for-coding/` routes. Recorded model names stay unchanged; other context variants and paid Moonshot routes are not inferred. Catalog zero rates remain known estimates, not a claim that subscriptions or extra usage are free. +- OpenAI's [Daybreak aliases](https://developers.openai.com/api/docs/pricing) resolve like the unsuffixed `gpt-5.6` alias: `gpt-daybreak-blue-latest` prices as `gpt-5.6-sol` and `gpt-daybreak-red-latest` as `gpt-5.6-cyber`. Recorded model names stay unchanged. +- Antigravity's Gemini 3.1 Pro aliases (`gemini-pro-default`, `gemini-pro-agent`, and the `gemini-3.1-pro` effort tiers) price as `gemini-3.1-pro-preview`, the only catalogued Gemini 3.1 Pro row. The alias is provider-local; recorded model names stay unchanged. - Vertex AI Claude logs: models.dev provider id `google-vertex-anthropic` ### Explicit provider identity in OpenCodex From a5252e24c8443f312b17bd8b1abaef7dd40670c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 00:27:21 -0700 Subject: [PATCH 037/122] feat(plugins): host cookie jar and LongCat plugin cutover (#4059) Plugin host gains an opaque, nonpersistent browser-cookie jar: imported cookies stay host-owned and each ctx.http request gets the RFC 6265 match for its URL (host-only/domain scope, path boundary, Secure, expiry) on both engines, with cross-origin redirects still blocked. LongCat now runs entirely as a bundled plugin (six native fetch/import/header files deleted), and browser cookie import no longer merges host-only and domain cookies that share a name. Net -43 production lines. --- CHANGELOG.md | 2 + .../BrowserCookieImportSupport.swift | 4 +- .../CodexBarCore/BrowserCookieProfiles.swift | 2 +- .../Plugins/ProviderPluginCookieBroker.swift | 81 ++- .../Plugins/ProviderPluginCookieJar.swift | 167 ++++++ .../Plugins/ProviderPluginEngine.swift | 11 +- .../Plugins/ProviderPluginHTTPResponse.swift | 43 +- .../Plugins/ProviderPluginManifest.swift | 17 + .../Plugins/ProviderPluginRuntime.swift | 24 +- .../Plugins/QuickJSProviderPluginEngine.swift | 11 +- .../Plugins/ScriptFetchStrategy.swift | 13 +- .../Providers/LongCat/LongCatAPIError.swift | 27 - .../LongCat/LongCatCookieHeader.swift | 107 ---- .../LongCat/LongCatCookieImporter.swift | 92 --- .../Providers/LongCat/LongCatModels.swift | 68 --- .../LongCat/LongCatProviderDescriptor.swift | 103 +--- .../LongCat/LongCatUsageFetcher.swift | 334 ----------- .../LongCat/LongCatUsageSnapshot.swift | 87 --- .../CodexBarCore/Resources/Plugins/abacus.js | 13 +- .../CodexBarCore/Resources/Plugins/abacus.ts | 6 +- .../Resources/Plugins/codexbar-plugin.d.ts | 8 +- .../CodexBarCore/Resources/Plugins/longcat.js | 226 +++++++ .../CodexBarCore/Resources/Plugins/longcat.ts | 174 ++++++ .../CodexBarCore/Resources/Plugins/muse.js | 2 +- .../CodexBarCore/Resources/Plugins/muse.ts | 2 +- .../Plugins/provider-plugin-prelude.js | 2 +- .../CodexBarCore/Resources/Plugins/raycast.js | 2 +- .../CodexBarCore/Resources/Plugins/raycast.ts | 2 +- .../BrowserCookieImportSupportTests.swift | 10 + .../BrowserCookieProfilesTests.swift | 22 + .../CodexBarTests/LongCatProviderTests.swift | 565 ++---------------- .../LongCatQuotaPresentationTests.swift | 20 +- .../ProviderPluginCookieBrokerTests.swift | 23 + .../ProviderPluginDetailsParityTests.swift | 2 +- TestsLinux/ProviderNumericBoundaryTests.swift | 81 --- TestsPlugin/AbacusPluginTests.swift | 60 +- TestsPlugin/LongCatPluginTests.swift | 234 ++++++++ .../ProviderPluginCookieJarTests.swift | 209 +++++++ ...ProviderPluginOptionalAdmissionTests.swift | 2 +- .../ProviderPluginTransportTests.swift | 6 +- docs/longcat.md | 10 + docs/plugin-conversion-matrix.md | 6 +- docs/plugins.md | 20 +- 43 files changed, 1447 insertions(+), 1453 deletions(-) create mode 100644 Sources/CodexBarCore/Plugins/ProviderPluginCookieJar.swift delete mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift delete mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift delete mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift delete mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift delete mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift delete mode 100644 Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift create mode 100644 Sources/CodexBarCore/Resources/Plugins/longcat.js create mode 100644 Sources/CodexBarCore/Resources/Plugins/longcat.ts create mode 100644 TestsPlugin/LongCatPluginTests.swift create mode 100644 TestsPlugin/ProviderPluginCookieJarTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index acf9be31e2..2f00492ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ - Claude and Vertex: reuse unchanged decoded cost-history caches across refreshes while preserving source, pricing, and time-zone validation (#4053). Thanks @djbclark! - Costs: keep All history priority checks proportional to recorded days instead of generating centuries of empty days, while preserving older logs (#4045). Thanks @djbclark! - CLI: macOS release builds compile again on the Xcode 26 release runners, so the 0.68 macOS CLI tarballs and the Homebrew `codexbar` formula ship alongside the app. +- LongCat: move quota fetching to the bundled plugin on both engines, keeping imported cookies private to the host and preserving per-request cookie scope, profile fallback, and optional fuel-pack data. +- Browser sessions: preserve distinct host-only and domain-scoped cookies when merging stores from the same profile. ## 0.68.0 — 2026-09-27 diff --git a/Sources/CodexBarCore/BrowserCookieImportSupport.swift b/Sources/CodexBarCore/BrowserCookieImportSupport.swift index 8ab75948de..a1506c778e 100644 --- a/Sources/CodexBarCore/BrowserCookieImportSupport.swift +++ b/Sources/CodexBarCore/BrowserCookieImportSupport.swift @@ -17,7 +17,7 @@ enum BrowserCookieImportSupport { #if os(macOS) static func collectSessions( from browsers: [Browser], - missingError: any Error, + missingError: (any Error)?, logger: (String) -> Void, load: (Browser) throws -> [Session]) throws -> [Session] { @@ -30,7 +30,7 @@ enum BrowserCookieImportSupport { logger("\(browser.displayName) cookie import failed: \(error.localizedDescription)") } } - guard !sessions.isEmpty else { throw missingError } + if sessions.isEmpty, let missingError { throw missingError } return sessions } diff --git a/Sources/CodexBarCore/BrowserCookieProfiles.swift b/Sources/CodexBarCore/BrowserCookieProfiles.swift index 8136555bd3..b9d2da8d91 100644 --- a/Sources/CodexBarCore/BrowserCookieProfiles.swift +++ b/Sources/CodexBarCore/BrowserCookieProfiles.swift @@ -52,7 +52,7 @@ enum BrowserCookieProfiles { } private static func recordKey(_ record: BrowserCookieRecord) -> String { - "\(record.name)|\(record.domain)|\(record.path)" + "\(record.name)|\(record.domain)|\(record.path)|\(record.scope)" } private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool { diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginCookieBroker.swift b/Sources/CodexBarCore/Plugins/ProviderPluginCookieBroker.swift index cbf918f7ae..c033384990 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginCookieBroker.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginCookieBroker.swift @@ -11,23 +11,30 @@ public struct ProviderPluginCookieSession: Codable, Equatable, Sendable { public let source: String public let origin: String public let cachedAt: TimeInterval? + public let records: [ProviderPluginCookieRecord]? public init( header: String, source: String, origin: String, id: String = UUID().uuidString, - cachedAt: TimeInterval? = nil) + cachedAt: TimeInterval? = nil, + records: [ProviderPluginCookieRecord]? = nil) { self.id = id self.header = header self.source = source self.origin = origin self.cachedAt = cachedAt + self.records = records } - func json() throws -> String { - guard let json = try String(data: JSONEncoder().encode(self), encoding: .utf8) else { + func json(opaque: Bool = false) throws -> String { + var value: [String: Any] = ["id": self.id, "source": self.source, "origin": self.origin] + if !opaque { value["header"] = self.header } + if let cachedAt { value["cachedAt"] = cachedAt } + let data = try JSONSerialization.data(withJSONObject: value) + guard let json = String(data: data, encoding: .utf8) else { throw ProviderPluginError.secretAccess("cookie session encoding failed") } return json @@ -37,6 +44,7 @@ public struct ProviderPluginCookieSession: Codable, Equatable, Sendable { final class ProviderPluginCookieBroker: @unchecked Sendable { typealias Importer = @Sendable (String) throws -> [(header: String, source: String)] typealias BatchImporter = @Sendable (String, Int) throws -> [(header: String, source: String)]? + typealias JarImporter = @Sendable () throws -> [ProviderPluginCookieSession] private struct Issued { let session: ProviderPluginCookieSession @@ -57,24 +65,39 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { private var imported: [String: [(header: String, source: String)]] = [:] private var seen: [String: Set] = [:] private var manualDomain: String? + private let jarImporter: JarImporter? + private var jarCandidates: [ProviderPluginCookieSession]? convenience init( provider: UsageProvider, domains: Set, context: ProviderFetchContext, - importer: BatchImporter? = nil) + importer: BatchImporter? = nil, + usesCookieJar: Bool = false, + settingsOverride: ProviderSettingsSnapshot.CookieProviderSettings? = nil) { + let canImport = context.runtime == .app && ProviderInteractionContext.current == .userInitiated + let jarImporter: JarImporter? = if usesCookieJar { + { + guard canImport else { return [] } + return try Self.importCookieJars( + provider: provider, domains: domains, browserDetection: context.browserDetection) + } + } else { + nil + } self.init( provider: provider, domains: domains, - settings: context.settings.flatMap { + settings: settingsOverride ?? context.settings.flatMap { ProviderDescriptorRegistry.descriptor(for: provider).settingsSection.cookieSettings(from: $0) } ?? .init(cookieSource: .auto, manualCookieHeader: nil), batches: importer ?? { domain, batch in guard batch == 0 else { return nil } return try Self.importCookieHeaders( provider: provider, domain: domain, browserDetection: context.browserDetection) - }) + }, + jarImporter: jarImporter) } convenience init( @@ -92,12 +115,14 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { provider: UsageProvider, domains: Set, settings: ProviderSettingsSnapshot.CookieProviderSettings, - batches: @escaping BatchImporter) + batches: @escaping BatchImporter, + jarImporter: JarImporter? = nil) { self.provider = provider self.domains = domains self.settings = settings self.importer = batches + self.jarImporter = jarImporter } var cookieSource: ProviderCookieSource { @@ -107,6 +132,9 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { func cookieHeader(domain: String) throws -> String { try self.lock.withLock { try self.validate(domain) + guard self.jarImporter == nil else { + throw ProviderPluginError.secretAccess("cookie jars do not expose headers") + } if let issued = self.observed[domain] { return issued.session.header } guard let session = try self.advance(domain: domain) else { throw ProviderPluginError.secretAccess("no session cookies were found for this domain") @@ -158,6 +186,19 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { self.visited.insert(domain) return self.issue(header: header, source: "manual", domain: domain, cacheEntry: nil) } + if let jarImporter { + guard !cachedOnly else { return nil } + if self.jarCandidates == nil { self.jarCandidates = try jarImporter() } + guard var candidates = self.jarCandidates, !candidates.isEmpty else { return nil } + let candidate = candidates.removeFirst() + self.jarCandidates = candidates + let session = ProviderPluginCookieSession( + header: "", source: candidate.source, origin: "https://\(domain)", records: candidate.records) + let issued = Issued(session: session, cacheEntry: nil, cacheScope: nil) + self.observed[domain] = issued + self.issuedSessions[session.id] = issued + return session + } if self.visited.insert(domain).inserted, let (cached, scope) = self.cachedEntry(domain: domain), let header = CookieHeaderNormalizer.normalize(cached.cookieHeader) @@ -276,6 +317,32 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { #endif } + static func importCookieJars( + provider: UsageProvider, domains: Set, browserDetection: BrowserDetection) throws + -> [ProviderPluginCookieSession] + { + #if os(macOS) + let client = BrowserCookieClient() + let query = BrowserCookieQuery(domains: domains.sorted(), domainMatch: .exact) + let order = ProviderDefaults.metadata[provider]?.browserCookieOrder ?? [Browser.chrome] + return try BrowserCookieImportSupport.collectSessions( + from: order.cookieImportCandidates(using: browserDetection), + missingError: nil, + logger: { _ in }, + load: { browser in + let sources = try client.codexBarRecords(matching: query, in: browser) + return BrowserCookieProfiles.merge(sources).map { profile in + ProviderPluginCookieSession( + header: "", source: profile.label, origin: "", records: profile.records + .filter { domains.contains(Self.normalizedDomain($0.domain)) } + .map(ProviderPluginCookieRecord.init)) + } + }) + #else + return [] + #endif + } + #if os(macOS) static func cookiesForRequest(_ cookies: [HTTPCookie], domain: String) -> [HTTPCookie] { var chosen: [String: HTTPCookie] = [:] diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginCookieJar.swift b/Sources/CodexBarCore/Plugins/ProviderPluginCookieJar.swift new file mode 100644 index 0000000000..0cf46f02b2 --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ProviderPluginCookieJar.swift @@ -0,0 +1,167 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Host-owned browser records. The JavaScript bridge exposes only a session identifier. +public struct ProviderPluginCookieRecord: Codable, Equatable, Sendable { + public let name: String + public let value: String + public let domain: String + public let hostOnly: Bool + public let path: String + public let secure: Bool + public let expires: Date? + + #if os(macOS) + init(record: BrowserCookieRecord) { + self.name = record.name + self.value = record.value + self.domain = record.domain.lowercased() + self.hostOnly = record.scope == .hostOnly + self.path = record.path.isEmpty ? "/" : record.path + self.secure = record.isSecure + self.expires = record.expires + } + #endif + + public init(cookie: HTTPCookie) { + self.name = cookie.name + self.value = cookie.value + self.domain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + self.hostOnly = !cookie.domain.hasPrefix(".") + self.path = cookie.path.isEmpty ? "/" : cookie.path + self.secure = cookie.isSecure + self.expires = cookie.expiresDate + } + + func matches(_ url: URL, now: Date) -> Bool { + guard let host = url.host?.lowercased(), self.expires.map({ $0 > now }) ?? true, + !self.secure || url.scheme?.lowercased() == "https", + host == self.domain || (!self.hostOnly && host.hasSuffix("." + self.domain)) + else { return false } + let encodedPath = url.path(percentEncoded: true) + let requestPath = Array((encodedPath.isEmpty ? "/" : encodedPath).utf8) + let cookiePath = Array(self.path.utf8) + guard requestPath.starts(with: cookiePath) else { return false } + return requestPath.count == cookiePath.count || cookiePath.last == 47 || requestPath[cookiePath.count] == 47 + } + + static func header(_ records: [Self], for url: URL, now: Date = Date()) -> String? { + let matching = records.filter { $0.matches(url, now: now) }.sorted { + if $0.path.utf8.count != $1.path.utf8.count { return $0.path.utf8.count > $1.path.utf8.count } + if $0.name != $1.name { return $0.name < $1.name } + return $0.domain < $1.domain + } + return matching.isEmpty ? nil : matching.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } +} + +/// A new registry for every fetch prevents scripts from reusing or guessing another refresh's sessions. +final class ProviderPluginCookieJar: @unchecked Sendable { + private let lock = NSLock() + private var sessions: [String: ProviderPluginCookieSession] = [:] + + func register(_ session: ProviderPluginCookieSession) { + self.lock.withLock { self.sessions[session.id] = session } + } + + func reject(id: String) { + _ = self.lock.withLock { self.sessions.removeValue(forKey: id) } + } + + static func authenticate( + _ request: inout URLRequest, + sessionID: Any?, + required: Bool, + jar: ProviderPluginCookieJar?) throws + { + guard required || sessionID != nil else { return } + guard required, let id = sessionID as? String, let jar, let url = request.url, + request.value(forHTTPHeaderField: "Cookie") == nil, + request.value(forHTTPHeaderField: "Host") == nil + else { + throw ProviderPluginError + .secretAccess("request requires an opaque cookie session without header overrides") + } + try request.setValue(jar.header(id: id, url: url), forHTTPHeaderField: "Cookie") + } + + func header(id: String, url: URL, now: Date = Date()) throws -> String { + guard let session = self.lock.withLock({ self.sessions[id] }), + url.scheme?.lowercased() == "https", url.port == nil || url.port == 443, + url.user == nil, url.password == nil + else { throw ProviderPluginError.secretAccess("cookie session is unavailable") } + let header: String? = if let records = session.records { + ProviderPluginCookieRecord.header(records, for: url, now: now) + } else { + session.origin == "https://\(url.host?.lowercased() ?? "")" ? session.header : nil + } + guard let header, !header.isEmpty else { + throw ProviderFetchClassifiedError( + kind: .missingCredential, + message: "No session cookies match this request URL.") + } + return header + } +} + +/// Imported cookies never enter URLSession storage; redirects reselect them using the same URL matcher. +struct ProviderPluginCookieTransport: ProviderHTTPTransport { + let base: any ProviderHTTPTransport + let jar: ProviderPluginCookieJar + let id: String + private static let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.urlCredentialStorage = nil + configuration.urlCache = nil + return URLSession(configuration: configuration) + }() + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + guard let url = request.url else { throw URLError(.badURL) } + var request = request + try request.setValue(self.jar.header(id: self.id, url: url), forHTTPHeaderField: "Cookie") + // Synthetic/injected transports remain under the caller's control; the production default is isolated here. + if let client = self.base as? ProviderHTTPClient, client === ProviderHTTPClient.shared { + return try await Self.session.data( + for: request, + delegate: CookieRedirectDelegate(jar: self.jar, id: self.id)) + } + return try await self.base.data(for: request) + } + + final class CookieRedirectDelegate: NSObject, URLSessionTaskDelegate, Sendable { + let jar: ProviderPluginCookieJar + let id: String + + init(jar: ProviderPluginCookieJar, id: String) { + self.jar = jar + self.id = id + } + + func redirectedRequest(originalURL: URL?, request: URLRequest) -> URLRequest? { + guard var request = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: originalURL, redirectRequest: request), + let url = request.url, let header = try? self.jar.header(id: self.id, url: url) + else { return nil } + request.setValue(header, forHTTPHeaderField: "Cookie") + return request + } + + func urlSession( + _: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void) + { + completionHandler(self.redirectedRequest(originalURL: task.originalRequest?.url, request: request)) + } + } +} diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginEngine.swift b/Sources/CodexBarCore/Plugins/ProviderPluginEngine.swift index 1505b9592e..fa477f3fb3 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginEngine.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginEngine.swift @@ -1,5 +1,8 @@ import CoreFoundation import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif public enum ProviderPluginEngineKind: Equatable, Sendable { case automatic @@ -13,14 +16,20 @@ struct ProviderPluginContextOptions: Sendable { let optionalRequestTimeoutSeconds: TimeInterval? // Internal test control; public runtime initializers always use the production budget. var optionalCollectionBudget: Duration = .milliseconds(200) + var waitForOptionalDeadline: @Sendable (ContinuousClock.Instant, Duration) async throws -> Void = { start, budget in + try await Task.sleep(until: start.advanced(by: budget), clock: .continuous) + } + var storage: ProviderPluginStorage? - var beforeHTTPAttempt: (@Sendable () async throws -> Void)? + var beforeHTTPAttempt: (@Sendable (URLRequest) async throws -> Void)? var cookieSource: ProviderCookieSource = .auto var cookieInvalidator: ProviderPluginRuntime.CookieInvalidator? var cookieSessionResolver: ProviderPluginRuntime.CookieSessionResolver? var cookieSessionInvalidator: ProviderPluginRuntime.CookieSessionInvalidator? + var cookieJar: ProviderPluginCookieJar? func rejectCookie(domain: String, id: String) { + self.cookieJar?.reject(id: id) if !id.isEmpty, let invalidate = self.cookieSessionInvalidator { invalidate(domain, id) } else { diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginHTTPResponse.swift b/Sources/CodexBarCore/Plugins/ProviderPluginHTTPResponse.swift index d9bba31ee4..803d5b2f6f 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginHTTPResponse.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginHTTPResponse.swift @@ -15,6 +15,9 @@ enum ProviderPluginHTTPResponse { let optional: URLRequest? let retryPolicy: ProviderHTTPRetryPolicy let optionalBudget: Duration? + let cookieJar: ProviderPluginCookieJar? + let primarySession: String? + let optionalSession: String? init( rawURL: String, @@ -24,8 +27,13 @@ enum ProviderPluginHTTPResponse { secrets: [String: String], manifest: ProviderPluginManifest, enforcesUserResponsePolicy: Bool, - redactionValues: ProviderPluginRedactionValues? = nil) throws + redactionValues: ProviderPluginRedactionValues? = nil, + cookieJar: ProviderPluginCookieJar? = nil) throws { + self.cookieJar = cookieJar + self.primarySession = options["cookieSession"] as? String + let optionalOptions = (options["optionalRequest"] as? [String: Any])?["options"] as? [String: Any] + self.optionalSession = optionalOptions?["cookieSession"] as? String Self.redactForm(options, into: redactionValues) if let budget = options["optionalBudgetSeconds"] { guard let number = budget as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), @@ -44,7 +52,8 @@ enum ProviderPluginHTTPResponse { settings: settings, secrets: secrets, manifest: manifest, - enforcesUserResponsePolicy: enforcesUserResponsePolicy) + enforcesUserResponsePolicy: enforcesUserResponsePolicy, + cookieJar: cookieJar) if let optional = options["optionalRequest"] { guard method == "GET", let value = optional as? [String: Any], let url = value["url"] as? String, let optionalMethod = value["method"] as? String, @@ -61,7 +70,8 @@ enum ProviderPluginHTTPResponse { settings: settings, secrets: secrets, manifest: manifest, - enforcesUserResponsePolicy: enforcesUserResponsePolicy) + enforcesUserResponsePolicy: enforcesUserResponsePolicy, + cookieJar: cookieJar) request.timeoutInterval = min(request.timeoutInterval, 5) self.optional = request } else { @@ -69,6 +79,11 @@ enum ProviderPluginHTTPResponse { } } + func transport(_ base: any ProviderHTTPTransport, session: String?) -> any ProviderHTTPTransport { + guard let cookieJar, let session else { return base } + return ProviderPluginCookieTransport(base: base, jar: cookieJar, id: session) + } + private static func redactForm(_ options: [String: Any], into redactionValues: ProviderPluginRedactionValues?) { if let form = options["form"] as? [String: String] { for value in form.values { @@ -109,10 +124,10 @@ enum ProviderPluginHTTPResponse { defer { started.finish() } return try await .primary(self.response( for: request.primary, - transport: transport, + transport: request.transport(transport, session: request.primarySession), retryPolicy: request.retryPolicy, - beforeAttempt: { - try await contextOptions.beforeHTTPAttempt?() + beforeAttempt: { request in + try await contextOptions.beforeHTTPAttempt?(request) started.yield(.now) started.finish() })) @@ -121,7 +136,7 @@ enum ProviderPluginHTTPResponse { group.addTask { await .optional(try? self.response( for: optional, - transport: transport, + transport: request.transport(transport, session: request.optionalSession), retryPolicy: .disabled, beforeAttempt: contextOptions.beforeHTTPAttempt)) } @@ -129,7 +144,7 @@ enum ProviderPluginHTTPResponse { // Admission and scheduling waits belong to the overall fetch timeout. var iterator = starts.makeAsyncIterator() if let start = await iterator.next() { - try await Task.sleep(until: start.advanced(by: collectionBudget), clock: .continuous) + try await contextOptions.waitForOptionalDeadline(start, collectionBudget) } return .budgetExpired } @@ -208,7 +223,8 @@ enum ProviderPluginHTTPResponse { settings: [String: String], secrets: [String: String], manifest: ProviderPluginManifest, - enforcesUserResponsePolicy: Bool) throws -> URLRequest + enforcesUserResponsePolicy: Bool, + cookieJar: ProviderPluginCookieJar? = nil) throws -> URLRequest { guard let url = URL(string: rawURL) else { throw ProviderPluginError.networkPolicy("request URL is invalid") @@ -271,6 +287,8 @@ enum ProviderPluginHTTPResponse { } request.setValue(value, forHTTPHeaderField: auth.header) } + try ProviderPluginCookieJar.authenticate( + &request, sessionID: options["cookieSession"], required: manifest.usesCookieJar, jar: cookieJar) return request } @@ -303,14 +321,14 @@ enum ProviderPluginHTTPResponse { for request: URLRequest, transport: any ProviderHTTPTransport, retryPolicy: ProviderHTTPRetryPolicy, - beforeAttempt: (@Sendable () async throws -> Void)? = nil) async throws -> ProviderHTTPResponse + beforeAttempt: (@Sendable (URLRequest) async throws -> Void)? = nil) async throws -> ProviderHTTPResponse { let bounded = ProviderHTTPTransportHandler { request in try Task.checkCancellation() let (starts, started) = AsyncStream.makeStream() let task = Task { defer { started.finish() } - try await beforeAttempt?() + try await beforeAttempt?(request) try Task.checkCancellation() started.yield(.now) return try await transport.data(for: request) @@ -362,6 +380,9 @@ enum ProviderPluginHTTPResponse { transportErrors: TransportErrors? = nil) -> [String: Any] { var payload: [String: Any] = ["message": message] + if let classified = error as? ProviderFetchClassifiedError { + payload["failureKind"] = classified.kind.rawValue + } if let failure = error as? StatusFailure { payload["status"] = failure.response.statusCode payload["transportClass"] = "http" diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift b/Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift index d3401a9b97..cbd7c56552 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift @@ -77,6 +77,7 @@ public struct ProviderPluginManifest: Sendable { public let settings: [ProviderPluginSetting] public let capabilities: Set public let cookieDomains: Set + public let usesCookieJar: Bool func cookieDomain(_ rawDomain: String) throws -> String { let domain = rawDomain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -287,6 +288,22 @@ public struct ProviderPluginManifest: Sendable { "the browser-cookies capability requires at least one declared cookie domain") } self.cookieDomains = cookieDomains + if let policy = definition.property("cookiePolicy"), !policy.isUndefined { + guard !allowsDynamicID, self.id.firstPartyProvider != nil, capabilities.contains(.browserCookies), + policy.isObject, !policy.isArray, + try Set(policy.propertyNames()) == ["selection", "cache"], + policy.property("selection")?.isString == true, + policy.property("cache")?.isString == true, + policy.property("selection")?.stringValue() == "request-url", + policy.property("cache")?.stringValue() == "nonpersistent" + else { + throw ProviderPluginError + .invalidManifest("cookiePolicy requires bundled request-url/nonpersistent cookies") + } + self.usesCookieJar = true + } else { + self.usesCookieJar = false + } } private static func requiredString(_ object: any ProviderPluginValue, property: String) throws -> String { diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginRuntime.swift b/Sources/CodexBarCore/Plugins/ProviderPluginRuntime.swift index 1d46000a95..a7690d90a6 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginRuntime.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginRuntime.swift @@ -224,6 +224,16 @@ public final class ProviderPluginRuntime: @unchecked Sendable { resolver: cookieResolver, instanceResolver: instanceCookieResolver) contextOptions.cookieSessionInvalidator = cookieSessionInvalidator + if self.manifest.usesCookieJar { + let jar = ProviderPluginCookieJar() + let resolver = contextOptions.cookieSessionResolver + contextOptions.cookieJar = jar + contextOptions.cookieSessionResolver = { domain, cachedOnly in + guard let session = try await resolver?(domain, cachedOnly) else { return nil } + jar.register(session) + return session + } + } let worker = try self.currentWorker() let gate = ProviderPluginCompletionGate() let finish: @Sendable (Result) -> Void = { [weak worker] result in @@ -853,7 +863,8 @@ final class JavaScriptCoreProviderPluginEngine: ProviderPluginEngine, @unchecked secrets: secrets, manifest: self.manifest, enforcesUserResponsePolicy: self.enforcesUserResponsePolicy, - redactionValues: redactionValues) + redactionValues: redactionValues, + cookieJar: contextOptions.cookieJar) } catch { self.reject(callbacks.reject, error: error, transportErrors: redactionValues.transportErrors) return @@ -919,13 +930,22 @@ final class JavaScriptCoreProviderPluginEngine: ProviderPluginEngine, @unchecked return } let resolveCookie: @Sendable () async throws -> (header: String, payload: String) + guard sessionResolver != nil || !self.manifest.usesCookieJar else { + self.reject( + ProviderPluginJSValueBox(reject), + error: ProviderPluginError.secretAccess("cookie jars do not expose headers")) + return + } if let sessionResolver { resolveCookie = { guard let session = try await sessionResolver(domain, cachedOnly) else { return ("", "null") } guard session.origin == "https://\(domain)" else { throw ProviderPluginError.secretAccess("cookie session origin does not match its domain") } - return try (session.header, session.json()) + for record in session.records ?? [] { + redactionValues.insert(record.value) + } + return try (session.header, session.json(opaque: self.manifest.usesCookieJar)) } } else if let provider = self.manifest.id.firstPartyProvider, let resolver { resolveCookie = { let header = try await resolver(provider, domain); return (header, header) } diff --git a/Sources/CodexBarCore/Plugins/QuickJSProviderPluginEngine.swift b/Sources/CodexBarCore/Plugins/QuickJSProviderPluginEngine.swift index f12897480a..42fdea9550 100644 --- a/Sources/CodexBarCore/Plugins/QuickJSProviderPluginEngine.swift +++ b/Sources/CodexBarCore/Plugins/QuickJSProviderPluginEngine.swift @@ -657,7 +657,8 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab secrets: state.secrets, manifest: self.manifest, enforcesUserResponsePolicy: self.enforcesUserResponsePolicy, - redactionValues: state.redactionValues) + redactionValues: state.redactionValues, + cookieJar: state.contextOptions.cookieJar) // Paired GETs run in the host, even while this confined worker waits for their result. let payload = try self.blockingValue(timeout: self.timeout) { try await ProviderPluginHTTPResponse.fetch( @@ -683,6 +684,9 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab } do { let domain = try self.manifest.cookieDomain(self.string(from: arguments[0])) + guard session || !self.manifest.usesCookieJar else { + throw ProviderPluginError.secretAccess("cookie jars do not expose headers") + } guard state.contextOptions.cookieSource != .off else { throw ProviderPluginError.secretAccess("browser cookies are disabled for this provider") } @@ -695,7 +699,10 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab throw ProviderPluginError.secretAccess("cookie session origin does not match its domain") } header = candidate?.header ?? "" - payload = try candidate?.json() ?? "null" + for record in candidate?.records ?? [] { + state.redactionValues.insert(record.value) + } + payload = try candidate?.json(opaque: self.manifest.usesCookieJar) ?? "null" } else if !session, let provider = self.manifest.id.firstPartyProvider, let resolver = state.cookieResolver { diff --git a/Sources/CodexBarCore/Plugins/ScriptFetchStrategy.swift b/Sources/CodexBarCore/Plugins/ScriptFetchStrategy.swift index b3541bb12f..09ea584339 100644 --- a/Sources/CodexBarCore/Plugins/ScriptFetchStrategy.swift +++ b/Sources/CodexBarCore/Plugins/ScriptFetchStrategy.swift @@ -25,11 +25,14 @@ public final class ScriptFetchStrategy: ProviderFetchStrategy, @unchecked Sendab public typealias ValuesResolver = @Sendable (ProviderFetchContext) -> Values? public typealias ContextValidator = @Sendable (ProviderFetchContext) throws -> Void public typealias EnabledResolver = @Sendable ([String: String]) -> Bool + public typealias CookieSettingsResolver = @Sendable (ProviderFetchContext) + -> ProviderSettingsSnapshot.CookieProviderSettings public let id: String public let kind: ProviderFetchKind private let cookieImport: CookieImport? + private let cookieSettings: CookieSettingsResolver? private let provider: UsageProvider private let bundledPlugin: String private let sourceLabel: String @@ -62,6 +65,7 @@ public final class ScriptFetchStrategy: ProviderFetchStrategy, @unchecked Sendab self.kind = kind self.secretKey = secretKey self.cookieImport = nil + self.cookieSettings = nil self.transport = transport self.timeout = timeout self.validateContext = validateContext @@ -83,6 +87,7 @@ public final class ScriptFetchStrategy: ProviderFetchStrategy, @unchecked Sendab timeout: TimeInterval = ProviderPluginRuntime.defaultTimeout, validateContext: @escaping ContextValidator = { _ in }, cookieImport: CookieImport? = nil, + cookieSettings: CookieSettingsResolver? = nil, resolveValues: @escaping ValuesResolver, isEnabled: @escaping EnabledResolver = { ProviderPluginPrototype.isEnabled(environment: $0) }) { @@ -96,6 +101,7 @@ public final class ScriptFetchStrategy: ProviderFetchStrategy, @unchecked Sendab self.timeout = timeout self.validateContext = validateContext self.cookieImport = cookieImport + self.cookieSettings = cookieSettings self.resolveValues = resolveValues self.isEnabled = isEnabled } @@ -128,7 +134,12 @@ public final class ScriptFetchStrategy: ProviderFetchStrategy, @unchecked Sendab nil } let cookies = ProviderPluginCookieBroker( - provider: self.provider, domains: runtime.manifest.cookieDomains, context: context, importer: importer) + provider: self.provider, + domains: runtime.manifest.cookieDomains, + context: context, + importer: importer, + usesCookieJar: runtime.manifest.usesCookieJar, + settingsOverride: self.cookieSettings?(context)) let result = try await runtime.fetchResult( settings: values.settings, secrets: values.secrets, diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift deleted file mode 100644 index 5f8d741c4d..0000000000 --- a/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -public enum LongCatAPIError: LocalizedError, Sendable, Equatable { - case missingCookies - case invalidSession - case invalidRequest(String) - case networkError(String) - case apiError(String) - case parseFailed(String) - - public var errorDescription: String? { - switch self { - case .missingCookies: - "LongCat session cookies are missing. Sign in at longcat.chat, or paste a cookie header." - case .invalidSession: - "LongCat session is invalid or expired. Please sign in again at longcat.chat." - case let .invalidRequest(message): - "Invalid request: \(message)" - case let .networkError(message): - "LongCat network error: \(message)" - case let .apiError(message): - "LongCat API error: \(message)" - case let .parseFailed(message): - "Failed to parse LongCat usage data: \(message)" - } - } -} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift deleted file mode 100644 index 4061c897a8..0000000000 --- a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation - -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -public struct LongCatCookieOverride: Sendable { - /// Full `Cookie:` header value (e.g. `name=value; name2=value2`). - public let cookieHeader: String - - public init(cookieHeader: String) { - self.cookieHeader = cookieHeader - } -} - -public enum LongCatCookieHeader { - private static let headerPatterns: [String] = [ - #"(?i)-H\s*'Cookie:\s*([^']+)'"#, - #"(?i)-H\s*"Cookie:\s*([^"]+)""#, - #"(?i)\bcookie:\s*'([^']+)'"#, - #"(?i)\bcookie:\s*"([^"]+)""#, - #"(?i)\bcookie:\s*([^\r\n]+)"#, - ] - - public static func resolveCookieOverride(context: ProviderFetchContext) -> LongCatCookieOverride? { - // Off disables LongCat web auth entirely — including a lingering env cookie. - if context.settings?.longcat?.cookieSource == .off { - return nil - } - - if let settings = context.settings?.longcat, settings.cookieSource == .manual { - if let manual = settings.manualCookieHeader, !manual.isEmpty { - return self.override(from: manual) - } - } - - // Route env cookies through the settings reader so the lower-case - // `longcat_manual_cookie` alias and quote-trimming apply on the env path too. - if let envValue = LongCatSettingsReader.cookieHeader(environment: context.env), - let envHeader = self.override(from: envValue) - { - return envHeader - } - - return nil - } - - public static func override(from raw: String?) -> LongCatCookieOverride? { - guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { - return nil - } - - if let header = CookieHeaderNormalizer.extractHeader(from: raw, patterns: self.headerPatterns) { - return LongCatCookieOverride(cookieHeader: header) - } - - // A bare `name=value; ...` string is itself a usable cookie header. - if raw.contains("=") { - return LongCatCookieOverride(cookieHeader: raw) - } - - return nil - } - - static func header(from cookies: [HTTPCookie], for url: URL, now: Date = Date()) -> String? { - guard let host = url.host?.lowercased() else { return nil } - let requestPath = url.path.isEmpty ? "/" : url.path - let isHTTPS = url.scheme?.lowercased() == "https" - - let matching = cookies.filter { cookie in - guard cookie.expiresDate.map({ $0 > now }) ?? true else { return false } - guard !cookie.isSecure || isHTTPS else { return false } - guard self.domain(cookie.domain, matches: host) else { return false } - return self.path(cookie.path, matches: requestPath) - }.sorted { lhs, rhs in - if lhs.path.count != rhs.path.count { - return lhs.path.count > rhs.path.count - } - if lhs.name != rhs.name { - return lhs.name < rhs.name - } - return lhs.domain < rhs.domain - } - - guard !matching.isEmpty else { return nil } - return matching.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") - } - - private static func domain(_ cookieDomain: String, matches host: String) -> Bool { - let normalized = cookieDomain.lowercased() - if normalized.hasPrefix(".") { - let base = String(normalized.dropFirst()) - return host == base || host.hasSuffix("." + base) - } - return host == normalized - } - - private static func path(_ cookiePath: String, matches requestPath: String) -> Bool { - let normalized = cookiePath.isEmpty ? "/" : cookiePath - guard requestPath.hasPrefix(normalized) else { return false } - if requestPath.count == normalized.count || normalized.hasSuffix("/") { - return true - } - let boundary = requestPath.index(requestPath.startIndex, offsetBy: normalized.count) - return requestPath[boundary] == "/" - } -} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift deleted file mode 100644 index 0e942682c8..0000000000 --- a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift +++ /dev/null @@ -1,92 +0,0 @@ -import Foundation - -#if os(macOS) -import SweetCookieKit - -public enum LongCatCookieImporter { - private static let log = CodexBarLog.logger(LogCategories.provider(.longcat, scope: "cookie")) - private static let cookieClient = BrowserCookieClient() - private static let cookieDomains = ["longcat.chat", "www.longcat.chat"] - private static let cookieImportOrder: BrowserCookieImportOrder = - ProviderDefaults.metadata[.longcat]?.browserCookieOrder ?? Browser.defaultImportOrder - - public struct SessionInfo: Sendable { - /// Full imported jar. The fetcher applies browser-equivalent URL matching - /// before building each request header. - public let cookies: [HTTPCookie] - public let sourceLabel: String - - public init(cookies: [HTTPCookie], sourceLabel: String) { - self.cookies = cookies - self.sourceLabel = sourceLabel - } - } - - public static func importSessions( - browserDetection: BrowserDetection = BrowserDetection(), - logger: ((String) -> Void)? = nil) throws -> [SessionInfo] - { - try BrowserCookieImportSupport.collectSessions( - from: self.cookieImportOrder.cookieImportCandidates(using: browserDetection), - missingError: LongCatCookieImportError.noCookies, - logger: { self.emit($0, logger: logger) }, - load: { try self.importSessions(from: $0, logger: logger) }) - } - - public static func importSessions( - from browserSource: Browser, - logger: ((String) -> Void)? = nil) throws -> [SessionInfo] - { - let log: (String) -> Void = { message in self.emit(message, logger: logger) } - let profiles = try BrowserCookieImportSupport.loadProfiles( - from: browserSource, - domains: self.cookieDomains, - client: self.cookieClient, - logger: log) - var sessions: [SessionInfo] = [] - for (label, httpCookies) in profiles { - log("Found \(httpCookies.count) longcat.chat cookie(s) in \(label)") - sessions.append(SessionInfo(cookies: httpCookies, sourceLabel: label)) - } - return sessions - } - - public static func importSession( - browserDetection: BrowserDetection = BrowserDetection(), - logger: ((String) -> Void)? = nil) throws -> SessionInfo - { - let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) - guard let first = sessions.first else { - throw LongCatCookieImportError.noCookies - } - return first - } - - public static func hasSession( - browserDetection: BrowserDetection = BrowserDetection(), - logger: ((String) -> Void)? = nil) -> Bool - { - do { - return try !self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty - } catch { - return false - } - } - - private static func emit(_ message: String, logger: ((String) -> Void)?) { - logger?("[longcat-cookie] \(message)") - self.log.debug(message) - } -} - -enum LongCatCookieImportError: LocalizedError { - case noCookies - - var errorDescription: String? { - switch self { - case .noCookies: - "No LongCat session cookies found in browsers." - } - } -} -#endif diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift deleted file mode 100644 index 5a2828478b..0000000000 --- a/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation - -/// LongCat's web console wraps every response in a Meituan-style envelope: -/// `{ "code": 0, "message": "...", "data": { ... } }`. -/// -/// The exact `data` field names are not documented and cannot be derived from the -/// minified front-end bundle, so extraction is intentionally lenient: we walk the -/// decoded JSON trying a list of candidate keys. See `LongCatUsageFetcher`. -enum LongCatEnvelope { - /// Returns the `data` payload if the envelope reports success, else throws. - static func unwrap(_ object: Any?) throws -> Any { - guard let dict = object as? [String: Any] else { - throw LongCatAPIError.parseFailed("response was not a JSON object") - } - // Meituan envelopes use code == 0 for success; some surfaces use 200. - if let rawCode = dict["code"] { - guard let code = LongCatJSON.int(rawCode) else { - throw LongCatAPIError.parseFailed("response code was not a valid integer") - } - guard code != 0, code != 200 else { return dict["data"] ?? dict } - let message = LongCatJSON.string(dict["message"]) ?? LongCatJSON.string(dict["msg"]) ?? "code \(code)" - if code == 401 || code == 403 { throw LongCatAPIError.invalidSession } - throw LongCatAPIError.apiError(message) - } - return dict["data"] ?? dict - } -} - -/// Tiny dynamic-JSON helper for lenient extraction by candidate key names. -enum LongCatJSON { - static func int(_ value: Any?) -> Int? { - switch value { - case let v as Int: v - case let v as Double: Int(exactly: v.rounded(.towardZero)) - case let v as String: Int(v) ?? Double(v).flatMap { Int(exactly: $0.rounded(.towardZero)) } - case let v as NSNumber: Int(exactly: v.doubleValue.rounded(.towardZero)) - default: nil - } - } - - static func double(_ value: Any?) -> Double? { - switch value { - case let v as Double: v - case let v as Int: Double(v) - case let v as String: Double(v) - case let v as NSNumber: v.doubleValue - default: nil - } - } - - static func string(_ value: Any?) -> String? { - switch value { - case let v as String: v - case let v as NSNumber: v.stringValue - default: nil - } - } - - static func object(_ value: Any?) -> [String: Any]? { - value as? [String: Any] - } - - static func array(_ value: Any?) -> [[String: Any]]? { - if let arr = value as? [[String: Any]] { return arr } - if let arr = value as? [Any] { return arr.compactMap { $0 as? [String: Any] } } - return nil - } -} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift index c883e60e12..8ef2e83074 100644 --- a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift @@ -62,7 +62,7 @@ public enum LongCatProviderDescriptor { secondaryDescriptionMode: .detailWhenResetDatePresent)), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [LongCatWebFetchStrategy()] })), + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [Self.webStrategy()] })), cli: ProviderCLIConfig( name: "longcat", aliases: ["long-cat", "lc"], @@ -70,88 +70,29 @@ public enum LongCatProviderDescriptor { } } -struct LongCatWebFetchStrategy: ProviderFetchStrategy { - let id: String = "longcat.web" - let kind: ProviderFetchKind = .web - private static let log = CodexBarLog.logger(LogCategories.provider(.longcat, scope: "web")) - - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - if LongCatCookieHeader.resolveCookieOverride(context: context) != nil { - return true - } - - #if os(macOS) - if Self.allowsBrowserImport(context: context) { - return LongCatCookieImporter.hasSession(browserDetection: context.browserDetection) - } - #endif - - return false - } - - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - let snapshot: LongCatUsageSnapshot - if let override = LongCatCookieHeader.resolveCookieOverride(context: context) { - snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: override.cookieHeader) - } else { - #if os(macOS) - guard Self.allowsBrowserImport(context: context) else { - throw LongCatAPIError.missingCookies - } - let sessions = try LongCatCookieImporter.importSessions(browserDetection: context.browserDetection) - snapshot = try await Self.fetchImportedSessions(sessions) { session in - try await LongCatUsageFetcher.fetchUsage(cookies: session.cookies) - } - #else - throw LongCatAPIError.missingCookies - #endif - } - return self.makeResult( - usage: snapshot.toUsageSnapshot(), - sourceLabel: "web") - } - - func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { - if case LongCatAPIError.missingCookies = error { - return false - } - if case LongCatAPIError.invalidSession = error { - return false - } - return true +extension LongCatProviderDescriptor { + static func webStrategy(transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) -> ScriptFetchStrategy { + ScriptFetchStrategy( + id: "longcat.web", + provider: .longcat, + bundledPlugin: "longcat", + sourceLabel: "web", + kind: .web, + transport: transport, + cookieSettings: self.cookieSettings, + resolveValues: { _ in .init() }, + isEnabled: { _ in true }) } - #if os(macOS) - static func fetchImportedSessions( - _ sessions: [LongCatCookieImporter.SessionInfo], - fetch: (LongCatCookieImporter.SessionInfo) async throws -> LongCatUsageSnapshot) async throws - -> LongCatUsageSnapshot - { - var lastCredentialError: LongCatAPIError? - for session in sessions { - do { - return try await fetch(session) - } catch let error as LongCatAPIError { - switch error { - case .invalidSession, .missingCookies: - lastCredentialError = error - default: - throw error - } - } + static func cookieSettings(_ context: ProviderFetchContext) -> ProviderSettingsSnapshot.CookieProviderSettings { + let settings = context.settings?.longcat + let source = settings?.cookieSource ?? .auto + guard source != .off else { return .init(cookieSource: .off, manualCookieHeader: nil) } + let manual = source == .manual ? settings?.manualCookieHeader : nil + let raw = manual?.isEmpty == false ? manual : LongCatSettingsReader.cookieHeader(environment: context.env) + if let header = CookieHeaderNormalizer.normalize(raw), header.contains("=") { + return .init(cookieSource: .manual, manualCookieHeader: header) } - throw lastCredentialError ?? LongCatAPIError.missingCookies - } - #endif - - /// Browser cookie/keychain import is only used for user-initiated app - /// refreshes in the Auto source. Manual must use the pasted header and Off - /// disables web auth, so neither should silently fall back to a browser - /// session. - static func allowsBrowserImport(context: ProviderFetchContext) -> Bool { - let source = context.settings?.longcat?.cookieSource - return context.runtime == .app && - ProviderInteractionContext.current == .userInitiated && - (source == nil || source == .auto) + return .init(cookieSource: source, manualCookieHeader: nil) } } diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift deleted file mode 100644 index 7342d0f1f4..0000000000 --- a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift +++ /dev/null @@ -1,334 +0,0 @@ -import Foundation - -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -public struct LongCatUsageFetcher: Sendable { - private enum Authentication: @unchecked Sendable { - case header(String) - case cookies([HTTPCookie]) - - func header(for url: URL) -> String? { - switch self { - case let .header(value): - value.isEmpty ? nil : value - case let .cookies(cookies): - LongCatCookieHeader.header(from: cookies, for: url) - } - } - } - - private static let log = CodexBarLog.logger(LogCategories.provider(.longcat, scope: "api")) - private static let host = "https://longcat.chat" - - private static let userCurrentPath = "/api/v1/user-current" - private static let tokenPacksSummaryPath = "/api/pay/quota/metering/token-packs/summary" - private static let tokenUsagePath = "/api/lc-platform/v1/tokenUsage" - private static let pendingFuelPath = "/api/lc-platform/v1/pending-fuel-packages" - - /// LongCat fetches run on an isolated, ephemeral, cookie-free session so the - /// console's `Set-Cookie` responses never enter the shared provider cookie jar; - /// auth is carried solely by the explicit request `Cookie` header. Mirrors the - /// Sakana provider's isolated transport. - private static let defaultTransport: ProviderHTTPClient = { - let configuration = URLSessionConfiguration.ephemeral - configuration.httpCookieStorage = nil - configuration.httpShouldSetCookies = false - let session = ProviderHTTPClient.redirectGuardedSession(configuration: configuration) - return ProviderHTTPClient(session: session) - }() - - public static func fetchUsage( - cookieHeader: String, - transport transportOverride: (any ProviderHTTPTransport)? = nil, - now: Date = Date()) async throws -> LongCatUsageSnapshot - { - try await self.fetchUsage( - authentication: .header(cookieHeader), - transport: transportOverride, - now: now) - } - - static func fetchUsage( - cookies: [HTTPCookie], - transport transportOverride: (any ProviderHTTPTransport)? = nil, - now: Date = Date()) async throws -> LongCatUsageSnapshot - { - try await self.fetchUsage( - authentication: .cookies(cookies), - transport: transportOverride, - now: now) - } - - private static func fetchUsage( - authentication: Authentication, - transport transportOverride: (any ProviderHTTPTransport)?, - now: Date) async throws -> LongCatUsageSnapshot - { - let transport = transportOverride ?? Self.defaultTransport - // Account name. The user-current payload also carries a session token and - // phone number, so its body is never logged. This is the required probe: - // a Meituan envelope with HTTP 200 but code 401/403 surfaces as - // `.invalidSession` here (via unwrap) so expired cookies are reported - // rather than masked by an empty snapshot. - var account: [String: Any]? - if let data = try await self.get( - self.userCurrentPath, - authentication: authentication, - transport: transport, - required: true) - { - let payload = try LongCatEnvelope.unwrap(self.json(data)) - guard let object = payload as? [String: Any] else { - throw LongCatAPIError.parseFailed("user-current data was not an object") - } - account = object - } - - var tokenPackSummary: [String: Any]? - do { - if let data = try await self.post( - self.tokenPacksSummaryPath, - authentication: authentication, - transport: transport, - required: true) - { - let payload = try LongCatEnvelope.unwrap(self.json(data)) - guard let object = payload as? [String: Any] else { - throw LongCatAPIError.parseFailed("token-packs summary data was not an object") - } - tokenPackSummary = object - } - } catch { - Self.log.error("LongCat token-packs summary probe failed: \(error.localizedDescription)") - } - - var usage: [String: Any]? - if self.activeTokenPackLot(from: tokenPackSummary) == nil { - guard let usageData = try await self.get( - self.tokenUsagePath, - authentication: authentication, - transport: transport, - required: true) - else { - throw LongCatAPIError.parseFailed("tokenUsage response was empty") - } - let usagePayload = try LongCatEnvelope.unwrap(self.json(usageData)) - guard let usageObject = usagePayload as? [String: Any] else { - throw LongCatAPIError.parseFailed("tokenUsage data was not an object") - } - let canonicalUsage = LongCatJSON.object(usageObject["usage"]) ?? usageObject - guard LongCatJSON.double(canonicalUsage["totalToken"]) != nil else { - throw LongCatAPIError.parseFailed("tokenUsage data was missing totalToken") - } - usage = usageObject - } - - var fuel: [String: Any]? - do { - if let data = try await self.get( - self.pendingFuelPath, - authentication: authentication, - transport: transport, - required: false) - { - let payload = try LongCatEnvelope.unwrap(self.json(data)) - guard let object = payload as? [String: Any] else { - throw LongCatAPIError.parseFailed("pending fuel data was not an object") - } - fuel = object - } - } catch { - Self.log.error("LongCat supplemental fuel probe failed: \(error.localizedDescription)") - } - - return self.buildSnapshot( - account: account, - tokenPackSummary: tokenPackSummary, - tokenUsage: usage, - pendingFuel: fuel, - now: now) - } - - /// Pure extraction over the unwrapped `data` payloads. Field paths are locked - /// against captured live responses; see `LongCatProviderTests`. - static func buildSnapshot( - account: [String: Any]?, - tokenPackSummary: [String: Any]?, - tokenUsage: [String: Any]?, - pendingFuel: [String: Any]?, - now: Date = Date()) -> LongCatUsageSnapshot - { - var snapshot = LongCatUsageSnapshot(updatedAt: now) - - if let account { - snapshot.accountName = LongCatJSON.string(account["name"]) ?? LongCatJSON.string(account["nickName"]) - } - - if let lot = self.activeTokenPackLot(from: tokenPackSummary), - let total = LongCatJSON.double(lot["totalToken"]) - { - let used = LongCatJSON.double(lot["consumedToken"]) ?? 0 - snapshot.totalQuota = total - snapshot.usedQuota = used - snapshot.remainingQuota = total - used - } else if let tokenUsage { - // Legacy token quota: data.usage is the canonical aggregate; extData holds the - // per-model breakdown (LongCat-Flash-Lite, LongCat-2.0-Preview, ...). - let usage = LongCatJSON.object(tokenUsage["usage"]) ?? tokenUsage - snapshot.totalQuota = LongCatJSON.double(usage["totalToken"]) - snapshot.usedQuota = LongCatJSON.double(usage["usedToken"]) - snapshot.remainingQuota = LongCatJSON.double(usage["availableToken"]) - } - - if let pendingFuel { - self.applyFuelPackages(pendingFuel, to: &snapshot) - } - - return snapshot - } - - private static func activeTokenPackLot(from summary: [String: Any]?) -> [String: Any]? { - guard let lot = LongCatJSON.object(summary?["currentLot"]), - LongCatJSON.string(lot["status"])?.uppercased() == "ACTIVE", - let total = LongCatJSON.double(lot["totalToken"]), - total > 0 - else { - return nil - } - return lot - } - - private static func applyFuelPackages(_ dict: [String: Any], to snapshot: inout LongCatUsageSnapshot) { - let total = LongCatJSON.double(dict["totalQuota"]) - let packages = LongCatJSON.array(dict["list"]) ?? [] - - var remaining = 0.0 - var sawRemaining = false - var nearestExpiry: Date? - for package in packages { - // Field names are pinned to the shapes captured from live longcat.chat - // responses (see LongCatProviderTests): a fuel package reports its remaining - // balance under `availableToken` and its expiry under `expireTime`. - if let value = LongCatJSON.double(package["availableToken"]) { - remaining += value - sawRemaining = true - } - if let expiry = self.parseDate(package["expireTime"]) { - if nearestExpiry == nil || expiry < nearestExpiry! { - nearestExpiry = expiry - } - } - } - - if let total, total > 0 { - snapshot.fuelPackTotal = total - snapshot.fuelPackRemaining = sawRemaining ? remaining : total - } - snapshot.nearestFuelExpiry = nearestExpiry - } - - // MARK: - HTTP - - private static func get( - _ path: String, - authentication: Authentication, - transport: any ProviderHTTPTransport, - required: Bool) async throws -> Data? - { - try await self.request( - path, - method: "GET", - authentication: authentication, - transport: transport, - required: required) - } - - private static func post( - _ path: String, - authentication: Authentication, - transport: any ProviderHTTPTransport, - required: Bool) async throws -> Data? - { - try await self.request( - path, - method: "POST", - authentication: authentication, - transport: transport, - required: required) - } - - private static func request( - _ path: String, - method: String, - authentication: Authentication, - transport: any ProviderHTTPTransport, - required: Bool) async throws -> Data? - { - guard let url = URL(string: self.host + path) else { - throw LongCatAPIError.invalidRequest("bad URL: \(path)") - } - var request = URLRequest(url: url) - request.httpMethod = method - if method == "POST" { - request.httpBody = Data("{}".utf8) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - } - guard let cookieHeader = authentication.header(for: url) else { - throw LongCatAPIError.missingCookies - } - request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") - request.setValue(self.host, forHTTPHeaderField: "Origin") - request.setValue("\(self.host)/platform/usage", forHTTPHeaderField: "Referer") - request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") - let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" - request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - - let response = try await transport.response(for: request) - guard response.statusCode == 200 else { - // The shared transport's redirect guard drops cross-origin / non-HTTPS - // hops, so an expired-cookie login redirect surfaces here as the raw 3xx. - // Classify 3xx (and explicit 401/403) as an invalid session rather than a - // generic HTTP error, so users see "sign in again" instead of "HTTP 302". - if response.statusCode == 401 || response.statusCode == 403 - || (300..<400).contains(response.statusCode) - { - throw LongCatAPIError.invalidSession - } - if required { - throw LongCatAPIError.apiError("HTTP \(response.statusCode) for \(path)") - } - Self.log.error("LongCat \(path) returned \(response.statusCode)") - return nil - } - return response.data - } - - private static func json(_ data: Data) -> Any? { - try? JSONSerialization.jsonObject(with: data) - } - - private static func parseDate(_ value: Any?) -> Date? { - if let number = LongCatJSON.double(value) { - let seconds = number > 1_000_000_000_000 ? number / 1000 : number - if seconds > 1_000_000_000 { - return Date(timeIntervalSince1970: seconds) - } - } - if let string = LongCatJSON.string(value) { - if let date = ISO8601DateParser.parse(string) { - return date - } - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - if let date = formatter.date(from: string) { - return date - } - } - return nil - } -} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift deleted file mode 100644 index cb6dfe9fa5..0000000000 --- a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation - -/// Parsed, Sendable view of the LongCat console quota model: -/// 总额度 (total token quota) plus 加油包额度 (fuel packs, which expire). -public struct LongCatUsageSnapshot: Sendable { - public var totalQuota: Double? - public var usedQuota: Double? - public var remainingQuota: Double? - public var fuelPackTotal: Double? - public var fuelPackRemaining: Double? - public var nearestFuelExpiry: Date? - public var accountName: String? - public var updatedAt: Date - - public init( - totalQuota: Double? = nil, - usedQuota: Double? = nil, - remainingQuota: Double? = nil, - fuelPackTotal: Double? = nil, - fuelPackRemaining: Double? = nil, - nearestFuelExpiry: Date? = nil, - accountName: String? = nil, - updatedAt: Date = Date()) - { - self.totalQuota = totalQuota - self.usedQuota = usedQuota - self.remainingQuota = remainingQuota - self.fuelPackTotal = fuelPackTotal - self.fuelPackRemaining = fuelPackRemaining - self.nearestFuelExpiry = nearestFuelExpiry - self.accountName = accountName - self.updatedAt = updatedAt - } -} - -extension LongCatUsageSnapshot { - private func resolvedUsed(total: Double) -> Double? { - let used = self.usedQuota ?? self.remainingQuota.map { total - $0 } ?? 0 - return used.isFinite ? max(0, used) : nil - } - - public func toUsageSnapshot() -> UsageSnapshot { - // Primary: overall token quota consumption (总额度). - var primary: RateWindow? - if let total = totalQuota, total.isFinite, total > 0, let used = self.resolvedUsed(total: total) { - primary = RateWindow( - usedPercent: min(100, used / total * 100), - windowMinutes: nil, - resetsAt: nil, - resetDescription: "\(Self.wholeNumber(used))/\(Self.wholeNumber(total))") - } - - // Secondary: fuel-pack balance (加油包额度), with nearest expiry as reset. - var secondary: RateWindow? - if let total = fuelPackTotal, total.isFinite, total > 0 { - let remaining = self.fuelPackRemaining ?? total - let used = max(0, total - remaining) - if remaining.isFinite, used.isFinite { - secondary = RateWindow( - usedPercent: min(100, used / total * 100), - windowMinutes: nil, - resetsAt: self.nearestFuelExpiry, - resetDescription: "Fuel pack: \(Self.wholeNumber(remaining))/\(Self.wholeNumber(total))") - } - } - - let identity = ProviderIdentitySnapshot( - providerID: .longcat, - accountEmail: nil, - accountOrganization: self.accountName, - loginMethod: nil) - - return UsageSnapshot( - primary: primary, - secondary: secondary, - tertiary: nil, - providerCost: nil, - updatedAt: self.updatedAt, - identity: identity) - } - - private static func wholeNumber(_ value: Double) -> String { - let truncated = value.rounded(.towardZero) - let normalized: Double = truncated == 0 ? 0 : truncated - return String(format: "%.0f", normalized) - } -} diff --git a/Sources/CodexBarCore/Resources/Plugins/abacus.js b/Sources/CodexBarCore/Resources/Plugins/abacus.js index 41994de8cc..267bc6f796 100644 --- a/Sources/CodexBarCore/Resources/Plugins/abacus.js +++ b/Sources/CodexBarCore/Resources/Plugins/abacus.js @@ -1,3 +1,10 @@ +function _nullishCoalesce(lhs, rhsFn) { + if (lhs != null) { + return lhs; + } else { + return rhsFn(); + } +} defineProvider({ id: "abacus", name: "Abacus AI", @@ -58,7 +65,11 @@ defineProvider({ for await (const session of ctx.browser.sessions(domain)) { clearCookie = false; try { - const headers = { Cookie: session.header, Accept: "application/json", "Content-Type": "application/json" }; + const headers = { + Cookie: _nullishCoalesce(session.header, () => ""), + Accept: "application/json", + "Content-Type": "application/json", + }; const response = await ctx.http.getWithOptional( `https://${domain}/api/_getOrganizationComputePoints`, { diff --git a/Sources/CodexBarCore/Resources/Plugins/abacus.ts b/Sources/CodexBarCore/Resources/Plugins/abacus.ts index af2ab82a19..f4bd68ebe1 100644 --- a/Sources/CodexBarCore/Resources/Plugins/abacus.ts +++ b/Sources/CodexBarCore/Resources/Plugins/abacus.ts @@ -58,7 +58,11 @@ defineProvider({ for await (const session of ctx.browser.sessions(domain)) { clearCookie = false; try { - const headers = { Cookie: session.header, Accept: "application/json", "Content-Type": "application/json" }; + const headers = { + Cookie: session.header ?? "", + Accept: "application/json", + "Content-Type": "application/json", + }; const response = await ctx.http.getWithOptional( `https://${domain}/api/_getOrganizationComputePoints`, { diff --git a/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts b/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts index 1278b48708..f3ede35bcd 100644 --- a/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts +++ b/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts @@ -1,7 +1,7 @@ -/** A secret header bound to one declared origin; reject with its opaque ID to advance safely. */ +/** A host-issued candidate. Request-URL cookie policies expose metadata only. */ interface CodexBarCookieSession { readonly id: string; - readonly header: string; + readonly header?: string; readonly source: string; readonly origin: string; readonly cachedAt?: number; @@ -157,6 +157,8 @@ interface CodexBarFetchResult { } interface CodexBarHTTPRequestOptions { + /** Opaque session ID issued by browser.sessions for a request-url cookie policy. */ + cookieSession?: string; headers?: Readonly>; /** Hard deadline from transport start, 1–90 seconds (default 15); also bounded by the overall fetch deadline. */ timeoutSeconds?: number; @@ -287,6 +289,8 @@ interface CodexBarProviderDefinition { /** Grants declared cookie access, HTTP status handling, or bounded non-secret persistent state. */ capabilities?: Array<"browser-cookies" | "http-status" | "persistent-storage">; cookieDomains?: string[]; + /** Bundled-only, host-owned per-profile cookie selection without persistent session caching. */ + cookiePolicy?: { selection: "request-url"; cache: "nonpersistent" }; fetchUsage( ctx: CodexBarPluginContext, ): CodexBarUsageSnapshot | CodexBarFetchResult | Promise; diff --git a/Sources/CodexBarCore/Resources/Plugins/longcat.js b/Sources/CodexBarCore/Resources/Plugins/longcat.js new file mode 100644 index 0000000000..7873ceddf9 --- /dev/null +++ b/Sources/CodexBarCore/Resources/Plugins/longcat.js @@ -0,0 +1,226 @@ +function _nullishCoalesce(lhs, rhsFn) { + if (lhs != null) { + return lhs; + } else { + return rhsFn(); + } +} +function _optionalChain(ops) { + let lastAccessLHS = undefined; + let value = ops[0]; + let i = 1; + while (i < ops.length) { + const op = ops[i]; + const fn = ops[i + 1]; + i += 2; + if ((op === "optionalAccess" || op === "optionalCall") && value == null) { + return undefined; + } + if (op === "access" || op === "optionalAccess") { + lastAccessLHS = value; + value = fn(value); + } else if (op === "call" || op === "optionalCall") { + value = fn((...args) => value.call(lastAccessLHS, ...args)); + lastAccessLHS = undefined; + } + } + return value; +} +defineProvider({ + id: "longcat", + name: "LongCat", + settings: [], + endpoints: ["https://longcat.chat"], + capabilities: ["browser-cookies", "http-status"], + cookieDomains: ["longcat.chat", "www.longcat.chat"], + cookiePolicy: { selection: "request-url", cache: "nonpersistent" }, + async fetchUsage(ctx) { + const object = (value) => + value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined; + const number = (value) => { + if (typeof value === "number" || typeof value === "boolean") return Number(value); + if (typeof value !== "string" || value.trim() === "") return undefined; + if (/^[+-]?nan$/i.test(value)) return NaN; + if (/^[+-]?inf(?:inity)?$/i.test(value)) return value.startsWith("-") ? -Infinity : Infinity; + const parsed = Number(value); + return Number.isNaN(parsed) ? undefined : parsed; + }; + const text = (value) => (typeof value === "string" || typeof value === "number" ? String(value) : undefined); + const invalid = (message) => { + throw ctx.fail.parseFailure(`Invalid LongCat response: ${message}`); + }; + const expired = () => + Object.assign(ctx.fail.authenticationExpired("LongCat session is invalid or expired. Sign in again."), { + retrySession: true, + }); + const domain = "longcat.chat"; + if (ctx.browser.availability(domain) === "off") throw ctx.fail.missingCredential("LongCat cookies are disabled."); + const headers = { + Accept: "application/json, text/plain, */*", + Origin: "https://longcat.chat", + Referer: "https://longcat.chat/platform/usage", + "Accept-Language": "en-US,en;q=0.9", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + }; + const unwrap = (body) => { + let parsed; + try { + parsed = JSON.parse(body); + } catch (error) { + if (error instanceof SyntaxError) return invalid("not a JSON object"); + throw error; + } + const envelope = _nullishCoalesce(object(parsed), () => invalid("not a JSON object")); + if ("code" in envelope) { + const raw = number(envelope.code); + if (raw === undefined || !Number.isFinite(raw) || raw >= 9223372036854775808 || raw < -9223372036854775808) + return invalid("response code was not a valid integer"); + const code = Math.trunc(raw); + if (code === 401 || code === 403) throw expired(); + if (code !== 0 && code !== 200) + throw ctx.fail.apiFailure( + _nullishCoalesce( + _nullishCoalesce(text(envelope.message), () => text(envelope.msg)), + () => `LongCat code ${code}`, + ), + ); + } + return _nullishCoalesce(object("data" in envelope ? envelope.data : envelope), () => + invalid("data was not an object"), + ); + }; + const expiry = (value) => { + const numeric = number(value); + let date; + if (numeric !== undefined && numeric > 1000000000) { + date = new Date(numeric > 1000000000000 ? numeric : numeric * 1000); + } else if (typeof value === "string") { + const legacy = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(value); + date = legacy + ? new Date(+legacy[1], +legacy[2] - 1, +legacy[3], +legacy[4], +legacy[5], +legacy[6]) + : new Date(value); + } else return undefined; + return Number.isFinite(date.getTime()) ? date : undefined; + }; + const whole = (value) => { + const truncated = Math.trunc(value); + const plain = truncated.toFixed(0); + if (!plain.includes("e")) return plain; + const [coefficient, exponent] = plain.split("e+"); + const [integer, fraction = ""] = coefficient.split("."); + return integer + fraction + "0".repeat(Number(exponent) - fraction.length); + }; + let lastCredentialError; + for await (const session of ctx.browser.sessions(domain)) { + const request = async (path, post = false) => { + const options = { headers, cookieSession: session.id }; + let response; + try { + response = post + ? await ctx.http.post(`https://longcat.chat${path}`, { ...options, body: {} }) + : await ctx.http.get(`https://longcat.chat${path}`, options); + } catch (error) { + if (error.failureKind === "missing-credential") + throw Object.assign(ctx.fail.missingCredential("No LongCat cookies match this request."), { + retrySession: true, + }); + throw error; + } + if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) + throw expired(); + if (response.status !== 200) throw ctx.fail.apiFailure(`LongCat HTTP ${response.status} for ${path}`); + return unwrap(response.bodyText); + }; + const optional = async (path, post = false) => { + try { + return await request(path, post); + } catch (error) { + if (error.transportClass === "cancelled") throw error; + return undefined; + } + }; + try { + const account = await request("/api/v1/user-current"); + const summary = await optional("/api/pay/quota/metering/token-packs/summary", true); + const lot = object(_optionalChain([summary, "optionalAccess", (_) => _.currentLot])); + let total; + let used; + if ( + _optionalChain([ + text, + "call", + (_2) => _2(_optionalChain([lot, "optionalAccess", (_3) => _3.status])), + "optionalAccess", + (_4) => _4.toUpperCase, + "call", + (_5) => _5(), + ]) === "ACTIVE" && + _nullishCoalesce(number(_optionalChain([lot, "optionalAccess", (_6) => _6.totalToken])), () => 0) > 0 + ) { + total = number(_optionalChain([lot, "optionalAccess", (_7) => _7.totalToken])); + used = _nullishCoalesce(number(_optionalChain([lot, "optionalAccess", (_8) => _8.consumedToken])), () => 0); + } else { + const payload = await request("/api/lc-platform/v1/tokenUsage"); + const usage = _nullishCoalesce(object(payload.usage), () => payload); + total = number(usage.totalToken); + if (total === undefined) return invalid("tokenUsage was missing totalToken"); + used = _nullishCoalesce( + number(usage.usedToken), + () => total - _nullishCoalesce(number(usage.availableToken), () => total), + ); + } + const fuel = await optional("/api/lc-platform/v1/pending-fuel-packages"); + const fuelTotal = number(_optionalChain([fuel, "optionalAccess", (_9) => _9.totalQuota])); + let remaining = 0; + let sawRemaining = false; + let reset; + for (const raw of Array.isArray(_optionalChain([fuel, "optionalAccess", (_10) => _10.list])) ? fuel.list : []) { + const pack = object(raw); + const available = number(_optionalChain([pack, "optionalAccess", (_11) => _11.availableToken])); + if (available !== undefined) { + remaining += available; + sawRemaining = true; + } + const date = expiry(_optionalChain([pack, "optionalAccess", (_12) => _12.expireTime])); + if (date && (!reset || date < reset)) reset = date; + } + if (!sawRemaining) remaining = _nullishCoalesce(fuelTotal, () => 0); + const primaryUsed = Math.max( + 0, + _nullishCoalesce(used, () => 0), + ); + const fuelUsed = Math.max(0, _nullishCoalesce(fuelTotal, () => 0) - remaining); + const primary = + total !== undefined && Number.isFinite(total) && total > 0 && Number.isFinite(primaryUsed) + ? { usedPercent: ctx.pct(primaryUsed, total), resetDescription: `${whole(primaryUsed)}/${whole(total)}` } + : undefined; + const secondary = + fuelTotal !== undefined && + Number.isFinite(fuelTotal) && + fuelTotal > 0 && + Number.isFinite(remaining) && + Number.isFinite(fuelUsed) + ? { + usedPercent: ctx.pct(fuelUsed, fuelTotal), + resetsAt: reset, + resetDescription: `Fuel pack: ${whole(remaining)}/${whole(fuelTotal)}`, + } + : undefined; + return { + empty: !primary && !secondary, + primary, + secondary, + identity: { organization: _nullishCoalesce(text(account.name), () => text(account.nickName)) }, + }; + } catch (error) { + if (!error.retrySession) throw error; + lastCredentialError = error; + ctx.browser.rejectCookie(domain, session); + } + } + throw _nullishCoalesce(lastCredentialError, () => + ctx.fail.missingCredential("No LongCat session cookies found in browsers."), + ); + }, +}); diff --git a/Sources/CodexBarCore/Resources/Plugins/longcat.ts b/Sources/CodexBarCore/Resources/Plugins/longcat.ts new file mode 100644 index 0000000000..67df7a464c --- /dev/null +++ b/Sources/CodexBarCore/Resources/Plugins/longcat.ts @@ -0,0 +1,174 @@ +defineProvider({ + id: "longcat", + name: "LongCat", + settings: [], + endpoints: ["https://longcat.chat"], + capabilities: ["browser-cookies", "http-status"], + cookieDomains: ["longcat.chat", "www.longcat.chat"], + cookiePolicy: { selection: "request-url", cache: "nonpersistent" }, + async fetchUsage(ctx) { + type ObjectValue = Record; + const object = (value: unknown): ObjectValue | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) ? (value as ObjectValue) : undefined; + const number = (value: unknown): number | undefined => { + if (typeof value === "number" || typeof value === "boolean") return Number(value); + if (typeof value !== "string" || value.trim() === "") return undefined; + if (/^[+-]?nan$/i.test(value)) return NaN; + if (/^[+-]?inf(?:inity)?$/i.test(value)) return value.startsWith("-") ? -Infinity : Infinity; + const parsed = Number(value); + return Number.isNaN(parsed) ? undefined : parsed; + }; + const text = (value: unknown): string | undefined => + typeof value === "string" || typeof value === "number" ? String(value) : undefined; + const invalid = (message: string): never => { + throw ctx.fail.parseFailure(`Invalid LongCat response: ${message}`); + }; + const expired = () => + Object.assign(ctx.fail.authenticationExpired("LongCat session is invalid or expired. Sign in again."), { + retrySession: true, + }); + const domain = "longcat.chat"; + if (ctx.browser.availability(domain) === "off") throw ctx.fail.missingCredential("LongCat cookies are disabled."); + const headers = { + Accept: "application/json, text/plain, */*", + Origin: "https://longcat.chat", + Referer: "https://longcat.chat/platform/usage", + "Accept-Language": "en-US,en;q=0.9", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + }; + const unwrap = (body: string): ObjectValue => { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch (error) { + if (error instanceof SyntaxError) return invalid("not a JSON object"); + throw error; + } + const envelope = object(parsed) ?? invalid("not a JSON object"); + if ("code" in envelope) { + const raw = number(envelope.code); + if (raw === undefined || !Number.isFinite(raw) || raw >= 9223372036854775808 || raw < -9223372036854775808) + return invalid("response code was not a valid integer"); + const code = Math.trunc(raw); + if (code === 401 || code === 403) throw expired(); + if (code !== 0 && code !== 200) + throw ctx.fail.apiFailure(text(envelope.message) ?? text(envelope.msg) ?? `LongCat code ${code}`); + } + return object("data" in envelope ? envelope.data : envelope) ?? invalid("data was not an object"); + }; + const expiry = (value: unknown): Date | undefined => { + const numeric = number(value); + let date: Date; + if (numeric !== undefined && numeric > 1000000000) { + date = new Date(numeric > 1000000000000 ? numeric : numeric * 1000); + } else if (typeof value === "string") { + const legacy = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(value); + date = legacy + ? new Date(+legacy[1], +legacy[2] - 1, +legacy[3], +legacy[4], +legacy[5], +legacy[6]) + : new Date(value); + } else return undefined; + return Number.isFinite(date.getTime()) ? date : undefined; + }; + const whole = (value: number): string => { + const truncated = Math.trunc(value); + const plain = truncated.toFixed(0); + if (!plain.includes("e")) return plain; + const [coefficient, exponent] = plain.split("e+"); + const [integer, fraction = ""] = coefficient.split("."); + return integer + fraction + "0".repeat(Number(exponent) - fraction.length); + }; + let lastCredentialError: unknown; + for await (const session of ctx.browser.sessions(domain)) { + const request = async (path: string, post = false): Promise => { + const options = { headers, cookieSession: session.id }; + let response: CodexBarHTTPTextResponse; + try { + response = post + ? await ctx.http.post(`https://longcat.chat${path}`, { ...options, body: {} }) + : await ctx.http.get(`https://longcat.chat${path}`, options); + } catch (error) { + if ((error as { failureKind?: string }).failureKind === "missing-credential") + throw Object.assign(ctx.fail.missingCredential("No LongCat cookies match this request."), { + retrySession: true, + }); + throw error; + } + if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) + throw expired(); + if (response.status !== 200) throw ctx.fail.apiFailure(`LongCat HTTP ${response.status} for ${path}`); + return unwrap(response.bodyText); + }; + const optional = async (path: string, post = false): Promise => { + try { + return await request(path, post); + } catch (error) { + if ((error as CodexBarHTTPError).transportClass === "cancelled") throw error; + return undefined; + } + }; + try { + const account = await request("/api/v1/user-current"); + const summary = await optional("/api/pay/quota/metering/token-packs/summary", true); + const lot = object(summary?.currentLot); + let total: number | undefined; + let used: number | undefined; + if (text(lot?.status)?.toUpperCase() === "ACTIVE" && (number(lot?.totalToken) ?? 0) > 0) { + total = number(lot?.totalToken); + used = number(lot?.consumedToken) ?? 0; + } else { + const payload = await request("/api/lc-platform/v1/tokenUsage"); + const usage = object(payload.usage) ?? payload; + total = number(usage.totalToken); + if (total === undefined) return invalid("tokenUsage was missing totalToken"); + used = number(usage.usedToken) ?? total - (number(usage.availableToken) ?? total); + } + const fuel = await optional("/api/lc-platform/v1/pending-fuel-packages"); + const fuelTotal = number(fuel?.totalQuota); + let remaining = 0; + let sawRemaining = false; + let reset: Date | undefined; + for (const raw of Array.isArray(fuel?.list) ? fuel.list : []) { + const pack = object(raw); + const available = number(pack?.availableToken); + if (available !== undefined) { + remaining += available; + sawRemaining = true; + } + const date = expiry(pack?.expireTime); + if (date && (!reset || date < reset)) reset = date; + } + if (!sawRemaining) remaining = fuelTotal ?? 0; + const primaryUsed = Math.max(0, used ?? 0); + const fuelUsed = Math.max(0, (fuelTotal ?? 0) - remaining); + const primary = + total !== undefined && Number.isFinite(total) && total > 0 && Number.isFinite(primaryUsed) + ? { usedPercent: ctx.pct(primaryUsed, total), resetDescription: `${whole(primaryUsed)}/${whole(total)}` } + : undefined; + const secondary = + fuelTotal !== undefined && + Number.isFinite(fuelTotal) && + fuelTotal > 0 && + Number.isFinite(remaining) && + Number.isFinite(fuelUsed) + ? { + usedPercent: ctx.pct(fuelUsed, fuelTotal), + resetsAt: reset, + resetDescription: `Fuel pack: ${whole(remaining)}/${whole(fuelTotal)}`, + } + : undefined; + return { + empty: !primary && !secondary, + primary, + secondary, + identity: { organization: text(account.name) ?? text(account.nickName) }, + }; + } catch (error) { + if (!(error as { retrySession?: boolean }).retrySession) throw error; + lastCredentialError = error; + ctx.browser.rejectCookie(domain, session); + } + } + throw lastCredentialError ?? ctx.fail.missingCredential("No LongCat session cookies found in browsers."); + }, +}); diff --git a/Sources/CodexBarCore/Resources/Plugins/muse.js b/Sources/CodexBarCore/Resources/Plugins/muse.js index 84afa20a51..d99507a215 100644 --- a/Sources/CodexBarCore/Resources/Plugins/muse.js +++ b/Sources/CodexBarCore/Resources/Plugins/muse.js @@ -133,7 +133,7 @@ defineProvider({ for await (const session of ctx.browser.sessions("dev.meta.ai")) { if (requestsLeft <= 0) break; let rejected = false; - const headers = { Cookie: session.header, "User-Agent": "CodexBar" }; + const headers = { Cookie: _nullishCoalesce(session.header, () => ""), "User-Agent": "CodexBar" }; const get = async (path) => { if (requestsLeft-- <= 0) throw new Error("Muse browser request budget exhausted"); const response = await ctx.http.get(`https://dev.meta.ai${path}`, { headers, timeoutSeconds: 8 }); diff --git a/Sources/CodexBarCore/Resources/Plugins/muse.ts b/Sources/CodexBarCore/Resources/Plugins/muse.ts index 1b3eabe9c6..9344b04979 100644 --- a/Sources/CodexBarCore/Resources/Plugins/muse.ts +++ b/Sources/CodexBarCore/Resources/Plugins/muse.ts @@ -89,7 +89,7 @@ defineProvider({ for await (const session of ctx.browser.sessions("dev.meta.ai")) { if (requestsLeft <= 0) break; let rejected = false; - const headers = { Cookie: session.header, "User-Agent": "CodexBar" }; + const headers = { Cookie: session.header ?? "", "User-Agent": "CodexBar" }; const get = async (path: string): Promise | undefined> => { if (requestsLeft-- <= 0) throw new Error("Muse browser request budget exhausted"); const response = await ctx.http.get(`https://dev.meta.ai${path}`, { headers, timeoutSeconds: 8 }); diff --git a/Sources/CodexBarCore/Resources/Plugins/provider-plugin-prelude.js b/Sources/CodexBarCore/Resources/Plugins/provider-plugin-prelude.js index 43d5179921..c8c3ed6a0f 100644 --- a/Sources/CodexBarCore/Resources/Plugins/provider-plugin-prelude.js +++ b/Sources/CodexBarCore/Resources/Plugins/provider-plugin-prelude.js @@ -53,7 +53,7 @@ hostOptions.bodyJSON = JSON.stringify(opts.body); if (hostOptions.bodyJSON === undefined) throw new TypeError("postJSON body is not JSON-serializable"); } - for (const key of ["headers", "timeoutSeconds", "retryPolicy", "openRouterManagementAuth"]) { + for (const key of ["headers", "timeoutSeconds", "retryPolicy", "openRouterManagementAuth", "cookieSession"]) { if (opts[key] !== undefined) hostOptions[key] = opts[key]; } return hostOptions; diff --git a/Sources/CodexBarCore/Resources/Plugins/raycast.js b/Sources/CodexBarCore/Resources/Plugins/raycast.js index 489385044c..9ec57eef8f 100644 --- a/Sources/CodexBarCore/Resources/Plugins/raycast.js +++ b/Sources/CodexBarCore/Resources/Plugins/raycast.js @@ -30,7 +30,7 @@ defineProvider({ let response; let rejected = false; for await (const session of ctx.browser.sessions(domain)) { - const cookie = session.header + const cookie = _nullishCoalesce(session.header, () => "") .split(";") .map((part) => part.trim()) .filter((part) => /^(?:__raycast_session|csrf_token)=/.test(part)) diff --git a/Sources/CodexBarCore/Resources/Plugins/raycast.ts b/Sources/CodexBarCore/Resources/Plugins/raycast.ts index 71ab268f04..6b018f6d34 100644 --- a/Sources/CodexBarCore/Resources/Plugins/raycast.ts +++ b/Sources/CodexBarCore/Resources/Plugins/raycast.ts @@ -23,7 +23,7 @@ defineProvider({ let response: CodexBarHTTPTextResponse | undefined; let rejected = false; for await (const session of ctx.browser.sessions(domain)) { - const cookie = session.header + const cookie = (session.header ?? "") .split(";") .map((part) => part.trim()) .filter((part) => /^(?:__raycast_session|csrf_token)=/.test(part)) diff --git a/Tests/CodexBarTests/BrowserCookieImportSupportTests.swift b/Tests/CodexBarTests/BrowserCookieImportSupportTests.swift index d53dc9525e..60032ba67a 100644 --- a/Tests/CodexBarTests/BrowserCookieImportSupportTests.swift +++ b/Tests/CodexBarTests/BrowserCookieImportSupportTests.swift @@ -4,6 +4,16 @@ import Testing @testable import CodexBarCore struct BrowserCookieImportSupportTests { + @Test + func `empty session iterators let the plugin classify missing credentials`() throws { + let sessions: [String] = try BrowserCookieImportSupport.collectSessions( + from: [.chrome], + missingError: nil, + logger: { _ in }, + load: { _ in [] }) + #expect(sessions.isEmpty) + } + @Test(arguments: [ UsageProvider.copilot, .grok, diff --git a/Tests/CodexBarTests/BrowserCookieProfilesTests.swift b/Tests/CodexBarTests/BrowserCookieProfilesTests.swift index 2c6e3e25c0..65c36cdb5e 100644 --- a/Tests/CodexBarTests/BrowserCookieProfilesTests.swift +++ b/Tests/CodexBarTests/BrowserCookieProfilesTests.swift @@ -77,6 +77,28 @@ struct BrowserCookieProfilesTests { records: records) } + @Test + func `host-only and domain cookies survive merging with identical names and paths`() throws { + let records = [BrowserCookieScope.hostOnly, .domain].map { scope in + BrowserCookieRecord( + domain: "example.test", + name: "session", + path: "/", + value: "\(scope)", + expires: nil, + isSecure: true, + isHTTPOnly: true, + scope: scope) + } + let profile = try #require(BrowserCookieProfiles.merge([ + Self.source("fixture", label: "Fixture", kind: .primary, records: records), + ]).first) + #expect(profile.records.count == 2) + let jar = profile.records.map(ProviderPluginCookieRecord.init) + let url = try #require(URL(string: "https://sub.example.test/api")) + #expect(ProviderPluginCookieRecord.header(jar, for: url) == "session=domain") + } + private static func cookie( _ name: String, value: String, diff --git a/Tests/CodexBarTests/LongCatProviderTests.swift b/Tests/CodexBarTests/LongCatProviderTests.swift index 57fa90f7af..08279e1083 100644 --- a/Tests/CodexBarTests/LongCatProviderTests.swift +++ b/Tests/CodexBarTests/LongCatProviderTests.swift @@ -29,187 +29,61 @@ struct LongCatProviderTests { #expect(LongCatSettingsReader.cookieHeader(environment: ["longcat_manual_cookie": "'a=b; c=d'"]) == "a=b; c=d") } - // MARK: - Cookie header override - - @Test - func `override accepts bare cookie pair string`() { - let override = LongCatCookieHeader.override(from: "passport_token=abc; uid=42") - #expect(override?.cookieHeader == "passport_token=abc; uid=42") - } - - @Test - func `override extracts from a curl Cookie header`() { - let raw = "curl 'https://longcat.chat/api/v1/user-current' -H 'Cookie: passport_token=abc; uid=42'" - let override = LongCatCookieHeader.override(from: raw) - #expect(override?.cookieHeader == "passport_token=abc; uid=42") - } - - @Test - func `override rejects a token-less string`() { - #expect(LongCatCookieHeader.override(from: "not a cookie") == nil) - #expect(LongCatCookieHeader.override(from: " ") == nil) - } - - @Test - func `imported cookies honor request host path secure and expiry scope`() throws { - let now = Date(timeIntervalSince1970: 1_700_000_000) - let cookies = try [ - self.cookie(name: "root", value: "1", domain: "longcat.chat", path: "/"), - self.cookie(name: "scoped", value: "2", domain: ".longcat.chat", path: "/api/v1"), - self.cookie(name: "www", value: "3", domain: "www.longcat.chat", path: "/"), - self.cookie(name: "other", value: "4", domain: "longcat.chat", path: "/platform"), - self.cookie(name: "expired", value: "5", domain: "longcat.chat", path: "/", expires: now - 1), - self.cookie(name: "secure", value: "6", domain: "longcat.chat", path: "/", secure: true), - ] - let secureURL = try #require(URL(string: "https://longcat.chat/api/v1/user-current")) - let insecureURL = try #require(URL(string: "http://longcat.chat/api/v1/user-current")) - - #expect(LongCatCookieHeader.header(from: cookies, for: secureURL, now: now) == "scoped=2; root=1; secure=6") - #expect(LongCatCookieHeader.header(from: cookies, for: insecureURL, now: now) == "scoped=2; root=1") - } - - // MARK: - Snapshot mapping - - @Test - func `total quota maps to primary used percent`() { - let snapshot = LongCatUsageSnapshot(totalQuota: 1000, usedQuota: 250) - let usage = snapshot.toUsageSnapshot() - #expect(usage.identity?.providerID == .longcat) - #expect(abs((usage.primary?.usedPercent ?? 0) - 25) < 0.001) - } - - @Test - func `remaining quota infers used when used is absent`() { - let snapshot = LongCatUsageSnapshot(totalQuota: 1000, remainingQuota: 400) - #expect(abs((snapshot.toUsageSnapshot().primary?.usedPercent ?? 0) - 60) < 0.001) - } - - @Test - func `missing quota data omits primary window`() { - let usage = LongCatUsageSnapshot(fuelPackTotal: 500, fuelPackRemaining: 200).toUsageSnapshot() - #expect(usage.primary == nil) - #expect(usage.secondary != nil) - } - - @Test - func `fuel pack populates secondary window`() { - let snapshot = LongCatUsageSnapshot(fuelPackTotal: 500, fuelPackRemaining: 200) - let usage = snapshot.toUsageSnapshot() - #expect(usage.secondary != nil) - #expect(abs((usage.secondary?.usedPercent ?? 0) - 60) < 0.001) - } - - // MARK: - buildSnapshot against captured live response shapes - - private func object(_ json: String) throws -> [String: Any] { - let parsed = try JSONSerialization.jsonObject(with: Data(json.utf8)) - return try #require(parsed as? [String: Any]) - } - - @Test - func `buildSnapshot maps live tokenUsage and account fields`() throws { - // Shapes captured from longcat.chat console (values neutralised). - let account = try self.object(#"{"userId":1,"name":"LongCat User","phone":"x","token":"secret"}"#) - let tokenUsage = try self.object(#""" - {"usage":{"totalToken":500000,"usedToken":120000,"availableToken":380000,"freeAvailableToken":380000}, - "extData":{"LongCat-Flash-Lite":{"totalToken":50000000,"usedToken":0}}} - """#) - let fuel = try self.object(#"{"totalQuota":0,"list":[]}"#) - - let snapshot = LongCatUsageFetcher.buildSnapshot( - account: account, - tokenPackSummary: nil, - tokenUsage: tokenUsage, - pendingFuel: fuel) - #expect(snapshot.accountName == "LongCat User") - #expect(snapshot.totalQuota == 500_000) - #expect(snapshot.usedQuota == 120_000) - #expect(snapshot.remainingQuota == 380_000) - #expect(snapshot.fuelPackTotal == nil) // empty fuel list - - let usage = snapshot.toUsageSnapshot() - #expect(abs((usage.primary?.usedPercent ?? 0) - 24) < 0.001) - #expect(usage.secondary == nil) - } - - @Test - func `buildSnapshot prefers an active token pack lot`() throws { - let tokenPackSummary = try self.object(#""" - {"currentLot":{"totalToken":50000000,"consumedToken":1212576,"consumedRatio":0.02425152, - "status":"ACTIVE"}} - """#) - let staleTokenUsage = try self.object(#""" - {"usage":{"totalToken":500000,"usedToken":0,"availableToken":500000}} - """#) - - let snapshot = LongCatUsageFetcher.buildSnapshot( - account: nil, - tokenPackSummary: tokenPackSummary, - tokenUsage: staleTokenUsage, - pendingFuel: nil) - #expect(snapshot.totalQuota == 50_000_000) - #expect(snapshot.usedQuota == 1_212_576) - #expect(snapshot.remainingQuota == 48_787_424) - #expect(abs((snapshot.toUsageSnapshot().primary?.usedPercent ?? 0) - 2.425152) < 0.001) - } - - @Test - func `buildSnapshot sums active fuel packages`() throws { - let fuel = try self.object(#""" - {"totalQuota":1000,"list":[{"availableToken":600,"expireTime":1750000000000}, - {"availableToken":150,"expireTime":1760000000000}]} - """#) - let snapshot = LongCatUsageFetcher.buildSnapshot( - account: nil, - tokenPackSummary: nil, - tokenUsage: nil, - pendingFuel: fuel) - #expect(snapshot.fuelPackTotal == 1000) - #expect(snapshot.fuelPackRemaining == 750) - #expect(snapshot.nearestFuelExpiry != nil) - #expect(snapshot.toUsageSnapshot().primary == nil) - } - - @Test(arguments: [ - "2025-06-15T15:06:40.250Z", - "2025-06-15T17:06:40.250+02:00", - ]) - func `fractional fuel expiry survives snapshot conversion`(expiry: String) throws { - let fuel: [String: Any] = [ - "totalQuota": 1000, - "list": [ - ["availableToken": 600, "expireTime": 1_760_000_000_000] as [String: Any], - ["availableToken": 150, "expireTime": expiry], - ], - ] - let snapshot = LongCatUsageFetcher.buildSnapshot( - account: nil, - tokenPackSummary: nil, - tokenUsage: nil, - pendingFuel: fuel) - let expected = Date(timeIntervalSince1970: 1_750_000_000.250) - let actual = try #require(snapshot.toUsageSnapshot().secondary?.resetsAt) - #expect(abs(actual.timeIntervalSince(expected)) < 0.001) - #expect(snapshot.fuelPackRemaining == 750) - } - - // MARK: - Envelope - - @Test - func `envelope surfaces invalid session on auth code`() { - #expect(throws: LongCatAPIError.invalidSession) { - try LongCatEnvelope.unwrap(["code": 401, "message": "unauthorized"]) + @Test(arguments: ["session=fixture", "curl 'https://longcat.chat/' -H 'Cookie: session=fixture'"]) + func `manual and environment headers share normalization`(raw: String) { + let automatic = self.context(env: ["LONGCAT_MANUAL_COOKIE": raw], cookieSource: .auto) + let settings = LongCatProviderDescriptor.cookieSettings(automatic) + #expect(settings.cookieSource == .manual) + #expect(settings.manualCookieHeader == "session=fixture") + } + + @Test + func `off disables environment cookies`() { + let settings = LongCatProviderDescriptor.cookieSettings(self.context( + env: ["LONGCAT_MANUAL_COOKIE": "session=fixture"], cookieSource: .off)) + #expect(settings.cookieSource == .off) + #expect(settings.manualCookieHeader == nil) + } + + @Test + func `manual takes precedence over environment and invalid manual does not fall back`() { + for header in ["session=manual", "not a cookie"] { + var context = self.context(env: ["LONGCAT_MANUAL_COOKIE": "session=env"], cookieSource: .manual) + context = ProviderFetchContext( + runtime: context.runtime, + sourceMode: context.sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: context.env, + settings: .make(longcat: .init(cookieSource: .manual, manualCookieHeader: header)), + fetcher: context.fetcher, + claudeFetcher: context.claudeFetcher, + browserDetection: context.browserDetection) + let settings = LongCatProviderDescriptor.cookieSettings(context) + #expect(settings.cookieSource == .manual) + #expect(settings.manualCookieHeader == (header.contains("=") ? header : nil)) } } @Test - func `envelope unwraps data on success`() throws { - let data = try LongCatEnvelope.unwrap(["code": 0, "data": ["x": 1]]) as? [String: Any] - #expect(data?["x"] as? Int == 1) + func `background and CLI automatic sessions never import`() throws { + for runtime in [ProviderRuntime.app, .cli] { + let context = self.context(env: [:], cookieSource: .auto, runtime: runtime) + let broker = ProviderPluginCookieBroker( + provider: .longcat, domains: ["longcat.chat"], context: context, usesCookieJar: true) + #expect(try broker.nextSession(domain: "longcat.chat") == nil) + if runtime == .cli { + try ProviderInteractionContext.$current.withValue(.userInitiated) { () throws in + let interactive = ProviderPluginCookieBroker( + provider: .longcat, domains: ["longcat.chat"], context: context, usesCookieJar: true) + #expect(try interactive.nextSession(domain: "longcat.chat") == nil) + } + } + } } - // MARK: - Cookie source semantics - private func context( env: [String: String], cookieSource: ProviderCookieSource, @@ -230,345 +104,4 @@ struct LongCatProviderTests { claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), browserDetection: browserDetection) } - - @Test - func `off source disables env cookie override`() { - let ctx = self.context(env: ["LONGCAT_MANUAL_COOKIE": "a=b"], cookieSource: .off) - #expect(LongCatCookieHeader.resolveCookieOverride(context: ctx) == nil) - } - - @Test - func `auto source allows env cookie override`() { - let ctx = self.context(env: ["LONGCAT_MANUAL_COOKIE": "a=b"], cookieSource: .auto) - #expect(LongCatCookieHeader.resolveCookieOverride(context: ctx)?.cookieHeader == "a=b") - } - - @Test - func `browser import is user initiated app auto only`() { - let appAuto = self.context(env: [:], cookieSource: .auto) - let cliAuto = self.context(env: [:], cookieSource: .auto, runtime: .cli) - let appManual = self.context(env: [:], cookieSource: .manual) - let appOff = self.context(env: [:], cookieSource: .off) - - #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appAuto) == false) - #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: cliAuto) == false) - - ProviderInteractionContext.$current.withValue(.userInitiated) { - #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appAuto)) - #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: cliAuto) == false) - #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appManual) == false) - #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appOff) == false) - } - } - - #if os(macOS) - @Test - func `browser import tries later profiles after credential failure`() async throws { - let cookie = try self.cookie(name: "session", value: "x", domain: "longcat.chat", path: "/") - let sessions = [ - LongCatCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome Profile 1"), - LongCatCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome Profile 2"), - ] - var attempts: [String] = [] - - let snapshot = try await LongCatWebFetchStrategy.fetchImportedSessions(sessions) { session in - attempts.append(session.sourceLabel) - if session.sourceLabel == "Chrome Profile 1" { - throw LongCatAPIError.invalidSession - } - return LongCatUsageSnapshot(totalQuota: 100, usedQuota: 10) - } - - #expect(attempts == ["Chrome Profile 1", "Chrome Profile 2"]) - #expect(snapshot.totalQuota == 100) - } - - @Test - func `browser import stops on non-credential failure`() async throws { - let cookie = try self.cookie(name: "session", value: "x", domain: "longcat.chat", path: "/") - let sessions = [ - LongCatCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome Profile 1"), - LongCatCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome Profile 2"), - ] - var attempts = 0 - - await #expect(throws: LongCatAPIError.apiError("HTTP 500")) { - _ = try await LongCatWebFetchStrategy.fetchImportedSessions(sessions) { _ in - attempts += 1 - throw LongCatAPIError.apiError("HTTP 500") - } - } - #expect(attempts == 1) - } - #endif - - // MARK: - HTTP status handling (fetchUsage over an injected transport) - - @Test - func `fetch surfaces invalid session on 401`() async { - let transport = LongCatScriptedTransport(results: [.status(401)]) - await #expect(throws: LongCatAPIError.invalidSession) { - _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - } - } - - @Test - func `fetch surfaces invalid session on 403`() async { - let transport = LongCatScriptedTransport(results: [.status(403)]) - await #expect(throws: LongCatAPIError.invalidSession) { - _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - } - } - - @Test - func `fetch treats a blocked login redirect as invalid session`() async { - // The shared transport's redirect guard drops the cross-origin login hop, so an - // expired cookie surfaces here as a raw 3xx; it must still read as invalid-session. - let transport = LongCatScriptedTransport(results: [.status(302)]) - await #expect(throws: LongCatAPIError.invalidSession) { - _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - } - } - - @Test - func `fetch uses the active token pack lot without legacy token usage`() async throws { - let transport = LongCatScriptedTransport(results: [ - .body(#"{"code":0,"data":{"name":"Leo"}}"#), - .body(#""" - {"code":0,"data":{"currentLot":{"totalToken":50000000,"consumedToken":1212576, - "consumedRatio":0.02425152,"status":"ACTIVE"}}} - """#), - .body(#"{"code":0,"data":{"totalQuota":1000,"list":[{"availableToken":600,"expireTime":1750000000000}]}}"#), - ]) - let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - #expect(snapshot.accountName == "Leo") - #expect(snapshot.totalQuota == 50_000_000) - #expect(snapshot.usedQuota == 1_212_576) - #expect(snapshot.fuelPackTotal == 1000) - #expect(snapshot.fuelPackRemaining == 600) - - let requests = await transport.capturedRequests() - #expect(requests.map(\.path) == [ - "/api/v1/user-current", - "/api/pay/quota/metering/token-packs/summary", - "/api/lc-platform/v1/pending-fuel-packages", - ]) - #expect(requests[1].method == "POST") - #expect(requests[1].body == Data("{}".utf8)) - #expect(requests[1].contentType == "application/json") - } - - @Test - func `fetch requires the canonical token usage response`() async { - let transport = LongCatScriptedTransport(results: [ - .body(#"{"code":0,"data":{"name":"Leo"}}"#), - .body(#"{"code":0,"data":{"currentLot":null}}"#), - .status(500), - ]) - await #expect(throws: LongCatAPIError.apiError("HTTP 500 for /api/lc-platform/v1/tokenUsage")) { - _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - } - } - - @Test - func `fetch rejects malformed canonical token usage data`() async { - let transport = LongCatScriptedTransport(results: [ - .body(#"{"code":0,"data":{"name":"Leo"}}"#), - .body(#"{"code":0,"data":{"currentLot":null}}"#), - .body(#"{"code":0,"data":[]}"#), - ]) - await #expect(throws: LongCatAPIError.parseFailed("tokenUsage data was not an object")) { - _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - } - } - - @Test - func `fetch rejects canonical token usage without quota fields`() async { - let transport = LongCatScriptedTransport(results: [ - .body(#"{"code":0,"data":{"name":"Leo"}}"#), - .body(#"{"code":0,"data":{"currentLot":null}}"#), - .body(#"{"code":0,"data":{"usage":{"usedToken":120000}}}"#), - ]) - await #expect(throws: LongCatAPIError.parseFailed("tokenUsage data was missing totalToken")) { - _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - } - } - - @Test - func `zero total token pack lot falls back to legacy token usage`() async throws { - let transport = LongCatScriptedTransport(results: [ - .body(#"{"code":0,"data":{"name":"Leo"}}"#), - .body(#"{"code":0,"data":{"currentLot":{"totalToken":0,"consumedToken":0,"status":"ACTIVE"}}}"#), - .body(#"{"code":0,"data":{"usage":{"totalToken":500000,"usedToken":120000,"availableToken":380000}}}"#), - .body(#"{"code":0,"data":{"totalQuota":0,"list":[]}}"#), - ]) - - let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - #expect(snapshot.totalQuota == 500_000) - #expect(snapshot.usedQuota == 120_000) - #expect(await (transport.capturedRequests()).map(\.path).contains("/api/lc-platform/v1/tokenUsage")) - } - - @Test - func `expired token pack lot falls back to legacy token usage`() async throws { - let transport = LongCatScriptedTransport(results: [ - .body(#"{"code":0,"data":{"name":"Leo"}}"#), - .body(#"{"code":0,"data":{"currentLot":{"totalToken":50000000,"status":"EXPIRED"}}}"#), - .body(#"{"code":0,"data":{"usage":{"totalToken":500000,"usedToken":120000}}}"#), - .body(#"{"code":0,"data":{"totalQuota":0,"list":[]}}"#), - ]) - - let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - #expect(snapshot.totalQuota == 500_000) - #expect(snapshot.usedQuota == 120_000) - } - - @Test(arguments: [ - #"{"code":0,"data":{}}"#, - #"{"code":0,"data":{"currentLot":null}}"#, - ]) - func `missing or null current token pack lot falls back to legacy token usage`(summaryBody: String) async throws { - let transport = LongCatScriptedTransport(results: [ - .body(#"{"code":0,"data":{"name":"Leo"}}"#), - .body(summaryBody), - .body(#"{"code":0,"data":{"usage":{"totalToken":500000,"usedToken":120000}}}"#), - .body(#"{"code":0,"data":{"totalQuota":0,"list":[]}}"#), - ]) - - let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - #expect(snapshot.totalQuota == 500_000) - #expect(snapshot.usedQuota == 120_000) - } - - @Test - func `token pack summary server failure falls back to legacy token usage`() async throws { - let transport = LongCatScriptedTransport(results: [ - .body(#"{"code":0,"data":{"name":"Leo"}}"#), - .status(500), - .body(#"{"code":0,"data":{"usage":{"totalToken":500000,"usedToken":120000}}}"#), - .body(#"{"code":0,"data":{"totalQuota":0,"list":[]}}"#), - ]) - - let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - #expect(snapshot.totalQuota == 500_000) - #expect(snapshot.usedQuota == 120_000) - } - - @Test - func `token pack summary auth failure falls back to legacy token usage`() async throws { - let transport = LongCatScriptedTransport(results: [ - .body(#"{"code":0,"data":{"name":"Leo"}}"#), - .status(401), - .body(#"{"code":0,"data":{"usage":{"totalToken":500000,"usedToken":120000}}}"#), - .body(#"{"code":0,"data":{"totalQuota":0,"list":[]}}"#), - ]) - - let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - #expect(snapshot.totalQuota == 500_000) - #expect(snapshot.usedQuota == 120_000) - } - - @Test - func `supplemental fuel failures do not erase primary quota`() async throws { - let transport = LongCatScriptedTransport(results: [ - .body(#"{"code":0,"data":{"name":"Leo"}}"#), - .body(#"{"code":0,"data":{"currentLot":null}}"#), - .body(#"{"code":0,"data":{"usage":{"totalToken":500000,"usedToken":120000}}}"#), - .status(500), - ]) - let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - #expect(snapshot.totalQuota == 500_000) - #expect(snapshot.usedQuota == 120_000) - #expect(snapshot.fuelPackTotal == nil) - } - - @Test - func `supplemental fuel auth failure does not erase primary quota`() async throws { - let transport = LongCatScriptedTransport(results: [ - .body(#"{"code":0,"data":{"name":"Leo"}}"#), - .body(#"{"code":0,"data":{"currentLot":null}}"#), - .body(#"{"code":0,"data":{"usage":{"totalToken":500000,"usedToken":120000}}}"#), - .status(401), - ]) - let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) - #expect(snapshot.totalQuota == 500_000) - #expect(snapshot.usedQuota == 120_000) - #expect(snapshot.fuelPackTotal == nil) - } - - private func cookie( - name: String, - value: String, - domain: String, - path: String, - expires: Date? = nil, - secure: Bool = false) throws -> HTTPCookie - { - var properties: [HTTPCookiePropertyKey: Any] = [ - .name: name, - .value: value, - .domain: domain, - .path: path, - ] - if let expires { - properties[.expires] = expires - } - if secure { - properties[.secure] = "TRUE" - } - return try #require(HTTPCookie(properties: properties)) - } -} - -/// Scripted transport for exercising `LongCatUsageFetcher.fetchUsage` HTTP paths -/// without a network. Returns the given results in order; an exhausted script -/// yields an empty 200 so best-effort follow-up probes decode to nil. -private actor LongCatScriptedTransport: ProviderHTTPTransport { - struct CapturedRequest: Sendable { - let method: String? - let path: String - let body: Data? - let contentType: String? - } - - enum Result { - case status(Int) - case body(String) - } - - private var results: [Result] - private var captured: [CapturedRequest] = [] - - init(results: [Result]) { - self.results = results - } - - func data(for request: URLRequest) throws -> (Data, URLResponse) { - self.captured.append(CapturedRequest( - method: request.httpMethod, - path: request.url?.path ?? "", - body: request.httpBody, - contentType: request.value(forHTTPHeaderField: "Content-Type"))) - let result = self.results.isEmpty ? .status(200) : self.results.removeFirst() - let statusCode: Int - let body: String - switch result { - case let .status(code): - statusCode = code - body = "" - case let .body(text): - statusCode = 200 - body = text - } - let response = HTTPURLResponse( - url: request.url!, - statusCode: statusCode, - httpVersion: nil, - headerFields: nil)! - return (Data(body.utf8), response) - } - - func capturedRequests() -> [CapturedRequest] { - self.captured - } } diff --git a/Tests/CodexBarTests/LongCatQuotaPresentationTests.swift b/Tests/CodexBarTests/LongCatQuotaPresentationTests.swift index da6d40f6b4..1edc6d8471 100644 --- a/Tests/CodexBarTests/LongCatQuotaPresentationTests.swift +++ b/Tests/CodexBarTests/LongCatQuotaPresentationTests.swift @@ -10,13 +10,19 @@ struct LongCatQuotaPresentationTests { private static let now = Date(timeIntervalSince1970: 1_790_000_000) private func snapshot(hasExpiry: Bool) -> UsageSnapshot { - LongCatUsageSnapshot( - totalQuota: 1000, - usedQuota: 250, - fuelPackTotal: 500, - fuelPackRemaining: 200, - nearestFuelExpiry: hasExpiry ? Self.now.addingTimeInterval(7200) : nil, - updatedAt: Self.now).toUsageSnapshot() + UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: "250/1000"), + secondary: RateWindow( + usedPercent: 60, + windowMinutes: nil, + resetsAt: hasExpiry ? Self.now.addingTimeInterval(7200) : nil, + resetDescription: "Fuel pack: 200/500"), + updatedAt: Self.now, + identity: ProviderIdentitySnapshot( + providerID: .longcat, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) } private func model(_ snapshot: UsageSnapshot) throws -> UsageMenuCardView.Model { diff --git a/Tests/CodexBarTests/ProviderPluginCookieBrokerTests.swift b/Tests/CodexBarTests/ProviderPluginCookieBrokerTests.swift index ff7e886d7f..4c7509e366 100644 --- a/Tests/CodexBarTests/ProviderPluginCookieBrokerTests.swift +++ b/Tests/CodexBarTests/ProviderPluginCookieBrokerTests.swift @@ -321,6 +321,29 @@ struct ProviderPluginCookieBrokerTests { importer: importer) } + @Test + func `nonpersistent jars neither read overwrite nor clear the provider cache`() throws { + try self.isolated { + CookieHeaderCache.store(provider: .longcat, cookieHeader: "session=old", sourceLabel: "Synthetic cached") + let expected = try #require(CookieHeaderCache.load(provider: .longcat)) + let broker = ProviderPluginCookieBroker( + provider: .longcat, + domains: ["longcat.chat"], + settings: .init(cookieSource: .auto, manualCookieHeader: nil), + batches: { _, _ in Issue.record("Legacy importer must not run"); return nil }, + jarImporter: { [.init(header: "", source: "Synthetic import", origin: "", records: [])] }) + #expect(try broker.nextSession(domain: "longcat.chat", cachedOnly: true) == nil) + let session = try #require(try broker.nextSession(domain: "longcat.chat")) + #expect(session.source == "Synthetic import") + broker.rejectCookie(domain: "longcat.chat", id: session.id) + #expect(try broker.nextSession(domain: "longcat.chat") == nil) + let actual = try #require(CookieHeaderCache.load(provider: .longcat)) + #expect(actual.cookieHeader == expected.cookieHeader) + #expect(actual.storedAt == expected.storedAt) + #expect(actual.sourceLabel == expected.sourceLabel) + } + } + private func isolated(_ body: () throws -> Void) rethrows { try KeychainCacheStore.withImplicitTestStoreForTesting { try KeychainCacheStore.withServiceOverrideForTesting("plugin-cookies-\(UUID().uuidString)") { diff --git a/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift b/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift index 35421276c7..7ecb0a1c59 100644 --- a/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift +++ b/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift @@ -156,7 +156,7 @@ struct ProviderPluginDetailsParityTests { transport: transport, contextOptions: ProviderPluginContextOptions( optionalRequestTimeoutSeconds: 1, - beforeHTTPAttempt: { + beforeHTTPAttempt: { _ in // Model a task queued longer than the attempt budget before the transport begins. if delaysTaskStart { try await Task.sleep(for: .milliseconds(1500)) } }), diff --git a/TestsLinux/ProviderNumericBoundaryTests.swift b/TestsLinux/ProviderNumericBoundaryTests.swift index db7d602f80..8509a70868 100644 --- a/TestsLinux/ProviderNumericBoundaryTests.swift +++ b/TestsLinux/ProviderNumericBoundaryTests.swift @@ -3,74 +3,6 @@ import Testing @testable import CodexBarCore struct ProviderNumericBoundaryTests { - @Test(arguments: ["1e100", "\"1e100\"", "\"Infinity\"", "\"NaN\""]) - func `LongCat rejects unrepresentable response codes`(code: String) throws { - let object = try JSONSerialization.jsonObject(with: Data("{\"code\":\(code),\"data\":{}}".utf8)) - #expect { - try LongCatEnvelope.unwrap(object) - } throws: { error in - guard case LongCatAPIError.parseFailed = error else { return false } - return true - } - } - - @Test(arguments: ["0", "200", "\"2e2\"", "200.9"]) - func `LongCat preserves supported success codes`(code: String) throws { - let object = try JSONSerialization.jsonObject(with: Data("{\"code\":\(code),\"data\":{\"value\":1}}".utf8)) - let payload = try LongCatEnvelope.unwrap(object) as? [String: Any] - #expect(payload?["value"] as? Int == 1) - } - - @Test - func `LongCat renders oversized token and fuel counts`() throws { - let data = Data(""" - {"usage":{"totalToken":200000000000000000000,"usedToken":100000000000000000000}, - "fuel":{"totalQuota":200000000000000000000,"list":[{"availableToken":100000000000000000000}]}} - """.utf8) - let payload = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - let usage = LongCatUsageFetcher.buildSnapshot( - account: nil, - tokenPackSummary: nil, - tokenUsage: payload["usage"] as? [String: Any], - pendingFuel: payload["fuel"] as? [String: Any]).toUsageSnapshot() - - #expect(usage.primary?.usedPercent == 50) - #expect(usage.primary?.resetDescription == "100000000000000000000/200000000000000000000") - #expect(usage.secondary?.usedPercent == 50) - #expect(usage.secondary?.resetDescription == "Fuel pack: 100000000000000000000/200000000000000000000") - _ = try JSONEncoder().encode(usage) - } - - @Test - func `LongCat truncates fractional counts and normalizes zero`() { - let usage = LongCatUsageSnapshot( - totalQuota: 10.9, usedQuota: 1.9, fuelPackTotal: 10.9, fuelPackRemaining: -0.25).toUsageSnapshot() - #expect(usage.primary?.resetDescription == "1/10") - #expect(usage.secondary?.resetDescription == "Fuel pack: 0/10") - } - - @Test - func `LongCat preserves the usable window when fuel totals overflow`() throws { - let usage = LongCatUsageFetcher.buildSnapshot( - account: nil, - tokenPackSummary: nil, - tokenUsage: ["totalToken": 100, "usedToken": 25], - pendingFuel: ["totalQuota": 1e308, "list": [["availableToken": 1e308], ["availableToken": 1e308]]]) - .toUsageSnapshot() - #expect(usage.primary?.usedPercent == 25) - #expect(usage.secondary == nil) - _ = try JSONEncoder().encode(usage) - } - - @Test(arguments: [Double.infinity, -.infinity, .nan]) - func `LongCat omits nonfinite quota data`(invalid: Double) throws { - let usage = LongCatUsageSnapshot( - totalQuota: 100, usedQuota: invalid, fuelPackTotal: 100, fuelPackRemaining: invalid).toUsageSnapshot() - #expect(usage.primary == nil) - #expect(usage.secondary == nil) - _ = try JSONEncoder().encode(usage) - } - @Test(arguments: [ ("0", "300", "0/300 credits"), ("1.25", "10.5", "1.25/10.50 credits"), @@ -167,17 +99,4 @@ struct ProviderNumericBoundaryTests { #expect(UsageFormatter.resetLine(for: window, style: .absolute, now: now) == nil) } - @Test - func `oversized LongCat expiry retains quota details without a reset countdown`() throws { - let usage = LongCatUsageFetcher.buildSnapshot( - account: nil, - tokenPackSummary: nil, - tokenUsage: nil, - pendingFuel: ["totalQuota": 1000, "list": [["availableToken": 500, "expireTime": 1e24]]]) - .toUsageSnapshot() - let window = try #require(usage.secondary) - #expect(window.usedPercent == 50) - #expect(window.resetDescription == "Fuel pack: 500/1000") - #expect(UsageFormatter.resetLine(for: window, style: .countdown) == "Resets Fuel pack: 500/1000") - } } diff --git a/TestsPlugin/AbacusPluginTests.swift b/TestsPlugin/AbacusPluginTests.swift index f3b7a95fb9..6f39a5e30c 100644 --- a/TestsPlugin/AbacusPluginTests.swift +++ b/TestsPlugin/AbacusPluginTests.swift @@ -46,16 +46,61 @@ struct AbacusPluginTests { #expect(usage.identity?.loginMethod == (failure == "date" ? "Pro" : nil)) } - @Test(arguments: BundledPluginTestSupport.engines) - func `billing timeout is bounded to five seconds on both engines`(engine: ProviderPluginEngineKind) async throws { - let runtime = try Self.runtime(engine, billingFailure: "slow") - let start = ContinuousClock.now + @Test(.timeLimit(.minutes(1)), arguments: BundledPluginTestSupport.engines) + func `five second billing deadline cancels pending billing and retains credits`( + engine: ProviderPluginEngineKind) async throws + { + let (starts, started) = AsyncStream.makeStream() + let (pending, release) = AsyncStream.makeStream() + let (cancellations, cancelled) = AsyncStream.makeStream() + let (budgets, budgetObserved) = AsyncStream.makeStream() + defer { + started.finish() + release.finish() + cancelled.finish() + budgetObserved.finish() + } + let runtime = try BundledPluginTestSupport.runtime( + "abacus", + engine: engine, + transport: ProviderHTTPTransportHandler { request in + #expect(request.httpMethod == "GET") + #expect(request.timeoutInterval == 15) + return Self.response(request, body: Self.points) + }, + contextOptions: ProviderPluginContextOptions( + optionalRequestTimeoutSeconds: nil, + waitForOptionalDeadline: { _, budget in + #expect(budget == .seconds(5)) + // Expire collection only after billing is waiting before its independent request timer. + var iterator = starts.makeAsyncIterator() + #expect(await iterator.next() != nil) + budgetObserved.yield(budget) + }, + beforeHTTPAttempt: { request in + guard request.httpMethod == "POST" else { return } + #expect(request.url?.path == "/api/_getBillingInfo") + #expect(request.timeoutInterval == 5) + started.yield() + for await _ in pending {} + cancelled.yield(Task.isCancelled) + cancelled.finish() + throw CancellationError() + })) let usage = try await runtime.fetchUsage(cookieResolver: Self.cookie) - let elapsed = start.duration(to: .now) + budgetObserved.finish() + var observedBudgets: [Duration] = [] + for await budget in budgets { + observedBudgets.append(budget) + } + #expect(observedBudgets == [.seconds(5)]) #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "250 / 1,000 credits") #expect(usage.primary?.resetsAt == nil) - #expect(elapsed >= .seconds(4)) - #expect(elapsed < .seconds(9)) + #expect(usage.primary?.windowMinutes == 43200) + #expect(usage.identity?.loginMethod == nil) + var iterator = cancellations.makeAsyncIterator() + #expect(await iterator.next() == true) } @Test(arguments: BundledPluginTestSupport.engines) @@ -208,7 +253,6 @@ struct AbacusPluginTests { case "auth": return Self.response(request, body: #"{"success":false,"error":"session expired"}"#) case "json": return Self.response(request, body: "error") case "timeout": throw URLError(.timedOut) - case "slow": try await Task.sleep(for: .seconds(30)) default: break } let date = billingFailure == "date" ? "not-a-date" : reset diff --git a/TestsPlugin/LongCatPluginTests.swift b/TestsPlugin/LongCatPluginTests.swift new file mode 100644 index 0000000000..faa2d282b5 --- /dev/null +++ b/TestsPlugin/LongCatPluginTests.swift @@ -0,0 +1,234 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct LongCatPluginTests { + @Test(arguments: BundledPluginTestSupport.engines) + func `active token pack bypasses stale legacy usage and retains fuel`(engine: ProviderPluginEngineKind) async throws { + let transport = Fixture([ + .init("/api/v1/user-current", body: #"{"data":{"name":"Fixture Account"}}"#), + .init("/api/pay/quota/metering/token-packs/summary", method: "POST", body: + #"{"code":0,"data":{"currentLot":{"status":"ACTIVE","totalToken":50000000,"consumedToken":1212576}}}"#), + .init("/api/lc-platform/v1/pending-fuel-packages", body: + #"{"data":{"totalQuota":1000,"list":[{"availableToken":600,"expireTime":1750000000000},{"availableToken":150,"expireTime":1760000000000}]}}"#), + ]) + let usage = try await Self.fetch(engine, transport: transport) + #expect(abs((usage.primary?.usedPercent ?? 0) - 2.425152) < 0.000001) + #expect(usage.primary?.resetDescription == "1212576/50000000") + #expect(usage.secondary?.usedPercent == 25) + #expect(usage.secondary?.resetDescription == "Fuel pack: 750/1000") + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_750_000_000)) + #expect(usage.identity?.accountOrganization == "Fixture Account") + #expect(await transport.remaining == 0) + } + + @Test(arguments: [401, 500], BundledPluginTestSupport.engines) + func `optional summary and fuel errors preserve required legacy quota`( + status: Int, engine: ProviderPluginEngineKind) async throws + { + let transport = Fixture([ + .init("/api/v1/user-current", body: #"{"data":{"nickName":"Fixture"}}"#), + .init("/api/pay/quota/metering/token-packs/summary", method: "POST", status: status, body: "invalid"), + .init("/api/lc-platform/v1/tokenUsage", body: + #"{"data":{"usage":{"totalToken":500000,"usedToken":120000,"availableToken":380000}}}"#), + .init("/api/lc-platform/v1/pending-fuel-packages", status: status, body: "invalid"), + ]) + let usage = try await Self.fetch(engine, transport: transport) + #expect(usage.primary?.usedPercent == 24) + #expect(usage.secondary == nil) + #expect(await transport.remaining == 0) + } + + @Test(arguments: ["0", "200", "\"2e2\"", "200.9"], BundledPluginTestSupport.engines) + func `supported envelope codes retain numeric boundaries`(code: String, engine: ProviderPluginEngineKind) async throws { + let usage = try await Self.fetch(engine, transport: Fixture([ + .init("/api/v1/user-current", body: "{\"code\":\(code),\"data\":{}}"), + .init("/api/pay/quota/metering/token-packs/summary", method: "POST", body: "{}"), + .init("/api/lc-platform/v1/tokenUsage", body: + #"{"totalToken":200000000000000000000,"usedToken":100000000000000000000}"#), + .init("/api/lc-platform/v1/pending-fuel-packages", body: + #"{"totalQuota":10.9,"list":[{"availableToken":-0.25}]}"#), + ])) + #expect(usage.primary?.usedPercent == 50) + #expect(usage.primary?.resetDescription == "100000000000000000000/200000000000000000000") + #expect(usage.secondary?.resetDescription == "Fuel pack: 0/10") + } + + @Test(arguments: ["1e100", "\"1e100\"", "\"Infinity\"", "\"NaN\"", "null", "{}"], BundledPluginTestSupport.engines) + func `malformed envelope codes fail parsing`(code: String, engine: ProviderPluginEngineKind) async throws { + await #expect { + try await Self.fetch(engine, transport: Fixture([ + .init("/api/v1/user-current", body: "{\"code\":\(code),\"data\":{}}"), + ])) + } throws: { ($0 as? ProviderFetchClassifiedError)?.kind == .parseFailure } + } + + @Test(arguments: [302, 401, 403], BundledPluginTestSupport.engines) + func `required authentication failures advance profiles`(status: Int, engine: ProviderPluginEngineKind) async throws { + let transport = Fixture([ + .init("/api/v1/user-current", status: status, body: "login"), + .init("/api/v1/user-current", body: "{}"), + .init("/api/pay/quota/metering/token-packs/summary", method: "POST", body: + #"{"currentLot":{"status":"ACTIVE","totalToken":100,"consumedToken":10}}"#), + .init("/api/lc-platform/v1/pending-fuel-packages", body: "{}"), + ]) + let usage = try await Self.fetch(engine, transport: transport, count: 2) + #expect(usage.primary?.usedPercent == 10) + #expect(await transport.remaining == 0) + } + + @Test(arguments: [#"{"code":401}"#, #"{"code":"403"}"#], BundledPluginTestSupport.engines) + func `HTTP success auth envelopes reject sessions`(body: String, engine: ProviderPluginEngineKind) async throws { + await #expect { + try await Self.fetch(engine, transport: Fixture([.init("/api/v1/user-current", body: body)])) + } throws: { ($0 as? ProviderFetchClassifiedError)?.kind == .authenticationExpired } + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `required server failures do not advance profiles`(engine: ProviderPluginEngineKind) async throws { + await #expect { + try await Self.fetch(engine, transport: Fixture([.init("/api/v1/user-current", status: 500)]), count: 2) + } throws: { ($0 as? ProviderFetchClassifiedError)?.kind == .apiFailure } + } + + @Test(arguments: ["2025-06-15T15:06:40.250Z", "2025-06-15T17:06:40.250+02:00"], BundledPluginTestSupport.engines) + func `fractional fuel dates survive both engines`(date: String, engine: ProviderPluginEngineKind) async throws { + let usage = try await Self.fetch(engine, transport: Fixture([ + .init("/api/v1/user-current", body: "{}"), + .init("/api/pay/quota/metering/token-packs/summary", method: "POST", body: "{}"), + .init("/api/lc-platform/v1/tokenUsage", body: #"{"totalToken":0}"#), + .init("/api/lc-platform/v1/pending-fuel-packages", body: + "{\"totalQuota\":1000,\"list\":[{\"availableToken\":150,\"expireTime\":\"\(date)\"}]}"), + ])) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_750_000_000.250)) + } + + @Test(arguments: ["{}", #"{"currentLot":null}"#, + #"{"currentLot":{"status":"EXPIRED","totalToken":100}}"#, + #"{"currentLot":{"status":"ACTIVE","totalToken":0}}"#], BundledPluginTestSupport.engines) + func `unusable token packs fall back and infer used from remaining`( + summary: String, engine: ProviderPluginEngineKind) async throws + { + let usage = try await Self.fetch(engine, transport: Fixture([ + .init("/api/v1/user-current", body: "{}"), + .init("/api/pay/quota/metering/token-packs/summary", method: "POST", body: summary), + .init("/api/lc-platform/v1/tokenUsage", body: #"{"totalToken":1000,"availableToken":400}"#), + .init("/api/lc-platform/v1/pending-fuel-packages", body: "{}"), + ])) + #expect(usage.primary?.usedPercent == 60) + #expect(usage.primary?.resetDescription == "600/1000") + } + + @Test(arguments: [#"{"data":[]}"#, #"{"data":{}}"#, "malformed"], BundledPluginTestSupport.engines) + func `required legacy quota must parse`(body: String, engine: ProviderPluginEngineKind) async throws { + await #expect { + try await Self.fetch(engine, transport: Fixture([ + .init("/api/v1/user-current", body: "{}"), + .init("/api/pay/quota/metering/token-packs/summary", method: "POST", body: "{}"), + .init("/api/lc-platform/v1/tokenUsage", body: body), + ])) + } throws: { ($0 as? ProviderFetchClassifiedError)?.kind == .parseFailure } + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `overflowing fuel totals preserve quota and huge expiry omits only the reset`( + engine: ProviderPluginEngineKind) async throws + { + for fuel in [ + #"{"totalQuota":1e308,"list":[{"availableToken":1e308},{"availableToken":1e308}]}"#, + #"{"totalQuota":1000,"list":[{"availableToken":500,"expireTime":1e24}]}"#, + ] { + let usage = try await Self.fetch(engine, transport: Fixture([ + .init("/api/v1/user-current", body: "{}"), + .init("/api/pay/quota/metering/token-packs/summary", method: "POST", body: "{}"), + .init("/api/lc-platform/v1/tokenUsage", body: #"{"totalToken":100,"usedToken":25}"#), + .init("/api/lc-platform/v1/pending-fuel-packages", body: fuel), + ])) + #expect(usage.primary?.usedPercent == 25) + #expect(usage.secondary?.resetsAt == nil) + if fuel.contains("1e308") { #expect(usage.secondary == nil) } + else { #expect(usage.secondary?.resetDescription == "Fuel pack: 500/1000") } + } + } + + private static func fetch( + _ engine: ProviderPluginEngineKind, transport: Fixture, count: Int = 1) async throws -> UsageSnapshot + { + let runtime = try BundledPluginTestSupport.runtime("longcat", engine: engine, transport: transport) + let sessions = Sessions(count: count) + return try await runtime.fetchUsage(cookieSessionResolver: { _, _ in await sessions.next() }) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `request URL misses advance the whole profile without flattening its cookies`( + engine: ProviderPluginEngineKind) async throws + { + let transport = Fixture([ + .init("/api/v1/user-current", body: "{}"), + .init("/api/v1/user-current", body: "{}"), + .init("/api/pay/quota/metering/token-packs/summary", method: "POST", body: + #"{"currentLot":{"status":"ACTIVE","totalToken":100,"consumedToken":10}}"#), + .init("/api/lc-platform/v1/pending-fuel-packages", body: "{}"), + ]) + let sessions = try JarSessions(values: ["/api/v1", "/"].map { path in + let cookie = try #require(HTTPCookie(properties: [ + .name: "session", .value: "fixture", .domain: "longcat.chat", .path: path, + ])) + return .init(header: "", source: "Synthetic", origin: "https://longcat.chat", + records: [ProviderPluginCookieRecord(cookie: cookie)]) + }) + let runtime = try BundledPluginTestSupport.runtime("longcat", engine: engine, transport: transport) + let usage = try await runtime.fetchUsage(cookieSessionResolver: { _, _ in await sessions.next() }) + #expect(usage.primary?.usedPercent == 10) + #expect(await transport.remaining == 0) + } + + private actor JarSessions { + var values: [ProviderPluginCookieSession] + init(values: [ProviderPluginCookieSession]) { self.values = values } + func next() -> ProviderPluginCookieSession? { self.values.isEmpty ? nil : self.values.removeFirst() } + } + + private actor Sessions { + var count: Int + init(count: Int) { self.count = count } + func next() -> ProviderPluginCookieSession? { + guard self.count > 0 else { return nil } + self.count -= 1 + return .init(header: "session=fixture", source: "Fixture", origin: "https://longcat.chat") + } + } + + private actor Fixture: ProviderHTTPTransport { + struct Step: Sendable { + let path: String + let method: String + let status: Int + let body: String + init(_ path: String, method: String = "GET", status: Int = 200, body: String = "{}") { + self.path = path + self.method = method + self.status = status + self.body = body + } + } + var steps: [Step] + var remaining: Int { self.steps.count } + init(_ steps: [Step]) { self.steps = steps } + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let step = try #require(self.steps.first) + self.steps.removeFirst() + let url = try #require(request.url) + #expect(url.path == step.path) + #expect(request.httpMethod == step.method) + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=fixture") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://longcat.chat") + if step.method == "POST" { #expect(request.httpBody == Data("{}".utf8)) } + return try (Data(step.body.utf8), #require(HTTPURLResponse( + url: url, statusCode: step.status, httpVersion: nil, headerFields: nil))) + } + } +} diff --git a/TestsPlugin/ProviderPluginCookieJarTests.swift b/TestsPlugin/ProviderPluginCookieJarTests.swift new file mode 100644 index 0000000000..25dfbf4e5d --- /dev/null +++ b/TestsPlugin/ProviderPluginCookieJarTests.swift @@ -0,0 +1,209 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct ProviderPluginCookieJarTests { + private static let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test + func `URL matcher preserves duplicate names and path boundaries`() throws { + let records = try [ + Self.record("session", "root", domain: "example.test"), + Self.record("session", "scoped", domain: ".example.test", path: "/api/v1"), + Self.record("sibling", "excluded", domain: "www.example.test"), + Self.record("expired", "excluded", domain: ".example.test", expires: Self.now), + Self.record("secure", "https-only", domain: ".example.test", secure: true), + Self.record("page", "excluded", domain: ".example.test", path: "/platform"), + ] + for (raw, expected) in [ + ("https://example.test/api/v1/me", "session=scoped; secure=https-only; session=root"), + ("https://example.test/api/v12", "secure=https-only; session=root"), + ("https://example.test/api/v1%2Fprivate", "secure=https-only; session=root"), + ("https://api.example.test/api/v1", "session=scoped; secure=https-only"), + ("http://api.example.test/api/v1", "session=scoped"), + ("https://example.test.evil.test/api/v1", nil), + ] { + let url = try #require(URL(string: raw)) + #expect(ProviderPluginCookieRecord.header(records, for: url, now: Self.now) == expected) + } + } + + @Test + func `same-origin redirects reselect cookies and reject credential-leaking destinations`() throws { + let jar = ProviderPluginCookieJar() + let session = ProviderPluginCookieSession(header: "", source: "Fixture", origin: "https://example.test", records: try [ + Self.record("session", "root", domain: "example.test"), + Self.record("session", "scoped", domain: "example.test", path: "/api"), + ]) + jar.register(session) + let delegate = ProviderPluginCookieTransport.CookieRedirectDelegate(jar: jar, id: session.id) + let original = try #require(URL(string: "https://example.test/api/me")) + for (raw, expected) in [ + ("https://example.test/api/usage", "session=scoped; session=root"), + ("https://example.test/platform", "session=root"), + ("https://other.test/api", nil), + ("http://example.test/api", nil), + ] { + var request = try URLRequest(url: #require(URL(string: raw))) + request.setValue("session=scoped; session=root", forHTTPHeaderField: "Cookie") + #expect(delegate.redirectedRequest(originalURL: original, request: request)? + .value(forHTTPHeaderField: "Cookie") == expected) + } + jar.reject(id: session.id) + #expect(delegate.redirectedRequest(originalURL: original, request: URLRequest(url: original)) == nil) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `scripts see metadata only and host selects cookies for every request`(engine: ProviderPluginEngineKind) async throws { + let record = try Self.record("session", "private-fixture", domain: "example.test", path: "/api") + let runtime = try Self.runtime(engine: engine, script: """ + for await (const session of ctx.browser.sessions("example.test")) { + if (session.header !== undefined || session.records !== undefined || JSON.stringify(session).includes("private-fixture")) + throw new Error("exposed cookie"); + let denied = false; + try { await ctx.browser.cookieHeader("example.test"); } catch (_) { denied = true; } + if (!denied) throw new Error("header bridge was allowed"); + await ctx.http.get("https://example.test/api/me", {cookieSession: session.id}); + await ctx.http.post("https://example.test/api/usage", {cookieSession: session.id, body: {}}); + try { await ctx.http.get("https://example.test/platform", {cookieSession: session.id}); } + catch (error) { + if (error.failureKind === "missing-credential") return {primary: {usedPercent: 25}}; + throw error; + } + throw new Error("path restriction was ignored"); + } + """, transport: ProviderHTTPTransportHandler { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=private-fixture") + #expect(request.url?.path.hasPrefix("/api/") == true) + return try Self.response(request) + }) + let usage = try await runtime.fetchUsage(cookieSessionResolver: { _, _ in + .init(header: "", source: "Synthetic", origin: "https://example.test", records: [record]) + }) + #expect(usage.primary?.usedPercent == 25) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `forged and previous-fetch session identifiers fail before transport`(engine: ProviderPluginEngineKind) async throws { + let runtime = try Self.runtime(engine: engine, script: """ + const prior = ctx.cache.get("session") || "forged"; + for await (const session of ctx.browser.sessions("example.test")) { + ctx.cache.set("session", session.id, 60); + await ctx.http.get("https://example.test/api", {cookieSession: prior}); + return {primary: {usedPercent: 1}}; + } + """, transport: ProviderHTTPTransportHandler { _ in + Issue.record("Rejected sessions must never reach transport") + throw URLError(.badURL) + }) + for _ in 0..<2 { + await #expect(throws: (any Error).self) { + try await runtime.fetchUsage(cookieSessionResolver: { _, _ in + .init(header: "session=fixture", source: "Synthetic", origin: "https://example.test") + }) + } + } + } + + @Test(arguments: [ + "https://other.test/api", "https://example.test:444/api", "https://user:pass@example.test/api", + ], BundledPluginTestSupport.engines) + func `manual sessions cannot cross their origin even to declared endpoints`( + url: String, engine: ProviderPluginEngineKind) async throws + { + let runtime = try Self.runtime(engine: engine, script: """ + for await (const session of ctx.browser.sessions("example.test")) { + await ctx.http.get("\(url)", {cookieSession: session.id}); + return {primary: {usedPercent: 1}}; + } + """, transport: ProviderHTTPTransportHandler { _ in + Issue.record("Origin mismatch must never reach transport") + throw URLError(.badURL) + }) + await #expect(throws: (any Error).self) { + try await runtime.fetchUsage(cookieSource: .manual, cookieSessionResolver: { _, _ in + .init(header: "session=fixture", source: "manual", origin: "https://example.test") + }) + } + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `off never resolves a session`(engine: ProviderPluginEngineKind) async throws { + let runtime = try Self.runtime(engine: engine, script: """ + for await (const session of ctx.browser.sessions("example.test")) return {primary: {usedPercent: 1}}; + """, transport: ProviderHTTPTransportHandler { _ in throw URLError(.badURL) }) + await #expect(throws: (any Error).self) { + try await runtime.fetchUsage(cookieSource: .off, cookieSessionResolver: { _, _ in + Issue.record("Off must not import") + return nil + }) + } + } + + private static func runtime( + engine: ProviderPluginEngineKind, script: String, transport: any ProviderHTTPTransport) throws -> ProviderPluginRuntime + { + try ProviderPluginRuntime(source: """ + defineProvider({id: "longcat", name: "Fixture", settings: [], endpoints: ["https://example.test", "https://other.test", "https://example.test:444"], + capabilities: ["browser-cookies", "http-status"], cookieDomains: ["example.test"], + cookiePolicy: {selection: "request-url", cache: "nonpersistent"}, + async fetchUsage(ctx) { \(script) } + }); + """, transport: transport, engine: engine) + } + + @Test(arguments: [ + "{}", "{headers:{Cookie:'session=forged'}}", "{cookieSession:session.id,headers:{Cookie:'session=forged'}}", + "{cookieSession:session.id,headers:{Host:'other.test'}}", + ], BundledPluginTestSupport.engines) + func `raw headers and requests without a candidate fail closed`( + options: String, engine: ProviderPluginEngineKind) async throws + { + let runtime = try Self.runtime(engine: engine, script: """ + for await (const session of ctx.browser.sessions("example.test")) { + await ctx.http.get("https://example.test/api", \(options)); + return {primary:{usedPercent:1}}; + } + """, transport: ProviderHTTPTransportHandler { _ in + Issue.record("Denied cookie request reached transport") + throw URLError(.badURL) + }) + await #expect(throws: (any Error).self) { + try await runtime.fetchUsage(cookieSessionResolver: { _, _ in + .init(header: "session=fixture", source: "Fixture", origin: "https://example.test") + }) + } + } + + @Test(arguments: [ + "null", "true", "{selection:'request-url',cache:'persistent'}", + "{selection:'request-url',cache:'nonpersistent',unknown:true}", + ], BundledPluginTestSupport.engines) + func `cookie policy rejects unsupported contracts`(policy: String, engine: ProviderPluginEngineKind) { + #expect(throws: ProviderPluginError.self) { + try ProviderPluginRuntime(source: """ + defineProvider({id:'longcat',name:'Fixture',settings:[],endpoints:['https://example.test'], + capabilities:['browser-cookies'],cookieDomains:['example.test'],cookiePolicy:\(policy), + async fetchUsage(){return {primary:{usedPercent:1}};}}); + """, engine: engine) + } + } + + private static func response(_ request: URLRequest) throws -> (Data, URLResponse) { + let url = try #require(request.url) + return try (Data("{}".utf8), #require(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil))) + } + + private static func record( + _ name: String, _ value: String, domain: String, path: String = "/", secure: Bool = false, + expires: Date? = nil) throws -> ProviderPluginCookieRecord + { + var properties: [HTTPCookiePropertyKey: Any] = [.name: name, .value: value, .domain: domain, .path: path] + if secure { properties[.secure] = "TRUE" } + if let expires { properties[.expires] = expires } + return try ProviderPluginCookieRecord(cookie: #require(HTTPCookie(properties: properties))) + } +} diff --git a/TestsPlugin/ProviderPluginOptionalAdmissionTests.swift b/TestsPlugin/ProviderPluginOptionalAdmissionTests.swift index 2684baa10e..38a67673cf 100644 --- a/TestsPlugin/ProviderPluginOptionalAdmissionTests.swift +++ b/TestsPlugin/ProviderPluginOptionalAdmissionTests.swift @@ -31,7 +31,7 @@ struct ProviderPluginOptionalAdmissionTests { contextOptions: ProviderPluginContextOptions( optionalRequestTimeoutSeconds: nil, optionalCollectionBudget: .seconds(2), - beforeHTTPAttempt: { try await Task.sleep(for: .seconds(3)) }), + beforeHTTPAttempt: { _ in try await Task.sleep(for: .seconds(3)) }), engine: engine) #expect(try await runtime.fetchUsage().identity?.loginMethod == "ready") } diff --git a/TestsPlugin/ProviderPluginTransportTests.swift b/TestsPlugin/ProviderPluginTransportTests.swift index 627ab59dd6..0aaae524bf 100644 --- a/TestsPlugin/ProviderPluginTransportTests.swift +++ b/TestsPlugin/ProviderPluginTransportTests.swift @@ -179,7 +179,7 @@ struct ProviderPluginTransportTests { timeout: 0.2, contextOptions: ProviderPluginContextOptions( optionalRequestTimeoutSeconds: nil, - beforeHTTPAttempt: { try await Task.sleep(for: .seconds(30)) }), + beforeHTTPAttempt: { _ in try await Task.sleep(for: .seconds(30)) }), transport: ProviderHTTPTransportHandler { _ in Issue.record("Transport must not run after the fetch deadline") throw URLError(.badURL) @@ -201,7 +201,7 @@ struct ProviderPluginTransportTests { body: "await ctx.http.get('https://example.com');", contextOptions: ProviderPluginContextOptions( optionalRequestTimeoutSeconds: nil, - beforeHTTPAttempt: { + beforeHTTPAttempt: { _ in continuation.yield("waiting") do { try await Task.sleep(for: .seconds(30)) } catch { continuation.yield("cancelled") @@ -227,7 +227,7 @@ struct ProviderPluginTransportTests { body: "await ctx.http.get('https://example.com');", contextOptions: ProviderPluginContextOptions( optionalRequestTimeoutSeconds: nil, - beforeHTTPAttempt: { throw URLError(.badURL) })) + beforeHTTPAttempt: { _ in throw URLError(.badURL) })) await #expect(throws: URLError(.badURL)) { try await runtime.fetchUsage() } } diff --git a/docs/longcat.md b/docs/longcat.md index 148c88ce52..e93426a4dd 100644 --- a/docs/longcat.md +++ b/docs/longcat.md @@ -16,6 +16,16 @@ LongCat reads quota data from an authenticated `longcat.chat` web session. It do `LONGCAT_MANUAL_COOKIE`. - Automatic mode can import supported browser cookies during a user-initiated refresh. +The bundled `longcat.ts` plugin owns requests, session-error classification, and quota mapping on QuickJS and +JavaScriptCore. The host keeps imported cookies opaque, groups them per browser profile, and selects cookies separately +for each request URL, including same-origin HTTPS redirects. It retains path-scoped duplicate names and honors +host-only scope, Secure, and expiry. Cross-origin redirects are rejected. + +Automatic imports try Chrome before Firefox and run only during a user-initiated app refresh. LongCat does not read or +write a persistent session cache. Background refreshes and the CLI require a manual/environment cookie. Manual settings +take precedence over the environment; Off disables environment cookies too. Profiles advance only for missing cookies +or an invalid session, so network and parse errors do not silently switch accounts. + ## Request sequence 1. `GET /api/v1/user-current` is required and validates the session while providing the account name. diff --git a/docs/plugin-conversion-matrix.md b/docs/plugin-conversion-matrix.md index a687294a50..2978063453 100644 --- a/docs/plugin-conversion-matrix.md +++ b/docs/plugin-conversion-matrix.md @@ -49,10 +49,10 @@ Abacus, Muse, LongCat, Replicate, and TypeSafe unchanged. | Status | Count | |---|---:| -| `cut-over` | 30 | +| `cut-over` | 31 | | `converted` | 0 | | `convertible-now` | 0 | -| `needs-cookie-import` | 7 | +| `needs-cookie-import` | 6 | | `needs-files/subprocess/oauth-broker` | 20 | | `needs-pty/webview/native` | 8 | | `needs-host-extension` | 4 | @@ -126,7 +126,7 @@ Abacus, Muse, LongCat, Replicate, and TypeSafe unchanged. | helmcode | `cut-over` | Yes | Both tenant HTTP flows and quota projection live in the bundled TypeScript plugin, using domain-scoped cookies and policy-only availability. Swift supplies registration, settings, and dashboard routing. No native fetcher or cURL-capture fallback. | | neuralwatt | `cut-over` | Yes | Cut over on both engines: validated configured HTTPS, subscription kWh, prepaid balance, key allowances, and exact confidence; the host preserves selective single retries, capped Retry-After, and cancellation. The native fetch twin is deleted. | | clawrouter | `cut-over` | Yes | Cut over on JavaScriptCore: validated configured origins, classified failures, exact confidence, budget/ledger details, and provider charts match native behavior; the native fetch core is Linux-only. | -| longcat | `needs-cookie-import` | No | Still needs path/domain-aware cookie selection and retries across imported profiles; per-domain cache isolation does not expose those candidates. | +| longcat | `cut-over` | Yes | Both engines use opaque, nonpersistent per-profile cookie jars with request-URL selection, required account/legacy quota requests, best-effort token-pack/fuel probes, and auth-only profile fallback. Automatic imports remain user-initiated app-only. Native fetcher, importer, cookie-header, and snapshot code are deleted. | | sub2api | `cut-over` | Yes | Cut over on JavaScriptCore: configured HTTPS/loopback origins, a hard 15-second request deadline, strict parsing, exact confidence, and classified failures match native behavior; the native fetch core is Linux-only. | | wayfinder | `needs-pty/webview/native` | No | The local unauthenticated HTTP gateway, metrics text, and routing/savings model violate HTTPS-only generic scope. | | zenmux | `cut-over` | Yes | Both engines use fixed-origin bearer GETs for required subscription quotas and optional USD PAYG balance. Auth failures and cancellation remain fatal during enrichment; the native fetcher and parser are deleted. | diff --git a/docs/plugins.md b/docs/plugins.md index 0bc47a7882..ba41dc45f7 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -414,8 +414,26 @@ capabilities and does not change network approval. ## Browser session cache +Bundled providers may declare `cookiePolicy: { selection: "request-url", cache: "nonpersistent" }` alongside +`browser-cookies` and `cookieDomains`. This policy imports declared domains together as one candidate per browser +profile. It never reads or writes the persistent cookie cache, and automatic imports require a user-initiated app +refresh. Manual headers remain usable in the CLI; Off disables both sources. + +With this policy, `ctx.browser.sessions(domain)` exposes only the candidate's `id`, source label, and origin. +The header and cookie records remain in Swift, and `ctx.browser.cookieHeader` is denied. Pass the candidate ID as +`cookieSession: session.id` in any GET or POST options. The host selects unexpired cookies for the request URL, +honors host-only/domain scope, Secure, and encoded path boundaries, and retains duplicate names in longest-path-first +order. Manual headers remain bound to their originating host. Unknown, rejected, or previous-fetch IDs fail closed; +scripts cannot combine this option with a Cookie or Host override. + +The production transport uses an ephemeral session without ambient cookies, credentials, or response caching. +Same-origin HTTPS redirects reselect cookies for each hop through that same matcher; cross-origin redirects are +rejected. This is not Qwen Cloud's cross-origin dashboard/navigation policy. Ranked source-domain selection, +validated persistent jars, and native session-file migration are not part of this initial policy. User-installed +plugins cannot request it. + Bundled plugins that declare multiple cookie domains use separate Keychain-backed cache scopes for each requested -domain. Single-domain plugins retain their existing provider cache. Automatic imports query only the requested domain; +domain under the default header policy. Single-domain plugins retain their existing provider cache. Automatic imports query only the requested domain; the default browser is Chrome, with existing provider browser-order overrides preserved. Manual headers bypass the cache and browser import, and Off fails before either is accessed. From 610c3912373328d9ac917b702344b903cc543021 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 00:33:34 -0700 Subject: [PATCH 038/122] fix(config): treat blank config files as absent (#4081) A zero-byte or whitespace-only config file now behaves like a missing one (defaults, usage keeps working, next save writes valid JSON with 0600 permissions) instead of failing closed and blanking usage; malformed non-empty JSON keeps its decode error and protected writes. Fixes #4071. Thanks @kvnloo! --- CHANGELOG.md | 1 + .../Config/CodexBarConfigStore.swift | 4 +- .../CLIPluginConfigPreservationTests.swift | 75 ++++++++++++++++++- .../SettingsStoreEmptyConfigTests.swift | 30 ++++++++ .../CodexBarConfigStoreEmptyTests.swift | 65 ++++++++++++++++ docs/cli-configuration.md | 4 + docs/configuration.md | 6 ++ 7 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 Tests/CodexBarTests/SettingsStoreEmptyConfigTests.swift create mode 100644 TestsLinux/CodexBarConfigStoreEmptyTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f00492ddc..0e5f166a17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Development: restore test compilation on Xcode 26.3 / Swift 6.2 and check app, CLI, and test compatibility in CI (#4070). Thanks @RowboTony! +- Configuration: treat empty or whitespace-only config files like missing files so usage keeps working; settings saves write valid JSON, while malformed non-empty files still report errors (#4071). - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBarCore/Config/CodexBarConfigStore.swift b/Sources/CodexBarCore/Config/CodexBarConfigStore.swift index 0a78ebf958..5c4c9cdada 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigStore.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigStore.swift @@ -32,9 +32,9 @@ public struct CodexBarConfigStore: @unchecked Sendable { public func load() throws -> CodexBarConfig? { guard self.fileManager.fileExists(atPath: self.fileURL.path) else { return nil } let data = try Data(contentsOf: self.fileURL) + guard !data.allSatisfy({ $0 == 0x20 || $0 == 0x09 || $0 == 0x0A || $0 == 0x0D }) else { return nil } do { - let decoded = try CodexBarConfig.decode(from: data) - return decoded.normalized() + return try CodexBarConfig.decode(from: data).normalized() } catch { throw CodexBarConfigStoreError.decodeFailed(error.localizedDescription) } diff --git a/Tests/CodexBarTests/CLIPluginConfigPreservationTests.swift b/Tests/CodexBarTests/CLIPluginConfigPreservationTests.swift index fbb915b82e..8d55fe9a45 100644 --- a/Tests/CodexBarTests/CLIPluginConfigPreservationTests.swift +++ b/Tests/CodexBarTests/CLIPluginConfigPreservationTests.swift @@ -3,6 +3,50 @@ import Foundation import Testing struct CLIPluginConfigPreservationTests { + @Test(arguments: [nil, "", " \t\r\n "] as [String?]) + func `missing and blank configs permit validation usage and settings writes`(_ contents: String?) async throws { + let fixture = try Fixture() + defer { fixture.remove() } + try fixture.installCodexStub() + try FileManager.default.removeItem(at: fixture.configURL) + if let contents { try Data(contents.utf8).write(to: fixture.configURL) } + + _ = try await fixture.run(["config", "validate", "--json"]) + let usage = try await fixture.run(["usage", "--provider", "codex", "--source", "cli", "--json", "--json-only"]) + let payloads = try #require(JSONSerialization.jsonObject(with: usage) as? [[String: Any]]) + let payload = try #require(payloads.first) + #expect(payload["error"] == nil) + let snapshot = try #require(payload["usage"] as? [String: Any]) + let primary = try #require(snapshot["primary"] as? [String: Any]) + #expect(primary["usedPercent"] as? Double == 1) + #expect((try? Data(contentsOf: fixture.configURL)) == contents.map { Data($0.utf8) }) + + _ = try await fixture.run(["config", "enable", "--provider", "grok", "--json"]) + let saved = try CodexBarConfigStore(fileURL: fixture.configURL).load() + #expect(saved?.providerConfig(for: .grok)?.enabled == true) + } + + @Test(arguments: ["{", " \n{\"providers\":"]) + func `malformed config reports an error and cannot be overwritten by config commands`( + _ contents: String) async throws + { + let fixture = try Fixture() + defer { fixture.remove() } + try Data(contents.utf8).write(to: fixture.configURL) + for arguments in [ + ["config", "validate", "--json"], + ["usage", "--provider", "grok", "--json", "--json-only"], + ["config", "enable", "--provider", "grok", "--json"], + ] { + let output = try await fixture.run(arguments, acceptsNonZeroExit: true) + let payloads = try #require(JSONSerialization.jsonObject(with: output) as? [[String: Any]]) + let error = try #require(payloads.first?["error"] as? [String: Any]) + #expect(error["kind"] as? String == "config") + #expect((error["message"] as? String)?.hasPrefix("Failed to decode CodexBar config:") == true) + #expect(try Data(contentsOf: fixture.configURL) == Data(contents.utf8)) + } + } + @Test(arguments: ["enable", "disable", "set-api-key"], ["missing", "invalid", "loaded"]) func `config writes preserve unavailable plugins`(_ command: String, discovery: String) async throws { let fixture = try Fixture(discoveryFails: discovery == "invalid") @@ -75,6 +119,32 @@ struct CLIPluginConfigPreservationTests { func remove() { try? FileManager.default.removeItem(at: self.directory) } + func installCodexStub() throws { + let source = #""" + #!/usr/bin/python3 -S + import json, sys + if "--version" in sys.argv: + print("codex-cli 1.0.0") + sys.exit(0) + assert "app-server" in sys.argv + for line in sys.stdin: + request = json.loads(line) + if "id" not in request: + continue + result = {} + if request.get("method") == "account/rateLimits/read": + result = {"rateLimits": {"planType": "plus", "primary": { + "usedPercent": 1, "windowDurationMins": 300}}} + elif request.get("method") == "account/read": + result = {"account": {"type": "chatgpt", "email": "fixture@example.com", + "planType": "plus"}, "requiresOpenaiAuth": False} + print(json.dumps({"id": request["id"], "result": result}), flush=True) + """# + let url = self.directory.appendingPathComponent("codex") + try Data(source.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + func installPlugin() throws { let source = #""" defineProvider({ @@ -86,7 +156,7 @@ struct CLIPluginConfigPreservationTests { try Data(source.utf8).write(to: self.providersDirectory.appendingPathComponent("fixture.js")) } - func run(_ arguments: [String]) async throws -> Data { + func run(_ arguments: [String], acceptsNonZeroExit: Bool = false) async throws -> Data { let result = try await SubprocessRunner.run( binary: TestBuildProducts.executableURL(named: "CodexBarCLI").path, arguments: arguments, @@ -95,10 +165,13 @@ struct CLIPluginConfigPreservationTests { "HOME": self.directory.path, "CFFIXED_USER_HOME": self.directory.path, "CODEX_HOME": self.directory.appendingPathComponent(".codex").path, + "CODEX_CLI_PATH": self.directory.appendingPathComponent("codex").path, + "SHELL": "/bin/sh", "CODEXBAR_CONFIG": self.configURL.path, "CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS": "1", ], timeout: 30, + acceptsNonZeroExit: acceptsNonZeroExit, label: "isolated plugin config") return Data(result.stdout.utf8) } diff --git a/Tests/CodexBarTests/SettingsStoreEmptyConfigTests.swift b/Tests/CodexBarTests/SettingsStoreEmptyConfigTests.swift new file mode 100644 index 0000000000..edb2b14896 --- /dev/null +++ b/Tests/CodexBarTests/SettingsStoreEmptyConfigTests.swift @@ -0,0 +1,30 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SettingsStoreEmptyConfigTests { + @Test(arguments: ["", " \t\r\n "]) + func `blank external config preserves in memory settings for the next save`(_ contents: String) throws { + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .grok, enabled: true), + ProviderConfig(id: .groq, enabled: true, apiKey: "fixture-key"), + ]) + let settings = testSettingsStore(suiteName: "SettingsStoreEmptyConfigTests", config: config) + let store = settings.configStore + defer { try? FileManager.default.removeItem(at: store.fileURL.deletingLastPathComponent()) } + let before = try store.encodedData(for: settings.configSnapshot) + try Data(contents.utf8).write(to: store.fileURL) + + settings.reloadConfig(reason: "blank-fixture", origin: .localFile) + #expect(try store.encodedData(for: settings.configSnapshot) == before) + #expect(try Data(contentsOf: store.fileURL) == Data(contents.utf8)) + + settings.updateProviderConfig(provider: .grok) { $0.enabled = false } + let saved = try #require(try store.load()) + #expect(saved.providerConfig(for: .grok)?.enabled == false) + #expect(saved.providerConfig(for: .groq)?.apiKey == "fixture-key") + #expect(saved.providerConfig(for: .groq)?.enabled == true) + } +} diff --git a/TestsLinux/CodexBarConfigStoreEmptyTests.swift b/TestsLinux/CodexBarConfigStoreEmptyTests.swift new file mode 100644 index 0000000000..62f5b6ab23 --- /dev/null +++ b/TestsLinux/CodexBarConfigStoreEmptyTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct CodexBarConfigStoreEmptyTests { + @Test(arguments: [nil, "", " \t\r\n "] as [String?]) + func `missing and blank configs load as absent without writing`(_ contents: String?) throws { + let fixture = try Fixture(contents) + defer { fixture.remove() } + + #expect(try fixture.store.load() == nil) + let snapshot = try CodexBarCLI.loadServeConfigSnapshot(configStore: fixture.store) + #expect(try snapshot.config.encodedData() == CodexBarConfig.makeDefault().encodedData()) + #expect(try fixture.contents() == contents.map { Data($0.utf8) }) + } + + @Test(arguments: [nil, "", " \t\r\n "] as [String?]) + func `default creation and subsequent settings save produce private valid JSON`(_ contents: String?) throws { + let fixture = try Fixture(contents) + defer { fixture.remove() } + + var config = try fixture.store.loadOrCreateDefault() + #expect(try config.encodedData() == CodexBarConfig.makeDefault().encodedData()) + config.setProviderConfig(ProviderConfig(id: .grok, enabled: true)) + try fixture.store.save(config) + #expect(try fixture.store.load()?.providerConfig(for: .grok)?.enabled == true) + let data = try #require(try fixture.contents()) + #expect(try CodexBarConfig.decode(from: data).encodedData() == config.encodedData()) + let attributes = try FileManager.default.attributesOfItem(atPath: fixture.store.fileURL.path) + #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600) + } + + @Test(arguments: ["{", " \n{\"providers\":", "null", "garbage", "\u{0000}"]) + func `nonempty malformed configs still fail closed and retain their bytes`(_ contents: String) throws { + let fixture = try Fixture(contents) + defer { fixture.remove() } + + #expect(throws: CodexBarConfigStoreError.self) { try fixture.store.load() } + #expect(throws: CodexBarConfigStoreError.self) { try fixture.store.loadOrCreateDefault() } + #expect(throws: CodexBarConfigStoreError.self) { + try CodexBarCLI.loadServeConfigSnapshot(configStore: fixture.store) + } + #expect(try fixture.contents() == Data(contents.utf8)) + } + + private struct Fixture { + let directory: URL + let store: CodexBarConfigStore + + init(_ contents: String?) throws { + self.directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + self.store = CodexBarConfigStore(fileURL: self.directory.appendingPathComponent("config.json")) + try FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true) + if let contents { try Data(contents.utf8).write(to: self.store.fileURL) } + } + + func contents() throws -> Data? { + guard FileManager.default.fileExists(atPath: self.store.fileURL.path) else { return nil } + return try Data(contentsOf: self.store.fileURL) + } + + func remove() { try? FileManager.default.removeItem(at: self.directory) } + } +} diff --git a/docs/cli-configuration.md b/docs/cli-configuration.md index 3dcbe65c70..0d8c7feb18 100644 --- a/docs/cli-configuration.md +++ b/docs/cli-configuration.md @@ -116,3 +116,7 @@ codexbar config dump --pretty ``` `dump` prints normalized config, including providers omitted from a hand-written file. + +Missing, empty, or JSON-whitespace-only config files use defaults on macOS and Linux. `validate`, `dump`, and +`usage` leave such files unchanged; the next `config enable`, `disable`, or `set-api-key` writes valid JSON. +Malformed non-empty JSON still produces a config error and a nonzero exit without overwriting the file. diff --git a/docs/configuration.md b/docs/configuration.md index 76ff3ce5d4..7ddced64b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,6 +22,12 @@ Keychain holds runtime cookie caches, browser Safe Storage access, and provider - The directory is created if missing. - Writes on macOS and Linux create a `0600` file inside a private `0700` staging directory beside the destination before writing any bytes, then sync and atomically replace the destination. Failed writes preserve the previous file and remove staging. +A missing, zero-byte, or JSON-whitespace-only file (spaces, tabs, carriage returns, and line feeds) means no +configuration. Reads use defaults without creating or rewriting the file; the next settings save writes valid JSON. +If the running app sees a blank file, it retains its in-memory settings just as it does when the file is removed. +Non-empty malformed JSON still reports a decode error in the CLI, blocks usage and config edits, and is not +replaced by `loadOrCreateDefault()`. + ## Root shape ```json { From 3dad9dabb4f0d7f6addc76de60998e5b7e3f882c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 00:34:02 -0700 Subject: [PATCH 039/122] fix(menu-bar): validate preserved status item positions (#4082) Status-item position preservation now validates the saved position before and after hide/remove/visibility mutations: an invalid value written during the operation is not kept, a valid replacement is retained, and an already-corrupt snapshot is never restored (bound: widest attached display + 512 pt). Split-provider visibility uses the same helper. Refs #3355. --- CHANGELOG.md | 1 + .../MenuBarStatusItemPlacementPreflight.swift | 17 ++-- ...nuBarStatusItemPlacementPreservation.swift | 9 ++- Sources/CodexBar/StatusItemController.swift | 2 +- .../MenuBarLayoutVisibilityTests.swift | 80 +++++++++++++++++++ ...StatusItemPlacementPreservationTests.swift | 53 ++++++++++++ .../StatusItemControllerShutdownTests.swift | 24 ++++-- .../StatusItemCreationOrderingTests.swift | 52 ++++++------ docs/ui.md | 4 +- 9 files changed, 196 insertions(+), 46 deletions(-) create mode 100644 Tests/CodexBarTests/MenuBarLayoutVisibilityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5f166a17..7d7e3a9393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Development: restore test compilation on Xcode 26.3 / Swift 6.2 and check app, CLI, and test compatibility in CI (#4070). Thanks @RowboTony! - Configuration: treat empty or whitespace-only config files like missing files so usage keeps working; settings saves write valid JSON, while malformed non-empty files still report errors (#4071). +- Menu bar: reject corrupt saved positions during status-item visibility changes and removal while preserving valid placement across restarts (#3355). - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift index a381f2d012..bdbb0775e4 100644 --- a/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift +++ b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift @@ -19,10 +19,11 @@ enum MenuBarStatusItemPlacementPreflight { -> Bool { let names = [autosaveName] + (legacyDefaultItemIndex.map { ["Item-\($0)"] } ?? []) - let keys = self.keysToClear( - defaults.dictionaryRepresentation(), - autosaveNames: names, - screenWidths: maximumPreferredPosition.map { [$0] } ?? []) + let keys = names.map { self.preferredPositionKey(autosaveName: $0) }.filter { key in + defaults.object(forKey: key).map { + self.shouldClearPreferredPosition($0, maximumPreferredPosition: maximumPreferredPosition) + } ?? false + } for key in keys { defaults.removeObject(forKey: key) CodexBarLog.logger(LogCategories.app).info( @@ -31,14 +32,6 @@ enum MenuBarStatusItemPlacementPreflight { return !keys.isEmpty } - static func keysToClear(_ defaults: [String: Any], autosaveNames: [String], screenWidths: [Double]) -> [String] { - autosaveNames.map { self.preferredPositionKey(autosaveName: $0) }.filter { key in - defaults[key].map { - self.shouldClearPreferredPosition($0, maximumPreferredPosition: screenWidths.max()) - } ?? false - } - } - static func shouldClearPreferredPosition(_ value: Any, maximumPreferredPosition: Double?) -> Bool { guard let position = (value as? NSNumber)?.doubleValue, position.isFinite, position > 0 else { return true } diff --git a/Sources/CodexBar/MenuBarStatusItemPlacementPreservation.swift b/Sources/CodexBar/MenuBarStatusItemPlacementPreservation.swift index 5e1f6f1a03..e169688273 100644 --- a/Sources/CodexBar/MenuBarStatusItemPlacementPreservation.swift +++ b/Sources/CodexBar/MenuBarStatusItemPlacementPreservation.swift @@ -5,20 +5,25 @@ import Foundation /// macOS 26 clears that default when a status item is removed or hidden while the app keeps running, /// and a later item with the same autosave name then lands at the far left of the menu bar. CodexBar /// removes and hides items for cleanup and recovery, not to forget where the user placed them, so the -/// saved position is written back when AppKit cleared it. Termination-time removals leave the default -/// untouched and are a no-op here. +/// saved valid position is written back when AppKit clears or corrupts it. Validation uses the same +/// display bounds as creation; unchanged valid positions are left alone, including during termination. @MainActor enum MenuBarStatusItemPlacementPreservation { @discardableResult static func preservingPreferredPosition( autosaveName: String, defaults: UserDefaults, + maximumPreferredPosition: Double? = MenuBarStatusItemPlacementPreflight.currentMaximumPreferredPosition(), _ body: () -> T) -> T { guard !autosaveName.isEmpty else { return body() } + MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, autosaveName: autosaveName, maximumPreferredPosition: maximumPreferredPosition) let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: autosaveName) let savedPosition = defaults.object(forKey: key) let result = body() + MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, autosaveName: autosaveName, maximumPreferredPosition: maximumPreferredPosition) if let savedPosition, defaults.object(forKey: key) == nil { defaults.set(savedPosition, forKey: key) } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 3822f9e46e..42b96a01e0 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -783,7 +783,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let shouldBeVisible = isEnabled || fallback == provider || force if shouldBeVisible { let item = self.lazyStatusItem(for: provider) - item.isVisible = true + self.setStatusItemVisiblePreservingPlacement(item, true) expectedVisibleAutosaveNames.insert(item.autosaveName) } else { self.removeProviderStatusItem(for: provider) diff --git a/Tests/CodexBarTests/MenuBarLayoutVisibilityTests.swift b/Tests/CodexBarTests/MenuBarLayoutVisibilityTests.swift new file mode 100644 index 0000000000..6399ba9132 --- /dev/null +++ b/Tests/CodexBarTests/MenuBarLayoutVisibilityTests.swift @@ -0,0 +1,80 @@ +import AppKit +import Testing +@testable import CodexBar + +@MainActor +struct MenuBarLayoutVisibilityTests { + @Test(arguments: MenuBarLayoutSize.allCases, [NSAppearance.Name.aqua, .darkAqua]) + func `icon and percent paints both parts at regular and small sizes`( + size: MenuBarLayoutSize, appearance: NSAppearance.Name) throws + { + let fixtures = MenuBarLayoutRendererTests() + let options = MenuBarLayoutRenderOptions( + size: size, + highContrast: false, + showUsed: true, + conditionals: [], + appearanceName: appearance.rawValue, + isDebugApp: false, + now: fixtures.now) + let icon = try #require(ProviderBrandIcon.image(for: .codex)) + let output = MenuBarLayoutRenderer().render( + layout: .defaultLayout, data: fixtures.data(), icon: icon, options: options) + let button = NSButton(frame: NSRect(x: 0, y: 0, width: 100, height: 22)) + button.isBordered = false + button.imageScaling = .scaleNone + button.appearance = NSAppearance(named: appearance) + let cell = try #require(button.cell) + + for gap in MenuBarLayoutGap.allCases { + let width = StatusItemController.applyMenuBarLayoutContent(output, for: button, gap: gap) + button.setFrameSize(NSSize(width: width, height: 22)) + #expect(width.isFinite && width >= 18) + #expect(button.image === output.leadingIcon) + #expect(button.imagePosition == .imageLeft) + #expect(button.attributedTitle.string.contains("50%")) + let imageRect = cell.imageRect(forBounds: button.bounds) + let titleRect = cell.titleRect(forBounds: button.bounds) + #expect(imageRect.width >= icon.size.width) + #expect(titleRect.width > 0) + + for scale in [1, 2] { + let context = try #require(CGContext( + data: nil, + width: Int(width) * scale, + height: 22 * scale, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: false) + context.scaleBy(x: CGFloat(scale), y: CGFloat(scale)) + button.effectiveAppearance.performAsCurrentDrawingAppearance { + cell.draw(withFrame: button.bounds, in: button) + } + NSGraphicsContext.restoreGraphicsState() + let bitmap = try NSBitmapImageRep(cgImage: #require(context.makeImage())) + for rect in [imageRect, titleRect] { + var painted = 0 + for y in 0.. 0.1 { + painted += 1 + } + } + } + #expect(painted > 10) + } + if let directory = ProcessInfo.processInfo.environment["CODEXBAR_LAYOUT_VISIBILITY_PROOF_DIR"] { + let name = "\(size.rawValue)-\(appearance.rawValue)-\(gap.rawValue)-\(scale)x.png" + let png = try #require(bitmap.representation(using: .png, properties: [:])) + try png.write(to: URL(fileURLWithPath: directory).appendingPathComponent(name)) + } + } + print("LAYOUT_VISIBILITY size=\(size.rawValue) appearance=\(appearance.rawValue) " + + "gap=\(gap.rawValue) width=\(width) height=22") + } + } +} diff --git a/Tests/CodexBarTests/MenuBarStatusItemPlacementPreservationTests.swift b/Tests/CodexBarTests/MenuBarStatusItemPlacementPreservationTests.swift index 4cceb16170..23f46582c2 100644 --- a/Tests/CodexBarTests/MenuBarStatusItemPlacementPreservationTests.swift +++ b/Tests/CodexBarTests/MenuBarStatusItemPlacementPreservationTests.swift @@ -5,6 +5,59 @@ import Testing @MainActor @Suite(.serialized) struct MenuBarStatusItemPlacementPreservationTests { + @Test + func `hide or removal cannot replace a valid placement with an invalid position`() { + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + let invalid: [Any] = [6247, 0, -1, Double.nan, Double.infinity, "invalid"] + for value in invalid { + let defaults = InMemoryUserDefaults(values: [key: 548]) + MenuBarStatusItemPlacementPreservation.preservingPreferredPosition( + autosaveName: "codexbar-codex", defaults: defaults, maximumPreferredPosition: 2560) + { + defaults.set(value, forKey: key) + } + #expect(defaults.double(forKey: key) == 548) + } + } + + @Test(arguments: [true, false]) + func `hide or removal never restores a corrupt saved placement`(cleared: Bool) { + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + let defaults = InMemoryUserDefaults(values: [key: 6247]) + MenuBarStatusItemPlacementPreservation.preservingPreferredPosition( + autosaveName: "codexbar-codex", defaults: defaults, maximumPreferredPosition: 2560) + { + if cleared { defaults.removeObject(forKey: key) } + } + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `invalid new placement without a saved position is removed only for the owned identity`() { + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + let otherKey = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-claude") + let defaults = InMemoryUserDefaults(values: [otherKey: 6247]) + MenuBarStatusItemPlacementPreservation.preservingPreferredPosition( + autosaveName: "codexbar-codex", defaults: defaults, maximumPreferredPosition: 2560) + { + defaults.set(6247, forKey: key) + } + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.integer(forKey: otherKey) == 6247) + } + + @Test(arguments: [nil, 6400.0]) + func `large valid positions survive when displays are unknown or wide enough`(maximum: Double?) { + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + let defaults = InMemoryUserDefaults(values: [key: 6247]) + MenuBarStatusItemPlacementPreservation.preservingPreferredPosition( + autosaveName: "codexbar-codex", defaults: defaults, maximumPreferredPosition: maximum) + { + defaults.removeObject(forKey: key) + } + #expect(defaults.integer(forKey: key) == 6247) + } + @Test func `preserving preferred position restores a value the body cleared`() { let defaults = InMemoryUserDefaults() diff --git a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift index a0001cf704..b78b16a390 100644 --- a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift @@ -196,8 +196,10 @@ struct StatusItemControllerShutdownTests { } } - @Test - func `runtime removal hides and removes before retiring identity and restores saved placement`() { + @Test(arguments: [false, true]) + func `runtime removal hides and removes before retiring identity and restores saved placement`( + invalidRewrite: Bool) + { let statusBar = RecordingStatusBar() let controller = self.makeController(statusBar: statusBar) defer { @@ -216,7 +218,11 @@ struct StatusItemControllerShutdownTests { #expect(removed === item) #expect(removed.autosaveName == name) #expect(!removed.isVisible) - defaults.removeObject(forKey: key) + if invalidRewrite { + defaults.set(Double.infinity, forKey: key) + } else { + defaults.removeObject(forKey: key) + } } controller.removeStatusItemPreservingPlacement(item) @@ -227,8 +233,8 @@ struct StatusItemControllerShutdownTests { statusBar.onRemove = nil } - @Test - func `visibility changes retain identity and restore saved placement`() { + @Test(arguments: [false, true]) + func `visibility changes retain identity and restore saved placement`(invalidRewrite: Bool) { let controller = self.makeController(statusBar: RecordingStatusBar()) defer { controller.prepareForAppShutdown() @@ -239,7 +245,13 @@ struct StatusItemControllerShutdownTests { item.autosaveName = "codexbar-claude" let defaults = controller.settings.userDefaults let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: item.autosaveName) - item.onVisibilityChange = { defaults.removeObject(forKey: key) } + item.onVisibilityChange = { + if invalidRewrite { + defaults.set(Double.infinity, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } for isVisible in [false, true] { defaults.set(845, forKey: key) diff --git a/Tests/CodexBarTests/StatusItemCreationOrderingTests.swift b/Tests/CodexBarTests/StatusItemCreationOrderingTests.swift index cf518f7fd9..9687a21bd2 100644 --- a/Tests/CodexBarTests/StatusItemCreationOrderingTests.swift +++ b/Tests/CodexBarTests/StatusItemCreationOrderingTests.swift @@ -81,7 +81,7 @@ struct StatusItemCreationOrderingTests { } @Test - func `pure placement repair scopes keys and respects display padding`() { + func `placement repair scopes keys and respects display padding`() { let prefix = MenuBarStatusItemPlacementPreflight.preferredPositionPrefix let values: [String: Any] = [ prefix + "codexbar-codex": 6247, @@ -91,38 +91,42 @@ struct StatusItemCreationOrderingTests { prefix + "codexbar-merged": 3072, MenuBarStatusItemDefaultsRepair.didRepairKey: true, ] - let names = ["codexbar-codex", "Item-1", "codexbar-merged", "codexbar-missing"] - #expect(MenuBarStatusItemPlacementPreflight.keysToClear( - values, autosaveNames: names, screenWidths: [1440, 2560]) == [prefix + "codexbar-codex", prefix + "Item-1"]) - #expect(MenuBarStatusItemPlacementPreflight.keysToClear( - values, autosaveNames: names, screenWidths: []).isEmpty) - #expect(MenuBarStatusItemPlacementPreflight.keysToClear( - values, autosaveNames: names, screenWidths: [6400]).isEmpty) - - let defaults = InMemoryUserDefaults(values: values) - #expect(MenuBarStatusItemPlacementPreflight.prepare( - defaults: defaults, - autosaveName: "codexbar-codex", - legacyDefaultItemIndex: 1, - maximumPreferredPosition: 2560)) - #expect(defaults.dictionaryRepresentation() as NSDictionary == values.filter { - $0.key != prefix + "codexbar-codex" && $0.key != prefix + "Item-1" - } as NSDictionary) + for maximum: Double? in [nil, 6400, 2560] { + let defaults = InMemoryUserDefaults(values: values) + let shouldRepair = maximum == 2560 + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1, + maximumPreferredPosition: maximum) == shouldRepair) + for name in ["codexbar-merged", "codexbar-missing"] { + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, autosaveName: name, maximumPreferredPosition: maximum)) + } + let expected = values.filter { + !shouldRepair || ($0.key != prefix + "codexbar-codex" && $0.key != prefix + "Item-1") + } + #expect(defaults.dictionaryRepresentation() as NSDictionary == expected as NSDictionary) + } } @Test - func `pure placement repair rejects invalid values with or without displays`() { + func `placement repair rejects invalid values with or without displays`() { let name = "codexbar-merged" let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: name) let invalid: [Any] = ["invalid", 0, -1, Double.nan, Double.infinity, -Double.infinity] for value in invalid { - for widths in [[], [2560.0]] { - #expect(MenuBarStatusItemPlacementPreflight.keysToClear( - [key: value], autosaveNames: [name], screenWidths: widths) == [key]) + for maximum: Double? in [nil, 2560] { + let defaults = InMemoryUserDefaults(values: [key: value]) + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, autosaveName: name, maximumPreferredPosition: maximum)) + #expect(defaults.object(forKey: key) == nil) } } - #expect(MenuBarStatusItemPlacementPreflight.keysToClear( - [key: 3072.5], autosaveNames: [name], screenWidths: [2560]) == [key]) + let defaults = InMemoryUserDefaults(values: [key: 3072.5]) + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, autosaveName: name, maximumPreferredPosition: 2560)) + #expect(defaults.object(forKey: key) == nil) } @Test diff --git a/docs/ui.md b/docs/ui.md index ccee4c7862..eb5bbb286e 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -32,7 +32,9 @@ read_when: retain their existing selection rules. - Normal quit removes status items with their stable identities intact, preventing retained blank menu bar slots on macOS 26.6.2 while preserving saved placement. - Status items receive stable autosave names before normal sizing, including during visibility recovery. Saved - positions beyond the widest attached display plus 512 points are cleared before creation; valid placements remain. + positions beyond the widest attached display plus 512 points are cleared before creation. Visibility changes and + removal validate positions before saving and after AppKit updates them: a missing or invalid result restores only + a valid previous position. Valid new positions remain untouched; unrelated defaults are never repaired by this path. - When Overview has selected providers, the switcher includes an Overview tab that renders up to 6 provider rows. - Overview row order follows provider order; selecting a row jumps to that provider detail card. - Menu → Overview layout offers Detailed (default) and Compact. Compact keeps provider/account headers and labeled quota bars, omits their reset/detail lines and supplemental sections, and retains detail-only providers. Select a provider for its full card. Visibility choices and the shared Usage & Spend summary continue to apply. From 2b455a5dad5270eddde6eb375a02f070e093983e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 00:34:16 -0700 Subject: [PATCH 040/122] fix(codex): refresh validated cost snapshots during catch-up (#4087) Codex cost catch-up now publishes each validated snapshot after every bounded pass instead of only the first, so a completed discovery replaces an older partial total before the next sleep (completeness, account/settings scope, cancellation, and freshness checks unchanged). Refs #3508. Thanks @kernnel! --- CHANGELOG.md | 1 + .../UsageStore+CodexCostCatchUp.swift | 2 +- .../CodexDayAttributionTests.swift | 164 ++++++++++++++++++ .../UsageStoreCodexCostCatchUpTests.swift | 26 +-- docs/codex.md | 4 +- 5 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 Tests/CodexBarTests/CodexDayAttributionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d7e3a9393..69eefddb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Development: restore test compilation on Xcode 26.3 / Swift 6.2 and check app, CLI, and test compatibility in CI (#4070). Thanks @RowboTony! - Configuration: treat empty or whitespace-only config files like missing files so usage keeps working; settings saves write valid JSON, while malformed non-empty files still report errors (#4071). - Menu bar: reject corrupt saved positions during status-item visibility changes and removal while preserving valid placement across restarts (#3355). +- Codex: publish newly validated token and cost totals after each catch-up pass, even when an earlier snapshot was already shown and historical scanning is still pending (#3508). Thanks @kernnel! - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift index e7dcf289c4..2a51e46230 100644 --- a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift @@ -179,7 +179,7 @@ extension UsageStore { context: context, phase: nextStatus.pending ? .indexing : .complete) status = nextStatus - if status.pending, !publishedCurrentWindow, + if status.pending, let publishedStatus = try await self.publishAvailableCodexCostCatchUpSnapshot(context: context) { publishedCurrentWindow = true diff --git a/Tests/CodexBarTests/CodexDayAttributionTests.swift b/Tests/CodexBarTests/CodexDayAttributionTests.swift new file mode 100644 index 0000000000..859951cfce --- /dev/null +++ b/Tests/CodexBarTests/CodexDayAttributionTests.swift @@ -0,0 +1,164 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexDayAttributionTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["CODEXBAR_DAY_BENCHMARK"] == "1")) + func `large synthetic history scan timing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 30) + let stamp = env.isoString(for: day) + let context = #"{"type":"turn_context","timestamp":"\#(stamp)","payload":{"model":"gpt-5.4"}}"# + let events = (1...100).map { index in + #"{"type":"event_msg","timestamp":"\#(stamp)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":\#(index * 100),"output_tokens":\#(index * 10)}}}}"# + }.joined(separator: "\n") + for index in 0..<1500 { + _ = try env.writeCodexSessionFile( + day: day, filename: "synthetic-\(index).jsonl", contents: context + "\n" + events + "\n") + } + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + for label in ["cold", "warm"] { + let start = ContinuousClock.now + let report = CostUsageScanner.loadDailyReport( + provider: .codex, since: day, until: day, now: day, options: options) + print("[day-attribution-benchmark] \(label): \(start.duration(to: .now)); 1500 files, 150000 events") + #expect(report.summary?.totalTokens == 16_500_000) + } + } + + @Test + func `completed directory discovery releases the retained token snapshot`() async throws { + let fixture = try CodexCurrentWindowFixture(kind: .historical) + defer { fixture.base.remove() } + var cache = CostUsageStoreAccess.read( + cacheRoot: fixture.base.env.cacheRoot, calendar: fixture.base.calendar) + let roots = try #require(cache.codexActiveLookbackState).rootPaths + cache.codexActiveLookbackState?.completedCurrentWindowRootPaths = [] + cache.codexActiveLookbackState?.completedCurrentWindowFlatRootPaths = [] + CostUsageStoreAccess.replace( + cacheRoot: fixture.base.env.cacheRoot, cache: cache, calendar: fixture.base.calendar) + #expect(await fixture.strictSnapshot() == nil) + let retained = try #require(await fixture.base.cachedSnapshot()) + #expect(retained.snapshot.last30DaysTokens == 13) + #expect(retained.snapshot.updatedAt == fixture.previousTime) + + cache.codexActiveLookbackState?.completedCurrentWindowRootPaths = roots + cache.codexActiveLookbackState?.completedCurrentWindowFlatRootPaths = roots + CostUsageStoreAccess.replace( + cacheRoot: fixture.base.env.cacheRoot, cache: cache, calendar: fixture.base.calendar) + let completed = try #require(await fixture.strictSnapshot()) + #expect(completed.snapshot.last30DaysTokens == 52) + #expect(completed.snapshot.updatedAt == fixture.base.now) + #expect(completed.staleSnapshotUpdatedAt == nil) + #expect(await CostUsageFetcher(scannerOptions: fixture.base.options).codexScanCatchUpStatus().pending) + } + + @Test(arguments: [false, true]) + func `resumed sessions keep event days through archive copies and parser migration`(force: Bool) throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let firstStamp = "2026-08-29T15:59:00Z" + let nextStamp = "2026-08-29T16:01:00Z" + let firstDate = try #require(ISO8601DateFormatter().date(from: firstStamp)) + let nextDate = try #require(ISO8601DateFormatter().date(from: nextStamp)) + func event(_ timestamp: String, _ total: [Int], _ last: [Int]) -> [String: Any] { + func tokens(_ values: [Int]) -> [String: Int] { + [ + "input_tokens": values[0], + "cached_input_tokens": values[1], + "output_tokens": values[2], + "reasoning_output_tokens": values[3], + ] + } + return [ + "type": "event_msg", + "timestamp": timestamp, + "payload": ["type": "token_count", "info": [ + "total_token_usage": tokens(total), "last_token_usage": tokens(last), + ]], + ] + } + let file = try env.writeCodexSessionFile( + day: firstDate, + filename: "synthetic-resume.jsonl", + contents: env.jsonl([ + ["type": "session_meta", "timestamp": firstStamp, "payload": ["id": "synthetic-resume"]], + ["type": "turn_context", "timestamp": firstStamp, "payload": ["model": "gpt-5.4"]], + event(firstStamp, [1000, 200, 100, 40], [1000, 200, 100, 40]), + ])) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"), + calendar: calendar) + options.refreshMinIntervalSeconds = 0 + let first = CostUsageScanner.loadDailyReport( + provider: .codex, since: firstDate, until: firstDate, now: firstDate, options: options) + #expect(first.summary?.totalTokens == 1100) + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data(env.jsonl([ + event(nextStamp, [1060, 220, 106, 43], [60, 20, 6, 3]), + event("2026-08-29T16:01:05Z", [1120, 240, 112, 46], [60, 20, 6, 3]), + ]).utf8)) + try handle.close() + options.forceRescan = force + let report = CostUsageScanner.loadDailyReport( + provider: .codex, since: firstDate, until: nextDate, now: nextDate, options: options) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot, calendar: calendar) + let saved = try #require(cache.files.values.first) + #expect(saved.parsedBytes == CostUsageScanner.codexFileMetadata(fileURL: file).size) + #expect(saved.codexScanComplete == true) + let today = report.data.first { $0.date == "2026-08-30" }?.totalTokens ?? 0 + #expect(today == 132) + let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: nextDate, calendar: calendar) + #expect(snapshot.sessionTokens == 132) + #expect(try #require(snapshot.sessionCostUSD) > 0) + #expect(saved.days.keys.sorted() == ["2026-08-29", "2026-08-30"]) + let repeated = CostUsageScanner.loadDailyReport( + provider: .codex, + since: firstDate, + until: nextDate, + now: nextDate.addingTimeInterval(120), + options: options) + #expect(repeated.data == report.data) + let thirdStamp = "2026-08-31T02:00:00Z" + let thirdDate = try #require(ISO8601DateFormatter().date(from: thirdStamp)) + let nextHandle = try FileHandle(forWritingTo: file) + try nextHandle.seekToEnd() + try nextHandle.write(contentsOf: Data(env.jsonl([ + event(thirdStamp, [1180, 260, 118, 49], [60, 20, 6, 3]), + ]).utf8)) + try nextHandle.close() + let archived = env.codexArchivedSessionsRoot.appendingPathComponent("rotated.jsonl") + try FileManager.default.copyItem(at: file, to: archived) + options.forceRescan = false + let rotated = CostUsageScanner.loadDailyReport( + provider: .codex, since: firstDate, until: thirdDate, now: thirdDate, options: options) + #expect(rotated.data.map(\.totalTokens) == [1100, 132, 66]) + #expect(rotated.summary?.totalTokens == 1298) + var legacy = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot, calendar: calendar) + for path in Array(legacy.files.keys) { + legacy.files[path]?.codexParserRevision = CostUsageFileUsage.currentCodexParserRevision - 1 + } + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: legacy, calendar: calendar) + let migrated = CostUsageScanner.loadDailyReport( + provider: .codex, + since: firstDate, + until: thirdDate, + now: thirdDate.addingTimeInterval(1), + options: options) + #expect(migrated.data == rotated.data) + let upgraded = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot, calendar: calendar) + let allFilesUpgraded = upgraded.files.values.allSatisfy(\.hasCurrentCodexParser) + #expect(allFilesUpgraded) + } +} diff --git a/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift b/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift index 6a50675244..c77c7b6c57 100644 --- a/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift +++ b/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift @@ -273,11 +273,9 @@ struct UsageStoreCodexCostCatchUpTests { } @Test - func `bounded catch-up publishes current window before historical completion`() async throws { + func `bounded catch-up republishes current window before historical completion`() async throws { let store = try Self.makeStore(suite: "publishes-final") var snapshotLoadCount = 0 - var cachedLoadCount = 0 - var statusLoadCount = 0 var advanceCount = 0 var sleepDurations: [TimeInterval] = [] store._test_codexCostCatchUpActiveDuration = 2 @@ -286,26 +284,25 @@ struct UsageStoreCodexCostCatchUpTests { return Self.tokenSnapshot(cost: Double(snapshotLoadCount), now: now) } store._test_cachedCodexTokenSnapshotLoaderOverride = { now, _, _ in - cachedLoadCount += 1 - return (Self.tokenSnapshot(cost: advanceCount == 2 ? 1 : 2, now: now), now, nil) + let cost = advanceCount == 2 ? 1 : Double(2 + advanceCount * 2) + return (Self.tokenSnapshot(cost: cost, now: now), now, nil) } store._test_codexCostCatchUpStatusOverride = { _ in - statusLoadCount += 1 - return CostUsageFetcher.CodexScanCatchUpStatus( + CostUsageFetcher.CodexScanCatchUpStatus( pending: advanceCount < 2, - progressKey: "status-\(statusLoadCount)") + progressKey: "status-\(advanceCount)") } store._test_codexCostCatchUpAdvanceOverride = { _, _, _ in advanceCount += 1 - if advanceCount == 2 { - #expect(store.tokenSnapshot(for: .codex)?.last30DaysCostUSD == 2) - } return CostUsageFetcher.CodexScanCatchUpStatus( pending: advanceCount < 2, progressKey: "advance-\(advanceCount)") } store._test_codexCostCatchUpSleepOverride = { duration in sleepDurations.append(duration) + if advanceCount == 1 { + #expect(store.tokenSnapshot(for: .codex)?.last30DaysCostUSD == 4) + } await Task.yield() } store._test_codexCostCatchUpResourceStateOverride = { @@ -313,17 +310,12 @@ struct UsageStoreCodexCostCatchUpTests { } await store.refreshTokenUsage(.codex, force: true) - await Self.waitUntil { - store.codexCostCatchUpTask == nil && cachedLoadCount == 2 - } + await Self.waitUntil { store.codexCostCatchUpTask == nil } #expect(advanceCount == 2) - #expect(statusLoadCount == 3) #expect(snapshotLoadCount == 1) - #expect(cachedLoadCount == 2) #expect(sleepDurations == [1998, 1998]) #expect(store.tokenSnapshot(for: .codex)?.last30DaysCostUSD == 1) - #expect(store.tokenSnapshotPublicationRevision(for: .codex) == 3) #expect(store.tokenError(for: .codex) == nil) } diff --git a/docs/codex.md b/docs/codex.md index 9781303711..d57de9bff4 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -285,7 +285,9 @@ the local result and returns a nonzero exit code. See [CLI host reporting](cli.m During historical catch-up, a validated reporting window can publish once its discovery, parser, materialization, and fork-ownership checks are complete. Metadata-only reads do not establish day coverage; unresolved or unparsed work retains the previous report. Cached publication is attempted before duty-cycle and resource-pause sleeps and - after bounded passes, preserving power limits and actual cache timestamps rather than stamping publication as a new scan. + after every bounded pass, including when an earlier pass already published a valid snapshot. Fresh validated totals + replace that earlier snapshot before the next sleep; final reconciliation can still lower totals. Publications use + actual cache timestamps, and the existing power limits and completeness checks still apply. A native scan loads exact usage rows once, deferring raw token history and checkpoints until a file changes or a fork needs its ancestors. A single-use receipt binds those deferred reads and saves to the original connection, database identity and SQLite change observations, From 66004975a5ef37bb61e186f8bcd19c0792dd2e43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 00:34:28 -0700 Subject: [PATCH 041/122] fix(keychain): bound validation stalls and recover Claude caches (#4089) Keychain signature validation runs on bounded utility workers with a two-second wait per caller, so a stalled native validation no longer blocks background quota refresh after app updates (late successes cannot authorize reads). Claude OAuth rechecks fresh, profile-scoped memory after stale-cache cleanup and persists it with its original owner binding. Fixes #3249; refs #3395 #3798. Thanks @lozcalver and @SilentKnight87! --- CHANGELOG.md | 3 + ...ychainAccessPreflight+ValidationMemo.swift | 45 +++++--- .../KeychainAccessPreflight.swift | 4 +- .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 95 +++++++---------- ...udeOAuthBackgroundCacheRecoveryTests.swift | 41 +++++-- .../KeychainAccessValidationMemoTests.swift | 100 +++++++++++++----- docs/claude.md | 14 ++- docs/keychain-prompts.md | 3 + 8 files changed, 191 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69eefddb91..2110fcecdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ - Configuration: treat empty or whitespace-only config files like missing files so usage keeps working; settings saves write valid JSON, while malformed non-empty files still report errors (#4071). - Menu bar: reject corrupt saved positions during status-item visibility changes and removal while preserving valid placement across restarts (#3355). - Codex: publish newly validated token and cost totals after each catch-up pass, even when an earlier snapshot was already shown and historical scanning is still pending (#3508). Thanks @kernnel! +- Claude: retain valid in-memory credentials after a rejected OAuth cache write once stale-cache cleanup succeeds, so the next automatic refresh can recover without another manual Refresh (#3395). Thanks @lozcalver! +- Keychain: bound stalled code-signature validation so it cannot hold cache locks and freeze all provider refreshes indefinitely (#3249). Thanks @SilentKnight87! + - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBarCore/KeychainAccessPreflight+ValidationMemo.swift b/Sources/CodexBarCore/KeychainAccessPreflight+ValidationMemo.swift index 00ace80011..5c42b1f471 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight+ValidationMemo.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight+ValidationMemo.swift @@ -10,45 +10,56 @@ extension KeychainAccessPreflight { /// Preflights are synchronous. Each pending key has its own result promise, so waiting callers /// share even a transient result without holding the dictionary lock or blocking unrelated keys. - private final class Flight { - private let condition = NSCondition() - private var completed = false + private final class Flight: @unchecked Sendable { + private let completion = DispatchGroup() + /// Written once before leave(), read only after a successful wait(). private var result: OSStatus? - func wait() -> OSStatus? { - self.condition.lock() - defer { self.condition.unlock() } - while !self.completed { - self.condition.wait() - } + init() { self.completion.enter() } + + func wait(timeout: DispatchTime = .distantFuture) -> OSStatus? { + guard self.completion.wait(timeout: timeout) == .success else { return nil } return self.result } func complete(_ result: OSStatus?) { - self.condition.lock() self.result = result - self.completed = true - self.condition.broadcast() - self.condition.unlock() + self.completion.leave() } } + // A timed-out native validation cannot be cancelled. Keep its slot occupied until it returns. + private let validationSlots = DispatchSemaphore(value: 4) private let lock = NSLock() private var entries: [ValidationKey: (status: OSStatus, expiresAt: TimeInterval)] = [:] private var flights: [ValidationKey: Flight] = [:] private let onJoin: @Sendable () -> Void + private let validationTimeout: TimeInterval - init(onJoin: @escaping @Sendable () -> Void = {}) { + init(validationTimeout: TimeInterval = 2, onJoin: @escaping @Sendable () -> Void = {}) { + self.validationTimeout = validationTimeout self.onJoin = onJoin } + private func performBoundedValidation(_ check: @escaping @Sendable () -> OSStatus?) -> OSStatus? { + guard self.validationSlots.wait(timeout: .now()) == .success else { return nil } + let result = Flight() + DispatchQueue.global(qos: .utility).async { + let value = check() + self.validationSlots.signal() + result.complete(value) + } + return result.wait(timeout: .now() + self.validationTimeout) + } + func validate( trustedApplication: Data?, path: String, now: TimeInterval = ProcessInfo.processInfo.systemUptime, - check: () -> OSStatus?) -> OSStatus? + check: @escaping @Sendable () -> OSStatus?) -> OSStatus? { - guard let key = ValidationKey(trustedApplication: trustedApplication, path: path) else { return check() } + guard let key = ValidationKey(trustedApplication: trustedApplication, path: path) + else { return self.performBoundedValidation(check) } self.lock.lock() if let entry = self.entries[key], now < entry.expiresAt { self.lock.unlock() @@ -63,7 +74,7 @@ extension KeychainAccessPreflight { self.flights[key] = flight self.lock.unlock() - let result = check() + let result = self.performBoundedValidation(check) self.lock.withLock { self.entries = self.entries.filter { now < $0.value.expiresAt } // Executable and bundle metadata cannot prove that all sealed resources are unchanged. diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index bb0afcc076..b4e1f68cc0 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -417,10 +417,12 @@ public enum KeychainAccessPreflight { named: "SecTrustedApplicationValidateWithPath", as: SecTrustedApplicationValidateWithPathFunction.self) else { return nil } + // Security's immutable handle must stay alive even if its validation outlasts the caller's wait. + nonisolated(unsafe) let retainedApplication = application return self.validationMemo.validate( trustedApplication: self.trustedApplicationRepresentation(application), path: path) { - path.withCString { validate(application, $0) } + path.withCString { validate(retainedApplication, $0) } } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index e372c34360..7464dfffb7 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -373,12 +373,18 @@ public enum ClaudeOAuthCredentialsStore { // A cache outage does not expire a token already read with consent. Retry persistent storage // on every load after the normal memory window, but keep valid memory ahead of stale files. - if cacheTemporarilyUnavailable, - let record = self.memoryCredentialRecord( - environment: environment, - profileIdentifier: profileIdentifier, - requireFreshTimestamp: false) + // A rejected write may also have left a tombstone: reconsider memory after its cleanup succeeds. + if let record = self.memoryCredentialRecord( + environment: environment, + profileIdentifier: profileIdentifier, + requireFreshTimestamp: !cacheTemporarilyUnavailable) { + ClaudeOAuthCredentialsStore.saveCredentialsToCache( + record.credentials, + historyOwnerIdentifier: record.historyOwnerIdentifier, + profileIdentifier: profileIdentifier, + owner: record.owner, + allowCacheKeychainWrite: !cacheTemporarilyUnavailable) return record } @@ -1229,6 +1235,20 @@ public enum ClaudeOAuthCredentialsStore { #endif } + private func cacheClaudeKeychainCredentials(_ credentials: ClaudeOAuthCredentials, data: Data, now: Date) { + ClaudeOAuthCredentialsStore.writeMemoryCache( + record: ClaudeOAuthCredentialRecord( + credentials: credentials, + owner: .claudeCLI, + source: .memoryCache), + timestamp: now, + profileIdentifier: self.profileIdentifier) + ClaudeOAuthCredentialsStore.saveToCacheKeychain( + data, + owner: .claudeCLI, + profileIdentifier: self.profileIdentifier) + } + @discardableResult func syncFromClaudeKeychainWithoutPrompt(now: Date = Date()) -> Bool { self.context.run { @@ -1242,17 +1262,7 @@ public enum ClaudeOAuthCredentialsStore { !data.isEmpty { if let creds = try? ClaudeOAuthCredentials.parse(data: data), !creds.isExpired { - ClaudeOAuthCredentialsStore.writeMemoryCache( - record: ClaudeOAuthCredentialRecord( - credentials: creds, - owner: .claudeCLI, - source: .memoryCache), - timestamp: now, - profileIdentifier: self.profileIdentifier) - ClaudeOAuthCredentialsStore.saveToCacheKeychain( - data, - owner: .claudeCLI, - profileIdentifier: self.profileIdentifier) + self.cacheClaudeKeychainCredentials(creds, data: data, now: now) return true } } @@ -1280,17 +1290,7 @@ public enum ClaudeOAuthCredentialsStore { { ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint( ClaudeOAuthCredentialsStore.currentClaudeKeychainFingerprintWithoutPrompt()) - ClaudeOAuthCredentialsStore.writeMemoryCache( - record: ClaudeOAuthCredentialRecord( - credentials: creds, - owner: .claudeCLI, - source: .memoryCache), - timestamp: now, - profileIdentifier: self.profileIdentifier) - ClaudeOAuthCredentialsStore.saveToCacheKeychain( - override, - owner: .claudeCLI, - profileIdentifier: self.profileIdentifier) + self.cacheClaudeKeychainCredentials(creds, data: override, now: now) return true } #endif @@ -1313,17 +1313,7 @@ public enum ClaudeOAuthCredentialsStore { if let creds = try? ClaudeOAuthCredentials.parse(data: data), !creds.isExpired { ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint(fingerprint) - ClaudeOAuthCredentialsStore.writeMemoryCache( - record: ClaudeOAuthCredentialRecord( - credentials: creds, - owner: .claudeCLI, - source: .memoryCache), - timestamp: now, - profileIdentifier: self.profileIdentifier) - ClaudeOAuthCredentialsStore.saveToCacheKeychain( - data, - owner: .claudeCLI, - profileIdentifier: self.profileIdentifier) + self.cacheClaudeKeychainCredentials(creds, data: data, now: now) return true } @@ -1340,17 +1330,7 @@ public enum ClaudeOAuthCredentialsStore { { ClaudeOAuthCredentialsStore.saveClaudeKeychainFingerprint( ClaudeOAuthCredentialsStore.currentClaudeKeychainFingerprintWithoutPrompt()) - ClaudeOAuthCredentialsStore.writeMemoryCache( - record: ClaudeOAuthCredentialRecord( - credentials: creds, - owner: .claudeCLI, - source: .memoryCache), - timestamp: now, - profileIdentifier: self.profileIdentifier) - ClaudeOAuthCredentialsStore.saveToCacheKeychain( - legacyData, - owner: .claudeCLI, - profileIdentifier: self.profileIdentifier) + self.cacheClaudeKeychainCredentials(creds, data: legacyData, now: now) return true } @@ -1382,7 +1362,7 @@ public enum ClaudeOAuthCredentialsStore { existingRateLimitTier: existingRateLimitTier, existingSubscriptionType: existingSubscriptionType) - ClaudeOAuthCredentialsStore.saveRefreshedCredentialsToCache( + ClaudeOAuthCredentialsStore.saveCredentialsToCache( newCredentials, historyOwnerIdentifier: historyOwnerIdentifier, profileIdentifier: self.profileIdentifier) @@ -1642,12 +1622,15 @@ public enum ClaudeOAuthCredentialsStore { } } - /// Save refreshed credentials to CodexBar's keychain cache - private static func saveRefreshedCredentialsToCache( + /// Persist a credential without changing who owns its refresh chain. + private static func saveCredentialsToCache( _ credentials: ClaudeOAuthCredentials, historyOwnerIdentifier: String?, - profileIdentifier: String) + profileIdentifier: String, + owner: ClaudeOAuthCredentialOwner = .codexbar, + allowCacheKeychainWrite: Bool = true) { + guard allowCacheKeychainWrite else { return } var oauth: [String: Any] = [ "accessToken": credentials.accessToken, "expiresAt": (credentials.expiresAt?.timeIntervalSince1970 ?? 0) * 1000, @@ -1667,16 +1650,16 @@ public enum ClaudeOAuthCredentialsStore { let oauthData: [String: Any] = ["claudeAiOauth": oauth] guard let jsonData = try? JSONSerialization.data(withJSONObject: oauthData) else { - self.log.error("Failed to serialize refreshed credentials for cache") + self.log.error("Failed to serialize credentials for cache") return } self.saveToCacheKeychain( jsonData, - owner: .codexbar, + owner: owner, historyOwnerIdentifier: historyOwnerIdentifier, profileIdentifier: profileIdentifier) - self.log.debug("Saved refreshed credentials to CodexBar keychain cache") + self.log.debug("Saved credentials to CodexBar keychain cache") } /// Response from the OAuth token refresh endpoint diff --git a/Tests/CodexBarTests/ClaudeOAuthBackgroundCacheRecoveryTests.swift b/Tests/CodexBarTests/ClaudeOAuthBackgroundCacheRecoveryTests.swift index 5be0a3339f..2dfca4ab1f 100644 --- a/Tests/CodexBarTests/ClaudeOAuthBackgroundCacheRecoveryTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthBackgroundCacheRecoveryTests.swift @@ -7,12 +7,17 @@ import Testing @Suite(.serialized) struct ClaudeOAuthBackgroundCacheRecoveryTests { enum CacheScenario: CaseIterable { - case available, writeRejected, temporarilyUnavailable, memoryOlderThanThirtyMinutes + case available, writeRejected, writeRejectedWithoutExpiry, temporarilyUnavailable, memoryOlderThanThirtyMinutes case expiredFile, expiredMemory, invalidated, neverPrompt, pendingInvalidation, profileChanged + var rejectsWrite: Bool { + self == .writeRejected || self == .writeRejectedWithoutExpiry + } + var expectsRecovery: Bool { switch self { - case .available, .temporarilyUnavailable, .memoryOlderThanThirtyMinutes, .expiredFile: true + case .available, .writeRejected, .temporarilyUnavailable, + .memoryOlderThanThirtyMinutes, .expiredFile: true default: false } } @@ -30,7 +35,7 @@ struct ClaudeOAuthBackgroundCacheRecoveryTests { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } let environment = ["HOME": root.path, "CLAUDE_CONFIG_DIR": root.path] - let data = self.credentialsData() + let data = self.credentialsData(expiresIn: scenario == .writeRejectedWithoutExpiry ? nil : 7200) try await KeychainCacheStore.withServiceOverrideForTesting(service) { KeychainCacheStore.setTestStoreForTesting(true) @@ -82,7 +87,7 @@ struct ClaudeOAuthBackgroundCacheRecoveryTests { .write(to: ClaudeOAuthCredentialsStore.resolvedCredentialsURLForTesting) #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged(environment: environment)) } - let loadFailure: OSStatus? = scenario == .available || scenario == .writeRejected + let loadFailure: OSStatus? = scenario == .available || scenario.rejectsWrite ? nil : errSecInteractionNotAllowed let interactiveRead: @Sendable () throws -> Data = { data } try await KeychainCacheStore.withLoadFailureStatusOverrideForTesting(loadFailure) { @@ -90,7 +95,7 @@ struct ClaudeOAuthBackgroundCacheRecoveryTests { read: interactiveRead) { try KeychainCacheStore.withStoreFailureStatusOverrideForTesting( - scenario == .writeRejected ? errSecInteractionNotAllowed : nil) + scenario.rejectsWrite ? errSecInteractionNotAllowed : nil) { let manual = try ProviderInteractionContext.$current.withValue(.userInitiated) { try ClaudeOAuthCredentialsStore.loadRecord( @@ -104,7 +109,7 @@ struct ClaudeOAuthBackgroundCacheRecoveryTests { #expect(memory.record?.credentials.accessToken == "synthetic-manual-token") } } - if scenario != .available, scenario != .temporarilyUnavailable, scenario != .writeRejected { + if scenario != .available, scenario != .temporarilyUnavailable, !scenario.rejectsWrite { memory.timestamp = Date(timeIntervalSinceNow: -1860) } if scenario == .expiredMemory { @@ -144,8 +149,22 @@ struct ClaudeOAuthBackgroundCacheRecoveryTests { let automatic = try load() #expect(automatic.credentials.accessToken == "synthetic-manual-token") #expect(automatic.source == .memoryCache) + if scenario.rejectsWrite { + let profile = try #require(memory.profileIdentifier) + let key = ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: profile) + guard case let .found(entry) = KeychainCacheStore.load( + key: key, as: ClaudeOAuthCredentialsStore.CacheEntry.self) + else { + Issue.record("Recovered credentials must be persisted for subsequent refreshes") + return + } + let persisted = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(persisted.accessToken == automatic.credentials.accessToken) + #expect(persisted.expiresAt == automatic.credentials.expiresAt) + #expect(entry.owner == .claudeCLI) + } } else { - // A retained credential must not bypass pending invalidation after a rejected write. + // Pending invalidation must be cleared before a retained credential can be reused. #expect(throws: ClaudeOAuthCredentialsError.self, performing: load) } } @@ -155,10 +174,12 @@ struct ClaudeOAuthBackgroundCacheRecoveryTests { } } - private func credentialsData(expiresIn: TimeInterval = 7200) -> Data { - Data(""" + private func credentialsData(expiresIn: TimeInterval? = 7200) -> Data { + let expiry = expiresIn.map { Int(Date(timeIntervalSinceNow: $0).timeIntervalSince1970 * 1000) } + let expiryField = expiry.map { "\"expiresAt\":\($0)," } ?? "" + return Data(""" {"claudeAiOauth":{"accessToken":"synthetic-manual-token", - "expiresAt":\(Int(Date(timeIntervalSinceNow: expiresIn).timeIntervalSince1970 * 1000)), + \(expiryField) "scopes":["user:profile"]}} """.utf8) } diff --git a/Tests/CodexBarTests/KeychainAccessValidationMemoTests.swift b/Tests/CodexBarTests/KeychainAccessValidationMemoTests.swift index 458b4c277d..f78b781055 100644 --- a/Tests/CodexBarTests/KeychainAccessValidationMemoTests.swift +++ b/Tests/CodexBarTests/KeychainAccessValidationMemoTests.swift @@ -64,7 +64,7 @@ struct KeychainAccessValidationMemoTests { func remove() { try? FileManager.default.removeItem(at: self.root) } } - private static func gate(memo: Memo, path: String, check: @escaping () -> OSStatus?) -> Bool { + private static func gate(memo: Memo, path: String, check: @escaping @Sendable () -> OSStatus?) -> Bool { KeychainAccessGate.withTaskOverrideForTesting(false) { ProviderInteractionContext.$current.withValue(.background) { KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in @@ -83,6 +83,50 @@ struct KeychainAccessValidationMemoTests { } } + @Test + func `stalled signature validation returns inconclusive without holding refresh locks`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let memo = Memo() + let started = Date() + let result = memo.validate(trustedApplication: Self.trust, path: fixture.helper.path) { + Thread.sleep(forTimeInterval: 5) + return errSecSuccess + } + #expect(result == nil) + #expect(Date().timeIntervalSince(started) < 4.5) + } + + @Test + func `timed out validations retain bounded worker slots until native work returns`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let memo = Memo() + let release = DispatchSemaphore(value: 0) + let started = DispatchGroup() + let calls = Counter() + defer { for _ in 0..<4 { + release.signal() + } } + for index in 0..<4 { + started.enter() + #expect(memo.validate(trustedApplication: Data("stalled-\(index)".utf8), path: fixture.helper.path) { + _ = calls.increment() + started.leave() + _ = release.wait(timeout: .now() + 30) + return errSecSuccess + } == nil) + } + #expect(started.wait(timeout: .now() + 5) == .success) + for index in 4..<10 { + #expect(memo.validate(trustedApplication: Data("stalled-\(index)".utf8), path: fixture.helper.path) { + _ = calls.increment() + return errSecSuccess + } == nil) + } + #expect(calls.count == 4) + } + @Test func `a changed sealed resource blocks the next background preflight`() throws { let fixture = try Fixture() @@ -90,7 +134,7 @@ struct KeychainAccessValidationMemoTests { let memo = Memo() let calls = Counter() let original = try Data(contentsOf: fixture.resource) - let check: () -> OSStatus? = { + let check: @Sendable () -> OSStatus? = { _ = calls.increment() return (try? Data(contentsOf: fixture.resource)) == original ? errSecSuccess : OSStatus(CSSMERR_CSP_VERIFY_FAILED) @@ -112,7 +156,7 @@ struct KeychainAccessValidationMemoTests { let calls = Counter() let plist = fixture.bundle.appendingPathComponent("Contents/Info.plist") let original = try Data(contentsOf: plist) - let check: () -> OSStatus? = { + let check: @Sendable () -> OSStatus? = { _ = calls.increment() return (try? Data(contentsOf: plist)) == original ? errSecSuccess : OSStatus(CSSMERR_CSP_VERIFY_FAILED) @@ -154,7 +198,7 @@ struct KeychainAccessValidationMemoTests { defer { fixture.remove() } let memo = Memo() let calls = Counter() - let check: () -> OSStatus? = { + let check: @Sendable () -> OSStatus? = { _ = calls.increment() return errSecSuccess } @@ -177,7 +221,7 @@ struct KeychainAccessValidationMemoTests { for _ in 0..<19 { joined.enter() } - let memo = Memo(onJoin: { joined.leave() }) + let memo = Memo(validationTimeout: 10, onJoin: { joined.leave() }) let started = DispatchSemaphore(value: 0) let release = DispatchSemaphore(value: 0) let done = DispatchGroup() @@ -224,16 +268,16 @@ struct KeychainAccessValidationMemoTests { let fixture = try Fixture() defer { fixture.remove() } let memo = Memo() - var calls = 0 + let calls = Counter() func validate() { #expect(memo.validate(trustedApplication: Self.trust, path: fixture.helper.path) { - calls += 1 + _ = calls.increment() return OSStatus(CSSMERR_CSP_VERIFY_FAILED) } == OSStatus(CSSMERR_CSP_VERIFY_FAILED)) } validate() validate() - #expect(calls == 1) + #expect(calls.count == 1) switch change { case "version": try fixture.setVersion("2") case "main executable": try Data("changed main executable size".utf8).write(to: fixture.main) @@ -244,7 +288,7 @@ struct KeychainAccessValidationMemoTests { ofItemAtPath: fixture.bundle.path) } validate() - #expect(calls == 2) + #expect(calls.count == 2) } @Test(arguments: [OSStatus?.none, errSecInteractionNotAllowed, errSecNotAvailable, errSecParam]) @@ -252,14 +296,14 @@ struct KeychainAccessValidationMemoTests { let fixture = try Fixture() defer { fixture.remove() } let memo = Memo() - var calls = 0 + let calls = Counter() for _ in 0..<2 { #expect(memo.validate(trustedApplication: Self.trust, path: fixture.helper.path) { - calls += 1 + _ = calls.increment() return status } == status) } - #expect(calls == 2) + #expect(calls.count == 2) #expect(Self.gate(memo: memo, path: fixture.helper.path) { errSecSuccess }) } @@ -270,14 +314,14 @@ struct KeychainAccessValidationMemoTests { let memo = Memo() let status = OSStatus(CSSMERR_CSP_VERIFY_FAILED) let lifetime = Memo.rejectionLifetime - var calls = 0 + let calls = Counter() for now in [100, 100 + lifetime - 1, 100 + lifetime] { #expect(memo.validate(trustedApplication: Self.trust, path: fixture.helper.path, now: now) { - calls += 1 + _ = calls.increment() return status } == status) } - #expect(calls == 2) + #expect(calls.count == 2) } @Test @@ -285,7 +329,7 @@ struct KeychainAccessValidationMemoTests { let fixture = try Fixture() defer { fixture.remove() } let memo = Memo() - var calls = 0 + let calls = Counter() for index in 0...Memo.capacity { #expect(memo .validate( @@ -293,30 +337,30 @@ struct KeychainAccessValidationMemoTests { path: fixture.helper.path, now: 100 + Double(index)) { - calls += 1 + _ = calls.increment() return OSStatus(CSSMERR_CSP_VERIFY_FAILED) } == OSStatus(CSSMERR_CSP_VERIFY_FAILED)) } #expect(memo.validate(trustedApplication: Data("trust-1".utf8), path: fixture.helper.path, now: 200) { - calls += 1 + _ = calls.increment() return OSStatus(CSSMERR_CSP_VERIFY_FAILED) } == OSStatus(CSSMERR_CSP_VERIFY_FAILED)) - #expect(calls == Memo.capacity + 1) + #expect(calls.count == Memo.capacity + 1) #expect(memo.validate(trustedApplication: Data("trust-0".utf8), path: fixture.helper.path, now: 200) { - calls += 1 + _ = calls.increment() return OSStatus(CSSMERR_CSP_VERIFY_FAILED) } == OSStatus(CSSMERR_CSP_VERIFY_FAILED)) - #expect(calls == Memo.capacity + 2) + #expect(calls.count == Memo.capacity + 2) #expect(memo.validate(trustedApplication: Data("trust-2".utf8), path: fixture.helper.path, now: 200) { - calls += 1 + _ = calls.increment() return OSStatus(CSSMERR_CSP_VERIFY_FAILED) } == OSStatus(CSSMERR_CSP_VERIFY_FAILED)) - #expect(calls == Memo.capacity + 2) + #expect(calls.count == Memo.capacity + 2) #expect(memo.validate(trustedApplication: Data("trust-0".utf8), path: fixture.main.path, now: 200) { - calls += 1 + _ = calls.increment() return OSStatus(CSSMERR_CSP_VERIFY_FAILED) } == OSStatus(CSSMERR_CSP_VERIFY_FAILED)) - #expect(calls == Memo.capacity + 3) + #expect(calls.count == Memo.capacity + 3) } @Test @@ -325,16 +369,16 @@ struct KeychainAccessValidationMemoTests { defer { fixture.remove() } try FileManager.default.removeItem(at: fixture.bundle.appendingPathComponent("Contents/Info.plist")) let memo = Memo() - var calls = 0 + let calls = Counter() for trust in [Self.trust, nil] { for _ in 0..<2 { #expect(memo.validate(trustedApplication: trust, path: fixture.helper.path) { - calls += 1 + _ = calls.increment() return errSecSuccess } == errSecSuccess) } } - #expect(calls == 4) + #expect(calls.count == 4) } } #endif diff --git a/docs/claude.md b/docs/claude.md index 5a725a2a40..fe33eeaffd 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -108,7 +108,10 @@ the cookie import. - CodexBar's `Always allow prompts` permits future prompts; macOS's **Always Allow** grants access to the current Keychain item. Claude Code can recreate `Claude Code-credentials` and reset that grant. An ACL entry still named CodexBar does not prove that its stored code-signing requirement matches the running binary. `Only on user action` - reduces background interruptions but may require a manual Refresh to recover OAuth access. + reduces background interruptions but may require a manual Refresh to recover OAuth access. In #3798, a + before/after trace shows Claude Code preserving the decrypt ACL's CodexBar entry but removing CodexBar's Team ID + from the separate partition ACL. Decrypt-ACL preflight alone cannot establish partition authorization; repeated + manual grants therefore need not survive the next Claude Code refresh. - If Preferences → Advanced → Disable Keychain access is enabled, this policy remains visible but inactive until Keychain access is re-enabled. @@ -122,6 +125,7 @@ the cookie import. - OAuth refresh form-encodes credential values, preserving literal plus signs and other reserved characters. - Expiry values outside the diagnostic integer range are reported as `out_of_range` without changing credential expiry or refresh decisions. - Credentials: + - Explicit OAuth environment override, when configured. - CodexBar OAuth cache when available. - File fallback: `~/.claude/.credentials.json`. - Claude CLI Keychain bootstrap/repair fallback: `Claude Code-credentials`. @@ -131,8 +135,14 @@ the cookie import. - If CodexBar's cache is temporarily unavailable, automatic refreshes can reuse an unexpired credential already in memory beyond the normal 30-minute cache window, ahead of a stale credentials file. Each refresh retries the persistent cache. Token expiry, profile changes, cache invalidation, and Never prompt still prevent reuse; - pending invalidation after a rejected cache write remains a separate recovery limitation. + after a rejected cache write, the next refresh first clears the stale persistent entry, then reuses and persists + a still-fresh in-memory credential once that cleanup succeeds. - For the default CLI profile, expired cached or file credentials can adopt a fresh CLI Keychain token after file fallback, even when its fingerprint was already observed during an earlier repair. Existing direct-read consent, prompt policy, cooldown, one-minute freshness-check throttle, and noninteractive-read checks still apply. Custom profiles are not recovered from the unscoped global item, and CLI credentials are never rewritten by this synchronization. Background recovery still requires the Always allow prompts policy; the default Only on user action policy requires an explicit Refresh. +- Credential selection does not rank unrelated sources by the largest `expiresAt`: expiry establishes validity, + not account identity or issuance order. A valid profile file remains ahead of Keychain bootstrap. Keychain candidates + are ordered by modification date (creation date as fallback); freshness sync reads only that newest item and never + rewrites Claude Code's credentials file. An expired default-profile record can be replaced even when the stored + Keychain fingerprint already matches, subject to the access gates above. - On Claude Code 2.1.x, `Claude Code-credentials` may contain only MCP server OAuth state (`mcpOAuth`) with no `claudeAiOauth`. CodexBar treats that as an OAuth configuration error, does not run background delegated `claude /status` refresh, and surfaces re-auth guidance. Use Web or CLI usage source, or restore a valid Claude OAuth keychain entry. See #1844. - Requires `user:profile` scope (CLI tokens with only `user:inference` cannot call usage). - Missing-scope errors require a Claude Code sign-in token with usage access. `claude setup-token` produces a token for model requests and is not a usage-scope recovery step ([Claude Code authentication](https://code.claude.com/docs/en/authentication#generate-a-long-lived-token)). Remove any configured OAuth token override before switching Claude Source to Web/CLI. diff --git a/docs/keychain-prompts.md b/docs/keychain-prompts.md index daf29be7bd..30af0371eb 100644 --- a/docs/keychain-prompts.md +++ b/docs/keychain-prompts.md @@ -66,6 +66,9 @@ When fresh cache data becomes available, CodexBar can delete and recreate its ow replacement is attempted at most once per cooldown; a failed retry starts another cooldown even if the old item is already gone. Successful replacement clears the rejection immediately, including when another first-party process wins the add race. Cache clearing honors an existing repair cooldown and uses no-UI deletion without requiring decrypt access. +Signature validation during preflight has a bounded wait. If macOS stalls inside validation, preflight returns an +inconclusive result so the caller can release its cache locks and the refresh cycle can finish. Timed-out validations +retain their worker slots until they actually return; retries cannot create an unlimited queue of blocked workers. Foreign items are never recreated this way. A direct delete that is only temporarily unavailable stays retryable; it does not establish a stale ACL. A temporarily locked Keychain or an incomplete ACL preflight also remains retryable sooner and is not replaced. From 62acfd8ead86a3ae443ec2d76c30c93ec72e09b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 00:34:51 -0700 Subject: [PATCH 042/122] fix(antigravity): group OAuth quotas and parse Starter weekly quotas (#4084) Antigravity: repair grouped model-family quotas on the OAuth path to match the agy CLI grouping, and parse weekly-only Starter (free-tier) quota fixtures with explicit cadence through the shared parser. Refs #2427 #3789. --- CHANGELOG.md | 1 + .../AntigravityProviderDescriptor.swift | 2 +- .../AntigravityQuotaSummaryParser.swift | 77 ++++----- .../AntigravityRemoteUsageFetcher.swift | 80 ++++----- .../Antigravity/AntigravityStatusProbe.swift | 35 ++-- ...tigravityLocalSnapshotSelectionTests.swift | 4 +- .../AntigravityQuotaHistoryTests.swift | 8 +- .../AntigravityQuotaSourceParityTests.swift | 156 ++++++++++++++++++ .../AntigravityStatusProbeTests.swift | 46 +----- .../ProviderArchitectureGatekeeperTests.swift | 10 -- docs/antigravity.md | 20 ++- 11 files changed, 274 insertions(+), 165 deletions(-) create mode 100644 Tests/CodexBarTests/AntigravityQuotaSourceParityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2110fcecdf..42c8035006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Claude: retain valid in-memory credentials after a rejected OAuth cache write once stale-cache cleanup succeeds, so the next automatic refresh can recover without another manual Refresh (#3395). Thanks @lozcalver! - Keychain: bound stalled code-signature validation so it cannot hold cache locks and freeze all provider refreshes indefinitely (#3249). Thanks @SilentKnight87! +- Antigravity: preserve grouped OAuth quotas, including weekly-only Starter allowances, and honor explicit quota-window cadence using the shared CLI parser (#2427, #3789). - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index 581e12368a..0815f3490c 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -885,7 +885,7 @@ struct AntigravityOAuthFetchStrategy: ProviderFetchStrategy { from snapshot: AntigravityStatusSnapshot, updatedAt: Date = Date()) throws -> UsageSnapshot { - if snapshot.modelQuotas.isEmpty { + if snapshot.modelQuotas.isEmpty, snapshot.quotaSummary == nil { return UsageSnapshot( primary: nil, secondary: nil, diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityQuotaSummaryParser.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityQuotaSummaryParser.swift index 26b8609a25..eb83ac4b12 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityQuotaSummaryParser.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityQuotaSummaryParser.swift @@ -21,34 +21,12 @@ struct AntigravityQuotaSummaryBucket: Sendable, Equatable { let resetTime: Date? let resetDescription: String? let disabled: Bool - - init( - bucketId: String, - displayName: String, - remainingFraction: Double?, - resetTime: Date? = nil, - resetDescription: String?, - disabled: Bool) - { - self.bucketId = bucketId - self.displayName = displayName - self.remainingFraction = remainingFraction - self.resetTime = resetTime - self.resetDescription = resetDescription - self.disabled = disabled - } + let window: String? } extension AntigravityStatusProbe { static func parseQuotaSummaryResponse(_ data: Data) throws -> AntigravityStatusSnapshot { - let response = try JSONDecoder().decode(QuotaSummaryResponse.self, from: data) - if let invalid = Self.invalidCode(response.code) { - throw AntigravityStatusProbeError.apiError(invalid) - } - guard let payload = response.response ?? response.summary ?? response.rootPayload else { - throw AntigravityStatusProbeError.parseFailed("Missing quota summary") - } - return try Self.quotaSummarySnapshot(payload) + try JSONDecoder().decode(AntigravityQuotaSummaryResponse.self, from: data).snapshot() } static func parseCLIUsageReport(_ data: Data) throws -> AntigravityStatusSnapshot { @@ -59,24 +37,27 @@ extension AntigravityStatusProbe { throw AntigravityStatusProbeError.parseFailed("Unsuccessful CLI usage report") } let snapshot = try Self.quotaSummarySnapshot(report.command.data) - guard snapshot.quotaSummary?.groups.contains(where: { group in - group.buckets.contains { !$0.disabled && $0.remainingFraction != nil } - }) == true else { + guard snapshot.hasKnownQuotaSummary else { throw AntigravityStatusProbeError.parseFailed("CLI usage report has no known quota") } return snapshot } - private static func quotaSummarySnapshot(_ payload: QuotaSummaryPayload) throws -> AntigravityStatusSnapshot { + fileprivate static func quotaSummarySnapshot( + _ payload: QuotaSummaryPayload, + accountEmail: String? = nil, + accountPlan: String? = nil, + source: AntigravityModelQuotaSource = .local) throws -> AntigravityStatusSnapshot + { let groups = (payload.groups ?? []).compactMap(self.quotaSummaryGroup(from:)) guard !groups.isEmpty else { throw AntigravityStatusProbeError.parseFailed("Missing quota groups") } return AntigravityStatusSnapshot( quotaSummary: AntigravityQuotaSummary(description: payload.description, groups: groups), - accountEmail: nil, - accountPlan: nil, - source: .local) + accountEmail: accountEmail, + accountPlan: accountPlan, + source: source) } private static func quotaSummaryGroup(from payload: QuotaSummaryGroupPayload) -> AntigravityQuotaSummaryGroup? { @@ -100,7 +81,8 @@ extension AntigravityStatusProbe { remainingFraction: payload.remainingFraction ?? payload.remaining?.remainingFraction, resetTime: resetTime, resetDescription: payload.description, - disabled: payload.disabled ?? false) + disabled: payload.disabled ?? false, + window: payload.window) } private static func nonEmpty(_ value: String?) -> String? { @@ -119,15 +101,27 @@ private struct QuotaSummaryCLIReport: Decodable { } } -private struct QuotaSummaryResponse: Decodable { - let code: CodeValue? - let response: QuotaSummaryPayload? - let summary: QuotaSummaryPayload? - let description: String? - let groups: [QuotaSummaryGroupPayload]? - - var rootPayload: QuotaSummaryPayload? { - self.groups.map { QuotaSummaryPayload(description: self.description, groups: $0) } +struct AntigravityQuotaSummaryResponse: Decodable { + private let code: CodeValue? + private let response: QuotaSummaryPayload? + private let summary: QuotaSummaryPayload? + private let description: String? + private let groups: [QuotaSummaryGroupPayload]? + + func snapshot( + accountEmail: String? = nil, + accountPlan: String? = nil, + source: AntigravityModelQuotaSource = .local) throws -> AntigravityStatusSnapshot + { + if let invalid = AntigravityStatusProbe.invalidCode(self.code) { + throw AntigravityStatusProbeError.apiError(invalid) + } + let root = self.groups.map { QuotaSummaryPayload(description: self.description, groups: $0) } + guard let payload = self.response ?? self.summary ?? root else { + throw AntigravityStatusProbeError.parseFailed("Missing quota summary") + } + return try AntigravityStatusProbe.quotaSummarySnapshot( + payload, accountEmail: accountEmail, accountPlan: accountPlan, source: source) } } @@ -153,6 +147,7 @@ private struct QuotaSummaryBucketPayload: Decodable { let remainingFraction: Double? let remaining: QuotaSummaryRemainingPayload? let resetTime: String? + let window: String? } private struct QuotaSummaryRemainingPayload: Decodable { diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift index 1e8272323b..b33b31f27f 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift @@ -36,8 +36,6 @@ public struct AntigravityRemoteUsageFetcher: Sendable { private static let baseURL = "https://cloudcode-pa.googleapis.com" private static let loadCodeAssistEndpoint = "\(baseURL)/v1internal:loadCodeAssist" private static let onboardUserEndpoint = "\(baseURL)/v1internal:onboardUser" - private static let fetchAvailableModelsEndpoint = "\(baseURL)/v1internal:fetchAvailableModels" - private static let retrieveUserQuotaEndpoint = "\(baseURL)/v1internal:retrieveUserQuota" private static let refreshSafetyWindow: TimeInterval = 60 private struct FetchContext { @@ -137,6 +135,25 @@ public struct AntigravityRemoteUsageFetcher: Sendable { Self.log.warning("Could not persist Antigravity project ID: \(error.localizedDescription)") } } + let plan = Self.resolvePlan(response: codeAssist, claims: claims) + do { + let response: AntigravityQuotaSummaryResponse = try await Self.fetchQuotaResponse( + method: "retrieveUserQuotaSummary", + accessToken: accessToken, + projectId: projectId, + timeout: min(self.timeout, 2), + dataLoader: self.dataLoader) + try Task.checkCancellation() + let summary = try response.snapshot(accountEmail: claims.email, accountPlan: plan, source: .remote) + if summary.hasKnownQuotaSummary { return summary } + } catch { + if error is CancellationError || (error as? URLError)?.code == .cancelled || + (error as? AntigravityRemoteFetchError) == .notLoggedIn + { + throw error + } + try Task.checkCancellation() + } let models = try await Self.fetchModelQuotas( accessToken: accessToken, projectId: projectId, @@ -146,7 +163,7 @@ public struct AntigravityRemoteUsageFetcher: Sendable { return AntigravityStatusSnapshot( modelQuotas: models, accountEmail: claims.email, - accountPlan: Self.resolvePlan(response: codeAssist, claims: claims), + accountPlan: plan, source: .remote) } @@ -176,12 +193,13 @@ public struct AntigravityRemoteUsageFetcher: Sendable { dataLoader: dataLoader) } - private static func fetchAvailableModels( + private static func fetchQuotaResponse( + method: String, accessToken: String, projectId: String?, timeout: TimeInterval, dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws - -> FetchAvailableModelsResponse + -> Response { let body: [String: Any] = if let projectId = projectId?.trimmedNonEmpty { ["project": projectId] @@ -189,7 +207,7 @@ public struct AntigravityRemoteUsageFetcher: Sendable { [:] } return try await Self.sendRequest( - endpoint: Self.fetchAvailableModelsEndpoint, + endpoint: "\(Self.baseURL)/v1internal:\(method)", accessToken: accessToken, body: body, timeout: timeout, @@ -204,7 +222,8 @@ public struct AntigravityRemoteUsageFetcher: Sendable { -> [AntigravityModelQuota] { do { - let response = try await Self.fetchAvailableModels( + let response: FetchAvailableModelsResponse = try await Self.fetchQuotaResponse( + method: "fetchAvailableModels", accessToken: accessToken, projectId: projectId, timeout: timeout, @@ -216,7 +235,7 @@ public struct AntigravityRemoteUsageFetcher: Sendable { projectId: projectId, timeout: timeout, dataLoader: dataLoader) - guard let quotaBuckets, Self.hasQuotaFractionData(quotaBuckets) else { + guard let quotaBuckets, quotaBuckets.contains(where: { $0.remainingFraction != nil }) else { return [] } return Self.mergeVerifiedQuotas(modelQuotas: modelQuotas, verifiedQuotas: quotaBuckets) @@ -274,12 +293,6 @@ public struct AntigravityRemoteUsageFetcher: Sendable { } } - private static func hasQuotaFractionData(_ quotas: [AntigravityModelQuota]) -> Bool { - quotas.contains { quota in - quota.remainingFraction != nil - } - } - private static func fetchQuotaBucketsIfPermitted( accessToken: String, projectId: String?, @@ -288,7 +301,8 @@ public struct AntigravityRemoteUsageFetcher: Sendable { -> [AntigravityModelQuota]? { do { - let response = try await Self.retrieveUserQuota( + let response: RetrieveUserQuotaResponse = try await Self.fetchQuotaResponse( + method: "retrieveUserQuota", accessToken: accessToken, projectId: projectId, timeout: timeout, @@ -303,26 +317,6 @@ public struct AntigravityRemoteUsageFetcher: Sendable { } } - private static func retrieveUserQuota( - accessToken: String, - projectId: String?, - timeout: TimeInterval, - dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws - -> RetrieveUserQuotaResponse - { - let body: [String: Any] = if let projectId = projectId?.trimmedNonEmpty { - ["project": projectId] - } else { - [:] - } - return try await Self.sendRequest( - endpoint: Self.retrieveUserQuotaEndpoint, - accessToken: accessToken, - body: body, - timeout: timeout, - dataLoader: dataLoader) - } - private static func resolveProjectID( accessToken: String, storedProjectID: String?, @@ -451,19 +445,15 @@ public struct AntigravityRemoteUsageFetcher: Sendable { for bucket in buckets { guard let modelID = bucket.modelId?.trimmedNonEmpty else { continue } let next = (bucket.remainingFraction, bucket.resetTime) - if let existing = modelQuotaMap[modelID] { - let existingValue = existing.fraction ?? Double.greatestFiniteMagnitude - let nextValue = next.0 ?? Double.greatestFiniteMagnitude - if nextValue < existingValue { - modelQuotaMap[modelID] = next - } - } else { - modelQuotaMap[modelID] = next + if let existing = modelQuotaMap[modelID], + (existing.fraction ?? .greatestFiniteMagnitude) <= (next.0 ?? .greatestFiniteMagnitude) + { + continue } + modelQuotaMap[modelID] = next } - return modelQuotaMap.keys.sorted().compactMap { modelID in - guard let info = modelQuotaMap[modelID] else { return nil } + return modelQuotaMap.sorted { $0.key < $1.key }.map { modelID, info in let resetTime = ISO8601DateParser.parse(info.resetTime) return AntigravityModelQuota( label: modelID, diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 7aa5386ecf..9a6a8e6333 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -90,6 +90,12 @@ public struct AntigravityStatusSnapshot: Sendable { public let source: AntigravityModelQuotaSource let quotaSummary: AntigravityQuotaSummary? + var hasKnownQuotaSummary: Bool { + self.quotaSummary?.groups.contains { group in + group.buckets.contains { !$0.disabled && $0.remainingFraction != nil } + } == true + } + public init( modelQuotas: [AntigravityModelQuota], accountEmail: String?, @@ -128,7 +134,7 @@ public struct AntigravityStatusSnapshot: Sendable { throw AntigravityStatusProbeError.parseFailed("No quota models available") } - let normalized = Self.normalizedModels(self.modelQuotas) + let normalized = self.modelQuotas.map(Self.normalizeModel) let summaryCandidates = normalized.filter(Self.isSummaryCandidate) let primaryQuota = Self.representative(for: .geminiAI, in: summaryCandidates) let secondaryQuota = Self.representative(for: .claudeGPT, in: summaryCandidates) @@ -329,14 +335,11 @@ public struct AntigravityStatusSnapshot: Sendable { } private static func quotaGroupSortRank(_ group: AntigravityQuotaSummaryGroup) -> Int { - let title = group.displayName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if title.contains("gemini") { - return 0 + switch self.displayTitle(forQuotaGroup: group) { + case "Gemini": 0 + case "Claude/GPT": 1 + default: 2 } - if title.contains("claude") || title.contains("gpt") { - return 1 - } - return 2 } private static func quotaBucketSortRank(_ bucket: AntigravityQuotaSummaryBucket) -> Int { @@ -370,8 +373,14 @@ public struct AntigravityStatusSnapshot: Sendable { ] private static func quotaCadenceCandidates(for bucket: AntigravityQuotaSummaryBucket) -> Set { + let explicit = bucket.window?.trimmingCharacters(in: .whitespacesAndNewlines) + let values = if let explicit, !explicit.isEmpty { + [explicit] + } else { + [bucket.bucketId, bucket.displayName] + } var candidates: Set = [] - for rawValue in [bucket.bucketId, bucket.displayName] { + for rawValue in values { let normalized = rawValue .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() @@ -518,10 +527,6 @@ public struct AntigravityStatusSnapshot: Sendable { !model.isLite && !model.isAutocomplete && !model.isImage } - private static func normalizedModels(_ models: [AntigravityModelQuota]) -> [AntigravityNormalizedModel] { - models.map { self.normalizeModel($0) } - } - private static func normalizeModel(_ quota: AntigravityModelQuota) -> AntigravityNormalizedModel { let canonicalQuota: AntigravityModelQuota = { let canonicalId = Self.canonicalModelID(quota.modelId) @@ -1583,9 +1588,7 @@ public struct AntigravityStatusProbe: Sendable { context: self.quotaSummaryRequestContext(from: context), send: send, parse: self.parseQuotaSummaryResponse) - guard quotaSummary.quotaSummary?.groups.contains(where: { group in - group.buckets.contains { !$0.disabled && $0.remainingFraction != nil } - }) == true else { + guard quotaSummary.hasKnownQuotaSummary else { throw AntigravityStatusProbeError.parseFailed("Quota summary has no usable quota buckets") } let identity = try? await self.makeParsedRequest( diff --git a/Tests/CodexBarTests/AntigravityLocalSnapshotSelectionTests.swift b/Tests/CodexBarTests/AntigravityLocalSnapshotSelectionTests.swift index 414d26bf82..91f3c574db 100644 --- a/Tests/CodexBarTests/AntigravityLocalSnapshotSelectionTests.swift +++ b/Tests/CodexBarTests/AntigravityLocalSnapshotSelectionTests.swift @@ -28,8 +28,10 @@ struct AntigravityLocalSnapshotSelectionTests { bucketId: "gemini-5h", displayName: "Five Hour Limit", remainingFraction: 0.9, + resetTime: nil, resetDescription: nil, - disabled: false), + disabled: false, + window: nil), ]), ]), accountEmail: "other@example.com", diff --git a/Tests/CodexBarTests/AntigravityQuotaHistoryTests.swift b/Tests/CodexBarTests/AntigravityQuotaHistoryTests.swift index e79af35ee2..a46e20cee9 100644 --- a/Tests/CodexBarTests/AntigravityQuotaHistoryTests.swift +++ b/Tests/CodexBarTests/AntigravityQuotaHistoryTests.swift @@ -75,16 +75,20 @@ struct AntigravityQuotaHistoryTests { bucketId: "gemini-custom", displayName: cadence, remainingFraction: 0.25, + resetTime: nil, resetDescription: nil, - disabled: false), + disabled: false, + window: nil), ]), AntigravityQuotaSummaryGroup(displayName: "Claude and GPT", description: nil, buckets: [ AntigravityQuotaSummaryBucket( bucketId: "claude-custom", displayName: cadence, remainingFraction: 0.5, + resetTime: nil, resetDescription: nil, - disabled: false), + disabled: false, + window: nil), ]), ]), accountEmail: nil, diff --git a/Tests/CodexBarTests/AntigravityQuotaSourceParityTests.swift b/Tests/CodexBarTests/AntigravityQuotaSourceParityTests.swift new file mode 100644 index 0000000000..4e3f7ceaa2 --- /dev/null +++ b/Tests/CodexBarTests/AntigravityQuotaSourceParityTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AntigravityQuotaSourceParityTests { + /// #2427 supplies the grouped response; #3789 supplies the weekly-only Starter values. + private static func summary(starter: Bool) -> [String: Any] { + let families = [("Gemini Models", "gemini"), ("Claude and GPT models", "3p")] + let groups: [[String: Any]] = families.map { title, id in + var buckets: [[String: Any]] = [[ + "bucketId": "\(id)-weekly", "displayName": "Weekly Limit", "window": "weekly", + "remainingFraction": starter ? 1.0 : 0.958, + ]] + if !starter { + buckets.append([ + "bucketId": "\(id)-5h", "displayName": "Five Hour Limit", "window": "5h", + "remainingFraction": 0.749, "resetTime": "2026-07-23T17:05:10Z", + ]) + } + return ["displayName": title, "buckets": buckets] + } + return ["groups": groups] + } + + @Test(arguments: [true, false]) + func `OAuth preserves the same grouped quotas as local and print sources`(starter: Bool) async throws { + let credentials = AntigravityOAuthCredentials( + accessToken: "synthetic-token", + refreshToken: nil, + expiryDate: Date().addingTimeInterval(3600), + idToken: nil, + email: "quota@example.com", + projectID: "synthetic-project") + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) + let summaryData = try JSONSerialization.data(withJSONObject: Self.summary(starter: starter)) + let fetcher = AntigravityRemoteUsageFetcher( + homeDirectory: "/synthetic-antigravity-home", + environment: [AntigravityOAuthCredentialsStore.environmentCredentialsKey: token], + dataLoader: GeminiAPITestHelpers.dataLoader { request in + let url = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-token") + let body: Data + switch url.path { + case "/v1internal:loadCodeAssist": + body = GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": starter ? "free-tier" : "standard-tier"], + ]) + case "/v1internal:retrieveUserQuotaSummary": + let posted = try #require(request.httpBody) + let project = try JSONSerialization.jsonObject(with: posted) as? [String: String] + #expect(project?["project"] == "synthetic-project") + body = summaryData + default: + // The old model endpoint loses all weekly/5h cadence information. + body = GeminiAPITestHelpers.jsonData(["models": [ + "gemini-2.5-pro": ["quotaInfo": ["remainingFraction": 0.749]], + ]]) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: body) + }) + let remote = try await fetcher.fetch() + let local = try AntigravityStatusProbe.parseQuotaSummaryResponse(summaryData) + let report = try JSONSerialization.data(withJSONObject: [ + "status": "SUCCESS", "command": ["name": "usage", "data": Self.summary(starter: starter)], + ]) + let cli = try AntigravityStatusProbe.parseCLIUsageReport(report) + let localUsage = try local.toUsageSnapshot() + let cliUsage = try cli.toUsageSnapshot() + let usage = try AntigravityOAuthFetchStrategy.usageSnapshot(from: remote) + let windows = try #require(usage.extraRateWindows) + #expect(windows.count == (starter ? 2 : 4)) + #expect(windows == localUsage.extraRateWindows) + #expect(windows == cliUsage.extraRateWindows) + #expect(remote.source == .remote) + #expect(usage.identity?.accountEmail == "quota@example.com") + #expect(usage.identity?.loginMethod == (starter ? "Free" : "Paid")) + #expect(usage.secondary != nil) + if starter { + #expect(windows.map(\.window.windowMinutes) == [10080, 10080]) + #expect(windows.map(\.window.remainingPercent) == [100, 100]) + #expect(AntigravityQuotaFamilyVisibility.idleWindowIDs(in: usage).isEmpty) + } + } + + @Test(arguments: ["weekly", "5h"]) + func `summary honors explicit cadence for opaque bucket IDs`(cadence: String) throws { + let data = try JSONSerialization.data(withJSONObject: ["groups": [[ + "displayName": "Gemini Models", "buckets": [[ + "bucketId": "gemini-allowance", "displayName": "Limit Remaining", + "window": cadence, "remainingFraction": 1, + ]], + ]]]) + let usage = try AntigravityStatusProbe.parseQuotaSummaryResponse(data).toUsageSnapshot() + let window = try #require(usage.extraRateWindows?.first) + #expect(window.id == "antigravity-quota-summary-gemini-allowance") + #expect(window.title == (cadence == "weekly" ? "Gemini weekly" : "Gemini 5-hour")) + #expect(window.window.windowMinutes == (cadence == "weekly" ? 10080 : 300)) + } + + @Test(arguments: ["weekly", "unknown"]) + func `explicit cadence takes precedence over legacy bucket names`(cadence: String) throws { + let data = Data(""" + {"groups":[{"displayName":"Gemini Models","buckets":[{ + "bucketId":"gemini-5h","displayName":"Five Hour Limit", + "window":"\(cadence)","remainingFraction":0.8 + }]}]} + """.utf8) + let usage = try AntigravityStatusProbe.parseQuotaSummaryResponse(data).toUsageSnapshot() + #expect(usage.extraRateWindows?.first?.window.windowMinutes == (cadence == "weekly" ? 10080 : nil)) + } + + @Test(arguments: [200, 401, 403, 404, 500, -1]) + func `summary fallback preserves authentication and cancellation errors`(statusCode: Int) async throws { + let credentials = AntigravityOAuthCredentials( + accessToken: "synthetic-token", + refreshToken: nil, + expiryDate: nil, + email: "quota@example.com", + projectID: "synthetic-project") + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) + let fetcher = AntigravityRemoteUsageFetcher( + homeDirectory: "/synthetic-antigravity-home", + environment: [AntigravityOAuthCredentialsStore.environmentCredentialsKey: token], + dataLoader: GeminiAPITestHelpers.dataLoader { request in + let url = try #require(request.url) + switch url.path { + case "/v1internal:loadCodeAssist": + return GeminiAPITestHelpers.response( + url: url.absoluteString, status: 200, body: Data("{}".utf8)) + case "/v1internal:retrieveUserQuotaSummary": + #expect(request.timeoutInterval <= 2) + if statusCode == -1 { throw CancellationError() } + return GeminiAPITestHelpers.response( + url: url.absoluteString, status: statusCode, body: Data(#"{"buckets":[]}"#.utf8)) + default: + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["models": [ + "gemini-2.5-pro": ["quotaInfo": ["remainingFraction": 0.5]], + ]])) + } + }) + if statusCode == 401 { + await #expect(throws: AntigravityRemoteFetchError.notLoggedIn) { try await fetcher.fetch() } + } else if statusCode == -1 { + await #expect(throws: CancellationError.self) { try await fetcher.fetch() } + } else { + let snapshot = try await fetcher.fetch() + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary?.remainingPercent == 50) + #expect(usage.identity?.accountEmail == "quota@example.com") + #expect(snapshot.quotaSummary == nil) + } + } +} diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index 5182cef097..4dc09426d2 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -206,7 +206,7 @@ struct AntigravityStatusProbeTests { } @Test - func `local snapshot score prefers quota summary over legacy model quotas`() { + func `local snapshot score prefers quota summary over legacy model quotas`() throws { let legacy = AntigravityStatusSnapshot( modelQuotas: [ AntigravityModelQuota( @@ -225,48 +225,8 @@ struct AntigravityStatusProbeTests { accountEmail: "user@example.com", accountPlan: "Pro", source: .local) - let summary = AntigravityStatusSnapshot( - quotaSummary: AntigravityQuotaSummary( - description: nil, - groups: [ - AntigravityQuotaSummaryGroup( - displayName: "Gemini Models", - description: nil, - buckets: [ - AntigravityQuotaSummaryBucket( - bucketId: "gemini-5h", - displayName: "Five Hour Limit", - remainingFraction: 0.9, - resetDescription: nil, - disabled: false), - AntigravityQuotaSummaryBucket( - bucketId: "gemini-weekly", - displayName: "Weekly Limit", - remainingFraction: 0.8, - resetDescription: nil, - disabled: false), - ]), - AntigravityQuotaSummaryGroup( - displayName: "Claude and GPT models", - description: nil, - buckets: [ - AntigravityQuotaSummaryBucket( - bucketId: "3p-5h", - displayName: "Five Hour Limit", - remainingFraction: 0.7, - resetDescription: nil, - disabled: false), - AntigravityQuotaSummaryBucket( - bucketId: "3p-weekly", - displayName: "Weekly Limit", - remainingFraction: 0.6, - resetDescription: nil, - disabled: false), - ]), - ]), - accountEmail: "user@example.com", - accountPlan: "Pro", - source: .local) + let summary = try AntigravityStatusProbe.parseQuotaSummaryResponse(Data(antigravityQuotaSummaryJSON().utf8)) + .withIdentity(from: legacy) #expect(AntigravityStatusProbe.localSnapshotScore(summary) > AntigravityStatusProbe.localSnapshotScore(legacy)) } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 6f85ab46e4..02e0a6d600 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1688,16 +1688,6 @@ struct ProviderArchitectureGatekeeperTests { anchor: "if lowercasedTitle.contains(\"claude\") || lowercasedTitle.contains(\"gpt\") {", expectedProviderIDs: ["claude"], reason: "Antigravity quota titles use this token to identify a model family for display."), - SuppressedProviderReference( - path: "Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift", - anchor: "if title.contains(\"gemini\") {", - expectedProviderIDs: ["gemini"], - reason: "Antigravity quota titles use this token to rank a model family."), - SuppressedProviderReference( - path: "Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift", - anchor: "if title.contains(\"claude\") || title.contains(\"gpt\") {", - expectedProviderIDs: ["claude"], - reason: "Antigravity quota titles use this token to rank a model family."), SuppressedProviderReference( path: "Sources/CodexBarCore/Providers/AzureOpenAI/AzureOpenAIUsageFetcher.swift", anchor: "let base = self.apiRoot(endpoint: endpoint, pathComponents: [\"openai\", \"v1\"])", diff --git a/docs/antigravity.md b/docs/antigravity.md index c148eee0a5..d575e6f79a 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -74,12 +74,18 @@ can take a few extra seconds while CodexBar waits for readiness; later refreshes The local and CLI paths both prefer Antigravity's internal `RetrieveUserQuotaSummary` quota payload and may fall back to `GetUserStatus`, then `GetCommandModelConfigs`; CodexBar never scrapes the desktop UI or the `agy` TUI. -As of Antigravity 2.x, the Antigravity app and `agy` CLI payloads can be richer than Google OAuth and IDE payloads. -`RetrieveUserQuotaSummary` exposes the same two groups shown by Antigravity's Model Quota UI: +The Antigravity app, `agy` CLI, and Google OAuth paths prefer quota summaries, using the same parser for +the two groups shown by Antigravity's Model Quota UI: - `Gemini Models`: weekly limit and five-hour limit. - `Claude and GPT models`: weekly limit and five-hour limit. +Starter accounts can supply only weekly limits. Both weekly groups remain visible when untouched, without +inventing five-hour allowances. OAuth first tries `retrieveUserQuotaSummary` with the selected account's +project and a two-second timeout cap. Unavailable, legacy model-bucket, or unmeasured summary responses fall +back to the existing model endpoints; authentication failures and cancellation still propagate. Grouped OAuth +quotas retain that account's existing email and plan, without an additional identity request. + Older local payloads may only include raw Claude, GPT-OSS, Gemini tiers, account plan, and session reset timestamps. Current Antigravity IDE local endpoints return `GetUserStatus`, `GetAvailableModels`, and `GetCascadeModelConfigData` with five-hour/session reset data, but not the app/CLI `RetrieveUserQuotaSummary` weekly/session grouping. OAuth @@ -120,8 +126,8 @@ be polled within the readiness deadline. - `POST https://cloudcode-pa.googleapis.com/v1internal:onboardUser` - `POST https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` - `POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota` -- `POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary` (available, but current observed OAuth - responses are model-bucket shaped rather than Antigravity 2.0's two quota groups) +- `POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary` (preferred when it returns measured + groups; older model-bucket responses use the model-endpoint fallback) ## Data sources + fallback order @@ -265,7 +271,8 @@ shared OAuth file can still be used as a fallback credential source. - `userStatus.cascadeModelConfigData.clientModelConfigs[].quotaInfo.resetTime` - Preferred quota summary UI: - Render `Gemini Session`, `Gemini Weekly`, `Claude + GPT Session`, and `Claude + GPT Weekly` as named windows. - - Keep Antigravity's bucket description as reset prose; infer `windowMinutes` from the bucket ID/display name. + - Keep Antigravity's bucket description as reset prose; use its explicit `window` cadence, falling back to the + bucket ID/display name only when the cadence is absent. - Use the most constrained known bucket as the compact/menu-bar metric. - Legacy user-facing quota groups: - `Gemini` groups Gemini Pro and Gemini Flash text models. @@ -278,7 +285,8 @@ shared OAuth file can still be used as a fallback credential source. - `resetTime` parsing: - ISO-8601 preferred; numeric epoch seconds as fallback. - Identity: - - `accountEmail` and `planName` only from `GetUserStatus`. + - Local HTTPS merges email and plan from the same server's `GetUserStatus`; print reports supply neither. + - OAuth retains the selected account's existing email claims and `loadCodeAssist` plan when parsing grouped quotas. ## UI mapping - Provider metadata: From 6dcac3df4b1b8f077ab4d3704f711797ec364e31 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 00:35:11 -0700 Subject: [PATCH 043/122] fix(kimi): clarify stale CLI credential recovery (#4086) Kimi CLI refresh tokens rotate and belong to the CLI, so CodexBar keeps CLI authentication read-only; when the CLI access token expires after the CLI quits, the error now says to run kimi or add a Kimi Code API key for unattended use, and configured web/API sources still take over. Fixes #4063. Thanks @kid0114! --- CHANGELOG.md | 1 + .../Providers/Kimi/KimiAPIError.swift | 9 +- Tests/CodexBarTests/KimiAPIErrorTests.swift | 28 ++++ .../KimiCLICredentialLifecycleTests.swift | 124 ++++++++++++++++++ Tests/CodexBarTests/KimiProviderTests.swift | 31 ++--- docs/kimi.md | 12 +- 6 files changed, 175 insertions(+), 30 deletions(-) create mode 100644 Tests/CodexBarTests/KimiAPIErrorTests.swift create mode 100644 Tests/CodexBarTests/KimiCLICredentialLifecycleTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c8035006..9a4bb3684e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Keychain: bound stalled code-signature validation so it cannot hold cache locks and freeze all provider refreshes indefinitely (#3249). Thanks @SilentKnight87! - Antigravity: preserve grouped OAuth quotas, including weekly-only Starter allowances, and honor explicit quota-window cadence using the shared CLI parser (#2427, #3789). +- Kimi: direct stale CLI sessions to run `kimi` or configure an API key in Settings, while retaining web fallback and leaving rotating CLI credentials read-only (#4063). Thanks @kid0114! - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiAPIError.swift b/Sources/CodexBarCore/Providers/Kimi/KimiAPIError.swift index 708f062a07..3555916411 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiAPIError.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiAPIError.swift @@ -30,12 +30,9 @@ public enum KimiAPIError: LocalizedError, Sendable, Equatable { "Kimi API error: \(message)" case let .parseFailed(message): "Failed to parse Kimi usage data: \(message)" - case .expiredCodeCredential: - "Kimi Code CLI credential is expired. Sign in again with Kimi Code CLI or set KIMI_CODE_API_KEY; " + - "CodexBar does not refresh CLI-owned credentials." - case .invalidCodeCredential: - "Kimi Code CLI credential is invalid or expired. Sign in again with Kimi Code CLI or set " + - "KIMI_CODE_API_KEY; CodexBar does not refresh CLI-owned credentials." + case .expiredCodeCredential, .invalidCodeCredential: + "Kimi Code CLI credential is invalid or expired. Run kimi to renew it, or add a Kimi Code API key in " + + "Settings > Providers > Kimi (KIMI_CODE_API_KEY). CodexBar does not refresh CLI-owned credentials." } } } diff --git a/Tests/CodexBarTests/KimiAPIErrorTests.swift b/Tests/CodexBarTests/KimiAPIErrorTests.swift new file mode 100644 index 0000000000..5432320796 --- /dev/null +++ b/Tests/CodexBarTests/KimiAPIErrorTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct KimiAPIErrorTests { + @Test(arguments: [KimiAPIError.expiredCodeCredential, .invalidCodeCredential]) + func `CLI credential errors explain renewal and app API key setup`(_ error: KimiAPIError) { + #expect(error.localizedDescription.contains("Run kimi")) + #expect(error.localizedDescription.contains("Settings > Providers > Kimi")) + #expect(error.localizedDescription.contains("KIMI_CODE_API_KEY")) + #expect(error.localizedDescription.contains("does not refresh")) + } + + @Test + func `error descriptions are helpful`() { + #expect(KimiAPIError.missingToken.errorDescription?.contains("missing") == true) + #expect(KimiAPIError.invalidToken.errorDescription?.contains("invalid") == true) + #expect(KimiAPIError.missingAPIKey.errorDescription?.contains("Settings > Providers > Kimi") == true) + #expect(KimiAPIError.missingAPIKey.errorDescription?.contains("KIMI_CODE_API_KEY") == true) + #expect(KimiAPIError.expiredCodeCredential.errorDescription?.contains("does not refresh") == true) + #expect(KimiAPIError.invalidCodeCredential.errorDescription?.contains("invalid") == true) + #expect(KimiAPIError.invalidAPIKey.errorDescription?.contains("API key") == true) + #expect(KimiAPIError.invalidRequest("Bad request").errorDescription?.contains("Bad request") == true) + #expect(KimiAPIError.networkError("Timeout").errorDescription?.contains("Timeout") == true) + #expect(KimiAPIError.apiError("HTTP 500").errorDescription?.contains("HTTP 500") == true) + #expect(KimiAPIError.parseFailed("Invalid JSON").errorDescription?.contains("Invalid JSON") == true) + } +} diff --git a/Tests/CodexBarTests/KimiCLICredentialLifecycleTests.swift b/Tests/CodexBarTests/KimiCLICredentialLifecycleTests.swift new file mode 100644 index 0000000000..c58cc6f773 --- /dev/null +++ b/Tests/CodexBarTests/KimiCLICredentialLifecycleTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct KimiCLICredentialLifecycleTests { + @Test + func `fifteen minute CLI credential becomes stale at fourteen minutes without modifying the file`() throws { + let home = try makeTemporaryKimiCodeHome() + defer { try? FileManager.default.removeItem(at: home) } + let issuedAt = Date(timeIntervalSince1970: 1_800_000_000) + let url = try writeKimiCodeCredential( + home: home, + accessToken: "synthetic-access", + expiresAt: issuedAt.addingTimeInterval(900).timeIntervalSince1970) + let original = try Data(contentsOf: url) + let environment = ["KIMI_CODE_HOME": home.path] + + #expect(KimiSettingsReader.kimiCodeAccessToken( + environment: environment, now: issuedAt.addingTimeInterval(839)) == "synthetic-access") + for seconds in [840.0, 900.0] { + #expect(KimiSettingsReader.kimiCodeAccessToken( + environment: environment, now: issuedAt.addingTimeInterval(seconds)) == nil) + } + #expect(try Data(contentsOf: url) == original) + } + + @Test + func `CLI only auto mode explains renewal and the app API key setting`() async throws { + let home = try makeTemporaryKimiCodeHome() + defer { try? FileManager.default.removeItem(at: home) } + let url = try writeKimiCodeCredential( + home: home, + accessToken: "synthetic-stale-access", + refreshToken: "synthetic-rotating-refresh", + expiresAt: Date().addingTimeInterval(30).timeIntervalSince1970) + let original = try Data(contentsOf: url) + let context = makeKimiFetchContext( + sourceMode: .auto, + environment: ["KIMI_CODE_HOME": home.path], + settings: .make(kimi: .init(cookieSource: .off, manualCookieHeader: nil))) + + let outcome = await KimiProviderDescriptor.descriptor.fetchPlan.fetchOutcome(context: context, provider: .kimi) + + guard case let .failure(error) = outcome.result else { + Issue.record("Expected stale CLI credential") + return + } + #expect(error as? KimiAPIError == .expiredCodeCredential) + #expect(error.localizedDescription.contains("Run kimi")) + #expect(error.localizedDescription.contains("Settings > Providers > Kimi")) + #expect(!error.localizedDescription.contains("synthetic-")) + #expect(outcome.attempts.map(\.strategyID) == ["kimi.api", "kimi.cli", "kimi.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, true, false]) + #expect(try Data(contentsOf: url) == original) + #expect(!FileManager.default.fileExists(atPath: home.appendingPathComponent("device_id").path)) + } + + @Test(arguments: [false, true]) + func `stale or rejected CLI credentials fall back to configured web auth without renewal`( + rejectedByServer: Bool) async throws + { + let home = try makeTemporaryKimiCodeHome() + defer { try? FileManager.default.removeItem(at: home) } + let url = try writeKimiCodeCredential( + home: home, + accessToken: "api-bad", + expiresAt: Date().addingTimeInterval(rejectedByServer ? 900 : -60).timeIntervalSince1970) + let original = try Data(contentsOf: url) + let transport = KimiOrderedCredentialTransport() + let pipeline = ProviderFetchPipeline { _ in + [ + KimiCLICredentialFetchStrategy(transport: transport, resolveWebAuthToken: { _ in nil }), + KimiWebFetchStrategy(fetchUsage: { token, _ in + #expect(token == "synthetic-web") + return KimiUsageSnapshot( + weekly: .init(limit: "100", used: "25", remaining: "75", resetTime: nil), + rateLimit: nil, + updatedAt: Date()) + }), + ] + } + let context = makeKimiFetchContext( + sourceMode: .auto, + environment: ["KIMI_CODE_HOME": home.path], + settings: .make(kimi: .init(cookieSource: .manual, manualCookieHeader: "kimi-auth=synthetic-web"))) + + let outcome = await pipeline.fetch(context: context, provider: .kimi) + let result = try outcome.result.get() + + #expect(result.sourceLabel == "Kimi web cookie") + #expect(result.usage.primary?.usedPercent == 25) + #expect(outcome.attempts.map(\.strategyID) == ["kimi.cli", "kimi.web"]) + #expect(outcome.attempts.first?.errorDescription?.contains("Run kimi") == true) + #expect(await transport.authorizationHeaders() == (rejectedByServer ? ["Bearer api-bad"] : [])) + #expect(try Data(contentsOf: url) == original) + } + + @Test + func `next fetch recovers when the CLI replaces its rotating credential`() async throws { + let home = try makeTemporaryKimiCodeHome() + defer { try? FileManager.default.removeItem(at: home) } + _ = try writeKimiCodeCredential(home: home, accessToken: "old-access", expiresAt: 1) + let transport = KimiOrderedCredentialTransport() + let strategy = KimiCLICredentialFetchStrategy(transport: transport) + let context = makeKimiFetchContext( + sourceMode: .auto, + environment: ["KIMI_CODE_HOME": home.path], + settings: .make(kimi: .init(cookieSource: .off, manualCookieHeader: nil))) + await #expect(throws: KimiAPIError.expiredCodeCredential) { try await strategy.fetch(context) } + + let url = try writeKimiCodeCredential( + home: home, + accessToken: "cli-ok", + refreshToken: "rotated-refresh", + expiresAt: Date().addingTimeInterval(900).timeIntervalSince1970) + let renewed = try Data(contentsOf: url) + let result = try await strategy.fetch(context) + + #expect(result.sourceLabel == "Kimi Code CLI") + #expect(result.usage.primary?.usedPercent == 25) + #expect(await transport.authorizationHeaders() == ["Bearer cli-ok"]) + #expect(try Data(contentsOf: url) == renewed) + } +} diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift index caf3f7356b..ef1d313a21 100644 --- a/Tests/CodexBarTests/KimiProviderTests.swift +++ b/Tests/CodexBarTests/KimiProviderTests.swift @@ -16,7 +16,7 @@ private struct KimiStubClaudeFetcher: ClaudeUsageFetching { } } -private func makeKimiFetchContext( +func makeKimiFetchContext( sourceMode: ProviderSourceMode, environment: [String: String] = [:], settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext @@ -36,7 +36,7 @@ private func makeKimiFetchContext( browserDetection: BrowserDetection(cacheTTL: 0)) } -private func makeTemporaryKimiCodeHome() throws -> URL { +func makeTemporaryKimiCodeHome() throws -> URL { let home = FileManager.default.temporaryDirectory .appendingPathComponent("CodexBar-KimiCode-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory( @@ -46,7 +46,7 @@ private func makeTemporaryKimiCodeHome() throws -> URL { return home } -private func writeKimiCodeCredential( +func writeKimiCodeCredential( home: URL, accessToken: String, refreshToken: String = "refresh", @@ -57,16 +57,20 @@ private func writeKimiCodeCredential( var payload: [String: Any] = [ "access_token": accessToken, "refresh_token": refreshToken, + "expires_in": 900, + "scope": "synthetic-scope", + "token_type": "Bearer", ] if let expiresAt { payload["expires_at"] = expiresAt } let url = credentials.appendingPathComponent("kimi-code.json") - try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]).write(to: url) + try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) + .write(to: url, options: .atomic) return url } -private actor KimiOrderedCredentialTransport: ProviderHTTPTransport { +actor KimiOrderedCredentialTransport: ProviderHTTPTransport { private var headers: [String] = [] func authorizationHeaders() -> [String] { @@ -1574,20 +1578,3 @@ struct KimiTokenResolverTests { } } } - -struct KimiAPIErrorTests { - @Test - func `error descriptions are helpful`() { - #expect(KimiAPIError.missingToken.errorDescription?.contains("missing") == true) - #expect(KimiAPIError.invalidToken.errorDescription?.contains("invalid") == true) - #expect(KimiAPIError.missingAPIKey.errorDescription?.contains("Settings > Providers > Kimi") == true) - #expect(KimiAPIError.missingAPIKey.errorDescription?.contains("KIMI_CODE_API_KEY") == true) - #expect(KimiAPIError.expiredCodeCredential.errorDescription?.contains("does not refresh") == true) - #expect(KimiAPIError.invalidCodeCredential.errorDescription?.contains("Sign in again") == true) - #expect(KimiAPIError.invalidAPIKey.errorDescription?.contains("API key") == true) - #expect(KimiAPIError.invalidRequest("Bad request").errorDescription?.contains("Bad request") == true) - #expect(KimiAPIError.networkError("Timeout").errorDescription?.contains("Timeout") == true) - #expect(KimiAPIError.apiError("HTTP 500").errorDescription?.contains("HTTP 500") == true) - #expect(KimiAPIError.parseFailed("Invalid JSON").errorDescription?.contains("Invalid JSON") == true) - } -} diff --git a/docs/kimi.md b/docs/kimi.md index 836591b816..dd847571d5 100644 --- a/docs/kimi.md +++ b/docs/kimi.md @@ -82,8 +82,16 @@ including the local hostname, OS details, and stable `~/.kimi-code/device_id` va missing, CodexBar creates it with private file permissions to match the official client. CodexBar treats CLI-owned authentication as read-only: it never uses the refresh token and never rewrites -the credential file. When the access token expires, sign in again with Kimi Code CLI or configure an API -key. Set `KIMI_CODE_HOME` only when the official CLI uses a non-default home directory. +the credential file. Kimi rotates refresh tokens, so refreshing only in CodexBar's memory could invalidate +the CLI's saved token; writing it back could race with the CLI's own renewal. The official CLI coordinates +renewal and persists the replacement credential itself. + +CLI access tokens are short-lived. For a 15-minute token, CodexBar's 60-second safety margin means it +becomes stale after 14 minutes without CLI renewal. Run `kimi` to renew it (sign in if the CLI asks), then +refresh CodexBar. The next fetch rereads the file; restarting CodexBar is unnecessary. Auto mode tries +configured web authentication when the CLI credential is stale or rejected, and prefers a configured API +key before the CLI. For unattended use, add a Kimi Code API key in **Settings → Providers → Kimi** or set +`KIMI_CODE_API_KEY`. Set `KIMI_CODE_HOME` only when the official CLI uses a non-default home directory. Custom `KIMI_CODE_BASE_URL`, `KIMI_CODE_OAUTH_HOST`, and `KIMI_OAUTH_HOST` values disable CLI credential reuse; use an explicit API key for endpoint-override testing. From daffd77b34a9f34e0229c4b64b342bf3c3edc6b0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 00:35:45 -0700 Subject: [PATCH 044/122] fix(cost): compact and reuse Claude cache artifacts (#4092) Claude/Vertex cost caches keep the decoded value of each successful save under its committed file stamp, skip re-encoding unmodified values, and use compact row keys (a 24k-row history artifact shrinks from 9.3 MB to 6.9 MB). Schema 3 -> 4 rebuilds once from existing transcripts; Unicode text is preserved exactly. Refs #3882 #3247. Thanks @djbclark for the CPU sample that pinned this down! --- CHANGELOG.md | 1 + .../CostUsage/CostUsageClaudeCache.swift | 138 +++++++++------- .../CostUsage/CostUsageScanner+Claude.swift | 28 +--- .../CostUsageClaudeKimiAliasTests.swift | 2 +- ...CostUsageClaudeRefreshBenchmarkTests.swift | 89 +++++++++++ ...stUsageClaudeWriteAmplificationTests.swift | 147 +++++++++++++++++- ...tUsageScannerClaudeCacheUpgradeTests.swift | 6 +- .../CostUsageScannerClaudeMemoTests.swift | 19 ++- .../CodexBarTests/CostUsageScannerTests.swift | 8 + docs/claude.md | 1 + 10 files changed, 337 insertions(+), 102 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageClaudeRefreshBenchmarkTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a4bb3684e..0fab4f785b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Antigravity: preserve grouped OAuth quotas, including weekly-only Starter allowances, and honor explicit quota-window cadence using the shared CLI parser (#2427, #3789). - Kimi: direct stale CLI sessions to run `kimi` or configure an API key in Settings, while retaining web fallback and leaving rotating CLI credentials read-only (#4063). Thanks @kid0114! +- Claude and Vertex: reuse freshly saved cost-history rows, skip encoding unchanged caches, and compact retained row fields to reduce CPU and disk writes during repeated refreshes (#3882, #3247, #3323). - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift index 81af4d28ed..4dd60a09ae 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -81,11 +81,6 @@ final class CostUsageClaudeReportMemo: @unchecked Sendable { /// Bump when bundled pricing, model aliases, or daily-report aggregation changes without new artifact stamps. static let reportSemanticsVersion = 6 - private struct StoredEntry { - let entry: Entry - let generation: UInt64 - } - private struct PersistedEnvelope: Codable { var version: Int var reportSemanticsVersion: Int @@ -99,13 +94,12 @@ final class CostUsageClaudeReportMemo: @unchecked Sendable { private let lock = NSLock() private let capacity = 8 - private var generation: UInt64 = 0 - private var entries: [String: StoredEntry] = [:] + private var entries: [(key: String, entry: Entry)] = [] func entry(provider: UsageProvider, canonicalCachePath: String) -> Entry? { let key = Self.key(provider: provider, canonicalCachePath: canonicalCachePath) self.lock.lock() - if let memory = self.entries[key]?.entry { + if let memory = self.entries.first(where: { $0.key == key })?.entry { self.lock.unlock() return memory } @@ -115,7 +109,7 @@ final class CostUsageClaudeReportMemo: @unchecked Sendable { self.lock.lock() defer { self.lock.unlock() } - if let memory = self.entries[key]?.entry { + if let memory = self.entries.first(where: { $0.key == key })?.entry { return memory } self.installUnlocked(key: key, entry: persisted) @@ -136,18 +130,14 @@ final class CostUsageClaudeReportMemo: @unchecked Sendable { reportKey: reportKey, report: report, hasWindowScopedRows: hasWindowScopedRows) - self.lock.lock() - self.installUnlocked(key: key, entry: entry) - self.lock.unlock() + self.lock.withLock { self.installUnlocked(key: key, entry: entry) } Self.persist(entry, canonicalCachePath: canonicalCachePath) } #if DEBUG func evict(provider: UsageProvider, canonicalCachePath: String) { let key = Self.key(provider: provider, canonicalCachePath: canonicalCachePath) - self.lock.lock() - defer { self.lock.unlock() } - self.entries.removeValue(forKey: key) + self.lock.withLock { self.entries.removeAll { $0.key == key } } } func evictPersisted(canonicalCachePath: String) { @@ -157,13 +147,9 @@ final class CostUsageClaudeReportMemo: @unchecked Sendable { #endif private func installUnlocked(key: String, entry: Entry) { - self.generation &+= 1 - self.entries[key] = StoredEntry(entry: entry, generation: self.generation) - if self.entries.count > self.capacity, - let oldest = self.entries.min(by: { $0.value.generation < $1.value.generation })?.key - { - self.entries.removeValue(forKey: oldest) - } + self.entries.removeAll { $0.key == key } + self.entries.append((key: key, entry: entry)) + if self.entries.count > self.capacity { self.entries.removeFirst() } } private static func key(provider: UsageProvider, canonicalCachePath: String) -> String { @@ -315,8 +301,15 @@ extension CostUsageScanner { #endif struct CostUsageClaudeCache: Codable { - var usage = CostUsageCache() - var sourceFileIDs: [String: String] = [:] + var usage = CostUsageCache() { + didSet { self.contentID = UUID() } + } + + var sourceFileIDs: [String: String] = [:] { + didSet { self.contentID = UUID() } + } + + private(set) var contentID = UUID() // String equality cannot establish byte-identical JSON. private enum CodingKeys: String, CodingKey { case sourceFileIDs } @@ -335,11 +328,33 @@ struct CostUsageClaudeCache: Codable { } } +/// Avoid repeating long field names for every retained response. +extension CostUsageScanner.ClaudeUsageRow { + enum CodingKeys: String, CodingKey { + case dayKey = "d" + case model = "m" + case sessionId = "s" + case messageId = "i" + case requestId = "r" + case timestampUnixMs = "t" + case isSidechain = "b" + case pathRole = "p" + case input = "in" + case cacheRead = "cr" + case cacheCreate = "cc" + case cacheCreate1h = "ch" + case output = "out" + case costNanos = "c" + case costPriced = "priced" + case isIncomplete = "partial" + } +} + /// Claude and Vertex retain their transcript cache. Codex deliberately has no route /// through this JSON I/O boundary; its only persistence authority is `CostUsageStore`. enum CostUsageClaudeCacheIO { - /// Reparse records written before proxy completion metadata was retained. - private static let schemaVersion = 3 + /// Compact row keys; older artifacts rebuild from their source transcripts. + private static let schemaVersion = 4 /// NSCache provides synchronized, memory-pressure-aware storage for the four app artifacts. /// This caches decoded bytes only; the scanner still validates source scope and reprices rows. @@ -362,27 +377,12 @@ enum CostUsageClaudeCacheIO { } } - /// Mirrors the validation the decode path applied inline, so a memoized artifact is - /// accepted or rejected on exactly the same terms as a freshly decoded one. - private static func validated(_ cache: CostUsageClaudeCache, calendar: Calendar?) -> CostUsageClaudeCache? { - guard cache.usage.version == self.schemaVersion else { return nil } - if let calendar, cache.usage.timeZoneIdentifier != calendar.timeZone.identifier { - return nil - } - return cache - } - #if DEBUG static func evictArtifactMemoForTesting(at url: URL) { ArtifactMemo.shared.entries.removeObject(forKey: url.standardizedFileURL.resolvingSymlinksInPath() as NSURL) } #endif - private static func defaultCacheRoot() -> URL { - let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! - return root.appendingPathComponent("CodexBar", isDirectory: true) - } - // Provider-specific by design: Claude/Vertex cost caching still uses the legacy JSON artifact pending its own // migration (see #2760). @@ -392,7 +392,8 @@ enum CostUsageClaudeCacheIO { reportContext: CostUsageReportContext = .regular) -> URL { precondition(provider == .claude || provider == .vertexai) - let root = cacheRoot ?? self.defaultCacheRoot() + let root = cacheRoot ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) // Parsing filters dates before selecting duplicate responses, so independent report windows // need their own rows. let suffix = reportContext == .spendDashboard ? "-history" : "" @@ -410,22 +411,27 @@ enum CostUsageClaudeCacheIO { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot, reportContext: reportContext) let key = url.standardizedFileURL.resolvingSymlinksInPath() as NSURL let stamp = CostUsageClaudeFileStamp.read(at: url) + let cache: CostUsageClaudeCache if let stamp, let memoized = ArtifactMemo.shared.entries.object(forKey: key), memoized.stamp == stamp { - return self.validated(memoized.cache, calendar: calendar) ?? CostUsageClaudeCache() - } - guard let data = try? Data(contentsOf: url) else { return CostUsageClaudeCache() } - #if DEBUG - CostUsageScanner.recordClaudeScanWork(.cacheDecode) - #endif - guard let cache = try? JSONDecoder().decode(CostUsageClaudeCache.self, from: data) else { - return CostUsageClaudeCache() - } - // Only memoize a read with stable metadata; a concurrent atomic replacement - // must fall through to a fresh decode next time. - if let stamp, CostUsageClaudeFileStamp.read(at: url) == stamp { - ArtifactMemo.shared.entries.setObject(ArtifactMemo.Entry(stamp: stamp, cache: cache), forKey: key) + cache = memoized.cache + } else { + guard let data = try? Data(contentsOf: url) else { return CostUsageClaudeCache() } + #if DEBUG + CostUsageScanner.recordClaudeScanWork(.cacheDecode) + #endif + guard let decoded = try? JSONDecoder().decode(CostUsageClaudeCache.self, from: data) else { + return CostUsageClaudeCache() + } + cache = decoded + // A concurrent replacement must fall through to a fresh decode next time. + if let stamp, CostUsageClaudeFileStamp.read(at: url) == stamp { + ArtifactMemo.shared.entries.setObject(ArtifactMemo.Entry(stamp: stamp, cache: cache), forKey: key) + } } - return self.validated(cache, calendar: calendar) ?? CostUsageClaudeCache() + guard cache.usage.version == self.schemaVersion, + calendar == nil || cache.usage.timeZoneIdentifier == calendar?.timeZone.identifier + else { return CostUsageClaudeCache() } + return cache } static func save( @@ -438,12 +444,26 @@ enum CostUsageClaudeCacheIO { { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot, reportContext: reportContext) var cache = cache - cache.usage.version = self.schemaVersion - cache.usage.timeZoneIdentifier = calendar.timeZone.identifier + let timeZoneID = calendar.timeZone.identifier + if cache.usage.version != self.schemaVersion { cache.usage.version = self.schemaVersion } + if cache.usage.timeZoneIdentifier?.utf8.elementsEqual(timeZoneID.utf8) != true { + cache.usage.timeZoneIdentifier = timeZoneID + } + let key = url.standardizedFileURL.resolvingSymlinksInPath() as NSURL + try checkCancellation?() + if let memoized = ArtifactMemo.shared.entries.object(forKey: key), memoized.cache.contentID == cache.contentID, + CostUsageClaudeFileStamp.read(at: url) == memoized.stamp + { + return memoized.stamp + } #if DEBUG CostUsageScanner.recordClaudeScanWork(.cacheEncode) #endif - return try self.write(cache, to: url, checkCancellation: checkCancellation) + let stamp = try self.write(cache, to: url, checkCancellation: checkCancellation) + if let stamp, CostUsageClaudeFileStamp.read(at: url) == stamp { + ArtifactMemo.shared.entries.setObject(ArtifactMemo.Entry(stamp: stamp, cache: cache), forKey: key) + } + return stamp } fileprivate static func write( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 64ac2d0b9d..a4c47c708d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -572,7 +572,7 @@ extension CostUsageScanner { } } - private final class ClaudeScanState { + private struct ClaudeScanState { var cache: CostUsageCache var sourceFileIDs: [String: String] let range: CostUsageDayRange @@ -581,31 +581,11 @@ extension CostUsageScanner { let changedPaths: Set let pricingResolver: CostUsagePricing.ClaudeResolver let checkCancellation: CancellationCheck? - - init( - cache: CostUsageCache, - sourceFileIDs: [String: String], - range: CostUsageDayRange, - providerFilter: ClaudeLogProviderFilter, - forceFullScan: Bool, - changedPaths: Set, - pricingResolver: CostUsagePricing.ClaudeResolver, - checkCancellation: CancellationCheck?) - { - self.cache = cache - self.sourceFileIDs = sourceFileIDs - self.range = range - self.providerFilter = providerFilter - self.forceFullScan = forceFullScan - self.changedPaths = changedPaths - self.pricingResolver = pricingResolver - self.checkCancellation = checkCancellation - } } private static func processClaudeFile( source: ClaudeSourceFile, - state: ClaudeScanState) throws + state: inout ClaudeScanState) throws { try state.checkCancellation?() let path = source.url.path @@ -775,7 +755,7 @@ extension CostUsageScanner { } else { [] } - let scanState = ClaudeScanState( + var scanState = ClaudeScanState( cache: cache, sourceFileIDs: artifact.sourceFileIDs, range: range, @@ -787,7 +767,7 @@ extension CostUsageScanner { for path in inventory.files.keys.sorted() { guard let source = inventory.files[path] else { continue } - try Self.processClaudeFile(source: source, state: scanState) + try Self.processClaudeFile(source: source, state: &scanState) } try checkCancellation?() diff --git a/Tests/CodexBarTests/CostUsageClaudeKimiAliasTests.swift b/Tests/CodexBarTests/CostUsageClaudeKimiAliasTests.swift index 1f50be5cdd..f56836870e 100644 --- a/Tests/CodexBarTests/CostUsageClaudeKimiAliasTests.swift +++ b/Tests/CodexBarTests/CostUsageClaudeKimiAliasTests.swift @@ -148,7 +148,7 @@ struct CostUsageClaudeKimiAliasTests { #expect(row.totalTokens == 160) #expect(try abs(#require(row.costUSD) - 0.000385) < 1e-12) let metrics = recorder.snapshot() - #expect(metrics.cacheDecodes == (cold ? 0 : 1)) + #expect(metrics.cacheDecodes == 0) #expect(metrics.transcriptParses == 0) #expect(metrics.cacheEncodes == 0) #expect(metrics.repricedRows == (cold ? 0 : 1)) diff --git a/Tests/CodexBarTests/CostUsageClaudeRefreshBenchmarkTests.swift b/Tests/CodexBarTests/CostUsageClaudeRefreshBenchmarkTests.swift new file mode 100644 index 0000000000..f8e6f11fe7 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageClaudeRefreshBenchmarkTests.swift @@ -0,0 +1,89 @@ +import Darwin +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized, .enabled(if: ProcessInfo.processInfo.environment["CODEXBAR_REFRESH_BENCHMARK"] == "1")) +struct CostUsageClaudeRefreshBenchmarkTests { + @Test + func `large history refresh CPU writes and retained memory`() throws { + let fixture = try autoreleasepool { + try CostUsageClaudeWriteAmplificationTests.Fixture(rowCount: 24000, identityLength: 48) + } + defer { fixture.env.cleanup() } + let context = CostUsageReportContext.spendDashboard + let initial = try autoreleasepool { try fixture.load(context: context) } + let url = fixture.cacheURL(context: context) + try print("[refresh-benchmark] rows=24000 artifactBytes=\(Data(contentsOf: url).count)") + + func measure(_ name: String, count: Int, work: (Int) throws -> Void) throws { + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + let cpuBefore = Self.cpuSeconds + let started = ContinuousClock.now + var written: Int64 = 0 + try CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { + for cycle in 0...size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + return result == KERN_SUCCESS ? info.resident_size : 0 + } +} diff --git a/Tests/CodexBarTests/CostUsageClaudeWriteAmplificationTests.swift b/Tests/CodexBarTests/CostUsageClaudeWriteAmplificationTests.swift index dc12bbb94d..894d9a2a80 100644 --- a/Tests/CodexBarTests/CostUsageClaudeWriteAmplificationTests.swift +++ b/Tests/CodexBarTests/CostUsageClaudeWriteAmplificationTests.swift @@ -55,11 +55,12 @@ struct CostUsageClaudeWriteAmplificationTests { } @Test - func `unchanged cache artifacts decode once and rewrites invalidate the memo`() throws { + func `unchanged cache artifacts decode once and external rewrites invalidate the memo`() throws { let fixture = try Fixture(rowCount: 2) defer { fixture.env.cleanup() } _ = try fixture.load(context: .regular) + CostUsageClaudeCacheIO.evictArtifactMemoForTesting(at: fixture.cacheURL(context: .regular)) let warm = CostUsageScanner.ClaudeScanWorkRecorder() let cache = CostUsageScanner.withClaudeScanWorkRecorderForTesting(warm) { var loaded = CostUsageClaudeCache() @@ -74,8 +75,7 @@ struct CostUsageClaudeWriteAmplificationTests { var mutated = cache mutated.usage.lastScanUnixMs += 1 - _ = try CostUsageClaudeCacheIO.save( - provider: .claude, cache: mutated, cacheRoot: fixture.env.cacheRoot) + try JSONEncoder().encode(mutated).write(to: fixture.cacheURL(context: .regular), options: .atomic) let rewritten = CostUsageScanner.ClaudeScanWorkRecorder() let reloaded = CostUsageScanner.withClaudeScanWorkRecorderForTesting(rewritten) { @@ -86,6 +86,137 @@ struct CostUsageClaudeWriteAmplificationTests { #expect(reloaded.usage.lastScanUnixMs == mutated.usage.lastScanUnixMs) } + @Test + func `retained row encoding stays compact and preserves every field`() throws { + let row = CostUsageScanner.ClaudeUsageRow( + dayKey: "2026-07-01", + model: "synthetic-model", + sessionId: "session", + messageId: "message", + requestId: "request", + timestampUnixMs: 123, + isSidechain: true, + pathRole: .subagent, + input: 1, + cacheRead: 2, + cacheCreate: 3, + cacheCreate1h: 4, + output: 5, + costNanos: 6, + costPriced: false, + isIncomplete: true) + let data = try JSONEncoder().encode(row) + #expect(data.count < 240) + #expect(try JSONDecoder().decode(CostUsageScanner.ClaudeUsageRow.self, from: data) == row) + let fields = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(fields.count == 16) + #expect(fields["d"] as? String == row.dayKey) + } + + @Test + func `saved artifacts are reused without decoding or encoding identical content`() throws { + let fixture = try Fixture(rowCount: 128) + defer { fixture.env.cleanup() } + _ = try fixture.load(context: .regular) + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + try CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { + for cycle in 1...3 { + var cache = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: fixture.env.cacheRoot) + let before = try fixture.stamps(context: .regular) + _ = try CostUsageClaudeCacheIO.save( + provider: .claude, cache: cache, cacheRoot: fixture.env.cacheRoot) + #expect(try fixture.stamps(context: .regular) == before) + cache.usage.lastScanUnixMs += Int64(cycle) + _ = try CostUsageClaudeCacheIO.save( + provider: .claude, cache: cache, cacheRoot: fixture.env.cacheRoot) + #expect(CostUsageClaudeCacheIO.load( + provider: .claude, cacheRoot: fixture.env.cacheRoot).usage == cache.usage) + } + } + #expect(recorder.snapshot().cacheDecodes == 0) + #expect(recorder.snapshot().cacheEncodes == 3) + } + + @Test + func `identical save checks cancellation and cannot ignore an external replacement`() throws { + let fixture = try Fixture(rowCount: 2) + defer { fixture.env.cleanup() } + _ = try fixture.load(context: .regular) + let cache = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: fixture.env.cacheRoot) + let before = try fixture.stamps(context: .regular) + #expect(throws: CancellationError.self) { + try CostUsageClaudeCacheIO.save( + provider: .claude, + cache: cache, + cacheRoot: fixture.env.cacheRoot, + checkCancellation: { throw CancellationError() }) + } + #expect(try fixture.stamps(context: .regular) == before) + var replacement = cache + replacement.usage.lastScanUnixMs += 1 + let url = fixture.cacheURL(context: .regular) + try JSONEncoder().encode(replacement).write(to: url, options: .atomic) + _ = try CostUsageClaudeCacheIO.save(provider: .claude, cache: cache, cacheRoot: fixture.env.cacheRoot) + let restored = try JSONDecoder().decode(CostUsageClaudeCache.self, from: Data(contentsOf: url)) + #expect(restored.usage == cache.usage) + #expect(restored.sourceFileIDs == cache.sourceFileIDs) + } + + @Test + func `canonically equal model edits persist exact UTF8 bytes`() throws { + let fixture = try Fixture(rowCount: 1) + defer { fixture.env.cleanup() } + _ = try fixture.load(context: .regular) + var cache = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: fixture.env.cacheRoot) + let path = try #require(cache.usage.files.keys.first) + let row = try #require(cache.usage.files[path]?.claudeRows?.first) + var fields = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(row)) as? [String: Any]) + let models = ["synthetic-\u{00E9}", "synthetic-e\u{0301}"] + #expect(models[0] == models[1]) + for model in models { + fields["m"] = model + let data = try JSONSerialization.data(withJSONObject: fields) + cache.usage.files[path]?.claudeRows = try [JSONDecoder().decode( + CostUsageScanner.ClaudeUsageRow.self, + from: data)] + _ = try CostUsageClaudeCacheIO.save(provider: .claude, cache: cache, cacheRoot: fixture.env.cacheRoot) + let stored = try JSONDecoder().decode( + CostUsageClaudeCache.self, from: Data(contentsOf: fixture.cacheURL(context: .regular))) + #expect(stored.usage.files[path]?.claudeRows?.first?.model.utf8.elementsEqual(model.utf8) == true) + } + } + + @Test + func `schema three rows rebuild from transcripts without changing totals`() throws { + let fixture = try Fixture(rowCount: 1) + defer { fixture.env.cleanup() } + let initial = try fixture.load(context: .regular) + let url = fixture.cacheURL(context: .regular) + var object = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + object["version"] = 3 + var files = try #require(object["files"] as? [String: [String: Any]]) + let path = try #require(files.keys.first) + files[path]?["claudeRows"] = [[ + "dayKey": "2026-07-01", "model": "claude-sonnet-4-20250514", "messageId": "message-0", + "requestId": "request-0", "timestampUnixMs": Int64(fixture.day.timeIntervalSince1970 * 1000), + "isSidechain": false, "pathRole": "parent", "input": 10, "cacheRead": 0, "cacheCreate": 0, + "output": 5, "costNanos": 105_000, "costPriced": true, + ]] + object["files"] = files + try JSONSerialization.data(withJSONObject: object).write(to: url, options: .atomic) + CostUsageScanner.evictClaudeReportMemoForTesting(provider: .claude, cacheRoot: fixture.env.cacheRoot) + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + let upgraded = try CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { + try fixture.load(context: .regular, cycle: 1) + } + #expect(upgraded.data == initial.data) + #expect(upgraded.hourly == initial.hourly) + #expect(upgraded.quotaSlices == initial.quotaSlices) + #expect(recorder.snapshot().transcriptParses == 1) + #expect(recorder.snapshot().incrementalTranscriptParses == 0) + #expect(CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: fixture.env.cacheRoot).usage.version == 4) + } + @Test func `changed usage persists once and a cancelled save preserves the artifacts`() throws { let fixture = try Fixture(rowCount: 2) @@ -242,11 +373,13 @@ struct CostUsageClaudeWriteAmplificationTests { #expect(try fixture.load(context: .regular).summary?.totalInputTokens == 20) } - private struct Fixture { + struct Fixture { let env: CostUsageTestEnvironment let day: Date + let identityPadding: String - init(rowCount: Int) throws { + init(rowCount: Int, identityLength: Int = 0) throws { + self.identityPadding = String(repeating: "s", count: identityLength) self.env = try CostUsageTestEnvironment() self.day = try self.env.makeLocalNoon(year: 2026, month: 7, day: 1) _ = try self.env.writeClaudeProjectFile( @@ -256,9 +389,9 @@ struct CostUsageClaudeWriteAmplificationTests { func event(index: Int) throws -> String { try self.env.jsonl([[ "type": "assistant", "timestamp": self.env.isoString(for: self.day.addingTimeInterval(Double(index))), - "requestId": "request-\(index)", + "requestId": "request-\(self.identityPadding)\(index)", "message": [ - "id": "message-\(index)", + "id": "message-\(self.identityPadding)\(index)", "model": "claude-sonnet-4-20250514", "usage": ["input_tokens": 10, "output_tokens": 5], ], diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeCacheUpgradeTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeCacheUpgradeTests.swift index 22f226bd91..6e8ea849f7 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeCacheUpgradeTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeCacheUpgradeTests.swift @@ -80,7 +80,7 @@ struct CostUsageScannerClaudeCacheUpgradeTests { let savedCache = try JSONDecoder().decode(CostUsageClaudeCache.self, from: Data(contentsOf: cacheURL)) let savedMemo = try JSONDecoder().decode(PersistedReportMemo.self, from: Data(contentsOf: memoURL)) - #expect(savedCache.usage.version == 3) + #expect(savedCache.usage.version == 4) #expect(savedCache.usage.files.count == 1) #expect(savedCache.usage.files[path]?.claudeRows?.map(\.output) == [19]) #expect(savedCache.usage.days == [dayKey: [model: [50, 100, 0, 19, 465_000, 1, 1, 0]]]) @@ -161,7 +161,7 @@ struct CostUsageScannerClaudeCacheUpgradeTests { costNanos: 611_593_000, costPriced: true)] cache.usage.days[dayKey]?[model]?[4] = 611_593_000 - #expect(cache.usage.version == 3) + #expect(cache.usage.version == 4) try JSONEncoder().encode(cache).write(to: cacheURL) let cacheStamp = try #require(CostUsageClaudeFileStamp.read(at: cacheURL)) var memo = try JSONDecoder().decode(PersistedReportMemo.self, from: Data(contentsOf: memoURL)) @@ -305,7 +305,7 @@ struct CostUsageScannerClaudeCacheUpgradeTests { #expect(work.transcriptParses == 1) #expect(work.cacheEncodes == 1) let savedCache = try JSONDecoder().decode(CostUsageClaudeCache.self, from: Data(contentsOf: cacheURL)) - #expect(savedCache.usage.version == 3) + #expect(savedCache.usage.version == 4) #expect(savedCache.usage.files[path]?.claudeRows?.first?.isIncomplete == true) let savedMemo = try JSONDecoder().decode(PersistedReportMemo.self, from: Data(contentsOf: memoURL)) #expect(savedMemo.reportSemanticsVersion == CostUsageClaudeReportMemo.reportSemanticsVersion) diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift index b7b27bc14c..52f953ae28 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift @@ -207,7 +207,7 @@ struct CostUsageScannerClaudeMemoTests { #expect(!initial.quotaSlices.isEmpty) #expect(restarted.hourly == initial.hourly) #expect(restarted.quotaSlices == initial.quotaSlices) - #expect(metrics.cacheDecodes == 1) + #expect(metrics.cacheDecodes == 0) #expect(metrics.transcriptParses == 0) #expect(CostUsageClaudeFileStamp.read(at: sourceURL) == sourceStamp) let rewritten = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: memoURL)) as? [String: Any]) @@ -230,6 +230,7 @@ struct CostUsageScannerClaudeMemoTests { try Data("invalid JSON".utf8).write(to: memoURL) } + CostUsageClaudeCacheIO.evictArtifactMemoForTesting(at: self.cacheURL(env: env)) let (restarted, metrics) = self.recordedLoad(day: day, options: options) #expect(restarted.data == initial.data) @@ -259,7 +260,7 @@ struct CostUsageScannerClaudeMemoTests { let (report, metrics) = self.recordedLoad(day: day, options: options) #expect(report.summary?.totalInputTokens == 30) - #expect(metrics.cacheDecodes == 1) + #expect(metrics.cacheDecodes == 0) #expect(metrics.transcriptParses == 1) #expect(metrics.cacheEncodes == 1) } @@ -286,7 +287,7 @@ struct CostUsageScannerClaudeMemoTests { let (report, metrics) = self.recordedLoad(day: day, options: options) #expect(report.summary?.totalInputTokens == 30) - #expect(metrics.cacheDecodes == 1) + #expect(metrics.cacheDecodes == 0) #expect(metrics.transcriptParses == 1) #expect(metrics.incrementalTranscriptParses == 1) #expect(metrics.cacheEncodes == 1) @@ -316,7 +317,7 @@ struct CostUsageScannerClaudeMemoTests { let (report, metrics) = self.recordedLoad(day: day, options: options) #expect(report.summary?.totalInputTokens == 20) - #expect(metrics.cacheDecodes == 1) + #expect(metrics.cacheDecodes == 0) #expect(metrics.transcriptParses == 0) #expect(metrics.cacheEncodes == 1) } @@ -334,7 +335,7 @@ struct CostUsageScannerClaudeMemoTests { let (report, metrics) = self.recordedLoad(day: day, options: options) #expect(report.data.isEmpty) - #expect(metrics.cacheDecodes == 1) + #expect(metrics.cacheDecodes == 0) #expect(metrics.transcriptParses == 0) #expect(metrics.cacheEncodes == 1) } @@ -375,7 +376,7 @@ struct CostUsageScannerClaudeMemoTests { let (report, metrics) = self.recordedLoad(day: day, options: options) #expect(report.summary?.totalInputTokens == 10) - #expect(metrics.cacheDecodes == 1) + #expect(metrics.cacheDecodes == 0) #expect(metrics.transcriptParses == 1) #expect(metrics.cacheEncodes == 1) #expect(metrics.repricedRows == 1) @@ -410,11 +411,12 @@ struct CostUsageScannerClaudeMemoTests { if cold { CostUsageScanner.evictClaudeReportMemoForTesting(provider: .claude, cacheRoot: env.cacheRoot) + CostUsageClaudeCacheIO.evictArtifactMemoForTesting(at: cacheURL) } let (repriced, metrics) = self.recordedLoad(day: day, options: options) #expect(abs((repriced.summary?.totalCostUSD ?? 0) - 0.002) < 0.000000001) - #expect(metrics.cacheDecodes == 1) + #expect(metrics.cacheDecodes == (cold ? 1 : 0)) #expect(metrics.transcriptParses == 0) #expect(metrics.cacheEncodes == 0) #expect(metrics.repricedRows == 1) @@ -438,7 +440,7 @@ struct CostUsageScannerClaudeMemoTests { let (report, metrics) = self.recordedLoad(day: day, options: options) #expect(report.summary?.totalInputTokens == 10) - #expect(metrics.cacheDecodes == 1) + #expect(metrics.cacheDecodes == 0) #expect(metrics.transcriptParses == 1) #expect(metrics.cacheEncodes == 1) } @@ -539,6 +541,7 @@ struct CostUsageScannerClaudeMemoTests { memo["report"] = report try JSONSerialization.data(withJSONObject: memo).write(to: memoURL) CostUsageScanner.evictClaudeReportMemoForTesting(provider: .claude, cacheRoot: env.cacheRoot) + CostUsageClaudeCacheIO.evictArtifactMemoForTesting(at: self.cacheURL(env: env)) let (loaded, work) = self.recordedLoad(day: day, options: options) let valid = ["absent", "zero", "positive"].contains(fixture) if valid { diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index 4598533b2d..378d4f7feb 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -1234,6 +1234,14 @@ struct CostUsageTestEnvironment { } func cleanup() { + for provider in [UsageProvider.claude, .vertexai] { + for context in [CostUsageReportContext.regular, .spendDashboard] { + CostUsageScanner.evictClaudeReportMemoForTesting( + provider: provider, cacheRoot: self.cacheRoot, reportContext: context) + CostUsageClaudeCacheIO.evictArtifactMemoForTesting(at: CostUsageClaudeCacheIO.cacheFileURL( + provider: provider, cacheRoot: self.cacheRoot, reportContext: context)) + } + } try? FileManager.default.removeItem(at: self.root) } diff --git a/docs/claude.md b/docs/claude.md index fe33eeaffd..7121bbf926 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -430,6 +430,7 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential - Report memo: `~/Library/Caches/CodexBar/cost-usage/claude-v6.report-memo.json` stores source stamps and the daily report across launches. It is reused only while transcript inventory, cache/pricing artifacts, requested window, and report-semantics revision still match. - Unchanged sources reuse the memo even when a menu refresh bypasses the scan debounce. Explicit rescans still reparse transcripts, but identical cache and report-memo content is not rewritten; an unchanged rebuild retains its previous scan timestamp. Existing artifacts may be rewritten once to establish deterministic key ordering. Changed transcripts or report metadata still replace the corresponding complete JSON artifacts. - Decoded cache artifacts can be reused in memory while their canonical path, file identity, size, and nanosecond modification time match. Schema and time-zone checks still run on every load; report-level source, window, filter, and pricing checks still run separately. Atomic replacements invalidate this reuse, and explicit rescans still reparse source transcripts. + - Successful cache saves retain the just-written decoded value, avoiding another full row decode on the next changed refresh. Unmodified loaded values skip encoding and writing while the artifact stamp still matches; external replacements, deleted files, and failed or cancelled saves cannot establish this reuse. Changed content still replaces the complete JSON artifact. Compact row field names reduce its size; schema 3 artifacts rebuild from transcripts once when the rows are next needed. Report memos and user-facing JSON retain their existing formats. - The app's Usage & Spend refresh uses `claude-history-v6.json` and its own report memo. The two app refreshes do not replace each other's retained rows or restart each other's transcript scans. Once both have established their windows, same-day append refreshes read changed tails once per cache. - App memos record whether every file's rows were selected for their scan window. Older or externally replaced caches without that proof rebuild once, even if their stored bounds already match; app window changes also rebuild to preserve cold-scan duplicate selection. The regular cache filename and row schema remain compatible, and standalone CLI range behavior is unchanged. - The Claude/Vertex cache artifact retains source file identities independently of the shared Codex parser fingerprint. Replacing a transcript rebuilds its rows rather than merging an old prefix into a new suffix; genuine appends still use the saved parse offset. Older entries without identity are rebuilt once before reuse, including during the normal refresh debounce. From e9b2823d6fd0e883d392f23b42038757f67b39ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 00:35:56 -0700 Subject: [PATCH 045/122] fix(providers): clarify Kimi and z.ai quota availability (#4091) Kimi: when the monthly membership is known to be exhausted, shorter windows show as blocked by the monthly limit instead of fresh capacity. z.ai: unsupported quota shapes explain that usage is unavailable instead of inventing numbers, while recognized limits stay visible. Refs #3536 #2522; closes #2871 (five-hour cadence already derived from API fields, now covered by the reported payload). --- CHANGELOG.md | 2 + .../CodexBar/MenuCardView+ModelHelpers.swift | 93 +++++------ Sources/CodexBar/MenuCardView.swift | 20 +-- .../Kimi/KimiProviderDescriptor.swift | 14 +- .../Providers/Kimi/KimiUsageSnapshot.swift | 23 +-- .../Providers/ProviderUsagePresentation.swift | 5 +- Sources/CodexBarCore/Resources/Plugins/zai.js | 14 +- .../KimiMonthlyBlockingTests.swift | 151 ++++++++++++++++++ Tests/CodexBarTests/ZaiProviderTests.swift | 56 +++++++ TestsPlugin/ZaiPluginResetTests.swift | 32 +++- docs/kimi.md | 1 + docs/zai.md | 3 +- 12 files changed, 327 insertions(+), 87 deletions(-) create mode 100644 Tests/CodexBarTests/KimiMonthlyBlockingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fab4f785b..674bd4facc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ - Antigravity: preserve grouped OAuth quotas, including weekly-only Starter allowances, and honor explicit quota-window cadence using the shared CLI parser (#2427, #3789). - Kimi: direct stale CLI sessions to run `kimi` or configure an API key in Settings, while retaining web fallback and leaving rotating CLI credentials read-only (#4063). Thanks @kid0114! - Claude and Vertex: reuse freshly saved cost-history rows, skip encoding unchanged caches, and compact retained row fields to reduce CPU and disk writes during repeated refreshes (#3882, #3247, #3323). +- Kimi Code: mark shorter Code windows as blocked when the known monthly membership pool is exhausted, without showing fresh quota or pace forecasts (#3536). +- z.ai: explain unavailable Coding Plan usage for empty or unsupported quota shapes while preserving recognized quotas and analytics (#2522). - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index b90c12fcf5..45274b3a70 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -229,33 +229,49 @@ extension UsageMenuCardView.Model { return PersonalInfoRedactor.redactEmails(in: "Team\(detail[separator.lowerBound...])", isEnabled: true) } - /// Clears the pace stripe and the forecast text when the user hides pace. - /// Copies every `Metric` field so unrelated decorations (quota and workday - /// ticks) survive; dropping one here would silently disable them. + static func blockingQuotaMetrics(_ metrics: [Metric], input: Input, snapshot: UsageSnapshot) -> [Metric] { + guard let policy = ProviderDescriptorRegistry.descriptor(for: input.provider).presentation.menuCard + .blockingQuota, + let blocker = snapshot.extraRateWindows?.first(where: { $0.id == policy.windowID && $0.usageKnown }) + else { return metrics } + return metrics.map { metric in + let window: RateWindow? = switch metric.id { + case "primary": snapshot.primary + case "secondary": snapshot.secondary + case "tertiary": snapshot.tertiary + default: snapshot.extraRateWindows?.first { $0.id == metric.id && $0.usageKnown }?.window + } + guard let window, !window.isSyntheticPlaceholder, + let projection = RateWindow.bindingQuotaProjection( + primary: window, bindingLanes: [blocker.window], now: input.now) + else { return metric } + var blocked = metric + blocked.percent = input.usageBarsShowUsed ? projection.usedPercent : 100 - projection.usedPercent + blocked.statusText = L(policy.message) + // The blocking quota's own row owns its reset; shorter resets cannot restore access. + blocked.resetText = nil + blocked.detailText = nil + blocked.detailLeftText = nil + blocked.detailRightText = nil + blocked.pacePercent = nil + blocked.sessionEquivalentDetail = nil + return blocked + } + } + + /// Clear only pace fields, preserving unrelated quota and workday decorations. static func paceGatedMetrics(_ metrics: [Metric], paceVisible: Bool) -> [Metric] { guard !paceVisible else { return metrics } return metrics.map { metric in - // The detail slots are shared: providers such as Kiro, Copilot, and - // ZenMux put their own credit and reset text there. Clear them only - // when they carry a pace forecast. - Metric( - id: metric.id, - title: metric.title, - percent: metric.percent, - percentStyle: metric.percentStyle, - statusText: metric.statusText, - resetText: metric.resetText, - detailText: metric.detailText, - detailLeftText: metric.detailIsPaceDerived ? nil : metric.detailLeftText, - detailRightText: metric.detailIsPaceDerived ? nil : metric.detailRightText, - pacePercent: nil, - detailIsPaceDerived: metric.detailIsPaceDerived, - paceOnTop: metric.paceOnTop, - warningMarkerPercents: metric.warningMarkerPercents, - workdayMarkerPercents: metric.workdayMarkerPercents, - workdayTickAppearance: metric.workdayTickAppearance, - cardStyle: metric.cardStyle, - sessionEquivalentDetail: nil) + var result = metric + // Provider-owned balance and reset text shares these slots with pace forecasts. + if metric.detailIsPaceDerived { + result.detailLeftText = nil + result.detailRightText = nil + } + result.pacePercent = nil + result.sessionEquivalentDetail = nil + return result } } @@ -266,27 +282,14 @@ extension UsageMenuCardView.Model { { guard hidePersonalInfo else { return metrics } return metrics.map { metric in - Metric( - id: metric.id, - title: PersonalInfoRedactor.redactEmails(in: metric.title, isEnabled: true) ?? metric.title, - percent: metric.percent, - percentStyle: metric.percentStyle, - statusText: PersonalInfoRedactor.redactEmails(in: metric.statusText, isEnabled: true), - resetText: PersonalInfoRedactor.redactEmails(in: metric.resetText, isEnabled: true), - detailText: Self.redactedMetricDetail( - metric.detailText, - provider: provider, - metricID: metric.id), - detailLeftText: PersonalInfoRedactor.redactEmails(in: metric.detailLeftText, isEnabled: true), - detailRightText: PersonalInfoRedactor.redactEmails(in: metric.detailRightText, isEnabled: true), - pacePercent: metric.pacePercent, - detailIsPaceDerived: metric.detailIsPaceDerived, - paceOnTop: metric.paceOnTop, - warningMarkerPercents: metric.warningMarkerPercents, - workdayMarkerPercents: metric.workdayMarkerPercents, - workdayTickAppearance: metric.workdayTickAppearance, - cardStyle: metric.cardStyle, - sessionEquivalentDetail: metric.sessionEquivalentDetail) + var result = metric + result.title = PersonalInfoRedactor.redactEmails(in: metric.title, isEnabled: true) ?? metric.title + result.statusText = PersonalInfoRedactor.redactEmails(in: metric.statusText, isEnabled: true) + result.resetText = PersonalInfoRedactor.redactEmails(in: metric.resetText, isEnabled: true) + result.detailText = Self.redactedMetricDetail(metric.detailText, provider: provider, metricID: metric.id) + result.detailLeftText = PersonalInfoRedactor.redactEmails(in: metric.detailLeftText, isEnabled: true) + result.detailRightText = PersonalInfoRedactor.redactEmails(in: metric.detailRightText, isEnabled: true) + return result } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index fd0bf2a9ff..8f99c4192a 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -32,15 +32,15 @@ struct UsageMenuCardView: View { } let id: String - let title: String - let percent: Double + var title: String + var percent: Double let percentStyle: PercentStyle - let statusText: String? - let resetText: String? - let detailText: String? - let detailLeftText: String? - let detailRightText: String? - let pacePercent: Double? + var statusText: String? + var resetText: String? + var detailText: String? + var detailLeftText: String? + var detailRightText: String? + var pacePercent: Double? /// True when detailLeftText/detailRightText came from a pace forecast. let detailIsPaceDerived: Bool let paceOnTop: Bool @@ -48,7 +48,7 @@ struct UsageMenuCardView: View { let workdayMarkerPercents: [Double] let workdayTickAppearance: WorkdayTickAppearance let cardStyle: Bool - let sessionEquivalentDetail: UsagePaceText.SessionEquivalentDetail? + var sessionEquivalentDetail: UsagePaceText.SessionEquivalentDetail? init( id: String, @@ -1247,7 +1247,7 @@ extension UsageMenuCardView.Model { pacePercent: nil, paceOnTop: true)) } - return metrics + return Self.blockingQuotaMetrics(metrics, input: input, snapshot: snapshot) } private static func primaryMetric( diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index a4cf95d9f2..057c287389 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -106,14 +106,8 @@ public enum KimiProviderDescriptor { semanticWindowResolver: { snapshot in let candidates = [snapshot.primary, snapshot.secondary, snapshot.tertiary] + (snapshot.extraRateWindows ?? []).map(\.window) - let usable = candidates.compactMap { window -> RateWindow? in - guard let window, !window.isSyntheticPlaceholder else { return nil } - return window - } - let session = usable.first { window in - guard let minutes = window.windowMinutes else { return false } - return (60...(12 * 60)).contains(minutes) - } + let usable = candidates.compactMap(\.self).filter { !$0.isSyntheticPlaceholder } + let session = usable.first { (60...(12 * 60)).contains($0.windowMinutes ?? 0) } let cadenceWeekly = usable.first { $0.windowMinutes == 7 * 24 * 60 } let primary = snapshot.primary.flatMap { $0.isSyntheticPlaceholder ? nil : $0 } return ProviderSemanticWindows(session: session, weekly: primary ?? cadenceWeekly) @@ -122,7 +116,9 @@ public enum KimiProviderDescriptor { secondarySemanticWindow: .session, menuBarWindowResolver: self.menuBarWindow, widgetRowLimitResolver: { _, _ in 3 }, - menuCard: ProviderMenuCardPresentation(resetWindowUsesWeeklyPace: true)), + menuCard: ProviderMenuCardPresentation( + resetWindowUsesWeeklyPace: true, + blockingQuota: ("kimi-monthly", "Blocked by monthly limit"))), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api, .web], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift index 66d56288a7..36d09522bc 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift @@ -11,14 +11,7 @@ public struct KimiUsageSnapshot: Sendable { let codeUsagePools: KimiCodeUsagePools? public init(weekly: KimiUsageDetail?, rateLimit: KimiUsageDetail?, updatedAt: Date) { - self.weekly = weekly - self.rateLimit = rateLimit - self.updatedAt = updatedAt - self.rateLimitWindow = nil - self.subscriptionBalance = nil - self.subscriptionCodeWeeklyLimit = nil - self.planName = nil - self.codeUsagePools = nil + self.init(weekly: weekly, rateLimit: rateLimit, subscriptionBalance: nil, updatedAt: updatedAt) } init( @@ -185,20 +178,16 @@ extension KimiUsageSnapshot { showsDistinctCodeWeeklyWindow ? subscriptionCodeWeeklyWindow : nil, ].compactMap(\.self) - let identity = ProviderIdentitySnapshot( - providerID: .kimi, - accountEmail: nil, - accountOrganization: nil, - loginMethod: self.planName) - return UsageSnapshot( primary: weeklyWindow, secondary: rateLimitWindow, - tertiary: nil, extraRateWindows: extraRateWindows.isEmpty ? nil : extraRateWindows, - providerCost: nil, updatedAt: self.updatedAt, - identity: identity) + identity: ProviderIdentitySnapshot( + providerID: .kimi, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.planName)) } private static func isEquivalentToWeeklyWindow(_ window: RateWindow, weeklyWindow: RateWindow?) -> Bool { diff --git a/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift b/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift index 40eb36a823..a054386834 100644 --- a/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift +++ b/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift @@ -311,6 +311,7 @@ public struct ProviderMenuCardPresentation: Sendable { public let usesSyntheticRollingRegen: Bool public let usesRawPrimaryResetDescription: Bool public let resetWindowUsesWeeklyPace: Bool + public let blockingQuota: (windowID: String, message: String)? public init( usageNotesResolver: @escaping UsageNotesResolver = { _ in .unhandled }, @@ -335,7 +336,8 @@ public struct ProviderMenuCardPresentation: Sendable { usesAbacusPace: Bool = false, usesSyntheticRollingRegen: Bool = false, usesRawPrimaryResetDescription: Bool = false, - resetWindowUsesWeeklyPace: Bool = false) + resetWindowUsesWeeklyPace: Bool = false, + blockingQuota: (windowID: String, message: String)? = nil) { self.usageNotesResolver = usageNotesResolver self.creditsVisibility = creditsVisibility @@ -360,6 +362,7 @@ public struct ProviderMenuCardPresentation: Sendable { self.usesSyntheticRollingRegen = usesSyntheticRollingRegen self.usesRawPrimaryResetDescription = usesRawPrimaryResetDescription self.resetWindowUsesWeeklyPace = resetWindowUsesWeeklyPace + self.blockingQuota = blockingQuota } public func usageNotes(context: ProviderUsageNotesContext) -> ProviderUsageNotesResolution { diff --git a/Sources/CodexBarCore/Resources/Plugins/zai.js b/Sources/CodexBarCore/Resources/Plugins/zai.js index 38472ef2ac..d5b4fd0bf2 100644 --- a/Sources/CodexBarCore/Resources/Plugins/zai.js +++ b/Sources/CodexBarCore/Resources/Plugins/zai.js @@ -49,7 +49,7 @@ defineProvider({ throw new Error(`z.ai quota API error: ${root && root.msg ? root.msg : "invalid response"}`); } if (!root.data || typeof root.data !== "object" || !Array.isArray(root.data.limits)) { - throw new Error("Failed to parse z.ai quota data"); + throw new Error("Unsupported z.ai quota format. Check Usage Dashboard for plan usage."); } function optionalInteger(value, field) { @@ -58,6 +58,8 @@ defineProvider({ return value; } function parseLimit(raw) { + if (raw && typeof raw.type === "string" && !["TOKENS_LIMIT", "TIME_LIMIT", "CREDIT_LIMIT"].includes(raw.type)) + return null; if ( !raw || typeof raw !== "object" || @@ -67,9 +69,8 @@ defineProvider({ !Number.isInteger(raw.number) || !Number.isInteger(raw.percentage) ) { - throw new Error("Failed to parse z.ai limit entry"); + throw new Error("Unsupported z.ai quota entry. Check Usage Dashboard for plan usage."); } - if (raw.type !== "TOKENS_LIMIT" && raw.type !== "TIME_LIMIT" && raw.type !== "CREDIT_LIMIT") return null; const usage = optionalInteger(raw.usage, "limit.usage"); const current = optionalInteger(raw.currentValue, "limit.currentValue"); const remaining = optionalInteger(raw.remaining, "limit.remaining"); @@ -176,6 +177,13 @@ defineProvider({ identity: {}, details: [{ title: "Quota details", rows: [] }], }; + if (!limits.length || limits.length < root.data.limits.length) { + result.details[0].rows.push({ + label: tokenLimits.length ? "Additional quota" : "Coding Plan usage", + value: "Unavailable", + secondaryValue: "Check Usage Dashboard for complete plan usage.", + }); + } if (tokenLimits.length >= 2) result.secondary = window(tokenLimit); if (tokenLimit && timeLimit) { result.extraWindows = [{ id: "zai-mcp", title: "MCP", window: window(timeLimit) }]; diff --git a/Tests/CodexBarTests/KimiMonthlyBlockingTests.swift b/Tests/CodexBarTests/KimiMonthlyBlockingTests.swift new file mode 100644 index 0000000000..0e1c01fe8c --- /dev/null +++ b/Tests/CodexBarTests/KimiMonthlyBlockingTests.swift @@ -0,0 +1,151 @@ +import AppKit +import Foundation +import SwiftUI +import Testing +import XCTest +@testable import CodexBar +@testable import CodexBarCore + +struct KimiMonthlyBlockingTests { + static let now = Date(timeIntervalSince1970: 1_788_000_000) + + @Test(arguments: [false, true]) + func `exhausted membership blocks fresh Code windows without changing raw usage`(showUsed: Bool) throws { + let snapshot = try Self.snapshot(ratio: 1) + let model = try Self.model(snapshot, showUsed: showUsed) + for id in ["primary", "secondary", "kimi-code-7d"] { + let metric = try #require(model.metrics.first { $0.id == id }) + #expect(metric.percent == (showUsed ? 100 : 0)) + #expect(metric.statusText == "Blocked by monthly limit") + #expect(metric.resetText == nil) + #expect(metric.pacePercent == nil) + #expect(metric.detailLeftText == nil) + #expect(metric.detailRightText == nil) + #expect(metric.sessionEquivalentDetail == nil) + } + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.secondary?.usedPercent == 0) + #expect(model.metrics.first { $0.id == "kimi-monthly" }?.statusText == nil) + } + + @Test(arguments: [0.5, 0.999]) + func `available membership preserves Code windows`(ratio: Double) throws { + let model = try Self.model(Self.snapshot(ratio: ratio)) + for id in ["primary", "secondary"] { + let metric = try #require(model.metrics.first { $0.id == id }) + #expect(metric.percent == 100) + #expect(metric.statusText == nil) + } + } + + @Test(arguments: [true, false]) + func `unknown or expired membership does not block`(unknown: Bool) throws { + let monthly = NamedRateWindow( + id: "kimi-monthly", + title: "Total usage", + window: Self.window(used: 100, minutes: 43200, reset: unknown ? nil : Self.now), + usageKnown: !unknown) + let snapshot = UsageSnapshot( + primary: Self.window(used: 0, minutes: 10080), + secondary: nil, + extraRateWindows: [monthly], + updatedAt: Self.now) + let metric = try #require(Self.model(snapshot).metrics.first { $0.id == "primary" }) + #expect(metric.percent == 100) + #expect(metric.statusText == nil) + } + + @Test + func `unknown monthly reset does not promise the shorter Code reset`() throws { + let snapshot = UsageSnapshot( + primary: Self.window(used: 0, minutes: 10080, reset: Self.now.addingTimeInterval(3600)), + secondary: nil, + extraRateWindows: [NamedRateWindow( + id: "kimi-monthly", title: "Total usage", window: Self.window(used: 100, minutes: 43200))], + updatedAt: Self.now) + let metric = try #require(Self.model(snapshot).metrics.first { $0.id == "primary" }) + #expect(metric.statusText == "Blocked by monthly limit") + #expect(metric.resetText == nil) + } + + static func snapshot(ratio: Double) throws -> UsageSnapshot { + let reset = ISO8601DateFormatter().string(from: self.now.addingTimeInterval(30 * 86400)) + // #3536 reports amountUsedRatio=1 while both Code counters are zero. + let stats = try JSONDecoder().decode(KimiSubscriptionStatsResponse.self, from: Data(""" + {"subscriptionBalance":{"amountUsedRatio":\(ratio),"expireTime":"\(reset)", + "overdrawn":true},"ratelimitCode7d":{"ratio":0.25,"enabled":true}} + """.utf8)) + let weekly = KimiUsageDetail( + limit: "100", + used: "0", + remaining: "100", + resetTime: ISO8601DateFormatter().string(from: self.now.addingTimeInterval(4 * 86400 + 9 * 3600))) + let session = KimiUsageDetail( + limit: "100", + used: "0", + remaining: "100", + resetTime: ISO8601DateFormatter().string(from: self.now.addingTimeInterval(3600))) + return KimiUsageSnapshot( + weekly: weekly, + rateLimit: session, + subscriptionBalance: stats.subscriptionBalance, + subscriptionCodeWeeklyLimit: stats.ratelimitCode7d, + updatedAt: self.now).toUsageSnapshot() + } + + private static func window(used: Double, minutes: Int, reset: Date? = nil) -> RateWindow { + RateWindow(usedPercent: used, windowMinutes: minutes, resetsAt: reset, resetDescription: nil) + } + + static func model(_ snapshot: UsageSnapshot, showUsed: Bool = false) throws -> UsageMenuCardView.Model { + let metadata = try #require(ProviderDefaults.metadata[.kimi]) + return UsageMenuCardView.Model.make(.init( + provider: .kimi, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: showUsed, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: self.now)) + } +} + +@MainActor +final class KimiMonthlyBlockingProofTests: XCTestCase { + func test_syntheticBlockedWindows() throws { + guard let path = ProcessInfo.processInfo.environment["CODEXBAR_KIMI_BLOCKING_PROOF_DIR"] else { + throw XCTSkip("Set CODEXBAR_KIMI_BLOCKING_PROOF_DIR for synthetic offscreen rendering") + } + let model = try KimiMonthlyBlockingTests.model(KimiMonthlyBlockingTests.snapshot(ratio: 1)) + let view = VStack(alignment: .leading, spacing: 16) { + Text("Kimi Code · Synthetic monthly limit").font(.headline) + ForEach(model.metrics) { metric in + MetricRow(metric: metric, layoutMetric: metric, title: metric.title, progressColor: model.progressColor) + } + } + .padding(20) + .frame(width: 500, height: 400, alignment: .topLeading) + .background(Color(NSColor.windowBackgroundColor)) + let hosting = NSHostingView(rootView: view) + hosting.frame = NSRect(x: 0, y: 0, width: 500, height: 400) + hosting.appearance = NSAppearance(named: .aqua) + hosting.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.15)) + let bitmap = try XCTUnwrap(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + let data = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + let output = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + try data.write(to: output.appendingPathComponent("kimi-monthly.png")) + } +} diff --git a/Tests/CodexBarTests/ZaiProviderTests.swift b/Tests/CodexBarTests/ZaiProviderTests.swift index b3b6b85255..cf794b9ea6 100644 --- a/Tests/CodexBarTests/ZaiProviderTests.swift +++ b/Tests/CodexBarTests/ZaiProviderTests.swift @@ -6,6 +6,7 @@ struct ZaiProviderTests { @Test(arguments: BundledPluginTestSupport.engines, [ "", #"{"type":"FUTURE_LIMIT","unit":3,"number":5,"percentage":40}"#, + #"{"type":"FUTURE_POINTS_POOL","pointsRemaining":800}"#, ]) func `missing recognized limits never fabricate unused quota`( engine: ProviderPluginEngineKind, @@ -21,6 +22,8 @@ struct ZaiProviderTests { #expect(snapshot.secondary == nil) #expect(snapshot.extraRateWindows?.isEmpty != false) #expect(snapshot.identity?.loginMethod == "Pro") + #expect(snapshot.detailRow(label: "Coding Plan usage")?.value == "Unavailable") + #expect(snapshot.detailRow(label: "Coding Plan usage")?.secondaryValue?.contains("Usage Dashboard") == true) #expect(snapshot.details.map(\.title) == (analytics == Self.emptyModelUsageFixture ? ["Quota details"] : ["Quota details", "Hourly tokens", "Daily tokens"])) } @@ -40,6 +43,59 @@ struct ZaiProviderTests { #expect(snapshot.primary?.usedPercent == 0) #expect(snapshot.primary?.windowMinutes == 300) #expect(snapshot.secondary == nil) + #expect(snapshot.detailRow(label: "Coding Plan usage") == nil) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `unsupported plan pool preserves known MCP without implying Coding Plan availability`( + engine: ProviderPluginEngineKind) async throws + { + let fixture = #""" + {"code":200,"success":true,"data":{"limits":[ + {"type":"FUTURE_POINTS_POOL","pointsRemaining":800}, + {"type":"TIME_LIMIT","unit":5,"number":1,"percentage":25} + ]}} + """# + let snapshot = try await Self.pluginSnapshot(quotaFixture: fixture, engine: engine) + #expect(snapshot.primary?.resetDescription == "MCP") + #expect(snapshot.primary?.usedPercent == 25) + #expect(snapshot.detailRow(label: "Coding Plan usage")?.value == "Unavailable") + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `unknown extra limits do not mark recognized Coding Plan usage unavailable`( + engine: ProviderPluginEngineKind) async throws + { + let fixture = #""" + {"code":200,"success":true,"data":{"limits":[ + {"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":25}, + {"type":"FUTURE_POINTS_POOL","pointsRemaining":800} + ]}} + """# + let snapshot = try await Self.pluginSnapshot(quotaFixture: fixture, engine: engine) + #expect(snapshot.primary?.usedPercent == 25) + #expect(snapshot.detailRow(label: "Coding Plan usage") == nil) + #expect(snapshot.detailRow(label: "Additional quota")?.value == "Unavailable") + } + + @Test(arguments: BundledPluginTestSupport.engines, [ + #"{"pointsPool":{"remaining":800}}"#, + #"{"limits":[{"unit":3,"number":5,"percentage":25}]}"#, + #"{"limits":[{"type":null,"unit":3,"number":5,"percentage":25}]}"#, + #"{"limits":[{"type":42,"unit":3,"number":5,"percentage":25}]}"#, + ]) + func `unsupported quota shapes explain where to check usage`( + engine: ProviderPluginEngineKind, + data: String) async + { + do { + _ = try await Self.pluginSnapshot( + quotaFixture: #"{"code":200,"success":true,"data":\#(data)}"#, + engine: engine) + Issue.record("An unsupported quota envelope must not invent usage") + } catch { + #expect(error.localizedDescription.contains("Usage Dashboard")) + } } @Test(arguments: BundledPluginTestSupport.engines) diff --git a/TestsPlugin/ZaiPluginResetTests.swift b/TestsPlugin/ZaiPluginResetTests.swift index 810e7d90a2..685acae5ed 100644 --- a/TestsPlugin/ZaiPluginResetTests.swift +++ b/TestsPlugin/ZaiPluginResetTests.swift @@ -8,6 +8,32 @@ import Testing struct ZaiPluginResetTests { private static let now = Date(timeIntervalSince1970: 1_800_000_000) + @Test + func `reported August payload uses its five hour cadence and exact epoch`() async throws { + // Transcribed from #2871's quota screenshot; the menu capture reads 18:39 in Santiago. + let now = try #require(ISO8601DateFormatter().date(from: "2026-08-11T22:39:00Z")) + let body = """ + {"code":200,"success":true,"data":{"level":"pro","limits":[ + {"type":"TIME_LIMIT","unit":5,"number":1,"usage":1000,"currentValue":0,"remaining":1000, + "percentage":0,"nextResetTime":1786489348996,"usageDetails":[ + {"modelCode":"search-prime","usage":0},{"modelCode":"web-reader","usage":0}, + {"modelCode":"zread","usage":0}]}, + {"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":42,"nextResetTime":1786493397235} + ]}} + """ + let snapshot = try await Self.fetch(body: body, now: now) + let reset = try #require(snapshot.primary?.resetsAt) + #expect(snapshot.primary?.windowMinutes == 300) + #expect(snapshot.primary?.usedPercent == 42) + #expect(reset.timeIntervalSince1970 == 1_786_493_397.235) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Santiago")) + #expect(calendar.component(.hour, from: reset) == 20) + #expect(calendar.component(.minute, from: reset) == 9) + #expect(snapshot.secondary == nil) + #expect(snapshot.extraRateWindows?.first?.window.resetDescription == "MCP") + } + @Test(arguments: ["TOKENS_LIMIT", "CREDIT_LIMIT"]) func `five hour windows omit impossible resets and preserve quota`(type: String) async throws { for offset in [TimeInterval(36000), 18060.001] { @@ -101,6 +127,10 @@ struct ZaiPluginResetTests { {"type":"TIME_LIMIT","unit":5,"number":1,"percentage":22,"nextResetTime":\(mcpMillis)} ]}} """ + return try await Self.fetch(body: body, now: Self.now) + } + + private static func fetch(body: String, now: Date) async throws -> UsageSnapshot { let runtime = try ProviderPluginRuntime( bundledPlugin: "zai", transport: ProviderHTTPTransportHandler { request in @@ -116,6 +146,6 @@ struct ZaiPluginResetTests { return try await runtime.fetchUsage( settings: ["Z_AI_REGION": "global", "Z_AI_USAGE_SCOPE": "personal"], secrets: ["Z_AI_API_KEY": "fixture-key"], - now: Self.now) + now: now) } } diff --git a/docs/kimi.md b/docs/kimi.md index dd847571d5..2a7d645c40 100644 --- a/docs/kimi.md +++ b/docs/kimi.md @@ -22,6 +22,7 @@ Code subscription credentials. - Detects the installed Kimi CLI version, including standalone installs outside the GUI app PATH - Enriches Code API/CLI usage with the monthly membership pool when a web session is available - Automatic menu-bar usage prioritizes an exhausted monthly Total usage pool over reset Code windows; explicit window selections remain authoritative +- When a known monthly Total usage pool is exhausted, the menu card marks shorter Code windows as blocked by the monthly limit and omits their pace forecasts. Raw API percentages and explicit menu-bar selections remain available; unknown or expired monthly limits do not block the card. - API-key, Kimi Code CLI, automatic cookie, and manual cookie authentication methods - Multiple labeled web accounts through the shared token-account editor - Automatic refresh countdown diff --git a/docs/zai.md b/docs/zai.md index d60ea72a05..e1c586b57f 100644 --- a/docs/zai.md +++ b/docs/zai.md @@ -142,7 +142,8 @@ Copy each value once, on one line. Multi-line or duplicated IDs can make the API - A single Coding Plan limit becomes primary. With multiple limits, the first becomes primary and the last becomes secondary after sorting by duration; unknown durations sort last. - `TIME_LIMIT` → a separate MCP lane when a Coding Plan window is available, otherwise the primary MCP window; never a fabricated monthly Coding Plan window. - Usage percentage: - - Empty or unrecognized quota limits remain unavailable; they never imply 0% used. Reported zero usage remains visible, and plan details and optional analytics are retained without a quota window. + - Empty or wholly unrecognized quota limits show Coding Plan usage as unavailable and direct users to Usage Dashboard; they never imply 0% used. Unknown string limit types are skipped without requiring legacy window fields. Mixed responses retain recognized windows and explain that additional quota is unavailable. Malformed entries and unsupported response envelopes fail with Dashboard guidance. Reported zero usage, plan details, and optional analytics remain supported. + - `CREDIT_LIMIT` supports points-based quotas using the supplied counts. An unknown plan shape is not treated as verified GLM Coding Plan V3 compatibility. - An integer `percentage` is required. When a positive `usage` limit and a `currentValue` or `remaining` count are present, the counts determine the used percentage. The result is clamped to 0–100%. - Window duration: - Unit + number → minutes/hours/days. From 03f4b68881930269793320d68776fd5f4f76d453 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 00:36:26 -0700 Subject: [PATCH 046/122] fix(grok): publish local token history when billing is unavailable (#4093) Grok local token history now reaches Usage & Spend and share output when x.ai billing is unavailable: wider dashboard requests keep the scan's actual 30-day coverage instead of an unknown horizon that the dashboard rejected, fresh local history publishes before retained-quota early returns, and the scanner clips files to its advertised calendar days so aggregates match daily buckets. Fixes #3716. Thanks @Chipagosfinest! --- CHANGELOG.md | 1 + .../Grok/UsageStore+GrokLocalSessions.swift | 15 ++-- Sources/CodexBar/UsageStore+Refresh.swift | 16 ++-- .../Grok/GrokLocalSessionScanner.swift | 8 +- Sources/CodexBarCore/UsageFetcher.swift | 7 +- .../GrokBillingFailurePublicationTests.swift | 74 +++++++++++++++++++ .../GrokLocalSessionScannerTests.swift | 30 ++++++++ .../GrokTokenSnapshotProjectionTests.swift | 67 ++++++++++++++--- docs/grok.md | 10 ++- 9 files changed, 190 insertions(+), 38 deletions(-) create mode 100644 Tests/CodexBarTests/GrokBillingFailurePublicationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 674bd4facc..1444a9e010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Claude and Vertex: reuse freshly saved cost-history rows, skip encoding unchanged caches, and compact retained row fields to reduce CPU and disk writes during repeated refreshes (#3882, #3247, #3323). - Kimi Code: mark shorter Code windows as blocked when the known monthly membership pool is exhausted, without showing fresh quota or pace forecasts (#3536). - z.ai: explain unavailable Coding Plan usage for empty or unsupported quota shapes while preserving recognized quotas and analytics (#2522). +- Grok: keep local token totals visible in Usage & Spend and shared cards across wider history views and billing outages, with consistent daily scan windows (#3716). Thanks @Chipagosfinest! - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift index 23d6ef47f0..09f3c04977 100644 --- a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift +++ b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift @@ -10,13 +10,14 @@ extension UsageStore { ?? (providerSnapshot == nil ? self.tokenSnapshotPublications[.grok]?.snapshot : nil) guard let published else { return nil } let days = max(1, historyDays) - guard published.historyDays != days else { return published } + // Wider views retain the scan's actual coverage; only narrower views need projection. + guard days < published.historyDays else { return published } let calendar = Calendar.current let today = calendar.startOfDay(for: published.updatedAt) guard let start = calendar.date(byAdding: .day, value: -(days - 1), to: today), - let firstDay = Self.grokLocalDayKey(for: start, calendar: calendar), - let lastDay = Self.grokLocalDayKey(for: today, calendar: calendar) + let firstDay = GrokLocalSessionScanner.dayKey(for: start, calendar: calendar), + let lastDay = GrokLocalSessionScanner.dayKey(for: today, calendar: calendar) else { return nil } let daily = published.daily.filter { $0.date >= firstDay && $0.date <= lastDay } guard !daily.isEmpty else { return nil } @@ -32,7 +33,7 @@ extension UsageStore { last30DaysRequests: requests.isEmpty ? nil : requests.reduce(0, +), currencyCode: published.currencyCode, historyDays: days, - historyCoverageIsEstablished: published.historyCoverageIsEstablished && published.historyDays >= days, + historyCoverageIsEstablished: published.historyCoverageIsEstablished, historyLabel: published.historyLabel, meteredCostUSD: published.meteredCostUSD, costProvenance: published.costProvenance, @@ -51,10 +52,4 @@ extension UsageStore { lookbackDays: historyDays) return summary.toCostUsageTokenSnapshot(historyDays: historyDays) } - - private static func grokLocalDayKey(for date: Date, calendar: Calendar) -> String? { - let parts = calendar.dateComponents([.year, .month, .day], from: date) - guard let year = parts.year, let month = parts.month, let day = parts.day else { return nil } - return String(format: "%04d-%02d-%02d", year, month, day) - } } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index a90d580b5c..a67fbe71f3 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -1315,6 +1315,12 @@ extension UsageStore { let shouldNotifyPermissionPrompt = Self.isPermissionPromptWaiting(error) await MainActor.run { guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } + // Local Grok tokens remain fresh even when a billing outage retains an older quota snapshot. + if let local = grokLocalFallback { + self.snapshots[provider.instanceID] = self.snapshots[provider.instanceID]? + .replacing(costUsage: .value(local)) + self.publishTokenSnapshot(local, for: provider) + } self.diagnostics[provider.instanceID] = nil let restoredClaudeHistory = self.prepareClaudeHistoryFallback( provider: provider, @@ -1432,15 +1438,7 @@ extension UsageStore { self.errors[provider.instanceID] = error.localizedDescription if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider.instanceID) - // Provider-specific by design: local ~/.grok/sessions tokens remain readable - // when the remote billing probe fails. - if provider == .grok { - if let local = grokLocalFallback { - self.publishTokenSnapshot(local, for: provider) - } else { - self.clearTokenSnapshot(for: provider) - } - } else if Self.tokenCostRequiresProviderSnapshot(provider) { + if Self.tokenCostRequiresProviderSnapshot(provider), grokLocalFallback == nil { self.clearTokenSnapshot(for: provider) } } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index c316a43d4c..ede4fd4fde 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -100,7 +100,9 @@ public enum GrokLocalSessionScanner { } let calendar = Calendar.current - let lookbackCutoff = calendar.date(byAdding: .day, value: -lookbackDays, to: now) ?? now + let today = calendar.startOfDay(for: now) + let lookbackCutoff = calendar.date(byAdding: .day, value: -(max(1, lookbackDays) - 1), to: today) ?? today + let lookbackEnd = calendar.date(byAdding: .day, value: 1, to: today) ?? now var sessionCount = 0 var totalTokens = 0 var lastSessionAt: Date? @@ -113,7 +115,7 @@ public enum GrokLocalSessionScanner { guard url.lastPathComponent == "signals.json" else { continue } let attrs = try? url.resourceValues(forKeys: [.contentModificationDateKey]) let mtime = attrs?.contentModificationDate ?? Date.distantPast - guard mtime >= lookbackCutoff else { continue } + guard mtime >= lookbackCutoff, mtime < lookbackEnd else { continue } guard let data = try? Data(contentsOf: url), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] @@ -187,7 +189,7 @@ public enum GrokLocalSessionScanner { } } - static func dayKey(for date: Date, calendar: Calendar) -> String? { + package static func dayKey(for date: Date, calendar: Calendar) -> String? { let components = calendar.dateComponents([.year, .month, .day], from: date) guard let year = components.year, let month = components.month, let day = components.day else { return nil diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index c86e86b1fa..a998f2936e 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -520,7 +520,7 @@ public struct UsageSnapshot: Codable, Sendable { return true } - enum Replacement { + package enum Replacement { case unchanged case value(Value) @@ -532,12 +532,13 @@ public struct UsageSnapshot: Codable, Sendable { } } - func replacing( + package func replacing( primary: Replacement = .unchanged, secondary: Replacement = .unchanged, tertiary: Replacement = .unchanged, extraRateWindows: Replacement<[NamedRateWindow]?> = .unchanged, providerCost: Replacement = .unchanged, + costUsage: Replacement = .unchanged, details: Replacement<[ProviderDetailSection]> = .unchanged, deepseekDetailedUsageState: Replacement = .unchanged, deepseekPlatformProfiles: Replacement<[DeepSeekPlatformProfile]> = .unchanged, @@ -554,7 +555,7 @@ public struct UsageSnapshot: Codable, Sendable { tertiary: tertiary.resolving(self.tertiary), extraRateWindows: extraRateWindows.resolving(self.extraRateWindows), providerCost: providerCost.resolving(self.providerCost), - costUsage: self.costUsage, + costUsage: costUsage.resolving(self.costUsage), details: details.resolving(self.details), deepseekDetailedUsageState: deepseekDetailedUsageState.resolving(self.deepseekDetailedUsageState), deepseekPlatformProfiles: deepseekPlatformProfiles.resolving(self.deepseekPlatformProfiles), diff --git a/Tests/CodexBarTests/GrokBillingFailurePublicationTests.swift b/Tests/CodexBarTests/GrokBillingFailurePublicationTests.swift new file mode 100644 index 0000000000..788cfc1d92 --- /dev/null +++ b/Tests/CodexBarTests/GrokBillingFailurePublicationTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct GrokBillingFailurePublicationTests { + @Test(arguments: ["missing", "persisted", "live"]) + func `billing outages publish local tokens while preserving cached quota`(prior: String) async throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("grok-outage-\(UUID())") + defer { try? FileManager.default.removeItem(at: home) } + let session = home.appendingPathComponent("sessions/project/session") + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + let signals = session.appendingPathComponent("signals.json") + try Data(#"{"totalTokensBeforeCompaction":1,"contextTokensUsed":0,"primaryModelId":"example-model"}"#.utf8) + .write(to: signals) + let env = ["GROK_HOME": home.path] + let oldTokens = GrokLocalSessionScanner.summarize(env: env).toCostUsageTokenSnapshot(historyDays: 30) + let quota = UsageSnapshot( + primary: RateWindow(usedPercent: 29, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + costUsage: oldTokens, + updatedAt: Date().addingTimeInterval(-3600)) + let settings = testSettingsStore( + suiteName: "GrokBillingFailurePublicationTests", + userDefaults: InMemoryUserDefaults(), + keychainAccessPolicy: .init(setDisabled: { _ in }, isExplicitlyDisabled: { false })) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + let metadata = try #require(ProviderRegistry.shared.metadata[.grok]) + settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + let store = UsageStore( + fetcher: UsageFetcher(environment: env), + browserDetection: BrowserDetection( + homeDirectory: home.path, cacheTTL: 0, fileExists: { _ in false }, directoryContents: { _ in [] }), + settings: settings, + startupBehavior: .testing, + environmentBase: env) + if prior != "missing" { + let restored = prior == "persisted" + ? try JSONDecoder().decode(UsageSnapshot.self, from: JSONEncoder().encode(quota)) : quota + store.snapshots[.grok] = restored + store.installProviderDerivedTokenSnapshot(from: restored, for: .grok) + } + store._test_providerFetchOutcomeOverride = { _ in + ProviderFetchOutcome(result: .failure(URLError(.notConnectedToInternet)), attempts: []) + } + + for tokens in [42, 85] { + try Data(""" + {"totalTokensBeforeCompaction":\(tokens - 2),"contextTokensUsed":2,"primaryModelId":"example-model"} + """.utf8).write(to: signals) + await store.refreshProvider(.grok, allowDisabled: true) + + if prior != "missing" { + #expect(store.snapshot(for: .grok)?.primary == quota.primary) + #expect(store.snapshot(for: .grok)?.updatedAt == quota.updatedAt) + #expect(store.snapshot(for: .grok)?.costUsage?.last30DaysTokens == tokens) + } + let published = try #require(store.tokenSnapshot(for: .grok)) + #expect(published.last30DaysTokens == tokens) + #expect(published.last30DaysCostUSD == nil) + let request = await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: .captureOnly) + let history = try #require(request.capturedInputs.first { $0.provider == .grok }?.snapshot) + #expect(history.last30DaysTokens == tokens) + let model = SpendDashboardModel.build( + inputs: request.capturedInputs, requestedDays: 30, now: history.updatedAt) + let shared = try #require(ShareStatsBuilder.make(model: model)) + #expect(shared.providers.first { $0.provider == .grok }?.totalTokens == tokens) + #expect(shared.providers.first { $0.provider == .grok }?.estimatedCost == nil) + } + } +} diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index 94f09a72d6..3c808bd27c 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -3,6 +3,36 @@ import Testing @testable import CodexBarCore struct GrokLocalSessionScannerTests { + @Test(arguments: [1, 7, 30]) + func `scan totals cover only the advertised local calendar days`(days: Int) throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("grok-window-\(UUID())") + defer { try? FileManager.default.removeItem(at: home) } + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date(timeIntervalSince1970: 1_787_079_600)) + let now = today.addingTimeInterval(12 * 3600) + let outside = try #require(calendar.date(byAdding: .day, value: -days, to: today)) + let first = try #require(calendar.date(byAdding: .day, value: -(days - 1), to: today)) + let tomorrow = try #require(calendar.date(byAdding: .day, value: 1, to: today)) + let dates = [ + outside.addingTimeInterval(18 * 3600), + first.addingTimeInterval(3600), + today.addingTimeInterval(2 * 3600), + tomorrow.addingTimeInterval(3600), + ] + for (index, date) in dates.enumerated() { + let session = home.appendingPathComponent("sessions/project/session-\(index)") + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + try self.writeSignals( + at: session.appendingPathComponent("signals.json"), tokens: 100, model: "example-model", date: date) + } + + let summary = GrokLocalSessionScanner.summarize(env: ["GROK_HOME": home.path], lookbackDays: days, now: now) + #expect(summary.sessionCount == 2) + #expect(summary.totalTokens == 200) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: days)) + #expect(snapshot.summary(forLastDays: days, calendar: calendar).totalTokens == snapshot.last30DaysTokens) + } + @Test func `daily buckets stay local and never invent dollars`() throws { let root = FileManager.default.temporaryDirectory diff --git a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift index 156055444b..6be93c69f9 100644 --- a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift +++ b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift @@ -97,7 +97,7 @@ struct GrokTokenSnapshotProjectionTests { } @Test - func `requested history wider than the published grok scan is marked incomplete`() throws { + func `requested history wider than the published grok scan preserves its actual coverage`() throws { let now = Date(timeIntervalSince1970: 1_787_079_600) let published = Self.snapshot( daily: [Self.entry(date: Self.dayKey(now, calendar: .current), tokens: 85)], @@ -110,24 +110,67 @@ struct GrokTokenSnapshotProjectionTests { provider: .grok, historyDays: 60)) - #expect(projected.historyDays == 60) - #expect(!projected.historyCoverageIsEstablished) + #expect(projected.historyDays == 30) + #expect(projected.historyCoverageIsEstablished) #expect(projected.last30DaysTokens == 85) } + @Test + func `successful quota refresh keeps local tokens in wider dashboard requests`() async throws { + let now = Date() + let published = Self.snapshot( + daily: [Self.entry(date: Self.dayKey(now, calendar: .current), tokens: 85)], + updatedAt: now) + let home = FileManager.default.temporaryDirectory.appendingPathComponent("grok-dashboard-\(UUID())") + let store = Self.makeStore(environment: ["GROK_HOME": home.path]) + store.settings.costUsageEnabled = true + let metadata = try #require(ProviderRegistry.shared.metadata[.grok]) + store.settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + store._test_providerFetchOutcomeOverride = { _ in + .init(result: .success(ProviderFetchResult( + usage: UsageSnapshot( + primary: .init(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + costUsage: published, + updatedAt: now), + credits: nil, + dashboard: nil, + sourceLabel: "grok-cli-proxy", + strategyID: "grok.fixture", + strategyKind: .web)), attempts: []) + } + + await store.refreshProvider(.grok, allowDisabled: true) + #expect(store.snapshot(for: .grok)?.costUsage?.last30DaysTokens == 85) + let request = await SpendDashboardSource.makeRequest( + settings: store.settings, store: store, mode: .captureOnly, now: now) + let history = try #require(request.capturedInputs.first { $0.provider == .grok }?.snapshot) + #expect(history.historyDays == 30) + #expect(history.historyCoverageIsEstablished) + let model = SpendDashboardModel.build(inputs: request.capturedInputs, requestedDays: 60, now: now) + let row = try #require(model.groups.flatMap(\.providers).first { $0.provider == .grok }) + #expect(row.totalTokens == 85) + #expect(row.coveredDayCount == 30) + let shared = try #require(ShareStatsBuilder.make(model: model)) + #expect(shared.providers.first { $0.provider == .grok }?.totalTokens == 85) + #expect(shared.providers.first { $0.provider == .grok }?.estimatedCost == nil) + } + private static func makeStore(environment: [String: String]) -> UsageStore { - let suite = "GrokTokenSnapshotProjectionTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: testConfigStore(suiteName: suite), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = testSettingsStore( + suiteName: "GrokTokenSnapshotProjectionTests", + userDefaults: InMemoryUserDefaults(), + keychainAccessPolicy: .init(setDisabled: { _ in }, isExplicitlyDisabled: { false })) settings.providerDetectionCompleted = true + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false return UsageStore( fetcher: UsageFetcher(environment: environment), - browserDetection: BrowserDetection(cacheTTL: 0), + browserDetection: BrowserDetection( + homeDirectory: environment["GROK_HOME"] ?? "/nonexistent", + cacheTTL: 0, + fileExists: { _ in false }, + directoryContents: { _ in [] }), settings: settings, startupBehavior: .testing, environmentBase: environment) diff --git a/docs/grok.md b/docs/grok.md index 136cfcf538..4d880383b5 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -158,7 +158,8 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. above. This keeps billing visible when `grok agent stdio` returns `Method not found`. 5) **Local session signals** (informational fallback) - - Walks `~/.grok/sessions///signals.json` files (last 30 days). + - Quota fetches scan `~/.grok/sessions///signals.json` for the last 30 local calendar days, + including today. Files dated outside that window are excluded so daily buckets and aggregate totals agree. - Aggregates `totalTokensBeforeCompaction`, `contextTokensUsed`, `modelsUsed`, and the most recent session timestamp. @@ -319,6 +320,13 @@ dollars. Local session scans run on the dedicated background usage-scan queue; menu cards and spend views reuse the already-published snapshot instead of walking the session directory whenever they render. +Wider dashboard ranges retain the scan's actual coverage instead of marking all of its token history unknown. +For example, a 30-day scan still contributes its tokens in a 60-day view; older days remain unscanned. + +If remote billing fails, readable local sessions still update Usage & Spend and shared cards, including when CodexBar +retains an older quota snapshot. The quota keeps its original timestamp; refreshed local tokens do not imply a fresh +quota response. Results from a refresh whose account or configuration changed are discarded. + `costUsage` is live-only data and is intentionally omitted from `codexbar usage` JSON and persisted usage snapshots. Its absence in JSON does not establish that Usage & Spend lost the in-memory local token history. In Auto mode, an RPC From 9013fd81b218dc211f4230188b3291d7bfa75eed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 01:49:17 -0700 Subject: [PATCH 047/122] fix(codex): reread fresh credentials and invalidate stale plan evidence (#4088) Codex credential reads retry with a bounded reread when auth.json is missing, partial, incomplete, or expiring while the Codex CLI publishes fresh credentials mid-fetch, and a fresh plan change invalidates the old quota/reset evidence so usage from the new plan replaces the previous one. Fixes #3389; refs #3635 #3523. Thanks @theDanielJLewis and @coygeek! --- CHANGELOG.md | 1 + .../Codex/CodexWeeklyResetConfirmation.swift | 8 +- .../Codex/UsageStore+CodexResetBackfill.swift | 17 ++ ...geStore+CodexWeeklyResetConfirmation.swift | 28 ++- Sources/CodexBar/UsageStore+Refresh.swift | 2 + .../CodexBar/UsageStore+TokenAccounts.swift | 4 +- .../CodexOAuth/CodexOAuthCredentials.swift | 7 +- .../Codex/CodexProviderDescriptor.swift | 152 +++++++--------- .../CodexOAuthCredentialReadTests.swift | 8 +- .../CodexOAuthExpiryPipelineTests.swift | 121 +++++++++++++ .../CodexPlanTransitionPublicationTests.swift | 171 ++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 5 + docs/codex-oauth.md | 5 + docs/codex.md | 6 + 14 files changed, 417 insertions(+), 118 deletions(-) create mode 100644 Tests/CodexBarTests/CodexPlanTransitionPublicationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1444a9e010..652af0bcde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Codex: retry brief credential-file publication races before reporting refresh errors, and discard the previous plan's quota baseline after a subscription change so fresh usage can appear (#3635, #3389). - Development: restore test compilation on Xcode 26.3 / Swift 6.2 and check app, CLI, and test compatibility in CI (#4070). Thanks @RowboTony! - Configuration: treat empty or whitespace-only config files like missing files so usage keeps working; settings saves write valid JSON, while malformed non-empty files still report errors (#4071). - Menu bar: reject corrupt saved positions during status-item visibility changes and removal while preserving valid placement across restarts (#3355). diff --git a/Sources/CodexBar/Providers/Codex/CodexWeeklyResetConfirmation.swift b/Sources/CodexBar/Providers/Codex/CodexWeeklyResetConfirmation.swift index 32af1da84f..1af794e8f6 100644 --- a/Sources/CodexBar/Providers/Codex/CodexWeeklyResetConfirmation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexWeeklyResetConfirmation.swift @@ -192,7 +192,8 @@ struct CodexWeeklyResetConfirmation: Sendable { return .publishConfirmation } - guard initialWeekly.usedPercent <= Self.resetThreshold, + guard Self.normalizedPlan(initial) == Self.normalizedPlan(confirmation), + initialWeekly.usedPercent <= Self.resetThreshold, let initialBoundary = Self.validResetBoundary(initialWeekly, capturedAt: initial.updatedAt), let confirmationBoundary = Self.validResetBoundary( confirmationWeekly, @@ -404,6 +405,11 @@ struct CodexWeeklyResetConfirmation: Sendable { return identities.allSatisfy { $0 == first } } + static func normalizedPlan(_ snapshot: UsageSnapshot?) -> String? { + let plan = snapshot?.loginMethod(for: .codex)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return plan?.isEmpty == false ? plan : nil + } + private static func haveCompatiblePlans(_ snapshots: UsageSnapshot...) -> Bool { // Codex exposes the subscription tier through loginMethod, so it is the plan identity here. let plans = snapshots.map { snapshot in diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexResetBackfill.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexResetBackfill.swift index 481931d86b..3f77c79ec8 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexResetBackfill.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexResetBackfill.swift @@ -4,10 +4,18 @@ import Foundation /// Reset-time backfill for Codex rate windows: rebuilds raw snapshot slots from cached lane data so /// missing reset timestamps survive refreshes without disturbing fresh quota values. extension UsageStore { + nonisolated static func codexPlanChanged(from previous: UsageSnapshot?, to current: UsageSnapshot) -> Bool { + guard let previousPlan = CodexWeeklyResetConfirmation.normalizedPlan(previous), + let currentPlan = CodexWeeklyResetConfirmation.normalizedPlan(current) + else { return false } + return previousPlan != currentPlan + } + nonisolated static func codexBackfillingResetWindows( _ snapshot: UsageSnapshot, from cached: UsageSnapshot) -> UsageSnapshot { + guard !self.codexPlanChanged(from: cached, to: snapshot) else { return snapshot } let primary = self.codexBackfilledSlotWindow( slotWindow: snapshot.primary, lane: .session, @@ -132,3 +140,12 @@ extension UsageStore { resetDescription: cached.resetDescription) } } + +extension ProviderFetchOutcome { + nonisolated func backfillingCodexResetWindows(from cached: UsageSnapshot?) -> ProviderFetchOutcome { + guard let cached, case let .success(result) = self.result else { return self } + return self.replacingUsage(UsageStore.codexBackfillingResetWindows( + result.usage.scoped(to: .codex), + from: cached)) + } +} diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexWeeklyResetConfirmation.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexWeeklyResetConfirmation.swift index 6ccd73e3a7..277e8a111a 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexWeeklyResetConfirmation.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexWeeklyResetConfirmation.swift @@ -49,16 +49,19 @@ extension UsageStore { return CodexWeeklyResetPublicationAdmission(outcome: initialOutcome, pendingCandidate: candidateForRetry) } let rawInitialSnapshot = rawInitialResult.usage.scoped(to: .codex) - let publicationBaseline = [previousSnapshot, missingWindowBackfillSnapshot] + let cachedBaseline = [previousSnapshot, missingWindowBackfillSnapshot] .compactMap(\.self) .max { $0.updatedAt < $1.updatedAt } - let publicationInitialOutcome = if let missingWindowBackfillSnapshot { - initialOutcome.replacingUsage(Self.codexBackfillingResetWindows( - rawInitialSnapshot, - from: missingWindowBackfillSnapshot)) - } else { - initialOutcome - } + let planBaseline = previousSnapshot ?? missingWindowBackfillSnapshot + let planChanged = Self.isExactCodexOAuthResult(rawInitialResult) + && rawInitialSnapshot.updatedAt > (cachedBaseline?.updatedAt ?? .distantFuture) + && Self.codexPlanChanged(from: planBaseline, to: rawInitialSnapshot) + // A new subscription has a different quota baseline, not evidence of a reset on the old plan. + let previousSnapshot = planChanged ? nil : previousSnapshot + let missingWindowBackfillSnapshot = planChanged ? nil : missingWindowBackfillSnapshot + let publicationBaseline = planChanged ? nil : cachedBaseline + if planChanged { candidateForRetry = nil } + let publicationInitialOutcome = initialOutcome.backfillingCodexResetWindows(from: missingWindowBackfillSnapshot) if CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: rawInitialSnapshot) == nil { return Self.codexMissingWeeklyAdmission(input: CodexMissingWeeklyAdmissionInput( @@ -168,15 +171,8 @@ extension UsageStore { trace: confirmationTrace) switch confirmationDecision { case .publishConfirmation: - if let missingWindowBackfillSnapshot { - return CodexWeeklyResetPublicationAdmission( - outcome: confirmationOutcome.replacingUsage(Self.codexBackfillingResetWindows( - confirmationSnapshot, - from: missingWindowBackfillSnapshot)), - pendingCandidate: nil) - } return CodexWeeklyResetPublicationAdmission( - outcome: confirmationOutcome, + outcome: confirmationOutcome.backfillingCodexResetWindows(from: missingWindowBackfillSnapshot), pendingCandidate: nil) case .preservePrevious: let candidate = Self.makeCodexDelayedCandidate( diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index a67fbe71f3..5dfe4c3acf 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -821,6 +821,8 @@ extension UsageStore { resetBackfillSource: UsageSnapshot?, context: ProviderRefreshOutcomeContext) -> UsageSnapshot { + let resetBackfillSource = provider == .codex && Self.codexPlanChanged(from: resetBackfillSource, to: snapshot) + ? nil : resetBackfillSource let profileStable = self.preservingDeepSeekProfileCatalog(in: snapshot, provider: provider) let stabilized = Self.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( current: profileStable, diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index c9d6242a36..c0b6663a28 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -1313,7 +1313,9 @@ extension UsageStore { } let labeled = self.applyCodexVisibleAccountLabel(scoped, account: account) let backfilled = - Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots) + Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots.filter { + !Self.codexPlanChanged(from: $0, to: labeled) + }) .map { Self.codexBackfillingResetWindows(labeled, from: $0) } ?? labeled let credits = CodexMonthlyCreditPreservation.merging( incoming: result.credits, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index 6a2b93f997..c2e6607558 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -241,10 +241,9 @@ public enum CodexOAuthCredentialsStore { private static func readAuthData(at url: URL) throws -> Data { guard CodexCredentialFileAccess.permits(url) else { throw CodexOAuthCredentialsError.notFound } do { - // Read once instead of checking existence first. Codex publishes auth.json atomically, - // so a single read avoids a TOCTOU window and lets us distinguish a missing file from a - // transiently unreadable/partially published one without logging credentials. - return try CodexCredentialFileAccess.read(at: url, options: [.mappedIfSafe]) + // Keep owned bytes while the owner may replace or truncate auth.json. The OAuth + // strategy retries publication races; retain filesystem error categories here. + return try CodexCredentialFileAccess.read(at: url) } catch { let nsError = error as NSError let missingFile = diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 6ac0290b99..69f27e8a00 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -380,25 +380,44 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { let kind: ProviderFetchKind = .oauth func isAvailable(_ context: ProviderFetchContext) async -> Bool { - (try? CodexOAuthCredentialsStore.loadForUsage( - env: context.env, - allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true)) != nil + await (try? Self.loadCredentials(context, retryStale: false)) != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - let credentials = try CodexOAuthCredentialsStore.loadForUsage( - env: context.env, - allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) + let credentials = try await Self.loadCredentials(context, retryStale: true) return try await Self.fetch(context: context, credentials: credentials) } + private static func loadCredentials( + _ context: ProviderFetchContext, + retryStale: Bool) async throws -> CodexOAuthCredentials + { + var retriesRemaining = 2 + while true { + try Task.checkCancellation() + do { + let credentials = try CodexOAuthCredentialsStore.loadForUsage( + env: context.env, + allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) + if !retryStale || credentials.source != .codexHome || !credentials + .needsRefresh || retriesRemaining == 0 + { + return credentials + } + } catch { + guard retriesRemaining > 0 else { throw error } + } + // The owner may be publishing replacement credentials. Reread without redeeming its token. + retriesRemaining -= 1 + try await Task.sleep(for: .milliseconds(50)) + } + } + private static func fetch( context: ProviderFetchContext, credentials initialCredentials: CodexOAuthCredentials) async throws -> ProviderFetchResult { - var credentials = try await Self.prepareCredentialsForUsage( - initialCredentials, - env: context.env) + var credentials = try Self.prepareCredentialsForUsage(initialCredentials) if let managedWorkspaceAccountID = context.settings?.codex?.managedWorkspaceAccountID, !managedWorkspaceAccountID.isEmpty { @@ -428,7 +447,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { credentials: credentials, updatedAt: updatedAt, includeCredits: context.includeCredits, - allowEmptyUsageForResetCreditEnrichment: Self.defersResetCreditFetchToApp(context), + allowEmptyUsageForResetCreditEnrichment: context.runtime == .app, codexResetCreditsAttempted: resetCreditsAttempted) let workspaceBalanceResult = try await Self.applyingWorkspaceRemainingBalance( oauthResult, @@ -444,21 +463,15 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } private static func prepareCredentialsForUsage( - _ credentials: CodexOAuthCredentials, - env _: [String: String]) async throws -> CodexOAuthCredentials + _ credentials: CodexOAuthCredentials) throws -> CodexOAuthCredentials { guard credentials.needsRefresh else { return credentials } - switch credentials.source { - case .codexHome: - // Codex CLI owns the native auth file and its refresh-token lifecycle. Do not redeem - // that shared token in-process: a rotated response would strand the CLI with the old - // refresh token because CodexBar deliberately never publishes it back to auth.json. - throw CodexOAuthCredentialsError.nativeRefreshRequired - case .legacyCodexHome, .openCode: - // External OAuth files are explicitly read-only and have no safe writer handoff. - // Failing closed avoids consuming a refresh token owned by another application. - throw CodexOAuthCredentialsError.readOnlySource - } + // Native Codex CLI and external applications own their refresh tokens. Redeeming a + // shared token without publishing the rotated response strands its owner with the old + // token. No source has a safe writer handoff from the usage path. + throw credentials.source == .codexHome + ? CodexOAuthCredentialsError.nativeRefreshRequired + : CodexOAuthCredentialsError.readOnlySource } private static func shouldFetchResetCredits(_ context: ProviderFetchContext) -> Bool { @@ -532,12 +545,6 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { creditsAvailable: includeCredits || balance != nil ? creditsAvailable : nil) } - private static func attachingExtraUsage( - to result: ProviderFetchResult) -> ProviderFetchResult - { - self.replacingCredits(in: result, with: result.credits) - } - private static func replacingCredits( in result: ProviderFetchResult, with credits: CreditsSnapshot?) -> ProviderFetchResult @@ -624,69 +631,42 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { credentials: credentials, updatedAt: updatedAt) + let usage: UsageSnapshot if let reconciled { let dataConfidence: UsageDataConfidence = usageResponse.rateLimit?.hasWindowDecodeFailure == true || usageResponse.additionalRateLimitsDecodeFailed ? .unknown : .exact - let result = CodexOAuthFetchStrategy().makeResult( - usage: reconciled.toUsageSnapshot() - .withCodexResetCredits(resetCredits) - .withDataConfidence(dataConfidence), - credits: credits, - sourceLabel: "oauth") - return Self.markResetCreditsAttempted( - Self.attachingExtraUsage(to: result), - attempted: codexResetCreditsAttempted) - } - - guard credits != nil - || (resetCredits?.availableInventory(at: updatedAt).count ?? 0) > 0 - || allowEmptyUsageForResetCreditEnrichment - else { - throw UsageError.noRateLimitsFound - } - - // Credit balances and manual resets remain useful when OAuth omits - // rate-limit windows. Keep the partial result instead of discarding it. - let result = CodexOAuthFetchStrategy().makeResult( - usage: UsageSnapshot( + usage = reconciled.toUsageSnapshot() + .withCodexResetCredits(resetCredits) + .withDataConfidence(dataConfidence) + } else { + guard credits != nil + || (resetCredits?.availableInventory(at: updatedAt).count ?? 0) > 0 + || allowEmptyUsageForResetCreditEnrichment + else { + throw UsageError.noRateLimitsFound + } + // Credit balances and manual resets remain useful when OAuth omits + // rate-limit windows. Keep the partial result instead of discarding it. + usage = UsageSnapshot( primary: nil, secondary: nil, - tertiary: nil, codexResetCredits: resetCredits, updatedAt: updatedAt, identity: CodexReconciledState.oauthIdentity( response: usageResponse, - credentials: credentials)), - credits: credits, - sourceLabel: "oauth") - return Self.markResetCreditsAttempted( - Self.attachingExtraUsage(to: result), - attempted: codexResetCreditsAttempted) - } - - private static func markResetCreditsAttempted( - _ result: ProviderFetchResult, - attempted: Bool) -> ProviderFetchResult - { - guard attempted else { return result } + credentials: credentials)) + } + let strategy = Self() return ProviderFetchResult( - usage: result.usage, - credits: result.credits, - dashboard: result.dashboard, - sourceLabel: result.sourceLabel, - strategyID: result.strategyID, - strategyKind: result.strategyKind, - codexResetCreditsAttempted: true, - codexMonthlyLimitEnrichmentFailed: result.codexMonthlyLimitEnrichmentFailed, - diagnostic: result.diagnostic, - claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, - claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, - claudeOAuthCredentialOwner: result.claudeOAuthCredentialOwner, - claudeOAuthKeychainCredentialMismatch: result.claudeOAuthKeychainCredentialMismatch, - claudeOAuthKeychainCredentialAbsent: result.claudeOAuthKeychainCredentialAbsent, - claudeOAuthKeychainCredentialUnavailable: result.claudeOAuthKeychainCredentialUnavailable) + usage: CodexExtraUsageCost.attaching(to: usage, credits: credits), + credits: credits, + dashboard: nil, + sourceLabel: "oauth", + strategyID: strategy.id, + strategyKind: strategy.kind, + codexResetCreditsAttempted: codexResetCreditsAttempted) } private static func replacingWithCLIMonthlyLimitIfAvailable( @@ -815,13 +795,6 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { }) } - private static func defersResetCreditFetchToApp(_ context: ProviderFetchContext) -> Bool { - if case .app = context.runtime { - return true - } - return false - } - private static func fetchResetCreditsIfRequested( context: ProviderFetchContext, credentials: CodexOAuthCredentials, @@ -876,10 +849,9 @@ extension CodexOAuthFetchStrategy { } static func _prepareCredentialsForTesting( - _ credentials: CodexOAuthCredentials, - env: [String: String] = [:]) async throws -> CodexOAuthCredentials + _ credentials: CodexOAuthCredentials) async throws -> CodexOAuthCredentials { - try await self.prepareCredentialsForUsage(credentials, env: env) + try self.prepareCredentialsForUsage(credentials) } static func _applySpendControlsMonthlyLimitForTesting( diff --git a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift index 698694ff91..66e6d794d5 100644 --- a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -246,9 +246,7 @@ struct CodexOAuthCredentialReadTests { homeDirectory: home, allowExternalSources: true) let error = await #expect(throws: CodexOAuthCredentialsError.self) { - try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( - credentials, - env: ["XDG_DATA_HOME": dataHome.path]) + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) } guard case .readOnlySource = error else { Issue.record("Expired external credentials must fail closed") @@ -321,9 +319,7 @@ struct CodexOAuthCredentialReadTests { lastRefresh: Date(timeIntervalSince1970: 0), source: .codexHome) let error = await #expect(throws: CodexOAuthCredentialsError.self) { - try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( - credentials, - env: ["CODEX_HOME": "/tmp/codexbar-native-refresh-memory"]) + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) } guard case .nativeRefreshRequired = error else { Issue.record("Native stale credentials must be handed to Codex CLI") diff --git a/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift b/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift index 3823502268..4817bf7e10 100644 --- a/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift +++ b/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift @@ -4,6 +4,127 @@ import Testing @Suite(CodexCredentialFixtures()) struct CodexOAuthExpiryPipelineTests { + private typealias Reader = @Sendable (CodexCredentialFileAccess.Operation, URL) throws -> Data + + @Test(arguments: ["missing", "partial", "incomplete", "expired", "near-expiry"]) + func `OAuth fetch retries an owner publication in progress`(publication: String) async throws { + let fresh = try Self.fixture(expiration: 4_102_444_800, lastRefresh: "2000-01-01T00:00:00Z") + let stale = try Self.fixture( + expiration: publication == "near-expiry" ? Int64(Date().timeIntervalSince1970 + 120) : 1, + lastRefresh: "2000-01-01T00:00:00Z") + let reads = LockIsolated(0) + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(fresh.token)") + #expect(request.value(forHTTPHeaderField: "ChatGPT-Account-Id") == "fixture-workspace") + return try Self.response(request, body: Self.usageBody) + } + let reader: Reader = { operation, url in + guard case .read = operation else { return Data(url.resolvingSymlinksInPath().path.utf8) } + let attempt = reads.value + 1 + reads.setValue(attempt) + guard attempt == 1 else { return fresh.data } + switch publication { + case "missing": throw CocoaError(.fileReadNoSuchFile) + case "partial": return Data(#"{"tokens":"#.utf8) + case "incomplete": return Data(#"{"tokens":{}}"#.utf8) + default: return stale.data + } + } + let result = try await CodexCredentialFileAccess.$testIO.withValue(reader) { + try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await CodexOAuthFetchStrategy().fetch(Self.context(mode: .oauth, managed: true, home: fresh.home)) + } + } + #expect(result.usage.primary?.usedPercent == 22) + #expect(reads.value == 2) + #expect(await transport.requests().count == 1) + try fresh.expectUnchanged() + } + + @Test + func `OAuth availability retries a partial credential publication`() async throws { + let fresh = try Self.fixture(expiration: 4_102_444_800, lastRefresh: "2000-01-01T00:00:00Z") + let reads = LockIsolated(0) + let reader: Reader = { operation, url in + guard case .read = operation else { return Data(url.resolvingSymlinksInPath().path.utf8) } + reads.setValue(reads.value + 1) + return reads.value == 1 ? Data("{".utf8) : fresh.data + } + let available = await CodexCredentialFileAccess.$testIO.withValue(reader) { + await CodexOAuthFetchStrategy().isAvailable(Self.context(mode: .auto, managed: true, home: fresh.home)) + } + #expect(available) + #expect(reads.value == 2) + } + + @Test(arguments: ["missing", "partial", "incomplete", "expired", "unreadable"]) + func `OAuth read retries are bounded and preserve the final error`(failure: String) async throws { + let stale = try Self.fixture(expiration: 1, lastRefresh: "2000-01-01T00:00:00Z") + let reads = LockIsolated(0) + let transport = ProviderHTTPTransportStub { _ in + Issue.record("Unusable credentials must never reach HTTP") + throw URLError(.cancelled) + } + let reader: Reader = { operation, url in + guard case .read = operation else { return Data(url.resolvingSymlinksInPath().path.utf8) } + reads.setValue(reads.value + 1) + switch failure { + case "missing": throw CocoaError(.fileReadNoSuchFile) + case "unreadable": throw CocoaError(.fileReadNoPermission) + case "partial": return Data("{".utf8) + case "incomplete": return Data(#"{"tokens":{}}"#.utf8) + default: return stale.data + } + } + await CodexCredentialFileAccess.$testIO.withValue(reader) { + await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + do { + _ = try await CodexOAuthFetchStrategy().fetch( + Self.context(mode: .oauth, managed: true, home: stale.home)) + Issue.record("Expected a credential error") + } catch let error as CodexOAuthCredentialsError { + switch (failure, error) { + case ("missing", .notFound), ("partial", .decodeFailed), ("incomplete", .missingTokens), + ("expired", .nativeRefreshRequired), ("unreadable", .unreadable): break + default: Issue.record("The final credential failure was misclassified") + } + } catch { + Issue.record("Unexpected error type") + } + } + } + #expect(reads.value == 3) + #expect(await transport.requests().isEmpty) + try stale.expectUnchanged() + } + + @Test + func `cancelled OAuth fetch does not read credentials`() async throws { + let fresh = try Self.fixture(expiration: 4_102_444_800, lastRefresh: "2000-01-01T00:00:00Z") + let reads = LockIsolated(0) + let reader: Reader = { operation, url in + guard case .read = operation else { return Data(url.resolvingSymlinksInPath().path.utf8) } + reads.setValue(reads.value + 1) + throw CocoaError(.fileReadNoSuchFile) + } + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return await CodexCredentialFileAccess.$testIO.withValue(reader) { + do { + _ = try await CodexOAuthFetchStrategy().fetch( + Self.context(mode: .oauth, managed: true, home: fresh.home)) + return false + } catch is CancellationError { + return true + } catch { + return false + } + } + } + #expect(await task.value) + #expect(reads.value == 0) + } + @Test(arguments: [ProviderSourceMode.auto, .oauth]) func `managed refresh observes owner credential replacement on the next fetch`( mode: ProviderSourceMode) async throws diff --git a/Tests/CodexBarTests/CodexPlanTransitionPublicationTests.swift b/Tests/CodexBarTests/CodexPlanTransitionPublicationTests.swift new file mode 100644 index 0000000000..4f54d927c6 --- /dev/null +++ b/Tests/CodexBarTests/CodexPlanTransitionPublicationTests.swift @@ -0,0 +1,171 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CodexPlanTransitionPublicationTests { + private let epoch = Int(Date().timeIntervalSince1970) - 30 + + @Test + func `new plan cannot borrow missing weekly usage from the old plan`() async throws { + let previous = try self.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let current = try self.snapshot(plan: "pro", usedPercent: 5, offset: 10, resetOffset: 3600) + .with(primary: nil, secondary: nil) + #expect(UsageStore.codexBackfillingResetWindows(current, from: previous).secondary == nil) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(current), + previousSnapshot: previous, + previousSourceLabel: "oauth", + missingWindowBackfillSnapshot: previous, + fetchConfirmation: { self.outcome(current) }) + let published = try #require(admission.outcome).result.get().usage + #expect(published.secondary == nil) + } + + @Test(arguments: [0, 5], [false, true]) + func `new token plan replaces previous plan quota baseline`( + usedPercent: Int, missingPrevious: Bool) async throws + { + let previous = try self.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let current = try self.snapshot(plan: "pro", usedPercent: usedPercent, offset: 10, resetOffset: 3600) + let confirmation = try self.snapshot(plan: "pro", usedPercent: usedPercent, offset: 20, resetOffset: 3600) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(current), + previousSnapshot: missingPrevious ? nil : previous, + previousSourceLabel: "oauth", + missingWindowBackfillSnapshot: previous, + fetchConfirmation: { self.outcome(confirmation) }) + let published = try #require(admission.outcome).result.get().usage + #expect(published.loginMethod(for: .codex) == "pro") + #expect(published.secondary?.usedPercent == Double(usedPercent)) + #expect(published.secondary?.resetsAt == current.secondary?.resetsAt) + #expect(admission.pendingCandidate == nil) + } + + @Test(arguments: ["plus", " PLUS ", ""]) + func `same or unknown token plan cannot discard previous quota evidence`(plan: String) async throws { + let previous = try self.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let current = try self.snapshot(plan: plan, usedPercent: 0, offset: 10, resetOffset: 3600) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(current), + previousSnapshot: previous, + previousSourceLabel: "oauth", + missingWindowBackfillSnapshot: previous, + fetchConfirmation: { self.outcome(current) }) + #expect(admission.outcome == nil) + } + + @Test(arguments: ["plus", ""], [false, true]) + func `near zero confirmation must retain the initial plan`(plan: String, hasPrevious: Bool) async throws { + let previous = try self.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let initial = try self.snapshot(plan: "pro", usedPercent: 0, offset: 10, resetOffset: 3600) + let confirmation = try self.snapshot(plan: plan, usedPercent: 0, offset: 20, resetOffset: 3600) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(initial), + previousSnapshot: hasPrevious ? previous : nil, + previousSourceLabel: "oauth", + missingWindowBackfillSnapshot: hasPrevious ? previous : nil, + fetchConfirmation: { self.outcome(confirmation) }) + #expect(admission.outcome == nil) + #expect(admission.pendingCandidate == nil) + } + + @Test + func `fresh nonzero confirmation can publish its own plan`() async throws { + let initial = try self.snapshot(plan: "pro", usedPercent: 0, offset: 10, resetOffset: 3600) + let confirmation = try self.snapshot(plan: "plus", usedPercent: 5, offset: 20, resetOffset: 3600) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(initial), + previousSnapshot: nil, + previousSourceLabel: nil, + missingWindowBackfillSnapshot: nil, + fetchConfirmation: { self.outcome(confirmation) }) + let published = try #require(admission.outcome).result.get().usage + #expect(published.loginMethod(for: .codex) == "plus") + #expect(published.secondary?.usedPercent == 5) + } + + @Test(arguments: [false, true]) + func `older or incomplete new plan cannot discard previous quota evidence`(older: Bool) async throws { + let previous = try self.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let current = try self.snapshot(plan: "pro", usedPercent: 0, offset: older ? -1 : 10, resetOffset: 3600) + .withDataConfidence(older ? .exact : .unknown) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(current), + previousSnapshot: previous, + previousSourceLabel: "oauth", + missingWindowBackfillSnapshot: previous, + fetchConfirmation: { self.outcome(current) }) + #expect(admission.outcome == nil) + } + + fileprivate func snapshot(plan: String, usedPercent: Int, offset: Int, resetOffset: Int) throws -> UsageSnapshot { + let epoch = self.epoch + let payload = try JSONSerialization.data(withJSONObject: [ + "email": "fixture@example.com", + "https://api.openai.com/auth": ["chatgpt_plan_type": plan], + ]).base64EncodedString() + let credentials = CodexOAuthCredentials( + accessToken: "fixture-access", + refreshToken: "fixture-refresh", + idToken: "fixture.\(payload).signature", + accountId: "fixture-account", + lastRefresh: nil) + let body = """ + {"rate_limit":{"primary_window":{"used_percent":5,"reset_at":\(epoch + 3600), + "limit_window_seconds":18000},"secondary_window":{"used_percent":\(usedPercent), + "reset_at":\(epoch + resetOffset),"limit_window_seconds":604800}}} + """ + let response = try JSONDecoder().decode(CodexUsageResponse.self, from: Data(body.utf8)) + let reconciled = try #require(CodexReconciledState.fromOAuth( + response: response, + credentials: credentials, + updatedAt: Date(timeIntervalSince1970: Double(epoch + offset)))) + return reconciled.toUsageSnapshot().withDataConfidence(.exact) + } + + private func outcome(_ snapshot: UsageSnapshot) -> ProviderFetchOutcome { + let result = ProviderFetchResult( + usage: snapshot, + credits: nil, + dashboard: nil, + sourceLabel: "oauth", + strategyID: "codex.oauth", + strategyKind: .oauth) + return ProviderFetchOutcome(result: .success(result), attempts: []) + } +} + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `subscription upgrade publishes new plan and quota without disabling Codex`() async throws { + let suite = "CodexPlanTransitionPublicationTests-upgrade" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "fixture@example.com", identity: .providerAccount(id: "fixture-account")) + defer { settings._test_liveSystemCodexAccount = nil } + let fixture = CodexPlanTransitionPublicationTests() + let previous = try fixture.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let current = try fixture.snapshot(plan: "pro", usedPercent: 0, offset: 10, resetOffset: 3600) + let confirmation = try fixture.snapshot(plan: "pro", usedPercent: 0, offset: 20, resetOffset: 3600) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, settings: settings, snapshot: previous, error: nil) + store.lastSourceLabels[.codex] = "oauth" + let loader = SequencedCodexSnapshotLoader(steps: [.success(current), .success(confirmation)]) + self.installContextualCodexProvider(on: store, sourceLabel: "oauth", kind: .oauth) { _ in + try await loader.load() + } + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.loginMethod(for: .codex) == "pro") + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 0) + #expect(store.lastKnownResetSnapshots[.codex]?.loginMethod(for: .codex) == "pro") + #expect(store.errors[.codex] == nil) + #expect(await loader.callCount == 2) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 02e0a6d600..c3da242adc 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1239,6 +1239,11 @@ struct ProviderArchitectureGatekeeperTests { anchor: "self.sessionEquivalentBurnCache.removeValue(forKey: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), + SuppressedProviderReference( + path: "Sources/CodexBar/UsageStore+Refresh.swift", + anchor: "let resetBackfillSource = provider == .codex && Self.codexPlanChanged(from: resetBackfillSource, to: snapshot)", + expectedProviderIDs: ["codex"], + reason: "Codex subscription changes must not inherit reset times from the previous plan."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", anchor: "previousSourceLabel: hydratedPrior?.sourceLabel ?? self.lastSourceLabels[.codex],", diff --git a/docs/codex-oauth.md b/docs/codex-oauth.md index 35cf7bcf9e..86613fdd89 100644 --- a/docs/codex-oauth.md +++ b/docs/codex-oauth.md @@ -53,6 +53,11 @@ If expiry is unavailable, the existing eight-day `last_refresh` rule applies; a timestamp still requires refresh. This keeps a future-expiry token on the OAuth path, including its model-specific usage windows, even when the refresh timestamp is old (#3221, #3222). +OAuth strategy reads allow three attempts, with cancellable 50-millisecond delays, to observe an owner publication +that overlaps availability or usage fetching. Usage rereads native credentials inside the renewal window; this is +not token redemption and does not alter the five-minute expiry margin. After the bounded retry, missing, unreadable, +malformed, incomplete, and stale credentials retain their separate error categories. No credentials are written. + The claim must be a signed integer JSON spelling within Codex's supported UTC date range (`-8334601228800...8210266876799` seconds). Booleans, strings, fractions, integral floating-point or exponent spellings, overflow, duplicate claims, and out-of-range dates fall back to age. diff --git a/docs/codex.md b/docs/codex.md index d57de9bff4..277f2c8935 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -29,6 +29,9 @@ Usage source picker: ### OAuth API (preferred for the app) - Reads OAuth tokens from `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`). +- OAuth availability and usage reads retry a missing, unreadable, or partially published credential file twice, + 50 milliseconds apart. Usage also rereads a native token due for renewal before reporting that it needs refresh. + A successful retry retains the selected workspace; unchanged stale credentials still require their owner's renewal. - CodexBar never publishes refreshed native tokens into `auth.json`; when native credentials are stale, the explicit OAuth path delegates recovery to the Codex CLI, which owns that file. If the CLI is unavailable, the OAuth error is surfaced instead of mutating the shared file. @@ -42,6 +45,9 @@ Usage source picker: - Suspicious weekly resets keep the last trusted usage while confirmation is pending. A successful refresh for the same account and workspace clears stale connectivity errors even when the reading is withheld; failed, cancelled, or superseded refreshes do not clear them. Cached usage, credits, and other accounts remain unchanged. +- A fresh exact OAuth result with a changed, known plan starts a new quota baseline for that account. Previous-plan + reset backfill and pending reset candidates cannot hold the old plan on screen. A first near-zero weekly reading + still requires confirmation from the same plan; missing or unchanged plans retain the normal reset safeguards. - Credits-only updates preserve pending weekly-reset evidence in memory and account-snapshot storage, including when published credits are cleared. Candidate admission, expiry, boundary tolerances, and account guards remain unchanged; preserving evidence does not make an otherwise incompatible reset eligible for publication. From 1060f3493af8fd06c9f24213d4e7043695a6a11f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 01:49:24 -0700 Subject: [PATCH 048/122] fix(refresh): detect the nested ChatGPT Codex app-server (#4090) Adaptive agent-aware refresh now recognizes the Codex app-server nested inside the ChatGPT app (both documented executable paths under /Applications/ChatGPT.app), after checking the running PID's kernel-reported path, OpenAI code signature, and symlink redirects on every scan; the outer bundle's Gatekeeper assessment is cached by bundle, Info.plist, executable, and CodeResources identity and retried on failure. Recent rollout activity stays authoritative. Fixes #4069. Thanks @jaychou0642-create! --- CHANGELOG.md | 1 + Sources/CodexBarCore/AgentSession.swift | 37 +-- .../LocalAgentSessionScanner.swift | 266 +++++++++--------- .../AgentSessionParserTests.swift | 108 ++++++- .../ChatGPTBundleTrustCacheTests.swift | 164 +++++++++++ .../CodexSessionRolloutTests.swift | 104 +++++-- .../ProviderArchitectureGatekeeperTests.swift | 29 +- docs/refresh-loop.md | 16 +- 8 files changed, 527 insertions(+), 198 deletions(-) create mode 100644 Tests/CodexBarTests/ChatGPTBundleTrustCacheTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 652af0bcde..2b28263617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Kimi Code: mark shorter Code windows as blocked when the known monthly membership pool is exhausted, without showing fresh quota or pace forecasts (#3536). - z.ai: explain unavailable Coding Plan usage for empty or unsupported quota shapes while preserving recognized quotas and analytics (#2522). - Grok: keep local token totals visible in Usage & Spend and shared cards across wider history views and billing outages, with consistent daily scan windows (#3716). Thanks @Chipagosfinest! +- Adaptive refresh: recognize ChatGPT's nested Codex app-server with per-scan running-process validation and update-aware signed-bundle assessment caching, avoiding repeated Gatekeeper subprocesses while keeping idle servers at the normal cadence (#4069, #4090). - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBarCore/AgentSession.swift b/Sources/CodexBarCore/AgentSession.swift index 436572852d..b04038f946 100644 --- a/Sources/CodexBarCore/AgentSession.swift +++ b/Sources/CodexBarCore/AgentSession.swift @@ -327,34 +327,25 @@ public enum AgentPSOutputParser { } } - static func chatGPTCodexAppServerExecutable( + static let chatGPTCodexExecutablePaths: Set = [ + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex", + ] + + static func hasTrustedChatGPTCodexAppServer( in records: [AgentProcessRecord], - homeDirectory: URL) -> String? + validator: (AgentProcessRecord) -> Bool) -> Bool { - let allowedPaths = Set([ - URL(fileURLWithPath: "/Applications/ChatGPT.app/Contents/Resources/codex") - .standardizedFileURL.path, - homeDirectory.appendingPathComponent("Applications/ChatGPT.app/Contents/Resources/codex") - .standardizedFileURL.path, - ]) - - return records.lazy.compactMap { record -> String? in - guard record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue, - self.arguments(record).contains("app-server"), - let executable = record.arguments?.first ?? record.command.split(whereSeparator: \ .isWhitespace) - .first.map(String.init) - else { return nil } - - let path = URL(fileURLWithPath: executable).standardizedFileURL.path - return allowedPaths.contains(path) ? path : nil - }.first + records.contains { record in + let executable = record.arguments?.first ?? record.command.split(whereSeparator: \ .isWhitespace) + .first.map(String.init) ?? "" + return self.chatGPTCodexExecutablePaths.contains(executable) && + self.arguments(record).contains("app-server") && validator(record) + } } private static func arguments(_ record: AgentProcessRecord) -> [String] { - if let arguments = record.arguments { - return Array(arguments.dropFirst()) - } - return self.arguments(record.command) + Array((record.arguments ?? record.command.split(whereSeparator: \ .isWhitespace).map(String.init)).dropFirst()) } private static func arguments(_ command: String) -> [String] { diff --git a/Sources/CodexBarCore/LocalAgentSessionScanner.swift b/Sources/CodexBarCore/LocalAgentSessionScanner.swift index df377b4cd0..2befcb3154 100644 --- a/Sources/CodexBarCore/LocalAgentSessionScanner.swift +++ b/Sources/CodexBarCore/LocalAgentSessionScanner.swift @@ -1,4 +1,7 @@ import Foundation +#if os(macOS) +import Security +#endif final class FutureModificationDateClamp: @unchecked Sendable { private let lock = NSLock() @@ -20,27 +23,102 @@ final class FutureModificationDateClamp: @unchecked Sendable { } } -private final class TrustedCodexAppServerCache: @unchecked Sendable { +enum ChatGPTCodexProcessTrust { + #if os(macOS) + static func isTrusted( + _ pid: Int32, + executablePath: (Int32) -> String? = DarwinProcessEnumerator.executablePath, + resolvePath: (String) -> String = { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path }, + processIsTrusted: (Int32) -> Bool = Self.isOpenAIProcess, + appIsTrusted: (String) -> Bool = { ChatGPTBundleTrustCache.shared.isTrusted($0) }) -> Bool + { + guard let path = executablePath(pid), + AgentPSOutputParser.chatGPTCodexExecutablePaths.contains(path), + resolvePath(path) == path + else { return false } + // Check the running code, not argv or a cached on-disk pathname. Validate the outer app's seal and identity. + return processIsTrusted(pid) && appIsTrusted("/Applications/ChatGPT.app") + } + + private static func isOpenAIProcess(_ pid: Int32) -> Bool { + var code: SecCode? + guard SecCodeCopyGuestWithAttributes( + nil, [kSecGuestAttributePid: pid] as CFDictionary, SecCSFlags(), &code) == errSecSuccess, + let code + else { return false } + var requirement: SecRequirement? + let requirementText = "anchor apple generic and certificate leaf[subject.OU] = \"2DC432GLL2\"" + guard SecRequirementCreateWithString( + requirementText as CFString, SecCSFlags(), &requirement) == errSecSuccess, + let requirement + else { return false } + return SecCodeCheckValidity(code, SecCSFlags(), requirement) == errSecSuccess + } + #else + static func isTrusted(_: Int32) -> Bool { + false + } + #endif +} + +#if os(macOS) +final class ChatGPTBundleTrustCache: @unchecked Sendable { + typealias Identity = [URL: NSDictionary] + static let shared = ChatGPTBundleTrustCache() private let lock = NSLock() - private var trustedExecutablePaths = Set() + private var trustedIdentity: Identity? - func isTrusted(_ path: String, validator: @Sendable (String) -> Bool) -> Bool { + func isTrusted( + _ path: String, + identity: (String) -> Identity? = ChatGPTBundleTrustCache.identity, + assess: (String) -> Bool = { CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) }) -> Bool + { self.lock.withLock { - if self.trustedExecutablePaths.contains(path) { - return true + guard let current = identity(path) else { + self.trustedIdentity = nil + return false } - guard validator(path) else { return false } - self.trustedExecutablePaths.insert(path) + if self.trustedIdentity == current { return true } + self.trustedIdentity = nil + guard assess(path), identity(path) == current else { return false } + self.trustedIdentity = current return true } } + + static func identity(_ path: String) -> Identity? { + let bundle = URL(fileURLWithPath: path) + // Read Info.plist directly: Bundle caches it across in-process app updates. + let plist = bundle.appendingPathComponent("Contents/Info.plist") + guard let data = try? Data(contentsOf: plist), + let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let executable = info["CFBundleExecutable"] as? String, + !executable.isEmpty, !executable.contains("/"), executable != ".", executable != ".." + else { return nil } + var identity: Identity = [:] + for url in [ + bundle, + plist, + bundle.appendingPathComponent("Contents/MacOS/\(executable)"), + bundle.appendingPathComponent("Contents/_CodeSignature/CodeResources"), + ] { + guard url.resolvingSymlinksInPath().path == url.path, + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + attributes[.systemNumber] != nil, attributes[.systemFileNumber] != nil, + attributes[.modificationDate] != nil + else { return nil } + identity[url] = attributes as NSDictionary + } + return identity + } } +#endif public struct LocalAgentSessionScanner: Sendable { typealias ProcessOutputProvider = @Sendable ([String: String]) async -> String typealias CWDProvider = @Sendable ([Int32], [String: String]) async -> [Int32: String] typealias ProcessEnvironmentProvider = @Sendable ([Int32]) async -> [Int32: [String: String]] - typealias AppServerTrustValidator = @Sendable (String) -> Bool + typealias AppServerTrustValidator = @Sendable (AgentProcessRecord) -> Bool private struct Rollout: Sendable { let url: URL @@ -53,15 +131,13 @@ public struct LocalAgentSessionScanner: Sendable { let host: String let now: Date let codexAppServerPresent: Bool - let includeFileOnlySessions: Bool - let includeTrustedCodexAppServerRollouts: Bool + let includeUnmatchedCodexRollouts: Bool let threadMetadata: [String: CodexThreadMetadata] let piFamilySessions: [AgentSession] } public let config: SessionScanConfig private let futureModificationDateClamp = FutureModificationDateClamp() - private let trustedCodexAppServerCache = TrustedCodexAppServerCache() private let processOutputProvider: ProcessOutputProvider? private let cwdProvider: CWDProvider? private let processEnvironmentProvider: ProcessEnvironmentProvider? @@ -69,21 +145,16 @@ public struct LocalAgentSessionScanner: Sendable { private let didVisitDirectoryEntry: (@Sendable () -> Void)? public init(config: SessionScanConfig = SessionScanConfig()) { - self.config = config - self.processOutputProvider = nil - self.cwdProvider = nil - self.processEnvironmentProvider = nil - self.appServerTrustValidator = { CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) } - self.didVisitDirectoryEntry = nil + self.init(config: config, processOutputProvider: nil, cwdProvider: nil) } init( config: SessionScanConfig = SessionScanConfig(), - processOutputProvider: @escaping ProcessOutputProvider, - cwdProvider: @escaping CWDProvider, + processOutputProvider: ProcessOutputProvider?, + cwdProvider: CWDProvider?, processEnvironmentProvider: ProcessEnvironmentProvider? = nil, appServerTrustValidator: @escaping AppServerTrustValidator = { - CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) + ChatGPTCodexProcessTrust.isTrusted($0.pid) }, didVisitDirectoryEntry: (@Sendable () -> Void)? = nil) { @@ -106,14 +177,8 @@ public struct LocalAgentSessionScanner: Sendable { AgentPSOutputParser.agentProcesses(from: allProcesses)) .prefix(max(0, self.config.maxProcessCount))) let homeDirectory = URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true) - let trustedCodexAppServerPresent = if let executable = AgentPSOutputParser.chatGPTCodexAppServerExecutable( - in: allProcesses, - homeDirectory: homeDirectory) - { - self.trustedCodexAppServerCache.isTrusted(executable, validator: self.appServerTrustValidator) - } else { - false - } + let trustedCodexAppServerPresent = AgentPSOutputParser.hasTrustedChatGPTCodexAppServer( + in: allProcesses, validator: self.appServerTrustValidator) guard Self.shouldScanSessionMetadata( hasAgentProcesses: !processes.isEmpty, includeFileOnlySessions: includeFileOnlySessions, @@ -121,15 +186,9 @@ public struct LocalAgentSessionScanner: Sendable { else { return [] } let codexAppServerPresent = AgentPSOutputParser.hasCodexAppServer(in: allProcesses) || trustedCodexAppServerPresent - let cwdByPID = if let cwdProvider = self.cwdProvider { - await cwdProvider(processes.map(\ .pid), environment) - } else { - await self.cwdByPID(processes.map(\ .pid), environment: environment) - } - let codexCWDs = processes.compactMap { process -> String? in - guard AgentPSOutputParser.provider(for: process) == .codex else { return nil } - return cwdByPID[process.pid] - } + let cwdByPID = await self.cwdByPID(processes.map(\.pid), environment: environment) + let codexCWDs = processes.filter { AgentPSOutputParser.provider(for: $0) == .codex } + .compactMap { cwdByPID[$0.pid] } let codexHomeDirectory = URL( fileURLWithPath: environment["CODEX_HOME"] ?? homeDirectory.appendingPathComponent(".codex").path, isDirectory: true) @@ -141,13 +200,7 @@ public struct LocalAgentSessionScanner: Sendable { ? self.config.directoryScanBudget : min(self.config.directoryScanBudget, self.config.adaptiveDirectoryScanBudget), didVisitEntry: self.didVisitDirectoryEntry) - var piFamilyDirectoryBudget = DirectoryMetadataScanBudget( - maxEntryCount: self.config.maxDirectoryEntryCount, - maxDepth: self.config.maxDirectoryDepth, - timeLimit: includeFileOnlySessions - ? self.config.directoryScanBudget - : min(self.config.directoryScanBudget, self.config.adaptiveDirectoryScanBudget), - didVisitEntry: self.didVisitDirectoryEntry) + var piFamilyDirectoryBudget = directoryBudget let piFamilySessions = PiFamilySessionScanner.scan( input: PiFamilySessionScanner.ScanInput( processes: processes, @@ -157,14 +210,12 @@ public struct LocalAgentSessionScanner: Sendable { host: host, config: self.config), directoryBudget: &piFamilyDirectoryBudget) - let includeTrustedCodexAppServerRollouts = trustedCodexAppServerPresent && !includeFileOnlySessions - let rollouts: [Rollout] = if includeFileOnlySessions || !codexCWDs.isEmpty || - includeTrustedCodexAppServerRollouts - { + let includeUnmatchedCodexRollouts = includeFileOnlySessions || trustedCodexAppServerPresent + let rollouts: [Rollout] = if includeUnmatchedCodexRollouts || !codexCWDs.isEmpty { self.codexRollouts( now: now, codexHomeDirectory: codexHomeDirectory, - matchingCWDs: includeFileOnlySessions || includeTrustedCodexAppServerRollouts ? nil : codexCWDs, + matchingCWDs: includeUnmatchedCodexRollouts ? nil : codexCWDs, directoryBudget: &directoryBudget) } else { [] @@ -182,8 +233,7 @@ public struct LocalAgentSessionScanner: Sendable { host: host, now: now, codexAppServerPresent: codexAppServerPresent, - includeFileOnlySessions: includeFileOnlySessions, - includeTrustedCodexAppServerRollouts: includeTrustedCodexAppServerRollouts, + includeUnmatchedCodexRollouts: includeUnmatchedCodexRollouts, threadMetadata: threadMetadata, piFamilySessions: piFamilySessions), directoryBudget: &directoryBudget) @@ -197,12 +247,7 @@ public struct LocalAgentSessionScanner: Sendable { { let contexts = await self.piSessionProcessContexts(environment: environment) var seen = Set() - return contexts.compactMap { context in - guard let workingDirectory = context.workingDirectory, - seen.insert(workingDirectory.path).inserted - else { return nil } - return workingDirectory - } + return contexts.compactMap(\.workingDirectory).filter { seen.insert($0.path).inserted } } /// Returns the command selectors and project directories of live Pi-family processes so cost scans can @@ -220,11 +265,7 @@ public struct LocalAgentSessionScanner: Sendable { .filter { AgentPSOutputParser.provider(for: $0) == .pi }) guard !processes.isEmpty, self.config.maxProcessCount > 0 else { return [] } - let cwdByPID = if let cwdProvider = self.cwdProvider { - await cwdProvider(processes.map(\.pid), environment) - } else { - await self.cwdByPID(processes.map(\.pid), environment: environment) - } + let cwdByPID = await self.cwdByPID(processes.map(\.pid), environment: environment) var seen = Set() let distinctContexts: [PiSessionProcessContext] = processes.compactMap { process in let workingDirectory = cwdByPID[process.pid] @@ -277,12 +318,7 @@ public struct LocalAgentSessionScanner: Sendable { environment: environment, resolvedWorkingDirectory: resolvedWorkingDirectory) let key = reader.databaseURL.path - if var group = groups[key] { - group.sessionIDs.insert(rollout.metadata.sessionID) - groups[key] = group - } else { - groups[key] = (reader, [rollout.metadata.sessionID]) - } + groups[key, default: (reader, [])].sessionIDs.insert(rollout.metadata.sessionID) } var metadata: [String: CodexThreadMetadata] = [:] @@ -325,62 +361,40 @@ public struct LocalAgentSessionScanner: Sendable { cwdByPID: cwdByPID) for process in processes { - guard let provider = AgentPSOutputParser.provider(for: process) else { continue } - let cwd = cwdByPID[process.pid] - switch provider { - case .claude: - let transcript = claudeTranscripts[process.pid] - sessions.append(AgentSession( - id: transcript?.url.deletingPathExtension().lastPathComponent ?? "pid:\(process.pid)", - provider: .claude, - source: AgentPSOutputParser.source(for: process), - state: self.config.state( - lastActivityAt: transcript?.modifiedAt, - now: context.now, - hasLiveProcess: true), - pid: process.pid, - cwd: cwd, - projectName: Self.projectName(cwd), - startedAt: process.startedAt, - lastActivityAt: transcript?.modifiedAt, - transcriptPath: transcript?.url.path, - host: context.host)) - case .codex: - let rollout = rollouts.first { candidate in - !matchedRolloutPaths.contains(candidate.url.path) && - AgentSessionCorrelation.codexWorkingDirectoriesMatch(candidate.metadata.cwd, cwd) - } - if let rollout { - matchedRolloutPaths.insert(rollout.url.path) - } - let rolloutSource = rollout?.metadata.sessionSource - sessions.append(AgentSession( - id: rollout?.metadata.sessionID ?? "pid:\(process.pid)", - provider: .codex, - source: rolloutSource == nil || rolloutSource == .unknown ? .cli : rolloutSource ?? .cli, - state: self.config.state( - lastActivityAt: rollout?.modifiedAt, - now: context.now, - hasLiveProcess: true), - pid: process.pid, - cwd: cwd ?? rollout?.metadata.cwd, - projectName: Self.projectName(cwd ?? rollout?.metadata.cwd), - sessionName: codexDescriptiveNamePIDs.contains(process.pid) - ? rollout?.metadata.descriptiveName( - threadMetadata: rollout.flatMap { context.threadMetadata[$0.metadata.sessionID] }) - : nil, - startedAt: process.startedAt, - lastActivityAt: rollout?.modifiedAt, - transcriptPath: rollout?.url.path, - host: context.host)) - // Provider-specific by design: Pi-family processes are correlated by PiFamilySessionScanner. - case .pi: - continue - } + // Pi-family processes are correlated by PiFamilySessionScanner. + guard let provider = AgentPSOutputParser.provider(for: process), provider != .pi else { continue } + let processCWD = cwdByPID[process.pid] + let rollout = provider == .codex ? rollouts.first { candidate in + !matchedRolloutPaths.contains(candidate.url.path) && + AgentSessionCorrelation.codexWorkingDirectoriesMatch(candidate.metadata.cwd, processCWD) + } : nil + if let rollout { matchedRolloutPaths.insert(rollout.url.path) } + let transcript = provider == .claude ? claudeTranscripts[process.pid] : nil + let modifiedAt = rollout?.modifiedAt ?? transcript?.modifiedAt + let cwd = processCWD ?? rollout?.metadata.cwd + let rolloutSource = rollout?.metadata.sessionSource + sessions.append(AgentSession( + id: rollout?.metadata.sessionID ?? transcript?.url.deletingPathExtension().lastPathComponent ?? + "pid:\(process.pid)", + provider: provider, + source: provider == .claude ? AgentPSOutputParser.source(for: process) : + (rolloutSource == .unknown ? nil : rolloutSource) ?? .cli, + state: self.config.state(lastActivityAt: modifiedAt, now: context.now, hasLiveProcess: true), + pid: process.pid, + cwd: cwd, + projectName: cwd.flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0).lastPathComponent }, + sessionName: codexDescriptiveNamePIDs.contains(process.pid) + ? rollout?.metadata.descriptiveName( + threadMetadata: rollout.flatMap { context.threadMetadata[$0.metadata.sessionID] }) + : nil, + startedAt: process.startedAt, + lastActivityAt: modifiedAt, + transcriptPath: rollout?.url.path ?? transcript?.url.path, + host: context.host)) } for rollout in rollouts - where (context.includeFileOnlySessions || context.includeTrustedCodexAppServerRollouts) && + where context.includeUnmatchedCodexRollouts && !matchedRolloutPaths.contains(rollout.url.path) { guard var session = CodexRolloutFirstLineParser.makeSession( @@ -484,6 +498,7 @@ public struct LocalAgentSessionScanner: Sendable { #endif private func cwdByPID(_ pids: [Int32], environment: [String: String]) async -> [Int32: String] { + if let cwdProvider = self.cwdProvider { return await cwdProvider(pids, environment) } guard !pids.isEmpty else { return [:] } #if canImport(Darwin) return Dictionary(uniqueKeysWithValues: pids.compactMap { pid in @@ -565,13 +580,4 @@ public struct LocalAgentSessionScanner: Sendable { .map { String($0) + "/" + name } .first { FileManager.default.isExecutableFile(atPath: $0) } } - - private static func standardized(_ path: String?) -> String? { - path.map { URL(fileURLWithPath: $0).standardizedFileURL.path } - } - - private static func projectName(_ cwd: String?) -> String? { - guard let cwd, !cwd.isEmpty else { return nil } - return URL(fileURLWithPath: cwd).lastPathComponent - } } diff --git a/Tests/CodexBarTests/AgentSessionParserTests.swift b/Tests/CodexBarTests/AgentSessionParserTests.swift index 842347f6e5..0005ef668f 100644 --- a/Tests/CodexBarTests/AgentSessionParserTests.swift +++ b/Tests/CodexBarTests/AgentSessionParserTests.swift @@ -1,6 +1,6 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore struct AgentSessionParserTests { @Test @@ -112,3 +112,109 @@ struct AgentSessionParserTests { try String(contentsOf: self.fixtureURL(name, extension: fileExtension), encoding: .utf8) } } + +#if os(macOS) +struct ChatGPTCodexProcessTrustTests { + private static let nested = + "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex" + + @Test(arguments: [ + "/Applications/ChatGPT.app/Contents/Resources/codex", + Self.nested, + ]) + func `signed process validates outer ChatGPT bundle rather than nested CLI bundle`(path: String) { + let trusted = ChatGPTCodexProcessTrust.isTrusted( + 123, + executablePath: { pid in + #expect(pid == 123) + return path + }, + resolvePath: { $0 }, + processIsTrusted: { $0 == 123 }, + appIsTrusted: { bundle in + #expect(bundle == "/Applications/ChatGPT.app") + return true + }) + #expect(trusted) + } + + @Test(arguments: [ + nil, + "/tmp/codex", + "/Users/test/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/ChatGPT-copy.app/Contents/Resources/codex", + ] as [String?]) + func `claimed command cannot replace kernel executable identity`(actualPath: String?) { + #expect(!ChatGPTCodexProcessTrust.isTrusted( + 123, + executablePath: { _ in actualPath }, + resolvePath: { $0 }, + processIsTrusted: { _ in + Issue.record("Unrecognized paths must be rejected before signature inspection") + return true + }, + appIsTrusted: { _ in true })) + } + + @Test(arguments: [false, true], [false, true]) + func `running signature and outer bundle assessment must both succeed`(processTrusted: Bool, bundleTrusted: Bool) { + let trusted = ChatGPTCodexProcessTrust.isTrusted( + 123, + executablePath: { _ in Self.nested }, + resolvePath: { $0 }, + processIsTrusted: { _ in processTrusted }, + appIsTrusted: { _ in bundleTrusted }) + #expect(trusted == (processTrusted && bundleTrusted)) + } + + @Test(arguments: [ + "/Users/test/Downloads/codex", + "/tmp/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex", + "/Applications/ChatGPT.app/Contents/Resources/other-codex", + ]) + func `symlink redirects cannot authorize scanning even with trusted signatures`(resolvedPath: String) { + #expect(!ChatGPTCodexProcessTrust.isTrusted( + 123, + executablePath: { _ in Self.nested }, + resolvePath: { _ in resolvedPath }, + processIsTrusted: { _ in true }, + appIsTrusted: { _ in true })) + } + + @Test + func `untrusted first candidate cannot hide a later trusted app server`() { + let records = AgentPSOutputParser.parse(""" + 123 1 Mon Jul 6 09:03:00 2026 \(Self.nested) app-server + 124 1 Mon Jul 6 09:03:00 2026 /Applications/ChatGPT.app/Contents/Resources/codex app-server + """) + #expect(AgentPSOutputParser.hasTrustedChatGPTCodexAppServer(in: records, validator: { $0.pid == 124 })) + } + + @Test(arguments: ["exec", "app-server-helper", "--help"]) + func `nested executable requires app server argument and cannot bypass trust as a CLI`(argument: String) { + let records = AgentPSOutputParser.parse("123 1 Mon Jul 6 09:03:00 2026 \(Self.nested) \(argument)") + #expect(!AgentPSOutputParser.hasTrustedChatGPTCodexAppServer(in: records, validator: { _ in + Issue.record("Non-server processes must not reach the app-server validator") + return true + })) + #expect(AgentPSOutputParser.agentProcesses(from: records).isEmpty) + } + + @Test + func `forged app server command cannot borrow installed ChatGPT identity`() throws { + let sleeper = Process() + sleeper.executableURL = URL(fileURLWithPath: "/bin/sleep") + sleeper.arguments = ["30"] + try sleeper.run() + defer { + sleeper.terminate() + sleeper.waitUntilExit() + } + let records = AgentPSOutputParser.parse( + "\(sleeper.processIdentifier) 1 Mon Jul 6 09:03:00 2026 \(Self.nested) app-server") + #expect(!AgentPSOutputParser.hasTrustedChatGPTCodexAppServer(in: records, validator: { + ChatGPTCodexProcessTrust.isTrusted($0.pid) + })) + } +} +#endif diff --git a/Tests/CodexBarTests/ChatGPTBundleTrustCacheTests.swift b/Tests/CodexBarTests/ChatGPTBundleTrustCacheTests.swift new file mode 100644 index 0000000000..9b53ad8e8b --- /dev/null +++ b/Tests/CodexBarTests/ChatGPTBundleTrustCacheTests.swift @@ -0,0 +1,164 @@ +#if os(macOS) +import Foundation +import Testing +@testable import CodexBarCore + +struct ChatGPTBundleTrustCacheTests { + private static let appPath = "/Applications/ChatGPT.app" + private static let executable = "/Applications/ChatGPT.app/Contents/Resources/codex" + + @Test + func `ten scans assess unchanged bundle once and check every running PID`() { + let cache = ChatGPTBundleTrustCache() + var assessments = 0 + var checkedPIDs: [Int32] = [] + for pid in Int32(100)..<110 { + let trusted = ChatGPTCodexProcessTrust.isTrusted( + pid, + executablePath: { _ in Self.executable }, + resolvePath: { $0 }, + processIsTrusted: { checkedPIDs.append($0); return true }, + appIsTrusted: { path in + cache.isTrusted(path, identity: { _ in Self.identity(1) }, assess: { bundle in + #expect(bundle == Self.appPath) + return CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/synthetic/ChatGPT.app", + fileManager: .default, + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { assessedPath in + #expect(assessedPath == "/synthetic/ChatGPT.app") + assessments += 1 + return .init( + output: "\(assessedPath): accepted\nsource=Notarized Developer ID", + exitStatus: 0) + }, + appSignatureIsTrusted: { _ in true }, + isMachOExecutable: { _ in false }) + }) + }) + #expect(trusted) + } + #expect(assessments == 1) + #expect(checkedPIDs == Array(Int32(100)..<110)) + print("ChatGPT trust harness: 10 scans, \(assessments) spctl assessment calls, \(checkedPIDs.count) PID checks") + } + + @Test + func `identity changes reassess and failure is retried on the next scan`() { + let cache = ChatGPTBundleTrustCache() + var assessments = 0 + var generation = 1 + var allowed = true + func scan() -> Bool { + cache.isTrusted(Self.appPath, identity: { _ in Self.identity(generation) }, assess: { _ in + assessments += 1 + return allowed + }) + } + #expect(scan()) + #expect(scan()) + #expect(assessments == 1) + generation = 2 + allowed = false + #expect(!scan()) + #expect(!scan()) + #expect(assessments == 3) + allowed = true + #expect(scan()) + #expect(scan()) + #expect(assessments == 4) + } + + @Test + func `missing identity and replacement during assessment fail closed and clear cached success`() { + let cache = ChatGPTBundleTrustCache() + var current: ChatGPTBundleTrustCache.Identity? = Self.identity(1) + var assessments = 0 + func scan(changesDuringAssessment: Bool = false) -> Bool { + cache.isTrusted(Self.appPath, identity: { _ in current }, assess: { _ in + assessments += 1 + if changesDuringAssessment { current = Self.identity(3) } + return true + }) + } + #expect(scan()) + current = nil + #expect(!scan()) + #expect(assessments == 1) + current = Self.identity(1) + #expect(scan()) + #expect(assessments == 2) + current = Self.identity(2) + #expect(!scan(changesDuringAssessment: true)) + #expect(scan()) + #expect(assessments == 4) + } + + @Test(arguments: ["Contents/MacOS/ChatGPT", "Contents/_CodeSignature/CodeResources", "Contents/Info.plist"]) + func `bundle identity detects file modification and replacement`(relativePath: String) throws { + let root = try Self.makeBundle() + defer { try? FileManager.default.removeItem(at: root) } + let original = try #require(ChatGPTBundleTrustCache.identity(root.path)) + #expect(ChatGPTBundleTrustCache.identity(root.path) == original) + let file = root.appendingPathComponent(relativePath) + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + let modifiedAt = try #require(attributes[.modificationDate] as? Date) + try FileManager.default.setAttributes( + [.modificationDate: modifiedAt.addingTimeInterval(10)], + ofItemAtPath: file.path) + #expect(ChatGPTBundleTrustCache.identity(root.path) != original) + let bytes = try Data(contentsOf: file) + try bytes.write(to: file, options: .atomic) + try FileManager.default.setAttributes([.modificationDate: modifiedAt], ofItemAtPath: file.path) + #expect(ChatGPTBundleTrustCache.identity(root.path) != original) + } + + @Test + func `bundle identity rejects missing seal and symlink redirected files`() throws { + let root = try Self.makeBundle() + defer { try? FileManager.default.removeItem(at: root) } + let seal = root.appendingPathComponent("Contents/_CodeSignature/CodeResources") + try FileManager.default.removeItem(at: seal) + #expect(ChatGPTBundleTrustCache.identity(root.path) == nil) + try FileManager.default.createSymbolicLink( + at: seal, + withDestinationURL: root.appendingPathComponent("Contents/Info.plist")) + #expect(ChatGPTBundleTrustCache.identity(root.path) == nil) + } + + @Test(arguments: [false, true]) + func `warm bundle cache cannot bypass process signature or symlink rejection`(redirected: Bool) { + let cache = ChatGPTBundleTrustCache() + #expect(cache.isTrusted(Self.appPath, identity: { _ in Self.identity(1) }, assess: { _ in true })) + #expect(!ChatGPTCodexProcessTrust.isTrusted( + 123, + executablePath: { _ in Self.executable }, + resolvePath: { redirected ? "/tmp/codex" : $0 }, + processIsTrusted: { _ in redirected }, + appIsTrusted: { _ in + Issue.record("Rejected processes must not reach even a warm bundle cache") + return true + })) + } + + private static func identity(_ generation: Int) -> ChatGPTBundleTrustCache.Identity { + [URL(fileURLWithPath: self.appPath): ["generation": generation] as NSDictionary] + } + + private static func makeBundle() throws -> URL { + let root = FileManager.default.temporaryDirectory.resolvingSymlinksInPath() + .appendingPathComponent("chatgpt-trust-\(UUID().uuidString).app") + for directory in ["Contents/MacOS", "Contents/_CodeSignature"] { + try FileManager.default.createDirectory( + at: root.appendingPathComponent(directory), withIntermediateDirectories: true) + } + let info = try PropertyListSerialization.data( + fromPropertyList: ["CFBundleExecutable": "ChatGPT", "CFBundleVersion": "1"], format: .xml, options: 0) + try info.write(to: root.appendingPathComponent("Contents/Info.plist")) + for file in ["Contents/MacOS/ChatGPT", "Contents/_CodeSignature/CodeResources"] { + try Data("synthetic".utf8).write(to: root.appendingPathComponent(file)) + } + return root + } +} +#endif diff --git a/Tests/CodexBarTests/CodexSessionRolloutTests.swift b/Tests/CodexBarTests/CodexSessionRolloutTests.swift index d7c1f0b9c1..0ec9e6b5dc 100644 --- a/Tests/CodexBarTests/CodexSessionRolloutTests.swift +++ b/Tests/CodexBarTests/CodexSessionRolloutTests.swift @@ -5,9 +5,15 @@ import SQLite3 import CSQLite3 #endif import Testing +@testable import CodexBar @testable import CodexBarCore struct CodexSessionRolloutTests { + private static let chatGPTExecutables = [ + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex", + ] + @Test func `first rollout line maps to file only agent session`() throws { let url = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") @@ -68,10 +74,12 @@ struct CodexSessionRolloutTests { #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch("/repo/alpha", nil)) } - @Test - func `trusted chatgpt app server projects recent codex rollout activity without an agent process`() async throws { + @Test(arguments: Self.chatGPTExecutables) + func `trusted chatgpt app server projects recent codex rollout activity without an agent process`( + executable: String) async throws + { let now = Date() - let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30) + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30, appServerExecutable: executable) defer { try? FileManager.default.removeItem(at: fixture.root) } let sessions = await fixture.scanner.scan( @@ -88,10 +96,15 @@ struct CodexSessionRolloutTests { #expect(try abs(#require(session.lastActivityAt).timeIntervalSince(now.addingTimeInterval(-30))) < 0.01) } - @Test - func `idle chatgpt app server with a stale rollout does not produce coding activity`() async throws { + @Test(arguments: Self.chatGPTExecutables) + func `idle chatgpt app server with a stale rollout does not produce coding activity`( + executable: String) async throws + { let now = Date() - let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 31 * 60) + let fixture = try Self.makeAdaptiveChatGPTFixture( + now: now, + rolloutAge: 31 * 60, + appServerExecutable: executable) defer { try? FileManager.default.removeItem(at: fixture.root) } let sessions = await fixture.scanner.scan( @@ -102,10 +115,12 @@ struct CodexSessionRolloutTests { #expect(sessions.isEmpty) } - @Test - func `continuing an existing chatgpt codex rollout advances the adaptive activity signal`() async throws { + @Test(arguments: Self.chatGPTExecutables) + func `continuing an existing chatgpt codex rollout advances the adaptive activity signal`( + executable: String) async throws + { let now = Date() - let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30) + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30, appServerExecutable: executable) defer { try? FileManager.default.removeItem(at: fixture.root) } let firstSessions = await fixture.scanner.scan( @@ -129,10 +144,11 @@ struct CodexSessionRolloutTests { #expect(abs(continuedActivity.timeIntervalSince(nextActivity)) < 0.01) } - @Test - func `untrusted chatgpt app server cannot authorize adaptive rollout inspection`() async throws { + @Test(arguments: Self.chatGPTExecutables) + func `untrusted chatgpt app server cannot authorize adaptive rollout inspection`(executable: String) async throws { let now = Date() - let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30, appServerIsTrusted: false) + let fixture = try Self.makeAdaptiveChatGPTFixture( + now: now, rolloutAge: 30, appServerExecutable: executable, appServerIsTrusted: false) defer { try? FileManager.default.removeItem(at: fixture.root) } let sessions = await fixture.scanner.scan( @@ -143,13 +159,20 @@ struct CodexSessionRolloutTests { #expect(sessions.isEmpty) } - @Test - func `unrelated chatgpt named bundle cannot authorize adaptive rollout inspection`() async throws { + @Test(arguments: [ + "codex", + "/tmp/codex", + "/tmp/ChatGPT.app/Contents/Resources/codex", + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex-fake", + "/Applications/ChatGPT.app/Contents/Resources/../Resources/codex", + ]) + func `unrecognized app server path cannot authorize adaptive rollout inspection`(executable: String) async throws { let now = Date() let fixture = try Self.makeAdaptiveChatGPTFixture( now: now, rolloutAge: 30, - appServerExecutable: "/tmp/ChatGPT.app/Contents/Resources/codex") + appServerExecutable: executable) defer { try? FileManager.default.removeItem(at: fixture.root) } let sessions = await fixture.scanner.scan( @@ -353,6 +376,48 @@ struct CodexSessionRolloutTests { #expect(sessions.allSatisfy { $0.sessionName == nil }) } + @Test(arguments: Self.chatGPTExecutables, [30.0, 6 * 60.0]) + func `chatgpt rollout freshness controls five versus thirty minute cadence`( + executable: String, age: TimeInterval) async throws + { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: age, appServerExecutable: executable) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let sessions = await fixture.scanner.scan( + now: now, environment: fixture.environment, includeFileOnlySessions: false) + let decision = UsageStore.adaptiveRefreshDecision( + now: now, + lastMenuOpenAt: nil, + lastCodingActivityAt: AgentSessionsStore.latestActivityAt(in: sessions), + lowPowerModeEnabled: false, + thermalState: .nominal) + + #expect(decision.reason == (age < 300 ? .codingActivity : .longIdle)) + #expect(decision.delay == .seconds(age < 300 ? 300 : 1800)) + } + + @Test(arguments: Self.chatGPTExecutables) + func `app server trust is revalidated after a successful scan`(executable: String) async throws { + let marker = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data().write(to: marker) + defer { try? FileManager.default.removeItem(at: marker) } + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture( + now: now, + rolloutAge: 30, + appServerExecutable: executable, + appServerTrustValidator: { _ in FileManager.default.fileExists(atPath: marker.path) }) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let trusted = await fixture.scanner.scan( + now: now, environment: fixture.environment, includeFileOnlySessions: false) + #expect(trusted.count == 1) + try FileManager.default.removeItem(at: marker) + let untrusted = await fixture.scanner.scan( + now: now, environment: fixture.environment, includeFileOnlySessions: false) + #expect(untrusted.isEmpty) + } + private struct AdaptiveChatGPTFixture { let root: URL let rollout: URL @@ -364,7 +429,9 @@ struct CodexSessionRolloutTests { now: Date, rolloutAge: TimeInterval, appServerExecutable: String = "/Applications/ChatGPT.app/Contents/Resources/codex", - appServerIsTrusted: Bool = true) throws -> AdaptiveChatGPTFixture + appServerIsTrusted: Bool = true, + appServerTrustValidator: LocalAgentSessionScanner + .AppServerTrustValidator? = nil) throws -> AdaptiveChatGPTFixture { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -385,13 +452,14 @@ struct CodexSessionRolloutTests { [.modificationDate: now.addingTimeInterval(-rolloutAge)], ofItemAtPath: rollout.path) + let executable = appServerExecutable.replacingOccurrences(of: "", with: root.path) let scanner = LocalAgentSessionScanner( processOutputProvider: { _ in - "4234 1 Mon Jul 6 09:03:00 2026 \(appServerExecutable) " + + "4234 1 Mon Jul 6 09:03:00 2026 \(executable) " + "-c features.code_mode_host=true app-server --analytics-default-enabled" }, cwdProvider: { _, _ in [:] }, - appServerTrustValidator: { _ in appServerIsTrusted }) + appServerTrustValidator: appServerTrustValidator ?? { _ in appServerIsTrusted }) return AdaptiveChatGPTFixture( root: root, rollout: rollout, diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index c3da242adc..2cf24015a8 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1510,11 +1510,6 @@ struct ProviderArchitectureGatekeeperTests { anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), - SuppressedProviderReference( - path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - anchor: "provider: .codex,", - expectedProviderIDs: ["codex"], - reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift", anchor: "CookieHeaderCache.loadSerialized(provider: .codex, scope: cacheScope)", @@ -3390,13 +3385,6 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 4, expectedReferenceFingerprint: ["pi@0", "pi@1", "claude@13", "codex@19"], reason: "This exact host integration maps a provider-owned process, path, or window contract."), - AllowedProviderConstruct( - path: "Sources/CodexBarCore/AgentSession.swift", - anchor: "guard record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue,", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact host integration recognizes only the Codex app-server bundled in ChatGPT.app."), AllowedProviderConstruct( path: "Sources/CodexBarCore/AgentSession.swift", anchor: "URL(fileURLWithPath: $0).lastPathComponent == AgentSession.Provider.claude.rawValue", @@ -3463,10 +3451,10 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - anchor: "guard AgentPSOutputParser.provider(for: process) == .codex else { return nil }", + anchor: "let codexCWDs = processes.filter { AgentPSOutputParser.provider(for: $0) == .codex }", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "codex@4"], + expectedReferenceFingerprint: ["codex@0", "codex@3"], reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", @@ -3478,16 +3466,9 @@ struct ProviderArchitectureGatekeeperTests { AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", anchor: "let codexProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .codex }", - expectedProviderIDs: ["claude", "codex"], - expectedReferenceCount: 3, - expectedReferenceFingerprint: ["codex@0", "claude@9", "claude@13"], - reason: "This exact host integration maps a provider-owned process, path, or window contract."), - AllowedProviderConstruct( - path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - anchor: "case .codex:", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], + expectedProviderIDs: ["claude", "codex", "pi"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["codex@0", "pi@7", "codex@9", "claude@14", "claude@22"], reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/OpenAIDashboardModels.swift", diff --git a/docs/refresh-loop.md b/docs/refresh-loop.md index 62fd4ac64d..f637f0668a 100644 --- a/docs/refresh-loop.md +++ b/docs/refresh-loop.md @@ -59,8 +59,8 @@ read_when: persisted `adaptiveActivityScanConsent` value is `undecided`, `allowed`, or `declined`; missing or invalid values are repaired to `undecided`, which never authorizes a scan. Declining selects plain Adaptive; explicitly selecting the agent-aware option again asks again. -- An allowed scan runs `ps -axo ... command=` to inspect the running-process list and identify Codex/Claude, then runs - `lsof` when needed and enumerates known session metadata only when an agent process is detected. It then reads +- An allowed scan inspects running processes and their arguments (through native process APIs on macOS, `ps` elsewhere), + resolves working directories, and enumerates known session metadata only when an agent process is detected. It then reads recent Codex rollouts, reads rollout first-line metadata and mtimes, and inspects Claude transcript metadata. When the Agent Sessions UI is off, CodexBar discards the resulting session records and retains only the latest `Date`. Each scan considers at most 64 agent processes, parses at most 128 Codex rollout metadata records, keeps at most 64 @@ -72,6 +72,18 @@ read_when: Sessions continues to authorize its local scan independently of the Adaptive consent choice. Tailscale discovery and SSH remain behind the Agent Sessions setting. The activity timestamp is not persisted, logged, or uploaded, and it is cleared when consent is revoked. +- ChatGPT's Codex `app-server` can authorize that local rollout scan at exactly + `/Applications/ChatGPT.app/Contents/Resources/codex` or + `/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex`. + The scanner requires an `app-server` argument, verifies the running PID's kernel-reported executable path and + Apple-anchored OpenAI signing team (`2DC432GLL2`), rejects symlink redirects, and validates the outer ChatGPT + bundle (`com.openai.codex`) with the existing signature and Gatekeeper preflight. Running-process trust is rechecked + on each scan. Successful bundle assessments are reused while the resolved paths and filesystem attributes + (including device, inode, and modification date) of the bundle, Info.plist, main executable, and CodeResources + remain unchanged. Updates trigger a new assessment; missing metadata and failed assessments are never cached. + A matching process name or command line alone is insufficient. Home-directory installations, temporary paths, + and similarly named bundles do not qualify for this app-server gate. Recent rollout modification times determine + coding activity; the app-server's presence alone never keeps the 5-minute cadence active. - Each adaptive tick recomputes the delay after the previous refresh completes, sleeps, then calls the same `UsageStore.refresh()` used by fixed-interval mode, so the existing `isRefreshing` coalescing guard still applies — only one provider-batch refresh runs at a time regardless of cadence mode. From c33760ecc34b122f985327aa917557b7753691f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 01:49:31 -0700 Subject: [PATCH 049/122] fix(widgets): retain eligible readings per provider (#4095) Widget snapshots now retain each provider's last eligible reading with its original measurement time instead of an all-or-nothing guard, so a disabled, invalidated, or failing provider no longer blanks the others; an invalidated account stays retired until replacement usage is published. Refs #3500 #3627 #3339 #2838. Thanks @jaxleezhang! --- CHANGELOG.md | 1 + .../CodexBar/UsageStore+WidgetSnapshot.swift | 37 +++----- Sources/CodexBar/UsageStore.swift | 2 +- .../WidgetEmptyProjectionTests.swift | 92 ++++++++++++++++++- docs/widgets.md | 12 ++- 5 files changed, 116 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b28263617..55e643add5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - z.ai: explain unavailable Coding Plan usage for empty or unsupported quota shapes while preserving recognized quotas and analytics (#2522). - Grok: keep local token totals visible in Usage & Spend and shared cards across wider history views and billing outages, with consistent daily scan windows (#3716). Thanks @Chipagosfinest! - Adaptive refresh: recognize ChatGPT's nested Codex app-server with per-scan running-process validation and update-aware signed-bundle assessment caching, avoiding repeated Gatekeeper subprocesses while keeping idle servers at the normal cadence (#4069, #4090). +- Widgets: retain each eligible provider's last-good reading and original age after failed refreshes, even when another provider is unavailable, disabled, or changes accounts (#3500). - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index c8abd0fc26..32ce42eccf 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -38,9 +38,7 @@ extension UsageStore { }() let snapshot = self.makeWidgetSnapshot(previousSnapshot: previousSnapshot) self.lastQueuedWidgetSnapshot = snapshot - self.lastQueuedWidgetSnapshotIsPreservable = snapshot.entries.allSatisfy { - !self.widgetUsagePreservationBlockedProviders.contains($0.provider) - } + self.invalidatedQueuedWidgetProviders = self.widgetUsagePreservationBlockedProviders NotificationCenter.default.post( name: .codexbarUsageSnapshotsDidChange, object: UsageSnapshotsDidChangeEvent(snapshots: self.cloudSyncAccountSnapshots())) @@ -191,33 +189,28 @@ extension UsageStore { self.lastWidgetSourceSnapshots[provider.instanceID] = nil self.widgetUsagePreservationBlockedProviders.insert(provider.instanceID) // A successful fetch cannot make an older queued account valid again. - if self.lastQueuedWidgetSnapshot?.entries.contains(where: { $0.provider == provider.instanceID }) == true { - self.lastQueuedWidgetSnapshotIsPreservable = false - } + self.invalidatedQueuedWidgetProviders.insert(provider.instanceID) } private func makeWidgetSnapshot(previousSnapshot: WidgetSnapshot?) -> WidgetSnapshot { let now = Date() let enabledProviders = self.enabledProviders() - var entries = UsageProvider.allCases.compactMap { provider in - self.makeWidgetEntry( + let entries = UsageProvider.allCases.compactMap { provider -> WidgetSnapshot.ProviderEntry? in + if let entry = self.makeWidgetEntry( for: provider, now: now, previousEntry: previousSnapshot?.entries.first { $0.provider == provider.instanceID }) - } - // Only reuse this process's publication; disk entries do not establish the current account's ownership. - if entries.isEmpty, self.lastQueuedWidgetSnapshotIsPreservable, - let previousSnapshot = self.lastQueuedWidgetSnapshot, - previousSnapshot.enabledProviders.allSatisfy(enabledProviders.contains), - previousSnapshot.entries.allSatisfy({ entry in - // Provider-specific by design: Claude's owner-aware preservation above remains authoritative. - entry.provider != .claude && enabledProviders.contains(entry.provider) && - self.errors[entry.provider] != nil && - (entry.providerCost == nil || self.settings.showOptionalCreditsAndExtraUsage) && - !self.widgetUsagePreservationBlockedProviders.contains(entry.provider) - }) - { - entries = previousSnapshot.entries.map { self.preservedWidgetEntryForCurrentMetric($0) } + { return entry } + // Provider-specific by design: Claude uses its owner-aware path; others require this process's publication. + guard provider != .claude, enabledProviders.contains(provider.instanceID), + self.errors[provider.instanceID] != nil, + !self.invalidatedQueuedWidgetProviders.contains(provider.instanceID), + !self.widgetUsagePreservationBlockedProviders.contains(provider.instanceID), + let entry = self.lastQueuedWidgetSnapshot?.entries + .first(where: { $0.provider == provider.instanceID }), + entry.providerCost == nil || self.settings.showOptionalCreditsAndExtraUsage + else { return nil } + return self.preservedWidgetEntryForCurrentMetric(entry) } return WidgetSnapshot( entries: entries, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6beeaeb89c..d022a7307c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -332,7 +332,7 @@ final class UsageStore { TimeInterval) async throws -> Void)? @ObservationIgnored var widgetSnapshotPersistTask: Task? @ObservationIgnored var lastQueuedWidgetSnapshot: WidgetSnapshot? - @ObservationIgnored var lastQueuedWidgetSnapshotIsPreservable = false + @ObservationIgnored var invalidatedQueuedWidgetProviders: Set = [] @ObservationIgnored var lastWidgetSourceSnapshots: [ProviderInstanceID: UsageSnapshot] = [:] @ObservationIgnored let widgetSnapshotURL: URL? @ObservationIgnored let widgetTimelineReloader: @MainActor () -> Void diff --git a/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift b/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift index e06848610e..08db3f6e0b 100644 --- a/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift +++ b/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift @@ -1,11 +1,82 @@ +import AppKit import CodexBarCore import Foundation +import SwiftUI import Testing +import WidgetKit @testable import CodexBar +@testable import CodexBarWidget @Suite(.serialized, ProviderTransportRegressionFixtures()) @MainActor struct WidgetEmptyProjectionTests { + @Test(arguments: ["claude", "disabled", "retired", "partial"]) + func `one ineligible provider cannot erase another providers last good widget reading`( + scenario: String) async throws + { + let (store, settings) = self.makeStore(providers: [.minimax, .deepseek, .claude]) + var saved: WidgetSnapshot? + store._test_widgetSnapshotSaveOverride = { saved = $0 } + self.seed(store, measuredAt: Date().addingTimeInterval(-3600)) + if scenario == "claude" { self.seed(store, providers: [.claude]) } + store.persistWidgetSnapshot(reason: "synthetic-before-wake") + await store.widgetSnapshotPersistTask?.value + let before = try #require(saved?.entries.first { $0.provider == .deepseek }) + #expect(before.balanceText == "$25.00") + store.snapshots.removeAll() + store.errors = [ + .minimax: "Synthetic offline failure", + .deepseek: "Synthetic offline failure", + .claude: "Synthetic offline failure", + ] + switch scenario { + case "claude": store.widgetUsagePreservationBlockedProviders.insert(.claude) + case "disabled": + settings.setProviderEnabled(provider: .minimax, metadata: store.metadata(for: .minimax), enabled: false) + case "retired": + store.clearProviderRuntimeState(.minimax) + store.errors[.minimax] = "Synthetic offline failure" + case "partial": self.seed(store, providers: [.minimax]) + default: break + } + store.persistWidgetSnapshot(reason: "synthetic-after-wake") + await store.widgetSnapshotPersistTask?.value + try self.renderProof(#require(saved), scenario: scenario) + let after = try #require(saved?.entries.first { $0.provider == .deepseek }) + #expect(after.updatedAt == before.updatedAt) + #expect(after.primary == before.primary) + #expect(after.balanceText == before.balanceText) + #expect(saved?.entries.contains { $0.provider == .claude } == false) + if scenario == "disabled" || scenario == "retired" { + #expect(saved?.entries.contains { $0.provider == .minimax } == false) + } + } + + private func renderProof(_ snapshot: WidgetSnapshot, scenario: String) throws { + guard let path = ProcessInfo.processInfo.environment["CODEXBAR_WIDGET_RETENTION_PROOF_DIR"] else { return } + let output = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + let entry = CodexBarSwitcherEntry( + date: snapshot.generatedAt, + provider: .deepseek, + availableProviders: [.minimax, .deepseek, .claude], + snapshot: snapshot) + let view = CodexBarSwitcherWidgetView(entry: entry) + .environment(\.widgetRenderingMode, .fullColor) + .environment(\.colorScheme, .light) + .padding(14) + .frame(width: 360, height: 170) + .background(.background) + let hosting = NSHostingView(rootView: view) + hosting.frame = NSRect(x: 0, y: 0, width: 360, height: 170) + hosting.appearance = NSAppearance(named: .aqua) + hosting.layoutSubtreeIfNeeded() + let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])) + .write(to: output.appendingPathComponent("\(scenario).png")) + } + @Test(arguments: [false, true]) func `all failed providers retain published entries and original ages`(queued: Bool) async throws { let (store, settings) = self.makeStore() @@ -63,7 +134,12 @@ struct WidgetEmptyProjectionTests { store.persistWidgetSnapshot(reason: "synthetic-invalidation") await store.widgetSnapshotPersistTask?.value if scenario == "cold-start" { saved = WidgetSnapshotStore.load(from: url) } - #expect(saved?.entries.count == (scenario == "partial" ? 1 : 0)) + let expected: Set = switch scenario { + case "disabled", "blocked", "retired": [.deepseek] + case "partial": [.minimax, .deepseek] + default: [] + } + #expect(Set(saved?.entries.map(\.provider) ?? []) == expected) } @Test(arguments: [false, true]) @@ -334,13 +410,21 @@ struct WidgetEmptyProjectionTests { return (store, settings) } - private func seed(_ store: UsageStore, providers: [UsageProvider] = [.minimax, .deepseek]) { + private func seed( + _ store: UsageStore, + providers: [UsageProvider] = [.minimax, .deepseek], + measuredAt: Date = Date(timeIntervalSince1970: 1_800_000_000)) + { for (index, provider) in providers.enumerated() { store._setSnapshotForTesting( UsageSnapshot( - primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: provider == .deepseek ? "$25.00 (Paid: $25.00 / Granted: $0.00)" : nil), secondary: nil, - updatedAt: Date(timeIntervalSince1970: 1_800_000_000 + Double(index))), + updatedAt: measuredAt.addingTimeInterval(Double(index))), provider: provider) } } diff --git a/docs/widgets.md b/docs/widgets.md index bbea482540..59fb448267 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -15,7 +15,7 @@ read_when: - WidgetKit owns the outer margins. All sizes share rendering and quota-selection rules, with overflow labels for omitted rows. Native relative-date text keeps snapshot ages and resets current between timeline reloads. Token-cost rows show their own saved age when more than ten minutes behind quota data. New usage still requires an app refresh and an accepted WidgetKit timeline. - The app writes snapshots after the main refresh pipeline and token-usage refreshes; narrow single-provider refresh paths may wait for the next snapshot write. - Claude-swap refreshes and cleared adapter state publish snapshots even when account widgets are off. When Claude-swap owns account presentation, provider widgets follow its active slot and measurement time. Missing quota can retain only that slot owner's saved reading, never ambient or another slot's quota. Local cost remains provider-wide. -- If every provider entry disappears during a failed refresh, the writer can retain its last queued entries while their providers remain enabled and preservation has not been invalidated. Measurement timestamps stay unchanged, so the widgets show the data's original age. Account invalidation keeps a queued publication retired until valid replacement usage is published. This fallback is limited to the current app session; it does not restore generic provider entries from disk across account changes or restarts. Claude keeps its existing ownership-checked preservation path. +- When a failed refresh has no usage for a provider, the writer can retain that provider's last queued entry while it remains enabled and preservation has not been invalidated. Another provider's missing, disabled, or invalidated entry does not discard eligible readings. Measurement timestamps stay unchanged, so widgets show the data's original age. Account invalidation retires only that provider's queued entry until valid replacement usage is published. This fallback is limited to the current app session; it does not restore generic provider entries from disk across account changes or restarts. Claude keeps its existing ownership-checked preservation path. - Scheduled provider refreshes trigger token/cost refreshes when their TTL permits, with a 15-minute local-history minimum (30 minutes in low-power mode). Manual disables the recurring timer; startup and pending Codex catch-up may still scan. These limits bound history work and WidgetKit reload requests without changing provider usage/status cadence. - Claude local cost/token history remains eligible for widget snapshots when its account does not expose numeric session or weekly quota data. @@ -140,6 +140,16 @@ extension and `chronod` logs. The reporter recovered by quitting only the `Codex extension process and allowing macOS to relaunch it. This is a manual diagnostic workaround, not an automatic recovery policy; restarting the main app may leave that process alive. +After an update, distinguish the installed extension from the executable already mapped by +its running process. In #2838 the reporter found an old extension mapped from a deleted +Sparkle staging directory while the installed app and extension had matching new versions. +`chronod` reported `bundleStubNotSupported` and "Bundle version did not match" before error +1050. The process command shown by `ps` and the installed `Info.plist` do not establish the +version of the running executable. Compare its mapped executable using `lsof -p ` with +the installed extension, and redact paths before sharing logs. Reload requests and a fresh +snapshot alone do not replace a stale extension process. This failure is separate from +Homebrew deleting widget placements and from a snapshot containing no provider entries. + ### 1) Verify the extension bundle exists where macOS expects it ``` APP="/Applications/CodexBar.app" From fdf411599704245f86f61cc002526e1d9ffdfe06 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 04:16:54 -0700 Subject: [PATCH 050/122] refactor(pi): share root canonicalization and verify root identity Keep the contributor's directory-marker fix in one shared helper. Remove pass-through URL wrappers and unused FileManager arguments without changing selector parsing or filesystem checks. Extend the cost-root regression to check identity after directory creation, and document the marker contract. Refs #4067 Co-authored-by: Sogl --- CHANGELOG.md | 1 + .../PiFamilySessionRootResolver.swift | 98 ++++++------------- .../CodexBarCore/PiFamilySessionScanner.swift | 47 ++++----- .../PiSharedRootMergeTests.swift | 3 + docs/pi.md | 2 + 5 files changed, 56 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de1094805d..5ab185c352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Pi: preserve the directory marker for session roots that do not exist yet (#4067). Thanks @Sogl! - CLI: macOS release builds compile again on the Xcode 26 release runners, so the 0.68 macOS CLI tarballs and the Homebrew `codexbar` formula ship alongside the app. ## 0.68.0 — 2026-09-27 diff --git a/Sources/CodexBarCore/PiFamilySessionRootResolver.swift b/Sources/CodexBarCore/PiFamilySessionRootResolver.swift index 5b7370f220..bce2a8f2ef 100644 --- a/Sources/CodexBarCore/PiFamilySessionRootResolver.swift +++ b/Sources/CodexBarCore/PiFamilySessionRootResolver.swift @@ -73,10 +73,9 @@ struct OMPSessionRootResolver: Sendable { environment: [String: String]) -> Bool { guard case .named = self.normalizedProfile(profile), - let home = homeURL( - environment: environment, - baseDirectory: nil, - fileManager: .default), + let home = self.environmentURL( + environment["HOME"], + baseDirectory: nil), configRoot(home: home, environment: environment) != nil else { return false } @@ -93,16 +92,14 @@ struct OMPSessionRootResolver: Sendable { baseDirectory: URL?, fileManager: FileManager) -> [URL] { - guard let home = homeURL( - environment: environment, - baseDirectory: baseDirectory, - fileManager: fileManager) + guard let home = self.environmentURL( + environment["HOME"], + baseDirectory: baseDirectory) else { return [] } guard let configRoot = Self.configRoot(home: home, environment: environment) else { return [] } - let customAgentRoot = Self.customAgentRoot( - environment: environment, - baseDirectory: baseDirectory, - fileManager: fileManager) + let customAgentRoot = self.environmentURL( + environment["PI_CODING_AGENT_DIR"], + baseDirectory: baseDirectory) let agentRoot: URL if let customAgentRoot { agentRoot = customAgentRoot @@ -114,7 +111,7 @@ struct OMPSessionRootResolver: Sendable { agentRoot = canonicalAgentRoot } - guard let root = Self.sessionRoot(agentRoot: agentRoot, fileManager: fileManager) else { return [] } + guard let root = Self.sessionRoot(agentRoot: agentRoot) else { return [] } var roots = [root] #if os(macOS) || os(Linux) @@ -122,16 +119,14 @@ struct OMPSessionRootResolver: Sendable { let xdgDataHome = Self.xdgDataHome( environment: environment, home: home, - baseDirectory: baseDirectory, - fileManager: fileManager) + baseDirectory: baseDirectory) { let xdgSessions = xdgDataHome .appendingPathComponent("omp", isDirectory: true) .appendingPathComponent("sessions", isDirectory: true) if Self.isDirectory(xdgSessions, fileManager: fileManager), let xdgRoot = Self.sessionRoot( - agentRoot: xdgDataHome.appendingPathComponent("omp", isDirectory: true), - fileManager: fileManager) + agentRoot: xdgDataHome.appendingPathComponent("omp", isDirectory: true)) { roots.append(xdgRoot) } @@ -148,10 +143,9 @@ struct OMPSessionRootResolver: Sendable { baseDirectory: URL?, fileManager: FileManager) -> [OMPSessionResolvedRoot] { - guard let home = homeURL( - environment: environment, - baseDirectory: baseDirectory, - fileManager: fileManager) + guard let home = self.environmentURL( + environment["HOME"], + baseDirectory: baseDirectory) else { return [] } guard let configRoot = Self.configRoot(home: home, environment: environment) else { return [] } let profileRoot = configRoot @@ -162,7 +156,7 @@ struct OMPSessionRootResolver: Sendable { home: home) else { return [] } - guard let root = Self.sessionRoot(agentRoot: agentRoot, fileManager: fileManager) else { return [] } + guard let root = Self.sessionRoot(agentRoot: agentRoot) else { return [] } var roots: [OMPSessionResolvedRoot] = [] func appendExistingLayouts(in profileRoot: URL) { @@ -187,8 +181,7 @@ struct OMPSessionRootResolver: Sendable { if let xdgDataHome = Self.xdgDataHome( environment: environment, home: home, - baseDirectory: baseDirectory, - fileManager: fileManager) + baseDirectory: baseDirectory) { let xdgProfileRoot = xdgDataHome .appendingPathComponent("omp", isDirectory: true) @@ -210,27 +203,23 @@ struct OMPSessionRootResolver: Sendable { /// This keeps profile discovery aligned with `sessionRoots` when `PI_CONFIG_DIR` is customized. static func profileDiscoveryDirectories( environment: [String: String], - baseDirectory: URL?, - fileManager: FileManager = .default) -> [URL] + baseDirectory: URL?) -> [URL] { - guard let home = homeURL( - environment: environment, - baseDirectory: baseDirectory, - fileManager: fileManager), + guard let home = self.environmentURL( + environment["HOME"], + baseDirectory: baseDirectory), let configRoot = Self.configRoot(home: home, environment: environment) else { return [] } var directories = [configRoot.appendingPathComponent("profiles", isDirectory: true)] #if os(macOS) || os(Linux) - if Self.customAgentRoot( - environment: environment, - baseDirectory: baseDirectory, - fileManager: fileManager) == nil, + if self.environmentURL( + environment["PI_CODING_AGENT_DIR"], + baseDirectory: baseDirectory) == nil, let xdgDataHome = Self.xdgDataHome( environment: environment, home: home, - baseDirectory: baseDirectory, - fileManager: fileManager) + baseDirectory: baseDirectory) { directories.append( xdgDataHome @@ -319,19 +308,6 @@ struct OMPSessionRootResolver: Sendable { } } - private static func homeURL( - environment: [String: String], - baseDirectory: URL?, - fileManager: FileManager) -> URL? - { - guard let home = environmentURL( - environment["HOME"], - baseDirectory: baseDirectory, - fileManager: fileManager) - else { return nil } - return home - } - private static func configRoot(home: URL, environment: [String: String]) -> URL? { let name: String = if let configuredPath = environment["PI_CONFIG_DIR"]? .trimmingCharacters(in: .whitespacesAndNewlines), @@ -350,21 +326,9 @@ struct OMPSessionRootResolver: Sendable { return configRoot } - private static func customAgentRoot( - environment: [String: String], - baseDirectory: URL?, - fileManager: FileManager) -> URL? - { - self.environmentURL( - environment["PI_CODING_AGENT_DIR"], - baseDirectory: baseDirectory, - fileManager: fileManager) - } - private static func environmentURL( _ value: String?, - baseDirectory: URL?, - fileManager: FileManager) -> URL? + baseDirectory: URL?) -> URL? { guard let value else { return nil } let path = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -383,16 +347,14 @@ struct OMPSessionRootResolver: Sendable { private static func xdgDataHome( environment: [String: String], home: URL, - baseDirectory: URL?, - fileManager: FileManager) -> URL? + baseDirectory: URL?) -> URL? { if let configured = environment["XDG_DATA_HOME"], !configured.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return self.environmentURL( configured, - baseDirectory: baseDirectory, - fileManager: fileManager) + baseDirectory: baseDirectory) } return home .appendingPathComponent(".local", isDirectory: true) @@ -412,7 +374,7 @@ struct OMPSessionRootResolver: Sendable { URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) } - private static func sessionRoot(agentRoot: URL, fileManager: FileManager) -> URL? { + private static func sessionRoot(agentRoot: URL) -> URL? { let canonicalAgentRoot = Self.canonicalURL(agentRoot) let candidate = Self.canonicalURL( agentRoot.appendingPathComponent("sessions", isDirectory: true)) @@ -427,7 +389,7 @@ struct OMPSessionRootResolver: Sendable { return canonicalAgentRoot } - private static func canonicalURL(_ url: URL) -> URL { + static func canonicalURL(_ url: URL) -> URL { let resolved = url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL // resolvingSymlinksInPath drops the directory marker for paths that do not exist yet, // which would make a root's canonical URL depend on whether the directory is on disk. diff --git a/Sources/CodexBarCore/PiFamilySessionScanner.swift b/Sources/CodexBarCore/PiFamilySessionScanner.swift index fd2059d5e5..4508823442 100644 --- a/Sources/CodexBarCore/PiFamilySessionScanner.swift +++ b/Sources/CodexBarCore/PiFamilySessionScanner.swift @@ -270,7 +270,7 @@ struct PiFamilySessionScanner: Sendable { cwd: processCWD) for root in roots { guard directoryBudget.hasTimeRemaining() else { break } - let canonicalRoot = Self.canonicalURL(root.url) + let canonicalRoot = OMPSessionRootResolver.canonicalURL(root.url) let rootKey = "\(dialect.rawValue):\(root.layout):\(canonicalRoot.path)" let rootRecords: [PiFamilySessionRecord] if let cached = recordsByRoot[rootKey] { @@ -292,10 +292,10 @@ struct PiFamilySessionScanner: Sendable { !recordCWD.isEmpty, Self.standardizedPath(recordCWD) == processStandardizedCWD else { return false } - return !usedRecordURLs.contains(Self.canonicalURL(candidate.url).path) + return !usedRecordURLs.contains(OMPSessionRootResolver.canonicalURL(candidate.url).path) }) { record = candidate - usedRecordURLs.insert(Self.canonicalURL(candidate.url).path) + usedRecordURLs.insert(OMPSessionRootResolver.canonicalURL(candidate.url).path) break } } @@ -414,7 +414,7 @@ struct PiFamilySessionScanner: Sendable { let cwdURLs = (configuredCWDs.isEmpty ? [URL( fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)] : configuredCWDs) - .map(Self.canonicalURL) + .map(OMPSessionRootResolver.canonicalURL) let uniqueCWDs = cwdURLs.reduce(into: [URL]()) { result, url in guard !result.contains(where: { $0.path == url.path }) else { return } result.append(url) @@ -428,7 +428,7 @@ struct PiFamilySessionScanner: Sendable { var outputIndexByPath: [String: Int] = [:] func appendCostRoot(_ root: CostSessionRoot) { - let canonical = Self.canonicalURL(root.url) + let canonical = OMPSessionRootResolver.canonicalURL(root.url) let candidate = CostSessionRoot( url: canonical, missingIsKnownEmpty: root.missingIsKnownEmpty, @@ -547,7 +547,7 @@ struct PiFamilySessionScanner: Sendable { continue } for root in roots { - let canonical = Self.canonicalURL(root.url) + let canonical = OMPSessionRootResolver.canonicalURL(root.url) appendCostRoot(CostSessionRoot( url: canonical, missingIsKnownEmpty: root.missingIsKnownEmpty, @@ -598,7 +598,7 @@ struct PiFamilySessionScanner: Sendable { guard let home = homeURL(environment) else { return nil } // Provider-specific by design: Pi and OMP keep their default histories under distinct home directories. let directory = dialect == .pi ? ".pi" : ".omp" - return Self.canonicalURL( + return OMPSessionRootResolver.canonicalURL( home .appendingPathComponent(directory, isDirectory: true) .appendingPathComponent("agent", isDirectory: true) @@ -757,10 +757,11 @@ struct PiFamilySessionScanner: Sendable { let base = if selectorIsCWDIndependent { "" } else { - ":base=" + self.canonicalURL(URL(fileURLWithPath: resolvingDirectory, isDirectory: true)).path + ":base=" + OMPSessionRootResolver.canonicalURL( + URL(fileURLWithPath: resolvingDirectory, isDirectory: true)).path } let homeEvidence = home.map { ":home=" + Data($0.utf8).base64EncodedString() } ?? "" - return "settings:" + self.canonicalURL(settingsURL).path + base + homeEvidence + return "settings:" + OMPSessionRootResolver.canonicalURL(settingsURL).path + base + homeEvidence } static func retainedSettingsRootResolution(retentionKey: String) -> RetainedSettingsRootResolution { @@ -841,7 +842,7 @@ struct PiFamilySessionScanner: Sendable { return .unavailable } return .resolved( - url: Self.canonicalURL(url), + url: OMPSessionRootResolver.canonicalURL(url), retentionKey: Self.settingsRetentionKey( settingsURL, sessionDirectory: sessionDirectory, @@ -857,7 +858,7 @@ struct PiFamilySessionScanner: Sendable { let grandparent = parent.deletingLastPathComponent() // Project settings live at /.pi/settings.json. Global settings use the // separate /.pi/agent/settings.json layout and never reach this helper. - return self.canonicalURL(grandparent).path + return OMPSessionRootResolver.canonicalURL(grandparent).path } private static func ompSessionRoots( @@ -932,7 +933,7 @@ struct PiFamilySessionScanner: Sendable { var resolvedRoots = OMPSessionRootResolver.resolvedSessionRoots( environment: safeEnvironment, baseDirectory: baseDirectory).map { root in - let canonical = Self.canonicalURL(root.url) + let canonical = OMPSessionRootResolver.canonicalURL(root.url) let defaultRootIsKnownEmpty = !processHasExplicitSelection && !Self.hasExplicitCostRootSelection(dialect: .omp, environment: environment) && Self.defaultCostSessionRoot(for: .omp, environment: environment) == canonical @@ -958,7 +959,7 @@ struct PiFamilySessionScanner: Sendable { var seen = Set() let roots: [SessionRoot] = resolvedRoots.compactMap { root in - let canonical = Self.canonicalURL(root.url) + let canonical = OMPSessionRootResolver.canonicalURL(root.url) guard seen.insert(canonical.path).inserted else { return nil } return SessionRoot( url: canonical, @@ -1121,13 +1122,13 @@ struct PiFamilySessionScanner: Sendable { var roots: [SessionRoot] = [] var isComplete = true - let canonicalProfilesDirectory = Self.canonicalURL(profilesDirectory) + let canonicalProfilesDirectory = OMPSessionRootResolver.canonicalURL(profilesDirectory) for profile in profiles { guard roots.count < 64 else { isComplete = false break } - let canonicalProfile = Self.canonicalURL(profile) + let canonicalProfile = OMPSessionRootResolver.canonicalURL(profile) guard OMPSessionRootResolver.isWithin( root: canonicalProfilesDirectory, candidate: canonicalProfile) @@ -1258,7 +1259,7 @@ struct PiFamilySessionScanner: Sendable { { let fileManager = FileManager.default var records: [PiFamilySessionRecord] = [] - let canonicalRoot = Self.canonicalURL(root) + let canonicalRoot = OMPSessionRootResolver.canonicalURL(root) guard directoryBudget.hasTimeRemaining() else { return [] } let projectDirectories: [URL] @@ -1268,7 +1269,7 @@ struct PiFamilySessionScanner: Sendable { case .projectDirectories: let directories = directoryBudget.childDirectories(in: canonicalRoot, fileManager: fileManager) projectDirectories = directoryBudget.compactMapWhileTimeRemains(directories) { directory in - let canonical = Self.canonicalURL(directory) + let canonical = OMPSessionRootResolver.canonicalURL(directory) return OMPSessionRootResolver.isWithin(root: canonicalRoot, candidate: canonical) ? canonical : nil }.sorted { $0.path < $1.path } } @@ -1278,7 +1279,7 @@ struct PiFamilySessionScanner: Sendable { let entries = directoryBudget.files(in: projectDirectory, fileManager: fileManager) let files = directoryBudget.compactMapWhileTimeRemains(entries) { entry -> URL? in guard entry.pathExtension == "jsonl" else { return nil } - let file = Self.canonicalURL(entry) + let file = OMPSessionRootResolver.canonicalURL(entry) guard OMPSessionRootResolver.isWithin(root: canonicalRoot, candidate: file), Self.isDirectFile(in: file, projectDirectory: projectDirectory) else { return nil } @@ -1314,7 +1315,7 @@ struct PiFamilySessionScanner: Sendable { return lhs.url.path < rhs.url.path } .filter { - seenURLs.insert(Self.canonicalURL($0.url).path).inserted && + seenURLs.insert(OMPSessionRootResolver.canonicalURL($0.url).path).inserted && seenIDs.insert($0.id).inserted } } @@ -1329,14 +1330,6 @@ struct PiFamilySessionScanner: Sendable { return name.isEmpty ? nil : name } - private static func canonicalURL(_ url: URL) -> URL { - let resolved = url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL - // resolvingSymlinksInPath drops the directory marker for paths that do not exist yet, - // which would make a root's canonical URL depend on whether the directory is on disk. - guard url.hasDirectoryPath, !resolved.hasDirectoryPath else { return resolved } - return URL(fileURLWithPath: resolved.path, isDirectory: true) - } - private static func isDirectFile(in file: URL, projectDirectory: URL) -> Bool { file.deletingLastPathComponent().standardizedFileURL.path == projectDirectory.path } diff --git a/Tests/CodexBarTests/PiSharedRootMergeTests.swift b/Tests/CodexBarTests/PiSharedRootMergeTests.swift index 01d2070f4f..1839181f12 100644 --- a/Tests/CodexBarTests/PiSharedRootMergeTests.swift +++ b/Tests/CodexBarTests/PiSharedRootMergeTests.swift @@ -102,6 +102,9 @@ struct PiSharedRootMergeTests { #expect(OMPSessionRootResolver.sessionRoots( environment: environment, baseDirectory: env.root) == missing) + #expect(PiFamilySessionScanner.costSessionRoots( + environment: environment, + baseDirectories: [env.root]).first { $0.url.path == missingRoot.path }?.url == missingCostRoot.url) } @Test diff --git a/docs/pi.md b/docs/pi.md index fe4c225cfa..3cecbba632 100644 --- a/docs/pi.md +++ b/docs/pi.md @@ -17,6 +17,8 @@ Cost collection can refresh the public [models.dev pricing catalog](model-pricin Default session roots include `~/.pi/agent/sessions` and the supported OMP agent/profile stores. Discovery honors `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, OMP configuration/XDG roots, and `OMP_PROFILE` (or `PI_PROFILE` when absent). A named profile limits discovery to that profile. Invalid or unresolved explicit selectors produce incomplete history. +Root canonicalization preserves an explicit directory marker even when the session directory does not exist yet. + Running Pi/OMP processes also contribute their environment, profile, `--session-dir`, and project settings. Relative paths resolve against that process's working directory. A missing working directory cannot turn an unresolved relative selector into a successful empty scan. Retained roots from explicit command-line or settings selectors survive process exit; settings are revalidated before reuse. Removing a setting from an accessible project drops its former root, while an inaccessible project or broken settings symlink preserves the previous scoped report and its original age. Assistant turns are bucketed by their own timestamp in the selected cost time zone. Matching entry IDs within the same session count once across overlapping roots. Distinct turns remain separate. The scanner retains per-message prices and token classes rather than repricing a daily aggregate. From 40ecce627a05c0bb7c8231324b2ff7739c93eccf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 04:23:01 -0700 Subject: [PATCH 051/122] fix(app): retain task isolation during session teardown Cancel stored task handles before reading the observed assertion ID. Its getter escapes self, so doing that read last lets the four task fields retain their existing main-actor isolation while deinit stays nonisolated. Add an off-actor final-release regression using a synthetic assertion, and document the teardown boundary and reported macOS 15 crash fix. Refs #4068 Co-authored-by: Sogl --- CHANGELOG.md | 1 + Sources/CodexBar/AgentSessionsStore.swift | 12 ++++++------ Tests/CodexBarTests/StayAwakeTests.swift | 23 +++++++++++++++++++++++ docs/agent-sessions-design.md | 2 ++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de1094805d..80b282d436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - CLI: macOS release builds compile again on the Xcode 26 release runners, so the 0.68 macOS CLI tarballs and the Homebrew `codexbar` formula ship alongside the app. +- Agent Sessions: avoid the macOS 15 isolated-teardown crash while retaining task cancellation and Stay Awake cleanup (#4068). Thanks @Sogl! ## 0.68.0 — 2026-09-27 diff --git a/Sources/CodexBar/AgentSessionsStore.swift b/Sources/CodexBar/AgentSessionsStore.swift index f59812ffb4..f493296cf3 100644 --- a/Sources/CodexBar/AgentSessionsStore.swift +++ b/Sources/CodexBar/AgentSessionsStore.swift @@ -53,12 +53,12 @@ final class AgentSessionsStore { private let remoteFetch: RemoteFetch private let remoteFetcher: RemoteSessionFetcher private let powerAssertion: AgentSessionPowerAssertion - private nonisolated(unsafe) var powerAssertionID: UInt32? + private nonisolated(unsafe) var powerAssertionID: UInt32? // Read last in deinit: its getter escapes self. private let periodicSleep: PeriodicSleep - @ObservationIgnored private nonisolated(unsafe) var localPeriodicTask: Task? - @ObservationIgnored private nonisolated(unsafe) var remotePeriodicTask: Task? - @ObservationIgnored private nonisolated(unsafe) var localImmediateTask: Task? - @ObservationIgnored private nonisolated(unsafe) var remoteImmediateTask: Task? + @ObservationIgnored private var localPeriodicTask: Task? + @ObservationIgnored private var remotePeriodicTask: Task? + @ObservationIgnored private var localImmediateTask: Task? + @ObservationIgnored private var remoteImmediateTask: Task? @ObservationIgnored private var localRefreshGate = AgentSessionRefreshGate() @ObservationIgnored private var remoteRefreshGate = AgentSessionRemoteRefreshGate() @ObservationIgnored var onUpdate: (@MainActor () -> Void)? @@ -114,11 +114,11 @@ final class AgentSessionsStore { } deinit { - if let powerAssertionID { self.powerAssertion.release(powerAssertionID) } self.localPeriodicTask?.cancel() self.remotePeriodicTask?.cancel() self.localImmediateTask?.cancel() self.remoteImmediateTask?.cancel() + if let powerAssertionID { self.powerAssertion.release(powerAssertionID) } } var totalCount: Int { diff --git a/Tests/CodexBarTests/StayAwakeTests.swift b/Tests/CodexBarTests/StayAwakeTests.swift index 625d61f506..52b9136bce 100644 --- a/Tests/CodexBarTests/StayAwakeTests.swift +++ b/Tests/CodexBarTests/StayAwakeTests.swift @@ -103,6 +103,29 @@ struct StayAwakeTests { #expect(assertions.counts.1 == [42]) } + @Test + func `off-actor final release cleans up without a main-actor hop`() throws { + let settings = testSettingsStore(suiteName: #function, userDefaults: InMemoryUserDefaults()) + settings.stayAwakeEnabled = true + let assertions = Assertions() + var store: AgentSessionsStore? = Self.store(settings, assertions) + store?.start() + store?.applyLocalScanResult([Self.session(pid: 42)]) + #expect(store?.isKeepingAwake == true) + + let retainedStore = try Unmanaged.passRetained(#require(store)) + store = nil + let finished = DispatchSemaphore(value: 0) + Thread.detachNewThread { + retainedStore.release() + finished.signal() + } + + // Hold the main actor until the releasing thread finishes; cleanup must not queue a hop back here. + #expect(finished.wait(timeout: .now() + 5) == .success) + #expect(assertions.counts.1 == [42]) + } + @Test func `old scan cannot acquire after disabling and reenabling`() async throws { let settings = testSettingsStore(suiteName: "awake-stale") diff --git a/docs/agent-sessions-design.md b/docs/agent-sessions-design.md index d31c5eb5da..30af472b20 100644 --- a/docs/agent-sessions-design.md +++ b/docs/agent-sessions-design.md @@ -48,6 +48,8 @@ Settings → Menu → Agent Sessions → **Stay Awake** is off by default and lo The assertion releases at the next scan with no process-backed sessions, immediately on disablement or quit, and through macOS on a crash. Stale scans cannot reacquire it after disablement or shutdown; failed acquisitions retry on the next scan. Stay Awake can use battery power. It cannot wake a Mac or prevent display, explicit, or lid-close sleep, and has no timer, grace period, or always-on mode. +Final store teardown cancels its tasks and releases an owned assertion on the thread that drops the last reference, without a main-actor cleanup hop. Live state changes remain on the main actor; teardown uses Sendable task handles and the thread-safe assertion-release closure. + ## Non-goals Historical browsing/analytics, cloud chat/task sessions, permission-waiting state, exact tmux pane focus, a persistent remote daemon, and treating either upstream on-disk dialect as a public compatibility guarantee are out of scope. From 7508e656cd679c52e238cba13ef06a6044d9e075 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 04:23:03 -0700 Subject: [PATCH 052/122] fix(mistral): verify billing price dimensions and share aggregation Cover price-table ordering, legacy unqualified prices, and unmatched API zones and service tiers while preserving consumed-token and paid-unit accounting. Share category and daily aggregation without changing library token coverage. Refs #4076. Co-authored-by: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> --- .../Mistral/MistralUsageFetcher.swift | 138 +++++------------- .../MistralUsageParserTests.swift | 33 ++++- docs/mistral.md | 1 + 3 files changed, 66 insertions(+), 106 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift index 6b6d13895b..8031e0fb16 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -245,77 +245,33 @@ public enum MistralUsageFetcher { var modelCount = 0 var daily: [String: DailyAccumulator] = [:] - // API, Le Chat, and Vibe completions share consumed-token and billed-cost accounting. - for category in [billing.completion, billing.chat, billing.vibeCode?.completion] { - for (modelName, modelData) in category?.models ?? [:] { - modelCount += 1 - let aggregate = try Self.aggregateModel(modelData, prices: prices, countsTokens: true) - try totalTokens.add(aggregate.tokens) - Self.accumulateFiniteCost(aggregate.cost, into: &totalCost) - try Self.addDailyEntries( - modelName: modelName, - data: modelData, - prices: prices, - daily: &daily, - countsTokens: true) - } - } - - // Aggregate OCR, connectors, audio if present - for category in [billing.ocr, billing.connectors, billing.audio] { - if let models = category?.models { - for (modelName, modelData) in models { - let (_, cost) = try Self.aggregateModel(modelData, prices: prices, countsTokens: false) - Self.accumulateFiniteCost(cost, into: &totalCost) - try Self.addDailyEntries( - modelName: modelName, - data: modelData, - prices: prices, - daily: &daily, - countsTokens: false) + // Library tokens count only in daily buckets; completion categories also own month totals/model counts. + let categories: [(models: [String: MistralModelUsageData]?, monthTokens: Bool, dailyTokens: Bool)] = [ + (billing.completion?.models, true, true), + (billing.chat?.models, true, true), + (billing.vibeCode?.completion?.models, true, true), + (billing.ocr?.models, false, false), + (billing.connectors?.models, false, false), + (billing.audio?.models, false, false), + (billing.librariesApi?.pages?.models, false, false), + (billing.librariesApi?.tokens?.models, false, true), + (billing.fineTuning?.training, false, false), + (billing.fineTuning?.storage, false, false), + ] + for category in categories { + for (modelName, modelData) in category.models ?? [:] { + let aggregate = try Self.aggregateModel(modelData, prices: prices, countsTokens: category.monthTokens) + if category.monthTokens { + modelCount += 1 + try totalTokens.add(aggregate.tokens) } - } - } - - // Aggregate libraries_api (pages + tokens) - if let models = billing.librariesApi?.pages?.models { - for (modelName, modelData) in models { - let (_, cost) = try Self.aggregateModel(modelData, prices: prices, countsTokens: false) - Self.accumulateFiniteCost(cost, into: &totalCost) - try Self.addDailyEntries( - modelName: modelName, - data: modelData, - prices: prices, - daily: &daily, - countsTokens: false) - } - } - if let models = billing.librariesApi?.tokens?.models { - for (modelName, modelData) in models { - let (_, cost) = try Self.aggregateModel(modelData, prices: prices, countsTokens: false) - Self.accumulateFiniteCost(cost, into: &totalCost) + Self.accumulateFiniteCost(aggregate.cost, into: &totalCost) try Self.addDailyEntries( modelName: modelName, data: modelData, prices: prices, daily: &daily, - countsTokens: true) - } - } - - // Aggregate fine_tuning (training + storage) - for models in [billing.fineTuning?.training, billing.fineTuning?.storage] { - if let models { - for (modelName, modelData) in models { - let (_, cost) = try Self.aggregateModel(modelData, prices: prices, countsTokens: false) - Self.accumulateFiniteCost(cost, into: &totalCost) - try Self.addDailyEntries( - modelName: modelName, - data: modelData, - prices: prices, - daily: &daily, - countsTokens: false) - } + countsTokens: category.dailyTokens) } } @@ -349,10 +305,9 @@ public enum MistralUsageFetcher { // MARK: - Private Helpers - /// Mistral lists one price per event type, metric, group, API zone, and service tier. The same metric and - /// group can carry a far higher per-second audio price or a priority-tier price, so every dimension is part of - /// the key; a price table without zone or tier still matches entries through the zone-less key. - fileprivate struct PriceKey: Hashable { + /// Event type, zone, and tier distinguish otherwise equal billing units. Legacy tables can omit both + /// zone and tier; only that explicitly unqualified row is a fallback. + private struct PriceKey: Hashable { let eventType: String? let metric: String let group: String @@ -411,14 +366,18 @@ public enum MistralUsageFetcher { (.input, data.input), (.output, data.output), (.cached, data.cached), ] for (kind, entries) in lanes { - try self.addDaily( - entries: entries ?? [], - context: DailyEntryContext( + for entry in entries ?? [] { + guard let day = dayKey(from: entry.timestamp) else { continue } + let cost = Self.cost(for: entry, units: entry.valuePaid ?? entry.value ?? 0, prices: prices) + var accumulator = daily[day] ?? DailyAccumulator(day: day) + try accumulator.add( + modelName: Self.displayModelName(modelName, entry: entry), kind: kind, - modelName: modelName, - prices: prices, - countsTokens: countsTokens), - daily: &daily) + units: entry.value ?? entry.valuePaid ?? 0, + cost: cost, + countsTokens: countsTokens) + daily[day] = accumulator + } } } @@ -428,26 +387,6 @@ public enum MistralUsageFetcher { case output } - private static func addDaily( - entries: [MistralUsageEntry], - context: DailyEntryContext, - daily: inout [String: DailyAccumulator]) throws - { - for entry in entries { - guard let day = dayKey(from: entry.timestamp) else { continue } - let units = entry.valuePaid ?? entry.value ?? 0 - let cost = Self.cost(for: entry, units: units, prices: context.prices) - var accumulator = daily[day] ?? DailyAccumulator(day: day) - try accumulator.add( - modelName: Self.displayModelName(context.modelName, entry: entry), - kind: context.kind, - units: entry.value ?? entry.valuePaid ?? 0, - cost: cost, - countsTokens: context.countsTokens) - daily[day] = accumulator - } - } - private static func cost(for entry: MistralUsageEntry, units: Int, prices: [PriceKey: Double]) -> Double { guard let metric = entry.billingMetric, let group = entry.billingGroup else { return 0 } let key = PriceKey( @@ -493,13 +432,6 @@ public enum MistralUsageFetcher { } } -private struct DailyEntryContext { - let kind: MistralUsageFetcher.TokenKind - let modelName: String - let prices: [MistralUsageFetcher.PriceKey: Double] - let countsTokens: Bool -} - private struct TokenCounts { var input = 0 var cached = 0 diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index 89b54c17dd..0b4ed427f4 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -47,8 +47,8 @@ struct MistralUsageParserTests { #expect(snapshot.totalCost > 0) } - @Test - func `prices entries by event type zone and tier instead of the last matching metric`() throws { + @Test(arguments: [false, true]) + func `prices entries by event type zone and tier instead of the last matching metric`(reversed: Bool) throws { // Trimmed from a real September 2026 response: the price table lists mistral-medium-3-5 input once per // zone and tier and then again as a per-second audio price, which is 100x the token price. let entry = { (group: String, value: Int) in @@ -64,7 +64,7 @@ struct MistralUsageParserTests { "api_zone":"\(zone)","service_tier":"\(tier)","price":"\(price)"} """ } - let prices = [ + var prices = [ price("api_tokens", "input", "global", "standard", "0.0000012750"), price("api_tokens", "input", "eu", "priority", "0.0000023588"), price("api_audio_seconds", "input", "global", "standard", "0.0001416667"), @@ -73,6 +73,7 @@ struct MistralUsageParserTests { price("api_tokens", "output", "global", "standard", "0.0000063750"), price("api_tokens", "output", "eu", "priority", "0.0000117938"), ] + if reversed { prices.reverse() } let json = """ {"vibe_code":{"completion":{"models":{"mistral-vibe-cli-latest::mistral-medium-3-5":{\ "input":[\(entry("input", 4_375_190))],"cached":[\(entry("cached", 21_628_160))],\ @@ -85,11 +86,37 @@ struct MistralUsageParserTests { let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: updatedAt) let expected = 4_375_190 * 0.000001275 + 21_628_160 * 1.275e-7 + 458_774 * 0.000006375 + #expect(snapshot.totalInputTokens == 4_375_190) + #expect(snapshot.totalCachedTokens == 21_628_160) + #expect(snapshot.totalOutputTokens == 458_774) #expect(abs(snapshot.totalCost - expected) < 1e-9) let history = snapshot.toCostUsageTokenSnapshot(historyDays: 30) #expect(abs((history.last30DaysCostUSD ?? 0) - expected) < 1e-9) } + @Test(arguments: ["legacy", "zone", "tier"]) + func `legacy prices match qualified usage without guessing a zone or tier`(dimension: String) throws { + let qualifier = switch dimension { + case "zone": #","api_zone":"eu","service_tier":"standard""# + case "tier": #","api_zone":"global","service_tier":"priority""# + default: "" + } + let json = """ + {"completion":{"models":{"fixture":{"input":[{ + "event_type":"api_tokens","billing_metric":"fixture","billing_group":"input", + "timestamp":"2026-09-16","value":100,"value_paid":40, + "api_zone":"global","service_tier":"standard" + }]}}},"prices":[{ + "event_type":"api_tokens","billing_metric":"fixture","billing_group":"input","price":"0.25" + \(qualifier) + }]} + """ + let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: Date()) + #expect(snapshot.totalInputTokens == 100) + #expect(snapshot.totalCost == (dimension == "legacy" ? 10 : 0)) + #expect(snapshot.daily.first?.cost == snapshot.totalCost) + } + @Test(arguments: ["NaN", "Infinity", "1e308"]) func `ignores prices that produce nonfinite costs`(price: String) async throws { let json = """ diff --git a/docs/mistral.md b/docs/mistral.md index ddfa846777..db87055087 100644 --- a/docs/mistral.md +++ b/docs/mistral.md @@ -54,6 +54,7 @@ For the console request, CodexBar forwards only the `csrftoken` and `ory_session the price with the same event type, metric, group, API zone, and service tier; the table lists one metric under several of these, and audio-second and priority prices are far higher than standard token prices. Token totals and daily buckets use consumed units (`value`, falling back to `value_paid`), so plan-covered usage still counts. + Legacy tables that omit both API zone and service tier use the unqualified price for the same event type, metric, and group. - Token totals include API completions, Le Chat, and Vibe Code completions from the billing usage response. - Daily usage buckets feed the inline usage dashboard. - The provider card can show credit balance when the credits endpoint returns it. From 3731502b1eb83396739ef2b8101eb1bf5aa0f076 Mon Sep 17 00:00:00 2001 From: Sogl Date: Mon, 28 Sep 2026 14:26:44 +0300 Subject: [PATCH 053/122] Support token-only scoped Antigravity credentials Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../AntigravityScopedPrintFetch.swift | 25 ++++++--- .../AntigravityScopedPrintFetchTests.swift | 55 ++++++++++++++++--- docs/antigravity.md | 3 +- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift index ed6a671b95..03684e4039 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift @@ -6,6 +6,9 @@ import Glibc import Musl #endif import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif // MARK: - agy file token storage payload @@ -135,9 +138,11 @@ enum AntigravityScopedAgyStaging { } /// Creates a fresh 0700 staging directory, writes the token file, then - /// re-reads and verifies that the staged `id_token` claim belongs to the - /// expected account — the identity `agy` will act as is proven from the - /// bytes it will read, not from the caller's label. + /// re-reads it and verifies the staged `id_token` claim against the + /// expected account when a claim is present. Saved credentials may carry + /// no `id_token` at all (the OAuth scopes CodexBar requests do not include + /// `openid`); those stage unchecked here because the post-run userinfo + /// verification binds the effective account anyway. static func stage( credentials: AntigravityOAuthCredentials, expectedAccountEmail: String, @@ -162,13 +167,16 @@ enum AntigravityScopedAgyStaging { try CredentialFileWriter.writePrivate(tokenData, to: tokenURL) guard let staged = try? Data(contentsOf: tokenURL), - let payload = AntigravityAgyFileTokenEncoder.decode(data: staged), - Self.normalizedEmail( - AntigravityOAuthCredentials.email(fromIDToken: payload.idToken)) == - Self.normalizedEmail(expectedAccountEmail) + let payload = AntigravityAgyFileTokenEncoder.decode(data: staged) else { throw AntigravityScopedStagingError.identityUnverifiable } + if let stagedEmail = Self.normalizedEmail( + AntigravityOAuthCredentials.email(fromIDToken: payload.idToken)), + stagedEmail != Self.normalizedEmail(expectedAccountEmail) + { + throw AntigravityScopedStagingError.identityUnverifiable + } return (root, home) } catch { try? fileManager.removeItem(at: root) @@ -232,7 +240,8 @@ extension AntigravityCLIHTTPSFetchStrategy { /// Runs `agy -p /usage` scoped to the injected token account's credentials: /// the account's OAuth tokens are staged into a private per-run `HOME`, the /// child receives an allowlist environment, and the staged token's `id_token` - /// claim is verified against the selected account before launch. Because the + /// claim, when present, is verified against the selected account before + /// launch. Because the /// CLI authenticates with the staged access/refresh tokens — which could /// disagree with the `id_token` claim — the access token `agy` actually used /// is resolved through Google's `userinfo` endpoint after the run and must diff --git a/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift b/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift index c3ce79f9e3..49cb6310bd 100644 --- a/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift +++ b/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift @@ -94,17 +94,24 @@ struct AntigravityScopedPrintFetchTests { } @Test - func `staging rejects credentials without an identity claim`() throws { + func `staging accepts token-only credentials without an id_token claim`() throws { + // Saved accounts created without the `openid` scope carry email, + // tokens, and expiry but no ID token; they must still stage because + // the post-run userinfo check binds the effective account. let credentials = AntigravityOAuthCredentials( accessToken: "access", refreshToken: "refresh", - expiryDate: Date().addingTimeInterval(3600)) - do { - _ = try AntigravityScopedAgyStaging.stage( - credentials: credentials, expectedAccountEmail: "scoped@example.com") - Issue.record("Unverifiable staged identity must fail closed") - } catch AntigravityScopedStagingError.identityUnverifiable {} - #expect(self.scopedStagingDirectories().isEmpty) + expiryDate: Date().addingTimeInterval(3600), + email: "scoped@example.com") + let staged = try AntigravityScopedAgyStaging.stage( + credentials: credentials, expectedAccountEmail: "scoped@example.com") + defer { try? FileManager.default.removeItem(at: staged.stagingRoot) } + + let tokenURL = staged.home + .appendingPathComponent(".gemini/antigravity-cli/antigravity-oauth-token") + let payload = try AntigravityAgyFileTokenEncoder.decode(data: Data(contentsOf: tokenURL)) + #expect(payload?.token.accessToken == "access") + #expect(payload?.idToken == nil) } // MARK: - Fallback wiring (platform-independent) @@ -255,6 +262,26 @@ struct AntigravityScopedPrintFetchTests { } } + @Test + func `scoped print attributes token-only credentials after userinfo match`() async throws { + let report = try self.reportJSON() + let fixture = try self.scopedPrintFixture(body: """ + /bin/cat <<'REPORT' + \(report) + REPORT + """) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + var environment = self.tokenOnlyAccountEnv(email: "scoped@example.com") + environment.merge(fixture.environment) { _, new in new } + + let result = try await AntigravityCLIHTTPSFetchStrategy().fetchScopedPrintUsage( + binary: fixture.binary.path, + environment: environment, + dataLoader: self.userinfoLoader(mapping: ["scoped-access-token": "scoped@example.com"])) + #expect(result.usage.identity?.accountEmail == "scoped@example.com") + } + @Test func `scoped print rejects a report when the effective account cannot be verified`() async throws { let report = try self.reportJSON() @@ -330,6 +357,18 @@ struct AntigravityScopedPrintFetchTests { return [AntigravityOAuthCredentialsStore.environmentCredentialsKey: value] } + private func tokenOnlyAccountEnv(email: String) -> [String: String] { + let credentials = AntigravityOAuthCredentials( + accessToken: "scoped-access-token", + refreshToken: "refresh", + expiryDate: Date().addingTimeInterval(3600), + email: email) + guard let value = try? AntigravityOAuthCredentialsStore.tokenAccountValue( + for: credentials) + else { return [:] } + return [AntigravityOAuthCredentialsStore.environmentCredentialsKey: value] + } + private func makeContext( sourceMode: ProviderSourceMode = .auto, selected: Bool = false, diff --git a/docs/antigravity.md b/docs/antigravity.md index dfce5aae10..87f0b62bd2 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -48,7 +48,8 @@ HTTPS results retain their verified identity. Failed command diagnostics do not On macOS, when a Google account is selected or injected in Auto mode and the ambient paths cannot prove that account, CodexBar instead runs the same print command scoped to the account: its OAuth credentials are written as `agy`'s file-token payload into a fresh private `HOME` under the per-user temporary -directory, the staged `id_token` claim is re-read from disk and verified against the selected account +directory, the staged `id_token` claim — when present; saved credentials may omit it when the account +was created without the `openid` scope — is re-read from disk and verified against the selected account before launch, and the child process receives an allowlist environment (login `PATH`, locale, proxy variables) without `ANTIGRAVITY_OAUTH_CREDENTIALS_JSON` or any ambient provider credentials. A non-empty `SSH_TTY` forces `agy` onto file-token storage so the scoped run never touches the OS keyring. The From d81f1878a88491db3c64b9dff034b03ffb02a8ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 04:41:27 -0700 Subject: [PATCH 054/122] fix(mistral): preserve explicit Monthly Plan picker choices Pin percentage layouts when users explicitly select a metric, including an already-automatic percentage. Keep metric-only layouts inherited and preserve saved widget choices during picker initialization. Share semantic-window mapping with layout migration and update its exact architecture-gate anchor. Refs #4072. Co-authored-by: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> --- Sources/CodexBar/MenuBarLayout.swift | 43 +++++++++++-------- .../MenuBarPercentWindowPreference.swift | 38 +++------------- ...iderMenuBarPercentWindowSettingsView.swift | 2 +- .../MistralMonthlyPlanAvailabilityTests.swift | 17 ++++++++ .../MistralMonthlyPlanPickerTests.swift | 43 +++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 6 +-- docs/mistral.md | 4 +- 7 files changed, 99 insertions(+), 54 deletions(-) create mode 100644 Tests/CodexBarTests/MistralMonthlyPlanAvailabilityTests.swift diff --git a/Sources/CodexBar/MenuBarLayout.swift b/Sources/CodexBar/MenuBarLayout.swift index 7655f30f09..7a872764bd 100644 --- a/Sources/CodexBar/MenuBarLayout.swift +++ b/Sources/CodexBar/MenuBarLayout.swift @@ -7,6 +7,26 @@ enum PercentWindow: String, CaseIterable, Codable, Hashable, Sendable { case scopedWeekly case automatic + /// Shared by the simplified picker and legacy layout migration. + static func forMetric( + _ metric: ProviderMenuBarMetric, + primarySemanticWindow: ProviderSemanticWindow, + secondarySemanticWindow: ProviderSemanticWindow) -> Self + { + switch metric { + case .primary: self.forSemanticWindow(primarySemanticWindow) + case .secondary: self.forSemanticWindow(secondarySemanticWindow) + case .automatic, .primaryAndSecondary, .tertiary, .extraUsage, .average, .monthlyPlan: .automatic + } + } + + static func forSemanticWindow(_ window: ProviderSemanticWindow) -> Self { + switch window { + case .session: .session + case .weekly: .weekly + } + } + func providerLabel(provider: UsageProvider?) -> String? { guard let provider else { return nil } let presentation = ProviderDescriptorRegistry.descriptor(for: provider).presentation @@ -913,23 +933,12 @@ extension MenuBarLayout { provider: UsageProvider?) -> PercentWindow { - switch preference { - case .primary: - self.percentWindow( - ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation.primarySemanticWindow) - case .secondary: - self.percentWindow( - ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation.secondarySemanticWindow) - case .automatic, .primaryAndSecondary, .tertiary, .extraUsage, .average, .monthlyPlan: - .automatic - } - } - - private static func percentWindow(_ window: ProviderSemanticWindow) -> PercentWindow { - switch window { - case .session: .session - case .weekly: .weekly - } + guard preference == .primary || preference == .secondary else { return .automatic } + let presentation = ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation + return PercentWindow.forMetric( + preference.providerMetric, + primarySemanticWindow: presentation.primarySemanticWindow, + secondarySemanticWindow: presentation.secondarySemanticWindow) } static func legacyPercentWindow(for lane: MenuBarLayoutLane, provider: UsageProvider?) -> PercentWindow { diff --git a/Sources/CodexBar/MenuBarPercentWindowPreference.swift b/Sources/CodexBar/MenuBarPercentWindowPreference.swift index ebf202fbec..696023aa9f 100644 --- a/Sources/CodexBar/MenuBarPercentWindowPreference.swift +++ b/Sources/CodexBar/MenuBarPercentWindowPreference.swift @@ -24,14 +24,9 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl } private var layoutToken: MenuBarLayoutToken { - switch self { - case .automatic: .percent(window: .automatic) - case .session: .percent(window: .session) - case .weekly: .percent(window: .weekly) - case .tertiary: .lanePercent(lane: .tertiary) - // Mistral's automatic percent reads the per-provider metric, which the picker sets to Monthly Plan. - case .monthlyPlan: .percent(window: .automatic) - } + if self == .tertiary { return .lanePercent(lane: .tertiary) } + // Metric-backed choices resolve through the automatic lane. + return .percent(window: self.percentWindow ?? .automatic) } /// The per-provider metric this choice stores for providers that offer Monthly Plan, which the @@ -47,7 +42,7 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl return MenuBarLayoutLaneLabels(provider: provider, snapshot: nil).label(for: .tertiary) } let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) - let primary = Self.percentWindow(descriptor.presentation.primarySemanticWindow) + let primary = PercentWindow.forSemanticWindow(descriptor.presentation.primarySemanticWindow) let presentation = descriptor.presentation return L(self.percentWindow == primary ? presentation.menuBarLayoutPrimaryLabel ?? descriptor.metadata.sessionLabel @@ -63,8 +58,8 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl { var windows = Set() for metric in metrics.supported { - windows.insert(Self.percentWindow( - for: metric, + windows.insert(PercentWindow.forMetric( + metric, primarySemanticWindow: primarySemanticWindow, secondarySemanticWindow: secondarySemanticWindow)) } @@ -164,25 +159,4 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl return window } } - - /// Same semantic mapping as layout migration: other metrics retain Automatic as an option. - private static func percentWindow( - for metric: ProviderMenuBarMetric, - primarySemanticWindow: ProviderSemanticWindow, - secondarySemanticWindow: ProviderSemanticWindow) -> PercentWindow - { - switch metric { - case .primary: self.percentWindow(primarySemanticWindow) - case .secondary: self.percentWindow(secondarySemanticWindow) - case .automatic, .primaryAndSecondary, .tertiary, .extraUsage, .average, .monthlyPlan: - .automatic - } - } - - private static func percentWindow(_ window: ProviderSemanticWindow) -> PercentWindow { - switch window { - case .session: .session - case .weekly: .weekly - } - } } diff --git a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift index 68b931412e..d880703bce 100644 --- a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift +++ b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift @@ -87,7 +87,7 @@ struct ProviderMenuBarPercentWindowPicker: View { self.metric.wrappedValue = preference.menuBarMetric // Without a percentage to change, only the metric is stored, so the layout keeps following // its source instead of becoming a provider override. - guard updated != layout else { return } + guard MenuBarPercentWindowPreference.hasPercentToken(in: layout) else { return } } self.layout = updated }) diff --git a/Tests/CodexBarTests/MistralMonthlyPlanAvailabilityTests.swift b/Tests/CodexBarTests/MistralMonthlyPlanAvailabilityTests.swift new file mode 100644 index 0000000000..c8f6fea22e --- /dev/null +++ b/Tests/CodexBarTests/MistralMonthlyPlanAvailabilityTests.swift @@ -0,0 +1,17 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +struct MistralMonthlyPlanAvailabilityTests { + @Test + func `the existing Monthly Plan capability remains reachable in every icon style`() { + let layout = MenuBarLayout(lines: [[.icon]]) + let options = MenuBarPercentWindowPreference.available(for: .mistral, layout: layout) + #expect(options.map(\.rawValue) == ["automatic", "monthlyPlan"]) + for style in [MenuBarIconStyle.critters, .bars, .iconAndPercent] { + #expect(MenuBarPercentWindowPreference.isVisible( + iconStyle: style, layout: layout, provider: .mistral)) + } + } +} diff --git a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift index e2ce1a8578..b76cdeea10 100644 --- a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift +++ b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift @@ -76,4 +76,47 @@ struct MistralMonthlyPlanPickerTests { #expect(picker.selectionBinding.wrappedValue == .automatic) #expect(settings.menuBarLayoutOverrides[.mistral] == nil) } + + @Test + func `explicit Monthly Plan selection pins a percentage layout against later global edits`() { + let settings = testSettingsStore( + suiteName: "MistralMonthlyPlanPickerTests-global", + userDefaults: InMemoryUserDefaults()) + settings.menuBarIconStyle = .iconAndPercent + let automatic = MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) + settings.setMenuBarLayout(automatic, for: nil) + let view = ProviderMenuBarPercentWindowSettingsView(provider: .mistral, settings: settings) + let picker = ProviderMenuBarPercentWindowPicker( + provider: .mistral, + iconStyle: .iconAndPercent, + layout: view.layoutBinding, + metric: view.metricBinding) + #expect(settings.menuBarLayoutOverrides[.mistral] == nil) + + picker.selectionBinding.wrappedValue = .monthlyPlan + #expect(settings.menuBarLayoutOverrides[.mistral] == automatic) + settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon, .percent(window: .session)]]), for: nil) + #expect(settings.menuBarLayout(for: .mistral) == automatic) + #expect(picker.selectionBinding.wrappedValue == .monthlyPlan) + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + } + + @Test + func `reading the picker preserves an existing Monthly Plan widget preference`() { + let settings = testSettingsStore( + suiteName: "MistralMonthlyPlanPickerTests-saved", + userDefaults: InMemoryUserDefaults()) + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) + let includedAPI = MenuBarLayout(lines: [[.icon, .percent(window: .session)]]) + settings.setMenuBarLayout(includedAPI, for: .mistral) + let view = ProviderMenuBarPercentWindowSettingsView(provider: .mistral, settings: settings) + let picker = ProviderMenuBarPercentWindowPicker( + provider: .mistral, + iconStyle: .iconAndPercent, + layout: view.layoutBinding, + metric: view.metricBinding) + #expect(picker.selectionBinding.wrappedValue == .session) + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + #expect(settings.menuBarLayout(for: .mistral) == includedAPI) + } } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 80b350279b..360eb0aa7c 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1834,10 +1834,10 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuBarLayout.swift", - anchor: "ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation.primarySemanticWindow)", + anchor: "let presentation = ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation", expectedProviderIDs: ["codex"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "codex@3"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuBarLayoutEditor.swift", diff --git a/docs/mistral.md b/docs/mistral.md index 22353ef16b..595819d92a 100644 --- a/docs/mistral.md +++ b/docs/mistral.md @@ -66,7 +66,9 @@ For the console request, CodexBar forwards only the `csrftoken` and `ory_session ## Widgets Usage widgets follow the **Menu bar metric** picker in Mistral's provider settings. The picker appears in every menu bar -style, so Critters and Meter bars users can still pick the widget allowance: +style, so Critters and Meter bars users can still pick the widget allowance. Choosing a percentage metric pins +Mistral’s layout against later global layout edits. Without a percentage, the picker changes only the stored metric +and keeps following the global layout: - **Automatic** and **Included API** show only the API allowance, preserving the existing default. - **Monthly Plan** shows only the Vibe allowance, falling back to Included API when the plan is missing or unknown. From 122051e01e8dbdab582a60a02a41691b183f269b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 05:16:32 -0700 Subject: [PATCH 055/122] fix(pricing): preserve Sol rates across the August cutoff Keep pre-August 21 Sol usage on its prior rates while using the published current rates for newer usage. Share explicit GPT-5.6 rate tuples and default nil labels without changing other model prices. Cover catalog and bundled rates, memoized resolution, cache writes, and unknown models. Preserve the existing long-context Fast eligibility boundary and clarify canonical aggregate IDs versus raw native model evidence. Refs #4094. Co-authored-by: Peter Urda --- CHANGELOG.md | 1 + .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsagePricing.swift | 163 ++++++------------ .../CodexSolHistoricalPricingTests.swift | 60 +++++++ .../CodexBarTests/CostUsagePricingTests.swift | 8 +- .../CostUsageScannerPriorityTests.swift | 2 +- docs/antigravity.md | 3 + docs/codex.md | 2 + docs/model-pricing.md | 6 +- 9 files changed, 137 insertions(+), 110 deletions(-) create mode 100644 Tests/CodexBarTests/CodexSolHistoricalPricingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index acf9be31e2..3ff39e80e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Costs: price documented Antigravity and Codex model aliases, add published Cyber fallback rates, and preserve Sol estimates across the August 21 price change (#4094). Thanks @urda! - Development: restore test compilation on Xcode 26.3 / Swift 6.2 and check app, CLI, and test compatibility in CI (#4070). Thanks @RowboTony! - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index c65242f05b..caf0ea9a14 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "2a146b3a97e5761a" + static let value = "04a6361469a4ff77" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index c169ac9289..ba45fb506b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -1,6 +1,5 @@ import Foundation -// swiftlint:disable:next type_body_length enum CostUsagePricing { private static let codexPriorityInputTokenLimit = 272_000 static let codexUnattributedModel = "unknown" @@ -24,7 +23,7 @@ enum CostUsagePricing { inputCostPerToken: Double, outputCostPerToken: Double, cacheReadInputCostPerToken: Double?, - displayLabel: String?, + displayLabel: String? = nil, cacheWriteInputCostPerToken: Double? = nil, thresholdTokens: Int? = nil, inputCostPerTokenAboveThreshold: Double? = nil, @@ -45,6 +44,22 @@ enum CostUsagePricing { } } + private static func gpt56Pricing( + standard: (input: Double, cached: Double, write: Double, output: Double), + longContext: (input: Double, cached: Double, write: Double, output: Double)) -> CodexPricing + { + CodexPricing( + inputCostPerToken: standard.input, + outputCostPerToken: standard.output, + cacheReadInputCostPerToken: standard.cached, + cacheWriteInputCostPerToken: standard.write, + thresholdTokens: 272_000, + inputCostPerTokenAboveThreshold: longContext.input, + outputCostPerTokenAboveThreshold: longContext.output, + cacheReadInputCostPerTokenAboveThreshold: longContext.cached, + cacheWriteInputCostPerTokenAboveThreshold: longContext.write) + } + struct ClaudePricing { let inputCostPerToken: Double let outputCostPerToken: Double @@ -70,68 +85,55 @@ enum CostUsagePricing { "gpt-5": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.25e-7), "gpt-5-codex": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.25e-7), "gpt-5-mini": CodexPricing( inputCostPerToken: 2.5e-7, outputCostPerToken: 2e-6, - cacheReadInputCostPerToken: 2.5e-8, - displayLabel: nil), + cacheReadInputCostPerToken: 2.5e-8), "gpt-5-nano": CodexPricing( inputCostPerToken: 5e-8, outputCostPerToken: 4e-7, - cacheReadInputCostPerToken: 5e-9, - displayLabel: nil), + cacheReadInputCostPerToken: 5e-9), "gpt-5-pro": CodexPricing( inputCostPerToken: 1.5e-5, outputCostPerToken: 1.2e-4, - cacheReadInputCostPerToken: nil, - displayLabel: nil), + cacheReadInputCostPerToken: nil), "gpt-5.1": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.25e-7), "gpt-5.1-codex": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.25e-7), "gpt-5.1-codex-max": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.25e-7), "gpt-5.1-codex-mini": CodexPricing( inputCostPerToken: 2.5e-7, outputCostPerToken: 2e-6, - cacheReadInputCostPerToken: 2.5e-8, - displayLabel: nil), + cacheReadInputCostPerToken: 2.5e-8), "gpt-5.2": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.75e-7), "gpt-5.2-codex": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.75e-7), "gpt-5.2-pro": CodexPricing( inputCostPerToken: 2.1e-5, outputCostPerToken: 1.68e-4, - cacheReadInputCostPerToken: nil, - displayLabel: nil), + cacheReadInputCostPerToken: nil), "gpt-5.3-codex": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.75e-7), "gpt-5.3-codex-spark": CodexPricing( inputCostPerToken: 0, outputCostPerToken: 0, @@ -141,7 +143,6 @@ enum CostUsagePricing { inputCostPerToken: 2.5e-6, outputCostPerToken: 1.5e-5, cacheReadInputCostPerToken: 2.5e-7, - displayLabel: nil, thresholdTokens: 272_000, inputCostPerTokenAboveThreshold: 5e-6, outputCostPerTokenAboveThreshold: 2.25e-5, @@ -149,23 +150,19 @@ enum CostUsagePricing { "gpt-5.4-mini": CodexPricing( inputCostPerToken: 7.5e-7, outputCostPerToken: 4.5e-6, - cacheReadInputCostPerToken: 7.5e-8, - displayLabel: nil), + cacheReadInputCostPerToken: 7.5e-8), "gpt-5.4-nano": CodexPricing( inputCostPerToken: 2e-7, outputCostPerToken: 1.25e-6, - cacheReadInputCostPerToken: 2e-8, - displayLabel: nil), + cacheReadInputCostPerToken: 2e-8), "gpt-5.4-pro": CodexPricing( inputCostPerToken: 3e-5, outputCostPerToken: 1.8e-4, - cacheReadInputCostPerToken: nil, - displayLabel: nil), + cacheReadInputCostPerToken: nil), "gpt-5.5": CodexPricing( inputCostPerToken: 5e-6, outputCostPerToken: 3e-5, cacheReadInputCostPerToken: 5e-7, - displayLabel: nil, thresholdTokens: 272_000, inputCostPerTokenAboveThreshold: 1e-5, outputCostPerTokenAboveThreshold: 4.5e-5, @@ -173,15 +170,13 @@ enum CostUsagePricing { "gpt-5.5-pro": CodexPricing( inputCostPerToken: 3e-5, outputCostPerToken: 1.8e-4, - cacheReadInputCostPerToken: nil, - displayLabel: nil), + cacheReadInputCostPerToken: nil), // https://developers.openai.com/api/docs/models/gpt-6-astra and /api/docs/pricing. // The full request switches to long-context rates above 272K input tokens, including Fast mode. "gpt-6-astra": CodexPricing( inputCostPerToken: 1e-5, outputCostPerToken: 5e-5, cacheReadInputCostPerToken: 1e-6, - displayLabel: nil, cacheWriteInputCostPerToken: 1.25e-5, thresholdTokens: 272_000, inputCostPerTokenAboveThreshold: 2e-5, @@ -192,53 +187,24 @@ enum CostUsagePricing { // Long context: prompts with >272K input tokens are 2x input / 1.5x output for the full // request. Cache writes: 1.25x uncached input. API Fast support and multipliers are applied // separately after Standard pricing resolves from models.dev or this bundled fallback. - // Sol was repriced from $5/$30 to $4/$20 on 2026-08-22. - "gpt-5.6-sol": CodexPricing( - inputCostPerToken: 4e-6, - outputCostPerToken: 2e-5, - cacheReadInputCostPerToken: 4e-7, - displayLabel: nil, - cacheWriteInputCostPerToken: 5e-6, - thresholdTokens: 272_000, - inputCostPerTokenAboveThreshold: 8e-6, - outputCostPerTokenAboveThreshold: 3e-5, - cacheReadInputCostPerTokenAboveThreshold: 8e-7, - cacheWriteInputCostPerTokenAboveThreshold: 1e-5), - "gpt-5.6-terra": CodexPricing( - inputCostPerToken: 2e-6, - outputCostPerToken: 1.2e-5, - cacheReadInputCostPerToken: 2e-7, - displayLabel: nil, - cacheWriteInputCostPerToken: 2.5e-6, - thresholdTokens: 272_000, - inputCostPerTokenAboveThreshold: 4e-6, - outputCostPerTokenAboveThreshold: 1.8e-5, - cacheReadInputCostPerTokenAboveThreshold: 4e-7, - cacheWriteInputCostPerTokenAboveThreshold: 5e-6), - "gpt-5.6-luna": CodexPricing( - inputCostPerToken: 2e-7, - outputCostPerToken: 1.2e-6, - cacheReadInputCostPerToken: 2e-8, - displayLabel: nil, - cacheWriteInputCostPerToken: 2.5e-7, - thresholdTokens: 272_000, - inputCostPerTokenAboveThreshold: 4e-7, - outputCostPerTokenAboveThreshold: 1.8e-6, - cacheReadInputCostPerTokenAboveThreshold: 4e-8, - cacheWriteInputCostPerTokenAboveThreshold: 5e-7), + // Sol was repriced from $5/$30 to $4/$20 on 2026-08-21. + "gpt-5.6-sol": Self.gpt56Pricing( + standard: (4e-6, 4e-7, 5e-6, 2e-5), longContext: (8e-6, 8e-7, 1e-5, 3e-5)), + "gpt-5.6-terra": Self.gpt56Pricing( + standard: (2e-6, 2e-7, 2.5e-6, 1.2e-5), longContext: (4e-6, 4e-7, 5e-6, 1.8e-5)), + "gpt-5.6-luna": Self.gpt56Pricing( + standard: (2e-7, 2e-8, 2.5e-7, 1.2e-6), longContext: (4e-7, 4e-8, 5e-7, 1.8e-6)), // Daybreak Cyber models (OpenAI pricing page). No long-context tier is published, and // gpt-5.5-cyber lists no cache-write rate. "gpt-5.6-cyber": CodexPricing( inputCostPerToken: 1.25e-5, outputCostPerToken: 7.5e-5, cacheReadInputCostPerToken: 1.25e-6, - displayLabel: nil, cacheWriteInputCostPerToken: 1.5625e-5), "gpt-5.5-cyber": CodexPricing( inputCostPerToken: 1.25e-5, outputCostPerToken: 7.5e-5, - cacheReadInputCostPerToken: 1.25e-6, - displayLabel: nil), + cacheReadInputCostPerToken: 1.25e-6), ] static func codexBuiltInPricingFingerprint() -> String { @@ -426,32 +392,18 @@ enum CostUsagePricing { ] // GPT-5.6 Terra and Luna rates effective before 2026-07-30 (Unix 1785369600). - // Sol pricing was unchanged. Values from OpenAI pricing page snapshot in PR #2521. + // Terra/Luna values from the OpenAI pricing page snapshot in PR #2521. // Co-authored-by: iam-brain (historical rate values). static let codexGPT56PricingCutoff = Date(timeIntervalSince1970: 1_785_369_600) - private static let codexHistoricalPricing: [String: CodexPricing] = [ - "gpt-5.6-terra": CodexPricing( - inputCostPerToken: 2.5e-6, - outputCostPerToken: 1.5e-5, - cacheReadInputCostPerToken: 2.5e-7, - displayLabel: nil, - cacheWriteInputCostPerToken: 3.125e-6, - thresholdTokens: 272_000, - inputCostPerTokenAboveThreshold: 5e-6, - outputCostPerTokenAboveThreshold: 2.25e-5, - cacheReadInputCostPerTokenAboveThreshold: 5e-7, - cacheWriteInputCostPerTokenAboveThreshold: 6.25e-6), - "gpt-5.6-luna": CodexPricing( - inputCostPerToken: 1e-6, - outputCostPerToken: 6e-6, - cacheReadInputCostPerToken: 1e-7, - displayLabel: nil, - cacheWriteInputCostPerToken: 1.25e-6, - thresholdTokens: 272_000, - inputCostPerTokenAboveThreshold: 2e-6, - outputCostPerTokenAboveThreshold: 9e-6, - cacheReadInputCostPerTokenAboveThreshold: 2e-7, - cacheWriteInputCostPerTokenAboveThreshold: 2.5e-6), + // Sol repricing is dated August 21 in https://developers.openai.com/api/docs/changelog. + private static let codexSolPricingCutoff = Date(timeIntervalSince1970: 1_787_270_400) + private static let codexHistoricalPricing: [String: (cutoff: Date, pricing: CodexPricing)] = [ + "gpt-5.6-sol": (Self.codexSolPricingCutoff, Self.gpt56Pricing( + standard: (5e-6, 5e-7, 6.25e-6, 3e-5), longContext: (1e-5, 1e-6, 1.25e-5, 4.5e-5))), + "gpt-5.6-terra": (Self.codexGPT56PricingCutoff, Self.gpt56Pricing( + standard: (2.5e-6, 2.5e-7, 3.125e-6, 1.5e-5), longContext: (5e-6, 5e-7, 6.25e-6, 2.25e-5))), + "gpt-5.6-luna": (Self.codexGPT56PricingCutoff, Self.gpt56Pricing( + standard: (1e-6, 1e-7, 1.25e-6, 6e-6), longContext: (2e-6, 2e-7, 2.5e-6, 9e-6))), ] private static let claudeFullContextStandardPricingCutoff = Date(timeIntervalSince1970: 1_773_360_000) @@ -616,13 +568,13 @@ enum CostUsagePricing { { let key = pricingResolver?.normalize(model) ?? self.normalizeCodexModel(model) guard key != self.codexUnattributedModel else { return nil } - // Use historical bundled rates when the usage predates a known pricing change and - // no custom overlay or models.dev catalog entry overrides the lookup. + // Known historical rates take precedence over today’s catalog. Callers resolve custom + // overlays before reaching this lookup. if let pricingDate, - pricingDate < self.codexGPT56PricingCutoff, - let historical = self.codexHistoricalPricing[key] + let historical = self.codexHistoricalPricing[key], + pricingDate < historical.cutoff { - return historical + return historical.pricing } let modelsDevLookup = if let pricingResolver { pricingResolver.lookup(model) @@ -652,7 +604,6 @@ enum CostUsagePricing { outputCostPerToken: lookup.pricing.outputCostPerToken, cacheReadInputCostPerToken: lookup.pricing.cacheReadInputCostPerToken ?? bundled?.cacheReadInputCostPerToken, - displayLabel: nil, cacheWriteInputCostPerToken: lookup.pricing.cacheCreationInputCostPerToken ?? bundled?.cacheWriteInputCostPerToken, thresholdTokens: bundled?.thresholdTokens ?? lookup.pricing.thresholdTokens, diff --git a/Tests/CodexBarTests/CodexSolHistoricalPricingTests.swift b/Tests/CodexBarTests/CodexSolHistoricalPricingTests.swift new file mode 100644 index 0000000000..9db0d06666 --- /dev/null +++ b/Tests/CodexBarTests/CodexSolHistoricalPricingTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexSolHistoricalPricingTests { + @Test(arguments: ["gpt-5.6-sol", "gpt-5.6"], [100, 272_001]) + func `Sol keeps historical rates before its August repricing`( + model: String, input: Int) throws + { + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(#""" + {"openai":{"id":"openai","models":{"gpt-5.6-sol":{ + "id":"gpt-5.6-sol","cost":{"input":4,"cache_read":0.4,"cache_write":5,"output":20} + }}}} + """#.utf8)) + let cutoff = try #require(ISO8601DateParser.parse("2026-08-21T00:00:00Z")) + for sourceCatalog in [catalog, ModelsDevCatalog(providers: [:])] { + let resolvers: [CostUsagePricing.CodexResolver?] = [nil, .init(catalog: sourceCatalog)] + for resolver in resolvers { + for (date, historical) in [(cutoff.addingTimeInterval(-1), true), (cutoff, false)] { + let longContext = input > 272_000 + let inputRate = historical ? (longContext ? 10.0 : 5) : (longContext ? 8.0 : 4) + let outputRate = historical ? (longContext ? 45.0 : 30) : (longContext ? 30.0 : 20) + let expected = (Double(input - 30) * inputRate + inputRate + 25 * inputRate + 5 * outputRate) + / 1_000_000 + let standard = try #require(CostUsagePricing.codexCostUSD( + model: model, + inputTokens: input, + cachedInputTokens: 10, + outputTokens: 5, + cacheWriteInputTokens: 20, + pricingDate: date, + modelsDevCatalog: sourceCatalog, + pricingResolver: resolver)) + #expect(abs(standard - expected) < 1e-12) + let fast = CostUsagePricing.codexPriorityCostUSD( + model: model, + inputTokens: input, + cachedInputTokens: 10, + cacheWriteInputTokens: 20, + outputTokens: 5, + pricingDate: date, + modelsDevCatalog: sourceCatalog, + pricingResolver: resolver) + if longContext { + #expect(fast == nil) + } else { + let fast = try #require(fast) + #expect(abs(fast - expected * 2) < 1e-12) + } + } + } + } + #expect(CostUsagePricing.codexCostUSD( + model: "fixture-unknown-model", + inputTokens: input, + cachedInputTokens: 0, + outputTokens: 5, + modelsDevCatalog: catalog) == nil) + } +} diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 8c1b0289ec..e0295099b1 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -274,12 +274,13 @@ struct CostUsagePricingTests { // Empty models.dev cache root forces the built-in table. let root = try Self.cacheRoot() - func cost(_ model: String) -> Double? { + func cost(_ model: String, writes: Int = 0) -> Double? { CostUsagePricing.codexCostUSD( model: model, inputTokens: 100, cachedInputTokens: 10, outputTokens: 5, + cacheWriteInputTokens: writes, modelsDevCacheRoot: root) } @@ -287,6 +288,11 @@ struct CostUsagePricingTests { let cyber = (90.0 * 1.25e-5) + (10.0 * 1.25e-6) + (5.0 * 7.5e-5) #expect(cost("gpt-5.6-cyber") == cyber) #expect(cost("gpt-5.5-cyber") == cyber) + let writeCost = try #require(cost("gpt-5.6-cyber", writes: 20)) + let expectedWriteCost = (70.0 * 1.25e-5) + (10.0 * 1.25e-6) + (20.0 * 1.5625e-5) + (5.0 * 7.5e-5) + #expect(abs(writeCost - expectedWriteCost) < 1e-12) + let legacyWriteCost = try #require(cost("gpt-5.5-cyber", writes: 20)) + #expect(abs(legacyWriteCost - cyber) < 1e-12) #expect(cost("gpt-daybreak-blue-latest") == cost("gpt-5.6-sol")) #expect(cost("gpt-daybreak-red-latest") == cyber) } diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index 0e709b72a1..b15bb3cc88 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -706,7 +706,7 @@ struct CostUsageScannerPriorityTests { until: day, now: day, options: options) - let expected = (172_001.0 * 8e-6) + (100_000.0 * 8e-7) + (5.0 * 3e-5) + let expected = (172_001.0 * 1e-5) + (100_000.0 * 1e-6) + (5.0 * 4.5e-5) #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) let breakdown = try #require(report.data.first?.modelBreakdowns?.first) diff --git a/docs/antigravity.md b/docs/antigravity.md index c148eee0a5..12fad2c80b 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -329,6 +329,9 @@ five-hour duration. ## Local token history +Recognized Gemini 3.1 Pro product and effort aliases use the catalog’s preview-model rates while retaining their +recorded breakdown names. See [model pricing](model-pricing.md) for the supported aliases. + Local history reads only the existing recognized roots: `~/.gemini/antigravity-cli/conversations/*.db`, `~/.gemini/antigravity/*.db`, and `~/.gemini/antigravity/conversations/*.db`. `GEMINI_CLI_HOME` replaces `~/.gemini`. When SQLite discovery completes without any databases, the reader can use diff --git a/docs/codex.md b/docs/codex.md index 9781303711..11eda0c4fc 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -230,6 +230,8 @@ the local result and returns a nonzero exit code. See [CLI host reporting](cli.m - `~/.pi/agent/sessions/**/*.jsonl` - `~/.omp/agent/sessions/**/*.jsonl` - Scanner: + - Published model aliases resolve through the existing pricing canonicalizer. GPT-5.6 Sol estimates use the + rates applicable before or after August 21, 2026; see [model pricing](model-pricing.md). - Codex reserve telemetry uses the bundled GPT-5.6 Luna list-price estimate, including existing cached token rows. This estimates API-equivalent cost; it is not a charge for using a subscription reserve allowance. - Bundled `gpt-6-astra` pricing covers input, cache reads/writes, output, and the full-request long-context diff --git a/docs/model-pricing.md b/docs/model-pricing.md index 1fe30bd2e1..d467f2c0b4 100644 --- a/docs/model-pricing.md +++ b/docs/model-pricing.md @@ -39,10 +39,14 @@ Local cost scanners preserve that scope when selecting a catalog: - Other bare Claude-session IDs are priced only when exactly one selected first-party catalog matches. Ambiguous cross-vendor matches remain unpriced. - Provider-qualified Claude-session IDs stay on an approved explicit route and never fall through to another vendor. - Claude's [documented `k3[1m]` alias](https://www.kimi.com/code/docs/en/third-party-tools/claude-code.html) resolves to `kimi-for-coding/k3` after exact-row lookup, including the existing `kimi-coding/` and `kimi-for-coding/` routes. Recorded model names stay unchanged; other context variants and paid Moonshot routes are not inferred. Catalog zero rates remain known estimates, not a claim that subscriptions or extra usage are free. -- OpenAI's [Daybreak aliases](https://developers.openai.com/api/docs/pricing) resolve like the unsuffixed `gpt-5.6` alias: `gpt-daybreak-blue-latest` prices as `gpt-5.6-sol` and `gpt-daybreak-red-latest` as `gpt-5.6-cyber`. Recorded model names stay unchanged. +- OpenAI's [Daybreak aliases](https://developers.openai.com/api/docs/pricing) resolve like the unsuffixed `gpt-5.6` alias: `gpt-daybreak-blue-latest` prices as `gpt-5.6-sol` and `gpt-daybreak-red-latest` as `gpt-5.6-cyber`. Native usage rows retain raw model evidence; Codex aggregate model IDs follow the canonicalizer. - Antigravity's Gemini 3.1 Pro aliases (`gemini-pro-default`, `gemini-pro-agent`, and the `gemini-3.1-pro` effort tiers) price as `gemini-3.1-pro-preview`, the only catalogued Gemini 3.1 Pro row. The alias is provider-local; recorded model names stay unchanged. - Vertex AI Claude logs: models.dev provider id `google-vertex-anthropic` +Dated Codex usage retains the prior bundled GPT-5.6 Sol rates before **2026-08-21 UTC**, the repricing date in the +[OpenAI changelog](https://developers.openai.com/api/docs/changelog). Current and undated usage use the published +current rates. Terra and Luna retain their separate July 30 cutoff. Custom-pricing overlays retain precedence. + ### Explicit provider identity in OpenCodex OpenCodex estimates use the recorded provider and model together. An unqualified model on `opencode-go` From 9ee7a351460e4a03d8433c04e89412049be54df7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 05:29:54 -0700 Subject: [PATCH 056/122] test(pricing): distinguish historical and catalog Pi costs --- .../PiSessionCostScannerTests.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift index fb8ad69e2e..336c9f82ac 100644 --- a/Tests/CodexBarTests/PiSessionCostScannerTests.swift +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -729,6 +729,7 @@ struct PiSessionCostScannerTests { cachedInputTokens: 10, outputTokens: 5, cacheWriteInputTokens: 20, + pricingDate: day, modelsDevCacheRoot: env.cacheRoot) ?? 0 // Stale: writes folded into uncached input at 1× (pre-v5 behavior). let staleCost = CostUsagePricing.codexCostUSD( @@ -736,6 +737,7 @@ struct PiSessionCostScannerTests { inputTokens: 100, cachedInputTokens: 10, outputTokens: 5, + pricingDate: day, modelsDevCacheRoot: env.cacheRoot) ?? 0 #expect(abs(expectedCost - staleCost) > 0.000001) @@ -1053,12 +1055,12 @@ extension PiSessionCostScannerTests { #expect(cache.files.values.flatMap(\.entryUsages.keys).count == 4) } - @Test - func `pi scanner reprices unchanged files when catalog rates change`() throws { + @Test(arguments: [false, true]) + func `pi scanner updates catalog pricing while retaining historical rates`(historical: Bool) throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } - let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) + let day = try env.makeLocalNoon(year: 2026, month: historical ? 7 : 9, day: 10) let model = "gpt-5.6-sol" func assistant(at timestamp: Date) -> [String: Any] { [ @@ -1078,7 +1080,7 @@ extension PiSessionCostScannerTests { ] } _ = try env.writePiSessionFile( - relativePath: "2026-07-10T10-00-00-000Z_catalog-change.jsonl", + relativePath: "catalog-change.jsonl", contents: env.jsonl([ assistant(at: day.addingTimeInterval(-1)), assistant(at: day), @@ -1099,7 +1101,7 @@ extension PiSessionCostScannerTests { let firstCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let firstPricingKey = try #require(firstCache.pricingKey) #expect(firstReport.data.first?.totalTokens == 300_000) - #expect(abs((firstReport.data.first?.costUSD ?? 0) - 1.2) < 0.0000001) + #expect(abs((firstReport.data.first?.costUSD ?? 0) - (historical ? 1.5 : 1.2)) < 0.0000001) let secondCatalog = try Self.modelsDevCatalog(inputCostPerMillion: 8) #expect(ModelsDevCache.save( @@ -1122,9 +1124,9 @@ extension PiSessionCostScannerTests { let secondCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) #expect(secondCache.pricingKey != firstPricingKey) // Each 150K message stays below the 272K threshold. The 300K daily aggregate must be the - // sum of two short-context costs, proving the pricing change triggered a full-file reparse. + // sum of two short-context costs. Historical rows keep their dated rate across catalog refreshes. #expect(secondReport.data.first?.totalTokens == 300_000) - #expect(abs((secondReport.data.first?.costUSD ?? 0) - 2.4) < 0.0000001) + #expect(abs((secondReport.data.first?.costUSD ?? 0) - (historical ? 1.5 : 2.4)) < 0.0000001) } @Test From 21fd29877829eedfea5e44655814a3d7213aaf6f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 05:43:10 -0700 Subject: [PATCH 057/122] test(pricing): isolate aliased model rate coverage --- .../CodexAliasedModelPricingTests.swift | 35 +++++++++++++++++++ .../CodexBarTests/CostUsagePricingTests.swift | 28 --------------- 2 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 Tests/CodexBarTests/CodexAliasedModelPricingTests.swift diff --git a/Tests/CodexBarTests/CodexAliasedModelPricingTests.swift b/Tests/CodexBarTests/CodexAliasedModelPricingTests.swift new file mode 100644 index 0000000000..c9039324f5 --- /dev/null +++ b/Tests/CodexBarTests/CodexAliasedModelPricingTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexAliasedModelPricingTests { + @Test + func `codex cost prices daybreak aliases and cyber bundled fallback`() throws { + // Empty models.dev cache root forces the built-in table. + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let root = env.cacheRoot + + func cost(_ model: String, writes: Int = 0) -> Double? { + CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + cacheWriteInputTokens: writes, + modelsDevCacheRoot: root) + } + + // Cyber rates per token: $12.50 input, $1.25 cached input, $75 output per 1M. + let cyber = (90.0 * 1.25e-5) + (10.0 * 1.25e-6) + (5.0 * 7.5e-5) + #expect(cost("gpt-5.6-cyber") == cyber) + #expect(cost("gpt-5.5-cyber") == cyber) + let writeCost = try #require(cost("gpt-5.6-cyber", writes: 20)) + let expectedWriteCost = (70.0 * 1.25e-5) + (10.0 * 1.25e-6) + (20.0 * 1.5625e-5) + (5.0 * 7.5e-5) + #expect(abs(writeCost - expectedWriteCost) < 1e-12) + let legacyWriteCost = try #require(cost("gpt-5.5-cyber", writes: 20)) + #expect(abs(legacyWriteCost - cyber) < 1e-12) + #expect(cost("gpt-daybreak-blue-latest") == cost("gpt-5.6-sol")) + #expect(cost("gpt-daybreak-red-latest") == cyber) + } +} diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index e0295099b1..b120d102a9 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -269,34 +269,6 @@ struct CostUsagePricingTests { #expect(alias == sol) } - @Test - func `codex cost prices daybreak aliases and cyber bundled fallback`() throws { - // Empty models.dev cache root forces the built-in table. - let root = try Self.cacheRoot() - - func cost(_ model: String, writes: Int = 0) -> Double? { - CostUsagePricing.codexCostUSD( - model: model, - inputTokens: 100, - cachedInputTokens: 10, - outputTokens: 5, - cacheWriteInputTokens: writes, - modelsDevCacheRoot: root) - } - - // Cyber rates per token: $12.50 input, $1.25 cached input, $75 output per 1M. - let cyber = (90.0 * 1.25e-5) + (10.0 * 1.25e-6) + (5.0 * 7.5e-5) - #expect(cost("gpt-5.6-cyber") == cyber) - #expect(cost("gpt-5.5-cyber") == cyber) - let writeCost = try #require(cost("gpt-5.6-cyber", writes: 20)) - let expectedWriteCost = (70.0 * 1.25e-5) + (10.0 * 1.25e-6) + (20.0 * 1.5625e-5) + (5.0 * 7.5e-5) - #expect(abs(writeCost - expectedWriteCost) < 1e-12) - let legacyWriteCost = try #require(cost("gpt-5.5-cyber", writes: 20)) - #expect(abs(legacyWriteCost - cyber) < 1e-12) - #expect(cost("gpt-daybreak-blue-latest") == cost("gpt-5.6-sol")) - #expect(cost("gpt-daybreak-red-latest") == cyber) - } - @Test func `codex models dev falls back from gpt56 alias to canonical sol pricing`() throws { let canonicalOnlyRoot = try Self.seedModelsDevCache(""" From b73186a7f07781041d03707bc32a753ae5a2e7b0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 05:49:03 -0700 Subject: [PATCH 058/122] fix(history): share quota burndown labels and chart preparation Preserve recorded quota and utilization history while using provider labels, including Claude Sonnet. Replace the debug proof app with synthetic light and dark render tests and document existing retention and privacy behavior. Co-authored-by: Dohyeon Park --- CHANGELOG.md | 4 + Sources/CodexBar/CodexbarApp.swift | 3 - .../PlanUtilizationHistoryChartMenuView.swift | 12 +- .../CodexBar/QuotaBurndownChartMenuView.swift | 40 +-- Sources/CodexBar/QuotaBurndownModel.swift | 8 +- .../CodexBar/QuotaBurndownNativeProof.swift | 235 ------------------ ...tatusItemController+UsageHistoryMenu.swift | 22 +- .../QuotaBurndownChartMenuViewTests.swift | 1 + .../QuotaBurndownRenderProofTests.swift | 84 ++++--- .../StatusMenuHostedSubmenuRefreshTests.swift | 12 +- docs/claude.md | 4 + docs/codex.md | 4 + docs/widgets/burndown-proof.md | 88 ++++--- 13 files changed, 150 insertions(+), 367 deletions(-) delete mode 100644 Sources/CodexBar/QuotaBurndownNativeProof.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cae62b8b..7420ee8437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.68.1 — Unreleased +### Added + +- Plan Usage: show recorded remaining-quota burndown for Codex and Claude alongside utilization history, with capture age and calendar endpoints (#4085). Thanks @callmejustdodo! + ### Changed - Menu bar: align the persistent Refresh row with other menu actions by removing its decorative icon, preserving the shortcut and accessibility action (#4057). Thanks @elijahfriedman! diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 46e9fd4575..2ac82fb885 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -27,9 +27,6 @@ enum CodexBarEntryPoint { exit(CodexBarCoreResourceSmoke.run()) } #if DEBUG - if QuotaBurndownNativeProof.runIfRequested() { - return - } if MenuBarLayoutNativeProof.runIfRequested() { return } diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 21b782935d..5fe7d899c2 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -13,7 +13,7 @@ struct PlanUtilizationHistoryChartMenuView: View { static let barWidth: CGFloat = 6 } - private struct SeriesSelection: Hashable { + struct SeriesSelection: Hashable { let name: PlanUtilizationSeriesName let windowMinutes: Int @@ -22,7 +22,7 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - private struct VisibleSeries: Identifiable, Equatable { + struct VisibleSeries: Identifiable, Equatable { let selection: SeriesSelection let title: String let history: PlanUtilizationSeriesHistory @@ -76,14 +76,14 @@ struct PlanUtilizationHistoryChartMenuView: View { provider: UsageProvider, histories: [PlanUtilizationSeriesHistory], snapshot: UsageSnapshot? = nil, - width: CGFloat) + width: CGFloat, + referenceDate: Date = Date()) { self.provider = provider let visibleSeries = Self.visibleSeries( histories: histories, provider: provider, snapshot: snapshot) - let referenceDate = Date() self.visibleSeries = visibleSeries self.modelsBySeriesID = Dictionary(uniqueKeysWithValues: visibleSeries.map { ($0.id, Self.makeModel(history: $0.history, provider: provider, referenceDate: referenceDate)) @@ -179,7 +179,7 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - private nonisolated static func visibleSeries( + nonisolated static func visibleSeries( histories: [PlanUtilizationSeriesHistory], provider: UsageProvider, snapshot: UsageSnapshot?) -> [VisibleSeries] @@ -237,7 +237,7 @@ struct PlanUtilizationHistoryChartMenuView: View { /// Histories recorded before duration-based classification stored a 43,200-minute Codex window /// under its payload slot (session for primary, weekly for secondary). Fold those into the /// monthly series so the chart does not split or hide the window's history. - nonisolated static func effectiveSeriesName( + private nonisolated static func effectiveSeriesName( provider: UsageProvider, history: PlanUtilizationSeriesHistory) -> PlanUtilizationSeriesName { diff --git a/Sources/CodexBar/QuotaBurndownChartMenuView.swift b/Sources/CodexBar/QuotaBurndownChartMenuView.swift index 83732d3be0..b47bf38a2a 100644 --- a/Sources/CodexBar/QuotaBurndownChartMenuView.swift +++ b/Sources/CodexBar/QuotaBurndownChartMenuView.swift @@ -23,8 +23,11 @@ struct QuotaBurndownChartMenuView: View { width: CGFloat, referenceDate: Date = Date()) { - self.series = Self.normalizedHistories(histories, provider: provider).compactMap { history in - guard let latest = history.entries.last, + self.series = PlanUtilizationHistoryChartMenuView.visibleSeries( + histories: histories, provider: provider, snapshot: nil).compactMap { series in + let history = series.history + guard [.session, .weekly, .monthly, .opus].contains(history.name), + let latest = history.entries.last, let reset = latest.resetsAt, latest.capturedAt <= referenceDate, reset > referenceDate @@ -36,17 +39,9 @@ struct QuotaBurndownChartMenuView: View { resetDescription: nil) guard let model = QuotaBurndownModel(history: history, window: window, now: latest.capturedAt) else { return nil } - let title: String - switch history.name { - case .session: title = L("Session") - case .weekly: title = L("Weekly") - case .monthly: title = L("Monthly") - case .opus: title = L("Opus") - default: return nil - } return Series( - id: "\(history.name.rawValue):\(history.windowMinutes)", - title: title, + id: series.id, + title: series.title, model: model, lastKnownUsageMessage: LastKnownUsagePresentation.message( capturedAt: latest.capturedAt, @@ -146,27 +141,6 @@ struct QuotaBurndownChartMenuView: View { !self.series.isEmpty } - private static func normalizedHistories( - _ histories: [PlanUtilizationSeriesHistory], - provider: UsageProvider) -> [PlanUtilizationSeriesHistory] - { - var orderedIDs: [String] = [] - var historiesByID: [String: PlanUtilizationSeriesHistory] = [:] - for history in histories { - guard [.session, .weekly, .monthly, .opus].contains(history.name) else { continue } - let name = PlanUtilizationHistoryChartMenuView.effectiveSeriesName(provider: provider, history: history) - let windowMinutes = name.canonicalWindowMinutes(history.windowMinutes) - let id = "\(name.rawValue):\(windowMinutes)" - if historiesByID[id] == nil { orderedIDs.append(id) } - historiesByID[id] = PlanUtilizationSeriesHistory( - name: name, - windowMinutes: windowMinutes, - entries: PlanUtilizationHistoryChartMenuView.mergedEntries( - (historiesByID[id]?.entries ?? []) + history.entries)) - } - return orderedIDs.compactMap { historiesByID[$0] } - } - private func axisLabel( for date: Date, model: QuotaBurndownModel, diff --git a/Sources/CodexBar/QuotaBurndownModel.swift b/Sources/CodexBar/QuotaBurndownModel.swift index a36ab6acbe..8fa61c1681 100644 --- a/Sources/CodexBar/QuotaBurndownModel.swift +++ b/Sources/CodexBar/QuotaBurndownModel.swift @@ -33,7 +33,7 @@ struct QuotaBurndownModel: Equatable, Sendable { now < reset else { return nil } - let historicalSamples = history.entries.enumerated().compactMap { index, entry -> (Int, Date, Double)? in + let historicalSamples = history.entries.compactMap { entry -> (Date, Double)? in guard entry.capturedAt >= start, entry.capturedAt <= now, entry.capturedAt.timeIntervalSinceReferenceDate.isFinite, @@ -42,12 +42,8 @@ struct QuotaBurndownModel: Equatable, Sendable { abs($0.timeIntervalSince(reset)) <= Self.resetEquivalenceTolerance }) ?? true else { return nil } - return (index, entry.capturedAt, entry.usedPercent) + return (entry.capturedAt, entry.usedPercent) } - .sorted { lhs, rhs in - lhs.1 == rhs.1 ? lhs.0 < rhs.0 : lhs.1 < rhs.1 - } - .map { ($0.1, $0.2) } var currentSegment: [(Date, Double)] = [] for sample in historicalSamples + [(now, window.usedPercent)] { diff --git a/Sources/CodexBar/QuotaBurndownNativeProof.swift b/Sources/CodexBar/QuotaBurndownNativeProof.swift deleted file mode 100644 index eb6ae5d70c..0000000000 --- a/Sources/CodexBar/QuotaBurndownNativeProof.swift +++ /dev/null @@ -1,235 +0,0 @@ -#if DEBUG -import AppKit -import CodexBarCore - -/// A separate, opt-in process exercises the production lazy submenu with synthetic captures. -@MainActor -enum QuotaBurndownNativeProof { - static func runIfRequested() -> Bool { - guard CommandLine.arguments.contains("--quota-burndown-proof") else { return false } - // SettingsStore has no injected app-group migration switch. Its existing test gate also - // disables shared defaults, login-item registration, and automatic background work. - setenv("SWIFT_TESTING_ENABLED", "1", 1) - guard TestProcessSafety.isRunning, SettingsStore.isRunningTests else { - FileHandle.standardError.write(Data("Quota proof requires isolated process safety gates.\n".utf8)) - return true - } - KeychainAccessGate.isDisabled = true - let app = NSApplication.shared - app.setActivationPolicy(.regular) - let delegate = Delegate() - app.delegate = delegate - withExtendedLifetime(delegate) { app.run() } - return true - } - - @MainActor - private final class Delegate: NSObject, NSApplicationDelegate { - private var controller: StatusItemController? - private var store: UsageStore? - private var settings: SettingsStore? - private var item: NSStatusItem? - private var directory: URL? - private var window: NSWindow? - private var stale = false - - func applicationDidFinishLaunching(_ notification: Notification) { - do { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("CodexBar-quota-proof-\(UUID().uuidString)", isDirectory: true) - self.directory = directory - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let configStore = CodexBarConfigStore(fileURL: directory.appendingPathComponent("config.json")) - try configStore.save(CodexBarConfig(providers: UsageProvider.allCases.map { - // Provider-specific by design: this native proof enables only synthetic Codex quota lanes. - ProviderConfig(id: $0.instanceID, enabled: $0 == .codex) - })) - let defaults = ProofDefaults(values: [ - "debugDisableKeychainAccess": true, - "agentSessionsEnabled": false, - "openAIWebAccessEnabled": false, - "launchAtLogin": false, - ]) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - tokenAccountStore: FileTokenAccountStore(fileURL: directory - .appendingPathComponent("accounts.json")), - antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore( - fileURL: directory.appendingPathComponent("antigravity.json")), - performInitialProviderDetection: false) - // Ownership selection otherwise consults real Codex auth state even without polling. - settings._test_codexAccountSnapshotLoader = { source in - CodexAccountReconciliationSnapshot( - storedAccounts: [], - activeStoredAccount: nil, - liveSystemAccount: nil, - matchingStoredAccountForLiveSystemAccount: nil, - activeSource: source, - hasUnreadableAddedAccountStore: false) - } - let environment = ["HOME": directory.path, "CODEX_HOME": directory.path] - let store = UsageStore( - fetcher: UsageFetcher(environment: environment), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings, - historicalUsageHistoryStore: HistoricalUsageHistoryStore( - fileURL: directory.appendingPathComponent("historical.json")), - planUtilizationHistoryStore: PlanUtilizationHistoryStore(directoryURL: nil), - startupBehavior: .testing, - environmentBase: environment, - widgetTimelineReloader: {}) - let controller = StatusItemController( - store: store, - settings: settings, - account: AccountInfo(email: nil, plan: nil), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - menuCardRenderingEnabled: true, - menuRefreshEnabled: false, - observeProviderConfigNotifications: false) - controller.statusItem.isVisible = false - self.settings = settings - self.store = store - self.controller = controller - let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - item.button?.title = "Quota proof" - item.button?.setAccessibilityIdentifier("codexbar-synthetic-quota-proof") - self.item = item - self.rebuildMenu() - self.showProofWindow() - } catch { - FileHandle.standardError.write(Data("Quota proof failed: \(error)\n".utf8)) - NSApplication.shared.terminate(nil) - } - } - - private func showProofWindow() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 560, height: 200), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false) - window.title = "CodexBar native menu proof" - let label = NSTextField(wrappingLabelWithString: - "Synthetic data only. Open Plan Usage, then choose Weekly in the burndown chart. " - + "The historical chart remains below. No accounts or providers are contacted.") - label.frame = NSRect(x: 24, y: 105, width: 512, height: 70) - window.contentView?.addSubview(label) - let button = NSButton(title: "Open Plan Usage", target: self, action: #selector(self.openMenu(_:))) - button.frame = NSRect(x: 24, y: 45, width: 180, height: 34) - window.contentView?.addSubview(button) - window.center() - window.makeKeyAndOrderFront(nil) - NSApplication.shared.activate(ignoringOtherApps: true) - self.window = window - } - - @objc private func openMenu(_ sender: NSButton) { - self.item?.menu?.popUp( - positioning: nil, - at: NSPoint(x: sender.frame.minX, y: sender.frame.minY), - in: sender.superview) - } - - private func rebuildMenu() { - guard let store, let controller, let item else { return } - let now = Date() - let sessionReset = now.addingTimeInterval(2 * 3600) - let weeklyReset = now.addingTimeInterval(2 * 24 * 3600) - let sessionCapture = now.addingTimeInterval(self.stale ? -3600 : -120) - let weeklyCapture = now.addingTimeInterval(self.stale ? -12 * 3600 : -3 * 3600) - var buckets = PlanUtilizationHistoryBuckets() - buckets.setHistories([ - PlanUtilizationSeriesHistory(name: .session, windowMinutes: 300, entries: [ - .init(capturedAt: now.addingTimeInterval(-2 * 3600), usedPercent: 8, resetsAt: sessionReset), - .init(capturedAt: sessionCapture, usedPercent: 42, resetsAt: sessionReset), - ]), - PlanUtilizationSeriesHistory(name: .weekly, windowMinutes: 10080, entries: [ - .init(capturedAt: now.addingTimeInterval(-4 * 24 * 3600), usedPercent: 5, resetsAt: weeklyReset), - .init(capturedAt: now.addingTimeInterval(-3 * 24 * 3600), usedPercent: 22, resetsAt: weeklyReset), - .init(capturedAt: now.addingTimeInterval(-24 * 3600), usedPercent: 48, resetsAt: weeklyReset), - .init(capturedAt: weeklyCapture, usedPercent: 68, resetsAt: weeklyReset), - ]), - ], for: nil) - // Provider-specific by design: the fixture seeds synthetic Codex history without a live snapshot. - store.planUtilizationHistory[.codex] = buckets - store.planUtilizationHistoryLoaded = true - store.planUtilizationHistoryRevision &+= 1 - // There is deliberately no live snapshot: these are explicitly saved captures. - let menu = NSMenu(title: "Synthetic quota proof") - menu.autoenablesItems = false - menu.addItem(NSMenuItem(title: "Synthetic data only", action: nil, keyEquivalent: "")) - menu.addItem(NSMenuItem( - title: self.stale ? "Older captures · no live snapshot" : "Session recent · Weekly 3h old", - action: nil, - keyEquivalent: "")) - menu.addItem(.separator()) - // Provider-specific by design: exercise the production Codex Plan Usage submenu. - if let submenu = controller.makeUsageHistorySubmenu(provider: .codex, width: 400) { - let entry = NSMenuItem(title: "Plan Usage", action: nil, keyEquivalent: "") - entry.isEnabled = true - entry.submenu = submenu - menu.addItem(entry) - // Keep the placeholder untouched; the real controller hydrates on submenu open. - } - menu.addItem(.separator()) - let toggle = NSMenuItem(title: "Toggle older captures", action: #selector(self.toggle), keyEquivalent: "") - toggle.target = self - menu.addItem(toggle) - let quit = NSMenuItem(title: "Quit proof", action: #selector(self.quit), keyEquivalent: "") - quit.target = self - menu.addItem(quit) - item.menu = menu - FileHandle.standardOutput.write(Data("quota-proof ready synthetic-only stale=\(self.stale)\n".utf8)) - } - - @objc private func toggle() { - self.stale.toggle() - self.rebuildMenu() - } - - @objc private func quit() { - NSApplication.shared.terminate(nil) - } - - func applicationWillTerminate(_ notification: Notification) { - self.controller?.prepareForAppShutdown() - if let item { NSStatusBar.system.removeStatusItem(item) } - // Only the unique synthetic directory created by this process is removed. - if let directory { try? FileManager.default.removeItem(at: directory) } - } - } - - /// Absent keys cannot fall through to Foundation defaults domains; writes stay in memory. - private final class ProofDefaults: UserDefaults, @unchecked Sendable { - private let lock = NSLock() - private var values: [String: Any] - - init(values: [String: Any]) { - self.values = values - super.init(suiteName: "QuotaProof-\(UUID().uuidString)")! - } - - override func object(forKey key: String) -> Any? { self.lock.withLock { self.values[key] } } - override func set(_ value: Any?, forKey key: String) { self.lock.withLock { self.values[key] = value } } - override func removeObject(forKey key: String) { self.set(nil as Any?, forKey: key) } - override func bool(forKey key: String) -> Bool { (self.object(forKey: key) as? NSNumber)?.boolValue ?? false } - override func integer(forKey key: String) -> Int { (self.object(forKey: key) as? NSNumber)?.intValue ?? 0 } - override func float(forKey key: String) -> Float { (self.object(forKey: key) as? NSNumber)?.floatValue ?? 0 } - override func double(forKey key: String) -> Double { (self.object(forKey: key) as? NSNumber)?.doubleValue ?? 0 } - override func string(forKey key: String) -> String? { self.object(forKey: key) as? String } - override func array(forKey key: String) -> [Any]? { self.object(forKey: key) as? [Any] } - override func dictionary(forKey key: String) -> [String: Any]? { self.object(forKey: key) as? [String: Any] } - override func data(forKey key: String) -> Data? { self.object(forKey: key) as? Data } - override func stringArray(forKey key: String) -> [String]? { self.object(forKey: key) as? [String] } - override func url(forKey key: String) -> URL? { self.object(forKey: key) as? URL } - override func set(_ value: Bool, forKey key: String) { self.set(value as Any, forKey: key) } - override func set(_ value: Int, forKey key: String) { self.set(value as Any, forKey: key) } - override func set(_ value: Float, forKey key: String) { self.set(value as Any, forKey: key) } - override func set(_ value: Double, forKey key: String) { self.set(value as Any, forKey: key) } - override func set(_ value: URL?, forKey key: String) { self.set(value as Any?, forKey: key) } - override func dictionaryRepresentation() -> [String: Any] { self.lock.withLock { self.values } } - } -} -#endif diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index e27d0d62d7..c8a39375f6 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -56,16 +56,7 @@ extension StatusItemController { histories: histories, width: width) if burndownView.hasSeries { - let hosting = UsageHistoryMenuHostingView(rootView: burndownView) - hosting.frame = NSRect( - origin: .zero, - size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = true - chartItem.representedObject = Self.usageHistoryChartID - chartItem.toolTip = provider.rawValue - submenu.addItem(chartItem) + self.appendUsageHistoryChart(burndownView, to: submenu, provider: provider, width: width) submenu.addItem(.separator()) } } @@ -75,6 +66,16 @@ extension StatusItemController { histories: histories, snapshot: snapshot, width: width) + self.appendUsageHistoryChart(chartView, to: submenu, provider: provider, width: width) + return true + } + + private func appendUsageHistoryChart( + _ chartView: some View, + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) + { let hosting = UsageHistoryMenuHostingView(rootView: chartView) hosting.frame = NSRect( origin: .zero, @@ -86,6 +87,5 @@ extension StatusItemController { chartItem.representedObject = Self.usageHistoryChartID chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) - return true } } diff --git a/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift b/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift index a13a37bfaa..e0e141d4bf 100644 --- a/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift +++ b/Tests/CodexBarTests/QuotaBurndownChartMenuViewTests.swift @@ -84,6 +84,7 @@ struct QuotaBurndownChartMenuViewTests { #expect(view._seriesRemainingForTesting["weekly:10080"] == 80) #expect(view._seriesRemainingForTesting["opus:10080"] == 30) + #expect(view._seriesTitlesForTesting["opus:10080"] == "Sonnet") #expect(view._seriesLastKnownMessagesForTesting["weekly:10080"] == LastKnownUsagePresentation.message( capturedAt: now, now: now)) diff --git a/Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift b/Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift index 933eeb9e33..cf3f3947f1 100644 --- a/Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift +++ b/Tests/CodexBarTests/QuotaBurndownRenderProofTests.swift @@ -6,43 +6,61 @@ import Testing @MainActor struct QuotaBurndownRenderProofTests { - @Test - func `render current window from synthetic quota samples`() throws { - guard let path = ProcessInfo.processInfo.environment["CODEXBAR_BURNDOWN_PROOF_PATH"] else { return } - let weekly = ProcessInfo.processInfo.environment["CODEXBAR_BURNDOWN_PROOF_WEEKLY"] == "1" - let now = Date() + enum Fixture: String, CaseIterable { + case session, weekly, monthly, claude, before, after + } + + @Test(arguments: Fixture.allCases, [false, true]) + func `render synthetic quota charts in both appearances`(fixture: Fixture, dark: Bool) throws { + let now = Date(timeIntervalSince1970: 1_790_553_600) + let weekly = fixture != .session + let minutes = fixture == .monthly ? 43200 : weekly ? 10080 : 300 let reset = now.addingTimeInterval(weekly ? 2 * 86400 : 2 * 3600) - let sampleInterval: TimeInterval = weekly ? 86400 : 3600 + let interval: TimeInterval = weekly ? 86400 : 3600 let history = PlanUtilizationSeriesHistory( - name: weekly ? .weekly : .session, - windowMinutes: weekly ? 10080 : 300, + name: fixture == .monthly ? .monthly : weekly ? .weekly : .session, + windowMinutes: minutes, entries: [ - .init(capturedAt: now.addingTimeInterval(-2 * sampleInterval), usedPercent: 10, resetsAt: reset), - .init(capturedAt: now.addingTimeInterval(-sampleInterval), usedPercent: 35, resetsAt: reset), - .init(capturedAt: now.addingTimeInterval(-sampleInterval / 2), usedPercent: 48, resetsAt: reset), - ]) - let current = PlanUtilizationSeriesHistory( - name: history.name, - windowMinutes: history.windowMinutes, - entries: history.entries + [ - .init(capturedAt: now, usedPercent: 60, resetsAt: reset), + .init(capturedAt: now.addingTimeInterval(-2 * interval), usedPercent: 10, resetsAt: reset), + .init(capturedAt: now.addingTimeInterval(-interval), usedPercent: 35, resetsAt: reset), + .init(capturedAt: now.addingTimeInterval(-interval / 2), usedPercent: 60, resetsAt: reset), ]) - let view = QuotaBurndownChartMenuView( - provider: .codex, - histories: [current], - width: 400, - referenceDate: now) - .frame(width: 400) - .padding(12) - .background(Color.white) - .environment(\.colorScheme, .light) + let histories = fixture == .claude ? [history, PlanUtilizationSeriesHistory( + name: .opus, + windowMinutes: 10080, + entries: [.init(capturedAt: now.addingTimeInterval(-7200), usedPercent: 30, resetsAt: reset)])] : [history] + let provider: UsageProvider = fixture == .claude ? .claude : .codex + let view = VStack(spacing: 0) { + if fixture != .before { + QuotaBurndownChartMenuView(provider: provider, histories: histories, width: 400, referenceDate: now) + } + if fixture == .after { Divider() } + if fixture == .before || fixture == .after { + PlanUtilizationHistoryChartMenuView( + provider: provider, histories: histories, width: 400, referenceDate: now) + } + } + .frame(width: 400) + .padding(12) + .background(dark ? Color.black : Color.white) + .environment(\.colorScheme, dark ? .dark : .light) let hosting = NSHostingView(rootView: view) - hosting.appearance = NSAppearance(named: .aqua) - hosting.frame = CGRect(origin: .zero, size: hosting.fittingSize) - hosting.layoutSubtreeIfNeeded() - let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) - hosting.cacheDisplay(in: hosting.bounds, to: bitmap) - try #require(bitmap.representation(using: .png, properties: [:])) - .write(to: URL(fileURLWithPath: path), options: .atomic) + let appearance = try #require(NSAppearance(named: dark ? .darkAqua : .aqua)) + hosting.appearance = appearance + var bitmap: NSBitmapImageRep? + appearance.performAsCurrentDrawingAppearance { + hosting.frame = CGRect(origin: .zero, size: hosting.fittingSize) + hosting.layoutSubtreeIfNeeded() + #expect(hosting.bounds.width == 424) + #expect(hosting.bounds.height > 150) + bitmap = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds) + if let bitmap { hosting.cacheDisplay(in: hosting.bounds, to: bitmap) } + } + let png = try #require(bitmap?.representation(using: .png, properties: [:])) + if let path = ProcessInfo.processInfo.environment["CODEXBAR_BURNDOWN_PROOF_DIR"] { + let directory = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try png.write(to: directory.appendingPathComponent("\(fixture.rawValue)-\(dark ? "dark" : "light").png")) + } } } diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 01627f33ee..70a4619083 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -542,14 +542,10 @@ struct StatusMenuHostedSubmenuRefreshTests { } private static func makeSettings() -> SettingsStore { - let suite = "StatusMenuHostedSubmenuRefreshTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - return SettingsStore( - userDefaults: defaults, - configStore: testConfigStore(suiteName: suite), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + testSettingsStore( + suiteName: "StatusMenuHostedSubmenuRefreshTests", + userDefaults: InMemoryUserDefaults(), + config: testConfigWithAllProvidersDisabled()) } private static func enableOnlyClaude(_ settings: SettingsStore) { diff --git a/docs/claude.md b/docs/claude.md index 4990045e1d..d81ed00046 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -9,6 +9,10 @@ read_when: # Claude provider +The **Plan Usage** submenu includes recorded remaining-quota burndown above utilization history, +using the same Session, Weekly, and Sonnet labels. See [recorded quota burndown](widgets/burndown-proof.md) +for capture-age semantics and the existing history retention/privacy behavior. + Claude supports three usage data paths plus local cost usage. The main provider pipeline uses runtime-specific automatic selection, but the codebase still has multiple active Claude `.auto` decision sites while the refactor is pending. For the exact current-state parity contract, see diff --git a/docs/codex.md b/docs/codex.md index 9781303711..03762ad4f7 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -9,6 +9,10 @@ read_when: # Codex provider +The **Plan Usage** submenu includes recorded remaining-quota burndown above utilization history, +including saved Monthly windows. See [recorded quota burndown](widgets/burndown-proof.md) +for capture-age semantics and the existing history retention/privacy behavior. + Codex has three automatic usage data paths (OAuth API, web dashboard, CLI RPC) plus a manual CLI PTY diagnostic parser and a local cost-usage scanner. The OAuth API is the default app source when credentials are available; web access is optional for dashboard extras. diff --git a/docs/widgets/burndown-proof.md b/docs/widgets/burndown-proof.md index d3a7a028b6..56986e52a3 100644 --- a/docs/widgets/burndown-proof.md +++ b/docs/widgets/burndown-proof.md @@ -1,37 +1,61 @@ -# Quota burndown proof - -The debug app has an opt-in synthetic fixture for the production Plan Usage submenu: - -```sh -CODEXBAR_SIGNING=adhoc ./Scripts/package_app.sh debug -open -n CodexBar.app --args --quota-burndown-proof -``` - -Choose **Open Plan Usage**, then **Weekly** in the upper chart. The submenu uses the -same lazy hydration and hosted views as the normal menu. It shows the recorded -burndown, its capture age, and the original utilization chart below a separator. -Weekly endpoints include localized weekday, month, date, and time. Session endpoints -remain compact time labels. - -The fixture runs before normal startup, disables Keychain access and background -refresh, and stores its synthetic configuration in a unique temporary directory. -It does not contact providers or load real accounts. **Toggle older captures** -exercises the last-known label without supplying a live snapshot. - -The native submenu below was captured from the running synthetic fixture. It shows -both charts and the capture-age label, before the calendar-label follow-up. - -![Running native Plan Usage submenu](burndown-native-synthetic.png) - -A separate rendering fixture produces the calendar-label screenshot: +# Recorded quota burndown + +Codex and Claude's **Plan Usage** submenu shows recorded remaining quota above the +existing utilization history when a saved quota window has not expired. Each chart +keeps its own selector. Labels and saved-window normalization come from the shared +utilization-chart preparation, including Claude's Sonnet lane and legacy Codex +30-day windows displayed as Monthly. + +The solid line joins recorded quota percentages; it is not an exact token count. +The dashed line is an even-use guide from 100% at the window start to 0% at reset, +not a forecast or the learned/workday pace calculation. Each selected series shows +its actual capture age. No line is extended to the current time after the last +capture. Weekly and monthly endpoints include localized calendar dates and times; +session endpoints use compact times. Expired windows disappear from the burndown, +while the utilization history remains accessible below it. + +## Retention and privacy + +This view adds no persistent store, setting, provider request, or background task. +Codex and Claude already record Plan Usage history during normal refreshes. The +menu reads the history selected by the existing provider/account ownership rules. + +Existing JSON files live under +`~/Library/Application Support/com.steipete.codexbar/history/`. Entries contain +capture time, used percentage, and reset time, grouped by quota series, duration, +and account. Existing hourly compaction preserves peaks and reset boundaries; +recording caps each account's series at 17,520 samples (roughly 730 days at one +sample per hour). This is not a strict age expiry, a global byte limit, or an +account-count bound. + +The existing account keys are not all anonymous: Codex provider-account keys can +contain the provider's account identifier in plain JSON, while email-based and +Claude keys are hashed. This chart does not change that storage format or add +identifiers. Treat existing history files as private; display masking does not +sanitize them for sharing. + +## Synthetic rendering + +The render tests exercise the chart views using fixed synthetic dates and samples, +without launching the app, creating status items, reading accounts, or contacting +providers. They always render in both light and dark appearance. To save the PNGs: ```sh -CODEXBAR_BURNDOWN_PROOF_PATH=/tmp/codexbar-weekly-calendar-proof.png \ -CODEXBAR_BURNDOWN_PROOF_WEEKLY=1 \ -swift test --filter QuotaBurndownRenderProofTests +source Scripts/test_environment.sh +env -u OP_SERVICE_ACCOUNT_TOKEN -u SLACK_APP_TOKEN -u SLACK_BOT_TOKEN \ + -u DISCORD_BOT_TOKEN -u GOOGLE_PLACES_API_KEY -u KIEAI_API_KEY \ + -u GOG_KEYRING_PASSWORD -u CLAUDE_CODE_MESSAGING_TOKEN \ + CODEXBAR_BURNDOWN_PROOF_DIR=/tmp/codexbar-burndown-proof \ + swift test --filter QuotaBurndownRenderProofTests ``` -![Weekly burndown with calendar endpoints](burndown-weekly-synthetic.png) +The fixture produces Codex Session, Weekly, and Monthly views, Claude's +Weekly/Sonnet selector, and a before/after pair with the retained utilization chart. +These are hosted-view renders, not captures of a running native menu. The hosted +submenu tests separately cover lazy hydration and refresh after a window expires. -This image is a hosted-view render. Native submenu verification also confirmed both -charts and the weekly endpoint labels in the running debug app's accessibility tree. +The contributor's earlier synthetic captures remain available for comparison: +[original chart](burndown-menu-synthetic.png), +[native submenu](burndown-native-synthetic.png), and +[weekly endpoints](burndown-weekly-synthetic.png). +The standalone debug-app proof mode used for those captures has been removed. From b4885d028d89317c71a61da6680f1d67f3ac2960 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 05:51:13 -0700 Subject: [PATCH 059/122] docs(changelog): place burndown entry beside existing menu changes --- CHANGELOG.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7420ee8437..d7e302b141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,11 @@ ## 0.68.1 — Unreleased -### Added - -- Plan Usage: show recorded remaining-quota burndown for Codex and Claude alongside utilization history, with capture age and calendar endpoints (#4085). Thanks @callmejustdodo! - ### Changed - Menu bar: align the persistent Refresh row with other menu actions by removing its decorative icon, preserving the shortcut and accessibility action (#4057). Thanks @elijahfriedman! +- Plan Usage: show recorded remaining-quota burndown for Codex and Claude alongside utilization history, with capture age and calendar endpoints (#4085). Thanks @callmejustdodo! + ### Fixed - CLI: macOS release builds compile again on the Xcode 26 release runners, so the 0.68 macOS CLI tarballs and the Homebrew `codexbar` formula ship alongside the app. From c15a962be3f4e4c6c711892a08b858a756424cc5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 05:00:15 -0700 Subject: [PATCH 060/122] fix(security): redact fetcher environments and scrub test environments (#4097) Security: failing Swift Testing expectations reflected the whole process environment stored in UsageFetcher, ClaudeUsageFetcher.Configuration, and the shared fetch context, so local test logs could contain credentials. A ProcessEnvironment wrapper now renders only an entry count in descriptions, debug descriptions, and mirrors while keeping dictionary access, and the test scripts, Makefile targets, and CI scrub credential-shaped environment variables (documented allowlist) before running tests. --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 4 + Makefile | 4 +- Scripts/test-plugin-engines.sh | 5 +- Scripts/test_environment.sh | 19 ++++ Scripts/test_fast_runner.py | 62 ++++++++++++ Scripts/test_swift_test_sharding.sh | 32 +++--- Sources/CodexBarCore/ProcessEnvironment.swift | 21 ++++ .../Providers/Claude/ClaudeUsageFetcher.swift | 2 +- .../Providers/ProviderFetchPlan.swift | 2 +- Sources/CodexBarCore/UsageFetcher.swift | 8 +- .../ProcessEnvironmentTests.swift | 99 +++++++++++++++++++ ...kenAccountEnvironmentPrecedenceTests.swift | 15 +-- docs/DEVELOPMENT.md | 16 +++ 14 files changed, 256 insertions(+), 37 deletions(-) create mode 100644 Sources/CodexBarCore/ProcessEnvironment.swift create mode 100644 Tests/CodexBarTests/ProcessEnvironmentTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cde2c82e76..3880741ad2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -558,7 +558,9 @@ jobs: run: swift build -c release --product CodexBarCLI --static-swift-stdlib - name: Swift Test (Linux only) - run: swift test --parallel + run: | + source Scripts/test_environment.sh + swift test --parallel - name: Smoke test CodexBarCLI shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e643add5..039b910a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.68.1 — Unreleased +### Security + +- Tests: scrub inherited credentials from test runners and redact stored environment dictionaries in Codex/Claude usage fetcher and shared fetch-context debug output. + ### Added - Claude: show saved usage-limit resets and their expiry from the Web source in the menu and `codexbar usage` details (#4048). Thanks @enieuwy! diff --git a/Makefile b/Makefile index 6efb211f8a..bb7206b623 100644 --- a/Makefile +++ b/Makefile @@ -44,10 +44,10 @@ test-skip-build: ./Scripts/test_fast.sh --skip-build $(test_filter_arg) test-tty: - CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1 swift test --filter TTYIntegrationTests + source ./Scripts/test_environment.sh && CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1 swift test --filter TTYIntegrationTests test-live: - LIVE_TEST=1 CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS=1 swift test --filter LiveAccountTests + export CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS=1 && source ./Scripts/test_environment.sh && LIVE_TEST=1 swift test --filter LiveAccountTests release: ./Scripts/package_app.sh release diff --git a/Scripts/test-plugin-engines.sh b/Scripts/test-plugin-engines.sh index b44b5b04bf..2c6043f555 100755 --- a/Scripts/test-plugin-engines.sh +++ b/Scripts/test-plugin-engines.sh @@ -1,8 +1,9 @@ -#!/bin/sh -set -eu +#!/usr/bin/env bash +set -euo pipefail ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$ROOT_DIR" +source "$ROOT_DIR/Scripts/test_environment.sh" FILTER='ProviderPluginRuntimeTests|ProviderPluginParityTests|ProviderPluginDetailsParityTests|ProviderPluginExtensionParityTests|Sub2APIPluginGoldenTests|UserProviderPluginPortableTests' diff --git a/Scripts/test_environment.sh b/Scripts/test_environment.sh index 95a13e4354..04fdbde7c3 100644 --- a/Scripts/test_environment.sh +++ b/Scripts/test_environment.sh @@ -1,5 +1,24 @@ #!/usr/bin/env bash +# Enumerate exported names only: never serialize inherited credential values. +codexbar_scrub_test_environment() { + local name + while IFS= read -r name; do + # Explicit non-secret controls and build search paths (_PAT also matches _PATH). + # Do not allow CODEXBAR_* wholesale; provider credentials use that prefix too. + case "$name" in + CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS|CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS|\ + CODEXBAR_DISABLE_KEYCHAIN_ACCESS|CODEXBAR_USE_LOCAL_SWEETCOOKIEKIT|\ + LD_LIBRARY_PATH|DYLD_LIBRARY_PATH|DYLD_FRAMEWORK_PATH|LIBRARY_PATH|PKG_CONFIG_PATH) continue ;; + esac + if [[ "$name" =~ [Tt][Oo][Kk][Ee][Nn]|[Kk][Ee][Yy]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Pp][Aa][Ss][Ss][Ww][Dd]|[Ww][Ee][Bb][Hh][Oo][Oo][Kk]|[Cc][Rr][Ee][Dd][Ee][Nn][Tt][Ii][Aa][Ll]|[Cc][Oo][Oo][Kk][Ii][Ee]|[Pp][Rr][Ii][Vv][Aa][Tt][Ee]|_[Pp][Aa][Tt] ]]; then + unset "$name" + fi + done < <(compgen -e) +} +codexbar_scrub_test_environment +unset -f codexbar_scrub_test_environment + # Inherited by test runners and their CLI children. export CODEXBAR_TEST_CODEX_FILE_ISOLATION=1 unset CODEXBAR_TEST_CODEX_FILE_FIXTURES diff --git a/Scripts/test_fast_runner.py b/Scripts/test_fast_runner.py index 5da8777631..1cf9cc654c 100644 --- a/Scripts/test_fast_runner.py +++ b/Scripts/test_fast_runner.py @@ -86,6 +86,28 @@ def test_native_exit_code_is_preserved(self): result = self.run_command(["bash", str(ROOT / "Scripts/test_fast.sh"), "--filter", "Example"]) self.assertEqual(result.returncode, 23, result.stderr) + def test_make_does_not_launch_swift_when_environment_setup_fails(self): + for target in ["test-tty", "test-live"]: + with self.subTest(target=target): + self.capture.unlink(missing_ok=True) + # This fixture directory intentionally has no Scripts/test_environment.sh. + result = self.run_command([ + "make", "-s", "-C", str(self.directory), "-f", str(ROOT / "Makefile"), target, + ]) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.capture.exists()) + + def test_scrubber_preserves_explicit_build_search_paths(self): + for name in ["LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", + "LIBRARY_PATH", "PKG_CONFIG_PATH"]: + with self.subTest(name=name): + # Assign inside Bash: macOS can strip DYLD variables when launching system binaries. + probe = (f"export {name}=synthetic-build-path\n" + "source Scripts/test_environment.sh\n" + f'[[ "${{{name}:-}}" == synthetic-build-path ]]') + result = self.run_command(["bash", "-c", probe]) + self.assertEqual(result.returncode, 0, result.stderr) + def test_invalid_deadline_fails_before_launch(self): for value in ["0", "-1", "invalid"]: with self.subTest(value=value): @@ -115,6 +137,46 @@ def test_both_runners_override_inherited_unsafe_test_environment(self): self.assertEqual(environment["CODEXBAR_TEST_SESSION_FILE_ISOLATION"], "1") self.assertIsNone(environment["CODEXBAR_TEST_CODEX_FILE_FIXTURES"]) + def test_all_test_entry_points_scrub_secret_names(self): + sensitive_names = [ + "CODEXBAR_TEST_SENTINEL_SECRET", "service_token", "Api_Key", "clientSECRET", + "PASSWORD", "test_Passwd", "a_webhook_url", "CREDENTIAL_path", "CookieJar", + "PRIVATE_FILE", "service_PAT", "CODEXBAR_API_KEY", "CODEXBAR_TEST_COOKIE", + ] + # The fake Swift process records only booleans, never inherited values. + binary = self.directory / "swift" + binary.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "from pathlib import Path\n" + f"names = {sensitive_names!r}\n" + "Path(os.environ['NATIVE_TEST_CAPTURE']).write_text(json.dumps({\n" + "'secrets_absent': all(name not in os.environ for name in names),\n" + "'ci_preserved': os.environ.get('CI') == 'true',\n" + "'flag_preserved': os.environ.get('CODEXBAR_DISABLE_KEYCHAIN_ACCESS') == '1',\n" + "'local_dependency_preserved': os.environ.get('CODEXBAR_USE_LOCAL_SWEETCOOKIEKIT') == '1',\n" + "}))\n" + "if sys.argv[1:] == ['test', 'list']: print('CodexBarTests.FixtureTests/example()')\n", + encoding="utf-8", + ) + self.environment.update(dict.fromkeys(sensitive_names, "sentinel-harness-secret")) + self.environment.update(CI="true", CODEXBAR_DISABLE_KEYCHAIN_ACCESS="1", + CODEXBAR_USE_LOCAL_SWEETCOOKIEKIT="1") + commands = [ + ["bash", "Scripts/test.sh"], ["bash", "Scripts/test_fast.sh"], + ["bash", "Scripts/test-plugin-engines.sh"], + *[["make", "-s", target] for target in + ["test", "test-fast", "test-skip-build", "test-tty", "test-live"]], + ] + for command in commands: + with self.subTest(command=command): + result = self.run_command(command) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(self.capture.read_text()), { + "secrets_absent": True, "ci_preserved": True, "flag_preserved": True, + "local_dependency_preserved": True, + }) + class TestGroupTests(unittest.TestCase): def test_expensive_suites_keep_their_own_deadline_without_losing_selections(self): diff --git a/Scripts/test_swift_test_sharding.sh b/Scripts/test_swift_test_sharding.sh index 2cc34ee4aa..592dcef394 100755 --- a/Scripts/test_swift_test_sharding.sh +++ b/Scripts/test_swift_test_sharding.sh @@ -16,7 +16,7 @@ if [[ "$*" == "build --show-bin-path" ]]; then fi if [[ "$*" == "test list" ]]; then if [[ "${FAKE_SWIFT_MODE:-success}" == "list_fail" ]]; then - sleep 0.25 + sleep "${FAKE_SWIFT_LIST_DELAY:?}" printf 'test-list stdout marker\n' printf 'test-list stderr marker\n' >&2 exit 42 @@ -246,18 +246,24 @@ set -e grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}" grep -Fq '| Recovered groups | `0` |' "${GITHUB_STEP_SUMMARY}" -reset_case list-failure -export FAKE_SWIFT_MODE=list_fail -set +e -run_harness --group-size 1 --timeout 10 > "${TEMP_DIR}/list-failure.log" 2>&1 -list_failure_status=$? -set -e -[[ "${list_failure_status}" -ne 0 ]] -grep -Fq "test-list stdout marker" "${TEMP_DIR}/list-failure.log" -grep -Fq "test-list stderr marker" "${TEMP_DIR}/list-failure.log" -[[ "$(wc -l < "${FAKE_SWIFT_LOG}")" -eq 1 ]] -grep -Eq -- '- Discovery seconds: 0\.[1-9]' "${TEMP_DIR}/list-failure.log" -grep -Fq '| Discovered selections | `0` |' "${GITHUB_STEP_SUMMARY}" +for list_delay in 0.25 1.1; do + reset_case list-failure + export FAKE_SWIFT_MODE=list_fail + export FAKE_SWIFT_LIST_DELAY="$list_delay" + set +e + run_harness --group-size 1 --timeout 10 > "${TEMP_DIR}/list-failure.log" 2>&1 + list_failure_status=$? + set -e + [[ "${list_failure_status}" -ne 0 ]] + grep -Fq "test-list stdout marker" "${TEMP_DIR}/list-failure.log" + grep -Fq "test-list stderr marker" "${TEMP_DIR}/list-failure.log" + [[ "$(wc -l < "${FAKE_SWIFT_LOG}")" -eq 1 ]] + # Scheduling can push discovery past one second; only a positive duration is required. + awk '/- Discovery seconds:/ { positive = ($4 + 0) > 0 } END { exit !positive }' \ + "${TEMP_DIR}/list-failure.log" + grep -Fq '| Discovered selections | `0` |' "${GITHUB_STEP_SUMMARY}" +done +unset FAKE_SWIFT_LIST_DELAY reset_case sparkle-recovery export FAKE_SWIFT_MODE=list_sparkle_fail_once diff --git a/Sources/CodexBarCore/ProcessEnvironment.swift b/Sources/CodexBarCore/ProcessEnvironment.swift new file mode 100644 index 0000000000..1ce5e7d9cd --- /dev/null +++ b/Sources/CodexBarCore/ProcessEnvironment.swift @@ -0,0 +1,21 @@ +/// Retains environment values for execution while keeping automatic diagnostics count-only. +@propertyWrapper +public struct ProcessEnvironment: Sendable, CustomReflectable, CustomStringConvertible, CustomDebugStringConvertible { + public var wrappedValue: [String: String] + + public init(wrappedValue: [String: String]) { + self.wrappedValue = wrappedValue + } + + public var description: String { + "ProcessEnvironment(\(self.wrappedValue.count) entries; redacted)" + } + + public var debugDescription: String { + self.description + } + + public var customMirror: Mirror { + Mirror(self, children: ["entryCount": self.wrappedValue.count], displayStyle: .struct) + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 48078fe5fc..d6b297adc1 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -116,7 +116,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private static let cliProbeTimeout: TimeInterval = 24 private static let cliRetryProbeTimeout: TimeInterval = 60 private struct Configuration { - let environment: [String: String] + @ProcessEnvironment var environment: [String: String] let runtime: ProviderRuntime let dataSource: ClaudeUsageDataSource let oauthKeychainPromptCooldownEnabled: Bool diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index 7f4a56a66d..6ccce316b9 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -35,7 +35,7 @@ public struct ProviderFetchContext: Sendable { public let webTimeout: TimeInterval public let webDebugDumpHTML: Bool public let verbose: Bool - public let env: [String: String] + @ProcessEnvironment public private(set) var env: [String: String] public let settings: ProviderSettingsSnapshot? public let fetcher: UsageFetcher public let claudeFetcher: any ClaudeUsageFetching diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index a998f2936e..951a7e6cbb 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -1125,18 +1125,14 @@ private final class CodexRPCClient: @unchecked Sendable { // MARK: - Public fetcher used by the app public struct UsageFetcher: Sendable { - private let environment: [String: String] + @ProcessEnvironment private var environment: [String: String] private let initializeTimeoutSeconds: TimeInterval private let requestTimeoutSeconds: TimeInterval private let codexExecutableResolver: CodexExecutableResolver private let codexArguments: [String] public init(environment: [String: String] = ProcessInfo.processInfo.environment) { - self.environment = environment - self.initializeTimeoutSeconds = 8.0 - self.requestTimeoutSeconds = 3.0 - self.codexExecutableResolver = defaultCodexExecutableResolver - self.codexArguments = ["-s", "read-only", "-a", "never", "app-server"] + self.init(environment: environment, initializeTimeoutSeconds: 8.0, requestTimeoutSeconds: 3.0) } init( diff --git a/Tests/CodexBarTests/ProcessEnvironmentTests.swift b/Tests/CodexBarTests/ProcessEnvironmentTests.swift new file mode 100644 index 0000000000..b7c78eb657 --- /dev/null +++ b/Tests/CodexBarTests/ProcessEnvironmentTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing +@testable import CodexBarCore + +extension ProcessEnvironment: CustomTestStringConvertible { + public var testDescription: String { + self.description + } +} + +struct ProcessEnvironmentTests { + private static let sentinel = "sentinel-environment-value-must-not-be-rendered" + private static let sentinelKey = "CODEXBAR_TEST_SENTINEL_SECRET" + + @Test + func `fetcher descriptions and recursive mirrors hide environment contents`() { + for value in Self.storingValues() { + Self.expectRedacted(String(describing: value)) + Self.expectRedacted(String(reflecting: value)) + Self.expectRedacted(String(describingForTest: value)) + var output = "" + dump(value, to: &output) + Self.expectRedacted(output) + Self.expectMirrorRedacted(value) + } + } + + @Test + func `failed expectations hide captured environment contents`() { + for value in Self.storingValues() { + let captured = CapturedValue(value: value) + let other = CapturedValue(value: nil) + withKnownIssue("Deliberate failure exercises Swift Testing operand expansion") { + #expect(captured == other) + } matching: { issue in + // Issue descriptions omit expanded operands; inspect the recorded values too. + var rendered = "" + dump(issue, to: &rendered) + return !rendered.contains(Self.sentinel) && !rendered.contains(Self.sentinelKey) + } + } + } + + @Test + func `wrapper preserves dictionary access and reports only the current count`() { + var environment = ProcessEnvironment(wrappedValue: [Self.sentinelKey: Self.sentinel]) + #expect(environment.wrappedValue[Self.sentinelKey] == Self.sentinel) + environment.wrappedValue["ORDINARY_NAME"] = Self.sentinel + #expect(environment.wrappedValue.count == 2) + #expect(environment.description == "ProcessEnvironment(2 entries; redacted)") + #expect(environment.debugDescription == environment.description) + #expect(String(describingForTest: environment) == environment.description) + let children = Array(Mirror(reflecting: environment).children) + #expect(children.count == 1) + #expect(children.first?.label == "entryCount") + #expect(children.first?.value as? Int == 2) + Self.expectMirrorRedacted(environment) + } + + private static func storingValues() -> [Any] { + let environment = [Self.sentinelKey: Self.sentinel, "ORDINARY_NAME": Self.sentinel] + let fetcher = UsageFetcher(environment: environment) + let browserDetection = BrowserDetection(homeDirectory: "/synthetic-home") + let claudeFetcher = ClaudeUsageFetcher(browserDetection: browserDetection, environment: environment) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: nil, + fetcher: fetcher, + claudeFetcher: claudeFetcher, + browserDetection: browserDetection) + return [fetcher, claudeFetcher, context] + } + + private struct CapturedValue: Equatable { + let value: Any? + + static func == (_: Self, _: Self) -> Bool { false } + } + + private static func expectRedacted(_ output: String) { + #expect(!output.contains(self.sentinel)) + #expect(!output.contains(self.sentinelKey)) + #expect(!output.contains("ORDINARY_NAME")) + } + + private static func expectMirrorRedacted(_ value: Any, depth: Int = 0) { + guard depth < 20 else { return } + for child in Mirror(reflecting: value).children { + self.expectRedacted(String(describing: child.value)) + self.expectMirrorRedacted(child.value, depth: depth + 1) + } + } +} diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 9c52543177..1e9b173245 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -700,15 +700,17 @@ struct TokenAccountEnvironmentPrecedenceTests { codexActiveSourceOverride: .liveSystem) #expect(liveEnv["CODEX_HOME"] == ambientHome.path) + try Self.writeCodexAuthFile(homeURL: firstHome, email: "first@example.com", accountID: "acct_first") + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "ambient@example.com", accountID: "acct_ambient") let firstFetcher = context.fetcher( base: UsageFetcher(environment: ["CODEX_HOME": ambientHome.path]), provider: .codex, env: firstEnv) - #expect(Self.codexHomePath(from: firstFetcher) == firstHome.path) + #expect(firstFetcher.loadAccountInfo().email == "first@example.com") let nonCodexBaseFetcher = UsageFetcher(environment: ["CODEX_HOME": ambientHome.path]) let nonCodexFetcher = context.fetcher(base: nonCodexBaseFetcher, provider: .claude, env: firstEnv) - #expect(Self.codexHomePath(from: nonCodexFetcher) == ambientHome.path) + #expect(nonCodexFetcher.loadAccountInfo().email == "ambient@example.com") let labeled = try context.applyCodexVisibleAccountLabel( UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), @@ -1070,15 +1072,6 @@ extension TokenAccountEnvironmentPrecedenceTests { return context.settingsSnapshot(for: .codex, account: nil)?.codex?.dashboardAuthorityKnownOwners } - fileprivate static func codexHomePath(from fetcher: UsageFetcher) -> String? { - guard let environment = Mirror(reflecting: fetcher).children.first(where: { $0.label == "environment" })? - .value as? [String: String] - else { - return nil - } - return environment["CODEX_HOME"] - } - fileprivate static func writeCodexAuthFile(homeURL: URL, email: String, accountID: String) throws { try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) let auth: [String: Any] = [ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2283719325..b948e5689c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -172,6 +172,22 @@ Control Center host removal or placement after process exit. This does not diagn ### Run Tests Only +The shell test runners and all Make test targets source `Scripts/test_environment.sh` before launching Swift. +The Linux CI test step sources it too. It removes exported variables whose names contain `TOKEN`, `KEY`, `SECRET`, +`PASSWORD`, `PASSWD`, `WEBHOOK`, `CREDENTIAL`, `COOKIE`, `PRIVATE`, or `_PAT`, ignoring case. Explicit non-secret +exceptions preserve `CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS`, `CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS`, +`CODEXBAR_DISABLE_KEYCHAIN_ACCESS`, and `CODEXBAR_USE_LOCAL_SWEETCOOKIEKIT`. Standard build and loader search paths +(`LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`, `DYLD_FRAMEWORK_PATH`, `LIBRARY_PATH`, and `PKG_CONFIG_PATH`) are also preserved: +their `_PATH` suffix otherwise matches `_PAT`. Other matching variables, including `CODEXBAR_*` credentials, are removed. +Use synthetic dictionaries or set synthetic sentinels inside fixtures; never depend on inherited real credentials. +For direct `swift test`, source the script in a Bash subshell first. This does not authorize live account tests. + +`ProcessEnvironment` provides count-only descriptions and reflection for stored environment dictionaries. +The Codex and Claude usage fetchers and shared fetch context use it so failed expectations cannot expand their +stored environments. Explicit dictionary access still returns the original values for provider/subprocess use; +never log that dictionary. Other stored environment types still need migration, so harness scrubbing remains +essential and does not replace a review of debug output before sharing it. + Lint tools are installed at repository-pinned versions by `Scripts/install_lint_tools.sh`, with archive checksums verified before installation. TypeScript 7 installs its native package for the running Node platform and architecture (including Rosetta). Plugin typechecking uses only its declared libraries and source declarations, so unrelated From 14bdca1665ef8ed00be18905e338ca101e82abcb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 05:00:19 -0700 Subject: [PATCH 061/122] feat(plugins): move Notion and ZoomMate to validated cookie sessions (#4098) Plugin host gains validated cookie-session policies on the #4059 jar: ranked source domains with required-cookie admission, validated single-entry persistence with legacy cache migration, host-owned migration of native session files with conditional invalidation and interactive commit/rollback, and an explicit opt-in to keep over-quota percentages above 100. Notion and ZoomMate now run entirely as bundled plugins; their native fetchers, importers, cookie-header code, and session stores are deleted. --- CHANGELOG.md | 4 + Sources/CodexBar/UsageStore+NotionDebug.swift | 30 +- Sources/CodexBarCore/CookieHeaderCache.swift | 30 +- .../Plugins/ProviderPluginCookieBroker.swift | 91 +- .../Plugins/ProviderPluginCookieJar.swift | 27 +- .../Plugins/ProviderPluginCookiePolicy.swift | 136 +++ .../Plugins/ProviderPluginEngine.swift | 10 + .../Plugins/ProviderPluginManifest.swift | 46 +- .../Plugins/ProviderPluginMemoryCache.swift | 29 + .../ProviderPluginPersistentCookies.swift | 181 ++++ .../Plugins/ProviderPluginResult.swift | 7 +- .../Plugins/ProviderPluginRuntime.swift | 62 +- .../Plugins/ProviderPluginSessionFile.swift | 49 + .../ProviderPluginSnapshotMapper.swift | 34 +- .../Plugins/QuickJSProviderPluginEngine.swift | 46 +- .../Plugins/ScriptFetchStrategy.swift | 3 +- .../Notion/NotionProviderDescriptor.swift | 86 +- .../Providers/Notion/NotionSessionStore.swift | 74 -- .../Providers/Notion/NotionUsageFetcher.swift | 430 --------- .../Notion/NotionUsageSnapshot.swift | 336 ------- .../ZoomMate/ZoomMateBearerTokenCache.swift | 64 -- .../ZoomMate/ZoomMateCookieImporter.swift | 142 --- .../ZoomMateCreditsHistoryFetcher.swift | 247 ----- .../Providers/ZoomMate/ZoomMateModels.swift | 284 ------ .../ZoomMate/ZoomMateProviderDescriptor.swift | 172 ++-- .../ZoomMate/ZoomMateUsageFetcher.swift | 555 ----------- .../Resources/Plugins/codexbar-plugin.d.ts | 13 +- .../CodexBarCore/Resources/Plugins/notion.js | 279 ++++++ .../CodexBarCore/Resources/Plugins/notion.ts | 171 ++++ .../Plugins/provider-plugin-prelude.js | 3 + .../Resources/Plugins/zoommate.js | 312 ++++++ .../Resources/Plugins/zoommate.ts | 257 +++++ .../NotionMenuCardModelTests.swift | 32 +- Tests/CodexBarTests/NotionProviderTests.swift | 86 ++ .../NotionSessionStoreTests.swift | 49 - .../NotionUsageFetcherTests.swift | 426 -------- .../ProviderPluginPersistentCookieTests.swift | 249 +++++ .../ProviderSessionStoreFileTests.swift | 10 +- .../ZoomMateCookieCacheTests.swift | 360 ------- .../ZoomMateCreditsHistoryFetcherTests.swift | 550 ----------- .../CodexBarTests/ZoomMateProviderTests.swift | 39 + .../ZoomMateUsageFetcherTests.swift | 906 ------------------ TestsPlugin/NotionPluginTests.swift | 150 +++ .../ProviderPluginCookieJarTests.swift | 60 +- ...rPluginPersistentCookieSecurityTests.swift | 142 +++ .../ProviderPluginSnapshotContractTests.swift | 19 + TestsPlugin/ZoomMatePluginTests.swift | 335 +++++++ docs/notion.md | 12 +- docs/plugin-conversion-matrix.md | 10 +- docs/plugins.md | 48 +- docs/zoommate.md | 40 +- 51 files changed, 3020 insertions(+), 4713 deletions(-) create mode 100644 Sources/CodexBarCore/Plugins/ProviderPluginCookiePolicy.swift create mode 100644 Sources/CodexBarCore/Plugins/ProviderPluginMemoryCache.swift create mode 100644 Sources/CodexBarCore/Plugins/ProviderPluginPersistentCookies.swift create mode 100644 Sources/CodexBarCore/Plugins/ProviderPluginSessionFile.swift delete mode 100644 Sources/CodexBarCore/Providers/Notion/NotionSessionStore.swift delete mode 100644 Sources/CodexBarCore/Providers/Notion/NotionUsageFetcher.swift delete mode 100644 Sources/CodexBarCore/Providers/Notion/NotionUsageSnapshot.swift delete mode 100644 Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift delete mode 100644 Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift delete mode 100644 Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift delete mode 100644 Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift delete mode 100644 Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift create mode 100644 Sources/CodexBarCore/Resources/Plugins/notion.js create mode 100644 Sources/CodexBarCore/Resources/Plugins/notion.ts create mode 100644 Sources/CodexBarCore/Resources/Plugins/zoommate.js create mode 100644 Sources/CodexBarCore/Resources/Plugins/zoommate.ts create mode 100644 Tests/CodexBarTests/NotionProviderTests.swift delete mode 100644 Tests/CodexBarTests/NotionSessionStoreTests.swift delete mode 100644 Tests/CodexBarTests/NotionUsageFetcherTests.swift create mode 100644 Tests/CodexBarTests/ProviderPluginPersistentCookieTests.swift delete mode 100644 Tests/CodexBarTests/ZoomMateCookieCacheTests.swift delete mode 100644 Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift create mode 100644 Tests/CodexBarTests/ZoomMateProviderTests.swift delete mode 100644 Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift create mode 100644 TestsPlugin/NotionPluginTests.swift create mode 100644 TestsPlugin/ProviderPluginPersistentCookieSecurityTests.swift create mode 100644 TestsPlugin/ZoomMatePluginTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 039b910a65..7a982025db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,12 @@ - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! +- Browser sessions: preserve interactive cookie-refresh authorization across plugin engine callbacks (#4098). + ### Changed +- Notion AI and ZoomMate: run usage fetching through bundled plugins while preserving browser-session reuse, validated cache migration, Notion over-quota values, and ZoomMate credits history (#4098). + - Plugins: user plugins now get their own switcher tab by default when Merge Icons is on; set `topLevel: false` to keep the appended card. - Menu bar: align the persistent Refresh row with other menu actions by removing its decorative icon, preserving the shortcut and accessibility action (#4057). Thanks @elijahfriedman! ### Fixed diff --git a/Sources/CodexBar/UsageStore+NotionDebug.swift b/Sources/CodexBar/UsageStore+NotionDebug.swift index b6fcf6b5ef..b90e563222 100644 --- a/Sources/CodexBar/UsageStore+NotionDebug.swift +++ b/Sources/CodexBar/UsageStore+NotionDebug.swift @@ -9,13 +9,29 @@ extension UsageStore { notionWorkspaceID: String) async -> String { await runWithTimeout(seconds: 15) { - let fetcher = NotionUsageFetcher(browserDetection: browserDetection) - let manualHeader = notionCookieSource == .manual - ? CookieHeaderNormalizer.normalize(notionCookieHeader) - : nil - return await fetcher.debugRawProbe( - cookieHeaderOverride: manualHeader, - preferredSpaceID: notionWorkspaceID.isEmpty ? nil : notionWorkspaceID) + let context = ProviderFetchContext( + runtime: .app, + sourceMode: .web, + includeCredits: false, + webTimeout: 15, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: .make(notion: .init( + cookieSource: notionCookieSource, + manualCookieHeader: notionCookieHeader, + workspaceID: notionWorkspaceID)), + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection, environment: [:]), + browserDetection: browserDetection) + do { + let usage = try await NotionProviderDescriptor.webStrategy().fetch(context).usage + let rolling = usage.primary?.usedPercent.description ?? "unavailable" + let monthly = usage.secondary?.usedPercent.description ?? "unavailable" + return "Notion plugin fetch succeeded\nRolling used: \(rolling)\nMonthly used: \(monthly)" + } catch { + return "Notion plugin fetch failed: \(error.localizedDescription)" + } } } } diff --git a/Sources/CodexBarCore/CookieHeaderCache.swift b/Sources/CodexBarCore/CookieHeaderCache.swift index 31fa27cac6..2ee08c1786 100644 --- a/Sources/CodexBarCore/CookieHeaderCache.swift +++ b/Sources/CodexBarCore/CookieHeaderCache.swift @@ -42,7 +42,7 @@ public struct CookieRefreshCommitSummary: Equatable, Sendable { } private enum CookieRefreshStagedMutation: Sendable { - case store(CookieHeaderCacheEntry) + case store(CookieHeaderCacheEntry, (@Sendable () -> Void)? = nil) case clear } @@ -563,7 +563,8 @@ public enum CookieHeaderCache { expected: Entry?, cookieHeader: String, sourceLabel: String, - now: Date = Date()) -> Bool + now: Date = Date(), + onCommit: (@Sendable () -> Void)? = nil) -> Bool { let trimmed = cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines) guard let normalized = CookieHeaderNormalizer.normalize(trimmed), !normalized.isEmpty else { return false } @@ -571,7 +572,8 @@ public enum CookieHeaderCache { do { return try self.withLegacyMutationLock { guard self.currentEntryMatches(expected, provider: provider, scope: scope) else { return false } - return self.storeLocked(entry: entry, provider: provider, scope: scope, sourceLabel: sourceLabel) + return self.storeLocked( + entry: entry, provider: provider, scope: scope, sourceLabel: sourceLabel, onCommit: onCommit) } } catch { self.log.error("Cookie cache conditional store lock failed: \(error)") @@ -584,11 +586,13 @@ public enum CookieHeaderCache { static func clearIfCurrent( provider: UsageProvider, scope: Scope? = nil, - expected: Entry?) -> Bool + expected: Entry?, + onClear: (@Sendable () -> Void)? = nil) -> Bool { do { return try self.withLegacyMutationLock { guard self.currentEntryMatches(expected, provider: provider, scope: scope) else { return false } + if self.stageRefreshMutation(.clear, key: self.key(for: provider, scope: scope)) { return true } // Keep the expected Keychain row intact when legacy cleanup fails so fallback can replace it. if scope == nil, self.removeLegacyEntry(for: provider) == .failed { return false @@ -597,6 +601,7 @@ public enum CookieHeaderCache { let result = KeychainCacheStore.clearResult(key: key) guard result != .failed else { return false } self.updateDisplaySnapshot(key: key, entry: nil) + onClear?() return true } } catch { @@ -1006,10 +1011,11 @@ extension CookieHeaderCache { entry: Entry, provider: UsageProvider, scope: Scope?, - sourceLabel: String) -> Bool + sourceLabel: String, + onCommit: (@Sendable () -> Void)? = nil) -> Bool { let key = self.key(for: provider, scope: scope) - if self.stageRefreshMutation(.store(entry), key: key) { + if self.stageRefreshMutation(.store(entry, onCommit), key: key) { self.log.debug("Cookie cache refresh staged", metadata: [ "provider": provider.rawValue, "source": sourceLabel, @@ -1024,6 +1030,7 @@ extension CookieHeaderCache { if scope == nil { _ = self.removeLegacyEntry(for: provider) } + onCommit?() self.log.debug("Cookie cache stored", metadata: ["provider": provider.rawValue, "source": sourceLabel]) return true } @@ -1055,7 +1062,7 @@ extension CookieHeaderCache { let stagedCount = state.stagedMutations.count guard stagedCount == 1, let (key, mutation) = state.stagedMutations.first, - case let .store(entry) = mutation + case let .store(entry, onCommit) = mutation else { return CookieRefreshCommitSummary( stagedCount: stagedCount, @@ -1069,6 +1076,7 @@ extension CookieHeaderCache { if key == self.key(for: state.provider, scope: nil) { _ = self.removeLegacyEntry(for: state.provider) } + onCommit?() return CookieRefreshCommitSummary( stagedCount: 1, committedCount: 1, @@ -1086,6 +1094,12 @@ extension CookieHeaderCache { } } + static func isRefreshReadSuppressed(provider: UsageProvider) -> Bool { + self.refreshReadSuppressionLock.withLock { + self.refreshReadSuppressions.values.contains { $0.provider == provider } + } + } + private static func resolveRefreshRead( key: KeychainCacheStore.Key, persisted _: Entry?) -> CookieRefreshReadResolution @@ -1096,7 +1110,7 @@ extension CookieHeaderCache { }) else { return .noGate } if let mutation = state.stagedMutations[key] { return switch mutation { - case let .store(entry): .visible(entry) + case let .store(entry, _): .visible(entry) case .clear: .visible(nil) } } diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginCookieBroker.swift b/Sources/CodexBarCore/Plugins/ProviderPluginCookieBroker.swift index c033384990..28e96f46a9 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginCookieBroker.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginCookieBroker.swift @@ -12,6 +12,9 @@ public struct ProviderPluginCookieSession: Codable, Equatable, Sendable { public let origin: String public let cachedAt: TimeInterval? public let records: [ProviderPluginCookieRecord]? + let headersByHost: [String: String]? + let cacheKey: String? + let permitsEmptyHosts: Set public init( header: String, @@ -19,7 +22,10 @@ public struct ProviderPluginCookieSession: Codable, Equatable, Sendable { origin: String, id: String = UUID().uuidString, cachedAt: TimeInterval? = nil, - records: [ProviderPluginCookieRecord]? = nil) + records: [ProviderPluginCookieRecord]? = nil, + headersByHost: [String: String]? = nil, + cacheKey: String? = nil, + permitsEmptyHosts: Set = []) { self.id = id self.header = header @@ -27,12 +33,22 @@ public struct ProviderPluginCookieSession: Codable, Equatable, Sendable { self.origin = origin self.cachedAt = cachedAt self.records = records + self.headersByHost = headersByHost + self.cacheKey = cacheKey + self.permitsEmptyHosts = permitsEmptyHosts + } + + var redactionValues: [String] { + let headers = [self.header] + Array(self.headersByHost?.values ?? [:].values) + return headers + headers.flatMap { CookieHeaderNormalizer.pairs(from: $0).map(\.value) } + + (self.records ?? []).map(\.value) } func json(opaque: Bool = false) throws -> String { var value: [String: Any] = ["id": self.id, "source": self.source, "origin": self.origin] if !opaque { value["header"] = self.header } if let cachedAt { value["cachedAt"] = cachedAt } + if let cacheKey { value["cacheKey"] = cacheKey } let data = try JSONSerialization.data(withJSONObject: value) guard let json = String(data: data, encoding: .utf8) else { throw ProviderPluginError.secretAccess("cookie session encoding failed") @@ -67,6 +83,8 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { private var manualDomain: String? private let jarImporter: JarImporter? private var jarCandidates: [ProviderPluginCookieSession]? + private let persistent: ProviderPluginPersistentCookies? + private let policy: ProviderPluginCookiePolicy? convenience init( provider: UsageProvider, @@ -74,10 +92,13 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { context: ProviderFetchContext, importer: BatchImporter? = nil, usesCookieJar: Bool = false, + policy: ProviderPluginCookiePolicy? = nil, settingsOverride: ProviderSettingsSnapshot.CookieProviderSettings? = nil) { - let canImport = context.runtime == .app && ProviderInteractionContext.current == .userInitiated - let jarImporter: JarImporter? = if usesCookieJar { + let interaction = ProviderInteractionContext.current + let canImport = policy?.allowsImportAttempt(runtime: context.runtime, interaction: interaction) + ?? (context.runtime == .app && interaction == .userInitiated) + let jarImporter: JarImporter? = if usesCookieJar || policy != nil { { guard canImport else { return [] } return try Self.importCookieJars( @@ -97,7 +118,9 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { return try Self.importCookieHeaders( provider: provider, domain: domain, browserDetection: context.browserDetection) }, - jarImporter: jarImporter) + jarImporter: jarImporter, + policy: policy, + background: ProviderInteractionContext.current != .userInitiated) } convenience init( @@ -116,13 +139,30 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { domains: Set, settings: ProviderSettingsSnapshot.CookieProviderSettings, batches: @escaping BatchImporter, - jarImporter: JarImporter? = nil) + jarImporter: JarImporter? = nil, + policy: ProviderPluginCookiePolicy? = nil, + background: Bool = false, + sessionFileURL: URL? = nil) { self.provider = provider self.domains = domains self.settings = settings self.importer = batches + #if os(macOS) + self.jarImporter = jarImporter.map { BrowserCookieAccessGate.operationPreservingAccessContext($0) } + #else self.jarImporter = jarImporter + #endif + self.policy = policy + self.persistent = policy.flatMap { + $0.cache == .validatedSingleEntry + ? ProviderPluginPersistentCookies( + provider: provider, + policy: $0, + background: background, + fileURL: sessionFileURL) + : nil + } } var cookieSource: ProviderCookieSource { @@ -150,8 +190,20 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { } } + func acceptCookie(domain: String, id: String) throws { + try self.lock.withLock { + try self.validate(domain) + guard let persistent else { throw ProviderPluginError.secretAccess("cookie persistence is not declared") } + try persistent.accept(domain: domain, id: id) + } + } + func rejectCookie(domain: String, id: String? = nil) { self.lock.withLock { + if let persistent, let id { + persistent.reject(domain: domain, id: id) + return + } guard self.domains.contains(domain), let issued = id.flatMap({ self.issuedSessions[$0] }) ?? self.observed[domain], id == nil || id == issued.session.id, @@ -181,23 +233,31 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { guard origin == nil || origin == "https://\(domain)", !self.visited.contains(domain), let header = CookieHeaderNormalizer.normalize(self.settings.manualCookieHeader) + ?? (self.policy?.missingCookies == .omit ? "" : nil) else { return nil } self.manualDomain = domain self.visited.insert(domain) return self.issue(header: header, source: "manual", domain: domain, cacheEntry: nil) } if let jarImporter { + if let persistent { + return try persistent.next(domain: domain, cachedOnly: cachedOnly, importer: jarImporter) + } guard !cachedOnly else { return nil } if self.jarCandidates == nil { self.jarCandidates = try jarImporter() } - guard var candidates = self.jarCandidates, !candidates.isEmpty else { return nil } - let candidate = candidates.removeFirst() - self.jarCandidates = candidates - let session = ProviderPluginCookieSession( - header: "", source: candidate.source, origin: "https://\(domain)", records: candidate.records) - let issued = Issued(session: session, cacheEntry: nil, cacheScope: nil) - self.observed[domain] = issued - self.issuedSessions[session.id] = issued - return session + while self.jarCandidates?.isEmpty == false { + let candidate = self.jarCandidates!.removeFirst() + let records = self.policy.map { $0.selected(candidate.records ?? [], domain: domain) } ?? candidate + .records + if self.policy != nil, records == nil { continue } + let session = ProviderPluginCookieSession( + header: "", source: candidate.source, origin: "https://\(domain)", records: records) + let issued = Issued(session: session, cacheEntry: nil, cacheScope: nil) + self.observed[domain] = issued + self.issuedSessions[session.id] = issued + return session + } + return nil } if self.visited.insert(domain).inserted, let (cached, scope) = self.cachedEntry(domain: domain), @@ -263,7 +323,8 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { header: header, source: source, origin: "https://\(domain)", - cachedAt: cachedAt) + cachedAt: cachedAt, + permitsEmptyHosts: self.policy?.missingCookies == .omit ? self.policy?.requestHosts ?? [] : []) let issued = Issued(session: session, cacheEntry: cacheEntry, cacheScope: cacheScope) self.observed[domain] = issued self.issuedSessions[session.id] = issued diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginCookieJar.swift b/Sources/CodexBarCore/Plugins/ProviderPluginCookieJar.swift index 0cf46f02b2..d15d66b3c4 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginCookieJar.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginCookieJar.swift @@ -38,6 +38,20 @@ public struct ProviderPluginCookieRecord: Codable, Equatable, Sendable { self.expires = cookie.expiresDate } + private init(name: String, value: String, domain: String, expires: Date?) { + self.name = name + self.value = value + self.domain = domain + self.hostOnly = true + self.path = "/" + self.secure = true + self.expires = expires + } + + func bound(to domain: String) -> Self { + Self(name: self.name, value: self.value, domain: domain, expires: self.expires) + } + func matches(_ url: URL, now: Date) -> Bool { guard let host = url.host?.lowercased(), self.expires.map({ $0 > now }) ?? true, !self.secure || url.scheme?.lowercased() == "https", @@ -69,6 +83,10 @@ final class ProviderPluginCookieJar: @unchecked Sendable { self.lock.withLock { self.sessions[session.id] = session } } + func contains(id: String, domain: String) -> Bool { + self.lock.withLock { self.sessions[id]?.origin == "https://\(domain)" } + } + func reject(id: String) { _ = self.lock.withLock { self.sessions.removeValue(forKey: id) } } @@ -95,12 +113,19 @@ final class ProviderPluginCookieJar: @unchecked Sendable { url.scheme?.lowercased() == "https", url.port == nil || url.port == 443, url.user == nil, url.password == nil else { throw ProviderPluginError.secretAccess("cookie session is unavailable") } + return try Self.header(for: session, url: url, now: now) + } + + static func header(for session: ProviderPluginCookieSession, url: URL, now: Date = Date()) throws -> String { let header: String? = if let records = session.records { ProviderPluginCookieRecord.header(records, for: url, now: now) + } else if let headers = session.headersByHost { + headers[url.host?.lowercased() ?? ""] } else { session.origin == "https://\(url.host?.lowercased() ?? "")" ? session.header : nil } - guard let header, !header.isEmpty else { + if header == nil, session.permitsEmptyHosts.contains(url.host?.lowercased() ?? "") { return "" } + guard let header, !header.isEmpty || session.permitsEmptyHosts.contains(url.host?.lowercased() ?? "") else { throw ProviderFetchClassifiedError( kind: .missingCredential, message: "No session cookies match this request URL.") diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginCookiePolicy.swift b/Sources/CodexBarCore/Plugins/ProviderPluginCookiePolicy.swift new file mode 100644 index 0000000000..65966e235e --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ProviderPluginCookiePolicy.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Bundled-only authority for selecting and retaining a single browser profile. +public struct ProviderPluginCookiePolicy: Sendable { + public enum Selection: String, Sendable { + case requestURL = "request-url" + case rankedSourceDomains = "ranked-source-domains" + } + + public enum Persistence: String, Sendable { + case nonpersistent + case validatedSingleEntry = "validated-single-entry" + } + + public enum Imports: String, Sendable { + case appInteractive = "app-interactive" + case accessGated = "access-gated" + } + + public enum MissingCookies: String, Sendable { + case reject + case omit + } + + public struct SessionFile: Sendable { + let tokenField: String + let cookieName: String + } + + let selection: Selection + let cache: Persistence + let sourceDomains: [String] + let requiredCookies: Set + let requestHosts: Set + let sessionFile: SessionFile? + let missingCookies: MissingCookies + let imports: Imports + + init(_ value: any ProviderPluginValue, domains: Set, endpoints: Set) throws { + let invalid = ProviderPluginError.invalidManifest("invalid bundled cookiePolicy") + guard value.isObject, !value.isArray, + try Set(value.propertyNames()).isSubset(of: [ + "selection", "cache", "sourceDomains", "requiredCookies", "sessionFile", "missingCookies", "imports", + ]), + let selection = value.property("selection"), selection.isString, + let selection = Selection(rawValue: selection.stringValue()), + let cache = value.property("cache"), cache.isString, + let cache = Persistence(rawValue: cache.stringValue()) + else { throw invalid } + if let missing = value.property("missingCookies"), !missing.isUndefined { + guard missing.isString, let policy = MissingCookies(rawValue: missing.stringValue()) else { throw invalid } + self.missingCookies = policy + } else { + self.missingCookies = .reject + } + if let imports = value.property("imports"), !imports.isUndefined { + guard imports.isString, let policy = Imports(rawValue: imports.stringValue()) else { throw invalid } + self.imports = policy + } else { + self.imports = .appInteractive + } + self.selection = selection + self.cache = cache + self.sourceDomains = try Self.strings(value.property("sourceDomains")) + self.requiredCookies = try Set(Self.strings(value.property("requiredCookies"))) + self.requestHosts = Set(endpoints.compactMap { endpoint in + guard case let .fixed(origin) = endpoint, let url = URL(string: origin), url.scheme == "https" else { + return nil + } + return url.host + }) + guard !self.requestHosts.isEmpty, + self.sourceDomains.count == Set(self.sourceDomains).count, + Set(self.sourceDomains).isSubset(of: domains), + self.requiredCookies + .allSatisfy({ $0.range(of: #"^[A-Za-z0-9_-]{1,128}$"#, options: .regularExpression) != nil }), + selection == .requestURL ? self.sourceDomains.isEmpty : !self.sourceDomains.isEmpty + else { throw invalid } + if let file = value.property("sessionFile"), !file.isUndefined { + guard cache == .validatedSingleEntry, selection == .rankedSourceDomains, + self.requestHosts.count == 1, file.isObject, !file.isArray, + try Set(file.propertyNames()) == ["tokenField", "cookieName"], + let field = file.property("tokenField"), field.isString, + field.stringValue().range(of: #"^[A-Za-z][A-Za-z0-9]{0,63}$"#, options: .regularExpression) != nil, + let cookie = file.property("cookieName"), cookie.isString, + self.requiredCookies.contains(cookie.stringValue()) + else { throw invalid } + self.sessionFile = SessionFile(tokenField: field.stringValue(), cookieName: cookie.stringValue()) + } else { + self.sessionFile = nil + } + } + + func allowsImportAttempt(runtime: ProviderRuntime, interaction: ProviderInteraction) -> Bool { + self.imports == .accessGated || (runtime == .app && interaction == .userInitiated) + } + + private static func strings(_ value: (any ProviderPluginValue)?) throws -> [String] { + guard let value, !value.isUndefined else { return [] } + guard value.isArray, let count = value.property("length"), (1...16).contains(count.int32Value()) else { + throw ProviderPluginError.invalidManifest("cookie policy lists must contain 1-16 strings") + } + return try (0.. [ProviderPluginCookieRecord]? + { + let records = records.filter { $0.expires.map { $0 > now } ?? true } + let selected: [ProviderPluginCookieRecord] + switch self.selection { + case .requestURL: + selected = records.sorted { lhs, rhs in + // Browser store merging returns dictionary values; keep the credential identity stable. + [lhs.domain, lhs.path, lhs.name, String(lhs.hostOnly), lhs.value] + .lexicographicallyPrecedes([rhs.domain, rhs.path, rhs.name, String(rhs.hostOnly), rhs.value]) + } + case .rankedSourceDomains: + guard self.requestHosts.contains(domain) else { return nil } + var best: [String: ProviderPluginCookieRecord] = [:] + for source in self.sourceDomains { + for record in records where record.domain == source && best[record.name] == nil { + best[record.name] = record.bound(to: domain) + } + } + selected = best.keys.sorted().compactMap { best[$0] } + } + guard self.requiredCookies.isSubset(of: Set(selected.map(\.name))) else { return nil } + return selected.isEmpty ? nil : selected + } +} diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginEngine.swift b/Sources/CodexBarCore/Plugins/ProviderPluginEngine.swift index fa477f3fb3..9c99b50d1a 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginEngine.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginEngine.swift @@ -27,6 +27,16 @@ struct ProviderPluginContextOptions: Sendable { var cookieSessionResolver: ProviderPluginRuntime.CookieSessionResolver? var cookieSessionInvalidator: ProviderPluginRuntime.CookieSessionInvalidator? var cookieJar: ProviderPluginCookieJar? + var cookieSessionValidator: ProviderPluginRuntime.CookieSessionValidator? + + func acceptCookie(domain: String, id: String) throws { + guard self.cookieJar?.contains(id: id, domain: domain) == true, + let validate = self.cookieSessionValidator + else { + throw ProviderPluginError.secretAccess("validated cookie session is unavailable") + } + try validate(domain, id) + } func rejectCookie(domain: String, id: String) { self.cookieJar?.reject(id: id) diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift b/Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift index cbd7c56552..72962498ed 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift @@ -67,6 +67,15 @@ public enum ProviderPluginCapability: String, Hashable, Sendable { case persistentStorage = "persistent-storage" } +public enum ProviderPluginPercentPolicy: String, Sendable { + case clamp + case preserveOverage = "preserve-overage" + + func map(_ value: Double) -> Double { + self == .preserveOverage ? max(0, value) : min(100, max(0, value)) + } +} + public struct ProviderPluginManifest: Sendable { public let id: ProviderInstanceID public let name: String @@ -77,7 +86,11 @@ public struct ProviderPluginManifest: Sendable { public let settings: [ProviderPluginSetting] public let capabilities: Set public let cookieDomains: Set - public let usesCookieJar: Bool + public let percentPolicy: ProviderPluginPercentPolicy + public let cookiePolicy: ProviderPluginCookiePolicy? + public var usesCookieJar: Bool { + self.cookiePolicy != nil + } func cookieDomain(_ rawDomain: String) throws -> String { let domain = rawDomain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -288,21 +301,28 @@ public struct ProviderPluginManifest: Sendable { "the browser-cookies capability requires at least one declared cookie domain") } self.cookieDomains = cookieDomains - if let policy = definition.property("cookiePolicy"), !policy.isUndefined { - guard !allowsDynamicID, self.id.firstPartyProvider != nil, capabilities.contains(.browserCookies), - policy.isObject, !policy.isArray, - try Set(policy.propertyNames()) == ["selection", "cache"], - policy.property("selection")?.isString == true, - policy.property("cache")?.isString == true, - policy.property("selection")?.stringValue() == "request-url", - policy.property("cache")?.stringValue() == "nonpersistent" + if let snapshot = definition.property("snapshotPolicy"), !snapshot.isUndefined { + guard snapshot.isObject, !snapshot.isArray, try Set(snapshot.propertyNames()) == ["percent"], + let value = snapshot.property("percent"), value.isString, + let policy = ProviderPluginPercentPolicy(rawValue: value.stringValue()) else { - throw ProviderPluginError - .invalidManifest("cookiePolicy requires bundled request-url/nonpersistent cookies") + throw ProviderPluginError.invalidManifest("snapshotPolicy requires a valid percent policy") + } + self.percentPolicy = policy + } else { + self.percentPolicy = .clamp + } + + if let policy = definition.property("cookiePolicy"), !policy.isUndefined { + guard !allowsDynamicID, self.id.firstPartyProvider != nil, capabilities.contains(.browserCookies) else { + throw ProviderPluginError.invalidManifest("cookiePolicy requires bundled browser cookies") } - self.usesCookieJar = true + self.cookiePolicy = try ProviderPluginCookiePolicy( + policy, + domains: cookieDomains, + endpoints: self.endpoints) } else { - self.usesCookieJar = false + self.cookiePolicy = nil } } diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginMemoryCache.swift b/Sources/CodexBarCore/Plugins/ProviderPluginMemoryCache.swift new file mode 100644 index 0000000000..241a26e492 --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ProviderPluginMemoryCache.swift @@ -0,0 +1,29 @@ +import Foundation + +/// JSON-only ephemeral state; validated cookie providers may reuse it across strategy instances. +final class ProviderPluginMemoryCache: @unchecked Sendable { + static let shared = ProviderPluginMemoryCache() + private let lock = NSLock() + private var entries: [String: (json: String, expires: Date)] = [:] + + func get(namespace: String, key: String) -> String? { + self.lock.withLock { + let key = namespace + ":" + key + guard let entry = self.entries[key], entry.expires > Date() else { + self.entries[key] = nil + return nil + } + return entry.json + } + } + + func set(namespace: String, key: String, json: String, ttl: Double) { + guard key.utf8.count <= 128, json.utf8.count <= 16384, ttl.isFinite, ttl > 0 else { return } + self.lock.withLock { + self.entries = self.entries.filter { $0.value.expires > Date() } + let key = namespace + ":" + key + guard self.entries[key] != nil || self.entries.count < 128 else { return } + self.entries[key] = (json, Date().addingTimeInterval(min(ttl, 86400))) + } + } +} diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginPersistentCookies.swift b/Sources/CodexBarCore/Plugins/ProviderPluginPersistentCookies.swift new file mode 100644 index 0000000000..fdd1fa363f --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ProviderPluginPersistentCookies.swift @@ -0,0 +1,181 @@ +import Foundation +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +/// One validated row per provider; the cookie cache owns conditional writes and refresh transactions. +final class ProviderPluginPersistentCookies { + private struct Payload: Codable { + var records: [ProviderPluginCookieRecord]? + var headersByHost: [String: String]? + } + + private struct Issued: Sendable { + let session: ProviderPluginCookieSession + let expected: CookieHeaderCache.Entry? + let file: [String: String]? + let fromFile: Bool + } + + private let provider: UsageProvider + private let policy: ProviderPluginCookiePolicy + private let file: ProviderPluginSessionFile? + private let background: Bool + private var readCache = false + private var candidates: [ProviderPluginCookieSession]? + private var issued: [String: Issued] = [:] + private var pendingCache: CookieHeaderCache.Entry? + private var expected: CookieHeaderCache.Entry? + private var fileSnapshot: [String: String]? + + init(provider: UsageProvider, policy: ProviderPluginCookiePolicy, background: Bool, fileURL: URL? = nil) { + self.provider = provider + self.policy = policy + self.background = background + self.file = policy.sessionFile.map { ProviderPluginSessionFile(provider: provider, policy: $0, url: fileURL) } + } + + func next(domain: String, cachedOnly: Bool, importer: ProviderPluginCookieBroker.JarImporter) throws + -> ProviderPluginCookieSession? + { + guard self.policy.requestHosts.contains(domain) else { + throw ProviderPluginError.secretAccess("cookie session destination is not declared") + } + if !self.readCache { + self.readCache = true + self.expected = CookieHeaderCache.load(provider: self.provider) + self.pendingCache = self.expected + self.fileSnapshot = self.file?.read() + if self.background, !CookieHeaderCache.isRefreshReadSuppressed(provider: self.provider), + let values = self.fileSnapshot, let header = self.file?.header(values) + { + return self.issue( + Payload(headersByHost: [domain: header]), + domain: domain, + source: values["sourceLabel"] ?? "Saved session", + cachedAt: 0, + fromFile: true) + } + } + if let entry = self.pendingCache { + self.pendingCache = nil + if let payload = self.decode(entry.cookieHeader, domain: domain) { + return self.issue( + payload, + domain: domain, + source: entry.sourceLabel, + cachedAt: entry.storedAt.timeIntervalSince1970) + } + } + guard !cachedOnly else { return nil } + if self.candidates == nil { self.candidates = try importer() } + while self.candidates?.isEmpty == false { + let candidate = self.candidates!.removeFirst() + guard let selected = self.policy.selected(candidate.records ?? [], domain: domain) else { continue } + return self.issue(Payload(records: selected), domain: domain, source: candidate.source) + } + return nil + } + + func accept(domain: String, id: String) throws { + guard let issued = self.issued[id], issued.session.origin == "https://\(domain)" else { + throw ProviderPluginError.secretAccess("validated cookie session is unavailable") + } + let payload = Payload(records: issued.session.records, headersByHost: issued.session.headersByHost) + let encoded = try Self.encode(payload) + let header = try? ProviderPluginCookieJar.header(for: issued.session, url: URL(string: "https://\(domain)/")!) + let file = self.file + let storedAt = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded(.down)) + let entry = CookieHeaderCache.Entry( + cookieHeader: encoded, + storedAt: storedAt, + sourceLabel: issued.session.source) + let saved = CookieHeaderCache.storeIfCurrent( + provider: self.provider, + expected: issued.expected, + cookieHeader: encoded, + sourceLabel: issued.session.source, + now: storedAt, + onCommit: { file?.replace(expected: issued.file, header: header, source: issued.session.source) }) + if saved { + self.expected = entry + self.fileSnapshot = file?.values(header: header, source: issued.session.source) + self.issued[id] = Issued( + session: issued.session, + expected: self.expected, + file: self.fileSnapshot, + fromFile: false) + } + } + + func reject(domain: String, id: String) { + guard let issued = self.issued[id], issued.session.origin == "https://\(domain)" else { return } + self.issued[id] = nil + let file = self.file + if issued.fromFile { + if !CookieHeaderCache.isRefreshReadSuppressed(provider: self.provider) { + file?.replace(expected: issued.file, header: nil, source: issued.session.source) + self.fileSnapshot = file?.read() + } + return + } + if CookieHeaderCache.clearIfCurrent(provider: self.provider, expected: issued.expected, onClear: { + file?.replace(expected: issued.file, header: nil, source: issued.session.source) + }) { + self.expected = nil + self.fileSnapshot = self.file?.read() + } + } + + private func issue( + _ payload: Payload, domain: String, source: String, cachedAt: TimeInterval? = nil, fromFile: Bool = false) + -> ProviderPluginCookieSession + { + // Hash the canonical credential, never the per-fetch ID, so bearer caches survive refreshes. + let canonical = (try? Self.encode(payload)) ?? "" + let key = SHA256.hash(data: Data(canonical.utf8)).map { String(format: "%02x", $0) }.joined() + let session = ProviderPluginCookieSession( + header: "", + source: source, + origin: "https://\(domain)", + cachedAt: cachedAt, + records: payload.records, + headersByHost: payload.headersByHost, + cacheKey: key, + permitsEmptyHosts: self.policy.missingCookies == .omit ? self.policy.requestHosts : []) + self.issued[session.id] = Issued( + session: session, + expected: self.expected, + file: self.fileSnapshot, + fromFile: fromFile) + return session + } + + private func decode(_ raw: String, domain: String) -> Payload? { + if let data = raw.data(using: .utf8), var payload = try? JSONDecoder().decode(Payload.self, from: data) { + if let records = payload.records { + payload.records = self.policy.selected(records, domain: domain) + } + payload.headersByHost = payload.headersByHost?.filter { self.policy.requestHosts.contains($0.key) } + return payload.records != nil || payload.headersByHost?.isEmpty == false ? payload : nil + } + // Native single-origin caches store the plain header; paired-host caches use headersByHost above. + guard self.policy.requestHosts.count == 1, + let header = CookieHeaderNormalizer.normalize(raw), !CookieHeaderNormalizer.pairs(from: header).isEmpty + else { return nil } + guard self.policy.requiredCookies.isSubset(of: Set(CookieHeaderNormalizer.pairs(from: header).map(\.name))) + else { return nil } + return Payload(headersByHost: [domain: header]) + } + + private static func encode(_ payload: Payload) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let encoded = try String(data: encoder.encode(payload), encoding: .utf8) else { + throw ProviderPluginError.secretAccess("cookie cache encoding failed") + } + return encoded + } +} diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginResult.swift b/Sources/CodexBarCore/Plugins/ProviderPluginResult.swift index b73b46eb19..a676adb506 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginResult.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginResult.swift @@ -88,13 +88,14 @@ extension ProviderPluginSnapshotMapper { _ value: any ProviderPluginValue, provider: ProviderInstanceID, now: Date, - allowsProviderExtensions: Bool = true) throws -> ProviderPluginResult + allowsProviderExtensions: Bool = true, + percentPolicy: ProviderPluginPercentPolicy = .clamp) throws -> ProviderPluginResult { let keys = try self.objectKeys(value, path: "result") let envelope = keys.contains("usage") guard envelope else { return try ProviderPluginResult( - usage: self.map(value, provider: provider, now: now), + usage: self.map(value, provider: provider, now: now, percentPolicy: percentPolicy), sourceLabel: nil, persist: [:]) } @@ -105,7 +106,7 @@ extension ProviderPluginSnapshotMapper { guard let rawUsage = value.property("usage") else { throw ProviderPluginError.invalidSnapshot("usage is required") } - var usage = try self.map(rawUsage, provider: provider, now: now) + var usage = try self.map(rawUsage, provider: provider, now: now, percentPolicy: percentPolicy) var sourceLabel: String? if keys.contains("sourceLabel"), let label = value.property("sourceLabel") { sourceLabel = try self.resultString(label, path: "sourceLabel") diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginRuntime.swift b/Sources/CodexBarCore/Plugins/ProviderPluginRuntime.swift index a7690d90a6..07747b211a 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginRuntime.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginRuntime.swift @@ -9,6 +9,7 @@ import FoundationNetworking public final class ProviderPluginRuntime: @unchecked Sendable { public typealias CookieInvalidator = @Sendable (String) -> Void public typealias CookieSessionResolver = @Sendable (String, Bool) async throws -> ProviderPluginCookieSession? + public typealias CookieSessionValidator = @Sendable (String, String) throws -> Void public typealias CookieSessionInvalidator = @Sendable (String, String) -> Void public typealias CookieResolver = @Sendable (UsageProvider, String) async throws -> String public typealias InstanceCookieResolver = @Sendable (ProviderInstanceID, String) async throws -> String @@ -172,6 +173,7 @@ public final class ProviderPluginRuntime: @unchecked Sendable { cookieInvalidator: CookieInvalidator? = nil, cookieSessionResolver: CookieSessionResolver? = nil, cookieSessionInvalidator: CookieSessionInvalidator? = nil, + cookieSessionValidator: CookieSessionValidator? = nil, cookieResolver: CookieResolver? = nil, instanceCookieResolver: InstanceCookieResolver? = nil) async throws -> UsageSnapshot { @@ -185,6 +187,7 @@ public final class ProviderPluginRuntime: @unchecked Sendable { cookieInvalidator: cookieInvalidator, cookieSessionResolver: cookieSessionResolver, cookieSessionInvalidator: cookieSessionInvalidator, + cookieSessionValidator: cookieSessionValidator, cookieResolver: cookieResolver, instanceCookieResolver: instanceCookieResolver).usage } @@ -199,6 +202,7 @@ public final class ProviderPluginRuntime: @unchecked Sendable { cookieInvalidator: CookieInvalidator? = nil, cookieSessionResolver: CookieSessionResolver? = nil, cookieSessionInvalidator: CookieSessionInvalidator? = nil, + cookieSessionValidator: CookieSessionValidator? = nil, cookieResolver: CookieResolver? = nil, instanceCookieResolver: InstanceCookieResolver? = nil) async throws -> ProviderPluginResult { @@ -224,6 +228,7 @@ public final class ProviderPluginRuntime: @unchecked Sendable { resolver: cookieResolver, instanceResolver: instanceCookieResolver) contextOptions.cookieSessionInvalidator = cookieSessionInvalidator + contextOptions.cookieSessionValidator = cookieSessionValidator if self.manifest.usesCookieJar { let jar = ProviderPluginCookieJar() let resolver = contextOptions.cookieSessionResolver @@ -591,7 +596,8 @@ final class JavaScriptCoreProviderPluginEngine: ProviderPluginEngine, @unchecked JavaScriptCorePluginValue(value, keyEnumerator: self.keyEnumerator), provider: self.manifest.id, now: now, - allowsProviderExtensions: !self.enforcesUserResponsePolicy) + allowsProviderExtensions: !self.enforcesUserResponsePolicy, + percentPolicy: self.manifest.percentPolicy) completion(.success(snapshot)) } catch { completion(.failure(ProviderPluginError @@ -758,6 +764,19 @@ final class JavaScriptCoreProviderPluginEngine: ProviderPluginEngine, @unchecked } host.setObject(rejectCookie, forKeyedSubscript: "rejectCookie" as NSString) + let acceptCookie: @convention(block) (String, String) -> Void = { [weak self] rawDomain, id in + guard let self else { return } + do { + let domain = try self.manifest.cookieDomain(rawDomain) + guard self.manifest.cookiePolicy?.cache == .validatedSingleEntry + else { throw ProviderPluginError.secretAccess("cookie persistence is unavailable") } + try contextOptions.acceptCookie(domain: domain, id: id) + } catch { + self.context.exception = JSValue(newErrorFromMessage: error.localizedDescription, in: self.context) + } + } + host.setObject(acceptCookie, forKeyedSubscript: "acceptCookie" as NSString) + let cookieHeader = self.makeCookieBlock( source: contextOptions.cookieSource, resolver: cookieResolver, @@ -772,8 +791,26 @@ final class JavaScriptCoreProviderPluginEngine: ProviderPluginEngine, @unchecked redactionValues: redactionValues) host.setObject(cookieSession, forKeyedSubscript: "cookieSession" as NSString) + self.installMemoryCache(on: host) + + let log: @convention(block) (String) -> Void = { [manifest] message in + let logger = CodexBarLog.logger(LogCategories.providerInstance(manifest.id, scope: "plugin")) + logger.debug("\(redactionValues.redact(message))") + } + host.setObject(log, forKeyedSubscript: "log" as NSString) + + _ = self.applyPrelude.call(withArguments: [ctx, host]) + return ctx + } + + private func installMemoryCache(on host: JSValue) { let cacheGet: @convention(block) (String) -> JSValue = { [weak self] key in guard let self else { return JSValue(undefinedIn: nil) } + if self.manifest.cookiePolicy?.cache == .validatedSingleEntry { + guard let json = ProviderPluginMemoryCache.shared.get(namespace: self.manifest.id.rawValue, key: key) + else { return JSValue(undefinedIn: self.context) } + return self.context.objectForKeyedSubscript("JSON").invokeMethod("parse", withArguments: [json]) + } guard let entry = self.cache[key], entry.expiresAt > Date() else { self.cache[key] = nil return JSValue(undefinedIn: self.context) @@ -782,19 +819,20 @@ final class JavaScriptCoreProviderPluginEngine: ProviderPluginEngine, @unchecked } let cacheSet: @convention(block) (String, JSValue, Double) -> Void = { [weak self] key, value, ttl in guard let self, ttl.isFinite, ttl > 0 else { return } + if self.manifest.cookiePolicy?.cache == .validatedSingleEntry { + guard let json = self.context.objectForKeyedSubscript("JSON") + .invokeMethod("stringify", withArguments: [value])?.toString() else { return } + ProviderPluginMemoryCache.shared.set( + namespace: self.manifest.id.rawValue, + key: key, + json: json, + ttl: ttl) + return + } self.cache[key] = (value, Date().addingTimeInterval(min(ttl, 86400))) } host.setObject(cacheGet, forKeyedSubscript: "cacheGet" as NSString) host.setObject(cacheSet, forKeyedSubscript: "cacheSet" as NSString) - - let log: @convention(block) (String) -> Void = { [manifest] message in - let logger = CodexBarLog.logger(LogCategories.providerInstance(manifest.id, scope: "plugin")) - logger.debug("\(redactionValues.redact(message))") - } - host.setObject(log, forKeyedSubscript: "log" as NSString) - - _ = self.applyPrelude.call(withArguments: [ctx, host]) - return ctx } func requestInterrupt() { @@ -942,8 +980,8 @@ final class JavaScriptCoreProviderPluginEngine: ProviderPluginEngine, @unchecked guard session.origin == "https://\(domain)" else { throw ProviderPluginError.secretAccess("cookie session origin does not match its domain") } - for record in session.records ?? [] { - redactionValues.insert(record.value) + for value in session.redactionValues { + redactionValues.insert(value) } return try (session.header, session.json(opaque: self.manifest.usesCookieJar)) } diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginSessionFile.swift b/Sources/CodexBarCore/Plugins/ProviderPluginSessionFile.swift new file mode 100644 index 0000000000..62d2d5357a --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ProviderPluginSessionFile.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Reads the native token-file shape without granting scripts filesystem or credential access. +final class ProviderPluginSessionFile: @unchecked Sendable { + private static let lock = NSLock() + private let url: URL + private let policy: ProviderPluginCookiePolicy.SessionFile + + init(provider: UsageProvider, policy: ProviderPluginCookiePolicy.SessionFile, url: URL? = nil) { + self.url = url ?? ProviderSessionStoreFile.url(for: provider.rawValue + "-session.json") + self.policy = policy + } + + func read() -> [String: String]? { + Self.lock.withLock { self.load() } + } + + private func load() -> [String: String]? { + CredentialFileWriter.repairPermissions(at: self.url) + guard let data = try? Data(contentsOf: self.url), data.count <= 65536, + let values = try? JSONDecoder().decode([String: String].self, from: data), + let token = values[self.policy.tokenField], !token.isEmpty + else { return nil } + return values + } + + func header(_ values: [String: String]) -> String? { + values[self.policy.tokenField].map { "\(self.policy.cookieName)=\($0)" } + } + + func values(header: String?, source: String) -> [String: String]? { + guard let header, let token = CookieHeaderNormalizer.pairs(from: header) + .first(where: { $0.name == self.policy.cookieName })?.value else { return nil } + return [self.policy.tokenField: token, "sourceLabel": source] + } + + func replace(expected: [String: String]?, header: String?, source: String) { + Self.lock.withLock { + guard self.load() == expected else { return } + guard let values = self.values(header: header, source: source), + let data = try? JSONEncoder().encode(values) + else { + try? FileManager.default.removeItem(at: self.url) + return + } + try? CredentialFileWriter.writePrivate(data, to: self.url) + } + } +} diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginSnapshotMapper.swift b/Sources/CodexBarCore/Plugins/ProviderPluginSnapshotMapper.swift index d28558fb41..c34f691949 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginSnapshotMapper.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginSnapshotMapper.swift @@ -37,7 +37,8 @@ enum ProviderPluginSnapshotMapper { static func map( _ value: any ProviderPluginValue, provider: ProviderInstanceID, - now: Date = Date()) throws -> UsageSnapshot + now: Date = Date(), + percentPolicy: ProviderPluginPercentPolicy = .clamp) throws -> UsageSnapshot { guard value.isObject, !value.isArray, !value.isNull else { throw ProviderPluginError.invalidSnapshot("fetchUsage must resolve to an object") @@ -52,10 +53,10 @@ enum ProviderPluginSnapshotMapper { ], path: "usage") - let primary = try self.window(value, property: "primary") - let secondary = try self.window(value, property: "secondary") - let tertiary = try self.window(value, property: "tertiary") - let extraRateWindows = try self.extraWindows(value) + let primary = try self.window(value, property: "primary", percentPolicy: percentPolicy) + let secondary = try self.window(value, property: "secondary", percentPolicy: percentPolicy) + let tertiary = try self.window(value, property: "tertiary", percentPolicy: percentPolicy) + let extraRateWindows = try self.extraWindows(value, percentPolicy: percentPolicy) let providerCost = try self.cost(value, now: now) let costUsage = try self.costUsage(value) let details = try self.details(value) @@ -226,17 +227,25 @@ enum ProviderPluginSnapshotMapper { return string.isEmpty ? nil : string } - private static func window(_ root: any ProviderPluginValue, property: String) throws -> RateWindow? { + private static func window( + _ root: any ProviderPluginValue, + property: String, + percentPolicy: ProviderPluginPercentPolicy) throws -> RateWindow? + { guard let value = root.property(property), !value.isUndefined, !value.isNull else { return nil } - return try self.window(value, path: property) + return try self.window(value, path: property, percentPolicy: percentPolicy) } - private static func window(_ value: any ProviderPluginValue, path: String) throws -> RateWindow { + private static func window( + _ value: any ProviderPluginValue, + path: String, + percentPolicy: ProviderPluginPercentPolicy) throws -> RateWindow + { guard value.isObject, !value.isArray else { throw ProviderPluginError.invalidSnapshot("\(path) must be an object") } let rawPercent = try self.requiredFiniteNumber(value, property: "usedPercent", path: path) - let usedPercent = min(100, max(0, rawPercent)) + let usedPercent = percentPolicy.map(rawPercent) let windowMinutes = try self.optionalPositiveInteger(value, property: "windowMinutes", path: path) let resetsAt = try self.optionalDate(value, property: "resetsAt", path: path) let resetDescription = try self.optionalString(value, property: "resetDescription", path: path) @@ -249,7 +258,9 @@ enum ProviderPluginSnapshotMapper { nextRegenPercent: nextRegenPercent.map { min(100, max(0, $0)) }) } - private static func extraWindows(_ root: any ProviderPluginValue) throws -> [NamedRateWindow]? { + private static func extraWindows( + _ root: any ProviderPluginValue, percentPolicy: ProviderPluginPercentPolicy) throws -> [NamedRateWindow]? + { guard let value = root.property("extraWindows"), !value.isUndefined, !value.isNull else { return nil } guard value.isArray else { throw ProviderPluginError.invalidSnapshot("extraWindows must be an array") @@ -265,7 +276,8 @@ enum ProviderPluginSnapshotMapper { let windowValue = item.property("window") let window = try self.window( windowValue?.isObject == true && windowValue?.isNull == false ? windowValue! : item, - path: "\(path).window") + path: "\(path).window", + percentPolicy: percentPolicy) var usageKnown = true if let value = item.property("usageKnown"), !value.isUndefined { guard value.isBoolean else { diff --git a/Sources/CodexBarCore/Plugins/QuickJSProviderPluginEngine.swift b/Sources/CodexBarCore/Plugins/QuickJSProviderPluginEngine.swift index 42fdea9550..83b1deff3a 100644 --- a/Sources/CodexBarCore/Plugins/QuickJSProviderPluginEngine.swift +++ b/Sources/CodexBarCore/Plugins/QuickJSProviderPluginEngine.swift @@ -9,6 +9,7 @@ private enum QuickJSHostFunction: Int32 { case settingGet case http case cookieAvailability + case acceptCookie case rejectCookie case cookieHeader case cookieSession @@ -505,7 +506,8 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab QuickJSPluginValue(engine: self, value: result), provider: self.manifest.id, now: now, - allowsProviderExtensions: !self.enforcesUserResponsePolicy) + allowsProviderExtensions: !self.enforcesUserResponsePolicy, + percentPolicy: self.manifest.percentPolicy) } private func installHostFunctions(on host: JSValue) throws { @@ -514,6 +516,7 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab (.http, "http", 6), (.cookieHeader, "cookieHeader", 4), (.rejectCookie, "rejectCookie", 2), + (.acceptCookie, "acceptCookie", 2), (.cookieSession, "cookieSession", 4), (.cookieAvailability, "cookieAvailability", 1), (.storage, "storage", 3), @@ -551,12 +554,10 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab try self.hostHTTP(values) return cqjs_undefined() case .cookieAvailability: - _ = try self.manifest.cookieDomain(values.first.map { try self.string(from: $0) } ?? "") - guard let state = self.fetchState else { return self.makeString("off") } - return self.makeString(state.contextOptions.cookieSource.pluginAvailability( - hasResolver: state.contextOptions.cookieSessionResolver != nil - || (self.manifest.id.firstPartyProvider != nil && state.cookieResolver != nil) - || state.instanceCookieResolver != nil)) + return try self.hostCookieAvailability(values) + case .acceptCookie: + try self.hostAcceptCookie(values) + return cqjs_undefined() case .rejectCookie: let domain = try self.manifest.cookieDomain(values.first.map { try self.string(from: $0) } ?? "") let id = values.count > 1 ? try self.string(from: values[1]) : "" @@ -678,6 +679,24 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab } } + private func hostCookieAvailability(_ values: UnsafeBufferPointer) throws -> JSValue { + _ = try self.manifest.cookieDomain(values.first.map { try self.string(from: $0) } ?? "") + guard let state = self.fetchState else { return self.makeString("off") } + return self.makeString(state.contextOptions.cookieSource.pluginAvailability( + hasResolver: state.contextOptions.cookieSessionResolver != nil + || (self.manifest.id.firstPartyProvider != nil && state.cookieResolver != nil) + || state.instanceCookieResolver != nil)) + } + + private func hostAcceptCookie(_ values: UnsafeBufferPointer) throws { + let domain = try self.manifest.cookieDomain(values.first.map { try self.string(from: $0) } ?? "") + let id = values.count > 1 ? try self.string(from: values[1]) : "" + guard self.manifest.cookiePolicy?.cache == .validatedSingleEntry, + let options = self.fetchState?.contextOptions + else { throw ProviderPluginError.secretAccess("cookie persistence is unavailable") } + try options.acceptCookie(domain: domain, id: id) + } + private func hostCookieHeader(_ arguments: UnsafeBufferPointer, session: Bool) throws { guard arguments.count >= 4, let state = self.fetchState else { throw ProviderPluginError.secretAccess("cookie bridge is unavailable") @@ -699,8 +718,8 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab throw ProviderPluginError.secretAccess("cookie session origin does not match its domain") } header = candidate?.header ?? "" - for record in candidate?.records ?? [] { - state.redactionValues.insert(record.value) + for value in candidate?.redactionValues ?? [] { + state.redactionValues.insert(value) } payload = try candidate?.json(opaque: self.manifest.usesCookieJar) ?? "null" } else if !session, let provider = self.manifest.id.firstPartyProvider, @@ -729,6 +748,11 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab private func hostCacheGet(_ arguments: UnsafeBufferPointer) throws -> JSValue { guard let keyValue = arguments.first else { return cqjs_undefined() } let key = try self.string(from: keyValue) + if self.manifest.cookiePolicy?.cache == .validatedSingleEntry { + guard let json = ProviderPluginMemoryCache.shared.get(namespace: self.manifest.id.rawValue, key: key) + else { return cqjs_undefined() } + return try self.parseJSON(json) + } guard let entry = self.cache[key], entry.expiresAt > Date() else { self.cache[key] = nil return cqjs_undefined() @@ -742,6 +766,10 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab var ttl = 0.0 guard JS_ToFloat64(self.context, &ttl, arguments[2]) == 0, ttl.isFinite, ttl > 0 else { return } let json = try self.jsonString(from: arguments[1]) + if self.manifest.cookiePolicy?.cache == .validatedSingleEntry { + ProviderPluginMemoryCache.shared.set(namespace: self.manifest.id.rawValue, key: key, json: json, ttl: ttl) + return + } self.cache[key] = CacheEntry(json: json, expiresAt: Date().addingTimeInterval(min(ttl, 86400))) } diff --git a/Sources/CodexBarCore/Plugins/ScriptFetchStrategy.swift b/Sources/CodexBarCore/Plugins/ScriptFetchStrategy.swift index 09ea584339..0882ae7e20 100644 --- a/Sources/CodexBarCore/Plugins/ScriptFetchStrategy.swift +++ b/Sources/CodexBarCore/Plugins/ScriptFetchStrategy.swift @@ -138,7 +138,7 @@ public final class ScriptFetchStrategy: ProviderFetchStrategy, @unchecked Sendab domains: runtime.manifest.cookieDomains, context: context, importer: importer, - usesCookieJar: runtime.manifest.usesCookieJar, + policy: runtime.manifest.cookiePolicy, settingsOverride: self.cookieSettings?(context)) let result = try await runtime.fetchResult( settings: values.settings, @@ -148,6 +148,7 @@ public final class ScriptFetchStrategy: ProviderFetchStrategy, @unchecked Sendab cookieInvalidator: { cookies.rejectCookie(domain: $0) }, cookieSessionResolver: { try cookies.nextSession(domain: $0, cachedOnly: $1) }, cookieSessionInvalidator: { cookies.rejectCookie(domain: $0, id: $1) }, + cookieSessionValidator: { try cookies.acceptCookie(domain: $0, id: $1) }, cookieResolver: { _, domain in try cookies.cookieHeader(domain: domain) }) try Task.checkCancellation() let saved = result.persist.isEmpty ? ProviderSettingsSaveOutcome.unchanged diff --git a/Sources/CodexBarCore/Providers/Notion/NotionProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Notion/NotionProviderDescriptor.swift index 39659f5d26..a3964411fb 100644 --- a/Sources/CodexBarCore/Providers/Notion/NotionProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Notion/NotionProviderDescriptor.swift @@ -16,7 +16,7 @@ public enum NotionProviderDescriptor { cookieSettings: { settings in CookieProviderSettings( cookieSource: settings.cookieSource, - manualCookieHeader: settings.manualCookieHeader) + manualCookieHeader: Self.manualHeader(settings.manualCookieHeader)) }, credentialSettings: { context in let settings = context.cookieSettings(for: .notion) @@ -92,7 +92,9 @@ public enum NotionProviderDescriptor { menuBarLayoutSecondaryLabel: "Monthly"), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [NotionWebFetchStrategy()] })), + pipeline: ProviderFetchPipeline(resolveStrategies: { context in + [Self.webStrategy(timeout: context.webTimeout)] + })), cli: ProviderCLIConfig( name: "notion", aliases: ["notion-ai", "notionai"], @@ -100,45 +102,49 @@ public enum NotionProviderDescriptor { } } -struct NotionWebFetchStrategy: ProviderFetchStrategy { - let id: String = "notion.web" - let kind: ProviderFetchKind = .web - - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - let cookieSource = context.settings?.notion?.cookieSource ?? .auto - guard cookieSource != .off else { return false } - if cookieSource == .manual { - return NotionUsageFetcher.requestContext(from: context.settings?.notion?.manualCookieHeader) != nil - } - #if os(macOS) - return true - #else - return false - #endif - } - - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - let fetcher = NotionUsageFetcher(browserDetection: context.browserDetection) - let manual = Self.manualCookieHeader(from: context) - let logger: ((String) -> Void)? = context.verbose - ? { msg in CodexBarLog.logger(LogCategories.provider(.notion)).verbose(msg) } - : nil - let snapshot = try await fetcher.fetch( - cookieHeaderOverride: manual, - preferredSpaceID: context.settings?.notion?.workspaceID, - timeout: context.webTimeout, - logger: logger) - return self.makeResult( - usage: snapshot.toUsageSnapshot(), - sourceLabel: "web") - } - - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false +extension NotionProviderDescriptor { + static func manualHeader(_ raw: String?) -> String? { + let fields = CurlCaptureParser.headerFields(from: raw ?? "") + guard let header = CookieHeaderNormalizer.normalize( + CurlCaptureParser.headerValue(named: "Cookie", in: fields) ?? raw) else { return nil } + return CookieHeaderNormalizer.pairs(from: header).isEmpty ? "token_v2=\(header)" : header } - private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { - guard context.settings?.notion?.cookieSource == .manual else { return nil } - return context.settings?.notion?.manualCookieHeader + public static func webStrategy( + timeout: TimeInterval = 15, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) -> ScriptFetchStrategy + { + ScriptFetchStrategy( + id: "notion.web", + provider: .notion, + bundledPlugin: "notion", + sourceLabel: "web", + kind: .web, + transport: transport, + timeout: max(30, timeout * 2), + resolveValues: { context in + let settings = context.settings?.notion + guard settings?.cookieSource != .off else { return nil } + let fields = settings?.cookieSource == .manual + ? CurlCaptureParser.headerFields(from: settings?.manualCookieHeader ?? "") : [] + let names = [ + "accept", + "accept-language", + "notion-audit-log-platform", + "notion-client-version", + "referer", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "user-agent", + "x-notion-active-user-header", + ] + let headers = CurlCaptureParser.forwardedHeaders( + from: fields, allowlist: Dictionary(uniqueKeysWithValues: names.map { ($0, $0) })) + let encoded = (try? JSONSerialization.data(withJSONObject: headers)) ?? Data("{}".utf8) + return .init( + settings: ["WORKSPACE_ID": settings?.workspaceID ?? ""], + secrets: ["HEADERS": String(data: encoded, encoding: .utf8) ?? "{}"]) + }, isEnabled: { _ in true }) } } diff --git a/Sources/CodexBarCore/Providers/Notion/NotionSessionStore.swift b/Sources/CodexBarCore/Providers/Notion/NotionSessionStore.swift deleted file mode 100644 index 1a7c25b2d6..0000000000 --- a/Sources/CodexBarCore/Providers/Notion/NotionSessionStore.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation - -#if os(macOS) - -public actor NotionSessionStore { - public struct Session: Codable, Equatable, Sendable { - public let tokenV2: String - public let sourceLabel: String - - public init(tokenV2: String, sourceLabel: String) { - self.tokenV2 = tokenV2 - self.sourceLabel = sourceLabel - } - - public var cookieHeader: String { - "\(NotionUsageFetcher.sessionCookieName)=\(self.tokenV2)" - } - } - - public static let shared = NotionSessionStore() - - private var session: Session? - private var hasLoadedFromDisk = false - private let fileURL: URL - - init(fileURL: URL? = nil) { - self.fileURL = fileURL ?? ProviderSessionStoreFile.url(for: "notion-session.json") - } - - public func setSession(tokenV2: String, sourceLabel: String) { - let token = tokenV2.trimmingCharacters(in: .whitespacesAndNewlines) - guard !token.isEmpty else { - self.clearSession() - return - } - self.hasLoadedFromDisk = true - self.session = Session(tokenV2: token, sourceLabel: sourceLabel) - self.saveToDisk() - } - - public func getSession() -> Session? { - self.loadFromDiskIfNeeded() - return self.session - } - - public func clearSession() { - self.hasLoadedFromDisk = true - self.session = nil - try? FileManager.default.removeItem(at: self.fileURL) - } - - private func loadFromDiskIfNeeded() { - guard !self.hasLoadedFromDisk else { return } - self.hasLoadedFromDisk = true - CredentialFileWriter.repairPermissions(at: self.fileURL) - guard let data = try? Data(contentsOf: self.fileURL), - let session = try? JSONDecoder().decode(Session.self, from: data), - !session.tokenV2.isEmpty - else { return } - self.session = session - } - - private func saveToDisk() { - guard let session = self.session, - let data = try? JSONEncoder().encode(session) - else { - try? FileManager.default.removeItem(at: self.fileURL) - return - } - try? CredentialFileWriter.writePrivate(data, to: self.fileURL) - } -} - -#endif diff --git a/Sources/CodexBarCore/Providers/Notion/NotionUsageFetcher.swift b/Sources/CodexBarCore/Providers/Notion/NotionUsageFetcher.swift deleted file mode 100644 index f9ec283709..0000000000 --- a/Sources/CodexBarCore/Providers/Notion/NotionUsageFetcher.swift +++ /dev/null @@ -1,430 +0,0 @@ -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -#if os(macOS) -import SweetCookieKit -#endif - -#if os(macOS) -public enum NotionCookieImporter { - private static let importSessionCacheTTL: TimeInterval = 5 - private static let importSessionCache = ExpiringValueCache(ttl: importSessionCacheTTL) - private static let cookieClient = BrowserCookieClient() - private static let cookieImportOrder: BrowserCookieImportOrder = - ProviderDefaults.metadata[.notion]?.browserCookieOrder ?? Browser.defaultImportOrder - /// The app moved to `app.notion.com`; `notion.so` is kept for sessions that predate the move. - private static let cookieDomains = [ - "app.notion.com", - "www.notion.com", - "notion.com", - "www.notion.so", - "notion.so", - ] - - public struct SessionInfo: Sendable { - public let cookies: [HTTPCookie] - public let sourceLabel: String - - public init(cookies: [HTTPCookie], sourceLabel: String) { - self.cookies = cookies - self.sourceLabel = sourceLabel - } - - public var cookieHeader: String { - self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") - } - - var tokenV2: String? { - self.cookies.first(where: { $0.name == NotionUsageFetcher.sessionCookieName })?.value - } - } - - public static func importSession( - browserDetection: BrowserDetection, - browserOrder: BrowserCookieImportOrder? = nil, - logger: ((String) -> Void)? = nil) throws -> SessionInfo - { - let log: (String) -> Void = { msg in logger?("[notion-cookie] \(msg)") } - let now = Date() - // Reading cookie stores touches browser Safe Storage; a short TTL keeps the polling refresh - // from doing that on every tick. - if let cached = self.importSessionCache.load(now: now) { - return cached - } - let importOrder = browserOrder ?? self.cookieImportOrder - let installed = importOrder.cookieImportCandidates(using: browserDetection) - // `cookieImportCandidates` drops Chromium browsers on anything but a user-initiated refresh, - // to avoid a Keychain prompt. Saying "log in" there would be wrong — the session is fine, it - // just cannot be read yet. - if installed.isEmpty, !importOrder.browsersWithProfileData(using: browserDetection).isEmpty { - throw NotionUsageError.cookieImportDeferred - } - - for browserSource in installed { - do { - let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try self.cookieClient.codexBarRecords( - matching: query, - in: browserSource, - logger: log) - for source in sources where !source.records.isEmpty { - let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) - guard !cookies.isEmpty else { continue } - let deduped = self.deduplicatedByName(cookies) - // `token_v2` is the session cookie; without it the API answers 401 for every call. - guard deduped.contains(where: { $0.name == NotionUsageFetcher.sessionCookieName }) else { - log("\(source.label) has Notion cookies but no session cookie") - continue - } - let names = deduped.map(\.name).joined(separator: ", ") - log("\(source.label) cookies: \(names)") - let session = SessionInfo(cookies: deduped, sourceLabel: source.label) - self.importSessionCache.store(session, now: now) - return session - } - } catch { - BrowserCookieAccessGate.recordIfNeeded(error) - log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") - } - } - - throw NotionUsageError.noSessionCookie - } - - /// A profile can hold the same cookie on several Notion domains — most often a stale `token_v2` - /// left on the legacy `notion.so` alongside the live one. Emitting both puts two `token_v2` - /// pairs in one header and the server picks arbitrarily, so keep the most specific domain's. - static func deduplicatedByName(_ cookies: [HTTPCookie]) -> [HTTPCookie] { - var best: [String: (rank: Int, cookie: HTTPCookie)] = [:] - for cookie in cookies { - let host = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain - let rank = self.cookieDomains.firstIndex(of: host.lowercased()) ?? self.cookieDomains.count - if let existing = best[cookie.name], existing.rank <= rank { - continue - } - best[cookie.name] = (rank, cookie) - } - return best.keys.sorted().compactMap { best[$0]?.cookie } - } -} -#endif - -public struct NotionUsageFetcher: Sendable { - private static let log = CodexBarLog.logger(LogCategories.provider(.notion)) - static let sessionCookieName = "token_v2" - private static let baseURL = URL(string: "https://app.notion.com")! - private static let refererURL = URL(string: "https://app.notion.com/")! - /// Browser fingerprint defaults are only fallbacks; full cURL captures override these forwarded headers. - private static let userAgent = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" - private static let forwardedManualHeaders = [ - "accept": "Accept", - "accept-language": "Accept-Language", - "notion-audit-log-platform": "notion-audit-log-platform", - "notion-client-version": "notion-client-version", - "referer": "Referer", - "sec-fetch-dest": "Sec-Fetch-Dest", - "sec-fetch-mode": "Sec-Fetch-Mode", - "sec-fetch-site": "Sec-Fetch-Site", - "user-agent": "User-Agent", - "x-notion-active-user-header": "x-notion-active-user-header", - // `x-notion-space-id` is deliberately not forwarded: a capture taken in one workspace would - // pin that space while the request body asks for the configured one, and the mismatch would - // surface as another workspace's usage rather than an error. - ] - - public struct RequestContext: Sendable { - /// Normalized at construction so each request can use it as-is. Empty means unusable. - public let cookieHeader: String - public let headers: [String: String] - - public init(cookieHeader: String, headers: [String: String] = [:]) { - self.cookieHeader = CookieHeaderNormalizer.normalize(cookieHeader) ?? "" - self.headers = headers - } - - var isUsable: Bool { - !self.cookieHeader.isEmpty - } - } - - public let browserDetection: BrowserDetection - - public init(browserDetection: BrowserDetection) { - self.browserDetection = browserDetection - } - - public func fetch( - cookieHeaderOverride: String? = nil, - preferredSpaceID: String? = nil, - timeout: TimeInterval = 15, - logger: ((String) -> Void)? = nil, - now: Date = Date(), - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> NotionUsageSnapshot - { - let log: (String) -> Void = { msg in logger?("[notion] \(msg)") } - let options = FetchOptions( - preferredSpaceID: preferredSpaceID, - timeout: timeout, - logger: logger, - now: now, - transport: transport) - - if let override = Self.requestContext(from: cookieHeaderOverride) { - log("Using \(override.headers.isEmpty ? "manual cookie header" : "manual cURL capture")") - return try await self.runFetch(context: override, options: options) - } - - #if os(macOS) - // Chromium cookie imports only run on a user-initiated refresh, so a timer tick has no way - // to read the browser store. Reusing the header cached by the last successful import is what - // keeps background refreshes working instead of reporting "no cookies found". - if let cached = CookieHeaderCache.load(provider: .notion), - !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - log("Using cached cookie header from \(cached.sourceLabel)") - do { - return try await self.runFetch( - context: RequestContext(cookieHeader: cached.cookieHeader), - options: options) - } catch NotionUsageError.invalidCredentials { - CookieHeaderCache.clear(provider: .notion) - await NotionSessionStore.shared.clearSession() - log("Cached session was rejected; cleared persisted copies and retrying with a fresh import") - } - } - - if ProviderInteractionContext.current != .userInitiated, - let stored = await NotionSessionStore.shared.getSession() - { - log("Using stored session from \(stored.sourceLabel)") - do { - return try await self.runFetch( - context: RequestContext(cookieHeader: stored.cookieHeader), - options: options) - } catch NotionUsageError.invalidCredentials { - await NotionSessionStore.shared.clearSession() - log("Stored session was rejected; cleared it and retrying with a fresh import") - } - } - - let session = try NotionCookieImporter.importSession( - browserDetection: self.browserDetection, - logger: logger) - log("Using cookies from \(session.sourceLabel)") - let snapshot = try await self.runFetch( - context: RequestContext(cookieHeader: session.cookieHeader), - options: options) - if let tokenV2 = session.tokenV2 { - await NotionSessionStore.shared.setSession(tokenV2: tokenV2, sourceLabel: session.sourceLabel) - } - CookieHeaderCache.store( - provider: .notion, - cookieHeader: session.cookieHeader, - sourceLabel: session.sourceLabel) - return snapshot - #else - throw NotionUsageError.noSessionCookie - #endif - } - - /// The per-call inputs that stay the same across every attempt a single `fetch` makes. - private struct FetchOptions { - let preferredSpaceID: String? - let timeout: TimeInterval - let logger: ((String) -> Void)? - let now: Date - let transport: any ProviderHTTPTransport - } - - private func runFetch( - context: RequestContext, - options: FetchOptions) async throws -> NotionUsageSnapshot - { - let (preferredSpaceID, timeout, logger, now, transport) = - (options.preferredSpaceID, options.timeout, options.logger, options.now, options.transport) - if let logger { - let names = CookieHeaderNormalizer.pairs(from: context.cookieHeader).map(\.name) - if !names.isEmpty { - logger("[notion] Cookie names: \(names.joined(separator: ", "))") - } - if !context.headers.isEmpty { - let headerNames = context.headers.keys.sorted().joined(separator: ", ") - logger("[notion] Forwarding captured headers: \(headerNames)") - } - } - let snapshot = try await Self.fetchUsage( - context: context, - preferredSpaceID: preferredSpaceID, - timeout: timeout, - now: now, - transport: transport) - if let workspace = snapshot.workspace { - logger?("[notion] Using workspace \(workspace.name ?? workspace.id) (\(workspace.id))") - } - return snapshot - } - - public func debugRawProbe( - cookieHeaderOverride: String? = nil, - preferredSpaceID: String? = nil) async -> String - { - let stamp = ISO8601DateFormatter().string(from: Date()) - var lines: [String] = [] - lines.append("=== Notion Debug Probe @ \(stamp) ===") - lines.append("") - - do { - let snapshot = try await self.fetch( - cookieHeaderOverride: cookieHeaderOverride, - preferredSpaceID: preferredSpaceID, - logger: { msg in lines.append(msg) }) - lines.append("") - lines.append("Fetch Success") - lines.append("workspace=\(snapshot.workspace?.name ?? "nil")") - lines.append("tier=\(snapshot.workspace?.subscriptionTier ?? "nil")") - lines.append("status=\(snapshot.rateLimit.status ?? "nil")") - lines.append("enforcement=\(snapshot.rateLimit.enforcement ?? "nil")") - lines.append("rollingWindow=\(snapshot.rateLimit.window?.window ?? "nil")") - lines.append("rollingUsed=\(snapshot.rateLimit.window?.used?.description ?? "nil")") - lines.append("rollingLimit=\(snapshot.rateLimit.window?.limit?.description ?? "nil")") - lines.append("resetsInSeconds=\(snapshot.rateLimit.resetsInSeconds?.description ?? "nil")") - lines.append("billingUsed=\(snapshot.rateLimit.billingPeriodWindow?.used?.description ?? "nil")") - lines.append("billingLimit=\(snapshot.rateLimit.billingPeriodWindow?.limit?.description ?? "nil")") - lines.append("periodEndMs=\(snapshot.rateLimit.billingPeriodWindow?.periodEndMs?.description ?? "nil")") - } catch { - lines.append("") - lines.append("Probe Failed: \(error.localizedDescription)") - } - - return lines.joined(separator: "\n") - } - - public static func fetchUsage( - cookieHeader: String, - preferredSpaceID: String? = nil, - timeout: TimeInterval = 15, - now: Date = Date(), - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> NotionUsageSnapshot - { - try await self.fetchUsage( - context: RequestContext(cookieHeader: cookieHeader), - preferredSpaceID: preferredSpaceID, - timeout: timeout, - now: now, - transport: transport) - } - - static func fetchUsage( - context: RequestContext, - preferredSpaceID: String?, - timeout: TimeInterval, - now: Date, - transport: any ProviderHTTPTransport) async throws -> NotionUsageSnapshot - { - guard context.isUsable else { - throw NotionUsageError.noSessionCookie - } - - let account = try await self.fetchAccount(context: context, timeout: timeout, transport: transport) - guard let workspace = account.resolveWorkspace(preferredID: preferredSpaceID) else { - throw NotionUsageError.noWorkspace - } - - let data = try await self.post( - endpoint: "getCreditRateLimitStatus", - body: ["spaceId": workspace.id], - context: context, - timeout: timeout, - transport: transport) - let status = try NotionUsageParser.parseRateLimitStatus(data) - - guard !status.isNotApplicable else { - throw NotionUsageError.allowanceNotApplicable(workspace: workspace.name) - } - - return NotionUsageSnapshot( - rateLimit: status, - workspace: workspace, - account: account, - updatedAt: now) - } - - static func fetchAccount( - context: RequestContext, - timeout: TimeInterval, - transport: any ProviderHTTPTransport) async throws -> NotionAccount - { - let data = try await self.post( - endpoint: "getSpaces", - body: [:], - context: context, - timeout: timeout, - transport: transport) - return try NotionUsageParser.parseSpaces(data) - } - - private static func post( - endpoint: String, - body: [String: String], - context: RequestContext, - timeout: TimeInterval, - transport: any ProviderHTTPTransport) async throws -> Data - { - guard let url = URL(string: "/api/v3/\(endpoint)", relativeTo: self.baseURL) else { - throw NotionUsageError.apiError("Failed to build \(endpoint) URL.") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.timeoutInterval = timeout - request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - self.applyDefaultHeaders(to: &request) - for (name, value) in context.headers { - request.setValue(value, forHTTPHeaderField: name) - } - request.setValue(self.baseURL.absoluteString, forHTTPHeaderField: "Origin") - request.setValue(context.cookieHeader, forHTTPHeaderField: "Cookie") - - let response = try await transport.response(for: request) - guard response.statusCode == 200 else { - let preview = String(data: response.data.prefix(200), encoding: .utf8) ?? "" - Self.log.error("Notion \(endpoint) returned \(response.statusCode): \(preview)") - if response.statusCode == 401 { - throw NotionUsageError.invalidCredentials - } - throw NotionUsageError.apiError("HTTP \(response.statusCode) from \(endpoint)") - } - return response.data - } - - static func requestContext(from raw: String?) -> RequestContext? { - guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } - let headerFields = CurlCaptureParser.headerFields(from: raw) - let headers = CurlCaptureParser.forwardedHeaders(from: headerFields, allowlist: self.forwardedManualHeaders) - guard let normalized = CookieHeaderNormalizer.normalize( - CurlCaptureParser.headerValue(named: "Cookie", in: headerFields) ?? raw) - else { return nil } - let cookieHeader = CookieHeaderNormalizer.pairs(from: normalized).isEmpty - ? "\(self.sessionCookieName)=\(normalized)" - : normalized - let context = RequestContext( - cookieHeader: cookieHeader, - headers: headers) - return context.isUsable ? context : nil - } - - private static func applyDefaultHeaders(to request: inout URLRequest) { - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("*/*", forHTTPHeaderField: "Accept") - request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") - request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") - request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") - request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") - request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") - request.setValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site") - } -} diff --git a/Sources/CodexBarCore/Providers/Notion/NotionUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Notion/NotionUsageSnapshot.swift deleted file mode 100644 index ccce666e42..0000000000 --- a/Sources/CodexBarCore/Providers/Notion/NotionUsageSnapshot.swift +++ /dev/null @@ -1,336 +0,0 @@ -import Foundation - -public enum NotionUsageError: LocalizedError, Sendable, Equatable { - case noSessionCookie - case cookieImportDeferred - case invalidCredentials - case noWorkspace - case allowanceNotApplicable(workspace: String?) - case apiError(String) - case parseFailed(String) - - public var errorDescription: String? { - switch self { - case .noSessionCookie: - "No Notion cookies found. Please log in to notion.com in your browser." - case .cookieImportDeferred: - "Notion cookies can only be read during a manual refresh. Refresh CodexBar once to import them." - case .invalidCredentials: - "Notion session cookie is invalid or expired." - case .noWorkspace: - "No Notion workspace found for this account." - case let .allowanceNotApplicable(workspace): - if let workspace { - "Notion AI usage allowance is not tracked for \"\(workspace)\". " + - "Allowances apply to Business and Enterprise workspaces." - } else { - "Notion AI usage allowance is not tracked for this workspace. " + - "Allowances apply to Business and Enterprise workspaces." - } - case let .apiError(message): - "Notion API error: \(message)" - case let .parseFailed(message): - "Could not parse Notion usage: \(message)" - } - } -} - -// MARK: - Account - -/// One workspace ("space") the signed-in account belongs to. -public struct NotionWorkspace: Sendable, Equatable { - public let id: String - public let name: String? - public let planType: String? - public let subscriptionTier: String? - - public init(id: String, name: String?, planType: String?, subscriptionTier: String?) { - self.id = id - self.name = name - self.planType = planType - self.subscriptionTier = subscriptionTier - } - - /// Only paid team plans carry a Notion AI usage allowance; free/personal spaces report `not_applicable`. - public var mayHaveAllowance: Bool { - switch self.subscriptionTier?.lowercased() { - case "business", "enterprise": true - default: false - } - } - - public var displayTier: String? { - guard let raw = self.subscriptionTier?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { - return nil - } - return raw.prefix(1).uppercased() + raw.dropFirst() - } -} - -public struct NotionAccount: Sendable, Equatable { - public let userID: String? - public let email: String? - public let name: String? - public let workspaces: [NotionWorkspace] - - public init(userID: String?, email: String?, name: String?, workspaces: [NotionWorkspace]) { - self.userID = userID - self.email = email - self.name = name - self.workspaces = workspaces - } - - /// Picks the workspace whose allowance we report: an explicit id when configured, otherwise the first - /// workspace on a plan that actually has an allowance, otherwise the first workspace at all. - public func resolveWorkspace(preferredID: String? = nil) -> NotionWorkspace? { - if let preferredID = Self.normalizeSpaceID(preferredID), - let match = self.workspaces.first(where: { Self.normalizeSpaceID($0.id) == preferredID }) - { - return match - } - // A configured id the account cannot see is almost always a typo. Querying it anyway only - // yields an opaque 403, so fall back to the workspace auto-selection would have picked. - return self.workspaces.first(where: \.mayHaveAllowance) ?? self.workspaces.first - } - - /// Notion accepts both dashed and undashed space ids; normalize to the dashed form the API returns. - static func normalizeSpaceID(_ raw: String?) -> String? { - guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { - return nil - } - let compact = trimmed.replacingOccurrences(of: "-", with: "").lowercased() - guard compact.count == 32, compact.allSatisfy(\.isHexDigit) else { return trimmed.lowercased() } - let chars = Array(compact) - let groups = [0..<8, 8..<12, 12..<16, 16..<20, 20..<32] - return groups.map { String(chars[$0]) }.joined(separator: "-") - } -} - -// MARK: - Rate limit payload - -public struct NotionRollingWindow: Decodable, Sendable, Equatable { - public let creditType: String? - public let scope: String? - public let window: String? - public let used: Double? - public let limit: Double? -} - -public struct NotionBillingPeriodWindow: Decodable, Sendable, Equatable { - public let creditType: String? - public let scope: String? - public let cadence: String? - public let used: Double? - public let limit: Double? - public let periodEndMs: Double? -} - -/// Response of `POST /api/v3/getCreditRateLimitStatus`. -public struct NotionCreditRateLimitStatus: Decodable, Sendable, Equatable { - public let status: String? - public let window: NotionRollingWindow? - public let resetsInSeconds: Double? - public let billingPeriodWindow: NotionBillingPeriodWindow? - public let enforcement: String? - - /// Notion returns this when the workspace plan has no allowance to report. - public var isNotApplicable: Bool { - self.status?.lowercased() == "not_applicable" - } -} - -// MARK: - Snapshot - -public struct NotionUsageSnapshot: Sendable { - public let rateLimit: NotionCreditRateLimitStatus - public let workspace: NotionWorkspace? - public let account: NotionAccount? - public let updatedAt: Date - - public init( - rateLimit: NotionCreditRateLimitStatus, - workspace: NotionWorkspace?, - account: NotionAccount?, - updatedAt: Date) - { - self.rateLimit = rateLimit - self.workspace = workspace - self.account = account - self.updatedAt = updatedAt - } - - public func toUsageSnapshot() -> UsageSnapshot { - // Only report a window we could actually measure. Fabricating 0% for a missing or - // unmeasurable window reads as "plenty of headroom" on a workspace that may be at its cap. - let primary: RateWindow? = self.rateLimit.window.flatMap { window in - Self.percent(used: window.used, limit: window.limit).map { percent in - RateWindow( - usedPercent: percent, - windowMinutes: Self.rollingMinutes(fromWindowToken: window.window), - resetsAt: Self.rollingReset(from: self.rateLimit.resetsInSeconds, now: self.updatedAt), - resetDescription: nil) - } - } - - let secondary: RateWindow? = self.rateLimit.billingPeriodWindow.flatMap { billing in - Self.percent(used: billing.used, limit: billing.limit).map { percent in - RateWindow( - usedPercent: percent, - // Notion reports only `periodEndMs`, so carry the shared monthly sentinel: it is what - // makes `ProviderPaceCapability.calendarMonthResetWindow` match, and resolution then - // replaces it with the real length of the calendar cycle ending at `resetsAt`. - // - // A nil length is not pace-safe on its own. `UsagePace.weekly` substitutes the - // caller's `defaultWindowMinutes` (7 days on every weekly path) rather than skipping - // the window, and the surfaces that do refuse a lengthless window drop it outright — - // so before the sentinel the monthly bar carried no estimate, and removing it now - // would score a billing period against a week. - windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, - resetsAt: Self.date(fromMilliseconds: billing.periodEndMs), - resetDescription: nil) - } - } - - let identity = ProviderIdentitySnapshot( - providerID: .notion, - accountEmail: self.account?.email, - accountOrganization: self.workspace?.name, - loginMethod: self.workspace?.displayTier, - accountID: self.account?.userID) - - return UsageSnapshot( - primary: primary, - secondary: secondary, - updatedAt: self.updatedAt, - identity: identity) - } - - /// Returns nil when the window carries no measurable allowance. A missing or non-positive limit - /// means "nothing to measure against", not "usage happens to equal this percentage" — the raw - /// credit count is on a different scale and would render as a wildly wrong gauge. - static func percent(used: Double?, limit: Double?) -> Double? { - guard let used, let limit, limit > 0 else { return nil } - return max(0, used / limit * 100) - } - - /// The rolling length, dropped when the token lands on the monthly sentinel. `30d` (and `720h`, - /// `43200m`) parses to exactly `monthlyWindowSentinelMinutes`, which pace matching keys on, so a - /// rolling window carrying one would be resolved as the calendar cycle ending at a reset that is - /// hours away. Reporting no length is wrong by less than mislabeling the window as a billing period. - static func rollingMinutes(fromWindowToken raw: String?) -> Int? { - guard let minutes = Self.minutes(fromWindowToken: raw), - minutes != ProviderPaceCapability.monthlyWindowSentinelMinutes - else { return nil } - return minutes - } - - /// Notion expresses the rolling window as a short token such as `6h`. - static func minutes(fromWindowToken raw: String?) -> Int? { - guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !raw.isEmpty else { - return nil - } - guard let unit = raw.last, let value = Int(raw.dropLast()), value > 0 else { return nil } - switch unit { - case "m": return value - case "h": return value * 60 - case "d": return value * 24 * 60 - case "w": return value * 7 * 24 * 60 - default: return nil - } - } - - /// Zero is a real answer — the window is resetting right now — so only negative values are dropped. - static func rollingReset(from seconds: Double?, now: Date) -> Date? { - guard let seconds, seconds >= 0 else { return nil } - return now.addingTimeInterval(seconds) - } - - static func date(fromMilliseconds raw: Double?) -> Date? { - guard let raw, raw > 0 else { return nil } - return Date(timeIntervalSince1970: raw / 1000) - } -} - -// MARK: - Parsing - -public enum NotionUsageParser { - public static func parseRateLimitStatus(_ data: Data) throws -> NotionCreditRateLimitStatus { - let status: NotionCreditRateLimitStatus - do { - status = try JSONDecoder().decode(NotionCreditRateLimitStatus.self, from: data) - } catch { - throw NotionUsageError.parseFailed(error.localizedDescription) - } - // Every field is optional, so an unrelated 200 body (an error envelope, or a changed shape) - // decodes cleanly into an all-nil status. Refuse it rather than reporting it as 0% used. - guard status.isNotApplicable || status.window != nil || status.billingPeriodWindow != nil else { - throw NotionUsageError.parseFailed("getCreditRateLimitStatus returned no usage windows.") - } - return status - } - - /// Parses `POST /api/v3/getSpaces`, which returns record maps keyed by user id and space id. - public static func parseSpaces(_ data: Data) throws -> NotionAccount { - guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw NotionUsageError.parseFailed("getSpaces response is not a JSON object.") - } - guard let userID = Self.resolveUserID(in: root), let container = root[userID] as? [String: Any] else { - throw NotionUsageError.parseFailed("getSpaces response did not identify a single user.") - } - - var email: String? - var name: String? - if let users = container["notion_user"] as? [String: Any] { - let record = users[userID].flatMap(Self.unwrapRecord) ?? users.values.compactMap(Self.unwrapRecord).first - email = record?["email"] as? String - name = record?["name"] as? String - } - - var workspaces: [NotionWorkspace] = [] - if let spaces = container["space"] as? [String: Any] { - for key in spaces.keys.sorted() { - guard let record = spaces[key].flatMap(Self.unwrapRecord) else { continue } - workspaces.append(NotionWorkspace( - id: (record["id"] as? String) ?? key, - name: record["name"] as? String, - planType: record["plan_type"] as? String, - subscriptionTier: record["subscription_tier"] as? String)) - } - } - - return NotionAccount(userID: userID, email: email, name: name, workspaces: workspaces) - } - - /// The payload is a record map keyed by user id. Pick the key whose own `notion_user` record - /// identifies it, rather than trusting key order, and refuse an ambiguous response outright — - /// binding to the wrong key would report another account's allowance under this account's email. - private static func resolveUserID(in root: [String: Any]) -> String? { - let identified = root.keys.filter { key in - guard let container = root[key] as? [String: Any], - let users = container["notion_user"] as? [String: Any], - let record = users[key].flatMap(Self.unwrapRecord) - else { - return false - } - return record["id"] as? String == key - } - if identified.count == 1 { - return identified.first - } - // Older responses omit the self-identifying id; a single-key payload is still unambiguous. - if identified.isEmpty, root.count == 1 { - return root.keys.first - } - return nil - } - - /// Records arrive as `{"value": {...}}` or, on newer responses, `{"value": {"value": {...}}}`. - private static func unwrapRecord(_ raw: Any) -> [String: Any]? { - guard let outer = raw as? [String: Any] else { return nil } - guard let value = outer["value"] as? [String: Any] else { return outer } - if let inner = value["value"] as? [String: Any] { - return inner - } - return value - } -} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift deleted file mode 100644 index 26914db789..0000000000 --- a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -#if canImport(CryptoKit) -import CryptoKit -#else -import Crypto -#endif - -/// Process-lifetime, in-memory cache of freshly-minted ZoomMate bearer JWTs. -/// -/// The `.auto` cookie-mint path exchanges long-lived browser session cookies for a short-lived -/// (~hourly) bearer JWT on demand. Without a cache that mint happens on *every* refresh; this cache -/// lets a still-valid token be reused across refreshes instead. -/// -/// Safety properties (why reuse can't serve a bad token): -/// - Entries are keyed by a non-reversible SHA-256 of the originating host-scoped cookie headers, so distinct -/// browser sessions / accounts never collide and the raw cookies are never stored as a key. -/// - A token is cached *only* when its JWT carries a decodable `exp` claim, and is served only -/// while `now < exp - refreshSkew`. A token whose expiry cannot be determined is never cached -/// (the caller mints fresh), so the cache can never hand back a token past its own expiry. -/// - Nothing is persisted — the cache is empty on every launch. -/// -/// A revoked-before-expiry session is handled by the caller: a `401/403` from a downstream request -/// evicts the entry (see `ZoomMateWebFetchStrategy`) so the next refresh mints fresh. -actor ZoomMateBearerTokenCache { - static let shared = ZoomMateBearerTokenCache() - - /// Refresh this many seconds before the JWT's own `exp`, so an in-flight request never rides a - /// token that expires mid-flight. - static let refreshSkew: TimeInterval = 60 - - struct Entry: Sendable { - let token: String - let accountEmail: String? - let expiry: Date - } - - private var entries: [String: Entry] = [:] - - /// Non-reversible cache key for a cookie session. SHA-256 hex of its canonical host map. - static func key(forCookieHeaders cookieHeaders: ZoomMateCookieHeaders) -> String { - let canonical = cookieHeaders.encodedForStorage() ?? "" - let digest = SHA256.hash(data: Data(canonical.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() - } - - /// Returns the cached entry for `key` when it is still comfortably in-date, evicting and - /// returning `nil` once it enters the `refreshSkew` window (or has passed `exp`). - func validEntry(forKey key: String, now: Date) -> Entry? { - guard let entry = self.entries[key] else { return nil } - guard entry.expiry.addingTimeInterval(-Self.refreshSkew) > now else { - self.entries[key] = nil - return nil - } - return entry - } - - func store(_ entry: Entry, forKey key: String) { - self.entries[key] = entry - } - - func invalidate(forKey key: String) { - self.entries[key] = nil - } -} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift deleted file mode 100644 index d00482dc56..0000000000 --- a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift +++ /dev/null @@ -1,142 +0,0 @@ -import Foundation -#if os(macOS) -import SweetCookieKit -#endif - -/// Cookie headers narrowed to ZoomMate's fixed request hosts. Keeping the destination in the -/// credential value makes it impossible for host failover to reuse a leaf-host cookie on its -/// sibling host. -public struct ZoomMateCookieHeaders: Codable, Equatable, Sendable { - static let allowedHosts = ["ai.zoom.us", "zoommate.zoom.us"] - - private let headersByHost: [String: String] - - public init(headersByHost: [String: String]) { - self.headersByHost = Dictionary(uniqueKeysWithValues: Self.allowedHosts.compactMap { host in - guard let header = headersByHost[host]?.trimmingCharacters(in: .whitespacesAndNewlines), - !header.isEmpty - else { - return nil - } - return (host, header) - }) - } - - public func header(forHost host: String) -> String? { - self.headersByHost[host.lowercased()] - } - - public var isEmpty: Bool { - self.headersByHost.isEmpty - } - - func encodedForStorage() -> String? { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - guard let data = try? encoder.encode(self) else { return nil } - return String(data: data, encoding: .utf8) - } - - static func decodeFromStorage(_ value: String) -> Self? { - guard let data = value.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(Self.self, from: data) - } -} - -#if os(macOS) -private let zoomMateCookieImportOrder: BrowserCookieImportOrder = - ProviderDefaults.metadata[.zoommate]?.browserCookieOrder ?? Browser.defaultImportOrder - -/// Imports ZoomMate's browser session cookies (not the bearer JWT itself — see -/// `ZoomMateUsageFetcher.mintBearerToken`, which exchanges these cookies for a fresh JWT via -/// ZoomMate's own cookie-to-token bootstrap endpoint). Modeled on `T3ChatCookieImporter`. -public enum ZoomMateCookieImporter { - private static let cookieClient = BrowserCookieClient() - /// Includes the parent "zoom.us" domain — ZoomMate's SSO session cookies (`_zm_*`, - /// `cf_clearance`, etc.) are scoped to the shared parent domain, not the leaf subdomains, and - /// domain matching here is substring-based (`.contains`), so this one pattern also matches the - /// leaf domains below; both are kept for clarity. The broad read is narrowed per destination - /// using each record's explicit browser scope. - private static let cookieDomains = ["zoommate.zoom.us", "ai.zoom.us", "zoom.us"] - - public struct SessionInfo: Sendable { - public let cookieHeaders: ZoomMateCookieHeaders - public let sourceLabel: String - - public init(cookieHeaders: ZoomMateCookieHeaders, sourceLabel: String) { - self.cookieHeaders = cookieHeaders - self.sourceLabel = sourceLabel - } - } - - public static func importSession( - browserDetection: BrowserDetection, - logger: (@Sendable (String) -> Void)? = nil) throws -> SessionInfo - { - try self.importSessions(browserDetection: browserDetection, logger: logger)[0] - } - - public static func importSessions( - browserDetection: BrowserDetection, - logger: (@Sendable (String) -> Void)? = nil) throws -> [SessionInfo] - { - let log: @Sendable (String) -> Void = { msg in logger?("[zoommate-cookie] \(msg)") } - let installed = zoomMateCookieImportOrder.cookieImportCandidates(using: browserDetection) - var sessions: [SessionInfo] = [] - - for browserSource in installed { - do { - let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try self.cookieClient.codexBarRecords( - matching: query, - in: browserSource, - logger: log) - for source in sources where !source.records.isEmpty { - let cookieHeaders = Self.cookieHeaders(from: source.records) - guard !cookieHeaders.isEmpty else { continue } - log("\(source.label): found host-scoped cookie headers") - sessions.append(SessionInfo(cookieHeaders: cookieHeaders, sourceLabel: source.label)) - } - } catch { - BrowserCookieAccessGate.recordIfNeeded(error) - log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") - } - } - - guard !sessions.isEmpty else { throw ZoomMateUsageError.noSession } - return sessions - } - - /// Whether a browser would attach a cookie to `host`, per RFC 6265 domain-matching. Scope is - /// carried separately because Chromium normalizes `.zoom.us` and `zoom.us` to the same domain - /// string when records become `HTTPCookie` values. - static func isSendable(cookieDomain: String, scope: BrowserCookieScope, toHost host: String) -> Bool { - let normalizedDomain = cookieDomain - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingPrefix(".") - .lowercased() - let normalizedHost = host.lowercased() - guard ZoomMateCookieHeaders.allowedHosts.contains(normalizedHost), !normalizedDomain.isEmpty else { - return false - } - switch scope { - case .hostOnly: - return normalizedHost == normalizedDomain - case .domain: - return normalizedHost == normalizedDomain || normalizedHost.hasSuffix("." + normalizedDomain) - } - } - - static func cookieHeaders(from records: [BrowserCookieRecord]) -> ZoomMateCookieHeaders { - let pairs: [(String, String)] = ZoomMateCookieHeaders.allowedHosts.compactMap { host in - let sendable = records.filter { - Self.isSendable(cookieDomain: $0.domain, scope: $0.scope, toHost: host) - } - guard !sendable.isEmpty else { return nil } - let header = sendable.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") - return (host, header) - } - return ZoomMateCookieHeaders(headersByHost: Dictionary(uniqueKeysWithValues: pairs)) - } -} -#endif diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift deleted file mode 100644 index 35953c08ed..0000000000 --- a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift +++ /dev/null @@ -1,247 +0,0 @@ -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -/// One raw ledger row from `GET .../credits/history` (design.md D3). `time` is ISO8601-shaped; -/// `cost` is the credits consumed by that session/task run. -public struct ZoomMateCreditHistoryRecord: Decodable, Sendable { - public let sessionID: String? - public let title: String? - public let cost: Double? - public let time: String? - public let isRunning: Bool? - public let isDeleted: Bool? - - private enum CodingKeys: String, CodingKey { - case sessionID = "session_id" - case title - case cost - case time - case isRunning = "is_running" - case isDeleted = "is_deleted" - } - - public init( - sessionID: String?, - title: String?, - cost: Double?, - time: String?, - isRunning: Bool?, - isDeleted: Bool?) - { - self.sessionID = sessionID - self.title = title - self.cost = cost - self.time = time - self.isRunning = isRunning - self.isDeleted = isDeleted - } -} - -/// Aggregated result of fetching `credits/history` across as many pages as needed to cover the -/// requested window. Kept separate from the daily-bucketed breakdown so the same raw records can -/// be re-aggregated without refetching. -/// -/// `creditStatus` carries the `credits/status` snapshot the history fetch was paired with, so -/// the menu layer can compute the pacing verdict (`ZoomMateUsageSnapshot.pacingVerdict`) directly -/// from this one attached object instead of needing a second field on `UsageSnapshot` — deferring -/// pace computation to render time also means it always reflects "now," not the last fetch time. -public struct ZoomMateCreditsHistorySnapshot: Sendable { - public let records: [ZoomMateCreditHistoryRecord] - public let creditStatus: ZoomMateCreditStatus? - public let updatedAt: Date - - public init( - records: [ZoomMateCreditHistoryRecord], - creditStatus: ZoomMateCreditStatus? = nil, - updatedAt: Date) - { - self.records = records - self.creditStatus = creditStatus - self.updatedAt = updatedAt - } - - /// Pacing verdict computed from the paired `credits/status` snapshot, if one was attached at - /// fetch time. `nil` when no `creditStatus` is available (e.g. it wasn't passed to `fetch`) - /// or when the account is unlimited / missing cycle dates — see - /// `ZoomMateCreditStatus.pacingVerdict`. - public func pacingVerdict(now: Date = Date()) -> UsagePace? { - self.creditStatus?.pacingVerdict(now: now) - } -} - -/// Fetches and paginates `GET https://ai.zoom.us/ai-computer/api/v1/credits/history` (design.md -/// D3). Reuses the same minted-bearer `RequestContext` as `credits/status` — no separate auth -/// mechanism. `app_id` is confirmed not a scoping filter (D3/R2), so a fixed placeholder matching -/// ZoomMate's own web UI (`demo_app`) is sent on every request. -public struct ZoomMateCreditsHistoryFetcher: Sendable { - private static let log = CodexBarLog.logger(LogCategories.provider(.zoommate)) - private static let historyPath = "/ai-computer/api/v1/credits/history" - private static let refererURL = URL(string: "https://zoommate.zoom.us")! - private static let userAgent = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" - - /// Confirmed cheap for real accounts (design.md D3/R3): 30 days of history is at most a - /// couple of pages at this size, well below any practical rate-limit concern. A larger - /// `limit` than the web UI's `10` reduces round-trips without meaningfully increasing - /// payload size (records are small). - public static let defaultPageLimit = 50 - /// Hard ceiling on pagination requests per fetch, independent of the account's actual - /// history size — guards against an unexpectedly large or misbehaving account/response - /// (e.g. a `total` that never gets satisfied) turning into an unbounded fetch loop. - public static let maxPages = 20 - - public init() {} - - /// Fetches every record whose `time` falls within `[startTime, endTime]`, paginating with - /// `limit`/`page` until the endpoint's flat `total` count is satisfied (no other pagination - /// metadata exists — design.md D3). - public static func fetch( - context: ZoomMateUsageFetcher.RequestContext, - startTime: Date, - endTime: Date, - creditStatus: ZoomMateCreditStatus? = nil, - limit: Int = ZoomMateCreditsHistoryFetcher.defaultPageLimit, - timeout: TimeInterval = 15, - now: Date = Date(), - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateCreditsHistorySnapshot - { - // The whole pagination loop fails over as a unit so all pages of one snapshot come from - // the same host. - try await ZoomMateUsageFetcher.withAPIHostFailover( - hosts: ZoomMateUsageFetcher.hosts(preferred: context.preferredHost)) - { host in - var allRecords: [ZoomMateCreditHistoryRecord] = [] - var page = 0 - var total = Int.max - - while page * limit < total, page < self.maxPages { - let request = PageRequest( - host: host, - context: context, - startTime: startTime, - endTime: endTime, - limit: limit, - page: page, - timeout: timeout, - transport: transport) - let envelope = try await self.fetchPage(request) - guard let data = envelope.data else { - throw ZoomMateUsageError.parseFailed("Missing data object in credits/history response.") - } - let pageRecords = data.records ?? [] - allRecords.append(contentsOf: pageRecords) - total = data.total ?? allRecords.count - if pageRecords.isEmpty { - // Defensive: stop if the server ever returns an empty page before `total` is - // reached, rather than looping until `maxPages`. - break - } - // Defensive date-boundary stop (design.md D2): `total` reflects the account's entire - // history, not just the requested window, so a server-side filtering quirk could - // otherwise cause extra pagination past what the window actually needs. If every - // record on this page is already older than the requested `startTime` (rows are - // sorted `time desc`, so an entirely-stale page means all subsequent pages are stale - // too), stop here rather than trusting `total`/`maxPages` to eventually end the loop. - let allOlderThanWindow = pageRecords.allSatisfy { record in - guard let parsed = ISO8601DateParser.parse(record.time) else { return false } - return parsed < startTime - } - if allOlderThanWindow { - break - } - page += 1 - } - - return ZoomMateCreditsHistorySnapshot(records: allRecords, creditStatus: creditStatus, updatedAt: now) - } - } - - private static func fetchPage(_ pageRequest: PageRequest) async throws -> HistoryEnvelope { - var components = URLComponents(string: "https://\(pageRequest.host)\(self.historyPath)")! - components.queryItems = [ - URLQueryItem(name: "app_id", value: "demo_app"), - URLQueryItem(name: "limit", value: String(pageRequest.limit)), - URLQueryItem(name: "page", value: String(pageRequest.page)), - URLQueryItem(name: "sort_by", value: "time"), - URLQueryItem(name: "sort_order", value: "desc"), - URLQueryItem(name: "start_time", value: Self.iso8601String(pageRequest.startTime)), - URLQueryItem(name: "end_time", value: Self.iso8601String(pageRequest.endTime)), - ] - guard let url = components.url else { - throw ZoomMateUsageError.apiError("Failed to build credits/history URL.") - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.timeoutInterval = pageRequest.timeout - request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") - request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") - request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") - request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") - request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") - request.setValue("same-site", forHTTPHeaderField: "Sec-Fetch-Site") - for (name, value) in pageRequest.context.headers { - request.setValue(value, forHTTPHeaderField: name) - } - request.setValue( - pageRequest.context.cookieHeaders.header(forHost: pageRequest.host), - forHTTPHeaderField: "Cookie") - request.setValue(pageRequest.context.authorization, forHTTPHeaderField: "Authorization") - request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") - request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") - - let response = try await pageRequest.transport.response(for: request) - let data = response.data - guard response.statusCode == 200 else { - Self.log.error("ZoomMate credits/history returned \(response.statusCode)") - if response.statusCode == 401 || response.statusCode == 403 { - throw ZoomMateUsageError.invalidCredentials - } - throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") - } - - do { - return try JSONDecoder().decode(HistoryEnvelope.self, from: data) - } catch { - Self.log.error("ZoomMate credits/history parse failed") - throw ZoomMateUsageError.parseFailed(error.localizedDescription) - } - } - - private static func iso8601String(_ date: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - return formatter.string(from: date) - } - - private struct PageRequest { - let host: String - let context: ZoomMateUsageFetcher.RequestContext - let startTime: Date - let endTime: Date - let limit: Int - let page: Int - let timeout: TimeInterval - let transport: any ProviderHTTPTransport - } - - private struct HistoryEnvelope: Decodable { - struct DataBox: Decodable { - let records: [ZoomMateCreditHistoryRecord]? - let total: Int? - } - - let data: DataBox? - let statusCode: Int? - let errorMessage: String? - - private enum CodingKeys: String, CodingKey { - case data - case statusCode = "status_code" - case errorMessage = "error_message" - } - } -} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift deleted file mode 100644 index f93cdde1d4..0000000000 --- a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift +++ /dev/null @@ -1,284 +0,0 @@ -import Foundation - -private func zoomMateDate(fromMilliseconds raw: Int64?) -> Date? { - guard let raw, raw > 0 else { return nil } - return Date(timeIntervalSince1970: Double(raw) / 1000) -} - -public enum ZoomMateUsageError: LocalizedError, Sendable { - case noCapture - case noSession - case invalidCredentials - case apiError(String) - case parseFailed(String) - - public var errorDescription: String? { - switch self { - case .noCapture: - "Paste a cURL capture of the HTTPS ZoomMate credits/status request " + - "(from ai.zoom.us or zoommate.zoom.us)." - case .noSession: - "No ZoomMate session is cached and no session cookies were imported from Chrome. " + - "Sign in to zoommate.zoom.us in Chrome and refresh from CodexBar, or paste a cURL capture." - case .invalidCredentials: - "ZoomMate rejected the current credentials. Sign in again in Chrome or paste a fresh cURL capture." - case let .apiError(message): - "ZoomMate API error: \(message)" - case let .parseFailed(message): - "Could not parse ZoomMate usage: \(message)" - } - } -} - -/// Decoded shape of `data.credit_status` from -/// `GET https://ai.zoom.us/ai-computer/api/v1/credits/status`. Dates are epoch milliseconds. -public struct ZoomMateCreditStatus: Decodable, Sendable { - public let budgetCap: Double? - public let usedCredit: Double? - public let remainingCredit: Double? - public let overageCredit: Double? - public let allowOverage: Bool? - public let cycleStartDate: Int64? - public let cycleEndDate: Int64? - public let isQuotaAvailable: Bool? - public let isUnlimited: Bool? - - private enum CodingKeys: String, CodingKey { - case budgetCap = "budget_cap" - case usedCredit = "used_credit" - case remainingCredit = "remaining_credit" - case overageCredit = "overage_credit" - case allowOverage = "allow_overage" - case cycleStartDate = "cycle_start_date" - case cycleEndDate = "cycle_end_date" - case isQuotaAvailable = "is_quota_available" - case isUnlimited = "is_unlimited" - } - - public init( - budgetCap: Double?, - usedCredit: Double?, - remainingCredit: Double?, - overageCredit: Double?, - allowOverage: Bool?, - cycleStartDate: Int64?, - cycleEndDate: Int64?, - isQuotaAvailable: Bool?, - isUnlimited: Bool?) - { - self.budgetCap = budgetCap - self.usedCredit = usedCredit - self.remainingCredit = remainingCredit - self.overageCredit = overageCredit - self.allowOverage = allowOverage - self.cycleStartDate = cycleStartDate - self.cycleEndDate = cycleEndDate - self.isQuotaAvailable = isQuotaAvailable - self.isUnlimited = isUnlimited - } -} - -public struct ZoomMateUsageSnapshot: Sendable { - public let creditStatus: ZoomMateCreditStatus - public let updatedAt: Date - - public init(creditStatus: ZoomMateCreditStatus, updatedAt: Date) { - self.creditStatus = creditStatus - self.updatedAt = updatedAt - } - - /// Implements design D5's credits mapping. `history` is optional and attached only when a - /// `credits/history` fetch succeeded (design.md D3) — its absence never blocks the primary - /// credits/status snapshot from being usable. - public func toUsageSnapshot( - history: ZoomMateCreditsHistorySnapshot? = nil, - accountEmail: String? = nil) -> UsageSnapshot - { - let budgetCap = self.creditStatus.budgetCap ?? 0 - let usedCredit = self.creditStatus.usedCredit ?? 0 - let isUnlimited = self.creditStatus.isUnlimited ?? false - - let usedPercent: Double = if isUnlimited || budgetCap <= 0 { - 0 - } else { - min(100, max(0, usedCredit / budgetCap * 100)) - } - - let resetsAt: Date? = (isUnlimited || budgetCap <= 0) - ? nil - : zoomMateDate(fromMilliseconds: self.creditStatus.cycleEndDate) - - let primary = RateWindow( - usedPercent: usedPercent, - windowMinutes: nil, - resetsAt: resetsAt, - resetDescription: "Credits") - - let identity = ProviderIdentitySnapshot( - providerID: .zoommate, - accountEmail: accountEmail, - accountOrganization: nil, - loginMethod: accountEmail != nil ? "Cookie" : nil) - - let breakdown = history?.dailyBreakdown(now: self.updatedAt) ?? [] - var detailRows: [ProviderDetailSection.Row] = [] - if let history { - let today = history.todayCreditsUsed(now: self.updatedAt) ?? 0 - let total = breakdown.reduce(0) { $0 + $1.totalCreditsUsed } - detailRows.append(.makeRow(label: "Today", value: Self.creditsString(today))) - detailRows.append(.makeRow(label: "30d credits", value: Self.creditsString(total))) - if let pace = history.pacingVerdict(now: self.updatedAt) { - detailRows.append(.makeRow(label: "Pace", value: Self.paceString(pace))) - } - } - - return UsageSnapshot( - primary: primary, - secondary: nil, - details: history.map { _ in - [.makeSection( - title: "Credit history", - rows: detailRows, - chart: breakdown.isEmpty ? nil : .makeChart( - title: "Daily credits", - unit: "credits", - points: breakdown.map { ($0.day, $0.totalCreditsUsed) }))] - } ?? [], - updatedAt: self.updatedAt, - identity: identity) - } - - private static func creditsString(_ value: Double) -> String { - value.formatted(.number.precision(.fractionLength(0...2))) - } - - private static func paceString(_ pace: UsagePace) -> String { - let delta = Int(abs(pace.deltaPercent).rounded()) - switch pace.stage { - case .onTrack: - return "On track" - case .slightlyAhead, .ahead, .farAhead: - return delta == 0 ? "Ahead of budget" : "\(delta)% ahead of budget" - case .slightlyBehind, .behind, .farBehind: - return delta == 0 ? "Behind budget" : "\(delta)% behind budget" - } - } - - /// Pacing verdict (design.md D3), delegated to `ZoomMateCreditStatus.pacingVerdict` so both - /// this snapshot and `ZoomMateCreditsHistorySnapshot` (which carries its own paired - /// `creditStatus`) can compute the identical verdict without duplicating the algorithm. - public func pacingVerdict(now: Date = Date()) -> UsagePace? { - self.creditStatus.pacingVerdict(now: now) - } -} - -extension ZoomMateCreditStatus { - /// Pacing verdict (design.md D3): reuses `UsagePace`'s generic stage thresholds rather than - /// reinventing them. ZoomMate's billing cycle has an arbitrary length (not a fixed weekly - /// cadence), so `windowMinutes` is set to the actual cycle duration in minutes — with - /// `workDays: nil`, `UsagePace.weekly()`'s workday-aware branch never engages and it reduces - /// to a plain linear elapsed-fraction-of-cycle comparison, which is exactly what's needed - /// here despite the "weekly" name. - public func pacingVerdict(now: Date = Date()) -> UsagePace? { - guard let budgetCap, budgetCap > 0, - self.isUnlimited != true, - let cycleStartMillis = self.cycleStartDate, - let cycleEndMillis = self.cycleEndDate - else { - return nil - } - guard let cycleStart = zoomMateDate(fromMilliseconds: cycleStartMillis), - let cycleEnd = zoomMateDate(fromMilliseconds: cycleEndMillis), - cycleEnd > cycleStart - else { - return nil - } - - let usedCredit = self.usedCredit ?? 0 - let usedPercent = min(100, max(0, usedCredit / budgetCap * 100)) - let cycleMinutes = Int(cycleEnd.timeIntervalSince(cycleStart) / 60) - guard cycleMinutes > 0 else { return nil } - - let window = RateWindow( - usedPercent: usedPercent, - windowMinutes: cycleMinutes, - resetsAt: cycleEnd, - resetDescription: "Credits") - return UsagePace.weekly(window: window, now: now, workDays: nil) - } -} - -/// One calendar day's total credit consumption, aggregated from raw `credits/history` ledger -/// records. Mirrors `OpenAIDashboardDailyBreakdown`'s shape (`day` as a local `yyyy-MM-dd` key) -/// so the same day-key parsing/formatting used by the Codex credits-history chart applies here -/// unchanged. -public struct ZoomMateCreditDailyBreakdown: Equatable, Sendable { - /// Day key in `yyyy-MM-dd` (local time). - public let day: String - public let totalCreditsUsed: Double - - public init(day: String, totalCreditsUsed: Double) { - self.day = day - self.totalCreditsUsed = totalCreditsUsed - } -} - -extension ZoomMateCreditsHistorySnapshot { - /// Aggregates raw `credits/history` records into a Today/N-day series, one entry per - /// calendar day (local time) that has at least one qualifying record. `is_deleted` records - /// are excluded per design.md D3 (they represent removed sessions, not real spend); running - /// sessions (`is_running == true`) are still counted since their `cost` reflects consumption - /// so far. Records with an unparseable `time` or a negative `cost` are skipped defensively - /// rather than corrupting the aggregate. - /// - /// Records older than a trailing 30-calendar-day window from `now` are excluded before - /// bucketing, mirroring `CostUsageFetcher`'s `since = now - (historyDays - 1)` boundary - /// (design.md D3). This makes the 30-day window an explicit, model-level guarantee rather - /// than an implicit assumption inherited from the fetcher's request parameters — the result - /// stays calendar-bounded even if the fetch window, caching, or pagination ever changes. - public func dailyBreakdown(calendar: Calendar = .current, now: Date = Date()) -> [ZoomMateCreditDailyBreakdown] { - var totalsByDay: [String: Double] = [:] - let dayKeyFormatter = DateFormatter() - dayKeyFormatter.calendar = calendar - dayKeyFormatter.timeZone = calendar.timeZone - dayKeyFormatter.dateFormat = "yyyy-MM-dd" - - let isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let isoFormatterNoFraction = ISO8601DateFormatter() - isoFormatterNoFraction.formatOptions = [.withInternetDateTime] - - // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. - let since = calendar.date(byAdding: .day, value: -29, to: now) ?? now - - for record in self.records { - guard record.isDeleted != true else { continue } - guard let cost = record.cost, cost >= 0 else { continue } - guard let timeString = record.time else { continue } - guard let date = isoFormatter.date(from: timeString) ?? isoFormatterNoFraction.date(from: timeString) - else { - continue - } - guard date >= calendar.startOfDay(for: since) else { continue } - let dayKey = dayKeyFormatter.string(from: date) - totalsByDay[dayKey, default: 0] += cost - } - - return totalsByDay - .map { ZoomMateCreditDailyBreakdown(day: $0.key, totalCreditsUsed: $0.value) } - .sorted { $0.day < $1.day } - } - - /// Sum of `cost` for whichever calendar day (local time) is "today" relative to `now`, i.e. - /// the current-day bucket from `dailyBreakdown()` if one exists. Used by the inline Today/30d - /// KPI tiles (tasks.md 3.4 follow-up) so the UI layer doesn't need to re-derive day-key - /// formatting itself. - public func todayCreditsUsed(now: Date = Date(), calendar: Calendar = .current) -> Double? { - let dayKeyFormatter = DateFormatter() - dayKeyFormatter.calendar = calendar - dayKeyFormatter.timeZone = calendar.timeZone - dayKeyFormatter.dateFormat = "yyyy-MM-dd" - let todayKey = dayKeyFormatter.string(from: now) - return self.dailyBreakdown(calendar: calendar, now: now).first { $0.day == todayKey }?.totalCreditsUsed - } -} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift index 62102df00d..c52f736fe4 100644 --- a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift @@ -55,7 +55,9 @@ public enum ZoomMateProviderDescriptor { noDataMessage: { "ZoomMate cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ZoomMateWebFetchStrategy()] })), + pipeline: ProviderFetchPipeline(resolveStrategies: { context in + [Self.webStrategy(timeout: context.webTimeout)] + })), cli: ProviderCLIConfig( name: "zoommate", aliases: [], @@ -63,114 +65,72 @@ public enum ZoomMateProviderDescriptor { } } -/// Single unified strategy (modeled on `T3ChatWebFetchStrategy`) branching internally on the -/// selected `cookieSource`: `.auto` resolves a cookie session — the `CookieHeaderCache`d host map -/// first, else a fresh browser import whose validated headers are persisted back through the cache — -/// and mints a bearer JWT via `ZoomMateUsageFetcher.mintBearerToken`, reusing a still-valid token -/// from `ZoomMateBearerTokenCache` across refreshes; `.manual` uses the pasted cURL capture. -/// Cookies outlive the ~hourly JWT by weeks, so minting from cookies (and caching the result until -/// it nears expiry) avoids the manual re-paste entirely as long as the underlying browser session -/// stays valid, and the persisted headers let background refreshes and the bundled CLI reuse that -/// session without rereading Chrome. A rejected session clears the cached header and retries once -/// with a fresh import (see `fetch`). -struct ZoomMateWebFetchStrategy: ProviderFetchStrategy { - let id: String = "zoommate.web" - let kind: ProviderFetchKind = .web +extension ZoomMateProviderDescriptor { + static let hosts = ["ai.zoom.us", "zoommate.zoom.us"] - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - let cookieSource = context.settings?.zoommate?.cookieSource ?? .auto - guard cookieSource != .off else { return false } - if cookieSource == .manual { - return true - } - #if os(macOS) - return true - #else - return false - #endif + static func capture(_ raw: String?) -> (host: String, headers: [String: String])? { + guard let raw, let url = CurlCaptureParser.requestURL(from: raw), let host = url.host?.lowercased(), + hosts.contains(host), url.scheme?.lowercased() == "https", url.port == nil, + url.user == nil, url.password == nil, url.query == nil, url.fragment == nil, + url.path == "/ai-computer/api/v1/credits/status" else { return nil } + let fields = CurlCaptureParser.headerFields(from: raw) + let names = [ + "authorization", + "cookie", + "user-agent", + "accept", + "accept-language", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + ] + let headers = CurlCaptureParser.forwardedHeaders( + from: fields, allowlist: Dictionary(uniqueKeysWithValues: names.map { ($0, $0) })) + guard headers["authorization"]?.isEmpty == false else { return nil } + return (host, headers) } - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - let cookieSource = context.settings?.zoommate?.cookieSource ?? .auto - do { - return try await self.fetchOnce(context, allowCachedCookieHeader: true) - } catch ZoomMateUsageError.invalidCredentials where cookieSource == .auto { - // The persisted cookie session (or a bearer minted from it) was rejected. Drop the - // cached headers and retry once against a fresh browser import, mirroring - // OpenCodeUsageFetchStrategy. Outside user-initiated contexts the import is - // gate-blocked, so the retry surfaces `noSession` instead of replaying a dead cookie. - CookieHeaderCache.clear(provider: .zoommate) - return try await self.fetchOnce(context, allowCachedCookieHeader: false) - } - } - - private func fetchOnce( - _ context: ProviderFetchContext, - allowCachedCookieHeader: Bool) async throws -> ProviderFetchResult + static func webStrategy( + timeout: TimeInterval = 15, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) -> ScriptFetchStrategy { - let fetcher = ZoomMateUsageFetcher(browserDetection: context.browserDetection) - let manual = Self.manualCookieHeader(from: context) - let logger: (@Sendable (String) -> Void)? = context.verbose - ? { @Sendable msg in CodexBarLog.logger(LogCategories.provider(.zoommate)).verbose(msg) } - : nil - let requestContext = try await fetcher.resolveRequestContext( - manualCaptureOverride: manual, - allowCachedCookieHeader: allowCachedCookieHeader, - timeout: context.webTimeout, - logger: logger) - let snapshot: ZoomMateUsageSnapshot - do { - snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( - context: requestContext, - timeout: context.webTimeout) - } catch ZoomMateUsageError.invalidCredentials { - // A reused cached bearer token was rejected (revoked session before its own expiry). - // Evict it so the next refresh mints fresh rather than replaying the dead token. - await Self.invalidateCachedBearerToken(for: requestContext) - throw ZoomMateUsageError.invalidCredentials - } - - // The Today/30-day history chart (design.md D3) is a non-fatal adjunct: a failure here - // (e.g. a transient credits/history error) must never block the primary credits/status - // snapshot from being usable, mirroring ZaiUsageStats.fetchUsageWithModelUsage's - // secondary-fetch pattern. - var history: ZoomMateCreditsHistorySnapshot? - do { - let now = Date() - let startTime = Calendar.current.date(byAdding: .day, value: -30, to: now) ?? now - history = try await ZoomMateCreditsHistoryFetcher.fetch( - context: requestContext, - startTime: startTime, - endTime: now, - creditStatus: snapshot.creditStatus, - timeout: context.webTimeout) - } catch ZoomMateUsageError.invalidCredentials { - await Self.invalidateCachedBearerToken(for: requestContext) - CodexBarLog.logger(LogCategories.provider(.zoommate)) - .info("ZoomMate credits history fetch failed (non-fatal): invalid credentials") - history = nil - } catch { - CodexBarLog.logger(LogCategories.provider(.zoommate)) - .info("ZoomMate credits history fetch failed (non-fatal): \(error.localizedDescription)") - history = nil - } - - return self.makeResult( - usage: snapshot.toUsageSnapshot(history: history, accountEmail: requestContext.accountEmail), - sourceLabel: "web") - } - - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false - } - - private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { - guard context.settings?.zoommate?.cookieSource == .manual else { return nil } - return context.settings?.zoommate?.manualCookieHeader ?? "" - } - - private static func invalidateCachedBearerToken(for requestContext: ZoomMateUsageFetcher.RequestContext) async { - guard let cacheKey = requestContext.cacheKey else { return } - await ZoomMateBearerTokenCache.shared.invalidate(forKey: cacheKey) + ScriptFetchStrategy( + id: "zoommate.web", + provider: .zoommate, + bundledPlugin: "zoommate", + sourceLabel: "web", + kind: .web, + transport: transport, + timeout: max(30, timeout * 4), + validateContext: { context in + if context.settings?.zoommate?.cookieSource == .manual, + Self.capture(context.settings?.zoommate?.manualCookieHeader) == nil + { + throw ProviderFetchClassifiedError( + kind: .missingCredential, + message: "Paste a cURL capture of the HTTPS ZoomMate credits/status request.") + } + }, cookieSettings: { context in + let settings = context.settings?.zoommate + let capture = Self.capture(settings?.manualCookieHeader) + return .init( + cookieSource: settings?.cookieSource ?? .auto, + manualCookieHeader: capture?.headers["cookie"], + manualCookieOrigin: capture.map { "https://\($0.host)" }) + }, resolveValues: { context in + let settings = context.settings?.zoommate + guard settings?.cookieSource != .off else { return nil } + let capture = settings?.cookieSource == .manual ? Self.capture(settings?.manualCookieHeader) : nil + var headers = capture?.headers ?? [:] + let auth = headers.removeValue(forKey: "authorization") + headers.removeValue(forKey: "cookie") + let encoded = (try? JSONSerialization.data(withJSONObject: headers)) ?? Data("{}".utf8) + return .init( + settings: ["HOST": capture?.host ?? ""], + secrets: [ + "AUTHORIZATION": auth ?? "", + "HEADERS": String(data: encoded, encoding: .utf8) ?? "{}", + ]) + }, isEnabled: { _ in true }) } } diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift deleted file mode 100644 index 968f8bb281..0000000000 --- a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift +++ /dev/null @@ -1,555 +0,0 @@ -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -public struct ZoomMateUsageFetcher: Sendable { - private static let log = CodexBarLog.logger(LogCategories.provider(.zoommate)) - private static let refererURL = URL(string: "https://zoommate.zoom.us")! - /// First-party API hosts, tried in order. `ai.zoom.us` and `zoommate.zoom.us` currently serve - /// the same `/ai-computer/` API interchangeably and either may retire in the future, so every - /// API request falls over to the next host on non-auth failures via `withAPIHostFailover` - /// (precedent: `FactoryStatusProbe`'s base-URL candidates). - static let apiHosts = ZoomMateCookieHeaders.allowedHosts - static let creditsStatusPath = "/ai-computer/api/v1/credits/status" - private static let userAgent = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" - - /// Forwarded headers allowlist for the manual `.web` cURL capture. Unlike T3 Chat's, this - /// MUST include `authorization` (design D2) because ZoomMate's credential is a bearer token, - /// not a cookie. - private static let forwardedManualHeaders = [ - "authorization": "Authorization", - "cookie": "Cookie", - "user-agent": "User-Agent", - "accept": "Accept", - "accept-language": "Accept-Language", - "sec-fetch-dest": "Sec-Fetch-Dest", - "sec-fetch-mode": "Sec-Fetch-Mode", - "sec-fetch-site": "Sec-Fetch-Site", - ] - - public struct RequestContext: Sendable { - public let authorization: String - public let headers: [String: String] - public let cookieHeaders: ZoomMateCookieHeaders - public let preferredHost: String? - /// Signed-in user's email, when known. Only populated by the `.auto` cookie-mint path - /// (sourced from the login bootstrap response's `data.user_profile.email`); the manual - /// `.web` cURL-capture path has no equivalent payload to read it from, so this stays `nil` - /// there. - public let accountEmail: String? - /// Bearer-token cache key for the originating cookie session (`.auto` path only). Lets a - /// caller evict the reused token from `ZoomMateBearerTokenCache` when a downstream request - /// rejects it (`401/403`). `nil` for the manual `.web` path, which carries its own bearer. - public let cacheKey: String? - - public init( - authorization: String, - headers: [String: String] = [:], - cookieHeaders: ZoomMateCookieHeaders = ZoomMateCookieHeaders(headersByHost: [:]), - preferredHost: String? = nil, - accountEmail: String? = nil, - cacheKey: String? = nil) - { - self.authorization = authorization - self.headers = headers - self.cookieHeaders = cookieHeaders - self.preferredHost = preferredHost - self.accountEmail = accountEmail - self.cacheKey = cacheKey - } - } - - /// Result of `mintBearerToken`: the freshly-minted bearer JWT plus whatever identity - /// enrichment (currently just `email`) the same login bootstrap response happened to include - /// in its `data.user_profile` object. Modeled on the small multi-field result structs other - /// providers return from a single fetch (e.g. `ZoomMateCookieImporter.SessionInfo`). - public struct MintedToken: Sendable { - public let bearerToken: String - public let accountEmail: String? - - public init(bearerToken: String, accountEmail: String?) { - self.bearerToken = bearerToken - self.accountEmail = accountEmail - } - } - - public let browserDetection: BrowserDetection - - public init(browserDetection: BrowserDetection) { - self.browserDetection = browserDetection - } - - public func fetch( - manualCaptureOverride: String? = nil, - timeout: TimeInterval = 15, - logger: (@Sendable (String) -> Void)? = nil, - now: Date = Date(), - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateUsageSnapshot - { - let log: @Sendable (String) -> Void = { msg in logger?("[zoommate] \(msg)") } - let context = try await self.resolveRequestContext( - manualCaptureOverride: manualCaptureOverride, - timeout: timeout, - logger: log, - transport: transport) - if !context.headers.isEmpty || !context.cookieHeaders.isEmpty { - var names = Set(context.headers.keys) - if !context.cookieHeaders.isEmpty { - names.insert("Cookie") - } - let headerNames = names.sorted().joined(separator: ", ") - log("Forwarding captured headers: \(headerNames)") - } - return try await Self.fetchCreditsStatus( - context: context, - timeout: timeout, - now: now, - transport: transport) - } - - public static func fetchCreditsStatus( - context: RequestContext, - timeout: TimeInterval = 15, - now: Date = Date(), - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateUsageSnapshot - { - try await self.withAPIHostFailover(hosts: self.hosts(preferred: context.preferredHost)) { host in - try await self.fetchCreditsStatus( - context: context, - host: host, - timeout: timeout, - now: now, - transport: transport) - } - } - - /// Runs one API request per host in `apiHosts` order, returning the first success. Auth - /// rejections and parse failures propagate immediately — the host answered, so retrying the - /// interchangeable alternate cannot help; anything else (unreachable host, non-auth HTTP - /// error) falls through to the next host so the provider keeps working if either host - /// retires. - static func withAPIHostFailover( - hosts: [String] = ZoomMateUsageFetcher.apiHosts, - operation: (String) async throws -> T) async throws -> T - { - var lastError: Error? - for (index, host) in hosts.enumerated() { - try Task.checkCancellation() - do { - return try await operation(host) - } catch is CancellationError { - throw CancellationError() - } catch let error as URLError where error.code == .cancelled { - throw CancellationError() - } catch ZoomMateUsageError.invalidCredentials { - throw ZoomMateUsageError.invalidCredentials - } catch let ZoomMateUsageError.parseFailed(message) { - throw ZoomMateUsageError.parseFailed(message) - } catch { - if Task.isCancelled { - throw CancellationError() - } - lastError = error - if index < hosts.count - 1 { - Self.log.info("ZoomMate API host unavailable; retrying on the alternate host") - } - } - } - throw lastError ?? ZoomMateUsageError.apiError("No ZoomMate API host succeeded.") - } - - private static func fetchCreditsStatus( - context: RequestContext, - host: String, - timeout: TimeInterval, - now: Date, - transport: any ProviderHTTPTransport) async throws -> ZoomMateUsageSnapshot - { - var request = URLRequest(url: URL(string: "https://\(host)\(self.creditsStatusPath)")!) - request.httpMethod = "GET" - request.timeoutInterval = timeout - self.applyDefaultHeaders(to: &request) - for (name, value) in context.headers { - request.setValue(value, forHTTPHeaderField: name) - } - request.setValue(context.cookieHeaders.header(forHost: host), forHTTPHeaderField: "Cookie") - // Authorization is always sent (the required credential per design D2). Origin and Referer - // are fixed here so captured values can never widen the first-party request boundary. - request.setValue(context.authorization, forHTTPHeaderField: "Authorization") - request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") - request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") - - let response = try await transport.response(for: request) - let data = response.data - guard response.statusCode == 200 else { - Self.log.error("ZoomMate API returned \(response.statusCode)") - if response.statusCode == 401 || response.statusCode == 403 { - throw ZoomMateUsageError.invalidCredentials - } - throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") - } - - do { - let envelope = try JSONDecoder().decode(CreditsStatusEnvelope.self, from: data) - guard let creditStatus = envelope.data?.creditStatus else { - throw ZoomMateUsageError.parseFailed("Missing credit_status object.") - } - return ZoomMateUsageSnapshot(creditStatus: creditStatus, updatedAt: now) - } catch let error as ZoomMateUsageError { - throw error - } catch { - Self.log.error("ZoomMate credits/status parse failed") - throw ZoomMateUsageError.parseFailed(error.localizedDescription) - } - } - - /// Exchanges ZoomMate/Zoom session cookie headers for a fresh bearer JWT via ZoomMate's own - /// cookie-to-token bootstrap endpoint — the same call its web frontend makes on every page - /// load. Cookies (session/SSO-backed) live far longer than the ~hourly JWT, so minting a fresh - /// token from cookies avoids the manual re-paste entirely as long as the underlying browser - /// session cookies remain valid. Callers should prefer `cachedOrMintedToken`, which reuses a - /// still-valid minted token from `ZoomMateBearerTokenCache` instead of re-minting every fetch. - public static func mintBearerToken( - cookieHeaders: ZoomMateCookieHeaders, - timeout: TimeInterval = 15, - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MintedToken - { - try await self.withAPIHostFailover { host in - try await self.mintBearerToken( - cookieHeader: cookieHeaders.header(forHost: host), - host: host, - timeout: timeout, - transport: transport) - } - } - - private static func mintBearerToken( - cookieHeader: String?, - host: String, - timeout: TimeInterval, - transport: any ProviderHTTPTransport) async throws -> MintedToken - { - var components = URLComponents(string: "https://\(host)/ai-computer/api/v1/login/")! - components.queryItems = [URLQueryItem(name: "continue", value: "https://zoommate.zoom.us/")] - guard let url = components.url else { - throw ZoomMateUsageError.apiError("Failed to build login bootstrap URL.") - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.timeoutInterval = timeout - self.applyDefaultHeaders(to: &request) - request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") - request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") - request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - - let response = try await transport.response(for: request) - let data = response.data - guard response.statusCode == 200 else { - Self.log.error("ZoomMate login bootstrap returned \(response.statusCode)") - if response.statusCode == 401 || response.statusCode == 403 { - throw ZoomMateUsageError.invalidCredentials - } - throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") - } - - do { - let envelope = try JSONDecoder().decode(LoginBootstrapEnvelope.self, from: data) - guard let nak = envelope.data?.nak, !nak.isEmpty else { - throw ZoomMateUsageError.parseFailed("Missing nak in login bootstrap response.") - } - let email = envelope.data?.userProfile?.email?.trimmingCharacters(in: .whitespacesAndNewlines) - return MintedToken(bearerToken: nak, accountEmail: (email?.isEmpty ?? true) ? nil : email) - } catch let error as ZoomMateUsageError { - throw error - } catch { - throw ZoomMateUsageError.parseFailed(error.localizedDescription) - } - } - - func resolveRequestContext( - manualCaptureOverride: String?, - allowCachedCookieHeader: Bool = true, - timeout: TimeInterval, - logger: (@Sendable (String) -> Void)?, - cache: ZoomMateBearerTokenCache = .shared, - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> RequestContext - { - if let manualCaptureOverride { - guard let override = Self.requestContext(from: manualCaptureOverride) else { - throw ZoomMateUsageError.noCapture - } - logger?("[zoommate] Using manual cURL capture") - return override - } - - #if os(macOS) - // Cached host-scoped cookie headers first (Perplexity/OpenCode precedent): Chrome's cookie decryption - // is gated behind user-initiated contexts (`BrowserCookieAccessGate`) to avoid Keychain - // prompts, so background refreshes and the bundled CLI must be able to run entirely from - // the last validated session instead of rereading the browser. - if allowCachedCookieHeader, - let cached = CookieHeaderCache.load(provider: .zoommate), - let cookieHeaders = ZoomMateCookieHeaders.decodeFromStorage(cached.cookieHeader), - !cookieHeaders.isEmpty - { - logger?("[zoommate] Using cached cookie headers from \(cached.sourceLabel)") - return try await Self.requestContext( - forCookieHeaders: cookieHeaders, - persistingValidatedHeaderAs: nil, - cache: cache, - timeout: timeout, - transport: transport, - logger: logger) - } - - let sessions = try ZoomMateCookieImporter.importSessions( - browserDetection: self.browserDetection, - logger: logger) - return try await Self.requestContext( - forCookieSessions: sessions, - cache: cache, - timeout: timeout, - transport: transport, - logger: logger) - #else - throw ZoomMateUsageError.noSession - #endif - } - - #if os(macOS) - /// Tries browser cookie profiles in import order, advancing only when the login bootstrap - /// explicitly rejects a candidate. Network and parse failures surface immediately rather than - /// being hidden by another profile. Only the first successfully minted session is persisted. - static func requestContext( - forCookieSessions sessions: [ZoomMateCookieImporter.SessionInfo], - cache: ZoomMateBearerTokenCache = .shared, - timeout: TimeInterval, - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, - logger: (@Sendable (String) -> Void)?) async throws -> RequestContext - { - guard !sessions.isEmpty else { throw ZoomMateUsageError.noSession } - - for session in sessions { - logger?("[zoommate] Trying cookies from \(session.sourceLabel)") - do { - return try await self.requestContext( - forCookieHeaders: session.cookieHeaders, - persistingValidatedHeaderAs: session.sourceLabel, - cache: cache, - timeout: timeout, - transport: transport, - logger: logger) - } catch ZoomMateUsageError.invalidCredentials { - logger?("[zoommate] Cookie session from \(session.sourceLabel) was rejected") - } - } - - throw ZoomMateUsageError.invalidCredentials - } - - /// Builds the `.auto` request context for a cookie session: reuses or mints the bearer JWT - /// and, when `sourceLabel` is non-nil (a fresh browser import), persists the now-validated - /// cookie headers through `CookieHeaderCache`. The successful mint is the validation — - /// ZoomMate's login bootstrap rejects a dead session with 401/403 before anything is stored. - /// Only the cookie headers are persisted; the minted bearer stays in the in-memory - /// `ZoomMateBearerTokenCache`. - static func requestContext( - forCookieHeaders cookieHeaders: ZoomMateCookieHeaders, - persistingValidatedHeaderAs sourceLabel: String?, - cache: ZoomMateBearerTokenCache = .shared, - timeout: TimeInterval, - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, - logger: (@Sendable (String) -> Void)?) async throws -> RequestContext - { - let minted = try await Self.cachedOrMintedToken( - cookieHeaders: cookieHeaders, - cache: cache, - timeout: timeout, - transport: transport, - logger: logger) - if let sourceLabel, let encodedCookieHeaders = cookieHeaders.encodedForStorage() { - CookieHeaderCache.store( - provider: .zoommate, - cookieHeader: encodedCookieHeaders, - sourceLabel: sourceLabel) - } - return RequestContext( - authorization: Self.bearerHeaderValue(from: minted.bearerToken), - cookieHeaders: cookieHeaders, - accountEmail: minted.accountEmail, - cacheKey: ZoomMateBearerTokenCache.key(forCookieHeaders: cookieHeaders)) - } - #endif - - /// Returns a still-valid cached bearer token for `cookieHeaders`, or mints a fresh one and caches - /// it when the minted JWT exposes an `exp` claim. A token whose expiry can't be read is returned - /// but never cached, so `.auto` refreshes degrade to the mint-every-fetch behavior rather than - /// risk serving an undatable (possibly expired) token. - static func cachedOrMintedToken( - cookieHeaders: ZoomMateCookieHeaders, - cache: ZoomMateBearerTokenCache, - timeout: TimeInterval, - transport: any ProviderHTTPTransport, - logger: (@Sendable (String) -> Void)?) async throws -> MintedToken - { - let cacheKey = ZoomMateBearerTokenCache.key(forCookieHeaders: cookieHeaders) - if let entry = await cache.validEntry(forKey: cacheKey, now: Date()) { - logger?("[zoommate] Reusing cached bearer token") - return MintedToken(bearerToken: entry.token, accountEmail: entry.accountEmail) - } - let minted = try await Self.mintBearerToken( - cookieHeaders: cookieHeaders, - timeout: timeout, - transport: transport) - if let expiry = Self.expiry(fromJWT: minted.bearerToken) { - await cache.store( - ZoomMateBearerTokenCache.Entry( - token: minted.bearerToken, - accountEmail: minted.accountEmail, - expiry: expiry), - forKey: cacheKey) - logger?("[zoommate] Minted fresh bearer token via cookie session (cached until expiry)") - } else { - logger?("[zoommate] Minted fresh bearer token via cookie session (not cached: no expiry claim)") - } - return minted - } - - /// Reads the `exp` claim (seconds since epoch) from a bearer JWT, returning its expiry `Date`. - /// Returns `nil` for anything that isn't a decodable JWT with a numeric `exp` — the caller then - /// treats the token as non-cacheable. Mirrors the base64url/JSON payload decode used elsewhere - /// (e.g. `MiniMaxLocalStorageImporter`); no signature verification (we minted it ourselves). - static func expiry(fromJWT token: String) -> Date? { - let raw = Self.bearerHeaderValue(from: token).dropFirst("Bearer ".count) - let parts = raw.split(separator: ".") - guard parts.count >= 2, let data = Self.base64URLDecode(String(parts[1])) else { return nil } - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let exp = (object["exp"] as? NSNumber)?.doubleValue, exp > 0 - else { - return nil - } - return Date(timeIntervalSince1970: exp) - } - - private static func base64URLDecode(_ value: String) -> Data? { - var base64 = value.replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let padding = (4 - base64.count % 4) % 4 - if padding > 0 { - base64.append(String(repeating: "=", count: padding)) - } - return Data(base64Encoded: base64) - } - - static func bearerHeaderValue(from rawToken: String) -> String { - let trimmed = rawToken.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.lowercased().hasPrefix("bearer ") { - return trimmed - } - return "Bearer \(trimmed)" - } - - /// Parses a manual cURL capture into a `RequestContext`. Returns `nil` when no non-empty - /// `Authorization` header can be extracted — that's the required credential (design D2). - static func requestContext(from raw: String?) -> RequestContext? { - guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } - guard let captureURL = CurlCaptureParser.requestURL(from: raw), - self.isAllowedCaptureURL(captureURL), - let captureHost = captureURL.host?.lowercased() - else { - return nil - } - let headerFields = CurlCaptureParser.headerFields(from: raw) - guard let authorization = CurlCaptureParser.headerValue(named: "Authorization", in: headerFields), - !authorization.isEmpty - else { - return nil - } - var headers = CurlCaptureParser.forwardedHeaders(from: headerFields, allowlist: self.forwardedManualHeaders) - headers.removeValue(forKey: "Authorization") - let cookieHeader = headers.removeValue(forKey: "Cookie") - let cookieHeaders = ZoomMateCookieHeaders(headersByHost: cookieHeader.map { [captureHost: $0] } ?? [:]) - return RequestContext( - authorization: Self.bearerHeaderValue(from: authorization), - headers: headers, - cookieHeaders: cookieHeaders, - preferredHost: captureHost) - } - - /// Captures are accepted from any host in `apiHosts` (the interchangeable first-party API - /// hosts) — DevTools shows the credits/status request on whichever host the web client used — - /// but only for the exact HTTPS credits/status path with no port, userinfo, query, or fragment. - private static func isAllowedCaptureURL(_ url: URL) -> Bool { - guard let host = url.host?.lowercased() else { return false } - return url.scheme?.lowercased() == "https" && - self.apiHosts.contains(host) && - url.port == nil && - url.user == nil && - url.password == nil && - url.path == self.creditsStatusPath && - url.query == nil && - url.fragment == nil - } - - private static func applyDefaultHeaders(to request: inout URLRequest) { - request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") - request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") - request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") - request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") - request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") - request.setValue("same-site", forHTTPHeaderField: "Sec-Fetch-Site") - } - - static func hosts(preferred host: String?) -> [String] { - guard let host = host?.lowercased(), self.apiHosts.contains(host) else { return self.apiHosts } - return [host] + self.apiHosts.filter { $0 != host } - } - - private struct CreditsStatusEnvelope: Decodable { - struct DataBox: Decodable { - let creditStatus: ZoomMateCreditStatus? - - private enum CodingKeys: String, CodingKey { - case creditStatus = "credit_status" - } - } - - let data: DataBox? - let statusCode: Int? - let errorMessage: String? - - private enum CodingKeys: String, CodingKey { - case data - case statusCode = "status_code" - case errorMessage = "error_message" - } - } - - /// Shape of ZoomMate's cookie-to-token bootstrap response (`GET .../login/?continue=...`). - /// `data.nak` (the freshly-minted bearer JWT) is required; `data.user_profile.email` is - /// decoded as an optional identity-enrichment nice-to-have (never required — a missing/absent - /// `user_profile` or `email` must never fail the mint). The rest of the payload (permissions, - /// cluster config, etc.) is ignored. - private struct LoginBootstrapEnvelope: Decodable { - struct UserProfile: Decodable { - let email: String? - } - - struct DataBox: Decodable { - let nak: String? - let userProfile: UserProfile? - - private enum CodingKeys: String, CodingKey { - case nak - case userProfile = "user_profile" - } - } - - let success: Bool? - let data: DataBox? - } -} diff --git a/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts b/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts index f3ede35bcd..e54cc094fe 100644 --- a/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts +++ b/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts @@ -5,6 +5,7 @@ interface CodexBarCookieSession { readonly source: string; readonly origin: string; readonly cachedAt?: number; + readonly cacheKey?: string; } type CodexBarJSONPrimitive = boolean | number | string | null; @@ -231,6 +232,7 @@ interface CodexBarPluginContext { }; readonly browser: { availability(domain: string): "available" | "off" | "manual"; + acceptCookie(domain: string, session: CodexBarCookieSession): void; rejectCookie(domain: string, session?: CodexBarCookieSession): void; sessions(domain: string, options?: { cachedOnly?: boolean }): AsyncIterable; cookieHeader(domain: string): Promise; @@ -289,8 +291,17 @@ interface CodexBarProviderDefinition { /** Grants declared cookie access, HTTP status handling, or bounded non-secret persistent state. */ capabilities?: Array<"browser-cookies" | "http-status" | "persistent-storage">; cookieDomains?: string[]; + snapshotPolicy?: { percent: "clamp" | "preserve-overage" }; /** Bundled-only, host-owned per-profile cookie selection without persistent session caching. */ - cookiePolicy?: { selection: "request-url"; cache: "nonpersistent" }; + cookiePolicy?: { + selection: "request-url" | "ranked-source-domains"; + cache: "nonpersistent" | "validated-single-entry"; + sourceDomains?: string[]; + requiredCookies?: string[]; + missingCookies?: "reject" | "omit"; + imports?: "app-interactive" | "access-gated"; + sessionFile?: { tokenField: string; cookieName: string }; + }; fetchUsage( ctx: CodexBarPluginContext, ): CodexBarUsageSnapshot | CodexBarFetchResult | Promise; diff --git a/Sources/CodexBarCore/Resources/Plugins/notion.js b/Sources/CodexBarCore/Resources/Plugins/notion.js new file mode 100644 index 0000000000..50b2f812b4 --- /dev/null +++ b/Sources/CodexBarCore/Resources/Plugins/notion.js @@ -0,0 +1,279 @@ +function _nullishCoalesce(lhs, rhsFn) { + if (lhs != null) { + return lhs; + } else { + return rhsFn(); + } +} +function _optionalChain(ops) { + let lastAccessLHS = undefined; + let value = ops[0]; + let i = 1; + while (i < ops.length) { + const op = ops[i]; + const fn = ops[i + 1]; + i += 2; + if ((op === "optionalAccess" || op === "optionalCall") && value == null) { + return undefined; + } + if (op === "access" || op === "optionalAccess") { + lastAccessLHS = value; + value = fn(value); + } else if (op === "call" || op === "optionalCall") { + value = fn((...args) => value.call(lastAccessLHS, ...args)); + lastAccessLHS = undefined; + } + } + return value; +} +defineProvider({ + id: "notion", + name: "Notion AI", + settings: [ + { key: "WORKSPACE_ID", title: "Workspace ID", type: "plain" }, + { key: "HEADERS", title: "Captured headers", type: "secure" }, + ], + endpoints: ["https://app.notion.com"], + capabilities: ["browser-cookies", "http-status"], + cookieDomains: ["app.notion.com", "www.notion.com", "notion.com", "www.notion.so", "notion.so"], + cookiePolicy: { + selection: "ranked-source-domains", + sourceDomains: ["app.notion.com", "www.notion.com", "notion.com", "www.notion.so", "notion.so"], + requiredCookies: ["token_v2"], + cache: "validated-single-entry", + imports: "access-gated", + sessionFile: { tokenField: "tokenV2", cookieName: "token_v2" }, + }, + snapshotPolicy: { percent: "preserve-overage" }, + async fetchUsage(ctx) { + const object = (value) => + value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined; + const text = (value) => (typeof value === "string" ? value : undefined); + const invalid = (message) => { + throw ctx.fail.parseFailure(`Could not parse Notion usage: ${message}`); + }; + const unwrap = (value) => { + const outer = object(value); + const inner = object(_optionalChain([outer, "optionalAccess", (_) => _.value])); + return _nullishCoalesce( + _nullishCoalesce(object(_optionalChain([inner, "optionalAccess", (_2) => _2.value])), () => inner), + () => outer, + ); + }; + const normalize = (value) => value.trim().replace(/-/g, "").toLowerCase(); + const domain = "app.notion.com"; + const availability = ctx.browser.availability(domain); + if (availability === "off") throw ctx.fail.missingCredential("Notion cookies are disabled."); + const headers = { + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.9", + Referer: "https://app.notion.com/", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + ...JSON.parse(ctx.settings.getSecret("HEADERS") || "{}"), + Origin: "https://app.notion.com", + }; + const numeric = (value) => { + if (value === undefined || value === null) return undefined; + return typeof value === "number" && Number.isFinite(value) ? value : invalid("invalid numeric field"); + }; + const window = (raw, rolling, resets) => { + if (raw === undefined || raw === null) return undefined; + const value = _nullishCoalesce(object(raw), () => invalid("window is not an object")); + const used = numeric(value.used), + limit = numeric(value.limit); + if (used === undefined || limit === undefined || limit <= 0) return undefined; + let windowMinutes; + let resetsAt; + if (rolling) { + const token = _optionalChain([ + text, + "call", + (_3) => _3(value.window), + "optionalAccess", + (_4) => _4.trim, + "call", + (_5) => _5(), + "access", + (_6) => _6.toLowerCase, + "call", + (_7) => _7(), + ]); + const parts = _optionalChain([ + token, + "optionalAccess", + (_8) => _8.match, + "call", + (_9) => _9(/^([1-9][0-9]*)([mhdw])$/), + ]); + if (parts) { + const minutes = Number(parts[1]) * _nullishCoalesce({ m: 1, h: 60, d: 1440, w: 10080 }[parts[2]], () => 0); + if (Number.isSafeInteger(minutes) && minutes !== 43200) windowMinutes = minutes; + } + const seconds = numeric(resets); + if (seconds !== undefined && seconds >= 0) resetsAt = new Date(ctx.date.now().getTime() + seconds * 1000); + } else { + windowMinutes = 43200; + const end = numeric(value.periodEndMs); + if (end !== undefined && end > 0) resetsAt = new Date(end); + } + return { usedPercent: Math.max(0, (used / limit) * 100), windowMinutes, resetsAt }; + }; + for await (const session of ctx.browser.sessions(domain)) { + const post = async (endpoint, body) => { + const response = await ctx.http.post(`https://${domain}/api/v3/${endpoint}`, { + body, + headers, + cookieSession: session.id, + }); + if (response.status === 401) + throw Object.assign(ctx.fail.authenticationExpired("Notion session cookie is invalid or expired."), { + failureKind: "authentication-expired", + }); + if (response.status !== 200) throw ctx.fail.apiFailure(`Notion HTTP ${response.status} from ${endpoint}`); + let parsed; + try { + parsed = JSON.parse(response.bodyText); + } catch (error) { + void error; + return invalid(`${endpoint} returned invalid JSON`); + } + return _nullishCoalesce(object(parsed), () => invalid(`${endpoint} response is not an object`)); + }; + try { + const spaces = await post("getSpaces", {}); + const ids = Object.keys(spaces).filter( + (id) => + _optionalChain([ + unwrap, + "call", + (_10) => + _10( + _optionalChain([ + object, + "call", + (_11) => + _11( + _optionalChain([ + object, + "call", + (_12) => _12(spaces[id]), + "optionalAccess", + (_13) => _13.notion_user, + ]), + ), + "optionalAccess", + (_14) => _14[id], + ]), + ), + "optionalAccess", + (_15) => _15.id, + ]) === id, + ); + const userID = + ids.length === 1 + ? ids[0] + : ids.length === 0 && Object.keys(spaces).length === 1 + ? Object.keys(spaces)[0] + : undefined; + if (!userID) return invalid("getSpaces response did not identify a single user"); + const container = _nullishCoalesce(object(spaces[userID]), () => invalid("getSpaces user is not an object")); + const users = _nullishCoalesce(object(container.notion_user), () => ({})); + const user = _nullishCoalesce(unwrap(users[userID]), () => Object.values(users).map(unwrap).find(Boolean)); + const records = _nullishCoalesce(object(container.space), () => ({})); + const workspaces = Object.keys(records) + .sort() + .flatMap((key) => { + const record = unwrap(records[key]); + return record ? [{ ...record, id: _nullishCoalesce(text(record.id), () => key) }] : []; + }); + const preferred = ctx.settings.get("WORKSPACE_ID"); + const workspace = _nullishCoalesce( + _nullishCoalesce( + preferred ? workspaces.find((space) => normalize(space.id) === normalize(preferred)) : undefined, + () => + workspaces.find((space) => + ["business", "enterprise"].includes( + _nullishCoalesce( + _optionalChain([ + text, + "call", + (_16) => _16(space.subscription_tier), + "optionalAccess", + (_17) => _17.toLowerCase, + "call", + (_18) => _18(), + ]), + () => "", + ), + ), + ), + ), + () => workspaces[0], + ); + if (!workspace) throw ctx.fail.apiFailure("No Notion workspace found for this account."); + const usage = await post("getCreditRateLimitStatus", { spaceId: workspace.id }); + for (const field of ["status", "enforcement"]) { + if (usage[field] != null && typeof usage[field] !== "string") return invalid(`invalid ${field}`); + } + numeric(usage.resetsInSeconds); + for (const raw of [usage.window, usage.billingPeriodWindow]) { + if (raw == null) continue; + const value = _nullishCoalesce(object(raw), () => invalid("window is not an object")); + for (const field of ["creditType", "scope", "window", "cadence"]) { + if (value[field] != null && typeof value[field] !== "string") return invalid(`invalid ${field}`); + } + for (const field of ["used", "limit", "periodEndMs"]) numeric(value[field]); + } + if ( + _optionalChain([ + text, + "call", + (_19) => _19(usage.status), + "optionalAccess", + (_20) => _20.toLowerCase, + "call", + (_21) => _21(), + ]) === "not_applicable" + ) + throw ctx.fail.apiFailure( + "Notion AI usage allowance is not tracked for this workspace. Allowances apply to Business and Enterprise workspaces.", + ); + if (usage.window == null && usage.billingPeriodWindow == null) + return invalid("getCreditRateLimitStatus returned no usage windows"); + const primary = window(usage.window, true, usage.resetsInSeconds); + const secondary = window(usage.billingPeriodWindow, false, undefined); + const tier = _optionalChain([ + text, + "call", + (_22) => _22(workspace.subscription_tier), + "optionalAccess", + (_23) => _23.trim, + "call", + (_24) => _24(), + ]); + const result = { + primary, + secondary, + empty: !primary && !secondary, + identity: { + email: text(_optionalChain([user, "optionalAccess", (_25) => _25.email])), + accountID: userID, + organization: text(workspace.name), + loginMethod: tier ? tier[0].toUpperCase() + tier.slice(1) : undefined, + }, + }; + if (availability !== "manual") ctx.browser.acceptCookie(domain, session); + return result; + } catch (error) { + if (error.failureKind !== "authentication-expired") throw error; + ctx.browser.rejectCookie(domain, session); + if (session.cachedAt === undefined) throw error; + } + } + throw ctx.fail.missingCredential("No Notion cookies found. Sign in to Notion and refresh once to import them."); + }, +}); diff --git a/Sources/CodexBarCore/Resources/Plugins/notion.ts b/Sources/CodexBarCore/Resources/Plugins/notion.ts new file mode 100644 index 0000000000..98570189e1 --- /dev/null +++ b/Sources/CodexBarCore/Resources/Plugins/notion.ts @@ -0,0 +1,171 @@ +defineProvider({ + id: "notion", + name: "Notion AI", + settings: [ + { key: "WORKSPACE_ID", title: "Workspace ID", type: "plain" }, + { key: "HEADERS", title: "Captured headers", type: "secure" }, + ], + endpoints: ["https://app.notion.com"], + capabilities: ["browser-cookies", "http-status"], + cookieDomains: ["app.notion.com", "www.notion.com", "notion.com", "www.notion.so", "notion.so"], + cookiePolicy: { + selection: "ranked-source-domains", + sourceDomains: ["app.notion.com", "www.notion.com", "notion.com", "www.notion.so", "notion.so"], + requiredCookies: ["token_v2"], + cache: "validated-single-entry", + imports: "access-gated", + sessionFile: { tokenField: "tokenV2", cookieName: "token_v2" }, + }, + snapshotPolicy: { percent: "preserve-overage" }, + async fetchUsage(ctx) { + type ObjectValue = Record; + const object = (value: unknown): ObjectValue | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) ? (value as ObjectValue) : undefined; + const text = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined); + const invalid = (message: string): never => { + throw ctx.fail.parseFailure(`Could not parse Notion usage: ${message}`); + }; + const unwrap = (value: unknown): ObjectValue | undefined => { + const outer = object(value); + const inner = object(outer?.value); + return object(inner?.value) ?? inner ?? outer; + }; + const normalize = (value: string): string => value.trim().replace(/-/g, "").toLowerCase(); + const domain = "app.notion.com"; + const availability = ctx.browser.availability(domain); + if (availability === "off") throw ctx.fail.missingCredential("Notion cookies are disabled."); + const headers: Record = { + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.9", + Referer: "https://app.notion.com/", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + ...JSON.parse(ctx.settings.getSecret("HEADERS") || "{}"), + Origin: "https://app.notion.com", + }; + const numeric = (value: unknown): number | undefined => { + if (value === undefined || value === null) return undefined; + return typeof value === "number" && Number.isFinite(value) ? value : invalid("invalid numeric field"); + }; + const window = (raw: unknown, rolling: boolean, resets: unknown): CodexBarRateWindow | undefined => { + if (raw === undefined || raw === null) return undefined; + const value = object(raw) ?? invalid("window is not an object"); + const used = numeric(value.used), + limit = numeric(value.limit); + if (used === undefined || limit === undefined || limit <= 0) return undefined; + let windowMinutes: number | undefined; + let resetsAt: Date | undefined; + if (rolling) { + const token = text(value.window)?.trim().toLowerCase(); + const parts = token?.match(/^([1-9][0-9]*)([mhdw])$/); + if (parts) { + const minutes = Number(parts[1]) * ({ m: 1, h: 60, d: 1440, w: 10080 }[parts[2]] ?? 0); + if (Number.isSafeInteger(minutes) && minutes !== 43200) windowMinutes = minutes; + } + const seconds = numeric(resets); + if (seconds !== undefined && seconds >= 0) resetsAt = new Date(ctx.date.now().getTime() + seconds * 1000); + } else { + windowMinutes = 43200; + const end = numeric(value.periodEndMs); + if (end !== undefined && end > 0) resetsAt = new Date(end); + } + return { usedPercent: Math.max(0, (used / limit) * 100), windowMinutes, resetsAt }; + }; + for await (const session of ctx.browser.sessions(domain)) { + const post = async (endpoint: string, body: Record): Promise => { + const response = await ctx.http.post(`https://${domain}/api/v3/${endpoint}`, { + body, + headers, + cookieSession: session.id, + }); + if (response.status === 401) + throw Object.assign(ctx.fail.authenticationExpired("Notion session cookie is invalid or expired."), { + failureKind: "authentication-expired", + }); + if (response.status !== 200) throw ctx.fail.apiFailure(`Notion HTTP ${response.status} from ${endpoint}`); + let parsed: unknown; + try { + parsed = JSON.parse(response.bodyText); + } catch (error) { + void error; + return invalid(`${endpoint} returned invalid JSON`); + } + return object(parsed) ?? invalid(`${endpoint} response is not an object`); + }; + try { + const spaces = await post("getSpaces", {}); + const ids = Object.keys(spaces).filter( + (id) => unwrap(object(object(spaces[id])?.notion_user)?.[id])?.id === id, + ); + const userID = + ids.length === 1 + ? ids[0] + : ids.length === 0 && Object.keys(spaces).length === 1 + ? Object.keys(spaces)[0] + : undefined; + if (!userID) return invalid("getSpaces response did not identify a single user"); + const container = object(spaces[userID]) ?? invalid("getSpaces user is not an object"); + const users = object(container.notion_user) ?? {}; + const user = unwrap(users[userID]) ?? Object.values(users).map(unwrap).find(Boolean); + const records = object(container.space) ?? {}; + const workspaces: Array = Object.keys(records) + .sort() + .flatMap((key) => { + const record = unwrap(records[key]); + return record ? [{ ...record, id: text(record.id) ?? key }] : []; + }); + const preferred = ctx.settings.get("WORKSPACE_ID"); + const workspace = + (preferred ? workspaces.find((space) => normalize(space.id) === normalize(preferred)) : undefined) ?? + workspaces.find((space) => + ["business", "enterprise"].includes(text(space.subscription_tier)?.toLowerCase() ?? ""), + ) ?? + workspaces[0]; + if (!workspace) throw ctx.fail.apiFailure("No Notion workspace found for this account."); + const usage = await post("getCreditRateLimitStatus", { spaceId: workspace.id }); + for (const field of ["status", "enforcement"]) { + if (usage[field] != null && typeof usage[field] !== "string") return invalid(`invalid ${field}`); + } + numeric(usage.resetsInSeconds); + for (const raw of [usage.window, usage.billingPeriodWindow]) { + if (raw == null) continue; + const value = object(raw) ?? invalid("window is not an object"); + for (const field of ["creditType", "scope", "window", "cadence"]) { + if (value[field] != null && typeof value[field] !== "string") return invalid(`invalid ${field}`); + } + for (const field of ["used", "limit", "periodEndMs"]) numeric(value[field]); + } + if (text(usage.status)?.toLowerCase() === "not_applicable") + throw ctx.fail.apiFailure( + "Notion AI usage allowance is not tracked for this workspace. Allowances apply to Business and Enterprise workspaces.", + ); + if (usage.window == null && usage.billingPeriodWindow == null) + return invalid("getCreditRateLimitStatus returned no usage windows"); + const primary = window(usage.window, true, usage.resetsInSeconds); + const secondary = window(usage.billingPeriodWindow, false, undefined); + const tier = text(workspace.subscription_tier)?.trim(); + const result = { + primary, + secondary, + empty: !primary && !secondary, + identity: { + email: text(user?.email), + accountID: userID, + organization: text(workspace.name), + loginMethod: tier ? tier[0].toUpperCase() + tier.slice(1) : undefined, + }, + }; + if (availability !== "manual") ctx.browser.acceptCookie(domain, session); + return result; + } catch (error) { + if ((error as { failureKind?: string }).failureKind !== "authentication-expired") throw error; + ctx.browser.rejectCookie(domain, session); + if (session.cachedAt === undefined) throw error; + } + } + throw ctx.fail.missingCredential("No Notion cookies found. Sign in to Notion and refresh once to import them."); + }, +}); diff --git a/Sources/CodexBarCore/Resources/Plugins/provider-plugin-prelude.js b/Sources/CodexBarCore/Resources/Plugins/provider-plugin-prelude.js index c8c3ed6a0f..b5e1949423 100644 --- a/Sources/CodexBarCore/Resources/Plugins/provider-plugin-prelude.js +++ b/Sources/CodexBarCore/Resources/Plugins/provider-plugin-prelude.js @@ -127,6 +127,9 @@ availability(domain) { return host.cookieAvailability(String(domain)); }, + acceptCookie(domain, session) { + host.acceptCookie(String(domain), String(session.id)); + }, rejectCookie(domain, session) { host.rejectCookie(String(domain), session === undefined ? "" : String(session.id)); }, diff --git a/Sources/CodexBarCore/Resources/Plugins/zoommate.js b/Sources/CodexBarCore/Resources/Plugins/zoommate.js new file mode 100644 index 0000000000..2de90ae93e --- /dev/null +++ b/Sources/CodexBarCore/Resources/Plugins/zoommate.js @@ -0,0 +1,312 @@ +function _nullishCoalesce(lhs, rhsFn) { + if (lhs != null) { + return lhs; + } else { + return rhsFn(); + } +} +async function _asyncNullishCoalesce(lhs, rhsFn) { + if (lhs != null) { + return lhs; + } else { + return await rhsFn(); + } +} +function _optionalChain(ops) { + let lastAccessLHS = undefined; + let value = ops[0]; + let i = 1; + while (i < ops.length) { + const op = ops[i]; + const fn = ops[i + 1]; + i += 2; + if ((op === "optionalAccess" || op === "optionalCall") && value == null) { + return undefined; + } + if (op === "access" || op === "optionalAccess") { + lastAccessLHS = value; + value = fn(value); + } else if (op === "call" || op === "optionalCall") { + value = fn((...args) => value.call(lastAccessLHS, ...args)); + lastAccessLHS = undefined; + } + } + return value; +} +defineProvider({ + id: "zoommate", + name: "ZoomMate", + settings: [ + { key: "AUTHORIZATION", title: "Captured authorization", type: "secure" }, + { key: "HEADERS", title: "Captured headers", type: "secure" }, + { key: "HOST", title: "Captured host", type: "plain" }, + ], + endpoints: ["https://ai.zoom.us", "https://zoommate.zoom.us"], + capabilities: ["browser-cookies", "http-status"], + cookieDomains: ["zoom.us", "ai.zoom.us", "zoommate.zoom.us"], + cookiePolicy: { + selection: "request-url", + cache: "validated-single-entry", + imports: "access-gated", + missingCookies: "omit", + }, + async fetchUsage(ctx) { + const object = (value) => + value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined; + const text = (value) => (typeof value === "string" ? value : undefined); + const invalid = (message) => { + throw Object.assign(ctx.fail.parseFailure(`Could not parse ZoomMate usage: ${message}`), { + failureKind: "parse-failure", + }); + }; + const numeric = (value) => { + if (value === undefined || value === null) return undefined; + return typeof value === "number" && Number.isFinite(value) ? value : invalid("invalid numeric field"); + }; + const manualHost = ctx.settings.get("HOST"); + const hosts = + manualHost === "zoommate.zoom.us" ? ["zoommate.zoom.us", "ai.zoom.us"] : ["ai.zoom.us", "zoommate.zoom.us"]; + const domain = hosts[0]; + const availability = ctx.browser.availability(domain); + if (availability === "off") throw ctx.fail.missingCredential("ZoomMate cookies are disabled."); + const headers = { + Accept: "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + ...JSON.parse(ctx.settings.getSecret("HEADERS") || "{}"), + Origin: "https://zoommate.zoom.us", + Referer: "https://zoommate.zoom.us", + }; + const bearer = (token) => (/^bearer /i.test(token.trim()) ? token.trim() : `Bearer ${token.trim()}`); + const failover = async (operation) => { + let failure; + for (const host of hosts) { + try { + return await operation(host); + } catch (error) { + const classified = error; + if ( + classified.transportClass === "cancelled" || + ["authentication-expired", "parse-failure"].includes(_nullishCoalesce(classified.failureKind, () => "")) + ) + throw error; + failure = error; + } + } + throw failure; + }; + let lastError; + for await (const session of ctx.browser.sessions(domain)) { + const request = async (host, path, token) => { + const response = await ctx.http.get(`https://${host}/ai-computer/api/v1/${path}`, { + headers: token ? { ...headers, Authorization: bearer(token) } : headers, + cookieSession: session.id, + }); + if (response.status === 401 || response.status === 403) + throw Object.assign( + ctx.fail.authenticationExpired( + "ZoomMate rejected the current credentials. Sign in again or paste a fresh cURL capture.", + ), + { failureKind: "authentication-expired" }, + ); + if (response.status !== 200) throw ctx.fail.apiFailure(`ZoomMate HTTP ${response.status}`); + let parsed; + try { + parsed = JSON.parse(response.bodyText); + } catch (error) { + void error; + return invalid("invalid JSON"); + } + return _nullishCoalesce( + object(_optionalChain([object, "call", (_) => _(parsed), "optionalAccess", (_2) => _2.data])), + () => invalid("missing data object"), + ); + }; + const key = session.cacheKey; + const evict = () => { + if (key) ctx.cache.set(key, null, 1); + }; + try { + let minted; + if (availability === "manual") { + const token = ctx.settings.getSecret("AUTHORIZATION"); + if (!token) + throw ctx.fail.missingCredential("Paste a cURL capture of the HTTPS ZoomMate credits/status request."); + minted = { token }; + } else { + const cached = key ? ctx.cache.get(key) : null; + if (cached) minted = cached; + else { + const login = await failover((host) => request(host, "login/?continue=https%3A%2F%2Fzoommate.zoom.us%2F")); + const token = text(login.nak); + if (!token) return invalid("missing nak in login bootstrap response"); + minted = { + token, + email: + _optionalChain([ + text, + "call", + (_3) => + _3( + _optionalChain([ + object, + "call", + (_4) => _4(login.user_profile), + "optionalAccess", + (_5) => _5.email, + ]), + ), + "optionalAccess", + (_6) => _6.trim, + "call", + (_7) => _7(), + ]) || undefined, + }; + try { + const exp = ctx.jwt.decode(token.replace(/^bearer /i, "")).exp; + const ttl = typeof exp === "number" ? exp - ctx.date.now().getTime() / 1000 - 60 : 0; + if (key && ttl > 0) ctx.cache.set(key, minted, ttl); + } catch (error) { + void error; + /* An unreadable JWT is usable for this request but is never cached. */ + } + } + ctx.browser.acceptCookie(domain, session); + } + const status = await _asyncNullishCoalesce( + object((await failover((host) => request(host, "credits/status", minted.token))).credit_status), + async () => invalid("missing credit_status object"), + ); + for (const field of ["budget_cap", "used_credit", "remaining_credit", "overage_credit"]) numeric(status[field]); + for (const field of ["allow_overage", "is_quota_available", "is_unlimited"]) { + if (status[field] != null && typeof status[field] !== "boolean") return invalid(`invalid ${field}`); + } + for (const field of ["cycle_start_date", "cycle_end_date"]) { + const value = numeric(status[field]); + if (value !== undefined && !Number.isSafeInteger(value)) return invalid(`invalid ${field}`); + } + const cap = _nullishCoalesce(numeric(status.budget_cap), () => 0), + used = _nullishCoalesce(numeric(status.used_credit), () => 0); + const unlimited = status.is_unlimited === true || cap <= 0; + const cycleEnd = numeric(status.cycle_end_date), + cycleStart = numeric(status.cycle_start_date); + const usedPercent = unlimited ? 0 : ctx.pct(used, cap); + const now = ctx.date.now(); + const start = new Date(now); + start.setDate(start.getDate() - 30); + let history; + try { + history = await failover(async (host) => { + const records = []; + for (let page = 0; page < 20; page++) { + const path = `credits/history?app_id=demo_app&limit=50&page=${page}&sort_by=time&sort_order=desc&start_time=${encodeURIComponent(start.toISOString())}&end_time=${encodeURIComponent(now.toISOString())}`; + const data = await request(host, path, minted.token); + if (data.records != null && !Array.isArray(data.records)) return invalid("invalid history records"); + const rows = _nullishCoalesce(data.records, () => []).map((row) => + _nullishCoalesce(object(row), () => invalid("invalid history record")), + ); + for (const row of rows) { + numeric(row.cost); + for (const field of ["session_id", "title", "time"]) { + if (row[field] != null && typeof row[field] !== "string") return invalid(`invalid history ${field}`); + } + for (const field of ["is_running", "is_deleted"]) { + if (row[field] != null && typeof row[field] !== "boolean") return invalid(`invalid history ${field}`); + } + } + records.push(...rows); + const total = _nullishCoalesce(numeric(data.total), () => records.length); + if ( + !rows.length || + (page + 1) * 50 >= total || + rows.every((row) => { + const date = text(row.time); + return date !== undefined && new Date(date).getTime() < start.getTime(); + }) + ) + break; + } + return records; + }); + } catch (error) { + if (error.transportClass === "cancelled") throw error; + if (error.failureKind === "authentication-expired") evict(); + } + const details = []; + if (history) { + const day = (date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + const since = new Date(now); + since.setDate(since.getDate() - 29); + since.setHours(0, 0, 0, 0); + const totals = {}; + for (const record of history) { + const cost = numeric(record.cost), + timestamp = text(record.time); + if (record.is_deleted === true || cost === undefined || cost < 0 || !timestamp) continue; + const date = new Date(timestamp); + if (!Number.isFinite(date.getTime()) || date < since) continue; + const key = day(date); + totals[key] = _nullishCoalesce(totals[key], () => 0) + cost; + } + const points = Object.keys(totals) + .sort() + .map((label) => ({ label, value: totals[label] })); + const format = (value) => ctx.format.number(value, { maximumFractionDigits: 2 }); + const rows = [ + { label: "Today", value: format(_nullishCoalesce(totals[day(now)], () => 0)) }, + { label: "30d credits", value: format(points.reduce((sum, point) => sum + point.value, 0)) }, + ]; + if (!unlimited && cycleStart && cycleEnd && cycleEnd > cycleStart) { + const duration = Math.trunc((cycleEnd - cycleStart) / 60000) * 60000; + const remaining = cycleEnd - now.getTime(); + if ( + duration > 0 && + remaining > 0 && + remaining <= duration && + !(remaining === duration && usedPercent > 0) + ) { + const delta = usedPercent - (1 - remaining / duration) * 100; + const amount = Math.round(Math.abs(delta)); + rows.push({ + label: "Pace", + value: + Math.abs(delta) <= 2 + ? "On track" + : delta > 0 + ? `${amount}% ahead of budget` + : `${amount}% behind budget`, + }); + } + } + details.push({ + title: "Credit history", + rows, + chart: points.length ? { kind: "bars", title: "Daily credits", unit: "credits", points } : undefined, + }); + } + return { + primary: { + usedPercent, + resetsAt: !unlimited && cycleEnd && cycleEnd > 0 ? new Date(cycleEnd) : undefined, + resetDescription: "Credits", + }, + details, + identity: { email: minted.email, loginMethod: minted.email ? "Cookie" : undefined }, + }; + } catch (error) { + if (error.failureKind !== "authentication-expired" || availability === "manual") throw error; + evict(); + ctx.browser.rejectCookie(domain, session); + lastError = error; + } + } + throw _nullishCoalesce(lastError, () => + ctx.fail.missingCredential("No ZoomMate session is cached and no session cookies were imported from Chrome."), + ); + }, +}); diff --git a/Sources/CodexBarCore/Resources/Plugins/zoommate.ts b/Sources/CodexBarCore/Resources/Plugins/zoommate.ts new file mode 100644 index 0000000000..d3db79c0d9 --- /dev/null +++ b/Sources/CodexBarCore/Resources/Plugins/zoommate.ts @@ -0,0 +1,257 @@ +defineProvider({ + id: "zoommate", + name: "ZoomMate", + settings: [ + { key: "AUTHORIZATION", title: "Captured authorization", type: "secure" }, + { key: "HEADERS", title: "Captured headers", type: "secure" }, + { key: "HOST", title: "Captured host", type: "plain" }, + ], + endpoints: ["https://ai.zoom.us", "https://zoommate.zoom.us"], + capabilities: ["browser-cookies", "http-status"], + cookieDomains: ["zoom.us", "ai.zoom.us", "zoommate.zoom.us"], + cookiePolicy: { + selection: "request-url", + cache: "validated-single-entry", + imports: "access-gated", + missingCookies: "omit", + }, + async fetchUsage(ctx) { + type ObjectValue = Record; + type Token = { token: string; email?: string }; + const object = (value: unknown): ObjectValue | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) ? (value as ObjectValue) : undefined; + const text = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined); + const invalid = (message: string): never => { + throw Object.assign(ctx.fail.parseFailure(`Could not parse ZoomMate usage: ${message}`), { + failureKind: "parse-failure", + }); + }; + const numeric = (value: unknown): number | undefined => { + if (value === undefined || value === null) return undefined; + return typeof value === "number" && Number.isFinite(value) ? value : invalid("invalid numeric field"); + }; + const manualHost = ctx.settings.get("HOST"); + const hosts = + manualHost === "zoommate.zoom.us" ? ["zoommate.zoom.us", "ai.zoom.us"] : ["ai.zoom.us", "zoommate.zoom.us"]; + const domain = hosts[0]; + const availability = ctx.browser.availability(domain); + if (availability === "off") throw ctx.fail.missingCredential("ZoomMate cookies are disabled."); + const headers: Record = { + Accept: "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + ...JSON.parse(ctx.settings.getSecret("HEADERS") || "{}"), + Origin: "https://zoommate.zoom.us", + Referer: "https://zoommate.zoom.us", + }; + const bearer = (token: string): string => + /^bearer /i.test(token.trim()) ? token.trim() : `Bearer ${token.trim()}`; + const failover = async (operation: (host: string) => Promise): Promise => { + let failure: unknown; + for (const host of hosts) { + try { + return await operation(host); + } catch (error) { + const classified = error as CodexBarHTTPError & { failureKind?: string }; + if ( + classified.transportClass === "cancelled" || + ["authentication-expired", "parse-failure"].includes(classified.failureKind ?? "") + ) + throw error; + failure = error; + } + } + throw failure; + }; + let lastError: unknown; + for await (const session of ctx.browser.sessions(domain)) { + const request = async (host: string, path: string, token?: string): Promise => { + const response = await ctx.http.get(`https://${host}/ai-computer/api/v1/${path}`, { + headers: token ? { ...headers, Authorization: bearer(token) } : headers, + cookieSession: session.id, + }); + if (response.status === 401 || response.status === 403) + throw Object.assign( + ctx.fail.authenticationExpired( + "ZoomMate rejected the current credentials. Sign in again or paste a fresh cURL capture.", + ), + { failureKind: "authentication-expired" }, + ); + if (response.status !== 200) throw ctx.fail.apiFailure(`ZoomMate HTTP ${response.status}`); + let parsed: unknown; + try { + parsed = JSON.parse(response.bodyText); + } catch (error) { + void error; + return invalid("invalid JSON"); + } + return object(object(parsed)?.data) ?? invalid("missing data object"); + }; + const key = session.cacheKey; + const evict = () => { + if (key) ctx.cache.set(key, null, 1); + }; + try { + let minted: Token; + if (availability === "manual") { + const token = ctx.settings.getSecret("AUTHORIZATION"); + if (!token) + throw ctx.fail.missingCredential("Paste a cURL capture of the HTTPS ZoomMate credits/status request."); + minted = { token }; + } else { + const cached = key ? ctx.cache.get(key) : null; + if (cached) minted = cached; + else { + const login = await failover((host) => request(host, "login/?continue=https%3A%2F%2Fzoommate.zoom.us%2F")); + const token = text(login.nak); + if (!token) return invalid("missing nak in login bootstrap response"); + minted = { token, email: text(object(login.user_profile)?.email)?.trim() || undefined }; + try { + const exp = ctx.jwt.decode<{ exp?: number }>(token.replace(/^bearer /i, "")).exp; + const ttl = typeof exp === "number" ? exp - ctx.date.now().getTime() / 1000 - 60 : 0; + if (key && ttl > 0) ctx.cache.set(key, minted, ttl); + } catch (error) { + void error; + /* An unreadable JWT is usable for this request but is never cached. */ + } + } + ctx.browser.acceptCookie(domain, session); + } + const status = + object((await failover((host) => request(host, "credits/status", minted.token))).credit_status) ?? + invalid("missing credit_status object"); + for (const field of ["budget_cap", "used_credit", "remaining_credit", "overage_credit"]) numeric(status[field]); + for (const field of ["allow_overage", "is_quota_available", "is_unlimited"]) { + if (status[field] != null && typeof status[field] !== "boolean") return invalid(`invalid ${field}`); + } + for (const field of ["cycle_start_date", "cycle_end_date"]) { + const value = numeric(status[field]); + if (value !== undefined && !Number.isSafeInteger(value)) return invalid(`invalid ${field}`); + } + const cap = numeric(status.budget_cap) ?? 0, + used = numeric(status.used_credit) ?? 0; + const unlimited = status.is_unlimited === true || cap <= 0; + const cycleEnd = numeric(status.cycle_end_date), + cycleStart = numeric(status.cycle_start_date); + const usedPercent = unlimited ? 0 : ctx.pct(used, cap); + const now = ctx.date.now(); + const start = new Date(now); + start.setDate(start.getDate() - 30); + let history: ObjectValue[] | undefined; + try { + history = await failover(async (host) => { + const records: ObjectValue[] = []; + for (let page = 0; page < 20; page++) { + const path = `credits/history?app_id=demo_app&limit=50&page=${page}&sort_by=time&sort_order=desc&start_time=${encodeURIComponent(start.toISOString())}&end_time=${encodeURIComponent(now.toISOString())}`; + const data = await request(host, path, minted.token); + if (data.records != null && !Array.isArray(data.records)) return invalid("invalid history records"); + const rows = ((data.records as unknown[] | undefined) ?? []).map( + (row) => object(row) ?? invalid("invalid history record"), + ); + for (const row of rows) { + numeric(row.cost); + for (const field of ["session_id", "title", "time"]) { + if (row[field] != null && typeof row[field] !== "string") return invalid(`invalid history ${field}`); + } + for (const field of ["is_running", "is_deleted"]) { + if (row[field] != null && typeof row[field] !== "boolean") return invalid(`invalid history ${field}`); + } + } + records.push(...rows); + const total = numeric(data.total) ?? records.length; + if ( + !rows.length || + (page + 1) * 50 >= total || + rows.every((row) => { + const date = text(row.time); + return date !== undefined && new Date(date).getTime() < start.getTime(); + }) + ) + break; + } + return records; + }); + } catch (error) { + if ((error as CodexBarHTTPError).transportClass === "cancelled") throw error; + if ((error as { failureKind?: string }).failureKind === "authentication-expired") evict(); + } + const details: CodexBarDetailSection[] = []; + if (history) { + const day = (date: Date): string => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + const since = new Date(now); + since.setDate(since.getDate() - 29); + since.setHours(0, 0, 0, 0); + const totals: Record = {}; + for (const record of history) { + const cost = numeric(record.cost), + timestamp = text(record.time); + if (record.is_deleted === true || cost === undefined || cost < 0 || !timestamp) continue; + const date = new Date(timestamp); + if (!Number.isFinite(date.getTime()) || date < since) continue; + const key = day(date); + totals[key] = (totals[key] ?? 0) + cost; + } + const points = Object.keys(totals) + .sort() + .map((label) => ({ label, value: totals[label] })); + const format = (value: number) => ctx.format.number(value, { maximumFractionDigits: 2 }); + const rows: CodexBarDetailRow[] = [ + { label: "Today", value: format(totals[day(now)] ?? 0) }, + { label: "30d credits", value: format(points.reduce((sum, point) => sum + point.value, 0)) }, + ]; + if (!unlimited && cycleStart && cycleEnd && cycleEnd > cycleStart) { + const duration = Math.trunc((cycleEnd - cycleStart) / 60000) * 60000; + const remaining = cycleEnd - now.getTime(); + if ( + duration > 0 && + remaining > 0 && + remaining <= duration && + !(remaining === duration && usedPercent > 0) + ) { + const delta = usedPercent - (1 - remaining / duration) * 100; + const amount = Math.round(Math.abs(delta)); + rows.push({ + label: "Pace", + value: + Math.abs(delta) <= 2 + ? "On track" + : delta > 0 + ? `${amount}% ahead of budget` + : `${amount}% behind budget`, + }); + } + } + details.push({ + title: "Credit history", + rows, + chart: points.length ? { kind: "bars", title: "Daily credits", unit: "credits", points } : undefined, + }); + } + return { + primary: { + usedPercent, + resetsAt: !unlimited && cycleEnd && cycleEnd > 0 ? new Date(cycleEnd) : undefined, + resetDescription: "Credits", + }, + details, + identity: { email: minted.email, loginMethod: minted.email ? "Cookie" : undefined }, + }; + } catch (error) { + if ((error as { failureKind?: string }).failureKind !== "authentication-expired" || availability === "manual") + throw error; + evict(); + ctx.browser.rejectCookie(domain, session); + lastError = error; + } + } + throw ( + lastError ?? + ctx.fail.missingCredential("No ZoomMate session is cached and no session cookies were imported from Chrome.") + ); + }, +}); diff --git a/Tests/CodexBarTests/NotionMenuCardModelTests.swift b/Tests/CodexBarTests/NotionMenuCardModelTests.swift index 9f804de4a1..2f893fbda0 100644 --- a/Tests/CodexBarTests/NotionMenuCardModelTests.swift +++ b/Tests/CodexBarTests/NotionMenuCardModelTests.swift @@ -13,28 +13,18 @@ struct NotionMenuCardModelTests { private static let periodEnd = Date(timeIntervalSince1970: 1_772_323_200) private static func snapshot() -> UsageSnapshot { - NotionUsageSnapshot( - rateLimit: NotionCreditRateLimitStatus( - status: "enforced", - window: NotionRollingWindow( - creditType: nil, - scope: nil, - window: "6h", - used: 50, - limit: 100), - resetsInSeconds: 3600, - billingPeriodWindow: NotionBillingPeriodWindow( - creditType: nil, - scope: nil, - cadence: nil, - used: 40, - limit: 100, - periodEndMs: self.periodEnd.timeIntervalSince1970 * 1000), - enforcement: nil), - workspace: nil, - account: nil, + UsageSnapshot( + primary: RateWindow( + usedPercent: 50, + windowMinutes: 360, + resetsAt: self.now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 43200, + resetsAt: self.periodEnd, + resetDescription: nil), updatedAt: self.now) - .toUsageSnapshot() } private static func model(weeklyPace: UsagePace?) throws -> UsageMenuCardView.Model { diff --git a/Tests/CodexBarTests/NotionProviderTests.swift b/Tests/CodexBarTests/NotionProviderTests.swift new file mode 100644 index 0000000000..b37909d708 --- /dev/null +++ b/Tests/CodexBarTests/NotionProviderTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct NotionProviderTests { + private static let now = Date(timeIntervalSince1970: 1_785_600_000) + + @Test + func `manual tokens headers and captures are normalized without exposing cookies to scripts`() { + #expect(NotionProviderDescriptor.manualHeader("fixture") == "token_v2=fixture") + #expect(NotionProviderDescriptor + .manualHeader("Cookie: token_v2=fixture; extra=value") == "token_v2=fixture; extra=value") + #expect(NotionProviderDescriptor + .manualHeader("curl https://app.notion.com -H 'Cookie: token_v2=fixture'") == "token_v2=fixture") + #expect(NotionProviderDescriptor.manualHeader("") == nil) + } + + private static func utcDate(year: Int, month: Int, day: Int) throws -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + return try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: year, + month: month, + day: day))) + } + + private static func monthlyWindow(usedPercent: Double, resetsAt: Date) -> RateWindow { + RateWindow( + usedPercent: usedPercent, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: resetsAt, + resetDescription: nil) + } + + @Test + func `scores the billing window against the real calendar month`() throws { + // The sentinel is a placeholder, not a duration: resolution has to yield the true length of the + // cycle ending at the reset. Asserting only the capability booleans would stay green if the + // descriptor were swapped for a plain 30-day capability, which is the regression to catch. + let pace = ProviderDescriptorRegistry.descriptor(for: .notion).pace + let februaryCycle = try Self.monthlyWindow( + usedPercent: 18, + resetsAt: Self.utcDate(year: 2026, month: 3, day: 1)) + let mayCycle = try Self.monthlyWindow( + usedPercent: 18, + resetsAt: Self.utcDate(year: 2026, month: 6, day: 1)) + + #expect(pace.resolvedResetWindowForPace(februaryCycle).windowMinutes == 28 * 24 * 60) + #expect(pace.resolvedResetWindowForPace(mayCycle).windowMinutes == 31 * 24 * 60) + #expect(pace.resolvedResetWindowForPace(februaryCycle).resetsAt == februaryCycle.resetsAt) + #expect(pace.resolvedResetWindowForPace(februaryCycle).usedPercent == februaryCycle.usedPercent) + } + + @Test + func `a billing window with no length is scored against the caller's default`() throws { + // A nil length is not pace-safe on its own: `UsagePace.weekly` substitutes `defaultWindowMinutes` + // rather than skipping the window, so dropping the sentinel would score a month against a week. + let resetsAt = try Self.utcDate(year: 2026, month: 3, day: 1) + let now = resetsAt.addingTimeInterval(-3 * 24 * 60 * 60) + let lengthless = RateWindow(usedPercent: 37, windowMinutes: nil, resetsAt: resetsAt, resetDescription: nil) + + let weekScored = try #require(UsagePace.weekly(window: lengthless, now: now, defaultWindowMinutes: 10080)) + // Four of seven days elapsed against a week that is really a month. + #expect((weekScored.expectedUsedPercent * 10).rounded() / 10 == 57.1) + + let resolved = ProviderDescriptorRegistry.descriptor(for: .notion).pace + .resolvedResetWindowForPace(Self.monthlyWindow(usedPercent: 37, resetsAt: resetsAt)) + let cycleScored = try #require(UsagePace.weekly(window: resolved, now: now, defaultWindowMinutes: 10080)) + // Twenty-five of February's twenty-eight days elapsed. + #expect((cycleScored.expectedUsedPercent * 10).rounded() / 10 == 89.3) + } + + @Test + func `does not treat the rolling window as a monthly one`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .notion) + let rolling = RateWindow( + usedPercent: 42.5, + windowMinutes: 360, + resetsAt: Self.now.addingTimeInterval(3600), + resetDescription: nil) + + #expect(!descriptor.pace.usesInferredMonthlyDuration(window: rolling)) + } +} diff --git a/Tests/CodexBarTests/NotionSessionStoreTests.swift b/Tests/CodexBarTests/NotionSessionStoreTests.swift deleted file mode 100644 index 5291dd44dc..0000000000 --- a/Tests/CodexBarTests/NotionSessionStoreTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -import Testing -@testable import CodexBarCore - -#if os(macOS) - -struct NotionSessionStoreTests { - @Test - func `session files are owner only and round trip`() async throws { - let (directory, fileURL) = try Self.makeSessionLocation() - defer { try? FileManager.default.removeItem(at: directory) } - let writer = NotionSessionStore(fileURL: fileURL) - await writer.setSession(tokenV2: "stored-token", sourceLabel: "Chrome") - - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - let permissions = try #require(attributes[.posixPermissions] as? NSNumber) - #expect(permissions.intValue & 0o777 == 0o600) - - let reader = NotionSessionStore(fileURL: fileURL) - let session = try #require(await reader.getSession()) - #expect(session.tokenV2 == "stored-token") - #expect(session.cookieHeader == "token_v2=stored-token") - #expect(session.sourceLabel == "Chrome") - } - - @Test - func `loading repairs legacy session file permissions`() async throws { - let (directory, fileURL) = try Self.makeSessionLocation() - defer { try? FileManager.default.removeItem(at: directory) } - let writer = NotionSessionStore(fileURL: fileURL) - await writer.setSession(tokenV2: "legacy-token", sourceLabel: "Chrome") - try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: fileURL.path) - - let reader = NotionSessionStore(fileURL: fileURL) - #expect(await reader.getSession()?.tokenV2 == "legacy-token") - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - let permissions = try #require(attributes[.posixPermissions] as? NSNumber) - #expect(permissions.intValue & 0o777 == 0o600) - } - - private static func makeSessionLocation() throws -> (URL, URL) { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("codexbar-notion-session-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return (directory, directory.appendingPathComponent("notion-session.json")) - } -} - -#endif diff --git a/Tests/CodexBarTests/NotionUsageFetcherTests.swift b/Tests/CodexBarTests/NotionUsageFetcherTests.swift deleted file mode 100644 index 368fb714bd..0000000000 --- a/Tests/CodexBarTests/NotionUsageFetcherTests.swift +++ /dev/null @@ -1,426 +0,0 @@ -import Foundation -import Testing -@testable import CodexBarCore - -struct NotionUsageFetcherTests { - private static let now = Date(timeIntervalSince1970: 1_785_600_000) - /// Billing period end reported by `getCreditRateLimitStatus` (milliseconds since epoch). - private static let periodEndMilliseconds = 1_788_000_000_000 - private static let periodEndSeconds = Self.periodEndMilliseconds / 1000 - private static let rollingResetSeconds = 12600 - - private static let businessSpaceID = "11111111-2222-3333-4444-555555555555" - private static let personalSpaceID = "66666666-7777-8888-9999-aaaaaaaaaaaa" - private static let userID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - - /// Older responses wrap each record once; newer ones wrap twice. Both shapes must parse. - private static let singlyWrappedSpacesResponse = """ - {"\(Self.userID)":{ - "notion_user":{"\(Self.userID)":{"value":{ - "id":"\(Self.userID)","email":"legacy@example.com","name":"Legacy Person"}}}, - "space":{ - "\(Self.businessSpaceID)":{"value":{ - "id":"\(Self.businessSpaceID)","name":"Acme","plan_type":"team","subscription_tier":"business"}}}}} - """ - - private static func rateLimitStatus() throws -> NotionCreditRateLimitStatus { - try NotionUsageParser.parseRateLimitStatus(self.fixtureData("get-credit-rate-limit-status")) - } - - private static func account() throws -> NotionAccount { - try NotionUsageParser.parseSpaces(self.fixtureData("get-spaces")) - } - - private static func fixtureData(_ name: String) throws -> Data { - let url = try #require(Bundle.module.url( - forResource: name, - withExtension: "json", - subdirectory: "Fixtures/Providers/Notion")) - return try Data(contentsOf: url) - } - - @Test - func `parses credit rate limit status`() throws { - let status = try Self.rateLimitStatus() - - #expect(status.status == "within_limit") - #expect(status.enforcement == "preview") - #expect(status.window?.window == "6h") - #expect(status.window?.used == 42.5) - #expect(status.window?.limit == 100) - #expect(status.resetsInSeconds == 12600) - #expect(status.billingPeriodWindow?.used == 18.0) - #expect(status.billingPeriodWindow?.cadence == "billing_period") - #expect(status.isNotApplicable == false) - } - - @Test - func `maps rolling and billing windows to usage snapshot`() throws { - let workspace = NotionWorkspace( - id: Self.businessSpaceID, - name: "Acme", - planType: "team", - subscriptionTier: "business") - let account = NotionAccount( - userID: Self.userID, - email: "person@example.com", - name: "Example Person", - workspaces: [workspace]) - let usage = try NotionUsageSnapshot( - rateLimit: Self.rateLimitStatus(), - workspace: workspace, - account: account, - updatedAt: Self.now).toUsageSnapshot() - - #expect(usage.primary?.usedPercent == 42.5) - #expect(usage.primary?.windowMinutes == 360) - #expect( - usage.primary?.resetsAt.map { Int($0.timeIntervalSince1970) } - == Int(Self.now.timeIntervalSince1970) + Self.rollingResetSeconds) - #expect(usage.secondary?.usedPercent == 18.0) - // The monthly sentinel, not nil: it is what makes the provider's pace capability match, which is - // what swaps in the real calendar cycle ending at `resetsAt`. - #expect(usage.secondary?.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) - #expect(usage.secondary?.resetsAt.map { Int($0.timeIntervalSince1970) } == Self.periodEndSeconds) - #expect(usage.identity?.providerID == .notion) - #expect(usage.identity?.accountEmail == "person@example.com") - #expect(usage.identity?.accountOrganization == "Acme") - #expect(usage.identity?.loginMethod == "Business") - } - - @Test - func `flags workspaces without an allowance`() throws { - let status = try NotionUsageParser.parseRateLimitStatus(Data(#"{"status":"not_applicable"}"#.utf8)) - - #expect(status.isNotApplicable) - #expect(status.window == nil) - #expect(status.billingPeriodWindow == nil) - } - - @Test - func `parses spaces payload into account and workspaces`() throws { - let account = try Self.account() - - #expect(account.userID == Self.userID) - #expect(account.email == "person@example.com") - #expect(account.name == "Example Person") - #expect(account.workspaces.count == 2) - #expect(account.workspaces.contains { $0.id == Self.businessSpaceID && $0.name == "Acme" }) - } - - @Test - func `prefers a workspace whose plan carries an allowance`() throws { - let account = try Self.account() - - // The personal/free space sorts first by id but reports `not_applicable`, so it must not win. - #expect(account.resolveWorkspace()?.id == Self.businessSpaceID) - } - - @Test - func `honours a configured workspace id in either uuid form`() throws { - let account = try Self.account() - let undashed = Self.personalSpaceID.replacingOccurrences(of: "-", with: "") - - #expect(account.resolveWorkspace(preferredID: Self.personalSpaceID)?.id == Self.personalSpaceID) - #expect(account.resolveWorkspace(preferredID: undashed)?.id == Self.personalSpaceID) - } - - @Test - func `falls back to the first workspace when none carries an allowance`() { - let account = NotionAccount( - userID: Self.userID, - email: nil, - name: nil, - workspaces: [ - NotionWorkspace( - id: Self.personalSpaceID, - name: "Personal", - planType: "personal", - subscriptionTier: "free"), - ]) - - #expect(account.resolveWorkspace()?.id == Self.personalSpaceID) - } - - @Test - func `converts notion window tokens to minutes`() { - #expect(NotionUsageSnapshot.minutes(fromWindowToken: "6h") == 360) - #expect(NotionUsageSnapshot.minutes(fromWindowToken: "30m") == 30) - #expect(NotionUsageSnapshot.minutes(fromWindowToken: "7d") == 10080) - #expect(NotionUsageSnapshot.minutes(fromWindowToken: "1w") == 10080) - #expect(NotionUsageSnapshot.minutes(fromWindowToken: "weekly") == nil) - #expect(NotionUsageSnapshot.minutes(fromWindowToken: nil) == nil) - } - - @Test - func `scales usage against the reported limit`() { - #expect(NotionUsageSnapshot.percent(used: 25, limit: 50) == 50) - #expect(NotionUsageSnapshot.percent(used: 42.5, limit: 100) == 42.5) - // Over-quota values are preserved; display clamping happens downstream. - #expect(NotionUsageSnapshot.percent(used: 120, limit: 100) == 120) - // Without a usable limit there is nothing to measure against, so no percentage is invented. - #expect(NotionUsageSnapshot.percent(used: nil, limit: 100) == nil) - #expect(NotionUsageSnapshot.percent(used: 42, limit: 0) == nil) - #expect(NotionUsageSnapshot.percent(used: 42, limit: nil) == nil) - } - - @Test - func `omits a window that carries no measurable allowance`() { - let status = NotionCreditRateLimitStatus( - status: "within_limit", - window: NotionRollingWindow( - creditType: "basic_ai_credits", - scope: "per_user", - window: "6h", - used: 42, - limit: nil), - resetsInSeconds: 60, - billingPeriodWindow: nil, - enforcement: "preview") - let usage = NotionUsageSnapshot( - rateLimit: status, - workspace: nil, - account: nil, - updatedAt: Self.now).toUsageSnapshot() - - // A fabricated 0% here would read as "plenty of headroom" on a workspace that may be capped. - #expect(usage.primary == nil) - #expect(usage.secondary == nil) - } - - @Test - func `rejects a response that carries no usage windows`() { - let body = Data(#"{"errorId":"abc","name":"UnauthorizedError"}"#.utf8) - - #expect(throws: NotionUsageError.parseFailed("getCreditRateLimitStatus returned no usage windows.")) { - try NotionUsageParser.parseRateLimitStatus(body) - } - } - - @Test - func `keeps a reset that lands exactly now`() { - #expect(NotionUsageSnapshot.rollingReset(from: 0, now: Self.now) == Self.now) - #expect(NotionUsageSnapshot.rollingReset(from: -1, now: Self.now) == nil) - } - - @Test - func `builds a request context from a manual cookie header`() { - let context = NotionUsageFetcher.requestContext(from: "token_v2=abc; notion_user_id=def") - - #expect(context?.cookieHeader.contains("token_v2=abc") == true) - #expect(NotionUsageFetcher.requestContext(from: " ") == nil) - } - - @Test - func `names a manually pasted bare token v2 value`() { - let context = NotionUsageFetcher.requestContext(from: "bare-token-value") - - #expect(context?.cookieHeader == "token_v2=bare-token-value") - } - - @Test - func `defaults automatic imports to Chrome only`() { - #if os(macOS) - #expect(NotionProviderDescriptor.descriptor.metadata.browserCookieOrder == [.chrome]) - #else - #expect(NotionProviderDescriptor.descriptor.metadata.browserCookieOrder == nil) - #endif - } - - @Test - func `parses singly wrapped records`() throws { - let account = try NotionUsageParser.parseSpaces(Data(Self.singlyWrappedSpacesResponse.utf8)) - - #expect(account.email == "legacy@example.com") - #expect(account.workspaces.count == 1) - #expect(account.workspaces.first?.name == "Acme") - } - - @Test - func `refuses a spaces payload naming more than one user`() { - let second = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" - let body = """ - {"\(Self.userID)":{"notion_user":{"\(Self.userID)":{"value":{"value":{"id":"\(Self.userID)"}}}}}, - "\(second)":{"notion_user":{"\(second)":{"value":{"value":{"id":"\(second)"}}}}}} - """ - - // Binding to whichever key sorts first would report the wrong account's allowance. - #expect(throws: NotionUsageError.parseFailed("getSpaces response did not identify a single user.")) { - try NotionUsageParser.parseSpaces(Data(body.utf8)) - } - } - - @Test - func `falls back to auto selection when the configured workspace id is unknown`() throws { - let account = try Self.account() - - // A typo'd id would otherwise be queried anyway and answered with an opaque 403. - #expect(account.resolveWorkspace(preferredID: "00000000-0000-0000-0000-000000000000")?.id - == Self.businessSpaceID) - } - - // MARK: - Transport-backed behaviour - - private struct StubResponse: Sendable { - let statusCode: Int - let body: Data - } - - private struct StubTransport: ProviderHTTPTransport { - let spaces: StubResponse - let rateLimit: StubResponse - - func data(for request: URLRequest) async throws -> (Data, URLResponse) { - let stub = (request.url?.path.hasSuffix("getSpaces") ?? false) ? self.spaces : self.rateLimit - guard let url = request.url, - let response = HTTPURLResponse( - url: url, - statusCode: stub.statusCode, - httpVersion: nil, - headerFields: nil) - else { - throw URLError(.badServerResponse) - } - return (stub.body, response) - } - } - - private static func fetchUsage(transport: StubTransport, preferredSpaceID: String? = nil) async throws - -> NotionUsageSnapshot - { - try await NotionUsageFetcher.fetchUsage( - context: NotionUsageFetcher.RequestContext(cookieHeader: "token_v2=abc"), - preferredSpaceID: preferredSpaceID, - timeout: 5, - now: self.now, - transport: transport) - } - - @Test - func `maps an unauthorized response to invalid credentials`() async throws { - let transport = try StubTransport( - spaces: StubResponse(statusCode: 401, body: Data("{}".utf8)), - rateLimit: StubResponse(statusCode: 200, body: Self.fixtureData("get-credit-rate-limit-status"))) - - await #expect(throws: NotionUsageError.invalidCredentials) { - try await Self.fetchUsage(transport: transport) - } - } - - @Test - func `maps a server error to an api error`() async throws { - let transport = try StubTransport( - spaces: StubResponse(statusCode: 200, body: Self.fixtureData("get-spaces")), - rateLimit: StubResponse(statusCode: 500, body: Data("nope".utf8))) - - await #expect(throws: NotionUsageError.apiError("HTTP 500 from getCreditRateLimitStatus")) { - try await Self.fetchUsage(transport: transport) - } - } - - @Test - func `throws when the resolved workspace has no allowance`() async throws { - let transport = try StubTransport( - spaces: StubResponse(statusCode: 200, body: Self.fixtureData("get-spaces")), - rateLimit: StubResponse(statusCode: 200, body: Data(#"{"status":"not_applicable"}"#.utf8))) - - await #expect(throws: NotionUsageError.allowanceNotApplicable(workspace: "Personal")) { - try await Self.fetchUsage(transport: transport, preferredSpaceID: Self.personalSpaceID) - } - } - - @Test - func `returns a snapshot for a workspace that carries an allowance`() async throws { - let transport = try StubTransport( - spaces: StubResponse(statusCode: 200, body: Self.fixtureData("get-spaces")), - rateLimit: StubResponse(statusCode: 200, body: Self.fixtureData("get-credit-rate-limit-status"))) - - let snapshot = try await Self.fetchUsage(transport: transport) - - #expect(snapshot.workspace?.id == Self.businessSpaceID) - #expect(snapshot.account?.email == "person@example.com") - #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 42.5) - } - - /// Midnight UTC on the given day, so a cycle length is exactly a whole number of days. - private static func utcDate(year: Int, month: Int, day: Int) throws -> Date { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) - return try #require(calendar.date(from: DateComponents( - calendar: calendar, - timeZone: calendar.timeZone, - year: year, - month: month, - day: day))) - } - - private static func monthlyWindow(usedPercent: Double, resetsAt: Date) -> RateWindow { - RateWindow( - usedPercent: usedPercent, - windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, - resetsAt: resetsAt, - resetDescription: nil) - } - - @Test - func `scores the billing window against the real calendar month`() throws { - // The sentinel is a placeholder, not a duration: resolution has to yield the true length of the - // cycle ending at the reset. Asserting only the capability booleans would stay green if the - // descriptor were swapped for a plain 30-day capability, which is the regression to catch. - let pace = ProviderDescriptorRegistry.descriptor(for: .notion).pace - let februaryCycle = try Self.monthlyWindow( - usedPercent: 18, - resetsAt: Self.utcDate(year: 2026, month: 3, day: 1)) - let mayCycle = try Self.monthlyWindow( - usedPercent: 18, - resetsAt: Self.utcDate(year: 2026, month: 6, day: 1)) - - #expect(pace.resolvedResetWindowForPace(februaryCycle).windowMinutes == 28 * 24 * 60) - #expect(pace.resolvedResetWindowForPace(mayCycle).windowMinutes == 31 * 24 * 60) - #expect(pace.resolvedResetWindowForPace(februaryCycle).resetsAt == februaryCycle.resetsAt) - #expect(pace.resolvedResetWindowForPace(februaryCycle).usedPercent == februaryCycle.usedPercent) - } - - @Test - func `a billing window with no length is scored against the caller's default`() throws { - // A nil length is not pace-safe on its own: `UsagePace.weekly` substitutes `defaultWindowMinutes` - // rather than skipping the window, so dropping the sentinel would score a month against a week. - let resetsAt = try Self.utcDate(year: 2026, month: 3, day: 1) - let now = resetsAt.addingTimeInterval(-3 * 24 * 60 * 60) - let lengthless = RateWindow(usedPercent: 37, windowMinutes: nil, resetsAt: resetsAt, resetDescription: nil) - - let weekScored = try #require(UsagePace.weekly(window: lengthless, now: now, defaultWindowMinutes: 10080)) - // Four of seven days elapsed against a week that is really a month. - #expect((weekScored.expectedUsedPercent * 10).rounded() / 10 == 57.1) - - let resolved = ProviderDescriptorRegistry.descriptor(for: .notion).pace - .resolvedResetWindowForPace(Self.monthlyWindow(usedPercent: 37, resetsAt: resetsAt)) - let cycleScored = try #require(UsagePace.weekly(window: resolved, now: now, defaultWindowMinutes: 10080)) - // Twenty-five of February's twenty-eight days elapsed. - #expect((cycleScored.expectedUsedPercent * 10).rounded() / 10 == 89.3) - } - - @Test - func `does not treat the rolling window as a monthly one`() { - let descriptor = ProviderDescriptorRegistry.descriptor(for: .notion) - let rolling = RateWindow( - usedPercent: 42.5, - windowMinutes: 360, - resetsAt: Self.now.addingTimeInterval(3600), - resetDescription: nil) - - #expect(!descriptor.pace.usesInferredMonthlyDuration(window: rolling)) - } - - @Test - func `drops a rolling length that collides with the monthly sentinel`() { - // `30d`, `720h` and `43200m` all parse to the monthly sentinel, which pace matching keys on, so a - // rolling window carrying one would be resolved as a calendar cycle ending hours from now. - #expect(NotionUsageSnapshot.minutes(fromWindowToken: "30d") - == ProviderPaceCapability.monthlyWindowSentinelMinutes) - #expect(NotionUsageSnapshot.rollingMinutes(fromWindowToken: "30d") == nil) - #expect(NotionUsageSnapshot.rollingMinutes(fromWindowToken: "720h") == nil) - #expect(NotionUsageSnapshot.rollingMinutes(fromWindowToken: "43200m") == nil) - #expect(NotionUsageSnapshot.rollingMinutes(fromWindowToken: "6h") == 360) - } -} diff --git a/Tests/CodexBarTests/ProviderPluginPersistentCookieTests.swift b/Tests/CodexBarTests/ProviderPluginPersistentCookieTests.swift new file mode 100644 index 0000000000..ed6171795c --- /dev/null +++ b/Tests/CodexBarTests/ProviderPluginPersistentCookieTests.swift @@ -0,0 +1,249 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ProviderPluginPersistentCookieTests { + @TaskLocal private static var fileURL: URL? + private let domain = "app.notion.com" + + @Test + func `ranked domains stay in one profile and require the session cookie`() throws { + try self.isolated { _ in + let broker = try self.broker(records: [ + [Self.record("analytics", "skip", "app.notion.com")], + [ + Self.record("token_v2", "legacy", "notion.so"), + Self.record("token_v2", "current", "app.notion.com"), + Self.record("other", "from-parent", "notion.com"), + ], + ]) + let session = try #require(try broker.nextSession(domain: self.domain)) + #expect(try self.header(session) == "other=from-parent; token_v2=current") + #expect(CookieHeaderCache.load(provider: .notion) == nil) + try broker.acceptCookie(domain: self.domain, id: session.id) + #expect(CookieHeaderCache.load(provider: .notion) != nil) + } + } + + @Test + func `one validated entry survives refresh with stable identity and no cookie exposure`() throws { + try self.isolated { _ in + let first = try self.broker(records: [[Self.record("token_v2", "fixture", "notion.so")]]) + let session = try #require(try first.nextSession(domain: self.domain)) + #expect(CookieHeaderCache.load(provider: .notion) == nil) + try first.acceptCookie(domain: self.domain, id: session.id) + let next = try self.broker() + let restored = try #require(try next.nextSession(domain: self.domain, cachedOnly: true)) + #expect(session.id != restored.id) + #expect(session.cacheKey == restored.cacheKey) + #expect(try self.header(restored) == "token_v2=fixture") + #expect(try !restored.json(opaque: true).contains("fixture")) + #expect(try next.nextSession(domain: self.domain, cachedOnly: true) == nil) + } + } + + @Test + func `paired session identity is independent of browser record enumeration`() throws { + try self.isolated { _ in + let policy = try self.policy(provider: "zoommate", declaration: """ + cookieDomains: ['zoom.us', 'ai.zoom.us'], endpoints: ['https://ai.zoom.us'], + cookiePolicy: {selection: 'request-url', cache: 'validated-single-entry'}, + """) + let records = try [Self.record("first", "one", "ai.zoom.us"), Self.record("second", "two", "ai.zoom.us")] + let keys = try [records, Array(records.reversed())].map { records in + let broker = ProviderPluginCookieBroker( + provider: .zoommate, + domains: ["zoom.us", "ai.zoom.us"], + settings: .init(cookieSource: .auto, manualCookieHeader: nil), + batches: { _, _ in nil }, + jarImporter: { [.init(header: "", source: "Fixture", origin: "", records: records)] }, + policy: policy) + return try #require(try broker.nextSession(domain: "ai.zoom.us")).cacheKey + } + #expect(keys[0] == keys[1]) + } + } + + @Test + func `late rejection and acceptance preserve newer cache and native file`() throws { + try self.isolated { fileURL in + try Self.write("old", to: fileURL) + let broker = try self.broker(background: true, fileURL: fileURL) + let session = try #require(try broker.nextSession(domain: self.domain)) + CookieHeaderCache.store(provider: .notion, cookieHeader: "token_v2=newer", sourceLabel: "Newer") + try Self.write("newer", to: fileURL) + try broker.acceptCookie(domain: self.domain, id: session.id) + broker.rejectCookie(domain: self.domain, id: session.id) + #expect(CookieHeaderCache.load(provider: .notion)?.cookieHeader == "token_v2=newer") + #expect(try Self.token(fileURL) == "newer") + } + } + + @Test + func `native session file is first in background and is conditionally cleared on rejection`() throws { + try self.isolated { fileURL in + try Self.write("legacy", to: fileURL) + let broker = try self.broker(background: true, fileURL: fileURL) + let session = try #require(try broker.nextSession(domain: self.domain, cachedOnly: true)) + #expect(try self.header(session) == "token_v2=legacy") + broker.rejectCookie(domain: self.domain, id: session.id) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + } + + @Test(arguments: [true, false]) + func `interactive refresh commits or rolls back the cache and native file together`(commit: Bool) throws { + try self.isolated { fileURL in + try Self.write("old", to: fileURL) + CookieHeaderCache.store(provider: .notion, cookieHeader: "token_v2=old", sourceLabel: "Old") + let gate = try #require(CookieHeaderCache.beginRefreshReadSuppression(provider: .notion)) + defer { CookieHeaderCache.endRefreshReadSuppression(gate) } + let broker = try self.broker(records: [[Self.record("token_v2", "new", "notion.so")]], fileURL: fileURL) + let session = try #require(try broker.nextSession(domain: self.domain)) + try broker.acceptCookie(domain: self.domain, id: session.id) + #expect(try Self.token(fileURL) == "old") + if commit { + #expect(CookieHeaderCache.commitRefreshReadSuppression(gate).committedCount == 1) + } else { + CookieHeaderCache.endRefreshReadSuppression(gate) + } + #expect(try Self.token(fileURL) == (commit ? "new" : "old")) + let restored = try #require(try self.broker(fileURL: fileURL).nextSession( + domain: self.domain, + cachedOnly: true)) + #expect(try self.header(restored) == (commit ? "token_v2=new" : "token_v2=old")) + } + } + + @Test + func `failed cache commit retains the native session file`() throws { + try self.isolated { fileURL in + try Self.write("old", to: fileURL) + let gate = try #require(CookieHeaderCache.beginRefreshReadSuppression(provider: .notion)) + defer { CookieHeaderCache.endRefreshReadSuppression(gate) } + let broker = try self.broker(records: [[Self.record("token_v2", "new", "notion.so")]], fileURL: fileURL) + let session = try #require(try broker.nextSession(domain: self.domain)) + try broker.acceptCookie(domain: self.domain, id: session.id) + let result = KeychainCacheStore.withStoreFailureStatusOverrideForTesting(-25308) { + CookieHeaderCache.commitRefreshReadSuppression(gate) + } + #expect(result.failedCount == 1) + #expect(try Self.token(fileURL) == "old") + } + } + + @Test + func `legacy paired host cache migrates without widening destinations`() throws { + try self.isolated { _ in + let policy = try self.policy(provider: "zoommate", declaration: """ + cookieDomains: ['zoom.us', 'ai.zoom.us', 'zoommate.zoom.us'], + endpoints: ['https://ai.zoom.us', 'https://zoommate.zoom.us'], + cookiePolicy: {selection: 'request-url', cache: 'validated-single-entry'}, + """) + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: """ + {"headersByHost":{"ai.zoom.us":"session=ai", + "zoommate.zoom.us":"session=mate","other.zoom.us":"session=bad"}} + """, + sourceLabel: "Legacy") + let broker = ProviderPluginCookieBroker( + provider: .zoommate, + domains: ["zoom.us", "ai.zoom.us", "zoommate.zoom.us"], + settings: .init(cookieSource: .auto, manualCookieHeader: nil), + batches: { _, _ in nil }, + jarImporter: { [] }, + policy: policy) + let session = try #require(try broker.nextSession(domain: "ai.zoom.us", cachedOnly: true)) + let jar = ProviderPluginCookieJar() + jar.register(session) + #expect(try jar + .header(id: session.id, url: #require(URL(string: "https://ai.zoom.us/api"))) == "session=ai") + #expect(try jar + .header(id: session.id, url: #require(URL(string: "https://zoommate.zoom.us/api"))) == "session=mate") + #expect(throws: (any Error).self) { try jar.header( + id: session.id, + url: #require(URL(string: "https://other.zoom.us/api"))) } + try broker.acceptCookie(domain: "ai.zoom.us", id: session.id) + #expect(CookieHeaderCache.load(provider: .zoommate)?.cookieHeader.contains("other.zoom.us") == false) + } + } + + private func policy(provider: String = "notion", declaration: String? = nil) throws -> ProviderPluginCookiePolicy { + let declaration = declaration ?? """ + endpoints: ['https://app.notion.com'], + cookieDomains: ['app.notion.com', 'www.notion.com', 'notion.com', 'www.notion.so', 'notion.so'], + cookiePolicy: {selection: 'ranked-source-domains', cache: 'validated-single-entry', + sourceDomains: ['app.notion.com', 'www.notion.com', 'notion.com', 'www.notion.so', 'notion.so'], + requiredCookies: ['token_v2'], sessionFile: {tokenField: 'tokenV2', cookieName: 'token_v2'}}, + """ + let runtime = try ProviderPluginRuntime(source: """ + defineProvider({id: '\(provider)', name: 'Fixture', settings: [], capabilities: ['browser-cookies'], + \(declaration) async fetchUsage() {return {empty: true};}}); + """) + return try #require(runtime.manifest.cookiePolicy) + } + + private func broker( + records: [[ProviderPluginCookieRecord]] = [], + background: Bool = false, + fileURL: URL? = nil) throws + -> ProviderPluginCookieBroker + { + try ProviderPluginCookieBroker( + provider: .notion, + domains: [ + "app.notion.com", + "www.notion.com", + "notion.com", + "www.notion.so", + "notion.so", + ], + settings: .init(cookieSource: .auto, manualCookieHeader: nil), + batches: { _, _ in nil }, + jarImporter: { records.map { .init( + header: "", + source: "Synthetic profile", + origin: "", + records: $0) } }, + policy: self.policy(), + background: background, + sessionFileURL: fileURL ?? Self.fileURL) + } + + private static func record(_ name: String, _ value: String, _ domain: String) throws -> ProviderPluginCookieRecord { + let cookie = try #require(HTTPCookie(properties: [.name: name, .value: value, .domain: domain, .path: "/"])) + return ProviderPluginCookieRecord(cookie: cookie) + } + + private func header(_ session: ProviderPluginCookieSession) throws -> String { + try ProviderPluginCookieJar.header( + for: session, + url: #require(URL(string: "https://\(self.domain)/api/v3/getSpaces"))) + } + + private static func write(_ token: String, to url: URL) throws { + try CredentialFileWriter.writePrivate( + Data("{\"tokenV2\":\"\(token)\",\"sourceLabel\":\"Fixture\"}".utf8), + to: url) + } + + private static func token(_ url: URL) throws -> String? { + try JSONDecoder().decode([String: String].self, from: Data(contentsOf: url))["tokenV2"] + } + + private func isolated(_ body: (URL) throws -> Void) rethrows { + try KeychainCacheStore.withImplicitTestStoreForTesting { + try KeychainCacheStore.withServiceOverrideForTesting("persistent-plugin-\(UUID().uuidString)") { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try CookieHeaderCache.withLegacyBaseURLOverrideForTesting(directory) { + try Self.$fileURL.withValue(directory.appendingPathComponent("notion-session.json")) { + try body(directory.appendingPathComponent("notion-session.json")) + } + } + } + } + } +} diff --git a/Tests/CodexBarTests/ProviderSessionStoreFileTests.swift b/Tests/CodexBarTests/ProviderSessionStoreFileTests.swift index 70047c307f..08a2bb193b 100644 --- a/Tests/CodexBarTests/ProviderSessionStoreFileTests.swift +++ b/Tests/CodexBarTests/ProviderSessionStoreFileTests.swift @@ -65,11 +65,13 @@ struct ProviderSessionStoreFileTests { let cursor = CursorSessionStore() let augment = AugmentSessionStore() let factory = FactorySessionStore() - let notion = NotionSessionStore() + let notion = ProviderPluginSessionFile( + provider: .notion, + policy: .init(tokenField: "tokenV2", cookieName: "token_v2")) await cursor.setCookies([cookie]) await augment.setCookies([cookie]) await factory.setCookies([cookie]) - await notion.setSession(tokenV2: "synthetic-notion", sourceLabel: "Fixture") + notion.replace(expected: nil, header: "token_v2=synthetic-notion", source: "Fixture") for file in files { let attributes = try FileManager.default.attributesOfItem(atPath: file.path) let permissions = try #require(attributes[.posixPermissions] as? NSNumber) @@ -78,11 +80,11 @@ struct ProviderSessionStoreFileTests { #expect(await CursorSessionStore().getCookies().map(\.value) == ["synthetic-default"]) #expect(await AugmentSessionStore().getCookies().map(\.value) == ["synthetic-default"]) #expect(await FactorySessionStore().getCookies().map(\.value) == ["synthetic-default"]) - #expect(await NotionSessionStore().getSession()?.tokenV2 == "synthetic-notion") + #expect(notion.read()?["tokenV2"] == "synthetic-notion") await cursor.clearCookies() await augment.clearCookies() await factory.clearSession() - await notion.clearSession() + notion.replace(expected: notion.read(), header: nil, source: "Fixture") #expect(files.allSatisfy { !FileManager.default.fileExists(atPath: $0.path) }) } diff --git a/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift b/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift deleted file mode 100644 index 545f4a3c62..0000000000 --- a/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift +++ /dev/null @@ -1,360 +0,0 @@ -import Foundation -import Testing -@testable import CodexBarCore - -/// Covers the `.auto` cookie-cache handoff: a validated browser session is persisted through -/// `CookieHeaderCache`, and later resolutions (background refreshes, the bundled CLI) run from the -/// cached header without rereading the browser. Modeled on `PerplexityCookieCacheTests`. -@Suite(.serialized) -struct ZoomMateCookieCacheTests { - private static let cachedHeader = "_zm_ssid=fake-session-value; cf_clearance=fake-clearance-value" - private static let cachedHeaders = ZoomMateCookieHeaders(headersByHost: [ - "ai.zoom.us": cachedHeader, - "zoommate.zoom.us": cachedHeader, - ]) - private static let cachedStorage = cachedHeaders.encodedForStorage() ?? "" - - private static func sharedCookieHeaders(_ header: String) -> ZoomMateCookieHeaders { - ZoomMateCookieHeaders(headersByHost: [ - "ai.zoom.us": header, - "zoommate.zoom.us": header, - ]) - } - - /// Minimal unsigned JWT carrying only a far-future `exp` claim, so minted tokens are cacheable. - private static func makeJWT(exp: Int = 9_999_999_999) -> String { - func b64url(_ text: String) -> String { - Data(text.utf8).base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - return "\(b64url("{\"alg\":\"none\"}")).\(b64url("{\"exp\":\(exp)}")).sig" - } - - private static func mintResponseStub( - nak: String, - email: String? = nil, - expectedCookieHeader: String? = nil) -> ProviderHTTPTransportStub - { - ProviderHTTPTransportStub { request in - if let expectedCookieHeader { - #expect(request.value(forHTTPHeaderField: "Cookie") == expectedCookieHeader) - } - let profile = email.map { ", \"user_profile\": {\"email\": \"\($0)\"}" } ?? "" - let body = "{\"success\": true, \"data\": {\"nak\": \"\(nak)\"\(profile)}}" - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - } - - #if os(macOS) - @Test - func `auto mode reuses the cached cookie header without a browser read`() async throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { - CookieHeaderCache.clear(provider: .zoommate) - KeychainCacheStore.setTestStoreForTesting(false) - } - CookieHeaderCache.store( - provider: .zoommate, - cookieHeader: Self.cachedStorage, - sourceLabel: "Chrome (Test)") - - let jwt = Self.makeJWT() - let stub = Self.mintResponseStub( - nak: jwt, - email: "fake.user@example.com", - expectedCookieHeader: Self.cachedHeader) - let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) - - let context = try await fetcher.resolveRequestContext( - manualCaptureOverride: nil, - timeout: 1, - logger: nil, - cache: ZoomMateBearerTokenCache(), - transport: stub) - - #expect(context.authorization == "Bearer \(jwt)") - #expect(context.cookieHeaders == Self.cachedHeaders) - #expect(context.accountEmail == "fake.user@example.com") - #expect(context.cacheKey == ZoomMateBearerTokenCache.key(forCookieHeaders: Self.cachedHeaders)) - #expect(await stub.requests().count == 1) // the mint only — no browser import happened - } - - @Test - func `resolution without cache falls back to the browser import path`() async throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { - CookieHeaderCache.clear(provider: .zoommate) - KeychainCacheStore.setTestStoreForTesting(false) - } - CookieHeaderCache.store( - provider: .zoommate, - cookieHeader: Self.cachedStorage, - sourceLabel: "Chrome (Test)") - - let stub = ProviderHTTPTransportStub { request in - Issue.record("Unexpected network request: \(request.url?.absoluteString ?? "nil")") - let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! - return (Data(), response) - } - let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) - - // The dead-session retry disallows the cache; under the test runner the browser cookie - // store is suppressed, so the fallback surfaces `noSession` without any network traffic. - await #expect { - _ = try await fetcher.resolveRequestContext( - manualCaptureOverride: nil, - allowCachedCookieHeader: false, - timeout: 1, - logger: nil, - cache: ZoomMateBearerTokenCache(), - transport: stub) - } throws: { error in - guard case ZoomMateUsageError.noSession = error else { return false } - return true - } - // Skipping the cache must not mutate it; clearing is the strategy's explicit decision. - #expect(CookieHeaderCache.load(provider: .zoommate)?.cookieHeader == Self.cachedStorage) - } - - @Test - func `rejected cached session surfaces invalidCredentials and leaves the entry intact`() async throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { - CookieHeaderCache.clear(provider: .zoommate) - KeychainCacheStore.setTestStoreForTesting(false) - } - CookieHeaderCache.store( - provider: .zoommate, - cookieHeader: Self.cachedStorage, - sourceLabel: "Chrome (Test)") - - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! - return (Data("{}".utf8), response) - } - let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) - - await #expect { - _ = try await fetcher.resolveRequestContext( - manualCaptureOverride: nil, - timeout: 1, - logger: nil, - cache: ZoomMateBearerTokenCache(), - transport: stub) - } throws: { error in - guard case ZoomMateUsageError.invalidCredentials = error else { return false } - return true - } - // The fetcher never clears the cache itself — the strategy clears and retries once with a - // fresh import, so a transient mis-clear can't wipe a concurrently refreshed entry. - #expect(CookieHeaderCache.load(provider: .zoommate) != nil) - } - - @Test - func `validated browser session is persisted through the cookie cache`() async throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { - CookieHeaderCache.clear(provider: .zoommate) - KeychainCacheStore.setTestStoreForTesting(false) - } - - let nak = Self.makeJWT() - let stub = Self.mintResponseStub(nak: nak, expectedCookieHeader: Self.cachedHeader) - - let context = try await ZoomMateUsageFetcher.requestContext( - forCookieHeaders: Self.cachedHeaders, - persistingValidatedHeaderAs: "Chrome (Test)", - cache: ZoomMateBearerTokenCache(), - timeout: 1, - transport: stub, - logger: nil) - - let cached = try #require(CookieHeaderCache.load(provider: .zoommate)) - #expect(cached.cookieHeader == Self.cachedStorage) - #expect(cached.sourceLabel == "Chrome (Test)") - // Only the cookie header is persisted — the minted bearer stays in memory. - #expect(!cached.cookieHeader.contains(nak)) - #expect(context.authorization == "Bearer \(nak)") - } - - @Test - func `auto mode continues past a rejected Chrome profile`() async throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { - CookieHeaderCache.clear(provider: .zoommate) - KeychainCacheStore.setTestStoreForTesting(false) - } - - let rejectedHeader = "_zm_ssid=fake-rejected-session" - let validHeader = "_zm_ssid=fake-valid-session" - let jwt = Self.makeJWT() - let sessions = [ - ZoomMateCookieImporter.SessionInfo( - cookieHeaders: Self.sharedCookieHeaders(rejectedHeader), - sourceLabel: "Chrome Profile 1"), - ZoomMateCookieImporter.SessionInfo( - cookieHeaders: Self.sharedCookieHeaders(validHeader), - sourceLabel: "Chrome Profile 2"), - ] - let stub = ProviderHTTPTransportStub { request in - let cookieHeader = request.value(forHTTPHeaderField: "Cookie") - if cookieHeader == rejectedHeader { - let response = HTTPURLResponse( - url: request.url!, - statusCode: 401, - httpVersion: nil, - headerFields: nil)! - return (Data("{}".utf8), response) - } - - #expect(cookieHeader == validHeader) - let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" - let response = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil)! - return (Data(body.utf8), response) - } - - let context = try await ZoomMateUsageFetcher.requestContext( - forCookieSessions: sessions, - cache: ZoomMateBearerTokenCache(), - timeout: 1, - transport: stub, - logger: nil) - - #expect(context.authorization == "Bearer \(jwt)") - #expect(context.cookieHeaders == Self.sharedCookieHeaders(validHeader)) - #expect(await stub.requests().count == 2) - let cached = try #require(CookieHeaderCache.load(provider: .zoommate)) - #expect(cached.cookieHeader == Self.sharedCookieHeaders(validHeader).encodedForStorage()) - #expect(cached.sourceLabel == "Chrome Profile 2") - } - - @Test - func `auto mode does not hide a parse failure behind another Chrome profile`() async throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { - CookieHeaderCache.clear(provider: .zoommate) - KeychainCacheStore.setTestStoreForTesting(false) - } - - let sessions = [ - ZoomMateCookieImporter.SessionInfo( - cookieHeaders: Self.sharedCookieHeaders("_zm_ssid=fake-malformed-response-session"), - sourceLabel: "Chrome Profile 1"), - ZoomMateCookieImporter.SessionInfo( - cookieHeaders: Self.sharedCookieHeaders("_zm_ssid=fake-unused-session"), - sourceLabel: "Chrome Profile 2"), - ] - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil)! - return (Data("{\"success\": true, \"data\": {}}".utf8), response) - } - - await #expect { - _ = try await ZoomMateUsageFetcher.requestContext( - forCookieSessions: sessions, - cache: ZoomMateBearerTokenCache(), - timeout: 1, - transport: stub, - logger: nil) - } throws: { error in - guard case ZoomMateUsageError.parseFailed = error else { return false } - return true - } - #expect(await stub.requests().count == 1) - #expect(CookieHeaderCache.load(provider: .zoommate) == nil) - } - - @Test - func `failed mint persists nothing`() async throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { - CookieHeaderCache.clear(provider: .zoommate) - KeychainCacheStore.setTestStoreForTesting(false) - } - - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! - return (Data("{}".utf8), response) - } - - await #expect { - _ = try await ZoomMateUsageFetcher.requestContext( - forCookieHeaders: Self.cachedHeaders, - persistingValidatedHeaderAs: "Chrome (Test)", - cache: ZoomMateBearerTokenCache(), - timeout: 1, - transport: stub, - logger: nil) - } throws: { error in - guard case ZoomMateUsageError.invalidCredentials = error else { return false } - return true - } - #expect(CookieHeaderCache.load(provider: .zoommate) == nil) - } - - @Test - func `already cached header is not re-persisted`() async throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { - CookieHeaderCache.clear(provider: .zoommate) - KeychainCacheStore.setTestStoreForTesting(false) - } - - let stub = Self.mintResponseStub(nak: Self.makeJWT()) - _ = try await ZoomMateUsageFetcher.requestContext( - forCookieHeaders: Self.cachedHeaders, - persistingValidatedHeaderAs: nil, - cache: ZoomMateBearerTokenCache(), - timeout: 1, - transport: stub, - logger: nil) - - #expect(CookieHeaderCache.load(provider: .zoommate) == nil) - } - - @Test - func `manual capture mode neither reads nor writes the cookie cache`() async throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { - CookieHeaderCache.clear(provider: .zoommate) - KeychainCacheStore.setTestStoreForTesting(false) - } - CookieHeaderCache.store( - provider: .zoommate, - cookieHeader: Self.cachedStorage, - sourceLabel: "Chrome (Test)") - - let curl = "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + - "-H 'authorization: Bearer fake-manual-token' -H 'cookie: session=fake-manual-cookie'" - let stub = ProviderHTTPTransportStub { request in - Issue.record("Unexpected network request: \(request.url?.absoluteString ?? "nil")") - let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! - return (Data(), response) - } - let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) - - let context = try await fetcher.resolveRequestContext( - manualCaptureOverride: curl, - timeout: 1, - logger: nil, - cache: ZoomMateBearerTokenCache(), - transport: stub) - - #expect(context.authorization == "Bearer fake-manual-token") - #expect(context.cookieHeaders.header(forHost: "ai.zoom.us") == "session=fake-manual-cookie") - #expect(context.cookieHeaders.header(forHost: "zoommate.zoom.us") == nil) - #expect(CookieHeaderCache.load(provider: .zoommate)?.cookieHeader == Self.cachedStorage) - } - #endif -} diff --git a/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift b/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift deleted file mode 100644 index 76dd28358a..0000000000 --- a/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift +++ /dev/null @@ -1,550 +0,0 @@ -import Foundation -import Testing -@testable import CodexBarCore - -struct ZoomMateCreditsHistoryFetcherTests { - // Every payload below is generated from synthetic IDs, titles, costs, and timestamps. - private static let now = Date(timeIntervalSince1970: 1_782_800_000) - private static let startTime = Self.now.addingTimeInterval(-30 * 24 * 3600) - - private static func page(records: String, total: Int) -> String { - """ - { "data": { "records": [\(records)], "total": \(total) }, "status_code": 200, "error_message": null } - """ - } - - private static func record( - id: String, - title: String, - cost: Double, - time: String, - isRunning: Bool = false, - isDeleted: Bool = false) -> String - { - """ - {"session_id": "\(id)", "title": "\(title)", "cost": \(cost), "time": "\(time)", - "is_running": \(isRunning), "is_deleted": \(isDeleted)} - """ - } - - @Test - func `decodes a single page fully within the limit`() async throws { - let body = Self.page( - records: [ - Self.record(id: "s1", title: "Task A", cost: 5, time: "2026-06-30T10:00:00Z"), - Self.record(id: "s2", title: "Task B", cost: 3, time: "2026-06-29T10:00:00Z"), - ].joined(separator: ","), - total: 2) - - let stub = ProviderHTTPTransportStub { request in - #expect(request.url?.scheme == "https") - #expect(request.url?.host == "ai.zoom.us") - #expect(request.url?.path == "/ai-computer/api/v1/credits/history") - #expect(request.url?.query?.contains("app_id=demo_app") == true) - #expect(request.url?.query?.contains("page=0") == true) - #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fake-token") - #expect(request.value(forHTTPHeaderField: "Origin") == "https://zoommate.zoom.us") - #expect(request.value(forHTTPHeaderField: "Referer") == "https://zoommate.zoom.us") - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext( - authorization: "Bearer fake-token", - headers: ["Origin": "https://attacker.example", "Referer": "https://attacker.example/path"]) - let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( - context: context, - startTime: Self.startTime, - endTime: Self.now, - now: Self.now, - transport: stub) - - #expect(snapshot.records.count == 2) - let requestCount = await stub.requests().count - #expect(requestCount == 1) - } - - @Test - func `history failover sends only the cookie header scoped to each host`() async throws { - let stub = ProviderHTTPTransportStub { request in - let statusCode = request.url?.host == "ai.zoom.us" ? 503 : 200 - if request.url?.host == "ai.zoom.us" { - #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; ai-only=fake") - } else { - #expect(request.url?.host == "zoommate.zoom.us") - #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; mate-only=fake") - } - let body = Self.page(records: "", total: 0) - let response = HTTPURLResponse( - url: request.url!, - statusCode: statusCode, - httpVersion: nil, - headerFields: nil)! - return (statusCode == 200 ? Data(body.utf8) : Data(), response) - } - let context = ZoomMateUsageFetcher.RequestContext( - authorization: "Bearer fake-token", - cookieHeaders: ZoomMateCookieHeaders(headersByHost: [ - "ai.zoom.us": "parent=fake; ai-only=fake", - "zoommate.zoom.us": "parent=fake; mate-only=fake", - ])) - - let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( - context: context, - startTime: Self.startTime, - endTime: Self.now, - now: Self.now, - transport: stub) - - #expect(snapshot.records.isEmpty) - #expect(await stub.requests().count == 2) - } - - @Test - func `paginates across multiple pages until total is satisfied`() async throws { - let stub = ProviderHTTPTransportStub { request in - let query = request.url?.query ?? "" - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - if query.contains("page=0") { - let body = Self.page( - records: (0..<50).map { - Self.record(id: "s\($0)", title: "Task \($0)", cost: 1, time: "2026-06-30T10:00:00Z") - }.joined(separator: ","), - total: 55) - return (Data(body.utf8), response) - } - #expect(query.contains("page=1")) - let body = Self.page( - records: (50..<55).map { - Self.record(id: "s\($0)", title: "Task \($0)", cost: 1, time: "2026-06-29T10:00:00Z") - }.joined(separator: ","), - total: 55) - return (Data(body.utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") - let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( - context: context, - startTime: Self.startTime, - endTime: Self.now, - now: Self.now, - transport: stub) - - #expect(snapshot.records.count == 55) - let requestCount = await stub.requests().count - #expect(requestCount == 2) - } - - @Test - func `stops pagination early when a page returns no records`() async throws { - let stub = ProviderHTTPTransportStub { request in - let body = Self.page(records: "", total: 1000) - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") - let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( - context: context, - startTime: Self.startTime, - endTime: Self.now, - now: Self.now, - transport: stub) - - #expect(snapshot.records.isEmpty) - let requestCount = await stub.requests().count - #expect(requestCount == 1) - } - - @Test - func `stops pagination early when a page is entirely older than startTime`() async throws { - // `total: 1000` implies many more pages exist, but every record on page 0 is already - // older than `startTime` — the defensive date-boundary stop (design.md D2) should break - // before requesting page 1, regardless of what `total`/`maxPages` would otherwise allow. - let staleTime = Self.startTime.addingTimeInterval(-24 * 3600) // 1 day before the window. - let stub = ProviderHTTPTransportStub { request in - #expect(request.url?.query?.contains("page=0") == true) - let body = Self.page( - records: (0..<50).map { - Self.record( - id: "s\($0)", - title: "Stale \($0)", - cost: 1, - time: ISO8601DateFormatter().string(from: staleTime)) - }.joined(separator: ","), - total: 1000) - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") - let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( - context: context, - startTime: Self.startTime, - endTime: Self.now, - now: Self.now, - transport: stub) - - #expect(snapshot.records.count == 50) - let requestCount = await stub.requests().count - #expect(requestCount == 1) - } - - @Test - func `unauthorized response maps to invalidCredentials`() async throws { - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! - return (Data("{\"detail\": \"unauthorized\"}".utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") - await #expect { - _ = try await ZoomMateCreditsHistoryFetcher.fetch( - context: context, - startTime: Self.startTime, - endTime: Self.now, - now: Self.now, - transport: stub) - } throws: { error in - guard case ZoomMateUsageError.invalidCredentials = error else { return false } - return true - } - } - - @Test - func `other server error maps to apiError`() async throws { - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! - return (Data("boom".utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") - await #expect { - _ = try await ZoomMateCreditsHistoryFetcher.fetch( - context: context, - startTime: Self.startTime, - endTime: Self.now, - now: Self.now, - transport: stub) - } throws: { error in - guard case ZoomMateUsageError.apiError = error else { return false } - return true - } - } - - @Test - func `malformed body surfaces parseFailed`() async throws { - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data("{\"unexpected\": true}".utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") - await #expect { - _ = try await ZoomMateCreditsHistoryFetcher.fetch( - context: context, - startTime: Self.startTime, - endTime: Self.now, - now: Self.now, - transport: stub) - } throws: { error in - guard case ZoomMateUsageError.parseFailed = error else { return false } - return true - } - } - - // MARK: - Daily aggregation - - @Test - func `daily breakdown sums cost per calendar day and sorts ascending`() { - let records: [ZoomMateCreditHistoryRecord] = [ - ZoomMateCreditHistoryRecord( - sessionID: "s1", - title: "A", - cost: 5, - time: "2026-06-30T10:00:00Z", - isRunning: false, - isDeleted: false), - ZoomMateCreditHistoryRecord( - sessionID: "s2", - title: "B", - cost: 3, - time: "2026-06-30T20:00:00Z", - isRunning: false, - isDeleted: false), - ZoomMateCreditHistoryRecord( - sessionID: "s3", - title: "C", - cost: 2, - time: "2026-06-29T10:00:00Z", - isRunning: false, - isDeleted: false), - ] - let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) - let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) - - #expect(breakdown.count == 2) - #expect(breakdown[0].day == "2026-06-29") - #expect(breakdown[0].totalCreditsUsed == 2) - #expect(breakdown[1].day == "2026-06-30") - #expect(breakdown[1].totalCreditsUsed == 8) - } - - @Test - func `daily breakdown excludes deleted records`() { - let records: [ZoomMateCreditHistoryRecord] = [ - ZoomMateCreditHistoryRecord( - sessionID: "s1", - title: "A", - cost: 5, - time: "2026-06-30T10:00:00Z", - isRunning: false, - isDeleted: false), - ZoomMateCreditHistoryRecord( - sessionID: "s2", - title: "B (deleted)", - cost: 100, - time: "2026-06-30T11:00:00Z", - isRunning: false, - isDeleted: true), - ] - let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) - let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) - - #expect(breakdown.count == 1) - #expect(breakdown[0].totalCreditsUsed == 5) - } - - @Test - func `daily breakdown includes running sessions`() { - let records: [ZoomMateCreditHistoryRecord] = [ - ZoomMateCreditHistoryRecord( - sessionID: "s1", - title: "Still running", - cost: 1.5, - time: "2026-06-30T10:00:00Z", - isRunning: true, - isDeleted: false), - ] - let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) - let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) - - #expect(breakdown.count == 1) - #expect(breakdown[0].totalCreditsUsed == 1.5) - } - - @Test - func `daily breakdown skips records with unparseable time or negative cost`() { - let records: [ZoomMateCreditHistoryRecord] = [ - ZoomMateCreditHistoryRecord( - sessionID: "s1", - title: "Bad time", - cost: 5, - time: "not-a-date", - isRunning: false, - isDeleted: false), - ZoomMateCreditHistoryRecord( - sessionID: "s2", - title: "Negative cost", - cost: -1, - time: "2026-06-30T10:00:00Z", - isRunning: false, - isDeleted: false), - ZoomMateCreditHistoryRecord( - sessionID: "s3", - title: "Missing time", - cost: 2, - time: nil, - isRunning: false, - isDeleted: false), - ZoomMateCreditHistoryRecord( - sessionID: "s4", - title: "Missing cost", - cost: nil, - time: "2026-06-30T10:00:00Z", - isRunning: false, - isDeleted: false), - ] - let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) - let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) - - #expect(breakdown.isEmpty) - } - - @Test - func `daily breakdown returns empty for no records`() { - let snapshot = ZoomMateCreditsHistorySnapshot(records: [], updatedAt: Self.now) - #expect(snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now).isEmpty) - } - - @Test - func `daily breakdown excludes records older than the trailing 30-day window`() throws { - // Fixed `now`; one record just inside the 30-day window, one just outside it. - let fixedNow = try #require(Self.utcCalendar.date(from: DateComponents(year: 2026, month: 7, day: 4, hour: 12))) - let withinWindow = "2026-06-05T10:00:00Z" // 29 days before `now` -> included. - let outsideWindow = "2026-06-03T10:00:00Z" // 31 days before `now` -> excluded. - let records: [ZoomMateCreditHistoryRecord] = [ - ZoomMateCreditHistoryRecord( - sessionID: "s1", - title: "Recent", - cost: 5, - time: withinWindow, - isRunning: false, - isDeleted: false), - ZoomMateCreditHistoryRecord( - sessionID: "s2", - title: "Stale", - cost: 100, - time: outsideWindow, - isRunning: false, - isDeleted: false), - ] - let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: fixedNow) - let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: fixedNow) - - #expect(breakdown.count == 1) - #expect(breakdown[0].day == "2026-06-05") - #expect(breakdown[0].totalCreditsUsed == 5) - } - - private static var utcCalendar: Calendar { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(identifier: "UTC")! - return calendar - } - - // MARK: - Pacing verdict - - @Test - func `pacing verdict reports onTrack when usage matches elapsed cycle fraction`() throws { - // Cycle: 100,000s long; now is 50,000s in (50% elapsed); used = 50% of budget. - let cycleStart = Self.now.addingTimeInterval(-50000) - let cycleEnd = Self.now.addingTimeInterval(50000) - let status = ZoomMateCreditStatus( - budgetCap: 1000, - usedCredit: 500, - remainingCredit: 500, - overageCredit: 0, - allowOverage: false, - cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), - cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), - isQuotaAvailable: true, - isUnlimited: false) - - let pace = try #require(status.pacingVerdict(now: Self.now)) - #expect(pace.stage == .onTrack) - } - - @Test - func `pacing verdict reports behind when usage is well below elapsed cycle fraction`() throws { - let cycleStart = Self.now.addingTimeInterval(-50000) - let cycleEnd = Self.now.addingTimeInterval(50000) - let status = ZoomMateCreditStatus( - budgetCap: 1000, - usedCredit: 100, - remainingCredit: 900, - overageCredit: 0, - allowOverage: false, - cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), - cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), - isQuotaAvailable: true, - isUnlimited: false) - - let pace = try #require(status.pacingVerdict(now: Self.now)) - #expect(pace.stage == .behind || pace.stage == .farBehind || pace.stage == .slightlyBehind) - #expect(pace.deltaPercent < 0) - } - - @Test - func `pacing verdict reports ahead when usage is well above elapsed cycle fraction`() throws { - let cycleStart = Self.now.addingTimeInterval(-50000) - let cycleEnd = Self.now.addingTimeInterval(50000) - let status = ZoomMateCreditStatus( - budgetCap: 1000, - usedCredit: 900, - remainingCredit: 100, - overageCredit: 0, - allowOverage: false, - cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), - cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), - isQuotaAvailable: true, - isUnlimited: false) - - let pace = try #require(status.pacingVerdict(now: Self.now)) - #expect(pace.stage == .ahead || pace.stage == .farAhead || pace.stage == .slightlyAhead) - #expect(pace.deltaPercent > 0) - } - - @Test - func `pacing verdict is nil for unlimited plans`() { - let status = ZoomMateCreditStatus( - budgetCap: 1000, - usedCredit: 500, - remainingCredit: 500, - overageCredit: 0, - allowOverage: false, - cycleStartDate: Int64(Self.now.addingTimeInterval(-50000).timeIntervalSince1970 * 1000), - cycleEndDate: Int64(Self.now.addingTimeInterval(50000).timeIntervalSince1970 * 1000), - isQuotaAvailable: true, - isUnlimited: true) - - #expect(status.pacingVerdict(now: Self.now) == nil) - } - - @Test - func `pacing verdict is nil when cycle dates are missing`() { - let status = ZoomMateCreditStatus( - budgetCap: 1000, - usedCredit: 500, - remainingCredit: 500, - overageCredit: 0, - allowOverage: false, - cycleStartDate: nil, - cycleEndDate: nil, - isQuotaAvailable: true, - isUnlimited: false) - - #expect(status.pacingVerdict(now: Self.now) == nil) - } - - @Test - func `pacing verdict is nil when budget cap is zero`() { - let status = ZoomMateCreditStatus( - budgetCap: 0, - usedCredit: 0, - remainingCredit: 0, - overageCredit: 0, - allowOverage: false, - cycleStartDate: Int64(Self.now.addingTimeInterval(-50000).timeIntervalSince1970 * 1000), - cycleEndDate: Int64(Self.now.addingTimeInterval(50000).timeIntervalSince1970 * 1000), - isQuotaAvailable: false, - isUnlimited: false) - - #expect(status.pacingVerdict(now: Self.now) == nil) - } - - @Test - func `ZoomMateCreditsHistorySnapshot pacingVerdict delegates to its attached creditStatus`() { - let cycleStart = Self.now.addingTimeInterval(-50000) - let cycleEnd = Self.now.addingTimeInterval(50000) - let status = ZoomMateCreditStatus( - budgetCap: 1000, - usedCredit: 500, - remainingCredit: 500, - overageCredit: 0, - allowOverage: false, - cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), - cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), - isQuotaAvailable: true, - isUnlimited: false) - let snapshot = ZoomMateCreditsHistorySnapshot(records: [], creditStatus: status, updatedAt: Self.now) - - #expect(snapshot.pacingVerdict(now: Self.now)?.stage == .onTrack) - } - - @Test - func `ZoomMateCreditsHistorySnapshot pacingVerdict is nil without an attached creditStatus`() { - let snapshot = ZoomMateCreditsHistorySnapshot(records: [], updatedAt: Self.now) - #expect(snapshot.pacingVerdict(now: Self.now) == nil) - } -} diff --git a/Tests/CodexBarTests/ZoomMateProviderTests.swift b/Tests/CodexBarTests/ZoomMateProviderTests.swift new file mode 100644 index 0000000000..1622dd44cc --- /dev/null +++ b/Tests/CodexBarTests/ZoomMateProviderTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ZoomMateProviderTests { + @Test + func `manual captures keep authorization and cookies behind the declared origin`() throws { + for host in ["ai.zoom.us", "zoommate.zoom.us"] { + let capture = try #require(ZoomMateProviderDescriptor.capture( + "curl 'https://\(host)/ai-computer/api/v1/credits/status' -H 'Authorization: Bearer fixture' " + + "-H 'Cookie: session=fixture' -H 'Host: attacker.test' -H 'Origin: https://attacker.test'")) + #expect(capture.host == host) + #expect(capture.headers["authorization"] == "Bearer fixture") + #expect(capture.headers["cookie"] == "session=fixture") + #expect(capture.headers["host"] == nil) + #expect(capture.headers["origin"] == nil) + } + } + + @Test(arguments: [ + "http://ai.zoom.us/ai-computer/api/v1/credits/status", + "https://ai.zoom.us:443/ai-computer/api/v1/credits/status", + "https://user@ai.zoom.us/ai-computer/api/v1/credits/status", + "https://other.zoom.us/ai-computer/api/v1/credits/status", + "https://ai.zoom.us/ai-computer/api/v1/credits/status?query=bad", + "https://ai.zoom.us/ai-computer/api/v1/credits/status#fragment", + "https://ai.zoom.us/ai-computer/api/v1/login/", + ]) + func `off boundary manual capture URLs are rejected`(url: String) { + #expect(ZoomMateProviderDescriptor.capture("curl '\(url)' -H 'Authorization: Bearer fixture'") == nil) + } + + @Test + func `manual capture requires authorization`() { + #expect(ZoomMateProviderDescriptor + .capture("curl https://ai.zoom.us/ai-computer/api/v1/credits/status -H 'Cookie: session=fixture'") == nil) + #expect(ZoomMateProviderDescriptor.capture("") == nil) + } +} diff --git a/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift b/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift deleted file mode 100644 index a76385898b..0000000000 --- a/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift +++ /dev/null @@ -1,906 +0,0 @@ -import Foundation -import Testing -@testable import CodexBarCore -#if os(macOS) -import SweetCookieKit -#endif - -struct ZoomMateUsageFetcherTests { - private final class MessageRecorder: @unchecked Sendable { - private var messages: [String] = [] - private let lock = NSLock() - - func append(_ message: String) { - self.lock.lock() - defer { self.lock.unlock() } - self.messages.append(message) - } - - func output() -> String { - self.lock.lock() - defer { self.lock.unlock() } - return self.messages.joined(separator: "\n") - } - } - - private struct StubClaudeFetcher: ClaudeUsageFetching { - func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { - throw ClaudeUsageError.parseFailed("stub") - } - - func debugRawProbe(model _: String) async -> String { - "stub" - } - - func detectVersion() -> String? { - nil - } - } - - private static let now = Date(timeIntervalSince1970: 1_782_800_000) - - private static func sharedCookieHeaders(_ header: String) -> ZoomMateCookieHeaders { - ZoomMateCookieHeaders(headersByHost: [ - "ai.zoom.us": header, - "zoommate.zoom.us": header, - ]) - } - - /// Fully synthetic payload matching the first-party web client's decoded response shape. - private static let sampleResponse = """ - { "data": { "credit_status": { - "budget_cap": 12345.0, "used_credit": 678.0, "remaining_credit": 11667.0, - "overage_credit": 0.0, "allow_overage": false, - "cycle_start_date": 1893456000000, "cycle_end_date": 1896134399000, - "is_quota_available": true, "is_unlimited": false } }, - "status_code": 200, "error_message": null } - """ - - @Test - func `decodes credit status from sample JSON`() throws { - let data = Data(Self.sampleResponse.utf8) - struct Envelope: Decodable { - struct DataBox: Decodable { - let creditStatus: ZoomMateCreditStatus - private enum CodingKeys: String, CodingKey { case creditStatus = "credit_status" } - } - - let data: DataBox - } - let envelope = try JSONDecoder().decode(Envelope.self, from: data) - let status = envelope.data.creditStatus - - #expect(status.budgetCap == 12345) - #expect(status.usedCredit == 678) - #expect(status.remainingCredit == 11667) - #expect(status.isUnlimited == false) - #expect(status.cycleEndDate == 1_896_134_399_000) - } - - @Test - func `maps normal credit usage to primary window`() { - let status = ZoomMateCreditStatus( - budgetCap: 35000, - usedCredit: 942, - remainingCredit: 34058, - overageCredit: 0, - allowOverage: false, - cycleStartDate: 1_782_777_600_000, - cycleEndDate: 1_785_455_999_000, - isQuotaAvailable: true, - isUnlimited: false) - let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() - - #expect(snapshot.primary != nil) - #expect(abs((snapshot.primary?.usedPercent ?? 0) - 2.691_428_57) < 0.001) - #expect(snapshot.primary?.resetsAt?.timeIntervalSince1970 == Double(1_785_455_999_000) / 1000) - #expect(snapshot.primary?.resetDescription == "Credits") - #expect(snapshot.secondary == nil) - #expect(snapshot.identity?.providerID == .zoommate) - #expect(snapshot.identity?.accountEmail == nil) - } - - @Test - func `unlimited plan reports zero percent and no reset`() { - let status = ZoomMateCreditStatus( - budgetCap: 35000, - usedCredit: 942, - remainingCredit: 34058, - overageCredit: 0, - allowOverage: false, - cycleStartDate: 1_782_777_600_000, - cycleEndDate: 1_785_455_999_000, - isQuotaAvailable: true, - isUnlimited: true) - let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() - - #expect(snapshot.primary?.usedPercent == 0) - #expect(snapshot.primary?.resetsAt == nil) - } - - @Test - func `zero budget cap avoids divide by zero`() { - let status = ZoomMateCreditStatus( - budgetCap: 0, - usedCredit: 0, - remainingCredit: 0, - overageCredit: 0, - allowOverage: false, - cycleStartDate: nil, - cycleEndDate: nil, - isQuotaAvailable: false, - isUnlimited: false) - let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() - - #expect(snapshot.primary?.usedPercent == 0) - #expect(snapshot.primary?.resetsAt == nil) - } - - @Test - func `fetch sends authorization and decodes credit status`() async throws { - let stub = ProviderHTTPTransportStub { request in - #expect(request.url?.scheme == "https") - #expect(request.url?.host == "ai.zoom.us") - #expect(request.url?.path == "/ai-computer/api/v1/credits/status") - #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fake-token") - #expect(request.value(forHTTPHeaderField: "Origin") == "https://zoommate.zoom.us") - #expect(request.value(forHTTPHeaderField: "Referer") == "https://zoommate.zoom.us") - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(Self.sampleResponse.utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext( - authorization: "Bearer fake-token", - headers: ["Origin": "https://attacker.example", "Referer": "https://attacker.example/path"]) - let snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( - context: context, - now: Self.now, - transport: stub) - - #expect(snapshot.creditStatus.usedCredit == 678) - } - - @Test - func `unauthorized response is invalid credentials`() async throws { - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! - return (Data("{\"detail\": \"Missing Authorization header\"}".utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") - await #expect { - _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) - } throws: { error in - guard case ZoomMateUsageError.invalidCredentials = error else { return false } - return true - } - } - - @Test - func `other server error is apiError`() async throws { - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! - return (Data("boom".utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") - await #expect { - _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) - } throws: { error in - guard case ZoomMateUsageError.apiError = error else { return false } - return true - } - } - - @Test - func `malformed 200 body surfaces parseFailed`() async throws { - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data("{\"unexpected\": true}".utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") - await #expect { - _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) - } throws: { error in - guard case ZoomMateUsageError.parseFailed = error else { return false } - return true - } - } - - @Test - func `manual curl capture extracts authorization and cookie`() throws { - let curl = """ - curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \\ - -H 'authorization: Bearer fake-manual-token' \\ - -H 'cookie: session=fake-cookie-value' \\ - -H 'origin: https://zoommate.zoom.us' \\ - -H 'referer: https://zoommate.zoom.us/' - """ - - let context = try #require(ZoomMateUsageFetcher.requestContext(from: curl)) - #expect(context.authorization == "Bearer fake-manual-token") - #expect(context.cookieHeaders.header(forHost: "ai.zoom.us") == "session=fake-cookie-value") - #expect(context.cookieHeaders.header(forHost: "zoommate.zoom.us") == nil) - #expect(context.preferredHost == "ai.zoom.us") - #expect(context.headers["Origin"] == nil) - #expect(context.headers["Referer"] == nil) - } - - @Test - func `manual curl capture rejects nonofficial and malformed targets`() { - let captures = [ - "curl 'http://ai.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", - "curl 'https://marketing.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", - "curl 'https://zoom.us.attacker.com/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", - "curl 'https://example.com/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", - "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/history' -H 'authorization: Bearer fake'", - "curl 'https://ai.zoom.us:444/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", - "curl --location 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + - "-H 'authorization: Bearer fake'", - ] - - for capture in captures { - #expect(ZoomMateUsageFetcher.requestContext(from: capture) == nil) - } - } - - @Test - func `manual curl capture accepts either interchangeable first-party host`() throws { - let capture = "curl 'https://zoommate.zoom.us/ai-computer/api/v1/credits/status' " + - "-H 'authorization: Bearer fake-manual-token' -H 'cookie: mate-only=fake'" - - let context = try #require(ZoomMateUsageFetcher.requestContext(from: capture)) - #expect(context.authorization == "Bearer fake-manual-token") - #expect(context.cookieHeaders.header(forHost: "ai.zoom.us") == nil) - #expect(context.cookieHeaders.header(forHost: "zoommate.zoom.us") == "mate-only=fake") - #expect(context.preferredHost == "zoommate.zoom.us") - } - - @Test - func `manual ai capture never sends its cookie to zoommate during failover`() async throws { - let capture = "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + - "-H 'authorization: Bearer fake-manual-token' -H 'cookie: ai-only=fake'" - let context = try #require(ZoomMateUsageFetcher.requestContext(from: capture)) - let stub = ProviderHTTPTransportStub { request in - let statusCode = request.url?.host == "ai.zoom.us" ? 503 : 200 - if request.url?.host == "ai.zoom.us" { - #expect(request.value(forHTTPHeaderField: "Cookie") == "ai-only=fake") - } else { - #expect(request.url?.host == "zoommate.zoom.us") - #expect(request.value(forHTTPHeaderField: "Cookie") == nil) - } - let response = HTTPURLResponse( - url: request.url!, - statusCode: statusCode, - httpVersion: nil, - headerFields: nil)! - return (statusCode == 200 ? Data(Self.sampleResponse.utf8) : Data(), response) - } - - _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) - #expect(await stub.requests().count == 2) - } - - @Test - func `manual zoommate capture starts on its host and drops its cookie during failover`() async throws { - let capture = "curl 'https://zoommate.zoom.us/ai-computer/api/v1/credits/status' " + - "-H 'authorization: Bearer fake-manual-token' -H 'cookie: mate-only=fake'" - let context = try #require(ZoomMateUsageFetcher.requestContext(from: capture)) - let stub = ProviderHTTPTransportStub { request in - let statusCode = request.url?.host == "zoommate.zoom.us" ? 503 : 200 - if request.url?.host == "zoommate.zoom.us" { - #expect(request.value(forHTTPHeaderField: "Cookie") == "mate-only=fake") - } else { - #expect(request.url?.host == "ai.zoom.us") - #expect(request.value(forHTTPHeaderField: "Cookie") == nil) - } - let response = HTTPURLResponse( - url: request.url!, - statusCode: statusCode, - httpVersion: nil, - headerFields: nil)! - return (statusCode == 200 ? Data(Self.sampleResponse.utf8) : Data(), response) - } - - _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) - #expect(await stub.requests().count == 2) - } - - @Test - func `credits status fails over to the alternate host on a non-auth failure`() async throws { - let stub = ProviderHTTPTransportStub { request in - if request.url?.host == "ai.zoom.us" { - let response = HTTPURLResponse( - url: request.url!, - statusCode: 503, - httpVersion: nil, - headerFields: nil)! - return (Data(), response) - } - #expect(request.url?.host == "zoommate.zoom.us") - #expect(request.url?.path == "/ai-computer/api/v1/credits/status") - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(Self.sampleResponse.utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext( - authorization: "Bearer fake-token", - cookieHeaders: ZoomMateCookieHeaders(headersByHost: [ - "ai.zoom.us": "parent=fake; ai-only=fake", - "zoommate.zoom.us": "parent=fake; mate-only=fake", - ])) - let snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( - context: context, - now: Self.now, - transport: stub) - - #expect(snapshot.creditStatus.usedCredit == 678) - #expect(await stub.requests().count == 2) - let requests = await stub.requests() - #expect(requests[0].value(forHTTPHeaderField: "Cookie") == "parent=fake; ai-only=fake") - #expect(requests[1].value(forHTTPHeaderField: "Cookie") == "parent=fake; mate-only=fake") - } - - @Test - func `auth rejection does not fail over to the alternate host`() async throws { - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! - return (Data("{}".utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") - await #expect { - _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) - } throws: { error in - guard case ZoomMateUsageError.invalidCredentials = error else { return false } - return true - } - #expect(await stub.requests().count == 1) - } - - @Test - func `parse failure does not fail over to the alternate host`() async throws { - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data("{\"unexpected\": true}".utf8), response) - } - - let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") - await #expect { - _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) - } throws: { error in - guard case ZoomMateUsageError.parseFailed = error else { return false } - return true - } - #expect(await stub.requests().count == 1) - } - - @Test - func `mint fails over to the alternate host on a non-auth failure`() async throws { - let stub = ProviderHTTPTransportStub { request in - if request.url?.host == "ai.zoom.us" { - #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; ai-only=fake") - let response = HTTPURLResponse( - url: request.url!, - statusCode: 500, - httpVersion: nil, - headerFields: nil)! - return (Data(), response) - } - #expect(request.url?.host == "zoommate.zoom.us") - #expect(request.url?.path == "/ai-computer/api/v1/login") - #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; mate-only=fake") - let body = "{\"success\": true, \"data\": {\"nak\": \"fake-minted-jwt\"}}" - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - - let minted = try await ZoomMateUsageFetcher.mintBearerToken( - cookieHeaders: ZoomMateCookieHeaders(headersByHost: [ - "ai.zoom.us": "parent=fake; ai-only=fake", - "zoommate.zoom.us": "parent=fake; mate-only=fake", - ]), - transport: stub) - - #expect(minted.bearerToken == "fake-minted-jwt") - #expect(await stub.requests().count == 2) - } - - @Test - func `host failover preserves cancellation without trying the alternate host`() async { - var attemptedHosts: [String] = [] - - do { - let _: String = try await ZoomMateUsageFetcher.withAPIHostFailover { host in - attemptedHosts.append(host) - throw CancellationError() - } - Issue.record("Expected cancellation") - } catch { - #expect(error is CancellationError) - } - - #expect(attemptedHosts == ["ai.zoom.us"]) - } - - @Test - func `curl capture without authorization header yields nil context`() { - let curl = """ - curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \\ - -H 'cookie: session=fake-cookie-value' - """ - - #expect(ZoomMateUsageFetcher.requestContext(from: curl) == nil) - } - - @Test - func `manual strategy remains available so malformed captures surface an honest error`() async { - let curl = "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + - "-H 'authorization: Bearer fake-manual-token'" - let settings = ProviderSettingsSnapshot.make( - zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( - cookieSource: .manual, - manualCookieHeader: curl)) - - #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings))) - - let emptySettings = ProviderSettingsSnapshot.make( - zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( - cookieSource: .manual, - manualCookieHeader: nil)) - #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: emptySettings))) - } - - @Test - func `manual mode with an empty or malformed capture returns noCapture`() async { - let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) - - for capture in ["", "curl 'https://example.com' -H 'authorization: Bearer fake'"] { - await #expect { - _ = try await fetcher.resolveRequestContext( - manualCaptureOverride: capture, - timeout: 1, - logger: nil) - } throws: { error in - guard case ZoomMateUsageError.noCapture = error else { return false } - return true - } - } - } - - @Test - func `auto strategy is available on macOS regardless of a stored manual capture`() async { - let settings = ProviderSettingsSnapshot.make( - zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( - cookieSource: .auto, - manualCookieHeader: nil)) - - #if os(macOS) - #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings))) - #else - #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings)) == false) - #endif - } - - @Test - func `strategy is unavailable when cookie source is off`() async { - let settings = ProviderSettingsSnapshot.make( - zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( - cookieSource: .off, - manualCookieHeader: nil)) - - #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings)) == false) - } - - @Test - func `mintBearerToken sends cookie and decodes nak from login bootstrap response`() async throws { - let stub = ProviderHTTPTransportStub { request in - #expect(request.url?.host == "ai.zoom.us") - #expect(request.url?.path == "/ai-computer/api/v1/login") - #expect(request.url?.query?.contains("continue=") == true) - #expect(request.value(forHTTPHeaderField: "Cookie") == "session=fake-cookie-value") - let body = """ - {"success": true, "data": {"nak": "fake-minted-jwt"}} - """ - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - - let minted = try await ZoomMateUsageFetcher.mintBearerToken( - cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), - transport: stub) - - #expect(minted.bearerToken == "fake-minted-jwt") - #expect(minted.accountEmail == nil) - } - - @Test - func `mintBearerToken extracts email from user_profile when present`() async throws { - let stub = ProviderHTTPTransportStub { request in - let body = """ - {"success": true, "data": {"nak": "fake-minted-jwt", "user_profile": { - "user_id": "fake-user-id", "email": "fake.user@example.com", "display_name": "Fake User" - }}} - """ - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - - let minted = try await ZoomMateUsageFetcher.mintBearerToken( - cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), - transport: stub) - - #expect(minted.bearerToken == "fake-minted-jwt") - #expect(minted.accountEmail == "fake.user@example.com") - } - - @Test - func `mintBearerToken tolerates missing user_profile without throwing`() async throws { - let stub = ProviderHTTPTransportStub { request in - let body = """ - {"success": true, "data": {"nak": "fake-minted-jwt"}} - """ - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - - let minted = try await ZoomMateUsageFetcher.mintBearerToken( - cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), - transport: stub) - - #expect(minted.bearerToken == "fake-minted-jwt") - #expect(minted.accountEmail == nil) - } - - @Test - func `mintBearerToken tolerates user_profile with missing email without throwing`() async throws { - let stub = ProviderHTTPTransportStub { request in - let body = """ - {"success": true, "data": {"nak": "fake-minted-jwt", "user_profile": { - "user_id": "fake-user-id", "display_name": "Fake User" - }}} - """ - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - - let minted = try await ZoomMateUsageFetcher.mintBearerToken( - cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), - transport: stub) - - #expect(minted.bearerToken == "fake-minted-jwt") - #expect(minted.accountEmail == nil) - } - - @Test - func `toUsageSnapshot populates identity accountEmail and loginMethod when email is known`() { - let status = ZoomMateCreditStatus( - budgetCap: 35000, - usedCredit: 942, - remainingCredit: 34058, - overageCredit: 0, - allowOverage: false, - cycleStartDate: 1_782_777_600_000, - cycleEndDate: 1_785_455_999_000, - isQuotaAvailable: true, - isUnlimited: false) - let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now) - .toUsageSnapshot(accountEmail: "fake.user@example.com") - - #expect(snapshot.identity?.accountEmail == "fake.user@example.com") - #expect(snapshot.identity?.loginMethod == "Cookie") - } - - @Test - func `mintBearerToken maps unauthorized to invalidCredentials`() async throws { - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! - return (Data("{}".utf8), response) - } - - await #expect { - _ = try await ZoomMateUsageFetcher.mintBearerToken( - cookieHeaders: Self.sharedCookieHeaders("session=expired"), - transport: stub) - } throws: { error in - guard case ZoomMateUsageError.invalidCredentials = error else { return false } - return true - } - } - - @Test - func `mintBearerToken surfaces parseFailed when nak is missing`() async throws { - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data("{\"success\": true, \"data\": {}}".utf8), response) - } - - await #expect { - _ = try await ZoomMateUsageFetcher.mintBearerToken( - cookieHeaders: Self.sharedCookieHeaders("session=fake"), - transport: stub) - } throws: { error in - guard case ZoomMateUsageError.parseFailed = error else { return false } - return true - } - } - - @Test - func `descriptor dashboard URL points to the credit usage pane`() { - #expect( - ZoomMateProviderDescriptor.descriptor.metadata.dashboardURL == - "https://zoommate.zoom.us/#/?settings=credit-usage") - } - - #if os(macOS) - @Test - func `descriptor limits automatic cookie import to Chrome`() throws { - let order = try #require(ZoomMateProviderDescriptor.descriptor.metadata.browserCookieOrder) - #expect(order == [.chrome]) - } - #endif - - @Test - func `credential errors describe distinct recovery actions`() { - #expect(ZoomMateUsageError.noCapture.localizedDescription.contains("ai.zoom.us")) - #expect(ZoomMateUsageError.noSession.localizedDescription.contains("Chrome")) - #expect(ZoomMateUsageError.invalidCredentials.localizedDescription.contains("rejected")) - } - - @Test - func `verbose logs omit captured cookies and bearer tokens`() async throws { - let cookieMarker = "COOKIE_SECRET_MARKER" - let tokenMarker = "TOKEN_SECRET_MARKER" - let nakMarker = "NAK_SECRET_MARKER" - let curl = """ - curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \ - -H 'authorization: Bearer \(tokenMarker)' \ - -H 'cookie: session=\(cookieMarker)' - """ - let stub = ProviderHTTPTransportStub { request in - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(Self.sampleResponse.utf8), response) - } - let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) - let messages = MessageRecorder() - - _ = try await fetcher.fetch( - manualCaptureOverride: curl, - logger: { messages.append($0) }, - transport: stub) - - let output = messages.output() - #expect(!output.contains(cookieMarker)) - #expect(!output.contains(tokenMarker)) - #expect(output.contains("Forwarding captured headers: Cookie")) - - let mintStub = ProviderHTTPTransportStub { request in - let body = "{\"success\": true, \"data\": {\"nak\": \"\(nakMarker)\"}}" - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( - cookieHeaders: Self.sharedCookieHeaders("session=\(cookieMarker)"), - cache: ZoomMateBearerTokenCache(), - timeout: 1, - transport: mintStub, - logger: { messages.append($0) }) - - let mintOutput = messages.output() - #expect(!mintOutput.contains(cookieMarker)) - #expect(!mintOutput.contains(nakMarker)) - } - - // MARK: - Bearer token expiry + in-memory cache - - /// Minimal unsigned JWT carrying only an `exp` claim, for cache-expiry tests. - private static func makeJWT(exp: Int) -> String { - func b64url(_ text: String) -> String { - Data(text.utf8).base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - return "\(b64url("{\"alg\":\"none\"}")).\(b64url("{\"exp\":\(exp)}")).sig" - } - - @Test - func `expiry decodes exp claim from a bearer JWT and ignores non-JWT tokens`() { - let jwt = Self.makeJWT(exp: 1_782_800_000) - #expect(ZoomMateUsageFetcher.expiry(fromJWT: jwt) == Date(timeIntervalSince1970: 1_782_800_000)) - // Tolerates an already-prefixed "Bearer " value. - #expect(ZoomMateUsageFetcher.expiry(fromJWT: "Bearer \(jwt)") == Date(timeIntervalSince1970: 1_782_800_000)) - // Opaque (non-JWT) tokens are undatable → nil (caller must not cache them). - #expect(ZoomMateUsageFetcher.expiry(fromJWT: "opaque-token") == nil) - } - - @Test - func `cachedOrMintedToken reuses an in-date token instead of re-minting`() async throws { - let jwt = Self.makeJWT(exp: 9_999_999_999) - let stub = ProviderHTTPTransportStub { request in - let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - let cache = ZoomMateBearerTokenCache() - - let first = try await ZoomMateUsageFetcher.cachedOrMintedToken( - cookieHeaders: Self.sharedCookieHeaders("session=abc"), - cache: cache, - timeout: 1, - transport: stub, - logger: nil) - let second = try await ZoomMateUsageFetcher.cachedOrMintedToken( - cookieHeaders: Self.sharedCookieHeaders("session=abc"), - cache: cache, - timeout: 1, - transport: stub, - logger: nil) - - #expect(first.bearerToken == jwt) - #expect(second.bearerToken == jwt) - #expect(await stub.requests().count == 1) // minted once, reused once - } - - @Test - func `cachedOrMintedToken re-mints a token without a decodable expiry`() async throws { - let stub = ProviderHTTPTransportStub { request in - let body = "{\"success\": true, \"data\": {\"nak\": \"opaque-not-a-jwt\"}}" - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - let cache = ZoomMateBearerTokenCache() - - _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( - cookieHeaders: Self.sharedCookieHeaders("session=abc"), - cache: cache, - timeout: 1, - transport: stub, - logger: nil) - _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( - cookieHeaders: Self.sharedCookieHeaders("session=abc"), - cache: cache, - timeout: 1, - transport: stub, - logger: nil) - - #expect(await stub.requests().count == 2) // undatable token is never cached - } - - @Test - func `cache serves an in-date entry but withholds one inside the refresh-skew window`() async { - let cache = ZoomMateBearerTokenCache() - let key = ZoomMateBearerTokenCache.key(forCookieHeaders: Self.sharedCookieHeaders("session=abc")) - let now = Date(timeIntervalSince1970: 1_000_000_000) - // Expiry comfortably beyond the 60s skew → served. - await cache.store( - ZoomMateBearerTokenCache.Entry( - token: "t", - accountEmail: nil, - expiry: now.addingTimeInterval(600)), - forKey: key) - #expect(await cache.validEntry(forKey: key, now: now) != nil) - - // Re-store with an expiry only 30s out (inside the 60s skew) → withheld and evicted. - await cache.store( - ZoomMateBearerTokenCache.Entry( - token: "t", - accountEmail: nil, - expiry: now.addingTimeInterval(30)), - forKey: key) - #expect(await cache.validEntry(forKey: key, now: now) == nil) - // Eviction is durable: a later lookup still misses. - #expect(await cache.validEntry(forKey: key, now: now) == nil) - } - - @Test - func `invalidate evicts a cached token so the next call re-mints`() async throws { - let jwt = Self.makeJWT(exp: 9_999_999_999) - let stub = ProviderHTTPTransportStub { request in - let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (Data(body.utf8), response) - } - let cache = ZoomMateBearerTokenCache() - let key = ZoomMateBearerTokenCache.key(forCookieHeaders: Self.sharedCookieHeaders("session=abc")) - - _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( - cookieHeaders: Self.sharedCookieHeaders("session=abc"), - cache: cache, - timeout: 1, - transport: stub, - logger: nil) - await cache.invalidate(forKey: key) - _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( - cookieHeaders: Self.sharedCookieHeaders("session=abc"), - cache: cache, - timeout: 1, - transport: stub, - logger: nil) - - #expect(await stub.requests().count == 2) - } - - #if os(macOS) - @Test - func `issue 2507 fixture routes parent domain cookie to both hosts without leaking host-only cookies`() throws { - let records = try Self.issue2507CookieRecords() - let headers = ZoomMateCookieImporter.cookieHeaders(from: records) - - #expect(headers.header(forHost: "ai.zoom.us") == "parent=fake; ai-only=fake") - #expect(headers.header(forHost: "zoommate.zoom.us") == "parent=fake; mate-only=fake") - } - - @Test - func `cookie scope filter follows explicit RFC 6265 scope`() { - #expect(ZoomMateCookieImporter.isSendable( - cookieDomain: "ai.zoom.us", scope: .hostOnly, toHost: "ai.zoom.us")) - #expect(!ZoomMateCookieImporter.isSendable( - cookieDomain: "ai.zoom.us", scope: .hostOnly, toHost: "zoommate.zoom.us")) - #expect(ZoomMateCookieImporter.isSendable( - cookieDomain: "zoom.us", scope: .domain, toHost: "ai.zoom.us")) - #expect(ZoomMateCookieImporter.isSendable( - cookieDomain: "zoom.us", scope: .domain, toHost: "zoommate.zoom.us")) - #expect(!ZoomMateCookieImporter.isSendable( - cookieDomain: "zoom.us", scope: .hostOnly, toHost: "ai.zoom.us")) - #expect(!ZoomMateCookieImporter.isSendable( - cookieDomain: "marketing.zoom.us", scope: .hostOnly, toHost: "ai.zoom.us")) - #expect(!ZoomMateCookieImporter.isSendable( - cookieDomain: "zoom.us.attacker.com", scope: .domain, toHost: "ai.zoom.us")) - #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "", scope: .domain, toHost: "ai.zoom.us")) - } - - private struct CookieScopeFixture: Decodable { - let records: [Record] - - struct Record: Decodable { - let sourceDomain: String - let domain: String - let scope: String - let name: String - let value: String - } - } - - private static func issue2507CookieRecords() throws -> [BrowserCookieRecord] { - let url = try #require(Bundle.module.url( - forResource: "issue-2507-cookie-scope", - withExtension: "json", - subdirectory: "Fixtures/ZoomMate")) - let fixture = try JSONDecoder().decode(CookieScopeFixture.self, from: Data(contentsOf: url)) - return try fixture.records.map { record in - let scope: BrowserCookieScope = switch record.scope { - case "domain": .domain - case "hostOnly": .hostOnly - default: throw ZoomMateUsageError.parseFailed("Unknown cookie fixture scope: \(record.scope)") - } - #expect(record.sourceDomain.trimmingPrefix(".") == record.domain) - return BrowserCookieRecord( - domain: record.domain, - name: record.name, - path: "/", - value: record.value, - expires: nil, - isSecure: true, - isHTTPOnly: true, - scope: scope) - } - } - #endif - - private static func makeContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext { - ProviderFetchContext( - runtime: .app, - sourceMode: .auto, - includeCredits: true, - webTimeout: 1, - webDebugDumpHTML: false, - verbose: false, - env: [:], - settings: settings, - fetcher: UsageFetcher(environment: [:]), - claudeFetcher: StubClaudeFetcher(), - browserDetection: BrowserDetection(cacheTTL: 0)) - } -} diff --git a/TestsPlugin/NotionPluginTests.swift b/TestsPlugin/NotionPluginTests.swift new file mode 100644 index 0000000000..b95969a43e --- /dev/null +++ b/TestsPlugin/NotionPluginTests.swift @@ -0,0 +1,150 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct NotionPluginTests { + private static let now = Date(timeIntervalSince1970: 1_785_600_000) + private static let spaces = #"{"user":{"notion_user":{"user":{"value":{"value":{"id":"user","email":"fixture@example.test"}}}},"space":{"free":{"value":{"id":"00000000-0000-0000-0000-000000000000","name":"Personal","subscription_tier":"free"}},"paid":{"value":{"id":"11111111-2222-3333-4444-555555555555","name":"Fixture team","subscription_tier":"business"}}}}}"# + + @Test(arguments: BundledPluginTestSupport.engines) + func `workspace selection identity and overage match native snapshots`( + engine: ProviderPluginEngineKind) async throws + { + for preferred in ["", "11111111222233334444555555555555", "unknown"] { + let runtime = try Self.runtime( + engine: engine, + usage: #"{"window":{"window":"6h","used":60,"limit":50},"resetsInSeconds":0,"billingPeriodWindow":{"used":18,"limit":100,"periodEndMs":1788000000000}}"#) + let usage = try await runtime.fetchUsage( + settings: ["WORKSPACE_ID": preferred], + now: Self.now, + cookieSource: .manual, + cookieSessionResolver: Self.session) + #expect(usage.primary?.usedPercent == 120) + #expect(usage.primary?.resetsAt == Self.now) + #expect(usage.primary?.windowMinutes == 360) + #expect(usage.secondary?.usedPercent == 18) + #expect(usage.secondary?.windowMinutes == 43200) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_788_000_000)) + #expect(usage.identity?.providerID == .notion) + #expect(usage.identity?.accountEmail == "fixture@example.test") + #expect(usage.identity?.accountOrganization == "Fixture team") + #expect(usage.identity?.accountID == "user") + #expect(usage.identity?.loginMethod == "Business") + } + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `unmeasurable windows and monthly sentinel collisions stay omitted`( + engine: ProviderPluginEngineKind) async throws + { + for token in ["30d", "720h", "43200m", "nonsense"] { + let runtime = try Self.runtime(engine: engine, usage: """ + {"window":{"window":"\(token)","used":-3,"limit":100},"resetsInSeconds":-1, + "billingPeriodWindow":{"used":25,"limit":0}} + """) + let usage = try await runtime.fetchUsage(cookieSource: .manual, cookieSessionResolver: Self.session) + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.secondary == nil) + } + let runtime = try Self.runtime(engine: engine, usage: #"{"window":{"used":25},"billingPeriodWindow":{}}"#) + let usage = try await runtime.fetchUsage(cookieSource: .manual, cookieSessionResolver: Self.session) + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `ambiguous identity invalid payloads and unsupported workspaces fail`( + engine: ProviderPluginEngineKind) async throws + { + for payload in [ + "{}", + "[]", + "not-json", + #"{"window":{"used":"25","limit":100}}"#, + #"{"status":"not_applicable"}"#, + ] { + let runtime = try Self.runtime(engine: engine, usage: payload) + await #expect(throws: (any Error).self) { + try await runtime.fetchUsage(cookieSource: .manual, cookieSessionResolver: Self.session) + } + } + let runtime = try Self.runtime(engine: engine, usage: "{}", spaces: #"{"first":{},"second":{}}"#) + await #expect(throws: (any Error).self) { + try await runtime.fetchUsage(cookieSource: .manual, cookieSessionResolver: Self.session) + } + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `a successful usage response validates the session and a fresh rejected profile stops`( + engine: ProviderPluginEngineKind) async throws + { + let validated = Calls() + let runtime = try Self.runtime(engine: engine, usage: #"{"window":{"used":1,"limit":100}}"#) + _ = try await runtime.fetchUsage( + cookieSessionResolver: Self.session, + cookieSessionValidator: { domain, id in validated.append("\(domain):\(id)") }) + #expect(validated.values == ["app.notion.com:synthetic-session"]) + let failing = try Self.runtime(engine: engine, usage: "{}", status: 401) + let rejected = Calls() + await #expect(throws: (any Error).self) { + try await failing.fetchUsage( + cookieSessionResolver: Self.session, + cookieSessionInvalidator: { _, id in rejected.append(id) }, + cookieSessionValidator: { _, _ in + Issue.record("A rejected session cannot be persisted") + }) + } + #expect(rejected.values == ["synthetic-session"]) + } + + private static let session: ProviderPluginRuntime.CookieSessionResolver = { _, _ in + .init( + header: "token_v2=synthetic", + source: "Fixture", + origin: "https://app.notion.com", + id: "synthetic-session") + } + + private static func runtime( + engine: ProviderPluginEngineKind, + usage: String, + spaces: String = Self.spaces, + status: Int = 200) throws + -> ProviderPluginRuntime + { + try BundledPluginTestSupport.runtime( + "notion", + engine: engine, + transport: ProviderHTTPTransportHandler { request in + #expect(request.httpMethod == "POST") + #expect(request.url?.host == "app.notion.com") + #expect(request.value(forHTTPHeaderField: "Cookie") == "token_v2=synthetic") + if request.url?.lastPathComponent == "getCreditRateLimitStatus" { + let body = try JSONDecoder().decode([String: String].self, from: #require(request.httpBody)) + #expect(body["spaceId"] == "11111111-2222-3333-4444-555555555555") + } + let data = request.url?.lastPathComponent == "getSpaces" ? spaces : usage + let url = try #require(request.url) + return try (Data(data.utf8), #require(HTTPURLResponse( + url: url, + statusCode: status, + httpVersion: nil, + headerFields: nil))) + }) + } + + private final class Calls: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + var values: [String] { + self.lock.withLock { self.storage } + } + + func append(_ value: String) { self.lock.withLock { self.storage.append(value) } } + } +} diff --git a/TestsPlugin/ProviderPluginCookieJarTests.swift b/TestsPlugin/ProviderPluginCookieJarTests.swift index 25dfbf4e5d..656f03c74b 100644 --- a/TestsPlugin/ProviderPluginCookieJarTests.swift +++ b/TestsPlugin/ProviderPluginCookieJarTests.swift @@ -8,6 +8,39 @@ import Testing struct ProviderPluginCookieJarTests { private static let now = Date(timeIntervalSince1970: 1_800_000_000) + #if os(macOS) + @Test(arguments: BundledPluginTestSupport.engines) + func `jar import retains interactive authorization across the engine callback`( + engine: ProviderPluginEngineKind) async throws + { + let broker = ProviderInteractionContext.$current.withValue(.userInitiated) { + ProviderPluginCookieBroker( + provider: .longcat, + domains: ["example.test"], + settings: .init(cookieSource: .auto, manualCookieHeader: nil), + batches: { _, _ in nil }, + jarImporter: { + [.init( + header: "", + source: ProviderInteractionContext.current == .userInitiated + ? "interactive" : "background", + origin: "", + records: [])] + }) + } + let runtime = try Self.runtime(engine: engine, script: """ + for await (const session of ctx.browser.sessions('example.test')) { + return {identity: {loginMethod: session.source}}; + } + throw new Error('missing fixture session'); + """, transport: ProviderHTTPTransportHandler { _ in throw URLError(.badURL) }) + let usage = try await runtime.fetchUsage(cookieSessionResolver: { domain, cachedOnly in + try broker.nextSession(domain: domain, cachedOnly: cachedOnly) + }) + #expect(usage.identity?.loginMethod == "interactive") + } + #endif + @Test func `URL matcher preserves duplicate names and path boundaries`() throws { let records = try [ @@ -34,10 +67,14 @@ struct ProviderPluginCookieJarTests { @Test func `same-origin redirects reselect cookies and reject credential-leaking destinations`() throws { let jar = ProviderPluginCookieJar() - let session = ProviderPluginCookieSession(header: "", source: "Fixture", origin: "https://example.test", records: try [ - Self.record("session", "root", domain: "example.test"), - Self.record("session", "scoped", domain: "example.test", path: "/api"), - ]) + let session = try ProviderPluginCookieSession( + header: "", + source: "Fixture", + origin: "https://example.test", + records: [ + Self.record("session", "root", domain: "example.test"), + Self.record("session", "scoped", domain: "example.test", path: "/api"), + ]) jar.register(session) let delegate = ProviderPluginCookieTransport.CookieRedirectDelegate(jar: jar, id: session.id) let original = try #require(URL(string: "https://example.test/api/me")) @@ -57,7 +94,9 @@ struct ProviderPluginCookieJarTests { } @Test(arguments: BundledPluginTestSupport.engines) - func `scripts see metadata only and host selects cookies for every request`(engine: ProviderPluginEngineKind) async throws { + func `scripts see metadata only and host selects cookies for every request`( + engine: ProviderPluginEngineKind) async throws + { let record = try Self.record("session", "private-fixture", domain: "example.test", path: "/api") let runtime = try Self.runtime(engine: engine, script: """ for await (const session of ctx.browser.sessions("example.test")) { @@ -87,7 +126,9 @@ struct ProviderPluginCookieJarTests { } @Test(arguments: BundledPluginTestSupport.engines) - func `forged and previous-fetch session identifiers fail before transport`(engine: ProviderPluginEngineKind) async throws { + func `forged and previous-fetch session identifiers fail before transport`( + engine: ProviderPluginEngineKind) async throws + { let runtime = try Self.runtime(engine: engine, script: """ const prior = ctx.cache.get("session") || "forged"; for await (const session of ctx.browser.sessions("example.test")) { @@ -144,7 +185,8 @@ struct ProviderPluginCookieJarTests { } private static func runtime( - engine: ProviderPluginEngineKind, script: String, transport: any ProviderHTTPTransport) throws -> ProviderPluginRuntime + engine: ProviderPluginEngineKind, script: String, + transport: any ProviderHTTPTransport) throws -> ProviderPluginRuntime { try ProviderPluginRuntime(source: """ defineProvider({id: "longcat", name: "Fixture", settings: [], endpoints: ["https://example.test", "https://other.test", "https://example.test:444"], @@ -194,7 +236,9 @@ struct ProviderPluginCookieJarTests { private static func response(_ request: URLRequest) throws -> (Data, URLResponse) { let url = try #require(request.url) - return try (Data("{}".utf8), #require(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil))) + return try ( + Data("{}".utf8), + #require(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil))) } private static func record( diff --git a/TestsPlugin/ProviderPluginPersistentCookieSecurityTests.swift b/TestsPlugin/ProviderPluginPersistentCookieSecurityTests.swift new file mode 100644 index 0000000000..c1a496db92 --- /dev/null +++ b/TestsPlugin/ProviderPluginPersistentCookieSecurityTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ProviderPluginPersistentCookieSecurityTests { + @Test(arguments: BundledPluginTestSupport.engines) + func `declared access gate preserves CLI refresh and prompt free import attempts`( + engine: ProviderPluginEngineKind) throws + { + let gated = try #require(Self.runtime( + engine, + policy: "{selection: 'request-url', cache: 'validated-single-entry', imports: 'access-gated'}", + body: "return {empty: true};").manifest.cookiePolicy) + #expect(gated.allowsImportAttempt(runtime: .cli, interaction: .userInitiated)) + #expect(gated.allowsImportAttempt(runtime: .cli, interaction: .background)) + #expect(gated.allowsImportAttempt(runtime: .app, interaction: .background)) + let restricted = try #require(Self.runtime(engine, body: "return {empty: true};").manifest.cookiePolicy) + #expect(restricted.allowsImportAttempt(runtime: .app, interaction: .userInitiated)) + #expect(!restricted.allowsImportAttempt(runtime: .cli, interaction: .userInitiated)) + #expect(!restricted.allowsImportAttempt(runtime: .app, interaction: .background)) + #if os(macOS) + let checks: [(KeychainAccessPreflight.Outcome, Bool)] = [ + (.allowed, true), (.interactionRequired, false), (.notFound, false), (.failure(-25293), false), + ] + for (outcome, allowed) in checks { + KeychainAccessGate.withTaskOverrideForTesting(false) { + ProviderInteractionContext.$current.withValue(.background) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in outcome } operation: { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == allowed) + } + } + } + } + #endif + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `cookie policy rejects malformed or undeclared authority`(engine: ProviderPluginEngineKind) { + for policy in [ + "null", "[]", "{selection: 'unknown', cache: 'validated-single-entry'}", + "{selection: 'request-url', cache: 'forever'}", + "{selection: 'request-url', cache: 'validated-single-entry', imports: 'always-prompt'}", + "{selection: 'ranked-source-domains', cache: 'validated-single-entry', sourceDomains: ['evil.test']}", + "{selection: 'ranked-source-domains', cache: 'validated-single-entry', sourceDomains: ['example.test', 'example.test']}", + "{selection: 'request-url', cache: 'validated-single-entry', requiredCookies: ['bad\\nname']}", + "{selection: 'request-url', cache: 'validated-single-entry', missingCookies: true}", + "{selection: 'request-url', cache: 'validated-single-entry', sessionFile: {path: '/tmp/arbitrary'}}", + "{selection: 'request-url', cache: 'nonpersistent', sessionFile: {tokenField: 'token', cookieName: 'session'}}", + ] { + #expect(throws: ProviderPluginError.self) { + try Self.runtime(engine, policy: policy, body: "return {empty: true};") + } + } + #expect(throws: ProviderPluginError.self) { + try ProviderPluginRuntime(source: """ + defineProvider({id: 'user-fixture', name: 'Fixture', settings: [], endpoints: ['https://example.test'], + capabilities: ['browser-cookies'], cookieDomains: ['example.test'], + cookiePolicy: {selection: 'request-url', cache: 'validated-single-entry'}, fetchUsage() {return {empty: true};}}); + """, allowsDynamicID: true, engine: engine) + } + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `forged rejected and wrong-origin sessions cannot be accepted`(engine: ProviderPluginEngineKind) async throws { + for body in [ + "ctx.browser.acceptCookie('example.test', {id: 'forged'});", + "for await (const session of ctx.browser.sessions('example.test')) { ctx.browser.rejectCookie('example.test', session); ctx.browser.acceptCookie('example.test', session); break; }", + "for await (const session of ctx.browser.sessions('example.test')) { ctx.browser.acceptCookie('other.test', session); break; }", + ] { + let runtime = try Self.runtime(engine, body: body + "return {empty: true};") + await #expect(throws: (any Error).self) { + try await runtime.fetchUsage(cookieSessionResolver: { _, _ in + .init(header: "session=fixture", source: "Fixture", origin: "https://example.test") + }, cookieSessionValidator: { _, _ in Issue.record("Invalid IDs must not reach persistence") }) + } + } + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `legacy host map cookies stay opaque and are redacted from errors`( + engine: ProviderPluginEngineKind) async throws + { + let runtime = try Self.runtime(engine, body: """ + for await (const session of ctx.browser.sessions('example.test')) { + if (session.header !== undefined || session.headersByHost !== undefined || session.records !== undefined) + throw new Error('cookie escaped'); + throw new Error('synthetic-private-cookie'); + } + """) + do { + _ = try await runtime.fetchUsage(cookieSessionResolver: { _, _ in + .init( + header: "", + source: "Fixture", + origin: "https://example.test", + headersByHost: ["example.test": "session=synthetic-private-cookie"]) + }) + Issue.record("Expected an error") + } catch { + #expect(!error.localizedDescription.contains("synthetic-private-cookie")) + #expect(!error.localizedDescription.contains("cookie escaped")) + } + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `snapshot overage policy is typed and still rejects nonfinite values`( + engine: ProviderPluginEngineKind) async throws + { + for policy in [ + "null", + "{percent: true}", + "{percent: 'unbounded'}", + "{percent: 'preserve-overage', extra: true}", + ] { + #expect(throws: ProviderPluginError.self) { + try ProviderPluginRuntime(source: """ + defineProvider({id: 'notion', name: 'Fixture', settings: [], endpoints: ['https://example.test'], + snapshotPolicy: \(policy), fetchUsage() {return {empty:true};}}); + """, engine: engine) + } + } + for number in ["NaN", "Infinity", "'120'"] { + let runtime = try ProviderPluginRuntime(source: """ + defineProvider({id: 'notion', name: 'Fixture', settings: [], endpoints: ['https://example.test'], + snapshotPolicy: {percent: 'preserve-overage'}, fetchUsage() {return {primary:{usedPercent:\(number)}};}}); + """, engine: engine) + await #expect(throws: ProviderPluginError.self) { try await runtime.fetchUsage() } + } + } + + private static func runtime( + _ engine: ProviderPluginEngineKind, + policy: String = "{selection: 'request-url', cache: 'validated-single-entry'}", + body: String) throws -> ProviderPluginRuntime + { + try ProviderPluginRuntime(source: """ + defineProvider({id: 'notion', name: 'Fixture', settings: [], endpoints: ['https://example.test'], + capabilities: ['browser-cookies'], cookieDomains: ['example.test', 'other.test'], + cookiePolicy: \(policy), async fetchUsage(ctx) {\(body)}}); + """, engine: engine) + } +} diff --git a/TestsPlugin/ProviderPluginSnapshotContractTests.swift b/TestsPlugin/ProviderPluginSnapshotContractTests.swift index 8199bc58c7..80440f341a 100644 --- a/TestsPlugin/ProviderPluginSnapshotContractTests.swift +++ b/TestsPlugin/ProviderPluginSnapshotContractTests.swift @@ -82,3 +82,22 @@ struct ProviderPluginSnapshotContractTests { } } } + +struct ProviderPluginOverQuotaTests { + @Test(arguments: ProviderPluginTransportTests.engines) + func `over quota values require an explicit manifest policy`(engine: ProviderPluginEngineKind) async throws { + for (policy, expected) in [("", 100.0), ("snapshotPolicy: {percent: 'preserve-overage'},", 120.0)] { + let runtime = try ProviderPluginRuntime(source: """ + defineProvider({id: 'notion', name: 'Fixture', settings: [], endpoints: ['https://example.test'], + \(policy) + async fetchUsage() { return {primary: {usedPercent: 120}, secondary: {usedPercent: -5}, + extraWindows: [{id: 'extra', title: 'Extra', usedPercent: 120}]}; } + }); + """, engine: engine) + let result = try await runtime.fetchUsage() + #expect(result.primary?.usedPercent == expected) + #expect(result.secondary?.usedPercent == 0) + #expect(result.extraRateWindows?.first?.window.usedPercent == expected) + } + } +} diff --git a/TestsPlugin/ZoomMatePluginTests.swift b/TestsPlugin/ZoomMatePluginTests.swift new file mode 100644 index 0000000000..2a60cf5b94 --- /dev/null +++ b/TestsPlugin/ZoomMatePluginTests.swift @@ -0,0 +1,335 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct ZoomMatePluginTests { + private static let now = Date(timeIntervalSince1970: 1_800_000_000) + private static let status = #"{"data":{"credit_status":{"budget_cap":1000,"used_credit":250,"cycle_start_date":1799000000000,"cycle_end_date":1801000000000}}}"# + + @Test(arguments: BundledPluginTestSupport.engines) + func `bootstrap validates before required usage and native credit history details survive`( + engine: ProviderPluginEngineKind) async throws + { + let calls = Calls() + let timestamp = ISO8601DateFormatter().string(from: Self.now) + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + calls.append(url.lastPathComponent) + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=synthetic") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://zoommate.zoom.us") + switch url.lastPathComponent { + case "login": + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + return try Self.response( + request, + body: #"{"data":{"nak":"opaque-fixture","user_profile":{"email":"fixture@example.test"}}}"#) + case "status": + #expect(calls.values.contains("validated")) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer opaque-fixture") + return try Self.response(request, body: Self.status) + case "history": + return try Self.response(request, body: """ + {"data":{"total":4,"records":[{"cost":2.5,"time":"\(timestamp)"}, + {"cost":1,"time":"\(timestamp)","is_running":true}, + {"cost":999,"time":"\(timestamp)","is_deleted":true}, + {"cost":-1,"time":"\(timestamp)"}]}} + """) + default: throw URLError(.badURL) + } + } + let runtime = try BundledPluginTestSupport.runtime("zoommate", engine: engine, transport: transport) + let usage = try await runtime.fetchUsage( + now: Self.now, + cookieSessionResolver: Self.session, + cookieSessionValidator: { _, _ in calls.append("validated") }) + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "Credits") + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_801_000_000)) + #expect(usage.identity?.accountEmail == "fixture@example.test") + #expect(usage.identity?.loginMethod == "Cookie") + let rows = try #require(usage.details.first?.rows) + #expect(rows.map(\.label) == ["Today", "30d credits", "Pace"]) + #expect(rows.map(\.value) == ["3.5", "3.5", "25% behind budget"]) + #expect(usage.details.first?.chart?.points.map(\.value) == [3.5]) + #expect(calls.values == ["login", "validated", "status", "history"]) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `bearer cache survives runtime replacement and is invalidated after a rejected history`( + engine: ProviderPluginEngineKind) async throws + { + let calls = Calls() + let key = UUID().uuidString + let payload = Data("{\"exp\":\(Date().timeIntervalSince1970 + 3600)}".utf8) + .base64EncodedString().replacingOccurrences(of: "=", with: "") + let token = "fixture.\(payload).signature" + for attempt in 0..<3 { + let runtime = try BundledPluginTestSupport.runtime( + "zoommate", + engine: engine, + transport: ProviderHTTPTransportHandler { request in + let path = request.url?.lastPathComponent ?? "" + calls.append(path) + if path == "login" { return try Self.response( + request, + body: "{\"data\":{\"nak\":\"\(token)\"}}") } + return try Self.response( + request, + code: path == "history" && attempt == 1 ? 401 : 200, + body: path == "status" ? Self + .status : #"{"data":{"records":[]}}"#) + }) + _ = try await runtime.fetchUsage(cookieSessionResolver: { _, _ in + .init(header: "session=synthetic", source: "Fixture", origin: "https://ai.zoom.us", cacheKey: key) + }, cookieSessionValidator: { _, _ in }) + } + #expect(calls.values.filter { $0 == "login" }.count == 2) + #expect(calls.values.filter { $0 == "status" }.count == 3) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `host failover preserves leaf scope and optional history failure preserves required usage`( + engine: ProviderPluginEngineKind) async throws + { + let calls = Calls() + let runtime = try BundledPluginTestSupport.runtime( + "zoommate", + engine: engine, + transport: ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + calls.append(url.host ?? "") + if url + .host == + "ai.zoom.us" { throw URLError(.cannotConnectToHost) } + #expect(request + .value(forHTTPHeaderField: "Cookie") == + "session=mate") + if url + .lastPathComponent == + "login" { return try Self.response( + request, + body: #"{"data":{"nak":"fixture"}}"#) } + return try Self.response( + request, + code: url.lastPathComponent == "history" ? 500 : 200, + body: Self.status) + }) + let usage = try await runtime.fetchUsage(now: Self.now, cookieSessionResolver: { _, _ in + .init( + header: "", + source: "Fixture", + origin: "https://ai.zoom.us", + headersByHost: ["ai.zoom.us": "session=ai", "zoommate.zoom.us": "session=mate"]) + }, cookieSessionValidator: { _, _ in }) + #expect(usage.primary?.usedPercent == 25) + #expect(usage.details.isEmpty) + #expect(calls.values == Array(repeating: ["ai.zoom.us", "zoommate.zoom.us"], count: 3).flatMap(\.self)) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `bootstrap parse failures do not validate or fail over`(engine: ProviderPluginEngineKind) async throws { + let calls = Calls() + let runtime = try BundledPluginTestSupport.runtime( + "zoommate", + engine: engine, + transport: ProviderHTTPTransportHandler { request in + calls.append(request.url?.host ?? "") + return try Self.response(request, body: #"{"data":{}}"#) + }) + await #expect(throws: (any Error).self) { + try await runtime.fetchUsage( + cookieSessionResolver: Self.session, + cookieSessionValidator: { _, _ in + Issue.record("Failed bootstrap must not persist") + }) + } + #expect(calls.values == ["ai.zoom.us"]) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `manual bearer without cookies survives sibling failover without a bootstrap`( + engine: ProviderPluginEngineKind) async throws + { + let runtime = try BundledPluginTestSupport.runtime( + "zoommate", + engine: engine, + transport: ProviderHTTPTransportHandler { request in + #expect(request.url?.lastPathComponent != "login") + #expect(request + .value(forHTTPHeaderField: "Authorization") == + "Bearer manual-fixture") + #expect(request.value(forHTTPHeaderField: "Cookie")? + .isEmpty != false) + if request.url? + .host == + "ai.zoom.us" { throw URLError(.cannotConnectToHost) } + return try Self.response( + request, + body: request.url? + .lastPathComponent == "status" ? Self + .status : #"{"data":{"records":[]}}"#) + }) + let usage = try await runtime.fetchUsage( + secrets: ["AUTHORIZATION": "manual-fixture"], + cookieSource: .manual, + cookieSessionResolver: { _, _ in + .init( + header: "", + source: "manual", + origin: "https://ai.zoom.us", + permitsEmptyHosts: ["ai.zoom.us", "zoommate.zoom.us"]) + }) + #expect(usage.primary?.usedPercent == 25) + #expect(usage.identity?.accountEmail == nil) + #expect(usage.identity?.loginMethod == nil) + } + + private static let session: ProviderPluginRuntime.CookieSessionResolver = { _, _ in + .init( + header: "session=synthetic", + source: "Fixture", + origin: "https://ai.zoom.us", + headersByHost: ["ai.zoom.us": "session=synthetic", "zoommate.zoom.us": "session=synthetic"]) + } + + private static func response(_ request: URLRequest, code: Int = 200, body: String) throws -> (Data, URLResponse) { + let url = try #require(request.url) + return try ( + Data(body.utf8), + #require(HTTPURLResponse( + url: url, + statusCode: code, + httpVersion: nil, + headerFields: nil))) + } + + private final class Calls: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + var values: [String] { + self.lock.withLock { self.storage } + } + + func append(_ value: String) { self.lock.withLock { self.storage.append(value) } } + } +} + +extension ZoomMatePluginTests { + @Test(arguments: BundledPluginTestSupport.engines) + func `history pagination restarts on the alternate host`(engine: ProviderPluginEngineKind) async throws { + let calls = Calls() + let timestamp = ISO8601DateFormatter().string(from: Self.now) + let runtime = try BundledPluginTestSupport.runtime( + "zoommate", + engine: engine, + transport: ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + if url + .lastPathComponent == + "status" { return try Self.response( + request, + body: Self.status) } + let page = URLComponents( + url: url, + resolvingAgainstBaseURL: false)?.queryItems? + .first(where: { $0.name == "page" })?.value ?? "missing" + calls.append("\(url.host ?? ""): \(page)") + if url.host == "ai.zoom.us", + page == "1" { return try Self.response( + request, + code: 500, + body: "{}") } + let cost = url.host == "ai.zoom.us" ? 100 : 1 + return try Self.response(request, body: """ + {"data":{"total":101,"records":[{"cost":\(cost),"time":"\(timestamp)"}]}} + """) + }) + let usage = try await runtime.fetchUsage( + secrets: ["AUTHORIZATION": "fixture"], + now: Self.now, + cookieSource: .manual, + cookieSessionResolver: Self.session) + #expect(calls.values == [ + "ai.zoom.us: 0", + "ai.zoom.us: 1", + "zoommate.zoom.us: 0", + "zoommate.zoom.us: 1", + "zoommate.zoom.us: 2", + ]) + #expect(usage.details.first?.rows.first?.value == "3") + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `history stops at twenty pages or an entirely older page`(engine: ProviderPluginEngineKind) async throws { + for old in [false, true] { + let calls = Calls() + let timestamp = ISO8601DateFormatter() + .string(from: old ? Self.now.addingTimeInterval(-40 * 86400) : Self.now) + let runtime = try BundledPluginTestSupport.runtime( + "zoommate", + engine: engine, + transport: ProviderHTTPTransportHandler { request in + if request.url? + .lastPathComponent == + "status" { return try Self.response( + request, + body: Self.status) } + calls.append("page") + return try Self.response(request, body: """ + {"data":{"total":99999,"records":[{"cost":1,"time":"\(timestamp)"}]}} + """) + }) + let usage = try await runtime.fetchUsage( + secrets: ["AUTHORIZATION": "fixture"], + now: Self.now, + cookieSource: .manual, + cookieSessionResolver: Self.session) + #expect(calls.values.count == (old ? 1 : 20)) + #expect(usage.details.first?.rows.first?.value == (old ? "0" : "20")) + } + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `bootstrap auth rejection advances profiles without host failover`( + engine: ProviderPluginEngineKind) async throws + { + let calls = Calls() + let sessions = Calls() + let runtime = try BundledPluginTestSupport.runtime( + "zoommate", + engine: engine, + transport: ProviderHTTPTransportHandler { request in + let cookie = request + .value(forHTTPHeaderField: "Cookie") ?? "" + if cookie == "session=old" { + calls.append(request.url?.host ?? "") + return try Self.response( + request, + code: 401, + body: "{}") + } + let path = request.url?.lastPathComponent ?? "" + let body = path == "login" ? + #"{"data":{"nak":"fixture"}}"# : path == "status" ? + Self.status : #"{"data":{"records":[]}}"# + return try Self.response(request, body: body) + }) + let usage = try await runtime.fetchUsage(cookieSessionResolver: { _, _ in + let index = sessions.values.count + guard index < 2 else { return nil } + sessions.append("candidate") + let header = index == 0 ? "session=old" : "session=new" + return .init( + header: header, + source: "Fixture", + origin: "https://ai.zoom.us", + cachedAt: index == 0 ? 1 : nil) + }, cookieSessionValidator: { _, _ in }) + #expect(usage.primary?.usedPercent == 25) + #expect(calls.values == ["ai.zoom.us"]) + #expect(sessions.values.count == 2) + } +} diff --git a/docs/notion.md b/docs/notion.md index 5ac2af99bb..b35402b1e4 100644 --- a/docs/notion.md +++ b/docs/notion.md @@ -31,7 +31,9 @@ provider error rather than an empty gauge. 1. Sign in to Notion in Chrome. 2. Enable **Notion AI** in **Settings → Providers**. -CodexBar imports your browser session cookie automatically and sends it only to `https://app.notion.com`. +The bundled Notion plugin runs on both engines. The host imports your browser session cookie automatically and sends +it only to `https://app.notion.com`; the script sees an opaque session ID, never the cookie values. Within each profile, +source domains rank as `app.notion.com`, `www.notion.com`, `notion.com`, `www.notion.so`, then `notion.so`. The import requires the `token_v2` session cookie; a browser profile that has Notion cookies but no `token_v2` is skipped rather than used for a request that would fail with 401. @@ -39,6 +41,12 @@ The import requires the `token_v2` session cookie; a browser profile that has No the shared browser-cookie plumbing can still supply an explicit browser list. Chrome cookie decryption may require macOS Keychain approval. +Validated sessions remain in the shared cookie cache. Background refreshes first reuse the existing owner-only +`notion-session.json` token file, then the shared cache. If neither succeeds, browser reads remain subject to the shared +access gate: background reads require existing prompt-free authorization, while explicit CLI cookie refreshes retain +their acknowledged retry scope. A successful allowance fetch updates both stores; a 401 conditionally clears the rejected session without erasing a newer one. An interactive +cookie refresh commits its replacement only after success, preserving both prior stores on failure. + ### Manual Set **Cookie source** to **Manual** in the Notion AI provider settings, then paste one of: @@ -103,7 +111,7 @@ The rate-limit response looks like this: Usage is reported against the returned `limit` rather than assumed to be a percentage, so a future non-100 limit keeps working. Over-quota values are preserved rather than clamped; display clamping happens -downstream. +downstream. The plugin declares `snapshotPolicy: {percent: "preserve-overage"}` to retain this behavior on both engines. Custom Agents and Workers are **not** covered by this allowance — Notion meters those with Notion credits (`getAIUsageEligibilityV2`), which this provider does not read. diff --git a/docs/plugin-conversion-matrix.md b/docs/plugin-conversion-matrix.md index 2978063453..775baf0280 100644 --- a/docs/plugin-conversion-matrix.md +++ b/docs/plugin-conversion-matrix.md @@ -24,7 +24,7 @@ Hugging Face, IBM Bob, Muse, Nous, Pi, Replicate, TypeSafe, and v0). `needs-cookie-import` now means **additional cookie/session capability**, not absence of cookie import. The current broker imports declared domains, caches each domain separately (#3815), and offers policy-only `ctx.browser.availability`. It now offers origin-bound candidate iteration and same-refresh advancement after rejection (#3933). -Remaining cookie rows need individual parity audits for their provider-specific ranking and recovery policies. Availability reports policy, not a validated browser login. +Notion and ZoomMate now use the declared validated-single-entry jar, with host-owned migration and conditional rejection. Remaining cookie rows need individual parity audits for their provider-specific ranking and recovery policies. Availability reports policy, not a validated browser login. `needs-files/subprocess/oauth-broker` identifies native credential/storage flows beyond that broker. `needs-host-extension` means another existing native behavior cannot be preserved with the current host APIs. @@ -49,10 +49,10 @@ Abacus, Muse, LongCat, Replicate, and TypeSafe unchanged. | Status | Count | |---|---:| -| `cut-over` | 31 | +| `cut-over` | 33 | | `converted` | 0 | | `convertible-now` | 0 | -| `needs-cookie-import` | 6 | +| `needs-cookie-import` | 4 | | `needs-files/subprocess/oauth-broker` | 20 | | `needs-pty/webview/native` | 8 | | `needs-host-extension` | 4 | @@ -131,9 +131,9 @@ Abacus, Muse, LongCat, Replicate, and TypeSafe unchanged. | wayfinder | `needs-pty/webview/native` | No | The local unauthenticated HTTP gateway, metrics text, and routing/savings model violate HTTPS-only generic scope. | | zenmux | `cut-over` | Yes | Both engines use fixed-origin bearer GETs for required subscription quotas and optional USD PAYG balance. Auth failures and cancellation remain fatal during enrichment; the native fetcher and parser are deleted. | | aiand | `cut-over` | Yes | Both engines use the bundled TypeScript plugin for paired-cursor log pagination, exact decimal sums, partial confidence, and explicit empty windows without a guessed currency; the native fetcher is deleted. | -| zoommate | `needs-cookie-import` | No | Domain-scoped bootstrap GET/JWT exchange and history pagination fit scripts, but rejected sessions advance to the next browser profile in the same refresh. | +| zoommate | `cut-over` | Yes | Both engines use the bundled plugin for bootstrap, bearer reuse, host failover, credits, and bounded optional history. The host owns URL-scoped cookies, validated single-entry persistence, and legacy paired-host migration; native fetch/import/header code is deleted. | | xai | `cut-over` | Yes | Cut over on both engines: bearer GET balance plus best-effort JSON POST history and billing details; the native fetch twins are deleted. | -| notion | `needs-cookie-import` | No | Workspace JSON POST fits scripts, but legacy/current-domain cookie ranking, persisted session reuse, and immediate re-import on rejection exceed the header broker. | +| notion | `cut-over` | Yes | Both engines preserve workspace selection, identity, allowance windows, and over-quota percentages. The host owns ranked source domains, required token_v2 admission, conditional native-session migration, and refresh commit/rollback; native fetch/import/session code is deleted. | ## Additional plugin-first providers diff --git a/docs/plugins.md b/docs/plugins.md index ba41dc45f7..1865406648 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -412,12 +412,23 @@ refreshes update visible plugin cards, and repeated requests for the same plugin Overview continues to summarize built-in providers. This setting changes placement only: it grants no additional host capabilities and does not change network approval. +## Over-quota snapshots + +`snapshotPolicy: {percent: "preserve-overage"}` explicitly preserves finite `usedPercent` values above 100 in all rate +windows, including extra windows. Negative values still become zero, and nonfinite/non-numeric values are rejected. +The default policy (`"clamp"`) remains 0–100. Notion opts in because its allowance endpoint reports meaningful overages; +`ctx.pct` remains clamped, so a preserving plugin computes its own ratio. + ## Browser session cache Bundled providers may declare `cookiePolicy: { selection: "request-url", cache: "nonpersistent" }` alongside `browser-cookies` and `cookieDomains`. This policy imports declared domains together as one candidate per browser -profile. It never reads or writes the persistent cookie cache, and automatic imports require a user-initiated app -refresh. Manual headers remain usable in the CLI; Off disables both sources. +profile. It never reads or writes the persistent cookie cache. The default `imports: "app-interactive"` requires a +user-initiated app refresh. `imports: "access-gated"` delegates import admission to the existing browser access gate, +including explicit CLI cookie refreshes and already-authorized, strictly no-UI background reads. Notion and ZoomMate +declare this policy to preserve their native source behavior. The caller's interaction and explicit-retry scope follow +the importer across engine callbacks; background calls do not gain interactive authorization. Manual headers remain +usable in the CLI; Off disables both sources. With this policy, `ctx.browser.sessions(domain)` exposes only the candidate's `id`, source label, and origin. The header and cookie records remain in Swift, and `ctx.browser.cookieHeader` is denied. Pass the candidate ID as @@ -428,9 +439,36 @@ scripts cannot combine this option with a Cookie or Host override. The production transport uses an ephemeral session without ambient cookies, credentials, or response caching. Same-origin HTTPS redirects reselect cookies for each hop through that same matcher; cross-origin redirects are -rejected. This is not Qwen Cloud's cross-origin dashboard/navigation policy. Ranked source-domain selection, -validated persistent jars, and native session-file migration are not part of this initial policy. User-installed -plugins cannot request it. +rejected. User-installed plugins cannot request these policies. + +`cache: "validated-single-entry"` opts into one host-owned cache row for the whole profile, including paired hosts. +Imported candidates are not persisted until the script calls `ctx.browser.acceptCookie(domain, session)` at its +validation boundary: ZoomMate does so after a successful bootstrap, Notion after a successful allowance response. +The call cannot accept unknown, rejected, previous-fetch, or wrong-origin IDs. Cache writes and rejection compare +against the observed entry, so late requests cannot overwrite or erase a replacement session. Interactive cookie +refreshes stage the single replacement and commit it only when the refresh succeeds; failure leaves the old entry intact. +Legacy plain headers and paired `headersByHost` entries are read by the host and upgraded on validation. No cookies +are copied into plugin storage. Candidates expose an opaque `cacheKey`, derived from the canonical credential rather +than the per-fetch ID. For this persistence policy, `ctx.cache` is process-memory-only JSON state shared across runtime +instances within the provider namespace (128 entries, 128-byte keys, 16 KiB values, maximum 24-hour TTL). ZoomMate +uses that key to reuse readable-expiry bearers until 60 seconds before expiry; bearer tokens are never persisted. + +`selection: "ranked-source-domains"` also requires an ordered `sourceDomains` list drawn from `cookieDomains`. +The host selects each cookie name from the highest-ranked source within one profile, binds the result to the declared +request host, and then uses the existing URL matcher. `requiredCookies` admits only candidates containing all listed +names. Notion ranks `app.notion.com`, `www.notion.com`, `notion.com`, `www.notion.so`, and `notion.so`, requiring `token_v2`. +Ranked source domains authorize that explicit legacy-to-current-host migration; scripts still receive no cookie values. + +An optional `sessionFile: {tokenField: "tokenV2", cookieName: "token_v2"}` declares migration of the provider's existing +`-session.json` file. It cannot name an arbitrary path and requires ranked, single-origin, validated +persistence. The host reads this candidate first in background contexts, writes the compatible file after validation, +and conditionally clears the observed file when rejected. File write-back participates in interactive refresh commit +and rollback and never runs after a failed cookie-cache commit. Files retain owner-only permissions. + +`missingCookies: "omit"` allows a declared HTTPS destination to receive a request with no matching cookie; the default +is `"reject"`. ZoomMate needs omission for bearer-only manual captures and failover to a sibling host lacking a leaf +cookie. This never forwards the first host's cookie to its sibling and never permits undeclared destinations. +Qwen Cloud's cross-origin dashboard navigation remains outside this contract. Bundled plugins that declare multiple cookie domains use separate Keychain-backed cache scopes for each requested domain under the default header policy. Single-domain plugins retain their existing provider cache. Automatic imports query only the requested domain; diff --git a/docs/zoommate.md b/docs/zoommate.md index 79a7a40fe9..3435309efe 100644 --- a/docs/zoommate.md +++ b/docs/zoommate.md @@ -131,8 +131,7 @@ GET https://ai.zoom.us/ai-computer/api/v1/credits/history?app_id=demo_app&limit= non-auth error. Manual requests start on the captured host and retry the other host without the captured host's cookies — see "Auth & privacy" above.) -The `credits/status` response's `data.credit_status` object is decoded into a -`ZoomMateCreditStatus` struct. The `credits/history` request is paginated (looping on `page` until +The bundled `zoommate` plugin decodes the `credits/status` response's `data.credit_status` object on both engines. The `credits/history` request is paginated (looping on `page` until `page * limit + records.length` reaches the response's flat `data.total`, or a page's records are entirely older than the requested `start_time`) to cover the last 30 days; real accounts have modest history (tens of records total), so this is normally 1–2 requests. `app_id` is sent as a @@ -191,9 +190,7 @@ includes: sessions (`is_running: true`) are included since their `cost` reflects consumption so far. The 30-day window is enforced independently at both fetch time (the request's `start_time`) and display time (`dailyBreakdown()` filters to the trailing 30 calendar days regardless of what the -fetch returned), so the chart's calendar span is guaranteed either way. The pacing line only needs -the always-fetched `credits/status` snapshot, so it can appear even in refreshes where -`credits/history` fails or returns nothing. The inline section is gated on having either a +fetch returned), so the chart's calendar span is guaranteed either way. The pacing line uses the paired `credits/status` cycle and appears only when the history request succeeds, including an empty history. The inline section is gated on having either a non-empty daily breakdown or a computable pacing verdict — an empty/failed history fetch silently omits the section instead of showing an empty dashboard. @@ -235,8 +232,8 @@ descriptor allowlist and keeps showing every component their feed returns, uncha codexbar usage --provider zoommate ``` -The CLI reuses the host-scoped cookie headers cached by a previous validated refresh; it does not read Chrome's -cookie store itself. If no cached session exists yet (`noSession`), refresh once from the app or +The CLI first reuses the host-scoped cookies cached by a previous validated refresh. Browser fallback uses the shared +access gate, which permits background reads only with existing prompt-free authorization. If no cached session exists yet (`noSession`), refresh once from the app or seed the cache from the terminal with `codexbar cookie --provider zoommate` (add `--allow-keychain-prompt` to acknowledge that Chrome cookie decryption may prompt). @@ -248,20 +245,27 @@ includes the credits history dashboard and status page above — both are app-me | Error | Cause | Fix | |---|---|---| | `noCapture` | Manual mode is selected but the capture is empty, off-domain, or lacks a parseable `Authorization` header | Paste a fresh cURL capture of the HTTPS `credits/status` request from `ai.zoom.us` or `zoommate.zoom.us` | -| `noSession` | Automatic mode found no cached session and no ZoomMate/Zoom session cookies it may read (background refreshes and the CLI never read Chrome directly) | Sign in to ZoomMate in Chrome and refresh once from the app (or `codexbar cookie --provider zoommate`), or switch to Manual and paste a capture | +| `noSession` | Automatic mode found no cached session and no ZoomMate/Zoom session cookies it may read (browser fallback is limited by the shared access gate) | Sign in to ZoomMate in Chrome and refresh once from the app (or `codexbar cookie --provider zoommate`), or switch to Manual and paste a capture | | `invalidCredentials` | HTTP 401/403 — the token expired (~hourly) or was revoked | Re-sign-in (auto) or re-paste a fresh capture (manual) | | `apiError` | Any other non-200 HTTP status | Check ZoomMate's status; retry later | | `parseFailed` | HTTP 200 body did not contain the expected `credit_status` shape | Open a CodexBar issue with a redacted response sample | +## Plugin and session ownership + +The bundled plugin owns bootstrap, host failover, credit parsing, and bounded history pagination. The host owns cookie +selection and a single validated session cache; scripts receive opaque IDs and never cookie values. Existing paired-host +cache entries migrate on successful validation. The successful bootstrap remains the persistence boundary, even if the +subsequent credits-status request fails. Interactive refreshes stage that replacement until the whole refresh commits. +A rejected stale candidate cannot erase a newer cached session. + +A SHA-256 identity derived from the canonical credential connects bearer reuse across runtime instances. Bearers remain +in bounded process memory only, and unreadable expiries are never cached. Leaf cookies remain restricted to their own +host; a bearer-only request may omit cookies on the alternate host. Cross-origin redirects remain blocked. + ## Key files -- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift` — provider metadata (including `statusPageURL` and the status-component allowlist) and the unified fetch strategy (calls both `credits/status` and `credits/history`) -- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift` — credits/status request, cURL parsing, and cookie-to-token minting -- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift` — credits/history request, paginated with a date-boundary stop, and the `ZoomMateCreditsHistorySnapshot` model -- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift` — response decoding, error taxonomy, window mapping, daily-bucket aggregation (`dailyBreakdown()`), today's-total lookup (`todayCreditsUsed(now:calendar:)`), and pacing verdict computation -- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift` — Chrome cookie-jar import (macOS only) -- `Sources/CodexBar/InlineUsageDashboardContent.swift` — shared Today/30d KPI-tile + mini-bar view also used by Claude/Codex/OpenRouter/etc.; ZoomMate renders through this same component -- `Sources/CodexBar/MenuCardView.swift` — renders the generic inline-dashboard slot for credits-only stacked cards -- `Sources/CodexBar/StatusItemController+Menu.swift` — `statusComponentsSubmenuProviders` and descriptor-backed `filterStatusComponents` -- `Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift` — settings pickers and bindings -- `Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift` — cookie source and capture persistence +- `Sources/CodexBarCore/Resources/Plugins/zoommate.ts` — requests, parsing, JWT expiry handling, history, and snapshot mapping. +- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift` — metadata, manual capture validation, and minimal strategy wiring. +- `Sources/CodexBarCore/Plugins/ProviderPluginCookieJar.swift` — host-owned URL cookie matcher and isolated transport. +- `Sources/CodexBarCore/Plugins/ProviderPluginPersistentCookies.swift` — validated single-entry cache, migration, and conditional rejection. +- `Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift` — existing settings pickers and bindings. From 5e895efd955eb53391fd14967d5325279d5b3949 Mon Sep 17 00:00:00 2001 From: Sogl Date: Mon, 28 Sep 2026 17:02:43 +0300 Subject: [PATCH 062/122] Persist scoped agy token refreshes to the selected account When agy refreshes an expired staged grant, the refreshed tokens lived only in the deleted scoped home, so every refresh restarted from the expired saved token. After the post-run userinfo check binds the effective account, write the staged payload back through the same guarded token-account updater the OAuth strategy uses. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../AntigravityProviderDescriptor.swift | 13 +- .../AntigravityScopedPrintFetch.swift | 90 ++++++++++++-- .../AntigravityScopedPrintFetchTests.swift | 114 +++++++++++++++++- docs/antigravity.md | 5 +- 4 files changed, 211 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index b4cbfa7170..84fd804868 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -531,7 +531,18 @@ struct AntigravityCLIHTTPSFetchStrategy: ProviderFetchStrategy { } #if os(macOS) let scopedReportFetch: (@Sendable () async throws -> ProviderFetchResult)? = { - try await self.fetchScopedPrintUsage(binary: binary, environment: context.env) + try await self.fetchScopedPrintUsage( + binary: binary, + environment: context.env, + credentialsUpdateHandler: { credentials in + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { + return + } + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) + await updater(.antigravity, accountID, token) + }) } #else let scopedReportFetch: (@Sendable () async throws -> ProviderFetchResult)? = nil diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift index 03684e4039..1b0fd8bb5e 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift @@ -77,6 +77,16 @@ enum AntigravityAgyFileTokenEncoder { formatter.timeZone = TimeZone(secondsFromGMT: 0) return formatter.string(from: date) } + + static func expiryDate(from string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: string) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } } // MARK: - Scoped staging @@ -191,6 +201,56 @@ enum AntigravityScopedAgyStaging { return trimmed.lowercased() } + /// Reads the staged token file as `agy` left it after a run. `agy` rewrites + /// this file when it refreshes an expired grant, so the payload may carry a + /// newer access token, refresh token, expiry, and `id_token` than what was + /// staged. + static func stagedTokenPayload( + home: URL, + fileManager: FileManager = .default) -> AntigravityAgyFileTokenPayload? + { + var tokenURL = home + for component in Self.tokenRelativePath { + tokenURL.appendPathComponent(component, isDirectory: false) + } + guard let data = fileManager.contents(atPath: tokenURL.path) else { return nil } + return AntigravityAgyFileTokenEncoder.decode(data: data) + } + + /// Returns the saved-account form of the staged payload when `agy` changed + /// it (typically a token refresh), or nil when the file is unchanged. The + /// caller must only persist the result after the effective account has been + /// verified — the payload alone does not prove which account it belongs to. + static func refreshedCredentials( + home: URL, + original: AntigravityOAuthCredentials, + fileManager: FileManager = .default) -> AntigravityOAuthCredentials? + { + guard let payload = stagedTokenPayload(home: home, fileManager: fileManager), + let originalData = AntigravityAgyFileTokenEncoder.encode(credentials: original), + let originalPayload = AntigravityAgyFileTokenEncoder.decode(data: originalData), + payload != originalPayload + else { + return nil + } + var updated = original + let accessToken = payload.token.accessToken.trimmingCharacters(in: .whitespacesAndNewlines) + let refreshToken = payload.token.refreshToken.trimmingCharacters(in: .whitespacesAndNewlines) + if !accessToken.isEmpty { + updated.accessToken = accessToken + } + if !refreshToken.isEmpty { + updated.refreshToken = refreshToken + } + if let expiry = AntigravityAgyFileTokenEncoder.expiryDate(from: payload.token.expiry) { + updated.expiryDateMilliseconds = expiry.timeIntervalSince1970 * 1000 + } + if let idToken = payload.idToken?.trimmingCharacters(in: .whitespacesAndNewlines), !idToken.isEmpty { + updated.idToken = idToken + } + return updated + } + /// The account whose credential `agy` actually used during a run. After the /// child exits, its staged token file holds the access token that made the /// API calls — refreshed in place when the staged grant was expired — so @@ -203,12 +263,7 @@ enum AntigravityScopedAgyStaging { dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse), fileManager: FileManager = .default) async -> String? { - var tokenURL = home - for component in Self.tokenRelativePath { - tokenURL.appendPathComponent(component, isDirectory: false) - } - guard let data = fileManager.contents(atPath: tokenURL.path), - let payload = AntigravityAgyFileTokenEncoder.decode(data: data) + guard let payload = stagedTokenPayload(home: home, fileManager: fileManager) else { return nil } @@ -245,7 +300,11 @@ extension AntigravityCLIHTTPSFetchStrategy { /// CLI authenticates with the staged access/refresh tokens — which could /// disagree with the `id_token` claim — the access token `agy` actually used /// is resolved through Google's `userinfo` endpoint after the run and must - /// match the selected account before the report is labeled with it. Fails + /// match the selected account before the report is labeled with it. When the + /// identity check succeeds and `agy` refreshed the staged grant, the updated + /// credentials are handed to `credentialsUpdateHandler` (the same guarded + /// token-account updater the OAuth strategy uses) so the next refresh starts + /// from the refreshed token instead of the discarded expired one. Fails /// closed: any error propagates so the pipeline falls through to the /// account-scoped OAuth strategy; ambient reports are never substituted for /// a selected account. @@ -253,7 +312,9 @@ extension AntigravityCLIHTTPSFetchStrategy { binary: String, environment: [String: String], timeout: TimeInterval = 90, - dataLoader: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil) async throws + dataLoader: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil, + credentialsUpdateHandler: (@Sendable (AntigravityOAuthCredentials) async throws -> Void)? = nil) + async throws -> ProviderFetchResult { guard let value = environment[AntigravityOAuthCredentialsStore.environmentCredentialsKey], @@ -316,6 +377,19 @@ extension AntigravityCLIHTTPSFetchStrategy { throw AntigravityStatusProbeError.accountMismatch( expected: expectedAccountEmail, found: effectiveEmail) } + // `agy` may have refreshed the staged grant in place; persist the verified + // refreshed credential so the next run does not start from the expired token. + if let credentialsUpdateHandler, + let refreshed = AntigravityScopedAgyStaging.refreshedCredentials( + home: staged.home, original: credentials) + { + do { + try await credentialsUpdateHandler(refreshed) + } catch { + Self.scopedPrintLog.warning( + "Scoped agy usage: could not persist refreshed credentials (\(error.localizedDescription))") + } + } let snapshot = parsed.withIdentity(from: AntigravityStatusSnapshot( modelQuotas: [], accountEmail: expectedAccountEmail, accountPlan: nil, source: parsed.source)) return try self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: Self.sourceLabel) diff --git a/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift b/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift index 49cb6310bd..4f1f486096 100644 --- a/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift +++ b/Tests/CodexBarTests/AntigravityScopedPrintFetchTests.swift @@ -114,6 +114,43 @@ struct AntigravityScopedPrintFetchTests { #expect(payload?.idToken == nil) } + // MARK: - Refreshed credential persistence + + @Test + func `refreshed credentials follow the staged payload after agy rewrites it`() throws { + let credentials = AntigravityOAuthCredentials( + accessToken: "expired-access", + refreshToken: "refresh", + expiryDate: Date(timeIntervalSince1970: 1_000_000), + email: "scoped@example.com") + let staged = try AntigravityScopedAgyStaging.stage( + credentials: credentials, expectedAccountEmail: "scoped@example.com") + defer { try? FileManager.default.removeItem(at: staged.stagingRoot) } + + // An unchanged staged file has nothing to persist. + #expect(AntigravityScopedAgyStaging.refreshedCredentials( + home: staged.home, original: credentials) == nil) + + let tokenURL = staged.home + .appendingPathComponent(".gemini/antigravity-cli/antigravity-oauth-token") + let rewritten = AntigravityAgyFileTokenPayload( + token: .init( + accessToken: "fresh-access", + tokenType: "Bearer", + refreshToken: "fresh-refresh", + expiry: "2030-01-01T00:00:00Z"), + authMethod: "consumer", + idToken: nil) + try JSONEncoder().encode(rewritten).write(to: tokenURL) + + let refreshed = try #require(AntigravityScopedAgyStaging.refreshedCredentials( + home: staged.home, original: credentials)) + #expect(refreshed.accessToken == "fresh-access") + #expect(refreshed.refreshToken == "fresh-refresh") + #expect(refreshed.expiryDate == Date(timeIntervalSince1970: 1_893_456_000)) + #expect(refreshed.email == "scoped@example.com") + } + // MARK: - Fallback wiring (platform-independent) @Test @@ -249,6 +286,7 @@ struct AntigravityScopedPrintFetchTests { var environment = self.accountEnv(email: "scoped@example.com") environment.merge(fixture.environment) { _, new in new } + let persisted = LockIsolated(nil) await #expect(throws: AntigravityStatusProbeError.accountMismatch( expected: "scoped@example.com", found: "donor@example.com")) { @@ -258,8 +296,69 @@ struct AntigravityScopedPrintFetchTests { dataLoader: self.userinfoLoader(mapping: [ "scoped-access-token": "scoped@example.com", "donor-access-token": "donor@example.com", - ])) + ]), + credentialsUpdateHandler: { persisted.setValue($0) }) } + // A rejected identity must never reach the saved-account updater. + #expect(persisted.value == nil) + } + + @Test + func `scoped print persists refreshed staged credentials for the next run`() async throws { + let report = try self.reportJSON() + // Simulate agy refreshing an expired staged grant: it rewrites the + // staged token file with fresh tokens and expiry before printing. + let firstFixture = try self.scopedPrintFixture(body: """ + TOKEN_FILE="$HOME/.gemini/antigravity-cli/antigravity-oauth-token" + /usr/bin/sed -i '' \ + -e 's/scoped-access-token/refreshed-access-token/' \ + -e 's/"refresh_token":"refresh"/"refresh_token":"refreshed-refresh"/' \ + -e 's/"expiry":"[^"]*"/"expiry":"2030-01-01T00:00:00Z"/' \ + "$TOKEN_FILE" + /bin/cat <<'REPORT' + \(report) + REPORT + """) + defer { try? FileManager.default.removeItem(at: firstFixture.directory) } + + var environment = self.expiredAccountEnv(email: "scoped@example.com") + environment.merge(firstFixture.environment) { _, new in new } + + let persisted = LockIsolated(nil) + let result = try await AntigravityCLIHTTPSFetchStrategy().fetchScopedPrintUsage( + binary: firstFixture.binary.path, + environment: environment, + dataLoader: self.userinfoLoader(mapping: ["refreshed-access-token": "scoped@example.com"]), + credentialsUpdateHandler: { persisted.setValue($0) }) + #expect(result.usage.identity?.accountEmail == "scoped@example.com") + + let updated = try #require(persisted.value) + #expect(updated.accessToken == "refreshed-access-token") + #expect(updated.refreshToken == "refreshed-refresh") + #expect(updated.expiryDate == Date(timeIntervalSince1970: 1_893_456_000)) + + // A second refresh stages the persisted credentials, so it must start + // from the refreshed token — not the expired grant that was staged first. + let secondTokenValue = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: updated) + var secondEnvironment = [ + AntigravityOAuthCredentialsStore.environmentCredentialsKey: secondTokenValue, + ] + let secondFixture = try self.scopedPrintFixture(body: """ + TOKEN_FILE="$HOME/.gemini/antigravity-cli/antigravity-oauth-token" + /usr/bin/grep -q 'refreshed-access-token' "$TOKEN_FILE" || exit 31 + ! /usr/bin/grep -q 'scoped-access-token' "$TOKEN_FILE" || exit 32 + /bin/cat <<'REPORT' + \(report) + REPORT + """) + defer { try? FileManager.default.removeItem(at: secondFixture.directory) } + secondEnvironment.merge(secondFixture.environment) { _, new in new } + + let secondResult = try await AntigravityCLIHTTPSFetchStrategy().fetchScopedPrintUsage( + binary: secondFixture.binary.path, + environment: secondEnvironment, + dataLoader: self.userinfoLoader(mapping: ["refreshed-access-token": "scoped@example.com"])) + #expect(secondResult.usage.identity?.accountEmail == "scoped@example.com") } @Test @@ -357,6 +456,19 @@ struct AntigravityScopedPrintFetchTests { return [AntigravityOAuthCredentialsStore.environmentCredentialsKey: value] } + private func expiredAccountEnv(email: String) -> [String: String] { + let credentials = AntigravityOAuthCredentials( + accessToken: "scoped-access-token", + refreshToken: "refresh", + expiryDate: Date(timeIntervalSince1970: 1_000_000), + idToken: GeminiAPITestHelpers.makeIDToken(email: email), + email: email) + guard let value = try? AntigravityOAuthCredentialsStore.tokenAccountValue( + for: credentials) + else { return [:] } + return [AntigravityOAuthCredentialsStore.environmentCredentialsKey: value] + } + private func tokenOnlyAccountEnv(email: String) -> [String: String] { let credentials = AntigravityOAuthCredentials( accessToken: "scoped-access-token", diff --git a/docs/antigravity.md b/docs/antigravity.md index 87f0b62bd2..52611f4970 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -56,7 +56,10 @@ variables) without `ANTIGRAVITY_OAUTH_CREDENTIALS_JSON` or any ambient provider staging directory is deleted after the run, and the report is only attributed to the account after the access token `agy` actually used — refreshed in place inside the staged file when expired — is resolved through Google's `userinfo` endpoint and its email matches it, so an identity-free report -can never carry a label its credentials did not prove. Any +can never carry a label its credentials did not prove. When that verification succeeds and `agy` +refreshed the staged grant, the refreshed credential is written back to the selected saved account +through the same guarded token-account updater the OAuth strategy uses, so the next refresh starts +from the fresh token instead of the discarded expired one; a rejected identity is never persisted. Any scoped failure preserves the original ambient error — an ambient report is never substituted for a selected account, so the pipeline falls through to the account-scoped OAuth fetch exactly as before. From d4caae94a69d74f75d121be544f8d1d255a1f004 Mon Sep 17 00:00:00 2001 From: Sogl Date: Mon, 28 Sep 2026 18:41:43 +0300 Subject: [PATCH 063/122] Isolate browser uninstall test from LaunchServices registrations The test stubbed file existence for /Applications but left applicationURLs on the real NSWorkspace lookup, so hosts with a Parallels-shared Chrome bundle still detected the browser after the simulated uninstall. Inject an empty registration list like the neighboring tests. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Tests/CodexBarTests/BrowserDetectionTests.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index 127385b449..214f02c119 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -585,6 +585,7 @@ struct BrowserDetectionTests { let detection = BrowserDetection( homeDirectory: temp.path, cacheTTL: 600, + now: Date.init, fileExists: { path in if path == "/Applications/Google Chrome.app" { return installed.withLock { $0 } @@ -593,7 +594,12 @@ struct BrowserDetectionTests { }, directoryContents: { path in try? FileManager.default.contentsOfDirectory(atPath: path) - }) + }, + // LaunchServices may report the browser from a path the fileExists + // stub does not cover (e.g. a Parallels-shared Windows app bundle), + // which would keep the "uninstalled" browser detected. + applicationURLs: { _ in [] }, + profileAccessIssue: { _ in nil }) #expect(detection.isCookieSourceAvailable(.chrome)) installed.withLock { $0 = false } From b4335754936075f5d11463f344cdcd7910daa285 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 10:09:11 -0700 Subject: [PATCH 064/122] chore(release): prepare 0.69.0 --- CHANGELOG.md | 79 ++++++++++++++++++++++++++-------------------------- version.env | 2 +- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f83881185..8c42bb1efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,55 +1,56 @@ # Changelog -## 0.68.1 — Unreleased +## 0.69.0 — 2026-09-28 + +### Highlights + +- Plugins feel native: user plugins now get their own switcher tab by default, and Notion AI, ZoomMate, and LongCat run as bundled plugins with browser sessions kept private to the host (#4074, #4098, #4059). +- Lighter on CPU and disk: Claude and Vertex cost history is reused instead of re-decoded on every scan, the history cache is about a quarter smaller, and cost scans no longer expand priority days back to year 1 (#4053, #4092, #4045). Thanks @djbclark for the CPU sample that pinned this down! +- Steadier refreshes: Codex rereads credentials while the CLI rewrites them and picks up plan upgrades right away, a stalled Keychain signature check can no longer freeze every provider, and Claude recovers from rejected cache writes on the next refresh (#4088, #4089). Thanks @lozcalver and @SilentKnight87! +- Widgets and the menu bar hold on to good data: each widget provider keeps its last good reading after failed refreshes, and corrupt saved menu bar positions are never restored (#4095, #4082). +- More accurate numbers: Grok token totals and model names survive billing outages, Claude shows saved limit resets from the Web source, Kimi marks windows blocked once the monthly pool is exhausted, Antigravity shows Starter quotas, and TypeSafe shows its balance in the menu bar (#4093, #4056, #4048, #4091, #4084, #4050). +- Leaner under the hood: the app ships with about 700 fewer lines of code than 0.68.0, even with the new plugin host capabilities. ### Security -- Tests: scrub inherited credentials from test runners and redact stored environment dictionaries in Codex/Claude usage fetcher and shared fetch-context debug output. +- Test and debug output no longer includes environment variable values: stored environments render only an entry count, and test runners scrub credential-shaped variables before running (#4097). ### Added - Claude: show saved usage-limit resets and their expiry from the Web source in the menu and `codexbar usage` details (#4048). Thanks @enieuwy! -### Fixed - -- Codex: retry brief credential-file publication races before reporting refresh errors, and discard the previous plan's quota baseline after a subscription change so fresh usage can appear (#3635, #3389). -- Development: restore test compilation on Xcode 26.3 / Swift 6.2 and check app, CLI, and test compatibility in CI (#4070). Thanks @RowboTony! -- Configuration: treat empty or whitespace-only config files like missing files so usage keeps working; settings saves write valid JSON, while malformed non-empty files still report errors (#4071). -- Menu bar: reject corrupt saved positions during status-item visibility changes and removal while preserving valid placement across restarts (#3355). -- Codex: publish newly validated token and cost totals after each catch-up pass, even when an earlier snapshot was already shown and historical scanning is still pending (#3508). Thanks @kernnel! -- Claude: retain valid in-memory credentials after a rejected OAuth cache write once stale-cache cleanup succeeds, so the next automatic refresh can recover without another manual Refresh (#3395). Thanks @lozcalver! -- Keychain: bound stalled code-signature validation so it cannot hold cache locks and freeze all provider refreshes indefinitely (#3249). Thanks @SilentKnight87! - -- Antigravity: preserve grouped OAuth quotas, including weekly-only Starter allowances, and honor explicit quota-window cadence using the shared CLI parser (#2427, #3789). -- Kimi: direct stale CLI sessions to run `kimi` or configure an API key in Settings, while retaining web fallback and leaving rotating CLI credentials read-only (#4063). Thanks @kid0114! -- Claude and Vertex: reuse freshly saved cost-history rows, skip encoding unchanged caches, and compact retained row fields to reduce CPU and disk writes during repeated refreshes (#3882, #3247, #3323). -- Kimi Code: mark shorter Code windows as blocked when the known monthly membership pool is exhausted, without showing fresh quota or pace forecasts (#3536). -- z.ai: explain unavailable Coding Plan usage for empty or unsupported quota shapes while preserving recognized quotas and analytics (#2522). -- Grok: keep local token totals visible in Usage & Spend and shared cards across wider history views and billing outages, with consistent daily scan windows (#3716). Thanks @Chipagosfinest! -- Antigravity: reap MCP servers left behind by usage probes using a unique inherited ownership marker, preserving unrelated processes even when they share the probe directory (#4077). Thanks @bcharleson! -- Adaptive refresh: recognize ChatGPT's nested Codex app-server with per-scan running-process validation and update-aware signed-bundle assessment caching, avoiding repeated Gatekeeper subprocesses while keeping idle servers at the normal cadence (#4069, #4090). -- Widgets: retain each eligible provider's last-good reading and original age after failed refreshes, even when another provider is unavailable, disabled, or changes accounts (#3500). -- Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! -- TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! -- Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! -- Browser sessions: preserve interactive cookie-refresh authorization across plugin engine callbacks (#4098). - ### Changed -- Notion AI and ZoomMate: run usage fetching through bundled plugins while preserving browser-session reuse, validated cache migration, Notion over-quota values, and ZoomMate credits history (#4098). - -- Plugins: user plugins now get their own switcher tab by default when Merge Icons is on; set `topLevel: false` to keep the appended card. -- Menu bar: align the persistent Refresh row with other menu actions by removing its decorative icon, preserving the shortcut and accessibility action (#4057). Thanks @elijahfriedman! -### Fixed - -- Claude: retain priced local spend as a partial estimate when an incomplete Pi or OMP mirror is included, across Usage & Spend, Overview, and sharing (#4052). Fixes #4051. Thanks @BUKOWSKIREAL! -- Claude and Vertex: reuse unchanged decoded cost-history caches across refreshes while preserving source, pricing, and time-zone validation (#4053). Thanks @djbclark! -- Costs: keep All history priority checks proportional to recorded days instead of generating centuries of empty days, while preserving older logs (#4045). Thanks @djbclark! +- Plugins: user plugins now get their own switcher tab by default when Merge Icons is on; set `topLevel: false` to keep the appended card (#4074). +- Notion AI, ZoomMate, and LongCat: usage fetching runs through bundled plugins with host-owned cookie sessions, preserving browser-session reuse, validated cache migration, Notion over-quota values, ZoomMate credits history, and LongCat fuel-pack data (#4098, #4059). +- Menu bar: the persistent Refresh row drops its decorative icon to match other menu actions, keeping the shortcut and accessibility action (#4057). Thanks @elijahfriedman! + +### Fixed + +- Codex: retry brief credential-file publication races before reporting refresh errors, and discard the previous plan's quota baseline after a subscription change so fresh usage appears (#4088, #3635, #3389). +- Codex: publish newly validated token and cost totals after each catch-up pass, even while historical scanning is still pending (#4087, #3508). Thanks @kernnel! +- Claude: retain valid in-memory credentials after a rejected OAuth cache write once stale-cache cleanup succeeds, so the next automatic refresh recovers without a manual Refresh (#4089, #3395). +- Keychain: bound stalled code-signature validation so it cannot hold cache locks and freeze all provider refreshes (#4089, #3249). +- Claude: keep priced local spend as a partial estimate when an incomplete Pi or OMP mirror is included, across Usage & Spend, Overview, and sharing (#4052). Fixes #4051. Thanks @BUKOWSKIREAL! +- Claude and Vertex: reuse unchanged decoded cost-history caches, skip encoding unchanged caches, and compact retained row fields to cut CPU and disk writes during refreshes (#4053, #4092, #3882). Thanks @djbclark! +- Costs: keep All-history priority checks proportional to recorded days instead of generating centuries of empty days, preserving older logs (#4045). Thanks @djbclark! +- Configuration: treat empty or whitespace-only config files like missing files so usage keeps working; malformed non-empty files still report errors (#4081, #4071). Thanks @kvnloo! +- Menu bar: reject corrupt saved positions during status-item visibility changes and removal while preserving valid placement across restarts (#4082, #3355). +- Widgets: keep each eligible provider's last good reading and original age after failed refreshes, even when another provider is unavailable, disabled, or changes accounts (#4095, #3500). +- Adaptive refresh: recognize ChatGPT's nested Codex app-server with per-scan running-process validation and update-aware bundle assessment caching (#4090, #4069). Thanks @jaychou0642-create! +- Grok: keep local token totals visible in Usage & Spend and shared cards across wider history views and billing outages (#4093, #3716). Thanks @Chipagosfinest! +- Grok: keep the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! +- Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! +- Kimi: point stale CLI sessions to running `kimi` or adding an API key in Settings, keeping web fallback and leaving rotating CLI credentials read-only (#4086, #4063). Thanks @kid0114! +- Kimi Code: mark shorter windows as blocked when the monthly membership pool is exhausted, without fresh quota or pace forecasts (#4091, #3536). +- z.ai: explain unavailable Coding Plan usage for empty or unsupported quota shapes while keeping recognized quotas and analytics (#4091, #2522). +- Antigravity: preserve grouped OAuth quotas, including weekly-only Starter allowances, and honor explicit quota-window cadence (#4084, #2427, #3789). +- Antigravity: usage probes no longer leave MCP server processes behind; cleanup only touches processes carrying the probe's inherited ownership marker, so unrelated processes in the same directory are never killed (#4077). Thanks @bcharleson! +- TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes (#4050). Thanks @lg! - Pi: preserve the directory marker for session roots that do not exist yet (#4067). Thanks @Sogl! -- CLI: macOS release builds compile again on the Xcode 26 release runners, so the 0.68 macOS CLI tarballs and the Homebrew `codexbar` formula ship alongside the app. -- LongCat: move quota fetching to the bundled plugin on both engines, keeping imported cookies private to the host and preserving per-request cookie scope, profile fallback, and optional fuel-pack data. -- Browser sessions: preserve distinct host-only and domain-scoped cookies when merging stores from the same profile. -- Agent Sessions: avoid the macOS 15 isolated-teardown crash while retaining task cancellation and Stay Awake cleanup (#4068). Thanks @Sogl! +- Agent Sessions: avoid the macOS 15 isolated-teardown crash while keeping task cancellation and Stay Awake cleanup (#4068). Thanks @Sogl! +- Browser sessions: keep distinct host-only and domain-scoped cookies when merging stores from the same profile, and preserve interactive cookie-refresh authorization across plugin engine callbacks (#4059, #4098). +- CLI and development: macOS CLI release builds and the test suite compile on Xcode 26.3 again, and CI now builds app, CLI, and tests on that toolchain (#4058, #4079, #4070). Thanks @RowboTony! ## 0.68.0 — 2026-09-27 diff --git a/version.env b/version.env index 9cc9eda19a..36fb0a7cd7 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.68.1 +MARKETING_VERSION=0.69.0 BUILD_NUMBER=160 From b9f0bfa9dcfb6b062d11e3d867e42d00a8c726dd Mon Sep 17 00:00:00 2001 From: Sogl Date: Mon, 28 Sep 2026 20:47:26 +0300 Subject: [PATCH 065/122] test(antigravity): prove token updater drops stale writebacks A fetch-scoped writeback must not clobber a credential that was re-authorised while the fetch was in flight; the app-path updater already compares the stored token to the one that authorised the run. Cover that guard with a focused test. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../StatusMenuTokenAccountSwitcherTests.swift | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift index 10c65da829..b39613fc6f 100644 --- a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -45,7 +45,8 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { private func installRotatingProvider( on store: UsageStore, provider: UsageProvider, - rotatedToken: String) + rotatedToken: String, + beforeUpdater: (@Sendable () async -> Void)? = nil) { let baseSpec = store.providerSpecs[provider]! let baseDescriptor = baseSpec.descriptor @@ -61,7 +62,8 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { RotatingTokenAccountFetchStrategy( provider: provider, rotatedToken: rotatedToken, - snapshot: snapshot), + snapshot: snapshot, + beforeUpdater: beforeUpdater), ] }), cli: baseDescriptor.cli) store.providerSpecs[provider] = ProviderSpec( @@ -522,6 +524,34 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { XCTAssertEqual(store.accountSnapshots[.antigravity]?.count, 2) } + func test_tokenAccountUpdaterDropsWritebackWhenCredentialChangedDuringFetch() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .segmented + self.enableOnly(.antigravity, settings) + settings.addTokenAccount(provider: .antigravity, label: "Primary", token: "p1") + settings.setActiveTokenAccountIndex(0, for: .antigravity) + let accountID = try XCTUnwrap(settings.tokenAccounts(for: .antigravity).first?.id) + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + self.installRotatingProvider(on: store, provider: .antigravity, rotatedToken: "n1") { + // Simulate the user re-authorizing the account while a fetch is in flight. + await MainActor.run { + settings.updateTokenAccount(provider: .antigravity, accountID: accountID, token: "edited-mid-run") + } + } + + await store.refreshProvider(.antigravity) + + // The writeback must not clobber the credential saved during the fetch. + XCTAssertEqual(settings.tokenAccounts(for: .antigravity).first?.token, "edited-mid-run") + } + func test_tokenAccountSwitchDefersOpenMenuRebuildUntilAfterSwitcherAction() async throws { self.disableMenuCardsForTesting() StatusItemController.setMenuRefreshEnabledForTesting(true) @@ -993,6 +1023,7 @@ private struct RotatingTokenAccountFetchStrategy: ProviderFetchStrategy { let provider: UsageProvider let rotatedToken: String let snapshot: UsageSnapshot + var beforeUpdater: (@Sendable () async -> Void)? var id: String { "rotating-token-account-test" @@ -1012,6 +1043,7 @@ private struct RotatingTokenAccountFetchStrategy: ProviderFetchStrategy { else { throw RotatingTokenAccountTestError.missingUpdater } + await self.beforeUpdater?() await updater(self.provider, accountID, self.rotatedToken) return self.makeResult(usage: self.snapshot, sourceLabel: "rotating-token-account-test") } From 48ded68da6932a4fe5de9037d06c4ac48bd36e90 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 11:06:05 -0700 Subject: [PATCH 066/122] docs: update appcast for 0.69.0 --- appcast.xml | 118 +++++++++++++++++++++++++++------------------------- 1 file changed, 62 insertions(+), 56 deletions(-) diff --git a/appcast.xml b/appcast.xml index f9fc9c266a..1762d0f9d6 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,68 @@ CodexBar + + 0.69.0 + Mon, 28 Sep 2026 11:05:39 -0700 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 160 + 0.69.0 + 14.0 + CodexBar 0.69.0 +

Highlights

+
    +
  • Plugins feel native: user plugins now get their own switcher tab by default, and Notion AI, ZoomMate, and LongCat run as bundled plugins with browser sessions kept private to the host (#4074, #4098, #4059).
  • +
  • Lighter on CPU and disk: Claude and Vertex cost history is reused instead of re-decoded on every scan, the history cache is about a quarter smaller, and cost scans no longer expand priority days back to year 1 (#4053, #4092, #4045). Thanks @djbclark for the CPU sample that pinned this down!
  • +
  • Steadier refreshes: Codex rereads credentials while the CLI rewrites them and picks up plan upgrades right away, a stalled Keychain signature check can no longer freeze every provider, and Claude recovers from rejected cache writes on the next refresh (#4088, #4089). Thanks @lozcalver and @SilentKnight87!
  • +
  • Widgets and the menu bar hold on to good data: each widget provider keeps its last good reading after failed refreshes, and corrupt saved menu bar positions are never restored (#4095, #4082).
  • +
  • More accurate numbers: Grok token totals and model names survive billing outages, Claude shows saved limit resets from the Web source, Kimi marks windows blocked once the monthly pool is exhausted, Antigravity shows Starter quotas, and TypeSafe shows its balance in the menu bar (#4093, #4056, #4048, #4091, #4084, #4050).
  • +
  • Leaner under the hood: the app ships with about 700 fewer lines of code than 0.68.0, even with the new plugin host capabilities.
  • +
+

Security

+
    +
  • Test and debug output no longer includes environment variable values: stored environments render only an entry count, and test runners scrub credential-shaped variables before running (#4097).
  • +
+

Added

+
    +
  • Claude: show saved usage-limit resets and their expiry from the Web source in the menu and codexbar usage details (#4048). Thanks @enieuwy!
  • +
+

Changed

+
    +
  • Plugins: user plugins now get their own switcher tab by default when Merge Icons is on; set topLevel: false to keep the appended card (#4074).
  • +
  • Notion AI, ZoomMate, and LongCat: usage fetching runs through bundled plugins with host-owned cookie sessions, preserving browser-session reuse, validated cache migration, Notion over-quota values, ZoomMate credits history, and LongCat fuel-pack data (#4098, #4059).
  • +
  • Menu bar: the persistent Refresh row drops its decorative icon to match other menu actions, keeping the shortcut and accessibility action (#4057). Thanks @elijahfriedman!
  • +
+

Fixed

+
    +
  • Codex: retry brief credential-file publication races before reporting refresh errors, and discard the previous plan's quota baseline after a subscription change so fresh usage appears (#4088, #3635, #3389).
  • +
  • Codex: publish newly validated token and cost totals after each catch-up pass, even while historical scanning is still pending (#4087, #3508). Thanks @kernnel!
  • +
  • Claude: retain valid in-memory credentials after a rejected OAuth cache write once stale-cache cleanup succeeds, so the next automatic refresh recovers without a manual Refresh (#4089, #3395).
  • +
  • Keychain: bound stalled code-signature validation so it cannot hold cache locks and freeze all provider refreshes (#4089, #3249).
  • +
  • Claude: keep priced local spend as a partial estimate when an incomplete Pi or OMP mirror is included, across Usage & Spend, Overview, and sharing (#4052). Fixes #4051. Thanks @BUKOWSKIREAL!
  • +
  • Claude and Vertex: reuse unchanged decoded cost-history caches, skip encoding unchanged caches, and compact retained row fields to cut CPU and disk writes during refreshes (#4053, #4092, #3882). Thanks @djbclark!
  • +
  • Costs: keep All-history priority checks proportional to recorded days instead of generating centuries of empty days, preserving older logs (#4045). Thanks @djbclark!
  • +
  • Configuration: treat empty or whitespace-only config files like missing files so usage keeps working; malformed non-empty files still report errors (#4081, #4071). Thanks @kvnloo!
  • +
  • Menu bar: reject corrupt saved positions during status-item visibility changes and removal while preserving valid placement across restarts (#4082, #3355).
  • +
  • Widgets: keep each eligible provider's last good reading and original age after failed refreshes, even when another provider is unavailable, disabled, or changes accounts (#4095, #3500).
  • +
  • Adaptive refresh: recognize ChatGPT's nested Codex app-server with per-scan running-process validation and update-aware bundle assessment caching (#4090, #4069). Thanks @jaychou0642-create!
  • +
  • Grok: keep local token totals visible in Usage & Spend and shared cards across wider history views and billing outages (#4093, #3716). Thanks @Chipagosfinest!
  • +
  • Grok: keep the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey!
  • +
  • Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL!
  • +
  • Kimi: point stale CLI sessions to running kimi or adding an API key in Settings, keeping web fallback and leaving rotating CLI credentials read-only (#4086, #4063). Thanks @kid0114!
  • +
  • Kimi Code: mark shorter windows as blocked when the monthly membership pool is exhausted, without fresh quota or pace forecasts (#4091, #3536).
  • +
  • z.ai: explain unavailable Coding Plan usage for empty or unsupported quota shapes while keeping recognized quotas and analytics (#4091, #2522).
  • +
  • Antigravity: preserve grouped OAuth quotas, including weekly-only Starter allowances, and honor explicit quota-window cadence (#4084, #2427, #3789).
  • +
  • Antigravity: usage probes no longer leave MCP server processes behind; cleanup only touches processes carrying the probe's inherited ownership marker, so unrelated processes in the same directory are never killed (#4077). Thanks @bcharleson!
  • +
  • TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes (#4050). Thanks @lg!
  • +
  • Pi: preserve the directory marker for session roots that do not exist yet (#4067). Thanks @Sogl!
  • +
  • Agent Sessions: avoid the macOS 15 isolated-teardown crash while keeping task cancellation and Stay Awake cleanup (#4068). Thanks @Sogl!
  • +
  • Browser sessions: keep distinct host-only and domain-scoped cookies when merging stores from the same profile, and preserve interactive cookie-refresh authorization across plugin engine callbacks (#4059, #4098).
  • +
  • CLI and development: macOS CLI release builds and the test suite compile on Xcode 26.3 again, and CI now builds app, CLI, and tests on that toolchain (#4058, #4079, #4070). Thanks @RowboTony!
  • +
+

View full changelog

+]]> + + 0.68.0 Sun, 27 Sep 2026 03:54:08 -0700 @@ -109,62 +171,6 @@ ]]> - - 0.66.0 - Thu, 24 Sep 2026 09:51:00 -0700 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 156 - 0.66.0 - 14.0 - CodexBar 0.66.0 -

Highlights

-
    -
  • Ten more providers run as bundled plugins (OpenAI, Fireworks, Perplexity, Qoder, Manus, T3 Chat, DeepInfra, ZenMux, Chutes, ai&), and Atlas Cloud, Vercel AI Gateway, DevPass, and llmman join as plugin-first providers — 84 providers total.
  • -
  • CLI config writes no longer delete user plugin settings and secrets (#3944), and menu bar layouts show balances for every balance provider (#3904).
  • -
  • Lower background cost: Codex and Claude history caches stop rewriting unchanged files, Cursor backs off geo-blocked requests, and stalled menu catch-up passes no longer loop.
  • -
-

Added

-
    -
  • Atlas Cloud: show account-wide available USD balance through the documented API-key endpoint (#2714). Thanks @clairernovotny!
  • -
  • Vercel AI Gateway: show team-wide USD balance and lifetime spend through the documented API-key endpoint (#2975). Thanks @pikant!
  • -
  • DevPass: track plan credits, premium weekly usage and resets, and API-key spending through the documented LLM Gateway API (#3433). Thanks @MichelKerkmeester!
  • -
  • llmman: show how much of a local llmman serve daemon's model memory its loaded models use, with loaded and stored model summaries and an optional API key (#3914). Thanks @ericcurtin!
  • -
  • iCloud Sync: let other Macs and their stale usage snapshots be removed from the Macs list, including duplicate records left after reinstalling (#3234).
  • -
  • Provider plugins: allow explicit HTTP deadlines up to 90 seconds while preserving request-start timing and overall fetch cancellation (#2784).
  • -
-

Fixed

-
    -
  • Provider plugins: preserve unrecognized plugin settings and secrets across app and CLI config writes, and discover installed plugins before CLI config loads (#3944). Thanks @lockhartheavyindustries!
  • -
  • Menu bar: resolve provider balances in stored layouts and show Doubao Agent Plan icon usage when Coding Plan lanes are absent (#3904, #3897, #3901, #3907, #3898, #3911). Thanks @vincent-peng, @mousebomb, and @harjothkhara!
  • -
  • Cost history: back off forbidden Cursor cost requests for six hours, honor timeout cooldowns without cached data, and preserve quota refreshes and manual recovery (#3910, #3918). Thanks @harjothkhara and @Sogl!
  • -
  • Codex costs: include local session history in Usage & Spend when CLI credentials are stored in the OS keyring instead of auth.json (#3922).
  • -
  • Codex costs: discard refreshes queued behind a stalled or failed menu catch-up pass instead of immediately restarting it (#3316).
  • -
  • Codex costs: avoid rewriting unchanged retained file state when another session or scan metadata changes, reducing local history disk writes (#3882).
  • -
  • Claude costs: skip identical cache and report-memo writes after rescans, reducing local history disk writes (#3882).
  • -
  • Codex: prefer the fresh CLI usage response's plan over the cached account plan after a subscription change (#3389).
  • -
  • Codex: scale personal credit bars with the balance instead of filling the bar at 1,000 credits, while preserving reported monthly caps and workspace balances (#3912).
  • -
  • Claude: preserve quota-threshold warnings across repeated CLI account-identity gaps instead of re-alerting on each refresh (#3450).
  • -
  • Claude widgets: refresh after claude-swap account updates and follow the active account without requiring account widgets, preserving quota ownership and measurement age (#3920, #3921). Thanks @aledeul!
  • -
  • Claude: document browser-session recovery and the explicit cookie-import retry when Claude works in Chrome but CodexBar cannot read the session (#3919). Thanks @PakAbhishek!
  • -
  • Grok: preserve team identity and local token history when a missing billing RPC method changes its error wording, using the JSON-RPC error code for fallback (related to #3716).
  • -
  • Alibaba Token Plan / Qwen Cloud: parse monthly quota windows, retain rolling windows alongside monthly usage, and read Personal/Solo monthly usage through the Bailian CLI's raw usage endpoint (#3903). Thanks @Josephur!
  • -
  • Command Code: size monthly usage from the grant reported with credits, keeping the row available when the optional subscription lookup fails (#3939). Thanks @enieuwy!
  • -
  • Kimi: import web access tokens from Chromium local storage for the selected region, preserving manual and saved-account credential isolation (#3923). Thanks @kaishin!
  • -
  • MiniMax: discover browser session storage across the shared Chromium catalog, including Comet and Yandex (#3883).
  • -
  • Ollama: explain empty Manual cookie configuration and offer a single action to use automatic cookies (#3891). Thanks @giovanninibarbosa!
  • -
  • Muse Code: check the CLI-owned Keychain item's access list before requesting its token, so refreshes fail promptly when access would require a prompt, and discover logins without reading secrets (#3916). Thanks @audreyt!
  • -
  • CLI: bound shell-discovery output to 1 MiB and reject incomplete captures so noisy startup scripts cannot cause runaway buffering or truncated PATH results (refs #1999).
  • -
  • Website: refresh the social preview image with the newest integrations and invalidate cached previews when the card changes.
  • -
-

Changed

-
    -
  • Bundled provider plugins now power OpenAI, Fireworks, Perplexity, Qoder, Manus, T3 Chat, DeepInfra, ZenMux, Chutes, and ai& on both JavaScript engines, preserving each provider's usage charts, project labels, regional cookies, browser-session retries, balances, and quota details while deleting the native fetchers (#3933, #3934).
  • -
  • Provider plugins: preserve browser-session iteration and candidate rejection when returning typed usage results (#3933, #3934).
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From 77051eac6f113dd657963f2eeda361599d48fe93 Mon Sep 17 00:00:00 2001 From: Sogl Date: Mon, 28 Sep 2026 21:34:33 +0300 Subject: [PATCH 067/122] Guard CLI token writeback against mid-run reauthorization The CLI updater compared only the account ID before replacing the stored token, so a scoped fetch finishing after the account was reauthorized elsewhere could overwrite the newer credential. Compare the on-disk credential with the one that authorized the fetch and skip the write when they differ, matching the app-path guard. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Sources/CodexBarCLI/TokenAccountCLI.swift | 23 +++++- .../TokenAccountCLISelectionTests.swift | 70 +++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 178040af9c..1b4c22ac9c 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -57,6 +57,7 @@ struct TokenAccountCLIContext { let accountsByProvider: [UsageProvider: ProviderTokenAccountData] private let baseEnvironment: [String: String] private let managedCodexAccountStoreURL: URL? + private let configStore: CodexBarConfigStore init( selection: TokenAccountCLISelection, @@ -64,12 +65,14 @@ struct TokenAccountCLIContext { verbose _: Bool, resolutionScope: TokenAccountCLIResolutionScope = .configuredAccounts, baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, - managedCodexAccountStoreURL: URL? = nil) throws + managedCodexAccountStoreURL: URL? = nil, + configStore: CodexBarConfigStore = CodexBarConfigStore()) throws { self.selection = selection self.config = config self.baseEnvironment = baseEnvironment self.managedCodexAccountStoreURL = managedCodexAccountStoreURL + self.configStore = configStore self.accountsByProvider = switch resolutionScope { case .configuredAccounts: Dictionary(uniqueKeysWithValues: config.providers.compactMap { provider in @@ -189,9 +192,16 @@ struct TokenAccountCLIContext { func tokenUpdater(for account: ProviderTokenAccount?) -> ProviderFetchContext.TokenAccountTokenUpdater? { guard let account else { return nil } + let configStore = self.configStore + let expectedToken = account.token return { provider, accountID, token in guard accountID == account.id else { return } - try? Self.updateStoredTokenAccount(provider: provider, accountID: accountID, token: token) + try? Self.updateStoredTokenAccount( + store: configStore, + provider: provider, + accountID: accountID, + expectedToken: expectedToken, + token: token) } } @@ -202,14 +212,15 @@ struct TokenAccountCLIContext { } private static func updateStoredTokenAccount( + store: CodexBarConfigStore, provider: UsageProvider, accountID: UUID, + expectedToken: String, token: String) throws { let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - let store = CodexBarConfigStore() guard var config = try store.load() else { return } guard var providerConfig = config.providerConfig(for: provider.instanceID), let data = providerConfig.tokenAccounts, @@ -219,6 +230,12 @@ struct TokenAccountCLIContext { } let existing = data.accounts[index] + guard existing.token == expectedToken else { + CodexBarLog.logger(LogCategories.tokenAccounts).warning( + "Skipped token account writeback: the stored credential changed during the fetch", + metadata: ["provider": provider.rawValue]) + return + } var accounts = data.accounts accounts[index] = ProviderTokenAccount( id: existing.id, diff --git a/Tests/CodexBarTests/TokenAccountCLISelectionTests.swift b/Tests/CodexBarTests/TokenAccountCLISelectionTests.swift index 815747ac5e..d1d97efb6b 100644 --- a/Tests/CodexBarTests/TokenAccountCLISelectionTests.swift +++ b/Tests/CodexBarTests/TokenAccountCLISelectionTests.swift @@ -66,6 +66,40 @@ struct TokenAccountCLISelectionTests { } } + @Test + func `cli token updater writes refreshed credential when stored token is unchanged`() async throws { + let account = Self.account(token: "original-token") + let config = Self.config(with: account) + let store = try Self.configStore() + try store.save(config) + let context = try Self.writebackContext(config: config, store: store) + let updater = try #require(context.tokenUpdater(for: account)) + + await updater(.antigravity, account.id, "refreshed-token") + + let stored = try Self.storedToken(store: store, accountID: account.id) + #expect(stored == "refreshed-token") + } + + @Test + func `cli token updater drops writeback when stored credential changed mid run`() async throws { + let account = Self.account(token: "original-token") + let config = Self.config(with: account) + let store = try Self.configStore() + try store.save(config) + let context = try Self.writebackContext(config: config, store: store) + let updater = try #require(context.tokenUpdater(for: account)) + + // Another process reauthorized the account while the fetch was in flight. + let reassigned = Self.account(id: account.id, token: "reauthorized-token") + try store.save(Self.config(with: reassigned)) + + await updater(.antigravity, account.id, "stale-refresh") + + let stored = try Self.storedToken(store: store, accountID: account.id) + #expect(stored == "reauthorized-token") + } + private static var accountOverrides: [TokenAccountCLISelection] { [ TokenAccountCLISelection(label: "Primary", index: nil, allAccounts: false), @@ -90,6 +124,42 @@ struct TokenAccountCLISelectionTests { selection: selection, config: config, verbose: false, baseEnvironment: [:]) } + private static func account(id: UUID = UUID(), token: String) -> ProviderTokenAccount { + ProviderTokenAccount(id: id, label: "Primary", token: token, addedAt: 0, lastUsed: nil) + } + + private static func config(with account: ProviderTokenAccount) -> CodexBarConfig { + CodexBarConfig(providers: [ProviderConfig( + id: UsageProvider.antigravity.instanceID, + tokenAccounts: ProviderTokenAccountData(version: 1, accounts: [account], activeIndex: 0))]) + } + + private static func configStore() throws -> CodexBarConfigStore { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("token-account-cli-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return CodexBarConfigStore(fileURL: directory.appendingPathComponent("config.json")) + } + + private static func writebackContext( + config: CodexBarConfig, + store: CodexBarConfigStore) throws -> TokenAccountCLIContext + { + try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false, + baseEnvironment: [:], + configStore: store) + } + + private static func storedToken(store: CodexBarConfigStore, accountID: UUID) throws -> String? { + try store.load()? + .providerConfig(for: UsageProvider.antigravity.instanceID)? + .tokenAccounts?.accounts + .first(where: { $0.id == accountID })?.token + } + private static func expectCLIAccountConflict(_ resolve: () throws -> [ProviderTokenAccount]) { do { _ = try resolve() From bd77ea6a7b35c8e3b66d46285f718c8eebf285b7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 12:01:16 -0700 Subject: [PATCH 068/122] chore: start 0.69.1 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c42bb1efb..e07fcf00fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.69.1 — Unreleased + ## 0.69.0 — 2026-09-28 ### Highlights diff --git a/version.env b/version.env index 36fb0a7cd7..810a2b018a 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.69.0 -BUILD_NUMBER=160 +MARKETING_VERSION=0.69.1 +BUILD_NUMBER=161 From f2db790e533c0636737b9e61f8bcacb976738cc2 Mon Sep 17 00:00:00 2001 From: Emanuel Stadler <9994339+emanuelst@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:38:56 +0200 Subject: [PATCH 069/122] Avoid presenting unavailable Claude quota as 100% remaining --- .../CodexBar/MenuCardView+ModelHelpers.swift | 4 + Sources/CodexBar/MenuCardView.swift | 2 +- Sources/CodexBar/MenuDescriptor.swift | 2 +- .../Resources/ar.lproj/Localizable.strings | 1 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/fa.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/gl.lproj/Localizable.strings | 1 + .../Resources/id.lproj/Localizable.strings | 1 + .../Resources/it.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../Resources/nl.lproj/Localizable.strings | 1 + .../Resources/pl.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/ru.lproj/Localizable.strings | 1 + .../Resources/sv.lproj/Localizable.strings | 1 + .../Resources/th.lproj/Localizable.strings | 1 + .../Resources/tr.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1 + .../Resources/vi.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + Sources/CodexBarCLI/CLIRenderer.swift | 2 +- Sources/CodexBarCore/UsageFetcher.swift | 19 +++ ...udeSyntheticPlaceholderMenuCardTests.swift | 155 ++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 4 +- 30 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeSyntheticPlaceholderMenuCardTests.swift diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 45274b3a70..2c905acc26 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -318,6 +318,10 @@ extension UsageMenuCardView.Model { return Self.mimoUsageNotes(input: input, subscriptionNotes: subscriptionNotes) } + if input.provider == .claude, input.snapshot?.primary?.isSyntheticPlaceholder == true { + return [L("Session usage unavailable for this account.")] + subscriptionNotes + } + if input.provider == .claude, input.snapshot?.dataConfidence == .percentOnly { // Both CLI scraping and restored history carry percentages without full usage detail. return [L("claude_limited_usage_detail")] + subscriptionNotes diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 8f99c4192a..ea7546bd39 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1163,7 +1163,7 @@ extension UsageMenuCardView.Model { input: input, projection: codexProjection, percentStyle: percentStyle)) - } else if let primary = snapshot.primary { + } else if let primary = snapshot.primaryForDisplay(for: input.provider) { metrics.append(Self.primaryMetric( input: input, primary: primary, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index c7e150af11..7332e3b467 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -262,7 +262,7 @@ struct MenuDescriptor { let presentation = ProviderDescriptorRegistry.descriptor(for: provider).presentation let paceVisible = settings.paceVisible && ProviderDescriptorRegistry.descriptor(for: provider).pace .allowsPace(dataConfidence: snap.dataConfidence) - if let primary = snap.primary { + if let primary = snap.primaryForDisplay(for: provider) { Self.appendRateWindow( entries: &entries, title: labels.primary, diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index ea3dbae929..cf99b9a1c0 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -389,6 +389,7 @@ "Weekly token limit" = "الحد الأسبوعي للرموز"; "Weekly usage" = "الاستخدام الأسبوعي"; "Weekly usage unavailable for this account." = "الاستخدام الأسبوعي غير متاح لهذا الحساب."; +"Session usage unavailable for this account." = "الاستخدام الخاص بالجلسة غير متاح لهذا الحساب."; "Window: \\(window)" = "النافذة: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "اكتب السجلات إلى \\(self.fileLogPath) للتصحيح."; "Yes" = "نعم"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index d2877fabe8..bc4d4f8a64 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -388,6 +388,7 @@ "Weekly token limit" = "Límit setmanal de tokens"; "Weekly usage" = "Ús setmanal"; "Weekly usage unavailable for this account." = "Ús setmanal no disponible per a aquest compte."; +"Session usage unavailable for this account." = "L'ús de la sessió no està disponible per a aquest compte."; "Window: \\(window)" = "Finestra: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Escriu els registres a \\(self.fileLogPath) per a la depuració."; "Yes" = "Sí"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 8be276122d..9558410f73 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -401,6 +401,7 @@ "Weekly token limit" = "Wöchentliches Token-Limit"; "Weekly usage" = "Wöchentliche Nutzung"; "Weekly usage unavailable for this account." = "Die wöchentliche Nutzung ist für dieses Konto nicht verfügbar."; +"Session usage unavailable for this account." = "Die Sitzungsnutzung ist für dieses Konto nicht verfügbar."; "Window: \\(window)" = "Fenster: \\\\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Schreiben Sie Protokolle zum Debuggen nach \\\\(self.fileLogPath)."; "Yes" = "Ja"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 10d3243f21..2fdf7a5a7b 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -370,6 +370,7 @@ "Weekly token limit" = "Weekly token limit"; "Weekly usage" = "Weekly usage"; "Weekly usage unavailable for this account." = "Weekly usage unavailable for this account."; +"Session usage unavailable for this account." = "Session usage unavailable for this account."; "Window: \\(window)" = "Window: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Write logs to \\(self.fileLogPath) for debugging."; "Yes" = "Yes"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index b603a722ed..9175b8f4bc 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -407,6 +407,7 @@ "Weekly token limit" = "Límite semanal de tokens"; "Weekly usage" = "Uso semanal"; "Weekly usage unavailable for this account." = "Uso semanal no disponible para esta cuenta."; +"Session usage unavailable for this account." = "El uso de la sesión no está disponible para esta cuenta."; "Window: \\(window)" = "Ventana: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Escribir registros en \\(self.fileLogPath) para depuración."; "Yes" = "Sí"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index af9a059ec2..02ba03bd5d 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -389,6 +389,7 @@ "Weekly token limit" = "محدودیت هفتگی توکن"; "Weekly usage" = "استفاده هفتگی"; "Weekly usage unavailable for this account." = "استفاده هفتگی برای این حساب در دسترس نیست."; +"Session usage unavailable for this account." = "استفاده از جلسه برای این حساب در دسترس نیست."; "Window: \\(window)" = "پنجره: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "لاگ ها را برای \\(self.fileLogPath) برای اشکال زدایی بنویسید."; "Yes" = "بله"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index c3ed832fad..c00ae10492 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -403,6 +403,7 @@ "Weekly token limit" = "Limite hebdomadaire de jetons"; "Weekly usage" = "Utilisation hebdomadaire"; "Weekly usage unavailable for this account." = "Utilisation hebdomadaire indisponible pour ce compte."; +"Session usage unavailable for this account." = "Utilisation de la session indisponible pour ce compte."; "Window: \\(window)" = "Fenêtre : \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Écrivez les journaux dans \\(self.fileLogPath) pour le débogage."; "Yes" = "Oui"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 8bd432a554..9609622037 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -384,6 +384,7 @@ "Weekly token limit" = "Límite semanal de tokens"; "Weekly usage" = "Uso semanal"; "Weekly usage unavailable for this account." = "O uso semanal non está dispoñible para esta conta."; +"Session usage unavailable for this account." = "O uso da sesión non está dispoñible para esta conta."; "Window: \\(window)" = "Xanela: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Escribe os rexistros en \\(self.fileLogPath) para depuración."; "Yes" = "Si"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 1802137505..0ea1aea174 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -389,6 +389,7 @@ "Weekly token limit" = "Batas token mingguan"; "Weekly usage" = "Penggunaan mingguan"; "Weekly usage unavailable for this account." = "Penggunaan mingguan tidak tersedia untuk akun ini."; +"Session usage unavailable for this account." = "Penggunaan sesi tidak tersedia untuk akun ini."; "Window: \\(window)" = "Jendela: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Tulis log ke \\(self.fileLogPath) untuk debugging."; "Yes" = "Ya"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 2477449503..be67b3876a 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -389,6 +389,7 @@ "Weekly token limit" = "Limite token settimanale"; "Weekly usage" = "Utilizzo settimanale"; "Weekly usage unavailable for this account." = "Utilizzo settimanale non disponibile per questo account."; +"Session usage unavailable for this account." = "Utilizzo della sessione non disponibile per questo account."; "Window: \\(window)" = "Finestra: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Scrive i log in \\(self.fileLogPath) per il debug."; "Yes" = "Sì"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 1880d5bad4..cfd8e4ce74 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -403,6 +403,7 @@ "Weekly token limit" = "週間トークン上限"; "Weekly usage" = "週間使用量"; "Weekly usage unavailable for this account." = "このアカウントでは週間使用量を取得できません。"; +"Session usage unavailable for this account." = "このアカウントではセッション使用量を取得できません。"; "Window: \\(window)" = "ウインドウ: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "デバッグ用にログを \\(self.fileLogPath) に書き込みます。"; "Yes" = "はい"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index aaec0a3d49..1fcbd032c6 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -404,6 +404,7 @@ "Weekly token limit" = "주간 토큰 한도"; "Weekly usage" = "주간 사용량"; "Weekly usage unavailable for this account." = "이 계정에서는 주간 사용량을 사용할 수 없습니다."; +"Session usage unavailable for this account." = "이 계정에서는 세션 사용량을 사용할 수 없습니다."; "Window: \\(window)" = "기간: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "디버깅을 위해 \\(self.fileLogPath)에 로그 기록"; "Yes" = "예"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 92bf3ccc35..9e8eaae93b 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -403,6 +403,7 @@ "Weekly token limit" = "Wekelijkse tokenlimiet"; "Weekly usage" = "Wekelijks gebruik"; "Weekly usage unavailable for this account." = "Wekelijks gebruik is niet beschikbaar voor dit account."; +"Session usage unavailable for this account." = "Sessiegebruik is niet beschikbaar voor dit account."; "Window: \\(window)" = "Venster: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Schrijf logboeken naar \\(self.fileLogPath) voor foutopsporing."; "Yes" = "Ja"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 17a45ff52a..f851980926 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -389,6 +389,7 @@ "Weekly token limit" = "Tygodniowy limit tokenów"; "Weekly usage" = "Tygodniowe użycie"; "Weekly usage unavailable for this account." = "Dane tygodniowego użycia są niedostępne dla tego konta."; +"Session usage unavailable for this account." = "Dane użycia sesji są niedostępne dla tego konta."; "Window: \\(window)" = "Okno: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Zapisuj logi do \\(self.fileLogPath) na potrzeby debugowania."; "Yes" = "Tak"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 0ac9990785..944d067917 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -403,6 +403,7 @@ "Weekly token limit" = "Limite semanal de tokens"; "Weekly usage" = "Uso semanal"; "Weekly usage unavailable for this account." = "Uso semanal indisponível para esta conta."; +"Session usage unavailable for this account." = "Uso da sessão indisponível para esta conta."; "Window: \\(window)" = "Janela: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Grava logs em \\(self.fileLogPath) para depuração."; "Yes" = "Sim"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index cffbabb49f..ca549e670a 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -406,6 +406,7 @@ "Weekly token limit" = "Еженедельный лимит токенов"; "Weekly usage" = "Еженедельное использование"; "Weekly usage unavailable for this account." = "Еженедельное использование недоступно для этого аккаунта."; +"Session usage unavailable for this account." = "Использование сессии недоступно для этого аккаунта."; "Window: \\(window)" = "Окно: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Записывать журналы в \\(self.fileLogPath) для отладки."; "Yes" = "Да"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 817607c1fb..edb8c89215 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -404,6 +404,7 @@ "Weekly token limit" = "Veckogräns för token"; "Weekly usage" = "Veckoanvändning"; "Weekly usage unavailable for this account." = "Veckoanvändning är inte tillgänglig för det här kontot."; +"Session usage unavailable for this account." = "Sessionsanvändning är inte tillgänglig för det här kontot."; "Window: \\(window)" = "Fönster: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Skriv loggar till \\(self.fileLogPath) för felsökning."; "Yes" = "Ja"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 530d738858..8647182fad 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -389,6 +389,7 @@ "Weekly token limit" = "ขีดจํากัดโทเค็นรายสัปดาห์"; "Weekly usage" = "การใช้งานรายสัปดาห์"; "Weekly usage unavailable for this account." = "การใช้งานรายสัปดาห์ไม่พร้อมใช้งานสําหรับบัญชีนี้"; +"Session usage unavailable for this account." = "การใช้งานเซสชันไม่พร้อมใช้งานสําหรับบัญชีนี้"; "Window: \\(window)" = "หน้าต่าง: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "เขียนบันทึกไปยัง \\(self.fileLogPath) เพื่อแก้ไขข้อบกพร่อง"; "Yes" = "ใช่"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 5f05a401f9..7ffda68eff 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -387,6 +387,7 @@ "Weekly token limit" = "Haftalık token limiti"; "Weekly usage" = "Haftalık kullanım"; "Weekly usage unavailable for this account." = "Bu hesap için haftalık kullanım verisi kullanılamıyor."; +"Session usage unavailable for this account." = "Bu hesap için oturum kullanım verisi kullanılamıyor."; "Window: \\(window)" = "Pencere: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Hata ayıklama için günlükleri \\(self.fileLogPath) konumuna yaz."; "Yes" = "Evet"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 22a202310d..54b3991e7e 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -403,6 +403,7 @@ "Weekly token limit" = "Тижневий ліміт жетонів"; "Weekly usage" = "Щотижневе використання"; "Weekly usage unavailable for this account." = "Щотижневе використання недоступне для цього облікового запису."; +"Session usage unavailable for this account." = "Використання сеансу недоступне для цього облікового запису."; "Window: \\(window)" = "Вікно: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Записати журнали в \\(self.fileLogPath) для налагодження."; "Yes" = "Так"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index e161baf8a7..168716be5c 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -403,6 +403,7 @@ "Weekly token limit" = "token giới hạn"; "Weekly usage" = "Hàng tuần Mức sử dụng"; "Weekly usage unavailable for this account." = "Hàng tuần Mức sử dụng không khả dụng cho tài khoản này."; +"Session usage unavailable for this account." = "Mức sử dụng phiên không khả dụng cho tài khoản này."; "Window: \\(window)" = "Cửa sổ: \\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "Ghi nhật ký vào \\(self.fileLogPath) để gỡ lỗi."; "Yes" = "Có"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index ebe3fb66a3..37c764b1c3 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -418,6 +418,7 @@ "Weekly token limit" = "每周 token 限制"; "Weekly usage" = "每周用量"; "Weekly usage unavailable for this account." = "此账户的每周用量不可用。"; +"Session usage unavailable for this account." = "此账户的会话用量不可用。"; "Window: \\(window)" = "窗口:\\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "将日志写入 \\(self.fileLogPath) 以进行调试。"; "Yes" = "是"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index b1bd36d6dc..d925baab69 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -415,6 +415,7 @@ "Weekly token limit" = "每週 token 限制"; "Weekly usage" = "每週使用量"; "Weekly usage unavailable for this account." = "此帳號無法取得每週使用量。"; +"Session usage unavailable for this account." = "此帳號無法取得工作階段使用量。"; "Window: \\(window)" = "時段:\\(window)"; "Write logs to \\(self.fileLogPath) for debugging." = "將記錄寫入 \\(self.fileLogPath) 以進行除錯。"; "Yes" = "是"; diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 54ca9a3ab9..faf7e00613 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -508,7 +508,7 @@ enum CLIRenderer { now: Date, lines: inout [String]) { - if let primary = snapshot.primary { + if let primary = snapshot.primaryForDisplay(for: provider) { self.appendRateWindowLines( provider: provider, title: labels.primary, diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index a998f2936e..abcfff9678 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -425,6 +425,15 @@ public struct UsageSnapshot: Codable, Sendable { !(self.extraRateWindows?.isEmpty ?? true) } + /// Returns the primary window when it represents measured usage for the selected provider. + /// Keep the raw snapshot intact so diagnostics and persistence can still inspect synthetic lanes. + public func primaryForDisplay(for provider: UsageProvider) -> RateWindow? { + guard let primary = self.primary else { return nil } + // Provider-specific by design: Claude's synthetic session lane is not measured usage. + guard !(provider == .claude && primary.isSyntheticPlaceholder) else { return nil } + return primary + } + public func detailRow(label: String) -> ProviderDetailSection.Row? { self.details.lazy.flatMap(\.rows).first { $0.label == label } } @@ -647,12 +656,22 @@ public enum UsageLimitsAvailability: Equatable, Sendable { // Provider-specific by design: Claude error text, Codex identity, and Doubao/Antigravity identities signal // whether a successful payload actually contains subscription limits. if provider == .claude { + if let snapshot, snapshot.primary?.isSyntheticPlaceholder == true { + let hasMeasuredWindow = [snapshot.secondary, snapshot.tertiary] + .compactMap(\.self) + .contains { !$0.isSyntheticPlaceholder } + || snapshot.extraRateWindows? + .contains { $0.usageKnown && !$0.window.isSyntheticPlaceholder } == true + return hasMeasuredWindow ? .available : .unavailable + } guard snapshot == nil else { return .available } return ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(lastErrorDescription) ? .unavailable : .available } + // Provider-specific by design: these identities gate whether their returned payload contains + // measured limits. if provider == .doubao || provider == .antigravity { guard let snapshot, snapshot.identity(for: provider.instanceID) != nil diff --git a/Tests/CodexBarTests/ClaudeSyntheticPlaceholderMenuCardTests.swift b/Tests/CodexBarTests/ClaudeSyntheticPlaceholderMenuCardTests.swift new file mode 100644 index 0000000000..88ade3575b --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSyntheticPlaceholderMenuCardTests.swift @@ -0,0 +1,155 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI +@testable import CodexBarCore + +struct ClaudeSyntheticPlaceholderMenuCardTests { + private static let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test + func `synthetic session is unavailable instead of 100 percent remaining`() throws { + let model = try Self.model( + snapshot: UsageSnapshot( + primary: Self.syntheticSession, + secondary: nil, + updatedAt: Self.now)) + + #expect(model.metrics.isEmpty) + #expect(model.placeholder == "Limits not available") + #expect(model.usageNotes == ["Session usage unavailable for this account."]) + #expect(UsageLimitsAvailability.resolve( + provider: .claude, + snapshot: UsageSnapshot(primary: Self.syntheticSession, secondary: nil, updatedAt: Self.now)) == + .unavailable) + } + + @Test + func `real weekly usage remains visible beside an unavailable session`() throws { + let weekly = RateWindow( + usedPercent: 42, + windowMinutes: 7 * 24 * 60, + resetsAt: Self.now.addingTimeInterval(3600), + resetDescription: nil) + let model = try Self.model( + snapshot: UsageSnapshot( + primary: Self.syntheticSession, + secondary: weekly, + updatedAt: Self.now)) + + #expect(model.metrics.map(\.id) == ["secondary"]) + #expect(model.usageNotes == ["Session usage unavailable for this account."]) + #expect(UsageLimitsAvailability.resolve( + provider: .claude, + snapshot: UsageSnapshot(primary: Self.syntheticSession, secondary: weekly, updatedAt: Self.now)) == + .available) + } + + @Test + func `real zero usage is not treated as unavailable`() throws { + let primary = RateWindow( + usedPercent: 0, + windowMinutes: 5 * 60, + resetsAt: Self.now.addingTimeInterval(3600), + resetDescription: nil) + let model = try Self.model( + snapshot: UsageSnapshot(primary: primary, secondary: nil, updatedAt: Self.now)) + + #expect(model.metrics.map(\.id) == ["primary"]) + #expect(model.usageNotes.isEmpty) + #expect(UsageLimitsAvailability.resolve( + provider: .claude, + snapshot: UsageSnapshot(primary: primary, secondary: nil, updatedAt: Self.now)) == .available) + } + + private static var syntheticSession: RateWindow { + RateWindow( + usedPercent: 0, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true) + } + + private static func model(snapshot: UsageSnapshot) throws -> UsageMenuCardView.Model { + UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: ProviderDescriptorRegistry.descriptor(for: .claude).metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + limitsAvailability: UsageLimitsAvailability.resolve(provider: .claude, snapshot: snapshot), + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: true, + now: self.now)) + } +} + +@MainActor +struct ClaudeSyntheticPlaceholderDisplayTests { + private static let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test + func `synthetic session is absent from compact menu and plain CLI output`() { + let settings = testSettingsStore(suiteName: "ClaudeSyntheticPlaceholderDisplayTests") + settings.statusChecksEnabled = false + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true), + secondary: nil, + updatedAt: Self.now, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "web")) + store._setSnapshotForTesting(snapshot, provider: .claude) + + let descriptor = MenuDescriptor.build( + provider: .claude, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let menuLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + #expect(!menuLines.contains(where: { $0.contains("100% left") })) + #expect(menuLines.contains("Limits not available")) + + let cli = CLIRenderer.renderText( + provider: .claude, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Claude", + status: nil, + useColor: false, + resetStyle: .countdown), + now: Self.now) + #expect(!cli.contains("100% left")) + #expect(cli.contains("Limits: not available")) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 2cf24015a8..26f45982e6 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1903,8 +1903,8 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", anchor: "if input.provider == .mimo, input.snapshot != nil {", expectedProviderIDs: ["claude", "mimo", "muse", "opencodego"], - expectedReferenceCount: 4, - expectedReferenceFingerprint: ["mimo@0", "claude@4", "opencodego@10", "muse@15"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["mimo@0", "claude@4", "claude@8", "opencodego@14", "muse@19"], reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", From 27d78fc88b317d197fc134f9e46b07dbe35c8380 Mon Sep 17 00:00:00 2001 From: Peter Urda Date: Mon, 28 Sep 2026 12:06:06 -0700 Subject: [PATCH 070/122] Price aliased Antigravity and Codex models Price Antigravity's Gemini 3.1 Pro aliases as gemini-3.1-pro-preview and OpenAI's Daybreak aliases as Sol and Cyber, with bundled Cyber rates. Keep pre-August 21 Sol usage on its former rates, and date the older Sol test fixtures after that cutoff. Also carries the Mistral Monthly Plan picker (#4072) and Mistral billing pricing (#4076) integrated on this branch. Co-authored-by: Peter Steinberger Co-authored-by: Tom Vaucourt <34662901+T0mSIlver@users.noreply.github.com> --- CHANGELOG.md | 6 + Sources/CodexBar/MenuBarLayout.swift | 43 +++-- .../MenuBarPercentWindowPreference.swift | 71 ++++--- ...iderMenuBarPercentWindowSettingsView.swift | 22 ++- .../Generated/CodexParserHash.generated.swift | 2 +- .../Antigravity/AntigravityLocalReader.swift | 18 +- .../Providers/Mistral/MistralModels.swift | 8 + .../Mistral/MistralUsageFetcher.swift | 172 +++++++---------- .../Vendored/CostUsage/CostUsagePricing.swift | 177 ++++++++---------- .../AntigravityLocalReaderTests.swift | 44 ++++- .../CodexAliasedModelPricingTests.swift | 35 ++++ .../CodexSolHistoricalPricingTests.swift | 60 ++++++ .../CostUsagePricingRaceTests.swift | 3 +- .../CodexBarTests/CostUsagePricingTests.swift | 45 +++-- .../CostUsageScannerForkSplitTests.swift | 29 +-- .../MenuBarPercentWindowPreferenceTests.swift | 9 +- .../MistralMonthlyPlanAvailabilityTests.swift | 17 ++ .../MistralMonthlyPlanPickerTests.swift | 122 ++++++++++++ .../MistralUsageParserTests.swift | 70 +++++++ ...PiSessionCostRefreshReliabilityTests.swift | 3 +- .../PiSessionCostScannerTests.swift | 16 +- .../ProviderArchitectureGatekeeperTests.swift | 6 +- docs/antigravity.md | 3 + docs/codex.md | 2 + docs/mistral.md | 10 +- docs/model-pricing.md | 6 + 26 files changed, 680 insertions(+), 319 deletions(-) create mode 100644 Tests/CodexBarTests/CodexAliasedModelPricingTests.swift create mode 100644 Tests/CodexBarTests/CodexSolHistoricalPricingTests.swift create mode 100644 Tests/CodexBarTests/MistralMonthlyPlanAvailabilityTests.swift create mode 100644 Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e07fcf00fa..ebb1f56aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.69.1 — Unreleased +### Fixed + +- Costs: price documented Antigravity and Codex model aliases, add published Cyber fallback rates, and preserve Sol estimates across the August 21 price change (#4094). Thanks @urda! +- Mistral: offer Monthly Plan in the provider's Menu bar metric picker, so the menu bar and widgets can show the Vibe allowance without a `defaults write` (#4072). Thanks @T0mSIlver! +- Mistral: price billing usage by event type, API zone, and service tier, so a per-second audio or priority price no longer inflates API spend and 30-day token cost (#4076). Thanks @T0mSIlver! + ## 0.69.0 — 2026-09-28 ### Highlights diff --git a/Sources/CodexBar/MenuBarLayout.swift b/Sources/CodexBar/MenuBarLayout.swift index 7655f30f09..7a872764bd 100644 --- a/Sources/CodexBar/MenuBarLayout.swift +++ b/Sources/CodexBar/MenuBarLayout.swift @@ -7,6 +7,26 @@ enum PercentWindow: String, CaseIterable, Codable, Hashable, Sendable { case scopedWeekly case automatic + /// Shared by the simplified picker and legacy layout migration. + static func forMetric( + _ metric: ProviderMenuBarMetric, + primarySemanticWindow: ProviderSemanticWindow, + secondarySemanticWindow: ProviderSemanticWindow) -> Self + { + switch metric { + case .primary: self.forSemanticWindow(primarySemanticWindow) + case .secondary: self.forSemanticWindow(secondarySemanticWindow) + case .automatic, .primaryAndSecondary, .tertiary, .extraUsage, .average, .monthlyPlan: .automatic + } + } + + static func forSemanticWindow(_ window: ProviderSemanticWindow) -> Self { + switch window { + case .session: .session + case .weekly: .weekly + } + } + func providerLabel(provider: UsageProvider?) -> String? { guard let provider else { return nil } let presentation = ProviderDescriptorRegistry.descriptor(for: provider).presentation @@ -913,23 +933,12 @@ extension MenuBarLayout { provider: UsageProvider?) -> PercentWindow { - switch preference { - case .primary: - self.percentWindow( - ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation.primarySemanticWindow) - case .secondary: - self.percentWindow( - ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation.secondarySemanticWindow) - case .automatic, .primaryAndSecondary, .tertiary, .extraUsage, .average, .monthlyPlan: - .automatic - } - } - - private static func percentWindow(_ window: ProviderSemanticWindow) -> PercentWindow { - switch window { - case .session: .session - case .weekly: .weekly - } + guard preference == .primary || preference == .secondary else { return .automatic } + let presentation = ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation + return PercentWindow.forMetric( + preference.providerMetric, + primarySemanticWindow: presentation.primarySemanticWindow, + secondarySemanticWindow: presentation.secondarySemanticWindow) } static func legacyPercentWindow(for lane: MenuBarLayoutLane, provider: UsageProvider?) -> PercentWindow { diff --git a/Sources/CodexBar/MenuBarPercentWindowPreference.swift b/Sources/CodexBar/MenuBarPercentWindowPreference.swift index d78c54a991..696023aa9f 100644 --- a/Sources/CodexBar/MenuBarPercentWindowPreference.swift +++ b/Sources/CodexBar/MenuBarPercentWindowPreference.swift @@ -8,6 +8,7 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl case session case weekly case tertiary + case monthlyPlan var id: String { self.rawValue @@ -18,26 +19,30 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl case .automatic: .automatic case .session: .session case .weekly: .weekly - case .tertiary: nil + case .tertiary, .monthlyPlan: nil } } private var layoutToken: MenuBarLayoutToken { - switch self { - case .automatic: .percent(window: .automatic) - case .session: .percent(window: .session) - case .weekly: .percent(window: .weekly) - case .tertiary: .lanePercent(lane: .tertiary) - } + if self == .tertiary { return .lanePercent(lane: .tertiary) } + // Metric-backed choices resolve through the automatic lane. + return .percent(window: self.percentWindow ?? .automatic) + } + + /// The per-provider metric this choice stores for providers that offer Monthly Plan, which the + /// automatic percent and widgets read. + var menuBarMetric: MenuBarMetricPreference { + self == .monthlyPlan ? .monthlyPlan : .automatic } func label(for provider: UsageProvider) -> String { guard self != .automatic else { return L("menu_bar_layout_token_auto") } + if self == .monthlyPlan { return MenuBarMetricPreference.monthlyPlan.label } if self == .tertiary { return MenuBarLayoutLaneLabels(provider: provider, snapshot: nil).label(for: .tertiary) } let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) - let primary = Self.percentWindow(descriptor.presentation.primarySemanticWindow) + let primary = PercentWindow.forSemanticWindow(descriptor.presentation.primarySemanticWindow) let presentation = descriptor.presentation return L(self.percentWindow == primary ? presentation.menuBarLayoutPrimaryLabel ?? descriptor.metadata.sessionLabel @@ -53,8 +58,8 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl { var windows = Set() for metric in metrics.supported { - windows.insert(Self.percentWindow( - for: metric, + windows.insert(PercentWindow.forMetric( + metric, primarySemanticWindow: primarySemanticWindow, secondarySemanticWindow: secondarySemanticWindow)) } @@ -65,6 +70,9 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl if metrics.supported.contains(.tertiary), !metrics.tertiaryRequiresWindow { options.append(.tertiary) } + if metrics.supported.contains(.monthlyPlan) { + options.append(.monthlyPlan) + } return options } @@ -77,18 +85,23 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl if let layout, !self.percentWindows(in: layout).isEmpty, self.hasTertiaryPercent(in: layout) { return options.filter { $0 != .tertiary } } + // Without a percentage in the layout, only the stored metric can change. + if let layout, options.contains(.monthlyPlan), !self.hasPercentToken(in: layout) { + return [.automatic, .monthlyPlan] + } return options } /// The simplified picker controls percent layouts without changing the global icon style. + /// Monthly Plan also picks the widget allowance, so it stays reachable in every style and layout. static func isVisible( iconStyle: MenuBarIconStyle, layout: MenuBarLayout, available: [Self]) -> Bool { - iconStyle == .iconAndPercent - && self.hasPercentToken(in: layout) - && available.count > 1 + guard available.count > 1 else { return false } + if available.contains(.monthlyPlan) { return true } + return iconStyle == .iconAndPercent && self.hasPercentToken(in: layout) } static func isVisible( @@ -104,10 +117,17 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl /// Ordinary percentages own the choice when a custom layout also has an independent tertiary /// token. Only layouts without ordinary percentages treat tertiary tokens as the controlled group. - static func current(in layout: MenuBarLayout) -> Self? { + /// Pass the stored metric for providers that offer Monthly Plan: it turns an all-automatic layout into the + /// Monthly Plan choice, and alone decides the choice when the layout has no percentage. + static func current(in layout: MenuBarLayout, metric: MenuBarMetricPreference? = nil) -> Self? { let windows = Self.percentWindows(in: layout) - guard let first = windows.first else { return self.hasTertiaryPercent(in: layout) ? .tertiary : nil } + guard let first = windows.first else { + if self.hasTertiaryPercent(in: layout) { return .tertiary } + guard let metric else { return nil } + return metric == .monthlyPlan ? .monthlyPlan : .automatic + } guard windows.allSatisfy({ $0 == first }) else { return nil } + if first == .automatic, metric == .monthlyPlan { return .monthlyPlan } return Self.allCases.first { $0.percentWindow == first } } @@ -139,25 +159,4 @@ enum MenuBarPercentWindowPreference: String, CaseIterable, Identifiable, Sendabl return window } } - - /// Same semantic mapping as layout migration: other metrics retain Automatic as an option. - private static func percentWindow( - for metric: ProviderMenuBarMetric, - primarySemanticWindow: ProviderSemanticWindow, - secondarySemanticWindow: ProviderSemanticWindow) -> PercentWindow - { - switch metric { - case .primary: self.percentWindow(primarySemanticWindow) - case .secondary: self.percentWindow(secondarySemanticWindow) - case .automatic, .primaryAndSecondary, .tertiary, .extraUsage, .average, .monthlyPlan: - .automatic - } - } - - private static func percentWindow(_ window: ProviderSemanticWindow) -> PercentWindow { - switch window { - case .session: .session - case .weekly: .weekly - } - } } diff --git a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift index 600e705c43..d880703bce 100644 --- a/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift +++ b/Sources/CodexBar/ProviderMenuBarPercentWindowSettingsView.swift @@ -14,7 +14,8 @@ struct ProviderMenuBarPercentWindowSettingsView: View { ProviderMenuBarPercentWindowPicker( provider: self.provider, iconStyle: self.settings.menuBarIconStyle, - layout: self.layoutBinding) + layout: self.layoutBinding, + metric: self.metricBinding) } var layoutBinding: Binding { @@ -22,6 +23,12 @@ struct ProviderMenuBarPercentWindowSettingsView: View { get: { self.settings.menuBarLayoutResolution(for: self.provider).layout }, set: { self.settings.setMenuBarLayout($0, for: self.provider) }) } + + var metricBinding: Binding { + Binding( + get: { self.settings.menuBarMetricPreference(for: self.provider) }, + set: { self.settings.setMenuBarMetricPreference($0, for: self.provider) }) + } } @MainActor @@ -29,6 +36,7 @@ struct ProviderMenuBarPercentWindowPicker: View { let provider: UsageProvider let iconStyle: MenuBarIconStyle @Binding var layout: MenuBarLayout + var metric: Binding = .constant(.automatic) var body: some View { let layout = self.layout @@ -65,7 +73,8 @@ struct ProviderMenuBarPercentWindowPicker: View { get: { let layout = self.layout let available = MenuBarPercentWindowPreference.available(for: self.provider, layout: layout) - return MenuBarPercentWindowPreference.current(in: layout) + let metric = available.contains(.monthlyPlan) ? self.metric.wrappedValue : nil + return MenuBarPercentWindowPreference.current(in: layout, metric: metric) .flatMap { available.contains($0) ? $0 : nil } }, set: { preference in @@ -73,7 +82,14 @@ struct ProviderMenuBarPercentWindowPicker: View { guard let preference, MenuBarPercentWindowPreference.available(for: self.provider, layout: layout).contains(preference) else { return } - self.layout = preference.applied(to: layout) + let updated = preference.applied(to: layout) + if MenuBarPercentWindowPreference.available(for: self.provider).contains(.monthlyPlan) { + self.metric.wrappedValue = preference.menuBarMetric + // Without a percentage to change, only the metric is stored, so the layout keeps following + // its source instead of becoming a provider override. + guard MenuBarPercentWindowPreference.hasPercentToken(in: layout) else { return } + } + self.layout = updated }) } } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index c5a1d17bb1..caf0ea9a14 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "33d3202ea786d9ce" + static let value = "04a6361469a4ff77" } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift index 8f89ec3989..3b7100d242 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift @@ -93,17 +93,29 @@ enum AntigravityLocalReader { } /// Antigravity records routing variants of a vendor model (`-tiered`, `-low`, `-thinking`) - /// that bill at the base model's public price. The alias stays provider-local so shared - /// Claude pricing keeps reporting unknown Claude variants as unpriced. + /// that bill at the base model's public price, and product aliases that name no catalogued + /// model at all. The alias stays provider-local so shared Claude pricing keeps reporting + /// unknown Claude variants as unpriced. static func pricingBaseModelID(for model: String) -> String? { let lowered = model.lowercased() + if let alias = self.pricingModelAliases[lowered] { return alias } guard let suffix = self.routingVariantSuffixes.first(where: lowered.hasSuffix) else { return nil } let base = String(model.dropLast(suffix.count)) - return base.isEmpty ? nil : base + return base.isEmpty ? nil : self.pricingModelAliases[base.lowercased()] ?? base } private static let routingVariantSuffixes = ["-tiered", "-low", "-thinking"] + /// Gemini 3.1 Pro is catalogued only as `gemini-3.1-pro-preview`. Antigravity records it under + /// its product aliases and effort tiers; ccusage's Antigravity adapter maps the same IDs. + private static let pricingModelAliases = [ + "gemini-pro-default": "gemini-3.1-pro-preview", + "gemini-pro-agent": "gemini-3.1-pro-preview", + "gemini-3.1-pro": "gemini-3.1-pro-preview", + "gemini-3.1-pro-high": "gemini-3.1-pro-preview", + "gemini-3.1-pro-low": "gemini-3.1-pro-preview", + ] + static func checkedAdd(_ lhs: Int, _ rhs: Int) -> Int? { let (result, overflow) = lhs.addingReportingOverflow(rhs) return overflow ? nil : result diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index 314f7548f3..eb339ec433 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -66,6 +66,8 @@ struct MistralUsageEntry: Codable { let billingMetric: String? let billingDisplayName: String? let billingGroup: String? + let apiZone: String? + let serviceTier: String? let timestamp: String? let value: Int? let valuePaid: Int? @@ -77,6 +79,8 @@ struct MistralUsageEntry: Codable { case billingMetric = "billing_metric" case billingDisplayName = "billing_display_name" case billingGroup = "billing_group" + case apiZone = "api_zone" + case serviceTier = "service_tier" case valuePaid = "value_paid" } } @@ -85,6 +89,8 @@ struct MistralPrice: Codable { let eventType: String? let billingMetric: String? let billingGroup: String? + let apiZone: String? + let serviceTier: String? let price: String? enum CodingKeys: String, CodingKey { @@ -92,6 +98,8 @@ struct MistralPrice: Codable { case eventType = "event_type" case billingMetric = "billing_metric" case billingGroup = "billing_group" + case apiZone = "api_zone" + case serviceTier = "service_tier" } } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift index 039f5ca430..8031e0fb16 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -245,77 +245,33 @@ public enum MistralUsageFetcher { var modelCount = 0 var daily: [String: DailyAccumulator] = [:] - // API, Le Chat, and Vibe completions share consumed-token and billed-cost accounting. - for category in [billing.completion, billing.chat, billing.vibeCode?.completion] { - for (modelName, modelData) in category?.models ?? [:] { - modelCount += 1 - let aggregate = try Self.aggregateModel(modelData, prices: prices, countsTokens: true) - try totalTokens.add(aggregate.tokens) - Self.accumulateFiniteCost(aggregate.cost, into: &totalCost) - try Self.addDailyEntries( - modelName: modelName, - data: modelData, - prices: prices, - daily: &daily, - countsTokens: true) - } - } - - // Aggregate OCR, connectors, audio if present - for category in [billing.ocr, billing.connectors, billing.audio] { - if let models = category?.models { - for (modelName, modelData) in models { - let (_, cost) = try Self.aggregateModel(modelData, prices: prices, countsTokens: false) - Self.accumulateFiniteCost(cost, into: &totalCost) - try Self.addDailyEntries( - modelName: modelName, - data: modelData, - prices: prices, - daily: &daily, - countsTokens: false) + // Library tokens count only in daily buckets; completion categories also own month totals/model counts. + let categories: [(models: [String: MistralModelUsageData]?, monthTokens: Bool, dailyTokens: Bool)] = [ + (billing.completion?.models, true, true), + (billing.chat?.models, true, true), + (billing.vibeCode?.completion?.models, true, true), + (billing.ocr?.models, false, false), + (billing.connectors?.models, false, false), + (billing.audio?.models, false, false), + (billing.librariesApi?.pages?.models, false, false), + (billing.librariesApi?.tokens?.models, false, true), + (billing.fineTuning?.training, false, false), + (billing.fineTuning?.storage, false, false), + ] + for category in categories { + for (modelName, modelData) in category.models ?? [:] { + let aggregate = try Self.aggregateModel(modelData, prices: prices, countsTokens: category.monthTokens) + if category.monthTokens { + modelCount += 1 + try totalTokens.add(aggregate.tokens) } - } - } - - // Aggregate libraries_api (pages + tokens) - if let models = billing.librariesApi?.pages?.models { - for (modelName, modelData) in models { - let (_, cost) = try Self.aggregateModel(modelData, prices: prices, countsTokens: false) - Self.accumulateFiniteCost(cost, into: &totalCost) - try Self.addDailyEntries( - modelName: modelName, - data: modelData, - prices: prices, - daily: &daily, - countsTokens: false) - } - } - if let models = billing.librariesApi?.tokens?.models { - for (modelName, modelData) in models { - let (_, cost) = try Self.aggregateModel(modelData, prices: prices, countsTokens: false) - Self.accumulateFiniteCost(cost, into: &totalCost) + Self.accumulateFiniteCost(aggregate.cost, into: &totalCost) try Self.addDailyEntries( modelName: modelName, data: modelData, prices: prices, daily: &daily, - countsTokens: true) - } - } - - // Aggregate fine_tuning (training + storage) - for models in [billing.fineTuning?.training, billing.fineTuning?.storage] { - if let models { - for (modelName, modelData) in models { - let (_, cost) = try Self.aggregateModel(modelData, prices: prices, countsTokens: false) - Self.accumulateFiniteCost(cost, into: &totalCost) - try Self.addDailyEntries( - modelName: modelName, - data: modelData, - prices: prices, - daily: &daily, - countsTokens: false) - } + countsTokens: category.dailyTokens) } } @@ -349,8 +305,18 @@ public enum MistralUsageFetcher { // MARK: - Private Helpers - private static func buildPriceIndex(_ prices: [MistralPrice]) -> [String: Double] { - var index: [String: Double] = [:] + /// Event type, zone, and tier distinguish otherwise equal billing units. Legacy tables can omit both + /// zone and tier; only that explicitly unqualified row is a fallback. + private struct PriceKey: Hashable { + let eventType: String? + let metric: String + let group: String + let apiZone: String? + let serviceTier: String? + } + + private static func buildPriceIndex(_ prices: [MistralPrice]) -> [PriceKey: Double] { + var index: [PriceKey: Double] = [:] for price in prices { guard let metric = price.billingMetric, let group = price.billingGroup, @@ -358,7 +324,12 @@ public enum MistralUsageFetcher { let value = Double(priceStr), value.isFinite else { continue } - let key = "\(metric)::\(group)" + let key = PriceKey( + eventType: price.eventType, + metric: metric, + group: group, + apiZone: price.apiZone, + serviceTier: price.serviceTier) index[key] = value } return index @@ -366,7 +337,7 @@ public enum MistralUsageFetcher { private static func aggregateModel( _ data: MistralModelUsageData, - prices: [String: Double], + prices: [PriceKey: Double], countsTokens: Bool) throws -> (tokens: TokenCounts, cost: Double) { var tokens = TokenCounts() @@ -387,7 +358,7 @@ public enum MistralUsageFetcher { private static func addDailyEntries( modelName: String, data: MistralModelUsageData, - prices: [String: Double], + prices: [PriceKey: Double], daily: inout [String: DailyAccumulator], countsTokens: Bool) throws { @@ -395,14 +366,18 @@ public enum MistralUsageFetcher { (.input, data.input), (.output, data.output), (.cached, data.cached), ] for (kind, entries) in lanes { - try self.addDaily( - entries: entries ?? [], - context: DailyEntryContext( + for entry in entries ?? [] { + guard let day = dayKey(from: entry.timestamp) else { continue } + let cost = Self.cost(for: entry, units: entry.valuePaid ?? entry.value ?? 0, prices: prices) + var accumulator = daily[day] ?? DailyAccumulator(day: day) + try accumulator.add( + modelName: Self.displayModelName(modelName, entry: entry), kind: kind, - modelName: modelName, - prices: prices, - countsTokens: countsTokens), - daily: &daily) + units: entry.value ?? entry.valuePaid ?? 0, + cost: cost, + countsTokens: countsTokens) + daily[day] = accumulator + } } } @@ -412,29 +387,21 @@ public enum MistralUsageFetcher { case output } - private static func addDaily( - entries: [MistralUsageEntry], - context: DailyEntryContext, - daily: inout [String: DailyAccumulator]) throws - { - for entry in entries { - guard let day = dayKey(from: entry.timestamp) else { continue } - let units = entry.valuePaid ?? entry.value ?? 0 - let cost = Self.cost(for: entry, units: units, prices: context.prices) - var accumulator = daily[day] ?? DailyAccumulator(day: day) - try accumulator.add( - modelName: Self.displayModelName(context.modelName, entry: entry), - kind: context.kind, - units: entry.value ?? entry.valuePaid ?? 0, - cost: cost, - countsTokens: context.countsTokens) - daily[day] = accumulator - } - } - - private static func cost(for entry: MistralUsageEntry, units: Int, prices: [String: Double]) -> Double { + private static func cost(for entry: MistralUsageEntry, units: Int, prices: [PriceKey: Double]) -> Double { guard let metric = entry.billingMetric, let group = entry.billingGroup else { return 0 } - let cost = Double(units) * (prices["\(metric)::\(group)"] ?? 0) + let key = PriceKey( + eventType: entry.eventType, + metric: metric, + group: group, + apiZone: entry.apiZone, + serviceTier: entry.serviceTier) + let zonelessKey = PriceKey( + eventType: entry.eventType, + metric: metric, + group: group, + apiZone: nil, + serviceTier: nil) + let cost = Double(units) * (prices[key] ?? prices[zonelessKey] ?? 0) return cost.isFinite ? cost : 0 } @@ -465,13 +432,6 @@ public enum MistralUsageFetcher { } } -private struct DailyEntryContext { - let kind: MistralUsageFetcher.TokenKind - let modelName: String - let prices: [String: Double] - let countsTokens: Bool -} - private struct TokenCounts { var input = 0 var cached = 0 diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 714885cc1c..ba45fb506b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -23,7 +23,7 @@ enum CostUsagePricing { inputCostPerToken: Double, outputCostPerToken: Double, cacheReadInputCostPerToken: Double?, - displayLabel: String?, + displayLabel: String? = nil, cacheWriteInputCostPerToken: Double? = nil, thresholdTokens: Int? = nil, inputCostPerTokenAboveThreshold: Double? = nil, @@ -44,6 +44,22 @@ enum CostUsagePricing { } } + private static func gpt56Pricing( + standard: (input: Double, cached: Double, write: Double, output: Double), + longContext: (input: Double, cached: Double, write: Double, output: Double)) -> CodexPricing + { + CodexPricing( + inputCostPerToken: standard.input, + outputCostPerToken: standard.output, + cacheReadInputCostPerToken: standard.cached, + cacheWriteInputCostPerToken: standard.write, + thresholdTokens: 272_000, + inputCostPerTokenAboveThreshold: longContext.input, + outputCostPerTokenAboveThreshold: longContext.output, + cacheReadInputCostPerTokenAboveThreshold: longContext.cached, + cacheWriteInputCostPerTokenAboveThreshold: longContext.write) + } + struct ClaudePricing { let inputCostPerToken: Double let outputCostPerToken: Double @@ -69,68 +85,55 @@ enum CostUsagePricing { "gpt-5": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.25e-7), "gpt-5-codex": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.25e-7), "gpt-5-mini": CodexPricing( inputCostPerToken: 2.5e-7, outputCostPerToken: 2e-6, - cacheReadInputCostPerToken: 2.5e-8, - displayLabel: nil), + cacheReadInputCostPerToken: 2.5e-8), "gpt-5-nano": CodexPricing( inputCostPerToken: 5e-8, outputCostPerToken: 4e-7, - cacheReadInputCostPerToken: 5e-9, - displayLabel: nil), + cacheReadInputCostPerToken: 5e-9), "gpt-5-pro": CodexPricing( inputCostPerToken: 1.5e-5, outputCostPerToken: 1.2e-4, - cacheReadInputCostPerToken: nil, - displayLabel: nil), + cacheReadInputCostPerToken: nil), "gpt-5.1": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.25e-7), "gpt-5.1-codex": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.25e-7), "gpt-5.1-codex-max": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.25e-7), "gpt-5.1-codex-mini": CodexPricing( inputCostPerToken: 2.5e-7, outputCostPerToken: 2e-6, - cacheReadInputCostPerToken: 2.5e-8, - displayLabel: nil), + cacheReadInputCostPerToken: 2.5e-8), "gpt-5.2": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.75e-7), "gpt-5.2-codex": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.75e-7), "gpt-5.2-pro": CodexPricing( inputCostPerToken: 2.1e-5, outputCostPerToken: 1.68e-4, - cacheReadInputCostPerToken: nil, - displayLabel: nil), + cacheReadInputCostPerToken: nil), "gpt-5.3-codex": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7, - displayLabel: nil), + cacheReadInputCostPerToken: 1.75e-7), "gpt-5.3-codex-spark": CodexPricing( inputCostPerToken: 0, outputCostPerToken: 0, @@ -140,7 +143,6 @@ enum CostUsagePricing { inputCostPerToken: 2.5e-6, outputCostPerToken: 1.5e-5, cacheReadInputCostPerToken: 2.5e-7, - displayLabel: nil, thresholdTokens: 272_000, inputCostPerTokenAboveThreshold: 5e-6, outputCostPerTokenAboveThreshold: 2.25e-5, @@ -148,23 +150,19 @@ enum CostUsagePricing { "gpt-5.4-mini": CodexPricing( inputCostPerToken: 7.5e-7, outputCostPerToken: 4.5e-6, - cacheReadInputCostPerToken: 7.5e-8, - displayLabel: nil), + cacheReadInputCostPerToken: 7.5e-8), "gpt-5.4-nano": CodexPricing( inputCostPerToken: 2e-7, outputCostPerToken: 1.25e-6, - cacheReadInputCostPerToken: 2e-8, - displayLabel: nil), + cacheReadInputCostPerToken: 2e-8), "gpt-5.4-pro": CodexPricing( inputCostPerToken: 3e-5, outputCostPerToken: 1.8e-4, - cacheReadInputCostPerToken: nil, - displayLabel: nil), + cacheReadInputCostPerToken: nil), "gpt-5.5": CodexPricing( inputCostPerToken: 5e-6, outputCostPerToken: 3e-5, cacheReadInputCostPerToken: 5e-7, - displayLabel: nil, thresholdTokens: 272_000, inputCostPerTokenAboveThreshold: 1e-5, outputCostPerTokenAboveThreshold: 4.5e-5, @@ -172,15 +170,13 @@ enum CostUsagePricing { "gpt-5.5-pro": CodexPricing( inputCostPerToken: 3e-5, outputCostPerToken: 1.8e-4, - cacheReadInputCostPerToken: nil, - displayLabel: nil), + cacheReadInputCostPerToken: nil), // https://developers.openai.com/api/docs/models/gpt-6-astra and /api/docs/pricing. // The full request switches to long-context rates above 272K input tokens, including Fast mode. "gpt-6-astra": CodexPricing( inputCostPerToken: 1e-5, outputCostPerToken: 5e-5, cacheReadInputCostPerToken: 1e-6, - displayLabel: nil, cacheWriteInputCostPerToken: 1.25e-5, thresholdTokens: 272_000, inputCostPerTokenAboveThreshold: 2e-5, @@ -191,39 +187,24 @@ enum CostUsagePricing { // Long context: prompts with >272K input tokens are 2x input / 1.5x output for the full // request. Cache writes: 1.25x uncached input. API Fast support and multipliers are applied // separately after Standard pricing resolves from models.dev or this bundled fallback. - "gpt-5.6-sol": CodexPricing( - inputCostPerToken: 5e-6, - outputCostPerToken: 3e-5, - cacheReadInputCostPerToken: 5e-7, - displayLabel: nil, - cacheWriteInputCostPerToken: 6.25e-6, - thresholdTokens: 272_000, - inputCostPerTokenAboveThreshold: 1e-5, - outputCostPerTokenAboveThreshold: 4.5e-5, - cacheReadInputCostPerTokenAboveThreshold: 1e-6, - cacheWriteInputCostPerTokenAboveThreshold: 1.25e-5), - "gpt-5.6-terra": CodexPricing( - inputCostPerToken: 2e-6, - outputCostPerToken: 1.2e-5, - cacheReadInputCostPerToken: 2e-7, - displayLabel: nil, - cacheWriteInputCostPerToken: 2.5e-6, - thresholdTokens: 272_000, - inputCostPerTokenAboveThreshold: 4e-6, - outputCostPerTokenAboveThreshold: 1.8e-5, - cacheReadInputCostPerTokenAboveThreshold: 4e-7, - cacheWriteInputCostPerTokenAboveThreshold: 5e-6), - "gpt-5.6-luna": CodexPricing( - inputCostPerToken: 2e-7, - outputCostPerToken: 1.2e-6, - cacheReadInputCostPerToken: 2e-8, - displayLabel: nil, - cacheWriteInputCostPerToken: 2.5e-7, - thresholdTokens: 272_000, - inputCostPerTokenAboveThreshold: 4e-7, - outputCostPerTokenAboveThreshold: 1.8e-6, - cacheReadInputCostPerTokenAboveThreshold: 4e-8, - cacheWriteInputCostPerTokenAboveThreshold: 5e-7), + // Sol was repriced from $5/$30 to $4/$20 on 2026-08-21. + "gpt-5.6-sol": Self.gpt56Pricing( + standard: (4e-6, 4e-7, 5e-6, 2e-5), longContext: (8e-6, 8e-7, 1e-5, 3e-5)), + "gpt-5.6-terra": Self.gpt56Pricing( + standard: (2e-6, 2e-7, 2.5e-6, 1.2e-5), longContext: (4e-6, 4e-7, 5e-6, 1.8e-5)), + "gpt-5.6-luna": Self.gpt56Pricing( + standard: (2e-7, 2e-8, 2.5e-7, 1.2e-6), longContext: (4e-7, 4e-8, 5e-7, 1.8e-6)), + // Daybreak Cyber models (OpenAI pricing page). No long-context tier is published, and + // gpt-5.5-cyber lists no cache-write rate. + "gpt-5.6-cyber": CodexPricing( + inputCostPerToken: 1.25e-5, + outputCostPerToken: 7.5e-5, + cacheReadInputCostPerToken: 1.25e-6, + cacheWriteInputCostPerToken: 1.5625e-5), + "gpt-5.5-cyber": CodexPricing( + inputCostPerToken: 1.25e-5, + outputCostPerToken: 7.5e-5, + cacheReadInputCostPerToken: 1.25e-6), ] static func codexBuiltInPricingFingerprint() -> String { @@ -411,32 +392,18 @@ enum CostUsagePricing { ] // GPT-5.6 Terra and Luna rates effective before 2026-07-30 (Unix 1785369600). - // Sol pricing was unchanged. Values from OpenAI pricing page snapshot in PR #2521. + // Terra/Luna values from the OpenAI pricing page snapshot in PR #2521. // Co-authored-by: iam-brain (historical rate values). static let codexGPT56PricingCutoff = Date(timeIntervalSince1970: 1_785_369_600) - private static let codexHistoricalPricing: [String: CodexPricing] = [ - "gpt-5.6-terra": CodexPricing( - inputCostPerToken: 2.5e-6, - outputCostPerToken: 1.5e-5, - cacheReadInputCostPerToken: 2.5e-7, - displayLabel: nil, - cacheWriteInputCostPerToken: 3.125e-6, - thresholdTokens: 272_000, - inputCostPerTokenAboveThreshold: 5e-6, - outputCostPerTokenAboveThreshold: 2.25e-5, - cacheReadInputCostPerTokenAboveThreshold: 5e-7, - cacheWriteInputCostPerTokenAboveThreshold: 6.25e-6), - "gpt-5.6-luna": CodexPricing( - inputCostPerToken: 1e-6, - outputCostPerToken: 6e-6, - cacheReadInputCostPerToken: 1e-7, - displayLabel: nil, - cacheWriteInputCostPerToken: 1.25e-6, - thresholdTokens: 272_000, - inputCostPerTokenAboveThreshold: 2e-6, - outputCostPerTokenAboveThreshold: 9e-6, - cacheReadInputCostPerTokenAboveThreshold: 2e-7, - cacheWriteInputCostPerTokenAboveThreshold: 2.5e-6), + // Sol repricing is dated August 21 in https://developers.openai.com/api/docs/changelog. + private static let codexSolPricingCutoff = Date(timeIntervalSince1970: 1_787_270_400) + private static let codexHistoricalPricing: [String: (cutoff: Date, pricing: CodexPricing)] = [ + "gpt-5.6-sol": (Self.codexSolPricingCutoff, Self.gpt56Pricing( + standard: (5e-6, 5e-7, 6.25e-6, 3e-5), longContext: (1e-5, 1e-6, 1.25e-5, 4.5e-5))), + "gpt-5.6-terra": (Self.codexGPT56PricingCutoff, Self.gpt56Pricing( + standard: (2.5e-6, 2.5e-7, 3.125e-6, 1.5e-5), longContext: (5e-6, 5e-7, 6.25e-6, 2.25e-5))), + "gpt-5.6-luna": (Self.codexGPT56PricingCutoff, Self.gpt56Pricing( + standard: (1e-6, 1e-7, 1.25e-6, 6e-6), longContext: (2e-6, 2e-7, 2.5e-6, 9e-6))), ] private static let claudeFullContextStandardPricingCutoff = Date(timeIntervalSince1970: 1_773_360_000) @@ -528,6 +495,15 @@ enum CostUsagePricing { return "gpt-5.6-luna" } + // OpenAI's Daybreak aliases currently point to Sol (blue) and Cyber (red). + // https://developers.openai.com/api/docs/pricing + if trimmed == "gpt-daybreak-blue-latest" { + return "gpt-5.6-sol" + } + if trimmed == "gpt-daybreak-red-latest" { + return "gpt-5.6-cyber" + } + if self.codex[trimmed] != nil { return trimmed } @@ -592,13 +568,13 @@ enum CostUsagePricing { { let key = pricingResolver?.normalize(model) ?? self.normalizeCodexModel(model) guard key != self.codexUnattributedModel else { return nil } - // Use historical bundled rates when the usage predates a known pricing change and - // no custom overlay or models.dev catalog entry overrides the lookup. + // Known historical rates take precedence over today’s catalog. Callers resolve custom + // overlays before reaching this lookup. if let pricingDate, - pricingDate < self.codexGPT56PricingCutoff, - let historical = self.codexHistoricalPricing[key] + let historical = self.codexHistoricalPricing[key], + pricingDate < historical.cutoff { - return historical + return historical.pricing } let modelsDevLookup = if let pricingResolver { pricingResolver.lookup(model) @@ -628,7 +604,6 @@ enum CostUsagePricing { outputCostPerToken: lookup.pricing.outputCostPerToken, cacheReadInputCostPerToken: lookup.pricing.cacheReadInputCostPerToken ?? bundled?.cacheReadInputCostPerToken, - displayLabel: nil, cacheWriteInputCostPerToken: lookup.pricing.cacheCreationInputCostPerToken ?? bundled?.cacheWriteInputCostPerToken, thresholdTokens: bundled?.thresholdTokens ?? lookup.pricing.thresholdTokens, diff --git a/Tests/CodexBarTests/AntigravityLocalReaderTests.swift b/Tests/CodexBarTests/AntigravityLocalReaderTests.swift index 01615b0dcc..0f63438899 100644 --- a/Tests/CodexBarTests/AntigravityLocalReaderTests.swift +++ b/Tests/CodexBarTests/AntigravityLocalReaderTests.swift @@ -159,7 +159,8 @@ struct AntigravityLocalReaderTests { #expect(AntigravityLocalReader.pricingBaseModelID(for: "gemini-3.8-flash-tiered") == "gemini-3.8-flash") - #expect(AntigravityLocalReader.pricingBaseModelID(for: "gemini-3.1-pro-low") == "gemini-3.1-pro") + #expect(AntigravityLocalReader.pricingBaseModelID(for: "gemini-3.1-pro-low") + == "gemini-3.1-pro-preview") #expect(AntigravityLocalReader.pricingBaseModelID(for: "claude-opus-4-6-thinking") == "claude-opus-4-6") #expect(AntigravityLocalReader.pricingBaseModelID(for: "gemini-3.8-flash") == nil) @@ -899,3 +900,44 @@ struct AntigravityLocalReaderTests { #expect(snapshot.last30DaysTokens == nil) } } + +extension AntigravityLocalReaderTests { + @Test + func `gemini pro product aliases price from the catalogued preview model`() async throws { + let fixture = try Fixture() + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(#""" + { + "google": { + "id": "google", + "name": "Google", + "models": { + "gemini-3.1-pro-preview": { + "id": "gemini-3.1-pro-preview", + "cost": {"input": 1, "output": 2, "cache_read": 0.2} + } + } + } + } + """#.utf8)) + let cacheRoot = fixture.root.appendingPathComponent("scanner-cache") + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: Fixture.now, cacheRoot: cacheRoot)) + try fixture.database(blobs: [Fixture.blob(model: "gemini-pro-default")]) + + let snapshot = try await fixture.snapshot() + let expected = 111e-6 + 50 * 0.2e-6 + 37 * 2e-6 + #expect(snapshot.last30DaysCostUSD == expected) + // The recorded alias keeps its own identity in the breakdown; only pricing resolves. + #expect(snapshot.daily.first?.modelBreakdowns?.first?.modelName == "gemini-pro-default") + + for alias in [ + "gemini-pro-default", + "gemini-pro-agent", + "gemini-3.1-pro", + "gemini-3.1-pro-high", + "gemini-3.1-pro-thinking", + ] { + #expect(AntigravityLocalReader.pricingBaseModelID(for: alias) == "gemini-3.1-pro-preview") + } + #expect(AntigravityLocalReader.pricingBaseModelID(for: "Gemini-Pro-Default") == "gemini-3.1-pro-preview") + } +} diff --git a/Tests/CodexBarTests/CodexAliasedModelPricingTests.swift b/Tests/CodexBarTests/CodexAliasedModelPricingTests.swift new file mode 100644 index 0000000000..c9039324f5 --- /dev/null +++ b/Tests/CodexBarTests/CodexAliasedModelPricingTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexAliasedModelPricingTests { + @Test + func `codex cost prices daybreak aliases and cyber bundled fallback`() throws { + // Empty models.dev cache root forces the built-in table. + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let root = env.cacheRoot + + func cost(_ model: String, writes: Int = 0) -> Double? { + CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + cacheWriteInputTokens: writes, + modelsDevCacheRoot: root) + } + + // Cyber rates per token: $12.50 input, $1.25 cached input, $75 output per 1M. + let cyber = (90.0 * 1.25e-5) + (10.0 * 1.25e-6) + (5.0 * 7.5e-5) + #expect(cost("gpt-5.6-cyber") == cyber) + #expect(cost("gpt-5.5-cyber") == cyber) + let writeCost = try #require(cost("gpt-5.6-cyber", writes: 20)) + let expectedWriteCost = (70.0 * 1.25e-5) + (10.0 * 1.25e-6) + (20.0 * 1.5625e-5) + (5.0 * 7.5e-5) + #expect(abs(writeCost - expectedWriteCost) < 1e-12) + let legacyWriteCost = try #require(cost("gpt-5.5-cyber", writes: 20)) + #expect(abs(legacyWriteCost - cyber) < 1e-12) + #expect(cost("gpt-daybreak-blue-latest") == cost("gpt-5.6-sol")) + #expect(cost("gpt-daybreak-red-latest") == cyber) + } +} diff --git a/Tests/CodexBarTests/CodexSolHistoricalPricingTests.swift b/Tests/CodexBarTests/CodexSolHistoricalPricingTests.swift new file mode 100644 index 0000000000..9db0d06666 --- /dev/null +++ b/Tests/CodexBarTests/CodexSolHistoricalPricingTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexSolHistoricalPricingTests { + @Test(arguments: ["gpt-5.6-sol", "gpt-5.6"], [100, 272_001]) + func `Sol keeps historical rates before its August repricing`( + model: String, input: Int) throws + { + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(#""" + {"openai":{"id":"openai","models":{"gpt-5.6-sol":{ + "id":"gpt-5.6-sol","cost":{"input":4,"cache_read":0.4,"cache_write":5,"output":20} + }}}} + """#.utf8)) + let cutoff = try #require(ISO8601DateParser.parse("2026-08-21T00:00:00Z")) + for sourceCatalog in [catalog, ModelsDevCatalog(providers: [:])] { + let resolvers: [CostUsagePricing.CodexResolver?] = [nil, .init(catalog: sourceCatalog)] + for resolver in resolvers { + for (date, historical) in [(cutoff.addingTimeInterval(-1), true), (cutoff, false)] { + let longContext = input > 272_000 + let inputRate = historical ? (longContext ? 10.0 : 5) : (longContext ? 8.0 : 4) + let outputRate = historical ? (longContext ? 45.0 : 30) : (longContext ? 30.0 : 20) + let expected = (Double(input - 30) * inputRate + inputRate + 25 * inputRate + 5 * outputRate) + / 1_000_000 + let standard = try #require(CostUsagePricing.codexCostUSD( + model: model, + inputTokens: input, + cachedInputTokens: 10, + outputTokens: 5, + cacheWriteInputTokens: 20, + pricingDate: date, + modelsDevCatalog: sourceCatalog, + pricingResolver: resolver)) + #expect(abs(standard - expected) < 1e-12) + let fast = CostUsagePricing.codexPriorityCostUSD( + model: model, + inputTokens: input, + cachedInputTokens: 10, + cacheWriteInputTokens: 20, + outputTokens: 5, + pricingDate: date, + modelsDevCatalog: sourceCatalog, + pricingResolver: resolver) + if longContext { + #expect(fast == nil) + } else { + let fast = try #require(fast) + #expect(abs(fast - expected * 2) < 1e-12) + } + } + } + } + #expect(CostUsagePricing.codexCostUSD( + model: "fixture-unknown-model", + inputTokens: input, + cachedInputTokens: 0, + outputTokens: 5, + modelsDevCatalog: catalog) == nil) + } +} diff --git a/Tests/CodexBarTests/CostUsagePricingRaceTests.swift b/Tests/CodexBarTests/CostUsagePricingRaceTests.swift index b62da76a11..d99e41cdf9 100644 --- a/Tests/CodexBarTests/CostUsagePricingRaceTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingRaceTests.swift @@ -181,7 +181,8 @@ private struct PricingRaceFixture { init() throws { let environment = try CostUsageTestEnvironment() - let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 7) + // After the 2026-08-21 Sol repricing, so catalog rates apply instead of historical bundled rates. + let day = try environment.makeLocalNoon(year: 2026, month: 9, day: 7) let timestamp = environment.isoString(for: day) let entries: [[String: Any]] = [ ["type": "turn_context", "timestamp": timestamp, "payload": ["model": "gpt-5.6-sol"]], diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index fa29825a9c..b120d102a9 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -21,6 +21,11 @@ struct CostUsagePricingTests { #expect(CostUsagePricing.normalizeCodexModel("gpt-5.6") == "gpt-5.6-sol") #expect(CostUsagePricing.normalizeCodexModel("gpt-reserve") == "gpt-5.6-luna") #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-reserve") == "gpt-5.6-luna") + #expect(CostUsagePricing.normalizeCodexModel("gpt-daybreak-blue-latest") == "gpt-5.6-sol") + #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-daybreak-blue-latest") == "gpt-5.6-sol") + #expect(CostUsagePricing.normalizeCodexModel("gpt-daybreak-red-latest") == "gpt-5.6-cyber") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.6-cyber") == "gpt-5.6-cyber") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.5-cyber") == "gpt-5.5-cyber") // Fictitious dated suffixes only exercise normalize stripping (not released snapshot IDs). #expect(CostUsagePricing.normalizeCodexModel("gpt-5.6-sol-2099-01-01") == "gpt-5.6-sol") #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5.6-terra-2099-01-01") == "gpt-5.6-terra") @@ -254,9 +259,9 @@ struct CostUsagePricingTests { outputTokens: 5, modelsDevCacheRoot: root) - // Rates per token: Sol $5/$30 per 1M, Terra $2/$12, Luna $0.20/$1.20; + // Rates per token: Sol $4/$20 per 1M, Terra $2/$12, Luna $0.20/$1.20; // cache read is 10% of input. Non-cached input is 90 tokens. - #expect(sol == (90.0 * 5e-6) + (10.0 * 5e-7) + (5.0 * 3e-5)) + #expect(sol == (90.0 * 4e-6) + (10.0 * 4e-7) + (5.0 * 2e-5)) #expect(terra == (90.0 * 2e-6) + (10.0 * 2e-7) + (5.0 * 1.2e-5)) #expect(luna == (90.0 * 2e-7) + (10.0 * 2e-8) + (5.0 * 1.2e-6)) #expect(reserve == luna) @@ -391,7 +396,7 @@ struct CostUsagePricingTests { // Long-context (>272K) rates apply to the entire request. Total input contains 10 cached, // 20 cache-write, and 271,971 ordinary input tokens. - #expect(sol == (271_971.0 * 1e-5) + (10.0 * 1e-6) + (20.0 * 1.25e-5) + (10.0 * 4.5e-5)) + #expect(sol == (271_971.0 * 8e-6) + (10.0 * 8e-7) + (20.0 * 1e-5) + (10.0 * 3e-5)) #expect(terra == (271_971.0 * 4e-6) + (10.0 * 4e-7) + (20.0 * 5e-6) + (10.0 * 1.8e-5)) #expect(luna == (271_971.0 * 4e-7) + (10.0 * 4e-8) + (20.0 * 5e-7) + (10.0 * 1.8e-6)) } @@ -408,7 +413,7 @@ struct CostUsagePricingTests { cacheWriteInputTokens: 20, modelsDevCacheRoot: root) - let expected = (70.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6) + (5.0 * 3e-5) + let expected = (70.0 * 4e-6) + (10.0 * 4e-7) + (20.0 * 5e-6) + (5.0 * 2e-5) #expect(sol == expected) } @@ -435,7 +440,7 @@ struct CostUsagePricingTests { modelsDevCacheRoot: root) // Public API Fast rates are 2x Standard for GPT-5.6. - let expectedSol = 2.02 + let expectedSol = 1.456 let expectedTerra = 0.808 let expectedLuna = 0.0808 #expect(abs((sol ?? 0) - expectedSol) < 1e-12) @@ -475,10 +480,10 @@ struct CostUsagePricingTests { outputTokens: 5, modelsDevCacheRoot: root) - let solInput = 70.0 * 5e-6 - let solCached = 10.0 * 5e-7 - let solWrite = 20.0 * 6.25e-6 - let solOutput = 5.0 * 3e-5 + let solInput = 70.0 * 4e-6 + let solCached = 10.0 * 4e-7 + let solWrite = 20.0 * 5e-6 + let solOutput = 5.0 * 2e-5 let expectedSol: Double = (solInput + solCached + solWrite + solOutput) * 2 let terraInput = 70.0 * 2e-6 let terraCached = 10.0 * 2e-7 @@ -820,8 +825,8 @@ struct CostUsagePricingTests { outputTokens: 100, modelsDevCacheRoot: catalogThresholdRoot) - #expect(bundledBelowThreshold == (200_000.0 * 5e-6) + (100.0 * 30e-6)) - #expect(bundledAtThreshold == (272_000.0 * 5e-6) + (100.0 * 30e-6)) + #expect(bundledBelowThreshold == (200_000.0 * 4e-6) + (100.0 * 20e-6)) + #expect(bundledAtThreshold == (272_000.0 * 4e-6) + (100.0 * 20e-6)) #expect(bundledAboveThreshold == nil) #expect(linear == (300_000.0 * 7.5e-7) + (100_000.0 * 7.5e-8) + (100.0 * 4.5e-6)) #expect(catalogAtThreshold == (200_000.0 * 5e-6) + (100.0 * 30e-6)) @@ -876,7 +881,7 @@ extension CostUsagePricingTests { "models": { "gpt-5.6-sol": { "id": "gpt-5.6-sol", - "cost": { "input": 5, "output": 30 } + "cost": { "input": 4, "output": 20 } } } } @@ -889,7 +894,7 @@ extension CostUsagePricingTests { "models": { "gpt-5.6-sol": { "id": "gpt-5.6-sol", - "cost": { "input": 5, "output": 30, "cache_read": 0, "cache_write": 0 } + "cost": { "input": 4, "output": 20, "cache_read": 0, "cache_write": 0 } } } } @@ -911,8 +916,8 @@ extension CostUsagePricingTests { cacheWriteInputTokens: 20, modelsDevCacheRoot: explicitZeroRoot) - #expect(missing == (70.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6)) - #expect(explicitZero == 70.0 * 5e-6) + #expect(missing == (70.0 * 4e-6) + (10.0 * 4e-7) + (20.0 * 5e-6)) + #expect(explicitZero == 70.0 * 4e-6) } @Test @@ -926,9 +931,9 @@ extension CostUsagePricingTests { "gpt-5.6-sol": { "id": "gpt-5.6-sol", "cost": { - "input": 5, - "output": 30, - "cache_read": 0.5 + "input": 4, + "output": 20, + "cache_read": 0.4 } } } @@ -943,9 +948,9 @@ extension CostUsagePricingTests { outputTokens: 10, modelsDevCacheRoot: root) - // Without bundled above-threshold fallback this would bill short rates ($5/$30) despite + // Without bundled above-threshold fallback this would bill short rates ($4/$20) despite // entering long-context mode via the bundled threshold. - #expect(cost == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) + #expect(cost == (272_001.0 * 8e-6) + (10.0 * 3e-5)) } @Test diff --git a/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift b/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift index 19a5078a9d..d8c908b4b5 100644 --- a/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift @@ -3,13 +3,14 @@ import Foundation import Testing @testable import CodexBarCore +/// Sol fixtures are dated after the 2026-08-21 repricing, so undated expected costs use the same rates. struct CostUsageScannerForkSplitTests { @Test func `source verified short fork requests preserve pricing and persistence`() async throws { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } - let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 11) + let day = try environment.makeLocalNoon(year: 2026, month: 9, day: 11) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let dayKey = range.sinceKey let model = "gpt-5.6-sol" @@ -115,7 +116,7 @@ struct CostUsageScannerForkSplitTests { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } - let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 11) + let day = try environment.makeLocalNoon(year: 2026, month: 9, day: 11) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let dayKey = range.sinceKey let model = "gpt-5.6-sol" @@ -213,7 +214,7 @@ struct CostUsageScannerForkSplitTests { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } - let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 11) + let day = try environment.makeLocalNoon(year: 2026, month: 9, day: 11) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let dayKey = range.sinceKey let model = "gpt-5.6-sol" @@ -271,7 +272,7 @@ struct CostUsageScannerForkSplitTests { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } - let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 11) + let day = try environment.makeLocalNoon(year: 2026, month: 9, day: 11) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let dayKey = range.sinceKey let model = "gpt-5.4-mini" @@ -321,7 +322,7 @@ struct CostUsageScannerForkSplitTests { @Test func `exact codex pricing rows retain persisted order`() { - let dayKey = "2026-08-11" + let dayKey = "2026-09-11" let model = "gpt-5.6-sol" let later = CostUsageScanner.CodexUsageRow( day: dayKey, @@ -358,16 +359,17 @@ struct CostUsageScannerForkSplitTests { func `source verified standard child retains pricing despite a later fast parent timestamp`() throws { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } - let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 11) + let day = try environment.makeLocalNoon(year: 2026, month: 9, day: 11) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let dayKey = range.sinceKey let model = "gpt-5.6-sol" + let timestamp = Int64(day.timeIntervalSince1970 * 1000) let parent = CostUsageScanner.CodexUsageRow( day: dayKey, model: model, turnID: "fast-parent", eventIndex: 0, - timestampUnixMs: 2, + timestampUnixMs: timestamp + 2, input: 100_000, cached: 0, output: 10, @@ -377,7 +379,7 @@ struct CostUsageScannerForkSplitTests { model: model, turnID: "standard-child", eventIndex: 1, - timestampUnixMs: 1, + timestampUnixMs: timestamp + 1, input: 100_000, cached: 0, output: 10, @@ -432,16 +434,17 @@ struct CostUsageScannerForkSplitTests { func `zero owned fork rows use an empty suffix without hiding parent cost`() throws { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } - let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 11) + let day = try environment.makeLocalNoon(year: 2026, month: 9, day: 11) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let dayKey = range.sinceKey let model = "gpt-5.6-sol" + let timestamp = Int64(day.timeIntervalSince1970 * 1000) let parentRow = CostUsageScanner.CodexUsageRow( day: dayKey, model: model, turnID: "parent", eventIndex: 0, - timestampUnixMs: 1, + timestampUnixMs: timestamp + 1, input: 300_000, cached: 0, output: 10) @@ -482,7 +485,7 @@ struct CostUsageScannerForkSplitTests { func `exact rows require complete request pricing coverage`() throws { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } - let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 11) + let day = try environment.makeLocalNoon(year: 2026, month: 9, day: 11) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let dayKey = range.sinceKey let model = "gpt-5.6-sol" @@ -550,7 +553,7 @@ struct CostUsageScannerForkSplitTests { func `project primary report propagates unresolved same model ownership`() throws { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } - let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 11) + let day = try environment.makeLocalNoon(year: 2026, month: 9, day: 11) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let dayKey = range.sinceKey let model = "gpt-5.6-sol" @@ -613,7 +616,7 @@ struct CostUsageScannerForkSplitTests { func `project primary report keeps priced models beside explicitly unpriced models`() throws { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } - let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 11) + let day = try environment.makeLocalNoon(year: 2026, month: 9, day: 11) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let dayKey = range.sinceKey let pricedModel = "gpt-5.6-sol" diff --git a/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift b/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift index 8cdadbbf11..9fe8201f0a 100644 --- a/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift +++ b/Tests/CodexBarTests/MenuBarPercentWindowPreferenceTests.swift @@ -246,7 +246,8 @@ struct MenuBarPercentWindowPreferenceTests { @Test func `picker stays hidden unless the global style is icon and percent`() { let layout = MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) - let options = MenuBarPercentWindowPreference.allCases + // Monthly Plan keeps the picker visible in every style; MistralMonthlyPlanPickerTests covers it. + let options = MenuBarPercentWindowPreference.allCases.filter { $0 != .monthlyPlan } #expect(MenuBarPercentWindowPreference.isVisible( iconStyle: .iconAndPercent, @@ -270,7 +271,7 @@ struct MenuBarPercentWindowPreferenceTests { func `picker hides when session and weekly cannot apply`() { let layout = MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) let automaticOnly = MenuBarPercentWindowPreference.available( - metrics: ProviderMenuBarMetricCapabilities(supported: [.automatic, .monthlyPlan])) + metrics: ProviderMenuBarMetricCapabilities(supported: [.automatic, .extraUsage])) #expect(automaticOnly == [.automatic]) #expect(MenuBarPercentWindowPreference.isVisible( @@ -286,7 +287,7 @@ struct MenuBarPercentWindowPreferenceTests { @Test func `available options follow provider percent-window capabilities`() { let mistralLike = ProviderMenuBarMetricCapabilities(supported: [.automatic, .monthlyPlan]) - #expect(MenuBarPercentWindowPreference.available(metrics: mistralLike) == [.automatic]) + #expect(MenuBarPercentWindowPreference.available(metrics: mistralLike) == [.automatic, .monthlyPlan]) let sessionOnlyPrimary = ProviderMenuBarMetricCapabilities(supported: [.automatic, .primary]) #expect(MenuBarPercentWindowPreference.available(metrics: sessionOnlyPrimary) == [.automatic, .session]) @@ -298,7 +299,7 @@ struct MenuBarPercentWindowPreferenceTests { #expect(MenuBarPercentWindowPreference.available( metrics: .standard) == [.automatic, .session, .weekly]) - #expect(MenuBarPercentWindowPreference.available(for: .mistral) == [.automatic, .session]) + #expect(MenuBarPercentWindowPreference.available(for: .mistral) == [.automatic, .session, .monthlyPlan]) #expect(MenuBarPercentWindowPreference.available(for: .openrouter) == [.automatic, .session]) #expect(MenuBarPercentWindowPreference.available(for: .codex) == [.automatic, .session, .weekly]) #expect(MenuBarPercentWindowPreference.isVisible( diff --git a/Tests/CodexBarTests/MistralMonthlyPlanAvailabilityTests.swift b/Tests/CodexBarTests/MistralMonthlyPlanAvailabilityTests.swift new file mode 100644 index 0000000000..c8f6fea22e --- /dev/null +++ b/Tests/CodexBarTests/MistralMonthlyPlanAvailabilityTests.swift @@ -0,0 +1,17 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +struct MistralMonthlyPlanAvailabilityTests { + @Test + func `the existing Monthly Plan capability remains reachable in every icon style`() { + let layout = MenuBarLayout(lines: [[.icon]]) + let options = MenuBarPercentWindowPreference.available(for: .mistral, layout: layout) + #expect(options.map(\.rawValue) == ["automatic", "monthlyPlan"]) + for style in [MenuBarIconStyle.critters, .bars, .iconAndPercent] { + #expect(MenuBarPercentWindowPreference.isVisible( + iconStyle: style, layout: layout, provider: .mistral)) + } + } +} diff --git a/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift new file mode 100644 index 0000000000..b76cdeea10 --- /dev/null +++ b/Tests/CodexBarTests/MistralMonthlyPlanPickerTests.swift @@ -0,0 +1,122 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MistralMonthlyPlanPickerTests { + @Test + func `menu bar metric picker stores Monthly Plan for the menu bar and widgets`() { + let settings = testSettingsStore( + suiteName: "MistralMonthlyPlanPickerTests", + userDefaults: InMemoryUserDefaults()) + let view = ProviderMenuBarPercentWindowSettingsView(provider: .mistral, settings: settings) + view.layoutBinding.wrappedValue = MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) + let picker = ProviderMenuBarPercentWindowPicker( + provider: .mistral, + iconStyle: .iconAndPercent, + layout: view.layoutBinding, + metric: view.metricBinding) + #expect(MenuBarPercentWindowPreference.monthlyPlan.label(for: .mistral) == "Monthly Plan") + #expect(picker.selectionBinding.wrappedValue == .automatic) + + picker.selectionBinding.wrappedValue = .monthlyPlan + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + #expect(settings.menuBarLayout(for: .mistral).lines == [[.icon, .percent(window: .automatic)]]) + #expect(picker.selectionBinding.wrappedValue == .monthlyPlan) + + picker.selectionBinding.wrappedValue = .session + #expect(settings.menuBarMetricPreference(for: .mistral) == .automatic) + #expect(settings.menuBarLayout(for: .mistral).lines == [[.icon, .percent(window: .session)]]) + #expect(picker.selectionBinding.wrappedValue == .session) + + picker.selectionBinding.wrappedValue = .monthlyPlan + picker.selectionBinding.wrappedValue = .automatic + #expect(settings.menuBarMetricPreference(for: .mistral) == .automatic) + #expect(picker.selectionBinding.wrappedValue == .automatic) + } + + @Test + func `Monthly Plan stays reachable when the menu bar hides percentages`() { + let settings = testSettingsStore( + suiteName: "MistralMonthlyPlanPickerTests-critters", + userDefaults: InMemoryUserDefaults()) + settings.menuBarIconStyle = .critters + settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon]]), for: nil) + let view = ProviderMenuBarPercentWindowSettingsView(provider: .mistral, settings: settings) + let picker = ProviderMenuBarPercentWindowPicker( + provider: .mistral, + iconStyle: settings.menuBarIconStyle, + layout: view.layoutBinding, + metric: view.metricBinding) + #expect(MenuBarPercentWindowPreference.isVisible( + iconStyle: .critters, + layout: settings.menuBarLayout(for: .mistral), + provider: .mistral)) + #expect(!MenuBarPercentWindowPreference.isVisible( + iconStyle: .critters, + layout: settings.menuBarLayout(for: .codex), + provider: .codex)) + + // Without a percentage, only the metric-backed choices apply. + #expect(MenuBarPercentWindowPreference.available( + for: .mistral, + layout: settings.menuBarLayout(for: .mistral)) == [.automatic, .monthlyPlan]) + #expect(picker.selectionBinding.wrappedValue == .automatic) + + picker.selectionBinding.wrappedValue = .monthlyPlan + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + #expect(picker.selectionBinding.wrappedValue == .monthlyPlan) + + picker.selectionBinding.wrappedValue = .session + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + + picker.selectionBinding.wrappedValue = .automatic + #expect(settings.menuBarMetricPreference(for: .mistral) == .automatic) + #expect(picker.selectionBinding.wrappedValue == .automatic) + #expect(settings.menuBarLayoutOverrides[.mistral] == nil) + } + + @Test + func `explicit Monthly Plan selection pins a percentage layout against later global edits`() { + let settings = testSettingsStore( + suiteName: "MistralMonthlyPlanPickerTests-global", + userDefaults: InMemoryUserDefaults()) + settings.menuBarIconStyle = .iconAndPercent + let automatic = MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) + settings.setMenuBarLayout(automatic, for: nil) + let view = ProviderMenuBarPercentWindowSettingsView(provider: .mistral, settings: settings) + let picker = ProviderMenuBarPercentWindowPicker( + provider: .mistral, + iconStyle: .iconAndPercent, + layout: view.layoutBinding, + metric: view.metricBinding) + #expect(settings.menuBarLayoutOverrides[.mistral] == nil) + + picker.selectionBinding.wrappedValue = .monthlyPlan + #expect(settings.menuBarLayoutOverrides[.mistral] == automatic) + settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon, .percent(window: .session)]]), for: nil) + #expect(settings.menuBarLayout(for: .mistral) == automatic) + #expect(picker.selectionBinding.wrappedValue == .monthlyPlan) + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + } + + @Test + func `reading the picker preserves an existing Monthly Plan widget preference`() { + let settings = testSettingsStore( + suiteName: "MistralMonthlyPlanPickerTests-saved", + userDefaults: InMemoryUserDefaults()) + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) + let includedAPI = MenuBarLayout(lines: [[.icon, .percent(window: .session)]]) + settings.setMenuBarLayout(includedAPI, for: .mistral) + let view = ProviderMenuBarPercentWindowSettingsView(provider: .mistral, settings: settings) + let picker = ProviderMenuBarPercentWindowPicker( + provider: .mistral, + iconStyle: .iconAndPercent, + layout: view.layoutBinding, + metric: view.metricBinding) + #expect(picker.selectionBinding.wrappedValue == .session) + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + #expect(settings.menuBarLayout(for: .mistral) == includedAPI) + } +} diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index 1ea4478260..0b4ed427f4 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -47,6 +47,76 @@ struct MistralUsageParserTests { #expect(snapshot.totalCost > 0) } + @Test(arguments: [false, true]) + func `prices entries by event type zone and tier instead of the last matching metric`(reversed: Bool) throws { + // Trimmed from a real September 2026 response: the price table lists mistral-medium-3-5 input once per + // zone and tier and then again as a per-second audio price, which is 100x the token price. + let entry = { (group: String, value: Int) in + """ + {"usage_type":"vibe","event_type":"api_tokens","billing_metric":"mistral-medium-3-5",\ + "billing_display_name":"mistral-vibe-cli-latest","billing_group":"\(group)","timestamp":"2026-09-16",\ + "value":\(value),"value_paid":\(value),"api_zone":"global","service_tier":"standard"} + """ + } + let price = { (event: String, group: String, zone: String, tier: String, price: String) in + """ + {"event_type":"\(event)","billing_metric":"mistral-medium-3-5","billing_group":"\(group)",\ + "api_zone":"\(zone)","service_tier":"\(tier)","price":"\(price)"} + """ + } + var prices = [ + price("api_tokens", "input", "global", "standard", "0.0000012750"), + price("api_tokens", "input", "eu", "priority", "0.0000023588"), + price("api_audio_seconds", "input", "global", "standard", "0.0001416667"), + price("api_tokens", "cached", "global", "standard", "1.275E-7"), + price("api_tokens", "cached", "eu", "priority", "2.359E-7"), + price("api_tokens", "output", "global", "standard", "0.0000063750"), + price("api_tokens", "output", "eu", "priority", "0.0000117938"), + ] + if reversed { prices.reverse() } + let json = """ + {"vibe_code":{"completion":{"models":{"mistral-vibe-cli-latest::mistral-medium-3-5":{\ + "input":[\(entry("input", 4_375_190))],"cached":[\(entry("cached", 21_628_160))],\ + "output":[\(entry("output", 458_774))]}}}},\ + "start_date":"2026-09-01T00:00:00Z","end_date":"2026-09-30T23:59:59Z","currency":"EUR",\ + "prices":[\(prices.joined(separator: ","))]} + """ + let updatedAt = try #require(ISO8601DateParser.parse("2026-09-27T12:00:00Z")) + + let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: updatedAt) + + let expected = 4_375_190 * 0.000001275 + 21_628_160 * 1.275e-7 + 458_774 * 0.000006375 + #expect(snapshot.totalInputTokens == 4_375_190) + #expect(snapshot.totalCachedTokens == 21_628_160) + #expect(snapshot.totalOutputTokens == 458_774) + #expect(abs(snapshot.totalCost - expected) < 1e-9) + let history = snapshot.toCostUsageTokenSnapshot(historyDays: 30) + #expect(abs((history.last30DaysCostUSD ?? 0) - expected) < 1e-9) + } + + @Test(arguments: ["legacy", "zone", "tier"]) + func `legacy prices match qualified usage without guessing a zone or tier`(dimension: String) throws { + let qualifier = switch dimension { + case "zone": #","api_zone":"eu","service_tier":"standard""# + case "tier": #","api_zone":"global","service_tier":"priority""# + default: "" + } + let json = """ + {"completion":{"models":{"fixture":{"input":[{ + "event_type":"api_tokens","billing_metric":"fixture","billing_group":"input", + "timestamp":"2026-09-16","value":100,"value_paid":40, + "api_zone":"global","service_tier":"standard" + }]}}},"prices":[{ + "event_type":"api_tokens","billing_metric":"fixture","billing_group":"input","price":"0.25" + \(qualifier) + }]} + """ + let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: Date()) + #expect(snapshot.totalInputTokens == 100) + #expect(snapshot.totalCost == (dimension == "legacy" ? 10 : 0)) + #expect(snapshot.daily.first?.cost == snapshot.totalCost) + } + @Test(arguments: ["NaN", "Infinity", "1e308"]) func `ignores prices that produce nonfinite costs`(price: String) async throws { let json = """ diff --git a/Tests/CodexBarTests/PiSessionCostRefreshReliabilityTests.swift b/Tests/CodexBarTests/PiSessionCostRefreshReliabilityTests.swift index e95f15ef79..c6dd50b735 100644 --- a/Tests/CodexBarTests/PiSessionCostRefreshReliabilityTests.swift +++ b/Tests/CodexBarTests/PiSessionCostRefreshReliabilityTests.swift @@ -8,7 +8,8 @@ struct PiSessionCostRefreshReliabilityTests { func `an incomplete catalog reprice retains the previous report until every source can be repriced`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } - let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) + // After the 2026-08-21 Sol repricing, so catalog rates apply instead of historical bundled rates. + let day = try env.makeLocalNoon(year: 2026, month: 9, day: 10) let contents = try env.jsonl([ self.row(env, day, input: 150_000, model: "gpt-5.6-sol"), ]) diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift index fb8ad69e2e..336c9f82ac 100644 --- a/Tests/CodexBarTests/PiSessionCostScannerTests.swift +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -729,6 +729,7 @@ struct PiSessionCostScannerTests { cachedInputTokens: 10, outputTokens: 5, cacheWriteInputTokens: 20, + pricingDate: day, modelsDevCacheRoot: env.cacheRoot) ?? 0 // Stale: writes folded into uncached input at 1× (pre-v5 behavior). let staleCost = CostUsagePricing.codexCostUSD( @@ -736,6 +737,7 @@ struct PiSessionCostScannerTests { inputTokens: 100, cachedInputTokens: 10, outputTokens: 5, + pricingDate: day, modelsDevCacheRoot: env.cacheRoot) ?? 0 #expect(abs(expectedCost - staleCost) > 0.000001) @@ -1053,12 +1055,12 @@ extension PiSessionCostScannerTests { #expect(cache.files.values.flatMap(\.entryUsages.keys).count == 4) } - @Test - func `pi scanner reprices unchanged files when catalog rates change`() throws { + @Test(arguments: [false, true]) + func `pi scanner updates catalog pricing while retaining historical rates`(historical: Bool) throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } - let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) + let day = try env.makeLocalNoon(year: 2026, month: historical ? 7 : 9, day: 10) let model = "gpt-5.6-sol" func assistant(at timestamp: Date) -> [String: Any] { [ @@ -1078,7 +1080,7 @@ extension PiSessionCostScannerTests { ] } _ = try env.writePiSessionFile( - relativePath: "2026-07-10T10-00-00-000Z_catalog-change.jsonl", + relativePath: "catalog-change.jsonl", contents: env.jsonl([ assistant(at: day.addingTimeInterval(-1)), assistant(at: day), @@ -1099,7 +1101,7 @@ extension PiSessionCostScannerTests { let firstCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let firstPricingKey = try #require(firstCache.pricingKey) #expect(firstReport.data.first?.totalTokens == 300_000) - #expect(abs((firstReport.data.first?.costUSD ?? 0) - 1.2) < 0.0000001) + #expect(abs((firstReport.data.first?.costUSD ?? 0) - (historical ? 1.5 : 1.2)) < 0.0000001) let secondCatalog = try Self.modelsDevCatalog(inputCostPerMillion: 8) #expect(ModelsDevCache.save( @@ -1122,9 +1124,9 @@ extension PiSessionCostScannerTests { let secondCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) #expect(secondCache.pricingKey != firstPricingKey) // Each 150K message stays below the 272K threshold. The 300K daily aggregate must be the - // sum of two short-context costs, proving the pricing change triggered a full-file reparse. + // sum of two short-context costs. Historical rows keep their dated rate across catalog refreshes. #expect(secondReport.data.first?.totalTokens == 300_000) - #expect(abs((secondReport.data.first?.costUSD ?? 0) - 2.4) < 0.0000001) + #expect(abs((secondReport.data.first?.costUSD ?? 0) - (historical ? 1.5 : 2.4)) < 0.0000001) } @Test diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 2cf24015a8..e588264c73 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1824,10 +1824,10 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuBarLayout.swift", - anchor: "ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation.primarySemanticWindow)", + anchor: "let presentation = ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation", expectedProviderIDs: ["codex"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "codex@3"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuBarLayoutEditor.swift", diff --git a/docs/antigravity.md b/docs/antigravity.md index 5fe62b8d57..b8f65b7411 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -343,6 +343,9 @@ five-hour duration. ## Local token history +Recognized Gemini 3.1 Pro product and effort aliases use the catalog’s preview-model rates while retaining their +recorded breakdown names. See [model pricing](model-pricing.md) for the supported aliases. + Local history reads only the existing recognized roots: `~/.gemini/antigravity-cli/conversations/*.db`, `~/.gemini/antigravity/*.db`, and `~/.gemini/antigravity/conversations/*.db`. `GEMINI_CLI_HOME` replaces `~/.gemini`. When SQLite discovery completes without any databases, the reader can use diff --git a/docs/codex.md b/docs/codex.md index 277f2c8935..e37b2a66a3 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -236,6 +236,8 @@ the local result and returns a nonzero exit code. See [CLI host reporting](cli.m - `~/.pi/agent/sessions/**/*.jsonl` - `~/.omp/agent/sessions/**/*.jsonl` - Scanner: + - Published model aliases resolve through the existing pricing canonicalizer. GPT-5.6 Sol estimates use the + rates applicable before or after August 21, 2026; see [model pricing](model-pricing.md). - Codex reserve telemetry uses the bundled GPT-5.6 Luna list-price estimate, including existing cached token rows. This estimates API-equivalent cost; it is not a charge for using a subscription reserve allowance. - Bundled `gpt-6-astra` pricing covers input, cache reads/writes, output, and the full-request long-context diff --git a/docs/mistral.md b/docs/mistral.md index 4b6ddb535d..2c75d2a349 100644 --- a/docs/mistral.md +++ b/docs/mistral.md @@ -50,8 +50,11 @@ For the console request, CodexBar forwards only the `csrftoken` and `ory_session - **Included API** shows the subscription allowance's used percentage, used / total / remaining amount, and reset time. - The optional **Monthly Plan** window shows the separate Vibe Code allowance with the same details. -- API spend is computed from billed units (`value_paid`, falling back to `value`) and the pricing table. Token totals +- API spend is computed from billed units (`value_paid`, falling back to `value`) and the pricing table. Each unit takes + the price with the same event type, metric, group, API zone, and service tier; the table lists one metric under + several of these, and audio-second and priority prices are far higher than standard token prices. Token totals and daily buckets use consumed units (`value`, falling back to `value_paid`), so plan-covered usage still counts. + Legacy tables that omit both API zone and service tier use the unqualified price for the same event type, metric, and group. - Token totals include API completions, Le Chat, and Vibe Code completions from the billing usage response. - Daily usage buckets feed the inline usage dashboard. - The provider card can show credit balance when the credits endpoint returns it. @@ -65,7 +68,10 @@ For the console request, CodexBar forwards only the `csrftoken` and `ory_session ## Widgets -Usage widgets follow Mistral's menu bar metric preference: +Usage widgets follow the **Menu bar metric** picker in Mistral's provider settings. The picker appears in every menu bar +style, so Critters and Meter bars users can still pick the widget allowance. Choosing a percentage metric pins +Mistral’s layout against later global layout edits. Without a percentage, the picker changes only the stored metric +and keeps following the global layout: - **Automatic** and **Included API** show only the API allowance, preserving the existing default. - **Monthly Plan** shows only the Vibe allowance, falling back to Included API when the plan is missing or unknown. diff --git a/docs/model-pricing.md b/docs/model-pricing.md index 9036ad7361..d467f2c0b4 100644 --- a/docs/model-pricing.md +++ b/docs/model-pricing.md @@ -39,8 +39,14 @@ Local cost scanners preserve that scope when selecting a catalog: - Other bare Claude-session IDs are priced only when exactly one selected first-party catalog matches. Ambiguous cross-vendor matches remain unpriced. - Provider-qualified Claude-session IDs stay on an approved explicit route and never fall through to another vendor. - Claude's [documented `k3[1m]` alias](https://www.kimi.com/code/docs/en/third-party-tools/claude-code.html) resolves to `kimi-for-coding/k3` after exact-row lookup, including the existing `kimi-coding/` and `kimi-for-coding/` routes. Recorded model names stay unchanged; other context variants and paid Moonshot routes are not inferred. Catalog zero rates remain known estimates, not a claim that subscriptions or extra usage are free. +- OpenAI's [Daybreak aliases](https://developers.openai.com/api/docs/pricing) resolve like the unsuffixed `gpt-5.6` alias: `gpt-daybreak-blue-latest` prices as `gpt-5.6-sol` and `gpt-daybreak-red-latest` as `gpt-5.6-cyber`. Native usage rows retain raw model evidence; Codex aggregate model IDs follow the canonicalizer. +- Antigravity's Gemini 3.1 Pro aliases (`gemini-pro-default`, `gemini-pro-agent`, and the `gemini-3.1-pro` effort tiers) price as `gemini-3.1-pro-preview`, the only catalogued Gemini 3.1 Pro row. The alias is provider-local; recorded model names stay unchanged. - Vertex AI Claude logs: models.dev provider id `google-vertex-anthropic` +Dated Codex usage retains the prior bundled GPT-5.6 Sol rates before **2026-08-21 UTC**, the repricing date in the +[OpenAI changelog](https://developers.openai.com/api/docs/changelog). Current and undated usage use the published +current rates. Terra and Luna retain their separate July 30 cutoff. Custom-pricing overlays retain precedence. + ### Explicit provider identity in OpenCodex OpenCodex estimates use the recorded provider and model together. An unqualified model on `opencode-go` From dd157920e153fb2ad9cda4b8acf77791411ae562 Mon Sep 17 00:00:00 2001 From: B Klug Date: Mon, 28 Sep 2026 14:26:04 -0500 Subject: [PATCH 071/122] proof: exact-head packaged-app before/after on the reporting Mac Official 0.68.0 vs this branch's memo, 30 minutes each, back to back: spctl runs 14 -> 10, syspolicyd CPU 50.2 s -> 33.4 s, on a cold-cache Intel Mac where most assessments hit the existing 5 s timeout. Log only; no code change. Co-Authored-By: Claude Opus 5.5 --- .../codex-gatekeeper-assessment-memo.log | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/pr-proof/codex-gatekeeper-assessment-memo.log b/.github/pr-proof/codex-gatekeeper-assessment-memo.log index f328be9178..5c6534511d 100644 --- a/.github/pr-proof/codex-gatekeeper-assessment-memo.log +++ b/.github/pr-proof/codex-gatekeeper-assessment-memo.log @@ -56,3 +56,21 @@ remembered entry is found and before it is returned. The remembered "allowed" for codex did not reach the decision for the ad-hoc binary: the path re-check before answering saw a different file and ran a fresh, unshared assessment instead. + +Exact-head packaged app, before and after (2026-09-28, 13:18-14:24, back to back, 30 minutes each). +Same Mac and codex binary. CODEX_CLI_PATH not in effect (pointed at a nonexistent file, which both +resolvers ignore, so the normal lookup and preflight run). CodexBar's child processes sampled every +second; syspolicyd CPU from ps; the spctl counts match the unified log (process == "spctl") exactly. + + spctl runs at launch 5 refreshes after syspolicyd CPU + official 0.68.0 (Developer ID) 14 4 10, a pair on each 50.2 s + this PR (0.68.1 build 160, ad-hoc, 10 2 8, none on one 33.4 s + memo/preflight/test files as this head) + +This Mac was under memory pressure (32 GB RAM, 8 GB of swap in use), so the 281 MB binary was +usually evicted between 5-minute refreshes. The first assessment of a refresh then ran into the +existing 5 s timeout (spctl killed at 5.0 s) and, as on main, was not remembered; the second, on a +warmer file, often finished in 3-4 s. On main that verdict is discarded. With the memo it is kept: +the refresh at +20:08 launched codex with no spctl at all. At launch, main ran three overlapping +assessments of the same file; with the memo, lookups that overlapped shared one assessment. +The saving here is in how many assessments run, not in what one costs. From ee6a89d903afc38997c1b63468daee86b2a2021f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 14:03:17 -0700 Subject: [PATCH 072/122] fix(security): redact remaining stored process environments (#4106) Security follow-up to #4097: every remaining stored process-environment dictionary (app, CLI, provider contexts, session scanners, optional environments) now uses the redacting ProcessEnvironment wrapper, so reflection, descriptions, dumps, and Swift Testing expansions can never print environment values; a repository guard test rejects new unredacted stored environment properties. Net -9 production lines. --- CHANGELOG.md | 4 + .../CodexAccountPromotionPreparation.swift | 2 +- .../CodexAccountPromotionService.swift | 2 +- .../Providers/Codex/CodexSettingsStore.swift | 2 +- .../Kilo/KiloProviderImplementation.swift | 2 +- .../Providers/Shared/ProviderContext.swift | 2 +- Sources/CodexBar/UsageStore+ClaudeDebug.swift | 2 +- .../UsageStore+CodexCostCatchUp.swift | 2 +- Sources/CodexBar/UsageStore+Refresh.swift | 2 +- Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- Sources/CodexBarCore/AgentSession.swift | 2 +- Sources/CodexBarCore/CostUsageFetcher.swift | 2 +- .../Host/PTY/TTYCommandRunner.swift | 2 +- .../CodexBarCore/PiFamilySessionScanner.swift | 2 +- .../CodexBarCore/PiSessionCostScanner.swift | 2 +- .../PiSessionProcessContext.swift | 2 +- Sources/CodexBarCore/ProcessEnvironment.swift | 20 +++- .../AlibabaTokenPlanUsageFetcher.swift | 2 +- .../Antigravity/AntigravityLocalScan.swift | 2 +- .../AntigravityRemoteUsageFetcher.swift | 2 +- .../Providers/Claude/ClaudeCLISession.swift | 4 +- .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 2 +- ...audeOAuthDelegatedRefreshCoordinator.swift | 2 +- .../Providers/Claude/ClaudeStatusProbe.swift | 2 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 107 +++++++----------- .../Codex/CodexAccountReconciliation.swift | 2 +- .../Providers/Codex/CodexCLISession.swift | 4 +- .../Providers/Codex/CodexStatusProbe.swift | 2 +- .../MiniMax/MiniMaxProviderDescriptor.swift | 2 +- .../MiniMax/MiniMaxUsageFetcher.swift | 2 +- .../QwenCloudTokenPlanAPIClient.swift | 2 +- .../ProcessEnvironmentStorageTests.swift | 89 +++++++++++++++ .../ProcessEnvironmentTests.swift | 66 ++++++++++- docs/DEVELOPMENT.md | 17 ++- 35 files changed, 260 insertions(+), 105 deletions(-) create mode 100644 Tests/CodexBarTests/ProcessEnvironmentStorageTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e07fcf00fa..f8f9ef5df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.69.1 — Unreleased +### Security + +- Redact every remaining stored process environment in the app, CLI, provider contexts, and session scanners, and guard against new unredacted environment properties with a repository check (#4106). + ## 0.69.0 — 2026-09-28 ### Highlights diff --git a/Sources/CodexBar/CodexAccountPromotionPreparation.swift b/Sources/CodexBar/CodexAccountPromotionPreparation.swift index 270ef00b6c..5c1ac180b3 100644 --- a/Sources/CodexBar/CodexAccountPromotionPreparation.swift +++ b/Sources/CodexBar/CodexAccountPromotionPreparation.swift @@ -101,7 +101,7 @@ struct PreparedPromotionContextBuilder { let workspaceResolver: any ManagedCodexWorkspaceResolving let snapshotLoader: any CodexAccountReconciliationSnapshotLoading let authMaterialReader: any CodexAuthMaterialReading - let baseEnvironment: [String: String] + @ProcessEnvironment private(set) var baseEnvironment: [String: String] let fileManager: FileManager func build(targetID: UUID) async throws -> PreparedPromotionContext { diff --git a/Sources/CodexBar/CodexAccountPromotionService.swift b/Sources/CodexBar/CodexAccountPromotionService.swift index db7ecd96a6..4165cc1d31 100644 --- a/Sources/CodexBar/CodexAccountPromotionService.swift +++ b/Sources/CodexBar/CodexAccountPromotionService.swift @@ -108,7 +108,7 @@ final class CodexAccountPromotionService { private let activeSourceWriter: any CodexActiveSourceWriting private let accountScopedRefresher: any CodexAccountScopedRefreshing private let daemon: CodexAppServerDaemon - private let baseEnvironment: [String: String] + @ProcessEnvironment private var baseEnvironment: [String: String] private let fileManager: FileManager init( diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 09ed67aa62..3af4d6ad29 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -463,7 +463,7 @@ private enum CodexManagedRemoteHomeTestingOverride { var unreadableStore: Bool = false var managedStoreURL: URL? var liveSystemAccount: ObservedSystemCodexAccount? - var reconciliationEnvironment: [String: String]? + @ProcessEnvironment var reconciliationEnvironment: [String: String]? var isEmpty: Bool { self.account == nil && self.homePath == nil && self.unreadableStore == false && self diff --git a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift index dbe9bd07ca..f9e8600baf 100644 --- a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift @@ -3,7 +3,7 @@ import Foundation struct KiloProviderImplementation: ProviderImplementation { let id: UsageProvider = .kilo - private let environment: [String: String]? + @ProcessEnvironment private var environment: [String: String]? private let fetchOrganizations: @Sendable (String) async throws -> [KiloOrganization] init( diff --git a/Sources/CodexBar/Providers/Shared/ProviderContext.swift b/Sources/CodexBar/Providers/Shared/ProviderContext.swift index 57493fe86b..434e67b015 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderContext.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderContext.swift @@ -11,7 +11,7 @@ struct ProviderPresentationContext { struct ProviderAvailabilityContext { let provider: UsageProvider let settings: SettingsStore - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] } struct ProviderSourceLabelContext { diff --git a/Sources/CodexBar/UsageStore+ClaudeDebug.swift b/Sources/CodexBar/UsageStore+ClaudeDebug.swift index 84f4588c32..3b11fdf458 100644 --- a/Sources/CodexBar/UsageStore+ClaudeDebug.swift +++ b/Sources/CodexBar/UsageStore+ClaudeDebug.swift @@ -13,7 +13,7 @@ extension UsageStore { struct ClaudeDebugLogConfiguration { let runtime: CodexBarCore.ProviderRuntime let sourceMode: ProviderSourceMode - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let webExtrasEnabled: Bool let usageDataSource: ClaudeUsageDataSource let cookieSource: ProviderCookieSource diff --git a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift index 2a51e46230..64b8e9d229 100644 --- a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift @@ -9,7 +9,7 @@ private struct CodexCostCatchUpContext { let providerConfigRevision: UInt64 let costUsageSettingsRevision: UInt64 let includePiSessions: Bool - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let piHistoryScopeGeneration: UInt64 } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 5dfe4c3acf..8b0336990b 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -33,7 +33,7 @@ extension UsageStore { private struct ClaudeRefreshReconciliationInput { let provider: UsageProvider let outcome: ProviderFetchOutcome - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let dataSource: ClaudeUsageDataSource? let priorSourceLabel: String? let beforeFetch: ClaudeRefreshAuthState? diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d022a7307c..dc9ef7292e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -344,7 +344,7 @@ final class UsageStore { @ObservationIgnored let browserDetection: BrowserDetection @ObservationIgnored private let registry: ProviderRegistry @ObservationIgnored let settings: SettingsStore - @ObservationIgnored let environmentBase: [String: String] + @ObservationIgnored @ProcessEnvironment private(set) var environmentBase: [String: String] @ObservationIgnored let pluginApprovalStore: ProviderPluginApprovalStore @ObservationIgnored let sessionQuotaNotifier: any SessionQuotaNotifying @ObservationIgnored let sessionQuotaLogger = CodexBarLog.logger(LogCategories.sessionQuota) diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 178040af9c..e062be4d22 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -55,7 +55,7 @@ struct TokenAccountCLIContext { let selection: TokenAccountCLISelection let config: CodexBarConfig let accountsByProvider: [UsageProvider: ProviderTokenAccountData] - private let baseEnvironment: [String: String] + @ProcessEnvironment private var baseEnvironment: [String: String] private let managedCodexAccountStoreURL: URL? init( diff --git a/Sources/CodexBarCore/AgentSession.swift b/Sources/CodexBarCore/AgentSession.swift index b04038f946..bba7950564 100644 --- a/Sources/CodexBarCore/AgentSession.swift +++ b/Sources/CodexBarCore/AgentSession.swift @@ -200,7 +200,7 @@ public struct AgentProcessRecord: Equatable, Sendable { /// Original argv when the platform exposes it. `command` remains the portable fallback. public let arguments: [String]? /// Only Pi root selectors; nil means unavailable and an empty map means a known empty selection. - public let piSelectorEnvironment: [String: String]? + @ProcessEnvironment public private(set) var piSelectorEnvironment: [String: String]? public init( pid: Int32, diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 567badaf4c..f6f69e31d5 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -819,7 +819,7 @@ public struct CostUsageFetcher: Sendable { let includePiSessions: Bool let shouldMergePiUsage: Bool let scanOptions: CostUsageScanner.Options - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let piOptions: PiSessionCostScanner.Options let reportContext: CostUsageReportContext? } diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index 92f33d3120..da14d0d5af 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -302,7 +302,7 @@ public struct TTYCommandRunner { public var idleTimeout: TimeInterval? public var workingDirectory: URL? public var extraArgs: [String] = [] - public var baseEnvironment: [String: String]? + @ProcessEnvironment public var baseEnvironment: [String: String]? public var initialDelay: TimeInterval = 0.4 public var sendEnterEvery: TimeInterval? public var sendOnSubstrings: [String: String] diff --git a/Sources/CodexBarCore/PiFamilySessionScanner.swift b/Sources/CodexBarCore/PiFamilySessionScanner.swift index 4508823442..5d4d3b7a94 100644 --- a/Sources/CodexBarCore/PiFamilySessionScanner.swift +++ b/Sources/CodexBarCore/PiFamilySessionScanner.swift @@ -157,7 +157,7 @@ struct PiFamilySessionScanner: Sendable { struct ScanInput: Sendable { let processes: [AgentProcessRecord] let cwdByPID: [Int32: String] - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let now: Date let host: String let config: SessionScanConfig diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index e69c2290d8..6b449351d1 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -27,7 +27,7 @@ enum PiSessionCostScanner { var calendar: Calendar var refreshMinIntervalSeconds: TimeInterval = 60 var forceRescan: Bool = false - var environment: [String: String] + @ProcessEnvironment var environment: [String: String] var workingDirectory: URL? var workingDirectories: [URL] var processContexts: [PiSessionProcessContext] diff --git a/Sources/CodexBarCore/PiSessionProcessContext.swift b/Sources/CodexBarCore/PiSessionProcessContext.swift index 77a9b1e565..1eaf5a7c63 100644 --- a/Sources/CodexBarCore/PiSessionProcessContext.swift +++ b/Sources/CodexBarCore/PiSessionProcessContext.swift @@ -8,7 +8,7 @@ public struct PiSessionProcessContext: Equatable, Sendable { /// The process CWD, when it could be read. An absolute `--session-dir` remains resolvable when this is nil. public let workingDirectory: URL? /// Captured Pi root selectors only. Missing evidence must never inherit the scanner's environment. - public let selectorEnvironment: [String: String]? + @ProcessEnvironment public private(set) var selectorEnvironment: [String: String]? public init( command: String, diff --git a/Sources/CodexBarCore/ProcessEnvironment.swift b/Sources/CodexBarCore/ProcessEnvironment.swift index 1ce5e7d9cd..58ec3ece27 100644 --- a/Sources/CodexBarCore/ProcessEnvironment.swift +++ b/Sources/CodexBarCore/ProcessEnvironment.swift @@ -1,14 +1,20 @@ /// Retains environment values for execution while keeping automatic diagnostics count-only. @propertyWrapper -public struct ProcessEnvironment: Sendable, CustomReflectable, CustomStringConvertible, CustomDebugStringConvertible { - public var wrappedValue: [String: String] +public struct ProcessEnvironment: Sendable, Equatable, + CustomReflectable, CustomStringConvertible, CustomDebugStringConvertible +{ + public var wrappedValue: Value - public init(wrappedValue: [String: String]) { + public init(wrappedValue: Value) where Value == [String: String] { + self.wrappedValue = wrappedValue + } + + public init(wrappedValue: Value) where Value == [String: String]? { self.wrappedValue = wrappedValue } public var description: String { - "ProcessEnvironment(\(self.wrappedValue.count) entries; redacted)" + "ProcessEnvironment(\(self.entryCount) entries; redacted)" } public var debugDescription: String { @@ -16,6 +22,10 @@ public struct ProcessEnvironment: Sendable, CustomReflectable, CustomStringConve } public var customMirror: Mirror { - Mirror(self, children: ["entryCount": self.wrappedValue.count], displayStyle: .struct) + Mirror(self, children: ["entryCount": self.entryCount], displayStyle: .struct) + } + + private var entryCount: Int { + (self.wrappedValue as? [String: String])?.count ?? 0 } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 3d2d0d57c9..bc06c2955d 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -37,7 +37,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { let apiCookieHeader: String let secToken: String? let region: AlibabaTokenPlanAPIRegion - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let now: Date let session: URLSession } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalScan.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalScan.swift index 326b700a9b..0ceac13449 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalScan.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalScan.swift @@ -3,7 +3,7 @@ import Foundation extension AntigravityLocalReader { struct Context: Sendable { let home: URL - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] init(environment: [String: String]) { self.environment = environment diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift index b33b31f27f..a22055ee90 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift @@ -26,7 +26,7 @@ public enum AntigravityRemoteFetchError: LocalizedError, Sendable, Equatable { public struct AntigravityRemoteUsageFetcher: Sendable { public var timeout: TimeInterval = 10.0 public var homeDirectory: String - public var environment: [String: String] + @ProcessEnvironment public var environment: [String: String] public var dataLoader: @Sendable (URLRequest) async throws -> (Data, URLResponse) public var oauthClientResolver: @Sendable () -> AntigravityOAuthClient? public var credentialsUpdateHandler: @Sendable (AntigravityOAuthCredentials) async throws -> Void diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index e121591a94..5f511f7480 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -52,7 +52,7 @@ actor ClaudeCLISession { private struct SessionIdentity: Equatable { let binaryPath: String let accountScope: String? - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] } private struct CaptureRequest { @@ -60,7 +60,7 @@ actor ClaudeCLISession { let binary: String let accountScope: String? let timeout: TimeInterval - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let idleTimeout: TimeInterval? let stopOnSubstrings: [String] let stopWhenNormalized: (@Sendable (String) -> Bool)? diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 7464dfffb7..33d1c13f46 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -1346,7 +1346,7 @@ public enum ClaudeOAuthCredentialsStore { private struct Refresher { let context: CollaboratorContext let profileIdentifier: String - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] func refreshAccessToken( refreshToken: String, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift index 93ca7d6486..2a01e3abab 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift @@ -115,7 +115,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { } private struct AttemptConfiguration { - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let profileIdentifier: String let interaction: ProviderInteraction let readStrategy: ClaudeOAuthKeychainReadStrategy diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index 5a8c5b20df..c790bc82f2 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -81,7 +81,7 @@ public struct ClaudeStatusProbe: Sendable { public var claudeBinary: String = "claude" public var timeout: TimeInterval = 20.0 public var keepCLISessionsAlive: Bool = false - public var environment: [String: String] = ProcessInfo.processInfo.environment + @ProcessEnvironment public var environment: [String: String] = ProcessInfo.processInfo.environment // Claude's interactive process binds account state at launch. Cross-refresh reuse is permitted only because the // session actor also requires the hashed config-root + active-account scope to match. static let accountScopedSessionReuseEnabled = true diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index d6b297adc1..1d31610fa0 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -139,42 +139,18 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { ProcessInfo.processInfo.environment["CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW"] == "1" } - private var environment: [String: String] { - self.configuration.environment - } - - private var runtime: ProviderRuntime { - self.configuration.runtime - } - - private var dataSource: ClaudeUsageDataSource { - self.configuration.dataSource - } - - private var oauthKeychainPromptCooldownEnabled: Bool { - self.configuration.oauthKeychainPromptCooldownEnabled - } - private var oauthSafeCredentialSourcesOnly: Bool { - self.dataSource == .auto || self.configuration.oauthSafeCredentialSourcesOnly - } - - private var preserveInvalidOAuthCache: Bool { - self.configuration.preserveInvalidOAuthCache + self.configuration.dataSource == .auto || self.configuration.oauthSafeCredentialSourcesOnly } private var allowsDelegatedOAuthRefresh: Bool { - self.runtime == .app + self.configuration.runtime == .app } private var allowBackgroundDelegatedRefresh: Bool { self.configuration.allowBackgroundDelegatedRefresh } - private var useWebExtras: Bool { - self.configuration.useWebExtras - } - private var manualCookieHeader: String? { self.configuration.manualCookieHeader } @@ -334,18 +310,18 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { do { let promptPolicy = ClaudeUsageFetcher.currentClaudeOAuthInteractivePromptPolicy() let credentialRecord = try await ClaudeUsageFetcher.loadOAuthCredentialRecord( - environment: self.fetcher.environment, + environment: self.fetcher.configuration.environment, allowKeychainPrompt: false, respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown, safeCredentialSourcesOnly: self.fetcher.oauthSafeCredentialSourcesOnly, - clearInvalidCache: !self.fetcher.preserveInvalidOAuthCache) + clearInvalidCache: !self.fetcher.configuration.preserveInvalidOAuthCache) let credentials = credentialRecord.credentials try self.validateRequiredOAuthScope(credentials) let usage = try await ClaudeUsageFetcher.fetchOAuthUsage( accessToken: credentials.accessToken, - detectClaudeVersion: self.fetcher.runtime == .app, - environment: self.fetcher.environment) + detectClaudeVersion: self.fetcher.configuration.runtime == .app, + environment: self.fetcher.configuration.environment) // History is scoped by the credential's one-way owner identifier. Do not compare the winning // credential with Claude Code's foreign Keychain item after a successful request. let keychainMatch: ClaudeKeychainCredentialMatch = credentialRecord.owner == .claudeCLI @@ -388,8 +364,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { // Explicit OAuth is an authority boundary. Retain a credential that reached the // service but failed so a later retry cannot reinterpret it as absence and fall // through to the ambient CLI. Auto retains its existing invalidation behavior. - if !self.fetcher.preserveInvalidOAuthCache { - ClaudeOAuthCredentialsStore.invalidateCache(environment: self.fetcher.environment) + if !self.fetcher.configuration.preserveInvalidOAuthCache { + ClaudeOAuthCredentialsStore.invalidateCache(environment: self.fetcher.configuration.environment) } if case let .serverError(statusCode, body) = error, statusCode == 403, @@ -424,7 +400,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { allowBackgroundDelegatedRefresh: self.fetcher.allowBackgroundDelegatedRefresh) let delegatedResult = await ClaudeUsageFetcher.attemptDelegatedRefresh( - environment: self.fetcher.environment) + environment: self.fetcher.configuration.environment) let delegatedOutcome = delegatedResult.outcome ClaudeUsageFetcher.log.info( "Claude OAuth delegated refresh attempted", @@ -434,7 +410,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { ]) do { - if self.fetcher.oauthKeychainPromptCooldownEnabled { + if self.fetcher.configuration.oauthKeychainPromptCooldownEnabled { switch delegatedOutcome { case .skippedByCooldown, .skippedByPromptPolicy, .cliUnavailable: throw ClaudeUsageError.oauthFailed( @@ -448,12 +424,12 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { try Task.checkCancellation() _ = ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged( - environment: self.fetcher.environment) + environment: self.fetcher.configuration.environment) let didSyncSilently = delegatedOutcome == .attemptedSucceeded && ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt( now: Date(), - environment: self.fetcher.environment) + environment: self.fetcher.configuration.environment) let promptPolicy = ClaudeUsageFetcher.currentClaudeOAuthInteractivePromptPolicy() ClaudeUsageFetcher.logDeferredBackgroundDelegatedRecoveryIfNeeded( @@ -465,7 +441,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { ClaudeUsageFetcher.log.debug( "Claude OAuth credential load (post-delegation retry start)", metadata: [ - "cooldownEnabled": "\(self.fetcher.oauthKeychainPromptCooldownEnabled)", + "cooldownEnabled": "\(self.fetcher.configuration.oauthKeychainPromptCooldownEnabled)", "didSyncSilently": "\(didSyncSilently)", "allowKeychainPrompt": "\(retryAllowKeychainPrompt)", "delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome), @@ -477,18 +453,18 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let refreshedRecord = try await ProviderRefreshRequestContext.withNewRequest { try await ClaudeUsageFetcher.loadOAuthCredentialRecord( - environment: self.fetcher.environment, + environment: self.fetcher.configuration.environment, allowKeychainPrompt: retryAllowKeychainPrompt, respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown, safeCredentialSourcesOnly: self.fetcher.oauthSafeCredentialSourcesOnly, - clearInvalidCache: !self.fetcher.preserveInvalidOAuthCache) + clearInvalidCache: !self.fetcher.configuration.preserveInvalidOAuthCache) } let refreshedCredentials = refreshedRecord.credentials if ClaudeUsageFetcher.isClaudeOAuthFlowDebugEnabled { ClaudeUsageFetcher.log.debug( "Claude OAuth credential load (post-delegation retry)", metadata: [ - "cooldownEnabled": "\(self.fetcher.oauthKeychainPromptCooldownEnabled)", + "cooldownEnabled": "\(self.fetcher.configuration.oauthKeychainPromptCooldownEnabled)", "didSyncSilently": "\(didSyncSilently)", "allowKeychainPrompt": "\(retryAllowKeychainPrompt)", "delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome), @@ -501,8 +477,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { try self.validateRequiredOAuthScope(refreshedCredentials) let usage = try await ClaudeUsageFetcher.fetchOAuthUsage( accessToken: refreshedCredentials.accessToken, - detectClaudeVersion: self.fetcher.runtime == .app, - environment: self.fetcher.environment) + detectClaudeVersion: self.fetcher.configuration.runtime == .app, + environment: self.fetcher.configuration.environment) let keychainMatch: ClaudeKeychainCredentialMatch = refreshedRecord.owner == .claudeCLI ? .unavailable : .notApplicable @@ -535,7 +511,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { "Claude OAuth post-delegation retry failed", metadata: ClaudeUsageFetcher.delegatedRetryFailureMetadata( error: error, - oauthKeychainPromptCooldownEnabled: self.fetcher.oauthKeychainPromptCooldownEnabled, + oauthKeychainPromptCooldownEnabled: self.fetcher.configuration + .oauthKeychainPromptCooldownEnabled, delegatedOutcome: delegatedOutcome)) throw ClaudeUsageFetcher.delegatedRefreshFailureError( for: delegatedResult, @@ -574,7 +551,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let fetcher: ClaudeUsageFetcher func loadLatestUsage(model: String) async throws -> ClaudeUsageSnapshot { - switch self.fetcher.dataSource { + switch self.fetcher.configuration.dataSource { case .auto: return try await self.executeAuto(model: model) case .api: @@ -624,15 +601,15 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } else { ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.fetcher.browserDetection) } - let hasCLI = ClaudeCLIResolver.isAvailable(environment: self.fetcher.environment) + let hasCLI = ClaudeCLIResolver.isAvailable(environment: self.fetcher.configuration.environment) return ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( - runtime: self.fetcher.runtime, + runtime: self.fetcher.configuration.runtime, selectedDataSource: .auto, - webExtrasEnabled: self.fetcher.useWebExtras, + webExtrasEnabled: self.fetcher.configuration.useWebExtras, hasWebSession: hasWebSession, hasCLI: hasCLI, // App Auto performs one real OAuth attempt; credential loading is execution, not planning. - hasOAuthCredentials: self.fetcher.runtime == .app)) + hasOAuthCredentials: self.fetcher.configuration.runtime == .app)) } private func logAutoPlan(_ plan: ClaudeFetchPlan) { @@ -640,7 +617,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { "plannerOrder": plan.orderLabel, "selected": plan.preferredStep?.dataSource.rawValue ?? "none", "noSourceAvailable": "\(plan.isNoSourceAvailable)", - "webExtrasEnabled": "\(self.fetcher.useWebExtras)", + "webExtrasEnabled": "\(self.fetcher.configuration.useWebExtras)", "oauthReadStrategy": ClaudeOAuthKeychainReadStrategyPreference.current().rawValue, ] for (index, step) in plan.orderedSteps.enumerated() { @@ -667,10 +644,11 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } private func loadViaAutoCLI(model: String) async throws -> ClaudeUsageSnapshot { - guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.fetcher.environment), - await ClaudeCLIAuthStatusProbe.isLoggedIn( - binary: binary, - environment: self.fetcher.environment) + guard let binary = ClaudeCLIResolver + .resolvedBinaryPath(environment: self.fetcher.configuration.environment), + await ClaudeCLIAuthStatusProbe.isLoggedIn( + binary: binary, + environment: self.fetcher.configuration.environment) else { throw ClaudeUsageError.parseFailed("Claude CLI is not logged in.") } @@ -862,7 +840,7 @@ extension ClaudeUsageFetcher { // MARK: - Public API public func detectVersion() -> String? { - ProviderVersionDetector.claudeVersion(environment: self.environment) + ProviderVersionDetector.claudeVersion(environment: self.configuration.environment) } public func debugRawProbe(model: String = "sonnet") async -> String { @@ -1312,26 +1290,28 @@ extension ClaudeUsageFetcher { // MARK: - PTY-based probe (no tmux) private func loadViaPTY(model: String, timeout: TimeInterval = 10) async throws -> ClaudeUsageSnapshot { - guard let claudeBinary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.environment) else { + guard let claudeBinary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.configuration.environment) + else { throw ClaudeUsageError.claudeNotInstalled } let probe = ClaudeStatusProbe( claudeBinary: claudeBinary, timeout: timeout, keepCLISessionsAlive: self.keepCLISessionsAlive, - environment: self.environment) + environment: self.configuration.environment) let snap = try await probe.fetch() return try Self.makeSnapshot(from: snap) } private func loadViaDirectCLI(timeout: TimeInterval) async throws -> ClaudeUsageSnapshot { - guard let claudeBinary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.environment) else { + guard let claudeBinary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.configuration.environment) + else { throw ClaudeUsageError.claudeNotInstalled } let workingDirectory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() - var environment = ClaudeCLISession.launchEnvironment(baseEnv: self.environment) + var environment = ClaudeCLISession.launchEnvironment(baseEnv: self.configuration.environment) environment["PWD"] = workingDirectory.path defer { ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( @@ -1408,7 +1388,8 @@ extension ClaudeUsageFetcher { to snapshot: ClaudeUsageSnapshot, oauthAccessToken: String? = nil) async throws -> ClaudeUsageSnapshot { - guard self.useWebExtras || self.includePrepaidBalance, self.dataSource != .web else { return snapshot } + guard self.configuration.useWebExtras || self.includePrepaidBalance, + self.configuration.dataSource != .web else { return snapshot } guard self.webExtrasTimeout.isFinite, self.webExtrasTimeout >= 0, self.webExtrasTimeout <= TimeInterval(Int64.max) @@ -1427,7 +1408,7 @@ extension ClaudeUsageFetcher { try await ClaudeWebAPIFetcher.fetchUsage( cookieHeader: header, targetOrganizationID: self.webOrganizationID, - includeUsageDetails: self.useWebExtras, + includeUsageDetails: self.configuration.useWebExtras, includePrepaidBalance: self.includePrepaidBalance) { msg in Self.log.debug(msg) @@ -1436,7 +1417,7 @@ extension ClaudeUsageFetcher { try await ClaudeWebAPIFetcher.fetchUsage( browserDetection: self.browserDetection, targetOrganizationID: self.webOrganizationID, - includeUsageDetails: self.useWebExtras, + includeUsageDetails: self.configuration.useWebExtras, includePrepaidBalance: self.includePrepaidBalance) { msg in Self.log.debug(msg) @@ -1459,7 +1440,7 @@ extension ClaudeUsageFetcher { return snapshot } // Only merge usage/cost extras; keep identity fields from the primary data source. - let mergedExtraRateWindows = self.useWebExtras + let mergedExtraRateWindows = self.configuration.useWebExtras ? Self.mergeExtraRateWindows( primary: snapshot.extraRateWindows, web: webData.extraRateWindows) @@ -1467,7 +1448,7 @@ extension ClaudeUsageFetcher { let mergedProviderCost = Self.mergeProviderCost( primary: snapshot.providerCost, web: webData.extraUsageCost, - includeUsageDetails: self.useWebExtras) + includeUsageDetails: self.configuration.useWebExtras) if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows { return snapshot.replacingWebExtras( extraRateWindows: mergedExtraRateWindows, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift index 5ac82dabef..ee028cae8a 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift @@ -218,7 +218,7 @@ public struct DefaultCodexAccountReconciler: Sendable { public let storeLoader: @Sendable () throws -> ManagedCodexAccountSet public let systemObserver: any CodexSystemAccountObserving public let activeSource: CodexActiveSource - public let baseEnvironment: [String: String] + @ProcessEnvironment public private(set) var baseEnvironment: [String: String] public let profileHomePaths: [String] public let managedEnvironmentBuilder: @Sendable ([String: String], ManagedCodexAccount) -> [String: String] diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift index ab3db3cf54..d56872303f 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift @@ -35,7 +35,7 @@ actor CodexCLISession { private var startedAt: Date? private var ptyRows: UInt16 = 0 private var ptyCols: UInt16 = 0 - private var sessionEnvironment: [String: String]? + @ProcessEnvironment private var sessionEnvironment: [String: String]? private var sessionArguments: [String] = [] private var sessionWorkingDirectory: URL? @@ -43,7 +43,7 @@ actor CodexCLISession { let timeout: TimeInterval let rows: UInt16 let cols: UInt16 - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let extraArgs: [String] let workingDirectory: URL? } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index a3cb8ee00f..8b0f075a53 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -65,7 +65,7 @@ public struct CodexStatusProbe { public var codexBinary: String = "codex" public var timeout: TimeInterval = Self.defaultTimeoutSeconds public var keepCLISessionsAlive: Bool = false - public var environment: [String: String] = ProcessInfo.processInfo.environment + @ProcessEnvironment public var environment: [String: String] = ProcessInfo.processInfo.environment public init() {} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index f8b144f7a4..49ad4d2528 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -327,7 +327,7 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { private struct FetchContext { let region: MiniMaxAPIRegion - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let includeBillingHistory: Bool } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index e9e49af4ed..a404ff599e 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -15,7 +15,7 @@ public struct MiniMaxUsageFetcher: Sendable { let cookie: String let authorizationToken: String? let region: MiniMaxAPIRegion - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let transport: any ProviderHTTPTransport } diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudTokenPlanAPIClient.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudTokenPlanAPIClient.swift index 1ef860d0f8..dcebf1d313 100644 --- a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudTokenPlanAPIClient.swift +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudTokenPlanAPIClient.swift @@ -16,7 +16,7 @@ struct QwenCloudTokenPlanAPIClient: Sendable { struct Context: Sendable { let secToken: String let secTokenSource: String - let environment: [String: String] + @ProcessEnvironment private(set) var environment: [String: String] let apiCookieHeader: String let dashboardURL: URL } diff --git a/Tests/CodexBarTests/ProcessEnvironmentStorageTests.swift b/Tests/CodexBarTests/ProcessEnvironmentStorageTests.swift new file mode 100644 index 0000000000..01d95b9f6c --- /dev/null +++ b/Tests/CodexBarTests/ProcessEnvironmentStorageTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing + +/// Lexical tripwire for environment dictionary declarations, including optional and multiline spellings. +/// It deliberately checks locals too: only exact, reviewed transient declarations may bypass storage protection. +struct ProcessEnvironmentStorageTests { + @Test + func `shipped environment dictionary storage uses the redacting wrapper`() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent() + // This dictionary exists only while constructing the hook's child process environment. + let transientLocals = ["Sources/CodexBarCore/Hooks/HookEvent.swift": "var env: [String: String] = ["] + var usedExceptions: Set = [] + for directory in ["Sources", "WidgetExtension"] { + let enumerator = try #require(FileManager.default.enumerator( + at: root.appendingPathComponent(directory), includingPropertiesForKeys: nil)) + for case let url as URL in enumerator where url.pathExtension == "swift" { + let path = String(url.path.dropFirst(root.path.count + 1)) + guard path != "Sources/CodexBarCore/ProcessEnvironment.swift" else { continue } + let source = try String(contentsOf: url, encoding: .utf8) + for declaration in try Self.unprotectedDeclarations(in: source) { + if transientLocals[path] == declaration { + #expect(usedExceptions.insert(path).inserted, "Duplicate transient exception: \(path)") + } else { + Issue.record("Unprotected environment storage: \(path): \(declaration)") + } + } + } + } + #expect(usedExceptions == Set(transientLocals.keys), "Remove stale transient exceptions") + } + + @Test + func `scanner recognizes storage spellings without flagging parameters or wrapped properties`() throws { + let source = """ + struct Example { + let environment: [String: String] + private var baseEnvironment: + [String: String]? + var env: Dictionary = [:] + @ProcessEnvironment private var protectedEnvironment: [String: String] + @ProcessEnvironment + public private(set) var anotherEnvironment: [String: String]? + var computedEnvironment: [String: String] { [:] } + var anotherComputedEnvironment: [String: String] + { [:] } + var observedEnvironment: [String: String] { didSet {} } + var initializedEnvironment: [String: String] = { [:] }() + lazy var lazyEnvironment: [String: String] = [:] + nonisolated(unsafe) static var sharedEnvironment: [String: String] = [:] + func run(environment: [String: String]) {} + } + """ + #expect(try Self.unprotectedDeclarations(in: source) == [ + "let environment: [String: String]", + "private var baseEnvironment: [String: String]?", + "var env: Dictionary = [:]", + "var observedEnvironment: [String: String] { didSet {} }", + "var initializedEnvironment: [String: String] = { [:] }()", + "lazy var lazyEnvironment: [String: String] = [:]", + "nonisolated(unsafe) static var sharedEnvironment: [String: String] = [:]", + ]) + } + + private static func unprotectedDeclarations(in source: String) throws -> [String] { + let pattern = #"(?m)^[\t ]*((?:@\w+(?:\([^\n]*\))?\s+)*"# + + #"(?:(?:public|private|internal|fileprivate|package|static|lazy|nonisolated|final)"# + + #"(?:\((?:set|unsafe)\))?\s+)*"# + + #"(?:let|var)\s+\w*[Ee]nv\w*\s*:\s*"# + + #"(?:\[\s*String\s*:\s*String\s*\]|Dictionary\s*<\s*String\s*,\s*String\s*>)\??[^\n]*)"# + let regex = try NSRegularExpression(pattern: pattern) + return regex.matches(in: source, range: NSRange(source.startIndex..., in: source)).compactMap { match in + guard let range = Range(match.range(at: 1), in: source) else { return nil } + let declaration = String(source[range]) + guard !declaration.contains("@ProcessEnvironment") else { return nil } + // Getters are transient; observers and initializer closures still have stored backing values. + if !declaration.contains("=") { + let body = declaration.firstIndex(of: "{").map { String(declaration[$0...]) } + ?? String(source[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + if body.hasPrefix("{"), + body.range(of: #"^\{\s*(?:didSet|willSet)\b"#, options: .regularExpression) == nil + { + return nil + } + } + return declaration.split(whereSeparator: \.isWhitespace).joined(separator: " ") + } + } +} diff --git a/Tests/CodexBarTests/ProcessEnvironmentTests.swift b/Tests/CodexBarTests/ProcessEnvironmentTests.swift index b7c78eb657..ae203c456b 100644 --- a/Tests/CodexBarTests/ProcessEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProcessEnvironmentTests.swift @@ -74,7 +74,71 @@ struct ProcessEnvironmentTests { fetcher: fetcher, claudeFetcher: claudeFetcher, browserDetection: browserDetection) - return [fetcher, claudeFetcher, context] + return [ + fetcher, claudeFetcher, context, + CodexStatusProbe(environment: environment), + ClaudeStatusProbe(environment: environment), + TTYCommandRunner.Options(baseEnvironment: environment), + CodexCLISession.CaptureOptions( + timeout: 1, rows: 1, cols: 1, environment: environment, extraArgs: [], workingDirectory: nil), + PiSessionCostScanner.Options(environment: environment), + PiSessionProcessContext( + command: "pi", workingDirectory: nil, selectorEnvironment: ["PI_PROFILE": Self.sentinel]), + AgentProcessRecord( + pid: 1, ppid: 0, startedAt: nil, command: "pi", piSelectorEnvironment: ["PI_PROFILE": Self.sentinel]), + DefaultCodexAccountReconciler(baseEnvironment: environment), + AntigravityRemoteUsageFetcher(homeDirectory: "/synthetic-home", environment: environment), + QwenCloudTokenPlanAPIClient.Context( + secToken: "", + secTokenSource: "fixture", + environment: environment, + apiCookieHeader: "", + dashboardURL: URL(string: "https://example.com")!), + MiniMaxUsageFetcher.WebFetchContext( + cookie: "", + authorizationToken: nil, + region: .global, + environment: environment, + transport: ProviderHTTPClient.shared), + ] + } + + @Test + func `optional environments preserve absence mutation and value equality`() { + var absent = OptionalConfiguration() + let empty = OptionalConfiguration(environment: [:]) + #expect(absent.environment == nil) + #expect(absent != empty) + #expect(absent == OptionalConfiguration()) + absent.environment = [Self.sentinelKey: Self.sentinel] + #expect(absent == OptionalConfiguration(environment: [Self.sentinelKey: Self.sentinel])) + var copy = absent + copy.environment?["ORDINARY_NAME"] = Self.sentinel + #expect(copy != absent) + #expect(absent.environment?.count == 1) + #expect(copy.environment?.count == 2) + #expect(String(describing: copy) == String(describing: OptionalConfiguration( + environment: ["unrelated": "value", "different": "contents"]))) + Self.expectMirrorRedacted(copy) + absent.environment = nil + #expect(absent == OptionalConfiguration()) + Self.expectMirrorRedacted(absent) + } + + @Test + func `optional wrapper counts track mutations without equating missing and empty values`() { + var environment = ProcessEnvironment(wrappedValue: nil as [String: String]?) + #expect(environment.description == "ProcessEnvironment(0 entries; redacted)") + #expect(environment != ProcessEnvironment(wrappedValue: [:] as [String: String]?)) + environment.wrappedValue = [Self.sentinelKey: Self.sentinel] + #expect(environment.description == "ProcessEnvironment(1 entries; redacted)") + Self.expectMirrorRedacted(environment) + environment.wrappedValue?["ORDINARY_NAME"] = Self.sentinel + #expect(environment.description == "ProcessEnvironment(2 entries; redacted)") + } + + private struct OptionalConfiguration: Equatable { + @ProcessEnvironment var environment: [String: String]? } private struct CapturedValue: Equatable { diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b948e5689c..63f6f2cd57 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -182,11 +182,18 @@ their `_PATH` suffix otherwise matches `_PAT`. Other matching variables, includi Use synthetic dictionaries or set synthetic sentinels inside fixtures; never depend on inherited real credentials. For direct `swift test`, source the script in a Bash subshell first. This does not authorize live account tests. -`ProcessEnvironment` provides count-only descriptions and reflection for stored environment dictionaries. -The Codex and Claude usage fetchers and shared fetch context use it so failed expectations cannot expand their -stored environments. Explicit dictionary access still returns the original values for provider/subprocess use; -never log that dictionary. Other stored environment types still need migration, so harness scrubbing remains -essential and does not replace a review of debug output before sharing it. +`@ProcessEnvironment` provides count-only descriptions and reflection for stored process-environment dictionaries +throughout the app, CLI, provider contexts, and session scanners. Use it on every stored environment, including +captured configuration structs and optional dictionaries. Optional storage preserves `nil` versus an empty map; +equality still compares the original contents. Keep formerly immutable properties `private(set)`. +Explicit dictionary access still returns the original values for provider/subprocess use; never log that dictionary. +Harness scrubbing remains essential and does not replace a review of debug output before sharing it. + +`ProcessEnvironmentStorageTests` scans shipped Swift in `Sources/` and `WidgetExtension/` for environment-named +dictionary declarations (including optional, multiline, and `Dictionary` spellings). This lexical +tripwire checks locals too; its exact-source allowlist documents only transient dictionaries and rejects stale or +duplicate exceptions. Computed getters and function parameters are not storage. Inferred types, aliases, differently +named dictionaries, and explicit dictionary logging still require code review; this is not a Swift dataflow analyzer. Lint tools are installed at repository-pinned versions by `Scripts/install_lint_tools.sh`, with archive checksums verified before installation. TypeScript 7 installs its native package for the running Node platform and architecture From 057d41a919428f78c3c07351081509ee42542297 Mon Sep 17 00:00:00 2001 From: Sogl Date: Tue, 29 Sep 2026 01:29:23 +0300 Subject: [PATCH 073/122] Reap descendants of the scoped agy usage probe Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../Providers/Antigravity/AntigravityScopedPrintFetch.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift index 1b0fd8bb5e..f06dbb2e72 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityScopedPrintFetch.swift @@ -344,6 +344,7 @@ extension AntigravityCLIHTTPSFetchStrategy { maxOutputBytes: 1_048_576, standardInput: FileHandle.nullDevice, currentDirectoryURL: staged.home, + reapDescendants: true, label: "antigravity-cli-scoped-usage") } catch let error as SubprocessRunnerError { try Task.checkCancellation() From 2db68ee85555117b9e5b5566af0876c2592d2a85 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 15:49:15 -0700 Subject: [PATCH 074/122] fix(process): keep ownership cleanup responsive under load (#4108) Process ownership cleanup stays responsive under load: a timed-out or cancelled subprocess returned only after ~13 s on a busy Linux process table because cleanup waited on full ownership sweeps; it now returns in ~3 s under the same load with the existing assertion bound. Adaptive scan deadline tests use the injected clock instead of wall time. --- CHANGELOG.md | 4 ++++ .../Host/Process/ProcessOwnershipReaper.swift | 8 ++------ Sources/CodexBarCore/PiProcessEnvironment.swift | 8 ++++++-- .../AdaptiveRefreshPerformanceTests.swift | 8 +------- .../DirectoryMetadataScanBudgetTests.swift | 10 +++++----- TestsLinux/ProcessOwnershipReaperTests.swift | 15 +++++++++++++++ docs/architecture.md | 2 ++ 7 files changed, 35 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 243b9264c9..e4ac0631e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ - Redact every remaining stored process environment in the app, CLI, provider contexts, and session scanners, and guard against new unredacted environment properties with a repository check (#4106). +### Fixed + +- CLI: keep probe timeout and cancellation cleanup responsive when other processes have large environments (#4077). + ## 0.69.0 — 2026-09-28 ### Highlights diff --git a/Sources/CodexBarCore/Host/Process/ProcessOwnershipReaper.swift b/Sources/CodexBarCore/Host/Process/ProcessOwnershipReaper.swift index 0191f2aa5f..ae4b70c0c2 100644 --- a/Sources/CodexBarCore/Host/Process/ProcessOwnershipReaper.swift +++ b/Sources/CodexBarCore/Host/Process/ProcessOwnershipReaper.swift @@ -22,7 +22,7 @@ struct ProcessOwnershipReaper: Sendable { let identities = self.ownedProcesses() if identities.isEmpty { return } for identity in identities { - self.signal(identity, SIGTERM) + Self.signal(identity, SIGTERM, owns: self.owns) } usleep(50000) } while Date() < deadline @@ -30,7 +30,7 @@ struct ProcessOwnershipReaper: Sendable { self.signalGroup(processGroup, signal: SIGKILL) } for identity in self.ownedProcesses() { - self.signal(identity, SIGKILL) + Self.signal(identity, SIGKILL, owns: self.owns) } } @@ -72,10 +72,6 @@ struct ProcessOwnershipReaper: Sendable { return environment?[Self.environmentKey] == self.marker } - private func signal(_ identity: TTYProcessTreeTerminator.ProcessIdentity, _ signal: Int32) { - Self.signal(identity, signal, owns: self.owns) - } - /// Revalidate at each signal, including escalation: a cached PID is never authority to kill. static func signal( _ identity: TTYProcessTreeTerminator.ProcessIdentity, diff --git a/Sources/CodexBarCore/PiProcessEnvironment.swift b/Sources/CodexBarCore/PiProcessEnvironment.swift index 4f4036f483..36d79943c5 100644 --- a/Sources/CodexBarCore/PiProcessEnvironment.swift +++ b/Sources/CodexBarCore/PiProcessEnvironment.swift @@ -27,7 +27,11 @@ enum PiProcessEnvironment { else { return nil } var selected: [String: String] = [:] - for record in data.split(separator: 0) { + // Byte-range search avoids walking large unrelated values through Data's generic split iterator. + var remainder = data.drop(while: { $0 == 0 }) + while let end = remainder.range(of: Data([0]))?.lowerBound { + let record = remainder[.. = [key] + var records = (0..<128).map { "FIXTURE_\($0)=\(String(repeating: "x", count: 4096))" } + records.insert("\(key)=fixture", at: position) + let data = Data(("\0" + records.joined(separator: "\0\0") + "\0").utf8) + + #expect(PiProcessEnvironment.parseNULSeparated(data, names: names) == [key: "fixture"]) + #expect(PiProcessEnvironment.parseNULSeparated(data.dropLast(), names: names) == nil) + #expect(PiProcessEnvironment.parseNULSeparated(data + Data("malformed\0".utf8), names: names) == nil) + #expect(PiProcessEnvironment.parseNULSeparated(data + Data("\(key)=different\0".utf8), names: names) == nil) + #expect(PiProcessEnvironment.parseNULSeparated(Data(repeating: 0, count: data.count), names: names) == [:]) + } } diff --git a/docs/architecture.md b/docs/architecture.md index e2d1c88075..cdd126ea64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,6 +33,8 @@ read_when: - The login runner and `SubprocessRunner` share `ProcessTermination` and process-tree termination. Cancelling a login stops its child process, joins its progress callback task, and produces no failure alert. Timeouts retain captured diagnostic output, and inherited pipes cannot keep the caller waiting indefinitely. +- Probe ownership cleanup reads exact environment markers, retaining PID identity checks before signaling. Environment + parsing searches NUL byte ranges so large unrelated values do not dominate timeout and cancellation cleanup. - Codex and Grok RPC clients share deadline selection through `RPCRequestTimeout`. The deadline wins before teardown can report stdout EOF; each client keeps its protocol initialization, encoding, diagnostics, and error types. From 0291dcdc007f3f9ec840f0b8aa33ad1a116f642e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 18:39:25 -0700 Subject: [PATCH 075/122] fix(ui): verify spend dashboard header layout Preserve the two-row header, add isolated light and dark layout regression coverage, and consolidate nearby view and day-label helpers without production growth. Co-authored-by: Elijah Friedman --- CHANGELOG.md | 4 ++ .../PreferencesSpendDashboardPane.swift | 64 ++++++++----------- .../SpendDashboardHeaderLayoutTests.swift | 60 +++++++++++++++++ docs/ui.md | 1 + 4 files changed, 91 insertions(+), 38 deletions(-) create mode 100644 Tests/CodexBarTests/SpendDashboardHeaderLayoutTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5696504ebb..1fefd07e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.68.1 — Unreleased +### Fixed + +- Settings: keep the Usage & Spend title and Refresh button readable by giving the time-range picker its own row (#4064). Thanks @elijahfriedman! + ## 0.68.0 — 2026-09-27 ### Highlights diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index bc43fbcb54..098dbd4aed 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -17,12 +17,8 @@ func spendDashboardDayRangeText(_ days: Int) -> String { if days >= SpendDashboardSource.scanDays { return L("All") } - let template: String - switch days { - case 7: template = L("7d") - case 30: template = L("30d") - case 90: template = L("90d") - default: return codexBarLocalizedInteger(days) + guard let template = [7: L("7d"), 30: L("30d"), 90: L("90d")][days] else { + return codexBarLocalizedInteger(days) } return template.replacingOccurrences( of: String(days), @@ -231,7 +227,7 @@ struct SpendDashboardPane: View { self.store.sharedSpendDashboardController() } - private var header: some View { + var header: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 16) { VStack(alignment: .leading, spacing: 4) { @@ -245,40 +241,32 @@ struct SpendDashboardPane: View { } .layoutPriority(1) Spacer(minLength: 0) - self.refreshButton - } - self.rangePicker - } - } - - private var rangePicker: some View { - Picker(L("Time range"), selection: self.periodBinding) { - Text(spendDashboardDayRangeText(7)).tag(CostReportingPeriod.rolling(days: 7)) - Text(spendDashboardDayRangeText(30)).tag(CostReportingPeriod.rolling(days: 30)) - Text(spendDashboardDayRangeText(90)).tag(CostReportingPeriod.rolling(days: 90)) - Text(L("Month to date")).tag(CostReportingPeriod.monthToDate) - Text(L("All")).tag(CostReportingPeriod.allTime) - if case let .rolling(days) = self.controller.selectedPeriod, ![7, 30, 90].contains(days) { - Text(spendDashboardDayRangeText(days)).tag(self.controller.selectedPeriod) + Button { + self.store.refreshSpendDashboard(accounts: self.codexSpendScanRequests) + } label: { + if self.controller.isRefreshing { + ProgressView().controlSize(.small) + } else { + Label(L("Refresh"), systemImage: "arrow.clockwise") + } + } + .disabled(self.controller.isRefreshing || !self.settings.costUsageEnabled) } - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: 480, alignment: .leading) - .accessibilityIdentifier("spend-dashboard-range-picker") - } - - private var refreshButton: some View { - Button { - self.store.refreshSpendDashboard(accounts: self.codexSpendScanRequests) - } label: { - if self.controller.isRefreshing { - ProgressView().controlSize(.small) - } else { - Label(L("Refresh"), systemImage: "arrow.clockwise") + Picker(L("Time range"), selection: self.periodBinding) { + Text(spendDashboardDayRangeText(7)).tag(CostReportingPeriod.rolling(days: 7)) + Text(spendDashboardDayRangeText(30)).tag(CostReportingPeriod.rolling(days: 30)) + Text(spendDashboardDayRangeText(90)).tag(CostReportingPeriod.rolling(days: 90)) + Text(L("Month to date")).tag(CostReportingPeriod.monthToDate) + Text(L("All")).tag(CostReportingPeriod.allTime) + if case let .rolling(days) = self.controller.selectedPeriod, ![7, 30, 90].contains(days) { + Text(spendDashboardDayRangeText(days)).tag(self.controller.selectedPeriod) + } } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: 480, alignment: .leading) + .accessibilityIdentifier("spend-dashboard-range-picker") } - .disabled(self.controller.isRefreshing || !self.settings.costUsageEnabled) } @ViewBuilder diff --git a/Tests/CodexBarTests/SpendDashboardHeaderLayoutTests.swift b/Tests/CodexBarTests/SpendDashboardHeaderLayoutTests.swift new file mode 100644 index 0000000000..99c2988fb1 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardHeaderLayoutTests.swift @@ -0,0 +1,60 @@ +import AppKit +import CodexBarCore +import SwiftUI +import XCTest +@testable import CodexBar + +@MainActor +final class SpendDashboardHeaderLayoutTests: XCTestCase { + func test_headerFitsNarrowSettingsInBothAppearances() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("en") { + let settings = testSettingsStore( + suiteName: "SpendDashboardHeaderLayout", + userDefaults: InMemoryUserDefaults(), + config: testConfigWithAllProvidersDisabled()) + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store.sharedSpendDashboardControllerStorage = SpendDashboardController( + userDefaults: InMemoryUserDefaults(), + requestBuilder: { _ in fatalError("Rendering the header must not load spend data") }) + defer { + store.stopSharedSpendDashboardPublication() + settings.configFileWatcher?.stop() + } + for dark in [false, true] { + let appearance = try XCTUnwrap(NSAppearance(named: dark ? .darkAqua : .aqua)) + let view = SpendDashboardPane(settings: settings, store: store).header + .frame(width: 520, alignment: .leading) + .padding(24) + .background(Color(nsColor: .windowBackgroundColor)) + .environment(\.colorScheme, dark ? .dark : .light) + .environment(\.locale, Locale(identifier: "en_US")) + let hosting = NSHostingView(rootView: view) + hosting.appearance = appearance + let size = hosting.fittingSize + XCTAssertEqual(size.width, 568, accuracy: 1) + XCTAssertLessThan(size.height, 150, "Title and subtitle must not collapse beside the range picker") + hosting.frame = CGRect(origin: .zero, size: size) + let window = NSWindow( + contentRect: hosting.bounds, styleMask: [.borderless], backing: .buffered, defer: false) + window.appearance = appearance + window.contentView = hosting + defer { window.contentView = nil } + window.layoutIfNeeded() + hosting.layoutSubtreeIfNeeded() + if let directory = ProcessInfo.processInfo.environment["CODEXBAR_HEADER_PROOF_DIR"] { + let bitmap = try XCTUnwrap(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + let data = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + let name = "4064-header-\(dark ? "dark" : "light").png" + try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent(name)) + } + } + } + } +} diff --git a/docs/ui.md b/docs/ui.md index ccee4c7862..18368dd058 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -8,6 +8,7 @@ read_when: # UI & icon ## Settings +- Usage & Spend places its time-range picker below the title and Refresh button, keeping the header readable in narrow settings windows. - General → Preferred Currency supports Turkish lira (`TRY`, `₺`), New Zealand dollar (`NZD`), `SEK`, `NOK`, `DKK`, `PLN`, `BRL`, `MXN`, `ZAR`, `THB`, `IDR`, `VND`, and `UAH` alongside the existing currencies, using the shared daily exchange rates and offline fallback for cost estimates. - General shows the app version and build beside Quit; About keeps its Version row even for Homebrew or unsigned builds. - Provider accent colors use a hex field and a color picker that also previews the selected color; Reset restores the provider default. From ed7e7c2dadc5fd7134b8dfb6eb476804c75a061d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 19:21:55 -0700 Subject: [PATCH 076/122] test(plugins): make optional request deadline checks deterministic (#4111) Optional plugin request tests measured elapsed time from the first transport call while the host starts its deadline at primary admission, so primary scheduling delay on busy CI runners failed the optional-request assertion. The test now drives the existing injected deadline, verifies cancellation, and requires the primary result while optional work stays blocked; production code is unchanged. --- .../ProviderPluginOptionalRequestTests.swift | 89 ++++++++++++++----- 1 file changed, 69 insertions(+), 20 deletions(-) diff --git a/TestsPlugin/ProviderPluginOptionalRequestTests.swift b/TestsPlugin/ProviderPluginOptionalRequestTests.swift index 49901033eb..0a2490065e 100644 --- a/TestsPlugin/ProviderPluginOptionalRequestTests.swift +++ b/TestsPlugin/ProviderPluginOptionalRequestTests.swift @@ -53,7 +53,7 @@ struct ProviderPluginOptionalRequestTests { func `slow primary keeps a secondary that completed after the collection budget`( engine: ProviderPluginEngineKind) async throws { - let runtime = try Self.runtime(engine: engine, collectionBudget: .milliseconds(200)) { request in + let runtime = try Self.runtime(engine: engine, contextOptions: .production) { request in try await Task.sleep(for: request.url?.path == "/primary" ? .seconds(2) : .seconds(1)) return try Self.response(request, body: "ready") } @@ -116,35 +116,92 @@ struct ProviderPluginOptionalRequestTests { #expect(calls.counts.1 == 2) } - @Test(arguments: BundledPluginTestSupport.engines) + @Test(.timeLimit(.minutes(1)), arguments: BundledPluginTestSupport.engines, [false, true]) func `optional transport ignoring cancellation cannot hold the result`( - engine: ProviderPluginEngineKind) async throws + engine: ProviderPluginEngineKind, waitingForAdmission: Bool) async throws { - let calls = RequestCalls() + let (starts, started) = AsyncStream.makeStream() let (release, continuation) = AsyncStream.makeStream() - defer { continuation.finish() } - let runtime = try Self.runtime(engine: engine, collectionBudget: .milliseconds(200)) { request in - calls.start() - if request.url?.path == "/optional" { + let (cancellations, cancelled) = AsyncStream.makeStream() + defer { + started.finish() + continuation.finish() + cancelled.finish() + } + let holdOptional: @Sendable () async -> Void = { + await withTaskCancellationHandler { + started.yield() // An independent task deliberately prevents caller cancellation from releasing this transport. await Task.detached { for await _ in release {} }.value + } onCancel: { + cancelled.yield() } + } + let options = ProviderPluginContextOptions( + optionalRequestTimeoutSeconds: nil, + waitForOptionalDeadline: { _, budget in + #expect(budget == .milliseconds(200)) + var iterator = starts.makeAsyncIterator() + #expect(await iterator.next() != nil) + }, + beforeHTTPAttempt: { request in + // Before admission, the independent five-second request timer cannot mask a broken collection deadline. + if waitingForAdmission, request.url?.path == "/optional" { await holdOptional() } + }) + let runtime = try Self.runtime(engine: engine, contextOptions: options) { request in + if !waitingForAdmission, request.url?.path == "/optional" { await holdOptional() } return try Self.response(request, body: "late") } - let task = Task { try await runtime.fetchUsage() } + let task = Task { + let usage = try await runtime.fetchUsage() + var iterator = cancellations.makeAsyncIterator() + #expect(await iterator.next() != nil) + return usage + } + defer { task.cancel() } switch await BoundedTaskJoin(sourceTask: task).value(joinGrace: .seconds(10)) { case let .value(usage): #expect(usage.identity?.loginMethod == "none") - #expect(try #require(calls.elapsed) < .seconds(1)) case .failure, .timedOut: Issue.record("Optional transport held the primary result until release") } } + @Test(.timeLimit(.minutes(1))) + func `bounded request join cancels without waiting for an uncooperative transport or its timeout`() async { + let (starts, started) = AsyncStream.makeStream() + let (pending, release) = AsyncStream.makeStream() + defer { + started.finish() + release.finish() + } + let transport = Task { + started.yield() + await Task.detached { for await _ in pending {} }.value + } + // Exercise the post-admission join directly so cancellation cannot win at an earlier admission check. + let task = Task, Error> { + await BoundedTaskJoin(sourceTask: transport).value(joinGrace: .seconds(60)) + } + defer { + task.cancel() + transport.cancel() + } + var iterator = starts.makeAsyncIterator() + #expect(await iterator.next() != nil) + task.cancel() + switch await BoundedTaskJoin(sourceTask: task).value(joinGrace: .seconds(10)) { + case let .value(.failure(error)): #expect(error is CancellationError) + case .value, .failure, .timedOut: Issue.record("Request cancellation waited for the transport or its timeout") + } + } + private static func runtime( engine: ProviderPluginEngineKind, optionalURL: String = "https://example.test/optional", limit: Int = 1024, - collectionBudget: Duration = .seconds(3), + contextOptions: ProviderPluginContextOptions = .init( + optionalRequestTimeoutSeconds: nil, + optionalCollectionBudget: .seconds(3)), handler: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) throws -> ProviderPluginRuntime { try ProviderPluginRuntime( @@ -163,9 +220,7 @@ struct ProviderPluginOptionalRequestTests { responseSizeLimit: limit, enforcesUserResponsePolicy: true, allowsDynamicID: true, - contextOptions: ProviderPluginContextOptions( - optionalRequestTimeoutSeconds: nil, - optionalCollectionBudget: collectionBudget), + contextOptions: contextOptions, engine: engine) } @@ -186,11 +241,6 @@ struct ProviderPluginOptionalRequestTests { private final class RequestCalls: @unchecked Sendable { private let lock = NSLock() private var started = 0 - private var startedAt: ContinuousClock.Instant? - var elapsed: Duration? { - self.lock.withLock { self.startedAt?.duration(to: .now) } - } - private var cancelled = 0 var counts: (Int, Int) { self.lock.withLock { (self.started, self.cancelled) } @@ -198,7 +248,6 @@ struct ProviderPluginOptionalRequestTests { func start() { self.lock.withLock { - self.startedAt = self.startedAt ?? .now self.started += 1 } } From d9444c907527dfdde08ee2f08d30463cec60f21d Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 29 Sep 2026 11:05:23 +0800 Subject: [PATCH 077/122] fix(claude): answer the workspace trust dialog in PTY probes --- CHANGELOG.md | 1 + .../Providers/Claude/ClaudeCLISession.swift | 89 +++++++++++-- .../ClaudeCLIWorkspaceTrustTests.swift | 126 ++++++++++++++++++ .../Claude/workspace-trust-dialog.ansi | 18 +++ .../Claude/workspace-trust-select-down.ansi | 4 + .../Providers/Claude/workspace-trust.md | 5 + docs/claude.md | 4 +- 7 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift create mode 100644 Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust-dialog.ansi create mode 100644 Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust-select-down.ansi create mode 100644 Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b09624ce03..34d67223ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Claude: answer Claude Code's workspace trust dialog in the CLI probe's dedicated directory by selecting "Yes, I trust this folder". Pressing Enter on the preselected "No, exit" made every PTY probe exit before `/usage` ran and fall back to non-interactive `/usage`; a PTY session that exits mid-capture now also logs its exit status (#4115, related to #4083). Thanks @sudoHG! - Costs: price documented Antigravity and Codex model aliases, add published Cyber fallback rates, and preserve Sol estimates across the August 21 price change (#4094). Thanks @urda! - Mistral: offer Monthly Plan in the provider's Menu bar metric picker, so the menu bar and widgets can show the Vibe allowance without a `defaults write` (#4072). Thanks @T0mSIlver! - Mistral: price billing usage by event type, API zone, and service tier, so a per-second audio or priority price no longer inflates API spend and 30-day token cost (#4076). Thanks @T0mSIlver! diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index 5f511f7480..96f653ed5a 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -82,14 +82,18 @@ actor ClaudeCLISession { self.workingDirectory = workingDirectory } + /// The workspace trust dialog ("Quick safety check: ...") is answered in `waitForStartup()`: Claude Code 2.1.282 + /// preselects "No, exit", so a bare Enter would quit. private let promptSends: [String: String] = [ "Do you trust the files in this folder?": "y\r", - "Quick safety check:": "\r", - "Yes, I trust this folder": "\r", "Ready to code here?": "\r", "Press Enter to continue": "\r", ] + private static let startupDelay: TimeInterval = 2.0 + private static let workspaceTrustOption = "Yes, I trust this folder" + private static let maxWorkspaceTrustKeys = 4 + private static func normalizedNeedle(_ text: String) -> String { String(text.lowercased().filter { !$0.isWhitespace }) } @@ -161,15 +165,7 @@ actor ClaudeCLISession { try self.send("\u{1b}") try await Task.sleep(nanoseconds: 150_000_000) } - if let startedAt { - let sinceStart = Date().timeIntervalSince(startedAt) - // Claude's TUI can drop early keystrokes while it's still initializing. Wait a bit longer than the - // original 0.4s to ensure slash commands reliably open their panels. - if sinceStart < 2.0 { - let delay = UInt64((2.0 - sinceStart) * 1_000_000_000) - try await Task.sleep(nanoseconds: delay) - } - } + try await self.waitForStartup() _ = self.readChunk() let trimmed = request.subcommand.trimmingCharacters(in: .whitespacesAndNewlines) @@ -252,6 +248,9 @@ actor ClaudeCLISession { self.sendPeriodicEnterIfNeeded(every: request.sendEnterEvery, lastEnterAt: &lastEnterAt) if let proc = self.process, !proc.isRunning { + Self.log.warning( + "Claude CLI session exited during capture", + metadata: ["status": "\(proc.terminationStatus)"]) throw SessionError.processExited } @@ -305,6 +304,74 @@ actor ClaudeCLISession { utf8Carry = Data(combined.suffix(12)) } + /// Claude's TUI can drop early keystrokes while it's still initializing. Wait a bit longer than the original 0.4s + /// to ensure slash commands reliably open their panels. A fresh launch in an untrusted folder shows the workspace + /// trust dialog in this window; CodexBar trusts its dedicated probe directory, so select the trust option + /// explicitly and give the main screen a fresh startup window. + private func waitForStartup() async throws { + guard let startedAt else { return } + var readyAt = startedAt.addingTimeInterval(Self.startupDelay) + var screenText = "" + var utf8Carry = Data() + var lastOutputAt = Date.distantPast + var hasUncheckedFrame = false + var trustKeysLeft = Self.maxWorkspaceTrustKeys + while Date() < readyAt { + let newData = self.readChunk() + if !newData.isEmpty { + Self.appendScanText(newData: newData, scanTailText: &screenText, utf8Carry: &utf8Carry) + if screenText.count > 8192 { + screenText = String(screenText.suffix(8192)) + } + lastOutputAt = Date() + hasUncheckedFrame = true + } + + // Check each settled frame once, so no key is sent again before Claude redraws the selection. + if hasUncheckedFrame, trustKeysLeft > 0, Date().timeIntervalSince(lastOutputAt) >= 0.2 { + hasUncheckedFrame = false + if let keys = Self.workspaceTrustKeys(onScreen: ClaudeCLIScreen.render(screenText)) { + try self.send(keys) + trustKeysLeft -= 1 + if keys == "\r" { + trustKeysLeft = 0 + readyAt = Date().addingTimeInterval(Self.startupDelay) + Self.log.info("Claude CLI workspace trust accepted for the probe directory") + } else { + readyAt = max(readyAt, Date().addingTimeInterval(1.0)) + } + } + } + + if let proc = self.process, !proc.isRunning { return } + try await Task.sleep(nanoseconds: 60_000_000) + } + } + + /// Returns the arrow key that moves the workspace trust dialog's `❯` marker toward "Yes, I trust this folder", or + /// Enter once the marker is on it. Returns nil when the dialog or its marker is not on screen, so Enter can never + /// confirm a different option. + static func workspaceTrustKeys(onScreen screen: String) -> String? { + let lines = screen.components(separatedBy: "\n") + let option = Self.normalizedNeedle(Self.workspaceTrustOption) + guard let optionRow = lines.firstIndex(where: { Self.normalizedNeedle($0).contains(option) }) else { + return nil + } + // Only accept a marker from the same option list; blank lines separate it from the dialog's other text. + let isBlank = { (row: Int) in lines[row].allSatisfy(\.isWhitespace) } + var firstRow = optionRow + while firstRow > lines.startIndex, !isBlank(firstRow - 1) { + firstRow -= 1 + } + var lastRow = optionRow + while lastRow < lines.endIndex - 1, !isBlank(lastRow + 1) { + lastRow += 1 + } + guard let markerRow = (firstRow...lastRow).first(where: { lines[$0].contains("❯") }) else { return nil } + if markerRow == optionRow { return "\r" } + return markerRow < optionRow ? "\u{1b}[B" : "\u{1b}[A" + } + func reset() async { let operationID = UUID() _ = await self.operationGate.acquire(id: operationID, rejectIfCancelled: false) diff --git a/Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift b/Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift new file mode 100644 index 0000000000..dc81c15b1a --- /dev/null +++ b/Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeCLIWorkspaceTrustTests { + @Test + func `captured trust dialog preselects No and is answered by moving to the trust option`() throws { + let dialog = try ClaudeCLIScreenProbeTests.capture("workspace-trust-dialog") + let screen = ClaudeCLIScreen.render(dialog) + let lines = screen.components(separatedBy: "\n") + #expect(lines.contains(" ❯ No, exit")) + #expect(lines.contains(" Yes, I trust this folder")) + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen) == "\u{1b}[B") + + let redrawn = try ClaudeCLIScreen.render(dialog + ClaudeCLIScreenProbeTests.capture( + "workspace-trust-select-down")) + let redrawnLines = redrawn.components(separatedBy: "\n") + #expect(redrawnLines.contains(" No, exit")) + #expect(redrawnLines.contains(" ❯ Yes, I trust this folder")) + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: redrawn) == "\r") + } + + @Test(arguments: [ + (" Quick safety check:\n\n ❯ Yes, I trust this folder\n No, exit\n\n Enter to confirm", "\r"), + (" Quick safety check:\n\n Yes, I trust this folder\n ❯ No, exit\n\n Enter to confirm", "\u{1b}[A"), + (" Quick safety check:\n\n ❯ No, exit\n Cancel\n Yes, I trust this folder", "\u{1b}[B"), + ]) + func `trust option is reached from either side before it is confirmed`(screen: String, expected: String) { + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen) == expected) + } + + @Test(arguments: [ + "", + " Do you trust the files in this folder?\n\n ❯ 1. Yes, proceed\n 2. No, exit", + " Quick safety check:\n\n No, exit\n Yes, I trust this folder\n\n Enter to confirm", + " ❯ Quick safety check:\n\n No, exit\n Yes, I trust this folder", + "────\n❯ \n────\n ? for shortcuts", + ]) + func `screens without a marked trust option send no keys`(screen: String) { + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen) == nil) + } + + @Test + func `fresh probe session trusts its directory before typing the command`() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-workspace-trust-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + for name in ["workspace-trust-dialog", "workspace-trust-select-down"] { + try Data(ClaudeCLIScreenProbeTests.capture(name).utf8) + .write(to: directory.appendingPathComponent("\(name).ansi")) + } + let binary = directory.appendingPathComponent("fake-claude") + let log = directory.appendingPathComponent("keys.log") + // Replays the captured Claude Code 2.1.282 frames: Enter on the preselected "No, exit" quits with status 1. + let script = #""" + #!/bin/bash + /bin/stty raw -echo -icrnl + /bin/cat "$HOME/workspace-trust-dialog.ansi" + selected=no + while IFS= read -r -n 1 key; do + case "$key" in + $'\e') + IFS= read -r -n 2 key + printf 'key:%s\n' "$key" >> "$HOME/keys.log" + if [[ "$key" == '[B' && "$selected" == no ]]; then + selected=yes + /bin/cat "$HOME/workspace-trust-select-down.ansi" + fi + ;; + # read -n 1 reports a newline as an empty key. + $'\r'|'') + printf 'confirm:%s\n' "$selected" >> "$HOME/keys.log" + [[ "$selected" == yes ]] || exit 1 + break + ;; + esac + done + printf 'ready\r\n' + command='' + while IFS= read -r -n 1 key; do + case "$key" in + $'\r'|'') + [[ "$command" == /exit ]] && exit 0 + printf 'command:%s\n' "$command" >> "$HOME/keys.log" + [[ "$command" == /status ]] && printf 'Account: trusted\r\nDONE\r\n' + command='' + ;; + *) command+="$key" ;; + esac + done + """# + try script.write(to: binary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) + let session = ClaudeCLISession(workingDirectory: directory) + let environment = [ + "HOME": directory.path, + "CLAUDE_CONFIG_DIR": directory.path, + "CLAUDE_SECURESTORAGE_CONFIG_DIR": directory.path, + "CODEXBAR_DISABLE_CLAUDE_WATCHDOG": "1", + ] + do { + let status = try await session.capture( + subcommand: "/status", + binary: binary.path, + accountScope: "synthetic-account", + timeout: 5, + environment: environment, + idleTimeout: nil, + stopOnSubstrings: ["DONE"], + settleAfterStop: 0) + await session.reset() + #expect(status.contains("Account: trusted")) + } catch { + print("Synthetic CLI keys:\n" + ((try? String(contentsOf: log, encoding: .utf8)) ?? "")) + await session.reset() + throw error + } + #expect(try String(contentsOf: log, encoding: .utf8) == """ + key:[B + confirm:yes + command:/status + + """) + } +} diff --git a/Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust-dialog.ansi b/Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust-dialog.ansi new file mode 100644 index 0000000000..2203d158c0 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust-dialog.ansi @@ -0,0 +1,18 @@ +78[?25h[?25l[?2004h[?2031h[?1004h +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Accessingworkspace: + +/Users/example/Library/Application Support/CodexBar/ClaudeProbe + +Quicksafetycheck:Isthisaprojectyoucreatedoroneyoutrust?(Likeyourowncode,awell-knownopensourceproject,orworkfromyourteam).Ifnot, +takeamomenttoreviewwhat'sinthisfolderfirst. + +ClaudeCode'llbeabletoread,edit,andexecutefileshere. + +Securityguide + +❯No,exit +Yes,Itrustthisfolder + +Entertoconfirm·Esctocancel +[>0q[?u \ No newline at end of file diff --git a/Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust-select-down.ansi b/Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust-select-down.ansi new file mode 100644 index 0000000000..2da867a890 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust-select-down.ansi @@ -0,0 +1,4 @@ +  ❯ + + + \ No newline at end of file diff --git a/Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust.md b/Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust.md new file mode 100644 index 0000000000..643d644b1c --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/Claude/workspace-trust.md @@ -0,0 +1,5 @@ +# Workspace trust dialog fixtures + +`workspace-trust-dialog.ansi` is the first frame Claude Code 2.1.282 writes to a 160x50 PTY when it starts in an untrusted folder with CodexBar's probe arguments. Only the workspace path is replaced with a synthetic one; the frame was byte-identical across three captures. `❯` marks the preselected `No, exit` option, and the gaps between words are cursor moves, not space characters. + +`workspace-trust-select-down.ansi` is the redraw that followed one Down arrow: it erases the marker on `No, exit` and draws it on `Yes, I trust this folder`. diff --git a/docs/claude.md b/docs/claude.md index 15e2cb6ba6..c50d673ff3 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -378,7 +378,9 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential `ClaudeProbe` project directory so background `/usage` polling does not clutter the user's Claude project history. - Command flow: 1) Start CLI with `--allowed-tools ""` (no tools). - 2) Auto-respond to first-run prompts (trust files, workspace, telemetry). + 2) Auto-respond to first-run prompts (trust files, workspace, telemetry). The workspace trust dialog ("Quick safety + check") preselects "No, exit", so the probe moves the `❯` selection to "Yes, I trust this folder" before pressing + Enter; Claude then remembers trust for the dedicated probe directory. 3) Send `/usage`, wait for rendered panel; send Enter retries if needed. 4) Dismiss the open panel with Escape before reusing the session for `/status` identity or the next `/usage` refresh. 5) Optionally send `/status` to extract identity fields. From e9a3f6534ad48b5f8c6c528109d24fa7da9536aa Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 29 Sep 2026 12:00:24 +0800 Subject: [PATCH 078/122] fix(claude): only trust the dedicated probe directory --- CHANGELOG.md | 2 +- .../Providers/Claude/ClaudeCLISession.swift | 50 ++++-- .../Providers/Claude/ClaudeStatusProbe.swift | 26 ++- .../ClaudeCLIWorkspaceTrustTests.swift | 153 +++++++++++++----- docs/claude.md | 3 +- 5 files changed, 171 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d67223ed..8821ca77a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Claude: answer Claude Code's workspace trust dialog in the CLI probe's dedicated directory by selecting "Yes, I trust this folder". Pressing Enter on the preselected "No, exit" made every PTY probe exit before `/usage` ran and fall back to non-interactive `/usage`; a PTY session that exits mid-capture now also logs its exit status (#4115, related to #4083). Thanks @sudoHG! +- Claude: answer Claude Code's workspace trust dialog in the CLI probe's dedicated directory by selecting "Yes, I trust this folder". Pressing Enter on the preselected "No, exit" made every PTY probe exit before `/usage` ran and fall back to non-interactive `/usage`. Outside that directory, including the temporary-directory fallback, the probe cancels the dialog instead, and a PTY session that exits mid-capture now logs its exit status (#4115, related to #4083). Thanks @sudoHG! - Costs: price documented Antigravity and Codex model aliases, add published Cyber fallback rates, and preserve Sol estimates across the August 21 price change (#4094). Thanks @urda! - Mistral: offer Monthly Plan in the provider's Menu bar metric picker, so the menu bar and widgets can show the Vibe allowance without a `defaults write` (#4072). Thanks @T0mSIlver! - Mistral: price billing usage by event type, API zone, and service tier, so a per-second audio or priority price no longer inflates API spend and 30-day token cost (#4076). Thanks @T0mSIlver! diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index 96f653ed5a..fbef341ce2 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -75,6 +75,7 @@ actor ClaudeCLISession { private var processGroup: pid_t? private var sessionIdentity: SessionIdentity? private var startedAt: Date? + private var launchedInProbeDirectory = false private let operationGate = AsyncOperationGate() private let workingDirectory: URL? @@ -82,13 +83,19 @@ actor ClaudeCLISession { self.workingDirectory = workingDirectory } - /// The workspace trust dialog ("Quick safety check: ...") is answered in `waitForStartup()`: Claude Code 2.1.282 - /// preselects "No, exit", so a bare Enter would quit. - private let promptSends: [String: String] = [ - "Do you trust the files in this folder?": "y\r", - "Ready to code here?": "\r", - "Press Enter to continue": "\r", - ] + /// Trust prompts are only answered in CodexBar's dedicated probe directory. The workspace trust dialog + /// ("Quick safety check: ...") is handled in `waitForStartup()`: Claude Code 2.1.282 preselects "No, exit", so a + /// bare Enter quits. + static func promptSends(acceptsTrust: Bool) -> [String: String] { + var sends = [ + "Ready to code here?": "\r", + "Press Enter to continue": "\r", + ] + if acceptsTrust { + sends["Do you trust the files in this folder?"] = "y\r" + } + return sends + } private static let startupDelay: TimeInterval = 2.0 private static let workspaceTrustOption = "Yes, I trust this folder" @@ -175,7 +182,7 @@ actor ClaudeCLISession { } let stopNeedles = request.stopOnSubstrings.map { Self.normalizedNeedle($0) } - var sendMap = self.promptSends + var sendMap = Self.promptSends(acceptsTrust: self.launchedInProbeDirectory) for (needle, keys) in Self.commandPaletteSends(for: trimmed) { sendMap[needle] = keys } @@ -306,8 +313,8 @@ actor ClaudeCLISession { /// Claude's TUI can drop early keystrokes while it's still initializing. Wait a bit longer than the original 0.4s /// to ensure slash commands reliably open their panels. A fresh launch in an untrusted folder shows the workspace - /// trust dialog in this window; CodexBar trusts its dedicated probe directory, so select the trust option - /// explicitly and give the main screen a fresh startup window. + /// trust dialog in this window. CodexBar only trusts its dedicated probe directory: there it selects the trust + /// option explicitly and gives the main screen a fresh startup window; anywhere else it cancels the dialog. private func waitForStartup() async throws { guard let startedAt else { return } var readyAt = startedAt.addingTimeInterval(Self.startupDelay) @@ -330,14 +337,20 @@ actor ClaudeCLISession { // Check each settled frame once, so no key is sent again before Claude redraws the selection. if hasUncheckedFrame, trustKeysLeft > 0, Date().timeIntervalSince(lastOutputAt) >= 0.2 { hasUncheckedFrame = false - if let keys = Self.workspaceTrustKeys(onScreen: ClaudeCLIScreen.render(screenText)) { + let screen = ClaudeCLIScreen.render(screenText) + if let keys = Self.workspaceTrustKeys(onScreen: screen, acceptsTrust: self.launchedInProbeDirectory) { try self.send(keys) trustKeysLeft -= 1 - if keys == "\r" { + switch keys { + case "\r": trustKeysLeft = 0 readyAt = Date().addingTimeInterval(Self.startupDelay) Self.log.info("Claude CLI workspace trust accepted for the probe directory") - } else { + case "\u{1b}": + trustKeysLeft = 0 + readyAt = max(readyAt, Date().addingTimeInterval(1.0)) + Self.log.warning("Claude CLI workspace trust declined outside the probe directory") + default: readyAt = max(readyAt, Date().addingTimeInterval(1.0)) } } @@ -348,15 +361,17 @@ actor ClaudeCLISession { } } - /// Returns the arrow key that moves the workspace trust dialog's `❯` marker toward "Yes, I trust this folder", or - /// Enter once the marker is on it. Returns nil when the dialog or its marker is not on screen, so Enter can never + /// Returns the key for the workspace trust dialog on screen, or nil when it is not shown. Without `acceptsTrust`, + /// Escape cancels the dialog whichever option is selected. With it, an arrow moves the `❯` marker toward "Yes, I + /// trust this folder" and Enter follows once the marker is on it; nil while no marker is found, so Enter can never /// confirm a different option. - static func workspaceTrustKeys(onScreen screen: String) -> String? { + static func workspaceTrustKeys(onScreen screen: String, acceptsTrust: Bool) -> String? { let lines = screen.components(separatedBy: "\n") let option = Self.normalizedNeedle(Self.workspaceTrustOption) guard let optionRow = lines.firstIndex(where: { Self.normalizedNeedle($0).contains(option) }) else { return nil } + guard acceptsTrust else { return "\u{1b}" } // Only accept a marker from the same option list; blank lines separate it from the dialog's other text. let isBlank = { (row: Int) in lines[row].allSatisfy(\.isWhitespace) } var firstRow = optionRow @@ -481,6 +496,8 @@ actor ClaudeCLISession { self.processGroup = processGroup self.sessionIdentity = sessionIdentity self.startedAt = Date() + // Only the dedicated probe directory may be trusted, never `probeWorkingDirectoryURL()`'s temporary fallback. + self.launchedInProbeDirectory = ClaudeStatusProbe.isDedicatedProbeWorkingDirectory(workingDirectory) return false } @@ -603,6 +620,7 @@ actor ClaudeCLISession { self.processGroup = nil self.sessionIdentity = nil self.startedAt = nil + self.launchedInProbeDirectory = false } private func readChunk() -> Data { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index c790bc82f2..5693133167 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -89,6 +89,8 @@ public struct ClaudeStatusProbe: Sendable { #if DEBUG public typealias FetchOverride = @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot @TaskLocal static var fetchOverride: FetchOverride? + /// Stands in for CodexBar's dedicated probe directory, so tests never use the real Application Support folder. + @TaskLocal static var dedicatedProbeDirectoryOverrideForTesting: URL? #endif public init( @@ -1398,10 +1400,7 @@ extension ClaudeStatusProbe { static func probeWorkingDirectoryURL() -> URL { let fm = FileManager.default - let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory - let dir = base - .appendingPathComponent("CodexBar", isDirectory: true) - .appendingPathComponent("ClaudeProbe", isDirectory: true) + let dir = self.dedicatedProbeWorkingDirectoryURL() do { try fm.createDirectory(at: dir, withIntermediateDirectories: true) return dir @@ -1410,6 +1409,25 @@ extension ClaudeStatusProbe { } } + /// CodexBar's own probe directory. `probeWorkingDirectoryURL()` falls back to the shared temporary directory when + /// it cannot be created; only this directory may have Claude's workspace trust accepted on the user's behalf. + static func dedicatedProbeWorkingDirectoryURL() -> URL { + #if DEBUG + if let override = self.dedicatedProbeDirectoryOverrideForTesting { + return override + } + #endif + let fm = FileManager.default + let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory + return base + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("ClaudeProbe", isDirectory: true) + } + + static func isDedicatedProbeWorkingDirectory(_ directory: URL) -> Bool { + directory.standardizedFileURL.path == self.dedicatedProbeWorkingDirectoryURL().standardizedFileURL.path + } + static func preparedProbeWorkingDirectoryURL() -> URL { let directory = self.probeWorkingDirectoryURL() do { diff --git a/Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift b/Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift index dc81c15b1a..de4e84988b 100644 --- a/Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift +++ b/Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift @@ -10,14 +10,16 @@ struct ClaudeCLIWorkspaceTrustTests { let lines = screen.components(separatedBy: "\n") #expect(lines.contains(" ❯ No, exit")) #expect(lines.contains(" Yes, I trust this folder")) - #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen) == "\u{1b}[B") + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen, acceptsTrust: true) == "\u{1b}[B") + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen, acceptsTrust: false) == "\u{1b}") let redrawn = try ClaudeCLIScreen.render(dialog + ClaudeCLIScreenProbeTests.capture( "workspace-trust-select-down")) let redrawnLines = redrawn.components(separatedBy: "\n") #expect(redrawnLines.contains(" No, exit")) #expect(redrawnLines.contains(" ❯ Yes, I trust this folder")) - #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: redrawn) == "\r") + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: redrawn, acceptsTrust: true) == "\r") + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: redrawn, acceptsTrust: false) == "\u{1b}") } @Test(arguments: [ @@ -26,33 +28,126 @@ struct ClaudeCLIWorkspaceTrustTests { (" Quick safety check:\n\n ❯ No, exit\n Cancel\n Yes, I trust this folder", "\u{1b}[B"), ]) func `trust option is reached from either side before it is confirmed`(screen: String, expected: String) { - #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen) == expected) + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen, acceptsTrust: true) == expected) } @Test(arguments: [ "", " Do you trust the files in this folder?\n\n ❯ 1. Yes, proceed\n 2. No, exit", + "────\n❯ \n────\n ? for shortcuts", + ]) + func `screens without the trust dialog send no keys`(screen: String) { + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen, acceptsTrust: true) == nil) + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen, acceptsTrust: false) == nil) + } + + @Test(arguments: [ " Quick safety check:\n\n No, exit\n Yes, I trust this folder\n\n Enter to confirm", " ❯ Quick safety check:\n\n No, exit\n Yes, I trust this folder", - "────\n❯ \n────\n ? for shortcuts", ]) - func `screens without a marked trust option send no keys`(screen: String) { - #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen) == nil) + func `trust dialog without a marked option is never confirmed`(screen: String) { + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen, acceptsTrust: true) == nil) + #expect(ClaudeCLISession.workspaceTrustKeys(onScreen: screen, acceptsTrust: false) == "\u{1b}") + } + + @Test + func `only the dedicated probe directory accepts workspace trust`() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-probe-trust-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let dedicated = root.appendingPathComponent("ClaudeProbe", isDirectory: true) + ClaudeStatusProbe.$dedicatedProbeDirectoryOverrideForTesting.withValue(dedicated) { + #expect(ClaudeStatusProbe.probeWorkingDirectoryURL() == dedicated) + #expect(ClaudeStatusProbe.isDedicatedProbeWorkingDirectory(dedicated)) + #expect(!ClaudeStatusProbe.isDedicatedProbeWorkingDirectory(root)) + } + // When the dedicated directory cannot be created, probes fall back to the shared temporary directory. + let unavailable = URL(fileURLWithPath: "/dev/null/CodexBar/ClaudeProbe", isDirectory: true) + ClaudeStatusProbe.$dedicatedProbeDirectoryOverrideForTesting.withValue(unavailable) { + let fallback = ClaudeStatusProbe.probeWorkingDirectoryURL() + #expect(fallback == FileManager.default.temporaryDirectory) + #expect(!ClaudeStatusProbe.isDedicatedProbeWorkingDirectory(fallback)) + } + let legacyPrompt = "Do you trust the files in this folder?" + #expect(ClaudeCLISession.promptSends(acceptsTrust: true)[legacyPrompt] == "y\r") + #expect(ClaudeCLISession.promptSends(acceptsTrust: false)[legacyPrompt] == nil) + } + + @Test + func `fresh probe session trusts its dedicated directory before typing the command`() async throws { + let directory = try Self.makeFakeClaude() + defer { try? FileManager.default.removeItem(at: directory) } + let probeDirectory = directory.appendingPathComponent("ClaudeProbe", isDirectory: true) + try await ClaudeStatusProbe.$dedicatedProbeDirectoryOverrideForTesting.withValue(probeDirectory) { + let session = ClaudeCLISession() + do { + let status = try await Self.captureStatus(session: session, directory: directory) + await session.reset() + #expect(status.contains("Account: trusted")) + } catch { + print("Synthetic CLI keys:\n" + Self.keysLog(in: directory)) + await session.reset() + throw error + } + } + #expect(Self.keysLog(in: directory) == """ + key:[B + confirm:yes + command:/status + + """) } @Test - func `fresh probe session trusts its directory before typing the command`() async throws { + func `probe session outside its dedicated directory cancels the trust dialog`() async throws { + let directory = try Self.makeFakeClaude() + defer { try? FileManager.default.removeItem(at: directory) } + let probeDirectory = directory.appendingPathComponent("ClaudeProbe", isDirectory: true) + let error = await ClaudeStatusProbe.$dedicatedProbeDirectoryOverrideForTesting.withValue(probeDirectory) { + // Like the temporary-directory fallback, the launch directory is not the dedicated probe directory. + let session = ClaudeCLISession(workingDirectory: directory) + let thrown = await #expect(throws: ClaudeCLISession.SessionError.self) { + try await Self.captureStatus(session: session, directory: directory) + } + await session.reset() + return thrown + } + #expect(error.map { String(describing: $0) } == "processExited") + #expect(Self.keysLog(in: directory) == "cancel\n") + } + + private static func captureStatus(session: ClaudeCLISession, directory: URL) async throws -> String { + try await session.capture( + subcommand: "/status", + binary: directory.appendingPathComponent("fake-claude").path, + accountScope: "synthetic-account", + timeout: 5, + environment: [ + "HOME": directory.path, + "CLAUDE_CONFIG_DIR": directory.path, + "CLAUDE_SECURESTORAGE_CONFIG_DIR": directory.path, + "CODEXBAR_DISABLE_CLAUDE_WATCHDOG": "1", + ], + idleTimeout: nil, + stopOnSubstrings: ["DONE"], + settleAfterStop: 0) + } + + private static func keysLog(in directory: URL) -> String { + (try? String(contentsOf: directory.appendingPathComponent("keys.log"), encoding: .utf8)) ?? "" + } + + /// Replays the captured Claude Code 2.1.282 frames: Enter on the preselected "No, exit" quits with status 1, and + /// Escape cancels the dialog with status 0. + private static func makeFakeClaude() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("claude-workspace-trust-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } for name in ["workspace-trust-dialog", "workspace-trust-select-down"] { try Data(ClaudeCLIScreenProbeTests.capture(name).utf8) .write(to: directory.appendingPathComponent("\(name).ansi")) } let binary = directory.appendingPathComponent("fake-claude") - let log = directory.appendingPathComponent("keys.log") - // Replays the captured Claude Code 2.1.282 frames: Enter on the preselected "No, exit" quits with status 1. let script = #""" #!/bin/bash /bin/stty raw -echo -icrnl @@ -61,7 +156,12 @@ struct ClaudeCLIWorkspaceTrustTests { while IFS= read -r -n 1 key; do case "$key" in $'\e') - IFS= read -r -n 2 key + key='' + IFS= read -r -t 1 -n 2 key + if [[ -z "$key" ]]; then + printf 'cancel\n' >> "$HOME/keys.log" + exit 0 + fi printf 'key:%s\n' "$key" >> "$HOME/keys.log" if [[ "$key" == '[B' && "$selected" == no ]]; then selected=yes @@ -92,35 +192,6 @@ struct ClaudeCLIWorkspaceTrustTests { """# try script.write(to: binary, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) - let session = ClaudeCLISession(workingDirectory: directory) - let environment = [ - "HOME": directory.path, - "CLAUDE_CONFIG_DIR": directory.path, - "CLAUDE_SECURESTORAGE_CONFIG_DIR": directory.path, - "CODEXBAR_DISABLE_CLAUDE_WATCHDOG": "1", - ] - do { - let status = try await session.capture( - subcommand: "/status", - binary: binary.path, - accountScope: "synthetic-account", - timeout: 5, - environment: environment, - idleTimeout: nil, - stopOnSubstrings: ["DONE"], - settleAfterStop: 0) - await session.reset() - #expect(status.contains("Account: trusted")) - } catch { - print("Synthetic CLI keys:\n" + ((try? String(contentsOf: log, encoding: .utf8)) ?? "")) - await session.reset() - throw error - } - #expect(try String(contentsOf: log, encoding: .utf8) == """ - key:[B - confirm:yes - command:/status - - """) + return directory } } diff --git a/docs/claude.md b/docs/claude.md index c50d673ff3..286b06e9e6 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -380,7 +380,8 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential 1) Start CLI with `--allowed-tools ""` (no tools). 2) Auto-respond to first-run prompts (trust files, workspace, telemetry). The workspace trust dialog ("Quick safety check") preselects "No, exit", so the probe moves the `❯` selection to "Yes, I trust this folder" before pressing - Enter; Claude then remembers trust for the dedicated probe directory. + Enter; Claude then remembers trust for the dedicated probe directory. Trust prompts are only answered there: if + the probe falls back to the shared temporary directory, it cancels the dialog with Escape instead. 3) Send `/usage`, wait for rendered panel; send Enter retries if needed. 4) Dismiss the open panel with Escape before reusing the session for `/status` identity or the next `/usage` refresh. 5) Optionally send `/status` to extract identity fields. From 709ff569c21bce69b9bd73f2806067d03cb62c6c Mon Sep 17 00:00:00 2001 From: sudoHG Date: Tue, 29 Sep 2026 12:55:58 +0800 Subject: [PATCH 079/122] fix(claude): keep the legacy trust prompt response unchanged --- .../Providers/Claude/ClaudeCLISession.swift | 22 +++++++------------ .../ClaudeCLIWorkspaceTrustTests.swift | 3 --- docs/claude.md | 4 ++-- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index fbef341ce2..941afd5f23 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -83,19 +83,13 @@ actor ClaudeCLISession { self.workingDirectory = workingDirectory } - /// Trust prompts are only answered in CodexBar's dedicated probe directory. The workspace trust dialog - /// ("Quick safety check: ...") is handled in `waitForStartup()`: Claude Code 2.1.282 preselects "No, exit", so a - /// bare Enter quits. - static func promptSends(acceptsTrust: Bool) -> [String: String] { - var sends = [ - "Ready to code here?": "\r", - "Press Enter to continue": "\r", - ] - if acceptsTrust { - sends["Do you trust the files in this folder?"] = "y\r" - } - return sends - } + /// The workspace trust dialog ("Quick safety check: ...") is answered in `waitForStartup()`, and only in CodexBar's + /// dedicated probe directory: Claude Code 2.1.282 preselects "No, exit", so a bare Enter would quit. + private let promptSends: [String: String] = [ + "Do you trust the files in this folder?": "y\r", + "Ready to code here?": "\r", + "Press Enter to continue": "\r", + ] private static let startupDelay: TimeInterval = 2.0 private static let workspaceTrustOption = "Yes, I trust this folder" @@ -182,7 +176,7 @@ actor ClaudeCLISession { } let stopNeedles = request.stopOnSubstrings.map { Self.normalizedNeedle($0) } - var sendMap = Self.promptSends(acceptsTrust: self.launchedInProbeDirectory) + var sendMap = self.promptSends for (needle, keys) in Self.commandPaletteSends(for: trimmed) { sendMap[needle] = keys } diff --git a/Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift b/Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift index de4e84988b..764f55fefb 100644 --- a/Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift +++ b/Tests/CodexBarTests/ClaudeCLIWorkspaceTrustTests.swift @@ -68,9 +68,6 @@ struct ClaudeCLIWorkspaceTrustTests { #expect(fallback == FileManager.default.temporaryDirectory) #expect(!ClaudeStatusProbe.isDedicatedProbeWorkingDirectory(fallback)) } - let legacyPrompt = "Do you trust the files in this folder?" - #expect(ClaudeCLISession.promptSends(acceptsTrust: true)[legacyPrompt] == "y\r") - #expect(ClaudeCLISession.promptSends(acceptsTrust: false)[legacyPrompt] == nil) } @Test diff --git a/docs/claude.md b/docs/claude.md index 286b06e9e6..7d9a430d24 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -380,8 +380,8 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential 1) Start CLI with `--allowed-tools ""` (no tools). 2) Auto-respond to first-run prompts (trust files, workspace, telemetry). The workspace trust dialog ("Quick safety check") preselects "No, exit", so the probe moves the `❯` selection to "Yes, I trust this folder" before pressing - Enter; Claude then remembers trust for the dedicated probe directory. Trust prompts are only answered there: if - the probe falls back to the shared temporary directory, it cancels the dialog with Escape instead. + Enter; Claude then remembers trust for the dedicated probe directory. The dialog is only accepted there: if the + probe falls back to the shared temporary directory, it cancels the dialog with Escape instead. 3) Send `/usage`, wait for rendered panel; send Enter retries if needed. 4) Dismiss the open panel with Escape before reusing the session for `/status` identity or the next `/usage` refresh. 5) Optionally send `/status` to extract identity fields. From 503e980f7f895e24e26a4de1a717fd40683201bc Mon Sep 17 00:00:00 2001 From: Mate Remias Date: Tue, 29 Sep 2026 10:09:41 +0200 Subject: [PATCH 080/122] Add codexbar-kde to Linux desktop integrations --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2031a6ee62..91c2ec300c 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,7 @@ CLI install: - [KodexBar](https://github.com/tylxr59/KodexBar) — KDE Plasma widget that shows CodexBar usage in the Plasma panel, built on top of the bundled Linux CLI. - [codexbar-plasmoid](https://github.com/psimaker/codexbar-plasmoid) — KDE Plasma 6 widget for CodexBar's meter icon, provider switcher, quota windows, pace, credits, local cost, and status, powered by the bundled Linux CLI. - [CodexBar Plasma](https://github.com/Lucenx9/codexbar-plasma) — KDE Plasma 6 widget with multi-provider views, account selection, cost history, notifications, configurable providers, and installable `.plasmoid` releases, powered by the bundled Linux CLI. +- [codexbar-kde](https://github.com/materemias/codexbar-kde) — KDE Plasma 6 command center for running many coding agents at once: see which Claude Code, Codex, OpenCode, pi and omp sessions are working or waiting on you, fuzzy-find one and jump to its terminal on any desktop, restore kitty sessions after a crash, and catch usage limits before they hit, powered by the bundled Linux CLI. - [CodexBar Meter](https://github.com/noctalia-dev/community-plugins/tree/main/codexbar-meter) — Noctalia v5 bar widget and panel showing every enabled provider's quota windows, credits, and pace, installable from Noctalia's plugin store, built on the bundled Linux CLI. ## Desk display From 9cbaedbc5c7ee7bdd6f0833f9e864b5cfbf8d3fe Mon Sep 17 00:00:00 2001 From: laitifranz <25352428+laitifranz@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:13:14 +0200 Subject: [PATCH 081/122] fix(claude): show claude-swap path field under its toggle The cswap executable field rendered in the fields area above the Options section, while its toggle's hint said to set the path "below". Let toggle descriptors carry inline fields and attach the path field to the claude-swap toggle so it appears directly beneath it when enabled. Co-Authored-By: Claude Opus 5.5 --- .../PreferencesProviderSettingsRows.swift | 5 +++++ .../Claude/ClaudeProviderImplementation.swift | 19 ++++++++++--------- .../Shared/ProviderSettingsDescriptors.swift | 5 +++++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 8c82e48f4f..46998c17e4 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -33,6 +33,11 @@ struct ProviderSettingsToggleRowView: View { .fixedSize(horizontal: false, vertical: true) } + ForEach(self.toggle.inlineFields) { field in + TextField(L(field.title), text: field.binding, prompt: field.placeholder.map { Text($0) }) + .textFieldStyle(.roundedBorder) + } + let actions = self.toggle.actions.filter { $0.isVisible?() ?? true } if !actions.isEmpty { HStack(spacing: 10) { diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index e6be669ded..fbd9b0e0d3 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -88,6 +88,15 @@ struct ClaudeProviderImplementation: ProviderImplementation { let claudeSwapBinding = context.binding(\.claudeSwapEnabled) let claudeSwapShowSingleAccountBinding = context.binding(\.claudeSwapShowSingleAccount) + let claudeSwapExecutableField = ProviderSettingsFieldDescriptor( + id: "claude-swap-executable-path", + title: "claude-swap executable", + subtitle: "Path to the cswap executable (github.com/realiti4/claude-swap).", + kind: .plain, + placeholder: "~/.local/bin/cswap", + binding: context.binding(\.claudeSwapExecutablePath), + actions: [], + isVisible: nil) return [ ProviderSettingsToggleDescriptor( @@ -149,6 +158,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { binding: claudeSwapBinding, statusText: { Self.claudeSwapStatusText(store: context.store, settings: context.settings) }, actions: [], + inlineFields: [claudeSwapExecutableField], isVisible: nil, isEnabled: nil, onChange: nil, @@ -273,15 +283,6 @@ struct ClaudeProviderImplementation: ProviderImplementation { binding: context.binding(\.claudeAdminAPIKey), actions: [], isVisible: nil), - ProviderSettingsFieldDescriptor( - id: "claude-swap-executable-path", - title: "claude-swap executable", - subtitle: "Path to the cswap executable (github.com/realiti4/claude-swap).", - kind: .plain, - placeholder: "~/.local/bin/cswap", - binding: context.binding(\.claudeSwapExecutablePath), - actions: [], - isVisible: { context.settings.claudeSwapEnabled }), ] } diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index e9c44c3b92..90050065bf 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -98,6 +98,9 @@ struct ProviderSettingsToggleDescriptor: Identifiable { /// Optional actions shown under the toggle when enabled. let actions: [ProviderSettingsActionDescriptor] + /// Optional text fields shown inline under the toggle when enabled. + let inlineFields: [ProviderSettingsFieldDescriptor] + /// Optional runtime visibility gate. let isVisible: (() -> Bool)? @@ -120,6 +123,7 @@ struct ProviderSettingsToggleDescriptor: Identifiable { binding: Binding, statusText: (() -> String?)?, actions: [ProviderSettingsActionDescriptor], + inlineFields: [ProviderSettingsFieldDescriptor] = [], isVisible: (() -> Bool)?, isEnabled: (() -> Bool)? = nil, onChange: ((_ enabled: Bool) async -> Void)?, @@ -132,6 +136,7 @@ struct ProviderSettingsToggleDescriptor: Identifiable { self.binding = binding self.statusText = statusText self.actions = actions + self.inlineFields = inlineFields self.isVisible = isVisible self.isEnabled = isEnabled self.onChange = onChange From 1d854173aef832299925c759f256c1d5e8eb32f8 Mon Sep 17 00:00:00 2001 From: Yash Raj Pandey Date: Tue, 29 Sep 2026 15:17:18 -0400 Subject: [PATCH 082/122] Fall back to Gemini Flash Lite in the menu bar Since #1950, a Gemini snapshot keeps primary nil when the account reports no Pro quota. When the account also reports no Flash quota, only the Flash Lite window is left, in tertiary. The shared menu bar paths read only primary and secondary, so the menu bar showed no value. Resolve the Flash Lite window in the Gemini menu bar hook when Pro and Flash are both absent. Other snapshots and other providers keep the shared behavior. Co-Authored-By: Claude Opus 5.5 --- .../Gemini/GeminiProviderDescriptor.swift | 11 ++++- .../GeminiMenuBarWindowTests.swift | 43 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/GeminiMenuBarWindowTests.swift diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift index 1e45e74882..2f244ed5ab 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift @@ -54,7 +54,16 @@ public enum GeminiProviderDescriptor { let display = UsageFormatter.cleanPlanName(plan) return ProviderIdentityPresentation(badge: display, plan: display) }, - iconDecorations: [.gemini]), + iconDecorations: [.gemini], + menuBarWindowResolver: { context in + // The shared metric paths read only the Pro and Flash lanes, so a Flash Lite-only + // account would show no menu bar value. + guard context.snapshot.primary == nil, + context.snapshot.secondary == nil, + let flashLite = context.snapshot.tertiary + else { return .unhandled } + return .resolved(flashLite) + }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [GeminiStatusFetchStrategy()] })), diff --git a/Tests/CodexBarTests/GeminiMenuBarWindowTests.swift b/Tests/CodexBarTests/GeminiMenuBarWindowTests.swift new file mode 100644 index 0000000000..ec15e2a86a --- /dev/null +++ b/Tests/CodexBarTests/GeminiMenuBarWindowTests.swift @@ -0,0 +1,43 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct GeminiMenuBarWindowTests { + @Test + func `gemini metrics fall back to Flash Lite when Pro and Flash are unavailable`() throws { + let snapshot = try GeminiStatusProbe.parse(text: """ + gemini-2.5-flash-lite 12 60.0% (Resets in 6h) + """).toUsageSnapshot() + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + + for preference in [MenuBarMetricPreference.automatic, .primary, .secondary, .average] { + let window = MenuBarMetricWindowResolver.rateWindow( + preference: preference, + provider: .gemini, + snapshot: snapshot, + supportsAverage: true) + + #expect(window?.usedPercent == 40, "Failed preference: \(preference)") + } + } + + @Test + func `gemini metrics keep Pro over Flash Lite when Flash is unavailable`() throws { + let snapshot = try GeminiStatusProbe.parse(text: """ + gemini-2.5-pro 3 70.0% (Resets in 24h) + gemini-2.5-flash-lite 12 60.0% (Resets in 6h) + """).toUsageSnapshot() + + for preference in [MenuBarMetricPreference.automatic, .primary, .secondary, .average] { + let window = MenuBarMetricWindowResolver.rateWindow( + preference: preference, + provider: .gemini, + snapshot: snapshot, + supportsAverage: true) + + #expect(window?.usedPercent == 30, "Failed preference: \(preference)") + } + } +} From 6c070c72731e1b85495ec75123e6ef80bd9c54c1 Mon Sep 17 00:00:00 2001 From: zleo-ai <54388301+zleo-ai@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:23:10 +0800 Subject: [PATCH 083/122] feat(notifications): add opt-in limit reset notifications Reset confetti signals that a quota came back but not which one. Post a system notification naming the provider and the session or weekly window (plus the account unless Hide personal info is on) when the existing reset detector publishes a reset. Skip a session reset notice that a "session restored" notice already covered in the last 10 minutes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + .../CodexBar/LimitResetNotifications.swift | 59 +++++ .../PreferencesNotificationsPane.swift | 6 + .../Resources/ar.lproj/Localizable.strings | 5 + .../Resources/ca.lproj/Localizable.strings | 5 + .../Resources/de.lproj/Localizable.strings | 5 + .../Resources/en.lproj/Localizable.strings | 5 + .../Resources/es.lproj/Localizable.strings | 5 + .../Resources/fa.lproj/Localizable.strings | 5 + .../Resources/fr.lproj/Localizable.strings | 5 + .../Resources/gl.lproj/Localizable.strings | 5 + .../Resources/id.lproj/Localizable.strings | 5 + .../Resources/it.lproj/Localizable.strings | 5 + .../Resources/ja.lproj/Localizable.strings | 5 + .../Resources/ko.lproj/Localizable.strings | 5 + .../Resources/nl.lproj/Localizable.strings | 5 + .../Resources/pl.lproj/Localizable.strings | 5 + .../Resources/pt-BR.lproj/Localizable.strings | 5 + .../Resources/ru.lproj/Localizable.strings | 5 + .../Resources/sv.lproj/Localizable.strings | 5 + .../Resources/th.lproj/Localizable.strings | 5 + .../Resources/tr.lproj/Localizable.strings | 5 + .../Resources/uk.lproj/Localizable.strings | 5 + .../Resources/vi.lproj/Localizable.strings | 5 + .../zh-Hans.lproj/Localizable.strings | 5 + .../zh-Hant.lproj/Localizable.strings | 5 + .../CodexBar/SessionQuotaNotifications.swift | 14 ++ Sources/CodexBar/SettingsStore+Defaults.swift | 5 + .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 6 +- Sources/CodexBar/SettingsStoreState.swift | 1 + .../UsageStore+LimitResetCelebration.swift | 8 + .../UsageStore+SessionQuotaTransition.swift | 3 + Sources/CodexBar/UsageStore.swift | 1 + .../Config/PreferencesDocument.swift | 6 +- .../LimitResetNotificationTests.swift | 236 ++++++++++++++++++ docs/configuration.md | 2 +- 37 files changed, 462 insertions(+), 5 deletions(-) create mode 100644 Sources/CodexBar/LimitResetNotifications.swift create mode 100644 Tests/CodexBarTests/LimitResetNotificationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 16191d5924..611106f0bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.69.1 — Unreleased +### Added + +- Notifications: opt-in reset notifications (Settings → Notifications) name the provider, the session or weekly window, and the account unless Hide personal info is on, so a reset confetti burst is no longer a mystery. A session reset already announced as "session restored" is not notified twice. + ### Changed - Provider colors: refresh 16 verified brand accents while preserving readable menu colors and existing widget palettes; synchronize website and social preview colors (#4075). Thanks @elijahfriedman! diff --git a/Sources/CodexBar/LimitResetNotifications.swift b/Sources/CodexBar/LimitResetNotifications.swift new file mode 100644 index 0000000000..b1598717b2 --- /dev/null +++ b/Sources/CodexBar/LimitResetNotifications.swift @@ -0,0 +1,59 @@ +import CodexBarCore +import Foundation + +enum LimitResetNotificationLogic { + /// A depleted session that comes back already posts "session restored" in the same refresh. + static let sessionRestoredDedupInterval: TimeInterval = 10 * 60 + + static func notificationIDPrefix(provider: UsageProvider, window: QuotaWarningWindow) -> String { + "limit-reset-\(provider.rawValue)-\(window.rawValue)" + } + + static func notificationCopy( + providerName: String, + window: QuotaWarningWindow, + accountDisplayName: String?) -> (title: String, body: String) + { + let title = L("limit_reset_notification_title", providerName, window.localizedNotificationDisplayName) + let body = if let accountDisplayName { + L("limit_reset_notification_body_with_account", accountDisplayName) + } else { + L("limit_reset_notification_body") + } + return (title, body) + } + + static func suppressesSessionReset(restoredPostedAt: Date?, now: Date) -> Bool { + guard let restoredPostedAt else { return false } + return abs(now.timeIntervalSince(restoredPostedAt)) < self.sessionRestoredDedupInterval + } +} + +@MainActor +extension UsageStore { + func postLimitResetNotificationIfNeeded( + provider: UsageProvider, + window: QuotaWarningWindow, + accountLabel: String?, + now: Date = Date()) + { + guard self.settings.limitResetNotificationsEnabled else { return } + if window == .session, + LimitResetNotificationLogic.suppressesSessionReset( + restoredPostedAt: self.sessionRestoredNotificationPostedAt[provider.instanceID], + now: now) + { + self.sessionQuotaLogger.debug( + "session reset notice covered by session restored: provider=\(provider.rawValue)") + return + } + let trimmedLabel = accountLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + let accountDisplayName = self.settings.hidePersonalInfo || trimmedLabel?.isEmpty != false + ? nil + : trimmedLabel + self.sessionQuotaNotifier.postLimitReset( + provider: provider, + window: window, + accountDisplayName: accountDisplayName) + } +} diff --git a/Sources/CodexBar/PreferencesNotificationsPane.swift b/Sources/CodexBar/PreferencesNotificationsPane.swift index f95e57c1be..623563ab8f 100644 --- a/Sources/CodexBar/PreferencesNotificationsPane.swift +++ b/Sources/CodexBar/PreferencesNotificationsPane.swift @@ -31,6 +31,12 @@ struct NotificationsPane: View { subtitle: L("predictive_pace_warnings_subtitle")) } + Toggle(isOn: self.$settings.limitResetNotificationsEnabled) { + SettingsRowLabel( + L("limit_reset_notifications_title"), + subtitle: L("limit_reset_notifications_subtitle")) + } + let warningSettingsVisibility = QuotaWarningSettingsVisibility( thresholdWarningsEnabled: self.settings.quotaWarningNotificationsEnabled, predictiveWarningsEnabled: self.settings.predictivePaceWarningNotificationsEnabled) diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index ea3dbae929..f116ef66f9 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -525,6 +525,11 @@ "session_depleted_notification_body" = "المتبقي 0%. سنبلغك عندما تصبح الحصة متاحة مجددًا."; "session_restored_notification_title" = "%@ الجلسة التي استعادت"; "session_restored_notification_body" = "حصة الجلسة متاحة مرة أخرى."; +"limit_reset_notifications_title" = "إشعارات إعادة التعيين"; +"limit_reset_notifications_subtitle" = "يُعلمك بالمزوّد والنافذة عند إعادة تعيين حد الجلسة أو الحد الأسبوعي."; +"limit_reset_notification_title" = "تمت إعادة تعيين حد %2$@ لدى %1$@"; +"limit_reset_notification_body" = "تتوفر حصة جديدة."; +"limit_reset_notification_body_with_account" = "الحساب %@. تتوفر حصة جديدة."; "quota_warning_warn_at" = "التحذير في"; "quota_warning_global_threshold_subtitle" = "النسب المتبقية للجلسات والفترات الأسبوعية ما لم يتجاوزها مقدم الخدمة."; "quota_warning_sound" = "تشغيل صوت الإشعار"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index d2877fabe8..f26bd970e0 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1280,6 +1280,11 @@ "session_depleted_notification_body" = "Queda un 0%. Es notificarà quan torni a estar disponible."; "session_depleted_notification_title" = "Quota de sessió de %@ esgotada"; "session_restored_notification_body" = "La quota de sessió torna a estar disponible."; +"limit_reset_notifications_title" = "Avisos de restabliment"; +"limit_reset_notifications_subtitle" = "Indica el proveïdor i la finestra quan es restableix un límit de sessió o setmanal."; +"limit_reset_notification_title" = "Límit de %1$@ restablert (%2$@)"; +"limit_reset_notification_body" = "Hi ha quota nova disponible."; +"limit_reset_notification_body_with_account" = "Compte %@. Hi ha quota nova disponible."; "session_restored_notification_title" = "Quota de sessió de %@ restablerta"; "Settings..." = "Configuració..."; "Source" = "Origen"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 8be276122d..dff37ba90b 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -539,6 +539,11 @@ "session_depleted_notification_body" = "0 % übrig. Werde benachrichtigen, wenn es wieder verfügbar ist."; "session_restored_notification_title" = "%@ Sitzung wiederhergestellt"; "session_restored_notification_body" = "Das Sitzungskontingent ist wieder verfügbar."; +"limit_reset_notifications_title" = "Reset-Benachrichtigungen"; +"limit_reset_notifications_subtitle" = "Nennt Anbieter und Zeitfenster, wenn ein Sitzungs- oder Wochenlimit zurückgesetzt wird."; +"limit_reset_notification_title" = "%1$@ %2$@ Limit zurückgesetzt"; +"limit_reset_notification_body" = "Neues Kontingent ist verfügbar."; +"limit_reset_notification_body_with_account" = "Konto %@. Neues Kontingent ist verfügbar."; "quota_warning_warn_at" = "Warnen Sie vor"; "quota_warning_global_threshold_subtitle" = "Verbleibende Prozentsätze für Sitzungs- und Wochenfenster, es sei denn, ein Anbieter überschreibt sie."; "quota_warning_sound" = "Benachrichtigungston abspielen"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 10d3243f21..37cdff4372 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -506,6 +506,11 @@ "session_depleted_notification_body" = "0% left. Will notify when it's available again."; "session_restored_notification_title" = "%@ session restored"; "session_restored_notification_body" = "Session quota is available again."; +"limit_reset_notifications_title" = "Reset notifications"; +"limit_reset_notifications_subtitle" = "Names the provider and window when a session or weekly limit resets."; +"limit_reset_notification_title" = "%1$@ %2$@ limit reset"; +"limit_reset_notification_body" = "Fresh quota is available."; +"limit_reset_notification_body_with_account" = "Account %@. Fresh quota is available."; "quota_warning_warn_at" = "Warn at"; "quota_warning_global_threshold_subtitle" = "Remaining percentages for session and weekly windows unless a provider overrides them."; "quota_warning_sound" = "Play notification sound"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index b603a722ed..42abf3f8b9 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1310,6 +1310,11 @@ "session_depleted_notification_body" = "Queda un 0%. Se te avisará cuando vuelva a estar disponible."; "session_depleted_notification_title" = "Cuota de sesión de %@ agotada"; "session_restored_notification_body" = "La cuota de sesión vuelve a estar disponible."; +"limit_reset_notifications_title" = "Avisos de reinicio"; +"limit_reset_notifications_subtitle" = "Indica el proveedor y la ventana cuando se reinicia un límite de sesión o semanal."; +"limit_reset_notification_title" = "%1$@: límite de %2$@ reiniciado"; +"limit_reset_notification_body" = "Hay cuota nueva disponible."; +"limit_reset_notification_body_with_account" = "Cuenta %@. Hay cuota nueva disponible."; "session_restored_notification_title" = "Cuota de sesión de %@ restablecida"; "Settings..." = "Ajustes..."; "Sign in with Claude Code..." = "Iniciar sesión con Claude Code..."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index af9a059ec2..c106972b82 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -525,6 +525,11 @@ "session_depleted_notification_body" = "0% باقی مانده است. وقتی دوباره در دسترس قرار گیرد اطلاع می‌دهیم."; "session_restored_notification_title" = "جلسه %@ بازیابی شد"; "session_restored_notification_body" = "سهمیه جلسه دوباره در دسترس است."; +"limit_reset_notifications_title" = "اعلان‌های بازنشانی"; +"limit_reset_notifications_subtitle" = "هنگام بازنشانی محدودیت جلسه یا هفتگی، ارائه‌دهنده و بازه را اعلام می‌کند."; +"limit_reset_notification_title" = "محدودیت %2$@ در %1$@ بازنشانی شد"; +"limit_reset_notification_body" = "سهمیه جدید در دسترس است."; +"limit_reset_notification_body_with_account" = "حساب %@. سهمیه جدید در دسترس است."; "quota_warning_warn_at" = "هشدار در"; "quota_warning_global_threshold_subtitle" = "درصدهای باقی مانده برای جلسات و بازه های هفتگی مگر اینکه ارائه دهنده آن ها را لغو کند."; "quota_warning_sound" = "صدای اعلان پخش کن"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index c3ed832fad..6328a136d3 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -541,6 +541,11 @@ "session_depleted_notification_body" = "0 % restant. Vous serez notifié quand elle redeviendra disponible."; "session_restored_notification_title" = "Session %@ rétablie"; "session_restored_notification_body" = "Le quota de session est à nouveau disponible."; +"limit_reset_notifications_title" = "Notifications de réinitialisation"; +"limit_reset_notifications_subtitle" = "Indique le fournisseur et la fenêtre lorsqu'une limite de session ou hebdomadaire est réinitialisée."; +"limit_reset_notification_title" = "%1$@ %2$@ : limite réinitialisée"; +"limit_reset_notification_body" = "Un nouveau quota est disponible."; +"limit_reset_notification_body_with_account" = "Compte %@. Un nouveau quota est disponible."; "quota_warning_warn_at" = "Avertir à"; "quota_warning_global_threshold_subtitle" = "Pourcentages restants pour les fenêtres de session et hebdomadaires, sauf si un fournisseur les remplace."; "quota_warning_sound" = "Lire un son de notification"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 8bd432a554..e851e0de35 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1312,6 +1312,11 @@ "session_depleted_notification_body" = "Queda un 0%. Avisarémoste cando volva estar dispoñible."; "session_depleted_notification_title" = "Sesión de %@ esgotada"; "session_restored_notification_body" = "A cota da sesión volve estar dispoñible."; +"limit_reset_notifications_title" = "Avisos de restablecemento"; +"limit_reset_notifications_subtitle" = "Indica o provedor e a xanela cando se restablece un límite de sesión ou semanal."; +"limit_reset_notification_title" = "Límite %2$@ de %1$@ restablecido"; +"limit_reset_notification_body" = "Hai cota nova dispoñible."; +"limit_reset_notification_body_with_account" = "Conta %@. Hai cota nova dispoñible."; "session_restored_notification_title" = "Sesión de %@ restaurada"; "today" = "hoxe"; "usage_percent_suffix_left" = "restante"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 1802137505..f30d03e76a 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -527,6 +527,11 @@ "session_depleted_notification_body" = "0% tersisa. Akan memberi tahu saat tersedia kembali."; "session_restored_notification_title" = "Sesi %@ pulih"; "session_restored_notification_body" = "Kuota sesi tersedia kembali."; +"limit_reset_notifications_title" = "Notifikasi reset"; +"limit_reset_notifications_subtitle" = "Memberi tahu penyedia dan jendela yang direset saat batas sesi atau mingguan direset."; +"limit_reset_notification_title" = "Batas %2$@ %1$@ direset"; +"limit_reset_notification_body" = "Kuota baru tersedia."; +"limit_reset_notification_body_with_account" = "Akun %@. Kuota baru tersedia."; "quota_warning_warn_at" = "Peringatkan pada"; "quota_warning_global_threshold_subtitle" = "Persentase sisa untuk jendela sesi dan mingguan kecuali penyedia menimpanya."; "quota_warning_sound" = "Mainkan suara notifikasi"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 2477449503..023daca2b2 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -527,6 +527,11 @@ "session_depleted_notification_body" = "Rimane lo 0%. Ti avviseremo quando tornerà disponibile."; "session_restored_notification_title" = "Sessione %@ ripristinata"; "session_restored_notification_body" = "La quota di sessione è di nuovo disponibile."; +"limit_reset_notifications_title" = "Notifiche di azzeramento"; +"limit_reset_notifications_subtitle" = "Indica il provider e la finestra quando un limite di sessione o settimanale si azzera."; +"limit_reset_notification_title" = "Limite %2$@ di %1$@ azzerato"; +"limit_reset_notification_body" = "È disponibile una nuova quota."; +"limit_reset_notification_body_with_account" = "Account %@. È disponibile una nuova quota."; "quota_warning_warn_at" = "Avvisa a"; "quota_warning_global_threshold_subtitle" = "Percentuali residue per le finestre di sessione e settimanale, salvo override del provider."; "quota_warning_sound" = "Riproduci suono di notifica"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 1880d5bad4..0f411cce95 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -538,6 +538,11 @@ "session_depleted_notification_body" = "残り 0% です。再び利用可能になったら通知します。"; "session_restored_notification_title" = "%@ のセッションが回復しました"; "session_restored_notification_body" = "セッションクォータが再び利用可能になりました。"; +"limit_reset_notifications_title" = "リセット通知"; +"limit_reset_notifications_subtitle" = "セッションまたは週間の上限がリセットされたときに、どのプロバイダのどの枠かを通知します。"; +"limit_reset_notification_title" = "%1$@ の%2$@上限がリセットされました"; +"limit_reset_notification_body" = "新しいクォータが利用可能です。"; +"limit_reset_notification_body_with_account" = "アカウント %@。新しいクォータが利用可能です。"; "quota_warning_warn_at" = "警告する残量"; "quota_warning_global_threshold_subtitle" = "プロバイダ側で上書きされない限り、セッションおよび週間ウインドウの残量パーセントに適用されます。"; "quota_warning_sound" = "通知音を再生"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index aaec0a3d49..84d5d51651 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -537,6 +537,11 @@ "session_depleted_notification_body" = "0% 남음. 다시 사용할 수 있게 되면 알립니다."; "session_restored_notification_title" = "%@ 세션 복원됨"; "session_restored_notification_body" = "세션 할당량을 다시 사용할 수 있습니다."; +"limit_reset_notifications_title" = "재설정 알림"; +"limit_reset_notifications_subtitle" = "세션 또는 주간 한도가 재설정되면 어떤 공급자의 어떤 한도인지 알려줍니다."; +"limit_reset_notification_title" = "%1$@ %2$@ 한도 재설정됨"; +"limit_reset_notification_body" = "새 할당량을 사용할 수 있습니다."; +"limit_reset_notification_body_with_account" = "계정 %@. 새 할당량을 사용할 수 있습니다."; "quota_warning_warn_at" = "경고 기준"; "quota_warning_global_threshold_subtitle" = "공급자가 재정의하지 않는 한 세션 및 주간 범위의 잔여 백분율입니다."; "quota_warning_sound" = "알림 소리 재생"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 92bf3ccc35..01879acdac 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -541,6 +541,11 @@ "session_depleted_notification_body" = "0% over. Zal op de hoogte stellen wanneer het weer beschikbaar is."; "session_restored_notification_title" = "%@ sessie hersteld"; "session_restored_notification_body" = "Sessiequota zijn weer beschikbaar."; +"limit_reset_notifications_title" = "Resetmeldingen"; +"limit_reset_notifications_subtitle" = "Meldt welke provider en welk venster zijn gereset wanneer een sessie- of weeklimiet wordt gereset."; +"limit_reset_notification_title" = "%1$@ %2$@ limiet gereset"; +"limit_reset_notification_body" = "Er is weer nieuw quotum beschikbaar."; +"limit_reset_notification_body_with_account" = "Account %@. Er is weer nieuw quotum beschikbaar."; "quota_warning_warn_at" = "Waarschuw bij"; "quota_warning_global_threshold_subtitle" = "Resterende percentages voor sessie- en wekelijkse vensters, tenzij een provider deze overschrijft."; "quota_warning_sound" = "Meldingsgeluid afspelen"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 17a45ff52a..ef7bf59f59 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -527,6 +527,11 @@ "session_depleted_notification_body" = "Pozostało 0%. Powiadomimy, gdy limit będzie ponownie dostępny."; "session_restored_notification_title" = "Przywrócono limit sesji (%@)"; "session_restored_notification_body" = "Limit sesji jest ponownie dostępny."; +"limit_reset_notifications_title" = "Powiadomienia o resecie"; +"limit_reset_notifications_subtitle" = "Informuje, u którego dostawcy i w jakim oknie zresetował się limit sesji lub tygodniowy."; +"limit_reset_notification_title" = "Zresetowano limit %1$@ (%2$@)"; +"limit_reset_notification_body" = "Nowy limit jest dostępny."; +"limit_reset_notification_body_with_account" = "Konto %@. Nowy limit jest dostępny."; "quota_warning_warn_at" = "Ostrzegaj przy"; "quota_warning_global_threshold_subtitle" = "Procent pozostałego limitu dla okien sesji i tygodnia, chyba że dostawca ma nadpisanie."; "quota_warning_sound" = "Odtwarzaj dźwięk powiadomienia"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 0ac9990785..3d7be5d2a6 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -538,6 +538,11 @@ "session_depleted_notification_body" = "0% restante. Avisaremos quando estiver disponível novamente."; "session_restored_notification_title" = "Sessão do %@ restaurada"; "session_restored_notification_body" = "A cota de sessão está disponível novamente."; +"limit_reset_notifications_title" = "Avisos de redefinição"; +"limit_reset_notifications_subtitle" = "Informa o provedor e a janela quando um limite de sessão ou semanal é redefinido."; +"limit_reset_notification_title" = "%1$@: limite redefinido (%2$@)"; +"limit_reset_notification_body" = "Há cota nova disponível."; +"limit_reset_notification_body_with_account" = "Conta %@. Há cota nova disponível."; "quota_warning_warn_at" = "Alertar em"; "quota_warning_global_threshold_subtitle" = "Percentuais restantes para as janelas de sessão e semanal, a menos que um provedor defina valores próprios."; "quota_warning_sound" = "Reproduzir som de notificação"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index cffbabb49f..4994656dff 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -542,6 +542,11 @@ "session_depleted_notification_body" = "Осталось 0%. Сообщим, когда квота снова станет доступна."; "session_restored_notification_title" = "Сеанс %@ восстановлен"; "session_restored_notification_body" = "Квота сеанса снова доступна."; +"limit_reset_notifications_title" = "Уведомления о сбросе"; +"limit_reset_notifications_subtitle" = "Сообщает, у какого провайдера и в каком окне сбросился лимит сеанса или недели."; +"limit_reset_notification_title" = "Лимит %1$@ сброшен (%2$@)"; +"limit_reset_notification_body" = "Новая квота доступна."; +"limit_reset_notification_body_with_account" = "Аккаунт %@. Новая квота доступна."; "quota_warning_warn_at" = "Предупреждать при"; "quota_warning_global_threshold_subtitle" = "Остаток в процентах для сессионного и недельного окон, если провайдер не переопределяет пороги."; "quota_warning_sound" = "Воспроизвести звук уведомления"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 817607c1fb..d39940503a 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -541,6 +541,11 @@ "session_depleted_notification_body" = "0 % kvar. Du får en avisering när den är tillgänglig igen."; "session_restored_notification_title" = "%@-sessionen är återställd"; "session_restored_notification_body" = "Sessionskvoten är tillgänglig igen."; +"limit_reset_notifications_title" = "Återställningsaviseringar"; +"limit_reset_notifications_subtitle" = "Visar vilken leverantör och vilket fönster som återställdes när en sessions- eller veckogräns återställs."; +"limit_reset_notification_title" = "Gräns för %1$@ återställd (%2$@)"; +"limit_reset_notification_body" = "Ny kvot är tillgänglig."; +"limit_reset_notification_body_with_account" = "Konto %@. Ny kvot är tillgänglig."; "quota_warning_warn_at" = "Varna vid"; "quota_warning_global_threshold_subtitle" = "Återstående procent för sessions- och veckofönster, om inte en leverantör åsidosätter dem."; "quota_warning_sound" = "Spela aviseringsljud"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 530d738858..0f31b0155b 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -525,6 +525,11 @@ "session_depleted_notification_body" = "เหลือ 0% จะแจ้งเตือนเมื่อกลับมาใช้งานได้อีกครั้ง"; "session_restored_notification_title" = "%@ เซสชันที่กู้คืน"; "session_restored_notification_body" = "โควต้าเซสชันพร้อมใช้งานอีกครั้ง"; +"limit_reset_notifications_title" = "การแจ้งเตือนการรีเซ็ต"; +"limit_reset_notifications_subtitle" = "แจ้งว่าผู้ให้บริการใดและช่วงใดถูกรีเซ็ตเมื่อขีดจำกัดเซสชันหรือรายสัปดาห์รีเซ็ต"; +"limit_reset_notification_title" = "ขีดจำกัด%2$@ของ %1$@ รีเซ็ตแล้ว"; +"limit_reset_notification_body" = "มีโควตาใหม่พร้อมใช้งาน"; +"limit_reset_notification_body_with_account" = "บัญชี %@ มีโควตาใหม่พร้อมใช้งาน"; "quota_warning_warn_at" = "เตือนที่"; "quota_warning_global_threshold_subtitle" = "เปอร์เซ็นต์ที่เหลืออยู่สําหรับกรอบเวลาเซสชันและรายสัปดาห์ เว้นแต่ผู้ให้บริการจะแทนที่"; "quota_warning_sound" = "เล่นเสียงแจ้งเตือน"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 5f05a401f9..7a5029bf02 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -525,6 +525,11 @@ "session_depleted_notification_body" = "0% kaldı. Tekrar kullanılabilir olduğunda bildirilecek."; "session_restored_notification_title" = "%@ oturumu geri yüklendi"; "session_restored_notification_body" = "Oturum kotası tekrar kullanılabilir."; +"limit_reset_notifications_title" = "Sıfırlama bildirimleri"; +"limit_reset_notifications_subtitle" = "Bir oturum veya haftalık limit sıfırlandığında hangi sağlayıcı ve pencere olduğunu bildirir."; +"limit_reset_notification_title" = "%1$@ %2$@ limiti sıfırlandı"; +"limit_reset_notification_body" = "Yeni kota kullanılabilir."; +"limit_reset_notification_body_with_account" = "Hesap %@. Yeni kota kullanılabilir."; "quota_warning_warn_at" = "Uyarı eşikleri"; "quota_warning_global_threshold_subtitle" = "Bir sağlayıcı kendi değerini belirlemedikçe oturum ve haftalık pencereler için kalan kota yüzdesi eşiklerini belirler."; "quota_warning_sound" = "Bildirim sesi çal"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 22a202310d..11ee28a128 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -541,6 +541,11 @@ "session_depleted_notification_body" = "Залишилося 0%. Надішле сповіщення, коли знову стане доступним."; "session_restored_notification_title" = "%@ сеанс відновлено"; "session_restored_notification_body" = "Квота сесії знову доступна."; +"limit_reset_notifications_title" = "Сповіщення про скидання"; +"limit_reset_notifications_subtitle" = "Повідомляє, у якого провайдера та в якому вікні скинуто ліміт сесії або тижня."; +"limit_reset_notification_title" = "Ліміт %1$@ скинуто (%2$@)"; +"limit_reset_notification_body" = "Нова квота доступна."; +"limit_reset_notification_body_with_account" = "Обліковий запис %@. Нова квота доступна."; "quota_warning_warn_at" = "Попередити при"; "quota_warning_global_threshold_subtitle" = "Відсотки, що залишилися для вікон сесії та тижня, якщо постачальник не замінить їх."; "quota_warning_sound" = "Відтворити звук сповіщення"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index e161baf8a7..5971dacb6f 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -537,6 +537,11 @@ "session_depleted_notification_body" = "còn lại 0%. Sẽ thông báo khi có lại."; "session_restored_notification_title" = "%@ phiên đã được khôi phục"; "session_restored_notification_body" = "Phiên Hạn mức đã có sẵn trở lại."; +"limit_reset_notifications_title" = "Thông báo đặt lại"; +"limit_reset_notifications_subtitle" = "Cho biết nhà cung cấp và khung nào được đặt lại khi giới hạn phiên hoặc hàng tuần được đặt lại."; +"limit_reset_notification_title" = "%1$@: giới hạn %2$@ đã được đặt lại"; +"limit_reset_notification_body" = "Hạn mức mới đã sẵn sàng."; +"limit_reset_notification_body_with_account" = "Tài khoản %@. Hạn mức mới đã sẵn sàng."; "quota_warning_warn_at" = "Cảnh báo ở"; "quota_warning_global_threshold_subtitle" = "Tỷ lệ phần trăm còn lại cho phiên và thời lượng hàng tuần trừ khi Nhà cung cấp ghi đè chúng."; "quota_warning_sound" = "Phát âm thanh thông báo"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index ebe3fb66a3..aea29f9d92 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1054,6 +1054,11 @@ "session_depleted_notification_body" = "剩余 0%。可用时会通知你。"; "session_restored_notification_title" = "%@ 会话已恢复"; "session_restored_notification_body" = "会话额度已重新可用。"; +"limit_reset_notifications_title" = "额度重置通知"; +"limit_reset_notifications_subtitle" = "会话或每周额度重置时,通知是哪个提供商的哪项额度。"; +"limit_reset_notification_title" = "%1$@ %2$@额度已重置"; +"limit_reset_notification_body" = "新额度已可用。"; +"limit_reset_notification_body_with_account" = "账户 %@。新额度已可用。"; "quota_warning_notification_title" = "%1$@ 的 %2$@ 额度偏低"; "quota_warning_notification_body" = "剩余 %1$@。已达到 %2$d%% 的 %3$@ 预警阈值。"; "quota_warning_notification_body_with_account" = "账户 %1$@。剩余 %2$@。已达到 %3$d%% 的 %4$@ 预警阈值。"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index b1bd36d6dc..7767ad3784 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -546,6 +546,11 @@ "session_depleted_notification_body" = "剩餘 0%。恢復可用時會再通知。"; "session_restored_notification_title" = "%@ 工作階段已恢復"; "session_restored_notification_body" = "工作階段配額已恢復可用。"; +"limit_reset_notifications_title" = "配額重設通知"; +"limit_reset_notifications_subtitle" = "工作階段或每週配額重設時,通知是哪個提供者的哪項配額。"; +"limit_reset_notification_title" = "%1$@ %2$@配額已重設"; +"limit_reset_notification_body" = "新配額已可用。"; +"limit_reset_notification_body_with_account" = "帳號 %@。新配額已可用。"; "quota_warning_warn_at" = "提醒門檻"; "quota_warning_global_threshold_subtitle" = "工作階段和每週時段的剩餘百分比,除非提供者另有設定。"; "quota_warning_sound" = "播放通知音效"; diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 59bf497cf6..b9746c5359 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -508,6 +508,7 @@ protocol SessionQuotaNotifying: AnyObject { soundEnabled: Bool, onScreenAlertEnabled: Bool, now: Date) + func postLimitReset(provider: UsageProvider, window: QuotaWarningWindow, accountDisplayName: String?) } @MainActor @@ -519,6 +520,8 @@ extension SessionQuotaNotifying { onScreenAlertEnabled _: Bool, now _: Date) {} + + func postLimitReset(provider _: UsageProvider, window _: QuotaWarningWindow, accountDisplayName _: String?) {} } @MainActor @@ -599,6 +602,17 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { } AppNotifications.shared.post(idPrefix: idPrefix, title: copy.title, body: copy.body, soundEnabled: false) } + + func postLimitReset(provider: UsageProvider, window: QuotaWarningWindow, accountDisplayName: String?) { + let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + let copy = LimitResetNotificationLogic.notificationCopy( + providerName: providerName, + window: window, + accountDisplayName: accountDisplayName) + let idPrefix = LimitResetNotificationLogic.notificationIDPrefix(provider: provider, window: window) + self.logger.info("enqueuing", metadata: ["prefix": idPrefix]) + AppNotifications.shared.post(idPrefix: idPrefix, title: copy.title, body: copy.body) + } } extension QuotaWarningWindow { diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index e8982704ee..3e62095196 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -185,6 +185,11 @@ extension SettingsStore { } } + var limitResetNotificationsEnabled: Bool { + get { self.defaultsState.limitResetNotificationsEnabled } + set { self.setDefault(\.limitResetNotificationsEnabled, newValue, key: "limitResetNotificationsEnabled") } + } + var quotaWarningThresholds: [Int] { get { QuotaWarningThresholds.sanitized(self.defaultsState.quotaWarningThresholdsRaw) } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index cebcaaa5ee..1a7c601f91 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -14,6 +14,7 @@ extension SettingsStore { _ = self.sessionQuotaNotificationsEnabled _ = self.quotaWarningNotificationsEnabled _ = self.predictivePaceWarningNotificationsEnabled + _ = self.limitResetNotificationsEnabled _ = self.quotaWarningThresholds _ = self.quotaWarningThresholds(.session) _ = self.quotaWarningThresholds(.weekly) diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 2d42659c38..b3c857ea40 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -448,6 +448,7 @@ extension SettingsStore { let statusChecksEnabled: Bool let sessionQuotaNotificationsEnabled: Bool let predictivePaceWarningNotificationsEnabled: Bool + let limitResetNotificationsEnabled: Bool } private static func scheduleAppGroupMigration() { @@ -556,6 +557,7 @@ extension SettingsStore { sessionQuotaNotificationsEnabled: notificationDefaults.sessionQuotaNotificationsEnabled, quotaWarningNotificationsEnabled: quotaWarnings.notificationsEnabled, predictivePaceWarningNotificationsEnabled: notificationDefaults.predictivePaceWarningNotificationsEnabled, + limitResetNotificationsEnabled: notificationDefaults.limitResetNotificationsEnabled, quotaWarningThresholdsRaw: quotaWarnings.thresholdsRaw, quotaWarningSessionThresholdsRaw: quotaWarnings.sessionThresholdsRaw, quotaWarningWeeklyThresholdsRaw: quotaWarnings.weeklyThresholdsRaw, @@ -740,7 +742,9 @@ extension SettingsStore { fallback: true, from: userDefaults), predictivePaceWarningNotificationsEnabled: userDefaults.object( - forKey: "predictivePaceWarningNotificationsEnabled") as? Bool ?? false) + forKey: "predictivePaceWarningNotificationsEnabled") as? Bool ?? false, + limitResetNotificationsEnabled: userDefaults.object( + forKey: "limitResetNotificationsEnabled") as? Bool ?? false) } private static func loadCostSummaryDisplayStyleRaw( diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index d7c16df6c6..3e7d54d76e 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -18,6 +18,7 @@ struct SettingsDefaultsState { var sessionQuotaNotificationsEnabled: Bool var quotaWarningNotificationsEnabled: Bool var predictivePaceWarningNotificationsEnabled: Bool + var limitResetNotificationsEnabled: Bool var quotaWarningThresholdsRaw: [Int] var quotaWarningSessionThresholdsRaw: [Int] var quotaWarningWeeklyThresholdsRaw: [Int] diff --git a/Sources/CodexBar/UsageStore+LimitResetCelebration.swift b/Sources/CodexBar/UsageStore+LimitResetCelebration.swift index 89fbcb3424..aa2fa44d06 100644 --- a/Sources/CodexBar/UsageStore+LimitResetCelebration.swift +++ b/Sources/CodexBar/UsageStore+LimitResetCelebration.swift @@ -235,6 +235,10 @@ extension UsageStore { window: .session, usedPercent: currentUsed, accountLabel: accountLabel) + self.postLimitResetNotificationIfNeeded( + provider: context.provider, + window: .session, + accountLabel: accountLabel) let event = SessionLimitResetEvent( provider: context.provider, accountIdentifier: accountIdentifier, @@ -247,6 +251,10 @@ extension UsageStore { window: .weekly, usedPercent: currentUsed, accountLabel: accountLabel) + self.postLimitResetNotificationIfNeeded( + provider: context.provider, + window: .weekly, + accountLabel: accountLabel) let event = WeeklyLimitResetEvent( provider: context.provider, accountIdentifier: accountIdentifier, diff --git a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift index 21fb514142..b96f21118e 100644 --- a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift +++ b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift @@ -116,6 +116,9 @@ extension UsageStore { sessionWindow: sessionWindow, snapshot: snapshot, notificationsEnabled: notificationsEnabled) + if transition == .restored, notificationsEnabled { + self.sessionRestoredNotificationPostedAt[provider.instanceID] = now + } } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index dc9ef7292e..95efcba81c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -434,6 +434,7 @@ final class UsageStore { @ObservationIgnored var claudeHistoryFallbackEligible = false @ObservationIgnored var deepseekProfileTransition: DeepSeekProfileTransition? @ObservationIgnored var sessionQuotaTransitionStates: [ProviderInstanceID: SessionQuotaTransitionState] = [:] + @ObservationIgnored var sessionRestoredNotificationPostedAt: [ProviderInstanceID: Date] = [:] @ObservationIgnored var codexSessionQuotaBaselineRequirement: CodexSessionQuotaBaselineRequirement? var codexSessionQuotaBaselineRequired: Bool { self.codexSessionQuotaBaselineRequirement != nil diff --git a/Sources/CodexBarCore/Config/PreferencesDocument.swift b/Sources/CodexBarCore/Config/PreferencesDocument.swift index ed5ac9e314..ed731c07c3 100644 --- a/Sources/CodexBarCore/Config/PreferencesDocument.swift +++ b/Sources/CodexBarCore/Config/PreferencesDocument.swift @@ -23,9 +23,9 @@ public struct PreferencesDocument: Codable, Sendable { "quotaWarningSoundEnabled", "quotaWarningOnScreenAlertEnabled", "quotaWarningMarkersVisible", "paceVisible", "usageBarsShowUsed", "resetTimesShowAbsolute", "costUsageEnabled", "costComparisonPeriodsEnabled", "hidePersonalInfo", "randomBlinkEnabled", "confettiOnSessionLimitResetsEnabled", - "confettiOnWeeklyLimitResetsEnabled", "menuBarShowsHighestUsage", "showOptionalCreditsAndExtraUsage", - "providerChangelogLinksEnabled", "providersSortedAlphabetically", "refreshAllProvidersOnMenuOpen", - "mergeIcons", "mergeIconsStacked", "switcherShowsIcons", + "confettiOnWeeklyLimitResetsEnabled", "limitResetNotificationsEnabled", "menuBarShowsHighestUsage", + "showOptionalCreditsAndExtraUsage", "providerChangelogLinksEnabled", "providersSortedAlphabetically", + "refreshAllProvidersOnMenuOpen", "mergeIcons", "mergeIconsStacked", "switcherShowsIcons", ]) private static let stringChoices: [String: [String]] = [ "refreshFrequency": [ diff --git a/Tests/CodexBarTests/LimitResetNotificationTests.swift b/Tests/CodexBarTests/LimitResetNotificationTests.swift new file mode 100644 index 0000000000..9b4a581947 --- /dev/null +++ b/Tests/CodexBarTests/LimitResetNotificationTests.swift @@ -0,0 +1,236 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct LimitResetNotificationTests { + struct LimitResetPost: Equatable { + let provider: UsageProvider + let window: QuotaWarningWindow + let accountDisplayName: String? + } + + @MainActor + final class NotifierSpy: SessionQuotaNotifying { + private(set) var transitionPosts: [(transition: SessionQuotaTransition, provider: UsageProvider)] = [] + private(set) var limitResetPosts: [LimitResetPost] = [] + + func post(transition: SessionQuotaTransition, provider: UsageProvider, badge _: NSNumber?) { + self.transitionPosts.append((transition: transition, provider: provider)) + } + + func postQuotaWarning( + event _: QuotaWarningEvent, + provider _: UsageProvider, + soundEnabled _: Bool, + onScreenAlertEnabled _: Bool) + {} + + func postLimitReset(provider: UsageProvider, window: QuotaWarningWindow, accountDisplayName: String?) { + self.limitResetPosts.append(LimitResetPost( + provider: provider, + window: window, + accountDisplayName: accountDisplayName)) + } + } + + private static let accountEmail = "limit-reset-notice@example.com" + private static let start = Date(timeIntervalSince1970: 1_784_600_000) + + @Test + func `limit reset notifications default off and persist when enabled`() throws { + let suite = "LimitResetNotificationTests-default-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = Self.makeSettings(defaults: defaults, suiteName: suite) + + #expect(settings.limitResetNotificationsEnabled == false) + #expect(defaults.object(forKey: "limitResetNotificationsEnabled") == nil) + + settings.limitResetNotificationsEnabled = true + + #expect(defaults.bool(forKey: "limitResetNotificationsEnabled") == true) + #expect(Self.makeSettings(defaults: defaults, suiteName: suite).limitResetNotificationsEnabled == true) + } + + @Test + func `weekly reset notice names provider window and account`() async { + let notifier = NotifierSpy() + let store = Self.makeStore(notifier: notifier) + store.settings.limitResetNotificationsEnabled = true + + await Self.record(store, sessionUsed: 20, weeklyUsed: 60, offset: 0) + await Self.record(store, sessionUsed: 20, weeklyUsed: 0, offset: 60 * 60) + + #expect(notifier.limitResetPosts == [ + LimitResetPost(provider: .claude, window: .weekly, accountDisplayName: Self.accountEmail), + ]) + } + + @Test + func `session reset notice names the session window`() async { + let notifier = NotifierSpy() + let store = Self.makeStore(notifier: notifier) + store.settings.limitResetNotificationsEnabled = true + + await Self.record(store, sessionUsed: 65, weeklyUsed: 20, offset: 0) + await Self.record(store, sessionUsed: 0, weeklyUsed: 20, offset: 60 * 60) + + #expect(notifier.limitResetPosts == [ + LimitResetPost(provider: .claude, window: .session, accountDisplayName: Self.accountEmail), + ]) + } + + @Test + func `reset notices stay silent when disabled`() async { + let notifier = NotifierSpy() + let store = Self.makeStore(notifier: notifier) + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: Self.accountEmail) + defer { recorder.invalidate() } + + await Self.record(store, sessionUsed: 20, weeklyUsed: 60, offset: 0) + await Self.record(store, sessionUsed: 20, weeklyUsed: 0, offset: 60 * 60) + + #expect(recorder.events.count == 1) + #expect(notifier.limitResetPosts.isEmpty) + } + + @Test + func `hide personal info omits the account from reset notices`() async { + let notifier = NotifierSpy() + let store = Self.makeStore(notifier: notifier) + store.settings.limitResetNotificationsEnabled = true + store.settings.hidePersonalInfo = true + + await Self.record(store, sessionUsed: 20, weeklyUsed: 60, offset: 0) + await Self.record(store, sessionUsed: 20, weeklyUsed: 0, offset: 60 * 60) + + #expect(notifier.limitResetPosts == [ + LimitResetPost(provider: .claude, window: .weekly, accountDisplayName: nil), + ]) + } + + @Test + func `session restored notice covers the matching session reset notice`() async { + let notifier = NotifierSpy() + let store = Self.makeStore(notifier: notifier) + store.settings.sessionQuotaNotificationsEnabled = true + store.settings.limitResetNotificationsEnabled = true + + let depleted = Self.snapshot(sessionUsed: 100, weeklyUsed: 60, offset: 0) + let reset = Self.snapshot(sessionUsed: 0, weeklyUsed: 0, offset: 60 * 60) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: depleted, now: depleted.updatedAt) + store.handleSessionQuotaTransition(provider: .claude, snapshot: reset) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: reset, now: reset.updatedAt) + + #expect(notifier.transitionPosts.map(\.transition) == [.depleted, .restored]) + #expect(notifier.limitResetPosts == [ + LimitResetPost(provider: .claude, window: .weekly, accountDisplayName: Self.accountEmail), + ]) + } + + @Test + func `session restored dedup expires after the interval`() { + let restoredAt = Date(timeIntervalSince1970: 1_784_600_000) + let interval = LimitResetNotificationLogic.sessionRestoredDedupInterval + + #expect(LimitResetNotificationLogic.suppressesSessionReset(restoredPostedAt: nil, now: restoredAt) == false) + #expect(LimitResetNotificationLogic.suppressesSessionReset( + restoredPostedAt: restoredAt, + now: restoredAt.addingTimeInterval(interval - 1))) + #expect(LimitResetNotificationLogic.suppressesSessionReset( + restoredPostedAt: restoredAt, + now: restoredAt.addingTimeInterval(interval)) == false) + } + + @Test + func `reset notice copy names provider and window`() { + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + let weekly = LimitResetNotificationLogic.notificationCopy( + providerName: "Claude", + window: .weekly, + accountDisplayName: nil) + #expect(weekly.title == "Claude weekly limit reset") + #expect(weekly.body == "Fresh quota is available.") + + let session = LimitResetNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + accountDisplayName: "work@example.com") + #expect(session.title == "Codex session limit reset") + #expect(session.body == "Account work@example.com. Fresh quota is available.") + } + } + + @Test + func `reset notice copy is localized for simplified Chinese`() { + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + let copy = LimitResetNotificationLogic.notificationCopy( + providerName: "Claude", + window: .weekly, + accountDisplayName: nil) + #expect(copy.title == "Claude 每周额度已重置") + } + } + + // MARK: - Helpers + + private static func makeSettings(defaults: UserDefaults, suiteName: String) -> SettingsStore { + SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private static func makeStore(notifier: NotifierSpy) -> UsageStore { + let suiteName = "LimitResetNotificationTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create isolated UserDefaults suite for tests") + } + defaults.removePersistentDomain(forName: suiteName) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = false + settings.hidePersonalInfo = false + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: testPlanUtilizationHistoryStore(suiteName: suiteName), + sessionQuotaNotifier: notifier, + startupBehavior: .testing) + store._cancelPlanUtilizationHistoryLoadForTesting() + store.planUtilizationHistory = [:] + return store + } + + private static func snapshot(sessionUsed: Double, weeklyUsed: Double, offset: TimeInterval) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: sessionUsed, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: weeklyUsed, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: self.start.addingTimeInterval(offset), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: self.accountEmail, + accountOrganization: nil, + loginMethod: "max")) + } + + private static func record( + _ store: UsageStore, + sessionUsed: Double, + weeklyUsed: Double, + offset: TimeInterval) async + { + let snapshot = self.snapshot(sessionUsed: sessionUsed, weeklyUsed: weeklyUsed, offset: offset) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: snapshot, now: snapshot.updatedAt) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 7ddced64b1..236b6b52aa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -302,7 +302,7 @@ and notification windows; sound, on-screen alerts and threshold markers; pace vi and tick appearance; usage/reset display; local cost display, comparisons and summary style; privacy, blink/confetti effects, highest-usage selection, optional credits/extra usage, changelog links, currency and alphabetical provider sorting. JSON keys match the `SyncedPreferences` fields. It additionally includes -`mergeIcons`, `mergeIconsStacked`, `switcherShowsIcons`, `mergedOverviewLayout`, +`limitResetNotificationsEnabled`, `mergeIcons`, `mergeIconsStacked`, `switcherShowsIcons`, `mergedOverviewLayout`, `mergedOverviewSelectedProviders`, and `switcherShortcuts`. An overview selection is applied intentionally to the receiving Mac's active providers, including an empty selection. `weeklyProgressWorkDays: null` restores the seven-day default. Missing keys leave the receiving Mac's settings unchanged. Unknown preference keys, From 97391d5cd2d84405ff779db83d1f4765e92cdc34 Mon Sep 17 00:00:00 2001 From: giles douglas Date: Tue, 29 Sep 2026 21:25:10 -0700 Subject: [PATCH 084/122] better method to find path --- .../Host/PTY/TTYCommandRunner.swift | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index da14d0d5af..80c3e110c8 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -472,25 +472,27 @@ public struct TTYCommandRunner { return found } - if let argv0 = CommandLine.arguments.first { - var url = URL(fileURLWithPath: argv0) - if !argv0.hasPrefix("/") { - url = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(argv0) - } - var probe = url - for _ in 0..<6 { - let parent = probe.deletingLastPathComponent() - if parent.pathExtension == "app", let found = candidate(inAppBundleURL: parent) { - return found - } - if parent.path == probe.path { - break - } - probe = parent - } - } - + // Real, symlink-resolved location of the running binary. Never argv0/cwd. + guard let exe = Self.realExecutableURL() else { return nil } + // Expect .app/Contents/{Helpers,MacOS}/ + let contents = exe.deletingLastPathComponent().deletingLastPathComponent() + let app = contents.deletingLastPathComponent() + guard contents.lastPathComponent == "Contents", app.pathExtension == "app" else { return nil } + return candidate(inAppBundleURL: app) + } + + private static func realExecutableURL() -> URL? { + #if os(macOS) + var size: UInt32 = 0 + _NSGetExecutablePath(nil, &size) + var buf = [CChar](repeating: 0, count: Int(size)) + guard _NSGetExecutablePath(&buf, &size) == 0, + let resolved = realpath(buf, nil) else { return nil } + defer { free(resolved) } + return URL(fileURLWithPath: String(cString: resolved)) + #else return nil + #endif } // swiftlint:disable function_body_length From cb7e8a6d35ac18eba273dacb15046f81a74b7643 Mon Sep 17 00:00:00 2001 From: giles douglas Date: Tue, 29 Sep 2026 22:13:23 -0700 Subject: [PATCH 085/122] better fix --- .../Host/PTY/TTYCommandRunner.swift | 55 +++++-------------- .../CodexBarTests/TTYCommandRunnerTests.swift | 29 ++++++++++ 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index 80c3e110c8..fd39cd7fe4 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -446,53 +446,24 @@ public struct TTYCommandRunner { } static func locateBundledHelper(_ name: String) -> String? { - let fm = FileManager.default - - func isExecutable(_ path: String) -> Bool { - fm.isExecutableFile(atPath: path) - } - if let override = ProcessInfo.processInfo.environment["CODEXBAR_HELPER_\(name.uppercased())"], - isExecutable(override) + FileManager.default.isExecutableFile(atPath: override) { return override } - - func candidate(inAppBundleURL appURL: URL) -> String? { - let path = appURL - .appendingPathComponent("Contents", isDirectory: true) - .appendingPathComponent("Helpers", isDirectory: true) - .appendingPathComponent(name, isDirectory: false) - .path - return isExecutable(path) ? path : nil - } - - let mainURL = Bundle.main.bundleURL - if mainURL.pathExtension == "app", let found = candidate(inAppBundleURL: mainURL) { - return found - } - - // Real, symlink-resolved location of the running binary. Never argv0/cwd. - guard let exe = Self.realExecutableURL() else { return nil } - // Expect .app/Contents/{Helpers,MacOS}/ - let contents = exe.deletingLastPathComponent().deletingLastPathComponent() - let app = contents.deletingLastPathComponent() - guard contents.lastPathComponent == "Contents", app.pathExtension == "app" else { return nil } - return candidate(inAppBundleURL: app) + guard let exe = Bundle.main.executableURL else { return nil } + return self.bundledHelperPath(name, executableURL: exe) } - - private static func realExecutableURL() -> URL? { - #if os(macOS) - var size: UInt32 = 0 - _NSGetExecutablePath(nil, &size) - var buf = [CChar](repeating: 0, count: Int(size)) - guard _NSGetExecutablePath(&buf, &size) == 0, - let resolved = realpath(buf, nil) else { return nil } - defer { free(resolved) } - return URL(fileURLWithPath: String(cString: resolved)) - #else - return nil - #endif + + /// Expects the real location to be `.app/Contents/{MacOS,Helpers}/`, even when launched via a symlink. + static func bundledHelperPath(_ name: String, executableURL: URL) -> String? { + let app = executableURL.resolvingSymlinksInPath() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + guard app.pathExtension == "app" else { return nil } + let helper = app.appendingPathComponent("Contents/Helpers/\(name)").path + return FileManager.default.isExecutableFile(atPath: helper) ? helper : nil } // swiftlint:disable function_body_length diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index 373fd8ac39..f60fba98b3 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -655,4 +655,33 @@ struct TTYCommandRunnerEnvTests { let lowered = StreamScanBuffer.lowercasedASCII(data) #expect(String(data: lowered, encoding: .utf8) == "update") } + + @Test + func `bundled helper resolves from app executable and symlinks`() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("helper-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let contents = root.appendingPathComponent("Test.app/Contents", isDirectory: true) + let macOS = contents.appendingPathComponent("MacOS", isDirectory: true) + let helpers = contents.appendingPathComponent("Helpers", isDirectory: true) + try fm.createDirectory(at: macOS, withIntermediateDirectories: true) + try fm.createDirectory(at: helpers, withIntermediateDirectories: true) + let helper = helpers.appendingPathComponent("Watchdog") + let cli = helpers.appendingPathComponent("Tool") + let gui = macOS.appendingPathComponent("Test") + for url in [helper, cli, gui] { + try Data("#!/bin/sh\n".utf8).write(to: url) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + let link = root.appendingPathComponent("tool-link") + try fm.createSymbolicLink(at: link, withDestinationURL: cli) + + let expected = helper.resolvingSymlinksInPath().path + for exe in [gui, cli, link] { + let found = TTYCommandRunner.bundledHelperPath("Watchdog", executableURL: exe) + #expect(found.map { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path } == expected) + } + #expect(TTYCommandRunner.bundledHelperPath("Missing", executableURL: gui) == nil) + #expect(TTYCommandRunner.bundledHelperPath("Watchdog", executableURL: root.appendingPathComponent("bare")) == nil) + } } From b2b4823b211171c975b4c5de8e7f9f02c74ac58a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 29 Sep 2026 23:06:41 -0700 Subject: [PATCH 086/122] test(plugins): make optional POST cancellation checks deterministic (#4113) Optional POST cancellation tests use the same injected-deadline approach as #4111 instead of wall-clock bounds, so primary scheduling delay on busy CI runners no longer fails them; production code is unchanged. --- TestsPlugin/AbacusPluginTests.swift | 16 +- ...roviderPluginCancellationTestSupport.swift | 111 +++++++++++++ .../ProviderPluginOptionalPOSTTests.swift | 35 +--- .../ProviderPluginOptionalRequestTests.swift | 30 +--- TestsPlugin/SakanaPluginTests.swift | 154 ++++++++++-------- 5 files changed, 223 insertions(+), 123 deletions(-) create mode 100644 TestsPlugin/ProviderPluginCancellationTestSupport.swift diff --git a/TestsPlugin/AbacusPluginTests.swift b/TestsPlugin/AbacusPluginTests.swift index 6f39a5e30c..344c69b54e 100644 --- a/TestsPlugin/AbacusPluginTests.swift +++ b/TestsPlugin/AbacusPluginTests.swift @@ -167,11 +167,13 @@ struct AbacusPluginTests { #expect(try await strategy.fetch(context).usage.primary?.usedPercent == 25) } - @Test(arguments: BundledPluginTestSupport.engines) + @Test(.timeLimit(.minutes(1)), arguments: BundledPluginTestSupport.engines) func `slow first candidate leaves time for a successful second candidate`( engine: ProviderPluginEngineKind) async throws { let sessions = Sessions() + let (cancellations, cancelled) = AsyncStream.makeStream() + defer { cancelled.finish() } let requestTimeout = 2.0 let bundle = try #require(CodexBarCoreResources.bundle) let url = try #require(bundle.url(forResource: "abacus", withExtension: "js")) @@ -182,7 +184,12 @@ struct AbacusPluginTests { #expect(request.timeoutInterval == requestTimeout) if request.httpMethod == "POST" { return Self.response(request, body: Self.billing) } if request.value(forHTTPHeaderField: "Cookie") == "session=stale" { - try await Task.sleep(for: .seconds(30)) + do { + try await Task.sleep(for: .seconds(30)) + } catch { + cancelled.yield(Task.isCancelled) + throw error + } } else { try await Task.sleep(for: .seconds(1.5)) } @@ -190,15 +197,14 @@ struct AbacusPluginTests { }, timeout: AbacusProviderDescriptor.refreshTimeout(for: requestTimeout), engine: engine) - let start = ContinuousClock.now let usage = try await runtime.fetchUsage( settings: ["REQUEST_TIMEOUT": String(requestTimeout)], cookieSessionResolver: { _, _ in sessions.next() }, cookieSessionInvalidator: { _, id in sessions.reject(id) }) #expect(usage.primary?.usedPercent == 25) #expect(sessions.rejected.isEmpty) - #expect(start.duration(to: .now) >= .seconds(3)) - #expect(start.duration(to: .now) < .seconds(12)) + var iterator = cancellations.makeAsyncIterator() + #expect(await iterator.next() == true) } @Test(arguments: BundledPluginTestSupport.engines) diff --git a/TestsPlugin/ProviderPluginCancellationTestSupport.swift b/TestsPlugin/ProviderPluginCancellationTestSupport.swift new file mode 100644 index 0000000000..4bab5eb81a --- /dev/null +++ b/TestsPlugin/ProviderPluginCancellationTestSupport.swift @@ -0,0 +1,111 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +enum ProviderPluginCancellationTestSupport { + static func checkCallerCancellation( + engine: ProviderPluginEngineKind, + optionalMethod: String, + waitingForAdmission: Bool) async throws + { + let (starts, started) = AsyncStream.makeStream() + let (cancellations, cancelled) = AsyncStream.makeStream() + let caller = CancellationRequest() + let optional = optionalMethod == "POST" + ? "{url: 'https://example.test/optional', method: 'POST', body: {}}" + : "'https://example.test/optional'" + defer { + started.finish() + cancelled.finish() + } + let hold: @Sendable (URLRequest) async throws -> Void = { request in + let path = request.url!.path + let (pending, release) = AsyncStream.makeStream() + defer { release.finish() } + try await withTaskCancellationHandler { + started.yield(path) + for await _ in pending {} + try Task.checkCancellation() + Issue.record("Request was released without cancellation") + } onCancel: { + #expect(caller.wasRequested, "A request timeout must not substitute for caller cancellation") + cancelled.yield(path) + } + } + let runtime = try ProviderPluginRuntime( + source: """ + defineProvider({ + id: 'cancellation-fixture', name: 'Cancellation fixture', endpoints: ['https://example.test'], settings: [], + async fetchUsage(ctx) { + await ctx.http.getWithOptional('https://example.test/primary', + \(optional), {optionalBudgetSeconds: 5}); + return {empty: true}; + } + }); + """, + resourceBundle: CodexBarCoreResources.bundle, + transport: ProviderHTTPTransportHandler { request in + #expect(!waitingForAdmission, "Cancelled admission must not reach transport") + try await hold(request) + throw CancellationError() + }, + allowsDynamicID: true, + contextOptions: ProviderPluginContextOptions( + optionalRequestTimeoutSeconds: nil, + waitForOptionalDeadline: { _, budget in + #expect(budget == .seconds(5)) + // Only caller cancellation may end collection in this test. + let (pending, release) = AsyncStream.makeStream() + defer { release.finish() } + for await _ in pending {} + try Task.checkCancellation() + }, + beforeHTTPAttempt: { request in + // Admission coverage cannot be rescued by the independent request timers. + if waitingForAdmission { try await hold(request) } + }), + engine: engine) + let task = Task { + defer { started.finish() } + do { + return try await runtime.fetchUsage() + } catch { + if !(error is CancellationError) { Issue.record(error) } + throw error + } + } + defer { + caller.request() + task.cancel() + } + var startIterator = starts.makeAsyncIterator() + let first = try #require(await startIterator.next()) + let second = try #require(await startIterator.next()) + #expect(Set([first, second]) == ["/primary", "/optional"]) + caller.request() + task.cancel() + switch await BoundedTaskJoin(sourceTask: task).value(joinGrace: .seconds(10)) { + case let .failure(error): #expect(error is CancellationError) + case .value, .timedOut: + Issue.record("Cancelled fetch did not return CancellationError") + return + } + var cancellationIterator = cancellations.makeAsyncIterator() + let firstCancelled = try #require(await cancellationIterator.next()) + let secondCancelled = try #require(await cancellationIterator.next()) + #expect(Set([firstCancelled, secondCancelled]) == ["/primary", "/optional"]) + } + + private final class CancellationRequest: @unchecked Sendable { + private let lock = NSLock() + private var requested = false + var wasRequested: Bool { + self.lock.withLock { self.requested } + } + + func request() { self.lock.withLock { self.requested = true } } + } +} diff --git a/TestsPlugin/ProviderPluginOptionalPOSTTests.swift b/TestsPlugin/ProviderPluginOptionalPOSTTests.swift index c2b0e7cd9f..345d54ee66 100644 --- a/TestsPlugin/ProviderPluginOptionalPOSTTests.swift +++ b/TestsPlugin/ProviderPluginOptionalPOSTTests.swift @@ -94,29 +94,12 @@ struct ProviderPluginOptionalPOSTTests { #expect(error?.localizedDescription.contains("private-fixture") == false) } - @Test(arguments: BundledPluginTestSupport.engines) - func `caller cancellation interrupts required GET and optional POST`(engine: ProviderPluginEngineKind) async throws { - let calls = Calls() - let runtime = try Self.runtime(engine) { request in - calls.started() - do { try await Task.sleep(for: .seconds(30)) } catch { - calls.cancelled() - throw error - } - return ProviderPluginConsoleCapabilitiesTests.response(request, body: "late") - } - let task = Task { try await runtime.fetchUsage() } - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) - while calls.count < 2, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(10)) - } - #expect(calls.count == 2) - task.cancel() - await #expect(throws: CancellationError.self) { try await task.value } - while calls.cancellations < 2, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(10)) - } - #expect(calls.cancellations == 2) + @Test(.timeLimit(.minutes(1)), arguments: BundledPluginTestSupport.engines, [false, true]) + func `caller cancellation interrupts required GET and optional POST`( + engine: ProviderPluginEngineKind, waitingForAdmission: Bool) async throws + { + try await ProviderPluginCancellationTestSupport.checkCallerCancellation( + engine: engine, optionalMethod: "POST", waitingForAdmission: waitingForAdmission) } private static func runtime( @@ -136,16 +119,10 @@ struct ProviderPluginOptionalPOSTTests { private final class Calls: @unchecked Sendable { private let lock = NSLock() private var starts = 0 - private var cancels = 0 var count: Int { self.lock.withLock { self.starts } } - var cancellations: Int { - self.lock.withLock { self.cancels } - } - func started() { self.lock.withLock { self.starts += 1 } } - func cancelled() { self.lock.withLock { self.cancels += 1 } } } } diff --git a/TestsPlugin/ProviderPluginOptionalRequestTests.swift b/TestsPlugin/ProviderPluginOptionalRequestTests.swift index 0a2490065e..c07a1b0ffa 100644 --- a/TestsPlugin/ProviderPluginOptionalRequestTests.swift +++ b/TestsPlugin/ProviderPluginOptionalRequestTests.swift @@ -90,30 +90,12 @@ struct ProviderPluginOptionalRequestTests { #expect(payload.value["optional"] is NSNull) } - @Test(arguments: BundledPluginTestSupport.engines) - func `caller cancellation reaches both requests`(engine: ProviderPluginEngineKind) async throws { - let calls = RequestCalls() - let runtime = try Self.runtime(engine: engine) { request in - calls.start() - do { try await Task.sleep(for: .seconds(30)) } catch { - calls.cancel() - throw error - } - return try Self.response(request, body: "unexpected") - } - let task = Task { try await runtime.fetchUsage() } - let deadline = ContinuousClock.now.advanced(by: .seconds(10)) - while calls.counts.0 < 2, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(10)) - } - #expect(calls.counts.0 == 2) - task.cancel() - await #expect(throws: CancellationError.self) { _ = try await task.value } - let cancelledDeadline = ContinuousClock.now.advanced(by: .seconds(10)) - while calls.counts.1 < 2, ContinuousClock.now < cancelledDeadline { - try await Task.sleep(for: .milliseconds(10)) - } - #expect(calls.counts.1 == 2) + @Test(.timeLimit(.minutes(1)), arguments: BundledPluginTestSupport.engines, [false, true]) + func `caller cancellation reaches both requests`( + engine: ProviderPluginEngineKind, waitingForAdmission: Bool) async throws + { + try await ProviderPluginCancellationTestSupport.checkCallerCancellation( + engine: engine, optionalMethod: "GET", waitingForAdmission: waitingForAdmission) } @Test(.timeLimit(.minutes(1)), arguments: BundledPluginTestSupport.engines, [false, true]) diff --git a/TestsPlugin/SakanaPluginTests.swift b/TestsPlugin/SakanaPluginTests.swift index 0fd5c1aea0..f379dad47b 100644 --- a/TestsPlugin/SakanaPluginTests.swift +++ b/TestsPlugin/SakanaPluginTests.swift @@ -80,45 +80,98 @@ struct SakanaPluginTests { ]) } - @Test(arguments: BundledPluginTestSupport.engines) + @Test(.timeLimit(.minutes(1)), arguments: BundledPluginTestSupport.engines, [false, true]) func `slow optional request is cancelled without waiting for its timeout`( - engine: ProviderPluginEngineKind) async throws + engine: ProviderPluginEngineKind, waitingForAdmission: Bool) async throws { - let transport = SakanaScriptedTransport( - statusCode: 200, - body: Self.billingHTML, - billingWaitsForPayAsYouGo: true, - payAsYouGoBlocksUntilCancelled: true) - let task = Task { - try await Self.fetch( - transport, - engine: engine, - collectionBudget: ProviderPluginContextOptions.production.optionalCollectionBudget) - } - let usage: UsageSnapshot - switch await BoundedTaskJoin(sourceTask: task).value(joinGrace: .seconds(10)) { - case let .value(value): usage = value - case .failure, .timedOut: - Issue.record("Optional request held the primary result") - return - } - #expect(usage.primary?.usedPercent == 92) - #expect(usage.details.isEmpty) - #expect(try await transport.waitForPayAsYouGoCancellation()) - #expect(await transport.payAsYouGoCancellationDuration() < .seconds(4)) + try await Self.checkOptionalCancellation(engine, waitingForAdmission: waitingForAdmission, failsPrimary: false) } - @Test(arguments: BundledPluginTestSupport.engines) + @Test(.timeLimit(.minutes(1)), arguments: BundledPluginTestSupport.engines, [false, true]) func `required HTTP failure cancels optional work and preserves login diagnosis`( - engine: ProviderPluginEngineKind) async throws + engine: ProviderPluginEngineKind, waitingForAdmission: Bool) async throws { - let transport = SakanaScriptedTransport( - statusCode: 401, - body: "expired", - billingWaitsForPayAsYouGo: true, - payAsYouGoBlocksUntilCancelled: true) - await Self.expectFailure(.authenticationExpired) { try await Self.fetch(transport, engine: engine) } - #expect(try await transport.waitForPayAsYouGoCancellation()) + try await Self.checkOptionalCancellation(engine, waitingForAdmission: waitingForAdmission, failsPrimary: true) + } + + private static func checkOptionalCancellation( + _ engine: ProviderPluginEngineKind, + waitingForAdmission: Bool, + failsPrimary: Bool) async throws + { + let (starts, started) = AsyncStream.makeStream() + let (cancellations, cancelled) = AsyncStream.makeStream() + defer { + started.finish() + cancelled.finish() + } + let holdOptional: @Sendable () async throws -> Void = { + let (pending, release) = AsyncStream.makeStream() + defer { release.finish() } + started.yield() + for await _ in pending {} + cancelled.yield(Task.isCancelled) + try Task.checkCancellation() + } + let runtime = try BundledPluginTestSupport.runtime( + "sakana", + engine: engine, + transport: ProviderHTTPTransportHandler { request in + if request.url?.query == "tab=payAsYouGo" { + #expect(!waitingForAdmission) + try await holdOptional() + } else if failsPrimary { + var iterator = starts.makeAsyncIterator() + #expect(await iterator.next() != nil) + } + return (Data(Self.billingHTML.utf8), HTTPURLResponse( + url: request.url!, statusCode: failsPrimary ? 401 : 200, httpVersion: nil, headerFields: nil)!) + }, + contextOptions: ProviderPluginContextOptions( + optionalRequestTimeoutSeconds: nil, + waitForOptionalDeadline: { _, budget in + #expect(budget == .milliseconds(200)) + if failsPrimary { + // Only the required failure may cancel optional work in this case. + let (pending, release) = AsyncStream.makeStream() + defer { release.finish() } + for await _ in pending {} + try Task.checkCancellation() + } else { + var iterator = starts.makeAsyncIterator() + #expect(await iterator.next() != nil) + } + }, + beforeHTTPAttempt: { request in + // Keep the independent request timer out of the admission case. + if waitingForAdmission, request.url?.query == "tab=payAsYouGo" { try await holdOptional() } + })) + let fetch: @Sendable () async throws -> UsageSnapshot = { + try await runtime.fetchUsage( + settings: ["OPTIONAL_USAGE": "true"], secrets: ["SAKANA_COOKIE": "session=fixture"]) + } + let task = Task { + let usage: UsageSnapshot? + if failsPrimary { + await Self.expectFailure(.authenticationExpired, operation: fetch) + usage = nil + } else { + usage = try await fetch() + } + var iterator = cancellations.makeAsyncIterator() + #expect(await iterator.next() == true) + return usage + } + defer { task.cancel() } + switch await BoundedTaskJoin(sourceTask: task).value(joinGrace: .seconds(10)) { + case let .value(usage): + if !failsPrimary { + #expect(usage?.primary?.usedPercent == 92) + #expect(usage?.details.isEmpty == true) + } + case .failure, .timedOut: + Issue.record("Optional request held the primary result or failure") + } } @Test(arguments: BundledPluginTestSupport.engines, [401, 403, 302]) @@ -217,8 +270,7 @@ struct SakanaPluginTests { _ transport: any ProviderHTTPTransport, engine: ProviderPluginEngineKind, optional: Bool = true, - now: Date = Date(), - collectionBudget: Duration = .seconds(3)) async throws -> UsageSnapshot + now: Date = Date()) async throws -> UsageSnapshot { // Parser fixtures must not race loaded CI runners against the production 200 ms budget. let runtime = try BundledPluginTestSupport.runtime( @@ -227,7 +279,7 @@ struct SakanaPluginTests { transport: transport, contextOptions: ProviderPluginContextOptions( optionalRequestTimeoutSeconds: nil, - optionalCollectionBudget: collectionBudget)) + optionalCollectionBudget: .seconds(3))) return try await runtime.fetchUsage( settings: ["OPTIONAL_USAGE": String(optional)], secrets: ["SAKANA_COOKIE": "session=fixture"], @@ -298,15 +350,12 @@ private actor SakanaScriptedTransport: ProviderHTTPTransport { /// `(statusCode, body)` for any URL not present here. private let overridesByURL: [String: (statusCode: Int, body: String)] private let billingWaitsForPayAsYouGo: Bool - private let payAsYouGoBlocksUntilCancelled: Bool private let payAsYouGoDelayAfterBilling: Duration? private let billingCompletions: AsyncStream private let billingCompleted: AsyncStream.Continuation private var capturedRequests: [CapturedRequest] = [] private var payAsYouGoStarted = false private var payAsYouGoCompleted = false - private var payAsYouGoWasCancelled = false - private var cancellationDuration: Duration = .seconds(30) private var payAsYouGoStartWaiters: [CheckedContinuation] = [] private var payAsYouGoCompletionWaiters: [CheckedContinuation] = [] @@ -317,7 +366,6 @@ private actor SakanaScriptedTransport: ProviderHTTPTransport { headers: [String: String] = [:], overridesByURL: [String: (statusCode: Int, body: String)] = [:], billingWaitsForPayAsYouGo: Bool = false, - payAsYouGoBlocksUntilCancelled: Bool = false, payAsYouGoDelayAfterBilling: Duration? = nil) { self.statusCode = statusCode @@ -326,7 +374,6 @@ private actor SakanaScriptedTransport: ProviderHTTPTransport { self.headers = headers self.overridesByURL = overridesByURL self.billingWaitsForPayAsYouGo = billingWaitsForPayAsYouGo - self.payAsYouGoBlocksUntilCancelled = payAsYouGoBlocksUntilCancelled self.payAsYouGoDelayAfterBilling = payAsYouGoDelayAfterBilling (self.billingCompletions, self.billingCompleted) = AsyncStream.makeStream() } @@ -339,17 +386,6 @@ private actor SakanaScriptedTransport: ProviderHTTPTransport { self.capturedRequests } - func payAsYouGoCancellationDuration() -> Duration { self.cancellationDuration } - - func waitForPayAsYouGoCancellation() async throws -> Bool { - // Stay below the optional request's five-second fallback timeout. - let deadline = ContinuousClock.now.advanced(by: .seconds(1)) - while !self.payAsYouGoWasCancelled, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(10)) - } - return self.payAsYouGoWasCancelled - } - func data(for request: URLRequest) async throws -> (Data, URLResponse) { let isPayAsYouGo = request.url?.query == "tab=payAsYouGo" if isPayAsYouGo { @@ -358,21 +394,9 @@ private actor SakanaScriptedTransport: ProviderHTTPTransport { for await _ in self.billingCompletions {} try await Task.sleep(for: payAsYouGoDelayAfterBilling) } - if self.payAsYouGoBlocksUntilCancelled { - let startedAt = ContinuousClock.now - do { - try await Task.sleep(for: .seconds(30)) - } catch { - self.cancellationDuration = startedAt.duration(to: .now) - self.payAsYouGoWasCancelled = true - throw error - } - } } else if self.billingWaitsForPayAsYouGo { await self.waitForPayAsYouGoStart() - if !self.payAsYouGoBlocksUntilCancelled { - await self.waitForPayAsYouGoCompletion() - } + await self.waitForPayAsYouGoCompletion() } self.capturedRequests.append(CapturedRequest( From 001ed11d4d3a475809765145e36f44a756ef52ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 29 Sep 2026 23:09:06 -0700 Subject: [PATCH 087/122] chore(release): prepare 0.70.0 --- CHANGELOG.md | 27 +++++++++++++++++---------- version.env | 2 +- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a521d18a75..3c4f1aecdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,21 @@ # Changelog -## 0.69.1 — Unreleased +## 0.70.0 — 2026-09-29 + +### Highlights + +- Quota burndown: Plan Usage now shows the recorded remaining-quota burndown for Codex and Claude next to utilization history, with capture age and calendar endpoints (#4085). Thanks @callmejustdodo! +- Refreshed provider colors: 16 accents now match official brand sources, while colors that would hurt menu bar or widget readability keep their current values (#4075). Thanks @elijahfriedman! +- More accurate costs: Mistral usage is priced by event type, API zone, and service tier, Antigravity and Codex model aliases and published Cyber rates are priced, and Mistral's Monthly Plan can drive the menu bar metric (#4076, #4094, #4072). Thanks @T0mSIlver and @urda! +- Hardened diagnostics: every stored process environment is redacted from debug and test output, with a repository guard against regressions (#4106). + +### Security + +- Redact every remaining stored process environment in the app, CLI, provider contexts, and session scanners, and guard against new unredacted environment properties with a repository check (#4106). + +### Added + +- Plan Usage: show recorded remaining-quota burndown for Codex and Claude alongside utilization history, with capture age and calendar endpoints (#4085). Thanks @callmejustdodo! ### Changed @@ -12,13 +27,7 @@ - Costs: price documented Antigravity and Codex model aliases, add published Cyber fallback rates, and preserve Sol estimates across the August 21 price change (#4094). Thanks @urda! - Mistral: offer Monthly Plan in the provider's Menu bar metric picker, so the menu bar and widgets can show the Vibe allowance without a `defaults write` (#4072). Thanks @T0mSIlver! - Mistral: price billing usage by event type, API zone, and service tier, so a per-second audio or priority price no longer inflates API spend and 30-day token cost (#4076). Thanks @T0mSIlver! -### Security - -- Redact every remaining stored process environment in the app, CLI, provider contexts, and session scanners, and guard against new unredacted environment properties with a repository check (#4106). - -### Fixed - -- CLI: keep probe timeout and cancellation cleanup responsive when other processes have large environments (#4077). +- CLI: keep probe timeout and cancellation cleanup responsive on busy hosts with large process tables (#4108). ## 0.69.0 — 2026-09-28 @@ -41,8 +50,6 @@ ### Changed -- Menu bar: align the persistent Refresh row with other menu actions by removing its decorative icon, preserving the shortcut and accessibility action (#4057). Thanks @elijahfriedman! -- Plan Usage: show recorded remaining-quota burndown for Codex and Claude alongside utilization history, with capture age and calendar endpoints (#4085). Thanks @callmejustdodo! - Plugins: user plugins now get their own switcher tab by default when Merge Icons is on; set `topLevel: false` to keep the appended card (#4074). - Notion AI, ZoomMate, and LongCat: usage fetching runs through bundled plugins with host-owned cookie sessions, preserving browser-session reuse, validated cache migration, Notion over-quota values, ZoomMate credits history, and LongCat fuel-pack data (#4098, #4059). - Menu bar: the persistent Refresh row drops its decorative icon to match other menu actions, keeping the shortcut and accessibility action (#4057). Thanks @elijahfriedman! diff --git a/version.env b/version.env index 810a2b018a..76690d1eb1 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.69.1 +MARKETING_VERSION=0.70.0 BUILD_NUMBER=161 From fcaffd75ace3790cca3b768ae3fd3293281692ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 29 Sep 2026 23:53:23 -0700 Subject: [PATCH 088/122] docs: update appcast for 0.70.0 --- appcast.xml | 93 ++++++++++++++++++++++------------------------------- 1 file changed, 39 insertions(+), 54 deletions(-) diff --git a/appcast.xml b/appcast.xml index 1762d0f9d6..a0e8eb6f32 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,45 @@ CodexBar + + 0.70.0 + Tue, 29 Sep 2026 23:52:57 -0700 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 161 + 0.70.0 + 14.0 + CodexBar 0.70.0 +

Highlights

+
    +
  • Quota burndown: Plan Usage now shows the recorded remaining-quota burndown for Codex and Claude next to utilization history, with capture age and calendar endpoints (#4085). Thanks @callmejustdodo!
  • +
  • Refreshed provider colors: 16 accents now match official brand sources, while colors that would hurt menu bar or widget readability keep their current values (#4075). Thanks @elijahfriedman!
  • +
  • More accurate costs: Mistral usage is priced by event type, API zone, and service tier, Antigravity and Codex model aliases and published Cyber rates are priced, and Mistral's Monthly Plan can drive the menu bar metric (#4076, #4094, #4072). Thanks @T0mSIlver and @urda!
  • +
  • Hardened diagnostics: every stored process environment is redacted from debug and test output, with a repository guard against regressions (#4106).
  • +
+

Security

+
    +
  • Redact every remaining stored process environment in the app, CLI, provider contexts, and session scanners, and guard against new unredacted environment properties with a repository check (#4106).
  • +
+

Added

+
    +
  • Plan Usage: show recorded remaining-quota burndown for Codex and Claude alongside utilization history, with capture age and calendar endpoints (#4085). Thanks @callmejustdodo!
  • +
+

Changed

+
    +
  • Provider colors: refresh 16 verified brand accents while preserving readable menu colors and existing widget palettes; synchronize website and social preview colors (#4075). Thanks @elijahfriedman!
  • +
+

Fixed

+
    +
  • Settings: keep the Usage & Spend title and Refresh button readable by giving the time-range picker its own row (#4064). Thanks @elijahfriedman!
  • +
  • Costs: price documented Antigravity and Codex model aliases, add published Cyber fallback rates, and preserve Sol estimates across the August 21 price change (#4094). Thanks @urda!
  • +
  • Mistral: offer Monthly Plan in the provider's Menu bar metric picker, so the menu bar and widgets can show the Vibe allowance without a defaults write (#4072). Thanks @T0mSIlver!
  • +
  • Mistral: price billing usage by event type, API zone, and service tier, so a per-second audio or priority price no longer inflates API spend and 30-day token cost (#4076). Thanks @T0mSIlver!
  • +
  • CLI: keep probe timeout and cancellation cleanup responsive on busy hosts with large process tables (#4108).
  • +
+

View full changelog

+]]>
+ +
0.69.0 Mon, 28 Sep 2026 11:05:39 -0700 @@ -117,60 +156,6 @@ ]]> - - 0.67.0 - Fri, 25 Sep 2026 16:37:15 -0700 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 158 - 0.67.0 - 14.0 - CodexBar 0.67.0 -

Highlights

-
    -
  • Reporting periods are one shared model: pick calendar month-to-date or all available history in menus, Usage & Spend, the CLI, the HTTP cost output, and widgets, with rolling windows and the pinned cost time zone preserved (#2087, #2859, #1708).
  • -
  • Portable preferences: export and import UI settings as versioned JSON from Settings or the CLI, keep them in dotfiles, and customize the provider-switcher shortcuts (#1282, #3457). Thanks @kiankyars and @lghsigma597!
  • -
  • Opt-in Stay Awake keeps the Mac from idle-sleeping while a local agent session is live, and opt-in credential-expiry notifications tell you when a provider needs a fresh login (#2740, #2512). Thanks @kocaemre and @LeoLin990405!
  • -
  • Plugins can keep small persistent checkpoints, and Sakana AI now runs as a bundled plugin with concurrent optional balance collection on both engines (#3170). Thanks @CrackedPoly!
  • -
  • Burn Down widgets work for every provider quota that reports usage, a window, and a reset, including Devin daily/weekly and Cursor billing cycles (#3097). Thanks @thatlev!
  • -
  • Four more providers and richer breakdowns: xKiro, Raycast, and Aixy join the registry, LiteLLM shows per-model activity, the Claude Admin API breaks spend down by workspace, and Grok lists product usage shares (#3729, #3960, #3958, #3432, #2350, #3975).
  • -
  • Fixes worth calling out: a System Account switch now reaches the running Codex app-server so /status follows the new account (#3990), an explicit browser-cookie denial survives restarts and credential files are staged privately (#3986), long Usage & Spend ranges lay out about six times faster (#3998), and the bundled QuickJS-NG is 0.17.0 with upstream memory-safety fixes (#3987). Thanks @massdo, @bo-vavrik, and @Yuxin-Qiao!
  • -
-

Added

-
    -
  • Costs: select calendar month-to-date or all available history across menus, Usage & Spend, CLI, HTTP cost output, and widgets; preserve rolling windows and the pinned cost time zone (#2087, #2859, #1708).
  • -
  • Preferences: export and import portable UI settings as versioned JSON from Settings or the CLI, preserving local credentials and consent (#1282). Thanks @kiankyars!
  • -
  • Provider switcher: customize local navigation and selection shortcuts in Settings or portable preferences, with duplicate and reserved-command validation (#3457). Thanks @lghsigma597!
  • -
  • Agent sessions: add opt-in Stay Awake for live local agent processes, including idle sessions, with automatic release and a menu status indicator (#2740). Thanks @kocaemre!
  • -
  • Notifications: add opt-in, account-scoped credential-expiry alerts and route Augment keepalive through shared delivery without repeated refresh notifications (#2512). Thanks @LeoLin990405!
  • -
  • Plugins: preserve small non-secret checkpoints across app and CLI restarts with isolated, bounded string storage and explicit approval (#3170). Thanks @CrackedPoly!
  • -
  • Sakana AI: run billing parsing as a bundled plugin on macOS and Linux while preserving concurrent, bounded optional balance collection on both engines.
  • -
  • Widgets: offer Burn Down for compatible provider quotas, including Devin daily/weekly and Cursor billing cycles, with accurate labels and preserved Codex/Claude selections (#3097). Thanks @thatlev!
  • -
  • xKiro: track the account's daily free-token allowance and midnight UTC reset through the documented, unmetered usage API, separately from paid balances (#3729).
  • -
  • Raycast: show monthly AI credits and renewal through a bundled plugin, with Chrome/manual cookies and expired-session recovery (#3960). Thanks @raulgg!
  • -
  • Aixy: track key-scoped usage and applicable personal/shared budgets, including idle keys with zero spend (#3958). Thanks @oscarcpozas!
  • -
  • LiteLLM: optionally show per-model input/output/total tokens and logged requests for the last 30 days while preserving personal/team budgets (#3432). Thanks @anyingiit!
  • -
  • Claude Admin API: optionally break down 30-day spend by workspace while preserving organization totals (#2350). Thanks @ShawNova!
  • -
  • Grok: show product usage shares beneath the quota bar using the existing billing response, while preserving the total and reset credits (#3975). Thanks @olddonkey!
  • -
  • Preferred Currency: add NZD, SEK, NOK, DKK, PLN, BRL, MXN, ZAR, THB, IDR, VND, and UAH for spend estimates with daily exchange rates and offline fallback (#3984). Thanks @realistkrook!
  • -
-

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!
  • -
  • Provider plugins: update bundled QuickJS-NG to 0.17.0 with upstream memory-safety and numeric-correctness fixes (#3987). Thanks @bo-vavrik!
  • -
  • OpenRouter: explain the required API key field instead of reporting no available fetch strategy, and clarify where regular and Management keys belong (#3966, #3969). Thanks @harjothkhara!
  • -
  • Antigravity: let menu-bar layouts pin Gemini and Claude/GPT weekly percentages separately when each allowance is available (#3394). Thanks @ksuchoi216!
  • -
  • Antigravity: preserve decoded local history as a marked lower bound when later databases exhaust the schema budget, while retaining hard scan limits (#3957). Thanks @Niclassslua!
  • -
  • Mistral: count plan-covered API, Le Chat, and Vibe Code tokens in usage history while keeping spend based on billed units (#3953). Thanks @welcoMattic!
  • -
  • Codex costs: preserve inherited cumulative counters across direct forks and empty intermediate sessions, preventing copied history from becoming oversized billable requests (#3524). Thanks @korboybeats and @vnnkl!
  • -
  • Plugins: compute daily reset times from the refresh clock in both plugin engines, so xKiro and other daily-reset plugins report the same reset the rest of the refresh saw.
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From 5de8b9ccfdcf3ed13d7c67e3639a2dd18d11230f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 01:02:01 -0700 Subject: [PATCH 089/122] chore: start 0.70.1 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4f1aecdb..39766219d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.70.1 — Unreleased + ## 0.70.0 — 2026-09-29 ### Highlights diff --git a/version.env b/version.env index 76690d1eb1..2b12011947 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.70.0 -BUILD_NUMBER=161 +MARKETING_VERSION=0.70.1 +BUILD_NUMBER=162 From 1d7f20576275174546948785b4e554915bf01ece Mon Sep 17 00:00:00 2001 From: sudoHG Date: Wed, 30 Sep 2026 16:07:47 +0800 Subject: [PATCH 090/122] docs(changelog): move the Claude trust fix under 0.70.1 --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee902c272..9102a48f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.70.1 — Unreleased + +### Fixed + +- Claude: answer Claude Code's workspace trust dialog in the CLI probe's dedicated directory by selecting "Yes, I trust this folder". Pressing Enter on the preselected "No, exit" made every PTY probe exit before `/usage` ran and fall back to non-interactive `/usage`. Outside that directory, including the temporary-directory fallback, the probe cancels the dialog instead, and a PTY session that exits mid-capture now logs its exit status (#4115, related to #4083). Thanks @sudoHG! + ## 0.70.0 — 2026-09-29 ### Highlights @@ -24,7 +30,6 @@ ### Fixed - Settings: keep the Usage & Spend title and Refresh button readable by giving the time-range picker its own row (#4064). Thanks @elijahfriedman! -- Claude: answer Claude Code's workspace trust dialog in the CLI probe's dedicated directory by selecting "Yes, I trust this folder". Pressing Enter on the preselected "No, exit" made every PTY probe exit before `/usage` ran and fall back to non-interactive `/usage`. Outside that directory, including the temporary-directory fallback, the probe cancels the dialog instead, and a PTY session that exits mid-capture now logs its exit status (#4115, related to #4083). Thanks @sudoHG! - Costs: price documented Antigravity and Codex model aliases, add published Cyber fallback rates, and preserve Sol estimates across the August 21 price change (#4094). Thanks @urda! - Mistral: offer Monthly Plan in the provider's Menu bar metric picker, so the menu bar and widgets can show the Vibe allowance without a `defaults write` (#4072). Thanks @T0mSIlver! - Mistral: price billing usage by event type, API zone, and service tier, so a per-second audio or priority price no longer inflates API spend and 30-day token cost (#4076). Thanks @T0mSIlver! From 73e7378949b1b566b22fc965d1e20252c2b08175 Mon Sep 17 00:00:00 2001 From: Slava Kurilyak Date: Tue, 29 Sep 2026 12:16:39 +0200 Subject: [PATCH 091/122] Keep agent sessions running from a deleted binary proc_pidpath fails with ENOENT once an in-place CLI update removes the running executable, so the macOS scanner dropped every Claude Code session started before an auto-update. Build process records from argv, which already identifies agents, and keep the executable path only as the fallback command. --- .../LocalAgentSessionScanner.swift | 39 ++++++++++++------- .../DarwinProcessEnumeratorTests.swift | 38 ++++++++++++++++++ 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBarCore/LocalAgentSessionScanner.swift b/Sources/CodexBarCore/LocalAgentSessionScanner.swift index 2befcb3154..1ab09269af 100644 --- a/Sources/CodexBarCore/LocalAgentSessionScanner.swift +++ b/Sources/CodexBarCore/LocalAgentSessionScanner.swift @@ -440,19 +440,7 @@ public struct LocalAgentSessionScanner: Sendable { } #if canImport(Darwin) return DarwinProcessEnumerator.allPIDs().compactMap { pid in - guard let bsdInfo = DarwinProcessEnumerator.bsdInfo(pid: pid), - let executablePath = DarwinProcessEnumerator.executablePath(pid: pid) - else { return nil } - let processArguments = DarwinProcessEnumerator.argumentsWithPiSelectorEnvironment(pid: pid) - let arguments = processArguments?.arguments - let command = arguments?.joined(separator: " ") ?? executablePath - return AgentProcessRecord( - pid: pid, - ppid: bsdInfo.ppid, - startedAt: bsdInfo.startTime, - command: command, - arguments: arguments, - piSelectorEnvironment: processArguments?.piSelectorEnvironment) + Self.darwinProcessRecord(pid: pid) } #else let records = await AgentPSOutputParser.parse(self.processOutput(environment: environment)) @@ -469,6 +457,31 @@ public struct LocalAgentSessionScanner: Sendable { #endif } + #if canImport(Darwin) + /// Builds a process record from libproc data. `proc_pidpath` fails with ENOENT once an updater deletes the + /// running binary (for example the old package directory after a Claude Code update), so argv is preferred + /// and the executable path is only the fallback command when argv is unavailable. + static func darwinProcessRecord( + pid: Int32, + bsdInfo: (Int32) -> (ppid: Int32, startTime: Date)? = DarwinProcessEnumerator.bsdInfo, + processArguments: (Int32) -> (arguments: [String], piSelectorEnvironment: [String: String]?)? = + DarwinProcessEnumerator.argumentsWithPiSelectorEnvironment, + executablePath: (Int32) -> String? = DarwinProcessEnumerator.executablePath) -> AgentProcessRecord? + { + guard let bsdInfo = bsdInfo(pid) else { return nil } + let processArguments = processArguments(pid) + let arguments = processArguments?.arguments + guard let command = arguments?.joined(separator: " ") ?? executablePath(pid) else { return nil } + return AgentProcessRecord( + pid: pid, + ppid: bsdInfo.ppid, + startedAt: bsdInfo.startTime, + command: command, + arguments: arguments, + piSelectorEnvironment: processArguments?.piSelectorEnvironment) + } + #endif + private static func withPiSelectorEnvironment( _ environment: [String: String]?, record: AgentProcessRecord) -> AgentProcessRecord diff --git a/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift b/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift index d71b7e9787..dbd2c4ce91 100644 --- a/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift +++ b/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift @@ -189,6 +189,44 @@ struct DarwinProcessEnumeratorTests { #expect(DarwinProcessEnumerator.listeningTCPPorts(pid: getpid()).contains(listener.port)) } + + @Test + func `darwin process record keeps agent argv when the executable was deleted`() throws { + // proc_pidpath returns ENOENT after an updater removes the package directory of a running binary. + let record = try #require(LocalAgentSessionScanner.darwinProcessRecord( + pid: 4242, + bsdInfo: { _ in (ppid: 1, startTime: Date(timeIntervalSince1970: 1_700_000_000)) }, + processArguments: { _ in (arguments: ["claude"], piSelectorEnvironment: nil) }, + executablePath: { _ in nil })) + + #expect(record.command == "claude") + #expect(record.arguments == ["claude"]) + #expect(AgentPSOutputParser.provider(for: record) == .claude) + #expect(AgentPSOutputParser.agentProcesses(from: [record]).map(\.pid) == [4242]) + } + + @Test + func `darwin process record falls back to the executable path without argv`() throws { + let record = try #require(LocalAgentSessionScanner.darwinProcessRecord( + pid: 4243, + bsdInfo: { _ in (ppid: 1, startTime: Date(timeIntervalSince1970: 1_700_000_000)) }, + processArguments: { _ in nil }, + executablePath: { _ in "/opt/homebrew/bin/codex" })) + + #expect(record.command == "/opt/homebrew/bin/codex") + #expect(record.arguments == nil) + } + + @Test + func `darwin process record skips processes without argv or executable path`() { + let record = LocalAgentSessionScanner.darwinProcessRecord( + pid: 4244, + bsdInfo: { _ in (ppid: 1, startTime: Date(timeIntervalSince1970: 1_700_000_000)) }, + processArguments: { _ in nil }, + executablePath: { _ in nil }) + + #expect(record == nil) + } #endif private static func procArgsData(arguments: [String], environment: [String] = []) -> Data { From ca051cfa55dc921a4490d48522f1cab0add8ee77 Mon Sep 17 00:00:00 2001 From: Slava Kurilyak Date: Tue, 29 Sep 2026 12:19:58 +0200 Subject: [PATCH 092/122] Add changelog entry for agent sessions fix --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39766219d8..c8cd202901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.70.1 — Unreleased +### Fixed + +- Agent Sessions: keep sessions that are still running after an in-place CLI update deleted their binary, such as Claude Code sessions started before an auto-update, instead of dropping them from the menu and `codexbar sessions` (#4120). Thanks @slavakurilyak! + ## 0.70.0 — 2026-09-29 ### Highlights From 89229b37ed7e1ef0064a39ba86d6f42a1e5de9cf Mon Sep 17 00:00:00 2001 From: cuidong233 Date: Wed, 30 Sep 2026 16:35:22 +0800 Subject: [PATCH 093/122] fix(opencode): support migrated Console workspaces --- CHANGELOG.md | 6 + .../OpenCodeConsoleUsageFetcher.swift | 207 +++++++++++ .../OpenCode/OpenCodeUsageFetcher.swift | 49 ++- .../OpenCode/OpenCodeUsageSnapshot.swift | 63 ++-- .../OpenCodeConsoleSnapshotTests.swift | 89 +++++ .../OpenCodeMenuCardCostTests.swift | 19 +- .../OpenCodeUsageFetcherErrorTests.swift | 4 +- .../OpenCodeUsageParserTests.swift | 12 +- .../OpenCodeConsoleUsageFetcherTests.swift | 347 ++++++++++++++++++ docs/opencode.md | 16 +- 10 files changed, 769 insertions(+), 43 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/OpenCode/OpenCodeConsoleUsageFetcher.swift create mode 100644 Tests/CodexBarTests/OpenCodeConsoleSnapshotTests.swift create mode 100644 TestsLinux/OpenCodeConsoleUsageFetcherTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4f1aecdb..cab4b6da17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- OpenCode: read migrated Console workspaces with the Console session, preserve workspace-scoped quota and prepaid balance, and label explicit 30-day spend without inventing a monthly spending limit (#4131). + ## 0.70.0 — 2026-09-29 ### Highlights diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeConsoleUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeConsoleUsageFetcher.swift new file mode 100644 index 0000000000..cc502f4f79 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeConsoleUsageFetcher.swift @@ -0,0 +1,207 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Console reads stay scoped to one workspace, even when its legacy session is also available. +enum OpenCodeConsoleUsageFetcher { + static func fetchWorkspaceID( + cookieHeader: String, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> String + { + let text = try await self.fetchText( + url: OpenCodeGoUsageFetcher.consoleWorkspacesURL, + workspaceID: nil, + cookieHeader: cookieHeader, + timeout: timeout, + transport: transport) + guard let workspaceID = OpenCodeGoUsageFetcher.parseConsoleWorkspaceIDs(text: text).first else { + throw OpenCodeUsageError.parseFailed("Missing Console workspace id.") + } + return workspaceID + } + + static func fetchUsage( + workspaceID: String, + cookieHeader: String, + timeout: TimeInterval, + now: Date, + transport: any ProviderHTTPTransport) async throws -> OpenCodeUsageSnapshot + { + let text = try await self.fetchText( + url: OpenCodeGoUsageFetcher.consoleGoStatusURL, + workspaceID: workspaceID, + cookieHeader: cookieHeader, + timeout: timeout, + transport: transport) + if let quota = OpenCodeGoUsageFetcher.parseConsoleGoStatus(text: text, now: now) { + return OpenCodeUsageSnapshot( + hasWeeklyUsage: quota.hasWeeklyUsage, + rollingUsagePercent: quota.rollingUsagePercent, + weeklyUsagePercent: quota.weeklyUsagePercent, + rollingResetInSec: quota.rollingResetInSec, + weeklyResetInSec: quota.weeklyResetInSec, + renewsAt: quota.renewsAt, + updatedAt: now) + } + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), + object is NSNull || (object as? [String: Any])?["access"] is NSNull + else { throw OpenCodeUsageError.parseFailed("Invalid Console usage payload.") } + + // A successful null Go status means no quota. Confirm the workspace's subscription state + // before publishing spend alone; malformed or inaccessible quota is not a PAYG account. + let organizationText = try await self.fetchText( + url: URL(string: "https://opencode.ai/console/api/orgs/current")!, + workspaceID: workspaceID, + cookieHeader: cookieHeader, + timeout: timeout, + transport: transport) + guard let organizationData = organizationText.data(using: .utf8), + let organization = try? JSONDecoder().decode(Organization.self, from: organizationData), + !organization.hasGoSubscription + else { throw OpenCodeUsageError.parseFailed("Console subscription usage is unavailable.") } + + let billingText = try await self.fetchText( + url: OpenCodeGoUsageFetcher.consoleBillingStatusURL, + workspaceID: workspaceID, + cookieHeader: cookieHeader, + timeout: timeout, + transport: transport) + guard let billingData = billingText.data(using: .utf8), + let billing = try? JSONDecoder().decode(Billing.self, from: billingData), + billing.mode == "pay-as-you-go" + else { throw OpenCodeUsageError.parseFailed("No supported Console pay-as-you-go billing is available.") } + + let summaryText = try await self.fetchText( + url: URL(string: "https://opencode.ai/console/api/usage/summary?range=30d")!, + workspaceID: workspaceID, + cookieHeader: cookieHeader, + timeout: timeout, + transport: transport) + let usageUSD = try self.parseSummary(text: summaryText) + // Unsupported or missing balance data does not erase spend for a confirmed PAYG account. + let balanceUSD = try? OpenCodeGoZenBalanceParser.parseConsoleBillingStatus(text: billingText) + return .payAsYouGo( + .init(usageUSD: usageUSD, limitUSD: nil, balanceUSD: balanceUSD, period: .last30Days), + updatedAt: now) + } + + private struct Organization: Decodable { + let hasGoSubscription: Bool + } + + private struct Billing: Decodable { + let mode: String + } + + static func parseSummary(text: String) throws -> Double { + guard let data = text.data(using: .utf8), + let summary = try? JSONDecoder().decode(Summary.self, from: data) + else { throw OpenCodeUsageError.parseFailed("Invalid Console usage summary.") } + return summary.totalCostMicroCents / 100_000_000 + } + + private struct Summary: Decodable { + let totalCostMicroCents: Double + + private enum CodingKeys: String, CodingKey { + case totalCostMicroCents + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let raw = try? container.decode(String.self, forKey: .totalCostMicroCents), + !raw.isEmpty, raw.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }), + let value = Double(raw), value.isFinite + { + self.totalCostMicroCents = value + } else { + self.totalCostMicroCents = try Double(container.decode(UInt64.self, forKey: .totalCostMicroCents)) + } + } + } + + private static func fetchText( + url: URL, + workspaceID: String?, + cookieHeader: String, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> String + { + try Task.checkCancellation() + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let workspaceID { + request.setValue(workspaceID, forHTTPHeaderField: OpenCodeGoUsageFetcher.consoleWorkspaceHeaderField) + } + let response = try await transport.response(for: request) + try Task.checkCancellation() + if response.statusCode == 401 { throw OpenCodeUsageError.invalidCredentials } + guard response.statusCode == 200 else { + // Console JSON is not a legacy sign-in page. A scope/permission failure is not expired auth. + throw OpenCodeUsageError.apiError("Console HTTP \(response.statusCode)") + } + guard let text = String(data: response.data, encoding: .utf8) else { + throw OpenCodeUsageError.parseFailed("Console response was not UTF-8.") + } + return text + } + + static func withLegacyFallback( + cookieHeader: String, + console: @Sendable () async throws -> Value, + legacy: @Sendable () async throws -> Value) async throws -> Value + { + try Task.checkCancellation() + let cookies = CookieHeaderNormalizer.pairs(from: cookieHeader) + let hasConsoleSession = cookies + .contains { OpenCodeWebCookieSupport.consoleSessionCookieNames.contains($0.name) } + // The Console authenticates with its own cookie; keep legacy-only accounts on their existing path. + guard hasConsoleSession else { return try await legacy() } + do { + return try await console() + } catch { + let consoleError = error + try self.checkCancellation(error) + guard self.canTryLegacy(after: error), + cookies.contains(where: { OpenCodeWebCookieSupport.sessionCookieNames.contains($0.name) }) + else { throw error } + do { + let value = try await legacy() + try Task.checkCancellation() + return value + } catch { + try self.checkCancellation(error) + // Keep a Console permission or parsing error if the legacy endpoint says signed out. + if case .invalidCredentials? = consoleError as? OpenCodeUsageError { throw error } + if error is OpenCodeUsageError { throw consoleError } + throw error + } + } + } + + private static func canTryLegacy(after error: Error) -> Bool { + if error is OpenCodeUsageError { return true } + guard let error = error as? URLError else { return false } + switch error.code { + case .timedOut, .networkConnectionLost, .cannotConnectToHost, .cannotFindHost, + .dnsLookupFailed, .notConnectedToInternet, .resourceUnavailable: + return true + default: + return false + } + } + + private static func checkCancellation(_ error: Error) throws { + try Task.checkCancellation() + if error is CancellationError || (error as? URLError)?.code == .cancelled { + throw CancellationError() + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift index 6a2d95df2f..d61783b06b 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift @@ -53,18 +53,51 @@ public struct OpenCodeUsageFetcher: Sendable { guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { throw OpenCodeUsageError.invalidCredentials } - let workspaceID: String = if let override = OpenCodeWebParsing.normalizeWorkspaceID(workspaceIDOverride) { + let normalizedOverride = OpenCodeGoUsageFetcher.normalizeWorkspaceID(workspaceIDOverride) + if let rawOverride = workspaceIDOverride?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawOverride.isEmpty, normalizedOverride == nil + { + throw OpenCodeUsageError.apiError("Invalid workspace override.") + } + let workspaceID: String = if let override = normalizedOverride { override } else { - try await self.fetchWorkspaceID( + try await OpenCodeConsoleUsageFetcher.withLegacyFallback(cookieHeader: requestCookieHeader) { + try await OpenCodeConsoleUsageFetcher.fetchWorkspaceID( + cookieHeader: requestCookieHeader, timeout: timeout, transport: transport) + } legacy: { + try await self.fetchWorkspaceID( + cookieHeader: requestCookieHeader, timeout: timeout, transport: transport) + } + } + return try await OpenCodeConsoleUsageFetcher.withLegacyFallback(cookieHeader: requestCookieHeader) { + try await OpenCodeConsoleUsageFetcher.fetchUsage( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: timeout, + now: now, + transport: transport) + } legacy: { + try await self.fetchLegacyUsage( + workspaceID: workspaceID, cookieHeader: requestCookieHeader, timeout: timeout, + now: now, transport: transport) } + } + + private static func fetchLegacyUsage( + workspaceID: String, + cookieHeader: String, + timeout: TimeInterval, + now: Date, + transport: any ProviderHTTPTransport) async throws -> OpenCodeUsageSnapshot + { do { let subscriptionText = try await self.fetchSubscriptionInfo( workspaceID: workspaceID, - cookieHeader: requestCookieHeader, + cookieHeader: cookieHeader, timeout: timeout, transport: transport) return try self.parseSubscription(text: subscriptionText, now: now) @@ -76,7 +109,7 @@ public struct OpenCodeUsageFetcher: Sendable { do { if let snapshot = try await self.fetchPayAsYouGoUsage( workspaceID: workspaceID, - cookieHeader: requestCookieHeader, + cookieHeader: cookieHeader, timeout: timeout, now: now, transport: transport) @@ -85,6 +118,10 @@ public struct OpenCodeUsageFetcher: Sendable { } } catch OpenCodeUsageError.invalidCredentials { throw OpenCodeUsageError.invalidCredentials + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() } catch { Self.log.error("OpenCode billing fallback failed: \(error.localizedDescription)") } @@ -138,8 +175,8 @@ extension OpenCodeUsageFetcher { "limit \(billing.monthlyLimitUSD == nil ? "unset" : "set")).") return .payAsYouGo( OpenCodeUsageSnapshot.PayAsYouGoUsage( - monthlyUsageUSD: billing.monthlyUsageUSD, - monthlyLimitUSD: billing.monthlyLimitUSD, + usageUSD: billing.monthlyUsageUSD, + limitUSD: billing.monthlyLimitUSD, balanceUSD: billing.balanceUSD), updatedAt: now) } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift index b493ce975c..27e13e1347 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift @@ -1,43 +1,54 @@ import Foundation public struct OpenCodeUsageSnapshot: Sendable { - /// Monthly spend of a pay-as-you-go Zen workspace, which bills per request instead of + /// Spend of a pay-as-you-go Zen workspace, which bills per request instead of /// exposing the rolling/weekly quota windows subscription workspaces report. public struct PayAsYouGoUsage: Equatable, Sendable { - public let monthlyUsageUSD: Double - public let monthlyLimitUSD: Double? + public enum Period: Equatable, Sendable { + case monthly + case last30Days + } + + public let usageUSD: Double + public let limitUSD: Double? public let balanceUSD: Double? + public let period: Period - public init(monthlyUsageUSD: Double, monthlyLimitUSD: Double?, balanceUSD: Double?) { - self.monthlyUsageUSD = monthlyUsageUSD - self.monthlyLimitUSD = monthlyLimitUSD + public init(usageUSD: Double, limitUSD: Double?, balanceUSD: Double?, period: Period = .monthly) { + self.usageUSD = usageUSD + self.limitUSD = limitUSD self.balanceUSD = balanceUSD + self.period = period } - /// Percent of the configured monthly limit consumed, or `nil` when no limit is set. + /// Percent of the configured monthly limit consumed. Rolling spend cannot be compared + /// with a calendar-month limit, so it has no percentage even if a limit is supplied. public var usedPercent: Double? { - guard let limit = self.monthlyLimitUSD, limit > 0 else { return nil } - return min(100, max(0, (self.monthlyUsageUSD / limit) * 100)) + guard self.period == .monthly, let limit = self.limitUSD, limit > 0 else { return nil } + return min(100, max(0, (self.usageUSD / limit) * 100)) } } + public let hasWeeklyUsage: Bool public let rollingUsagePercent: Double public let weeklyUsagePercent: Double - public let rollingResetInSec: Int - public let weeklyResetInSec: Int + public let rollingResetInSec: Int? + public let weeklyResetInSec: Int? public let renewsAt: Date? public let payAsYouGo: PayAsYouGoUsage? public let updatedAt: Date public init( + hasWeeklyUsage: Bool = true, rollingUsagePercent: Double, weeklyUsagePercent: Double, - rollingResetInSec: Int, - weeklyResetInSec: Int, + rollingResetInSec: Int?, + weeklyResetInSec: Int?, renewsAt: Date? = nil, payAsYouGo: PayAsYouGoUsage? = nil, updatedAt: Date) { + self.hasWeeklyUsage = hasWeeklyUsage self.rollingUsagePercent = rollingUsagePercent self.weeklyUsagePercent = weeklyUsagePercent self.rollingResetInSec = rollingResetInSec @@ -65,19 +76,23 @@ public struct OpenCodeUsageSnapshot: Sendable { return self.payAsYouGoUsageSnapshot(payAsYouGo) } - let rollingReset = self.updatedAt.addingTimeInterval(TimeInterval(self.rollingResetInSec)) - let weeklyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.weeklyResetInSec)) - + let rollingReset = self.rollingResetInSec.map { self.updatedAt.addingTimeInterval(TimeInterval($0)) } let primary = RateWindow( usedPercent: self.rollingUsagePercent, windowMinutes: 5 * 60, resetsAt: rollingReset, resetDescription: nil) - let secondary = RateWindow( - usedPercent: self.weeklyUsagePercent, - windowMinutes: 7 * 24 * 60, - resetsAt: weeklyReset, - resetDescription: nil) + let secondary: RateWindow? + if self.hasWeeklyUsage { + let weeklyReset = self.weeklyResetInSec.map { self.updatedAt.addingTimeInterval(TimeInterval($0)) } + secondary = RateWindow( + usedPercent: self.weeklyUsagePercent, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil) + } else { + secondary = nil + } var extraWindows: [NamedRateWindow]? if let renewsAt = self.renewsAt { @@ -113,10 +128,10 @@ public struct OpenCodeUsageSnapshot: Sendable { resetDescription: nil) } let cost = ProviderCostSnapshot( - used: usage.monthlyUsageUSD, - limit: usage.monthlyLimitUSD ?? 0, + used: usage.usageUSD, + limit: usage.period == .monthly ? usage.limitUSD ?? 0 : 0, currencyCode: "USD", - period: "Monthly", + period: usage.period == .monthly ? "Monthly" : "Last 30 days", balance: usage.balanceUSD, updatedAt: self.updatedAt) diff --git a/Tests/CodexBarTests/OpenCodeConsoleSnapshotTests.swift b/Tests/CodexBarTests/OpenCodeConsoleSnapshotTests.swift new file mode 100644 index 0000000000..90f47490e9 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeConsoleSnapshotTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodeConsoleSnapshotTests { + private static let now = Date(timeIntervalSince1970: 1_700_000_000) + + @Test + func `unknown Console resets remain unknown`() { + let snapshot = OpenCodeUsageSnapshot( + rollingUsagePercent: 17, + weeklyUsagePercent: 75, + rollingResetInSec: nil, + weeklyResetInSec: nil, + updatedAt: Self.now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 17) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.secondary?.usedPercent == 75) + #expect(usage.secondary?.resetsAt == nil) + } + + @Test + func `missing Console weekly quota does not appear as unused quota`() { + let snapshot = OpenCodeUsageSnapshot( + hasWeeklyUsage: false, + rollingUsagePercent: 17, + weeklyUsagePercent: 0, + rollingResetInSec: 600, + weeklyResetInSec: nil, + updatedAt: Self.now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 17) + #expect(usage.primary?.resetsAt == Self.now.addingTimeInterval(600)) + #expect(usage.secondary == nil) + } + + @Test + func `zero usage and immediate reset remain valid supplied quota values`() { + let snapshot = OpenCodeUsageSnapshot( + rollingUsagePercent: 0, + weeklyUsagePercent: 0, + rollingResetInSec: 0, + weeklyResetInSec: 0, + updatedAt: Self.now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetsAt == Self.now) + #expect(usage.secondary?.usedPercent == 0) + #expect(usage.secondary?.resetsAt == Self.now) + } + + @Test + func `Console spend keeps its rolling thirty day period`() { + let snapshot = OpenCodeUsageSnapshot.payAsYouGo( + .init(usageUSD: 3.25, limitUSD: nil, balanceUSD: 12.5, period: .last30Days), + updatedAt: Self.now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.providerCost?.used == 3.25) + #expect(usage.providerCost?.limit == 0) + #expect(usage.providerCost?.balance == 12.5) + #expect(usage.providerCost?.period == "Last 30 days") + #expect(usage.providerCost?.currencyCode == "USD") + } + + @Test + func `rolling spend cannot consume a monthly quota`() { + let payAsYouGo = OpenCodeUsageSnapshot.PayAsYouGoUsage( + usageUSD: 15, + limitUSD: 20, + balanceUSD: nil, + period: .last30Days) + let usage = OpenCodeUsageSnapshot.payAsYouGo(payAsYouGo, updatedAt: Self.now).toUsageSnapshot() + + #expect(payAsYouGo.usedPercent == nil) + #expect(usage.primary == nil) + #expect(usage.providerCost?.limit == 0) + } +} diff --git a/Tests/CodexBarTests/OpenCodeMenuCardCostTests.swift b/Tests/CodexBarTests/OpenCodeMenuCardCostTests.swift index ebe12a38d7..d9d4d0d585 100644 --- a/Tests/CodexBarTests/OpenCodeMenuCardCostTests.swift +++ b/Tests/CodexBarTests/OpenCodeMenuCardCostTests.swift @@ -34,7 +34,7 @@ struct OpenCodeMenuCardCostTests { func `pay as you go card shows monthly spend against the limit`() throws { let now = Date() let model = try self.makeModel( - .init(monthlyUsageUSD: 15, monthlyLimitUSD: 20, balanceUSD: 12.5), + .init(usageUSD: 15, limitUSD: 20, balanceUSD: 12.5), now: now) #expect(model.providerCost?.spendLine == "Monthly: $15.00 / $20.00") @@ -45,7 +45,7 @@ struct OpenCodeMenuCardCostTests { func `pay as you go card without a limit still shows spend and balance`() throws { let now = Date() let model = try self.makeModel( - .init(monthlyUsageUSD: 15, monthlyLimitUSD: nil, balanceUSD: 12.5), + .init(usageUSD: 15, limitUSD: nil, balanceUSD: 12.5), now: now) let cost = try #require(model.providerCost) @@ -60,11 +60,24 @@ struct OpenCodeMenuCardCostTests { func `pay as you go card without a limit or balance still shows spend`() throws { let now = Date() let model = try self.makeModel( - .init(monthlyUsageUSD: 15, monthlyLimitUSD: nil, balanceUSD: nil), + .init(usageUSD: 15, limitUSD: nil, balanceUSD: nil), now: now) let cost = try #require(model.providerCost) #expect(cost.spendLine == "Monthly: $15.00") #expect(cost.balanceLine == nil) } + + @Test + func `Console pay as you go card labels rolling spend without a monthly quota`() throws { + let model = try self.makeModel( + .init(usageUSD: 15, limitUSD: nil, balanceUSD: 12.5, period: .last30Days), + now: Date()) + + let cost = try #require(model.providerCost) + #expect(cost.spendLine == "Last 30 days: $15.00") + #expect(cost.balanceLine == "Balance: $12.50") + #expect(cost.percentUsed == nil) + #expect(cost.percentLine == nil) + } } diff --git a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift index a5907e69b3..5540253890 100644 --- a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift @@ -153,8 +153,8 @@ struct OpenCodeUsageFetcherErrorTests { session: self.makeSession()) let payAsYouGo = try #require(snapshot.payAsYouGo) - #expect(payAsYouGo.monthlyUsageUSD == 15) - #expect(payAsYouGo.monthlyLimitUSD == 20) + #expect(payAsYouGo.usageUSD == 15) + #expect(payAsYouGo.limitUSD == 20) #expect(payAsYouGo.balanceUSD == 12.5) #expect(payAsYouGo.usedPercent == 75) #expect(methods == ["GET", "GET"]) diff --git a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift index ff866b02c1..32a2e26278 100644 --- a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift @@ -281,8 +281,8 @@ struct OpenCodeUsageParserTests { let now = Date(timeIntervalSince1970: 1_700_000_000) let snapshot = OpenCodeUsageSnapshot.payAsYouGo( OpenCodeUsageSnapshot.PayAsYouGoUsage( - monthlyUsageUSD: 15, - monthlyLimitUSD: 20, + usageUSD: 15, + limitUSD: 20, balanceUSD: 12.5), updatedAt: now) @@ -303,8 +303,8 @@ struct OpenCodeUsageParserTests { let now = Date(timeIntervalSince1970: 1_700_000_000) let snapshot = OpenCodeUsageSnapshot.payAsYouGo( OpenCodeUsageSnapshot.PayAsYouGoUsage( - monthlyUsageUSD: 3, - monthlyLimitUSD: nil, + usageUSD: 3, + limitUSD: nil, balanceUSD: 1), updatedAt: now) @@ -319,8 +319,8 @@ struct OpenCodeUsageParserTests { @Test func `pay as you go spend above the monthly limit clamps to 100 percent`() { let usage = OpenCodeUsageSnapshot.PayAsYouGoUsage( - monthlyUsageUSD: 25, - monthlyLimitUSD: 20, + usageUSD: 25, + limitUSD: 20, balanceUSD: 0) #expect(usage.usedPercent == 100) diff --git a/TestsLinux/OpenCodeConsoleUsageFetcherTests.swift b/TestsLinux/OpenCodeConsoleUsageFetcherTests.swift new file mode 100644 index 0000000000..c27177586d --- /dev/null +++ b/TestsLinux/OpenCodeConsoleUsageFetcherTests.swift @@ -0,0 +1,347 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct OpenCodeConsoleUsageFetcherTests { + private static let now = Date(timeIntervalSince1970: 1_789_862_400) + private static let workspaceID = "wrk_CONSOLE123" + private static let consoleCookie = "__Host-console_session=synthetic-session" + private static let bothCookies = Self.consoleCookie + "; auth=synthetic-legacy" + private static let goPath = "/console/api/go/status" + private static let orgPath = "/console/api/orgs/current" + private static let summaryPath = "/console/api/usage/summary" + private static let billingPath = "/console/api/billing/status" + private static let quota = """ + {"access":{"endsAt":"2026-10-19T00:00:00Z","meters":{ + "fiveHour":{"limitMicroCents":"1200000000","usedMicroCents":"300000000", + "resetsAt":"2026-09-20T03:00:00Z"}, + "week":{"limitMicroCents":"3000000000","usedMicroCents":"1200000000", + "resetsAt":"2026-09-21T00:00:00Z"}}}} + """ + private static let legacyQuota = """ + {"rollingUsage":{"usagePercent":17,"resetInSec":600}, + "weeklyUsage":{"usagePercent":75,"resetInSec":7200}} + """ + + @Test + func `Console only session discovers its workspace and reads subscription quota`() async throws { + let transport = ConsoleUsageTransport(replies: [ + "/console/api/orgs": .init(body: #"[{"id":"wrk_CONSOLE123"},{"id":"wrk_OTHER456"}]"#), + Self.goPath: .init(body: Self.quota), + ]) + + let snapshot = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: Self.consoleCookie, + timeout: 17, + now: Self.now, + session: transport) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 40) + #expect(snapshot.rollingResetInSec == 10800) + #expect(snapshot.weeklyResetInSec == 86400) + #expect(snapshot.updatedAt == Self.now) + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == ["/console/api/orgs", Self.goPath]) + #expect(requests.first?.value(forHTTPHeaderField: "x-org-id") == nil) + #expect(requests.last?.value(forHTTPHeaderField: "x-org-id") == Self.workspaceID) + #expect(requests.allSatisfy { $0.httpMethod == "GET" && $0.timeoutInterval == 17 }) + } + + @Test(arguments: [#"{"access":null}"#, "null"]) + func `PAYG reads stay in the selected org and use thirty day microcent totals`(goStatus: String) async throws { + let transport = ConsoleUsageTransport(replies: Self.payAsYouGoReplies(goStatus: goStatus)) + + let snapshot = try await Self.fetch(transport: transport) + + let payAsYouGo = try #require(snapshot.payAsYouGo) + #expect(payAsYouGo.usageUSD == 3.25) + #expect(payAsYouGo.balanceUSD == 12.5) + #expect(payAsYouGo.limitUSD == nil) + #expect(payAsYouGo.period == .last30Days) + #expect(payAsYouGo.usedPercent == nil) + #expect(snapshot.toUsageSnapshot().providerCost?.period == "Last 30 days") + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [Self.goPath, Self.orgPath, Self.billingPath, Self.summaryPath]) + #expect(requests.allSatisfy { $0.value(forHTTPHeaderField: "x-org-id") == Self.workspaceID }) + let summaryURL = try #require(requests.first { $0.url?.path == Self.summaryPath }?.url) + let query = URLComponents(url: summaryURL, resolvingAgainstBaseURL: false)?.queryItems + #expect(query == [URLQueryItem(name: "range", value: "30d")]) + } + + @Test + func `explicit workspace override skips discovery and preserves missing quota windows`() async throws { + let transport = ConsoleUsageTransport(replies: [ + Self.goPath: .init(body: """ + {"access":{"meters":{"fiveHour":{ + "limitMicroCents":"1200000000","usedMicroCents":"300000000"}}}} + """), + ]) + + let snapshot = try await Self.fetch(transport: transport) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.rollingResetInSec == nil) + #expect(snapshot.hasWeeklyUsage == false) + #expect(snapshot.toUsageSnapshot().primary?.resetsAt == nil) + #expect(snapshot.toUsageSnapshot().secondary == nil) + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [Self.goPath]) + #expect(requests.first?.value(forHTTPHeaderField: "x-org-id") == Self.workspaceID) + } + + @Test + func `subscribed org with null quota cannot be presented as PAYG spend`() async throws { + let transport = ConsoleUsageTransport(replies: [ + Self.goPath: .init(body: #"{"access":null}"#), + Self.orgPath: .init(body: #"{"hasGoSubscription":true}"#), + ]) + + do { + _ = try await Self.fetch(transport: transport) + Issue.record("Expected unavailable subscription quota to fail") + } catch OpenCodeUsageError.parseFailed { + // Valid credentials without quota must not publish spend as a subscription replacement. + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [Self.goPath, Self.orgPath]) + } + + @Test(arguments: [ + #"{"billingMode":"prepaid","mode":"pay-as-you-go"}"#, + #"{"billingMode":"prepaid","mode":"pay-as-you-go","balanceMicroCents":"invalid"}"#, + ]) + func `missing or malformed balance keeps spend for confirmed PAYG`(billing: String) async throws { + var replies = Self.payAsYouGoReplies() + replies[Self.billingPath] = .init(body: billing) + let transport = ConsoleUsageTransport(replies: replies) + + let snapshot = try await Self.fetch(transport: transport) + + #expect(snapshot.payAsYouGo?.usageUSD == 3.25) + #expect(snapshot.payAsYouGo?.balanceUSD == nil) + #expect(snapshot.payAsYouGo?.limitUSD == nil) + #expect(snapshot.toUsageSnapshot().providerCost?.used == 3.25) + } + + @Test + func `invoiceable account is not rendered as PAYG spend`() async throws { + var replies = Self.payAsYouGoReplies() + replies[Self.billingPath] = .init(body: #"{"billingMode":"credit","mode":"invoiceable"}"#) + let transport = ConsoleUsageTransport(replies: replies) + + do { + _ = try await Self.fetch(transport: transport) + Issue.record("Expected unsupported billing mode to fail") + } catch OpenCodeUsageError.parseFailed { + // The summary alone cannot establish a supported account type. + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [Self.goPath, Self.orgPath, Self.billingPath]) + } + + @Test(arguments: ["true", "-1", "1.5", #""NaN""#, "null", #""-1""#, #""1.5""#]) + func `malformed microcent totals fail instead of becoming spend`(raw: String) throws { + do { + _ = try OpenCodeConsoleUsageFetcher.parseSummary(text: "{\"totalCostMicroCents\":\(raw)}") + Issue.record("Expected invalid monetary total to fail") + } catch OpenCodeUsageError.parseFailed { + // Only nonnegative whole microcents can be a spend total. + } + } + + @Test(arguments: [("0", 0.0), ("5520922478", 55.20922478), (#""6653244286""#, 66.53244286)]) + func `numeric zero and reported Console totals preserve microcent scale`(raw: String, usd: Double) throws { + let amount = try OpenCodeConsoleUsageFetcher.parseSummary(text: "{\"totalCostMicroCents\":\(raw)}") + + #expect(amount == usd) + } + + @Test(arguments: ["org_SELECTED123", "https://opencode.ai/console/org_SELECTED123/billing"]) + func `organization IDs survive raw and Console URL overrides`(override: String) async throws { + let transport = ConsoleUsageTransport(replies: [Self.goPath: .init(body: Self.quota)]) + + _ = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: Self.consoleCookie, + timeout: 17, + now: Self.now, + workspaceIDOverride: override, + session: transport) + + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [Self.goPath]) + #expect(requests.first?.value(forHTTPHeaderField: "x-org-id") == "org_SELECTED123") + } + + @Test + func `Console unauthorized can fall back to valid legacy quota`() async throws { + let transport = ConsoleUsageTransport(replies: [ + Self.goPath: .init(status: 401, body: #"{"error":"unauthorized"}"#), + "/_server": .init(body: Self.legacyQuota), + ]) + + let snapshot = try await Self.fetch(transport: transport, cookieHeader: Self.bothCookies) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.weeklyUsagePercent == 75) + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [Self.goPath, "/_server"]) + #expect(requests.last?.value(forHTTPHeaderField: "Referer")?.contains(Self.workspaceID) == true) + } + + @Test + func `recoverable Console timeout can use valid legacy quota`() async throws { + let transport = ConsoleUsageTransport( + replies: ["/_server": .init(body: Self.legacyQuota)], + failures: [Self.goPath: .timeout]) + + let snapshot = try await Self.fetch(transport: transport, cookieHeader: Self.bothCookies) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.weeklyUsagePercent == 75) + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [Self.goPath, "/_server"]) + } + + @Test(arguments: [403, 500]) + func `Console API errors survive a signed out legacy fallback`(status: Int) async throws { + let transport = ConsoleUsageTransport(replies: [ + Self.goPath: .init(status: status, body: #"{"error":"not associated with an account"}"#), + "/_server": .init(body: "Please sign in to continue"), + ]) + + do { + _ = try await Self.fetch(transport: transport, cookieHeader: Self.bothCookies) + Issue.record("Expected Console permission error") + } catch let OpenCodeUsageError.apiError(message) { + #expect(message == "Console HTTP \(status)") + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [Self.goPath, "/_server"]) + } + + @Test + func `invalid explicit workspace never falls back to account discovery`() async throws { + let transport = ConsoleUsageTransport(replies: [:]) + do { + _ = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: Self.consoleCookie, + timeout: 17, + workspaceIDOverride: "not-a-workspace", + session: transport) + Issue.record("Expected invalid workspace override") + } catch let OpenCodeUsageError.apiError(message) { + #expect(message == "Invalid workspace override.") + } + #expect(await transport.requests().isEmpty) + } + + @Test + func `Console only credentials never trigger a legacy authentication request`() async throws { + let transport = ConsoleUsageTransport(replies: [ + Self.goPath: .init(status: 401, body: #"{"error":"unauthorized"}"#), + ]) + + do { + _ = try await Self.fetch(transport: transport) + Issue.record("Expected invalid Console credentials") + } catch OpenCodeUsageError.invalidCredentials { + // The legacy endpoint has no applicable session cookie. + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [Self.goPath]) + } + + @Test(arguments: [ConsoleUsageTransport.Failure.cancellation, .cancelledURL, .tls]) + func `cancellation and TLS failures never fall back to legacy`( + failure: ConsoleUsageTransport.Failure) async throws + { + let transport = ConsoleUsageTransport(replies: [:], failures: [Self.goPath: failure]) + + do { + _ = try await Self.fetch(transport: transport, cookieHeader: Self.bothCookies) + Issue.record("Expected transport failure") + } catch { + if failure == .tls { + #expect((error as? URLError)?.code == .serverCertificateUntrusted) + } else { + #expect(error is CancellationError) + } + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [Self.goPath]) + } + + private static func fetch( + transport: ConsoleUsageTransport, + cookieHeader: String = Self.consoleCookie) async throws -> OpenCodeUsageSnapshot + { + try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + timeout: 17, + now: self.now, + workspaceIDOverride: self.workspaceID, + session: transport) + } + + private static func payAsYouGoReplies(goStatus: String = "null") -> [String: ConsoleUsageTransport.Reply] { + [ + goPath: .init(body: goStatus), + orgPath: .init(body: #"{"id":"wrk_CONSOLE123","hasGoSubscription":false}"#), + summaryPath: .init(body: #"{"totalCostMicroCents":"325000000","totalRequests":12}"#), + billingPath: .init(body: """ + {"billingMode":"prepaid","mode":"pay-as-you-go","balanceMicroCents":"1250000000", + "creditLimitMicroCents":"99900000000","dailyLimitMicroCents":"2000000000"} + """), + ] + } +} + +actor ConsoleUsageTransport: ProviderHTTPTransport { + struct Reply: Sendable { + var status = 200 + let body: String + } + + enum Failure: Equatable, Sendable { + case cancellation + case cancelledURL + case tls + case timeout + } + + private let replies: [String: Reply] + private let failures: [String: Failure] + private var recordedRequests: [URLRequest] = [] + + init(replies: [String: Reply], failures: [String: Failure] = [:]) { + self.replies = replies + self.failures = failures + } + + func requests() -> [URLRequest] { + self.recordedRequests + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.recordedRequests.append(request) + let url = try #require(request.url) + switch self.failures[url.path] { + case .cancellation: throw CancellationError() + case .cancelledURL: throw URLError(.cancelled) + case .tls: throw URLError(.serverCertificateUntrusted) + case .timeout: throw URLError(.timedOut) + case nil: break + } + let reply = try #require(self.replies[url.path], "Unexpected request to \(url.path)") + let response = try #require(HTTPURLResponse( + url: url, + statusCode: reply.status, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])) + return (Data(reply.body.utf8), response) + } +} diff --git a/docs/opencode.md b/docs/opencode.md index 09473dfc61..663f149614 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -21,6 +21,16 @@ read_when: - `GET https://opencode.ai/console/api/billing/status` reads the selected workspace's prepaid PAYG Zen balance with the same `x-org-id` header. Convert its signed `balanceMicroCents` string to USD by dividing by 100,000,000; `availableMicroCents` is a separate credit value and is not substituted for the balance. +- The base OpenCode provider also uses these Console endpoints when the cookie header contains + `__Host-console_session`. It maps Go's five-hour/week meters and subscription end into its existing quota + windows. Legacy-only cookies retain the existing server-function path. +- For base OpenCode workspaces without Go quota, `GET /console/api/orgs/current` must confirm + `hasGoSubscription: false` and billing status must confirm `mode: "pay-as-you-go"` before + `GET /console/api/usage/summary?range=30d` supplies spend. Its + `totalCostMicroCents` is divided by 100,000,000 and displayed as **Last 30 days**. The default summary + range is not used, and rolling spend is not presented as calendar-month spend. No Console monthly + spending limit has been established, so no spending percentage is published; the Go month quota + is not substituted for a spending limit. An optional prepaid balance uses the existing billing mapping. - `POST https://opencode.ai/_server` with server function IDs, used for workspaces that have not migrated to the console and for the Zen balance: - `workspaces` (`def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f`) @@ -71,8 +81,10 @@ usage is a separate [OpenAI provider](openai.md), not Codex subscription quota. to each candidate browser. Other browsers stay on Manual Cookie import until CodexBar has an explicit browser selector. - Set `CODEXBAR_OPENCODE_WORKSPACE_ID` to skip workspace lookup and force a specific workspace. -- Workspace override accepts a raw `wrk_…` ID or a full `https://opencode.ai/workspace/...` URL. OpenCode Go also - accepts Console `org_…` IDs and `https://opencode.ai/console/...` URLs. +- Workspace override accepts a raw `wrk_…` or `org_…` ID, a full `https://opencode.ai/workspace/...` URL, + or a `https://opencode.ai/console/...` URL. Both web providers pin all usage, billing and fallback requests + to the selected workspace. Console reads send `x-org-id` except during discovery; legacy fallback + keeps the same workspace ID in its existing requests. - Console migration: OpenCode redirects migrated workspaces from `opencode.ai/workspace/` to the console, which serves an empty client-rendered shell, so the legacy scraped payload is absent. Web reads try the console API first and fall back to the legacy page when a legacy session cookie is present. The two sessions From 4b3c65917d074ea2dce4ffcea37b635fa2287d03 Mon Sep 17 00:00:00 2001 From: cuidong233 Date: Wed, 30 Sep 2026 16:40:43 +0800 Subject: [PATCH 094/122] fix(opencode): parse Console reset timestamps without fractions --- .../CodexBarCore/Providers/Shared/OpenCodeWebParsing.swift | 6 +----- Tests/CodexBarTests/OpenCodeWebParsingTests.swift | 3 ++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Shared/OpenCodeWebParsing.swift b/Sources/CodexBarCore/Providers/Shared/OpenCodeWebParsing.swift index 1ef8b89889..4f7eb8cb67 100644 --- a/Sources/CodexBarCore/Providers/Shared/OpenCodeWebParsing.swift +++ b/Sources/CodexBarCore/Providers/Shared/OpenCodeWebParsing.swift @@ -131,11 +131,7 @@ enum OpenCodeWebParsing { if let number = Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) { return self.dateValue(from: number) } - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let parsed = formatter.date(from: string) { - return parsed - } + return ISO8601DateParser.parse(string) } return nil } diff --git a/Tests/CodexBarTests/OpenCodeWebParsingTests.swift b/Tests/CodexBarTests/OpenCodeWebParsingTests.swift index 83a9551a1f..de4e7d25ce 100644 --- a/Tests/CodexBarTests/OpenCodeWebParsingTests.swift +++ b/Tests/CodexBarTests/OpenCodeWebParsingTests.swift @@ -20,7 +20,7 @@ struct OpenCodeWebParsingTests { } @Test - func `dates preserve epoch thresholds and fractional ISO parsing`() { + func `dates preserve epoch thresholds and both ISO timestamp forms`() { let expected = Date(timeIntervalSince1970: 1_800_000_000) #expect(OpenCodeWebParsing.dateValue(from: 1_800_000_000) == expected) #expect(OpenCodeWebParsing.dateValue(from: " 1800000000000 ") == expected) @@ -28,6 +28,7 @@ struct OpenCodeWebParsingTests { #expect(OpenCodeWebParsing.dateValue(from: 1_000_000_000_000) == Date(timeIntervalSince1970: 1_000_000_000_000)) #expect(OpenCodeWebParsing.dateValue(from: "2027-01-15T08:00:00.000Z") == expected) + #expect(OpenCodeWebParsing.dateValue(from: "2027-01-15T08:00:00Z") == expected) #expect(OpenCodeWebParsing.dateValue(from: "invalid") == nil) #expect(OpenCodeWebParsing.dateValue(from: Double.infinity) == nil) } From 45557db2931bc57b172da22bfaf0ef461d7dcc70 Mon Sep 17 00:00:00 2001 From: Peter Urda Date: Wed, 30 Sep 2026 01:44:11 -0700 Subject: [PATCH 095/122] Correct Antigravity usage field mapping Usage field 1 is the model enum ID, not system-prompt tokens, so it no longer counts as input. Field 9 is reasoning and field 10 is visible output. Price gemini-3.7-flash-safety-le as gemini-3.7-flash, whose model enum ID those turns carry; the recorded model name stays. --- .../Antigravity/AntigravityLocalReader.swift | 8 +- .../Antigravity/AntigravityProtoReader.swift | 14 +- .../AntigravityBotIDValidationTests.swift | 2 +- .../AntigravityCLICostTests.swift | 4 +- .../AntigravityCostScreenshotTests.swift | 2 +- .../AntigravityLocalFixture.swift | 21 ++- .../AntigravityLocalIntegrityTests.swift | 30 +++- .../AntigravityLocalPublicationTests.swift | 16 +- .../AntigravityLocalReaderTests.swift | 156 +++++++++++++----- .../AntigravityLocalScanTests.swift | 6 +- .../AntigravityLocalWALTests.swift | 8 +- .../AntigravityPricingRefreshTests.swift | 11 +- docs/antigravity.md | 12 +- docs/model-pricing.md | 1 + 14 files changed, 200 insertions(+), 91 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift index 3b7100d242..d7479979ad 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift @@ -52,15 +52,14 @@ enum AntigravityLocalReader { init?(session: String, row: Int64, turn: AntigravityProtoReader.ParsedTurn, cacheWrite: Int) { guard let usage = turn.usage, turn.timestampMs != nil, - let input = AntigravityLocalReader.checkedAdd(usage.systemPrompt, usage.newInput), let total = CheckedSum.integers( - [input, usage.output, usage.cacheRead, cacheWrite, usage.reasoning]) + [usage.newInput, usage.output, usage.cacheRead, cacheWrite, usage.reasoning]) else { return nil } self.session = session self.row = row self.turn = turn self.cacheWrite = cacheWrite - self.input = input + self.input = usage.newInput self.total = total } } @@ -108,12 +107,15 @@ enum AntigravityLocalReader { /// Gemini 3.1 Pro is catalogued only as `gemini-3.1-pro-preview`. Antigravity records it under /// its product aliases and effort tiers; ccusage's Antigravity adapter maps the same IDs. + /// Antigravity also records safety-routed Gemini 3.7 Flash turns under `gemini-3.7-flash-safety-le` + /// while the usage record's model enum ID matches ordinary `gemini-3.7-flash` turns. private static let pricingModelAliases = [ "gemini-pro-default": "gemini-3.1-pro-preview", "gemini-pro-agent": "gemini-3.1-pro-preview", "gemini-3.1-pro": "gemini-3.1-pro-preview", "gemini-3.1-pro-high": "gemini-3.1-pro-preview", "gemini-3.1-pro-low": "gemini-3.1-pro-preview", + "gemini-3.7-flash-safety-le": "gemini-3.7-flash", ] static func checkedAdd(_ lhs: Int, _ rhs: Int) -> Int? { diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProtoReader.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProtoReader.swift index 3b2492ac05..3a1dac20df 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProtoReader.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProtoReader.swift @@ -16,12 +16,14 @@ struct AntigravityProtoReader { } struct ParsedUsage: Equatable, Sendable { - var systemPrompt = 0 var newInput = 0 var cacheRead = 0 var output = 0 var reasoning = 0 var responseID: String? + /// Field 1: the model enum ID (e.g. 1298 for `gemini-3.7-flash`). Validated and kept for + /// copied-row identity; never counted as tokens. Absent for JSONL-sourced usage. + var modelID: Int? fileprivate var botIdentifier = AuxiliaryIdentifier() var botID: String? { @@ -273,12 +275,16 @@ struct AntigravityProtoReader { { try self.fields(bytes, checkCancellation: checkCancellation) { field in switch field.number { - case 1: usage.systemPrompt = try field.counter() + case 1: + // Field 1 is the model enum ID (e.g. 1298 for gemini-3.7-flash), not a token count. + // Keep it for copied-row identity so two rows differing only here still conflict, + // but never add it to input or total tokens. + usage.modelID = try field.counter() case 2: usage.newInput = try field.counter() case 5: usage.cacheRead = try field.counter() case 7: try usage.botIdentifier.read(field) - case 9: usage.output = try field.counter() - case 10: usage.reasoning = try field.counter() + case 9: usage.reasoning = try field.counter() + case 10: usage.output = try field.counter() case 11: usage.responseID = try field.string() default: break } diff --git a/Tests/CodexBarTests/AntigravityBotIDValidationTests.swift b/Tests/CodexBarTests/AntigravityBotIDValidationTests.swift index 77a9ad169a..227bbf1317 100644 --- a/Tests/CodexBarTests/AntigravityBotIDValidationTests.swift +++ b/Tests/CodexBarTests/AntigravityBotIDValidationTests.swift @@ -259,7 +259,7 @@ struct AntigravityBotIDValidationTests { #expect(report.coverage == .complete) #expect(report.report.data.count == 1) - #expect(report.report.data.first?.totalTokens == 198) + #expect(report.report.data.first?.totalTokens == 187) } private static func read(_ url: URL) throws -> AntigravityLocalReader.SourceResult { diff --git a/Tests/CodexBarTests/AntigravityCLICostTests.swift b/Tests/CodexBarTests/AntigravityCLICostTests.swift index 4dfef6d183..c663090130 100644 --- a/Tests/CodexBarTests/AntigravityCLICostTests.swift +++ b/Tests/CodexBarTests/AntigravityCLICostTests.swift @@ -126,7 +126,7 @@ struct AntigravityCLICostTests { #expect(payload.provider == "antigravity") #expect(payload.source == "local") let established = source == "valid" || source == "empty" - let expectedTokens: Int? = source == "empty" ? 0 : (source == "valid" ? 198 : nil) + let expectedTokens: Int? = source == "empty" ? 0 : (source == "valid" ? 187 : nil) let expectedCost: Double? = source == "empty" ? 0 : nil #expect(payload.historyCoverageIsEstablished == established) #expect(payload.last30DaysTokens == expectedTokens) @@ -144,6 +144,6 @@ struct AntigravityCLICostTests { #expect(text.contains("dollar costs unavailable")) #expect(text.contains("Local token history is unavailable or incomplete.") == !established) #expect(text.contains("No token usage found in the selected period.") == (source == "empty")) - if source == "valid" { #expect(text.contains("198")) } + if source == "valid" { #expect(text.contains("187")) } } } diff --git a/Tests/CodexBarTests/AntigravityCostScreenshotTests.swift b/Tests/CodexBarTests/AntigravityCostScreenshotTests.swift index 97998e7a77..cd2f78dcd3 100644 --- a/Tests/CodexBarTests/AntigravityCostScreenshotTests.swift +++ b/Tests/CodexBarTests/AntigravityCostScreenshotTests.swift @@ -15,7 +15,7 @@ final class AntigravityCostScreenshotTests: XCTestCase { let fixture = try AntigravityLocalFixture() try fixture.database(blobs: [AntigravityLocalFixture.blob( model: "claude-sonnet-4-6", - system: 0, + modelID: 0, input: 400_000, output: 50000, cacheRead: 100_000, diff --git a/Tests/CodexBarTests/AntigravityLocalFixture.swift b/Tests/CodexBarTests/AntigravityLocalFixture.swift index e47cbf805c..c9460fc079 100644 --- a/Tests/CodexBarTests/AntigravityLocalFixture.swift +++ b/Tests/CodexBarTests/AntigravityLocalFixture.swift @@ -9,9 +9,12 @@ import CSQLite3 /// Synthetic, not a private capture. Independent schema and JSONL producer provenance: /// https://github.com/junhoyeo/tokscale/tree/62ca1eb1677556972ba963fdfa3a41ab23c1eb4b -/// crates/tokscale-core/src/sessions/antigravity_cli.rs records six DBs / 140 turns: -/// usage #9 text + #10 thinking == #3 total output. The opaque 1.1.18 time inference is NOT used. -/// The separate producer in crates/tokscale-cli/src/antigravity.rs emits sessionId and retry usage. +/// crates/tokscale-core/src/sessions/antigravity_cli.rs records six DBs / 140 turns and the +/// usage #9 + #10 == #3 total output identity. Tokscale itself reads #9 as text and #10 as thinking; +/// ccusage's Antigravity adapter reads #9 as reasoning and #10 as text, and this fixture follows +/// ccusage, per independent confirmation from decoding real local databases. The opaque 1.1.18 time +/// inference is NOT used. The separate producer in crates/tokscale-cli/src/antigravity.rs emits +/// sessionId and retry usage. final class AntigravityLocalFixture: Sendable { static let now = Date(timeIntervalSince1970: 1_787_832_000) // 2026-08-27 12:00 UTC static let calendar = CostUsageBucketTimeZone.calendar(identifier: "UTC") @@ -162,7 +165,7 @@ final class AntigravityLocalFixture: Sendable { static func blob( model: String? = "fixture-model-a", label: String? = "Fixture model", - system: UInt64 = 11, + modelID: UInt64 = 1298, input: UInt64 = 100, output: UInt64 = 30, cacheRead: UInt64 = 50, @@ -170,8 +173,8 @@ final class AntigravityLocalFixture: Sendable { response: String? = nil, seconds: UInt64? = 1_787_832_000) -> [UInt8] { - var usage = self.varint(1, system) + self.varint(2, input) + self.varint(5, cacheRead) - + self.varint(9, output) + self.varint(10, reasoning) + var usage = self.varint(1, modelID) + self.varint(2, input) + self.varint(5, cacheRead) + + self.varint(9, reasoning) + self.varint(10, output) if let response { usage += self.message(11, Array(response.utf8)) } @@ -193,7 +196,7 @@ final class AntigravityLocalFixture: Sendable { botID: String? = nil, model: String? = "fixture-model-a", label: String? = "Fixture model", - system: UInt64 = 11, + modelID: UInt64 = 1298, input: UInt64 = 100, output: UInt64 = 30, cacheRead: UInt64 = 50, @@ -205,8 +208,8 @@ final class AntigravityLocalFixture: Sendable { if let stepUUID { root += self.message(4, Array(stepUUID.utf8)) } - var usage = self.varint(1, system) + self.varint(2, input) + self.varint(5, cacheRead) - + self.varint(9, output) + self.varint(10, reasoning) + var usage = self.varint(1, modelID) + self.varint(2, input) + self.varint(5, cacheRead) + + self.varint(9, reasoning) + self.varint(10, output) if let botID { usage += self.message(7, Array(botID.utf8)) } diff --git a/Tests/CodexBarTests/AntigravityLocalIntegrityTests.swift b/Tests/CodexBarTests/AntigravityLocalIntegrityTests.swift index 718492d9d5..6ff56ff58a 100644 --- a/Tests/CodexBarTests/AntigravityLocalIntegrityTests.swift +++ b/Tests/CodexBarTests/AntigravityLocalIntegrityTests.swift @@ -23,7 +23,7 @@ struct AntigravityLocalIntegrityTests { #expect(report.coverage == .partial) #expect(!report.evidenceIsContradicted) let snapshot = try await fixture.snapshot() - #expect(snapshot.last30DaysTokens == 198) + #expect(snapshot.last30DaysTokens == 187) #expect(snapshot.historyScanIsPartial) #expect(!snapshot.historyCoverageIsEstablished) } @@ -54,12 +54,34 @@ struct AntigravityLocalIntegrityTests { #expect(snapshot.daily.isEmpty) #expect(snapshot.last30DaysTokens == nil) } else { - #expect(snapshot.last30DaysTokens == (sqlite ? 198 : 12)) + #expect(snapshot.last30DaysTokens == (sqlite ? 187 : 12)) #expect(snapshot.daily.first?.requestCount == 1) } } } + @Test(arguments: [false, true]) + func `copied rows differing only in model enum ID conflict like any other unequal copy`( + conflicting: Bool) async throws + { + let fixture = try Fixture() + let first = Fixture.blob(modelID: 1298) + let second = Fixture.blob(modelID: conflicting ? 1318 : 1298) + try fixture.database(rootIndex: 0, blobs: [first]) + try fixture.database(rootIndex: 1, blobs: [second]) + let report = try fixture.report() + #expect(report.coverage == (conflicting ? .partial : .complete)) + #expect(report.evidenceIsContradicted == conflicting) + let snapshot = try await fixture.snapshot() + #expect(snapshot.historyCoverageIsEstablished == !conflicting) + if conflicting { + #expect(snapshot.daily.isEmpty) + #expect(snapshot.last30DaysTokens == nil) + } else { + #expect(snapshot.last30DaysTokens == 187) + } + } + @Test(arguments: [ "view", "expression", @@ -125,7 +147,7 @@ struct AntigravityLocalIntegrityTests { try Fixture.insert(database, row: 0, blob: Fixture.blob()) let report = try fixture.report() #expect(report.coverage == .complete) - #expect(report.report.summary?.totalTokens == 198) + #expect(report.report.summary?.totalTokens == 187) #expect(report.statistics.rows == 1) } @@ -170,7 +192,7 @@ struct AntigravityLocalIntegrityTests { let report = try fixture.report() #expect(report.coverage == .complete) - #expect(report.report.summary?.totalTokens == 198) + #expect(report.report.summary?.totalTokens == 187) #expect(report.statistics.foreignDatabases == 1) #expect(report.statistics.sqliteHandlesOpened == report.statistics.sqliteHandlesClosed) } diff --git a/Tests/CodexBarTests/AntigravityLocalPublicationTests.swift b/Tests/CodexBarTests/AntigravityLocalPublicationTests.swift index 3ed59f9f22..4694b43a32 100644 --- a/Tests/CodexBarTests/AntigravityLocalPublicationTests.swift +++ b/Tests/CodexBarTests/AntigravityLocalPublicationTests.swift @@ -42,7 +42,7 @@ struct AntigravityLocalPublicationTests { await store.refreshTokenUsageNow(for: .antigravity, force: true) await store.refreshSpendDashboardTokenUsageNow(for: .antigravity, force: true) #expect(store.spendDashboardTokenSnapshotPublicationForCurrentConfig(for: .antigravity)? - .snapshot?.last30DaysTokens == (hasHistory ? 396 : nil)) + .snapshot?.last30DaysTokens == (hasHistory ? 374 : nil)) // A new regular publication must not be acknowledged by a failing independent refresh. await store.refreshTokenUsageNow(for: .antigravity, force: true) } @@ -56,10 +56,10 @@ struct AntigravityLocalPublicationTests { let publication = store.spendDashboardTokenSnapshotPublicationForCurrentConfig(for: .antigravity) let regular = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .antigravity) if source == "partial", hasHistory { - #expect(publication?.snapshot?.last30DaysTokens == 396) + #expect(publication?.snapshot?.last30DaysTokens == 374) #expect(publication?.snapshot?.historyCoverageIsEstablished == true) #expect(publication?.publicationRevision == revision) - #expect(regular?.snapshot?.last30DaysTokens == 396) + #expect(regular?.snapshot?.last30DaysTokens == 374) #expect(regular?.publicationRevision == regularRevision) #expect(store.spendDashboardTokenIncorporatedTriggers[.antigravity] == acknowledged) #expect(store.spendDashboardTokenFailedTriggers[.antigravity] != nil) @@ -68,11 +68,11 @@ struct AntigravityLocalPublicationTests { // partial history instead of failing. Coverage stays unclaimed, which is what keeps the // totals marked as a lower bound everywhere they are rendered. let snapshot = try #require(publication?.snapshot) - #expect(snapshot.last30DaysTokens == 198) + #expect(snapshot.last30DaysTokens == 187) #expect(snapshot.historyCoverageIsEstablished == false) #expect(snapshot.historyScanIsPartial) #expect(store.spendDashboardTokenFailedTriggers[.antigravity] == nil) - #expect(regular?.snapshot?.last30DaysTokens == 198) + #expect(regular?.snapshot?.last30DaysTokens == 187) #expect(store.tokenFailureGates[.antigravity]?.streak == 0) #expect(store.tokenError(for: .antigravity) == nil) } else if source == "empty" || source == "out-of-window" { @@ -92,7 +92,7 @@ struct AntigravityLocalPublicationTests { #expect(store.spendDashboardTokenFailedTriggers[.antigravity] != nil) #expect(store.spendDashboardTokenIncorporatedTriggers[.antigravity] == acknowledged) #expect(store.tokenSnapshotPublicationRevision(for: .antigravity) == regularRevision) - #expect(regular?.snapshot?.last30DaysTokens == (hasHistory ? 396 : nil)) + #expect(regular?.snapshot?.last30DaysTokens == (hasHistory ? 374 : nil)) #expect((regular != nil) == hasHistory) #expect(store.tokenFailureGates[.antigravity]?.streak == 1) #expect((store.tokenError(for: .antigravity) != nil) == !hasHistory) @@ -116,7 +116,7 @@ struct AntigravityLocalPublicationTests { store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in try await absent.snapshot() } await store.refreshTokenUsageNow(for: provider, force: true) #expect(store.tokenSnapshotPublicationRevision(for: provider) == revision) - #expect(store.tokenSnapshot(for: provider)?.last30DaysTokens == 198) + #expect(store.tokenSnapshot(for: provider)?.last30DaysTokens == 187) #expect(store.tokenError(for: provider) == nil) #expect(store.tokenFailureGates[provider.instanceID]?.streak == (provider == .codex ? 0 : 1)) await store.codexCostCatchUpTask?.value @@ -131,7 +131,7 @@ struct AntigravityLocalPublicationTests { let fixture = try Fixture() for (index, tokens) in [Int.max - 1, 2, 7].enumerated() { try fixture.database("day-\(index)", blobs: [Fixture.blob( - system: 0, + modelID: 0, input: UInt64(tokens), output: 0, cacheRead: 0, diff --git a/Tests/CodexBarTests/AntigravityLocalReaderTests.swift b/Tests/CodexBarTests/AntigravityLocalReaderTests.swift index 0f63438899..b2a1316649 100644 --- a/Tests/CodexBarTests/AntigravityLocalReaderTests.swift +++ b/Tests/CodexBarTests/AntigravityLocalReaderTests.swift @@ -13,7 +13,7 @@ struct AntigravityLocalReaderTests { @Test func `literal synthetic schema example has independently calculated counts and time`() async throws { // Handwritten bytes, not a round-trip through the fixture encoder. - // Pinned upstream fields: input 11 + 100, cache 50, text 30, thinking 7 = 198. + // Pinned upstream fields: model ID 11 (excluded), input 100, cache 50, reasoning 30, output 7 = 187. let bytes: [UInt8] = [ 0x0A, 0x1B, 0x22, 0x0A, 0x08, 0x0B, 0x10, 0x64, 0x28, 0x32, 0x48, 0x1E, 0x50, 0x07, 0x4A, 0x0D, 0x22, 0x0B, 0x08, 0xC0, 0xCD, 0xC0, 0xD4, 0x06, 0x10, 0x80, 0xE5, 0x9A, 0x77, @@ -23,11 +23,11 @@ struct AntigravityLocalReaderTests { let report = try fixture.report() #expect(report.coverage == .complete) #expect(report.report.data.first?.date == "2026-08-27") - #expect(report.report.data.first?.inputTokens == 111) - #expect(report.report.data.first?.outputTokens == 30) - #expect(report.report.data.first?.reasoningTokens == 7) + #expect(report.report.data.first?.inputTokens == 100) + #expect(report.report.data.first?.outputTokens == 7) + #expect(report.report.data.first?.reasoningTokens == 30) #expect(report.report.data.first?.modelBreakdowns?.first?.modelName == "unknown") - #expect(try await fixture.snapshot().last30DaysTokens == 198) + #expect(try await fixture.snapshot().last30DaysTokens == 187) } @Test @@ -76,12 +76,12 @@ struct AntigravityLocalReaderTests { ]) let report = try fixture.report() #expect(report.coverage == .complete) - #expect(report.report.summary?.totalTokens == 792) // 4 × (11 + 100 + 30 + 50 + 7) + #expect(report.report.summary?.totalTokens == 748) // 4 × (100 + 30 + 50 + 7) #expect(report.report.data.first?.requestCount == 4) #expect(report.report.data.first?.modelBreakdowns?.first?.requestCount == 4) let snapshot = try await fixture.snapshot() - #expect(snapshot.last30DaysTokens == 792) - #expect(snapshot.sessionTokens == 792) + #expect(snapshot.last30DaysTokens == 748) + #expect(snapshot.sessionTokens == 748) #expect(snapshot.historyCoverageIsEstablished) #expect(snapshot.last30DaysCostUSD == nil) #expect(snapshot.sessionCostUSD == nil) @@ -118,11 +118,11 @@ struct AntigravityLocalReaderTests { ]) let snapshot = try await fixture.snapshot() - let expected = 111e-6 + 50 * 0.2e-6 + 37 * 2e-6 - #expect(snapshot.last30DaysCostUSD == expected) - #expect(snapshot.sessionCostUSD == expected) + let expected = 100e-6 + 50 * 0.2e-6 + 37 * 2e-6 + #expect(abs((snapshot.last30DaysCostUSD ?? .nan) - expected) < 1e-9) + #expect(abs((snapshot.sessionCostUSD ?? .nan) - expected) < 1e-9) #expect(snapshot.costProvenance == .listPriceEstimate) - #expect(snapshot.daily.first?.modelBreakdowns?.first?.costUSD == expected) + #expect(abs((snapshot.daily.first?.modelBreakdowns?.first?.costUSD ?? .nan) - expected) < 1e-9) #expect(snapshot.daily.first?.modelBreakdowns?.last?.costUSD == nil) #expect(snapshot.summary(forLastDays: 30, calendar: Fixture.calendar).coverage == CostUsageCoverageCounts(unpriced: 1, estimated: 1)) @@ -152,8 +152,8 @@ struct AntigravityLocalReaderTests { try fixture.database(blobs: [Fixture.blob(model: "gemini-fixture-a-tiered")]) let snapshot = try await fixture.snapshot() - let expected = 111e-6 + 50 * 0.2e-6 + 37 * 2e-6 - #expect(snapshot.last30DaysCostUSD == expected) + let expected = 100e-6 + 50 * 0.2e-6 + 37 * 2e-6 + #expect(abs((snapshot.last30DaysCostUSD ?? .nan) - expected) < 1e-9) // The recorded variant keeps its own identity in the breakdown; only pricing falls back. #expect(snapshot.daily.first?.modelBreakdowns?.first?.modelName == "gemini-fixture-a-tiered") @@ -179,7 +179,7 @@ struct AntigravityLocalReaderTests { try fixture.database(blobs: [ Fixture.blob( model: model, - system: 0, + modelID: 0, input: 250_000, output: 0, cacheRead: 0, @@ -187,7 +187,7 @@ struct AntigravityLocalReaderTests { seconds: 1_771_588_800), Fixture.blob( model: model, - system: 0, + modelID: 0, input: 250_000, output: 0, cacheRead: 0, @@ -213,7 +213,7 @@ struct AntigravityLocalReaderTests { #expect(result.report.data.isEmpty == false) let snapshot = try await fixture.snapshot() - #expect(snapshot.last30DaysTokens == 198) + #expect(snapshot.last30DaysTokens == 187) // Rows stay usable, but the scan may not claim days it never reached. #expect(snapshot.historyCoverageIsEstablished == false) #expect(snapshot.historyScanIsPartial) @@ -249,19 +249,19 @@ struct AntigravityLocalReaderTests { try fixture.jsonl([Fixture.cacheUsage]) #expect(try await fixture.snapshot().last30DaysTokens == 180) try fixture.database(blobs: [Fixture.blob(), Fixture.blob()]) - #expect(try await fixture.snapshot().last30DaysTokens == 396) + #expect(try await fixture.snapshot().last30DaysTokens == 374) let invalid = try fixture.database("broken") try Data("broken".utf8).write(to: invalid) let report = try fixture.report() #expect(report.coverage == .partial) - #expect(report.report.summary?.totalTokens == 396) + #expect(report.report.summary?.totalTokens == 374) let snapshot = try await fixture.snapshot() // Rows read before the broken database are kept as an explicitly partial lower bound // rather than discarded; coverage still may not be claimed. #expect(!snapshot.historyCoverageIsEstablished) #expect(snapshot.historyScanIsPartial) #expect(snapshot.daily.isEmpty == false) - #expect(snapshot.last30DaysTokens == 396) + #expect(snapshot.last30DaysTokens == 374) } @Test @@ -279,7 +279,7 @@ struct AntigravityLocalReaderTests { try FileManager.default.createDirectory(at: conversations, withIntermediateDirectories: true) try FileManager.default.moveItem(at: original, to: conversations.appendingPathComponent("session-a.db")) environment["GEMINI_CLI_HOME"] = gemini.path - #expect(try await fixture.snapshot(environment: environment).last30DaysTokens == 198) + #expect(try await fixture.snapshot(environment: environment).last30DaysTokens == 187) } @Test @@ -294,7 +294,7 @@ struct AntigravityLocalReaderTests { try fixture.database("healthy", rootIndex: 1, blobs: [Fixture.blob()]) let report = try fixture.report() #expect(report.coverage == .partial) - #expect(report.report.summary?.totalTokens == 198) + #expect(report.report.summary?.totalTokens == 187) } @Test @@ -308,7 +308,7 @@ struct AntigravityLocalReaderTests { try fixture.database("session-b", rootIndex: 2, blobs: [Fixture.blob(response: "same")]) let report = try fixture.report() #expect(report.coverage == .complete) - #expect(report.report.summary?.totalTokens == 792) + #expect(report.report.summary?.totalTokens == 748) #expect(report.report.data.first?.requestCount == 4) } @@ -319,7 +319,7 @@ struct AntigravityLocalReaderTests { try fixture.database(rootIndex: 1, blobs: [Fixture.blob(response: "response")]) let report = try fixture.report() #expect(report.coverage == .partial) - #expect(report.report.summary?.totalTokens == 198) + #expect(report.report.summary?.totalTokens == 187) try fixture.database(rootIndex: 2, blobs: [Fixture.blob(input: 200, response: "response")]) #expect(try fixture.report().coverage == .partial) } @@ -329,21 +329,21 @@ struct AntigravityLocalReaderTests { let fixture = try Fixture() try fixture.database(blobs: [ Fixture.blob( - system: 0, + modelID: 0, input: UInt64(Int.max - 1), output: 0, cacheRead: 0, reasoning: 0, seconds: 1_787_745_600), Fixture.blob( - system: 0, + modelID: 0, input: 2, output: 0, cacheRead: 0, reasoning: 0, response: "retry", seconds: 1_787_745_600), - Fixture.blob(system: 0, input: 7, output: 0, cacheRead: 0, reasoning: 0, response: "retry"), + Fixture.blob(modelID: 0, input: 7, output: 0, cacheRead: 0, reasoning: 0, response: "retry"), ]) let report = try fixture.report() #expect(report.coverage == .partial) @@ -389,7 +389,7 @@ struct AntigravityLocalReaderTests { defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) } let report = try fixture.report() #expect(report.coverage == .partial) - #expect(report.report.summary?.totalTokens == 198) + #expect(report.report.summary?.totalTokens == 187) } @Test @@ -410,18 +410,20 @@ struct AntigravityLocalReaderTests { @Test func `repeated known messages merge scalar fields after validating every occurrence`() throws { - let usage = Fixture.message(4, Fixture.varint(1, 11)) - + Fixture.message(4, Fixture.varint(2, 100)) + // Field 1 (model ID) is deliberately not used here: it is validated but never stored, so it + // cannot demonstrate that a later occurrence's fields merge onto an earlier one's. + let usage = Fixture.message(4, Fixture.varint(2, 100)) + + Fixture.message(4, Fixture.varint(5, 50)) let time = Fixture.message(9, Fixture.message(4, Fixture.varint(1, 1_787_832_000))) + Fixture.message(9, Fixture.message(4, Fixture.varint(2, 123_000_000))) let bytes = Fixture.message(1, usage) + Fixture.message(1, time) let turn = try #require(try AntigravityProtoReader.parseTurn(bytes)) - #expect(turn.usage?.systemPrompt == 11) #expect(turn.usage?.newInput == 100) + #expect(turn.usage?.cacheRead == 50) #expect(turn.timestampMs == 1_787_832_000_123) let fixture = try Fixture() try fixture.database(blobs: [bytes]) - #expect(try fixture.report().report.summary?.totalTokens == 111) + #expect(try fixture.report().report.summary?.totalTokens == 150) } @Test(arguments: malformedBlobs) @@ -449,7 +451,7 @@ struct AntigravityLocalReaderTests { Fixture.blob(seconds: nil) + Fixture.message( 1, Fixture.message(9, Fixture.message(10, [1, 2, 3, 4, 5, 6, 7, 8]))), - Fixture.blob(system: UInt64(Int.max), input: 1), + Fixture.blob(input: UInt64(Int.max)), ] } @@ -514,18 +516,18 @@ struct AntigravityLocalReaderTests { try FileManager.default.moveItem(at: original.deletingLastPathComponent(), to: external) try FileManager.default.createSymbolicLink( at: original.deletingLastPathComponent(), withDestinationURL: external) - #expect(try await fixture.snapshot().last30DaysTokens == 198) + #expect(try await fixture.snapshot().last30DaysTokens == 187) let linkedFile = fixture.root.appendingPathComponent("owned-session-data") try FileManager.default.moveItem(at: external.appendingPathComponent("session-a.db"), to: linkedFile) try FileManager.default.createSymbolicLink( at: external.appendingPathComponent("session-a.db"), withDestinationURL: linkedFile) - #expect(try await fixture.snapshot().last30DaysTokens == 198) + #expect(try await fixture.snapshot().last30DaysTokens == 187) let linkedGemini = fixture.root.appendingPathComponent("linked-gemini") try FileManager.default.createSymbolicLink( at: linkedGemini, withDestinationURL: fixture.root.appendingPathComponent(".gemini")) var linkedEnvironment = fixture.environment linkedEnvironment["GEMINI_CLI_HOME"] = linkedGemini.path - #expect(try await fixture.snapshot(environment: linkedEnvironment).last30DaysTokens == 198) + #expect(try await fixture.snapshot(environment: linkedEnvironment).last30DaysTokens == 187) let cache = try Fixture() let cacheFile = try cache.jsonl([Fixture.cacheUsage]) @@ -599,11 +601,11 @@ struct AntigravityLocalReaderTests { let report = try fixture.report() #expect(report.coverage == .complete) #expect(report.report.data.first?.date == "2026-08-27") - #expect(report.report.data.first?.inputTokens == 111) + #expect(report.report.data.first?.inputTokens == 100) #expect(report.report.data.first?.outputTokens == 30) #expect(report.report.data.first?.reasoningTokens == 7) #expect(report.report.data.first?.modelBreakdowns?.first?.modelName == "gemini-3.7-flash") - #expect(try await fixture.snapshot().last30DaysTokens == 198) + #expect(try await fixture.snapshot().last30DaysTokens == 187) } @Test @@ -626,7 +628,7 @@ struct AntigravityLocalReaderTests { #expect(report.coverage == .complete) #expect(report.report.data.map(\.date) == ["2026-08-27", "2026-08-28"]) #expect(report.report.data.map(\.requestCount) == [1, 1]) - #expect(report.report.data.map(\.inputTokens) == [111, 211]) + #expect(report.report.data.map(\.inputTokens) == [100, 200]) } @Test @@ -789,7 +791,7 @@ struct AntigravityLocalReaderTests { #expect(report.coverage == .complete) #expect(report.report.data.map(\.date) == ["2026-08-27", "2026-08-28"]) - #expect(report.report.data.map(\.inputTokens) == [111, 211]) + #expect(report.report.data.map(\.inputTokens) == [100, 200]) } @Test @@ -817,7 +819,7 @@ struct AntigravityLocalReaderTests { #expect(source.events.map(\.turn.timestampMs) == [1_787_875_260_000, 1_787_875_140_250]) #expect(report.coverage == .partial) #expect(report.report.data.map(\.date) == ["2026-08-28"]) - #expect(report.report.data.map(\.inputTokens) == [111]) + #expect(report.report.data.map(\.inputTokens) == [100]) } @Test @@ -883,7 +885,7 @@ struct AntigravityLocalReaderTests { #expect(report.coverage == .complete) #expect(report.report.data.first?.date == "2026-08-27") #expect(report.report.data.first?.modelBreakdowns?.first?.modelName == "claude-opus-4-6-thinking") - #expect(try await fixture.snapshot().last30DaysTokens == 198) + #expect(try await fixture.snapshot().last30DaysTokens == 187) } @Test @@ -924,8 +926,8 @@ extension AntigravityLocalReaderTests { try fixture.database(blobs: [Fixture.blob(model: "gemini-pro-default")]) let snapshot = try await fixture.snapshot() - let expected = 111e-6 + 50 * 0.2e-6 + 37 * 2e-6 - #expect(snapshot.last30DaysCostUSD == expected) + let expected = 100e-6 + 50 * 0.2e-6 + 37 * 2e-6 + #expect(abs((snapshot.last30DaysCostUSD ?? .nan) - expected) < 1e-9) // The recorded alias keeps its own identity in the breakdown; only pricing resolves. #expect(snapshot.daily.first?.modelBreakdowns?.first?.modelName == "gemini-pro-default") @@ -940,4 +942,70 @@ extension AntigravityLocalReaderTests { } #expect(AntigravityLocalReader.pricingBaseModelID(for: "Gemini-Pro-Default") == "gemini-3.1-pro-preview") } + + @Test + func `model enum ID in usage field 1 does not count as input tokens`() throws { + let fixture = try Fixture() + try fixture.database(blobs: [ + Fixture.blob(modelID: 1318, input: 40, output: 0, cacheRead: 0, reasoning: 0), + ]) + let report = try fixture.report() + #expect(report.coverage == .complete) + #expect(report.report.data.first?.inputTokens == 40) + #expect(report.report.data.first?.totalTokens == 40) + + // A row whose usage carries nothing but the model enum ID still parses as one valid, + // fully zero-token event instead of being treated as malformed or excluded. + let zeroFixture = try Fixture() + try zeroFixture.database(blobs: [ + Fixture.blob(modelID: 1318, input: 0, output: 0, cacheRead: 0, reasoning: 0), + ]) + let zeroReport = try zeroFixture.report() + #expect(zeroReport.coverage == .complete) + #expect(zeroReport.report.data.first?.inputTokens == 0) + #expect(zeroReport.report.data.first?.totalTokens == 0) + #expect(zeroReport.report.data.first?.requestCount == 1) + } + + @Test + func `usage field 9 is reasoning and field 10 is visible output`() throws { + let usage = Fixture.varint(2, 10) + Fixture.varint(9, 5) + Fixture.varint(10, 3) + let chat = Fixture.message(4, usage) + + Fixture.message(9, Fixture.message(4, Fixture.varint(1, 1_787_832_000))) + let bytes = Fixture.message(1, chat) + let turn = try #require(try AntigravityProtoReader.parseTurn(bytes)) + #expect(turn.usage?.reasoning == 5) + #expect(turn.usage?.output == 3) + } + + @Test + func `safety routed Gemini Flash turns price from the base Flash model`() async throws { + let fixture = try Fixture() + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(#""" + { + "google": { + "id": "google", + "name": "Google", + "models": { + "gemini-3.7-flash": { + "id": "gemini-3.7-flash", + "cost": {"input": 1, "output": 2, "cache_read": 0.2} + } + } + } + } + """#.utf8)) + let cacheRoot = fixture.root.appendingPathComponent("scanner-cache") + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: Fixture.now, cacheRoot: cacheRoot)) + try fixture.database(blobs: [Fixture.blob(model: "gemini-3.7-flash-safety-le")]) + + let snapshot = try await fixture.snapshot() + let expected = 100e-6 + 50 * 0.2e-6 + 37 * 2e-6 + #expect(abs((snapshot.last30DaysCostUSD ?? .nan) - expected) < 1e-9) + // The recorded model name stays exactly as observed; only pricing resolves through the alias. + #expect(snapshot.daily.first?.modelBreakdowns?.first?.modelName == "gemini-3.7-flash-safety-le") + + #expect(AntigravityLocalReader.pricingBaseModelID(for: "gemini-3.7-flash-safety-le") + == "gemini-3.7-flash") + } } diff --git a/Tests/CodexBarTests/AntigravityLocalScanTests.swift b/Tests/CodexBarTests/AntigravityLocalScanTests.swift index 464f0a51a0..52c8017f47 100644 --- a/Tests/CodexBarTests/AntigravityLocalScanTests.swift +++ b/Tests/CodexBarTests/AntigravityLocalScanTests.swift @@ -38,7 +38,7 @@ struct AntigravityLocalScanTests { limits.databases = 1 let exact = try fixture.report(limits: limits) #expect(exact.coverage == .complete) - #expect(exact.report.summary?.totalTokens == 198) + #expect(exact.report.summary?.totalTokens == 187) try fixture.database("second", blobs: [Fixture.blob()]) let exceeded = try fixture.report(limits: limits) @@ -55,7 +55,7 @@ struct AntigravityLocalScanTests { try fixture.database("session-1", blobs: [Fixture.blob()]) let initial = try fixture.report() #expect(initial.coverage == .complete) - #expect(initial.report.summary?.totalTokens == 198) + #expect(initial.report.summary?.totalTokens == 187) try fixture.database("session-2", blobs: [Fixture.blob()]) var limits = AntigravityLocalReader.Limits() @@ -63,7 +63,7 @@ struct AntigravityLocalScanTests { let partial = try fixture.report(limits: limits) #expect(partial.coverage == .partial) #expect(!partial.report.data.isEmpty) - #expect(partial.report.summary?.totalTokens == 198) + #expect(partial.report.summary?.totalTokens == 187) #expect(partial.statistics.files == 2) #expect(partial.statistics.rows == 1) #expect(partial.statistics.schemaBytes > limits.schemaBytes) diff --git a/Tests/CodexBarTests/AntigravityLocalWALTests.swift b/Tests/CodexBarTests/AntigravityLocalWALTests.swift index a68e66a54e..69ea1591e1 100644 --- a/Tests/CodexBarTests/AntigravityLocalWALTests.swift +++ b/Tests/CodexBarTests/AntigravityLocalWALTests.swift @@ -54,7 +54,7 @@ struct AntigravityLocalWALTests { let beforeWAL = try Data(contentsOf: wal) let beforeSHM = try Data(contentsOf: shm) - #expect(try fixture.report().report.summary?.totalTokens == 198) + #expect(try fixture.report().report.summary?.totalTokens == 187) #expect(try Data(contentsOf: url) == beforeDB) #expect(try Data(contentsOf: wal) == beforeWAL) @@ -93,7 +93,7 @@ struct AntigravityLocalWALTests { #expect(source.isComplete) #expect(source.events.count == 1) #expect(try Data(contentsOf: wal) != before) // Attributed to the coordinated writer, not the reader. - #expect(try fixture.report().report.summary?.totalTokens == 396) + #expect(try fixture.report().report.summary?.totalTokens == 374) var cancelBudget: AntigravityLocalReader.Budget? cancelBudget = AntigravityLocalReader.Budget(limits: .init(), cancellation: { @@ -264,7 +264,7 @@ struct AntigravityLocalWALTests { let report = try fixture.report() #expect(report.coverage == .complete) - #expect(report.report.summary?.totalTokens == 198) + #expect(report.report.summary?.totalTokens == 187) #expect(report.statistics.foreignDatabases == 1) #expect(report.statistics.immutableFallbacks == (control == SQLITE_CANTOPEN ? 1 : 0)) #expect(report.statistics.sqliteHandlesOpened == report.statistics.sqliteHandlesClosed) @@ -352,7 +352,7 @@ struct AntigravityLocalWALTests { // The writer has closed, so the next scan sees one stable file with both rows. let next = try fixture.report() #expect(next.coverage == .complete) - #expect(next.report.summary?.totalTokens == 396) + #expect(next.report.summary?.totalTokens == 374) } else { // The ordinary read-only snapshot excludes the coordinated later write, as on any WAL database. #expect(statistics.immutableFallbacks == 0) diff --git a/Tests/CodexBarTests/AntigravityPricingRefreshTests.swift b/Tests/CodexBarTests/AntigravityPricingRefreshTests.swift index f186817772..3b914ec0d6 100644 --- a/Tests/CodexBarTests/AntigravityPricingRefreshTests.swift +++ b/Tests/CodexBarTests/AntigravityPricingRefreshTests.swift @@ -54,7 +54,7 @@ struct AntigravityPricingRefreshTests { #expect(await gate.requestCount == 0) #expect(snapshot.daily.isEmpty) } else { - #expect(snapshot.last30DaysTokens == 198) + #expect(snapshot.last30DaysTokens == 187) #expect((snapshot.last30DaysCostUSD != nil) == (scenario == "known")) // Drain the detached refresh before its fixture directory is removed. let drainDeadline = clock.now.advanced(by: .seconds(2)) @@ -82,8 +82,9 @@ struct AntigravityPricingRefreshTests { try fixture.database(blobs: [Fixture.blob(model: "gemini-fixture-priced")]) let snapshot = try await Self.fetch( fixture, force: true, client: ModelsDevClient(transport: AntigravityPricingTransport {})) - #expect(snapshot.last30DaysTokens == 198) - #expect(snapshot.last30DaysCostUSD == 111e-6 + 50 * 0.2e-6 + 37 * 2e-6) + #expect(snapshot.last30DaysTokens == 187) + let expected = 100e-6 + 50 * 0.2e-6 + 37 * 2e-6 + #expect(abs((snapshot.last30DaysCostUSD ?? .nan) - expected) < 1e-9) } @Test @@ -102,7 +103,7 @@ struct AntigravityPricingRefreshTests { try Fixture.execute(database, "DELETE FROM gen_metadata WHERE idx = 1") try Fixture.insert(database, row: 1, blob: [0x08, 0xFF]) })) - #expect(snapshot.last30DaysTokens == 396) + #expect(snapshot.last30DaysTokens == 374) #expect(snapshot.historyCoverageIsEstablished) #expect(!snapshot.historyScanIsPartial) } @@ -117,7 +118,7 @@ struct AntigravityPricingRefreshTests { client: ModelsDevClient(transport: AntigravityPricingTransport { throw URLError(.notConnectedToInternet) })) - #expect(snapshot.last30DaysTokens == 198) + #expect(snapshot.last30DaysTokens == 187) #expect(snapshot.last30DaysCostUSD == nil) #expect(snapshot.historyCoverageIsEstablished) } diff --git a/docs/antigravity.md b/docs/antigravity.md index b8f65b7411..43b1955a3d 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -384,9 +384,15 @@ Hard database-count, row-count, cumulative-byte, or duration budget exhaustion d Schema-budget exhaustion preserves validated rows from earlier databases as partial history, subject to the same lower-bound labeling and prior-complete-report rules. The schema cap remains 64 KiB. The schema evidence is [Tokscale's pinned SQLite parser](https://github.com/junhoyeo/tokscale/blob/62ca1eb1677556972ba963fdfa3a41ab23c1eb4b/crates/tokscale-core/src/sessions/antigravity_cli.rs), -whose header records six databases and 140 turns. SQLite usage fields 1 + 2 are input, 5 is cache read, -9 is text output, and 10 is thinking output: text and thinking are separate counts. Historical model IDs are retained; -missing models stay unknown unless an unambiguous raw label maps to a model within the same session. +whose header records six databases and 140 turns and establishes the table layout below. SQLite usage +field 2 is input. Field 1 is the model enum ID (for example 1298 for `gemini-3.7-flash`), and CodexBar +does not count it. Field 5 is cache read. Field 9 is reasoning (thinking) output, and field 10 is text +(visible) output: reasoning and text are separate counts. CodexBar follows +[ccusage's Antigravity adapter](https://github.com/ccusage/ccusage/blob/d41bf3d48a911e9742793087142e323093ae6a4f/rust/adapters/antigravity/src/parser.rs) +for this field 1/9/10 reading, confirmed independently by decoding real local databases. This reading differs +from Tokscale's own reading of fields 9/10. +Historical model IDs are retained; missing models stay unknown unless an unambiguous raw label maps to a +model within the same session. Conflicting mappings remain unresolved. Every repeated known protobuf envelope is validated and merged. The supported database layout is an ordinary `gen_metadata` table with stored `idx` and `data` columns. Extra ordinary columns and `WITHOUT ROWID` tables are supported; views, virtual tables, and generated/hidden columns diff --git a/docs/model-pricing.md b/docs/model-pricing.md index d467f2c0b4..0db21206e2 100644 --- a/docs/model-pricing.md +++ b/docs/model-pricing.md @@ -41,6 +41,7 @@ Local cost scanners preserve that scope when selecting a catalog: - Claude's [documented `k3[1m]` alias](https://www.kimi.com/code/docs/en/third-party-tools/claude-code.html) resolves to `kimi-for-coding/k3` after exact-row lookup, including the existing `kimi-coding/` and `kimi-for-coding/` routes. Recorded model names stay unchanged; other context variants and paid Moonshot routes are not inferred. Catalog zero rates remain known estimates, not a claim that subscriptions or extra usage are free. - OpenAI's [Daybreak aliases](https://developers.openai.com/api/docs/pricing) resolve like the unsuffixed `gpt-5.6` alias: `gpt-daybreak-blue-latest` prices as `gpt-5.6-sol` and `gpt-daybreak-red-latest` as `gpt-5.6-cyber`. Native usage rows retain raw model evidence; Codex aggregate model IDs follow the canonicalizer. - Antigravity's Gemini 3.1 Pro aliases (`gemini-pro-default`, `gemini-pro-agent`, and the `gemini-3.1-pro` effort tiers) price as `gemini-3.1-pro-preview`, the only catalogued Gemini 3.1 Pro row. The alias is provider-local; recorded model names stay unchanged. +- Antigravity's safety-routed alias `gemini-3.7-flash-safety-le` prices as `gemini-3.7-flash`: the usage record's model enum ID matches ordinary `gemini-3.7-flash` turns. The alias is provider-local; the recorded model name stays unchanged. - Vertex AI Claude logs: models.dev provider id `google-vertex-anthropic` Dated Codex usage retains the prior bundled GPT-5.6 Sol rates before **2026-08-21 UTC**, the repricing date in the From bd0ebc0db32b73f27b4de16e347952b0dc7b45e3 Mon Sep 17 00:00:00 2001 From: Peter Urda Date: Wed, 30 Sep 2026 01:44:40 -0700 Subject: [PATCH 096/122] Apply the Antigravity schema allowance per database The 64 KiB allowance for inspected schema text was shared by the whole scan, while the entry and column limits already applied to each database. An ordinary conversation database uses a few hundred bytes, so about 240 databases exhausted the shared allowance, well below the 500-database cap, and the report turned partial. Keep the 64 KiB value and apply it to each database. A database whose schema exceeds a limit is withheld alone; the scan continues with the other databases and reports partial coverage. --- .../Antigravity/AntigravityLocalSQLite.swift | 8 ++-- .../Antigravity/AntigravityLocalScan.swift | 13 ++++++- .../AntigravityLocalIntegrityTests.swift | 16 ++++---- .../AntigravityLocalScanTests.swift | 39 ++++++++++++++++--- docs/antigravity.md | 6 ++- 5 files changed, 58 insertions(+), 24 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalSQLite.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalSQLite.swift index d36fff82cf..8d6e540990 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalSQLite.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalSQLite.swift @@ -13,19 +13,17 @@ extension AntigravityLocalReader { try budget.check() budget.statistics.files += 1 guard budget.statistics.files <= budget.limits.databases else { throw ScanFailure.exhausted } + budget.beginDatabase() let source = try self.readDatabase(url, budget: budget) result.events.append(contentsOf: source.events) result.isComplete = result.isComplete && source.isComplete result.containsHistorySource = result.containsHistorySource || source.containsHistorySource result.evidenceIsUnstable = result.evidenceIsUnstable || source.evidenceIsUnstable } catch ScanFailure.schemaExhausted { - // Schema-budget exhaustion is a soft limit: preserve rows already decoded from earlier - // databases. They are valid partial history and are more useful than an empty result when - // a large history tree hits the cumulative schema-byte cap. Hard row, byte, and duration + // Schema limits apply to each database, so an oversized schema costs only its own database. + // Rows from the other databases stay valid partial history. Hard row, byte, and duration // limits are not caught here and continue to withhold newly truncated reports as documented. - guard !result.events.isEmpty else { throw ScanFailure.schemaExhausted } result.isComplete = false - break } } return result diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalScan.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalScan.swift index 0ceac13449..0ff80d8ee5 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalScan.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalScan.swift @@ -74,6 +74,9 @@ extension AntigravityLocalReader { let clock: () -> TimeInterval let started: TimeInterval var statistics = Statistics() + /// Schema text inspected in the current database. The schema allowance applies to each database, + /// like the entry and column limits, so a long history of small schemas never adds up to it. + private(set) var databaseSchemaBytes = 0 init( limits: Limits, @@ -104,10 +107,16 @@ extension AntigravityLocalReader { guard self.statistics.rows <= self.limits.rows else { throw ScanFailure.exhausted } } + func beginDatabase() { + self.databaseSchemaBytes = 0 + } + func chargeSchemaBytes(_ count: Int) throws { try self.check() - let (attempted, overflow) = self.statistics.schemaBytes.addingReportingOverflow(count) - self.statistics.schemaBytes = overflow ? Int.max : attempted + let (total, totalOverflow) = self.statistics.schemaBytes.addingReportingOverflow(count) + self.statistics.schemaBytes = totalOverflow ? Int.max : total + let (attempted, overflow) = self.databaseSchemaBytes.addingReportingOverflow(count) + self.databaseSchemaBytes = overflow ? Int.max : attempted guard !overflow, attempted <= self.limits.schemaBytes else { throw ScanFailure.schemaExhausted } } } diff --git a/Tests/CodexBarTests/AntigravityLocalIntegrityTests.swift b/Tests/CodexBarTests/AntigravityLocalIntegrityTests.swift index 718492d9d5..0029844bb8 100644 --- a/Tests/CodexBarTests/AntigravityLocalIntegrityTests.swift +++ b/Tests/CodexBarTests/AntigravityLocalIntegrityTests.swift @@ -313,7 +313,7 @@ struct AntigravityLocalIntegrityTests { } @Test - func `schema entry column and cumulative byte limits reject before payload reads`() throws { + func `schema entry column and byte limits reject before payload reads`() throws { let fixture = try Fixture() let url = try fixture.database() let database = try Fixture.open(url) @@ -340,14 +340,12 @@ struct AntigravityLocalIntegrityTests { limits.schemaColumns = 64 let complete = try fixture.report(limits: limits) #expect(complete.coverage == .complete) - limits.schemaBytes = complete.statistics.schemaBytes - try fixture.database("session-b", blobs: [Fixture.blob()]) - let cumulative = try fixture.report(limits: limits) - #expect(cumulative.coverage == .partial) - #expect(cumulative.statistics.files == 2) - #expect(cumulative.statistics.rows == 1) - #expect(cumulative.statistics.schemaBytes > limits.schemaBytes) - #expect(cumulative.statistics.sqliteHandlesOpened == cumulative.statistics.sqliteHandlesClosed) + limits.schemaBytes = complete.statistics.schemaBytes - 1 + let bytes = try fixture.report(limits: limits) + #expect(bytes.coverage == .partial) + #expect(bytes.statistics.rows == 0) + #expect(bytes.statistics.schemaBytes > limits.schemaBytes) + #expect(bytes.statistics.sqliteHandlesOpened == bytes.statistics.sqliteHandlesClosed) } @Test(arguments: [false, true]) diff --git a/Tests/CodexBarTests/AntigravityLocalScanTests.swift b/Tests/CodexBarTests/AntigravityLocalScanTests.swift index 464f0a51a0..5a7e36d519 100644 --- a/Tests/CodexBarTests/AntigravityLocalScanTests.swift +++ b/Tests/CodexBarTests/AntigravityLocalScanTests.swift @@ -50,23 +50,50 @@ struct AntigravityLocalScanTests { } @Test - func `database reading preserves decoded rows when subsequent databases exhaust the schema budget`() throws { + func `the schema byte allowance applies to each database rather than the whole history`() throws { let fixture = try Fixture() try fixture.database("session-1", blobs: [Fixture.blob()]) let initial = try fixture.report() #expect(initial.coverage == .complete) #expect(initial.report.summary?.totalTokens == 198) + // Many small schemas together exceed one database's allowance without truncating the scan. try fixture.database("session-2", blobs: [Fixture.blob()]) + try fixture.database("session-3", blobs: [Fixture.blob()]) + var limits = AntigravityLocalReader.Limits() + limits.schemaBytes = initial.statistics.schemaBytes + let report = try fixture.report(limits: limits) + #expect(report.coverage == .complete) + #expect(report.report.summary?.totalTokens == 594) + #expect(report.statistics.files == 3) + #expect(report.statistics.schemaBytes > limits.schemaBytes) + } + + @Test + func `an oversized schema withholds only its own database and keeps the other rows as partial history`() throws { + let fixture = try Fixture() + try fixture.database("session-1", blobs: [Fixture.blob()]) + let initial = try fixture.report() + #expect(initial.coverage == .complete) + + // Catalogue entries before gen_metadata are inspected, so this schema is larger than session-1's. + let url = try fixture.database("session-2") + let database = try Fixture.open(url) + defer { sqlite3_close(database) } + try Fixture.execute(database, """ + DROP TABLE gen_metadata; + CREATE TABLE an_unrelated_table_with_a_long_name (value); + CREATE TABLE gen_metadata (idx INTEGER, data BLOB); + """) + try Fixture.insert(database, row: 0, blob: Fixture.blob()) + try fixture.database("session-3", blobs: [Fixture.blob()]) var limits = AntigravityLocalReader.Limits() limits.schemaBytes = initial.statistics.schemaBytes let partial = try fixture.report(limits: limits) #expect(partial.coverage == .partial) - #expect(!partial.report.data.isEmpty) - #expect(partial.report.summary?.totalTokens == 198) - #expect(partial.statistics.files == 2) - #expect(partial.statistics.rows == 1) - #expect(partial.statistics.schemaBytes > limits.schemaBytes) + #expect(partial.report.summary?.totalTokens == 396) + #expect(partial.statistics.files == 3) + #expect(partial.statistics.rows == 2) } @Test diff --git a/docs/antigravity.md b/docs/antigravity.md index b8f65b7411..ef5b49aabc 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -381,7 +381,7 @@ the menu, Usage & Spend, exported JSON, and the CLI; they never establish empty zero. Failed or retained-partial dashboard attempts do not acknowledge successful incorporation of a refresh trigger. Overflowed aggregate totals remain unknown rather than becoming saturated or wrapping. Hard database-count, row-count, cumulative-byte, or duration budget exhaustion does not publish a newly truncated report; it remains unavailable and preserves prior complete history. -Schema-budget exhaustion preserves validated rows from earlier databases as partial history, subject to the same lower-bound labeling and prior-complete-report rules. The schema cap remains 64 KiB. +Schema-budget exhaustion withholds only the database whose schema exceeded a limit. Validated rows from the other databases remain partial history, subject to the same lower-bound labeling and prior-complete-report rules. The schema evidence is [Tokscale's pinned SQLite parser](https://github.com/junhoyeo/tokscale/blob/62ca1eb1677556972ba963fdfa3a41ab23c1eb4b/crates/tokscale-core/src/sessions/antigravity_cli.rs), whose header records six databases and 140 turns. SQLite usage fields 1 + 2 are input, 5 is cache read, @@ -435,7 +435,9 @@ One cancellable job on `CostUsageScanExecutor` owns discovery, SQL, decoding, an 128 MiB of attempted payload bytes overall, and a five-second cooperative scan deadline. Rejected rows consume the budget; exactly 500 complete databases are accepted. Discovery is incremental and JSONL is read in bounded chunks. Schema inspection accepts at most 128 catalogue entries and 64 columns per database (one additional row detects -truncation), with a cumulative 64 KiB allowance for inspected schema text and the same cooperative deadline/cancellation. +truncation), with a 64 KiB allowance per database for inspected schema text and the same cooperative deadline/cancellation. +An ordinary conversation database uses a few hundred bytes of that allowance. A job-wide allowance let a long history of +small schemas add up to it: about 240 databases exhausted 64 KiB, well below the 500-database cap. SQLite values are capped at 64 KiB during inspection (or the smaller payload limit plus record overhead). SQLite then uses one streaming payload SELECT over the validated ordinary table. A length-based conditional projection checks the remaining From d9f3aed901b1b9c8efc4075f89a3e5b5ea301e91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 17:19:50 -0700 Subject: [PATCH 097/122] fix(antigravity): preserve consecutive credential updates Track successful writes within a fetch so OAuth can persist its discovered project after refreshing tokens. Keep compare-and-save protection against external reauthorization, skipped writes, and failed writes. Add a regression for consecutive owned writes followed by external reauthorization. --- Sources/CodexBarCLI/TokenAccountCLI.swift | 45 ++++++++++++++----- .../TokenAccountCLISelectionTests.swift | 20 +++++++++ docs/antigravity.md | 3 +- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 37c4242342..78f904414b 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -192,16 +192,9 @@ struct TokenAccountCLIContext { func tokenUpdater(for account: ProviderTokenAccount?) -> ProviderFetchContext.TokenAccountTokenUpdater? { guard let account else { return nil } - let configStore = self.configStore - let expectedToken = account.token + let writeback = TokenAccountCLIWriteback(account: account, store: self.configStore) return { provider, accountID, token in - guard accountID == account.id else { return } - try? Self.updateStoredTokenAccount( - store: configStore, - provider: provider, - accountID: accountID, - expectedToken: expectedToken, - token: token) + await writeback.update(provider: provider, accountID: accountID, token: token) } } @@ -211,16 +204,18 @@ struct TokenAccountCLIContext { } } + @discardableResult static func updateStoredTokenAccount( store: CodexBarConfigStore, provider: UsageProvider, accountID: UUID, expectedToken: String, - token: String) throws + token: String) throws -> Bool { let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard !trimmed.isEmpty else { return false } + var didUpdate = false try store.updateIfAvailable { config in guard var providerConfig = config.providerConfig(for: provider.instanceID), let data = providerConfig.tokenAccounts, @@ -253,8 +248,10 @@ struct TokenAccountCLIContext { accounts: accounts, activeIndex: data.clampedActiveIndex()) config.setProviderConfig(providerConfig) + didUpdate = true return true } + return didUpdate } func fetcher(base: UsageFetcher, provider: UsageProvider, env: [String: String]) -> UsageFetcher { @@ -347,3 +344,29 @@ struct TokenAccountCLIContext { } } } + +/// A fetch may persist a refreshed token and then discovered account metadata. +private actor TokenAccountCLIWriteback { + private let accountID: UUID + private let store: CodexBarConfigStore + private var expectedToken: String + + init(account: ProviderTokenAccount, store: CodexBarConfigStore) { + self.accountID = account.id + self.store = store + self.expectedToken = account.token + } + + func update(provider: UsageProvider, accountID: UUID, token: String) { + guard accountID == self.accountID else { return } + if (try? TokenAccountCLIContext.updateStoredTokenAccount( + store: self.store, + provider: provider, + accountID: accountID, + expectedToken: self.expectedToken, + token: token)) == true + { + self.expectedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + } + } +} diff --git a/Tests/CodexBarTests/TokenAccountCLISelectionTests.swift b/Tests/CodexBarTests/TokenAccountCLISelectionTests.swift index f33898f76d..c7900be895 100644 --- a/Tests/CodexBarTests/TokenAccountCLISelectionTests.swift +++ b/Tests/CodexBarTests/TokenAccountCLISelectionTests.swift @@ -102,6 +102,26 @@ struct TokenAccountCLISelectionTests { #expect(stored == "reauthorized-token") } + @Test + func `cli token updater accepts successive owned writes but rejects external reauthorization`() async throws { + let account = Self.account(token: "original-token") + let config = Self.config(with: account) + let store = try Self.configStore() + defer { try? FileManager.default.removeItem(at: store.fileURL.deletingLastPathComponent()) } + try store.save(config) + let context = try Self.writebackContext(config: config, store: store) + let updater = try #require(context.tokenUpdater(for: account)) + + // OAuth first refreshes the grant, then persists the discovered project on the same fetch. + await updater(.antigravity, account.id, "refreshed-token") + await updater(.antigravity, account.id, "refreshed-token-with-project") + #expect(try Self.storedToken(store: store, accountID: account.id) == "refreshed-token-with-project") + + try store.save(Self.config(with: Self.account(id: account.id, token: "reauthorized-token"))) + await updater(.antigravity, account.id, "late-owned-update") + #expect(try Self.storedToken(store: store, accountID: account.id) == "reauthorized-token") + } + @Test func `cli refresh cannot publish during another config writer transaction`() throws { let account = Self.account(token: "original-token") diff --git a/docs/antigravity.md b/docs/antigravity.md index c49b485575..a7570d1bf8 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -60,7 +60,8 @@ keeping the ambient CLI login and Keychain untouched. Credentials without an ID Before attributing usage, CodexBar checks the effective access token through Google's userinfo endpoint and rejects a different or unverifiable account, including conflicting refreshed ID-token claims. Only verified refreshed credentials reach the existing saved-account updater. The CLI compares and saves -under the shared config-file lock; a concurrent writer or changed credential skips the best-effort update. +under the shared config-file lock, advancing its comparison only after its own successful write. A concurrent +writer or externally changed credential skips the best-effort update. The app retains its account-token and config-revision guards. The temporary home is removed on success, failure, and cancellation. Scoped failures retain the ambient diagnostic and allow account-scoped OAuth fallback; cancellation stops the pipeline. Linux keeps its existing OAuth fallback. From c40987f52e4f5c523f252cd140ab220f9e076a99 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 17:21:08 -0700 Subject: [PATCH 098/122] refactor(antigravity): reuse parsed usage for local pricing Remove the duplicate pricing token carrier while preserving exact catalog precedence and provider-local aliases. Verify model IDs survive repeated usage envelopes, link the schema evidence, and record the 0.70.1 fix. Refs #4124 Co-authored-by: Peter Urda --- CHANGELOG.md | 4 +++ .../Antigravity/AntigravityLocalReader.swift | 25 ++++++------------- .../AntigravityLocalReaderTests.swift | 5 ++-- docs/antigravity.md | 5 ++-- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39766219d8..e8dcd6b15d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.70.1 — Unreleased +### Fixed + +- Antigravity: exclude model IDs from local token totals, correct visible and reasoning output counts, and estimate safety-routed Gemini Flash usage (#4124). Thanks @urda! + ## 0.70.0 — 2026-09-29 ### Highlights diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift index d7479979ad..a4bd1c4c2b 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift @@ -276,11 +276,8 @@ enum AntigravityLocalReader { pricing: $0, model: model, date: date, - tokens: PricedTokens( - input: input, - cacheRead: usage.cacheRead, - cacheCreation: event.cacheWrite, - output: usage.output + usage.reasoning)) + usage: usage, + cacheWrite: event.cacheWrite) } let day = CostUsageLocalDay.key(from: date, calendar: calendar) return .init( @@ -308,28 +305,22 @@ enum AntigravityLocalReader { estimatedRequestCount: cost == nil ? 0 : 1) } - private struct PricedTokens { - let input: Int - let cacheRead: Int - let cacheCreation: Int - let output: Int - } - /// Prices the exact recorded model ID first so an explicitly catalogued variant keeps its own /// price, then falls back to the base model of a known routing variant. private static func costUSD( pricing: CostUsagePricing.ClaudeResolver, model: String, date: Date, - tokens: PricedTokens) -> Double? + usage: AntigravityProtoReader.ParsedUsage, + cacheWrite: Int) -> Double? { func resolve(_ candidate: String) -> Double? { pricing.costUSD( model: candidate, - inputTokens: tokens.input, - cacheReadInputTokens: tokens.cacheRead, - cacheCreationInputTokens: tokens.cacheCreation, - outputTokens: tokens.output, + inputTokens: usage.newInput, + cacheReadInputTokens: usage.cacheRead, + cacheCreationInputTokens: cacheWrite, + outputTokens: usage.output + usage.reasoning, pricingDate: date) } if let cost = resolve(model) { return cost } diff --git a/Tests/CodexBarTests/AntigravityLocalReaderTests.swift b/Tests/CodexBarTests/AntigravityLocalReaderTests.swift index b2a1316649..6305f85d1d 100644 --- a/Tests/CodexBarTests/AntigravityLocalReaderTests.swift +++ b/Tests/CodexBarTests/AntigravityLocalReaderTests.swift @@ -410,14 +410,13 @@ struct AntigravityLocalReaderTests { @Test func `repeated known messages merge scalar fields after validating every occurrence`() throws { - // Field 1 (model ID) is deliberately not used here: it is validated but never stored, so it - // cannot demonstrate that a later occurrence's fields merge onto an earlier one's. - let usage = Fixture.message(4, Fixture.varint(2, 100)) + let usage = Fixture.message(4, Fixture.varint(1, 1298) + Fixture.varint(2, 100)) + Fixture.message(4, Fixture.varint(5, 50)) let time = Fixture.message(9, Fixture.message(4, Fixture.varint(1, 1_787_832_000))) + Fixture.message(9, Fixture.message(4, Fixture.varint(2, 123_000_000))) let bytes = Fixture.message(1, usage) + Fixture.message(1, time) let turn = try #require(try AntigravityProtoReader.parseTurn(bytes)) + #expect(turn.usage?.modelID == 1298) #expect(turn.usage?.newInput == 100) #expect(turn.usage?.cacheRead == 50) #expect(turn.timestampMs == 1_787_832_000_123) diff --git a/docs/antigravity.md b/docs/antigravity.md index 43b1955a3d..3c7c7a71ef 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -389,8 +389,9 @@ field 2 is input. Field 1 is the model enum ID (for example 1298 for `gemini-3.7 does not count it. Field 5 is cache read. Field 9 is reasoning (thinking) output, and field 10 is text (visible) output: reasoning and text are separate counts. CodexBar follows [ccusage's Antigravity adapter](https://github.com/ccusage/ccusage/blob/d41bf3d48a911e9742793087142e323093ae6a4f/rust/adapters/antigravity/src/parser.rs) -for this field 1/9/10 reading, confirmed independently by decoding real local databases. This reading differs -from Tokscale's own reading of fields 9/10. +for this field 1/9/10 reading, cross-checked against +[decoded local history](https://github.com/steipete/CodexBar/pull/4124). This reading differs from Tokscale's own reading +of fields 9/10. Historical model IDs are retained; missing models stay unknown unless an unambiguous raw label maps to a model within the same session. Conflicting mappings remain unresolved. Every repeated known protobuf envelope is validated and merged. From 4a350f05fc214a9f00a1241669b9ef28b68f0588 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 17:29:46 -0700 Subject: [PATCH 099/122] fix(sessions): verify discovery after executable removal Add a real process-unlink regression and verify that a missing executable path still cannot establish trusted ChatGPT app-server identity. Document session discovery across in-place CLI updates. Remove the unused string parser wrapper and copy filtered Pi selector environments on AgentProcessRecord instead of rebuilding every field. The complete change reduces production code by two lines against main. Co-authored-by: Slava Kurilyak --- Sources/CodexBarCore/AgentSession.swift | 6 ++ .../DarwinProcessEnumerator.swift | 6 -- .../LocalAgentSessionScanner.swift | 19 +----- .../DarwinProcessEnumeratorTests.swift | 66 ++++++++++++++++--- docs/sessions.md | 3 + 5 files changed, 69 insertions(+), 31 deletions(-) diff --git a/Sources/CodexBarCore/AgentSession.swift b/Sources/CodexBarCore/AgentSession.swift index bba7950564..6a17322267 100644 --- a/Sources/CodexBarCore/AgentSession.swift +++ b/Sources/CodexBarCore/AgentSession.swift @@ -218,6 +218,12 @@ public struct AgentProcessRecord: Equatable, Sendable { self.piSelectorEnvironment = PiProcessEnvironment.filtered(piSelectorEnvironment) } + func withPiSelectorEnvironment(_ environment: [String: String]?) -> Self { + var record = self + record.piSelectorEnvironment = PiProcessEnvironment.filtered(environment) + return record + } + public var executableBasename: String { let firstToken = self.arguments?.first ?? self.command.split(whereSeparator: \ .isWhitespace).first .map(String.init) ?? "" diff --git a/Sources/CodexBarCore/DarwinProcessEnumerator.swift b/Sources/CodexBarCore/DarwinProcessEnumerator.swift index 0122bac808..1d0d56a225 100644 --- a/Sources/CodexBarCore/DarwinProcessEnumerator.swift +++ b/Sources/CodexBarCore/DarwinProcessEnumerator.swift @@ -19,12 +19,6 @@ enum DarwinProcessEnumerator { ["agy", "antigravity-cli", "antigravity_cli", "node", "bun"].contains(basename) } - /// Parses the `KERN_PROCARGS2` payload without consuming the environment - /// strings that follow argv. - static func parseProcArgs2(_ data: Data) -> String? { - self.parseProcArgs2Arguments(data)?.joined(separator: " ") - } - /// Returns the original argv from a `KERN_PROCARGS2` payload. Keeping the /// boundaries matters for flags whose values contain whitespace. static func parseProcArgs2Arguments(_ data: Data) -> [String]? { diff --git a/Sources/CodexBarCore/LocalAgentSessionScanner.swift b/Sources/CodexBarCore/LocalAgentSessionScanner.swift index 1ab09269af..9f4124a523 100644 --- a/Sources/CodexBarCore/LocalAgentSessionScanner.swift +++ b/Sources/CodexBarCore/LocalAgentSessionScanner.swift @@ -435,7 +435,7 @@ public struct LocalAgentSessionScanner: Sendable { let environments = await processEnvironmentProvider(piPIDs) return records.map { record in guard AgentPSOutputParser.piDialect(for: record) != nil else { return record } - return Self.withPiSelectorEnvironment(environments[record.pid], record: record) + return record.withPiSelectorEnvironment(environments[record.pid]) } } #if canImport(Darwin) @@ -447,9 +447,7 @@ public struct LocalAgentSessionScanner: Sendable { #if os(Linux) return records.map { record in guard AgentPSOutputParser.piDialect(for: record) != nil else { return record } - return Self.withPiSelectorEnvironment( - PiProcessEnvironment.readLinuxEnvironment(pid: record.pid), - record: record) + return record.withPiSelectorEnvironment(PiProcessEnvironment.readLinuxEnvironment(pid: record.pid)) } #else return records @@ -482,19 +480,6 @@ public struct LocalAgentSessionScanner: Sendable { } #endif - private static func withPiSelectorEnvironment( - _ environment: [String: String]?, - record: AgentProcessRecord) -> AgentProcessRecord - { - AgentProcessRecord( - pid: record.pid, - ppid: record.ppid, - startedAt: record.startedAt, - command: record.command, - arguments: record.arguments, - piSelectorEnvironment: environment) - } - #if !canImport(Darwin) private func processOutput(environment: [String: String]) async -> String { let binary = ["/bin/ps", "/usr/bin/ps"].first { FileManager.default.isExecutableFile(atPath: $0) } diff --git a/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift b/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift index dbd2c4ce91..1cf93cb50d 100644 --- a/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift +++ b/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift @@ -8,10 +8,9 @@ import Darwin struct DarwinProcessEnumeratorTests { @Test - func `proc args parser joins normal argv`() { + func `proc args parser preserves normal argv`() { let data = Self.procArgsData(arguments: ["/usr/bin/tool", "--flag", "value"]) - #expect(DarwinProcessEnumerator.parseProcArgs2(data) == "/usr/bin/tool --flag value") #expect(DarwinProcessEnumerator.parseProcArgs2Arguments(data) == ["/usr/bin/tool", "--flag", "value"]) } @@ -19,12 +18,12 @@ struct DarwinProcessEnumeratorTests { func `proc args parser accepts zero argc`() { let data = Self.procArgsData(arguments: []) - #expect(DarwinProcessEnumerator.parseProcArgs2(data)?.isEmpty == true) + #expect(DarwinProcessEnumerator.parseProcArgs2Arguments(data) == []) } @Test func `proc args parser rejects truncated buffer`() { - #expect(DarwinProcessEnumerator.parseProcArgs2(Data([2, 0, 0])) == nil) + #expect(DarwinProcessEnumerator.parseProcArgs2Arguments(Data([2, 0, 0])) == nil) } @Test @@ -33,7 +32,7 @@ struct DarwinProcessEnumeratorTests { arguments: ["/usr/bin/tool", "--flag"], environment: ["SECRET=value", "HOME=/tmp"]) - let command = DarwinProcessEnumerator.parseProcArgs2(data) + let command = DarwinProcessEnumerator.parseProcArgs2Arguments(data)?.joined(separator: " ") #expect(command == "/usr/bin/tool --flag") #expect(command?.contains("SECRET") == false) #expect(command?.contains("HOME") == false) @@ -52,7 +51,7 @@ struct DarwinProcessEnumeratorTests { #expect(DarwinProcessEnumerator.parseProcArgs2Environment(data) == [ "HOME": "/synthetic/home", "OMP_PROFILE": "work", ]) - #expect(DarwinProcessEnumerator.parseProcArgs2(data)?.contains("HOME=") == false) + #expect(DarwinProcessEnumerator.parseProcArgs2Arguments(data)?.contains("HOME=/synthetic/home") == false) } @Test @@ -84,7 +83,7 @@ struct DarwinProcessEnumeratorTests { func `proc args parser preserves embedded empty arguments`() { let data = Self.procArgsData(arguments: ["/usr/bin/tool", "", "value"]) - #expect(DarwinProcessEnumerator.parseProcArgs2(data) == "/usr/bin/tool value") + #expect(DarwinProcessEnumerator.parseProcArgs2Arguments(data) == ["/usr/bin/tool", "", "value"]) } @Test @@ -92,7 +91,7 @@ struct DarwinProcessEnumeratorTests { var data = Self.procArgsData(arguments: ["/usr/bin/tool", "value"]) data.replaceSubrange(0..<4, with: Self.littleEndianBytes(3)) - #expect(DarwinProcessEnumerator.parseProcArgs2(data) == nil) + #expect(DarwinProcessEnumerator.parseProcArgs2Arguments(data) == nil) } @Test @@ -205,6 +204,57 @@ struct DarwinProcessEnumeratorTests { #expect(AgentPSOutputParser.agentProcesses(from: [record]).map(\.pid) == [4242]) } + @Test + func `running agent remains discoverable after its executable is unlinked`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let executable = root.appendingPathComponent("claude") + try Data(contentsOf: URL(fileURLWithPath: "/bin/cat")).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + // Relocated platform binaries need a new signature; ad-hoc signing never accesses a signing key. + _ = try await SubprocessRunner.run( + binary: "/usr/bin/codesign", + arguments: ["--force", "--sign", "-", executable.path], + environment: [:], + timeout: 10, + label: "synthetic-agent-signing") + let input = Pipe() + let output = Pipe() + let process = Process() + process.executableURL = executable + process.environment = [:] + process.standardInput = input + process.standardOutput = output + try process.run() + defer { + process.terminate() + process.waitUntilExit() + } + let ready = Data("ready\n".utf8) + try input.fileHandleForWriting.write(contentsOf: ready) + #expect(try output.fileHandleForReading.read(upToCount: ready.count) == ready) + let pid = process.processIdentifier + #expect(DarwinProcessEnumerator.executablePath(pid: pid) != nil) + try FileManager.default.removeItem(at: executable) + #expect(DarwinProcessEnumerator.executablePath(pid: pid) == nil) + let record = try #require(LocalAgentSessionScanner.darwinProcessRecord(pid: pid)) + #expect(AgentPSOutputParser.agentProcesses(from: [record]).map(\.pid) == [pid]) + #expect(AgentPSOutputParser.provider(for: record) == .claude) + } + + @Test + func `deleted executable cannot establish trusted chatgpt app server identity`() { + #expect(!ChatGPTCodexProcessTrust.isTrusted( + 4242, + executablePath: { _ in nil }, + processIsTrusted: { _ in + Issue.record("Missing executable path must fail before signature validation") + return true + }, + appIsTrusted: { _ in true })) + } + @Test func `darwin process record falls back to the executable path without argv`() throws { let record = try #require(LocalAgentSessionScanner.darwinProcessRecord( diff --git a/docs/sessions.md b/docs/sessions.md index 96c84a030c..bf75bea81a 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -11,6 +11,9 @@ CodexBar can list live Codex, Claude Code, pi, and OMP sessions on this Mac and Enable **Settings → Menu → Agent sessions**. Local sessions refresh every 30 seconds. Remote sessions refresh every 60 seconds and whenever the menu opens. Tailscale discovery includes online macOS and Linux peers; add extra SSH destinations as a comma-separated list, such as `user@host`. +On macOS, sessions remain discoverable from their running process arguments after a CLI update removes the old executable. +This does not relax ChatGPT app-server trust: that check still requires the exact executable path and signed running code. + SSH usernames retain their case when destinations are deduplicated: `user@host` and `USER@host` are separate targets. Hostname case alone does not create a duplicate target. From 4d1b834ab28736a913a328d6cd51f9765b922f46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 17:56:38 -0700 Subject: [PATCH 100/122] fix(opencode): share Console parsing and preserve snapshot API Retain the existing public spend and reset interfaces while mapping migrated Console quota and 30-day spend. Share legacy window parsing and session fallback between OpenCode and OpenCode Go, and remove redundant parsing and diagnostics code. Cover migrated routing, Console timestamps, legacy billing cancellation, and public initializer compatibility with isolated regressions. Refs #4139. Fixes #4131. Co-authored-by: cuidong233 --- CHANGELOG.md | 2 +- .../OpenCodeConsoleUsageFetcher.swift | 63 +--- .../OpenCode/OpenCodeUsageFetcher.swift | 274 ++---------------- .../OpenCode/OpenCodeUsageSnapshot.swift | 65 +++-- .../OpenCode/OpenCodeZenBillingParser.swift | 31 +- .../OpenCodeGo/OpenCodeGoUsageFetcher.swift | 238 +++------------ .../OpenCodeGoZenBalanceFetcher.swift | 2 +- .../OpenCodeGoZenBalanceParser.swift | 19 +- .../OpenCodeLegacyFallback.swift} | 13 +- .../Shared/OpenCodeSubscriptionParser.swift | 193 ++++++++++++ .../OpenCodeConsoleSnapshotTests.swift | 39 ++- .../OpenCodeMenuCardCostTests.swift | 8 +- .../OpenCodeUsageFetcherErrorTests.swift | 54 +++- .../OpenCodeUsageParserTests.swift | 33 ++- .../OpenCodeConsoleUsageFetcherTests.swift | 9 +- 15 files changed, 438 insertions(+), 605 deletions(-) rename Sources/CodexBarCore/Providers/{OpenCodeGo/OpenCodeGoLegacyFallback.swift => Shared/OpenCodeLegacyFallback.swift} (84%) create mode 100644 Sources/CodexBarCore/Providers/Shared/OpenCodeSubscriptionParser.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f80d295293..b91cd2ad13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- OpenCode: read migrated Console workspaces with the Console session, preserve workspace-scoped quota and prepaid balance, and label explicit 30-day spend without inventing a monthly spending limit (#4131). +- OpenCode: restore migrated Console workspace quota and prepaid balance, preserve legacy sessions, and label 30-day spend without inventing a monthly spending limit (#4131, #4139). Thanks @luochen211! ## 0.70.0 — 2026-09-29 diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeConsoleUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeConsoleUsageFetcher.swift index cc502f4f79..4924710774 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeConsoleUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeConsoleUsageFetcher.swift @@ -36,14 +36,7 @@ enum OpenCodeConsoleUsageFetcher { timeout: timeout, transport: transport) if let quota = OpenCodeGoUsageFetcher.parseConsoleGoStatus(text: text, now: now) { - return OpenCodeUsageSnapshot( - hasWeeklyUsage: quota.hasWeeklyUsage, - rollingUsagePercent: quota.rollingUsagePercent, - weeklyUsagePercent: quota.weeklyUsagePercent, - rollingResetInSec: quota.rollingResetInSec, - weeklyResetInSec: quota.weeklyResetInSec, - renewsAt: quota.renewsAt, - updatedAt: now) + return OpenCodeUsageSnapshot(quota: quota) } guard let data = text.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), @@ -84,7 +77,7 @@ enum OpenCodeConsoleUsageFetcher { // Unsupported or missing balance data does not erase spend for a confirmed PAYG account. let balanceUSD = try? OpenCodeGoZenBalanceParser.parseConsoleBillingStatus(text: billingText) return .payAsYouGo( - .init(usageUSD: usageUSD, limitUSD: nil, balanceUSD: balanceUSD, period: .last30Days), + .init(monthlyUsageUSD: usageUSD, monthlyLimitUSD: nil, balanceUSD: balanceUSD, period: .last30Days), updatedAt: now) } @@ -152,56 +145,4 @@ enum OpenCodeConsoleUsageFetcher { } return text } - - static func withLegacyFallback( - cookieHeader: String, - console: @Sendable () async throws -> Value, - legacy: @Sendable () async throws -> Value) async throws -> Value - { - try Task.checkCancellation() - let cookies = CookieHeaderNormalizer.pairs(from: cookieHeader) - let hasConsoleSession = cookies - .contains { OpenCodeWebCookieSupport.consoleSessionCookieNames.contains($0.name) } - // The Console authenticates with its own cookie; keep legacy-only accounts on their existing path. - guard hasConsoleSession else { return try await legacy() } - do { - return try await console() - } catch { - let consoleError = error - try self.checkCancellation(error) - guard self.canTryLegacy(after: error), - cookies.contains(where: { OpenCodeWebCookieSupport.sessionCookieNames.contains($0.name) }) - else { throw error } - do { - let value = try await legacy() - try Task.checkCancellation() - return value - } catch { - try self.checkCancellation(error) - // Keep a Console permission or parsing error if the legacy endpoint says signed out. - if case .invalidCredentials? = consoleError as? OpenCodeUsageError { throw error } - if error is OpenCodeUsageError { throw consoleError } - throw error - } - } - } - - private static func canTryLegacy(after error: Error) -> Bool { - if error is OpenCodeUsageError { return true } - guard let error = error as? URLError else { return false } - switch error.code { - case .timedOut, .networkConnectionLost, .cannotConnectToHost, .cannotFindHost, - .dnsLookupFailed, .notConnectedToInternet, .resourceUnavailable: - return true - default: - return false - } - } - - private static func checkCancellation(_ error: Error) throws { - try Task.checkCancellation() - if error is CancellationError || (error as? URLError)?.code == .cancelled { - throw CancellationError() - } - } } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift index d61783b06b..248698aec3 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift @@ -62,7 +62,7 @@ public struct OpenCodeUsageFetcher: Sendable { let workspaceID: String = if let override = normalizedOverride { override } else { - try await OpenCodeConsoleUsageFetcher.withLegacyFallback(cookieHeader: requestCookieHeader) { + try await OpenCodeLegacyFallback.fetch(cookieHeader: requestCookieHeader, requiresConsoleCookie: true) { try await OpenCodeConsoleUsageFetcher.fetchWorkspaceID( cookieHeader: requestCookieHeader, timeout: timeout, transport: transport) } legacy: { @@ -70,7 +70,7 @@ public struct OpenCodeUsageFetcher: Sendable { cookieHeader: requestCookieHeader, timeout: timeout, transport: transport) } } - return try await OpenCodeConsoleUsageFetcher.withLegacyFallback(cookieHeader: requestCookieHeader) { + return try await OpenCodeLegacyFallback.fetch(cookieHeader: requestCookieHeader, requiresConsoleCookie: true) { try await OpenCodeConsoleUsageFetcher.fetchUsage( workspaceID: workspaceID, cookieHeader: requestCookieHeader, @@ -175,8 +175,8 @@ extension OpenCodeUsageFetcher { "limit \(billing.monthlyLimitUSD == nil ? "unset" : "set")).") return .payAsYouGo( OpenCodeUsageSnapshot.PayAsYouGoUsage( - usageUSD: billing.monthlyUsageUSD, - limitUSD: billing.monthlyLimitUSD, + monthlyUsageUSD: billing.monthlyUsageUSD, + monthlyLimitUSD: billing.monthlyLimitUSD, balanceUSD: billing.balanceUSD), updatedAt: now) } @@ -293,12 +293,7 @@ extension OpenCodeUsageFetcher { { return true } - guard let data = trimmed.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data, options: []) - else { - return false - } - return object is NSNull + return false } private static func missingSubscriptionDataError(workspaceID: String) -> OpenCodeUsageError { @@ -400,22 +395,10 @@ extension OpenCodeUsageFetcher { } private static func parseSubscriptionJSON(text: String, now: Date) -> OpenCodeUsageSnapshot? { - guard let data = text.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data, options: []) - else { - return nil - } - - if let snapshot = self.parseUsageJSON(object: object, now: now) { - return snapshot - } - - if let snapshot = self.parseUsageFromCandidates(object: object, now: now) { - return snapshot - } - - self.logParseSummary(object: object) - return nil + guard let quota = OpenCodeSubscriptionParser(requiresWeeklyUsage: true) + .parseSubscriptionJSON(text: text, now: now) + else { return nil } + return OpenCodeUsageSnapshot(quota: quota) } private static func serverRequestURL(serverID: String, args: [Any]?, method: String) -> URL { @@ -435,202 +418,6 @@ extension OpenCodeUsageFetcher { return components?.url ?? self.serverURL } - private static func parseUsageJSON(object: Any, now: Date) -> OpenCodeUsageSnapshot? { - guard let dict = object as? [String: Any] else { return nil } - let renewsAt = OpenCodeWebParsing.dateValue(from: OpenCodeWebParsing.value( - from: dict, - keys: OpenCodeWebParsing.renewAtKeys)) - if let snapshot = self.parseUsageDictionary(dict, now: now, inheritedRenewsAt: renewsAt) { - return snapshot - } - - for key in ["data", "result", "usage", "billing", "payload"] { - if let nested = dict[key] as? [String: Any], - let snapshot = self.parseUsageDictionary(nested, now: now, inheritedRenewsAt: renewsAt) - { - return snapshot - } - } - - if let snapshot = self.parseUsageNested(dict, now: now, depth: 0, inheritedRenewsAt: renewsAt) { - return snapshot - } - return self.parseUsageFromCandidates(object: object, now: now, inheritedRenewsAt: renewsAt) - } - - private static func parseUsageDictionary( - _ dict: [String: Any], - now: Date, - inheritedRenewsAt: Date?) -> OpenCodeUsageSnapshot? - { - let renewsAt = OpenCodeWebParsing - .dateValue(from: OpenCodeWebParsing.value(from: dict, keys: OpenCodeWebParsing.renewAtKeys)) ?? - inheritedRenewsAt - if let usage = dict["usage"] as? [String: Any], - let snapshot = self.parseUsageDictionary(usage, now: now, inheritedRenewsAt: renewsAt) - { - return snapshot - } - - let rollingKeys = ["rollingUsage", "rolling", "rolling_usage", "rollingWindow", "rolling_window"] - let weeklyKeys = ["weeklyUsage", "weekly", "weekly_usage", "weeklyWindow", "weekly_window"] - - let rolling = rollingKeys.compactMap { dict[$0] as? [String: Any] }.first - let weekly = weeklyKeys.compactMap { dict[$0] as? [String: Any] }.first - - if let rolling, let weekly { - return self.buildSnapshot(rolling: rolling, weekly: weekly, now: now, renewsAt: renewsAt) - } - - return nil - } - - private static func parseUsageNested( - _ dict: [String: Any], - now: Date, - depth: Int, - inheritedRenewsAt: Date?) -> OpenCodeUsageSnapshot? - { - if depth > 3 { return nil } - let renewsAt = OpenCodeWebParsing - .dateValue(from: OpenCodeWebParsing.value(from: dict, keys: OpenCodeWebParsing.renewAtKeys)) ?? - inheritedRenewsAt - var rolling: [String: Any]? - var weekly: [String: Any]? - - for (key, value) in dict { - guard let sub = value as? [String: Any] else { continue } - let lower = key.lowercased() - if lower.contains("rolling") { - rolling = sub - } else if lower.contains("weekly") || lower.contains("week") { - weekly = sub - } - } - - if let rolling, let weekly { - let snapshot = self.buildSnapshot(rolling: rolling, weekly: weekly, now: now, renewsAt: renewsAt) - if let snapshot { return snapshot } - } - - for value in dict.values { - if let sub = value as? [String: Any], - let snapshot = self.parseUsageNested( - sub, - now: now, - depth: depth + 1, - inheritedRenewsAt: renewsAt) - { - return snapshot - } - } - - return nil - } - - private static func parseUsageFromCandidates( - object: Any, - now: Date, - inheritedRenewsAt: Date? = nil) -> OpenCodeUsageSnapshot? - { - let candidates = OpenCodeWebParsing.collectWindowCandidates(object: object) { self.parseWindow($0, now: now) } - guard !candidates.isEmpty else { return nil } - - let rollingCandidates = candidates.filter { candidate in - candidate.pathLower.contains("rolling") || - candidate.pathLower.contains("hour") || - candidate.pathLower.contains("5h") || - candidate.pathLower.contains("5-hour") - } - let weeklyCandidates = candidates.filter { candidate in - candidate.pathLower.contains("weekly") || - candidate.pathLower.contains("week") - } - - let rolling = OpenCodeWebParsing.pickCandidate( - preferred: rollingCandidates, - fallback: candidates, - pickShorter: true) - let weekly = OpenCodeWebParsing.pickCandidate( - preferred: weeklyCandidates, - fallback: candidates, - pickShorter: false, - excluding: rolling?.id) - - guard let rolling, let weekly else { return nil } - - let renewsAt = OpenCodeWebParsing.dateValue(from: OpenCodeWebParsing.value( - from: object as? [String: Any] ?? [:], - keys: OpenCodeWebParsing.renewAtKeys)) - ?? inheritedRenewsAt - return OpenCodeUsageSnapshot( - rollingUsagePercent: rolling.percent, - weeklyUsagePercent: weekly.percent, - rollingResetInSec: rolling.resetInSec, - weeklyResetInSec: weekly.resetInSec, - renewsAt: renewsAt, - updatedAt: now) - } - - private static func buildSnapshot( - rolling: [String: Any], - weekly: [String: Any], - now: Date, - renewsAt: Date? = nil) -> OpenCodeUsageSnapshot? - { - guard let rollingWindow = self.parseWindow(rolling, now: now), - let weeklyWindow = self.parseWindow(weekly, now: now) - else { - return nil - } - - return OpenCodeUsageSnapshot( - rollingUsagePercent: rollingWindow.percent, - weeklyUsagePercent: weeklyWindow.percent, - rollingResetInSec: rollingWindow.resetInSec, - weeklyResetInSec: weeklyWindow.resetInSec, - renewsAt: renewsAt, - updatedAt: now) - } - - private static func parseWindow(_ dict: [String: Any], now: Date) -> (percent: Double, resetInSec: Int)? { - var percent = OpenCodeWebParsing.doubleValue(from: dict, keys: OpenCodeWebParsing.percentKeys) - // A direct percent field may arrive as a fraction (0...1) or a percent (0...100), so it goes - // through the `<= 1` heuristic below. A computed used/limit percent is already 0...100 and must not. - let percentIsDirect = percent != nil - - if percent == nil { - let used = OpenCodeWebParsing.doubleValue( - from: dict, - keys: ["used", "usage", "consumed", "count", "usedTokens"]) - let limit = OpenCodeWebParsing.doubleValue( - from: dict, - keys: ["limit", "total", "quota", "max", "cap", "tokenLimit"]) - if let used, let limit, limit > 0 { - percent = (used / limit) * 100 - } - } - - guard var resolvedPercent = percent else { return nil } - if percentIsDirect, resolvedPercent <= 1.0, resolvedPercent >= 0 { - resolvedPercent *= 100 - } - resolvedPercent = max(0, min(100, resolvedPercent)) - - var resetInSec = OpenCodeWebParsing.intValue(from: dict, keys: OpenCodeWebParsing.resetInKeys) - if resetInSec == nil { - let resetAtValue = OpenCodeWebParsing.value(from: dict, keys: OpenCodeWebParsing.resetAtKeys) - if let resetAt = OpenCodeWebParsing.dateValue(from: resetAtValue), - let interval = OpenCodeWebParsing.resetInterval(from: resetAt, now: now) - { - resetInSec = interval - } - } - - let resolvedReset = max(0, resetInSec ?? 0) - return (resolvedPercent, resolvedReset) - } - private static func logParseSummary(text: String) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard let data = text.data(using: .utf8), @@ -648,52 +435,29 @@ extension OpenCodeUsageFetcher { Self.log.error("OpenCode response non-JSON: hint=\(hint) length=\(text.count)") return } - self.logParseSummary(object: object) - } - - private static func logParseSummary(object: Any) { let summary = self.summarizeJSON(object: object, depth: 0) guard !summary.isEmpty else { return } Self.log.error("OpenCode response summary: \(summary)") } private static func summarizeJSON(object: Any, depth: Int) -> String { - if depth > 3 { return "" } if let dict = object as? [String: Any] { - let keys = dict.keys.sorted() - var parts: [String] = [] - for key in keys { - let value = dict[key] - let type = self.valueTypeDescription(value, depth: depth + 1) - parts.append("\(key):\(type)") + guard depth <= 3 else { return "" } + let parts = dict.sorted { $0.key < $1.key }.map { key, value in + "\(key):\(self.summarizeJSON(object: value, depth: depth + 1))" } return "{\(parts.joined(separator: ", "))}" } if let array = object as? [Any] { + guard depth <= 3 else { return "" } guard let first = array.first else { return "[]" } - let type = self.valueTypeDescription(first, depth: depth + 1) - return "[\(type)]" + return "[\(self.summarizeJSON(object: first, depth: depth + 1))]" } - return self.scalarTypeDescription(object) - } - - private static func valueTypeDescription(_ value: Any?, depth: Int) -> String { - guard let value else { return "null" } - if let dict = value as? [String: Any] { - return self.summarizeJSON(object: dict, depth: depth) - } - if let array = value as? [Any] { - return self.summarizeJSON(object: array, depth: depth) - } - return self.scalarTypeDescription(value) - } - - private static func scalarTypeDescription(_ value: Any) -> String { - switch value { - case is String: "string" - case is Bool: "bool" - case is Int, is Double, is NSNumber: "number" - default: "value" + switch object { + case is String: return "string" + case is Bool: return "bool" + case is Int, is Double, is NSNumber: return "number" + default: return "value" } } } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift index 27e13e1347..67fc80e3a9 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift @@ -9,14 +9,22 @@ public struct OpenCodeUsageSnapshot: Sendable { case last30Days } - public let usageUSD: Double - public let limitUSD: Double? + public let monthlyUsageUSD: Double + public let monthlyLimitUSD: Double? public let balanceUSD: Double? public let period: Period - public init(usageUSD: Double, limitUSD: Double?, balanceUSD: Double?, period: Period = .monthly) { - self.usageUSD = usageUSD - self.limitUSD = limitUSD + public init(monthlyUsageUSD: Double, monthlyLimitUSD: Double?, balanceUSD: Double?) { + self.init( + monthlyUsageUSD: monthlyUsageUSD, + monthlyLimitUSD: monthlyLimitUSD, + balanceUSD: balanceUSD, + period: .monthly) + } + + public init(monthlyUsageUSD: Double, monthlyLimitUSD: Double?, balanceUSD: Double?, period: Period) { + self.monthlyUsageUSD = monthlyUsageUSD + self.monthlyLimitUSD = monthlyLimitUSD self.balanceUSD = balanceUSD self.period = period } @@ -24,40 +32,59 @@ public struct OpenCodeUsageSnapshot: Sendable { /// Percent of the configured monthly limit consumed. Rolling spend cannot be compared /// with a calendar-month limit, so it has no percentage even if a limit is supplied. public var usedPercent: Double? { - guard self.period == .monthly, let limit = self.limitUSD, limit > 0 else { return nil } - return min(100, max(0, (self.usageUSD / limit) * 100)) + guard self.period == .monthly, let limit = self.monthlyLimitUSD, limit > 0 else { return nil } + return min(100, max(0, (self.monthlyUsageUSD / limit) * 100)) } } public let hasWeeklyUsage: Bool public let rollingUsagePercent: Double public let weeklyUsagePercent: Double - public let rollingResetInSec: Int? - public let weeklyResetInSec: Int? + /// Keep the existing integer API while preserving unknown Console resets when rendering windows. + public var rollingResetInSec: Int { + self.rollingReset ?? 0 + } + + public var weeklyResetInSec: Int { + self.weeklyReset ?? 0 + } + + private let rollingReset: Int? + private let weeklyReset: Int? public let renewsAt: Date? public let payAsYouGo: PayAsYouGoUsage? public let updatedAt: Date public init( - hasWeeklyUsage: Bool = true, rollingUsagePercent: Double, weeklyUsagePercent: Double, - rollingResetInSec: Int?, - weeklyResetInSec: Int?, + rollingResetInSec: Int, + weeklyResetInSec: Int, renewsAt: Date? = nil, payAsYouGo: PayAsYouGoUsage? = nil, updatedAt: Date) { - self.hasWeeklyUsage = hasWeeklyUsage + self.hasWeeklyUsage = true self.rollingUsagePercent = rollingUsagePercent self.weeklyUsagePercent = weeklyUsagePercent - self.rollingResetInSec = rollingResetInSec - self.weeklyResetInSec = weeklyResetInSec + self.rollingReset = rollingResetInSec + self.weeklyReset = weeklyResetInSec self.renewsAt = renewsAt self.payAsYouGo = payAsYouGo self.updatedAt = updatedAt } + init(quota: OpenCodeGoUsageSnapshot) { + self.hasWeeklyUsage = quota.hasWeeklyUsage + self.rollingUsagePercent = quota.rollingUsagePercent + self.weeklyUsagePercent = quota.weeklyUsagePercent + self.rollingReset = quota.rollingResetInSec + self.weeklyReset = quota.weeklyResetInSec + self.renewsAt = quota.renewsAt + self.payAsYouGo = nil + self.updatedAt = quota.updatedAt + } + public static func payAsYouGo( _ usage: PayAsYouGoUsage, updatedAt: Date) -> OpenCodeUsageSnapshot @@ -76,7 +103,7 @@ public struct OpenCodeUsageSnapshot: Sendable { return self.payAsYouGoUsageSnapshot(payAsYouGo) } - let rollingReset = self.rollingResetInSec.map { self.updatedAt.addingTimeInterval(TimeInterval($0)) } + let rollingReset = self.rollingReset.map { self.updatedAt.addingTimeInterval(TimeInterval($0)) } let primary = RateWindow( usedPercent: self.rollingUsagePercent, windowMinutes: 5 * 60, @@ -84,7 +111,7 @@ public struct OpenCodeUsageSnapshot: Sendable { resetDescription: nil) let secondary: RateWindow? if self.hasWeeklyUsage { - let weeklyReset = self.weeklyResetInSec.map { self.updatedAt.addingTimeInterval(TimeInterval($0)) } + let weeklyReset = self.weeklyReset.map { self.updatedAt.addingTimeInterval(TimeInterval($0)) } secondary = RateWindow( usedPercent: self.weeklyUsagePercent, windowMinutes: 7 * 24 * 60, @@ -128,8 +155,8 @@ public struct OpenCodeUsageSnapshot: Sendable { resetDescription: nil) } let cost = ProviderCostSnapshot( - used: usage.usageUSD, - limit: usage.period == .monthly ? usage.limitUSD ?? 0 : 0, + used: usage.monthlyUsageUSD, + limit: usage.period == .monthly ? usage.monthlyLimitUSD ?? 0 : 0, currencyCode: "USD", period: usage.period == .monthly ? "Monthly" : "Last 30 days", balance: usage.balanceUSD, diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeZenBillingParser.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeZenBillingParser.swift index 982e82a661..737e167b0c 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeZenBillingParser.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeZenBillingParser.swift @@ -132,34 +132,13 @@ enum OpenCodeZenBillingParser { } private static func doubleValue(from value: Any?) -> Double? { - let number: Double? = switch value { - case is Bool: - nil - case let number as Double: - number - case let number as NSNumber: - number.doubleValue - case let string as String: - Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) - default: - nil - } - guard let number, number.isFinite else { return nil } - return number + guard !(value is Bool) else { return nil } + return OpenCodeWebParsing.doubleValue(from: value) } private static func dateValue(from value: Any?) -> Date? { - if let string = value as? String { - return ISO8601DateParser.parse(string) - } - if let number = self.doubleValue(from: value) { - if number > 1_000_000_000_000 { - return Date(timeIntervalSince1970: number / 1000) - } - if number > 1_000_000_000 { - return Date(timeIntervalSince1970: number) - } - } - return nil + if let string = value as? String { return ISO8601DateParser.parse(string) } + guard !(value is Bool) else { return nil } + return OpenCodeWebParsing.dateValue(from: value) } } diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index 94fbc59f64..55f0a5bc7d 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -319,7 +319,7 @@ extension OpenCodeGoUsageFetcher { timeout: TimeInterval, session: URLSession) async throws -> String { - try await OpenCodeGoLegacyFallback.fetch(cookieHeader: cookieHeader) { + try await OpenCodeLegacyFallback.fetch(cookieHeader: cookieHeader) { try await self.fetchConsoleWorkspaceID(cookieHeader: cookieHeader, timeout: timeout, session: session) } legacy: { try await self.fetchLegacyWorkspaceID(cookieHeader: cookieHeader, timeout: timeout, session: session) @@ -384,7 +384,7 @@ extension OpenCodeGoUsageFetcher { timeout: TimeInterval, session: URLSession) async throws -> String { - try await OpenCodeGoLegacyFallback.fetch(cookieHeader: cookieHeader) { + try await OpenCodeLegacyFallback.fetch(cookieHeader: cookieHeader) { try await self.fetchConsoleGoStatus( workspaceID: workspaceID, cookieHeader: cookieHeader, @@ -644,203 +644,40 @@ extension OpenCodeGoUsageFetcher { } private static func parseSubscriptionJSON(text: String, now: Date) -> OpenCodeGoUsageSnapshot? { - guard let data = text.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data, options: []), - let dict = object as? [String: Any] - else { - return nil - } - - // The console reports micro-cent meters, which the generic window parser cannot key on. - if let snapshot = self.parseConsoleGoStatus(text: text, now: now) { - return snapshot - } - - let renewsAt = OpenCodeWebParsing.dateValue(from: OpenCodeWebParsing.value( - from: dict, - keys: OpenCodeWebParsing.renewAtKeys)) - if let snapshot = self.parseUsageDictionary(dict, now: now, inheritedRenewsAt: renewsAt) { - return snapshot - } - for key in ["data", "result", "usage", "billing", "payload"] { - if let nested = dict[key] as? [String: Any], - let snapshot = self.parseUsageDictionary(nested, now: now, inheritedRenewsAt: renewsAt) - { - return snapshot - } - } - if let snapshot = self.parseUsageNested(dict, now: now, depth: 0, inheritedRenewsAt: renewsAt) { - return snapshot - } - return self.parseUsageFromCandidates(object: object, now: now, inheritedRenewsAt: renewsAt) - } - - private static func parseUsageDictionary( - _ dict: [String: Any], - now: Date, - inheritedRenewsAt: Date?) -> OpenCodeGoUsageSnapshot? - { - let renewsAt = OpenCodeWebParsing - .dateValue(from: OpenCodeWebParsing.value(from: dict, keys: OpenCodeWebParsing.renewAtKeys)) ?? - inheritedRenewsAt - if let usage = dict["usage"] as? [String: Any], - let snapshot = self.parseUsageDictionary(usage, now: now, inheritedRenewsAt: renewsAt) - { - return snapshot - } - - let rollingKeys = ["rollingUsage", "rolling", "rolling_usage", "rollingWindow", "rolling_window"] - let weeklyKeys = ["weeklyUsage", "weekly", "weekly_usage", "weeklyWindow", "weekly_window"] - let monthlyKeys = ["monthlyUsage", "monthly", "monthly_usage", "monthlyWindow", "monthly_window"] - - let rolling = self.firstDict(from: dict, keys: rollingKeys) - let weekly = self.firstDict(from: dict, keys: weeklyKeys) - let monthly = self.firstDict(from: dict, keys: monthlyKeys) - - guard let rolling else { return nil } - - return self.buildSnapshot(rolling: rolling, weekly: weekly, monthly: monthly, now: now, renewsAt: renewsAt) + self.parseConsoleGoStatus(text: text, now: now) + ?? OpenCodeSubscriptionParser(requiresWeeklyUsage: false).parseSubscriptionJSON(text: text, now: now) } - private static func parseUsageNested( - _ dict: [String: Any], - now: Date, - depth: Int, - inheritedRenewsAt: Date?) -> OpenCodeGoUsageSnapshot? - { - if depth > 3 { return nil } - let renewsAt = OpenCodeWebParsing - .dateValue(from: OpenCodeWebParsing.value(from: dict, keys: OpenCodeWebParsing.renewAtKeys)) ?? - inheritedRenewsAt - var rolling: [String: Any]? - var weekly: [String: Any]? - var monthly: [String: Any]? - - for (key, value) in dict { - guard let sub = value as? [String: Any] else { continue } - let lower = key.lowercased() - if lower.contains("rolling") || lower.contains("hour") || lower.contains("5h") || lower.contains("5-hour") { - rolling = sub - } else if lower.contains("weekly") || lower.contains("week") { - weekly = sub - } else if lower.contains("monthly") || lower.contains("month") { - monthly = sub - } - } - - if let rolling { - let snapshot = self.buildSnapshot( - rolling: rolling, - weekly: weekly, - monthly: monthly, - now: now, - renewsAt: renewsAt) - if let snapshot { return snapshot } - } - - for value in dict.values { - if let sub = value as? [String: Any], - let snapshot = self.parseUsageNested( - sub, - now: now, - depth: depth + 1, - inheritedRenewsAt: renewsAt) - { - return snapshot - } - } - - return nil - } - - private static func parseUsageFromCandidates( - object: Any, - now: Date, - inheritedRenewsAt: Date? = nil) -> OpenCodeGoUsageSnapshot? - { - let candidates = OpenCodeWebParsing.collectWindowCandidates(object: object) { self.parseWindow($0, now: now) } - guard !candidates.isEmpty else { return nil } - - let rollingCandidates = candidates.filter { candidate in - candidate.pathLower.contains("rolling") || - candidate.pathLower.contains("hour") || - candidate.pathLower.contains("5h") || - candidate.pathLower.contains("5-hour") - } - let weeklyCandidates = candidates.filter { candidate in - candidate.pathLower.contains("weekly") || - candidate.pathLower.contains("week") - } - let monthlyCandidates = candidates.filter { candidate in - candidate.pathLower.contains("monthly") || - candidate.pathLower.contains("month") - } - - let nonRollingIDs = Set((weeklyCandidates + monthlyCandidates).map(\.id)) - let rolling = OpenCodeWebParsing.pickCandidate( - preferred: rollingCandidates, - fallback: candidates.filter { !nonRollingIDs.contains($0.id) }, - pickShorter: true) - let weekly = OpenCodeWebParsing.pickCandidate( - from: weeklyCandidates.filter { candidate in - candidate.id != rolling?.id - }, - pickShorter: false) - let monthly = OpenCodeWebParsing.pickCandidate( - from: monthlyCandidates.filter { candidate in - candidate.id != rolling?.id && candidate.id != weekly?.id - }, - pickShorter: false) - - guard let rolling else { return nil } - - let renewsAt = OpenCodeWebParsing.dateValue(from: OpenCodeWebParsing.value( - from: object as? [String: Any] ?? [:], - keys: OpenCodeWebParsing.renewAtKeys)) - ?? inheritedRenewsAt - return OpenCodeGoUsageSnapshot( - hasWeeklyUsage: weekly != nil, - hasMonthlyUsage: monthly != nil, - rollingUsagePercent: rolling.percent, - weeklyUsagePercent: weekly?.percent ?? 0, - monthlyUsagePercent: monthly?.percent ?? 0, - rollingResetInSec: rolling.resetInSec, - weeklyResetInSec: weekly?.resetInSec ?? 0, - monthlyResetInSec: monthly?.resetInSec ?? 0, - renewsAt: renewsAt, - updatedAt: now) - } - - private static func firstDict(from dict: [String: Any], keys: [String]) -> [String: Any]? { - for key in keys { - if let value = dict[key] as? [String: Any] { - return value - } - } - return nil - } - - private enum DirectPercentEncoding { + enum DirectPercentEncoding { case percent case fractionOrPercent } - private static func buildSnapshot( + static func buildSnapshot( rolling: [String: Any], weekly: [String: Any]?, monthly: [String: Any]?, now: Date, renewsAt: Date? = nil, - directPercentEncoding: DirectPercentEncoding = .fractionOrPercent) -> OpenCodeGoUsageSnapshot? + directPercentEncoding: DirectPercentEncoding = .fractionOrPercent, + usesBaseFields: Bool = false) -> OpenCodeGoUsageSnapshot? { - guard let rollingWindow = self.parseWindow(rolling, now: now, directPercentEncoding: directPercentEncoding) + guard let rollingWindow = self.parseWindow( + rolling, + now: now, + directPercentEncoding: directPercentEncoding, + usesBaseFields: usesBaseFields) else { return nil } let weeklyWindow: (percent: Double, resetInSec: Int)? if let weekly { - guard let parsed = self.parseWindow(weekly, now: now, directPercentEncoding: directPercentEncoding) + guard let parsed = self.parseWindow( + weekly, + now: now, + directPercentEncoding: directPercentEncoding, + usesBaseFields: usesBaseFields) else { return nil } weeklyWindow = parsed } else { @@ -863,18 +700,21 @@ extension OpenCodeGoUsageFetcher { updatedAt: now) } - private static func parseWindow( + static func parseWindow( _ dict: [String: Any], now: Date, - directPercentEncoding: DirectPercentEncoding = .fractionOrPercent) -> (percent: Double, resetInSec: Int)? + directPercentEncoding: DirectPercentEncoding = .fractionOrPercent, + usesBaseFields: Bool = false) -> (percent: Double, resetInSec: Int)? { var percent = OpenCodeWebParsing.doubleValue(from: dict, keys: OpenCodeWebParsing.percentKeys) // Dashboard JSON may use fractions. API fields and computed used/limit percentages already use 0...100. let percentIsDirect = percent != nil if percent == nil { - let usedKeys = ["used", "usage", "consumed", "count", "usedTokens", "usedMicroCents"] - let limitKeys = ["limit", "total", "quota", "max", "cap", "tokenLimit", "limitMicroCents"] + let usedKeys = ["used", "usage", "consumed", "count", "usedTokens"] + + (usesBaseFields ? [] : ["usedMicroCents"]) + let limitKeys = ["limit", "total", "quota", "max", "cap", "tokenLimit"] + + (usesBaseFields ? [] : ["limitMicroCents"]) let used = OpenCodeWebParsing.doubleValue(from: dict, keys: usedKeys) let limit = OpenCodeWebParsing.doubleValue(from: dict, keys: limitKeys) if let used, let limit, limit > 0 { @@ -891,7 +731,10 @@ extension OpenCodeGoUsageFetcher { var resetInSec = OpenCodeWebParsing.intValue(from: dict, keys: OpenCodeWebParsing.resetInKeys) if resetInSec == nil { - for key in OpenCodeWebParsing.resetAtKeys { + let keys = usesBaseFields + ? Array(OpenCodeWebParsing.resetAtKeys.filter { dict[$0] != nil }.prefix(1)) + : OpenCodeWebParsing.resetAtKeys + for key in keys { if let resetAt = OpenCodeWebParsing.dateValue(from: dict[key]), let interval = OpenCodeWebParsing.resetInterval(from: resetAt, now: now) { @@ -934,28 +777,13 @@ extension OpenCodeGoUsageFetcher { let httpResponse = try await session.response(for: urlRequest) - guard httpResponse.statusCode == 200 else { - let bodyText = String(data: httpResponse.data, encoding: .utf8) ?? "" + if httpResponse.statusCode != 200 { let contentType = httpResponse.response.value(forHTTPHeaderField: "Content-Type") ?? "unknown" let dataLength = httpResponse.data.count Self.log.error( "OpenCode Go returned \(httpResponse.statusCode) (type=\(contentType) length=\(dataLength))") - if self.looksSignedOut(text: bodyText) { - throw OpenCodeGoUsageError.invalidCredentials - } - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { - throw OpenCodeGoUsageError.invalidCredentials - } - if let message = OpenCodeWebParsing.extractServerErrorMessage(from: bodyText) { - throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode): \(message)") - } - throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode)") } - - guard let text = String(data: httpResponse.data, encoding: .utf8) else { - throw OpenCodeGoUsageError.parseFailed("Response was not UTF-8.") - } - return text + return try self.legacyText(from: httpResponse) } static func fetchPageText( @@ -974,6 +802,10 @@ extension OpenCodeGoUsageFetcher { forHTTPHeaderField: "Accept") let httpResponse = try await session.response(for: request) + return try self.legacyText(from: httpResponse) + } + + private static func legacyText(from httpResponse: ProviderHTTPResponse) throws -> String { guard httpResponse.statusCode == 200 else { let bodyText = String(data: httpResponse.data, encoding: .utf8) ?? "" if self.looksSignedOut(text: bodyText) { diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift index 8a5c91b349..dcde898036 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift @@ -156,7 +156,7 @@ extension OpenCodeGoUsageFetcher { timeout: TimeInterval, session: URLSession) async throws -> Double? { - try await OpenCodeGoLegacyFallback.fetch( + try await OpenCodeLegacyFallback.fetch( cookieHeader: cookieHeader, isUsableLegacyValue: { $0 != nil }, console: { diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift index 68235e3b3f..2ab0f55a50 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift @@ -31,11 +31,11 @@ enum OpenCodeGoZenBalanceParser { #"(?i)(?:current\s+balance|zen\s+balance|現在の残高)"#, #"[^$]{0,80}\$\s*([0-9][0-9,]*(?:\.[0-9]+)?)"#, ].joined() - if let value = self.extractDollarValue(pattern: localizedPattern, text: text) { + if let value = self.extractNumber(pattern: localizedPattern, text: text) { return value } let nearbyPattern = #"(?i)(?:balance|残高)[\s\S]{0,120}?\$\s*([0-9][0-9,]*(?:\.[0-9]+)?)"# - return self.extractDollarValue(pattern: nearbyPattern, text: text) + return self.extractNumber(pattern: nearbyPattern, text: text) } static func parseBillingServerResponse(text: String) -> Double? { @@ -64,27 +64,26 @@ enum OpenCodeGoZenBalanceParser { else { return nil } - return self.findBalanceValue(in: object, path: []) + return self.findBalanceValue(in: object) } - private static func findBalanceValue(in object: Any, path: [String]) -> Double? { + private static func findBalanceValue(in object: Any) -> Double? { if let dict = object as? [String: Any] { for (key, value) in dict { - let nextPath = path + [key] if self.isExplicitBalanceAmountKey(key), let number = self.doubleValue(from: value) { return number } - if let found = self.findBalanceValue(in: value, path: nextPath) { + if let found = self.findBalanceValue(in: value) { return found } } return nil } if let array = object as? [Any] { - for (index, value) in array.enumerated() { - if let found = self.findBalanceValue(in: value, path: path + ["[\(index)]"]) { + for value in array { + if let found = self.findBalanceValue(in: value) { return found } } @@ -140,10 +139,6 @@ enum OpenCodeGoZenBalanceParser { ].contains(normalized) } - private static func extractDollarValue(pattern: String, text: String) -> Double? { - self.extractNumber(pattern: pattern, text: text) - } - private static func extractNumber(pattern: String, text: String) -> Double? { guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } let nsrange = NSRange(text.startIndex..( cookieHeader: String, + requiresConsoleCookie: Bool = false, isUsableLegacyValue: @Sendable (Value) -> Bool = { _ in true }, console: @Sendable () async throws -> Value, legacy: @Sendable () async throws -> Value) async throws -> Value { try Task.checkCancellation() + if requiresConsoleCookie, + !self.hasCookie(in: cookieHeader, names: OpenCodeWebCookieSupport.consoleSessionCookieNames) + { + return try await legacy() + } do { let value = try await console() try Task.checkCancellation() @@ -28,7 +34,7 @@ enum OpenCodeGoLegacyFallback { } catch { try self.checkCancellation(error) // Failed legacy reads cannot turn a Console access failure into invalid auth or absent Go usage. - if error is OpenCodeGoUsageError, + if error is OpenCodeGoUsageError || error is OpenCodeUsageError, !self.isInvalidCredentials(consoleError), self.hasCookie(in: cookieHeader, names: OpenCodeWebCookieSupport.consoleSessionCookieNames) { @@ -43,7 +49,7 @@ enum OpenCodeGoLegacyFallback { try self.checkCancellation(error) if case .noSubscription? = error as? OpenCodeGoUsageError { return false } guard self.hasCookie(in: cookieHeader, names: OpenCodeWebCookieSupport.sessionCookieNames) else { return false } - if error is OpenCodeGoUsageError { return true } + if error is OpenCodeGoUsageError || error is OpenCodeUsageError { return true } guard let error = error as? URLError else { return false } switch error.code { case .timedOut, .networkConnectionLost, .cannotConnectToHost, .cannotFindHost, @@ -63,6 +69,7 @@ enum OpenCodeGoLegacyFallback { private static func isInvalidCredentials(_ error: Error) -> Bool { if case .invalidCredentials? = error as? OpenCodeGoUsageError { return true } + if case .invalidCredentials? = error as? OpenCodeUsageError { return true } return false } diff --git a/Sources/CodexBarCore/Providers/Shared/OpenCodeSubscriptionParser.swift b/Sources/CodexBarCore/Providers/Shared/OpenCodeSubscriptionParser.swift new file mode 100644 index 0000000000..a47c224f01 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/OpenCodeSubscriptionParser.swift @@ -0,0 +1,193 @@ +import Foundation + +/// Legacy dashboard parsing shared by both OpenCode providers. The base provider requires +/// two windows and supports unnamed candidates; Go keeps weekly and monthly windows optional. +struct OpenCodeSubscriptionParser { + let requiresWeeklyUsage: Bool + + func parseSubscriptionJSON(text: String, now: Date) -> OpenCodeGoUsageSnapshot? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []) + else { + return nil + } + + guard let dict = object as? [String: Any] else { + return self.requiresWeeklyUsage ? self.parseUsageFromCandidates(object: object, now: now) : nil + } + + let renewsAt = OpenCodeWebParsing.dateValue(from: OpenCodeWebParsing.value( + from: dict, + keys: OpenCodeWebParsing.renewAtKeys)) + if let snapshot = self.parseUsageDictionary(dict, now: now, inheritedRenewsAt: renewsAt) { + return snapshot + } + for key in ["data", "result", "usage", "billing", "payload"] { + if let nested = dict[key] as? [String: Any], + let snapshot = self.parseUsageDictionary(nested, now: now, inheritedRenewsAt: renewsAt) + { + return snapshot + } + } + if let snapshot = self.parseUsageNested(dict, now: now, depth: 0, inheritedRenewsAt: renewsAt) { + return snapshot + } + return self.parseUsageFromCandidates(object: object, now: now, inheritedRenewsAt: renewsAt) + } + + private func parseUsageDictionary( + _ dict: [String: Any], + now: Date, + inheritedRenewsAt: Date?) -> OpenCodeGoUsageSnapshot? + { + let renewsAt = OpenCodeWebParsing + .dateValue(from: OpenCodeWebParsing.value(from: dict, keys: OpenCodeWebParsing.renewAtKeys)) ?? + inheritedRenewsAt + if let usage = dict["usage"] as? [String: Any], + let snapshot = self.parseUsageDictionary(usage, now: now, inheritedRenewsAt: renewsAt) + { + return snapshot + } + + let rollingKeys = ["rollingUsage", "rolling", "rolling_usage", "rollingWindow", "rolling_window"] + let weeklyKeys = ["weeklyUsage", "weekly", "weekly_usage", "weeklyWindow", "weekly_window"] + let monthlyKeys = ["monthlyUsage", "monthly", "monthly_usage", "monthlyWindow", "monthly_window"] + + let rolling = rollingKeys.lazy.compactMap { dict[$0] as? [String: Any] }.first + let weekly = weeklyKeys.lazy.compactMap { dict[$0] as? [String: Any] }.first + let monthly = self.requiresWeeklyUsage ? nil : monthlyKeys.lazy.compactMap { dict[$0] as? [String: Any] }.first + + guard let rolling, !self.requiresWeeklyUsage || weekly != nil else { return nil } + + return self.buildSnapshot(rolling: rolling, weekly: weekly, monthly: monthly, now: now, renewsAt: renewsAt) + } + + private func parseUsageNested( + _ dict: [String: Any], + now: Date, + depth: Int, + inheritedRenewsAt: Date?) -> OpenCodeGoUsageSnapshot? + { + if depth > 3 { return nil } + let renewsAt = OpenCodeWebParsing + .dateValue(from: OpenCodeWebParsing.value(from: dict, keys: OpenCodeWebParsing.renewAtKeys)) ?? + inheritedRenewsAt + var rolling: [String: Any]? + var weekly: [String: Any]? + var monthly: [String: Any]? + + for (key, value) in dict { + guard let sub = value as? [String: Any] else { continue } + let lower = key.lowercased() + if lower.contains("rolling") || lower.contains("hour") || lower.contains("5h") || lower.contains("5-hour") { + rolling = sub + } else if lower.contains("weekly") || lower.contains("week") { + weekly = sub + } else if !self.requiresWeeklyUsage, lower.contains("monthly") || lower.contains("month") { + monthly = sub + } + } + + if let rolling, !self.requiresWeeklyUsage || weekly != nil { + let snapshot = self.buildSnapshot( + rolling: rolling, + weekly: weekly, + monthly: monthly, + now: now, + renewsAt: renewsAt) + if let snapshot { return snapshot } + } + + for value in dict.values { + if let sub = value as? [String: Any], + let snapshot = self.parseUsageNested( + sub, + now: now, + depth: depth + 1, + inheritedRenewsAt: renewsAt) + { + return snapshot + } + } + + return nil + } + + private func parseUsageFromCandidates( + object: Any, + now: Date, + inheritedRenewsAt: Date? = nil) -> OpenCodeGoUsageSnapshot? + { + let candidates = OpenCodeWebParsing.collectWindowCandidates(object: object) { self.parseWindow($0, now: now) } + guard !candidates.isEmpty else { return nil } + + let rollingCandidates = candidates.filter { candidate in + candidate.pathLower.contains("rolling") || + candidate.pathLower.contains("hour") || + candidate.pathLower.contains("5h") || + candidate.pathLower.contains("5-hour") + } + let weeklyCandidates = candidates.filter { candidate in + candidate.pathLower.contains("weekly") || + candidate.pathLower.contains("week") + } + let monthlyCandidates = candidates.filter { candidate in + candidate.pathLower.contains("monthly") || + candidate.pathLower.contains("month") + } + + let nonRollingIDs = Set((weeklyCandidates + monthlyCandidates).map(\.id)) + let rolling = OpenCodeWebParsing.pickCandidate( + preferred: rollingCandidates, + fallback: self.requiresWeeklyUsage ? candidates : candidates.filter { !nonRollingIDs.contains($0.id) }, + pickShorter: true) + let weekly = OpenCodeWebParsing.pickCandidate( + preferred: weeklyCandidates, + fallback: self.requiresWeeklyUsage ? candidates : weeklyCandidates, + pickShorter: false, + excluding: rolling?.id) + let monthly = self.requiresWeeklyUsage ? nil : OpenCodeWebParsing.pickCandidate( + from: monthlyCandidates.filter { candidate in + candidate.id != rolling?.id && candidate.id != weekly?.id + }, + pickShorter: false) + + guard let rolling, !self.requiresWeeklyUsage || weekly != nil else { return nil } + + let renewsAt = OpenCodeWebParsing.dateValue(from: OpenCodeWebParsing.value( + from: object as? [String: Any] ?? [:], + keys: OpenCodeWebParsing.renewAtKeys)) + ?? inheritedRenewsAt + return OpenCodeGoUsageSnapshot( + hasWeeklyUsage: weekly != nil, + hasMonthlyUsage: monthly != nil, + rollingUsagePercent: rolling.percent, + weeklyUsagePercent: weekly?.percent ?? 0, + monthlyUsagePercent: monthly?.percent ?? 0, + rollingResetInSec: rolling.resetInSec, + weeklyResetInSec: weekly?.resetInSec ?? 0, + monthlyResetInSec: monthly?.resetInSec ?? 0, + renewsAt: renewsAt, + updatedAt: now) + } + + private func parseWindow(_ dict: [String: Any], now: Date) -> (percent: Double, resetInSec: Int)? { + OpenCodeGoUsageFetcher.parseWindow(dict, now: now, usesBaseFields: self.requiresWeeklyUsage) + } + + private func buildSnapshot( + rolling: [String: Any], + weekly: [String: Any]?, + monthly: [String: Any]?, + now: Date, + renewsAt: Date?) -> OpenCodeGoUsageSnapshot? + { + OpenCodeGoUsageFetcher.buildSnapshot( + rolling: rolling, + weekly: weekly, + monthly: monthly, + now: now, + renewsAt: renewsAt, + usesBaseFields: self.requiresWeeklyUsage) + } +} diff --git a/Tests/CodexBarTests/OpenCodeConsoleSnapshotTests.swift b/Tests/CodexBarTests/OpenCodeConsoleSnapshotTests.swift index 90f47490e9..bb2bbadd6b 100644 --- a/Tests/CodexBarTests/OpenCodeConsoleSnapshotTests.swift +++ b/Tests/CodexBarTests/OpenCodeConsoleSnapshotTests.swift @@ -5,14 +5,36 @@ import Testing struct OpenCodeConsoleSnapshotTests { private static let now = Date(timeIntervalSince1970: 1_700_000_000) + @Test + func `legacy public initializers and integer reset accessors remain source compatible`() { + let makeSpend: (Double, Double?, Double?) -> OpenCodeUsageSnapshot.PayAsYouGoUsage = + OpenCodeUsageSnapshot.PayAsYouGoUsage.init + let makeQuota: (Double, Double, Int, Int, Date?, OpenCodeUsageSnapshot.PayAsYouGoUsage?, Date) + -> OpenCodeUsageSnapshot = OpenCodeUsageSnapshot.init + let spend = makeSpend(3, 20, 4) + let quota = makeQuota(17, 75, 600, 7200, nil, nil, Self.now) + let rollingReset: Int = quota.rollingResetInSec + let weeklyReset: Int = quota.weeklyResetInSec + + #expect(spend.monthlyUsageUSD == 3) + #expect(spend.monthlyLimitUSD == 20) + #expect(spend.period == .monthly) + #expect(rollingReset == 600) + #expect(weeklyReset == 7200) + } + @Test func `unknown Console resets remain unknown`() { - let snapshot = OpenCodeUsageSnapshot( + let snapshot = OpenCodeUsageSnapshot(quota: .init( + hasWeeklyUsage: true, + hasMonthlyUsage: false, rollingUsagePercent: 17, weeklyUsagePercent: 75, + monthlyUsagePercent: 0, rollingResetInSec: nil, weeklyResetInSec: nil, - updatedAt: Self.now) + monthlyResetInSec: nil, + updatedAt: Self.now)) let usage = snapshot.toUsageSnapshot() @@ -24,13 +46,16 @@ struct OpenCodeConsoleSnapshotTests { @Test func `missing Console weekly quota does not appear as unused quota`() { - let snapshot = OpenCodeUsageSnapshot( + let snapshot = OpenCodeUsageSnapshot(quota: .init( hasWeeklyUsage: false, + hasMonthlyUsage: false, rollingUsagePercent: 17, weeklyUsagePercent: 0, + monthlyUsagePercent: 0, rollingResetInSec: 600, weeklyResetInSec: nil, - updatedAt: Self.now) + monthlyResetInSec: nil, + updatedAt: Self.now)) let usage = snapshot.toUsageSnapshot() @@ -59,7 +84,7 @@ struct OpenCodeConsoleSnapshotTests { @Test func `Console spend keeps its rolling thirty day period`() { let snapshot = OpenCodeUsageSnapshot.payAsYouGo( - .init(usageUSD: 3.25, limitUSD: nil, balanceUSD: 12.5, period: .last30Days), + .init(monthlyUsageUSD: 3.25, monthlyLimitUSD: nil, balanceUSD: 12.5, period: .last30Days), updatedAt: Self.now) let usage = snapshot.toUsageSnapshot() @@ -76,8 +101,8 @@ struct OpenCodeConsoleSnapshotTests { @Test func `rolling spend cannot consume a monthly quota`() { let payAsYouGo = OpenCodeUsageSnapshot.PayAsYouGoUsage( - usageUSD: 15, - limitUSD: 20, + monthlyUsageUSD: 15, + monthlyLimitUSD: 20, balanceUSD: nil, period: .last30Days) let usage = OpenCodeUsageSnapshot.payAsYouGo(payAsYouGo, updatedAt: Self.now).toUsageSnapshot() diff --git a/Tests/CodexBarTests/OpenCodeMenuCardCostTests.swift b/Tests/CodexBarTests/OpenCodeMenuCardCostTests.swift index d9d4d0d585..8696eee076 100644 --- a/Tests/CodexBarTests/OpenCodeMenuCardCostTests.swift +++ b/Tests/CodexBarTests/OpenCodeMenuCardCostTests.swift @@ -34,7 +34,7 @@ struct OpenCodeMenuCardCostTests { func `pay as you go card shows monthly spend against the limit`() throws { let now = Date() let model = try self.makeModel( - .init(usageUSD: 15, limitUSD: 20, balanceUSD: 12.5), + .init(monthlyUsageUSD: 15, monthlyLimitUSD: 20, balanceUSD: 12.5), now: now) #expect(model.providerCost?.spendLine == "Monthly: $15.00 / $20.00") @@ -45,7 +45,7 @@ struct OpenCodeMenuCardCostTests { func `pay as you go card without a limit still shows spend and balance`() throws { let now = Date() let model = try self.makeModel( - .init(usageUSD: 15, limitUSD: nil, balanceUSD: 12.5), + .init(monthlyUsageUSD: 15, monthlyLimitUSD: nil, balanceUSD: 12.5), now: now) let cost = try #require(model.providerCost) @@ -60,7 +60,7 @@ struct OpenCodeMenuCardCostTests { func `pay as you go card without a limit or balance still shows spend`() throws { let now = Date() let model = try self.makeModel( - .init(usageUSD: 15, limitUSD: nil, balanceUSD: nil), + .init(monthlyUsageUSD: 15, monthlyLimitUSD: nil, balanceUSD: nil), now: now) let cost = try #require(model.providerCost) @@ -71,7 +71,7 @@ struct OpenCodeMenuCardCostTests { @Test func `Console pay as you go card labels rolling spend without a monthly quota`() throws { let model = try self.makeModel( - .init(usageUSD: 15, limitUSD: nil, balanceUSD: 12.5, period: .last30Days), + .init(monthlyUsageUSD: 15, monthlyLimitUSD: nil, balanceUSD: 12.5, period: .last30Days), now: Date()) let cost = try #require(model.providerCost) diff --git a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift index 5540253890..fec35afc03 100644 --- a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift @@ -4,6 +4,56 @@ import Testing @Suite(.serialized) struct OpenCodeUsageFetcherErrorTests { + @Test(arguments: [false, true]) + func `legacy billing cancellation is not replaced with the subscription error`(cancelledURL: Bool) async { + let transport = ProviderHTTPTransportHandler { request in + if request.value(forHTTPHeaderField: "X-Server-Id") == + "c83b78a614689c38ebee981f9b39a8b377716db85c1fd7dbab604adc02d3313d" + { + if cancelledURL { throw URLError(.cancelled) } + throw CancellationError() + } + let url = try #require(request.url) + let (response, data) = Self.makeResponse( + url: url, body: "null", statusCode: 200, contentType: "application/json") + return (data, response) + } + await #expect(throws: CancellationError.self) { + try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "auth=synthetic", timeout: 2, workspaceIDOverride: "wrk_TEST123", session: transport) + } + } + + @Test + func `migrated workspace reads Console instead of the signed out legacy endpoint`() async throws { + defer { OpenCodeStubURLProtocol.handler = nil } + let now = Date(timeIntervalSince1970: 1_790_640_000) + OpenCodeStubURLProtocol.handler = { request in + let url = try #require(request.url) + let body: String + switch url.path { + case "/console/api/orgs": + body = #"[{"id":"wrk_MIGRATED","name":"Synthetic"}]"# + case "/console/api/go/status": + #expect(request.value(forHTTPHeaderField: "x-org-id") == "wrk_MIGRATED") + body = """ + {"access":{"meters":{ + "fiveHour":{"usedMicroCents":"89062297","limitMicroCents":"1200000000", + "resetsAt":"2026-09-30T01:31:21Z"}, + "week":{"usedMicroCents":"703325271","limitMicroCents":"3000000000", + "resetsAt":"2026-10-05T00:00:00Z"}}}} + """ + default: + body = #"new Error('actor of type "public" is not associated with an account')"# + } + return Self.makeResponse(url: url, body: body, statusCode: 200, contentType: "application/json") + } + let snapshot = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "__Host-console_session=synthetic", timeout: 2, now: now, session: self.makeSession()) + #expect(abs(snapshot.rollingUsagePercent - 7.421858083333333) < 0.000001) + #expect(abs(snapshot.weeklyUsagePercent - 23.4441757) < 0.000001) + } + private func makeSession() -> URLSession { let config = URLSessionConfiguration.ephemeral config.protocolClasses = [OpenCodeStubURLProtocol.self] @@ -153,8 +203,8 @@ struct OpenCodeUsageFetcherErrorTests { session: self.makeSession()) let payAsYouGo = try #require(snapshot.payAsYouGo) - #expect(payAsYouGo.usageUSD == 15) - #expect(payAsYouGo.limitUSD == 20) + #expect(payAsYouGo.monthlyUsageUSD == 15) + #expect(payAsYouGo.monthlyLimitUSD == 20) #expect(payAsYouGo.balanceUSD == 12.5) #expect(payAsYouGo.usedPercent == 75) #expect(methods == ["GET", "GET"]) diff --git a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift index 32a2e26278..94d567dd16 100644 --- a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift @@ -3,6 +3,27 @@ import Testing @testable import CodexBarCore struct OpenCodeUsageParserTests { + @Test + func `legacy base parser still requires two windows while Go permits rolling only`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let text = #"{"rollingUsage":{"usagePercent":17,"resetInSec":600}}"# + #expect(throws: OpenCodeUsageError.self) { + try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + } + let go = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + #expect(go.rollingUsagePercent == 17) + #expect(!go.hasWeeklyUsage) + } + + @Test + func `legacy base candidate arrays retain both unnamed windows`() throws { + let text = #"[{"percent":17,"resetInSec":600},{"percent":75,"resetInSec":7200}]"# + let snapshot = try OpenCodeUsageFetcher.parseSubscription( + text: text, now: Date(timeIntervalSince1970: 1_700_000_000)) + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.weeklyUsagePercent == 75) + } + @Test func `parses workspace I ds`() { let text = ";0x00000089;((self.$R=self.$R||{})[\"codexbar\"]=[]," + @@ -281,8 +302,8 @@ struct OpenCodeUsageParserTests { let now = Date(timeIntervalSince1970: 1_700_000_000) let snapshot = OpenCodeUsageSnapshot.payAsYouGo( OpenCodeUsageSnapshot.PayAsYouGoUsage( - usageUSD: 15, - limitUSD: 20, + monthlyUsageUSD: 15, + monthlyLimitUSD: 20, balanceUSD: 12.5), updatedAt: now) @@ -303,8 +324,8 @@ struct OpenCodeUsageParserTests { let now = Date(timeIntervalSince1970: 1_700_000_000) let snapshot = OpenCodeUsageSnapshot.payAsYouGo( OpenCodeUsageSnapshot.PayAsYouGoUsage( - usageUSD: 3, - limitUSD: nil, + monthlyUsageUSD: 3, + monthlyLimitUSD: nil, balanceUSD: 1), updatedAt: now) @@ -319,8 +340,8 @@ struct OpenCodeUsageParserTests { @Test func `pay as you go spend above the monthly limit clamps to 100 percent`() { let usage = OpenCodeUsageSnapshot.PayAsYouGoUsage( - usageUSD: 25, - limitUSD: 20, + monthlyUsageUSD: 25, + monthlyLimitUSD: 20, balanceUSD: 0) #expect(usage.usedPercent == 100) diff --git a/TestsLinux/OpenCodeConsoleUsageFetcherTests.swift b/TestsLinux/OpenCodeConsoleUsageFetcherTests.swift index c27177586d..3b5571f74d 100644 --- a/TestsLinux/OpenCodeConsoleUsageFetcherTests.swift +++ b/TestsLinux/OpenCodeConsoleUsageFetcherTests.swift @@ -58,9 +58,9 @@ struct OpenCodeConsoleUsageFetcherTests { let snapshot = try await Self.fetch(transport: transport) let payAsYouGo = try #require(snapshot.payAsYouGo) - #expect(payAsYouGo.usageUSD == 3.25) + #expect(payAsYouGo.monthlyUsageUSD == 3.25) #expect(payAsYouGo.balanceUSD == 12.5) - #expect(payAsYouGo.limitUSD == nil) + #expect(payAsYouGo.monthlyLimitUSD == nil) #expect(payAsYouGo.period == .last30Days) #expect(payAsYouGo.usedPercent == nil) #expect(snapshot.toUsageSnapshot().providerCost?.period == "Last 30 days") @@ -84,7 +84,6 @@ struct OpenCodeConsoleUsageFetcherTests { let snapshot = try await Self.fetch(transport: transport) #expect(snapshot.rollingUsagePercent == 25) - #expect(snapshot.rollingResetInSec == nil) #expect(snapshot.hasWeeklyUsage == false) #expect(snapshot.toUsageSnapshot().primary?.resetsAt == nil) #expect(snapshot.toUsageSnapshot().secondary == nil) @@ -121,9 +120,9 @@ struct OpenCodeConsoleUsageFetcherTests { let snapshot = try await Self.fetch(transport: transport) - #expect(snapshot.payAsYouGo?.usageUSD == 3.25) + #expect(snapshot.payAsYouGo?.monthlyUsageUSD == 3.25) #expect(snapshot.payAsYouGo?.balanceUSD == nil) - #expect(snapshot.payAsYouGo?.limitUSD == nil) + #expect(snapshot.payAsYouGo?.monthlyLimitUSD == nil) #expect(snapshot.toUsageSnapshot().providerCost?.used == 3.25) } From 94984614a6ea0076b0850c6f019020f160d8f01d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 17:56:58 -0700 Subject: [PATCH 101/122] fix(claude): unify safe PTY prompt and quota handling Handle startup and capture prompts through the same rendered-frame loop. Limit workspace trust, probe preparation, and transcript cleanup to the owned directory, and reject redirected paths before launching a usage probe. Wait for actual session quota instead of treating usage-insights percentages as a completed panel. Preserve the contributor's captured trust-dialog fixtures and cover legacy prompts, redirected paths, transcript ownership, and quota readiness with synthetic PTY regressions. Refs #4115 Refs #4083 Co-authored-by: sudoHG --- CHANGELOG.md | 2 +- .../Providers/Claude/ClaudeCLISession.swift | 277 ++++++------------ .../ClaudeProbeSessionArtifactCleaner.swift | 1 + .../Providers/Claude/ClaudeStatusProbe.swift | 26 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 2 +- .../ClaudeCLIWorkspaceTrustTests.swift | 77 ++++- .../ClaudeProbeWorkingDirectoryTests.swift | 35 ++- Tests/CodexBarTests/TTYIntegrationTests.swift | 49 ++++ docs/claude.md | 12 +- 9 files changed, 266 insertions(+), 215 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9102a48f76..a9720ecc83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Claude: answer Claude Code's workspace trust dialog in the CLI probe's dedicated directory by selecting "Yes, I trust this folder". Pressing Enter on the preselected "No, exit" made every PTY probe exit before `/usage` ran and fall back to non-interactive `/usage`. Outside that directory, including the temporary-directory fallback, the probe cancels the dialog instead, and a PTY session that exits mid-capture now logs its exit status (#4115, related to #4083). Thanks @sudoHG! +- Claude: answer current and legacy CLI trust dialogs only in the isolated probe directory, reject redirected paths, and wait for real quota values when usage insights are visible (#4115, #4083). Thanks @sudoHG! ## 0.70.0 — 2026-09-29 diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index 941afd5f23..996dcc0b52 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -83,39 +83,19 @@ actor ClaudeCLISession { self.workingDirectory = workingDirectory } - /// The workspace trust dialog ("Quick safety check: ...") is answered in `waitForStartup()`, and only in CodexBar's - /// dedicated probe directory: Claude Code 2.1.282 preselects "No, exit", so a bare Enter would quit. - private let promptSends: [String: String] = [ - "Do you trust the files in this folder?": "y\r", - "Ready to code here?": "\r", - "Press Enter to continue": "\r", - ] - - private static let startupDelay: TimeInterval = 2.0 - private static let workspaceTrustOption = "Yes, I trust this folder" - private static let maxWorkspaceTrustKeys = 4 + /// Workspace permission prompts use the directory-constrained handler below, never a generic Enter response. + private let continuePrompts = ["Press Enter to continue"] private static func normalizedNeedle(_ text: String) -> String { String(text.lowercased().filter { !$0.isWhitespace }) } - private static func commandPaletteSends(for subcommand: String) -> [String: String] { - let normalized = subcommand.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - switch normalized { - case "/usage": - // Claude's command palette can render several "Show ..." actions together; only auto-confirm the - // usage-related actions here so we do not accidentally execute /status. - return [ - "Show plan": "\r", - "Show plan usage limits": "\r", - ] - case "/status": - return [ - "Show Claude Code": "\r", - "Show Claude Code status": "\r", - ] - default: - return [:] + private static func commandPalettePrompt(for subcommand: String) -> String? { + // Claude can render several "Show ..." actions together; confirm only the requested panel's action. + switch subcommand.lowercased() { + case "/usage": "Show plan" + case "/status": "Show Claude Code" + default: nil } } @@ -166,30 +146,15 @@ actor ClaudeCLISession { try self.send("\u{1b}") try await Task.sleep(nanoseconds: 150_000_000) } - try await self.waitForStartup() - _ = self.readChunk() - + var readyAt = (self.startedAt ?? Date()).addingTimeInterval(2) + var commandSent = false let trimmed = request.subcommand.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - try self.send(trimmed) - try self.send("\r") - } - - let stopNeedles = request.stopOnSubstrings.map { Self.normalizedNeedle($0) } - var sendMap = self.promptSends - for (needle, keys) in Self.commandPaletteSends(for: trimmed) { - sendMap[needle] = keys - } - let sendNeedles = sendMap.map { (needle: Self.normalizedNeedle($0.key), keys: $0.value) } - let cursorQuery = Data([0x1B, 0x5B, 0x36, 0x6E]) - let needleLengths = - request.stopOnSubstrings.map(\.utf8.count) + - sendMap.keys.map(\.utf8.count) + - [cursorQuery.count] - let maxNeedle = needleLengths.max() ?? cursorQuery.count - var scanBuffer = StreamScanBuffer(maxNeedle: maxNeedle) + let stopNeedles = request.stopOnSubstrings.map(Self.normalizedNeedle) + let sendNeedles = (self.continuePrompts + [Self.commandPalettePrompt(for: trimmed)].compactMap(\.self)) + .map(Self.normalizedNeedle) var triggeredSends = Set() - + let cursorQuery = Data([0x1B, 0x5B, 0x36, 0x6E]) + var scanBuffer = StreamScanBuffer(maxNeedle: cursorQuery.count) var buffer = BoundedOutputBuffer() func appendOutput(_ data: Data) throws { guard buffer.append(data) else { @@ -197,172 +162,105 @@ actor ClaudeCLISession { throw SessionError.outputTooLarge } } - var scanTailText = "" - var normalizedScan = "" - var utf8Carry = Data() - let deadline = Date().addingTimeInterval(request.timeout) + var deadline = max(Date(), readyAt).addingTimeInterval(request.timeout) var lastOutputAt = Date() var lastEnterAt = Date() + var uncheckedFrame = false + var trustPending = false + var trustKeysLeft = 4 var stoppedEarly = false - // Only send periodic Enter when the caller explicitly asks for it (used for /usage rendering). - // For /status, periodic input can keep producing output and prevent idle-timeout short-circuiting. while Date() < deadline { let newData = self.readChunk() if !newData.isEmpty { try appendOutput(newData) lastOutputAt = Date() - Self.appendScanText(newData: newData, scanTailText: &scanTailText, utf8Carry: &utf8Carry) - if scanTailText.count > 8192 { - scanTailText = String(scanTailText.suffix(8192)) - } - normalizedScan = Self.normalizedNeedle(TextParsing.stripANSICodes(scanTailText)) - - let scanData = scanBuffer.append(newData) - if scanData.range(of: cursorQuery) != nil { + uncheckedFrame = true + if scanBuffer.append(newData).range(of: cursorQuery) != nil { try? self.send("\u{1b}[1;1R") } - - for item in sendNeedles where !triggeredSends.contains(item.needle) { - if normalizedScan.contains(item.needle) { - try? self.send(item.keys) - triggeredSends.insert(item.needle) + } + // Startup and command captures share one prompt path. Decode the retained bytes together so split UTF-8 + // and cursor redraws cannot turn the default No selection into an accidental confirmation. + if uncheckedFrame, Date().timeIntervalSince(lastOutputAt) >= 0.2 || self.process?.isRunning == false, + let text = String(data: buffer.data, encoding: .utf8) + { + uncheckedFrame = false + let screen = ClaudeCLIScreen.render(text) + let normalized = Self.normalizedNeedle(screen) + let trustKeys = Self.workspaceTrustKeys(onScreen: screen, acceptsTrust: self.launchedInProbeDirectory) + trustPending = trustKeys != nil || normalized.contains("quicksafetycheck:") || + normalized.contains("doyoutrustthefilesinthisfolder?") || + normalized.contains("readytocodehere?") + if trustPending { + if self.process?.isRunning == true, trustKeysLeft > 0, let trustKeys { + try self.send(trustKeys) + trustKeysLeft = trustKeys == "\u{1b}" ? 0 : trustKeysLeft - 1 + readyAt = Date().addingTimeInterval(2) + } + } else { + if commandSent, stopNeedles.contains(where: normalized.contains) + || (request.stopWhenNormalized?(normalized) == true) + { + stoppedEarly = true + break + } + for needle in sendNeedles where !triggeredSends.contains(needle) { + if self.process?.isRunning == true, normalized.contains(needle) { + try? self.send("\r") + triggeredSends.insert(needle) + if !commandSent { readyAt = Date().addingTimeInterval(2) } + } } } - - if stopNeedles - .contains(where: normalizedScan.contains) || (request.stopWhenNormalized?(normalizedScan) == true) + } + if !commandSent, !trustPending, !uncheckedFrame, Date() >= readyAt { + buffer.removeAll(keepingCapacity: true) + if !trimmed.isEmpty { try self.send(trimmed + "\r") } + commandSent = true + lastOutputAt = Date() + lastEnterAt = Date() + deadline = Date().addingTimeInterval(request.timeout) + } + if commandSent, !trustPending, !uncheckedFrame { + if request.idleTimeout.map({ !buffer.isEmpty && Date().timeIntervalSince(lastOutputAt) >= $0 }) == true { stoppedEarly = true break } + self.sendPeriodicEnterIfNeeded(every: request.sendEnterEvery, lastEnterAt: &lastEnterAt) } - - if self.shouldStopForIdleTimeout( - idleTimeout: request.idleTimeout, - bufferIsEmpty: buffer.isEmpty, - lastOutputAt: lastOutputAt) - { - stoppedEarly = true - break - } - - self.sendPeriodicEnterIfNeeded(every: request.sendEnterEvery, lastEnterAt: &lastEnterAt) - if let proc = self.process, !proc.isRunning { Self.log.warning( "Claude CLI session exited during capture", metadata: ["status": "\(proc.terminationStatus)"]) throw SessionError.processExited } - try await Task.sleep(nanoseconds: 60_000_000) } - if stoppedEarly { - let settle = max(0, min(request.settleAfterStop, deadline.timeIntervalSinceNow)) - if settle > 0 { - let settleDeadline = Date().addingTimeInterval(settle) - while Date() < settleDeadline { - let newData = self.readChunk() - if !newData.isEmpty { - try appendOutput(newData) - } - try await Task.sleep(nanoseconds: 50_000_000) - } + let settleDeadline = min(deadline, Date().addingTimeInterval(max(0, request.settleAfterStop))) + while Date() < settleDeadline { + try appendOutput(self.readChunk()) + try await Task.sleep(nanoseconds: 50_000_000) } } - - guard !buffer.data.isEmpty, let text = String(data: buffer.data, encoding: .utf8) else { - throw SessionError.timedOut - } + guard commandSent, !trustPending, !buffer.isEmpty, + let text = String(data: buffer.data, encoding: .utf8) else { throw SessionError.timedOut } return text } - private static func appendScanText(newData: Data, scanTailText: inout String, utf8Carry: inout Data) { - // PTY reads can split multibyte UTF-8 sequences. Keep a small carry buffer so prompt/stop scanning doesn't - // drop chunks when the decode fails due to an incomplete trailing sequence. - var combined = Data() - combined.reserveCapacity(utf8Carry.count + newData.count) - combined.append(utf8Carry) - combined.append(newData) - - if let chunk = String(data: combined, encoding: .utf8) { - scanTailText.append(chunk) - utf8Carry.removeAll(keepingCapacity: true) - return - } - - for trimCount in 1...3 where combined.count > trimCount { - let prefix = combined.dropLast(trimCount) - if let chunk = String(data: prefix, encoding: .utf8) { - scanTailText.append(chunk) - utf8Carry = Data(combined.suffix(trimCount)) - return - } - } - - // If the data is still not UTF-8 decodable, keep only a small suffix to avoid unbounded growth. - utf8Carry = Data(combined.suffix(12)) - } - - /// Claude's TUI can drop early keystrokes while it's still initializing. Wait a bit longer than the original 0.4s - /// to ensure slash commands reliably open their panels. A fresh launch in an untrusted folder shows the workspace - /// trust dialog in this window. CodexBar only trusts its dedicated probe directory: there it selects the trust - /// option explicitly and gives the main screen a fresh startup window; anywhere else it cancels the dialog. - private func waitForStartup() async throws { - guard let startedAt else { return } - var readyAt = startedAt.addingTimeInterval(Self.startupDelay) - var screenText = "" - var utf8Carry = Data() - var lastOutputAt = Date.distantPast - var hasUncheckedFrame = false - var trustKeysLeft = Self.maxWorkspaceTrustKeys - while Date() < readyAt { - let newData = self.readChunk() - if !newData.isEmpty { - Self.appendScanText(newData: newData, scanTailText: &screenText, utf8Carry: &utf8Carry) - if screenText.count > 8192 { - screenText = String(screenText.suffix(8192)) - } - lastOutputAt = Date() - hasUncheckedFrame = true - } - - // Check each settled frame once, so no key is sent again before Claude redraws the selection. - if hasUncheckedFrame, trustKeysLeft > 0, Date().timeIntervalSince(lastOutputAt) >= 0.2 { - hasUncheckedFrame = false - let screen = ClaudeCLIScreen.render(screenText) - if let keys = Self.workspaceTrustKeys(onScreen: screen, acceptsTrust: self.launchedInProbeDirectory) { - try self.send(keys) - trustKeysLeft -= 1 - switch keys { - case "\r": - trustKeysLeft = 0 - readyAt = Date().addingTimeInterval(Self.startupDelay) - Self.log.info("Claude CLI workspace trust accepted for the probe directory") - case "\u{1b}": - trustKeysLeft = 0 - readyAt = max(readyAt, Date().addingTimeInterval(1.0)) - Self.log.warning("Claude CLI workspace trust declined outside the probe directory") - default: - readyAt = max(readyAt, Date().addingTimeInterval(1.0)) - } - } - } - - if let proc = self.process, !proc.isRunning { return } - try await Task.sleep(nanoseconds: 60_000_000) - } - } - /// Returns the key for the workspace trust dialog on screen, or nil when it is not shown. Without `acceptsTrust`, /// Escape cancels the dialog whichever option is selected. With it, an arrow moves the `❯` marker toward "Yes, I /// trust this folder" and Enter follows once the marker is on it; nil while no marker is found, so Enter can never /// confirm a different option. static func workspaceTrustKeys(onScreen screen: String, acceptsTrust: Bool) -> String? { + let normalized = Self.normalizedNeedle(screen) let lines = screen.components(separatedBy: "\n") - let option = Self.normalizedNeedle(Self.workspaceTrustOption) + let option = "yes,itrustthisfolder" guard let optionRow = lines.firstIndex(where: { Self.normalizedNeedle($0).contains(option) }) else { + if normalized.contains("quicksafetycheck:") { return acceptsTrust ? nil : "\u{1b}" } + if normalized.contains("doyoutrustthefilesinthisfolder?") { return acceptsTrust ? "y\r" : "\u{1b}" } + if normalized.contains("readytocodehere?") { return acceptsTrust ? "\r" : "\u{1b}" } return nil } guard acceptsTrust else { return "\u{1b}" } @@ -399,6 +297,7 @@ actor ClaudeCLISession { return true } self.cleanup() + let workingDirectory = try self.workingDirectory ?? Self.isolatedProbeWorkingDirectoryURL() var primaryFD: Int32 = -1 var secondaryFD: Int32 = -1 @@ -418,7 +317,6 @@ actor ClaudeCLISession { let proc = Process() let resolvedURL = URL(fileURLWithPath: request.binary) - let workingDirectory = self.workingDirectory ?? ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() // A crashed probe can leave a JSONL behind. Claude treats `--session-id` as creation-only when that local // transcript exists, so clear the probe-owned artifact before reusing the account-side identifier. ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( @@ -495,6 +393,14 @@ actor ClaudeCLISession { return false } + static func isolatedProbeWorkingDirectoryURL() throws -> URL { + let directory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() + guard ClaudeStatusProbe.isDedicatedProbeWorkingDirectory(directory) else { + throw SessionError.ioFailed("Cannot prepare Claude's isolated probe directory.") + } + return directory + } + /// Opt usage probes out of Remote Control without changing saved settings or managed policy. static let probeSettingsArguments = ["--settings", #"{"remoteControlAtStartup":false}"#] @@ -632,15 +538,6 @@ actor ClaudeCLISession { return appended } - private func shouldStopForIdleTimeout( - idleTimeout: TimeInterval?, - bufferIsEmpty: Bool, - lastOutputAt: Date) -> Bool - { - guard let idleTimeout, !bufferIsEmpty else { return false } - return Date().timeIntervalSince(lastOutputAt) >= idleTimeout - } - private func sendPeriodicEnterIfNeeded(every: TimeInterval?, lastEnterAt: inout Date) { guard let every, Date().timeIntervalSince(lastEnterAt) >= every else { return } try? self.send("\r") @@ -648,9 +545,7 @@ actor ClaudeCLISession { } private func send(_ text: String) throws { - guard let data = text.data(using: .utf8) else { return } - guard self.primaryFD >= 0 else { throw SessionError.processExited } - try self.writeAllToPrimary(data) + try self.writeAllToPrimary(Data(text.utf8)) } private func writeAllToPrimary(_ data: Data) throws { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift index bcccdc5aa2..961eccc39e 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift @@ -10,6 +10,7 @@ enum ClaudeProbeSessionArtifactCleaner { environment: [String: String] = ProcessInfo.processInfo.environment, fileManager fm: FileManager = .default) -> [URL] { + guard ClaudeStatusProbe.isDedicatedProbeWorkingDirectory(probeDirectory) else { return [] } let projectDirectoryName = self.claudeProjectDirectoryName(for: probeDirectory) let profileRoot = ClaudeConfigPaths.configRoot( environment: environment, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index 5693133167..60ccaa866d 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -1401,6 +1401,7 @@ extension ClaudeStatusProbe { static func probeWorkingDirectoryURL() -> URL { let fm = FileManager.default let dir = self.dedicatedProbeWorkingDirectoryURL() + guard self.isDedicatedProbeWorkingDirectory(dir) else { return fm.temporaryDirectory } do { try fm.createDirectory(at: dir, withIntermediateDirectories: true) return dir @@ -1425,11 +1426,18 @@ extension ClaudeStatusProbe { } static func isDedicatedProbeWorkingDirectory(_ directory: URL) -> Bool { - directory.standardizedFileURL.path == self.dedicatedProbeWorkingDirectoryURL().standardizedFileURL.path + let expected = self.dedicatedProbeWorkingDirectoryURL().standardizedFileURL + let parent = expected.deletingLastPathComponent() + let unredirected = parent.deletingLastPathComponent().resolvingSymlinksInPath() + .appendingPathComponent(parent.lastPathComponent, isDirectory: true) + .appendingPathComponent(expected.lastPathComponent, isDirectory: true) + return directory.standardizedFileURL.path == expected.path + && expected.resolvingSymlinksInPath().path == unredirected.path } static func preparedProbeWorkingDirectoryURL() -> URL { let directory = self.probeWorkingDirectoryURL() + guard self.isDedicatedProbeWorkingDirectory(directory) else { return directory } do { try self.prepareProbeWorkingDirectory(at: directory) } catch { @@ -1474,14 +1482,7 @@ extension ClaudeStatusProbe { timeout: TimeInterval, environment: [String: String]) async throws -> String { - let stopOnSubstrings = subcommand == "/usage" - ? [ - "Failed to load usage data", - "failed to load usage data", - "Failedto loadusagedata", - "failedtoloadusagedata", - ] - : [] + let stopOnSubstrings = subcommand == "/usage" ? ["Failed to load usage data"] : [] let idleTimeout: TimeInterval? = subcommand == "/usage" ? nil : 3.0 let sendEnterEvery: TimeInterval? = subcommand == "/usage" ? 0.8 : nil let stopWhenNormalized: (@Sendable (String) -> Bool)? = subcommand == "/usage" @@ -1517,8 +1518,11 @@ extension ClaudeStatusProbe { } private static func usageCaptureHasSessionValue(_ normalizedText: String) -> Bool { - guard let labelRange = normalizedText.range(of: "currentsession") else { return false } - let tail = normalizedText[labelRange.upperBound...] + let insightsStart = normalizedText.range(of: "what'scontributingtoyourlimitsusage?")?.lowerBound + ?? normalizedText.endIndex + let quotaText = normalizedText[.. String { try await session.capture( subcommand: "/status", @@ -136,7 +190,7 @@ struct ClaudeCLIWorkspaceTrustTests { /// Replays the captured Claude Code 2.1.282 frames: Enter on the preselected "No, exit" quits with status 1, and /// Escape cancels the dialog with status 0. - private static func makeFakeClaude() throws -> URL { + private static func makeFakeClaude(legacyAfterCommand: Bool = false) throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("claude-workspace-trust-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -145,13 +199,27 @@ struct ClaudeCLIWorkspaceTrustTests { .write(to: directory.appendingPathComponent("\(name).ansi")) } let binary = directory.appendingPathComponent("fake-claude") + let initial = legacyAfterCommand ? #""" + command='' + while IFS= read -r -n 1 key; do + case "$key" in + $'\r'|'') break ;; + *) command+="$key" ;; + esac + done + printf 'initial:%s\n' "$command" >> "$HOME/keys.log" + printf 'Do you trust the files in this folder?\r\n' + """# : #"/bin/cat "$HOME/workspace-trust-dialog.ansi""# + let legacyChoice = legacyAfterCommand ? "y) selected=yes ;;" : "" + let legacyReply = legacyAfterCommand ? #"printf 'Account: trusted\r\nDONE\r\n'"# : "" let script = #""" #!/bin/bash /bin/stty raw -echo -icrnl - /bin/cat "$HOME/workspace-trust-dialog.ansi" + \#(initial) selected=no while IFS= read -r -n 1 key; do case "$key" in + \#(legacyChoice) $'\e') key='' IFS= read -r -t 1 -n 2 key @@ -173,7 +241,8 @@ struct ClaudeCLIWorkspaceTrustTests { ;; esac done - printf 'ready\r\n' + printf '\033[2J\033[Hready\r\n' + \#(legacyReply) command='' while IFS= read -r -n 1 key; do case "$key" in diff --git a/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift b/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift index b77ca77faf..9e4656270e 100644 --- a/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift +++ b/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift @@ -3,6 +3,28 @@ import Testing @testable import CodexBarCore struct ClaudeProbeWorkingDirectoryTests { + @Test + func `cleanup preserves transcripts outside the owned probe directory`() throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let project = root.appendingPathComponent("user-project") + let owned = root.appendingPathComponent("CodexBar/ClaudeProbe") + let profile = root.appendingPathComponent("profile") + let archive = profile.appendingPathComponent("projects") + .appendingPathComponent(ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName(for: project)) + try FileManager.default.createDirectory(at: archive, withIntermediateDirectories: true) + let transcript = archive.appendingPathComponent("user-session.jsonl") + try Data("{}\n".utf8).write(to: transcript) + let removed = ClaudeStatusProbe.$dedicatedProbeDirectoryOverrideForTesting.withValue(owned) { + ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: project, + environment: ["CLAUDE_CONFIG_DIR": profile.path, "HOME": root.path]) + } + let transcriptSurvives = FileManager.default.fileExists(atPath: transcript.path) + #expect(removed.isEmpty) + #expect(transcriptSurvives) + } + @Test func `probe working directory disables deep link registration`() throws { let directory = try Self.makeTemporaryDirectory() @@ -104,7 +126,7 @@ struct ClaudeProbeWorkingDirectoryTests { try Data("keep".utf8).write(to: probeNote) try Data("{}\n".utf8).write(to: unrelatedSession) - let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + let removed = Self.cleanupOwnedProbeArtifacts( probeDirectory: probeDirectory, environment: ["CLAUDE_CONFIG_DIR": claudeRoot.path, "HOME": claudeRoot.path]) @@ -131,7 +153,7 @@ struct ClaudeProbeWorkingDirectoryTests { let probeSession = probeProject.appendingPathComponent("probe-session.jsonl") try Data("{}\n".utf8).write(to: probeSession) - let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + let removed = Self.cleanupOwnedProbeArtifacts( probeDirectory: probeDirectory, environment: ["CLAUDE_CONFIG_DIR": claudeRoot.path, "HOME": claudeRoot.path]) @@ -167,7 +189,7 @@ struct ClaudeProbeWorkingDirectoryTests { try Data("{}\n".utf8).write(to: selectedTranscript) try Data("{}\n".utf8).write(to: defaultTranscript) - let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + let removed = Self.cleanupOwnedProbeArtifacts( probeDirectory: probeDirectory, environment: ["CLAUDE_CONFIG_DIR": relativeProfile, "HOME": homeDirectory.path]) @@ -176,6 +198,13 @@ struct ClaudeProbeWorkingDirectoryTests { #expect(FileManager.default.fileExists(atPath: defaultTranscript.path)) } + private static func cleanupOwnedProbeArtifacts(probeDirectory: URL, environment: [String: String]) -> [URL] { + ClaudeStatusProbe.$dedicatedProbeDirectoryOverrideForTesting.withValue(probeDirectory) { + ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: probeDirectory, environment: environment) + } + } + private static func makeTemporaryDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-claude-probe-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift index acb4353446..36b661623e 100644 --- a/Tests/CodexBarTests/TTYIntegrationTests.swift +++ b/Tests/CodexBarTests/TTYIntegrationTests.swift @@ -5,6 +5,55 @@ import Testing @Suite(.serialized) struct TTYIntegrationTests { + @Test + func `claude pty waits for real quota beyond usage insights`() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let binary = directory.appendingPathComponent("claude-insights") + let script = #""" + #!/bin/bash + /bin/stty -echo + pending=0 + while IFS= read -r line; do + case "$line" in + *"/usage"*) + pending=1 + printf '\033[2J\033[H' + printf '%s\n' 'Current session' 'Loading usage...' "What's contributing to your limits usage?" \ + '30% of your usage was at >150k context' + ;; + "") + printf 'continue\n' >> "$HOME/continues.log" + if [[ "$pending" == 1 ]]; then + pending=0 + printf '\033[2J\033[H' + printf '%s\n' 'Current session' '13% used' 'Current week (all models)' '2% used' \ + "What's contributing to your limits usage?" '30% of your usage was at >150k context' \ + 'Top MCP servers: Show plan 10%' + fi + ;; + *"/status"*) printf '%s\n' 'Account: fixture@example.com' ;; + *"/exit"*) exit 0 ;; + esac + done + """# + try script.write(to: binary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) + let probe = ClaudeStatusProbe(claudeBinary: binary.path, timeout: 8, environment: [ + "HOME": directory.path, + "CLAUDE_CONFIG_DIR": directory.path, + "CLAUDE_SECURESTORAGE_CONFIG_DIR": directory.path, + ]) + let snapshot = try await ClaudeCLISession.withIsolatedSessionForTesting { + try await probe.fetch() + } + #expect(snapshot.sessionPercentLeft == 87) + #expect(snapshot.weeklyPercentLeft == 98) + let continues = try String(contentsOf: directory.appendingPathComponent("continues.log"), encoding: .utf8) + #expect(continues == "continue\n") + } + @Test func `codex RPC usage live`() async throws { guard ProcessInfo.processInfo.environment["LIVE_CODEX_TTY"] == "1" else { diff --git a/docs/claude.md b/docs/claude.md index 7d9a430d24..095cc81b38 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -378,10 +378,11 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential `ClaudeProbe` project directory so background `/usage` polling does not clutter the user's Claude project history. - Command flow: 1) Start CLI with `--allowed-tools ""` (no tools). - 2) Auto-respond to first-run prompts (trust files, workspace, telemetry). The workspace trust dialog ("Quick safety - check") preselects "No, exit", so the probe moves the `❯` selection to "Yes, I trust this folder" before pressing - Enter; Claude then remembers trust for the dedicated probe directory. The dialog is only accepted there: if the - probe falls back to the shared temporary directory, it cancels the dialog with Escape instead. + 2) Handle first-run prompts during startup and command capture. For the modern trust dialog, move the `❯` + selection to "Yes, I trust this folder" before confirming. Modern and legacy trust prompts are accepted only + in the dedicated probe directory. Redirected paths are rejected before local settings are prepared; headless + probes require that isolated directory and do not launch from the shared temporary fallback. Transcript cleanup + is also limited to that directory. An explicitly supplied different working directory never receives trust. 3) Send `/usage`, wait for rendered panel; send Enter retries if needed. 4) Dismiss the open panel with Escape before reusing the session for `/status` identity or the next `/usage` refresh. 5) Optionally send `/status` to extract identity fields. @@ -390,6 +391,9 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential "Current session" + "Current week" headers. Cursor jumps preserve unchanged cells from earlier frames, keeping scoped weekly percentages, reset spacing, and account identity intact. Erased content is not reused as history. - Plain reports, including color-only ANSI output and legacy CR-delimited text, retain their existing parsing behavior. + - Capture completion uses the current rendered frame and waits for session quota values; percentages in the + "What's contributing to your limits usage?" insights section cannot finish a loading quota probe. Once quota is + complete, insight text cannot trigger another command-palette confirmation. - Extracts percent left/used and reset text near those headers. - When a reset date cannot be parsed, the menu preserves its description and normalizes leading `Reset` or `Resets` labels once, including scoped weekly limits. - Parses `Account:` and `Org:` lines when present. From 949cac4e88b5aa6f12ba52bef9112fab2b3e9901 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 21:55:22 -0700 Subject: [PATCH 102/122] fix(sync): recover removed records and gate push registration (#4147) iCloud Sync: a Mac removed while running no longer fails every later save with CKError (stale records are recovered or dropped cleanly), and CloudKit change-notification registration is gated on the push capability being present. Fixes #4144; refs #4132 (push delivery needs the aps-environment entitlement in the release profile). --- CHANGELOG.md | 2 + Sources/CodexBar/Sync/CloudSyncEngine.swift | 210 +++++++++--------- .../CloudSyncDeviceRemovalTests.swift | 57 +++++ .../CloudSyncPushRegistrationTests.swift | 62 ++++++ Tests/CodexBarTests/SyncModelTests.swift | 10 +- docs/RELEASING.md | 2 + docs/configuration.md | 4 +- 7 files changed, 236 insertions(+), 111 deletions(-) create mode 100644 Tests/CodexBarTests/CloudSyncPushRegistrationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5157365e6a..afde8a16fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Fixed - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! +- iCloud Sync: recover a live Mac's saves after its records are removed from another Mac, without resetting shared sync state (#4144). +- iCloud Sync: register for silent change notifications when the signed build supports push; release provisioning must enable that capability for automatic delivery (#4132). ## 0.70.0 — 2026-09-29 diff --git a/Sources/CodexBar/Sync/CloudSyncEngine.swift b/Sources/CodexBar/Sync/CloudSyncEngine.swift index 2e6da243e5..92f4b1f045 100644 --- a/Sources/CodexBar/Sync/CloudSyncEngine.swift +++ b/Sources/CodexBar/Sync/CloudSyncEngine.swift @@ -1,3 +1,4 @@ +import AppKit import CloudKit import CodexBarCore import Foundation @@ -386,8 +387,10 @@ enum CloudSyncSnapshotMigration { return payload } } +} - static func shouldResumeDelayedRetry( +enum CloudSyncLifecycle { + static func isCurrentEngine( originatingEngine: ObjectIdentifier?, currentEngine: ObjectIdentifier?) -> Bool { @@ -399,16 +402,30 @@ enum CloudSyncSnapshotMigration { enum CloudSyncEntitlementGate { static let entitlement = "com.apple.developer.icloud-services" + private static func entitlementValue(_ name: String) -> Any? { + guard let task = SecTaskCreateFromSelf(nil) else { return nil } + return SecTaskCopyValueForEntitlement(task, name as CFString, nil) + } + static func hasICloudServicesEntitlement() -> Bool { - guard let task = SecTaskCreateFromSelf(nil), - let value = SecTaskCopyValueForEntitlement(task, self.entitlement as CFString, nil) - else { + (self.entitlementValue(self.entitlement) as? [String])?.contains("CloudKit") == true + } + + @MainActor + static func prepareForSync( + enabled: Bool, + entitlementValue: (String) -> Any? = Self.entitlementValue, + register: () -> Void = { NSApplication.shared.registerForRemoteNotifications() }) -> Bool + { + guard enabled, (entitlementValue(self.entitlement) as? [String])?.contains("CloudKit") == true else { return false } - if let services = value as? [String] { - return services.contains("CloudKit") + if let environment = entitlementValue("com.apple.developer.aps-environment") as? String, + ["development", "production"].contains(environment) + { + register() } - return false + return true } } @@ -609,10 +626,10 @@ actor CloudSyncEngine: CKSyncEngineDelegate { } } - func fetchChanges() async { + func fetchChanges(scopedToSyncZone: Bool = true) async { guard self.enabled, let engine = self.engine else { return } do { - try await engine.fetchChanges(.init(scope: .zoneIDs([Self.zoneID]))) + try await engine.fetchChanges(.init(scope: scopedToSyncZone ? .zoneIDs([Self.zoneID]) : .all)) await MainActor.run { self.state.status.lastSuccessfulFetchAt = Date() } } catch { await self.record(error: error) @@ -643,11 +660,13 @@ actor CloudSyncEngine: CKSyncEngineDelegate { return false } - var configuration = CKSyncEngine.Configuration( + guard await MainActor.run(body: { + CloudSyncEntitlementGate.prepareForSync(enabled: self.settings.iCloudSyncEnabled) + }), self.enabled, self.engine == nil else { return false } + let configuration = CKSyncEngine.Configuration( database: container.privateCloudDatabase, stateSerialization: self.persistenceEnvelope.stateSerialization, delegate: self) - configuration.automaticallySync = true let engine = CKSyncEngine(configuration) self.engine = engine await self.rehydrateFleetStateIfNeeded() @@ -783,6 +802,9 @@ actor CloudSyncEngine: CKSyncEngineDelegate { } private func processEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + guard self.enabled, CloudSyncLifecycle.isCurrentEngine( + originatingEngine: ObjectIdentifier(syncEngine), + currentEngine: self.engine.map(ObjectIdentifier.init)) else { return } switch event { case let .stateUpdate(update): self.persistenceEnvelope.stateSerialization = update.stateSerialization @@ -817,7 +839,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { savedRecordNames: changes.savedRecords.map(\.recordID.recordName), syncEngine: syncEngine) for failure in changes.failedRecordSaves { - await self.handleSaveFailure(failure, syncEngine: syncEngine) + await self.handleSaveFailure(failure.record, error: failure.error, syncEngine: syncEngine) } await self.handleSentRecordDeletes( deletedIDs: changes.deletedRecordIDs, @@ -858,9 +880,9 @@ actor CloudSyncEngine: CKSyncEngineDelegate { } } - private func recordForPendingSave(_ recordID: CKRecord.ID, syncEngine: CKSyncEngine) -> CKRecord? { + func recordForPendingSave(_ recordID: CKRecord.ID, syncEngine: CKSyncEngine? = nil) -> CKRecord? { CloudSyncBatchRecordProvider.record(for: recordID, desiredRecords: self.desiredRecords) { change in - syncEngine.state.remove(pendingRecordZoneChanges: [change]) + syncEngine?.state.remove(pendingRecordZoneChanges: [change]) } } @@ -905,16 +927,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { else { return true } - let localValue = SyncConflictValue( - value: local, - editCount: self.editCount(local), - modifiedAt: self.modifiedAt(local)) - let serverValue = SyncConflictValue( - value: server, - editCount: self.editCount(server), - modifiedAt: self.modifiedAt(server)) - let winner = SyncConflictResolver.winner(local: localValue, server: serverValue) - guard winner.value === local else { + guard self.localWinsConflict(local, server: server) else { engine.state.remove(pendingRecordZoneChanges: [.saveRecord(server.recordID)]) self.desiredRecords.removeValue(forKey: server.recordID) return true @@ -1007,56 +1020,84 @@ actor CloudSyncEngine: CKSyncEngineDelegate { await MainActor.run { self.state.fleetSnapshots[record.recordID.recordName] = payload } } - private func handleSaveFailure( - _ failure: CKSyncEngine.Event.SentRecordZoneChanges.FailedRecordSave, - syncEngine: CKSyncEngine) async + func handleSaveFailure( + _ record: CKRecord, + error: CKError, + syncEngine: CKSyncEngine? = nil) async { - switch failure.error.code { + guard syncEngine == nil || (self.enabled && self.engine === syncEngine) else { return } + switch error.code { case .quotaExceeded: - let retry = self.quotaRetryState.nextDelay(serverRetryAfter: failure.error.retryAfterSeconds) - self.scheduleRetry(recordID: failure.record.recordID, after: retry) + let retry = self.quotaRetryState.nextDelay(serverRetryAfter: error.retryAfterSeconds) + self.scheduleRetry(recordID: record.recordID, after: retry) case .accountTemporarilyUnavailable: - let retry = CloudSyncSnapshotMigration.retryDelay(for: failure.error) ?? 1 - self.scheduleRetry(recordID: failure.record.recordID, after: retry) + let retry = CloudSyncSnapshotMigration.retryDelay(for: error) ?? 1 + self.scheduleRetry(recordID: record.recordID, after: retry) case .serverRecordChanged: - guard let server = failure.error.serverRecord else { - await self.record(error: failure.error) - self.pendingSaveHashes.removeValue(forKey: failure.record.recordID.recordName) + guard let server = error.serverRecord else { + await self.record(error: error) + self.pendingSaveHashes.removeValue(forKey: record.recordID.recordName) return } - await self.resolveConflict(with: server, syncEngine: syncEngine) + if let syncEngine { await self.resolveConflict(with: server, syncEngine: syncEngine) } + case .unknownItem where record.recordChangeTag != nil + || self.persistenceEnvelope.encodedSystemFields[record.recordID.recordName] != nil: + let name = record.recordID.recordName + self.persistenceEnvelope.encodedSystemFields.removeValue(forKey: name) + self.persistenceEnvelope.recordMetadata.removeValue(forKey: name) + self.lastSnapshotHashes.removeValue(forKey: name) + self.skippedTerminalReplacementHashes.removeValue(forKey: name) + // Rebuild the prepared record too; clearing the persisted change tag alone cannot heal this save. + let desired = self.desiredRecords[record.recordID] ?? record + self.desiredRecords[record.recordID] = Self.copyUserFields( + from: desired, onto: CKRecord(recordType: desired.recordType, recordID: record.recordID)) + self.persistEnvelope() + syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(record.recordID)]) case .zoneNotFound: - self.recreateZoneAndRequeue(failure.record, syncEngine: syncEngine) + self.recreateZoneAndRequeue(record, syncEngine: syncEngine) default: - let resetEncryptedData = (failure.error.userInfo[CKErrorUserDidResetEncryptedDataKey] as? NSNumber)? + let resetEncryptedData = (error.userInfo[CKErrorUserDidResetEncryptedDataKey] as? NSNumber)? .boolValue == true if resetEncryptedData { - self.recreateZoneAndRequeue(failure.record, syncEngine: syncEngine) + self.recreateZoneAndRequeue(record, syncEngine: syncEngine) } else { - await self.record(error: failure.error) - self.abandonTerminalReplacementSave(failure) + await self.record(error: error) + self.abandonTerminalReplacementSave(recordName: record.recordID.recordName, error: error) } } } - private func recreateZoneAndRequeue(_ record: CKRecord, syncEngine: CKSyncEngine) { + private func recreateZoneAndRequeue(_ record: CKRecord, syncEngine: CKSyncEngine?) { if self.desiredRecords[record.recordID] == nil { self.desiredRecords[record.recordID] = record } - syncEngine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: Self.zoneID))]) - syncEngine.state.add(pendingRecordZoneChanges: [.saveRecord(record.recordID)]) + syncEngine?.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: Self.zoneID))]) + syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(record.recordID)]) } - private func scheduleRetry(recordID: CKRecord.ID, after delay: TimeInterval) { + private func scheduleRetry(recordID: CKRecord.ID, after delay: TimeInterval, deleting: Bool = false) { + let originatingEngine = self.engine.map(ObjectIdentifier.init) Task { [weak self] in do { if delay > 0 { try await Task.sleep(for: .seconds(delay)) } await Task.yield() - guard let self, let engine = await self.engine, await self.enabled else { return } - engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) + guard let self, await self.enabled else { return } + if deleting, await !self.persistenceEnvelope.pendingSnapshotDeletes.contains(recordID.recordName) { + return + } + guard let engine = await self.engine, + CloudSyncLifecycle.isCurrentEngine( + originatingEngine: originatingEngine, + currentEngine: ObjectIdentifier(engine)) + else { return } + engine.state.add(pendingRecordZoneChanges: [ + deleting ? .deleteRecord(recordID) : .saveRecord(recordID), + ]) try await engine.sendChanges(.init(scope: .recordIDs([recordID]))) + } catch is CancellationError { + return } catch { await self?.record(error: error) } @@ -1075,15 +1116,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { } guard let local = self.desiredRecords[server.recordID] else { return } self.cacheSystemFields(server) - let localValue = SyncConflictValue( - value: local, - editCount: self.editCount(local), - modifiedAt: self.modifiedAt(local)) - let serverValue = SyncConflictValue( - value: server, - editCount: self.editCount(server), - modifiedAt: self.modifiedAt(server)) - if SyncConflictResolver.winner(local: localValue, server: serverValue).value === local { + if self.localWinsConflict(local, server: server) { self.desiredRecords[server.recordID] = Self.copyUserFields(from: local, onto: server) self.scheduleRetry(recordID: server.recordID, after: 0) } else { @@ -1094,25 +1127,20 @@ actor CloudSyncEngine: CKSyncEngineDelegate { } } + private func localWinsConflict(_ local: CKRecord, server: CKRecord) -> Bool { + let localValue = SyncConflictValue( + value: local, + editCount: self.editCount(local), + modifiedAt: self.modifiedAt(local)) + let serverValue = SyncConflictValue( + value: server, editCount: self.editCount(server), modifiedAt: self.modifiedAt(server)) + return SyncConflictResolver.winner(local: localValue, server: serverValue).value === local + } + private func scheduleFetchChanges(scopedToSyncZone: Bool) { Task { [weak self] in await Task.yield() - guard let self else { return } - if scopedToSyncZone { - await self.fetchChanges() - } else { - await self.fetchAllChanges() - } - } - } - - private func fetchAllChanges() async { - guard self.enabled, let engine = self.engine else { return } - do { - try await engine.fetchChanges() - await MainActor.run { self.state.status.lastSuccessfulFetchAt = Date() } - } catch { - await self.record(error: error) + await self?.fetchChanges(scopedToSyncZone: scopedToSyncZone) } } @@ -1346,7 +1374,7 @@ extension CloudSyncEngine { for recordID in CloudSyncSnapshotMigration.retryableFailedDeletes(failures, liveNames: liveNames) { self.rememberPendingSnapshotDeletes([recordID.recordName]) let delay = failures[recordID].flatMap(CloudSyncSnapshotMigration.retryDelay(for:)) ?? 1 - self.scheduleDeleteRetry(recordID: recordID, after: delay) + self.scheduleRetry(recordID: recordID, after: delay, deleting: true) } } @@ -1386,50 +1414,20 @@ extension CloudSyncEngine { } } - private func abandonTerminalReplacementSave( - _ failure: CKSyncEngine.Event.SentRecordZoneChanges.FailedRecordSave) - { - let name = failure.record.recordID.recordName + private func abandonTerminalReplacementSave(recordName name: String, error: CKError) { let abandoned = CloudSyncSnapshotMigration.abandonedReplacementNames( - failures: [name: failure.error], + failures: [name: error], pendingReplacements: Set(self.persistenceEnvelope.pendingPredecessorDeletes.keys)) if abandoned.contains(name) { self.persistenceEnvelope.pendingPredecessorDeletes.removeValue(forKey: name) } CloudSyncSnapshotMigration.applyTerminalSaveSkip( recordName: name, - error: failure.error, + error: error, pendingSaveHashes: &self.pendingSaveHashes, skippedTerminalReplacementHashes: &self.skippedTerminalReplacementHashes) } - private func scheduleDeleteRetry(recordID: CKRecord.ID, after delay: TimeInterval) { - Task { [weak self] in - let originatingEngine = await self?.engine.map { ObjectIdentifier($0) } - do { - if delay > 0 { - try await Task.sleep(for: .seconds(delay)) - } - await Task.yield() - guard let self, await self.enabled else { return } - guard await self.persistenceEnvelope.pendingSnapshotDeletes.contains(recordID.recordName) else { - return - } - guard let engine = await self.engine, - CloudSyncSnapshotMigration.shouldResumeDelayedRetry( - originatingEngine: originatingEngine, - currentEngine: ObjectIdentifier(engine)) - else { return } - engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) - try await engine.sendChanges(.init(scope: .recordIDs([recordID]))) - } catch is CancellationError { - return - } catch { - await self?.record(error: error) - } - } - } - private func pushPendingSnapshots() async { guard let engine = self.engine else { return } guard !self.pendingSnapshots.isEmpty || !self.persistenceEnvelope.pendingSnapshotDeletes.isEmpty else { diff --git a/Tests/CodexBarTests/CloudSyncDeviceRemovalTests.swift b/Tests/CodexBarTests/CloudSyncDeviceRemovalTests.swift index 840c543071..2e107476b2 100644 --- a/Tests/CodexBarTests/CloudSyncDeviceRemovalTests.swift +++ b/Tests/CodexBarTests/CloudSyncDeviceRemovalTests.swift @@ -103,6 +103,57 @@ struct CloudSyncDeviceRemovalTests { #expect(Set(state.fleetSnapshots.keys) == Set(saved.fleetSnapshots.keys)) } + @Test(arguments: [SyncRecordType.device, .accountSnapshot], [false, true]) + func `removed live record is recreated once with its payload intact`( + _ type: SyncRecordType, deletionFetched: Bool) async throws + { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let persistence = CloudSyncPersistence(fileURL: directory.appendingPathComponent("sync.json")) + let state = CloudSyncState() + let id = CKRecord.ID(recordName: "removed-current", zoneID: CloudSyncEngine.zoneID) + let stale = deletionFetched + ? PreviouslySavedRecord(recordType: type.rawValue, recordID: id) + : CKRecord(recordType: type.rawValue, recordID: id) + stale["deviceID"] = "current" as CKRecordValue + stale["schemaVersion"] = 1 as CKRecordValue + stale.encryptedValues["usagePayload"] = "synthetic usage" as CKRecordValue + var envelope = CloudSyncPersistence.Envelope( + stateSerialization: nil, encodedSystemFields: [:], dirtyProviders: ["claude"], preferencesDirty: true) + CloudSyncPersistence.cacheSystemFields(of: stale, in: &envelope) + envelope.encodedSystemFields["unrelated"] = Data([1]) + envelope.pendingPredecessorDeletes[id.recordName] = ["predecessor"] + try persistence.save(envelope) + let engine = CloudSyncEngine( + settings: Self.makeSettings(directory: directory), state: state, persistence: persistence) + let error = CKError(_nsError: NSError(domain: CKErrorDomain, code: CKError.unknownItem.rawValue)) + + if deletionFetched { await engine.applyDeletedRecords([id.recordName]) } + await engine.handleSaveFailure(stale, error: error) + + let saved = persistence.load() + #expect(saved.encodedSystemFields[id.recordName] == nil) + #expect(saved.recordMetadata[id.recordName] == nil) + #expect(saved.encodedSystemFields["unrelated"] == Data([1])) + #expect(saved.pendingPredecessorDeletes[id.recordName] == ["predecessor"]) + #expect(saved.dirtyProviders == ["claude"]) + #expect(saved.preferencesDirty) + #expect(state.status.lastError == nil) + let replacement = try #require(await engine.recordForPendingSave(id)) + #expect(replacement !== stale) + #expect(replacement.recordID == id) + #expect(replacement.recordType == type.rawValue) + #expect(replacement.recordChangeTag == nil) + #expect(replacement["deviceID"] as? String == "current") + #expect(replacement["schemaVersion"] as? Int == 1) + #expect(replacement.encryptedValues["usagePayload"] as? String == "synthetic usage") + + await engine.handleSaveFailure(replacement, error: error) + + #expect(state.status.lastError != nil) + #expect(await engine.recordForPendingSave(id) === replacement) + } + @Test func `disabled engine does not remove records or access CloudKit`() async { let state = Self.makeState() @@ -209,3 +260,9 @@ struct CloudSyncDeviceRemovalTests { return state } } + +private final class PreviouslySavedRecord: CKRecord, @unchecked Sendable { + override var recordChangeTag: String? { + "fixture-change-tag" + } +} diff --git a/Tests/CodexBarTests/CloudSyncPushRegistrationTests.swift b/Tests/CodexBarTests/CloudSyncPushRegistrationTests.swift new file mode 100644 index 0000000000..c12591f57c --- /dev/null +++ b/Tests/CodexBarTests/CloudSyncPushRegistrationTests.swift @@ -0,0 +1,62 @@ +import Testing +@testable import CodexBar + +@MainActor +struct CloudSyncPushRegistrationTests { + @Test(arguments: ["development", "production"]) + func `CloudKit builds with a valid push environment register for notifications`(_ environment: String) { + var registrations = 0 + let canActivate = CloudSyncEntitlementGate.prepareForSync( + enabled: true, + entitlementValue: { name in + name == CloudSyncEntitlementGate.entitlement ? ["CloudKit"] : environment + }, + register: { registrations += 1 }) + #expect(canActivate) + #expect(registrations == 1) + } + + @Test(arguments: [nil, "", "Production", "sandbox"] as [String?]) + func `missing or invalid push entitlement never registers`(_ environment: String?) { + var registrations = 0 + let canActivate = CloudSyncEntitlementGate.prepareForSync( + enabled: true, + entitlementValue: { name in + name == CloudSyncEntitlementGate.entitlement ? ["CloudKit"] : environment + }, + register: { registrations += 1 }) + #expect(canActivate) + #expect(registrations == 0) + } + + @Test + func `push entitlement alone does not bypass the CloudKit capability gate`() { + var registrations = 0 + let canActivate = CloudSyncEntitlementGate.prepareForSync( + enabled: true, + entitlementValue: { name in + name == CloudSyncEntitlementGate.entitlement ? ["CloudDocuments"] : "production" + }, + register: { registrations += 1 }) + #expect(!canActivate) + #expect(registrations == 0) + } + + @Test + func `opting out before queued activation prevents registration and engine creation`() { + var enabled = true + var registrations = 0 + let activate = { + CloudSyncEntitlementGate.prepareForSync( + enabled: enabled, + entitlementValue: { name in + name == CloudSyncEntitlementGate.entitlement ? ["CloudKit"] : "production" + }, + register: { registrations += 1 }) + } + enabled = false + + #expect(!activate()) + #expect(registrations == 0) + } +} diff --git a/Tests/CodexBarTests/SyncModelTests.swift b/Tests/CodexBarTests/SyncModelTests.swift index c3c016fb25..986ba1f3c4 100644 --- a/Tests/CodexBarTests/SyncModelTests.swift +++ b/Tests/CodexBarTests/SyncModelTests.swift @@ -776,20 +776,22 @@ struct CloudSyncSnapshotMigrationSaveThenDeleteTests { } @Test - func `delayed delete retries do not resume on a replacement sync engine`() { + func `events and retries do not resume on a retired sync engine`() { let original = NSObject() #expect( - CloudSyncSnapshotMigration.shouldResumeDelayedRetry( + CloudSyncLifecycle.isCurrentEngine( originatingEngine: ObjectIdentifier(original), currentEngine: ObjectIdentifier(original))) #expect( - !CloudSyncSnapshotMigration.shouldResumeDelayedRetry( + !CloudSyncLifecycle.isCurrentEngine( originatingEngine: ObjectIdentifier(original), currentEngine: ObjectIdentifier(NSObject()))) #expect( - !CloudSyncSnapshotMigration.shouldResumeDelayedRetry( + !CloudSyncLifecycle.isCurrentEngine( originatingEngine: ObjectIdentifier(original), currentEngine: nil)) + #expect(!CloudSyncLifecycle.isCurrentEngine(originatingEngine: nil, currentEngine: ObjectIdentifier(original))) + #expect(!CloudSyncLifecycle.isCurrentEngine(originatingEngine: nil, currentEngine: nil)) } private static func cloudKitError(_ code: CKError.Code, retryAfter: TimeInterval? = nil) -> CKError { diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7ee85e6026..2c8897b86c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -58,6 +58,8 @@ Gotchas fixed: ## iCloud sync (CloudKit) Upstream-team identity-signed release builds embed `Scripts/profiles/CodexBar-DeveloperID.provisionprofile` at `Contents/embedded.provisionprofile` and claim the iCloud entitlements (`Scripts/package_app.sh` does both automatically; it fails hard if the profile file is missing). Packaging derives the team from the selected `APP_IDENTITY`; other teams omit upstream CloudKit resources. `sign-and-notarize.sh` honors that same identity, with required timestamping and hardened runtime. The profile expires 2044-07-29; Gatekeeper re-validates it at every launch. +Push delivery setup is pending owner approval (#4132): enable **Push Notifications** for the macOS App ID `com.steipete.codexbar`, regenerate its Developer ID provisioning profile, and replace the profile above with one authorizing `com.apple.developer.aps-environment = production`. Then add that exact entitlement and value to the upstream release-only entitlement block in `Scripts/package_app.sh` and extend `Scripts/test_package_signing.sh`. The macOS key is `com.apple.developer.aps-environment`, not the iOS `aps-environment` key. Verify both the embedded profile and final signed app authorize it before publishing. The runtime registers only when CloudKit and a valid push entitlement are present; CKSyncEngine creates or reuses its own `CKDatabaseSubscription`, so no separate subscription or CloudKit schema change is needed. Confirm changes arrive between two signed-in Macs before closing #4132. Registration alone does not repair releases signed without this capability. + Schema changes: any new record type or field in `Sources/CodexBar*/Sync/` must be reflected in `Scripts/cloudkit/schema.ckdb` and deployed **before** shipping the build: ``` CLOUDKIT_MANAGEMENT_TOKEN=… Scripts/cloudkit/deploy_schema.sh development # validate diff --git a/docs/configuration.md b/docs/configuration.md index 7ddced64b1..81187b3a55 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -250,7 +250,9 @@ Opt-in (Settings → iCloud Sync, off by default; requires a signed release buil - **A curated preferences subset** — notification/threshold/display settings. - **Usage snapshots** — per-device current usage per account, so other Macs can show last-known data ("via · 1h ago") and accounts discovered on other Macs. -The **Macs** list offers **Remove** for other devices, including stale duplicates left after a reinstall. Removal deletes that device record and its cached usage snapshots from iCloud; it leaves shared settings, credentials, and this Mac intact. Sync must be enabled and available. Failed removals remain visible and report a sync error. A Mac still running CodexBar with sync enabled can publish its records again. +The **Macs** list offers **Remove** for other devices, including stale duplicates left after a reinstall. Removal deletes that device record and its cached usage snapshots from iCloud; it leaves shared settings, credentials, and this Mac intact. Sync must be enabled and available. Failed removals remain visible and report a sync error. A Mac still running CodexBar with sync enabled can publish its records again: if a save finds that its previous record was deleted, it drops the stale server version and retries with a fresh record. If that fresh record is also reported missing, CodexBar surfaces the error instead of retrying indefinitely. + +Automatic reception of changes requires a release signed with the macOS Push Notifications entitlement. When that capability is present, enabling sync registers for silent remote notifications; CKSyncEngine manages the CloudKit database subscription. The existing launch, foreground, and 15-minute fetch requests remain, but are not a guarantee of prompt delivery without push support. See [release setup](RELEASING.md#icloud-sync-cloudkit). Never synced, by design: `hooks` (sync payloads structurally cannot create or modify hook rules — they execute local binaries), machine-local paths (`claudeSwapExecutablePath`, `codexProfileHomePaths`, `awsProfile`/`awsAuthMode`, `source`, `codexActiveSource`, `cookieSource`), menu-bar layout/geometry, debug settings, usage history, and cost ledgers. A provider is never auto-enabled on a Mac where its required local CLI is missing. Records carry a schema version; older app versions pause sync instead of rewriting newer payloads. The CLI does not talk to CloudKit — the running app watches `config.json`, applies CLI or hand edits locally, and syncs changed provider payloads to the fleet when iCloud sync is enabled. Remote changes written to the file are recognized as app writes and are not echoed back. The app tracks per-provider dirty state and never re-uploads unchanged state at launch. From 9930e06d4fc4d5ffb1fa4c7238981c64e13d978e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 21:55:29 -0700 Subject: [PATCH 103/122] test: replace hooks and process wall-clock assertions (#4162) Eight tests (CLI hooks watch sleep, process ownership reaper, process pipe capture) verify promptness with recorded ticks or controlled fixture lifetime instead of wall-clock elapsed-time bounds, removing a class of failures on busy CI runners; production timeouts are unchanged. --- .../CodexBarCLI/CLIHooksWatchCommand.swift | 16 +-- TestsLinux/CLIHooksWatchSleepLinuxTests.swift | 46 ++++---- TestsLinux/ProcessOwnershipReaperTests.swift | 33 ++++-- TestsLinux/ProcessPipeCaptureLinuxTests.swift | 105 ++++++------------ 4 files changed, 85 insertions(+), 115 deletions(-) diff --git a/Sources/CodexBarCLI/CLIHooksWatchCommand.swift b/Sources/CodexBarCLI/CLIHooksWatchCommand.swift index a963dc0910..d7986d3cbb 100644 --- a/Sources/CodexBarCLI/CLIHooksWatchCommand.swift +++ b/Sources/CodexBarCLI/CLIHooksWatchCommand.swift @@ -277,11 +277,15 @@ extension CodexBarCLI { /// end. `CLITerminationSignalMonitor` only flips a flag — it does not cancel the /// running task — so a single long `Task.sleep` would leave `hooks watch` /// appearing hung on SIGINT/SIGTERM/SIGHUP until the full interval elapsed. - static func sleepInterruptibly(interval: TimeInterval, stop: HooksWatchStopSignal) async { + static func sleepInterruptibly( + interval: TimeInterval, + stop: HooksWatchStopSignal, + sleep: (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) }) async + { var remainingNanoseconds = UInt64((max(0, interval) * 1_000_000_000).rounded()) while remainingNanoseconds > 0, !stop.isRequested { let sleepNanoseconds = min(remainingNanoseconds, Self.hooksWatchSleepTickNanoseconds) - try? await Task.sleep(nanoseconds: sleepNanoseconds) + try? await sleep(sleepNanoseconds) remainingNanoseconds -= sleepNanoseconds } } @@ -372,15 +376,11 @@ final class HooksWatchStopSignal: @unchecked Sendable { private var requested = false func request() { - self.lock.lock() - self.requested = true - self.lock.unlock() + self.lock.withLock { self.requested = true } } var isRequested: Bool { - self.lock.lock() - defer { self.lock.unlock() } - return self.requested + self.lock.withLock { self.requested } } } diff --git a/TestsLinux/CLIHooksWatchSleepLinuxTests.swift b/TestsLinux/CLIHooksWatchSleepLinuxTests.swift index da44bf8088..e2e66a298a 100644 --- a/TestsLinux/CLIHooksWatchSleepLinuxTests.swift +++ b/TestsLinux/CLIHooksWatchSleepLinuxTests.swift @@ -5,44 +5,40 @@ import Testing struct CLIHooksWatchSleepLinuxTests { @Test - func `returns quickly when stop already requested`() async { + func `already requested stop skips every sleep tick`() async { let stop = HooksWatchStopSignal() stop.request() - let start = DispatchTime.now() - await CodexBarCLI.sleepInterruptibly(interval: 30, stop: stop) - let elapsedSeconds = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1e9 - - #expect(elapsedSeconds < 1) + await CodexBarCLI.sleepInterruptibly(interval: 30, stop: stop) { _ in + Issue.record("An already requested stop must not sleep") + } } @Test - func `stops promptly when signaled mid sleep`() async { - // Regression: CLITerminationSignalMonitor only flips a flag, it does not - // cancel the running task. A single long Task.sleep would leave `hooks - // watch` appearing hung on SIGINT until the full interval elapsed. + func `stop requested during a tick prevents the next sleep`() async { + // The signal monitor flips the flag without cancelling this task. Record the + // requested ticks so a single full-interval sleep cannot satisfy this test. let stop = HooksWatchStopSignal() - Task.detached { - try? await Task.sleep(nanoseconds: 300_000_000) + var ticks: [UInt64] = [] + await CodexBarCLI.sleepInterruptibly(interval: 10, stop: stop) { nanoseconds in + ticks.append(nanoseconds) stop.request() } - let start = DispatchTime.now() - await CodexBarCLI.sleepInterruptibly(interval: 10, stop: stop) - let elapsedSeconds = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1e9 - - // The 0.3s signal must interrupt the 10s interval promptly; allow headroom for loaded - // CI runners (observed 2.02s on x64 under contention). - #expect(elapsedSeconds < 5) + #expect(ticks == [200_000_000]) } - @Test - func `sleeps the full interval when never signaled`() async { + @Test(arguments: [0.0, -1.0, 0.05, 0.4, 0.45]) + func `unsignaled sleep requests the full interval in bounded ticks`(interval: TimeInterval) async { let stop = HooksWatchStopSignal() - let start = DispatchTime.now() - await CodexBarCLI.sleepInterruptibly(interval: 0.4, stop: stop) - let elapsedSeconds = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1e9 + var ticks: [UInt64] = [] + await CodexBarCLI.sleepInterruptibly(interval: interval, stop: stop) { nanoseconds in + ticks.append(nanoseconds) + } - #expect(elapsedSeconds >= 0.35) + let expected = UInt64((max(0, interval) * 1_000_000_000).rounded()) + #expect(ticks.reduce(0, +) == expected) + #expect(ticks.allSatisfy { $0 > 0 && $0 <= 200_000_000 }) + #expect(ticks.count == Int((expected + 199_999_999) / 200_000_000)) } } diff --git a/TestsLinux/ProcessOwnershipReaperTests.swift b/TestsLinux/ProcessOwnershipReaperTests.swift index 9cbcd8b73b..29ac02e652 100644 --- a/TestsLinux/ProcessOwnershipReaperTests.swift +++ b/TestsLinux/ProcessOwnershipReaperTests.swift @@ -90,30 +90,39 @@ struct ProcessOwnershipReaperTests { @Test(arguments: [false, true]) func `cleared environment cannot defeat timeout or cancellation`(cancel: Bool) async throws { - let start = Date() + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: root) } + let ready = root.appendingPathComponent("ready") + let input = Pipe() + defer { try? input.fileHandleForWriting.close() } let task = Task { try await SubprocessRunner.run( binary: "/usr/bin/env", - arguments: ["-i", "/bin/sleep", "30"], + arguments: ["-i", "/bin/sh", "-c", "printf ready > \"$1\"; exec /bin/cat", "fixture", ready.path], environment: [:], - timeout: cancel ? 60 : 0.2, + timeout: cancel ? 600 : 30, + standardInput: input, reapDescendants: true, label: "cleared-marker-fixture") } - if cancel { - try await Task.sleep(for: .milliseconds(200)) - task.cancel() + defer { task.cancel() } + let deadline = Date().addingTimeInterval(60) + while !FileManager.default.fileExists(atPath: ready.path), Date() < deadline { + try await Task.sleep(for: .milliseconds(20)) } - do { - _ = try await task.value - Issue.record("Expected timeout or cancellation") - } catch is CancellationError { + try #require(FileManager.default.fileExists(atPath: ready.path)) + if cancel { task.cancel() } + // Stdin stays open until after the result: cat cannot rescue broken teardown by exiting naturally. + switch await BoundedTaskJoin(sourceTask: task).value(joinGrace: .seconds(60)) { + case let .failure(error) where error is CancellationError: #expect(cancel) - } catch let error as SubprocessRunnerError { + case let .failure(error as SubprocessRunnerError): guard case .timedOut = error else { throw error } #expect(!cancel) + case let .failure(error): throw error + case .value, .timedOut: Issue.record("Expected timeout or cancellation while stdin remains open") } - #expect(Date().timeIntervalSince(start) < 10) } @Test diff --git a/TestsLinux/ProcessPipeCaptureLinuxTests.swift b/TestsLinux/ProcessPipeCaptureLinuxTests.swift index cd472f13e9..a688d5bf1c 100644 --- a/TestsLinux/ProcessPipeCaptureLinuxTests.swift +++ b/TestsLinux/ProcessPipeCaptureLinuxTests.swift @@ -15,7 +15,6 @@ struct ProcessPipeCaptureLinuxTests { let callbackStarted = DispatchSemaphore(value: 0) let callbackFinished = DispatchSemaphore(value: 0) let releaseCallback = DispatchSemaphore(value: 0) - let closeStarted = DispatchSemaphore(value: 0) let closeFinished = DispatchGroup() let state = BlockedCallbackCloseState() let pipe = Pipe() @@ -26,14 +25,9 @@ struct ProcessPipeCaptureLinuxTests { releaseCallback.wait() }) closeFinished.enter() - let readinessDeadline = DispatchTime.now() + 1 let closer = Thread { defer { closeFinished.leave() } - let measuring = state.beginClose(before: readinessDeadline) - if measuring { closeStarted.signal() } - // A late worker still cleans up the capture without acknowledging an abandoned scenario. _ = capture.finishSynchronously(timeout: 0.05) - if measuring { state.recordCloseReturn() } } var closerLaunched = false defer { @@ -44,14 +38,13 @@ struct ProcessPipeCaptureLinuxTests { } catch { Issue.record(error, "Writer cleanup failed; \(state.diagnostic)") } - let cleanupDeadline = DispatchTime.now() + 1 if !closerLaunched { closer.start() } #expect( - closeFinished.wait(timeout: cleanupDeadline) == .success, + closeFinished.wait(timeout: .now() + 60) == .success, "Close worker cleanup did not finish; \(state.diagnostic)") if callbackEntered { #expect( - callbackFinished.wait(timeout: cleanupDeadline) == .success, + callbackFinished.wait(timeout: .now() + 60) == .success, "Callback cleanup did not finish; \(state.diagnostic)") } } @@ -59,27 +52,28 @@ struct ProcessPipeCaptureLinuxTests { capture.start() try pipe.fileHandleForWriting.write(contentsOf: Data("hello".utf8)) try #require( - callbackStarted.wait(timeout: readinessDeadline) == .success, + callbackStarted.wait(timeout: .now() + 60) == .success, "Callback readiness expired; \(state.diagnostic)") closerLaunched = true closer.start() try #require( - closeStarted.wait(timeout: readinessDeadline) == .success, - "Close worker entry exceeded shared readiness budget; \(state.diagnostic)") - try #require( - closeFinished.wait(timeout: .now() + 0.5) == .success, + closeFinished.wait(timeout: .now() + 60) == .success, "Close did not finish while the callback was blocked; \(state.diagnostic)") - let elapsed = try #require(state.invocationDuration, "Missing close timing; \(state.diagnostic)") - #expect(elapsed < .milliseconds(500), "Close invocation exceeded 500 ms; \(state.diagnostic)") + #expect(!capture.reachedEOF) } @Test func `continuous output does not defeat the capture timeout`() throws { let writerStarted = DispatchSemaphore(value: 0) let stopWriter = DispatchSemaphore(value: 0) - let writerFinished = DispatchSemaphore(value: 0) + let writerFinished = DispatchGroup() let pipe = Pipe() + defer { + stopWriter.signal() + #expect(writerFinished.wait(timeout: .now() + 60) == .success) + try? pipe.fileHandleForWriting.close() + } let writerDescriptor = pipe.fileHandleForWriting.fileDescriptor let writerFlags = Glibc.fcntl(writerDescriptor, F_GETFL) #expect(writerFlags >= 0) @@ -87,7 +81,9 @@ struct ProcessPipeCaptureLinuxTests { let capture = ProcessPipeCapture(pipe: pipe, maxBytes: 1024) capture.start() + writerFinished.enter() DispatchQueue.global().async { + defer { writerFinished.leave() } var blockedSignals = sigset_t() var previousSignals = sigset_t() Glibc.sigemptyset(&blockedSignals) @@ -114,18 +110,10 @@ struct ProcessPipeCaptureLinuxTests { writerStarted.signal() } } - writerFinished.signal() } - #expect(writerStarted.wait(timeout: .now() + 1) == .success) - - let startedAt = ContinuousClock.now - _ = capture.finishSynchronously(timeout: 0.01) - let elapsed = startedAt.duration(to: .now) - stopWriter.signal() - - #expect(elapsed < .milliseconds(500)) - #expect(writerFinished.wait(timeout: .now() + 1) == .success) - try pipe.fileHandleForWriting.close() + try #require(writerStarted.wait(timeout: .now() + 60) == .success) + try Self.waitForCaptureOperation { _ = capture.finishSynchronously(timeout: 0.01) } + #expect(!capture.reachedEOF) } @Test @@ -144,18 +132,14 @@ struct ProcessPipeCaptureLinuxTests { } @Test - func `silent open pipe stops promptly without claiming EOF`() throws { + func `silent pipe stops while the writer remains open without claiming EOF`() throws { let pipe = Pipe() + defer { try? pipe.fileHandleForWriting.close() } let capture = ProcessPipeCapture(pipe: pipe) capture.start() - let startedAt = ContinuousClock.now - capture.stop() - let elapsed = startedAt.duration(to: .now) - - #expect(elapsed < .milliseconds(500)) + try Self.waitForCaptureOperation { capture.stop() } #expect(!capture.reachedEOF) - try pipe.fileHandleForWriting.close() } @Test @@ -180,6 +164,7 @@ struct ProcessPipeCaptureLinuxTests { @Test func `Linux descriptor setup failure closes the read end immediately`() throws { let pipe = Pipe() + defer { try? pipe.fileHandleForWriting.close() } let capture = ProcessPipeCapture(pipe: pipe) capture.start(linuxDescriptorSetup: { descriptor in errno = EMFILE @@ -195,13 +180,10 @@ struct ProcessPipeCaptureLinuxTests { #expect(Glibc.poll(&writerPollDescriptor, 1, 0) == 1) #expect(writerPollDescriptor.revents & Int16(POLLERR) != 0) - let startedAt = ContinuousClock.now - let data = capture.finishSynchronously(timeout: 5) - let elapsed = startedAt.duration(to: .now) - - #expect(data.isEmpty) - #expect(elapsed < .milliseconds(500)) - try pipe.fileHandleForWriting.close() + try Self.waitForCaptureOperation { + // The operation's timeout is outside the hang guard: setup failure must resolve it. + #expect(capture.finishSynchronously(timeout: 600).isEmpty) + } } @Test @@ -288,6 +270,16 @@ struct ProcessPipeCaptureLinuxTests { #expect(String(decoding: data, as: UTF8.self) == "hello") #expect(capture.reachedEOF) } + + private static func waitForCaptureOperation(_ operation: @escaping @Sendable () -> Void) throws { + let finished = DispatchGroup() + finished.enter() + Thread.detachNewThread { + defer { finished.leave() } + operation() + } + try #require(finished.wait(timeout: .now() + 60) == .success, "Capture operation did not complete") + } } /// All callback/closer state is protected by the lock; only the test thread launches the closer. @@ -295,8 +287,6 @@ private final class BlockedCallbackCloseState: @unchecked Sendable { private let lock = NSLock() private var abandoned = false private var callbackEntered = false - private var startedAt: ContinuousClock.Instant? - private var returnedAt: ContinuousClock.Instant? func beginCallback() -> Bool { self.lock.withLock { @@ -306,21 +296,6 @@ private final class BlockedCallbackCloseState: @unchecked Sendable { } } - func beginClose(before deadline: DispatchTime) -> Bool { - self.lock.withLock { - guard !self.abandoned, DispatchTime.now().uptimeNanoseconds < deadline.uptimeNanoseconds else { - return false - } - self.startedAt = .now - return true - } - } - - func recordCloseReturn() { - let returnedAt = ContinuousClock.now - self.lock.withLock { self.returnedAt = returnedAt } - } - func abandon() -> Bool { self.lock.withLock { self.abandoned = true @@ -328,19 +303,9 @@ private final class BlockedCallbackCloseState: @unchecked Sendable { } } - var invocationDuration: Duration? { - self.lock.withLock { - guard let startedAt = self.startedAt, let returnedAt = self.returnedAt else { return nil } - return startedAt.duration(to: returnedAt) - } - } - var diagnostic: String { self.lock.withLock { - let elapsed = self.startedAt.map { $0.duration(to: self.returnedAt ?? .now) } - return "callbackEntered=\(self.callbackEntered), abandoned=\(self.abandoned), " + - "closeEntered=\(self.startedAt != nil), closeReturned=\(self.returnedAt != nil), " + - "closeElapsed=\(String(describing: elapsed))" + "callbackEntered=\(self.callbackEntered), abandoned=\(self.abandoned)" } } } From 705a588307b0ee8acedc8733b5200afe653b2391 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 21:55:37 -0700 Subject: [PATCH 104/122] fix(claude): preserve CLI recovery and isolate quota probes (#4155) Claude CLI: a retryable probe failure no longer revokes an established CLI-availability marker, so the next Auto refresh doesn't surface an unrelated OAuth credentials error; the usage-insights footer can no longer become the plan badge or mark an unfinished quota panel complete; MCP servers are isolated during probes. Fixes #4129; refs #4083. Thanks @sudoHG for the MCP isolation fix from #4112! --- CHANGELOG.md | 4 ++ .../Claude/ClaudeProviderDescriptor.swift | 13 ++++- .../Providers/Claude/ClaudeStatusProbe.swift | 18 +++--- .../Providers/Claude/ClaudeUsageFetcher.swift | 58 ++++++++----------- ...ClaudeCLIBackgroundAvailabilityTests.swift | 32 ++++++++++ .../ClaudeDirectUsageFallbackTests.swift | 2 +- .../ClaudeUsageInsightsTests.swift | 44 ++++++++++++++ docs/claude.md | 6 ++ 8 files changed, 129 insertions(+), 48 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeUsageInsightsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index afde8a16fc..8a41b11577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ ### Fixed +- Claude: retain an established CLI source after transient timeouts and loading stalls so Auto refreshes can retry without an unrelated missing-OAuth-credentials warning (#4129). +- Claude: exclude usage-insights tool names and percentages from quota and account parsing (#4083). +- Claude: keep configured MCP servers out of the direct `/usage` fallback (#4112). Thanks @sudoHG! + - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! - iCloud Sync: recover a live Mac's saves after its records are removed from another Mac, without resetting shared sync state (#4144). - iCloud Sync: register for silent change notifications when the signed build supports push; release provisioning must enable that capability for automatic delivery (#4132). diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 72b61e4cb4..15e959e057 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -1067,6 +1067,12 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { if Task.isCancelled || ClaudeOAuthFetchError.isCancellation(error) { throw error } + // A transient probe failure does not revoke this account's established CLI availability. + if ClaudeUsageFetcher.isRetryableCLIProbeError(error), + ClaudeCLIBackgroundAvailability.isEstablished(backgroundAvailabilityMarker) + { + throw error + } if let backgroundAvailabilityMarker { ClaudeCLIBackgroundAvailability.revoke(backgroundAvailabilityMarker) } @@ -1152,8 +1158,11 @@ enum ClaudeCLIBackgroundAvailability { } static func isEstablished(binary: String, environment: [String: String]) -> Bool { - guard let marker = self.captureMarker(binary: binary, environment: environment) else { return false } - return self.store.contains(marker) + self.isEstablished(self.captureMarker(binary: binary, environment: environment)) + } + + static func isEstablished(_ marker: Marker?) -> Bool { + marker.map { self.store.contains($0) } ?? false } static func allowsOpaqueChildExecution(binary: String, environment: [String: String]) -> Bool { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index c790bc82f2..69d7284f58 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -202,7 +202,11 @@ extension ClaudeStatusProbe { // MARK: - Parsing helpers private static func cleanCapture(_ text: String) -> String { - ClaudeCLIScreen.render(text, preservePlainReports: true) + // Insights contain arbitrary tool names and percentages, not account or quota fields. + let rendered = ClaudeCLIScreen.render(text, preservePlainReports: true) + let marker = "What's contributing to your limits usage?" + guard let insights = rendered.range(of: marker, options: .caseInsensitive) else { return rendered } + return String(rendered[.. String? { guard !text.isEmpty else { return nil } if let explicit = self.extractFirst(pattern: #"(?i)login\s+method:\s*(.+)"#, text: text) { - return ClaudePlan.cliCompatibilityLoginMethod(self.cleanPlan(explicit)) + return ClaudePlan.cliCompatibilityLoginMethod(UsageFormatter.cleanPlanName(explicit)) } // Capture any "Claude <...>" phrase (e.g., Max/Pro/Ultra/Team) to avoid future plan-name churn. // Strip any leading ANSI that may have survived (rare) before matching. @@ -1259,9 +1263,8 @@ extension ClaudeStatusProbe { guard let match, match.numberOfRanges >= 2, let r = Range(match.range(at: 1), in: text) else { return } - let raw = String(text[r]) - let val = ClaudePlan.cliCompatibilityLoginMethod(Self.cleanPlan(raw)) ?? Self.cleanPlan(raw) - candidates.append(val) + let cleaned = UsageFormatter.cleanPlanName(String(text[r])) + candidates.append(ClaudePlan.cliCompatibilityLoginMethod(cleaned) ?? cleaned) } } if let plan = candidates.first(where: { cand in @@ -1273,11 +1276,6 @@ extension ClaudeStatusProbe { return nil } - /// Strips ANSI and stray bracketed codes like "[22m" that can survive CLI output. - private static func cleanPlan(_ text: String) -> String { - UsageFormatter.cleanPlanName(text) - } - private static func dumpIfNeeded(enabled: Bool, reason: String, usage: String, status: String?) { guard enabled else { return } let stamp = ISO8601DateFormatter().string(from: Date()) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index af79639b4d..b7eb6b9a20 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -562,7 +562,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { case .web: return try await self.fetcher.loadViaWebAPI() case .cli: - return try await self.loadViaCLIWithRetry(model: model) + return try await self.loadViaCLIWithRetry(model: model, timeout: ClaudeUsageFetcher.cliProbeTimeout) } } @@ -652,25 +652,14 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { else { throw ClaudeUsageError.parseFailed("Claude CLI is not logged in.") } - do { - return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliAutoProbeTimeout) - } catch { - if error is CancellationError { - throw error - } - guard Self.shouldRetryCLIProbe(after: error) else { throw error } - return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliRetryProbeTimeout) - } + return try await self.loadViaCLIWithRetry(model: model, timeout: ClaudeUsageFetcher.cliAutoProbeTimeout) } - private func loadViaCLIWithRetry(model: String) async throws -> ClaudeUsageSnapshot { + private func loadViaCLIWithRetry(model: String, timeout: TimeInterval) async throws -> ClaudeUsageSnapshot { do { - return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliProbeTimeout) + return try await self.loadViaCLI(model: model, timeout: timeout) } catch { - if error is CancellationError { - throw error - } - guard Self.shouldRetryCLIProbe(after: error) else { throw error } + guard ClaudeUsageFetcher.isRetryableCLIProbeError(error) else { throw error } return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliRetryProbeTimeout) } } @@ -728,26 +717,12 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } private static func shouldTryDirectCLIUsage(after error: Error) -> Bool { - if case ClaudeStatusProbeError.timedOut = error { - return true - } - if case let ClaudeStatusProbeError.parseFailed(message) = error { - let lower = message.lowercased() - return lower.contains("still loading usage") || lower.contains("could not load usage data") - } - let message = error.localizedDescription.lowercased() - return message.contains("timed out") || message.contains("timeout") - } - - private static func shouldRetryCLIProbe(after error: Error) -> Bool { - if case ClaudeStatusProbeError.timedOut = error { + if case let ClaudeStatusProbeError.parseFailed(message) = error, + message.lowercased().contains("could not load usage data") + { return true } - if case let ClaudeStatusProbeError.parseFailed(message) = error { - return message.lowercased().contains("still loading usage") - } - let message = error.localizedDescription.lowercased() - return message.contains("timed out") || message.contains("timeout") + return ClaudeUsageFetcher.isRetryableCLIProbeError(error) } } } @@ -865,6 +840,19 @@ extension ClaudeUsageFetcher { try await StepExecutor(fetcher: self).loadLatestUsage(model: model) } + static func isRetryableCLIProbeError(_ error: Error) -> Bool { + guard !(error is CancellationError) else { return false } + if case ClaudeStatusProbeError.authenticationFailed = error { return false } + if case ClaudeStatusProbeError.timedOut = error { + return true + } + if case let ClaudeStatusProbeError.parseFailed(message) = error { + return message.lowercased().contains("still loading usage") + } + let message = error.localizedDescription.lowercased() + return message.contains("timed out") || message.contains("timeout") + } + public static func isCLIRateLimitError(_ error: Error) -> Bool { ClaudeCLIRateLimitGate.isRateLimitError(error) } @@ -1321,7 +1309,7 @@ extension ClaudeUsageFetcher { let result = try await SubprocessRunner.run( binary: claudeBinary, - arguments: ClaudeCLISession.probeSettingsArguments + ["/usage"], + arguments: ["--strict-mcp-config"] + ClaudeCLISession.probeSettingsArguments + ["/usage"], environment: environment, timeout: timeout, standardInput: FileHandle.nullDevice, diff --git a/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift index 2ea59fcefd..33bf470b87 100644 --- a/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift @@ -3,6 +3,38 @@ import Testing @testable import CodexBarCore struct ClaudeCLIBackgroundAvailabilityTests { + @Test(arguments: [ + (false, ClaudeStatusProbeError.timedOut, false), + (true, ClaudeStatusProbeError.timedOut, true), + (true, ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data."), true), + (true, ClaudeStatusProbeError.authenticationFailed("Synthetic authentication timeout"), false), + ]) + func `transient failures preserve only established background CLI availability`( + established: Bool, failure: ClaudeStatusProbeError, remainsAvailable: Bool) async throws + { + let strategy = self.makeStrategy() + let profile = try self.makeProfile(accountID: "timeout-account") + defer { try? FileManager.default.removeItem(at: profile.root) } + let context = self.makeContext(environment: profile.environment) + await self.withBackgroundGates( + keychainDisabled: !established, + promptMode: .always, + establishedBinary: established ? "/bin/echo" : nil, + establishedEnvironment: context.env, + oauthCredentialsMissing: true) + { + #expect(await strategy.isAvailable(context)) + let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, _, _ in throw failure } + let error = await #expect(throws: ClaudeStatusProbeError.self) { + try await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { + try await strategy.fetch(context) + } + } + #expect(error?.localizedDescription == failure.localizedDescription) + #expect(await strategy.isAvailable(context) == remainsAvailable) + } + } + @Test func `disabled Keychain allows cold background Auto usage without an established marker`() async throws { let strategy = self.makeStrategy() diff --git a/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift b/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift index 04b21f0be9..98ea0754d5 100644 --- a/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift +++ b/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift @@ -64,7 +64,7 @@ struct ClaudeDirectUsageFallbackTests { #expect(!invocations.contains("secret-env")) #expect(!invocations.contains("remote-registration-would-occur")) #expect(self.log.arguments(for: "direct") == [ - "--settings", #"{"remoteControlAtStartup":false}"#, "/usage", + "--strict-mcp-config", "--settings", #"{"remoteControlAtStartup":false}"#, "/usage", ]) let ptyArguments = self.log.arguments(for: "pty") #expect(Array(ptyArguments.dropLast()) == [ diff --git a/Tests/CodexBarTests/ClaudeUsageInsightsTests.swift b/Tests/CodexBarTests/ClaudeUsageInsightsTests.swift new file mode 100644 index 0000000000..697073b41c --- /dev/null +++ b/Tests/CodexBarTests/ClaudeUsageInsightsTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeUsageInsightsTests { + private let insights = """ + What's contributing to your limits usage? + Approximate, based on local sessions on this machine — does not include other devices or claude.ai. + Last 24h · 100 requests · 2 sessions + 30% of your usage was at >150k context + 20% of your usage was while 4+ sessions ran in parallel + Top MCP servers: Claude Browser 10%, claude-in-chrome 5% + Last 7d · 1000 requests · 20 sessions + Top skills: Claude Max 20% + """ + + @Test + func `quota panel ignores user named insights when extracting identity`() throws { + let snapshot = try ClaudeStatusProbe.parse(text: """ + Settings Status Config Usage Stats + Current session + ██████ 13% used + Resets 2:20pm (Asia/Singapore) + Current week (all models) + █ 2% used + Resets Oct 3 at 8am (Asia/Singapore) + \(self.insights) + """) + #expect(snapshot.sessionPercentLeft == 87) + #expect(snapshot.weeklyPercentLeft == 98) + #expect(snapshot.loginMethod == nil) + #expect(snapshot.primaryResetDescription == "Resets 2:20pm (Asia/Singapore)") + } + + @Test + func `insights without quotas never become numeric limits`() { + #expect(throws: ClaudeStatusProbeError.self) { + try ClaudeStatusProbe.parse(text: """ + You are currently using your subscription to power your Claude Code usage + \(self.insights) + """) + } + } +} diff --git a/docs/claude.md b/docs/claude.md index d033540b63..7a8ba9617e 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -372,7 +372,11 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential - The bundled watchdog is discovered only in the running executable's resolved app bundle; launching through a CLI symlink preserves that association. - Default behavior: exit after each probe; Debug → "Keep CLI sessions alive" keeps it running between probes. - Both PTY probes and the non-PTY `/usage` fallback pass `--settings '{"remoteControlAtStartup":false}'` to disable Remote Control startup for the probe process. This process-local override leaves the user's saved settings unchanged; Claude's managed-settings policy still applies. +- Both launches use `--strict-mcp-config` to skip the user's configured MCP servers. Saved nonessential-traffic restrictions remain in force. - A PTY timeout or usage-loading failure can trigger the non-PTY `/usage` fallback. Cancellation and rate limits stop the probe; a subscription-only notice from the fallback takes precedence over the original PTY failure. +- Transient CLI timeouts and loading stalls preserve availability already established for that account, so a later + Auto refresh can retry CLI instead of stopping at missing OAuth credentials. They do not establish availability + for a previously unverified account; the existing Keychain and prompt policies still apply. - Probe working directory: `~/Library/Application Support/CodexBar/ClaudeProbe` with local Claude settings that disable deep-link URL handler registration during headless probes. - After transient probes exit, CodexBar removes Claude Code `.jsonl` session artifacts for that dedicated @@ -391,6 +395,8 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential - Extracts percent left/used and reset text near those headers. - When a reset date cannot be parsed, the menu preserves its description and normalizes leading `Reset` or `Resets` labels once, including scoped weekly limits. - Parses `Account:` and `Org:` lines when present. + - Excludes the "What's contributing to your limits usage?" insights section from quota and identity parsing. + User-defined tool names and usage-share percentages cannot become a plan badge or quota value. - A successful CLI quota read keeps the menu's Switch Account action even when optional identity fields are absent. Restored history and failed refreshes do not count as a successful sign-in. - Surfaces CLI errors (e.g. token expired) directly. - Some Education and organization-managed subscriptions return only a subscription notice, with no numeric From 627130e34ed5c9025716cd1e34b466d61b0c1269 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 21:55:53 -0700 Subject: [PATCH 105/122] fix(refresh): detect Codex activity from rollout timestamps (#4156) Agent-aware Adaptive refresh treats recent Codex rollout modification times as activity without requiring a recognized live process (no content reads, no new persistence, existing consent, power, budget, and future-date limits), so a managed Codex daemon no longer leaves refresh on the long-idle cadence. Fixes #4119. --- CHANGELOG.md | 1 + Sources/CodexBar/AgentSessionsStore.swift | 51 ++- .../LocalAgentSessionScanner.swift | 88 +++-- .../AdaptiveRefreshPerformanceTests.swift | 15 +- .../AdaptiveRolloutActivityTests.swift | 337 ++++++++++++++++++ ...entSessionVisibilityNativeProofTests.swift | 2 +- .../AgentSessionsStoreSchedulerTests.swift | 21 +- .../CodexSessionRolloutTests.swift | 3 +- Tests/CodexBarTests/StayAwakeTests.swift | 4 +- docs/predictive-refresh-policy.md | 26 +- docs/refresh-loop.md | 20 +- 11 files changed, 501 insertions(+), 67 deletions(-) create mode 100644 Tests/CodexBarTests/AdaptiveRolloutActivityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a4bbb225..98b0acff06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - iCloud Sync: recover a live Mac's saves after its records are removed from another Mac, without resetting shared sync state (#4144). - iCloud Sync: register for silent change notifications when the signed build supports push; release provisioning must enable that capability for automatic delivery (#4132). - Claude: answer current and legacy CLI trust dialogs only in the isolated probe directory, reject redirected paths, and wait for real quota values when usage insights are visible (#4115, #4083). Thanks @sudoHG! +- Agent-aware Adaptive: use recent Codex rollout modification times to keep refreshes at five minutes even without a recognized live process, preserving consent and scan limits without reading rollout contents for activity (#4119, #4118). Thanks @hhh2210! ## 0.70.0 — 2026-09-29 diff --git a/Sources/CodexBar/AgentSessionsStore.swift b/Sources/CodexBar/AgentSessionsStore.swift index f493296cf3..343b755f37 100644 --- a/Sources/CodexBar/AgentSessionsStore.swift +++ b/Sources/CodexBar/AgentSessionsStore.swift @@ -34,10 +34,12 @@ typealias AgentSessionRemoteRefreshGate = AgentSessionRefreshGate @MainActor @Observable final class AgentSessionsStore { - typealias LocalScan = @Sendable (_ includeFileOnlySessions: Bool) async -> [AgentSession] + typealias LocalScan = @Sendable ( + _ includeFileOnlySessions: Bool, _ includeRolloutActivity: Bool) async -> LocalAgentSessionScanner.ScanResult typealias RemoteHostDiscovery = @Sendable () async -> [String] typealias RemoteFetch = @Sendable (_ hosts: [String]) async -> [RemoteSessionHostResult] typealias PeriodicSleep = @Sendable (_ duration: Duration) async throws -> Void + typealias PowerState = @Sendable () -> (lowPowerModeEnabled: Bool, thermalState: ProcessInfo.ThermalState) struct SchedulerState: Equatable { let isStarted: Bool @@ -55,6 +57,7 @@ final class AgentSessionsStore { private let powerAssertion: AgentSessionPowerAssertion private nonisolated(unsafe) var powerAssertionID: UInt32? // Read last in deinit: its getter escapes self. private let periodicSleep: PeriodicSleep + private let powerState: PowerState @ObservationIgnored private var localPeriodicTask: Task? @ObservationIgnored private var remotePeriodicTask: Task? @ObservationIgnored private var localImmediateTask: Task? @@ -76,8 +79,10 @@ final class AgentSessionsStore { { self.init( settings: settings, - localScan: { includeFileOnlySessions in - await localScanner.scan(includeFileOnlySessions: includeFileOnlySessions) + localScan: { includeFileOnlySessions, includeRolloutActivity in + await localScanner.scanWithActivity( + includeFileOnlySessions: includeFileOnlySessions, + includeRolloutActivity: includeRolloutActivity) }, remoteFetcher: remoteFetcher) } @@ -102,6 +107,10 @@ final class AgentSessionsStore { remoteFetch: @escaping RemoteFetch, remoteFetcher: RemoteSessionFetcher = RemoteSessionFetcher(), powerAssertion: AgentSessionPowerAssertion = .live, + powerState: @escaping PowerState = { + let info = ProcessInfo.processInfo + return (info.isLowPowerModeEnabled, info.thermalState) + }, periodicSleep: @escaping PeriodicSleep = { duration in try await Task.sleep(for: duration) }) { self.settings = settings @@ -110,6 +119,7 @@ final class AgentSessionsStore { self.remoteFetch = remoteFetch self.remoteFetcher = remoteFetcher self.powerAssertion = powerAssertion + self.powerState = powerState self.periodicSleep = periodicSleep } @@ -141,8 +151,8 @@ final class AgentSessionsStore { hasRemoteImmediateTask: self.remoteImmediateTask != nil) } - nonisolated static func latestActivityAt(in sessions: [AgentSession]) -> Date? { - sessions.compactMap(\.lastActivityAt).max() + nonisolated static func latestActivityAt(in sessions: [AgentSession], rolloutActivityAt: Date? = nil) -> Date? { + (sessions.compactMap(\.lastActivityAt) + [rolloutActivityAt].compactMap(\.self)).max() } nonisolated static func shouldScanLocally( @@ -229,10 +239,14 @@ final class AgentSessionsStore { await task?.value } - func applyLocalScanResult(_ sessions: [AgentSession], updatedAt: Date = Date()) { + func applyLocalScanResult( + _ sessions: [AgentSession], rolloutActivityAt: Date? = nil, updatedAt: Date = Date()) + { let wasKeepingAwake = self.isKeepingAwake self.updatePowerAssertion(hasLiveSession: sessions.contains { ($0.pid ?? 0) > 0 }) - let latestActivityAt = Self.latestActivityAt(in: sessions) + let latestActivityAt = Self.latestActivityAt( + in: sessions, + rolloutActivityAt: self.settings.adaptiveActivityScanningEnabled ? rolloutActivityAt : nil) let effectiveSessions = self.settings.agentSessionsEnabled ? sessions : [] // Rescans that reproduce the current content must not publish: `onUpdate` invalidates // menus, and a redundant invalidation landing while the user hovers an Overview row's @@ -322,39 +336,40 @@ final class AgentSessionsStore { private func requestLocalRefresh() { guard self.isStarted, self.localMonitoringEnabled, self.localImmediateTask == nil else { return } - let processInfo = ProcessInfo.processInfo - guard self.settings.stayAwakeEnabled || Self.shouldScanLocally( + let powerState = self.powerState() + let activityScanAllowed = Self.shouldScanLocally( agentSessionsEnabled: self.settings.agentSessionsEnabled, adaptiveActivityScanningEnabled: self.settings.adaptiveActivityScanningEnabled, - lowPowerModeEnabled: processInfo.isLowPowerModeEnabled, - thermalState: processInfo.thermalState) - else { return } + lowPowerModeEnabled: powerState.lowPowerModeEnabled, + thermalState: powerState.thermalState) + guard self.settings.stayAwakeEnabled || activityScanAllowed else { return } guard let generation = self.localRefreshGate.begin() else { return } let includeFileOnlySessions = self.settings.agentSessionsEnabled + let includeRolloutActivity = self.settings.adaptiveActivityScanningEnabled && activityScanAllowed let localScan = self.localScan self.localImmediateTask = Task { [weak self] in guard !Task.isCancelled else { - self?.completeLocalRefresh(generation: generation, sessions: nil, wasCancelled: true) + self?.completeLocalRefresh(generation: generation, result: nil, wasCancelled: true) return } - let sessions = await localScan(includeFileOnlySessions) + let result = await localScan(includeFileOnlySessions, includeRolloutActivity) self?.completeLocalRefresh( generation: generation, - sessions: sessions, + result: result, wasCancelled: Task.isCancelled) } } private func completeLocalRefresh( generation: Int, - sessions: [AgentSession]?, + result: LocalAgentSessionScanner.ScanResult?, wasCancelled: Bool) { self.localImmediateTask = nil let outcome = self.localRefreshGate.finish(generation: generation) - if !wasCancelled, outcome.shouldPublish, self.isStarted, self.localMonitoringEnabled, let sessions { - self.applyLocalScanResult(sessions) + if !wasCancelled, outcome.shouldPublish, self.isStarted, self.localMonitoringEnabled, let result { + self.applyLocalScanResult(result.sessions, rolloutActivityAt: result.latestRolloutActivityAt) } if outcome.shouldRetry, self.isStarted, self.localMonitoringEnabled { self.requestLocalRefresh() diff --git a/Sources/CodexBarCore/LocalAgentSessionScanner.swift b/Sources/CodexBarCore/LocalAgentSessionScanner.swift index ec8e196dbb..d5cbb706bf 100644 --- a/Sources/CodexBarCore/LocalAgentSessionScanner.swift +++ b/Sources/CodexBarCore/LocalAgentSessionScanner.swift @@ -120,6 +120,17 @@ public struct LocalAgentSessionScanner: Sendable { typealias ProcessEnvironmentProvider = @Sendable ([Int32]) async -> [Int32: [String: String]] typealias AppServerTrustValidator = @Sendable (AgentProcessRecord) -> Bool + public struct ScanResult: Sendable { + public let sessions: [AgentSession] + public let latestRolloutActivityAt: Date? + + public init(sessions: [AgentSession] = [], latestRolloutActivityAt: Date? = nil) { + self.sessions = sessions + self.latestRolloutActivityAt = latestRolloutActivityAt + } + } + + private typealias RolloutCandidate = (url: URL, modifiedAt: Date) private struct Rollout: Sendable { let url: URL let modifiedAt: Date @@ -142,6 +153,8 @@ public struct LocalAgentSessionScanner: Sendable { private let cwdProvider: CWDProvider? private let processEnvironmentProvider: ProcessEnvironmentProvider? private let appServerTrustValidator: AppServerTrustValidator + private let rolloutMetadataReader: @Sendable (URL) -> CodexRolloutMetadata? + private let directoryScanStartedAt: @Sendable () -> Date private let didVisitDirectoryEntry: (@Sendable () -> Void)? public init(config: SessionScanConfig = SessionScanConfig()) { @@ -156,6 +169,10 @@ public struct LocalAgentSessionScanner: Sendable { appServerTrustValidator: @escaping AppServerTrustValidator = { ChatGPTCodexProcessTrust.isTrusted($0.pid) }, + rolloutMetadataReader: @escaping @Sendable (URL) -> CodexRolloutMetadata? = { + CodexRolloutFirstLineParser.read(from: $0) + }, + directoryScanStartedAt: @escaping @Sendable () -> Date = Date.init, didVisitDirectoryEntry: (@Sendable () -> Void)? = nil) { self.config = config @@ -163,6 +180,8 @@ public struct LocalAgentSessionScanner: Sendable { self.cwdProvider = cwdProvider self.processEnvironmentProvider = processEnvironmentProvider self.appServerTrustValidator = appServerTrustValidator + self.rolloutMetadataReader = rolloutMetadataReader + self.directoryScanStartedAt = directoryScanStartedAt self.didVisitDirectoryEntry = didVisitDirectoryEntry } @@ -171,19 +190,35 @@ public struct LocalAgentSessionScanner: Sendable { now: Date = Date(), environment: [String: String] = ProcessInfo.processInfo.environment, includeFileOnlySessions: Bool = true) async -> [AgentSession] + { + await self.scanWithActivity( + now: now, + environment: environment, + includeFileOnlySessions: includeFileOnlySessions, + includeRolloutActivity: false).sessions + } + + /// Activity is a timestamp projection, never a file-only session or an identity assertion. + @concurrent + public func scanWithActivity( + now: Date = Date(), + environment: [String: String] = ProcessInfo.processInfo.environment, + includeFileOnlySessions: Bool, + includeRolloutActivity: Bool) async -> ScanResult { let allProcesses = await self.processRecords(environment: environment) let processes = Array(AgentSessionCorrelation.newestProcessesFirst( AgentPSOutputParser.agentProcesses(from: allProcesses)) .prefix(max(0, self.config.maxProcessCount))) let homeDirectory = URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true) - let trustedCodexAppServerPresent = AgentPSOutputParser.hasTrustedChatGPTCodexAppServer( + let readRolloutMetadata = includeFileOnlySessions || !includeRolloutActivity + let trustedCodexAppServerPresent = readRolloutMetadata && AgentPSOutputParser.hasTrustedChatGPTCodexAppServer( in: allProcesses, validator: self.appServerTrustValidator) guard Self.shouldScanSessionMetadata( hasAgentProcesses: !processes.isEmpty, includeFileOnlySessions: includeFileOnlySessions, - hasTrustedCodexAppServer: trustedCodexAppServerPresent) - else { return [] } + hasTrustedCodexAppServer: trustedCodexAppServerPresent) || includeRolloutActivity + else { return ScanResult() } let codexAppServerPresent = AgentPSOutputParser.hasCodexAppServer(in: allProcesses) || trustedCodexAppServerPresent let cwdByPID = await self.cwdByPID(processes.map(\.pid), environment: environment) @@ -199,6 +234,7 @@ public struct LocalAgentSessionScanner: Sendable { timeLimit: includeFileOnlySessions ? self.config.directoryScanBudget : min(self.config.directoryScanBudget, self.config.adaptiveDirectoryScanBudget), + startedAt: self.directoryScanStartedAt(), didVisitEntry: self.didVisitDirectoryEntry) var piFamilyDirectoryBudget = directoryBudget let piFamilySessions = PiFamilySessionScanner.scan( @@ -211,20 +247,18 @@ public struct LocalAgentSessionScanner: Sendable { config: self.config), directoryBudget: &piFamilyDirectoryBudget) let includeUnmatchedCodexRollouts = includeFileOnlySessions || trustedCodexAppServerPresent - let rollouts: [Rollout] = if includeUnmatchedCodexRollouts || !codexCWDs.isEmpty { - self.codexRollouts( - now: now, - codexHomeDirectory: codexHomeDirectory, - matchingCWDs: includeUnmatchedCodexRollouts ? nil : codexCWDs, - directoryBudget: &directoryBudget) - } else { - [] - } - let threadMetadata = Self.codexThreadMetadata( + let enrichRollouts = readRolloutMetadata && (includeUnmatchedCodexRollouts || !codexCWDs.isEmpty) + var candidates = enrichRollouts ? self.codexRolloutCandidates( + now: now, codexHomeDirectory: codexHomeDirectory, directoryBudget: &directoryBudget) : [] + let rollouts = enrichRollouts ? self.codexRollouts( + candidates: candidates, + matchingCWDs: includeUnmatchedCodexRollouts ? nil : codexCWDs, + directoryBudget: &directoryBudget) : [] + let threadMetadata = rollouts.isEmpty ? [:] : Self.codexThreadMetadata( rollouts: rollouts, codexHomeDirectory: codexHomeDirectory, environment: environment) - return self.sessions( + let sessions = self.sessions( processes: processes, cwdByPID: cwdByPID, rollouts: rollouts, @@ -237,6 +271,14 @@ public struct LocalAgentSessionScanner: Sendable { threadMetadata: threadMetadata, piFamilySessions: piFamilySessions), directoryBudget: &directoryBudget) + if includeRolloutActivity, !enrichRollouts { + // Preserve the shared budget for process-backed Claude activity before the new file-only signal. + candidates = self.codexRolloutCandidates( + now: now, codexHomeDirectory: codexHomeDirectory, directoryBudget: &directoryBudget) + } + return ScanResult( + sessions: sessions, + latestRolloutActivityAt: includeRolloutActivity ? candidates.first?.modifiedAt : nil) } /// Returns the project directories of live Pi processes so historical cost scans can resolve @@ -527,11 +569,10 @@ public struct LocalAgentSessionScanner: Sendable { #endif } - private func codexRollouts( + private func codexRolloutCandidates( now: Date, codexHomeDirectory: URL, - matchingCWDs: [String]?, - directoryBudget: inout DirectoryMetadataScanBudget) -> [Rollout] + directoryBudget: inout DirectoryMetadataScanBudget) -> [RolloutCandidate] { let root = codexHomeDirectory.appendingPathComponent("sessions", isDirectory: true) let calendar = Calendar(identifier: .gregorian) @@ -541,7 +582,7 @@ public struct LocalAgentSessionScanner: Sendable { formatter.dateFormat = "yyyy/MM/dd" let fileManager = FileManager.default - let candidates = days.flatMap { day -> [(url: URL, modifiedAt: Date)] in + let candidates = days.flatMap { day -> [RolloutCandidate] in let directory = root.appendingPathComponent(formatter.string(from: day), isDirectory: true) let files = directoryBudget.files(in: directory, fileManager: fileManager) return directoryBudget.compactMapWhileTimeRemains(files) { file in @@ -555,12 +596,19 @@ public struct LocalAgentSessionScanner: Sendable { now: now)) } }.sorted { $0.modifiedAt > $1.modifiedAt } + return Array(candidates.prefix(max(0, self.config.maxCodexRolloutCount))) + } + private func codexRollouts( + candidates: [RolloutCandidate], + matchingCWDs: [String]?, + directoryBudget: inout DirectoryMetadataScanBudget) -> [Rollout] + { var remainingCWDs = matchingCWDs ?? [] var rollouts: [Rollout] = [] - for candidate in candidates.prefix(max(0, self.config.maxCodexRolloutCount)) { + for candidate in candidates { guard directoryBudget.hasTimeRemaining() else { break } - guard let metadata = CodexRolloutFirstLineParser.read(from: candidate.url) else { continue } + guard let metadata = self.rolloutMetadataReader(candidate.url) else { continue } rollouts.append(Rollout(url: candidate.url, modifiedAt: candidate.modifiedAt, metadata: metadata)) if let index = remainingCWDs.firstIndex(where: { AgentSessionCorrelation.codexWorkingDirectoriesMatch(metadata.cwd, $0) diff --git a/Tests/CodexBarTests/AdaptiveRefreshPerformanceTests.swift b/Tests/CodexBarTests/AdaptiveRefreshPerformanceTests.swift index 2e3f2914ec..acff62dc35 100644 --- a/Tests/CodexBarTests/AdaptiveRefreshPerformanceTests.swift +++ b/Tests/CodexBarTests/AdaptiveRefreshPerformanceTests.swift @@ -88,20 +88,31 @@ struct AdaptiveRefreshPerformanceTests { let spy = AdaptiveLocalScanSpy() let store = AgentSessionsStore( settings: settings, - localScan: { includeFileOnlySessions in - await spy.scan(includeFileOnlySessions: includeFileOnlySessions) + localScan: { includeFileOnlySessions, _ in + await .init(sessions: spy.scan(includeFileOnlySessions: includeFileOnlySessions)) }) + store.start() + defer { store.stop() } + store.settingsDidChange(remoteConfigurationChanged: false) await store.refreshLocal() + #expect(!store.schedulerState.hasLocalPeriodicTask) + #expect(!store.schedulerState.hasLocalImmediateTask) #expect(await spy.callCount == 0) settings.refreshFrequency = .adaptiveAgentAware settings.adaptiveActivityScanConsent = .undecided + store.settingsDidChange(remoteConfigurationChanged: false) await store.refreshLocal() + #expect(!store.schedulerState.hasLocalPeriodicTask) + #expect(!store.schedulerState.hasLocalImmediateTask) #expect(await spy.callCount == 0) settings.adaptiveActivityScanConsent = .declined + store.settingsDidChange(remoteConfigurationChanged: false) await store.refreshLocal() + #expect(!store.schedulerState.hasLocalPeriodicTask) + #expect(!store.schedulerState.hasLocalImmediateTask) #expect(await spy.callCount == 0) } } diff --git a/Tests/CodexBarTests/AdaptiveRolloutActivityTests.swift b/Tests/CodexBarTests/AdaptiveRolloutActivityTests.swift new file mode 100644 index 0000000000..7be899fc97 --- /dev/null +++ b/Tests/CodexBarTests/AdaptiveRolloutActivityTests.swift @@ -0,0 +1,337 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct AdaptiveRolloutActivityTests { + private static let now = Date(timeIntervalSince1970: 1_790_769_600) + private static let daemon = "4234 1 Mon Sep 28 09:03:00 2026 " + + "/synthetic/codex-home/packages/app-server-daemon/releases/fixture/bin/codex " + + "app-server --listen unix:// --managed-daemon" + + @Test(arguments: ["", Self.daemon], [30.0, 300.0, 6 * 60.0, 31 * 60.0]) + func `rollout freshness selects cadence without a recognized process`( + process: String, + age: TimeInterval) async throws + { + let fixture = try Fixture(age: age) + defer { fixture.remove() } + let scanner = LocalAgentSessionScanner( + processOutputProvider: { _ in process }, + cwdProvider: { _, _ in [:] }, + appServerTrustValidator: { _ in false }, + directoryScanStartedAt: { .distantFuture }) + let result = await fixture.scan(scanner) + let activity = result.latestRolloutActivityAt + let decision = UsageStore.adaptiveRefreshDecision( + now: Self.now, + lastMenuOpenAt: nil, + lastCodingActivityAt: activity, + lowPowerModeEnabled: false, + thermalState: .nominal) + + #expect(decision.reason == (age < 300 ? .codingActivity : .longIdle)) + #expect(decision.delay == .seconds(age < 300 ? 300 : 1800)) + } + + @Test(arguments: [ + Self.daemon, + "4234 1 Mon Sep 28 09:03:00 2026 /usr/local/bin/codex exec", + "4234 1 Mon Sep 28 09:03:00 2026 /Applications/ChatGPT.app/Contents/Resources/codex app-server", + ]) + func `activity only never reads rollout contents or asserts a process identity`(process: String) async throws { + let fixture = try Fixture(age: 30) + defer { fixture.remove() } + try Data("not session metadata".utf8).write(to: fixture.rollout) + try fixture.setAge(30) + let scanner = LocalAgentSessionScanner( + processOutputProvider: { _ in process }, + cwdProvider: { _, _ in [:] }, + appServerTrustValidator: { _ in + Issue.record("Activity does not need a signature assertion") + return false + }, + rolloutMetadataReader: { _ in + Issue.record("Activity must not read rollout contents") + return nil + }, + directoryScanStartedAt: { .distantFuture }) + let result = await fixture.scan(scanner) + + #expect(result.latestRolloutActivityAt == Self.now.addingTimeInterval(-30)) + #expect(result.sessions.allSatisfy { $0.lastActivityAt == nil && $0.transcriptPath == nil }) + } + + @Test + func `future rollouts keep the first clamp across scans and age out`() async throws { + let fixture = try Fixture(age: -3600) + defer { fixture.remove() } + let scanner = Self.scanner() + let first = await fixture.scan(scanner) + let later = Self.now.addingTimeInterval(360) + let repeated = await fixture.scan(scanner, now: later) + + #expect(first.latestRolloutActivityAt == Self.now) + #expect(repeated.latestRolloutActivityAt == Self.now) + let decision = UsageStore.adaptiveRefreshDecision( + now: later, + lastMenuOpenAt: nil, + lastCodingActivityAt: repeated.latestRolloutActivityAt, + lowPowerModeEnabled: false, + thermalState: .nominal) + #expect(decision.reason == .longIdle) + #expect(decision.delay == .seconds(1800)) + } + + @Test(arguments: ["entries", "depth", "candidates", "time"]) + func `exhausted rollout budgets produce no activity`(limit: String) async throws { + let fixture = try Fixture(age: 30) + defer { fixture.remove() } + var config = SessionScanConfig() + switch limit { + case "entries": config.maxDirectoryEntryCount = 0 + case "depth": config.maxDirectoryDepth = 0 + case "candidates": config.maxCodexRolloutCount = 0 + default: config.adaptiveDirectoryScanBudget = 0 + } + let result = await fixture.scan(Self.scanner( + config: config, directoryScanStartedAt: limit == "time" ? .distantPast : .distantFuture)) + #expect(result.latestRolloutActivityAt == nil) + #expect(result.sessions.isEmpty) + } + + @Test + func `rollout activity respects the entry cap without a process`() async throws { + let fixture = try Fixture(age: 30) + defer { fixture.remove() } + for index in 0..<12 { + let url = fixture.rollout.deletingLastPathComponent().appendingPathComponent("rollout-\(index).jsonl") + try FileManager.default.copyItem(at: fixture.rollout, to: url) + try FileManager.default.setAttributes( + [.modificationDate: Self.now.addingTimeInterval(-30)], ofItemAtPath: url.path) + } + let visits = Counter() + let scanner = LocalAgentSessionScanner( + config: SessionScanConfig(maxProcessCount: 0, maxDirectoryEntryCount: 3), + processOutputProvider: { _ in "" }, + cwdProvider: { _, _ in [:] }, + directoryScanStartedAt: { .distantFuture }, + didVisitDirectoryEntry: { visits.increment() }) + let result = await fixture.scan(scanner) + #expect(result.latestRolloutActivityAt == Self.now.addingTimeInterval(-30)) + #expect(visits.count == 3) + } + + @Test + func `yesterday rollouts count while other filenames and deeper directories do not`() async throws { + let fixture = try Fixture(age: 30) + defer { fixture.remove() } + let today = fixture.rollout.deletingLastPathComponent() + let yesterday = try #require(Calendar(identifier: .gregorian).date(byAdding: .day, value: -1, to: Self.now)) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy/MM/dd" + let directory = fixture.root.appendingPathComponent("codex-home/sessions/\(formatter.string(from: yesterday))") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.moveItem(at: fixture.rollout, to: directory.appendingPathComponent("rollout-old.jsonl")) + for name in ["other.jsonl", "rollout-ignore.txt", "nested/rollout-ignore.jsonl"] { + let url = today.appendingPathComponent(name) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data("unrelated".utf8).write(to: url) + try FileManager.default.setAttributes([.modificationDate: Self.now], ofItemAtPath: url.path) + } + let result = await fixture.scan(Self.scanner()) + #expect(result.latestRolloutActivityAt == Self.now.addingTimeInterval(-30)) + } + + @Test(arguments: [1, 2]) + func `file only codex activity cannot starve a live claude transcript`(entryLimit: Int) async throws { + let fixture = try Fixture(age: 6 * 60) + defer { fixture.remove() } + let cwd = "/synthetic/claude-project" + let directory = fixture.root.appendingPathComponent(".claude/projects") + .appendingPathComponent(ClaudeSessionProjectMapper.escapedCWD(cwd)) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let transcript = directory.appendingPathComponent("active.jsonl") + try Data("synthetic transcript".utf8).write(to: transcript) + let activity = Self.now.addingTimeInterval(-30) + try FileManager.default.setAttributes([.modificationDate: activity], ofItemAtPath: transcript.path) + let visits = Counter() + let scanner = LocalAgentSessionScanner( + config: SessionScanConfig(maxDirectoryEntryCount: entryLimit), + processOutputProvider: { _ in "4234 1 Mon Sep 28 09:03:00 2026 /usr/local/bin/claude" }, + cwdProvider: { _, _ in [4234: cwd] }, + directoryScanStartedAt: { .distantFuture }, + didVisitDirectoryEntry: { visits.increment() }) + let result = await fixture.scan(scanner) + let latestActivity = AgentSessionsStore.latestActivityAt( + in: result.sessions, rolloutActivityAt: result.latestRolloutActivityAt) + let decision = UsageStore.adaptiveRefreshDecision( + now: Self.now, + lastMenuOpenAt: nil, + lastCodingActivityAt: latestActivity, + lowPowerModeEnabled: false, + thermalState: .nominal) + + #expect(result.sessions.first?.lastActivityAt == activity) + #expect(decision.reason == .codingActivity) + #expect(decision.delay == .seconds(300)) + #expect(visits.count == entryLimit) + } + + @Test + func `session enrichment and activity share one directory walk`() async throws { + let fixture = try Fixture(age: 30) + defer { fixture.remove() } + let visits = Counter() + let reads = Counter() + let scanner = LocalAgentSessionScanner( + processOutputProvider: { _ in "" }, + cwdProvider: { _, _ in [:] }, + rolloutMetadataReader: { url in + reads.increment() + return CodexRolloutFirstLineParser.read(from: url) + }, + directoryScanStartedAt: { .distantFuture }, + didVisitDirectoryEntry: { visits.increment() }) + let result = await scanner.scanWithActivity( + now: Self.now, + environment: fixture.environment, + includeFileOnlySessions: true, + includeRolloutActivity: true) + + #expect(visits.count == 1) + #expect(reads.count == 1) + #expect(result.sessions.count == 1) + #expect(result.latestRolloutActivityAt == Self.now.addingTimeInterval(-30)) + } + + @Test + @MainActor + func `rollout signal publishes without sessions or power assertions and obeys consent`() async { + let settings = testSettingsStore(suiteName: "AdaptiveRolloutActivityTests-store") + settings.refreshFrequency = .adaptiveAgentAware + settings.adaptiveActivityScanConsent = .allowed + settings.stayAwakeEnabled = true + let store = AgentSessionsStore( + settings: settings, + localScan: { includeSessions, includeActivity in + #expect(!includeSessions) + return .init(latestRolloutActivityAt: includeActivity ? Self.now : nil) + }, + remoteHostDiscovery: { + Issue.record("Activity must not discover remote hosts") + return [] + }, + remoteFetch: { _ in + Issue.record("Activity must not fetch remote sessions") + return [] + }, + powerAssertion: .init(acquire: { + Issue.record("File activity must not acquire a power assertion") + return nil + }, release: { _ in }), + powerState: { (false, .nominal) }) + store.start() + defer { store.stop() } + await store.refreshLocal() + #expect(store.latestLocalActivityAt == Self.now) + #expect(store.localSessions.isEmpty) + #expect(!store.isKeepingAwake) + + settings.adaptiveActivityScanConsent = .declined + store.settingsDidChange(remoteConfigurationChanged: false) + await store.refreshLocal() + #expect(store.latestLocalActivityAt == nil) + #expect(store.localSessions.isEmpty) + } + + @Test(arguments: [false, true], [ProcessInfo.ThermalState.nominal, .fair, .serious, .critical]) + @MainActor + func `stay awake respects power constraints for rollout activity`( + lowPowerModeEnabled: Bool, thermalState: ProcessInfo.ThermalState) async + { + let settings = testSettingsStore(suiteName: "AdaptiveRolloutActivityTests-power") + settings.refreshFrequency = .adaptiveAgentAware + settings.adaptiveActivityScanConsent = .allowed + settings.stayAwakeEnabled = true + let calls = Counter() + let expectedActivity = !lowPowerModeEnabled && thermalState != .serious && thermalState != .critical + let store = AgentSessionsStore( + settings: settings, + localScan: { includeSessions, includeActivity in + calls.increment() + #expect(!includeSessions) + #expect(includeActivity == expectedActivity) + return .init() + }, + remoteHostDiscovery: { [] }, + remoteFetch: { _ in [] }, + powerState: { (lowPowerModeEnabled, thermalState) }) + store.start() + defer { store.stop() } + await store.refreshLocal() + #expect(calls.count == 1) + } + + private static func scanner( + config: SessionScanConfig = SessionScanConfig(), directoryScanStartedAt: Date = .distantFuture) + -> LocalAgentSessionScanner + { + LocalAgentSessionScanner( + config: config, + processOutputProvider: { _ in "" }, + cwdProvider: { _, _ in [:] }, + directoryScanStartedAt: { directoryScanStartedAt }) + } + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + func increment() { self.lock.withLock { self.value += 1 } } + var count: Int { + self.lock.withLock { self.value } + } + } + + private struct Fixture { + let root: URL + let rollout: URL + var environment: [String: String] { + ["HOME": self.root.path, "CODEX_HOME": self.root.appendingPathComponent("codex-home").path, "PATH": ""] + } + + init(age: TimeInterval) throws { + self.root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy/MM/dd" + let directory = self.root.appendingPathComponent("codex-home/sessions/\(formatter.string(from: Self.now))") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + self.rollout = directory.appendingPathComponent("rollout-fixture.jsonl") + let source = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") + try FileManager.default.copyItem(at: source, to: self.rollout) + try self.setAge(age) + } + + func setAge(_ age: TimeInterval) throws { + try FileManager.default.setAttributes( + [.modificationDate: Self.now.addingTimeInterval(-age)], ofItemAtPath: self.rollout.path) + } + + func scan(_ scanner: LocalAgentSessionScanner, now: Date = AdaptiveRolloutActivityTests.now) async + -> LocalAgentSessionScanner.ScanResult + { + await scanner.scanWithActivity( + now: now, environment: self.environment, includeFileOnlySessions: false, includeRolloutActivity: true) + } + + private static var now: Date { + AdaptiveRolloutActivityTests.now + } + + func remove() { try? FileManager.default.removeItem(at: self.root) } + } +} diff --git a/Tests/CodexBarTests/AgentSessionVisibilityNativeProofTests.swift b/Tests/CodexBarTests/AgentSessionVisibilityNativeProofTests.swift index 3da5887f28..ebba23a5ae 100644 --- a/Tests/CodexBarTests/AgentSessionVisibilityNativeProofTests.swift +++ b/Tests/CodexBarTests/AgentSessionVisibilityNativeProofTests.swift @@ -32,7 +32,7 @@ final class AgentSessionVisibilityNativeProofTests: XCTestCase { environmentBase: [:]) let sessions = AgentSessionsStore( settings: settings, - localScan: { _ in [] }, + localScan: { _, _ in .init() }, remoteHostDiscovery: { ["ready.example.invalid", "offline.example.invalid"] }, remoteFetch: { _ in [ diff --git a/Tests/CodexBarTests/AgentSessionsStoreSchedulerTests.swift b/Tests/CodexBarTests/AgentSessionsStoreSchedulerTests.swift index fc7c8d8deb..33a590fba1 100644 --- a/Tests/CodexBarTests/AgentSessionsStoreSchedulerTests.swift +++ b/Tests/CodexBarTests/AgentSessionsStoreSchedulerTests.swift @@ -6,15 +6,17 @@ import Testing private actor AgentSessionScanHarness { struct Call: Equatable, Sendable { let includeFileOnlySessions: Bool + let includeRolloutActivity: Bool } private var calls: [Call] = [] private var continuations: [Int: CheckedContinuation<[AgentSession], Never>] = [:] private var callWaiters: [(count: Int, continuation: CheckedContinuation)] = [] - func scan(includeFileOnlySessions: Bool) async -> [AgentSession] { + func scan(includeFileOnlySessions: Bool, includeRolloutActivity: Bool) async -> [AgentSession] { let index = self.calls.count - self.calls.append(Call(includeFileOnlySessions: includeFileOnlySessions)) + self.calls.append(Call( + includeFileOnlySessions: includeFileOnlySessions, includeRolloutActivity: includeRolloutActivity)) self.resumeSatisfiedWaiters() return await withCheckedContinuation { continuation in self.continuations[index] = continuation @@ -229,8 +231,8 @@ struct AgentSessionsStoreSchedulerTests { await scan.waitForCallCount(2) #expect(await scan.recordedCalls() == [ - .init(includeFileOnlySessions: false), - .init(includeFileOnlySessions: true), + .init(includeFileOnlySessions: false, includeRolloutActivity: true), + .init(includeFileOnlySessions: true, includeRolloutActivity: false), ]) #expect(store.localSessions.isEmpty) #expect(store.latestLocalActivityAt == nil) @@ -326,9 +328,10 @@ struct AgentSessionsStoreSchedulerTests { { AgentSessionsStore( settings: settings, - localScan: { includeFileOnlySessions in - guard let scan else { return [] } - return await scan.scan(includeFileOnlySessions: includeFileOnlySessions) + localScan: { includeFileOnlySessions, includeRolloutActivity in + guard let scan else { return .init() } + return await .init(sessions: scan.scan( + includeFileOnlySessions: includeFileOnlySessions, includeRolloutActivity: includeRolloutActivity)) }, remoteHostDiscovery: { [] }, remoteFetch: { hosts in @@ -343,8 +346,8 @@ struct AgentSessionsStoreSchedulerTests { { AgentSessionsStore( settings: settings, - localScan: { includeFileOnlySessions in - await spy.scan(includeFileOnlySessions: includeFileOnlySessions) + localScan: { includeFileOnlySessions, _ in + await .init(sessions: spy.scan(includeFileOnlySessions: includeFileOnlySessions)) }, remoteHostDiscovery: { [] }, remoteFetch: { hosts in diff --git a/Tests/CodexBarTests/CodexSessionRolloutTests.swift b/Tests/CodexBarTests/CodexSessionRolloutTests.swift index 0ec9e6b5dc..070ae70035 100644 --- a/Tests/CodexBarTests/CodexSessionRolloutTests.swift +++ b/Tests/CodexBarTests/CodexSessionRolloutTests.swift @@ -459,7 +459,8 @@ struct CodexSessionRolloutTests { "-c features.code_mode_host=true app-server --analytics-default-enabled" }, cwdProvider: { _, _ in [:] }, - appServerTrustValidator: appServerTrustValidator ?? { _ in appServerIsTrusted }) + appServerTrustValidator: appServerTrustValidator ?? { _ in appServerIsTrusted }, + directoryScanStartedAt: { .distantFuture }) return AdaptiveChatGPTFixture( root: root, rollout: rollout, diff --git a/Tests/CodexBarTests/StayAwakeTests.swift b/Tests/CodexBarTests/StayAwakeTests.swift index 52b9136bce..879f7557df 100644 --- a/Tests/CodexBarTests/StayAwakeTests.swift +++ b/Tests/CodexBarTests/StayAwakeTests.swift @@ -134,7 +134,7 @@ struct StayAwakeTests { let scan = DeferredScan() let store = AgentSessionsStore( settings: settings, - localScan: { _ in await scan.scan() }, + localScan: { _, _ in await .init(sessions: scan.scan()) }, remoteHostDiscovery: { [] }, remoteFetch: { _ in [] }, powerAssertion: assertions.api) @@ -179,7 +179,7 @@ struct StayAwakeTests { private static func store(_ settings: SettingsStore, _ assertions: Assertions) -> AgentSessionsStore { AgentSessionsStore( settings: settings, - localScan: { _ in [] }, + localScan: { _, _ in .init() }, remoteHostDiscovery: { [] }, remoteFetch: { _ in [] }, powerAssertion: assertions.api, diff --git a/docs/predictive-refresh-policy.md b/docs/predictive-refresh-policy.md index 078eb95796..cfb0639762 100644 --- a/docs/predictive-refresh-policy.md +++ b/docs/predictive-refresh-policy.md @@ -32,6 +32,11 @@ in-memory activity timestamp and changes the fallback after the offline replay, and scanner-cost proof recorded here. It does not approve per-account prediction, persistent interaction history, learned ranking, or menu prewarming. +The 2026-09-30 decision in [#4119](https://github.com/steipete/CodexBar/issues/4119) permits recent Codex rollout +modification times to supply that activity timestamp without a recognized process. Existing agent-aware consent +covers this contents-free, bounded scan. Process/signature checks remain responsible for identity enrichment; +Plain Adaptive, remote discovery, persistence, and power constraints keep their existing behavior. + ## Options considered | Option | Freshness | Complexity | Provider work | Decision | @@ -180,16 +185,23 @@ Adaptive stores no persistent interaction history. - Keep `lastMenuOpenAt` and `lastCodingActivityAt` in memory; reset both on launch. - Read Low Power Mode and thermal state at decision time. - Log only the selected delay and stable `Reason` code through the existing local logger. -- After the agent-aware mode is selected and explicit consent is granted, reuse the existing local scanner every 30 seconds. It inspects the running-process list and - command lines via `ps`, runs `lsof` when needed, and, only after detecting an agent process, enumerates recent Codex - rollouts; reads rollout first-line metadata and mtimes; and inspects Claude transcript metadata. This is a local - metadata scan, not a provider request. Pause agent-aware scans under Low Power Mode or serious/critical thermal - pressure; keep scanning when the user explicitly enables Agent Sessions presentation. +- After the agent-aware mode is selected and explicit consent is granted, reuse the existing local scanner every 30 seconds. + Enumerate today's and yesterday's Codex `rollout-*.jsonl` modification times even without a recognized live process. + This cadence signal reads no rollout contents and uses the same bounded enumeration as Agent Sessions. A sync or + restore can also update those times and select the five-minute activity cadence; the signal does not establish + process or session identity. Inspect running processes through native APIs on macOS (`ps` elsewhere), resolve + working directories when needed, and keep Claude transcript inspection process-gated. Codex first-line metadata + and identity enrichment remain in the session path, with its existing process/signature checks or explicit Agent + Sessions authorization. This is a local metadata scan, not a provider request. Pause agent-aware scans under Low + Power Mode or serious/critical thermal pressure; keep scanning when the user explicitly enables Agent Sessions presentation. + Stay Awake retains its live-process scan without enabling process-independent rollout activity while constrained. - Bound each scan to the newest 64 agent processes, 128 Codex rollout metadata records, and 64 Claude transcript candidates per project. Share a 512-entry, depth-1, 150 ms budget across agent-aware Codex and Claude directory enumeration, and - clamp future transcript mtimes to the scan time. Keep the first clamped value for an unchanged future-dated file so - repeated scans cannot synthesize newer activity. + clamp future transcript mtimes to one scanner-lifetime timestamp. The clamp stores no file paths, and repeated scans + of unchanged future-dated files cannot synthesize newer activity. + Activity-only scans inspect process-backed Claude transcripts before spending the remaining shared budget on + process-independent Codex rollouts. - When Agent Sessions presentation is disabled, discard the full scan result after deriving the newest `Date`. Do not retain or publish its PID, CWD, project, transcript path, or session identity fields. - Do not log or persist provider identity, account identity, email, workspace, path, credentials, response data, menu diff --git a/docs/refresh-loop.md b/docs/refresh-loop.md index f637f0668a..16b11c5d6e 100644 --- a/docs/refresh-loop.md +++ b/docs/refresh-loop.md @@ -59,20 +59,26 @@ read_when: persisted `adaptiveActivityScanConsent` value is `undecided`, `allowed`, or `declined`; missing or invalid values are repaired to `undecided`, which never authorizes a scan. Declining selects plain Adaptive; explicitly selecting the agent-aware option again asks again. -- An allowed scan inspects running processes and their arguments (through native process APIs on macOS, `ps` elsewhere), - resolves working directories, and enumerates known session metadata only when an agent process is detected. It then reads - recent Codex rollouts, reads rollout first-line metadata and mtimes, and inspects Claude transcript metadata. When - the Agent Sessions UI is off, CodexBar discards the resulting session records and retains only the latest `Date`. +- An allowed scan uses today's and yesterday's Codex `rollout-*.jsonl` modification times as activity even when no + recognized live process exists, including when only a managed Codex daemon is running. This signal reads no rollout + contents and reuses the Agent Sessions directory walk. With the Agent Sessions UI off, no Codex first-line metadata + or thread names are read for cadence. The scanner still inspects running processes and their arguments (through + native process APIs on macOS, `ps` elsewhere), resolves working directories, and inspects process-gated Claude + transcript metadata. CodexBar discards session records when the UI is off and retains only the latest `Date`. Each scan considers at most 64 agent processes, parses at most 128 Codex rollout metadata records, keeps at most 64 Claude transcript candidates per project, and shares a 512-entry, depth-1, 150 ms agent-aware directory metadata budget. Future transcript mtimes are clamped to one scanner-lifetime timestamp. The clamp retains no file paths, and unchanged future-dated files cannot manufacture newer activity every 30 seconds. Agent-aware scans pause under Low Power Mode and serious/critical thermal pressure. Explicitly enabling Agent - Sessions continues to authorize its local scan independently of the Adaptive consent choice. Tailscale discovery and - SSH remain behind the Agent Sessions setting. The activity timestamp is not persisted, logged, or uploaded, and it is + Sessions continues to authorize its local scan independently of the Adaptive consent choice. Stay Awake can keep + its live-process scan running while constrained, but does not enable process-independent rollout activity then. + Tailscale discovery and SSH remain behind the Agent Sessions setting. The activity timestamp is not persisted, logged, or uploaded, and it is cleared when consent is revoked. -- ChatGPT's Codex `app-server` can authorize that local rollout scan at exactly +- Process recognition remains an identity-enrichment boundary, not a prerequisite for the rollout-mtime activity + signal. Sync or restore tools updating rollout mtimes can also select the five-minute cadence. Plain Adaptive + does not use this signal, and remote discovery is unchanged. +- ChatGPT's Codex `app-server` can authorize the existing session metadata path at exactly `/Applications/ChatGPT.app/Contents/Resources/codex` or `/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex`. The scanner requires an `app-server` argument, verifies the running PID's kernel-reported executable path and From f8ecbff50d7670a72ad8382e8b1b56d52965bcab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 19:59:29 -0700 Subject: [PATCH 106/122] perf(sessions): avoid filesystem probes when classifying processes (#4145) Resolving pi history scope enumerates every process on the machine (2,100+ on the owner's Mac) on each refresh, and classifying each one took basenames through URL(fileURLWithPath:).lastPathComponent, which stats the path. piDialect did this for the executable and, for bun, for every argument, and argumentsWithPiSelectorEnvironment built a throwaway AgentProcessRecord per pid. xctrace on the signed 0.70.0 app attributed 13% of a steady-state refresh to this path. Use a pure string basename that matches the URL semantics these call sites relied on (including relative, dot, empty and tilde inputs; one cached capability probe keeps tilde handling identical across Foundation versions), decide the pi dialect directly from argv, and drop the throwaway record. Process classification is unchanged. Per sweep of 2,500 synthetic processes: metadata-probing URL constructions 5,000 -> 0; process CPU 248.6 ms -> 24.7 ms (median). --- CHANGELOG.md | 1 + Sources/CodexBarCore/AgentSession.swift | 59 +++++- .../DarwinProcessEnumerator.swift | 8 +- .../AgentProcessBasenameTests.swift | 191 ++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 2 +- 5 files changed, 242 insertions(+), 19 deletions(-) create mode 100644 Tests/CodexBarTests/AgentProcessBasenameTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d470a8a449..2051a7860e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! +- Reduce CPU and filesystem work while identifying local agent processes during refreshes. ## 0.70.0 — 2026-09-29 diff --git a/Sources/CodexBarCore/AgentSession.swift b/Sources/CodexBarCore/AgentSession.swift index bba7950564..e12899a80a 100644 --- a/Sources/CodexBarCore/AgentSession.swift +++ b/Sources/CodexBarCore/AgentSession.swift @@ -192,6 +192,33 @@ struct DirectoryMetadataScanBudget { } } +enum AgentProcessPath { + /// Foundation's tilde rules vary by runtime/SDK. An explicit directory hint avoids a metadata probe. + static let expandsBareTilde = URL(fileURLWithPath: "~", isDirectory: false).relativePath != "~" + + static func basename( + _ path: String, + currentDirectory: @autoclosure () -> String = FileManager.default.currentDirectoryPath, + expandTilde: (String) -> String = { ($0 as NSString).expandingTildeInPath }, + expandsBareTilde: Bool = Self.expandsBareTilde) -> String + { + let path = path.hasPrefix("~") && (expandsBareTilde || path.hasPrefix("~/")) ? expandTilde(path) : path + let basename = (path as NSString).lastPathComponent + if path.hasPrefix("/") { return basename } + guard basename.isEmpty || basename == "." || basename == ".." else { return basename } + // File URLs resolve relative dot components against CWD, but leave absolute ones alone. + var components = currentDirectory().components(separatedBy: "/") + for component in path.components(separatedBy: "/") { + if component == ".." { + if components.count > 1 { components.removeLast() } + } else if component != "." { + components.append(component) + } + } + return components.last(where: { !$0.isEmpty }) ?? "/" + } +} + public struct AgentProcessRecord: Equatable, Sendable { public let pid: Int32 public let ppid: Int32 @@ -221,11 +248,8 @@ public struct AgentProcessRecord: Equatable, Sendable { public var executableBasename: String { let firstToken = self.arguments?.first ?? self.command.split(whereSeparator: \ .isWhitespace).first .map(String.init) ?? "" - let firstBasename = URL(fileURLWithPath: firstToken).lastPathComponent - if firstBasename == "disclaimer" { - return firstBasename - } - if self.command.contains("Application Support/Claude/claude-code/claude") { + let firstBasename = AgentProcessPath.basename(firstToken) + if firstBasename != "disclaimer", self.command.contains("Application Support/Claude/claude-code/claude") { return AgentSession.Provider.claude.rawValue } return firstBasename @@ -298,10 +322,23 @@ public enum AgentPSOutputParser { } public static func piDialect(for record: AgentProcessRecord) -> AgentSession.Dialect? { - let tokens = [record.executableBasename] + self.arguments(record) - guard let firstToken = tokens.first else { return nil } + self.piDialect(executableBasename: record.executableBasename, arguments: self.arguments(record)) + } + + static func piDialect(arguments: [String]) -> AgentSession.Dialect? { + guard let dialect = self.piDialect( + executableBasename: AgentProcessPath.basename(arguments.first ?? ""), + arguments: Array(arguments.dropFirst())) + else { return nil } + return arguments.joined(separator: " ") + .contains("Application Support/Claude/claude-code/claude") ? nil : dialect + } - let firstBasename = URL(fileURLWithPath: firstToken).lastPathComponent.lowercased() + static func piDialect( + executableBasename: String, + arguments: @autoclosure () -> [String]) -> AgentSession.Dialect? + { + let firstBasename = AgentProcessPath.basename(executableBasename).lowercased() if firstBasename == AgentSession.Provider.pi.rawValue { return .pi } @@ -309,8 +346,8 @@ public enum AgentPSOutputParser { return .omp } guard firstBasename == "bun" else { return nil } - return tokens.dropFirst().contains { - URL(fileURLWithPath: $0).lastPathComponent.lowercased() == "omp" + return arguments().contains { + AgentProcessPath.basename($0).lowercased() == "omp" } ? .omp : nil } @@ -355,7 +392,7 @@ public enum AgentPSOutputParser { private static func normalizedClaudeArguments(_ command: String) -> [String] { let arguments = self.arguments(command) if let index = arguments.firstIndex(where: { - URL(fileURLWithPath: $0).lastPathComponent == AgentSession.Provider.claude.rawValue + AgentProcessPath.basename($0) == AgentSession.Provider.claude.rawValue }) { return Array(arguments.suffix(from: arguments.index(after: index))) } diff --git a/Sources/CodexBarCore/DarwinProcessEnumerator.swift b/Sources/CodexBarCore/DarwinProcessEnumerator.swift index 0122bac808..db7bb61623 100644 --- a/Sources/CodexBarCore/DarwinProcessEnumerator.swift +++ b/Sources/CodexBarCore/DarwinProcessEnumerator.swift @@ -137,13 +137,7 @@ extension DarwinProcessEnumerator { guard let data = self.procArgs2Data(pid: pid), let layout = self.parseProcArgs2Layout(data) else { return nil } - let process = AgentProcessRecord( - pid: pid, - ppid: 0, - startedAt: nil, - command: layout.arguments.joined(separator: " "), - arguments: layout.arguments) - let environment = AgentPSOutputParser.piDialect(for: process) == nil + let environment = AgentPSOutputParser.piDialect(arguments: layout.arguments) == nil ? nil : self.parseProcArgs2Environment(data) return (layout.arguments, environment) diff --git a/Tests/CodexBarTests/AgentProcessBasenameTests.swift b/Tests/CodexBarTests/AgentProcessBasenameTests.swift new file mode 100644 index 0000000000..2f7df41afd --- /dev/null +++ b/Tests/CodexBarTests/AgentProcessBasenameTests.swift @@ -0,0 +1,191 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AgentProcessBasenameTests { + @Test(arguments: [ + "", "~", "~/", "~/pi", "~/~", "~/pi/~", "~root", "~root/", "~root/..", "/", "//", "///", + ".", "..", "./", "../", "~no-such-synthetic-user/..", + "pi", "./pi", "../bin/pi", "pi/", "pi///", "/usr/local/bin/pi/", "foo/.", "foo/..", + "a//..", "a//../..", "..//..", "/foo/.", "/foo/..", "~/.", "~/..", "~/pi/..", + "/Applications/Claude.app/Contents/MacOS/Claude", "Application Support/Claude/claude-code/claude", + "/path with spaces/omp", "./path with spaces/bun/", "a%2Fb", "a\\b", "a/\u{301}", " pi ", + ]) + func `string basenames preserve file URL semantics`(path: String) { + #expect(AgentProcessPath.basename(path) == URL(fileURLWithPath: path).lastPathComponent) + } + + @Test + func `ordinary basenames never consult directory context`() { + var directoryReads = 0 + func directory() -> String { + directoryReads += 1 + return "/synthetic/omp" + } + for index in 0..<2500 { + #expect(AgentProcessPath.basename( + "/synthetic/process-\(index)/node/", + currentDirectory: directory(), + expandTilde: { _ in directory() }) == "node") + } + #expect(directoryReads == 0) + #expect(AgentProcessPath.basename("", currentDirectory: directory()) == "omp") + #expect(directoryReads == 1) + #expect(AgentProcessPath.basename("..", currentDirectory: directory()) == "synthetic") + #expect(directoryReads == 2) + #expect(AgentProcessPath.basename("~/", expandTilde: { _ in directory() }) == "omp") + #expect(directoryReads == 3) + } + + @Test + func `both Foundation tilde dialects preserve relative and absolute dot semantics`() { + let cases = [ + ("~", "~", "home"), ("~/", "home", "home"), ("~root", "~root", "root"), + ("~root/", "~root", "root"), ("~root/..", "omp", ".."), ("~/pi/..", "..", ".."), + ("~unknown/..", "omp", "omp"), ("foo/../~", "~", "~"), + ] + for (path, modern, legacy) in cases { + for expandsBareTilde in [false, true] { + let basename = AgentProcessPath.basename( + path, + currentDirectory: "/work/omp", + expandTilde: { path in + if path == "~root" || path.hasPrefix("~root/") { + return "/synthetic/root" + path.dropFirst(5) + } + if path == "~" || path.hasPrefix("~/") { + return "/synthetic/home" + path.dropFirst() + } + return path + }, + expandsBareTilde: expandsBareTilde) + #expect(basename == (expandsBareTilde ? legacy : modern)) + } + } + } + + @Test + func `non bun dialect checks do not materialize arguments`() { + var argumentReads = 0 + func arguments() -> [String] { + argumentReads += 1 + return ["/opt/omp"] + } + for _ in 0..<2500 { + #expect(AgentPSOutputParser.piDialect(executableBasename: "node", arguments: arguments()) == nil) + } + #expect(argumentReads == 0) + #expect(AgentPSOutputParser.piDialect(executableBasename: "bun", arguments: arguments()) == .omp) + #expect(argumentReads == 1) + } + + @Test + func `parser has no filesystem probing URL constructors`() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String( + contentsOf: root.appendingPathComponent("Sources/CodexBarCore/AgentSession.swift"), + encoding: .utf8) + let start = try #require(source.range(of: "enum AgentProcessPath {")) + let end = try #require(source.range(of: "public enum LSOFCWDOutputParser {")) + let parser = String(source[start.lowerBound.. ( + basename: String, dialect: AgentSession.Dialect?, provider: AgentSession.Provider?) + { + let arguments = record.arguments ?? record.command.split(whereSeparator: \.isWhitespace).map(String.init) + var basename = URL(fileURLWithPath: arguments.first ?? "").lastPathComponent + if basename != "disclaimer", record.command.contains("Application Support/Claude/claude-code/claude") { + basename = AgentSession.Provider.claude.rawValue + } + let first = URL(fileURLWithPath: basename).lastPathComponent.lowercased() + let dialect: AgentSession.Dialect? = switch first { + case "pi": .pi + case "omp": .omp + case "bun": arguments.dropFirst().contains { + URL(fileURLWithPath: $0).lastPathComponent.lowercased() == "omp" + } ? .omp : nil + default: nil + } + let provider: AgentSession.Provider? = if let direct = AgentSession.Provider(rawValue: basename.lowercased()), + direct != .pi + { + direct + } else if basename.lowercased() == "disclaimer" { + .claude + } else if dialect != nil, + !["--help", "--version", "--smoke-test", "__omp_worker_"].contains(where: { + record.command.lowercased().contains($0) + }) + { + .pi + } else { + nil + } + return (basename, dialect, provider) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 22f150e71d..5270794a77 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -3388,7 +3388,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/AgentSession.swift", - anchor: "URL(fileURLWithPath: $0).lastPathComponent == AgentSession.Provider.claude.rawValue", + anchor: "AgentProcessPath.basename($0) == AgentSession.Provider.claude.rawValue", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, expectedReferenceFingerprint: ["claude@0"], From d97dfdeb77c60ca6e5e57a4631dca24ec3791d00 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 19:59:45 -0700 Subject: [PATCH 107/122] perf(cost): index models.dev fallback pricing merges (#4146) Refreshing models.dev pricing for unknown models merged the cached catalog with an O(N^2) scan: every cached model rescanned the whole provider and recomputed regex-based stable identities. xctrace on the signed 0.70.0 app attributed 18% of a steady-state refresh to this path. Build priced-identity counts once per provider, memoize raw IDs within the merge, index normalized IDs lazily for lookups, and precompile the normalizer's regexes with an ASCII fast path (non-ASCII keeps the original matching API). The obsolete scan helper and computed stableIdentity are removed. On the owner's real catalog snapshot (225 providers, 8,433 models) the merge drops from 1,935 ms to 12.8 ms of CPU (median of 5); identity computations fall from 756,819 to 3,681. Golden catalog/lookup equivalence tests cover replacement collisions, duplicate prices, dated aliases and Unicode. --- CHANGELOG.md | 1 + .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/ModelsDevPricing.swift | 83 +++-- .../ModelsDevMergeIndexTests.swift | 343 ++++++++++++++++++ docs/model-pricing.md | 2 + 5 files changed, 406 insertions(+), 25 deletions(-) create mode 100644 Tests/CodexBarTests/ModelsDevMergeIndexTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2051a7860e..9d4611e5e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! - Reduce CPU and filesystem work while identifying local agent processes during refreshes. +- Reduce CPU use when refreshing model pricing while preserving historical fallback rates. ## 0.70.0 — 2026-09-29 diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index caf0ea9a14..70ca634624 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "04a6361469a4ff77" + static let value = "8a25bf2647df7765" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 08570f9125..0c53f4ee32 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -86,7 +86,17 @@ struct ModelsDevCatalog: Codable, Equatable { } } - func mergingFallbackPricing(from cachedCatalog: ModelsDevCatalog) -> ModelsDevCatalog { + func mergingFallbackPricing( + from cachedCatalog: ModelsDevCatalog, + stableIdentity: (String) -> String = ModelsDevModelIDNormalizer.stableIdentity) -> ModelsDevCatalog + { + var identities: [String: String] = [:] + func identity(_ model: ModelsDevModel) -> String { + if let cached = identities[model.id] { return cached } + let value = stableIdentity(model.id) + identities[model.id] = value + return value + } var merged = self for (providerID, cachedProvider) in cachedCatalog.providers { let normalizedProviderID = ModelsDevProvider.normalizeProviderID(providerID) @@ -95,14 +105,21 @@ struct ModelsDevCatalog: Codable, Equatable { continue } - for (modelKey, cachedModel) in cachedProvider.models - where cachedModel.isPriceable && !provider.containsPricedModel( - withStableIdentity: cachedModel.stableIdentity) - { + var pricedIdentities: [String: Int] = [:] + for model in provider.models.values where model.isPriceable { + pricedIdentities[identity(model), default: 0] += 1 + } + for (modelKey, cachedModel) in cachedProvider.models where cachedModel.isPriceable { + let modelIdentity = identity(cachedModel) + guard pricedIdentities[modelIdentity, default: 0] == 0 else { continue } let fallbackKey = provider.models[modelKey] == nil ? modelKey : "codexbar-fallback:\(modelKey):\(cachedModel.normalizedID)" - provider.models[fallbackKey] = cachedModel + // A preexisting fallback key can be replaced; retain counts for duplicate identities. + if let replaced = provider.models.updateValue(cachedModel, forKey: fallbackKey), replaced.isPriceable { + pricedIdentities[identity(replaced), default: 0] -= 1 + } + pricedIdentities[modelIdentity, default: 0] += 1 } merged.providers[normalizedProviderID] = provider } @@ -173,6 +190,7 @@ struct ModelsDevProvider: Codable, Equatable { let candidates = exactModelID ? [ModelsDevModelIDNormalizer.normalize(rawModelID)] : ModelsDevModelIDNormalizer.candidates(rawModelID) + var normalizedModels: [String: ModelsDevModel]? for candidate in candidates { if let model = self.models[candidate], let pricing = model.pricing(providerID: self.id ?? self.mapKey ?? "", providerName: self.name) @@ -180,21 +198,27 @@ struct ModelsDevProvider: Codable, Equatable { return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: candidate) } - for match in self.models.values where match.normalizedID == candidate { - if let pricing = match.pricing(providerID: self.id ?? self.mapKey ?? "", providerName: self.name) { - return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: match.normalizedID) + if normalizedModels == nil { + normalizedModels = [:] + var normalizedIDs: [String: String] = [:] + for model in self.models.values where model.isPriceable { + let normalizedID = normalizedIDs[model.id] ?? model.normalizedID + normalizedIDs[model.id] = normalizedID + if normalizedModels?[normalizedID] == nil { normalizedModels?[normalizedID] = model } } } + if let normalizedModels, + let index = normalizedModels.index(forKey: candidate), + let pricing = normalizedModels[index].value.pricing( + providerID: self.id ?? self.mapKey ?? "", providerName: self.name) + { + // Preserve the catalog's spelling when Unicode-equivalent query strings differ in bytes. + return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: normalizedModels[index].key) + } } return nil } - - func containsPricedModel(withStableIdentity modelID: String) -> Bool { - self.models.values.contains { model in - model.isPriceable && model.stableIdentity == modelID - } - } } struct ModelsDevModel: Codable, Equatable { @@ -207,10 +231,6 @@ struct ModelsDevModel: Codable, Equatable { ModelsDevModelIDNormalizer.normalize(self.id) } - var stableIdentity: String { - ModelsDevModelIDNormalizer.stableIdentity(self.id) - } - var isPriceable: Bool { self.cost?.input != nil && self.cost?.output != nil } @@ -274,6 +294,21 @@ struct ModelsDevLimit: Codable, Equatable { } enum ModelsDevModelIDNormalizer { + private static let snapshotDate = try? NSRegularExpression(pattern: #"^\d{8}$"#) + private static let dashedDate = try? NSRegularExpression(pattern: #"-\d{4}-\d{2}-\d{2}$"#) + private static let compactDate = try? NSRegularExpression(pattern: #"-\d{8}$"#) + private static let version = try? NSRegularExpression(pattern: #"-v\d+:\d+$"#) + + private static func range(of expression: NSRegularExpression?, in value: String) -> Range? { + guard let expression else { return nil } + // String.range can match whole graphemes where ICU only matches Unicode scalars. + guard value.utf8.allSatisfy({ $0 < 128 }) else { + return value.range(of: expression.pattern, options: .regularExpression) + } + return expression.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)) + .flatMap { Range($0.range, in: value) } + } + static func normalize(_ raw: String) -> String { raw.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -283,7 +318,7 @@ enum ModelsDevModelIDNormalizer { if let atSign = normalized.firstIndex(of: "@") { let base = String(normalized[.. ModelsDevModel { + ModelsDevModel(id: id, name: id, cost: ModelsDevCost(input: priced ? rate : nil, output: rate * 2)) + } + + private static func provider(_ models: [String: ModelsDevModel]) -> ModelsDevProvider { + ModelsDevProvider(id: nil, name: "Synthetic provider", models: models, mapKey: "openai") + } + + private static func catalogs() -> (ModelsDevCatalog, ModelsDevCatalog) { + var fresh: [String: ModelsDevProvider] = [:] + var cached: [String: ModelsDevProvider] = [:] + for providerID in ["openai", "anthropic", "synthetic"] { + var freshModels: [String: ModelsDevModel] = [:] + var cachedModels: [String: ModelsDevModel] = [:] + for index in 0..<160 { + let base = "synthetic-\(index)" + let aliases = [ + base, + "openai/\(base)", + "\(base)@20250101", + "\(base)-20250101", + "\(base)-2025-01-01", + "\(base)-v1:0", + "claude-\(base)@default", + " \(base) ", + ] + cachedModels[base] = self.model(aliases[index % aliases.count], priced: index % 7 != 0) + cachedModels["duplicate-\(index)"] = cachedModels[base] + if index % 3 != 0 { + freshModels[base] = self.model(base, priced: index % 5 != 0, rate: 99) + } + } + fresh[providerID] = self.provider(freshModels) + cached[providerID] = self.provider(cachedModels) + } + cached["removed"] = self.provider(["only": self.model("removed")]) + return (ModelsDevCatalog(providers: fresh), ModelsDevCatalog(providers: cached)) + } + + private static func legacyMerge( + _ fresh: ModelsDevCatalog, + _ cached: ModelsDevCatalog, + stableIdentity: (String) -> String = ModelsDevModelIDNormalizer.stableIdentity) -> ModelsDevCatalog + { + var merged = fresh + for (providerID, cachedProvider) in cached.providers { + let normalizedProviderID = ModelsDevProvider.normalizeProviderID(providerID) + guard var provider = merged.providers[normalizedProviderID] else { + merged.providers[normalizedProviderID] = cachedProvider + continue + } + for (modelKey, cachedModel) in cachedProvider.models where cachedModel.isPriceable { + let identity = stableIdentity(cachedModel.id) + guard !provider.models.values.contains(where: { + $0.isPriceable && stableIdentity($0.id) == identity + }) else { continue } + let fallbackKey = provider.models[modelKey] == nil + ? modelKey + : "codexbar-fallback:\(modelKey):\(cachedModel.normalizedID)" + provider.models[fallbackKey] = cachedModel + } + merged.providers[normalizedProviderID] = provider + } + return merged + } + + private static func legacyPricing( + _ provider: ModelsDevProvider, + modelID: String, + exact: Bool) -> ModelsDevPricingLookup? + { + let candidates = exact ? [ModelsDevModelIDNormalizer.normalize(modelID)] + : ModelsDevModelIDNormalizer.candidates(modelID) + for candidate in candidates { + if let model = provider.models[candidate], + let pricing = model.pricing( + providerID: provider.id ?? provider.mapKey ?? "", + providerName: provider.name) + { + return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: candidate) + } + for match in provider.models.values where match.normalizedID == candidate { + if let pricing = match.pricing( + providerID: provider.id ?? provider.mapKey ?? "", + providerName: provider.name) + { + return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: match.normalizedID) + } + } + } + return nil + } +} + +/// Frozen pre-index normalizer: covers ICU digit, anchor, and Unicode range behavior. +private enum LegacyModelsDevModelIDNormalizer { + static func normalize(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func stableIdentity(_ raw: String) -> String { + let normalized = self.normalize(raw) + if let atSign = normalized.firstIndex(of: "@") { + let base = String(normalized[.. String { + self.candidates(raw, preserveDatedSnapshots: true).reversed().lazy + .map { candidate in + guard candidate.hasSuffix("@default") else { return candidate } + return String(candidate.dropLast("@default".count)) + } + .first { !$0.isEmpty } ?? self.normalize(raw) + } + + static func candidates(_ raw: String, preserveDatedSnapshots: Bool = false) -> [String] { + var candidates: [String] = [] + + func append(_ value: String) { + let normalized = self.normalize(value) + guard !normalized.isEmpty, !candidates.contains(normalized) else { return } + candidates.append(normalized) + } + + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + append(trimmed) + + if trimmed.hasPrefix("openai/") { + append(String(trimmed.dropFirst("openai/".count))) + } + + if trimmed.hasPrefix("anthropic.") { + append(String(trimmed.dropFirst("anthropic.".count))) + } + + if let lastDot = trimmed.lastIndex(of: "."), + trimmed.contains("claude-") + { + let tail = String(trimmed[trimmed.index(after: lastDot)...]) + if tail.hasPrefix("claude-") { + append(tail) + } + } + + var index = 0 + while index < candidates.count { + let candidate = candidates[index] + if let atSign = candidate.firstIndex(of: "@") { + let base = String(candidate[.. Date: Wed, 30 Sep 2026 20:00:08 -0700 Subject: [PATCH 108/122] perf(cost): reuse local day keys and Gregorian calendars (#4149) Every parsed Codex/Claude log line computed its local day key by allocating a fresh Gregorian Calendar, extracting date components and formatting with String(format:). xctrace on the signed 0.70.0 app attributed ~10% of the Claude scan to claudeTimestampAndDayKey, and an older trace showed 15.7% of CPU inside String(format:) from these helpers. Cache one Gregorian calendar and the current [dayStart, nextDayStart) interval per time zone (eight zones, existing NSLock pattern), so consecutive same-day timestamps skip component extraction, and replace String(format:) with a zero-pad helper that keeps printf's signed 32-bit %d behavior for odd components. The DeepSeek, MiniMax, Grok, XAI and OpenAI dashboard day-key helpers reuse the shared formatter. Optimized standalone harness (median of 3): 1M day-key calls 4.81 s -> 0.64 s (7.6x); 200k Claude timestamp/day-key calls 1.83 s -> 0.50 s (3.7x); 10,000 same-day calls do 1 component extraction instead of 10,000. Golden tests cover 1900-2100 dates, DST and skipped days in six zones, offsets, fractions, eviction and concurrency. --- CHANGELOG.md | 1 + Sources/CodexBarCore/CostUsageModels.swift | 75 +++++-- .../CodexBarCore/OpenAIDashboardModels.swift | 9 +- .../DeepSeek/DeepSeekUsageCostParser.swift | 2 +- .../Grok/GrokLocalSessionScanner.swift | 2 +- .../MiniMax/MiniMaxBillingHistory.swift | 2 +- .../Providers/XAI/XAICostUsageMapping.swift | 7 +- .../CostUsageScanner+Timestamp.swift | 4 +- .../CostUsageLocalDayTests.swift | 195 ++++++++++++++++++ docs/cost-reporting-periods.md | 2 +- 10 files changed, 261 insertions(+), 38 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageLocalDayTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d4611e5e3..9769844d78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! - Reduce CPU and filesystem work while identifying local agent processes during refreshes. - Reduce CPU use when refreshing model pricing while preserving historical fallback rates. +- Costs: reduce CPU use while bucketing local agent logs into daily usage. ## 0.70.0 — 2026-09-29 diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index ef19848eb2..33d26bfc37 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -1571,19 +1571,48 @@ enum CostUsageBucketInterval { } enum CostUsageLocalDay { + private static let cache = Cache() + + /// Only calendar arithmetic is shared: no account, provider, path, or usage data is retained. + final class Cache: @unchecked Sendable { + private let lock = NSLock() + private var memos: [TimeZone: CostUsageLocalDayKeyMemo] = [:] + + func withMemo(calendar: Calendar, body: (inout CostUsageLocalDayKeyMemo) -> T) -> T { + self.lock.withLock { + let zone = calendar.timeZone + if self.memos[zone] == nil { + if self.memos.count == 8 { self.memos.removeAll(keepingCapacity: true) } + self.memos[zone] = CostUsageLocalDayKeyMemo(calendar: calendar) + } + return body(&self.memos[zone]!) + } + } + } + static func gregorianCalendar(matching calendar: Calendar = .current) -> Calendar { - var gregorian = Calendar(identifier: .gregorian) - gregorian.timeZone = calendar.timeZone - return gregorian + self.cache.withMemo(calendar: calendar) { $0.calendar } } static func key(from date: Date, calendar: Calendar = .current) -> String { - let calendar = Self.gregorianCalendar(matching: calendar) + self.cache.withMemo(calendar: calendar) { $0.key(for: date, calendar: calendar) } + } + + static func uncachedKey(from date: Date, calendar: Calendar) -> String { let components = calendar.dateComponents([.year, .month, .day], from: date) - let year = components.year ?? 0 - let month = components.month ?? 0 - let day = components.day ?? 0 - return String(format: "%04d-%02d-%02d", year, month, day) + return Self.key(year: components.year ?? 0, month: components.month ?? 0, day: components.day ?? 0) + } + + static func key(year: Int, month: Int, day: Int) -> String { + func padded(_ value: Int, width: Int) -> String { + // Preserve printf's signed 32-bit %d conversion, including unusual component values. + let value = Int32(truncatingIfNeeded: value) + let digits = String(value.magnitude) + let sign = value < 0 ? "-" : "" + let zeros = String(repeating: "0", count: max(0, width - sign.utf8.count - digits.utf8.count)) + return "\(sign)\(zeros)\(digits)" + } + return "\(padded(year, width: 4))-\(padded(month, width: 2))-\(padded(day, width: 2))" } static func date(fromKey key: String, calendar: Calendar = .current) -> Date? { @@ -1605,23 +1634,27 @@ enum CostUsageLocalDay { /// y-m-d from the same Gregorian-in-timezone calendar whose `.day` interval is cached here, so the memo can never /// disagree with computing the key per entry (DST days are simply 23 h / 25 h intervals). struct CostUsageLocalDayKeyMemo { + private(set) var calendar: Calendar var start = Date.distantPast var end = Date.distantPast var key = "" - mutating func key(for timestamp: Date, calendar: Calendar) -> String { - if timestamp >= self.start, timestamp < self.end { - return self.key - } - let dayCalendar = CostUsageLocalDay.gregorianCalendar(matching: calendar) - guard let interval = dayCalendar.dateInterval(of: .day, for: timestamp) else { - self.start = Date.distantPast - self.end = Date.distantPast - return CostUsageLocalDay.key(from: timestamp, calendar: calendar) - } - self.start = interval.start - self.end = interval.end - self.key = CostUsageLocalDay.key(from: timestamp, calendar: calendar) + init(calendar: Calendar = .current) { + self.calendar = Calendar(identifier: .gregorian) + self.calendar.timeZone = calendar.timeZone + } + + mutating func key( + for timestamp: Date, + calendar: Calendar, + build: (Date, Calendar) -> String = CostUsageLocalDay.uncachedKey) -> String + { + if self.calendar.timeZone != calendar.timeZone { self = Self(calendar: calendar) } + if timestamp >= self.start, timestamp < self.end { return self.key } + let interval = self.calendar.dateInterval(of: .day, for: timestamp) + self.start = interval?.start ?? .distantPast + self.end = interval?.end ?? .distantPast + self.key = build(timestamp, self.calendar) return self.key } } diff --git a/Sources/CodexBarCore/OpenAIDashboardModels.swift b/Sources/CodexBarCore/OpenAIDashboardModels.swift index d836493d3a..851d78d13e 100644 --- a/Sources/CodexBarCore/OpenAIDashboardModels.swift +++ b/Sources/CodexBarCore/OpenAIDashboardModels.swift @@ -338,11 +338,10 @@ public struct OpenAIDashboardDailyBreakdown: Codable, Equatable, Sendable { private static func dayKey(from date: Date, calendar: Calendar) -> String { let components = calendar.dateComponents([.year, .month, .day], from: date) - return String( - format: "%04d-%02d-%02d", - components.year ?? 0, - components.month ?? 0, - components.day ?? 0) + return CostUsageLocalDay.key( + year: components.year ?? 0, + month: components.month ?? 0, + day: components.day ?? 0) } } diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift index 2576f69d2b..0aa8ba6602 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift @@ -417,7 +417,7 @@ enum DeepSeekUsageCostParser { let month = components.month, let day = components.day else { return "" } - return String(format: "%04d-%02d-%02d", year, month, day) + return CostUsageLocalDay.key(year: year, month: month, day: day) } static func buildAmountMap( diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index ede4fd4fde..686f0cb136 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -194,6 +194,6 @@ public enum GrokLocalSessionScanner { guard let year = components.year, let month = components.month, let day = components.day else { return nil } - return String(format: "%04d-%02d-%02d", year, month, day) + return CostUsageLocalDay.key(year: year, month: month, day: day) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift index 159e72af8c..b13135b2c7 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift @@ -355,6 +355,6 @@ enum MiniMaxBillingHistoryParser { let month = components.month, let day = components.day else { return "" } - return String(format: "%04d-%02d-%02d", year, month, day) + return CostUsageLocalDay.key(year: year, month: month, day: day) } } diff --git a/Sources/CodexBarCore/Providers/XAI/XAICostUsageMapping.swift b/Sources/CodexBarCore/Providers/XAI/XAICostUsageMapping.swift index dbea99b8a3..3edc822bd4 100644 --- a/Sources/CodexBarCore/Providers/XAI/XAICostUsageMapping.swift +++ b/Sources/CodexBarCore/Providers/XAI/XAICostUsageMapping.swift @@ -49,11 +49,6 @@ public enum XAICostUsageMapping { static func utcDayKey(_ date: Date) -> String { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt - let components = calendar.dateComponents([.year, .month, .day], from: date) - return String( - format: "%04d-%02d-%02d", - components.year ?? 0, - components.month ?? 0, - components.day ?? 0) + return CostUsageLocalDay.key(from: date, calendar: calendar) } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift index 0591e8e4f2..397927867f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift @@ -163,7 +163,7 @@ extension CostUsageScanner { } var comps = DateComponents() - comps.calendar = Calendar(identifier: .gregorian) + comps.calendar = CostUsageLocalDay.gregorianCalendar() comps.timeZone = TimeZone(secondsFromGMT: offsetSeconds) comps.year = year comps.month = month @@ -178,7 +178,7 @@ extension CostUsageScanner { guard let localYear = local.year, let localMonth = local.month, let localDay = local.day else { return nil } - return String(format: "%04d-%02d-%02d", localYear, localMonth, localDay) + return CostUsageLocalDay.key(year: localYear, month: localMonth, day: localDay) } static func dayKeyFromParsedISO(_ text: String, calendar: Calendar = .current) -> String? { diff --git a/Tests/CodexBarTests/CostUsageLocalDayTests.swift b/Tests/CodexBarTests/CostUsageLocalDayTests.swift new file mode 100644 index 0000000000..65a8103de4 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageLocalDayTests.swift @@ -0,0 +1,195 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageLocalDayTests { + private static let zones = [ + "America/Los_Angeles", "Europe/Berlin", "Australia/Lord_Howe", + "Asia/Kathmandu", "Pacific/Apia", "UTC", + ] + + private static func calendar(_ zone: String) -> Calendar { + var calendar = Calendar(identifier: .buddhist) + calendar.timeZone = TimeZone(identifier: zone)! + return calendar + } + + private static func reference(_ date: Date, calendar: Calendar) -> String { + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = calendar.timeZone + let components = gregorian.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } + + @Test + func `formatter preserves signed printf padding and truncation`() { + let values = [ + Int.min, + Int.max, + -4_294_967_297, + -2_147_483_648, + -10000, + -999, + -10, + -1, + 0, + 1, + 9, + 10, + 99, + 999, + 1000, + 9999, + 10000, + 2_147_483_647, + 4_294_967_297, + ] + for year in values { + for month in values { + for day in values { + #expect(CostUsageLocalDay.key(year: year, month: month, day: day) + == String(format: "%04d-%02d-%02d", year, month, day)) + } + } + } + } + + @Test(arguments: Self.zones) + func `random dates and day boundaries match fresh Gregorian calendars`(_ zone: String) throws { + let calendar = Self.calendar(zone) + var seed: UInt64 = 42 + for _ in 0..<1000 { + seed = seed &* 6_364_136_223_846_793_005 &+ 1 + let seconds = -2_208_988_800 + Double(seed % 6_343_056_001) + let date = Date(timeIntervalSince1970: seconds) + #expect(CostUsageLocalDay.key(from: date, calendar: calendar) == Self.reference(date, calendar: calendar)) + } + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = calendar.timeZone + for (year, month, day) in [ + (2026, 3, 8), (2026, 11, 1), (2026, 3, 29), (2026, 10, 25), + (2026, 4, 5), (2026, 10, 4), (2011, 12, 29), (2011, 12, 31), + (-1000, 1, 1), (0, 1, 1), (1, 1, 1), (999, 12, 31), (10000, 1, 1), + ] { + let date = try #require(gregorian.date(from: DateComponents(year: year, month: month, day: day))) + let interval = try #require(gregorian.dateInterval(of: .day, for: date)) + let samples = [ + -0.001, + 0, + 0.001, + 3600, + 7200, + 10800, + interval.duration - 0.001, + interval.duration, + interval.duration + 0.001, + ] + for seconds in samples + samples.reversed() { + let timestamp = interval.start.addingTimeInterval(seconds) + #expect(CostUsageLocalDay.key(from: timestamp, calendar: calendar) + == Self.reference(timestamp, calendar: calendar)) + } + } + } + + @Test(arguments: Self.zones) + func `timestamp offsets and fractions retain legacy day conversion`(_ zone: String) throws { + let calendar = Self.calendar(zone) + let fractions = ["", ".0", ".001", ".999999999"] + for (suffix, offset) in [ + ("Z", 0), + ("+05:45", 20700), + ("-08:00", -28800), + ("+14:00", 50400), + ("-03:30", -12600), + ("+0545", 20700), + ("-08", -28800), + ] { + for (year, month, day) in [(2026, 3, 8), (2026, 11, 1), (2011, 12, 30), (999, 1, 1), (0, 1, 1)] { + for hour in [0, 12, 23] { + let date = try #require(DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: offset), + year: year, + month: month, + day: day, + hour: hour, + minute: 59, + second: 59).date) + let prefix = String(format: "%04d-%02d-%02dT%02d:59:59", year, month, day, hour) + for fraction in fractions { + #expect(CostUsageScanner.dayKeyFromTimestamp(prefix + fraction + suffix, calendar: calendar) + == Self.reference(date, calendar: calendar)) + } + } + } + } + } + + @Test + func `ten thousand same day keys build components once and invalidate at boundaries`() throws { + let cache = CostUsageLocalDay.Cache() + let calendar = Self.calendar("America/Los_Angeles") + let start = try #require(CostUsageLocalDay.date(fromKey: "2026-03-08", calendar: calendar)) + var builds = 0 + func key(_ date: Date, _ calendar: Calendar) -> String { + cache.withMemo(calendar: calendar) { + $0.key(for: date, calendar: calendar) { timestamp, gregorian in + builds += 1 + return CostUsageLocalDay.uncachedKey(from: timestamp, calendar: gregorian) + } + } + } + for second in 0..<10000 { + #expect(key(start.addingTimeInterval(Double(second)), calendar) == "2026-03-08") + } + #expect(builds == 1) + let next = start.addingTimeInterval(23 * 3600) + #expect(key(next.addingTimeInterval(-0.001), calendar) == "2026-03-08") + #expect(builds == 1) + #expect(key(next, calendar) == "2026-03-09") + #expect(builds == 2) + #expect(key(start, calendar) == "2026-03-08") + #expect(builds == 3) + let utc = Self.calendar("UTC") + #expect(key(start, utc) == Self.reference(start, calendar: utc)) + #expect(builds == 4) + #expect(key(start, calendar) == "2026-03-08") + #expect(builds == 4) + for offset in 1...8 { + var other = calendar + other.timeZone = TimeZone(secondsFromGMT: offset * 3600)! + #expect(key(start, other) == Self.reference(start, calendar: other)) + } + #expect(builds == 12) + #expect(key(start, calendar) == "2026-03-08") + #expect(builds == 13) + } + + @Test + func `scan memo invalidates when caller changes time zone`() { + let date = Date(timeIntervalSince1970: 1_772_956_800) + var memo = CostUsageLocalDayKeyMemo() + for zone in Self.zones + Self.zones.reversed() { + let calendar = Self.calendar(zone) + #expect(memo.key(for: date, calendar: calendar) == Self.reference(date, calendar: calendar)) + } + } + + @Test + func `concurrent time zones and eviction retain independent day keys`() async { + await withTaskGroup(of: Void.self) { group in + for offset in -12...14 { + group.addTask { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: offset * 3600)! + for second in 0..<500 { + let date = Date(timeIntervalSince1970: 1_772_956_800 + Double(second * 1800)) + #expect(CostUsageLocalDay.key(from: date, calendar: calendar) + == Self.reference(date, calendar: calendar)) + } + } + } + } + } +} diff --git a/docs/cost-reporting-periods.md b/docs/cost-reporting-periods.md index 665f0861ea..4297180b92 100644 --- a/docs/cost-reporting-periods.md +++ b/docs/cost-reporting-periods.md @@ -2,7 +2,7 @@ The menu's **History window** supports rolling days, **Month to date**, and **All**. Usage & Spend uses the same period model and keeps its own range selection. Existing saved day counts retain their rolling windows; the dashboard's former All selection migrates to All available history. -Month to date starts at midnight on the first day of the current month and includes today. It uses the pinned cost-bucketing time zone from Settings, falling back to the current local zone. Calendar arithmetic handles leap years and 23/25-hour daylight-saving days. Each operation resolves its window again, and cache identities include the selection, dates, and time zone. +Month to date starts at midnight on the first day of the current month and includes today. It uses the pinned cost-bucketing time zone from Settings, falling back to the current local zone. Calendar arithmetic handles leap years and 23/25-hour daylight-saving days. Local log scans reuse Gregorian calendars and the latest day interval for up to eight time zones; crossing a day boundary or changing zones resolves the day again without changing stored cost-cache identities. Each operation resolves its window again, and cache identities include the selection, dates, and time zone. The menu selection also supplies the default for `codexbar cost`, the HTTP `/cost` endpoint, and widget cost summaries. The widget metric is named **Cost**; its displayed period comes from the app's snapshot. Explicit CLI options override the saved selection: From 4c9e083177204e7262bdec68926705599de57c4f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 20:00:30 -0700 Subject: [PATCH 109/122] perf(cost): avoid filesystem probes during Codex cache reconciliation (#4150) Reconciling the Codex cache with the SQLite store normalized every cached file path through URL(fileURLWithPath:).standardizedFileURL, and the directory-hint-less URL initializer issues an lstat per path. With ~22k cached rollouts this ran twice per refresh (scan + read view); xctrace on the signed 0.70.0 app showed reconcileCompletedCodexCatchUp at 5.1%. Skip root lookup and path normalization when every current root device would reproduce the persisted identity, compute the normalized identity once per file, avoid rewriting unchanged cache entries, and pass explicit file hints where paths are known files. Per reconciliation of 25,000 cached paths: path normalizations and stat/lstat calls drop from 25,000 to 0; elapsed time 259 ms -> 53 ms (median). Tests cover /var vs /private/var roots, persisted inode mismatches, device changes and output equality. --- CHANGELOG.md | 1 + .../CostUsage/CostUsageStore+CodexCache.swift | 37 ++--- .../CostUsage/CostUsageStore+ReadView.swift | 6 +- ...ostUsageStorePathReconciliationTests.swift | 150 ++++++++++++++++++ 4 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageStorePathReconciliationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9769844d78..afa72f67f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Reduce CPU and filesystem work while identifying local agent processes during refreshes. - Reduce CPU use when refreshing model pricing while preserving historical fallback rates. - Costs: reduce CPU use while bucketing local agent logs into daily usage. +- Costs: reduce CPU use while reconciling cached local Codex logs. ## 0.70.0 — 2026-09-29 diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift index d73b40b50f..471aee6672 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift @@ -472,7 +472,7 @@ extension CostUsageStore { var usage: CostUsageFileUsage? } - private struct CurrentCodexRootDevice { + struct CurrentCodexRootDevice { var path: String var device: String } @@ -662,8 +662,7 @@ extension CostUsageStore { remainingIdentityValidationVisits -= 1 restoredScanState = Self.restoredCodexScanState( file: file, - currentRootDevices: currentRootDevices, - validateMetadata: true) + identity: normalizedIdentity) if restoredScanState.isComplete, restoredScanState.validatedCurrentSnapshot { completedIdentityValidationPaths.append(file.path) } else { @@ -679,6 +678,9 @@ extension CostUsageStore { identity: normalizedIdentity, isComplete: file.scanState.isComplete) } + guard usage.codexScanFileId != restoredScanState.identity + || usage.codexScanComplete != restoredScanState.isComplete + else { continue } usage.codexScanFileId = restoredScanState.identity usage.codexScanComplete = restoredScanState.isComplete cache.files[file.path] = usage @@ -747,9 +749,10 @@ extension CostUsageStore { }.sorted { $0.path.count > $1.path.count } } - private static func normalizedCodexFileIdentity( + static func normalizedCodexFileIdentity( file: CostUsageStoreFile, - currentRootDevices: [CurrentCodexRootDevice]) -> String? + currentRootDevices: [CurrentCodexRootDevice], + normalizePath: (String) -> String = CostUsageStore.normalizedCodexPath) -> String? { guard let identity = file.scanState.fileIdentity, let inode = Self.inode(from: identity) @@ -757,7 +760,9 @@ extension CostUsageStore { if let persistedInode = file.inode, persistedInode != inode { return identity } - let filePath = Self.normalizedCodexPath(file.path) + // A root lookup cannot change an identity that every current device would reproduce. + guard !currentRootDevices.allSatisfy({ identity == "\($0.device):\(inode)" }) else { return identity } + let filePath = normalizePath(file.path) guard let root = currentRootDevices.first(where: { root in if filePath == root.path { return true @@ -770,17 +775,9 @@ extension CostUsageStore { private static func restoredCodexScanState( file: CostUsageStoreFile, - currentRootDevices: [CurrentCodexRootDevice], - validateMetadata: Bool) -> RestoredCodexScanState + identity: String?) -> RestoredCodexScanState { - let identity = Self.normalizedCodexFileIdentity( - file: file, - currentRootDevices: currentRootDevices) - guard validateMetadata else { - return RestoredCodexScanState(identity: identity, isComplete: file.scanState.isComplete) - } - - let fileURL = URL(fileURLWithPath: file.path) + let fileURL = URL(fileURLWithPath: file.path, isDirectory: false) let metadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) guard let currentIdentity = metadata.fileId else { return RestoredCodexScanState(identity: identity, isComplete: file.scanState.isComplete) @@ -801,8 +798,8 @@ extension CostUsageStore { isComplete: false) } - private static func normalizedCodexPath(_ path: String) -> String { - let path = URL(fileURLWithPath: path).standardizedFileURL.path + static func normalizedCodexPath(_ path: String) -> String { + let path = URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL.path if path.hasPrefix("/private/var/") { return String(path.dropFirst("/private".count)) } @@ -821,7 +818,7 @@ extension CostUsageStore { var completedIdentityValidationPathKeys: Set = [] for path in candidatePaths { Self.codexCatchUpReconciliationVisitForTesting?() - let fileURL = URL(fileURLWithPath: path) + let fileURL = URL(fileURLWithPath: path, isDirectory: false) let metadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) guard let fileId = metadata.fileId, let cachedEntry = Self.cachedCodexUsageEntry(for: path, cache: cache), @@ -939,7 +936,7 @@ extension CostUsageStore { var seenIdentities: Set = [] var totalBytes: Int64 = 0 for path in inventoryPaths { - let fileURL = URL(fileURLWithPath: path) + let fileURL = URL(fileURLWithPath: path, isDirectory: false) let metadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) guard let fileId = metadata.fileId else { return nil } guard seenIdentities.insert(fileId).inserted else { continue } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+ReadView.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+ReadView.swift index 2b47c1b9c7..61e432cf38 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+ReadView.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+ReadView.swift @@ -95,10 +95,10 @@ struct CostUsageStoreReadView: Sendable { var filesByResolvedPath: [String: CostUsageFileUsage] = [:] for (path, usage) in scoped.cache.files { - filesByResolvedPath[Self.resolvedCodexPath(URL(fileURLWithPath: path))] = usage + filesByResolvedPath[Self.resolvedCodexPath(URL(fileURLWithPath: path, isDirectory: false))] = usage } for path in lookback.pendingFilePaths { - let resolvedPath = Self.resolvedCodexPath(URL(fileURLWithPath: path)) + let resolvedPath = Self.resolvedCodexPath(URL(fileURLWithPath: path, isDirectory: false)) guard let usage = filesByResolvedPath[resolvedPath] else { return false } if lookback.cacheWideMigrationQueueActive == true, usage.touchesCodexScanWindow( @@ -108,7 +108,7 @@ struct CostUsageStoreReadView: Sendable { { return false } - let fileURL = URL(fileURLWithPath: resolvedPath) + let fileURL = URL(fileURLWithPath: resolvedPath, isDirectory: false) guard FileManager.default.fileExists(atPath: fileURL.path) else { continue } let metadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) if CostUsageScanner.codexLogicalTargetHasUnconsumedTail(metadata: metadata, cached: usage) diff --git a/Tests/CodexBarTests/CostUsageStorePathReconciliationTests.swift b/Tests/CodexBarTests/CostUsageStorePathReconciliationTests.swift new file mode 100644 index 0000000000..250fb88ca6 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageStorePathReconciliationTests.swift @@ -0,0 +1,150 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageStorePathReconciliationTests { + @Test + func `same device identities skip all cached path normalization`() { + let roots = [ + CostUsageStore.CurrentCodexRootDevice(path: "/synthetic/sessions", device: "17"), + CostUsageStore.CurrentCodexRootDevice(path: "/synthetic/archived_sessions", device: "17"), + ] + var visits = 0 + for index in 0..<25000 { + let file = Self.file(path: "/synthetic/sessions/missing-\(index).jsonl", identity: "17:\(index)") + let identity = CostUsageStore.normalizedCodexFileIdentity(file: file, currentRootDevices: roots) { path in + visits += 1 + return CostUsageStore.normalizedCodexPath(path) + } + #expect(identity == file.scanState.fileIdentity) + } + #expect(visits == 0) + } + + @Test + func `identity shortcut preserves mixed roots and unusual persisted identities`() { + let rootSets: [[CostUsageStore.CurrentCodexRootDevice]] = [ + [], + [.init(path: "/synthetic", device: "17")], + [.init(path: "/synthetic/nested", device: "18"), .init(path: "/synthetic", device: "17")], + ] + let identities: [String?] = [nil, "17:42", "18:42", "17:0042", "17:extra:42", "42", "invalid"] + for roots in rootSets { + for identity in identities { + for path in ["/synthetic/missing.jsonl", "/synthetic/nested/missing.jsonl", "/outside/missing.jsonl"] { + for persistedInode: Int64? in [nil, 42, 99] { + var file = Self.file(path: path, identity: identity) + file.inode = persistedInode + let expected = Self.legacyIdentity(file: file, roots: roots) + #expect(CostUsageStore.normalizedCodexFileIdentity( + file: file, currentRootDevices: roots) == expected) + } + } + } + } + } + + @Test + func `file URL hints preserve paths normalization comparisons and dictionary keys`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let file = env.root.appendingPathComponent("rollout space # café.jsonl", isDirectory: false) + try Data("synthetic\n".utf8).write(to: file) + let link = env.root.appendingPathComponent("linked.jsonl", isDirectory: false) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: file) + let missing = env.root.appendingPathComponent("missing.jsonl", isDirectory: false) + let paths = [ + file.path, + link.path, + missing.path, + env.root.path + "/./" + file.lastPathComponent, + env.root.path + "/absent/../missing.jsonl", + ] + for path in paths { + let old = URL(fileURLWithPath: path) + let hinted = URL(fileURLWithPath: path, isDirectory: false) + #expect(hinted == old) + #expect(hinted.path == old.path) + #expect(hinted.standardizedFileURL == old.standardizedFileURL) + #expect(hinted.standardizedFileURL.path == old.standardizedFileURL.path) + #expect(hinted.resolvingSymlinksInPath() == old.resolvingSymlinksInPath()) + #expect([old: 7][hinted] == 7) + #expect([old.standardizedFileURL.path: 7][hinted.standardizedFileURL.path] == 7) + } + // Root normalization uses only the path; the URL's directory marker is immaterial. + for path in paths + [env.root.path, env.root.path + "/", "/var/tmp", "/private/var/tmp"] { + #expect(CostUsageStore.normalizedCodexPath(path) == Self.legacyPath(path)) + } + } + + static func file(path: String, identity: String?) -> CostUsageStoreFile { + CostUsageStoreFile( + path: path, + mtimeUnixMs: 0, + size: 0, + scanState: .init(isComplete: true, fileIdentity: identity), + updatedAtUnixMs: 0) + } + + private static func legacyPath(_ path: String) -> String { + let path = URL(fileURLWithPath: path).standardizedFileURL.path + return path.hasPrefix("/private/var/") ? String(path.dropFirst("/private".count)) : path + } + + private static func legacyIdentity( + file: CostUsageStoreFile, + roots: [CostUsageStore.CurrentCodexRootDevice]) -> String? + { + guard let identity = file.scanState.fileIdentity, + let inode = identity.split(separator: ":").last.flatMap({ Int64($0) }) + else { return file.scanState.fileIdentity } + if let persistedInode = file.inode, persistedInode != inode { return identity } + let path = Self.legacyPath(file.path) + guard let root = roots.first(where: { + path == $0.path || path.hasPrefix($0.path.hasSuffix("/") ? $0.path : $0.path + "/") + }) else { return identity } + return "\(root.device):\(inode)" + } +} + +extension CostUsageStorePathReconciliationTests { + @Test + func `reconciliation preserves cached missing files and repairs changed state`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let root = env.root.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let device = try #require(CostUsageScanner.codexFileMetadata(fileURL: root).fileId?.split(separator: ":").first) + var cache = CostUsageCache() + cache.codexScanCatchUpPending = false + var files: [CostUsageStoreFile] = [] + for index in 0..<25000 { + let file = CostUsageStorePathReconciliationTests.file( + path: root.path + "/missing-\(index).jsonl", identity: "\(device):\(index)") + files.append(file) + cache.files[file.path] = CostUsageFileUsage( + mtimeUnixMs: 0, + size: 0, + days: [:], + codexScanFileId: file.scanState.fileIdentity, + codexScanComplete: true) + } + var metadata = CostUsageStoreMetadata.empty + metadata.rootMtimes = [root.path: 0] + let persistence = CostUsageStore.CodexPersistenceState(snapshot: .init( + metadata: metadata, + files: files, + tokenSnapshots: [], + fileDayAggregates: [], + dayAggregates: [], + forkLineage: [], + bufferedLines: [], + accumulators: [])) + #expect(CostUsageStore.reconciledCodexCache(cache, persistence: persistence) == cache) + let path = try #require(files.first?.path) + var changed = cache + changed.files[path]?.codexScanFileId = "stale" + changed.files[path]?.codexScanComplete = nil + #expect(CostUsageStore.reconciledCodexCache(changed, persistence: persistence) == cache) + } +} From 4b0bda1324e1467278dde6b749207e86cfc64794 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 20:00:47 -0700 Subject: [PATCH 110/122] perf(cost): reuse Codex thread database discovery (#4151) Overlaying Codex thread titles grouped sessions by constructing a full CodexThreadMetadataReader per session, and every reader listed its SQLite home directory to find the newest state_N.sqlite. With thousands of sessions that meant thousands of identical directory listings per refresh; xctrace on the signed 0.70.0 app attributed 11.8% of a steady-state refresh to this path (getattrlistbulk was the single heaviest stack). Resolve the database once per distinct working directory and SQLite home within a call. The public reader API is unchanged. 5,000 sessions across three homes now list 3 directories instead of 5,000; the debug benchmark drops from 2,074 ms to 89 ms (median of 5). Tests cover newer-database discovery, config redirects, missing homes and complete output equality. --- CHANGELOG.md | 1 + .../CodexThreadMetadataReader.swift | 20 +- Sources/CodexBarCore/CostUsageFetcher.swift | 15 +- .../CostUsageThreadTitleTests.swift | 177 ++++++++++++++++++ docs/architecture.md | 2 + 5 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageThreadTitleTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index afa72f67f4..499180b780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Reduce CPU use when refreshing model pricing while preserving historical fallback rates. - Costs: reduce CPU use while bucketing local agent logs into daily usage. - Costs: reduce CPU use while reconciling cached local Codex logs. +- Codex: reduce CPU use when loading conversation titles for large local cost histories. ## 0.70.0 — 2026-09-29 diff --git a/Sources/CodexBarCore/CodexThreadMetadataReader.swift b/Sources/CodexBarCore/CodexThreadMetadataReader.swift index ad2a318ea9..798585259b 100644 --- a/Sources/CodexBarCore/CodexThreadMetadataReader.swift +++ b/Sources/CodexBarCore/CodexThreadMetadataReader.swift @@ -29,20 +29,24 @@ public struct CodexThreadMetadataReader: Sendable { codexHomeDirectory: codexHomeDirectory, environment: environment, resolvedWorkingDirectory: resolvedWorkingDirectory) + self.databaseURL = Self.databaseURL(sqliteHomeDirectory: sqliteHomeDirectory, fileManager: fileManager) + self.sessionIndexURL = codexHomeDirectory.appendingPathComponent("session_index.jsonl") + } + + public init(databaseURL: URL) { + self.databaseURL = databaseURL + self.sessionIndexURL = nil + } + + static func databaseURL(sqliteHomeDirectory: URL, fileManager: FileManager) -> URL { let candidates = (try? fileManager.contentsOfDirectory( at: sqliteHomeDirectory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])) ?? [] - self.databaseURL = candidates + return candidates .filter { $0.pathExtension == "sqlite" && $0.deletingPathExtension().lastPathComponent.hasPrefix("state_") } .max { Self.stateVersion($0) < Self.stateVersion($1) } ?? sqliteHomeDirectory.appendingPathComponent("state_5.sqlite") - self.sessionIndexURL = codexHomeDirectory.appendingPathComponent("session_index.jsonl") - } - - public init(databaseURL: URL) { - self.databaseURL = databaseURL - self.sessionIndexURL = nil } public func metadata(for sessionIDs: Set) -> [String: CodexThreadMetadata] { @@ -144,7 +148,7 @@ public struct CodexThreadMetadataReader: Sendable { Int(url.deletingPathExtension().lastPathComponent.dropFirst("state_".count)) ?? 0 } - private static func sqliteHomeDirectory( + static func sqliteHomeDirectory( codexHomeDirectory: URL, environment: [String: String], resolvedWorkingDirectory: URL?) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index f6f69e31d5..c722c538b1 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -968,7 +968,8 @@ public struct CostUsageFetcher: Sendable { static func codexSessionsWithThreadTitles( _ sessions: [CostUsageSessionBreakdown], sessionsRoot: URL?, - environment: [String: String] = ProcessInfo.processInfo.environment) -> [CostUsageSessionBreakdown] + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> [CostUsageSessionBreakdown] { guard !sessions.isEmpty, let sessionsRoot, @@ -979,13 +980,21 @@ public struct CostUsageFetcher: Sendable { let home = sessionsRoot.deletingLastPathComponent() let indexedNames = CodexThreadMetadataReader.indexedThreadNames( codexHomeDirectory: home, sessionIDs: Set(sessions.map(\.sessionID))) + var databasesByWorkingDirectory: [String?: URL] = [:] + var databasesBySQLiteHome: [URL: URL] = [:] let groups = Dictionary(grouping: sessions) { session in - CodexThreadMetadataReader( + if let database = databasesByWorkingDirectory[session.workingDirectory] { return database } + let sqliteHome = CodexThreadMetadataReader.sqliteHomeDirectory( codexHomeDirectory: home, environment: environment, resolvedWorkingDirectory: session.workingDirectory.map { URL(fileURLWithPath: $0, isDirectory: true) - }).databaseURL + }) + let database = databasesBySQLiteHome[sqliteHome] ?? CodexThreadMetadataReader.databaseURL( + sqliteHomeDirectory: sqliteHome, fileManager: fileManager) + databasesBySQLiteHome[sqliteHome] = database + databasesByWorkingDirectory[session.workingDirectory] = database + return database } var metadata: [String: CodexThreadMetadata] = [:] for (database, sessions) in groups { diff --git a/Tests/CodexBarTests/CostUsageThreadTitleTests.swift b/Tests/CodexBarTests/CostUsageThreadTitleTests.swift new file mode 100644 index 0000000000..b840d07d27 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageThreadTitleTests.swift @@ -0,0 +1,177 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif +import Testing +@testable import CodexBarCore + +#if canImport(SQLite3) || canImport(CSQLite3) +struct CostUsageThreadTitleTests { + @Test + func `five thousand sessions list each sqlite home once and preserve every field`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let manager = ListingFileManager() + + let result = CostUsageFetcher.codexSessionsWithThreadTitles( + fixture.sessions, + sessionsRoot: fixture.home.appendingPathComponent("sessions"), + environment: ["CODEX_SQLITE_HOME": ".codex"], + fileManager: manager) + + #expect(result == fixture.expected) + #expect(manager.listings.count == 3) + #expect(Set(manager.listings) == Set(fixture.sqliteHomes)) + } + + @Test(arguments: [false, true]) + func `different working directories share the same absolute sqlite home`(configured: Bool) throws { + let fixture = try Fixture() + defer { fixture.remove() } + if configured { + try "sqlite_home = '\(fixture.home.path)'\n".write( + to: fixture.home.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8) + } + let manager = ListingFileManager() + let environment = ["CODEX_SQLITE_HOME": configured ? "/unused/environment/home" : fixture.home.path] + + let result = CostUsageFetcher.codexSessionsWithThreadTitles( + fixture.sessions, + sessionsRoot: fixture.home.appendingPathComponent("sessions"), + environment: environment, + fileManager: manager) + + #expect(result == fixture.sessions.enumerated().map { index, session in + session.withTitle(index == 0 ? "Explicit name" : "Home 0: \(index)") + }) + #expect(manager.listings.count == 1) + #expect(Set(manager.listings) == [fixture.home]) + } + + @Test + func `next call discovers database versions config and index edits`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let manager = ListingFileManager() + let sessions = Array(fixture.sessions.prefix(2)) + let sessionsRoot = fixture.home.appendingPathComponent("sessions") + let initial = CostUsageFetcher.codexSessionsWithThreadTitles( + sessions, sessionsRoot: sessionsRoot, environment: [:], fileManager: manager) + #expect(initial.map(\.title) == ["Explicit name", "Home 0: 1"]) + + try Fixture.createDatabase(at: fixture.home.appendingPathComponent("state_10.sqlite"), home: 10) + try "{\"id\":\"session-0\",\"thread_name\":\"Renamed\"}\n".write( + to: fixture.home.appendingPathComponent("session_index.jsonl"), atomically: true, encoding: .utf8) + let newer = CostUsageFetcher.codexSessionsWithThreadTitles( + sessions, sessionsRoot: sessionsRoot, environment: [:], fileManager: manager) + #expect(newer.map(\.title) == ["Renamed", "Home 10: 1"]) + + try "sqlite_home = '\(fixture.sqliteHomes[1].path)'\n".write( + to: fixture.home.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8) + let redirected = CostUsageFetcher.codexSessionsWithThreadTitles( + sessions, sessionsRoot: sessionsRoot, environment: [:], fileManager: manager) + #expect(redirected.map(\.title) == ["Renamed", "Home 1: 1"]) + #expect(manager.listings == [fixture.home, fixture.home, fixture.sqliteHomes[1]]) + } + + @Test + func `missing sqlite home is attempted only once and retains index and original titles`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let manager = ListingFileManager() + let missing = fixture.home.appendingPathComponent("missing", isDirectory: true) + let result = CostUsageFetcher.codexSessionsWithThreadTitles( + fixture.sessions, + sessionsRoot: fixture.home.appendingPathComponent("sessions"), + environment: ["CODEX_SQLITE_HOME": missing.path], + fileManager: manager) + + #expect(result == fixture.sessions.enumerated().map { index, session in + index == 0 ? session.withTitle("Explicit name") : session + }) + #expect(manager.listings.count == 1) + #expect(Set(manager.listings) == [missing]) + } + + private struct Fixture { + let root: URL + let home: URL + let sqliteHomes: [URL] + let sessions: [CostUsageSessionBreakdown] + let expected: [CostUsageSessionBreakdown] + + init() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cost-thread-titles-\(UUID().uuidString)", isDirectory: true) + self.root = root + self.home = root.appendingPathComponent("codex", isDirectory: true) + let projects = (1...2).map { root.appendingPathComponent("project-\($0)", isDirectory: true) } + self.sqliteHomes = [self.home] + projects.map { $0.appendingPathComponent(".codex", isDirectory: true) } + for (index, sqliteHome) in self.sqliteHomes.enumerated() { + try FileManager.default.createDirectory(at: sqliteHome, withIntermediateDirectories: true) + try Self.createDatabase(at: sqliteHome.appendingPathComponent("state_9.sqlite"), home: index) + try Data().write(to: sqliteHome.appendingPathComponent("state_5.sqlite")) + } + try "model = 'fixture-model'\n".write( + to: self.home.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8) + try "{\"id\":\"session-0\",\"thread_name\":\"Explicit name\"}\n".write( + to: self.home.appendingPathComponent("session_index.jsonl"), atomically: true, encoding: .utf8) + self.sessions = (0..<5000).map { index in + var session = CostUsageSessionBreakdown( + sessionID: "session-\(index)", + lastActivity: Date(timeIntervalSince1970: Double(index)), + inputTokens: index, + cachedInputTokens: 1, + outputTokens: 2, + reasoningTokens: 1, + totalTokens: index + 2, + requestCount: 1, + costUSD: 0.01, + modelBreakdowns: [], + projectPath: "/synthetic/canonical", + projectName: "Synthetic", + title: "Original") + session.workingDirectory = index % 3 == 0 ? nil : projects[index % 3 - 1].path + return session + } + self.expected = self.sessions.enumerated().map { index, session in + session.withTitle(index == 0 ? "Explicit name" : "Home \(index % 3): \(index)") + } + } + + func remove() { + try? FileManager.default.removeItem(at: self.root) + } + + static func createDatabase(at url: URL, home: Int) throws { + var database: OpaquePointer? + #expect(sqlite3_open(url.path, &database) == SQLITE_OK) + let opened = try #require(database) + defer { sqlite3_close(opened) } + let rows = (0..<5000).map { "('session-\($0)', 'Home \(home): \($0)')" }.joined(separator: ",") + let sql = "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT); INSERT INTO threads VALUES \(rows);" + #expect(sqlite3_exec(opened, sql, nil, nil, nil) == SQLITE_OK) + } + } +} + +private final class ListingFileManager: FileManager, @unchecked Sendable { + private let lock = NSLock() + private var recorded: [URL] = [] + + var listings: [URL] { + self.lock.withLock { self.recorded } + } + + override func contentsOfDirectory( + at url: URL, + includingPropertiesForKeys keys: [URLResourceKey]?, + options mask: FileManager.DirectoryEnumerationOptions = []) throws -> [URL] + { + self.lock.withLock { self.recorded.append(url) } + return try super.contentsOfDirectory(at: url, includingPropertiesForKeys: keys, options: mask) + } +} +#endif diff --git a/docs/architecture.md b/docs/architecture.md index cdd126ea64..48d80f4f9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,8 @@ read_when: - Background refresh → `UsageFetcher`/provider probes → `UsageStore` → menu/icon/widgets. - Settings toggles feed `SettingsStore` → `UsageStore` refresh cadence + feature flags. - Runtime-only provider settings flow through typed, descriptor-registered sections in `ProviderSettingsSnapshot`. +- Codex cost conversation titles reuse SQLite database discovery per working directory and SQLite home within each + enrichment call. The next call resolves paths again, so config edits and new state database versions are picked up. ## CLI login lifecycle - `CodexLoginRunner` and `KiroLoginRunner` resolve their own executable and environment, including Codex home scoping. From 25888a0ff7a5fead0893f2cb193e39e074eac4ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 20:01:13 -0700 Subject: [PATCH 111/122] perf(cost): reuse Codex listing metadata and paths (#4153) loadCodexDaily spent most of its time on file bookkeeping rather than parsing: with ~22k rollouts, xctrace on the signed 0.70.0 app put it at 20.4% of CPU per refresh while JSONL scanning was 2%. Each load sorted session files newest-first twice, stat'ing every file per sort and re-deriving URL.path plus a dictionary lookup inside every comparison, and standardized every listed child path (a stat per call on Darwin). Share listing metadata within one load, sort via precomputed (path, mtime, size) keys, standardize listing roots once instead of every child, reuse known path keys, and pass explicit file/directory hints to URL initializers. Ordering, scanned files and cache keys are unchanged (byte-level golden tests cover partitions, flat/legacy layouts, symlinked and /private/var roots, equal mtimes and missing files). Warm-cache loadCodexDaily over a synthetic tree: 11.77 s -> 8.57 s (median of 3); on a repeated catch-up refresh, listing metadata reads fall 24 -> 12 and root standardizations 24 -> 2. --- CHANGELOG.md | 2 + .../Vendored/CostUsage/CostUsageScanner.swift | 146 +++++++++++------- .../CostUsageFileListingTests.swift | 145 +++++++++++++++++ 3 files changed, 240 insertions(+), 53 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageFileListingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 499180b780..e7c6b401de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - Docs: link the community codexbar-kde Plasma widget for Linux usage meters and agent-session views (#4117). Thanks @materemias! + ### Fixed - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! @@ -13,6 +14,7 @@ - Costs: reduce CPU use while bucketing local agent logs into daily usage. - Costs: reduce CPU use while reconciling cached local Codex logs. - Codex: reduce CPU use when loading conversation titles for large local cost histories. +- Reduce CPU use while scanning local Codex logs for cost data. ## 0.70.0 — 2026-09-29 diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index c4a21ccfd3..3d0e6b9637 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -15,6 +15,7 @@ import Glibc enum CostUsageScanner { static let codexProjectMetadataVersion = 1 typealias CancellationCheck = () throws -> Void + typealias CodexListingMetadataReader = (URL) -> CodexFileMetadata static let log = CodexBarLog.logger(LogCategories.tokenCost) static let codexActiveSessionLookbackDays = 30 @@ -65,6 +66,8 @@ enum CostUsageScanner { var codexCandidateSelectionVisits: Int var codexFileScanAttempts: Int var codexProgressAccountingVisits: Int + var codexListingMetadataReads: Int + var codexListingRootStandardizations: Int } final class CodexScanWorkRecorder: @unchecked Sendable { @@ -81,6 +84,8 @@ enum CostUsageScanner { private var codexFileScanAttempts = 0 private var codexFileScanAttemptPaths: Set = [] private var codexProgressAccountingVisits = 0 + private var codexListingMetadataReads = 0 + private var codexListingRootStandardizations = 0 func record(processed: Int, repriced: Int) { self.lock.lock() @@ -141,6 +146,14 @@ enum CostUsageScanner { self.lock.unlock() } + func recordCodexListingMetadataRead() { + self.lock.withLock { self.codexListingMetadataReads += 1 } + } + + func recordCodexListingRootStandardization() { + self.lock.withLock { self.codexListingRootStandardizations += 1 } + } + func snapshot() -> CodexScanWorkMetrics { self.lock.lock() defer { self.lock.unlock() } @@ -155,7 +168,9 @@ enum CostUsageScanner { codexDiscoveryVisits: self.codexDiscoveryVisits, codexCandidateSelectionVisits: self.codexCandidateSelectionVisits, codexFileScanAttempts: self.codexFileScanAttempts, - codexProgressAccountingVisits: self.codexProgressAccountingVisits) + codexProgressAccountingVisits: self.codexProgressAccountingVisits, + codexListingMetadataReads: self.codexListingMetadataReads, + codexListingRootStandardizations: self.codexListingRootStandardizations) } } @@ -2117,7 +2132,7 @@ enum CostUsageScanner { } let env = ProcessInfo.processInfo.environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) if let env, !env.isEmpty { - return URL(fileURLWithPath: env).appendingPathComponent("sessions", isDirectory: true) + return URL(fileURLWithPath: env, isDirectory: true).appendingPathComponent("sessions", isDirectory: true) } return FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".codex", isDirectory: true) @@ -2139,13 +2154,16 @@ enum CostUsageScanner { .appendingPathComponent("archived_sessions", isDirectory: true) } - private static func listCodexSessionFiles( + static func listCodexSessionFiles( root: URL, scanSinceKey: String, scanUntilKey: String, includeRecursive: Bool, - calendar: Calendar = .current) -> [URL] + calendar: Calendar = .current, + workRecorder: CodexScanWorkRecorder? = nil) -> [URL] { + workRecorder?.recordCodexListingRootStandardization() + let root = root.standardizedFileURL let partitioned = self.listCodexSessionFilesByDatePartition( root: root, scanSinceKey: scanSinceKey, @@ -2154,12 +2172,9 @@ enum CostUsageScanner { let flat = self.listCodexSessionFilesFlat(root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey) let recursive = includeRecursive ? self.listCodexLegacySessionFilesRecursive(root: root) : [] var seen: Set = [] - var out: [URL] = [] - for item in partitioned + flat + recursive where !seen.contains(Self.codexPathKey(item)) { - seen.insert(Self.codexPathKey(item)) - out.append(item) + return (partitioned + flat + recursive).filter { + seen.insert(Self.codexPathKey(standardizedPath: $0.path)).inserted } - return out } private static func cachedCodexSessionFiles( @@ -2169,7 +2184,9 @@ enum CostUsageScanner { excludingPaths: Set) -> [URL] { cache.files.compactMap { path, usage in - guard !excludingPaths.contains(Self.codexPathKey(URL(fileURLWithPath: path))) else { return nil } + guard !excludingPaths.contains(path), + !excludingPaths.contains(Self.codexPathKey(URL(fileURLWithPath: path, isDirectory: false))) + else { return nil } let hasRelevantDay = usage.days.keys.contains { CostUsageDayRange.isInRange(dayKey: $0, since: range.scanSinceKey, until: range.scanUntilKey) } @@ -2501,15 +2518,14 @@ enum CostUsageScanner { } private static func codexResolvedPath(_ url: URL) -> String { - let path = url.resolvingSymlinksInPath().standardizedFileURL.path - if path.hasPrefix("/private/var/") { - return String(path.dropFirst("/private".count)) - } - return path + self.codexPathKey(standardizedPath: url.resolvingSymlinksInPath().standardizedFileURL.path) } static func codexPathKey(_ url: URL) -> String { - let path = url.standardizedFileURL.path + self.codexPathKey(standardizedPath: url.standardizedFileURL.path) + } + + static func codexPathKey(standardizedPath path: String) -> String { if path.hasPrefix("/private/var/") { return String(path.dropFirst("/private".count)) } @@ -2825,6 +2841,7 @@ enum CostUsageScanner { remainingDiscoveryVisits: inout Int, excludedPendingPathKeys: Set, workRecorder: CodexScanWorkRecorder?, + listingMetadata: CodexListingMetadataReader, state: inout CostUsageCodexActiveLookbackState) { let rootPath = Self.codexResolvedPath(root) @@ -2898,7 +2915,8 @@ enum CostUsageScanner { if !discoveredFilePaths.isEmpty { let discoveredFiles = discoveredFilePaths.map { URL(fileURLWithPath: $0) } Self.appendCodexActiveLookbackPaths( - preferNewest ? Self.sortedCodexSessionFilesNewestFirst(discoveredFiles) : discoveredFiles, + preferNewest ? Self + .sortedCodexSessionFilesNewestFirst(discoveredFiles, metadata: listingMetadata) : discoveredFiles, state: &state) } state.completedCurrentWindowRootPaths = completedPartitionRoots.sorted() @@ -2953,6 +2971,7 @@ enum CostUsageScanner { remainingDiscoveryVisits: inout Int, excludedPendingPathKeys: Set, workRecorder: CodexScanWorkRecorder?, + listingMetadata: CodexListingMetadataReader, state: inout CostUsageCodexActiveLookbackState) { let rootPath = Self.codexResolvedPath(root) @@ -2989,7 +3008,8 @@ enum CostUsageScanner { if !discoveredFilePaths.isEmpty { let discoveredFiles = discoveredFilePaths.map { URL(fileURLWithPath: $0) } Self.appendCodexActiveLookbackPaths( - preferNewest ? Self.sortedCodexSessionFilesNewestFirst(discoveredFiles) : discoveredFiles, + preferNewest ? Self + .sortedCodexSessionFilesNewestFirst(discoveredFiles, metadata: listingMetadata) : discoveredFiles, state: &state) } if page.isComplete { @@ -3003,18 +3023,17 @@ enum CostUsageScanner { state.completedRootPaths = completedRootPaths.sorted() } - private static func appendCodexActiveLookbackPaths( - _ files: some Sequence, + static func appendCodexActiveLookbackPaths( + _ files: [URL], normalizeExisting: Bool = false, state: inout CostUsageCodexActiveLookbackState) { - let files = Array(files) guard !files.isEmpty else { return } var queuedPaths: Set if normalizeExisting { var normalizedPaths: Set = [] state.pendingFilePaths = state.pendingFilePaths.compactMap { path in - let resolvedPath = Self.codexResolvedPath(URL(fileURLWithPath: path)) + let resolvedPath = Self.codexResolvedPath(URL(fileURLWithPath: path, isDirectory: false)) return normalizedPaths.insert(resolvedPath).inserted ? resolvedPath : nil } queuedPaths = normalizedPaths @@ -3036,6 +3055,7 @@ enum CostUsageScanner { let shouldBoundCatchUp: Bool let shouldSeedBoundedQueue: Bool let preferNewest: Bool + let listingMetadata: CodexListingMetadataReader } private static func seedOrExtendCodexActiveLookbackQueue( @@ -3048,7 +3068,9 @@ enum CostUsageScanner { self.reseedCodexActiveLookbackPathKeys(migrationSeedPathKeys, state: &state) } else { self.appendCodexActiveLookbackPaths( - context.preferNewest ? self.sortedCodexSessionFilesNewestFirst(context.seedFiles) : context + context.preferNewest ? self.sortedCodexSessionFilesNewestFirst( + context.seedFiles, + metadata: context.listingMetadata) : context .seedFiles, normalizeExisting: true, state: &state) @@ -3057,11 +3079,12 @@ enum CostUsageScanner { } guard let previousDiscovery = context.previousDiscovery else { return } let previousPaths = Set(previousDiscovery.fileStamps.keys.map { - Self.codexResolvedPath(URL(fileURLWithPath: $0)) + Self.codexResolvedPath(URL(fileURLWithPath: $0, isDirectory: false)) }) let newFiles = context.discoveredFiles.filter { !previousPaths.contains(Self.codexResolvedPath($0)) } Self.appendCodexActiveLookbackPaths( - context.preferNewest ? self.sortedCodexSessionFilesNewestFirst(newFiles) : newFiles, + context.preferNewest ? self + .sortedCodexSessionFilesNewestFirst(newFiles, metadata: context.listingMetadata) : newFiles, state: &state) } @@ -3116,18 +3139,18 @@ enum CostUsageScanner { { if context.validateRoots { state.pendingFilePaths = state.pendingFilePaths.filter { path in - Self.isWithinCodexRoots(fileURL: URL(fileURLWithPath: path), roots: context.roots) + Self.isWithinCodexRoots(fileURL: URL(fileURLWithPath: path, isDirectory: false), roots: context.roots) } } let pendingCount = min(context.maxCount ?? state.pendingFilePaths.count, state.pendingFilePaths.count) var normalizedPathSet: Set = [] let normalizedPrefix = state.pendingFilePaths.prefix(pendingCount).compactMap { path in - let resolvedPath = Self.codexResolvedPath(URL(fileURLWithPath: path)) + let resolvedPath = Self.codexResolvedPath(URL(fileURLWithPath: path, isDirectory: false)) return normalizedPathSet.insert(resolvedPath).inserted ? resolvedPath : nil } state.pendingFilePaths.replaceSubrange(0.. [URL] { guard FileManager.default.fileExists(atPath: root.path) else { return [] } - let rootPath = root.standardizedFileURL.path + let rootPath = root.path guard let enumerator = FileManager.default.enumerator( at: root, includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], @@ -3287,12 +3313,10 @@ enum CostUsageScanner { } private static func isCodexDatePartitionAncestor(_ url: URL, rootPath: String) -> Bool { - let path = url.standardizedFileURL.path + let path = url.path guard path.hasPrefix(rootPath + "/") else { return false } - let relative = String(path.dropFirst(rootPath.count + 1)) - let parts = relative.split(separator: "/") - guard parts.count == 1 else { return false } - return Self.isDatePartitionComponent(String(parts[0]), length: 4) + let relative = path.dropFirst(rootPath.count + 1) + return !relative.contains("/") && Self.isDatePartitionComponent(String(relative), length: 4) } private static let codexFilenameDateRegex = try? NSRegularExpression(pattern: "(\\d{4}-\\d{2}-\\d{2})") @@ -5861,6 +5885,16 @@ enum CostUsageScanner { history.reset() } + // Bookkeeping shares one snapshot; parsing and completion still revalidate live files. + var listingMetadataByPath: [String: CodexFileMetadata] = [:] + func listingMetadata(_ url: URL) -> CodexFileMetadata { + let path = url.path + if let cached = listingMetadataByPath[path] { return cached } + options.codexScanWorkRecorderForTesting?.recordCodexListingMetadataRead() + let metadata = Self.codexFileMetadata(fileURL: url) + listingMetadataByPath[path] = metadata + return metadata + } let cachedSinceKey = cache.scanSinceKey let cachedUntilKey = cache.scanUntilKey let shouldRunColdCacheLookback = cache.files.isEmpty || plan.rootsChanged @@ -5919,6 +5953,7 @@ enum CostUsageScanner { remainingDiscoveryVisits: &remainingDiscoveryVisits, excludedPendingPathKeys: discoveryExcludedPathKeys, workRecorder: options.codexScanWorkRecorderForTesting, + listingMetadata: listingMetadata, state: &activeLookbackState) } else { let rootFiles = Self.listCodexSessionFiles( @@ -5926,11 +5961,12 @@ enum CostUsageScanner { scanSinceKey: range.scanSinceKey, scanUntilKey: range.scanUntilKey, includeRecursive: options.forceRescan || isExactInventoryProofPass, - calendar: options.calendar) - for fileURL in rootFiles.sorted(by: { $0.path < $1.path }) { - let pathKey = Self.codexPathKey(fileURL) + calendar: options.calendar, + workRecorder: options.codexScanWorkRecorderForTesting) + for path in rootFiles.map(\.path).sorted() { + let pathKey = Self.codexPathKey(standardizedPath: path) guard seenPaths.insert(pathKey).inserted else { continue } - let canonicalFileURL = URL(fileURLWithPath: pathKey) + let canonicalFileURL = URL(fileURLWithPath: pathKey, isDirectory: false) fileURLsByPathKey[pathKey] = canonicalFileURL files.append(canonicalFileURL) } @@ -5962,6 +5998,7 @@ enum CostUsageScanner { remainingDiscoveryVisits: &remainingDiscoveryVisits, excludedPendingPathKeys: discoveryExcludedPathKeys, workRecorder: options.codexScanWorkRecorderForTesting, + listingMetadata: listingMetadata, state: &activeLookbackState) } else { Self.advanceCodexActiveLookback( @@ -6017,14 +6054,14 @@ enum CostUsageScanner { let dirtyFiles = files.filter { fileURL in guard let usage = cache.files[fileURL.path], usage.codexScanComplete == true, !usage.hasBufferedCodexForkRetryLines else { return true } - let metadata = Self.codexFileMetadata(fileURL: fileURL) + let metadata = listingMetadata(fileURL) return Self.codexLogicalTargetHasUnconsumedTail(metadata: metadata, cached: usage) || usage.size != metadata.size || usage.mtimeUnixMs != metadata.mtimeUnixMs || usage.codexScanFileId != metadata.fileId } Self.appendCodexActiveLookbackPaths( options.preferNewestCodexSessionsFirst - ? Self.sortedCodexSessionFilesNewestFirst(dirtyFiles) : dirtyFiles, + ? Self.sortedCodexSessionFilesNewestFirst(dirtyFiles, metadata: listingMetadata) : dirtyFiles, state: &activeLookbackState) } let cacheWideMigrationNeedsQueueReseed = Self.cacheWideMigrationNeedsQueueReseed( @@ -6034,7 +6071,8 @@ enum CostUsageScanner { let migrationSeedPathKeys = cacheWideMigrationNeedsQueueReseed ? (options.preferNewestCodexSessionsFirst ? Self.sortedCodexSessionFilesNewestFirst( - inventoryPathKeys.map { URL(fileURLWithPath: $0) }) + inventoryPathKeys.map { URL(fileURLWithPath: $0, isDirectory: false) }, + metadata: listingMetadata) : inventoryPathKeys.sorted().map { URL(fileURLWithPath: $0) }) .map(Self.codexPathKey) : nil @@ -6044,7 +6082,7 @@ enum CostUsageScanner { // One-shot metadata keys can advance in this pass because the durable queue now owns // every required revisit. Later passes observe the new key and drain the queue without reseeding. let shouldSeedBoundedQueue = activeLookbackStateWasReset || cacheWideMigrationNeedsQueueReseed - var filePathsInScan = Set(files.map(Self.codexPathKey)) + var filePathsInScan = seenPaths if activeLookbackState.cacheWideMigrationQueueActive == true { filePathsInScan.formUnion(inventoryPathKeys) } @@ -6056,7 +6094,8 @@ enum CostUsageScanner { previousDiscovery: cache.codexSessionDiscovery, shouldBoundCatchUp: shouldBoundCatchUp, shouldSeedBoundedQueue: shouldSeedBoundedQueue, - preferNewest: options.preferNewestCodexSessionsFirst), + preferNewest: options.preferNewestCodexSessionsFirst, + listingMetadata: listingMetadata), state: &activeLookbackState) let canExtendSelectedPrefix = shouldSeedBoundedQueue || (!isExactInventoryProofPass && !hasUnmaterializedPendingPaths) @@ -6071,6 +6110,7 @@ enum CostUsageScanner { shouldBoundCatchUp: shouldBoundCatchUp, boundedQueuePathCount: boundedQueuePathCount, preferNewest: options.preferNewestCodexSessionsFirst, + listingMetadata: listingMetadata, workRecorder: options.codexScanWorkRecorderForTesting)) let filesScheduledForRefresh = refreshSelection.files let completionStatesBeforeScan = Self.codexCompletionStates( @@ -6691,21 +6731,21 @@ enum CostUsageScanner { workRecorder: options.codexScanWorkRecorderForTesting) } - static func sortedCodexSessionFilesNewestFirst(_ files: [URL]) -> [URL] { - let metadata = files.reduce(into: [String: CodexFileMetadata]()) { result, fileURL in - result[fileURL.path] = Self.codexFileMetadata(fileURL: fileURL) - } - return files.sorted { lhs, rhs in - let left = metadata[lhs.path] ?? Self.codexFileMetadata(fileURL: lhs) - let right = metadata[rhs.path] ?? Self.codexFileMetadata(fileURL: rhs) + static func sortedCodexSessionFilesNewestFirst( + _ files: [URL], + metadata: CodexListingMetadataReader = { Self.codexFileMetadata(fileURL: $0) }) -> [URL] + { + files.map { (url: $0, metadata: metadata($0)) }.sorted { lhs, rhs in + let left = lhs.metadata + let right = rhs.metadata if left.mtimeUnixMs != right.mtimeUnixMs { return left.mtimeUnixMs > right.mtimeUnixMs } if left.size != right.size { return left.size < right.size } - return lhs.path < rhs.path - } + return left.path < right.path + }.map(\.url) } private static func reconcileCodexCachePathAliases( diff --git a/Tests/CodexBarTests/CostUsageFileListingTests.swift b/Tests/CodexBarTests/CostUsageFileListingTests.swift new file mode 100644 index 0000000000..75bc380273 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFileListingTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageFileListingTests { + @Test + func `listing preserves legacy path keys and lookback aliases across root spellings`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let fm = FileManager.default + let day = try env.makeLocalNoon(year: 2026, month: 9, day: 30) + let link = env.root.appendingPathComponent("linked-home", isDirectory: true) + try fm.createSymbolicLink(at: link, withDestinationURL: env.codexHomeRoot) + for root in [env.codexSessionsRoot, env.codexArchivedSessionsRoot] { + for relative in [ + "2026/09/29/older.jsonl", + "2026/09/30/a.jsonl", + "2026/09/30/b.jsonl", + "2026/09/30/large.jsonl", + "2026/09/30/space % café.jsonl", + "flat.jsonl", + "legacy/nested.jsonl", + ] { + let file = root.appendingPathComponent(relative, isDirectory: false) + try fm.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(repeating: 32, count: relative.contains("large") ? 8 : 1).write(to: file) + try fm.setAttributes( + [.modificationDate: relative.contains("older") ? day.addingTimeInterval(-86400) : day], + ofItemAtPath: file.path) + } + try fm.createSymbolicLink( + at: root.appendingPathComponent("alias.jsonl", isDirectory: false), + withDestinationURL: root.appendingPathComponent("2026/09/30/a.jsonl", isDirectory: false)) + } + let plain = env.codexSessionsRoot.path.replacingOccurrences(of: "/private/var/", with: "/var/") + var spellings = [plain + "/", link.path + "/sessions/", link.path + "/../linked-home/sessions///"] + if plain.hasPrefix("/var/") { spellings.append("/private" + plain) } + for spelling in spellings { + let options = CostUsageScanner.Options(codexSessionsRoot: URL(fileURLWithPath: spelling, isDirectory: true)) + for root in CostUsageScanner.codexSessionsRoots(options: options) { + let legacy = try Self.legacyListing(root: root) + let recorder = CostUsageScanner.CodexScanWorkRecorder() + let listed = CostUsageScanner.listCodexSessionFiles( + root: root, + scanSinceKey: "2026-09-29", + scanUntilKey: "2026-09-30", + includeRecursive: true, + workRecorder: recorder) + let keys = listed.map { CostUsageScanner.codexPathKey(standardizedPath: $0.path) } + #expect(Self.pathBytes(keys) == Self.pathBytes(legacy.map(CostUsageScanner.codexPathKey))) + #expect(recorder.snapshot().codexListingRootStandardizations == 1) + let missing = root.appendingPathComponent("missing.jsonl", isDirectory: false) + let candidates = listed + [missing, listed[0]] + let sorted = CostUsageScanner.sortedCodexSessionFilesNewestFirst(candidates) + #expect(Self.pathBytes(sorted.map(\.path)) == Self.pathBytes(Self.legacySorted(candidates).map(\.path))) + var state = CostUsageCodexActiveLookbackState( + scanSinceKey: "2026-09-29", rootPaths: [], pendingFilePaths: [missing.path]) + CostUsageScanner.appendCodexActiveLookbackPaths(sorted, normalizeExisting: true, state: &state) + var seen: Set = [] + let expected = ([missing] + sorted).map(Self.resolvedKey).filter { seen.insert($0).inserted } + #expect(Self.pathBytes(state.pendingFilePaths) == Self.pathBytes(expected)) + } + } + } + + @Test + func `bookkeeping reads metadata once per file across catch-up sorts and refreshes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 9, day: 30) + let count = 12 + for index in 0.. [[UInt8]] { + paths.map { Array($0.utf8) } + } + + private static func legacyListing(root: URL) throws -> [URL] { + let fm = FileManager.default + var files: [URL] = [] + for day in ["29", "30"] { + files += try fm.contentsOfDirectory( + at: root.appendingPathComponent("2026/09/\(day)", isDirectory: true), + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) + .filter { $0.pathExtension.lowercased() == "jsonl" } + } + files += try fm.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]).filter { $0.pathExtension.lowercased() == "jsonl" } + let enumerator = try #require(fm.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants])) + while let item = enumerator.nextObject() as? URL { + let relative = item.standardizedFileURL.path.dropFirst(root.standardizedFileURL.path.count + 1) + if relative == "2026" { enumerator.skipDescendants(); continue } + if item.pathExtension.lowercased() == "jsonl" { files.append(item) } + } + var seen: Set = [] + return files.filter { seen.insert(CostUsageScanner.codexPathKey($0)).inserted } + } + + private static func legacySorted(_ files: [URL]) -> [URL] { + let metadata = files.reduce(into: [String: CostUsageScanner.CodexFileMetadata]()) { + $0[$1.path] = CostUsageScanner.codexFileMetadata(fileURL: $1) + } + return files.sorted { + let left = metadata[$0.path]! + let right = metadata[$1.path]! + if left.mtimeUnixMs != right.mtimeUnixMs { return left.mtimeUnixMs > right.mtimeUnixMs } + if left.size != right.size { return left.size < right.size } + return $0.path < $1.path + } + } + + private static func resolvedKey(_ url: URL) -> String { + url.resolvingSymlinksInPath().standardizedFileURL.path + .replacingOccurrences(of: "/private/var/", with: "/var/", options: [.anchored]) + } +} From 958494057ba2b10cee7509506083c84f27ddebc1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 20:01:26 -0700 Subject: [PATCH 112/122] perf(cost): reuse Claude reconciliation during cache rebuilds (#4158) Every Claude cost load that changed the cache reconciled all cached rows twice: once to rebuild packed daily totals and again inside buildClaudeReportFromCache, each pass sorting paths, hashing canonical row identities and sorting winners. xctrace on the signed 0.70.0 app showed reconciledClaudeRows at 7.5%, report building at 4.8% and day rebuilding at 4.2% of a steady-state window, and this runs every cost cycle whenever any transcript grew. Reconcile once per load and reuse the ordered rows for both consumers, and accumulate packed counts through mutable dictionary subscripts. Winner and summation ordering, incomplete/overflow handling, and the separate regular and history row sets are unchanged. Synthetic warm cache (5,000 files, 250,000 rows), one appended event per load, median of 3: full load 3.67 s -> 3.03 s CPU; reconciliations per load 2 -> 1. Fixed-seed golden hashes cover rows, packed days, daily/hourly reports and quota slices. --- CHANGELOG.md | 1 + .../CostUsage/CostUsageScanner+Claude.swift | 70 ++++++------ ...CostUsageClaudeRebuildBenchmarkTests.swift | 103 +++++++++++++++++ .../CostUsageClaudeRebuildTests.swift | 104 ++++++++++++++++++ .../CostUsageScannerClaudeMemoTests.swift | 1 + docs/cost-reporting-periods.md | 2 + 6 files changed, 249 insertions(+), 32 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageClaudeRebuildBenchmarkTests.swift create mode 100644 Tests/CodexBarTests/CostUsageClaudeRebuildTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e7c6b401de..78f73985f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Claude costs: reduce CPU use when rebuilding reports after local transcripts grow. - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! - Reduce CPU and filesystem work while identifying local agent processes during refreshes. - Reduce CPU use when refreshing model pricing while preserving historical fallback rates. diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index a4c47c708d..647027bacf 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -352,7 +352,7 @@ extension CostUsageScanner { return lhs.path < rhs.path } - private static func reconciledClaudeRows(cache: CostUsageCache) -> [ClaudeUsageRow] { + static func reconciledClaudeRows(cache: CostUsageCache) -> [ClaudeUsageRow] { #if DEBUG recordClaudeScanWork(.reconcile) #endif @@ -381,48 +381,47 @@ extension CostUsageScanner { return rows } - private static func rebuildClaudeDays(cache: inout CostUsageCache) { + static func rebuildClaudeDays(cache: inout CostUsageCache, rows: [ClaudeUsageRow]) { var days: [String: [String: [Int]]] = [:] var overflowed: Set = [] - for row in Self.reconciledClaudeRows(cache: cache) { + for row in rows { let key = ClaudeDayModelKey(day: row.dayKey, model: row.model) guard !overflowed.contains(key) else { continue } - var dayModels = days[row.dayKey] ?? [:] - let packed = dayModels[row.model] ?? [0, 0, 0, 0, 0, 0, 0, 0] - if row.isIncomplete == true { - // Retain the day/model so missing usage is visible without treating it as zero activity. - dayModels[row.model] = packed - days[row.dayKey] = dayModels - continue - } - let delta = [ - row.input, - row.cacheRead, - row.cacheCreate, - row.output, - row.costNanos, - 1, - (row.costPriced ?? (row.costNanos > 0)) ? 1 : 0, - row.cacheCreate1h ?? 0, - ] - let summed = zip(packed, delta).compactMap { current, incoming -> Int? in - let sum = current.addingReportingOverflow(incoming) - return sum.overflow ? nil : sum.partialValue - } - if summed.count == packed.count { - dayModels[row.model] = summed - } else { + if !Self.addClaudeRow( + row, + to: &days[row.dayKey, default: [:]][row.model, default: [0, 0, 0, 0, 0, 0, 0, 0]]) + { // Raw rows retain every metric; the legacy packed format cannot represent an unavailable total. overflowed.insert(key) - dayModels.removeValue(forKey: row.model) + days[row.dayKey]?.removeValue(forKey: row.model) } - days[row.dayKey] = dayModels } cache.days = days } + private static func addClaudeRow(_ row: ClaudeUsageRow, to packed: inout [Int]) -> Bool { + // Retain incomplete day/models without treating their missing usage as zero activity. + guard row.isIncomplete != true else { return true } + let delta = [ + row.input, + row.cacheRead, + row.cacheCreate, + row.output, + row.costNanos, + 1, + (row.costPriced ?? (row.costNanos > 0)) ? 1 : 0, + row.cacheCreate1h ?? 0, + ] + for (index, incoming) in delta.enumerated() { + let sum = packed[index].addingReportingOverflow(incoming) + guard !sum.overflow else { return false } + packed[index] = sum.partialValue + } + return true + } + private static let vertexProviderKeys: Set = [ "provider", "platform", @@ -778,8 +777,12 @@ extension CostUsageScanner { for key in cache.files.keys where sourceInventory[key] == nil { cache.files.removeValue(forKey: key) } + } - Self.rebuildClaudeDays(cache: &cache) + // Keep the same winner and summation order for both projections of this cache snapshot. + let rows = Self.reconciledClaudeRows(cache: cache) + if shouldMutateCache { + Self.rebuildClaudeDays(cache: &cache, rows: rows) Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) cache.scanSinceKey = range.scanSinceKey cache.scanUntilKey = range.scanUntilKey @@ -791,6 +794,7 @@ extension CostUsageScanner { let report = Self.buildClaudeReportFromCache( cache: cache, + rows: rows, range: range, pricingResolver: pricingResolver) try checkCancellation?() @@ -867,12 +871,14 @@ extension CostUsageScanner { { self.buildClaudeReportFromCache( cache: cache, + rows: self.reconciledClaudeRows(cache: cache), range: range, pricingResolver: CostUsagePricing.ClaudeResolver(now: now, cacheRoot: modelsDevCacheRoot)) } private static func buildClaudeReportFromCache( cache: CostUsageCache, + rows: [ClaudeUsageRow], range: CostUsageDayRange, pricingResolver: CostUsagePricing.ClaudeResolver) -> CostUsageDailyReport { @@ -887,7 +893,7 @@ extension CostUsageScanner { var costSeen = false var hasTokens = false let repricedCosts = self.claudeTemporalPricing( - rows: Self.reconciledClaudeRows(cache: cache), + rows: rows, range: range, pricingResolver: pricingResolver, temporalBuckets: &temporalBuckets) diff --git a/Tests/CodexBarTests/CostUsageClaudeRebuildBenchmarkTests.swift b/Tests/CodexBarTests/CostUsageClaudeRebuildBenchmarkTests.swift new file mode 100644 index 0000000000..996fd85068 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageClaudeRebuildBenchmarkTests.swift @@ -0,0 +1,103 @@ +import Darwin +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized, .enabled(if: ProcessInfo.processInfo.environment["CODEXBAR_REBUILD_BENCHMARK"] == "1")) +struct CostUsageClaudeRebuildBenchmarkTests { + @Test + func `warm large cache with one appended line`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + var cache = CostUsageClaudeRebuildTests.cache(fileCount: 5000, rowsPerFile: 50) + let range = CostUsageClaudeRebuildTests.range + let now = CostUsageClaudeRebuildTests.now + var ids: [String: String] = [:] + // Seed retained rows directly: only the append is parsed in this warm-cache benchmark. + for path in Array(cache.files.keys) { + let written = try env.writeClaudeProjectFile(relativePath: path, contents: "{}\n") + // Match the scanner's /private/var spelling; Foundation resolves it back to /var. + let url = URL(fileURLWithPath: written.path.hasPrefix("/var/") ? "/private" + written.path : written.path) + let stamp = try #require(CostUsageClaudeFileStamp.read(at: url)) + let retained = cache.files.removeValue(forKey: path) + var usage = try #require(retained) + usage.mtimeUnixMs = stamp.mtimeUnixMs + cache.files[url.path] = usage + ids[url.path] = stamp.fileID + } + var artifact = CostUsageClaudeCache() + artifact.usage = cache + artifact.sourceFileIDs = ids + let saved = try CostUsageClaudeCacheIO.save( + provider: .claude, + cache: artifact, + cacheRoot: env.cacheRoot, + calendar: range.calendar) + try #require(saved != nil) + let seeded = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: env.cacheRoot, calendar: range.calendar) + try #require(seeded.usage.files.count == 5000) + #expect(seeded.usage.files.values.reduce(0) { $0 + ($1.claudeRows?.count ?? 0) } == 250_000) + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot, calendar: range.calendar) + options.refreshMinIntervalSeconds = 0 + func load() throws -> CostUsageDailyReport { + try CostUsageScanner.loadClaudeDaily( + provider: .claude, range: range, now: now, options: options, checkCancellation: nil) + } + let warm = CostUsageScanner.ClaudeScanWorkRecorder() + _ = try CostUsageScanner.withClaudeScanWorkRecorderForTesting(warm) { try load() } + try #require(warm.snapshot().transcriptParses == 0) + #expect(warm.snapshot().repricedRows > 100_000) + for iteration in 0..<3 { + try self.measure("reconcile", iteration: iteration) { + #expect(!CostUsageScanner.reconciledClaudeRows(cache: cache).isEmpty) + } + try self.measure("rebuild-days-including-reconcile", iteration: iteration) { + var copy = cache + CostUsageScanner.rebuildClaudeDays( + cache: ©, + rows: CostUsageScanner.reconciledClaudeRows(cache: cache)) + #expect(!copy.days.isEmpty) + } + try self.measure("report-including-reconcile", iteration: iteration) { + #expect(!CostUsageScanner.buildClaudeReportFromCache( + cache: cache, range: range, now: now, modelsDevCacheRoot: env.cacheRoot).data.isEmpty) + } + let source = try URL(fileURLWithPath: #require(cache.files.keys.min())) + let handle = try FileHandle(forWritingTo: source) + try handle.seekToEnd() + let line = """ + {"type":"assistant","timestamp":"2026-09-30T12:00:00Z","requestId":"append-\(iteration)", + "message":{"id":"append-\(iteration)","model":"claude-sonnet-4-6", + "usage":{"input_tokens":10,"output_tokens":1}}} + """.replacingOccurrences(of: "\n", with: "") + "\n" + try handle.write(contentsOf: Data(line.utf8)) + try handle.close() + try self.measure("warm-append-load", iteration: iteration) { + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + let report = try CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { try load() } + let work = recorder.snapshot() + #expect(!report.data.isEmpty) + #expect(work.repricedRows > 100_000) + #expect(work.transcriptParses == 1) + #expect(work.incrementalTranscriptParses == 1) + print("[rebuild-work] reconciliations=\(work.reconciliations) repricedRows=\(work.repricedRows)") + } + } + } + + private func measure(_ label: String, iteration: Int, work: () throws -> Void) throws { + let cpu = Self.cpuSeconds + let start = ContinuousClock.now + try autoreleasepool { try work() } + print("[rebuild-benchmark] phase=\(label) run=\(iteration) " + + "cpu=\(Self.cpuSeconds - cpu) wall=\(ContinuousClock.now - start)") + } + + private static var cpuSeconds: Double { + var usage = rusage() + getrusage(RUSAGE_SELF, &usage) + return Double(usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) + + Double(usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) / 1_000_000 + } +} diff --git a/Tests/CodexBarTests/CostUsageClaudeRebuildTests.swift b/Tests/CodexBarTests/CostUsageClaudeRebuildTests.swift new file mode 100644 index 0000000000..f527e4041f --- /dev/null +++ b/Tests/CodexBarTests/CostUsageClaudeRebuildTests.swift @@ -0,0 +1,104 @@ +import CryptoKit +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageClaudeRebuildTests { + /// Captured from the original ordered reconciliation and packed-day rebuild. + @Test + func `seeded cache preserves exact row day and report output`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + var cache = Self.cache(fileCount: 64, rowsPerFile: 50, includeOverflow: true) + let rows = CostUsageScanner.reconciledClaudeRows(cache: cache) + CostUsageScanner.rebuildClaudeDays(cache: &cache, rows: rows) + let report = CostUsageScanner.buildClaudeReportFromCache( + cache: cache, range: Self.range, now: Self.now, modelsDevCacheRoot: env.cacheRoot) + let digests = try [ + Self.digest(rows), Self.digest(cache.days), Self.digest(report), + Self.digest(report.hourly.map(CostUsageCodexPreviousReport.HourlyEntry.init)), + Self.digest(report.quotaSlices.map(CostUsageCodexPreviousReport.QuotaSlice.init)), + ] + #expect(digests == [ + "6260cd7640a6e48831af1d67ca2d78ecbe606db0374f5a1f31d8bda3b509a6f5", + "900db7be9b3420d62fccdea6d1ab359528f1b736c95bb332c1eb98a2b8aa4544", + "b502508338e96fc6552b09f546aafbfdfadf4dd4e7f4f3c226d0570d1b01cd9c", + "7e9f76fc6fd41c9755399563e10bfaad51708e0e5e9676abba785af3553133d4", + "ff44af15a44b217acedff8e0d76241c8d32152c36c5d671e2157b9fd557a2093", + ]) + #expect(cache.days["2026-09-01"]?["fixture/overflow"] == nil) + #expect(rows.contains { $0.isIncomplete == true }) + #expect(rows.contains { $0.messageId == nil }) + } + + static let now = Date(timeIntervalSince1970: 1_790_769_600) // 2026-09-30 12:00 UTC + static var range: CostUsageScanner.CostUsageDayRange { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return .init(since: self.now.addingTimeInterval(-29 * 86400), until: self.now, calendar: calendar) + } + + static func cache(fileCount: Int, rowsPerFile: Int, includeOverflow: Bool = false) -> CostUsageCache { + var seed: UInt64 = 0xC1A0DE + func next(_ limit: Int) -> Int { + seed = seed &* 6_364_136_223_846_793_005 &+ 1 + return Int(seed >> 32) % limit + } + var cache = CostUsageCache(scanSinceKey: self.range.scanSinceKey, scanUntilKey: self.range.scanUntilKey) + for file in 0.. String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try SHA256.hash(data: encoder.encode(value)).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift index 52f953ae28..74f1d097f8 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift @@ -290,6 +290,7 @@ struct CostUsageScannerClaudeMemoTests { #expect(metrics.cacheDecodes == 0) #expect(metrics.transcriptParses == 1) #expect(metrics.incrementalTranscriptParses == 1) + #expect(metrics.reconciliations == 1) #expect(metrics.cacheEncodes == 1) } diff --git a/docs/cost-reporting-periods.md b/docs/cost-reporting-periods.md index 4297180b92..3c7a744a8d 100644 --- a/docs/cost-reporting-periods.md +++ b/docs/cost-reporting-periods.md @@ -19,3 +19,5 @@ The existing host-summary protocol (`--remote` and `--summary-only`) remains lim All reads the available source history, including local logs older than a year. Missing or deleted logs cannot be recovered, provider APIs can impose their own history limits, and incomplete scans remain marked as incomplete. Local priority metadata is reconciled only for recorded days, without a fixed earliest-year cutoff or empty-day cache entries. This is not a permanent ledger or a lifetime bill since installation. The separate token-activity heatmap still covers one year. Cursor's quota bars keep the billing-cycle dates reported by Cursor. Calendar-month cost is a complementary view of dated usage events; it does not reinterpret a mid-month billing-cycle allowance as a calendar-month quota. + +Claude cache updates reconcile transcript rows once per load and reuse that ordered result for cached daily totals and the report. Winner precedence, report ordering, and cache invalidation remain unchanged; separate reporting windows still retain their own rows. From 74c0d6d78ff737855bde344c0ab2e4be0c8de18d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 20:01:29 -0700 Subject: [PATCH 113/122] chore(cost): regenerate codex parser hash --- Sources/CodexBarCore/Generated/CodexParserHash.generated.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 70ca634624..865bbb2e9c 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "8a25bf2647df7765" + static let value = "24c2f99bdb098f60" } From 3a0467f0d58003b1b9fbcaf386222bff8e1ae4a7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 21:22:29 -0700 Subject: [PATCH 114/122] perf(cost): skip impossible Vertex transcript classification (#4152) For every assistant usage line, Claude cost scanning decided whether the entry came from Vertex AI by recursively walking the entire decoded JSON object, including message content and tool payloads, and wrapping every nested dictionary. xctrace on the signed 0.70.0 app put this classification at 22.7% of all CPU during a Claude scan (14.9% in the metadata walk). Check the raw line bytes first: the recursive metadata walk now runs only when the line could contain a metadata marker (vertex or gcp in any case, or a \u004x-\u007x escape that could spell one). Message/request IDs and model names are checked in their decoded fields, so @ and _vrtx_ in tool content no longer force a walk. Vertex-only scans return before decoding when no marker of any kind is present. Classification results are unchanged for all three provider filters. Golden tables compare the gated and full-walk results for plain lines, vrtx IDs, @-versioned models, nested provider metadata, keys containing markers, escaped markers, non-ASCII neighbours and @ inside tool inputs; the walk counter is zero for lines without metadata markers. --- CHANGELOG.md | 1 + .../CostUsage/CostUsageClaudeCache.swift | 6 + .../CostUsage/CostUsageScanner+Claude.swift | 84 ++++++---- .../CostUsageClaudeVertexPrecheckTests.swift | 146 ++++++++++++++++++ docs/claude.md | 1 + 5 files changed, 210 insertions(+), 28 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageClaudeVertexPrecheckTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 78f73985f2..2d30cbff1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Costs: reduce CPU use while reconciling cached local Codex logs. - Codex: reduce CPU use when loading conversation titles for large local cost histories. - Reduce CPU use while scanning local Codex logs for cost data. +- Costs: reduce CPU use when separating Claude and Vertex AI usage in local transcripts. ## 0.70.0 — 2026-09-29 diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift index 4dd60a09ae..650af60a6c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -211,6 +211,8 @@ extension CostUsageScanner { case cacheEncode case reprice case normalizationCacheMiss + case vertexMetadataWalk + case claudeLineDecode case catalogModelLookup(found: Bool) } @@ -222,6 +224,8 @@ extension CostUsageScanner { var cacheEncodes = 0 var repricedRows = 0 var normalizationCacheMisses = 0 + var vertexMetadataWalks = 0 + var claudeLineDecodes = 0 var catalogModelLookups = 0 var catalogModelHits = 0 var catalogModelMisses = 0 @@ -245,6 +249,8 @@ extension CostUsageScanner { case .cacheEncode: self.metrics.cacheEncodes += 1 case .reprice: self.metrics.repricedRows += 1 case .normalizationCacheMiss: self.metrics.normalizationCacheMisses += 1 + case .vertexMetadataWalk: self.metrics.vertexMetadataWalks += 1 + case .claudeLineDecode: self.metrics.claudeLineDecodes += 1 case let .catalogModelLookup(found): self.metrics.catalogModelLookups += 1 if found { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 647027bacf..21f1fe7ce6 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1,4 +1,11 @@ import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif extension CostUsageScanner { static func loadDailyReportCancellable( @@ -179,16 +186,26 @@ extension CostUsageScanner { guard !line.wasTruncated else { return } guard line.bytes.containsAscii(#""type":"assistant""#) else { return } guard line.bytes.containsAscii(#""usage""#) else { return } + let couldContainMetadata = providerFilter != .all && Self.couldContainVertexAIMetadata(line.bytes) + if providerFilter == .vertexAIOnly, !couldContainMetadata, + !line.bytes.containsAscii("@"), !line.bytes.containsAscii("_vrtx_") + { return } autoreleasepool { + #if DEBUG + recordClaudeScanWork(.claudeLineDecode) + #endif guard let obj = try? ClaudeJSONObject.decode(line.bytes), let type = obj["type"] as? String, type == "assistant" else { return } let message = obj.dictionary("message") - guard Self.matchesClaudeProviderFilter(obj: obj, message: message, filter: providerFilter) - else { return } + if providerFilter != .all { + let isVertex = Self.isVertexAIUsageEntry( + obj: obj, message: message, scanMetadata: couldContainMetadata) + if isVertex != (providerFilter == .vertexAIOnly) { return } + } guard let tsText = obj["timestamp"] as? String, let parsedTimestamp = Self.claudeTimestampAndDayKey(tsText, calendar: range.calendar) @@ -435,18 +452,32 @@ extension CostUsageScanner { "client", ] - private static func matchesClaudeProviderFilter( - obj: ClaudeJSONObject, - message: ClaudeJSONObject?, - filter: ClaudeLogProviderFilter) -> Bool - { - switch filter { - case .all: - true - case .vertexAIOnly: - self.isVertexAIUsageEntry(obj: obj, message: message) - case .excludeVertexAI: - !self.isVertexAIUsageEntry(obj: obj, message: message) + private static let vertexMetadataRawMarkers: [StaticString] = [ + "vertex", "Vertex", "gcp", "Gcp", #"\u004"#, #"\u005"#, #"\u006"#, #"\u007"#, + ] + + private static func couldContainVertexAIMetadata(_ line: Data) -> Bool { + line.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in + guard let base = bytes.baseAddress else { return false } + // Foundation's lowercase/canonical substring matching cannot create these ASCII markers + // from non-ASCII. Combining scalars can prevent matches; the decoded classifier decides positives. + // Escape prefixes cover ASCII letters, @, and underscores regardless of the final hex digit's case. + return self.vertexMetadataRawMarkers.contains { marker in + marker.withUTF8Buffer { needle in + var offset = 0 + while offset <= bytes.count - needle.count { + // Skip payload bytes in libc; inspect only candidate starts in Swift. + guard let found = memchr(base + offset, Int32(needle[0]), bytes.count - offset) + else { return false } + offset = base.distance(to: found) + if needle.count <= bytes.count - offset, + needle.indices.allSatisfy({ bytes[offset + $0] | 0x20 == needle[$0] | 0x20 }) + { return true } + offset += 1 + } + return false + } + } } } @@ -459,7 +490,11 @@ extension CostUsageScanner { self.isVertexAIUsageEntry(obj: obj, message: obj.dictionary("message")) } - private static func isVertexAIUsageEntry(obj: ClaudeJSONObject, message: ClaudeJSONObject?) -> Bool { + private static func isVertexAIUsageEntry( + obj: ClaudeJSONObject, + message: ClaudeJSONObject?, + scanMetadata: Bool = true) -> Bool + { // Primary detection: Vertex AI message IDs and request IDs have "vrtx" prefix // e.g., "msg_vrtx_0154LUXjFVzQGUca3yK2RUeo", "req_vrtx_011CWjK86SWeFuXqZKUtgB1H" if let messageId = message?["id"] as? String, @@ -476,27 +511,20 @@ extension CostUsageScanner { // Secondary detection: model name with @ version separator (Vertex AI format) // e.g., "claude-opus-4-5@20251101" vs "claude-opus-4-5-20251101" if let model = message?["model"] as? String, - Self.modelNameLooksVertex(model) + model.hasPrefix("claude-"), model.contains("@") { return true } // The recursive walk already includes root and message metadata, requests, context, and client. - return Self.containsVertexAIMetadata(in: obj) - } - - /// Detects Vertex AI model names by format. - /// Vertex AI uses @ for version separator: claude-opus-4-5@20251101 - /// Anthropic API uses -: claude-opus-4-5-20251101 - private static func modelNameLooksVertex(_ model: String) -> Bool { - // Vertex AI model format: claude-{variant}@{version} - // Examples: claude-opus-4-5@20251101, claude-sonnet-4-5@20250514 - guard model.hasPrefix("claude-") else { return false } - return model.contains("@") + return scanMetadata && Self.containsVertexAIMetadata(in: obj) } private static func containsVertexAIMetadata(in dict: ClaudeJSONObject) -> Bool { - dict.contains { key, value in + #if DEBUG + recordClaudeScanWork(.vertexMetadataWalk) + #endif + return dict.contains { key, value in if self.containsClaudeVertexMarker(key, includeGCP: true) { return true } diff --git a/Tests/CodexBarTests/CostUsageClaudeVertexPrecheckTests.swift b/Tests/CodexBarTests/CostUsageClaudeVertexPrecheckTests.swift new file mode 100644 index 0000000000..78044d509d --- /dev/null +++ b/Tests/CodexBarTests/CostUsageClaudeVertexPrecheckTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageClaudeVertexPrecheckTests { + private static let plain = #"{"type":"assistant","timestamp":"2026-09-29T12:00:00Z","# + + #""metadata":{"provider":"anthropic"},"message":{"model":"claude-sonnet-4-6","# + + #""content":[{"type":"tool_use","input":{"text":"synthetic payload"}}],"# + + #""usage":{"input_tokens":10,"output_tokens":2}}}"# + + private static let nonVertexContent = [ + "@MainActor func render() {}", "import @scope/package", "fixture@example.test", + "@decorator", "msg_vrtx_unrelated", + ] + + @Test + func `raw escaped and Unicode markers preserve full walk filter results`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 9, day: 29) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day, calendar: .current) + var lines = [Self.plain] + for content in Self.nonVertexContent + [#"\u0040MainActor"#, #"msg_\u0076rtx_unrelated"#] { + lines.append(Self.plain.replacingOccurrences(of: "synthetic payload", with: content)) + } + for metadata in [ + #"{"provider":"Vertex"}"#, #"{"provider":"GCP"}"#, #"{"gcp_project":false}"#, + #"{"myVeRtExFlag":null}"#, #"{"nested":[{"backend":"VERTEX"}]}"#, + #"{"nested":[[{"backend":"VERTEX"}]]}"#, #"{"nested":["vertex","gcp"]}"#, + #"{"provider":"\u0076\u0065\u0072\u0074\u0065\u0078"}"#, + #"{"provider":"\u0056\u0045\u0052\u0054\u0045\u0058"}"#, + #"{"provider":"\u0056\u0045\u0052\u0054\u0045\u0058\u0301"}"#, + #"{"\u0067\u0063\u0070":false}"#, #"{"\u0047\u0043\u0050":false}"#, + #"{"provider":"ver\u0074ex"}"#, #"{"provider":"not a \\u0076 marker"}"#, + #"{"provider":"日本VERTEX🦞"}"#, #"{"provider":"vertex\u0301"}"#, + #"{"provider":"vértex"}"#, #"{"provider":"ve\u0301rtex"}"#, + #"{"provider":"vertex"}"#, #"{"provider":"ver\u200dtex"}"#, + #"{"gcp\u0301":false}"#, #"{"éGCP":false}"#, + #"{"café":{"provider":"vertex"},"cafe\u0301":{"provider":"vertex"}}"#, + ] { + lines.append(Self.plain.replacingOccurrences(of: #"{"provider":"anthropic"}"#, with: metadata)) + } + for id in [ + #"msg_vrtx_123"#, + #"req_vrtx_123"#, + #"msg_VRTX_123"#, + #"msg_\u0076rtx_123"#, + #"msg\u005Frtx_123"#, + #"msg\u005fvrtx_123"#, + #"msg_\u0076rtx\u005F123"#, + ] { + lines.append(Self.plain.replacingOccurrences( + of: #""model":"claude-sonnet-4-6""#, + with: #""id":"\#(id)","model":"claude-sonnet-4-6""#)) + lines.append(Self.plain.replacingOccurrences( + of: #""metadata":"#, + with: #""requestId":"\#(id)","metadata":"#)) + } + for model in [ + #"claude-test@date"#, + #"claude-test\u0040date"#, + #"other@date"#, + #"claude-test@date"#, + #"claude-test@\u0301date"#, + ] { + lines.append(Self.plain.replacingOccurrences(of: "claude-sonnet-4-6", with: model)) + } + for (index, line) in lines.enumerated() { + let decoded = try #require(try ClaudeJSONObject.decode(Data(line.utf8))) + let vertex = CostUsageScanner.isVertexAIUsageEntry(obj: decoded) + let file = try env.writeClaudeProjectFile(relativePath: "golden/session.jsonl", contents: line + "\n") + let all = CostUsageScanner.parseClaudeFile( + fileURL: file, range: range, providerFilter: .all, modelsDevCatalog: ModelsDevCatalog(providers: [:])) + #expect(all.rows.count == 1, "fixture \(index)") + for filter in [CostUsageScanner.ClaudeLogProviderFilter.all, .excludeVertexAI, .vertexAIOnly] { + let keep = filter == .all || (filter == .vertexAIOnly ? vertex : !vertex) + let actual = CostUsageScanner.parseClaudeFile( + fileURL: file, + range: range, + providerFilter: filter, + modelsDevCatalog: ModelsDevCatalog(providers: [:])) + #expect(actual.rows == (keep ? all.rows : []), "fixture \(index), filter \(filter)") + #expect(actual.parsedBytes == all.parsedBytes) + } + } + } + + @Test + func `content only id and model markers never require metadata walks`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 9, day: 29) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day, calendar: .current) + for content in Self.nonVertexContent { + let line = Self.plain.replacingOccurrences(of: "synthetic payload", with: content) + let file = try env.writeClaudeProjectFile( + relativePath: "content/session.jsonl", contents: String(repeating: line + "\n", count: 100)) + for filter in [CostUsageScanner.ClaudeLogProviderFilter.all, .excludeVertexAI, .vertexAIOnly] { + let work = CostUsageScanner.ClaudeScanWorkRecorder() + let parsed = CostUsageScanner.withClaudeScanWorkRecorderForTesting(work) { + CostUsageScanner.parseClaudeFile( + fileURL: file, + range: range, + providerFilter: filter, + modelsDevCatalog: ModelsDevCatalog(providers: [:])) + } + #expect(parsed.rows.count == (filter == .vertexAIOnly ? 0 : 100)) + #expect(parsed.parsedBytes == Int64((line.utf8.count + 1) * 100)) + #expect(work.snapshot().claudeLineDecodes == 100) + #expect(work.snapshot().vertexMetadataWalks == 0, "\(content), \(filter)") + } + } + } + + @Test + func `plain lines avoid metadata walks while marked lines retain them`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 9, day: 29) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day, calendar: .current) + for marked in [false, true] { + let line = marked ? Self.plain.replacingOccurrences(of: "anthropic", with: "Vertex") : Self.plain + let file = try env.writeClaudeProjectFile( + relativePath: "counter/session.jsonl", contents: String(repeating: line + "\n", count: 100)) + for filter in [CostUsageScanner.ClaudeLogProviderFilter.all, .excludeVertexAI, .vertexAIOnly] { + let work = CostUsageScanner.ClaudeScanWorkRecorder() + let parsed = CostUsageScanner.withClaudeScanWorkRecorderForTesting(work) { + CostUsageScanner.parseClaudeFile( + fileURL: file, + range: range, + providerFilter: filter, + modelsDevCatalog: ModelsDevCatalog(providers: [:])) + } + let keep = filter == .all || (filter == .vertexAIOnly ? marked : !marked) + #expect(parsed.rows.count == (keep ? 100 : 0)) + #expect(parsed.parsedBytes == Int64((line.utf8.count + 1) * 100)) + #expect(work.snapshot().claudeLineDecodes == (!marked && filter == .vertexAIOnly ? 0 : 100)) + if marked, filter != .all { + #expect(work.snapshot().vertexMetadataWalks >= 100) + } else { + #expect(work.snapshot().vertexMetadataWalks == 0) + } + } + } + } +} diff --git a/docs/claude.md b/docs/claude.md index 15e2cb6ba6..5867392ea4 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -416,6 +416,7 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential plus supported pi-compatible session files. - Parsing: - Native Claude logs parse lines with `type: "assistant"` and `message.usage`. + - Claude/Vertex filtering checks raw lines for possible metadata markers before walking decoded metadata. IDs and model names are checked in their decoded fields, so `@` and `_vrtx_` in tool content do not trigger that walk. Vertex-only scans skip decoding lines without any possible marker; escaped markers retain the full classifier and existing attribution rules. - Uses per-model token counts (input, cache read/create, output). - Oversized local token or cost values cannot crash history scanning. An overflowing token total stays unavailable while independent counts and finite dollar estimates remain visible; raw rows are retained for later repricing. - Deduplicates cumulative streaming chunks by `message.id + requestId`. When `requestId` is absent, From 455203d8e2710f6c14cef49bbcc411cd3e10a324 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 21:22:41 -0700 Subject: [PATCH 115/122] perf(cost): reuse Claude cache persistence identities (#4157) Every cost cycle re-saves the Claude caches (36-49 MB regular, 129 MB history) and their report memos (12/52 MB). The writer encoded the whole sorted-key JSON and, whenever the byte size matched, read the existing file back in full to compare, and a decoded-cache eviction also discarded the identity that let unchanged saves skip encoding. xctrace on the signed 0.70.0 app attributed ~8% of a steady-state window to these writes, with RSS spikes of several hundred MB. Keep eight small persistence identities (stamp, SHA-256 of the written bytes, content ID) independently of the decoded-cache NSCache: a matching content ID plus device/inode/size/mtime skips encoding entirely, otherwise the encoded bytes' fingerprint detects identical rebuilds without reading the file back. On-disk format, sorted keys, temp+rename and external replacement detection are unchanged. 200k-row synthetic cache (42 MB), median of 3 saves: unchanged save after memo eviction 1,357 ms -> 0.06 ms (3 encodes -> 0); changed save 1,754 ms -> 1,352 ms with no read-back. --- CHANGELOG.md | 1 + .../CostUsage/CostUsageClaudeCache.swift | 131 ++++++++++------ ...UsageClaudePersistenceBenchmarkTests.swift | 64 ++++++++ ...stUsageClaudeWriteAmplificationTests.swift | 144 ++++++++++++++++++ docs/claude.md | 1 + 5 files changed, 295 insertions(+), 46 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageClaudePersistenceBenchmarkTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d30cbff1f..9c370e0a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Claude costs: reduce CPU use when rebuilding reports after local transcripts grow. +- Reduce CPU use when saving unchanged local Claude and Vertex cost history. - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! - Reduce CPU and filesystem work while identifying local agent processes during refreshes. - Reduce CPU use when refreshing model pricing while preserving historical fallback rates. diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift index 650af60a6c..c49dc4d1a9 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -1,3 +1,4 @@ +import Crypto import Foundation struct CostUsageClaudeFileStamp: Equatable, Sendable, Codable { @@ -15,17 +16,15 @@ struct CostUsageClaudeFileStamp: Equatable, Sendable, Codable { guard url.path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { return nil } guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else { return nil } #if os(Linux) - let modifiedSeconds = Int64(info.st_mtim.tv_sec) - let modifiedNanoseconds = Int64(info.st_mtim.tv_nsec) + let modifiedTime = info.st_mtim #else - let modifiedSeconds = Int64(info.st_mtimespec.tv_sec) - let modifiedNanoseconds = Int64(info.st_mtimespec.tv_nsec) + let modifiedTime = info.st_mtimespec #endif return Self( fileID: "\(info.st_dev):\(info.st_ino)", size: Int64(info.st_size), - modifiedSeconds: modifiedSeconds, - modifiedNanoseconds: modifiedNanoseconds) + modifiedSeconds: Int64(modifiedTime.tv_sec), + modifiedNanoseconds: Int64(modifiedTime.tv_nsec)) } } @@ -98,22 +97,15 @@ final class CostUsageClaudeReportMemo: @unchecked Sendable { func entry(provider: UsageProvider, canonicalCachePath: String) -> Entry? { let key = Self.key(provider: provider, canonicalCachePath: canonicalCachePath) - self.lock.lock() - if let memory = self.entries.first(where: { $0.key == key })?.entry { - self.lock.unlock() + if let memory = self.lock.withLock({ self.entries.first(where: { $0.key == key })?.entry }) { return memory } - self.lock.unlock() - guard let persisted = Self.loadPersisted(canonicalCachePath: canonicalCachePath) else { return nil } - - self.lock.lock() - defer { self.lock.unlock() } - if let memory = self.entries.first(where: { $0.key == key })?.entry { - return memory + return self.lock.withLock { + if let memory = self.entries.first(where: { $0.key == key })?.entry { return memory } + self.installUnlocked(key: key, entry: persisted) + return persisted } - self.installUnlocked(key: key, entry: persisted) - return persisted } func store( @@ -140,10 +132,6 @@ final class CostUsageClaudeReportMemo: @unchecked Sendable { self.lock.withLock { self.entries.removeAll { $0.key == key } } } - func evictPersisted(canonicalCachePath: String) { - let url = Self.reportMemoFileURL(cacheFileURL: URL(fileURLWithPath: canonicalCachePath)) - try? FileManager.default.removeItem(at: url) - } #endif private func installUnlocked(key: String, entry: Entry) { @@ -165,12 +153,14 @@ final class CostUsageClaudeReportMemo: @unchecked Sendable { private static func loadPersisted(canonicalCachePath: String) -> Entry? { let url = Self.reportMemoFileURL(cacheFileURL: URL(fileURLWithPath: canonicalCachePath)) - guard let data = try? Data(contentsOf: url), + let stamp = CostUsageClaudeFileStamp.read(at: url) + guard let data = CostUsageClaudeCacheIO.read(at: url), let envelope = try? JSONDecoder().decode(PersistedEnvelope.self, from: data), envelope.version == Self.persistedVersion, envelope.reportSemanticsVersion == Self.reportSemanticsVersion, Self.hasValidIncompleteCounts(envelope.report) else { return nil } + CostUsageClaudeCacheIO.remember(data: data, at: url, stamp: stamp) return Entry( sourceInventory: envelope.sourceInventory, reportKey: envelope.reportKey, @@ -209,6 +199,8 @@ extension CostUsageScanner { case transcriptParse(startOffset: Int64) case reconcile case cacheEncode + case artifactRead + case artifactWrite case reprice case normalizationCacheMiss case vertexMetadataWalk @@ -234,6 +226,7 @@ extension CostUsageScanner { final class ClaudeScanWorkRecorder: @unchecked Sendable { private let lock = NSLock() private var metrics = ClaudeScanWorkMetrics() + private var artifactIO = (reads: 0, writes: 0) func record(_ work: ClaudeScanWork) { self.lock.lock() @@ -247,6 +240,8 @@ extension CostUsageScanner { } case .reconcile: self.metrics.reconciliations += 1 case .cacheEncode: self.metrics.cacheEncodes += 1 + case .artifactRead: self.artifactIO.reads += 1 + case .artifactWrite: self.artifactIO.writes += 1 case .reprice: self.metrics.repricedRows += 1 case .normalizationCacheMiss: self.metrics.normalizationCacheMisses += 1 case .vertexMetadataWalk: self.metrics.vertexMetadataWalks += 1 @@ -262,9 +257,11 @@ extension CostUsageScanner { } func snapshot() -> ClaudeScanWorkMetrics { - self.lock.lock() - defer { self.lock.unlock() } - return self.metrics + self.lock.withLock { self.metrics } + } + + func persistenceSnapshot() -> (reads: Int, writes: Int) { + self.lock.withLock { self.artifactIO } } } @@ -300,8 +297,8 @@ extension CostUsageScanner { static func evictPersistedClaudeReportMemoForTesting(provider: UsageProvider, cacheRoot: URL?) { let cacheURL = CostUsageClaudeCacheIO.cacheFileURL(provider: provider, cacheRoot: cacheRoot) - let canonicalCachePath = cacheURL.standardizedFileURL.resolvingSymlinksInPath().path - CostUsageClaudeReportMemo.shared.evictPersisted(canonicalCachePath: canonicalCachePath) + .standardizedFileURL.resolvingSymlinksInPath() + try? FileManager.default.removeItem(at: CostUsageClaudeReportMemo.reportMemoFileURL(cacheFileURL: cacheURL)) } } #endif @@ -362,8 +359,8 @@ enum CostUsageClaudeCacheIO { /// Compact row keys; older artifacts rebuild from their source transcripts. private static let schemaVersion = 4 - /// NSCache provides synchronized, memory-pressure-aware storage for the four app artifacts. - /// This caches decoded bytes only; the scanner still validates source scope and reprices rows. + /// Decoded rows may be evicted under memory pressure; eight small persistence identities survive independently. + /// The scanner still validates source scope and reprices rows. private final class ArtifactMemo: @unchecked Sendable { final class Entry { let stamp: CostUsageClaudeFileStamp @@ -378,6 +375,22 @@ enum CostUsageClaudeCacheIO { static let shared = ArtifactMemo() let entries = NSCache() + private let lock = NSLock() + private typealias Identity = (stamp: CostUsageClaudeFileStamp, digest: SHA256.Digest, contentID: UUID?) + private var identities: [(url: URL, value: Identity)] = [] + + func identity(at url: URL) -> (stamp: CostUsageClaudeFileStamp, digest: SHA256.Digest, contentID: UUID?)? { + self.lock.withLock { self.identities.last(where: { $0.url == url })?.value } + } + + func remember(at url: URL, stamp: CostUsageClaudeFileStamp, digest: SHA256.Digest, contentID: UUID?) { + self.lock.withLock { + self.identities.removeAll { $0.url == url } + self.identities.append((url, (stamp, digest, contentID))) + if self.identities.count > 8 { self.identities.removeFirst() } + } + } + private init() { self.entries.countLimit = 4 } @@ -421,7 +434,7 @@ enum CostUsageClaudeCacheIO { if let stamp, let memoized = ArtifactMemo.shared.entries.object(forKey: key), memoized.stamp == stamp { cache = memoized.cache } else { - guard let data = try? Data(contentsOf: url) else { return CostUsageClaudeCache() } + guard let data = self.read(at: url) else { return CostUsageClaudeCache() } #if DEBUG CostUsageScanner.recordClaudeScanWork(.cacheDecode) #endif @@ -431,6 +444,7 @@ enum CostUsageClaudeCacheIO { cache = decoded // A concurrent replacement must fall through to a fresh decode next time. if let stamp, CostUsageClaudeFileStamp.read(at: url) == stamp { + self.remember(data: data, at: url, stamp: stamp, contentID: cache.contentID) ArtifactMemo.shared.entries.setObject(ArtifactMemo.Entry(stamp: stamp, cache: cache), forKey: key) } } @@ -456,16 +470,7 @@ enum CostUsageClaudeCacheIO { cache.usage.timeZoneIdentifier = timeZoneID } let key = url.standardizedFileURL.resolvingSymlinksInPath() as NSURL - try checkCancellation?() - if let memoized = ArtifactMemo.shared.entries.object(forKey: key), memoized.cache.contentID == cache.contentID, - CostUsageClaudeFileStamp.read(at: url) == memoized.stamp - { - return memoized.stamp - } - #if DEBUG - CostUsageScanner.recordClaudeScanWork(.cacheEncode) - #endif - let stamp = try self.write(cache, to: url, checkCancellation: checkCancellation) + let stamp = try self.write(cache, to: url, contentID: cache.contentID, checkCancellation: checkCancellation) if let stamp, CostUsageClaudeFileStamp.read(at: url) == stamp { ArtifactMemo.shared.entries.setObject(ArtifactMemo.Entry(stamp: stamp, cache: cache), forKey: key) } @@ -475,17 +480,26 @@ enum CostUsageClaudeCacheIO { fileprivate static func write( _ value: some Encodable, to url: URL, + contentID: UUID? = nil, checkCancellation: CostUsageScanner.CancellationCheck? = nil) throws -> CostUsageClaudeFileStamp? { + let key = url.standardizedFileURL.resolvingSymlinksInPath() + try checkCancellation?() + let identity = ArtifactMemo.shared.identity(at: key) + if let identity, let contentID, identity.contentID == contentID, + CostUsageClaudeFileStamp.read(at: url) == identity.stamp { return identity.stamp } + #if DEBUG + if contentID != nil { CostUsageScanner.recordClaudeScanWork(.cacheEncode) } + #endif let encoder = JSONEncoder() + // Stable fingerprints preserve stamps when a rescan rebuilds byte-identical content with a new UUID. encoder.outputFormatting = [.sortedKeys] guard let data = try? encoder.encode(value) else { return nil } try checkCancellation?() - if let stamp = CostUsageClaudeFileStamp.read(at: url), stamp.size == Int64(data.count), - (try? Data(contentsOf: url)) == data, - CostUsageClaudeFileStamp.read(at: url) == stamp - { - return stamp + let digest = SHA256.hash(data: data) + if let identity, identity.digest == digest, CostUsageClaudeFileStamp.read(at: url) == identity.stamp { + ArtifactMemo.shared.remember(at: key, stamp: identity.stamp, digest: digest, contentID: contentID) + return identity.stamp } let directory = url.deletingLastPathComponent() try? FileManager.default.createDirectory( @@ -499,6 +513,31 @@ enum CostUsageClaudeCacheIO { else { return nil } + ArtifactMemo.shared.remember(at: key, stamp: stamp, digest: digest, contentID: contentID) + #if DEBUG + CostUsageScanner.recordClaudeScanWork(.artifactWrite) + #endif return stamp } + + fileprivate static func read(at url: URL) -> Data? { + #if DEBUG + CostUsageScanner.recordClaudeScanWork(.artifactRead) + #endif + return try? Data(contentsOf: url) + } + + fileprivate static func remember( + data: Data, + at url: URL, + stamp: CostUsageClaudeFileStamp?, + contentID: UUID? = nil) + { + guard let stamp, CostUsageClaudeFileStamp.read(at: url) == stamp else { return } + ArtifactMemo.shared.remember( + at: url.standardizedFileURL.resolvingSymlinksInPath(), + stamp: stamp, + digest: SHA256.hash(data: data), + contentID: contentID) + } } diff --git a/Tests/CodexBarTests/CostUsageClaudePersistenceBenchmarkTests.swift b/Tests/CodexBarTests/CostUsageClaudePersistenceBenchmarkTests.swift new file mode 100644 index 0000000000..c19a68fb59 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageClaudePersistenceBenchmarkTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.enabled(if: ProcessInfo.processInfo.environment["CODEXBAR_PERSISTENCE_BENCHMARK"] == "1")) +struct CostUsageClaudePersistenceBenchmarkTests { + @Test + func `synthetic two hundred thousand row saves`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + var cache = CostUsageClaudeCache() + cache.usage.version = 4 + cache.usage.timeZoneIdentifier = Calendar.current.timeZone.identifier + cache.usage.lastScanUnixMs = 1_780_000_000_000 + for file in 0..<1000 { + let path = "/synthetic/project/session-\(file).jsonl" + let rows = (0..<200).map { index in + CostUsageScanner.ClaudeUsageRow( + dayKey: "2026-07-01", + model: "synthetic-model", + sessionId: "session-\(file)", + messageId: "message-\(file)-\(index)", + requestId: "request-\(file)-\(index)", + timestampUnixMs: 1_780_000_000_000 + Int64(index), + isSidechain: false, + pathRole: .parent, + input: 100, + cacheRead: 20, + cacheCreate: 10, + cacheCreate1h: 0, + output: 50, + costNanos: 105_000, + costPriced: true) + } + cache.usage.files[path] = CostUsageFileUsage( + mtimeUnixMs: 1_780_000_000_000, size: 100_000, days: [:], claudeRows: rows) + cache.sourceFileIDs[path] = "synthetic:\(file)" + } + let url = CostUsageClaudeCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) + _ = try CostUsageClaudeCacheIO.save(provider: .claude, cache: cache, cacheRoot: root) + for phase in ["unchanged-warm", "unchanged-evicted", "changed"] { + var times: [Double] = [] + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + try CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { + for _ in 0..<3 { + if phase == "unchanged-evicted" { CostUsageClaudeCacheIO.evictArtifactMemoForTesting(at: url) } + if phase == "changed" { cache.usage.lastScanUnixMs += 1 } + try autoreleasepool { + let start = ContinuousClock.now + _ = try CostUsageClaudeCacheIO.save(provider: .claude, cache: cache, cacheRoot: root) + let duration = start.duration(to: .now).components + times.append(Double(duration.seconds) + Double(duration.attoseconds) / 1e18) + } + } + } + var usage = rusage() + getrusage(RUSAGE_SELF, &usage) + let bytes = try #require(CostUsageClaudeFileStamp.read(at: url)).size + print("[persistence-benchmark] rows=200000 bytes=\(bytes) phase=\(phase) " + + "seconds=\(times) median=\(times.sorted()[1]) encodes=\(recorder.snapshot().cacheEncodes) " + + "peakRSSBytes=\(usage.ru_maxrss)") + } + } +} diff --git a/Tests/CodexBarTests/CostUsageClaudeWriteAmplificationTests.swift b/Tests/CodexBarTests/CostUsageClaudeWriteAmplificationTests.swift index 894d9a2a80..c7cbbb7727 100644 --- a/Tests/CodexBarTests/CostUsageClaudeWriteAmplificationTests.swift +++ b/Tests/CodexBarTests/CostUsageClaudeWriteAmplificationTests.swift @@ -86,6 +86,150 @@ struct CostUsageClaudeWriteAmplificationTests { #expect(reloaded.usage.lastScanUnixMs == mutated.usage.lastScanUnixMs) } + @Test + func `unchanged saves survive decoded artifact eviction without encoding`() throws { + let fixture = try Fixture(rowCount: 128) + defer { fixture.env.cleanup() } + _ = try fixture.load(context: .regular) + let url = fixture.cacheURL(context: .regular) + let cache = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: fixture.env.cacheRoot) + let before = try fixture.stamps(context: .regular) + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + try CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { + for _ in 0..<3 { + CostUsageClaudeCacheIO.evictArtifactMemoForTesting(at: url) + _ = try CostUsageClaudeCacheIO.save(provider: .claude, cache: cache, cacheRoot: fixture.env.cacheRoot) + } + } + #expect(recorder.snapshot().cacheEncodes == 0) + #expect(recorder.persistenceSnapshot().reads == 0) + #expect(recorder.persistenceSnapshot().writes == 0) + #expect(try fixture.stamps(context: .regular) == before) + } + + @Test + func `changed content writes once without reading back either artifact`() throws { + let fixture = try Fixture(rowCount: 2) + defer { fixture.env.cleanup() } + _ = try fixture.load(context: .regular) + let url = fixture.cacheURL(context: .regular) + var cache = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: fixture.env.cacheRoot) + cache.usage.lastScanUnixMs += 1 + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + try CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { + for _ in 0..<3 { + _ = try CostUsageClaudeCacheIO.save(provider: .claude, cache: cache, cacheRoot: fixture.env.cacheRoot) + } + } + #expect(recorder.snapshot().cacheEncodes == 1) + #expect(recorder.persistenceSnapshot().writes == 1) + #expect(recorder.persistenceSnapshot().reads == 0) + let decoded = try JSONDecoder().decode(CostUsageClaudeCache.self, from: Data(contentsOf: url)) + #expect(decoded.usage == cache.usage) + #expect(decoded.sourceFileIDs == cache.sourceFileIDs) + + let memo = try #require(CostUsageClaudeReportMemo.shared.entry( + provider: .claude, canonicalCachePath: url.path)) + var inventory = memo.sourceInventory + inventory.removeAll() + let memoRecorder = CostUsageScanner.ClaudeScanWorkRecorder() + CostUsageScanner.withClaudeScanWorkRecorderForTesting(memoRecorder) { + for _ in 0..<3 { + CostUsageClaudeReportMemo.shared.store( + provider: .claude, + canonicalCachePath: url.path, + sourceInventory: inventory, + reportKey: memo.reportKey, + report: memo.report, + hasWindowScopedRows: memo.hasWindowScopedRows) + } + } + #expect(memoRecorder.persistenceSnapshot().writes == 1) + #expect(memoRecorder.persistenceSnapshot().reads == 0) + CostUsageClaudeReportMemo.shared.evict(provider: .claude, canonicalCachePath: url.path) + let loaded = try #require(CostUsageClaudeReportMemo.shared.entry( + provider: .claude, canonicalCachePath: url.path)) + #expect(loaded.sourceInventory.isEmpty) + #expect(loaded.report.data == memo.report.data) + #expect(loaded.report.hourly == memo.report.hourly) + #expect(loaded.report.quotaSlices == memo.report.quotaSlices) + } + + @Test + func `identical external replacements still rewrite with matching size and mtime`() throws { + let fixture = try Fixture(rowCount: 2) + defer { fixture.env.cleanup() } + _ = try fixture.load(context: .regular) + let url = fixture.cacheURL(context: .regular) + let memoURL = CostUsageClaudeReportMemo.reportMemoFileURL(cacheFileURL: url) + for artifactURL in [url, memoURL] { + try FileManager.default.setAttributes([.modificationDate: fixture.day], ofItemAtPath: artifactURL.path) + } + CostUsageClaudeReportMemo.shared.evict(provider: .claude, canonicalCachePath: url.path) + let memo = try #require(CostUsageClaudeReportMemo.shared.entry( + provider: .claude, canonicalCachePath: url.path)) + let cache = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: fixture.env.cacheRoot) + let before = try fixture.stamps(context: .regular) + for (index, artifactURL) in [url, memoURL].enumerated() { + try Data(contentsOf: artifactURL).write(to: artifactURL, options: .atomic) + try FileManager.default.setAttributes([.modificationDate: fixture.day], ofItemAtPath: artifactURL.path) + let replacement = try #require(CostUsageClaudeFileStamp.read(at: artifactURL)) + #expect(replacement.size == before[index].size) + #expect(replacement.modifiedSeconds == before[index].modifiedSeconds) + #expect(replacement.modifiedNanoseconds == before[index].modifiedNanoseconds) + #expect(replacement.fileID != before[index].fileID) + } + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + try CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { + _ = try CostUsageClaudeCacheIO.save(provider: .claude, cache: cache, cacheRoot: fixture.env.cacheRoot) + CostUsageClaudeReportMemo.shared.store( + provider: .claude, + canonicalCachePath: url.path, + sourceInventory: memo.sourceInventory, + reportKey: memo.reportKey, + report: memo.report, + hasWindowScopedRows: memo.hasWindowScopedRows) + } + #expect(recorder.snapshot().cacheEncodes == 1) + #expect(recorder.persistenceSnapshot().writes == 2) + #expect(recorder.persistenceSnapshot().reads == 0) + } + + @Test + func `legacy sorted JSON loads and rebuilt identical content preserves stamps`() throws { + let fixture = try Fixture(rowCount: 2) + defer { fixture.env.cleanup() } + _ = try fixture.load(context: .regular) + let url = fixture.cacheURL(context: .regular) + let original = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: fixture.env.cacheRoot) + // The current release's encoder and atomic replacement path, without any new identity memo. + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let legacyBytes = try encoder.encode(original) + let temporaryURL = url.appendingPathExtension("legacy") + try legacyBytes.write(to: temporaryURL) + #expect(rename(temporaryURL.path, url.path) == 0) + CostUsageClaudeCacheIO.evictArtifactMemoForTesting(at: url) + var loaded = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: fixture.env.cacheRoot) + #expect(loaded.usage == original.usage) + #expect(loaded.sourceFileIDs == original.sourceFileIDs) + let before = try #require(CostUsageClaudeFileStamp.read(at: url)) + let usage = loaded.usage + loaded.usage = usage // A rebuilt value has a new UUID even when its serialized bytes are identical. + #expect(loaded.contentID != original.contentID) + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + try CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { + for _ in 0..<2 { + _ = try CostUsageClaudeCacheIO.save(provider: .claude, cache: loaded, cacheRoot: fixture.env.cacheRoot) + } + } + #expect(recorder.snapshot().cacheEncodes == 1) + #expect(recorder.persistenceSnapshot().writes == 0) + #expect(recorder.persistenceSnapshot().reads == 0) + #expect(CostUsageClaudeFileStamp.read(at: url) == before) + #expect(try Data(contentsOf: url) == legacyBytes) + } + @Test func `retained row encoding stays compact and preserves every field`() throws { let row = CostUsageScanner.ClaudeUsageRow( diff --git a/docs/claude.md b/docs/claude.md index 5867392ea4..ea4b119b9a 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -434,6 +434,7 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential - Native provider cache: `~/Library/Caches/CodexBar/cost-usage/claude-v6.json` - Report memo: `~/Library/Caches/CodexBar/cost-usage/claude-v6.report-memo.json` stores source stamps and the daily report across launches. It is reused only while transcript inventory, cache/pricing artifacts, requested window, and report-semantics revision still match. - Unchanged sources reuse the memo even when a menu refresh bypasses the scan debounce. Explicit rescans still reparse transcripts, but identical cache and report-memo content is not rewritten; an unchanged rebuild retains its previous scan timestamp. Existing artifacts may be rewritten once to establish deterministic key ordering. Changed transcripts or report metadata still replace the corresponding complete JSON artifacts. + - Persistence keeps at most eight lightweight artifact identities independently of the decoded-row cache. An unchanged cache identity plus matching device/inode, size, and nanosecond mtime skips encoding; otherwise sorted JSON fingerprints avoid full-file read-back while preserving identical rebuilds. External replacement invalidates reuse, and changed files still use temporary-file rename. Evicted identities are re-established on load or the next save. - Decoded cache artifacts can be reused in memory while their canonical path, file identity, size, and nanosecond modification time match. Schema and time-zone checks still run on every load; report-level source, window, filter, and pricing checks still run separately. Atomic replacements invalidate this reuse, and explicit rescans still reparse source transcripts. - Successful cache saves retain the just-written decoded value, avoiding another full row decode on the next changed refresh. Unmodified loaded values skip encoding and writing while the artifact stamp still matches; external replacements, deleted files, and failed or cancelled saves cannot establish this reuse. Changed content still replaces the complete JSON artifact. Compact row field names reduce its size; schema 3 artifacts rebuild from transcripts once when the rows are next needed. Report memos and user-facing JSON retain their existing formats. - The app's Usage & Spend refresh uses `claude-history-v6.json` and its own report memo. The two app refreshes do not replace each other's retained rows or restart each other's transcript scans. Once both have established their windows, same-day append refreshes read changed tails once per cache. From 643dd0e795ee9df30cecc5ea11220134b1579571 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 21:22:49 -0700 Subject: [PATCH 116/122] perf(cost): preserve pricing stamps on identical refreshes (#4160) When local logs contain model IDs that models.dev does not price, the pricing pipeline refetches the catalog every 15 minutes and always rewrote models-dev-v1.json with a new fetchedAt, even when the catalog was identical. Claude cost reports key their memo on that file's stamp, so on the owner's machine every ~20-minute cost cycle began with a pricing rewrite that invalidated the report memo (or prevented storing it when the rewrite landed mid-scan), forcing a report rebuild, full repricing and a re-encode of both Claude caches. Identical refetches now record the fetch time in a small .refresh sidecar bound to the catalog's file stamp instead of rewriting the catalog. The effective fetch time still drives the 15-minute retry cooldown and the 24-hour TTL across relaunches; a replaced catalog invalidates the sidecar. Codex and Pi already key pricing by content and are unaffected. Net production lines: 0. Synthetic Claude report with an identical refetch between loads: 334 ms -> 1.6 ms per cycle, repriced rows 24,000 -> 0, reconciliations 1 -> 0. --- CHANGELOG.md | 1 + .../Vendored/CostUsage/ModelsDevPricing.swift | 116 ++++----- ...stUsageScannerClaudePricingMemoTests.swift | 108 ++++++++ .../ModelsDevIdenticalRefreshTests.swift | 235 ++++++++++++++++++ .../CodexBarTests/ModelsDevPricingTests.swift | 2 +- .../ModelsDevCacheReplacementTests.swift | 8 +- docs/model-pricing.md | 8 +- 7 files changed, 415 insertions(+), 63 deletions(-) create mode 100644 Tests/CodexBarTests/ModelsDevIdenticalRefreshTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c370e0a5f..4703cc374a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Claude costs: reduce CPU use when rebuilding reports after local transcripts grow. - Reduce CPU use when saving unchanged local Claude and Vertex cost history. +- Costs: avoid rebuilding Claude cost reports when refreshed model pricing is unchanged. - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! - Reduce CPU and filesystem work while identifying local agent processes during refreshes. - Reduce CPU use when refreshing model pricing while preserving historical fallback rates. diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 0c53f4ee32..30775cc5ec 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -1,9 +1,4 @@ import Foundation -#if canImport(Darwin) -import Darwin -#elseif canImport(Glibc) -import Glibc -#endif #if canImport(FoundationNetworking) import FoundationNetworking #endif @@ -429,30 +424,31 @@ private final class ModelsDevCacheMemo: @unchecked Sendable { } private struct Entry { - let modificationDate: Date? - let size: Int? + let metadata: ModelsDevCache.FileMetadata let outcome: Outcome } private let lock = NSLock() private var entries: [String: Entry] = [:] - func outcome(path: String, modificationDate: Date?, size: Int?) -> Outcome? { + func outcome(path: String, metadata: ModelsDevCache.FileMetadata) -> Outcome? { self.lock.lock() defer { self.lock.unlock() } guard let entry = self.entries[path], - entry.modificationDate == modificationDate, - entry.size == size + entry.metadata == metadata else { return nil } return entry.outcome } - func store(path: String, modificationDate: Date?, size: Int?, outcome: Outcome) { + func store(path: String, metadata: ModelsDevCache.FileMetadata, outcome: Outcome) { self.lock.lock() defer { self.lock.unlock() } - self.entries[path] = Entry(modificationDate: modificationDate, size: size, outcome: outcome) + if self.entries.count >= 128, self.entries[path] == nil { + self.entries.removeAll(keepingCapacity: true) + } + self.entries[path] = Entry(metadata: metadata, outcome: outcome) } func invalidate(path: String) { @@ -472,8 +468,18 @@ enum ModelsDevCache { static let artifactVersion = 1 static let ttlSeconds: TimeInterval = 24 * 60 * 60 + struct FileMetadata: Equatable { + let catalog: CostUsageClaudeFileStamp? + let refresh: CostUsageClaudeFileStamp? + } + + private struct RefreshMetadata: Codable { + let catalogStamp: CostUsageClaudeFileStamp + let fetchedAt: Date + } + private static let memo = ModelsDevCacheMemo() - /// Test-only instrumentation: counts `fileMetadata(at:)` reads (one per `load`) so tests can prove callers + /// Test-only instrumentation: counts initial metadata snapshots (one per `load`) so tests can prove callers /// resolve the catalog once instead of per pricing call. Task-local, so concurrent tests do not see each other's /// counts, and unset (zero cost) in production. @TaskLocal private static var metadataReadRecorder: MetadataReadRecorder? @@ -481,11 +487,17 @@ enum ModelsDevCache { final class MetadataReadRecorder: @unchecked Sendable { private let lock = NSLock() private var count = 0 + private let onRead: (@Sendable () -> Void)? + + init(onRead: (@Sendable () -> Void)? = nil) { + self.onRead = onRead + } func record() { self.lock.lock() self.count += 1 self.lock.unlock() + self.onRead?() } func snapshot() -> Int { @@ -513,33 +525,6 @@ enum ModelsDevCache { } } - /// Cheap POSIX stat for the (mtime, size) memo key. `attributesOfItem` also reads xattrs. - /// `stat(2)` follows a terminal symlink (matching what `Data(contentsOf:)` later reads) whereas - /// `attributesOfItem` did not. - private static func fileMetadata(at url: URL) -> (modificationDate: Date?, size: Int?) { - self.metadataReadRecorder?.record() - - return url.withUnsafeFileSystemRepresentation { pointer in - guard let pointer else { return (nil, nil) } - var status = stat() - guard stat(pointer, &status) == 0 else { - return (nil, nil) - } - return (Self.modificationDate(from: status), Int(status.st_size)) - } - } - - private static func modificationDate(from status: stat) -> Date { - #if canImport(Darwin) - let seconds = TimeInterval(status.st_mtimespec.tv_sec) - let nanoseconds = TimeInterval(status.st_mtimespec.tv_nsec) - #else - let seconds = TimeInterval(status.st_mtim.tv_sec) - let nanoseconds = TimeInterval(status.st_mtim.tv_nsec) - #endif - return Date(timeIntervalSince1970: seconds + nanoseconds / 1_000_000_000) - } - private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! return root.appendingPathComponent("CodexBar", isDirectory: true) @@ -554,39 +539,42 @@ enum ModelsDevCache { static func load(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCacheLoadResult { let url = self.cacheFileURL(cacheRoot: cacheRoot) - let metadata = Self.fileMetadata(at: url) + let metadata = FileMetadata( + catalog: CostUsageClaudeFileStamp.read(at: url), + refresh: CostUsageClaudeFileStamp.read(at: url.appendingPathExtension("refresh"))) + self.metadataReadRecorder?.record() // Staleness depends on `now`, so the result is always rebuilt; only the read+decode outcome is memoized. - if let outcome = Self.memo.outcome( - path: url.path, - modificationDate: metadata.modificationDate, - size: metadata.size) - { + if let outcome = Self.memo.outcome(path: url.path, metadata: metadata) { return Self.result(for: outcome, now: now) } - let outcome = Self.readOutcome(at: url) - Self.memo.store( - path: url.path, - modificationDate: metadata.modificationDate, - size: metadata.size, - outcome: outcome) + let outcome = Self.readOutcome(at: url, metadata: metadata) + Self.memo.store(path: url.path, metadata: metadata, outcome: outcome) return Self.result(for: outcome, now: now) } - private static func readOutcome(at url: URL) -> ModelsDevCacheMemo.Outcome { + private static func readOutcome(at url: URL, metadata: FileMetadata) -> ModelsDevCacheMemo.Outcome { guard let data = try? Data(contentsOf: url) else { return .failure(.unreadable) } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - guard let decoded = try? decoder.decode(ModelsDevCacheArtifact.self, from: data) else { + guard var decoded = try? decoder.decode(ModelsDevCacheArtifact.self, from: data) else { return .failure(.invalidJSON) } guard decoded.version == Self.artifactVersion else { return .failure(.invalidVersion) } + if metadata.refresh != nil, + let data = try? Data(contentsOf: url.appendingPathExtension("refresh")), + let refresh = try? decoder.decode(RefreshMetadata.self, from: data), + refresh.catalogStamp == metadata.catalog, + CostUsageClaudeFileStamp.read(at: url) == metadata.catalog + { + decoded.fetchedAt = refresh.fetchedAt + } return .decoded(decoded) } @@ -619,11 +607,23 @@ enum ModelsDevCache { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 - guard let data = try? encoder.encode(artifact) else { return false } - + let stamp = CostUsageClaudeFileStamp.read(at: url) + let cached = Self.load(cacheRoot: cacheRoot).artifact + let destination: URL + let data: Data do { - try data.write(to: url, options: [.atomic]) - // The on-disk catalog changed; drop the memo so the next load decodes the fresh file. + if let stamp, cached?.version == artifact.version, cached?.catalog == artifact.catalog, + CostUsageClaudeFileStamp.read(at: url) == stamp + { + // Fetch time must survive relaunches without invalidating consumers of the catalog stamp. + destination = url.appendingPathExtension("refresh") + data = try encoder.encode(RefreshMetadata(catalogStamp: stamp, fetchedAt: artifact.fetchedAt)) + } else { + destination = url + data = try encoder.encode(artifact) + } + try data.write(to: destination, options: [.atomic]) + // Either the catalog or its successful fetch time changed. Self.memo.invalidate(path: url.path) return true } catch { diff --git a/Tests/CodexBarTests/CostUsageScannerClaudePricingMemoTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudePricingMemoTests.swift index 79361b63c1..c064448962 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudePricingMemoTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudePricingMemoTests.swift @@ -207,6 +207,114 @@ struct CostUsageScannerClaudePricingMemoTests { #expect(freshWork.transcriptParses == 0) } + @Test(arguments: [false, true]) + func `identical pricing refetch preserves warm and persisted report memos`(restart: Bool) async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) + let catalog = try ModelsDevIdenticalRefreshTests.catalog() + #expect(ModelsDevCache.save( + catalog: catalog, + fetchedAt: day.addingTimeInterval(-901), + cacheRoot: env.cacheRoot)) + _ = try env.writeClaudeProjectFile(relativePath: "project/session.jsonl", contents: env.jsonl([ + self.event(day: day, env: env, id: "known", model: "claude-test-known", input: 100), + self.event(day: day, env: env, id: "unknown", model: "claude-test-unknown", input: 200), + ])) + let (cold, coldWork, _) = try self.load(env: env, day: day) + #expect(coldWork.transcriptParses == 1) + let transport = try ModelsDevIdenticalRefreshTests.Transport(catalog: catalog) + _ = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "anthropic", + modelIDs: ["claude-test-unknown"], + now: day, + cacheRoot: env.cacheRoot, + client: ModelsDevClient(transport: transport)) + #expect(transport.calls == 1) + if restart { + CostUsageScanner.evictClaudeReportMemoForTesting(provider: .claude, cacheRoot: env.cacheRoot) + } + let (warm, work, reads) = try self.load(env: env, day: day) + #expect(warm.data == cold.data) + #expect(warm.summary == cold.summary) + #expect(work == CostUsageScanner.ClaudeScanWorkMetrics()) + #expect(reads == 0) + } + + @Test + func `identical pricing save during report construction still publishes its memo`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) + let catalog = try ModelsDevIdenticalRefreshTests.catalog() + #expect(ModelsDevCache.save( + catalog: catalog, + fetchedAt: day.addingTimeInterval(-901), + cacheRoot: env.cacheRoot)) + _ = try env.writeClaudeProjectFile(relativePath: "project/session.jsonl", contents: env.jsonl([ + self.event(day: day, env: env, id: "known", model: "claude-test-known", input: 100), + ])) + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + var saved = false + let first = try CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { + try CostUsageScanner.loadDailyReportCancellable( + provider: .claude, + since: day, + until: day, + now: day, + options: self.options(env: env), + checkCancellation: { + if !saved, recorder.snapshot().repricedRows > 0 { + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: day, cacheRoot: env.cacheRoot)) + saved = true + } + }) + } + #expect(saved) + let (second, work, _) = try self.load(env: env, day: day) + #expect(second.data == first.data) + #expect(second.summary == first.summary) + #expect(work == CostUsageScanner.ClaudeScanWorkMetrics()) + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["CODEXBAR_IDENTICAL_PRICING_BENCHMARK"] == "1")) + func `measure identical pricing refetch and warm report`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) + let catalog = try ModelsDevIdenticalRefreshTests.catalog() + #expect(ModelsDevCache.save( + catalog: catalog, + fetchedAt: day.addingTimeInterval(-901), + cacheRoot: env.cacheRoot)) + let events = (0..<24000).map { index in + self.event( + day: day, + env: env, + id: "event-\(index)", + model: index.isMultiple(of: 2) ? "claude-test-known" : "claude-test-unknown", + input: 100) + } + _ = try env.writeClaudeProjectFile(relativePath: "project/session.jsonl", contents: env.jsonl(events)) + let (cold, _, _) = try self.load(env: env, day: day) + let transport = try ModelsDevIdenticalRefreshTests.Transport(catalog: catalog) + for cycle in 0..<5 { + let started = ContinuousClock.now + _ = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "anthropic", + modelIDs: ["claude-test-unknown"], + now: day.addingTimeInterval(Double(cycle * 900)), + cacheRoot: env.cacheRoot, + client: ModelsDevClient(transport: transport)) + let (report, work, _) = try self.load(env: env, day: day) + let elapsed = ContinuousClock.now - started + #expect(report.data == cold.data) + #expect(report.summary == cold.summary) + print("[identical-pricing] cycle=\(cycle) elapsed=\(elapsed) work=\(work)") + } + #expect(transport.calls == 5) + } + private func options(env: CostUsageTestEnvironment) -> CostUsageScanner.Options { var options = CostUsageScanner.Options( claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) diff --git a/Tests/CodexBarTests/ModelsDevIdenticalRefreshTests.swift b/Tests/CodexBarTests/ModelsDevIdenticalRefreshTests.swift new file mode 100644 index 0000000000..ca8cc2d5ce --- /dev/null +++ b/Tests/CodexBarTests/ModelsDevIdenticalRefreshTests.swift @@ -0,0 +1,235 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct ModelsDevIdenticalRefreshTests { + @Test + func `identical refresh preserves catalog bytes and stamp while persisting retry time`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let now = Date(timeIntervalSince1970: 100_000) + let catalog = try Self.catalog() + #expect(ModelsDevCache.save( + catalog: catalog, + fetchedAt: now.addingTimeInterval(-901), + cacheRoot: env.cacheRoot)) + let url = ModelsDevCache.cacheFileURL(cacheRoot: env.cacheRoot) + let bytes = try Data(contentsOf: url) + let stamp = try #require(CostUsageClaudeFileStamp.read(at: url)) + let transport = try Transport(catalog: catalog) + let client = ModelsDevClient(transport: transport) + for offset in [0.0, 899, 900, 901] { + let result = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "anthropic", + modelIDs: ["claude-test-unknown"], + now: now.addingTimeInterval(offset), + cacheRoot: env.cacheRoot, + client: client) + #expect(result == .unavailable) + #expect(transport.calls == (offset < 900 ? 1 : 2)) + #expect(CostUsageClaudeFileStamp.read(at: url) == stamp) + #expect(try Data(contentsOf: url) == bytes) + } + // A different path has no coordinator or decoded memo state, like a new process. + let restartedRoot = env.cacheRoot.appendingPathComponent("restarted") + let restartedURL = ModelsDevCache.cacheFileURL(cacheRoot: restartedRoot) + try FileManager.default.createDirectory( + at: restartedURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + // Hard links preserve the catalog identity to exercise persisted refresh metadata. + try FileManager.default.linkItem(at: url, to: restartedURL) + try FileManager.default.copyItem( + at: url.appendingPathExtension("refresh"), to: restartedURL.appendingPathExtension("refresh")) + let loaded = ModelsDevCache.load(now: now.addingTimeInterval(901), cacheRoot: restartedRoot) + #expect(loaded.artifact?.fetchedAt == now.addingTimeInterval(900)) + _ = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "anthropic", + modelIDs: ["claude-test-unknown"], + now: now.addingTimeInterval(901), + cacheRoot: restartedRoot, + client: client) + #expect(transport.calls == 2) + } + + @Test + func `identical stale refresh resets ttl and compares after fallback merging`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let now = Date(timeIntervalSince1970: 100_000) + let catalog = try Self.catalog() + var fetched = catalog + fetched.providers["anthropic"]?.models.removeValue(forKey: "claude-test-fallback") + #expect(ModelsDevCache.save( + catalog: catalog, + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: env.cacheRoot)) + let url = ModelsDevCache.cacheFileURL(cacheRoot: env.cacheRoot) + let stamp = CostUsageClaudeFileStamp.read(at: url) + let transport = try Transport(catalog: fetched) + let client = ModelsDevClient(transport: transport) + #expect(await ModelsDevPricingPipeline.refreshStaleCache(now: now, cacheRoot: env.cacheRoot, client: client)) + #expect(CostUsageClaudeFileStamp.read(at: url) == stamp) + #expect(ModelsDevCache.load(now: now, cacheRoot: env.cacheRoot).artifact?.catalog == catalog) + let boundary = now.addingTimeInterval(ModelsDevCache.ttlSeconds) + #expect(!ModelsDevCache.load(now: boundary, cacheRoot: env.cacheRoot).isStale) + #expect(await ModelsDevPricingPipeline.refreshStaleCache( + now: boundary, + cacheRoot: env.cacheRoot, + client: client)) + #expect(transport.calls == 1) + #expect(ModelsDevCache.load(now: boundary.addingTimeInterval(1), cacheRoot: env.cacheRoot).isStale) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: boundary.addingTimeInterval(1), cacheRoot: env.cacheRoot, client: client) + #expect(transport.calls == 2) + #expect(CostUsageClaudeFileStamp.read(at: url) == stamp) + } + + @Test + func `changed catalog rewrites and ignores refresh metadata from the prior catalog`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let now = Date(timeIntervalSince1970: 100_000) + let catalog = try Self.catalog() + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: now, cacheRoot: env.cacheRoot)) + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: now.addingTimeInterval(900), cacheRoot: env.cacheRoot)) + let url = ModelsDevCache.cacheFileURL(cacheRoot: env.cacheRoot) + let stamp = CostUsageClaudeFileStamp.read(at: url) + let changed = try Self.catalog(rate: 20) + let transport = try Transport(catalog: changed) + let refreshedAt = now.addingTimeInterval(1800) + _ = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "anthropic", + modelIDs: ["claude-test-unknown"], + now: refreshedAt, + cacheRoot: env.cacheRoot, + client: ModelsDevClient(transport: transport)) + #expect(transport.calls == 1) + #expect(CostUsageClaudeFileStamp.read(at: url) != stamp) + #expect(ModelsDevCache.load(now: refreshedAt, cacheRoot: env.cacheRoot).artifact?.catalog == changed) + #expect(ModelsDevCache.load(now: refreshedAt, cacheRoot: env.cacheRoot).artifact?.fetchedAt == refreshedAt) + } + + @Test + func `external replacement cannot inherit an identical refresh timestamp`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let now = Date(timeIntervalSince1970: 100_000) + let catalog = try Self.catalog() + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: now, cacheRoot: env.cacheRoot)) + let url = ModelsDevCache.cacheFileURL(cacheRoot: env.cacheRoot) + let bytes = try Data(contentsOf: url) + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: now.addingTimeInterval(900), cacheRoot: env.cacheRoot)) + // Same bytes, size and mtime, but an atomic replacement gets a new inode. + try bytes.write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.modificationDate: #require(attributes[.modificationDate])], + ofItemAtPath: url.path) + #expect(ModelsDevCache.load(now: now, cacheRoot: env.cacheRoot).artifact?.fetchedAt == now) + } + + @Test + func `replacement between metadata and catalog reads cannot inherit the prior fetch time`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let now = Date(timeIntervalSince1970: 100_000) + let catalog = try Self.catalog() + #expect(ModelsDevCache.save( + catalog: catalog, + fetchedAt: now.addingTimeInterval(-901), + cacheRoot: env.cacheRoot)) + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: now, cacheRoot: env.cacheRoot)) + let replacement = try Self.catalog(rate: 20) + let old = Date(timeIntervalSince1970: 1) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(ModelsDevCacheArtifact(version: 1, fetchedAt: old, catalog: replacement)) + let url = ModelsDevCache.cacheFileURL(cacheRoot: env.cacheRoot) + let recorder = ModelsDevCache.MetadataReadRecorder(onRead: { + try? data.write(to: url, options: .atomic) + }) + let loaded = ModelsDevCache.withMetadataReadRecorderForTesting(recorder) { + ModelsDevCache.load(now: now, cacheRoot: env.cacheRoot) + } + #expect(recorder.snapshot() == 1) + #expect(loaded.artifact?.catalog == replacement) + #expect(loaded.artifact?.fetchedAt == old) + #expect(loaded.isStale) + } + + @Test(arguments: [false, true]) + func `missing or corrupt refresh metadata falls back to catalog time`(corrupt: Bool) throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let now = Date(timeIntervalSince1970: 100_000) + let catalog = try Self.catalog() + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: now, cacheRoot: env.cacheRoot)) + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: now.addingTimeInterval(900), cacheRoot: env.cacheRoot)) + let url = ModelsDevCache.cacheFileURL(cacheRoot: env.cacheRoot).appendingPathExtension("refresh") + // Warm the memo before an external sidecar mutation. + #expect(ModelsDevCache.load(now: now, cacheRoot: env.cacheRoot).artifact?.fetchedAt == now + .addingTimeInterval(900)) + if corrupt { + try Data("invalid fixture".utf8).write(to: url, options: .atomic) + } else { + try FileManager.default.removeItem(at: url) + } + #expect(ModelsDevCache.load(now: now, cacheRoot: env.cacheRoot).artifact?.fetchedAt == now) + } + + @Test + func `failed refresh metadata write preserves the last successful fetch`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let now = Date(timeIntervalSince1970: 100_000) + let catalog = try Self.catalog() + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: now, cacheRoot: env.cacheRoot)) + let url = ModelsDevCache.cacheFileURL(cacheRoot: env.cacheRoot) + let stamp = CostUsageClaudeFileStamp.read(at: url) + try FileManager.default.createDirectory( + at: url.appendingPathExtension("refresh"), + withIntermediateDirectories: true) + #expect(!ModelsDevCache.save( + catalog: catalog, + fetchedAt: now.addingTimeInterval(900), + cacheRoot: env.cacheRoot)) + #expect(ModelsDevCache.load(now: now, cacheRoot: env.cacheRoot).artifact?.fetchedAt == now) + #expect(CostUsageClaudeFileStamp.read(at: url) == stamp) + } + + static func catalog(rate: Double = 10) throws -> ModelsDevCatalog { + try JSONDecoder().decode(ModelsDevCatalog.self, from: JSONSerialization.data(withJSONObject: [ + "anthropic": ["id": "anthropic", "models": [ + "claude-test-known": ["id": "claude-test-known", "cost": ["input": rate, "output": 1]], + "claude-test-fallback": ["id": "claude-test-fallback", "cost": ["input": 3, "output": 1]], + ]], + "openai": ["id": "openai", "models": [ + "gpt-test-anchor": ["id": "gpt-test-anchor", "cost": ["input": 2, "output": 1]], + ]], + ])) + } + + final class Transport: ModelsDevHTTPTransport, @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + private let body: Data + + init(catalog: ModelsDevCatalog) throws { + self.body = try JSONEncoder().encode(catalog) + } + + var calls: Int { + self.lock.withLock { self.count } + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.lock.withLock { self.count += 1 } + return ( + self.body, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + } +} diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index 9d9c156dec..ff9673c1da 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -1304,7 +1304,7 @@ extension ModelsDevPricingTests { } @Test - func `load metadata check is one stat per load`() throws { + func `load records one initial metadata snapshot per call`() throws { let root = try Self.cacheRoot() try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) let recorder = ModelsDevCache.MetadataReadRecorder() diff --git a/TestsLinux/ModelsDevCacheReplacementTests.swift b/TestsLinux/ModelsDevCacheReplacementTests.swift index 0082bbc36a..75d87ee22b 100644 --- a/TestsLinux/ModelsDevCacheReplacementTests.swift +++ b/TestsLinux/ModelsDevCacheReplacementTests.swift @@ -4,7 +4,7 @@ import Testing struct ModelsDevCacheReplacementTests { @Test - func `repeated catalog refreshes remain readable on disk and through the memo`() throws { + func `repeated changed catalogs remain readable on disk and through the memo`() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("models-dev-replacement-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } @@ -14,8 +14,10 @@ struct ModelsDevCacheReplacementTests { for revision in 1...10 { let fetchedAt = Date(timeIntervalSince1970: 1_700_000_000 + Double(revision)) - try #require(ModelsDevCache.save( - catalog: ModelsDevCatalog(providers: [:]), fetchedAt: fetchedAt, cacheRoot: root)) + let catalog = ModelsDevCatalog(providers: [ + "test-provider": ModelsDevProvider(id: "test-provider", name: "Revision \(revision)", models: [:]), + ]) + try #require(ModelsDevCache.save(catalog: catalog, fetchedAt: fetchedAt, cacheRoot: root)) let persisted = try decoder.decode(ModelsDevCacheArtifact.self, from: Data(contentsOf: url)) #expect(persisted.fetchedAt == fetchedAt) #expect(ModelsDevCache.load(cacheRoot: root).artifact == persisted) diff --git a/docs/model-pricing.md b/docs/model-pricing.md index 0d120d244c..f1a14099e8 100644 --- a/docs/model-pricing.md +++ b/docs/model-pricing.md @@ -20,7 +20,13 @@ CodexBar uses models.dev as an additive pricing source alongside bundled fallbac The pipeline lets future scanner code read the last valid cache synchronously with `ModelsDevPricingPipeline.lookup` and refresh stale metadata separately with `ModelsDevPricingPipeline.refreshIfNeeded`. If a refresh fails, the last valid cache remains usable. -Catalog saves use a single atomic write on macOS and Linux, so refreshing an existing cache replaces its contents without removing the destination first. Successful saves invalidate the in-memory catalog memo. +Changed catalogs use a single atomic write on macOS and Linux. After fallback pricing is merged, an identical +catalog instead atomically updates `models-dev-v1.json.refresh`, preserving the catalog stamp and cached Claude +reports. The sidecar stores the successful fetch time bound to the catalog's device/inode, size, and modification +time. Both file stamps validate the bounded in-memory catalog memo; missing, corrupt, or mismatched sidecars +fall back to the catalog's embedded fetch time. The 24-hour TTL and 15-minute unknown-model retry cooldown +use the effective fetch time, including after relaunch. The version-1 catalog remains readable by older releases, +which ignore the sidecar and use its embedded fetch time. Successful saves invalidate the decoded catalog memo. Refreshes preserve cached pricing for removed models using a provider-local stable-identity index. The index and model-ID normalization memo exist only during the merge; lookups likewise build their normalized-ID index only for the current provider and call. These indexes do not change cache lifetimes, provider boundaries, alias precedence, or dated snapshot pricing. From 2e4633073aa8e2bbb06c5563e02e61e44370c92f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 21:22:50 -0700 Subject: [PATCH 117/122] chore(cost): regenerate codex parser hash --- Sources/CodexBarCore/Generated/CodexParserHash.generated.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 865bbb2e9c..c05457de9d 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "24c2f99bdb098f60" + static let value = "f0c4c51efbbc563b" } From 67e836cb4429943f1c18e3348ce9e6d1bfdc6dc3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Oct 2026 02:48:45 -0700 Subject: [PATCH 118/122] fix(claude): select scoped-only menu bar quotas (#4159) Claude: when the scoped weekly window is the only meaningful quota (for example Fable-only plans), the menu bar metric selects it instead of showing a dash. Refs #4126. Thanks @marklights54-byte for the report! --- CHANGELOG.md | 1 + Sources/CodexBar/MenuBarLayout.swift | 14 +- .../MenuBarMetricWindowResolver.swift | 19 +-- .../Claude/ClaudeProviderDescriptor.swift | 6 +- .../ClaudeScopedWeeklyLimitMapper.swift | 17 +++ .../ClaudeScopedOnlyMetricTests.swift | 122 ++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 5 - docs/claude.md | 1 + 8 files changed, 151 insertions(+), 34 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeScopedOnlyMetricTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b47e66b11..b6ecbc02f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - OpenCode: restore migrated Console workspace quota and prepaid balance, preserve legacy sessions, and label 30-day spend without inventing a monthly spending limit (#4131, #4139). Thanks @luochen211! - Claude: treat unmeasured session placeholders as unavailable while retaining real weekly quotas in menus and the CLI (#4107). Thanks @emanuelst! - Claude: show the claude-swap executable field and its help beneath the enabled account toggle (#4122). Thanks @laitifranz! +- Claude: use a known model-scoped weekly quota for automatic and combined menu bar percentages when the regular quota windows are absent (#4126). - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! - Reduce CPU and filesystem work while identifying local agent processes during refreshes. - Reduce CPU use when refreshing model pricing while preserving historical fallback rates. diff --git a/Sources/CodexBar/MenuBarLayout.swift b/Sources/CodexBar/MenuBarLayout.swift index 7a872764bd..c8ecf754b0 100644 --- a/Sources/CodexBar/MenuBarLayout.swift +++ b/Sources/CodexBar/MenuBarLayout.swift @@ -598,20 +598,8 @@ enum MenuBarLayoutSemanticWindowResolver { .semanticWindows(snapshot: snapshot) } - /// The active model-scoped weekly carve-out (e.g. Claude's `claude-weekly-scoped-fable` - /// "Fable only" window), if the snapshot exposes one. Kept generic across models: keys off - /// the `claude-weekly-scoped-` id prefix rather than a specific model name, so it keeps - /// working when the promotional window rotates to a different model. - /// - /// When more than one scoped weekly window is active, the most constrained one (highest - /// used percentage) wins: that is the limit the user is closest to hitting and the one - /// worth showing in the always-visible menu bar. The full `NamedRateWindow` is returned so - /// callers can label the token with the active model instead of assuming Fable. static func scopedWeeklyNamedWindow(snapshot: UsageSnapshot?) -> NamedRateWindow? { - guard let snapshot else { return nil } - return (snapshot.extraRateWindows ?? []) - .filter { $0.id.hasPrefix("claude-weekly-scoped-") && !$0.window.isSyntheticPlaceholder } - .max { $0.window.usedPercent < $1.window.usedPercent } + snapshot?.claudeScopedWeeklyWindow } } diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index b518a3b07b..4737f24781 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -187,18 +187,11 @@ enum MenuBarMetricWindowResolver { /// marked placeholder). Lets the automatic and combined metrics surface the spend limit instead of an empty /// or 0% placeholder lane. Returns nil for accounts that expose genuine quota lanes. static func claudeSpendLimitWindow(snapshot: UsageSnapshot) -> RateWindow? { - let presentation = ProviderDescriptorRegistry.descriptor(for: .claude).presentation - switch presentation.menuBarWindow(context: ProviderMenuBarWindowContext( - metric: .automatic, - snapshot: snapshot, - supportsAverage: false, - prioritizesExhaustedQuotas: false, - now: .now)) - { - case let .resolved(window): - return window - case .unhandled: - return nil - } + guard snapshot.primary == nil || snapshot.primary?.isSyntheticPlaceholder == true, + snapshot.secondary == nil, snapshot.tertiary == nil, + snapshot.claudeScopedWeeklyWindow == nil, + let cost = snapshot.providerCost, cost.limit > 0 + else { return nil } + return cost.spendLimitWindow } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 72b61e4cb4..dd930fff58 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -230,13 +230,13 @@ public enum ClaudeProviderDescriptor { context: ProviderMenuBarWindowContext) -> ProviderMenuBarWindowResolution { guard context.metric == .automatic || context.metric == .primaryAndSecondary, - let cost = context.snapshot.providerCost, - cost.limit > 0, context.snapshot.secondary == nil, context.snapshot.tertiary == nil, context.snapshot.primary == nil || context.snapshot.primary?.isSyntheticPlaceholder == true else { return .unhandled } - return .resolved(cost.spendLimitWindow) + let window = context.snapshot.claudeScopedWeeklyWindow?.window + ?? context.snapshot.providerCost.flatMap { $0.limit > 0 ? $0.spendLimitWindow : nil } + return .resolved(window) } private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeScopedWeeklyLimitMapper.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeScopedWeeklyLimitMapper.swift index 6b3c536f6e..aa65d6850a 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeScopedWeeklyLimitMapper.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeScopedWeeklyLimitMapper.swift @@ -70,3 +70,20 @@ enum ClaudeScopedWeeklyLimitMapper { return idSlug == "all-models" || idSlug.hasSuffix("-all-models") } } + +extension UsageSnapshot { + /// The active model-scoped weekly carve-out (e.g. Claude's `claude-weekly-scoped-fable` + /// "Fable only" window), if the snapshot exposes one. Kept generic across models: keys off + /// the `claude-weekly-scoped-` id prefix rather than a specific model name, so it keeps + /// working when the promotional window rotates to a different model. + /// + /// When more than one scoped weekly window is active, the most constrained one (highest + /// used percentage) wins: that is the limit the user is closest to hitting and the one + /// worth showing in the always-visible menu bar. The full `NamedRateWindow` is returned so + /// callers can label the token with the active model instead of assuming Fable. + public var claudeScopedWeeklyWindow: NamedRateWindow? { + (self.extraRateWindows ?? []) + .filter { $0.id.hasPrefix("claude-weekly-scoped-") && $0.usageKnown && !$0.window.isSyntheticPlaceholder } + .max { $0.window.usedPercent < $1.window.usedPercent } + } +} diff --git a/Tests/CodexBarTests/ClaudeScopedOnlyMetricTests.swift b/Tests/CodexBarTests/ClaudeScopedOnlyMetricTests.swift new file mode 100644 index 0000000000..32cf5f06e9 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeScopedOnlyMetricTests.swift @@ -0,0 +1,122 @@ +import AppKit +import Foundation +import SwiftUI +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ClaudeScopedOnlyMetricTests { + private static let now = Date(timeIntervalSince1970: 1_790_726_400) + + @Test + func `scoped token ignores windows without measured usage`() { + let unknown = NamedRateWindow( + id: "claude-weekly-scoped-example", + title: "Example only", + window: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + usageKnown: false) + let snapshot = UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: [unknown], updatedAt: Self.now) + #expect(MenuBarLayoutSemanticWindowResolver.scopedWeeklyNamedWindow(snapshot: snapshot) == nil) + } + + @Test(arguments: [MenuBarMetricPreference.automatic, .primaryAndSecondary], [false, true]) + func `scoped weekly payload supplies the only measured menu bar metric`( + preference: MenuBarMetricPreference, withSpendLimit: Bool) throws + { + let snapshot = try Self.snapshot(withSpendLimit: withSpendLimit) + let scoped = try #require(MenuBarLayoutSemanticWindowResolver.scopedWeeklyNamedWindow(snapshot: snapshot)) + #expect(scoped.window.usedPercent == 6) + let selected = MenuBarMetricWindowResolver.rateWindow( + preference: preference, provider: .claude, snapshot: snapshot, supportsAverage: false, now: Self.now) + #expect(selected == scoped.window) + #expect(MenuBarMetricWindowResolver.claudeSpendLimitWindow(snapshot: snapshot) == nil) + } + + @MainActor + @Test + func `render synthetic scoped only metric proof`() throws { + guard let directory = ProcessInfo.processInfo.environment["CODEXBAR_CLAUDE_PRESENTATION_PROOF_DIR"] else { + return + } + let snapshot = try Self.snapshot(withSpendLimit: false) + let scoped = MenuBarLayoutSemanticWindowResolver.scopedWeeklyNamedWindow(snapshot: snapshot) + let selected = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, provider: .claude, snapshot: snapshot, supportsAverage: false, now: Self.now) + // The red regression established that the previous automatic selector returned the raw placeholder. + for (stage, window) in [("before", snapshot.primary), ("after", selected)] { + let data = Self.renderData(snapshot: snapshot, scoped: scoped, automatic: window) + let layout = MenuBarLayout(lines: [[.providerName, .space, .percent(window: .automatic)]]) + let options = MenuBarLayoutRenderOptions( + size: .regular, + highContrast: false, + showUsed: true, + conditionals: [], + appearanceName: NSAppearance.Name.aqua.rawValue, + isDebugApp: false, + now: Self.now) + let title = MenuBarLayoutRenderer().render(layout: layout, data: data, icon: nil, options: options) + let hosting = NSHostingView(rootView: Text(AttributedString(title.attributedTitle)) + .padding(20) + .frame(width: 240, height: 70) + .background(Color(nsColor: .windowBackgroundColor)) + .preferredColorScheme(.light)) + hosting.appearance = NSAppearance(named: .aqua) + let png = try #require(MenuLayoutScreenshotRenderTests.pngDataWithWindow(hosting: hosting)) + try png.write(to: URL(fileURLWithPath: directory).appendingPathComponent("scoped-\(stage).png")) + } + } + + @MainActor + private static func renderData( + snapshot: UsageSnapshot, + scoped: NamedRateWindow?, + automatic: RateWindow?) -> MenuBarLayoutRenderData + { + MenuBarLayoutRenderData( + provider: .claude, + iconKey: "synthetic-claude-scoped", + providerName: "Claude", + accountLabel: nil, + laneLabels: MenuBarLayoutLaneLabels(provider: .claude, snapshot: snapshot), + primary: nil, + secondary: nil, + tertiary: nil, + session: nil, + weekly: nil, + scopedWeekly: MenuBarLayoutRenderWindow(scoped?.window), + scopedWeeklyTitle: scoped?.title, + automatic: MenuBarLayoutRenderWindow(automatic), + automaticText: nil, + sessionPace: nil, + weeklyPace: nil, + automaticPace: nil, + runsOut: nil, + balance: nil, + costToday: nil, + cost30d: nil, + metrics: .unavailable) + } + + private static func snapshot(withSpendLimit: Bool) throws -> UsageSnapshot { + let data = Data(#""" + {"five_hour": null, "seven_day": null, "limits": [ + {"kind": "weekly_scoped", "group": "weekly", "percent": 6, + "resets_at": "2026-10-07T09:00:00Z", + "scope": {"model": {"display_name": "Fable"}}, "is_active": true} + ]} + """#.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + let cost = ProviderCostSnapshot( + used: 20, + limit: 100, + currencyCode: "USD", + period: "Monthly", + updatedAt: Self.now) + return UsageSnapshot( + primary: ClaudeUsageFetcher.webPrimaryWindow(from: parsed), + secondary: nil, + extraRateWindows: parsed.extraRateWindows, + providerCost: withSpendLimit ? cost : nil, + updatedAt: Self.now) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 5c92a8d4bb..8de4cb453f 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1034,11 +1034,6 @@ struct ProviderArchitectureGatekeeperTests { anchor: "provider: .antigravity,", expectedProviderIDs: ["antigravity"], reason: "This named provider resolver supplies its fixed provider identity to the shared presentation helper."), - SuppressedProviderReference( - path: "Sources/CodexBar/MenuBarMetricWindowResolver.swift", - anchor: "let presentation = ProviderDescriptorRegistry.descriptor(for: .claude).presentation", - expectedProviderIDs: ["claude"], - reason: "This named provider resolver supplies its fixed provider identity to the shared presentation helper."), SuppressedProviderReference( path: "Sources/CodexBar/MiniMaxAPITokenStore.swift", anchor: "logCategory: LogCategories.provider(.minimax, scope: \"api-token-store\"))", diff --git a/docs/claude.md b/docs/claude.md index 3041f68ada..62ac6bc61d 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -163,6 +163,7 @@ the cookie import. - `seven_day_sonnet` / `seven_day_opus` → model-specific weekly window. - `limits[].weekly_scoped` → model-specific weekly windows; generic `All models` scopes stay in the main weekly row. - The menu localizes scoped titles as a model name plus weekly duration; canonical snapshot and CLI titles remain unchanged. + - Automatic and Session + Weekly menu bar metrics fall back to the most constrained known scoped weekly window when the regular quota windows are missing. Unknown scoped measurements remain unavailable; Extra usage stays a spend-only fallback. - `seven_day_routines` / `seven_day_cowork` → Daily Routines extra window. - Claude Design/Omelette keys are ignored because Claude Design shares the main Claude usage limit. - `extra_usage` → Extra usage cost (monthly spend/limit). From 4367a701cc34f646a155f51bbcc71aa60333d721 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Oct 2026 02:45:14 -0700 Subject: [PATCH 119/122] perf(cost): reuse the Codex cache decoder per pass (#4166) Loading the SQLite-backed Codex cost cache constructed a new JSONDecoder for every file's details payload, every usage row, and each optional payload, and this decode runs for both the scan and the report view on every refresh. With ~22k rollouts on the owner's machine, xctrace on the signed 0.70.0 app showed loadCodexReadView at 9.4% and decodeCodexCache at 6.2% of a steady-state refresh. Reuse one decoder within each synchronous decode pass (including helper payloads), reserve the output dictionary, avoid intermediate retry-filter arrays, and drop a redundant discovery wrapper. Decoded caches are identical; cross-load reuse is left alone because the scan and report paths deliberately use separate store actors and validation stamps. Synthetic store with 22,000 files, 88,000 rows/snapshots and 110,000 buffered payloads, median of 3: scan + report-view load 9.56 s -> 5.07 s; decoder constructions per pass 22,531 -> 1. Full-cache golden equality and malformed-payload ordering are covered by tests. --- CHANGELOG.md | 1 + .../CostUsage/CostUsageStore+CodexCache.swift | 40 ++--- .../CostUsageStoreDecodeTests.swift | 159 ++++++++++++++++++ 3 files changed, 181 insertions(+), 19 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageStoreDecodeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4703cc374a..47d265c3e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Claude costs: reduce CPU use when rebuilding reports after local transcripts grow. - Reduce CPU use when saving unchanged local Claude and Vertex cost history. - Costs: avoid rebuilding Claude cost reports when refreshed model pricing is unchanged. +- Cost: reduce allocation overhead when loading cached local Codex usage history. - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! - Reduce CPU and filesystem work while identifying local agent processes during refreshes. - Reduce CPU use when refreshing model pricing while preserving historical fallback rates. diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift index 471aee6672..732bba6e77 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift @@ -504,10 +504,13 @@ extension CostUsageStore { retryPresence: [String: CostUsageCodexRetryBufferPresence]? = nil, tokenSnapshotsLoaded: Bool = true, unloadedTokenSnapshotPathRecorder: ((String) -> Void)? = nil, - decodedUsageRows: [String: [CostUsageScanner.CodexUsageRow]]? = nil) -> CostUsageCache + decodedUsageRows: [String: [CostUsageScanner.CodexUsageRow]]? = nil, + makeDecoder: () -> JSONDecoder = JSONDecoder.init) -> CostUsageCache { recorder?.recordCacheConversion() + let decoder = makeDecoder() var cache = CostUsageCache() + cache.files.reserveCapacity(snapshot.files.count) let metadata = snapshot.metadata cache.lastScanUnixMs = metadata.lastScanUnixMs cache.scanSinceKey = metadata.scanSinceDay @@ -524,17 +527,19 @@ extension CostUsageStore { cache.roots = metadata.rootMtimes cache.codexProjectMetadataVersion = metadata.projectMetadataVersion cache.codexPreviousReport = metadata.previousReportPayload.flatMap { - try? JSONDecoder().decode(CostUsageCodexPreviousReport.self, from: $0) + try? decoder.decode(CostUsageCodexPreviousReport.self, from: $0) } if let priority = metadata.priorityTurnStatePayload.flatMap({ - try? JSONDecoder().decode(StoredPriorityState.self, from: $0) + try? decoder.decode(StoredPriorityState.self, from: $0) }) { cache.codexPriorityTurnKeys = priority.turnKeys cache.codexPriorityTurnIDsByDay = priority.turnIDsByDay cache.codexPriorityTurnsCursor = priority.turnsCursor cache.codexResolvedPriorityTurns = priority.resolvedTurns } - cache.codexSessionDiscovery = snapshot.discoveryState.flatMap(Self.discovery(from:)) + cache.codexSessionDiscovery = snapshot.discoveryState?.payload.flatMap { + try? decoder.decode(CostUsageCodexSessionDiscovery.self, from: $0) + } cache.codexActiveLookbackState = snapshot.lookbackState.map(Self.lookback(from:)) let snapshotsByPath = Dictionary(grouping: snapshot.tokenSnapshots, by: \.path) @@ -546,7 +551,7 @@ extension CostUsageStore { for file in snapshot.files { guard let detailsData = file.scanState.detailsPayload, - let details = try? JSONDecoder().decode(StoredFileDetails.self, from: detailsData) + let details = try? decoder.decode(StoredFileDetails.self, from: detailsData) else { continue } let aggregates = (aggregatesByPath[file.path] ?? []).map(\.aggregate) let rows: [CostUsageScanner.CodexUsageRow] @@ -555,7 +560,7 @@ extension CostUsageStore { } else { recorder?.recordUsageRowDecodes(count: rowsByPath[file.path]?.count ?? 0) rows = (rowsByPath[file.path] ?? []).compactMap { - try? JSONDecoder().decode(CostUsageScanner.CodexUsageRow.self, from: $0.payload) + try? decoder.decode(CostUsageScanner.CodexUsageRow.self, from: $0.payload) } } let restoredRows = rows.isEmpty ? Self.aggregateRows(from: aggregates) : rows @@ -600,15 +605,15 @@ extension CostUsageStore { codexRows: details.hasRows ? restoredRows : nil, codexNextUsageRowIndex: details.hasExactUsageRowIndex == true ? file.scanState.nextUsageRowIndex : nil, codexPendingPricing: buffers.first { $0.kind == .pricingEvidence }.flatMap { - try? JSONDecoder().decode([String: CostUsageScanner.CodexPricingEvidence].self, from: $0.payload) + try? decoder.decode([String: CostUsageScanner.CodexPricingEvidence].self, from: $0.payload) }, codexPendingSourcePricing: buffers.first { $0.kind == .sourcePricingEvidence }.map { - (try? JSONDecoder().decode( + (try? decoder.decode( [CostUsageScanner.CodexSourcePricingKey: CostUsageScanner.CodexPricingEvidence].self, from: $0.payload)) ?? [:] }, codexPendingSourcePricingAnchor: buffers.first { $0.kind == .sourcePricingAnchor }.flatMap { - try? JSONDecoder().decode(CostUsageCodexTokenIndexAnchor.self, from: $0.payload) + try? decoder.decode(CostUsageCodexTokenIndexAnchor.self, from: $0.payload) }, codexTokenSnapshots: details.hasTokenSnapshots && tokenSnapshotsLoaded ? tokenSnapshots : nil, codexTokenCheckpoints: details.hasTokenSnapshots && tokenSnapshotsLoaded @@ -625,11 +630,11 @@ extension CostUsageStore { codexScanTargetSize: file.scanState.targetSize, codexScanComplete: file.scanState.isComplete, codexJSONLResumeState: file.scanState.resumePayload.flatMap { - try? JSONDecoder().decode(CostUsageJsonl.ResumeState.self, from: $0) + try? decoder.decode(CostUsageJsonl.ResumeState.self, from: $0) }, codexForkAccountingState: details.forkAccountingState, - codexBufferedSubagentLines: Self.bufferedLines(buffers, kind: .subagent), - codexBufferedUnresolvedForkLines: Self.bufferedLines(buffers, kind: .unresolvedFork), + codexBufferedSubagentLines: Self.bufferedLines(buffers, kind: .subagent, decoder: decoder), + codexBufferedUnresolvedForkLines: Self.bufferedLines(buffers, kind: .unresolvedFork, decoder: decoder), codexReadRetryBufferPresence: retryPresence.map { $0[file.path] ?? .init() }, codexParserRevision: details.parserRevision) cache.files[file.path] = usage @@ -1360,10 +1365,6 @@ extension CostUsageStore { } } - private static func discovery(from value: CostUsageStoreDiscoveryState) -> CostUsageCodexSessionDiscovery? { - value.payload.flatMap { try? JSONDecoder().decode(CostUsageCodexSessionDiscovery.self, from: $0) } - } - private static func lookbackState(_ value: CostUsageCodexActiveLookbackState?) -> CostUsageStoreLookbackState? { value.map { CostUsageStoreLookbackState( @@ -1458,10 +1459,11 @@ extension CostUsageStore { private static func bufferedLines( _ values: [CostUsageStoreBufferedLine], - kind: CostUsageStoreBufferedLineKind) -> [CostUsageScanner.CodexBufferedFastLine]? + kind: CostUsageStoreBufferedLineKind, + decoder: JSONDecoder) -> [CostUsageScanner.CodexBufferedFastLine]? { - let lines = values.filter { $0.kind == kind }.compactMap { - try? JSONDecoder().decode(CostUsageScanner.CodexBufferedFastLine.self, from: $0.payload) + let lines: [CostUsageScanner.CodexBufferedFastLine] = values.lazy.filter { $0.kind == kind }.compactMap { + try? decoder.decode(CostUsageScanner.CodexBufferedFastLine.self, from: $0.payload) } return lines.isEmpty ? nil : lines } diff --git a/Tests/CodexBarTests/CostUsageStoreDecodeTests.swift b/Tests/CodexBarTests/CostUsageStoreDecodeTests.swift new file mode 100644 index 0000000000..6dc5a8eae5 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageStoreDecodeTests.swift @@ -0,0 +1,159 @@ +import Foundation +import Testing +@testable import CodexBarCore + +extension CostUsageStoreReadWorkTests { + @Test + func `large decode preserves every persisted field with one decoder`() async throws { + let fixture = try ReadWorkFixture(fileCount: 1, rowsPerFile: 4, incomplete: true) + defer { fixture.remove() } + let expected = try Self.decodeGoldenCache(fixture: fixture, fileCount: 2048) + #expect(!fixture.save(expected).catchUpRequired) + let snapshot = await fixture.store.readSnapshot() + var constructions = 0 + let decoded = CostUsageStore.decodeCodexCache(from: snapshot, recorder: nil, makeDecoder: { + constructions += 1 + return JSONDecoder() + }) + #expect(decoded == expected) + #expect(snapshot.files.count == 2048) + #expect(snapshot.usageRows.count == 8192) + #expect(snapshot.tokenSnapshots.count == 8192) + #expect(snapshot.accumulators.count == 2048) + #expect(snapshot.forkLineage.count == 2048) + #expect(snapshot.bufferedLines.count == 10240) + #expect(snapshot.discoveryState != nil) + #expect(snapshot.lookbackState != nil) + #expect(constructions == 1) + print("[store-decode] files=\(snapshot.files.count) rows=\(snapshot.usageRows.count) " + + "decoders=\(constructions) golden_equal=\(decoded == expected)") + } + + @Test + func `malformed payloads do not affect subsequent decodes or row order`() async throws { + let fixture = try ReadWorkFixture(fileCount: 1, rowsPerFile: 4, incomplete: true) + defer { fixture.remove() } + var snapshot = await fixture.store.readSnapshot() + let path = try #require(snapshot.files.first?.path) + let invalid = Data("invalid JSON".utf8) + var unreadable = try #require(snapshot.files.first) + unreadable.path += ".invalid" + unreadable.scanState.detailsPayload = invalid + snapshot.files.insert(unreadable, at: 0) + snapshot.usageRows.insert(.init(path: path, rowIndex: -1, payload: invalid), at: 0) + snapshot.bufferedLines.insert(.init(path: path, kind: .unresolvedFork, lineIndex: -1, payload: invalid), at: 0) + snapshot.bufferedLines.append(.init(path: path, kind: .sourcePricingEvidence, lineIndex: 0, payload: invalid)) + snapshot.metadata.priorityTurnStatePayload = invalid + snapshot.metadata.previousReportPayload = invalid + var expected = fixture.canonical + expected.files[path]?.codexPendingSourcePricing = [:] + let decoded = CostUsageStore.decodeCodexCache(from: snapshot, recorder: nil) + #expect(decoded == expected) + } + + static func decodeGoldenCache(fixture: ReadWorkFixture, fileCount: Int) throws -> CostUsageCache { + var expected = fixture.canonical + let path = try #require(expected.files.keys.first) + var usage = try #require(expected.files[path]) + usage.lastTotals = .init(input: 40, cached: 8, output: 12) + usage.lastRawTotalsBaseline = .init(input: 10, cached: 2, output: 3) + usage.lastRawTotalsWatermark = usage.lastTotals + usage.seenRawTotals = [usage.lastRawTotalsBaseline!, usage.lastTotals!] + usage.hasDivergentTotals = true + usage.hasInterleavedTotals = true + usage.forkedFromId = "fixture-parent" + usage.forkBaselineDependencyKey = "fixture-dependency" + usage.codexWorkspaceContentFingerprint = "fixture-workspace" + usage.codexNextUsageRowIndex = 4 + usage.codexPendingPricing = ["fixture-turn": .init(pricingModel: "gpt-5.4", pricingMode: "priority")] + let source = CostUsageScanner.CodexUsageRow( + day: ReadWorkFixture.day, + model: ReadWorkFixture.model, + turnID: "fixture-source", + eventIndex: 0, + timestampUnixMs: 1_785_585_600_000, + input: 10, + cached: 2, + output: 3) + usage.codexPendingSourcePricing = try [#require(CostUsageScanner.CodexSourcePricingKey(source)): + .init(pricingModel: "gpt-5.4", pricingMode: "standard")] + usage.codexPendingSourcePricingAnchor = .init(indexedBytes: 40, windowStart: 10, sha256: "fixture-anchor") + usage.codexBufferedSubagentLines = usage.codexBufferedUnresolvedForkLines + let partial = fixture.env.root.appendingPathComponent("partial.jsonl") + try Data(("{\"message\":\"" + String(repeating: "x", count: 128)).utf8).write(to: partial) + let progress = try CostUsageJsonl.scanBounded( + fileURL: partial, + maxLineBytes: 256, + prefixBytes: 256, + maxBytesToRead: 64, + resumeState: nil, + onLine: { _ in }) + let resume = try #require(progress.resumeState) + usage.codexJSONLResumeState = resume + expected.files = Dictionary(uniqueKeysWithValues: (0.. Date: Thu, 1 Oct 2026 02:45:21 -0700 Subject: [PATCH 120/122] perf(cost): skip pricing Claude rows outside the scan range (#4169) Claude transcript parsing priced every assistant usage line (token extraction, one-hour cache-creation lookup, model normalization and pricing) before checking whether the line fell inside the scan window, so the 30-day context paid for pricing years of history it then discarded. The parse closure was ~70% of the startup profile of the signed 0.70.0 app. Move the existing padded range check ahead of token extraction and pricing, defer the one-hour cache lookup until after the zero-token rejection, and drop the redundant ClaudeTokens copy. Row ordering, duplicate replacement, Vertex filtering, incomplete usage and JSONL byte offsets are unchanged. Synthetic 2-year transcript set with a 30-day range: process CPU 1.98 s -> 1.22 s with identical retained rows, parsed bytes and summed cost; a DEBUG counter proves pricing runs only for in-range rows. --- CHANGELOG.md | 1 + .../CostUsage/CostUsageClaudeCache.swift | 3 + .../CostUsage/CostUsageScanner+Claude.swift | 52 +++---- .../CostUsageClaudePriceRangeTests.swift | 147 ++++++++++++++++++ 4 files changed, 169 insertions(+), 34 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageClaudePriceRangeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 47d265c3e0..a15b71f475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Reduce CPU use when saving unchanged local Claude and Vertex cost history. - Costs: avoid rebuilding Claude cost reports when refreshed model pricing is unchanged. - Cost: reduce allocation overhead when loading cached local Codex usage history. +- Costs: reduce CPU use when scanning older Claude transcripts for recent usage. - Menu bar: show the remaining quota when only the third usage window is available, including Gemini Flash Lite-only accounts, through the shared metric fallback (#4128). Thanks @devYRPauli! - Reduce CPU and filesystem work while identifying local agent processes during refreshes. - Reduce CPU use when refreshing model pricing while preserving historical fallback rates. diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift index c49dc4d1a9..451cab8559 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -205,6 +205,7 @@ extension CostUsageScanner { case normalizationCacheMiss case vertexMetadataWalk case claudeLineDecode + case claudeCostCalculation case catalogModelLookup(found: Bool) } @@ -218,6 +219,7 @@ extension CostUsageScanner { var normalizationCacheMisses = 0 var vertexMetadataWalks = 0 var claudeLineDecodes = 0 + var claudeCostCalculations = 0 var catalogModelLookups = 0 var catalogModelHits = 0 var catalogModelMisses = 0 @@ -246,6 +248,7 @@ extension CostUsageScanner { case .normalizationCacheMiss: self.metrics.normalizationCacheMisses += 1 case .vertexMetadataWalk: self.metrics.vertexMetadataWalks += 1 case .claudeLineDecode: self.metrics.claudeLineDecodes += 1 + case .claudeCostCalculation: self.metrics.claudeCostCalculations += 1 case let .catalogModelLookup(found): self.metrics.catalogModelLookups += 1 if found { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 21f1fe7ce6..a494a6b846 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -43,16 +43,6 @@ extension CostUsageScanner { // MARK: - Claude - private struct ClaudeTokens { - let input: Int - let cacheRead: Int - let cacheCreate: Int - let cacheCreate1h: Int - let output: Int - let costNanos: Int - let costPriced: Bool - } - private struct ClaudeDayModelKey: Hashable { let day: String let model: String @@ -212,6 +202,11 @@ extension CostUsageScanner { else { return } let timestamp = parsedTimestamp.date let dayKey = parsedTimestamp.dayKey + guard CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + else { return } guard let message else { return } guard let model = message["model"] as? String else { return } @@ -219,20 +214,23 @@ extension CostUsageScanner { let input = max(0, toInt(usage["input_tokens"])) let cacheCreate = max(0, toInt(usage["cache_creation_input_tokens"])) - let cacheCreate1h = Self.claudeOneHourCacheCreationTokens( - usage: usage, - total: cacheCreate) let cacheRead = max(0, toInt(usage["cache_read_input_tokens"])) let output = max(0, toInt(usage["output_tokens"])) if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } + let cacheCreate1h = Self.claudeOneHourCacheCreationTokens( + usage: usage, + total: cacheCreate) // Proxies may put a local, cache-unaware estimate in message_start. // Missing stop_reason alone is not evidence of incomplete legacy usage. let isIncomplete = message["stop_reason"] is NSNull && input > 0 && output == 0 && usage["cache_read_input_tokens"] == nil && usage["cache_creation_input_tokens"] == nil + #if DEBUG + if !isIncomplete { recordClaudeScanWork(.claudeCostCalculation) } + #endif let cost = isIncomplete ? nil : pricingResolver.costUSD( model: model, inputTokens: input, @@ -242,20 +240,6 @@ extension CostUsageScanner { outputTokens: output, pricingDate: timestamp) let costNanos = cost.flatMap { Int(exactly: ($0 * costScale).rounded()) } - let tokens = ClaudeTokens( - input: input, - cacheRead: cacheRead, - cacheCreate: cacheCreate, - cacheCreate1h: cacheCreate1h, - output: output, - costNanos: costNanos ?? 0, - costPriced: costNanos != nil) - - guard CostUsageDayRange.isInRange( - dayKey: dayKey, - since: range.scanSinceKey, - until: range.scanUntilKey) - else { return } let messageId = message["id"] as? String let requestId = obj["requestId"] as? String @@ -273,13 +257,13 @@ extension CostUsageScanner { timestampUnixMs: Int64((timestamp.timeIntervalSince1970 * 1000).rounded()), isSidechain: toBool(obj["isSidechain"]), pathRole: pathRole, - input: tokens.input, - cacheRead: tokens.cacheRead, - cacheCreate: tokens.cacheCreate, - cacheCreate1h: tokens.cacheCreate1h, - output: tokens.output, - costNanos: tokens.costNanos, - costPriced: tokens.costPriced, + input: input, + cacheRead: cacheRead, + cacheCreate: cacheCreate, + cacheCreate1h: cacheCreate1h, + output: output, + costNanos: costNanos ?? 0, + costPriced: costNanos != nil, isIncomplete: isIncomplete ? true : nil) // Keep the final cumulative chunk for each response. diff --git a/Tests/CodexBarTests/CostUsageClaudePriceRangeTests.swift b/Tests/CodexBarTests/CostUsageClaudePriceRangeTests.swift new file mode 100644 index 0000000000..1359a3fc2f --- /dev/null +++ b/Tests/CodexBarTests/CostUsageClaudePriceRangeTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageClaudePriceRangeTests { + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static let start = Date(timeIntervalSince1970: 1_727_784_000) // 2024-10-01 noon UTC + private static let model = "claude-sonnet-4-20250514" + + private static func day(_ index: Int) -> Date { + self.start.addingTimeInterval(Double(index) * 86400) + } + + private static func entry( + day: Int, + id: String? = nil, + request: String? = nil, + input: Int = 10, + output: Int = 2, + incomplete: Bool = false, + sidechain: Bool = false) -> [String: Any] + { + var usage: [String: Any] = ["input_tokens": input, "output_tokens": output] + if !incomplete, input > 0 { + usage["cache_read_input_tokens"] = 3 + usage["cache_creation_input_tokens"] = 4 + usage["cache_creation"] = ["ephemeral_1h_input_tokens": 2] + } + var message: [String: Any] = ["model": self.model, "usage": usage, "metadata": ["sessionId": "fallback"]] + message["id"] = id + if incomplete { message["stop_reason"] = NSNull() } + var row: [String: Any] = [ + "type": "assistant", "timestamp": self.day(day).ISO8601Format(), + "isSidechain": sidechain, "message": message, + ] + row["requestId"] = request + return row + } + + @Test(arguments: [false, true]) + func `range rejection preserves golden rows streaming winners and byte offsets`(vertex: Bool) throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let range = CostUsageScanner.CostUsageDayRange( + since: Self.day(700), until: Self.day(729), calendar: Self.calendar) + var entries = [ + Self.entry(day: 0, id: "a", request: "request"), + Self.entry(day: 700, id: "a", request: "request", output: 0, incomplete: true), + Self.entry(day: 700, id: "a", request: "request", output: 4), + Self.entry(day: 700, id: "a", request: "request", input: 999, output: 0, incomplete: true), + Self.entry(day: 730, id: "a", request: "request", output: 6, sidechain: true), + Self.entry(day: 731, id: "a", request: "request", output: 99), + Self.entry(day: 701, id: "b", output: 0, incomplete: true), + Self.entry(day: 702, id: "c", output: 3), + Self.entry(day: 702, id: "c", input: 0, output: 0), + Self.entry(day: 699, sidechain: true), + Self.entry(day: 698), + Self.entry(day: 729), + ["type": "assistant", "timestamp": Self.day(700).ISO8601Format(), "usage": [:]], + ["type": "assistant", "timestamp": "invalid", "message": ["usage": [:]]], + ] + if vertex { + for index in entries.indices { + entries[index]["metadata"] = ["provider": "vertex"] + } + } + let prefix = try env.jsonl([entries.removeFirst()]) + let complete = try prefix + (env.jsonl(entries)) + let content = complete + #"{"type":"assistant","message":{"usage":"# + let file = try env.writeClaudeProjectFile(relativePath: "project/subagents/range.jsonl", contents: content) + let expected = [ + Self.expected(day: 730, id: "a", request: "request", output: 6, sidechain: true), + Self.expected(day: 701, id: "b", output: 0, incomplete: true), + Self.expected(day: 702, id: "c", output: 3), + Self.expected(day: 699, sidechain: true), + Self.expected(day: 729), + ] + for filter: CostUsageScanner.ClaudeLogProviderFilter in [.all, .excludeVertexAI, .vertexAIOnly] { + for offset in [Int64(0), Int64(prefix.utf8.count)] { + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: file, + range: range, + providerFilter: filter, + startOffset: offset, + modelsDevCatalog: ModelsDevCatalog(providers: [:])) + let keep = filter == .all || (filter == .vertexAIOnly) == vertex + #expect(parsed.rows == (keep ? expected : [])) + #expect(parsed.parsedBytes == Int64(complete.utf8.count)) + } + } + } + + @Test + func `two years of usage only prices rows in the padded thirty day scan range`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let content = try env.jsonl((0..<730).map { Self.entry(day: $0) }) + let file = try env.writeClaudeProjectFile(relativePath: "project/history.jsonl", contents: content) + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + let parsed = CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { + CostUsageScanner.parseClaudeFile( + fileURL: file, + range: .init(since: Self.day(700), until: Self.day(729), calendar: Self.calendar), + providerFilter: .excludeVertexAI, + modelsDevCatalog: ModelsDevCatalog(providers: [:])) + } + #expect(parsed.rows == (699..<730).map { Self.expected(day: $0, pathRole: .parent) }) + #expect(parsed.parsedBytes == Int64(content.utf8.count)) + #expect(recorder.snapshot().claudeLineDecodes == 730) + #expect(recorder.snapshot().claudeCostCalculations == parsed.rows.count) + } + + private static func expected( + day: Int, + id: String? = nil, + request: String? = nil, + output: Int = 2, + incomplete: Bool = false, + sidechain: Bool = false, + pathRole: CostUsageScanner.ClaudePathRole = .subagent) -> CostUsageScanner.ClaudeUsageRow + { + // Sonnet 4: input $3/M, output $15/M, read $0.30/M, 5m creation $3.75/M, 1h creation $6/M. + let nanos = incomplete ? 0 : 50400 + output * 15000 + return CostUsageScanner.ClaudeUsageRow( + dayKey: CostUsageScanner.CostUsageDayRange.dayKey(from: self.day(day), calendar: self.calendar), + model: self.model, + sessionId: "fallback", + messageId: id, + requestId: request, + timestampUnixMs: Int64(self.day(day).timeIntervalSince1970 * 1000), + isSidechain: sidechain, + pathRole: pathRole, + input: 10, + cacheRead: incomplete ? 0 : 3, + cacheCreate: incomplete ? 0 : 4, + cacheCreate1h: incomplete ? 0 : 2, + output: output, + costNanos: nanos, + costPriced: !incomplete, + isIncomplete: incomplete ? true : nil) + } +} From 2d099c9d0f7a6e12398e6d1acb62525a5945418c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Oct 2026 04:34:50 -0700 Subject: [PATCH 121/122] chore(cost): regenerate codex parser hash --- Sources/CodexBarCore/Generated/CodexParserHash.generated.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index c05457de9d..2eca456c42 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "f0c4c51efbbc563b" + static let value = "6c68a34c2a205aa3" } From 5168ec57a5331eb459e200fffd10e87e3af35e7d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Oct 2026 09:16:56 -0700 Subject: [PATCH 122/122] fix(codex): gate assessment memo on host enforcement Cache the host decision once per process. Require successful full-SIP configuration, system code-signing enforcement, and readable boot arguments without enforcement overrides before any memo or signature-identity work. Keep the native SIGKILL proof strict on enforcing hosts and prove fresh assessment on other hosts. Preserve standalone signature invalidation, bundle behavior, and the contributor history. --- CHANGELOG.md | 2 +- .../CodexLaunchPreflight+AssessmentMemo.swift | 11 ++- ...CodexLaunchPreflight+HostEnforcement.swift | 53 ++++++++++++ ...exLaunchPreflightAssessmentMemoTests.swift | 2 +- ...xLaunchPreflightHostEnforcementTests.swift | 80 +++++++++++++++++++ .../CodexLaunchPreflightMachOTests.swift | 2 +- .../CodexLaunchPreflightSignatureTests.swift | 38 ++++++--- docs/codex.md | 6 +- 8 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 Sources/CodexBarCore/CodexLaunchPreflight+HostEnforcement.swift create mode 100644 Tests/CodexBarTests/CodexLaunchPreflightHostEnforcementTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e64d70596..715d8886dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ ### Fixed -- Codex: reduce repeated Gatekeeper CPU use for unchanged standalone hardened-runtime CLIs, checking every architecture’s complete signature before reusing a verdict (#4078, #4080). Thanks @dustball! +- Codex: reduce repeated Gatekeeper CPU use for unchanged standalone hardened-runtime CLIs on fully enforcing hosts, checking every architecture’s complete signature before reusing a verdict (#4078, #4080). Thanks @dustball! - Claude: retain an established CLI source after transient timeouts and loading stalls so Auto refreshes can retry without an unrelated missing-OAuth-credentials warning (#4129). - Claude: exclude usage-insights tool names and percentages from quota and account parsing (#4083). diff --git a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift index 5ab5989a3a..e7df86c305 100644 --- a/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift +++ b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift @@ -14,8 +14,9 @@ extension CodexLaunchPreflight { /// a hash of every embedded signature; unsigned files and app bundles are never memoized. /// Mapped writes can leave stat unchanged: changing any signature byte invalidates the key. Only hardened /// runtime code without a page-protection opt-out is eligible (Apple TN3126). The kernel validates signed - /// pages at page-in; quarantine/xattr changes invalidate via ctime. The remaining check-then-exec race also exists - /// without the memo. + /// pages at page-in only on enforcing hosts: a process-wide gate requires full SIP, system enforcement, + /// and readable boot arguments without enforcement overrides. Otherwise every assessment stays fresh. + /// Quarantine/xattr changes invalidate via ctime; the remaining check-then-exec race exists without the memo. /// A verdict is kept only if the file's identity is unchanged when `spctl` returns, and a caller receives /// it only while its own path still names that file; otherwise the caller gets a fresh, unshared /// assessment of its path. The lifetime bounds how long a certificate revoked in place, with the file @@ -54,15 +55,18 @@ extension CodexLaunchPreflight { private let lock = NSLock() private var entries: [FileIdentity: (assessment: GatekeeperAssessment, expiresAt: TimeInterval)] = [:] private var flights: [FileIdentity: Flight] = [:] + private let hostAllowsMemoization: Bool private let onJoin: @Sendable () -> Void private let onCacheHit: @Sendable () -> Void private let readSignature: @Sendable (String) -> SignatureIdentity? init( + hostAllowsMemoization: Bool = HostEnforcement.allowsMemoization, onJoin: @escaping @Sendable () -> Void = {}, onCacheHit: @escaping @Sendable () -> Void = {}, readSignature: @escaping @Sendable (String) -> SignatureIdentity? = SignatureIdentity.read) { + self.hostAllowsMemoization = hostAllowsMemoization self.onJoin = onJoin self.onCacheHit = onCacheHit self.readSignature = readSignature @@ -81,7 +85,8 @@ extension CodexLaunchPreflight { isDefinitive: (GatekeeperAssessment) -> Bool, assess: (String) -> GatekeeperAssessment?) -> GatekeeperAssessment? { - guard let file = self.identity(path), self.identity(file.volumePath) == file + guard self.hostAllowsMemoization, + let file = self.identity(path), self.identity(file.volumePath) == file else { return assess(path) } self.lock.lock() if let entry = self.entries[file], now < entry.expiresAt { diff --git a/Sources/CodexBarCore/CodexLaunchPreflight+HostEnforcement.swift b/Sources/CodexBarCore/CodexLaunchPreflight+HostEnforcement.swift new file mode 100644 index 0000000000..45088927eb --- /dev/null +++ b/Sources/CodexBarCore/CodexLaunchPreflight+HostEnforcement.swift @@ -0,0 +1,53 @@ +#if os(macOS) +import Darwin +import Foundation + +@_silgen_name("csr_get_active_config") +private func codexBarCSRGetActiveConfig(_ configuration: UnsafeMutablePointer) -> Int32 + +extension CodexLaunchPreflight { + struct HostEnforcement: Sendable { + static let current = Self.read() + static let allowsMemoization = Self.current.isEnforced + static let forbiddenBootArguments = ["amfi", "cs_enforcement", "cs_debug", "-arm64e_preview_abi"] + + let csrStatus: Int32 + let csrConfiguration: UInt32 + let systemEnforcement: Int32? + let bootArguments: String? + + var isEnforced: Bool { + guard self.csrStatus == 0, self.csrConfiguration == 0, self.systemEnforcement == 1, + let bootArguments = self.bootArguments?.lowercased() else { return false } + return !Self.forbiddenBootArguments.contains(where: bootArguments.contains) + } + + private static func read() -> Self { + var configuration: UInt32 = 0 + let csrStatus = codexBarCSRGetActiveConfig(&configuration) + var enforcement: Int32 = 0 + var size = MemoryLayout.size + let readable = sysctlbyname("vm.cs_system_enforcement", &enforcement, &size, nil, 0) == 0 && + size == MemoryLayout.size + return Self( + csrStatus: csrStatus, + csrConfiguration: configuration, + systemEnforcement: readable ? enforcement : nil, + bootArguments: self.readBootArguments()) + } + + private static func readBootArguments() -> String? { + var size = 0 + guard sysctlbyname("kern.bootargs", nil, &size, nil, 0) == 0, (1...65536).contains(size) + else { return nil } + var data = Data(count: size) + let result = data.withUnsafeMutableBytes { + sysctlbyname("kern.bootargs", $0.baseAddress, &size, nil, 0) + } + guard result == 0, size > 0, size <= data.count, data[size - 1] == 0, + !data.prefix(size - 1).contains(0) else { return nil } + return String(data: data.prefix(size - 1), encoding: .utf8) + } + } +} +#endif diff --git a/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift index c1371dcf51..a69ff5b8d5 100644 --- a/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift +++ b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift @@ -43,7 +43,7 @@ struct CodexLaunchPreflightAssessmentMemoTests { onJoin: @escaping @Sendable () -> Void = {}, onCacheHit: @escaping @Sendable () -> Void = {}) -> Memo { - Memo(onJoin: onJoin, onCacheHit: onCacheHit, readSignature: { _ in + Memo(hostAllowsMemoization: true, onJoin: onJoin, onCacheHit: onCacheHit, readSignature: { _ in .init(digest: Data("synthetic signature".utf8)) }) } diff --git a/Tests/CodexBarTests/CodexLaunchPreflightHostEnforcementTests.swift b/Tests/CodexBarTests/CodexLaunchPreflightHostEnforcementTests.swift new file mode 100644 index 0000000000..18d64ecb26 --- /dev/null +++ b/Tests/CodexBarTests/CodexLaunchPreflightHostEnforcementTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +struct CodexLaunchPreflightHostEnforcementTests { + private typealias Host = CodexLaunchPreflight.HostEnforcement + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + var total: Int { + self.lock.withLock { self.value } + } + + func increment() { self.lock.withLock { self.value += 1 } } + } + + @Test(arguments: ["", "-v", "keepsyms=1 debug=0x100"]) + func `fully enforced hosts accept readable ordinary boot arguments`(bootArguments: String) { + #expect(Host( + csrStatus: 0, csrConfiguration: 0, systemEnforcement: 1, bootArguments: bootArguments).isEnforced) + } + + @Test(arguments: [ + "amfi_get_out_of_my_way=1", "AMFI=0", "-v cs_enforcement_disable=1", "CS_EnForCement=1", + "cs_debug=0", "-ArM64E_PrEvIeW_AbI", "prefix_amfi_suffix=1", + ]) + func `enforcement boot overrides are rejected case insensitively`(bootArguments: String) { + #expect(!Host( + csrStatus: 0, csrConfiguration: 0, systemEnforcement: 1, bootArguments: bootArguments).isEnforced) + } + + @Test + func `CSR failures and any partial SIP configuration disable memoization`() { + for status: Int32 in [-1, 1, 5] { + #expect(!Host(csrStatus: status, csrConfiguration: 0, systemEnforcement: 1, bootArguments: "").isEnforced) + } + for configuration: UInt32 in [1, 2, 0x67, .max] { + #expect(!Host( + csrStatus: 0, csrConfiguration: configuration, systemEnforcement: 1, bootArguments: "").isEnforced) + } + } + + @Test + func `sysctl failures and unenforced values disable memoization`() { + for enforcement: Int32? in [nil, -1, 0, 2] { + #expect(!Host(csrStatus: 0, csrConfiguration: 0, systemEnforcement: enforcement, bootArguments: "") + .isEnforced) + } + #expect(!Host(csrStatus: 0, csrConfiguration: 0, systemEnforcement: 1, bootArguments: nil).isEnforced) + } + + @Test + func `a disallowed host bypasses identity reads and cached results entirely`() throws { + let path = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data("synthetic executable".utf8).write(to: path) + defer { try? FileManager.default.removeItem(at: path) } + let signatureReads = Counter() + let memo = CodexLaunchPreflight.AssessmentMemo( + hostAllowsMemoization: false, + readSignature: { _ in + signatureReads.increment() + return .init(digest: Data("synthetic signature".utf8)) + }) + var calls = 0 + for expected in 1...100 { + let result = memo.assessment(path: path.path, isDefinitive: { _ in true }, assess: { candidate in + #expect(candidate == path.path) + calls += 1 + return .init(output: "assessment \(calls)", exitStatus: 0) + }) + #expect(result?.output == "assessment \(expected)") + } + #expect(calls == 100) + #expect(signatureReads.total == 0) + print("host gate bypass: requests=100 assessments=\(calls) signatureReads=\(signatureReads.total)") + } +} +#endif diff --git a/Tests/CodexBarTests/CodexLaunchPreflightMachOTests.swift b/Tests/CodexBarTests/CodexLaunchPreflightMachOTests.swift index 5d23c7dd87..c8c339f6f4 100644 --- a/Tests/CodexBarTests/CodexLaunchPreflightMachOTests.swift +++ b/Tests/CodexBarTests/CodexLaunchPreflightMachOTests.swift @@ -67,7 +67,7 @@ struct CodexLaunchPreflightMachOTests { fixture.change(directory + 13) #expect(Signature.read(fixture.path) == nil) var calls = 0 - let memo = CodexLaunchPreflight.AssessmentMemo() + let memo = CodexLaunchPreflight.AssessmentMemo(hostAllowsMemoization: true) for _ in 0..<3 { _ = memo.assessment(path: fixture.path, isDefinitive: { _ in true }, assess: { path in calls += 1 diff --git a/Tests/CodexBarTests/CodexLaunchPreflightSignatureTests.swift b/Tests/CodexBarTests/CodexLaunchPreflightSignatureTests.swift index 7e0a66de61..fb55632e02 100644 --- a/Tests/CodexBarTests/CodexLaunchPreflightSignatureTests.swift +++ b/Tests/CodexBarTests/CodexLaunchPreflightSignatureTests.swift @@ -190,7 +190,7 @@ struct CodexLaunchPreflightSignatureTests { offset = blob + Int(length) - 1 } let original = try #require(Signature.read(fixture.path)) - let memo = Memo(onCacheHit: { if duringHit { fixture.change(offset) } }) + let memo = Memo(hostAllowsMemoization: true, onCacheHit: { if duringHit { fixture.change(offset) } }) var calls = 0 func assess() { _ = memo.assessment(path: fixture.path, isDefinitive: { _ in true }, assess: { path in @@ -211,11 +211,21 @@ struct CodexLaunchPreflightSignatureTests { } @Test(arguments: [false, true]) - func `mapped hardened text keeps the signature hash but the kernel kills execution`(universal: Bool) throws { + func `mapped hardened text follows the real host enforcement gate`(universal: Bool) throws { let fixture = try Fixture(universal: universal) let original = try #require(Signature.read(fixture.path)) let baseline = try Self.run(fixture.path) try #require(baseline.terminationReason == .exit && baseline.terminationStatus == 0) + let host = CodexLaunchPreflight.HostEnforcement.current + let allowed = CodexLaunchPreflight.HostEnforcement.allowsMemoization + let bootMatches = CodexLaunchPreflight.HostEnforcement.forbiddenBootArguments.filter { + host.bootArguments?.lowercased().contains($0) == true + } + print("host gate: csrStatus=\(host.csrStatus) csrConfiguration=\(host.csrConfiguration) " + + "systemEnforcement=\(String(describing: host.systemEnforcement)) " + + "bootargsReadable=\(host.bootArguments != nil) bootargsEmpty=\(host.bootArguments?.isEmpty == true) " + + "bootargsMatches=\(bootMatches) allowed=\(allowed)") + #expect(allowed == host.isEnforced) let memo = Memo() var calls = 0 func assess() { @@ -235,17 +245,25 @@ struct CodexLaunchPreflightSignatureTests { #expect(try fixture.metadata() == before) #expect(try #require(Signature.read(fixture.path)) == original) assess() - #expect(calls == 1) - let changed = try Self.run(fixture.path) - #expect(changed.terminationReason == .uncaughtSignal) - #expect(changed.terminationStatus == SIGKILL) - print("mapped hardened text: universal=\(universal) assessments=\(calls) signal=\(changed.terminationStatus)") + if allowed { + #expect(calls == 1) + let changed = try Self.run(fixture.path) + #expect(changed.terminationReason == .uncaughtSignal) + #expect(changed.terminationStatus == SIGKILL) + print( + "mapped hardened text: universal=\(universal) assessments=\(calls) signal=\(changed.terminationStatus)") + } else { + #expect(calls == 2) + assess() + #expect(calls == 3) + print("mapped hardened text: universal=\(universal) hostGate=false freshAssessments=\(calls)") + } } @Test func `a hardened executable shares one assessment across one hundred lookups`() throws { let fixture = try Fixture() - let memo = Memo() + let memo = Memo(hostAllowsMemoization: true) var calls = 0 for _ in 0..<100 { let result = memo.assessment(path: fixture.path, isDefinitive: { _ in true }, assess: { path in @@ -262,7 +280,7 @@ struct CodexLaunchPreflightSignatureTests { func `hardened binaries opting out of page protection are never memoized`(universal: Bool) throws { let fixture = try Fixture(universal: universal, optOut: true) #expect(Signature.read(fixture.path) == nil) - let memo = Memo() + let memo = Memo(hostAllowsMemoization: true) var calls = 0 for _ in 0..<3 { _ = memo.assessment(path: fixture.path, isDefinitive: { _ in true }, assess: { path in @@ -281,7 +299,7 @@ struct CodexLaunchPreflightSignatureTests { for path in [fixture.path, text.path, fixture.root.appendingPathComponent("missing").path] { #expect(Signature.read(path) == nil) var calls = 0 - let memo = Memo() + let memo = Memo(hostAllowsMemoization: true) for _ in 0..<3 { _ = memo.assessment(path: path, isDefinitive: { _ in true }, assess: { candidate in #expect(candidate == path) diff --git a/docs/codex.md b/docs/codex.md index 91c3a6deda..d354759148 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -161,9 +161,11 @@ and stable account numbers distinguish rows while usable workspace labels remain ### Codex CLI RPC (automatic CLI source) - Launches local RPC server: `codex -s read-only -a never app-server`. -- On macOS, standalone hardened-runtime CLI Gatekeeper verdicts are reused for at most five minutes while stat +- On fully enforcing macOS hosts, standalone hardened-runtime CLI Gatekeeper verdicts are reused for at most five minutes while stat metadata and every architecture’s complete embedded signature remain unchanged. Page-protection opt-outs, - unsigned or malformed files, and app bundles use fresh assessments. Malware/quarantine checks run per lookup. + unsigned or malformed files, and app bundles use fresh assessments. A process-wide host check requires full SIP, + system code-signing enforcement, and readable boot arguments without enforcement overrides; failed or unknown + checks retain fresh assessment. Malware/quarantine checks run per lookup. - JSON-RPC over stdin/stdout: - `initialize` (client name/version) - `account/read`