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..5c6534511d --- /dev/null +++ b/.github/pr-proof/codex-gatekeeper-assessment-memo.log @@ -0,0 +1,76 @@ +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. + +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. + +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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e3fdd6e8e..3880741ad2 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" @@ -522,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 4f07727e09..ef1f95face 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,26 +1,70 @@ # Changelog -## 0.68.1 — Unreleased +## 0.69.1 — Unreleased -### Added +### Fixed -- Claude: show saved usage-limit resets and their expiry from the Web source in the menu and `codexbar usage` details (#4048). Thanks @enieuwy! +- 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 -- 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! -### Changed +- 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 (#4078, #4080). Thanks @dustball! +- CLI: keep probe timeout and cancellation cleanup responsive when other processes have large environments (#4077). -- 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 +## 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. -- 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! -- 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. +### 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! ## 0.68.0 — 2026-09-27 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/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-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_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/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/CodexBar/AgentSessionsStore.swift b/Sources/CodexBar/AgentSessionsStore.swift index f1fac70bd3..f493296cf3 100644 --- a/Sources/CodexBar/AgentSessionsStore.swift +++ b/Sources/CodexBar/AgentSessionsStore.swift @@ -53,7 +53,7 @@ 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? // Read last in deinit: its getter escapes self. private let periodicSleep: PeriodicSleep @ObservationIgnored private var localPeriodicTask: Task? @ObservationIgnored private var remotePeriodicTask: Task? @@ -113,12 +113,12 @@ final class AgentSessionsStore { self.periodicSleep = periodicSleep } - isolated deinit { - if let powerAssertionID { self.powerAssertion.release(powerAssertionID) } + deinit { 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/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/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/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/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/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/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/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/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/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 e7dcf289c4..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 } @@ -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/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/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index a90d580b5c..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? @@ -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, @@ -1315,6 +1317,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 +1440,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/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/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..dc9ef7292e 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 @@ -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 436572852d..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, @@ -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/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/CodexLaunchPreflight+AssessmentMemo.swift b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift new file mode 100644 index 0000000000..231f3d90e2 --- /dev/null +++ b/Sources/CodexBarCore/CodexLaunchPreflight+AssessmentMemo.swift @@ -0,0 +1,173 @@ +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 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 = 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. + /// `bound` records whether the file was unchanged when `spctl` returned. + private final class Flight { + private let condition = NSCondition() + private var completed = false + private var result: (assessment: GatekeeperAssessment?, bound: Bool) = (nil, false) + + func wait() -> (assessment: GatekeeperAssessment?, bound: Bool) { + self.condition.lock() + defer { self.condition.unlock() } + while !self.completed { + self.condition.wait() + } + return self.result + } + + func complete(_ assessment: GatekeeperAssessment?, bound: Bool) { + self.condition.lock() + self.result = (assessment, bound) + self.completed = true + self.condition.broadcast() + self.condition.unlock() + } + } + + private let lock = NSLock() + 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 = {}, 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 + /// 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 file = FileIdentity(path: path), file.isRegularFile, + FileIdentity(path: file.volumePath) == file + else { return assess(path) } + self.lock.lock() + if let entry = self.entries[file], now < entry.expiresAt { + self.lock.unlock() + 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() + return Self.deliver(shared.assessment, bound: shared.bound, file: file, path: path, assess: assess) + } + let flight = Flight() + self.flights[file] = flight + self.lock.unlock() + + 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, 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[file] = (result, now + Self.lifetime) + } + flight.complete(result, bound: bound) + self.flights.removeValue(forKey: file) + } + 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. + private static func attributed( + _ assessment: GatekeeperAssessment?, + from source: String, + to path: String) -> 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 FileIdentity: Hashable { + let mode: mode_t + let device: dev_t + let inode: ino_t + let size: off_t + let modifiedSeconds: Int + let modifiedNanoseconds: Int + let changedSeconds: Int + let changedNanoseconds: Int + + var isRegularFile: Bool { + self.mode & S_IFMT == S_IFREG + } + + /// Names this file without traversing any directory or symlink. + var volumePath: String { + "/.vol/\(self.device)/\(self.inode)" + } + + /// `stat`, so a symlinked candidate resolves to the file it names right now. + init?(path: String) { + var info = stat() + guard stat(path, &info) == 0 else { return nil } + self.mode = info.st_mode + 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/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/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/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/DarwinProcessEnumerator.swift b/Sources/CodexBarCore/DarwinProcessEnumerator.swift index 2130895533..0122bac808 100644 --- a/Sources/CodexBarCore/DarwinProcessEnumerator.swift +++ b/Sources/CodexBarCore/DarwinProcessEnumerator.swift @@ -31,22 +31,26 @@ enum DarwinProcessEnumerator { self.parseProcArgs2Layout(data)?.arguments } - static func parseProcArgs2PiSelectorEnvironment(_ data: Data) -> [String: String]? { + static func parseProcArgs2Environment( + _ data: Data, + names: Set = PiProcessEnvironment.selectorNames) -> [String: String]? + { guard let layout = self.parseProcArgs2Layout(data) else { return nil } let suffix = Data(data[layout.environmentOffset...]) // Darwin can omit environment records from a successful procargs response. // An argv-only response or padding is not evidence of an empty environment. - guard let start = suffix.firstIndex(where: { $0 != 0 }) else { return nil } + guard let first = suffix.first, first != 0 else { return nil } + let start = suffix.startIndex var offset = start while offset < suffix.endIndex { guard let terminator = suffix[offset...].firstIndex(of: 0) else { return nil } if terminator == offset { // The empty environment terminator may be followed by unrelated Apple vectors. - return PiProcessEnvironment.parseNULSeparated(Data(suffix[start.. (arguments: [String], environmentOffset: Int)? { @@ -141,10 +145,14 @@ extension DarwinProcessEnumerator { arguments: layout.arguments) let environment = AgentPSOutputParser.piDialect(for: process) == nil ? nil - : self.parseProcArgs2PiSelectorEnvironment(data) + : self.parseProcArgs2Environment(data) return (layout.arguments, environment) } + static func environment(pid: Int32, names: Set) -> [String: String]? { + self.procArgs2Data(pid: pid).flatMap { self.parseProcArgs2Environment($0, names: names) } + } + private static func procArgs2Data(pid: Int32) -> Data? { var mib = [CTL_KERN, KERN_PROCARGS2, pid] var byteCount = 0 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/Host/Process/ProcessOwnershipReaper.swift b/Sources/CodexBarCore/Host/Process/ProcessOwnershipReaper.swift new file mode 100644 index 0000000000..ae4b70c0c2 --- /dev/null +++ b/Sources/CodexBarCore/Host/Process/ProcessOwnershipReaper.swift @@ -0,0 +1,86 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif +import Foundation + +/// A per-launch capability inherited across exec, setsid, and reparenting. Never infer ownership from cwd or argv. +struct ProcessOwnershipReaper: Sendable { + static let environmentKey = "CODEXBAR_PROBE_OWNER" + let marker = UUID().uuidString + + func reap(processGroup: pid_t? = nil) { + // A live marked member must still witness group ownership after the leader exits. + if let processGroup { + self.signalGroup(processGroup, signal: SIGTERM) + } + let deadline = Date().addingTimeInterval(0.4) + repeat { + let identities = self.ownedProcesses() + if identities.isEmpty { return } + for identity in identities { + Self.signal(identity, SIGTERM, owns: self.owns) + } + usleep(50000) + } while Date() < deadline + if let processGroup { + self.signalGroup(processGroup, signal: SIGKILL) + } + for identity in self.ownedProcesses() { + Self.signal(identity, SIGKILL, owns: self.owns) + } + } + + private func signalGroup(_ group: pid_t, signal: Int32) { + guard group > 0, group != getpgrp() else { return } + for identity in self.ownedProcesses() where getpgid(identity.pid) == group { + guard self.owns(identity.pid), TTYProcessTreeTerminator.isCurrent(identity), + getpgid(identity.pid) == group else { continue } + kill(-group, signal) + return + } + } + + private func ownedProcesses() -> [TTYProcessTreeTerminator.ProcessIdentity] { + #if canImport(Darwin) + let pids = DarwinProcessEnumerator.allPIDs() + #else + let pids = ((try? FileManager.default.contentsOfDirectory(atPath: "/proc")) ?? []).compactMap { pid_t($0) } + #endif + return pids.compactMap { pid in + guard let identity = TTYProcessTreeTerminator.processIdentity(for: pid), self.owns(pid), + TTYProcessTreeTerminator.isCurrent(identity) else { return nil } + return identity + } + } + + private func owns(_ pid: pid_t) -> Bool { + guard pid > 0, pid != getpid() else { return false } + #if canImport(Darwin) + var info = proc_bsdinfo() + guard proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size)) == + MemoryLayout.size, info.pbi_uid == getuid() else { return false } + let environment = DarwinProcessEnumerator.environment(pid: pid, names: [Self.environmentKey]) + #else + guard let attributes = try? FileManager.default.attributesOfItem(atPath: "/proc/\(pid)"), + (attributes[.ownerAccountID] as? NSNumber)?.uint32Value == getuid() else { return false } + let environment = PiProcessEnvironment.readLinuxEnvironment(pid: pid, names: [Self.environmentKey]) + #endif + return environment?[Self.environmentKey] == self.marker + } + + /// Revalidate at each signal, including escalation: a cached PID is never authority to kill. + static func signal( + _ identity: TTYProcessTreeTerminator.ProcessIdentity, + _ signal: Int32, + owns: (pid_t) -> Bool, + isCurrent: (TTYProcessTreeTerminator.ProcessIdentity) -> Bool = TTYProcessTreeTerminator.isCurrent, + send: (pid_t, Int32) -> Void = { kill($0, $1) }) + { + guard owns(identity.pid), isCurrent(identity) else { return } + send(identity.pid, signal) + } +} diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index 26f3444240..6424f80658 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -153,6 +153,8 @@ public enum SubprocessRunner { standardInput: Any? = nil, currentDirectoryURL: URL? = nil, acceptsNonZeroExit: Bool = false, + // Mark this invocation so even detached descendants can be reaped after it exits. + reapDescendants: Bool = false, label: String) async throws -> SubprocessResult { guard FileManager.default.isExecutableFile(atPath: binary) else { @@ -161,6 +163,11 @@ public enum SubprocessRunner { let start = Date() let binaryName = URL(fileURLWithPath: binary).lastPathComponent + func logMetadata(duration: TimeInterval, exitCode: Int32? = nil) -> [String: String] { + var metadata = ["label": label, "binary": binaryName, "duration_ms": "\(Int(duration * 1000))"] + if let exitCode { metadata["status"] = "\(exitCode)" } + return metadata + } self.log.debug( "Subprocess start", metadata: ["label": label, "binary": binaryName, "timeout": "\(timeout)"]) @@ -168,7 +175,10 @@ public enum SubprocessRunner { let process = Process() process.executableURL = URL(fileURLWithPath: binary) process.arguments = arguments - process.environment = environment + let ownership = reapDescendants ? ProcessOwnershipReaper() : nil + process.environment = ownership + .map { environment.merging([ProcessOwnershipReaper.environmentKey: $0.marker]) { _, new in new } } ?? + environment process.currentDirectoryURL = currentDirectoryURL let stdoutPipe = Pipe() @@ -202,7 +212,8 @@ public enum SubprocessRunner { stderrCapture.start() let pid = process.processIdentifier - let processGroup: pid_t? = setpgid(pid, pid) == 0 ? pid : nil + let processGroup: pid_t? = setpgid(pid, pid) == 0 || getpgid(pid) == pid ? pid : nil + defer { ownership?.reap(processGroup: processGroup) } let exitCodeTask = Task { await termination.wait() @@ -244,11 +255,7 @@ public enum SubprocessRunner { if killedByTimeout.isSet { self.log.warning( "Subprocess timed out", - metadata: [ - "label": label, - "binary": binaryName, - "duration_ms": "\(Int(duration * 1000))", - ]) + metadata: logMetadata(duration: duration)) throw SubprocessRunnerError.timedOut(label) } @@ -271,33 +278,19 @@ public enum SubprocessRunner { let duration = Date().timeIntervalSince(start) self.log.warning( "Subprocess failed", - metadata: [ - "label": label, - "binary": binaryName, - "status": "\(exitCode)", - "duration_ms": "\(Int(duration * 1000))", - ]) + metadata: logMetadata(duration: duration, exitCode: exitCode)) throw SubprocessRunnerError.nonZeroExit(code: exitCode, stderr: stderr) } self.log.debug( "Subprocess exit", - metadata: [ - "label": label, - "binary": binaryName, - "status": "\(exitCode)", - "duration_ms": "\(Int(duration * 1000))", - ]) + metadata: logMetadata(duration: duration, exitCode: exitCode)) return SubprocessResult(stdout: stdout, stderr: stderr) } catch { let duration = Date().timeIntervalSince(start) self.log.warning( "Subprocess error", - metadata: [ - "label": label, - "binary": binaryName, - "duration_ms": "\(Int(duration * 1000))", - ]) + metadata: logMetadata(duration: duration)) // Safety net: ensure the process is dead (may already be killed by timeout timer). self.terminateProcess(process, processGroup: processGroup) exitCodeTask.cancel() 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/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/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/Sources/CodexBarCore/PiFamilySessionRootResolver.swift b/Sources/CodexBarCore/PiFamilySessionRootResolver.swift index 8b83992657..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,8 +389,12 @@ struct OMPSessionRootResolver: Sendable { return canonicalAgentRoot } - private static func canonicalURL(_ url: URL) -> URL { - url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL + 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 isDirectory(_ url: URL, fileManager: FileManager) -> Bool { diff --git a/Sources/CodexBarCore/PiFamilySessionScanner.swift b/Sources/CodexBarCore/PiFamilySessionScanner.swift index 47dfc85c12..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 @@ -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,10 +1330,6 @@ struct PiFamilySessionScanner: Sendable { return name.isEmpty ? nil : name } - private static func canonicalURL(_ url: URL) -> URL { - url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL - } - private static func isDirectFile(in file: URL, projectDirectory: URL) -> Bool { file.deletingLastPathComponent().standardizedFileURL.path == projectDirectory.path } diff --git a/Sources/CodexBarCore/PiProcessEnvironment.swift b/Sources/CodexBarCore/PiProcessEnvironment.swift index cac7e3fa9b..36d79943c5 100644 --- a/Sources/CodexBarCore/PiProcessEnvironment.swift +++ b/Sources/CodexBarCore/PiProcessEnvironment.swift @@ -18,16 +18,23 @@ enum PiProcessEnvironment { } } - static func parseNULSeparated(_ data: Data) -> [String: String]? { + static func parseNULSeparated( + _ data: Data, + names: Set = Self.selectorNames) -> [String: String]? + { guard data.count <= self.maxEnvironmentBytes, data.isEmpty || data.last == 0 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[.. [String: String]? + procRoot: URL = URL(fileURLWithPath: "/proc", isDirectory: true), + names: Set = Self.selectorNames) -> [String: String]? { guard pid > 0 else { return nil } let url = procRoot @@ -53,8 +61,8 @@ enum PiProcessEnvironment { var data = Data() while data.count <= self.maxEnvironmentBytes { let remaining = self.maxEnvironmentBytes + 1 - data.count - let chunk = try file.read(upToCount: min(16384, remaining)) ?? Data() - if chunk.isEmpty { return self.parseNULSeparated(data) } + let chunk = try file.read(upToCount: remaining) ?? Data() + if chunk.isEmpty { return self.parseNULSeparated(data, names: names) } data.append(chunk) } } catch { 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/Plugins/ProviderPluginCookieBroker.swift b/Sources/CodexBarCore/Plugins/ProviderPluginCookieBroker.swift index cbf918f7ae..28e96f46a9 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginCookieBroker.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginCookieBroker.swift @@ -11,23 +11,46 @@ public struct ProviderPluginCookieSession: Codable, Equatable, Sendable { public let source: String 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, source: String, origin: String, id: String = UUID().uuidString, - cachedAt: TimeInterval? = nil) + cachedAt: TimeInterval? = nil, + records: [ProviderPluginCookieRecord]? = nil, + headersByHost: [String: String]? = nil, + cacheKey: String? = nil, + permitsEmptyHosts: Set = []) { self.id = id self.header = header self.source = source 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() 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 } + 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") } return json @@ -37,6 +60,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 +81,46 @@ 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]? + private let persistent: ProviderPluginPersistentCookies? + private let policy: ProviderPluginCookiePolicy? convenience init( provider: UsageProvider, domains: Set, context: ProviderFetchContext, - importer: BatchImporter? = nil) + importer: BatchImporter? = nil, + usesCookieJar: Bool = false, + policy: ProviderPluginCookiePolicy? = nil, + settingsOverride: ProviderSettingsSnapshot.CookieProviderSettings? = nil) { + 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( + 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, + policy: policy, + background: ProviderInteractionContext.current != .userInitiated) } convenience init( @@ -92,12 +138,31 @@ final class ProviderPluginCookieBroker: @unchecked Sendable { provider: UsageProvider, domains: Set, settings: ProviderSettingsSnapshot.CookieProviderSettings, - batches: @escaping BatchImporter) + batches: @escaping BatchImporter, + 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 { @@ -107,6 +172,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") @@ -122,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, @@ -153,11 +233,32 @@ 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() } + 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), let header = CookieHeaderNormalizer.normalize(cached.cookieHeader) @@ -222,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 @@ -276,6 +378,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..d15d66b3c4 --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ProviderPluginCookieJar.swift @@ -0,0 +1,192 @@ +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 + } + + 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", + 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 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) } + } + + 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") } + 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 + } + 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.") + } + 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/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 1505b9592e..9c99b50d1a 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,30 @@ 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? + 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) 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..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,6 +86,11 @@ public struct ProviderPluginManifest: Sendable { public let settings: [ProviderPluginSetting] public let capabilities: Set public let cookieDomains: Set + 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() @@ -287,6 +301,29 @@ public struct ProviderPluginManifest: Sendable { "the browser-cookies capability requires at least one declared cookie domain") } self.cookieDomains = cookieDomains + 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("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.cookiePolicy = try ProviderPluginCookiePolicy( + policy, + domains: cookieDomains, + endpoints: self.endpoints) + } else { + self.cookiePolicy = nil + } } private static func requiredString(_ object: any ProviderPluginValue, property: String) throws -> String { 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 1d46000a95..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,17 @@ 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 + 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 @@ -581,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 @@ -748,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, @@ -762,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) @@ -772,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() { @@ -853,7 +901,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 +968,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 value in session.redactionValues { + redactionValues.insert(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/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 f12897480a..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]) : "" @@ -657,7 +658,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( @@ -677,12 +679,33 @@ 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") } 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 +718,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 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, let resolver = state.cookieResolver { @@ -722,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() @@ -735,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 b3541bb12f..0882ae7e20 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, + policy: runtime.manifest.cookiePolicy, + settingsOverride: self.cookieSettings?(context)) let result = try await runtime.fetchResult( settings: values.settings, secrets: values.secrets, @@ -137,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/ProcessEnvironment.swift b/Sources/CodexBarCore/ProcessEnvironment.swift new file mode 100644 index 0000000000..58ec3ece27 --- /dev/null +++ b/Sources/CodexBarCore/ProcessEnvironment.swift @@ -0,0 +1,31 @@ +/// Retains environment values for execution while keeping automatic diagnostics count-only. +@propertyWrapper +public struct ProcessEnvironment: Sendable, Equatable, + CustomReflectable, CustomStringConvertible, CustomDebugStringConvertible +{ + public var wrappedValue: Value + + 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.entryCount) entries; redacted)" + } + + public var debugDescription: String { + self.description + } + + public var customMirror: Mirror { + 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/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index 581e12368a..ea45670398 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -579,7 +579,11 @@ struct AntigravityCLIHTTPSFetchStrategy: ProviderFetchStrategy { 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 { + func run( + _ arguments: [String], + timeout: TimeInterval, + reapDescendants: Bool = false) async throws -> SubprocessResult + { try await SubprocessRunner.run( binary: binary, arguments: arguments, @@ -588,6 +592,7 @@ struct AntigravityCLIHTTPSFetchStrategy: ProviderFetchStrategy { maxOutputBytes: 1_048_576, standardInput: FileHandle.nullDevice, currentDirectoryURL: directory, + reapDescendants: reapDescendants, label: "antigravity-cli-usage") } let result: SubprocessResult @@ -597,7 +602,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, @@ -885,7 +892,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..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 @@ -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/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 e372c34360..33d1c13f46 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 } @@ -1366,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, @@ -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/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 48078fe5fc..1d31610fa0 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 @@ -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/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/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/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/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/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/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/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/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/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/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/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/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/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/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..e54cc094fe 100644 --- a/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts +++ b/Sources/CodexBarCore/Resources/Plugins/codexbar-plugin.d.ts @@ -1,10 +1,11 @@ -/** 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; + readonly cacheKey?: string; } type CodexBarJSONPrimitive = boolean | number | string | null; @@ -157,6 +158,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; @@ -229,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; @@ -287,6 +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" | "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/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/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 43d5179921..b5e1949423 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; @@ -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/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/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/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/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index c86e86b1fa..951a7e6cbb 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), @@ -1124,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/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/AdaptiveRefreshPerformanceTests.swift b/Tests/CodexBarTests/AdaptiveRefreshPerformanceTests.swift index 75c9adb26e..2e3f2914ec 100644 --- a/Tests/CodexBarTests/AdaptiveRefreshPerformanceTests.swift +++ b/Tests/CodexBarTests/AdaptiveRefreshPerformanceTests.swift @@ -28,7 +28,7 @@ private actor AdaptiveLocalScanSpy { @MainActor struct AdaptiveRefreshPerformanceTests { @Test - func `agent aware detection stays within the bounded scan budget`() async throws { + func `agent aware detection limits directory entry visits`() async throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("AdaptiveRefreshPerformanceTests-\(UUID().uuidString)", isDirectory: true) @@ -67,7 +67,6 @@ struct AdaptiveRefreshPerformanceTests { cwdProvider: { _, _ in [201: "/Users/test/Projects/alpha"] }, didVisitDirectoryEntry: { visits.increment() }) - let startedAt = ContinuousClock.now let sessions = await scanner.scan( now: now, environment: [ @@ -76,13 +75,8 @@ struct AdaptiveRefreshPerformanceTests { "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", ], includeFileOnlySessions: false) - let elapsed = startedAt.duration(to: .now) - #expect(!sessions.isEmpty) #expect(visits.count <= config.maxDirectoryEntryCount) - #expect( - elapsed < .milliseconds(250), - "Bounded agent scan exceeded 250 ms: \(elapsed), visited \(visits.count) entries") } @Test 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/AntigravityCLIUsageReportTests.swift b/Tests/CodexBarTests/AntigravityCLIUsageReportTests.swift index 99c617de54..e4a37c76d4 100644 --- a/Tests/CodexBarTests/AntigravityCLIUsageReportTests.swift +++ b/Tests/CodexBarTests/AntigravityCLIUsageReportTests.swift @@ -189,6 +189,7 @@ extension AntigravityCLIHTTPSFetchStrategyTests { let fixture = try Self.printExecutable(""" [ "$*" = '-p /usage --output-format json --print-timeout 90s' ] || exit 9 [ "$PWD" != "$HOME" ] || exit 10 + [ -n "$CODEXBAR_PROBE_OWNER" ] || exit 12 [ -z "${ANTIGRAVITY_OAUTH_CREDENTIALS_JSON+x}" ] || exit 11 /bin/cat <<'REPORT' \(report) 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/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/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/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/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/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/CodexLaunchPreflightAssessmentMemoTests.swift b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift new file mode 100644 index 0000000000..20227fe5c2 --- /dev/null +++ b/Tests/CodexBarTests/CodexLaunchPreflightAssessmentMemoTests.swift @@ -0,0 +1,521 @@ +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) + } + + /// 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 cannot lend its target's verdict`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + 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: unsigned) + let memo = Memo() + let calls = Counter() + let swap = { (destination: URL) in + try? FileManager.default.removeItem(at: link) + try? FileManager.default.createSymbolicLink(at: link, withDestinationURL: destination) + } + + // 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) + } + })) + // 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 cannot lend its verdict`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let manager = FileManager.default + 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(contents.utf8).write(to: bin.appendingPathComponent("codex")) + } + let current = fixture.root.appendingPathComponent("current") + try manager.createSymbolicLink(atPath: current.path, withDestinationPath: "release-1") + let codex = current.appendingPathComponent("bin/codex").path + let memo = Memo() + let calls = Counter() + + #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 `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() + 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) + }) + let second = memo.assessment(path: codex.path, isDefinitive: { _ in true }, assess: { _ in nil }) + + #expect(assessedPaths.count == 1) + #expect(assessedPaths.first?.hasPrefix("/.vol/") == true) + #expect(first?.output.hasPrefix("\(codex.path): rejected") == true) + #expect(second?.output == first?.output) + } + + @Test + func `launch decisions follow the memoized verdict to the final effect`() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let codex = try fixture.executable("codex", contents: "signed release") + let memo = Memo() + let calls = Counter() + var revoked = false + let decide = { (now: TimeInterval) -> 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) + } + + 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" + #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 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/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/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/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift b/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift index ce3409b851..d71b7e9787 100644 --- a/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift +++ b/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift @@ -49,7 +49,7 @@ struct DarwinProcessEnumeratorTests { #expect(DarwinProcessEnumerator.parseProcArgs2Arguments(data) == [ "/usr/local/bin/omp", "", "--profile", "work", ]) - #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(data) == [ + #expect(DarwinProcessEnumerator.parseProcArgs2Environment(data) == [ "HOME": "/synthetic/home", "OMP_PROFILE": "work", ]) #expect(DarwinProcessEnumerator.parseProcArgs2(data)?.contains("HOME=") == false) @@ -58,16 +58,16 @@ struct DarwinProcessEnumeratorTests { @Test func `proc args selector environment distinguishes omitted empty and truncated evidence`() { let empty = Self.procArgsData(arguments: ["pi"]) - #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(empty) == nil) + #expect(DarwinProcessEnumerator.parseProcArgs2Environment(empty) == nil) var paddedEmpty = empty paddedEmpty.append(contentsOf: [0, 0]) - #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(paddedEmpty) == nil) + #expect(DarwinProcessEnumerator.parseProcArgs2Environment(paddedEmpty) == nil) let knownEmpty = Self.procArgsData(arguments: ["pi"], environment: ["UNRELATED=value"]) - #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(knownEmpty) == [:]) + #expect(DarwinProcessEnumerator.parseProcArgs2Environment(knownEmpty) == [:]) var truncated = Self.procArgsData(arguments: ["pi"], environment: ["HOME=/synthetic/home"]) truncated.removeLast() #expect(DarwinProcessEnumerator.parseProcArgs2Arguments(truncated) == ["pi"]) - #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(truncated) == nil) + #expect(DarwinProcessEnumerator.parseProcArgs2Environment(truncated) == nil) } @Test @@ -75,7 +75,7 @@ struct DarwinProcessEnumeratorTests { var data = Self.procArgsData(arguments: ["pi"], environment: ["HOME=/synthetic/process"]) data.append(0) data.append(contentsOf: "HOME=/synthetic/apple-vector\0ptr_munge=ignored\0".utf8) - #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(data) == [ + #expect(DarwinProcessEnumerator.parseProcArgs2Environment(data) == [ "HOME": "/synthetic/process", ]) } diff --git a/Tests/CodexBarTests/DirectoryMetadataScanBudgetTests.swift b/Tests/CodexBarTests/DirectoryMetadataScanBudgetTests.swift index e43fe61335..f73f7020ad 100644 --- a/Tests/CodexBarTests/DirectoryMetadataScanBudgetTests.swift +++ b/Tests/CodexBarTests/DirectoryMetadataScanBudgetTests.swift @@ -58,8 +58,8 @@ struct DirectoryMetadataScanBudgetTests { #expect(results == ["first.jsonl", "last.jsonl"]) } - @Test - func `entry fetched after the deadline is not retained`() throws { + @Test(arguments: [0.149, 0.15, 0.151]) + func `adaptive entry retention honors the injected deadline`(elapsed: TimeInterval) throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("DirectoryMetadataScanBudgetTests-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } @@ -69,17 +69,17 @@ struct DirectoryMetadataScanBudgetTests { var budget = DirectoryMetadataScanBudget( maxEntryCount: 1, maxDepth: 1, - timeLimit: 1, + timeLimit: SessionScanConfig().adaptiveDirectoryScanBudget, startedAt: startedAt) var clockReads = 0 let files = budget.files(in: root, clock: { clockReads += 1 - return startedAt.addingTimeInterval(clockReads < 3 ? 0 : 2) + return startedAt.addingTimeInterval(clockReads < 3 ? 0 : elapsed) }) #expect(clockReads == 3) - #expect(files.isEmpty) + #expect(files.count == (elapsed < 0.15 ? 1 : 0)) } @Test(arguments: [false, true]) 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/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/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/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/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/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/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/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/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/PiSharedRootMergeTests.swift b/Tests/CodexBarTests/PiSharedRootMergeTests.swift index e50ea87a3a..1839181f12 100644 --- a/Tests/CodexBarTests/PiSharedRootMergeTests.swift +++ b/Tests/CodexBarTests/PiSharedRootMergeTests.swift @@ -71,6 +71,42 @@ 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) + #expect(PiFamilySessionScanner.costSessionRoots( + environment: environment, + baseDirectories: [env.root]).first { $0.url.path == missingRoot.path }?.url == missingCostRoot.url) + } + @Test func `shared pi and omp root keeps required process provenance`() throws { let env = try CostUsageTestEnvironment() 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 new file mode 100644 index 0000000000..ae203c456b --- /dev/null +++ b/Tests/CodexBarTests/ProcessEnvironmentTests.swift @@ -0,0 +1,163 @@ +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, + 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 { + 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/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 6f85ab46e4..2cf24015a8 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],", @@ -1505,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)", @@ -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\"])", @@ -3395,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", @@ -3468,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", @@ -3483,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/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/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/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/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/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/Tests/CodexBarTests/SubprocessRunnerTests.swift b/Tests/CodexBarTests/SubprocessRunnerTests.swift index d793fc773c..890f8ac399 100644 --- a/Tests/CodexBarTests/SubprocessRunnerTests.swift +++ b/Tests/CodexBarTests/SubprocessRunnerTests.swift @@ -369,4 +369,53 @@ 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) + } + + let 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) + } } 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/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/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/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/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/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/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/TestsLinux/ProcessOwnershipReaperTests.swift b/TestsLinux/ProcessOwnershipReaperTests.swift new file mode 100644 index 0000000000..9cbcd8b73b --- /dev/null +++ b/TestsLinux/ProcessOwnershipReaperTests.swift @@ -0,0 +1,191 @@ +import Foundation +import Testing +@testable import CodexBarCore +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + +struct ProcessOwnershipReaperTests { + @Test(arguments: ["success", "timeout", "cancellation", "failure"]) + func `probe reaps detached grandchild and preserves unrelated twin`(completion: String) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + let pidFile = root.appendingPathComponent("owned.pid") + let readyFile = pidFile.appendingPathExtension("ready") + let script = "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)" + let unrelated = Process() + unrelated.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + unrelated.arguments = ["-c", script] + unrelated.currentDirectoryURL = root + unrelated.environment = [:] + unrelated.standardOutput = FileHandle.nullDevice + unrelated.standardError = FileHandle.nullDevice + try unrelated.run() + defer { + if unrelated.isRunning { kill(unrelated.processIdentifier, SIGKILL) } + try? FileManager.default.removeItem(at: root) + } + // The intermediate session leader exits before the probe does. The grandchild has closed + // stdout/stderr, a different session, and no parent relationship left for a tree scan. + let launcher = """ + import os, subprocess, sys, time + subprocess.run([sys.executable, '-c', ''' + import os, subprocess, sys + child = subprocess.Popen([sys.executable, '-c', sys.argv[1]], + start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + with open(sys.argv[2], 'w') as f: f.write(str(child.pid)) + ''', sys.argv[1], sys.argv[2]], start_new_session=True) + with open(sys.argv[2] + '.ready', 'w') as handle: handle.write('ready') + time.sleep(0.2) + if sys.argv[3] in ('timeout', 'cancellation'): time.sleep(60) + if sys.argv[3] == 'failure': sys.exit(7) + print('usage-fixture') + """ + let task = Task { + try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", launcher, script, pidFile.path, completion], + environment: [:], + timeout: completion == "timeout" ? 10 : 30, + currentDirectoryURL: root, + reapDescendants: true, + label: "owned-probe-fixture") + } + defer { task.cancel() } + let readyDeadline = Date().addingTimeInterval(10) + while !FileManager.default.fileExists(atPath: readyFile.path), Date() < readyDeadline { + try await Task.sleep(for: .milliseconds(20)) + } + let text = try String(contentsOf: pidFile, encoding: .utf8) + let childPID = try #require(pid_t(text)) + let childIdentity = TTYProcessTreeTerminator.processIdentity(for: childPID) + defer { + if let childIdentity, TTYProcessTreeTerminator.isCurrent(childIdentity) { kill(childPID, SIGKILL) } + } + if completion == "cancellation" { task.cancel() } + do { + let result = try await task.value + #expect(completion == "success") + #expect(result.stdout == "usage-fixture\n") + } catch is CancellationError { + #expect(completion == "cancellation") + } catch let error as SubprocessRunnerError { + switch error { + case .timedOut: #expect(completion == "timeout") + case .nonZeroExit: #expect(completion == "failure") + default: throw error + } + } + let deadline = Date().addingTimeInterval(2) + while kill(childPID, 0) == 0, Date() < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(kill(childPID, 0) == -1) + #expect(unrelated.isRunning) + } + + @Test(arguments: [false, true]) + func `cleared environment cannot defeat timeout or cancellation`(cancel: Bool) async throws { + let start = Date() + let task = Task { + try await SubprocessRunner.run( + binary: "/usr/bin/env", + arguments: ["-i", "/bin/sleep", "30"], + environment: [:], + timeout: cancel ? 60 : 0.2, + reapDescendants: true, + label: "cleared-marker-fixture") + } + if cancel { + try await Task.sleep(for: .milliseconds(200)) + task.cancel() + } + do { + _ = try await task.value + Issue.record("Expected timeout or cancellation") + } catch is CancellationError { + #expect(cancel) + } catch let error as SubprocessRunnerError { + guard case .timedOut = error else { throw error } + #expect(!cancel) + } + #expect(Date().timeIntervalSince(start) < 10) + } + + @Test + func `signals reject reused PIDs and lost markers before escalation`() { + let identity = TTYProcessTreeTerminator.ProcessIdentity(pid: 42, startToken: 1) + var marked = true + var current = true + var sent: [Int32] = [] + func signal(_ value: Int32) { + ProcessOwnershipReaper.signal( + identity, + value, + owns: { _ in marked }, + isCurrent: { _ in current }, + send: { _, value in sent.append(value) }) + } + signal(SIGTERM) + marked = false + signal(SIGKILL) + #expect(sent == [SIGTERM]) + marked = true + current = false + signal(SIGKILL) + #expect(sent == [SIGTERM]) + } + + @Test + func `Linux environment reader selects the marker and rejects unavailable evidence`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let process = root.appendingPathComponent("101", isDirectory: true) + try FileManager.default.createDirectory(at: process, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = process.appendingPathComponent("environ") + let key = ProcessOwnershipReaper.environmentKey + let names: Set = [key] + try Data("OTHER=ignored\0\(key)=fixture\0".utf8).write(to: file) + #expect(PiProcessEnvironment.readLinuxEnvironment(pid: 101, procRoot: root, names: names) == [key: "fixture"]) + try Data("\(key)=fixture".utf8).write(to: file) + #expect(PiProcessEnvironment.readLinuxEnvironment(pid: 101, procRoot: root, names: names) == nil) + #expect(PiProcessEnvironment.readLinuxEnvironment(pid: 102, procRoot: root, names: names) == nil) + } + + @Test + func `marker parsing requires an exact environment entry`() { + let key = ProcessOwnershipReaper.environmentKey + let names: Set = [key] + #expect(PiProcessEnvironment.parseNULSeparated(Data("\(key)=fixture\0".utf8), names: names)?[key] == "fixture") + #expect(PiProcessEnvironment.parseNULSeparated(Data("OTHER=\(key)=fixture\0".utf8), names: names) == [:]) + #expect(PiProcessEnvironment.parseNULSeparated(Data("\(key)=fixture".utf8), names: names) == nil) + #expect(PiProcessEnvironment.parseNULSeparated( + Data("\(key)=fixture\0\(key)=different\0".utf8), names: names) == nil) + var argc: Int32 = 2 + var data = withUnsafeBytes(of: &argc) { Data($0) } + data.append(Data("/fixture\0\0fixture\0\(key)=argument-only\0OTHER=ok\0\0\(key)=apple-vector\0".utf8)) + #expect(DarwinProcessEnumerator.parseProcArgs2Environment(data, names: names) == [:]) + var empty = withUnsafeBytes(of: &argc) { Data($0) } + empty.append(Data("/fixture\0\0fixture\0arg\0\0\(key)=apple-vector\0".utf8)) + #expect(DarwinProcessEnumerator.parseProcArgs2Environment(empty, names: names) == nil) + } + + @Test(arguments: [0, 64, 128]) + func `marker parsing preserves boundaries around large unrelated values`(position: Int) { + let key = ProcessOwnershipReaper.environmentKey + let names: Set = [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/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/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 new file mode 100644 index 0000000000..656f03c74b --- /dev/null +++ b/TestsPlugin/ProviderPluginCookieJarTests.swift @@ -0,0 +1,253 @@ +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) + + #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 [ + 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 = 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")) + 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/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/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/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/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/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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ac2424d371..63f6f2cd57 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 @@ -159,6 +172,29 @@ 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 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 (including Rosetta). Plugin typechecking uses only its declared libraries and source declarations, so unrelated 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. 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. diff --git a/docs/antigravity.md b/docs/antigravity.md index c148eee0a5..5fe62b8d57 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -41,6 +41,12 @@ or later before using print mode; [Google introduced non-interactive usage repor It requires a successful `usage` command report with known, enabled quota buckets, bounds the command to 90 seconds and its output to 1 MiB, and terminates the command on cancellation. It runs in a private empty directory and does not send a model prompt or parse TUI output. +Each print probe receives a fresh `CODEXBAR_PROBE_OWNER` environment marker. On completion, timeout, or +cancellation, CodexBar stops its process group and reaps same-user processes that still carry that exact marker, +including detached MCP servers. Ownership and process start identity are checked again before each signal; +unreadable environments are skipped. Cleanup never selects a process by name, executable, or working directory. +Children that deliberately discard the inherited environment cannot be identified by this safety net. The installed +`agy` 1.2.11 `--help` offers MCP configuration commands but no per-probe switch to disable MCP startup. The report contains no account or plan identity: explicit CLI mode remains authoritative, while Auto uses 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. @@ -74,12 +80,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 +132,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 +277,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 +291,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: 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. diff --git a/docs/claude.md b/docs/claude.md index 5a725a2a40..7121bbf926 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. @@ -420,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. 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/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 9781303711..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. @@ -285,7 +291,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, 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 { 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 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. diff --git a/docs/kimi.md b/docs/kimi.md index 836591b816..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 @@ -82,8 +83,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. 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/mistral.md b/docs/mistral.md index 4b6ddb535d..db87055087 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. 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/pi.md b/docs/pi.md index 2f6a30dea3..54b0305ed2 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. diff --git a/docs/plugin-conversion-matrix.md b/docs/plugin-conversion-matrix.md index a687294a50..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` | 30 | +| `cut-over` | 33 | | `converted` | 0 | | `convertible-now` | 0 | -| `needs-cookie-import` | 7 | +| `needs-cookie-import` | 4 | | `needs-files/subprocess/oauth-broker` | 20 | | `needs-pty/webview/native` | 8 | | `needs-host-extension` | 4 | @@ -126,14 +126,14 @@ 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. | | 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 0bc47a7882..1865406648 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -412,10 +412,66 @@ 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. 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 +`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. 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. 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. 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. 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. 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" 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. 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. diff --git a/version.env b/version.env index 9cc9eda19a..810a2b018a 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.68.1 -BUILD_NUMBER=160 +MARKETING_VERSION=0.69.1 +BUILD_NUMBER=161