diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..662e2e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Install system test dependencies + run: sudo apt-get update && sudo apt-get install -y fzf jq + + - name: Install uv + run: python -m pip install --disable-pip-version-check uv==0.9.5 + + - name: Check shell syntax + run: bash -n bin/codex bin/codex-auth install.sh lib/codex-auth/*.sh tests/run.sh + + - name: Run shell tests + run: bash tests/run.sh + + - name: Sync locked TUI environment + run: uv sync --project tui --locked + + - name: Run TUI tests + run: uv run --project tui pytest -q diff --git a/.gitignore b/.gitignore index bffc725..d0e6649 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,16 @@ .DS_Store *.log *.tmp +*.pyc +.pytest_cache/ +.venv/ +__pycache__/ tmp/ +/reports/ +/assets/.frames/ +/node_modules/ +/auth.json +/auth-profiles/ +/auth-backups/ +/auth-state.json +/active-profile.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a442b87 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## 0.1.0 - 2026-07-10 + +- Added the persistent Textual watch and autoswitch UI. +- Added safe capture of the current Codex login as a named profile. +- Added per-profile earned reset counts and confirmed reset redemption. +- Added generation-bound in-session auth reload support for patched Codex. +- Added automatic patch rebuilds after official curl-installed Codex updates. +- Added token-rotation lineage checks, atomic profile writes, and idempotent reset retries. +- Fixed auto mode treating an invalid or slow unrelated profile as a failed full-pool refresh. +- Added deterministic synthetic screenshots, GIF/video demos, and Linux CI. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..3b833db --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,34 @@ +# Product + +## Register + +product + +## Users + +Codex CLI users who keep several saved ChatGPT auth profiles and need to see quota, choose an account, or leave automatic rotation running from a terminal. The primary user is already in an active coding workflow and needs the auth tool to stay understandable without becoming another process tree to babysit. + +## Product Purpose + +Codex Rolling Auth keeps the live Codex auth file on a usable saved profile. Success means one persistent screen shows current quota and freshness, manual switching is deliberate, and live automatic switching can run with an inspectable reason and no stale-cache guesswork. + +## Brand Personality + +Quiet, direct, trustworthy. The interaction reference is `claude-swap`: low chrome, dense readable usage rows, literal state labels, and keyboard behavior that disappears into the task. + +## Anti-references + +Do not recreate the one-shot `fzf` selector as a full-screen app. Avoid decorative terminal effects, nested panels, ambiguous color-only status, surprise credential changes, generic dashboard copy, and controls whose live or dry-run state is unclear. + +## Design Principles + +1. Show the actual state first: active profile, quota, automatic reset time, earned reset count, data age, and reload readiness. +2. Make safety visible: passive watch, dry-run auto, and live auto must look and behave differently. +3. Keep one source of truth: the TUI renders structured snapshots and typed engine decisions rather than duplicating policy. +4. Explain every switch: show the chosen profile, the comparison, and the reason in plain language. +5. Stay terminal-native: keyboard-first, compact, stable at narrow widths, and useful without animation. +6. Treat earned resets as scarce: refresh before offering one, default confirmation to Cancel, never combine redemption with a hidden profile switch, and preserve idempotency across retries. + +## Accessibility & Inclusion + +Every action is available from the keyboard. Selection, active state, errors, stale data, and live mode use text or symbols in addition to color. Focus is explicit only when selection is armed. Layout must remain readable in narrow terminals, and state changes must not depend on decorative motion. diff --git a/README.md b/README.md index f4b6d13..7a51861 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,60 @@ # Codex Rolling Auth -Small shell wrapper for switching Codex to the best saved ChatGPT auth profile before a session starts. +Small shell wrapper for switching Codex to the best saved ChatGPT auth profile before or during a session. It keeps `auth.json` pointed at the best available profile before a session starts. The optional `codex` shim only does a cached auth selection and then execs the real Codex binary, so normal Codex launches do not create nested runners, live log monitors, or extra MCP/app-server sidecars. -![Codex rolling auth selector](assets/usage-selector.png) +[![CI](https://github.com/editnori/codex-rolling-auth/actions/workflows/ci.yml/badge.svg)](https://github.com/editnori/codex-rolling-auth/actions/workflows/ci.yml) -![Codex rolling auth selector scroll](assets/usage-selector.gif) +![Codex Auth watch screen with synthetic work and backup profiles](assets/codex-auth-watch.png) + +_Synthetic demo data. No live credentials or account identifiers._ + +
+Watch the keyboard flow + +![Codex Auth TUI demo showing profile switching and earned reset confirmation](assets/codex-auth-demo.gif) + +
+ +| Dry-run autoswitch | Earned reset confirmation | +| --- | --- | +| ![Dry-run autoswitch screen](assets/codex-auth-auto.png) | ![Earned reset confirmation](assets/codex-auth-reset.png) | + +All captures use the real Textual app with an in-memory synthetic backend. + +## Requirements + +- Linux or WSL with Bash, `jq`, `flock`, Git, and the official Codex CLI. +- [`uv`](https://docs.astral.sh/uv/) for the isolated Textual environment. +- `crontab` is optional; without it, run `codex-auth maintain` after a direct curl update. +- Building the patched Codex generation also needs a Rust/Cargo toolchain and normal native build dependencies. ## Install +If Codex came from the official curl installer, install or update Codex first. That installer owns the visible `~/.local/bin/codex` link. `./install.sh --wrap-codex` installs a one-minute maintenance job that restores the rolling-auth shim after a later curl update and queues the matching patch build. + +```bash +curl -fsSL https://chatgpt.com/codex/install.sh | sh +``` + ```bash git clone https://github.com/editnori/codex-rolling-auth.git cd codex-rolling-auth ./install.sh --wrap-codex ``` +The wrapper resolves the native binary through `$CODEX_HOME/packages/standalone/current/bin/codex` (and the legacy `current/codex` layout), so it follows the curl installer's current release instead of falling back to an older Bun/npm copy. + That installs: - `codex-auth`, the profile manager and rolling runner +- `codex-auth-tui`, the full-screen watcher in a private project `.venv` - `codex`, an optional shim that runs `codex-auth auto --quiet --no-background` and then starts the real Codex binary -- patched-Codex selection: when a matching patched binary exists, the shim uses it with in-process rolling auth enabled; when Codex updates, the shim falls back to stock Codex +- patched-Codex selection: when a matching generation exists, the shim uses it with in-process rolling auth enabled; when Codex updates, the shim immediately runs stock Codex and starts one detached patch build +- a marked, idempotent cron entry that runs `codex-auth maintain --quiet` once per minute without replacing existing cron jobs + +The installer uses `uv` with the platform's native TLS store to create the TUI environment under `PREFIX/lib/codex-auth/tui/.venv`. It does not install Python packages globally. Packaging and isolated tests can copy the project without resolving dependencies by setting `CODEX_AUTH_TUI_SKIP_BOOTSTRAP=1`; normal installs should leave bootstrap enabled. If you only want the manager and not the `codex` shim: @@ -30,18 +64,79 @@ If you only want the manager and not the `codex` shim: ## Usage +Open the persistent account monitor without changing auth: + +```bash +codex-auth watch +``` + +Run the autoswitch policy in dry-run mode. It refreshes and shows decisions but does not switch accounts: + +```bash +codex-auth watch --auto +``` + +Allow the watcher to apply decisions through a generation-bound compare-and-switch transaction: + +```bash +codex-auth watch --auto --live +``` + +`codex-auth tui` is an alias for `codex-auth watch`. `--threshold 0` means proactively prefer any strictly better ready account. In that mode cooldown is the anti-flap guard; hysteresis applies when the threshold is above zero. + +Each auto tick trusts only usage returned by that refresh generation. A profile that needs a new sign-in is marked unavailable instead of keeping an old bar, and an unrelated unavailable profile does not block fresh profiles from being evaluated. If the active session itself is invalidated, live auto can recover to a fresh ready profile through the same generation-bound switch guard. + +Watcher keys: + +- `s` arms or disarms manual selection. +- `n` saves the current Codex auth as a named profile. +- `u` checks and uses an earned rate-limit reset for a selected profile. +- Arrow keys or `j`/`k` move between accounts. +- `Enter` confirms an armed switch and keeps the watcher open. +- `Esc` disarms first, then goes back or quits. +- `a` opens the autoswitch view. +- `l` toggles LIVE; entering live asks for confirmation, while returning to dry-run is immediate. +- `r` forces a refresh. +- `q` goes back or quits. + +Live mode changes `auth.json`, so new Codex processes use the selected account immediately. A stock Codex process that is already running does not hot-reload that file. In-session switching requires a patched Codex build that matches the installed native binary: + +```bash +codex-auth patch-codex +codex-auth patch-codex --status +``` + +The automatic builder requires the exact `rust-v` source tag. It never stamps `origin/main` as a release match. The patch reloads a deliberate account change before Codex can refresh the old account's token, including the unauthorized-recovery path that normally reports that you logged out or signed in to another account. If the installed Codex release changes or a build fails, the shim stays on stock Codex until that exact generation is ready. + Save the current login as a profile: ```bash -codex-auth add Layth08 --current +codex-auth add work --current +``` + +From `codex-auth tui`, press `n`, enter the profile name, and press `Enter`. +Replacing an existing profile requires confirmation. + +### Earned resets + +When Codex reports an earned reset bank, each profile card shows its authoritative remaining count separately from the automatic `5h` and weekly reset countdowns. Press `u`, select a ChatGPT profile, and confirm. The TUI refreshes that profile before confirmation, uses one reset through Codex app-server, then refreshes the usage bars and remaining count. It does **not** switch the active profile. + +The equivalent explicit CLI command requires confirmation: + +```bash +codex-auth reset work --yes ``` +Ambiguous transport retries reuse the same idempotency key, so retrying cannot spend a second reset. `alreadyRedeemed` is treated as success; `nothingToReset` and `noCredit` do not pretend a reset happened. See the official [Codex app-server earned reset contract](https://learn.chatgpt.com/docs/app-server#8-earned-rate-limit-resets-chatgpt). + Open the usage selector: ```bash codex-auth usage --refresh --select ``` +![Inline usage selector with synthetic profiles](assets/usage-selector.png) + Run a rolling Codex session explicitly: ```bash @@ -78,6 +173,12 @@ Build the patched Codex binary in the foreground: codex-auth patch-codex ``` +Reconcile the curl-installed command and queue a missing generation manually: + +```bash +codex-auth maintain +``` + Terminate only the direct MCP sidecars under legacy `--yolo` Codex processes: ```bash @@ -88,24 +189,45 @@ codex-auth doctor --kill-sidecars --yes - Profiles live under `$CODEX_HOME/auth-profiles` by default. - The live auth file stays at `$CODEX_HOME/auth.json`. -- `bin/codex-auth` is a thin entrypoint. Runtime code lives in `lib/codex-auth/*.sh` by area: core, profiles, usage, render, selector, patch, run, and doctor. +- Saved profiles contain live credentials. Files are written with restrictive permissions, but they must never be committed, uploaded, pasted into issues, or included in captures. +- `$CODEX_HOME/active-profile.json` records the selected profile using hashed account identity and a credential fingerprint. When Codex rotates that account's refresh token, the wrapper compare-and-swaps the newer auth back into the same saved profile; a real account change or concurrent edit is never overwritten. +- Usage probes run in temporary Codex homes. If a probe rotates a refresh token, the rotated auth is persisted before the temporary home is removed and is attached to usage state only after the profile lineage check succeeds. +- `bin/codex-auth` is a thin entrypoint. Shell runtime code lives in `lib/codex-auth/*.sh`; the persistent watcher lives in the isolated project under `lib/codex-auth/tui` after installation. - Set `CODEX_AUTH_CODEX_BIN=/path/to/codex` if the wrapper cannot find your real Codex binary. - Set `CODEX_AUTH_AUTO=0` to bypass automatic profile selection for one command. - `codex-auth run` is explicit opt-in. It can monitor a bounded session log to retry after usage-limit errors, but the normal `codex` shim does not use it. - Set `CODEX_AUTH_ROLL_WATCH=1` to enable in-session periodic profile checks for `codex-auth run`; it is off by default to avoid background sidecar churn. - Set `CODEX_AUTH_ROLL_LIVE_MONITOR=0` to disable live log monitoring for `codex-auth run`. - Set `CODEX_AUTH_PATCH_AUTO=0` to stop the `codex` shim from using or building patched Codex. -- Set `CODEX_AUTH_PATCH_BUILD_AUTO=1` if you want the `codex` shim to start a background rebuild when the patched binary is stale. -- Patched Codex builds live under `$CODEX_HOME/patched-codex`. The shim only uses a patched binary when its marker matches the current stock Codex version/hash. +- Background rebuilds are on by default. Set `CODEX_AUTH_PATCH_BUILD_AUTO=0` to use an existing matching patch without building a missing one. +- Patched binaries live under `$CODEX_HOME/patched-codex/generations//codex`. Each marker and binary are published together as one immutable directory, and the shim never selects a stale generation. +- The builder hashes a stock binary once in the detached worker, reuses a shared Cargo target, strips release debug data, and retains two patched/source generations by default. Set `CODEX_AUTH_PATCH_KEEP_GENERATIONS` to change retention. +- Build failures back off for 15 minutes by default while stock Codex remains usable. Set `CODEX_AUTH_PATCH_RETRY_SECS` to change the retry window. +- The maintenance cron waits for the official standalone installer lock, restores the shim only when the curl installer owns the visible command, and queues the new generation. Set `CODEX_AUTH_INSTALL_MAINTAIN_CRON=0` during installation to opt out. - Patched builds are stamped as the installed Codex version plus `+local`, so they do not appear older to Codex's update check. -- Set `CODEX_AUTH_REFRESH_JOBS` to tune concurrent usage refreshes. The default is 4 and `CODEX_AUTH_REFRESH_JOBS_MAX` caps it at 4 unless you explicitly change the cap. +- Set `CODEX_AUTH_REFRESH_JOBS` to tune concurrent usage refreshes. The default is 4 and `CODEX_AUTH_REFRESH_JOBS_MAX` caps it at 4 unless you explicitly change the cap; `usage --sync` temporarily raises the default up to the profile count, capped at 12. - `codex-auth doctor --kill-sidecars --yes` does not kill the Codex TUI processes; it only terminates direct MCP sidecars under legacy `--yolo` Codex roots. - Set `CODEX_AUTH_USAGE_HEADER=1` or `CODEX_AUTH_USAGE_STATUS=1` if you want the full table header or status column back. - The selector defaults to the inline fzf scrolling TUI when fzf is available. It uses adaptive height instead of taking over the whole terminal. Set `CODEX_AUTH_NO_FZF=1` or `CODEX_AUTH_SELECTOR=numbered` for the plain numbered fallback. -- `codex-auth usage --refresh --select` opens the selector from cached usage and refreshes in the background. Set `CODEX_AUTH_SELECT_SYNC_REFRESH=1` or pass `--sync` if you want to wait for a full refresh before the selector opens. +- `codex-auth usage --sync` refreshes usage first, then opens the TUI. `codex-auth usage --refresh --select` opens the TUI from cache right away and refreshes in the background. - Set `CODEX_AUTH_SELECTOR_CENTER=1` if you want the fallback selector vertically centered in the terminal. - Selector bars default to background-color lanes with thin horizontal row borders. Set `CODEX_AUTH_SELECTOR_BAR_STYLE=glyph` if your terminal does not render those cleanly. +## Remove the wrapper + +Remove the marked maintenance block first so it cannot restore the shim, reinstall stock Codex, then remove the manager files: + +```bash +crontab -l 2>/dev/null \ + | awk '$0 == "# BEGIN codex-auth maintain" {skip=1; next} $0 == "# END codex-auth maintain" {skip=0; next} !skip' \ + | crontab - +curl -fsSL https://chatgpt.com/codex/install.sh | sh +rm -f ~/.local/bin/codex-auth ~/.local/bin/codex-auth-tui ~/.local/bin/codex-real +rm -rf ~/.local/lib/codex-auth +``` + +This intentionally leaves `$CODEX_HOME/auth-profiles` and other account state in place. + ## Test ```bash @@ -114,6 +236,12 @@ tests/run.sh ## Regenerate Assets +Media generation is development-only and uses synthetic in-memory profiles. It requires Fontconfig, FFmpeg, ImageMagick, and the locked Playwright/Pillow dependencies: + ```bash +uv sync --project tui --dev --locked +uv run --project tui playwright install chromium +uv run --project tui python scripts/capture_tui.py +uv run --project tui python scripts/capture_tui.py --check node scripts/generate-assets.mjs ``` diff --git a/assets/codex-auth-auto.png b/assets/codex-auth-auto.png new file mode 100644 index 0000000..dcd39b2 Binary files /dev/null and b/assets/codex-auth-auto.png differ diff --git a/assets/codex-auth-demo.gif b/assets/codex-auth-demo.gif new file mode 100644 index 0000000..617c1af Binary files /dev/null and b/assets/codex-auth-demo.gif differ diff --git a/assets/codex-auth-demo.mp4 b/assets/codex-auth-demo.mp4 new file mode 100644 index 0000000..ec222a0 Binary files /dev/null and b/assets/codex-auth-demo.mp4 differ diff --git a/assets/codex-auth-reset.png b/assets/codex-auth-reset.png new file mode 100644 index 0000000..10bc5f4 Binary files /dev/null and b/assets/codex-auth-reset.png differ diff --git a/assets/codex-auth-watch.png b/assets/codex-auth-watch.png new file mode 100644 index 0000000..5f36d0e Binary files /dev/null and b/assets/codex-auth-watch.png differ diff --git a/assets/usage-selector.gif b/assets/usage-selector.gif index 0b6d11f..82bb24e 100644 Binary files a/assets/usage-selector.gif and b/assets/usage-selector.gif differ diff --git a/assets/usage-selector.png b/assets/usage-selector.png index dd27ec6..9281421 100644 Binary files a/assets/usage-selector.png and b/assets/usage-selector.png differ diff --git a/assets/usage-selector.svg b/assets/usage-selector.svg index 8414d23..2ea584f 100644 --- a/assets/usage-selector.svg +++ b/assets/usage-selector.svg @@ -27,7 +27,7 @@ filter stay -Layth08 +work 43 @@ -40,7 +40,7 @@ ready use -Layth48 +backup 76 @@ -53,7 +53,7 @@ ready use -Layth18 +personal 64 @@ -66,7 +66,7 @@ ready cap -Layth38 +lab 0 @@ -78,7 +78,7 @@ old cap cap -Layth +spare-1 0 @@ -90,7 +90,7 @@ cap cap -Layth.qassem +spare-2 0 @@ -102,7 +102,7 @@ cap cap -Layth28 +spare-3 0 diff --git a/bin/codex b/bin/codex index 260cbce..29e7788 100755 --- a/bin/codex +++ b/bin/codex @@ -3,37 +3,74 @@ set -euo pipefail CODEX_AUTH_SHIM=1 SHIM_PATH="$(realpath "$0" 2>/dev/null || printf '%s\n' "$0")" +CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" + +is_codex_auth_shim() { + local path="$1" + + [[ -r "$path" ]] || return 1 + [[ "$(LC_ALL=C head -c 2 "$path" 2>/dev/null || true)" == '#!' ]] || return 1 + LC_ALL=C head -c 8192 "$path" 2>/dev/null | grep -aEq 'CODEX_AUTH_SHIM|codex-auth (run|auto)' +} + +print_real_codex_candidate() { + local candidate="$1" + local candidate_path + + [[ -x "$candidate" ]] || return 1 + candidate_path="$(realpath "$candidate" 2>/dev/null || printf '%s' "$candidate")" + [[ "$candidate_path" != "$SHIM_PATH" ]] || return 1 + is_codex_auth_shim "$candidate" && return 1 + printf '%s\n' "$candidate" +} find_real_codex() { - local wrapper_dir path_dir candidate candidate_path - local candidates=() + local wrapper_dir path_dir candidate index + local path_candidates=() + local fallback_candidates=() + local backups=() local path_dirs=() - [[ -n "${CODEX_AUTH_CODEX_BIN:-}" ]] && candidates+=("$CODEX_AUTH_CODEX_BIN") wrapper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" - candidates+=( \ - "$wrapper_dir/codex-real" \ - "$HOME/.bun/bin/codex-real" \ - "$HOME/.bun/bin/codex" \ - "$HOME/.npm-global/bin/codex" \ - /usr/local/bin/codex \ - /usr/bin/codex \ - /bin/codex \ - "$wrapper_dir"/codex.backup.* - ) + + if [[ -n "${CODEX_AUTH_CODEX_BIN:-}" ]]; then + print_real_codex_candidate "$CODEX_AUTH_CODEX_BIN" && return 0 + fi + + for candidate in \ + "$CODEX_HOME_DIR/packages/standalone/current/bin/codex" \ + "$CODEX_HOME_DIR/packages/standalone/current/codex" \ + "$wrapper_dir/codex-real" + do + print_real_codex_candidate "$candidate" && return 0 + done IFS=':' read -r -a path_dirs <<<"${PATH:-}" for path_dir in "${path_dirs[@]}"; do [[ -n "$path_dir" ]] || path_dir='.' - candidates+=("$path_dir/codex") + path_candidates+=("$path_dir/codex") + done + for candidate in "${path_candidates[@]}"; do + print_real_codex_candidate "$candidate" && return 0 + done + + fallback_candidates=( + "$HOME/.bun/bin/codex-real" + "$HOME/.bun/bin/codex" + "$HOME/.npm-global/bin/codex" + /usr/local/bin/codex + /usr/bin/codex + /bin/codex + ) + for candidate in "${fallback_candidates[@]}"; do + print_real_codex_candidate "$candidate" && return 0 done - for candidate in "${candidates[@]}"; do - [[ -x "$candidate" ]] || continue - candidate_path="$(realpath "$candidate" 2>/dev/null || printf '%s' "$candidate")" - [[ "$candidate_path" == "$SHIM_PATH" ]] && continue - printf '%s\n' "$candidate" - return 0 + shopt -s nullglob + backups=("$wrapper_dir"/codex.backup.*) + shopt -u nullglob + for (( index=${#backups[@]} - 1; index >= 0; index-- )); do + print_real_codex_candidate "${backups[index]}" && return 0 done return 1 @@ -55,23 +92,82 @@ case "${1:-}" in ;; esac +# The standalone updater owns the visible `codex` entrypoint and can replace +# this shim while it runs. Keep an inode-local copy, run the real updater, and +# put the shim back only if the updater actually replaced it. The next launch +# will therefore see the new stock binary and either select its matching patch +# or start a non-blocking rebuild. +if [[ "${1:-}" == "update" ]]; then + shim_dir="$(cd "$(dirname "$SHIM_PATH")" && pwd -P)" + shim_copy="$(mktemp "$shim_dir/.codex-auth-shim.XXXXXX")" + cp "$SHIM_PATH" "$shim_copy" + chmod 0755 "$shim_copy" + restore_update_shim() { + [[ -n "${shim_copy:-}" && -e "$shim_copy" ]] || return 0 + if ! is_codex_auth_shim "$SHIM_PATH"; then + if ! mv -Tf "$shim_copy" "$SHIM_PATH" 2>/dev/null; then + rm -f "$SHIM_PATH" + mv -f "$shim_copy" "$SHIM_PATH" + fi + else + rm -f "$shim_copy" + fi + shim_copy="" + } + trap restore_update_shim EXIT + set +e + env CODEX_AUTH_RUNNER=1 CODEX_AUTH_CODEX_BIN="$real_codex" "$real_codex" "$@" + update_status=$? + set -e + restore_update_shim + trap - EXIT + if (( update_status == 0 )) \ + && [[ "${CODEX_AUTH_PATCH_AUTO:-1}" != "0" ]] \ + && [[ "${CODEX_AUTH_PATCH_BUILD_AUTO:-1}" != "0" ]] \ + && command -v codex-auth >/dev/null 2>&1; then + post_update_stock="" + for candidate in \ + "$CODEX_HOME_DIR/packages/standalone/current/bin/codex" \ + "$CODEX_HOME_DIR/packages/standalone/current/codex" + do + if [[ -x "$candidate" ]]; then + post_update_stock="$candidate" + break + fi + done + [[ -n "$post_update_stock" ]] || post_update_stock="$real_codex" + CODEX_AUTH_STOCK_CODEX_BIN="$post_update_stock" codex-auth patch-codex --print-bin --background --quiet >/dev/null 2>&1 || true + fi + exit "$update_status" +fi + if (( wrap_session )) && [[ "${CODEX_AUTH_AUTO:-1}" != "0" && "${CODEX_AUTH_RUNNER:-0}" != "1" ]] && command -v codex-auth >/dev/null 2>&1; then CODEX_AUTH_CODEX_BIN="$real_codex" CODEX_AUTH_NO_BACKGROUND=1 codex-auth auto --quiet --no-background || true fi if (( wrap_session )) && [[ "${CODEX_AUTH_YOLO:-1}" != "0" ]]; then - case "${1:-}" in - --yolo|--dangerously-bypass-approvals-and-sandbox|--dangerously-auto-approve-everything) ;; - *) codex_args=(--yolo "$@") ;; - esac + has_yolo=0 + for arg in "$@"; do + case "$arg" in + --yolo|--dangerously-bypass-approvals-and-sandbox|--dangerously-auto-approve-everything) + has_yolo=1 + break + ;; + esac + done + (( has_yolo )) || codex_args=(--yolo "$@") fi if (( wrap_session )) && [[ "${CODEX_AUTH_PATCH_AUTO:-1}" != "0" ]] && command -v codex-auth >/dev/null 2>&1; then - if patched_codex="$(CODEX_AUTH_STOCK_CODEX_BIN="$real_codex" codex-auth patch-codex --print-bin --quiet 2>/dev/null)" && [[ -n "$patched_codex" ]]; then + # This single command either returns the exact matching patched binary or + # starts one detached build and returns immediately with no path. Startup + # never waits for Cargo, and a stale patched binary is never selected. + patch_background_args=() + [[ "${CODEX_AUTH_PATCH_BUILD_AUTO:-1}" == "0" ]] || patch_background_args=(--background) + patched_codex="$(CODEX_AUTH_STOCK_CODEX_BIN="$real_codex" codex-auth patch-codex --print-bin "${patch_background_args[@]}" --quiet 2>/dev/null || true)" + if [[ -n "$patched_codex" ]]; then exec_codex="$patched_codex" export CODEX_AUTH_ROLLING="${CODEX_AUTH_ROLLING:-1}" - elif [[ "${CODEX_AUTH_PATCH_BUILD_AUTO:-0}" != "0" ]]; then - CODEX_AUTH_STOCK_CODEX_BIN="$real_codex" codex-auth patch-codex --background --quiet >/dev/null 2>&1 || true fi fi diff --git a/bin/codex-auth b/bin/codex-auth index 9b91cfa..3ff7a0b 100755 --- a/bin/codex-auth +++ b/bin/codex-auth @@ -8,6 +8,7 @@ AUTH_FILE="$CODEX_HOME/auth.json" PROFILE_DIR="${CODEX_AUTH_PROFILES_DIR:-$CODEX_HOME/auth-profiles}" BACKUP_DIR="$CODEX_HOME/auth-backups" STATE_FILE="${CODEX_AUTH_STATE_FILE:-$CODEX_HOME/auth-state.json}" +ACTIVE_PROFILE_FILE="${CODEX_AUTH_ACTIVE_PROFILE_FILE:-$CODEX_HOME/active-profile.json}" CACHE_TTL="${CODEX_AUTH_CACHE_TTL:-900}" CODEX_BIN="${CODEX_AUTH_CODEX_BIN:-}" USAGE_VERBOSE=0 @@ -133,6 +134,12 @@ fast_patch_codex_bin() { printf '%s\n' "$CODEX_AUTH_STOCK_CODEX_BIN" elif [[ -n "$CODEX_BIN" && -x "$CODEX_BIN" ]]; then printf '%s\n' "$CODEX_BIN" + elif [[ -x "$CODEX_HOME/packages/standalone/current/bin/codex" ]]; then + printf '%s\n' "$CODEX_HOME/packages/standalone/current/bin/codex" + elif [[ -x "$CODEX_HOME/packages/standalone/current/codex" ]]; then + printf '%s\n' "$CODEX_HOME/packages/standalone/current/codex" + elif [[ -x "$CODEX_AUTH_SCRIPT_DIR/codex-real" ]]; then + printf '%s\n' "$CODEX_AUTH_SCRIPT_DIR/codex-real" elif [[ -x "$HOME/.bun/bin/codex-real" ]]; then printf '%s\n' "$HOME/.bun/bin/codex-real" elif [[ -x "$HOME/.bun/bin/codex" ]]; then @@ -144,10 +151,16 @@ fast_patch_codex_bin() { fast_patch_canonical_codex_bin() { local codex_cli="$1" - local line target + local line target line_count=0 [[ -r "$codex_cli" ]] || { printf '%s\n' "$codex_cli"; return 0; } + if [[ "$(LC_ALL=C head -c 2 "$codex_cli" 2>/dev/null || true)" != '#!' ]]; then + printf '%s\n' "$codex_cli" + return 0 + fi while IFS= read -r line; do + line_count=$((line_count + 1)) + (( line_count <= 32 )) || break [[ "$line" == exec\ * ]] || [[ "$line" == " exec "* ]] || continue target="${line#*exec }" target="${target%% \"\$@\"*}" @@ -176,7 +189,7 @@ fast_patch_marker_value() { } fast_patch_codex() { - local print_key=0 print_bin=0 quiet=0 arg stock_bin stock_version hash_line stock_hash checksum size stock_key marker root marker_key marker_patch_version + local print_key=0 print_bin=0 quiet=0 arg stock_bin stock_real stock_version stock_stat hash_line checksum size stock_key marker root marker_key marker_patch_version patched_bin for arg in "$@"; do case "$arg" in @@ -192,33 +205,71 @@ fast_patch_codex() { return 1 } stock_bin="$(fast_patch_canonical_codex_bin "$stock_bin")" + stock_real="$(realpath "$stock_bin" 2>/dev/null || printf '%s' "$stock_bin")" stock_version="$("$stock_bin" --version 2>/dev/null || true)" stock_version="${stock_version%%$'\n'*}" stock_version="${stock_version%"${stock_version##*[![:space:]]}"}" [[ -n "$stock_version" ]] || stock_version="unknown" - if command -v sha256sum >/dev/null 2>&1 && [[ -f "$stock_bin" ]]; then - hash_line="$(sha256sum "$stock_bin")"; stock_hash="${hash_line%% *}" - hash_line="$(printf '%s\037%s\n' "$stock_version" "$stock_hash" | sha256sum)"; stock_key="${hash_line%% *}" + if command -v stat >/dev/null 2>&1; then + stock_stat="$(stat -Lc '%d:%i:%s:%y:%z' "$stock_bin" 2>/dev/null || true)" + fi + [[ -n "${stock_stat:-}" ]] || stock_stat="$(wc -c < "$stock_bin" 2>/dev/null || printf 'unknown')" + if command -v sha256sum >/dev/null 2>&1; then + hash_line="$(printf 'layout=1\037%s\037%s\037%s\n' "$stock_real" "$stock_version" "$stock_stat" | sha256sum)" + stock_key="${hash_line%% *}" else - read -r checksum size _ <<<"$(printf '%s' "$stock_bin" | cksum)"; stock_hash="$checksum-$size" - read -r checksum size _ <<<"$(printf '%s\037%s\n' "$stock_version" "$stock_hash" | cksum)"; stock_key="$checksum-$size" + read -r checksum size _ <<<"$(printf 'layout=1\037%s\037%s\037%s\n' "$stock_real" "$stock_version" "$stock_stat" | cksum)" + stock_key="$checksum-$size" fi if (( print_key )); then printf '%s\n' "$stock_key" return 0 fi root="${CODEX_AUTH_PATCH_CODEX_DIR:-$CODEX_HOME/patched-codex}" - marker="$root/current.env" + marker="$root/generations/$stock_key/current.env" + patched_bin="$root/generations/$stock_key/codex" + if [[ ! -f "$marker" ]]; then + marker="$root/current.env" + patched_bin="$root/bin/codex" + fi marker_key="$(fast_patch_marker_value "$marker" stock_key)" marker_patch_version="$(fast_patch_marker_value "$marker" patch_version)" - [[ "$marker_key" == "$stock_key" && "$marker_patch_version" == "1" && -x "$root/bin/codex" ]] || return 1 - printf '%s/bin/codex\n' "$root" + [[ "$marker_key" == "$stock_key" && "$marker_patch_version" == "2" && -x "$patched_bin" ]] || return 1 + printf '%s\n' "$patched_bin" +} + +start_tui_patch_check() { + local stock_bin + + [[ "${CODEX_AUTH_TUI_PATCH_AUTO:-1}" != "0" ]] || return 0 + stock_bin="$(fast_patch_codex_bin 2>/dev/null)" || return 0 + + # Keep version/hash discovery off the TUI startup path. patch-codex owns the + # detached build, its log, and its non-blocking lock; this process only + # dispatches that existing background mechanism. + nohup env \ + CODEX_AUTH_STOCK_CODEX_BIN="$stock_bin" \ + CODEX_AUTH_TUI_PATCH_AUTO=0 \ + "$CODEX_AUTH_SELF" patch-codex --background --quiet \ + /dev/null 2>&1 & } main() { local command="${1:-}" shift || true + if [[ "$command" == "watch" || "$command" == "tui" ]]; then + local tui_launcher="${CODEX_AUTH_TUI_BIN:-$CODEX_AUTH_SCRIPT_DIR/codex-auth-tui}" + if [[ "$tui_launcher" == */* && ! -x "$tui_launcher" ]]; then + printf 'codex-auth TUI launcher not found: %s\n' "$tui_launcher" >&2 + printf 'Reinstall from a complete checkout with: ./install.sh\n' >&2 + return 1 + fi + start_tui_patch_check + export CODEX_AUTH_BIN="${CODEX_AUTH_BIN:-$CODEX_AUTH_SELF}" + exec "$tui_launcher" "$@" + fi + if [[ "$command" == "paths" && "$#" == 0 && "${CODEX_AUTH_FAST_PATHS:-1}" != "0" ]]; then fast_paths return 0 @@ -235,12 +286,13 @@ main() { source_codex_auth_libs "${CODEX_AUTH_BASE_LIB_FILES[@]}" case "$command" in add) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; cmd_add "$@" ;; - usage|limits) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}"; cmd_usage "$@" ;; + usage|limits) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}" profiles.sh; cmd_usage "$@" ;; list|ls) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; cmd_list "$@" ;; current|status) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; cmd_current "$@" ;; save) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; require_arg_count_between "$#" 1 2 "usage: codex-auth save [file]"; cmd_save "$@" ;; import) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; require_arg_count_between "$#" 2 2 "usage: codex-auth import "; cmd_save "$1" "$2" "imported $1" ;; use|switch) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; require_arg_count_between "$#" 1 1 "usage: codex-auth use "; cmd_use "$1" ;; + use-if-current) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; require_arg_count_between "$#" 2 3 "usage: codex-auth use-if-current [refresh-generation]"; cmd_use_if_current "$@" ;; login) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; require_arg_count_between "$#" 1 999999 "usage: codex-auth login [codex login args...]"; cmd_login "$@" ;; auto) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}" profiles.sh; cmd_auto "$@" ;; patch-codex) @@ -254,14 +306,16 @@ main() { esac cmd_patch_codex "$@" ;; + maintain) source_codex_auth_libs patch.sh maintain.sh; cmd_maintain "$@" ;; run|wrap) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}" profiles.sh run.sh; cmd_run "$@" ;; recover) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}" profiles.sh run.sh; cmd_recover "$@" ;; - refresh) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}"; cmd_refresh "$@" ;; + refresh) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}" profiles.sh; cmd_refresh "$@" ;; + reset|use-reset) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}" profiles.sh; require_arg_count_between "$#" 2 2 "usage: codex-auth reset --yes"; cmd_reset "$@" ;; doctor) source_codex_auth_libs doctor.sh; cmd_doctor "$@" ;; export) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; require_arg_count_between "$#" 2 2 "usage: codex-auth export "; cmd_export "$@" ;; remove|rm) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; require_arg_count_between "$#" 1 2 "usage: codex-auth remove --yes"; cmd_remove "$@" ;; paths) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; cmd_paths ;; - -h|--help|help|'') source_codex_auth_libs "${CODEX_AUTH_UI_LIB_FILES[@]}" help.sh; usage ;; + -h|--help|help|'') source_codex_auth_libs "${CODEX_AUTH_UI_LIB_FILES[@]}" help.sh; usage "$@" ;; *) source_codex_auth_libs "${CODEX_AUTH_UI_LIB_FILES[@]}"; die "unknown command: $command. Use: codex-auth help" ;; esac } diff --git a/bin/codex-auth-tui b/bin/codex-auth-tui new file mode 100755 index 0000000..32ff5f4 --- /dev/null +++ b/bin/codex-auth-tui @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +launcher_path="${BASH_SOURCE[0]}" +case "$launcher_path" in + /*) ;; + */*) launcher_path="$PWD/${launcher_path#./}" ;; + *) launcher_path="$(command -v "$launcher_path" 2>/dev/null || printf '%s\n' "$launcher_path")" ;; +esac +launcher_dir="${launcher_path%/*}" +[[ "$launcher_dir" == "$launcher_path" ]] && launcher_dir="." + +if [[ -n "${CODEX_AUTH_TUI_BIN:-}" ]]; then + exec "$CODEX_AUTH_TUI_BIN" "$@" +fi + +project_dir="" +for candidate in \ + "$launcher_dir/../tui" \ + "$launcher_dir/../lib/codex-auth/tui" +do + if [[ -f "$candidate/pyproject.toml" && -d "$candidate/src/codex_auth_tui" ]]; then + project_dir="$(cd "$candidate" && pwd -P)" + break + fi +done + +if [[ -z "$project_dir" ]]; then + printf 'codex-auth TUI project not found next to %s\n' "$launcher_path" >&2 + printf 'Reinstall from a complete checkout with: ./install.sh\n' >&2 + exit 1 +fi + +if [[ -x "$project_dir/.venv/bin/python" ]]; then + if PYTHONDONTWRITEBYTECODE=1 "$project_dir/.venv/bin/python" -c 'import codex_auth_tui, textual' >/dev/null 2>&1; then + if [[ -x "$project_dir/.venv/bin/codex-auth-tui" ]]; then + exec "$project_dir/.venv/bin/codex-auth-tui" "$@" + fi + exec "$project_dir/.venv/bin/python" -m codex_auth_tui.cli "$@" + fi +fi + +if command -v uv >/dev/null 2>&1; then + printf 'codex-auth TUI environment is missing; preparing %s with uv\n' "$project_dir" >&2 + exec uv --native-tls run --project "$project_dir" --no-dev --locked codex-auth-tui "$@" +fi + +printf 'codex-auth TUI dependencies are not installed and uv was not found.\n' >&2 +printf 'Install uv, then rerun ./install.sh (or run uv sync --project %s --no-dev).\n' "$project_dir" >&2 +exit 1 diff --git a/install.sh b/install.sh index a5a609e..f05e30e 100755 --- a/install.sh +++ b/install.sh @@ -5,27 +5,104 @@ prefix="${PREFIX:-$HOME/.local}" bindir="$prefix/bin" libdir="$prefix/lib/codex-auth" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +codex_home="${CODEX_HOME:-$HOME/.codex}" +standalone_root="$codex_home/packages/standalone" +tui_source="$repo_dir/tui" +tui_dir="$libdir/tui" mkdir -p "$bindir" "$libdir" +if [[ ! -f "$tui_source/pyproject.toml" || ! -f "$tui_source/uv.lock" || ! -d "$tui_source/src/codex_auth_tui" ]]; then + printf 'codex-auth TUI project is missing from %s\n' "$tui_source" >&2 + exit 1 +fi + +# Stage the complete TUI, publish it at its final absolute path, and bootstrap +# there before publishing any shell files. uv environments are not +# relocatable; the rollback trap restores the previous TUI if sync fails. +install_stage="$(mktemp -d "$libdir/.codex-auth-install.XXXXXX")" +tui_stage="$install_stage/tui" +tui_previous="" +tui_published=0 +install_complete=0 +mkdir -p "$tui_stage" + +cleanup_install_stage() { + if [[ "$install_complete" != "1" && "$tui_published" == "1" ]]; then + rm -rf "$tui_dir" + if [[ -n "$tui_previous" && -e "$tui_previous" ]]; then + mv "$tui_previous" "$tui_dir" 2>/dev/null || true + fi + fi + [[ ! -e "$install_stage" ]] || rm -rf "$install_stage" +} +trap cleanup_install_stage EXIT + +for tui_file in README.md pyproject.toml uv.lock; do + install -m 0644 "$tui_source/$tui_file" "$tui_stage/$tui_file" +done +while IFS= read -r -d '' tui_file; do + tui_relative="${tui_file#$tui_source/}" + mkdir -p "$tui_stage/${tui_relative%/*}" + install -m 0644 "$tui_file" "$tui_stage/$tui_relative" +done < <(find "$tui_source/src" -type f \( -name '*.py' -o -name '*.tcss' \) -print0) + +if [[ -e "$tui_dir" ]]; then + tui_previous="$install_stage/tui.previous" + mv "$tui_dir" "$tui_previous" +fi +mv "$tui_stage" "$tui_dir" +tui_published=1 + +if [[ "${CODEX_AUTH_TUI_SKIP_BOOTSTRAP:-0}" != "1" ]]; then + if ! command -v uv >/dev/null 2>&1; then + printf 'uv is required to create the private codex-auth TUI environment.\n' >&2 + printf 'Install uv and rerun ./install.sh. No Python packages are installed globally.\n' >&2 + exit 1 + fi + if ! uv --native-tls sync --project "$tui_dir" --no-dev --locked; then + exit 1 + fi +fi + install -m 0755 "$repo_dir/bin/codex-auth" "$bindir/codex-auth" +install -m 0755 "$repo_dir/bin/codex-auth-tui" "$bindir/codex-auth-tui" +install -m 0755 "$repo_dir/bin/codex" "$libdir/codex-shim" install -m 0644 "$repo_dir/lib/codex-auth/"*.sh "$libdir/" +shopt -s nullglob +codex_auth_patch_files=("$repo_dir/lib/codex-auth/"*.patch) +shopt -u nullglob +if (( ${#codex_auth_patch_files[@]} > 0 )); then + install -m 0644 "${codex_auth_patch_files[@]}" "$libdir/" +fi +[[ -z "$tui_previous" ]] || rm -rf "$tui_previous" +install_complete=1 +rm -rf "$install_stage" +trap - EXIT is_codex_auth_shim() { local path="$1" - [[ -f "$path" ]] || return 1 - grep -Eq 'CODEX_AUTH_SHIM|codex-auth (run|auto)' "$path" 2>/dev/null + [[ -r "$path" ]] || return 1 + [[ "$(LC_ALL=C head -c 2 "$path" 2>/dev/null || true)" == '#!' ]] || return 1 + LC_ALL=C head -c 8192 "$path" 2>/dev/null | grep -aEq 'CODEX_AUTH_SHIM|codex-auth (run|auto)' } write_codex_real_launcher() { local target="$1" local real="$2" + local tmp - { + tmp="$(mktemp "${real%/*}/.codex-real.XXXXXX")" + if ! { printf '#!/usr/bin/env bash\n' printf 'set -euo pipefail\n' printf 'exec %q "$@"\n' "$target" - } > "$real" - chmod 0755 "$real" + } > "$tmp" \ + || ! chmod 0755 "$tmp" \ + || ! mv -f "$tmp" "$real" + then + rm -f "$tmp" + return 1 + fi } promote_real_candidate() { @@ -37,7 +114,14 @@ promote_real_candidate() { [[ -x "$candidate" ]] || return 1 [[ "$(realpath "$candidate" 2>/dev/null || printf '%s' "$candidate")" == "$(realpath "$current" 2>/dev/null || printf '%s' "$current")" ]] && return 1 is_codex_auth_shim "$candidate" && return 1 - target="$(realpath "$candidate" 2>/dev/null || printf '%s' "$candidate")" + case "$candidate" in + "$standalone_root/current/bin/codex"|"$standalone_root/current/codex") + target="$candidate" + ;; + *) + target="$(realpath "$candidate" 2>/dev/null || printf '%s' "$candidate")" + ;; + esac write_codex_real_launcher "$target" "$real" } @@ -53,9 +137,14 @@ promote_real_backup() { promote_real_backups() { local real="$1" - local backup + local backup index + local backups=() - for backup in "$bindir"/codex.backup.*; do + shopt -s nullglob + backups=("$bindir"/codex.backup.*) + shopt -u nullglob + for (( index=${#backups[@]} - 1; index >= 0; index-- )); do + backup="${backups[index]}" promote_real_backup "$real" "$backup" && return 0 done return 1 @@ -92,9 +181,13 @@ promote_real_codex() { local current="$bindir/codex" local real="$bindir/codex-real" - [[ -x "$real" ]] && return 0 - + promote_listed_real_candidates "$current" "$real" \ + "$standalone_root/current/bin/codex" \ + "$standalone_root/current/codex" && return 0 promote_real_backup "$real" "$current" && return 0 + if [[ -x "$real" ]] && ! is_codex_auth_shim "$real"; then + return 0 + fi promote_real_backups "$real" && return 0 promote_listed_real_candidates "$current" "$real" \ "$HOME/.bun/bin/codex-real" \ @@ -107,15 +200,55 @@ promote_real_codex() { /bin/codex && return 0 } +install_maintenance_cron() { + local begin_marker='# BEGIN codex-auth maintain' + local end_marker='# END codex-auth maintain' + local current filtered updated cron_path cron_command + + [[ "${CODEX_AUTH_INSTALL_MAINTAIN_CRON:-1}" != "0" ]] || return 0 + if ! command -v crontab >/dev/null 2>&1; then + printf 'warning: crontab is unavailable; automatic direct-curl recovery was not installed\n' >&2 + return 0 + fi + current="$(mktemp "$libdir/.cron-current.XXXXXX")" + filtered="$(mktemp "$libdir/.cron-filtered.XXXXXX")" + updated="$(mktemp "$libdir/.cron-updated.XXXXXX")" + crontab -l > "$current" 2>/dev/null || : + awk -v begin="$begin_marker" -v end="$end_marker" ' + $0 == begin { skip = 1; next } + $0 == end { skip = 0; next } + !skip { print } + ' "$current" > "$filtered" + cron_path="$HOME/.cargo/bin:$bindir:/usr/local/bin:/usr/bin:/bin" + cron_command="* * * * * PATH='$cron_path' '$bindir/codex-auth' maintain --quiet" + { + cat "$filtered" + [[ ! -s "$filtered" ]] || printf '\n' + printf '%s\n%s\n%s\n' "$begin_marker" "$cron_command" "$end_marker" + } > "$updated" + if ! crontab "$updated"; then + printf 'warning: could not install codex-auth maintenance cron job\n' >&2 + fi + rm -f "$current" "$filtered" "$updated" +} + if [[ "${1:-}" == "--wrap-codex" ]]; then promote_real_codex if [[ -e "$bindir/codex" ]] && ! is_codex_auth_shim "$bindir/codex"; then - cp -L "$bindir/codex" "$bindir/codex.backup.$(date +%Y%m%d%H%M%S)" + codex_backup="$bindir/codex.backup.$(date +%Y%m%d%H%M%S)" + if [[ -L "$bindir/codex" ]]; then + cp -P "$bindir/codex" "$codex_backup" + else + cp -L "$bindir/codex" "$codex_backup" + fi fi + rm -f "$bindir/codex" install -m 0755 "$repo_dir/bin/codex" "$bindir/codex" + install_maintenance_cron fi printf 'installed %s\n' "$bindir/codex-auth" +printf 'installed %s\n' "$bindir/codex-auth-tui" if [[ "${1:-}" == "--wrap-codex" ]]; then printf 'installed %s\n' "$bindir/codex" if [[ -x "$bindir/codex-real" ]]; then @@ -123,6 +256,8 @@ if [[ "${1:-}" == "--wrap-codex" ]]; then else printf 'warning: no existing codex binary was captured as %s\n' "$bindir/codex-real" >&2 fi + printf 'enable in-session switching with: codex-auth patch-codex\n' else + printf 'watch with: codex-auth watch\n' printf 'run with: codex-auth run -- resume --last\n' fi diff --git a/lib/codex-auth/core.sh b/lib/codex-auth/core.sh index 6a55ffc..776139c 100644 --- a/lib/codex-auth/core.sh +++ b/lib/codex-auth/core.sh @@ -51,6 +51,12 @@ require_arg_count_between() { codex_bin() { if [[ -n "$CODEX_BIN" && -x "$CODEX_BIN" ]]; then printf '%s\n' "$CODEX_BIN" + elif [[ -x "$CODEX_HOME/packages/standalone/current/bin/codex" ]]; then + printf '%s\n' "$CODEX_HOME/packages/standalone/current/bin/codex" + elif [[ -x "$CODEX_HOME/packages/standalone/current/codex" ]]; then + printf '%s\n' "$CODEX_HOME/packages/standalone/current/codex" + elif [[ -n "${CODEX_AUTH_SCRIPT_DIR:-}" && -x "$CODEX_AUTH_SCRIPT_DIR/codex-real" ]]; then + printf '%s\n' "$CODEX_AUTH_SCRIPT_DIR/codex-real" elif [[ -x "$HOME/.bun/bin/codex-real" ]]; then printf '%s\n' "$HOME/.bun/bin/codex-real" elif [[ -x "$HOME/.bun/bin/codex" ]]; then @@ -60,11 +66,23 @@ codex_bin() { fi } +codex_launcher_is_script() { + local codex_cli="$1" + + [[ -r "$codex_cli" ]] || return 1 + [[ "$(LC_ALL=C head -c 2 "$codex_cli" 2>/dev/null || true)" == '#!' ]] +} + canonical_codex_bin() { local codex_cli="$1" local target - target="$(sed -nE 's/^[[:space:]]*exec[[:space:]]+"?([^" ]+)"?[[:space:]]+"\$@".*$/\1/p' "$codex_cli" 2>/dev/null | head -n 1 || true)" + if ! codex_launcher_is_script "$codex_cli"; then + printf '%s\n' "$codex_cli" + return 0 + fi + + target="$(sed -nE '1,32s/^[[:space:]]*exec[[:space:]]+"?([^" ]+)"?[[:space:]]+"\$@".*$/\1/p' "$codex_cli" 2>/dev/null | head -n 1 || true)" if [[ -n "$target" ]]; then if [[ "$target" != /* ]]; then target="$(cd "$(dirname "$codex_cli")" && realpath "$target" 2>/dev/null || printf '%s/%s\n' "$(pwd -P)" "$target")" @@ -82,7 +100,7 @@ codex_launcher_needs_node() { local codex_cli="$1" local first_line="" - [[ -r "$codex_cli" ]] || return 1 + codex_launcher_is_script "$codex_cli" || return 1 IFS= read -r first_line < "$codex_cli" || return 1 [[ "$first_line" == "#!"*"env node"* ]] } @@ -95,6 +113,20 @@ require_codex_launcher() { fi } +normalize_codex_status_text() { + local text="$1" line normalized="" + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" == "WARNING: proceeding, even though we could not update PATH:"* ]] && continue + line="${line//$'\t'/ }" + normalized+=" $line" + done <<<"$text" + while [[ "$normalized" == *" "* ]]; do normalized="${normalized// / }"; done + while [[ "$normalized" == " "* ]]; do normalized="${normalized# }"; done + while [[ "$normalized" == *" " ]]; do normalized="${normalized% }"; done + printf '%s\n' "$normalized" +} + ensure_dirs() { mkdir -p "$CODEX_HOME" "$PROFILE_DIR" "$BACKUP_DIR" "$CODEX_HOME/.tmp" chmod 700 "$CODEX_HOME" "$PROFILE_DIR" "$BACKUP_DIR" "$CODEX_HOME/.tmp" 2>/dev/null || true @@ -131,6 +163,65 @@ profile_path() { printf '%s/%s.json\n' "$PROFILE_DIR" "$name" } +active_profile_marker_read() { + [[ -f "$ACTIVE_PROFILE_FILE" ]] || return 1 + command -v jq >/dev/null 2>&1 || return 1 + jq -er ' + select(.version == 2) + | .profile + | select(type == "string" and test("^[A-Za-z0-9._-]+$")) + ' "$ACTIVE_PROFILE_FILE" 2>/dev/null +} + +active_profile_marker_write() { + local name="$1" source="${2:-}" tmp kind identity fingerprint revision + + require_name "$name" + ensure_dirs + [[ -n "$source" ]] || source="$(profile_path "$name")" + require_auth_file "$source" + command -v jq >/dev/null 2>&1 || return 1 + kind="$(auth_file_kind "$source" || true)" + identity="$(auth_file_account_identity "$source" || true)" + fingerprint="$(credential_fingerprint "$source" || true)" + revision="$(auth_file_revision "$source" || true)" + [[ -n "$fingerprint" && -n "$revision" ]] || return 1 + tmp="$(mktemp "$CODEX_HOME/.tmp/auth-active-profile.XXXXXX")" + if ! jq -n \ + --arg profile "$name" \ + --arg kind "$kind" \ + --arg account_identity "$identity" \ + --arg profile_fingerprint "$fingerprint" \ + --arg profile_revision "$revision" \ + '{version: 2, profile: $profile, kind: $kind, account_identity: $account_identity, profile_fingerprint: $profile_fingerprint, profile_revision: $profile_revision}' \ + > "$tmp" \ + || ! chmod 600 "$tmp" \ + || ! mv -f "$tmp" "$ACTIVE_PROFILE_FILE" + then + rm -f "$tmp" + return 1 + fi +} + +active_profile_marker_field() { + local field="$1" + + [[ "$field" =~ ^(profile|kind|account_identity|profile_fingerprint|profile_revision)$ ]] || return 1 + [[ -f "$ACTIVE_PROFILE_FILE" ]] || return 1 + command -v jq >/dev/null 2>&1 || return 1 + jq -er --arg field "$field" 'select(.version == 2) | .[$field] | select(type == "string")' "$ACTIVE_PROFILE_FILE" 2>/dev/null +} + +active_profile_marker_clear() { + local expected="${1:-}" current="" + + if [[ -n "$expected" ]]; then + current="$(active_profile_marker_read || true)" + [[ "$current" == "$expected" ]] || return 0 + fi + rm -f "$ACTIVE_PROFILE_FILE" +} + require_auth_file() { local path="$1" [[ -f "$path" ]] || die "auth file not found: $path" @@ -187,6 +278,57 @@ credential_fingerprint() { printf '%s\n' "${hash%% *}" } +auth_file_revision() { + local path="$1" hash + + [[ -f "$path" ]] || return 1 + command -v jq >/dev/null 2>&1 || return 1 + jq -e . "$path" >/dev/null 2>&1 || return 1 + hash="$(jq -cS . "$path" 2>/dev/null | sha256sum)" || return 1 + printf '%s\n' "${hash%% *}" +} + +auth_file_kind() { + local path="$1" + + [[ -f "$path" ]] || return 1 + command -v jq >/dev/null 2>&1 || return 1 + jq -r ' + if (((.OPENAI_API_KEY? // null) | type) == "string" and ((.OPENAI_API_KEY | length) > 0)) then "api_key" + elif (.tokens | type == "object") then "chatgpt" + else "unknown" + end + ' "$path" 2>/dev/null +} + +auth_file_account_identity() { + local path="$1" identity hash + + [[ -f "$path" ]] || return 1 + command -v jq >/dev/null 2>&1 || return 1 + identity="$(jq -r ' + def jwt_payload: + split(".") as $parts + | if ($parts | length) < 2 then null + else ($parts[1] | gsub("-"; "+") | gsub("_"; "/") | @base64d | fromjson?) + end; + . as $root + | ($root.tokens.id_token // $root.tokens.access_token // "") as $jwt + | ($jwt | jwt_payload) as $claims + | ($claims["https://api.openai.com/auth"] // {}) as $auth + | [ + ($auth.chatgpt_account_id // $root.tokens.account_id // ""), + ($auth.chatgpt_user_id // $auth.user_id // $claims.sub // "") + ] + | map(if type == "string" then gsub("[[:space:]]"; "") else "" end) + | select(length == 2 and all(.[]; length > 0)) + | join("\u001f") + ' "$path" 2>/dev/null || true)" + [[ -n "$identity" ]] || return 1 + hash="$(printf '%s\n' "$identity" | sha256sum)" + printf '%s\n' "${hash%% *}" +} + auth_metadata_records() { if (( $# == 0 )); then return 0 @@ -301,4 +443,3 @@ auth_mode_display_label() { ;; esac } - diff --git a/lib/codex-auth/help.sh b/lib/codex-auth/help.sh index 3f85cbc..ccfbe2e 100644 --- a/lib/codex-auth/help.sh +++ b/lib/codex-auth/help.sh @@ -1,7 +1,18 @@ # shellcheck shell=bash usage() { - local width path path_w + local width path path_w show_all=0 arg + for arg in "$@"; do + case "$arg" in + --all|-a|all) + show_all=1 + ;; + *) + die "usage: codex-auth help [--all]" + ;; + esac + done + width="$(terminal_width)" USAGE_COLOR_ENABLED=0 color_enabled && USAGE_COLOR_ENABLED=1 @@ -14,8 +25,42 @@ usage() { elif (( width >= 22 )); then print_flat_context_line "profiles auth-profiles" "$width" fi + + if (( show_all )); then + usage_all "$width" + return 0 + fi + + print_help_section "main" "$width" + print_help_item "watch" "Passive monitor" "$width" + print_help_item "watch --auto" "Dry-run autoswitch" "$width" + print_help_item "watch --auto --live" "Live autoswitch" "$width" + print_help_item "usage --sync" "Fresh TUI" "$width" + print_help_item "usage --refresh --select" "Fast TUI" "$width" + print_help_item "usage --cached --select" "Cached TUI" "$width" + print_help_item "current" "Active auth" "$width" + print_help_item "list" "Saved profiles" "$width" + print_help_section "profiles" "$width" + print_help_item "add " "Browser login" "$width" + print_help_item "use " "Select profile" "$width" + print_help_item "add --current" "Save current auth" "$width" + print_help_item "reset --yes" "Use earned reset" "$width" + print_help_item "remove --yes" "Delete profile" "$width" + print_help_section "more" "$width" + print_help_item "help --all" "Full command list" "$width" + printf '\n' +} + +usage_all() { + local width="$1" + print_help_section "daily" "$width" - print_help_item "usage --refresh --select" "Refresh and select" "$width" + print_help_item "watch" "Passive monitor" "$width" + print_help_item "watch --auto" "Dry-run autoswitch" "$width" + print_help_item "watch --auto --live" "Live autoswitch" "$width" + print_help_item "usage --sync" "Fresh TUI" "$width" + print_help_item "usage --refresh --select" "Fast TUI" "$width" + print_help_item "usage --cached --select" "Cached TUI" "$width" print_help_item "usage --cached" "Cached usage" "$width" print_help_item "usage --refresh -v" "Wide usage" "$width" print_help_item "add " "Browser login" "$width" @@ -30,8 +75,10 @@ usage() { print_help_item "login " "Login profile" "$width" print_help_section "maintenance" "$width" print_help_item "refresh" "Refresh usage" "$width" + print_help_item "reset --yes" "Use earned reset" "$width" print_help_item "auto" "Select best profile" "$width" print_help_item "patch-codex" "Build patched Codex" "$width" + print_help_item "maintain" "Repair shim + queue patch" "$width" print_help_item "recover [session]" "Resume with best" "$width" print_help_item "run -- " "Rolling session" "$width" print_help_item "remove --yes" "Delete profile" "$width" @@ -58,7 +105,13 @@ print_help_item() { local action action_tone action_w command_w desc_w line gap row pattern token first local tokens=() local action_rows=( + "watch"$'\t'"watch" + "watch --auto"$'\t'"dry" + "watch --auto --live"$'\t'"live" + "usage --sync"$'\t'"sync" "usage --refresh --select"$'\t'"select" + "usage --refresh --sync --select"$'\t'"sync" + "usage --cached --select"$'\t'"cache" "usage --cached"$'\t'"cache" "usage --refresh -v"$'\t'"wide" "add "$'\t'"new" @@ -72,6 +125,7 @@ print_help_item() { "import*"$'\t'"load" "login "$'\t'"auth" "refresh"$'\t'"sync" + "reset --yes"$'\t'"reset" "auto"$'\t'"best" "patch-codex"$'\t'"patch" "recover*"$'\t'"resume" @@ -79,6 +133,7 @@ print_help_item() { "remove*"$'\t'"delete" "doctor"$'\t'"audit" "export*"$'\t'"write" + "help --all"$'\t'"more" ) action="${desc%% *}" @@ -90,18 +145,21 @@ print_help_item() { action="${desc%% *}" done case "$action" in - select|best) + select|best|live) action_tone="accent" ;; - use|save|view|cache|export|new|copy|load|write) + use|save|view|cache|export|new|copy|load|write|watch) action_tone="good" ;; - auth|login|import|wide|refresh|resume|roll|audit) + auth|login|import|wide|refresh|resume|roll|audit|dry|reset) action_tone="warn" ;; delete|remove) action_tone="bad" ;; + more) + action_tone="muted" + ;; *) action_tone="muted" ;; @@ -163,5 +221,3 @@ print_help_item() { print_toned_fit_rtrim "$desc" "$desc_w" muted printf '\n' } - - diff --git a/lib/codex-auth/maintain.sh b/lib/codex-auth/maintain.sh new file mode 100644 index 0000000..e5aae0e --- /dev/null +++ b/lib/codex-auth/maintain.sh @@ -0,0 +1,76 @@ +# shellcheck shell=bash + +codex_auth_path_is_shim() { + local path="$1" + + [[ -r "$path" ]] || return 1 + [[ "$(LC_ALL=C head -c 2 "$path" 2>/dev/null || true)" == '#!' ]] || return 1 + LC_ALL=C head -c 8192 "$path" 2>/dev/null | grep -aEq 'CODEX_AUTH_SHIM|codex-auth (run|auto)' +} + +codex_auth_standalone_bin() { + local candidate + + for candidate in \ + "$CODEX_HOME/packages/standalone/current/bin/codex" \ + "$CODEX_HOME/packages/standalone/current/codex" + do + [[ -x "$candidate" ]] && { printf '%s\n' "$candidate"; return 0; } + done + return 1 +} + +codex_auth_restore_shim_if_needed() { + local stock_bin="$1" + local template target target_real stock_real tmp + + template="${CODEX_AUTH_SHIM_TEMPLATE:-$CODEX_AUTH_LIB_DIR/codex-shim}" + target="${CODEX_AUTH_SHIM_PATH:-$CODEX_AUTH_SCRIPT_DIR/codex}" + [[ -r "$template" ]] || return 0 + codex_auth_path_is_shim "$target" && return 0 + + # Only reclaim the command when the official standalone installer owns it. + # A Homebrew/npm/custom Codex at this path is user intent, not drift. + target_real="$(realpath "$target" 2>/dev/null || true)" + stock_real="$(realpath "$stock_bin" 2>/dev/null || true)" + [[ -n "$target_real" && "$target_real" == "$stock_real" ]] || return 0 + + mkdir -p "${target%/*}" + tmp="$(mktemp "${target%/*}/.codex-auth-maintain.XXXXXX")" + if ! install -m 0755 "$template" "$tmp" \ + || ! mv -Tf "$tmp" "$target" 2>/dev/null + then + rm -f "$tmp" + return 1 + fi +} + +cmd_maintain() { + local quiet=0 stock_bin standalone_root install_lock_fd + + while (( $# > 0 )); do + case "$1" in + --quiet|-q) quiet=1 ;; + *) die "usage: codex-auth maintain [--quiet]" ;; + esac + shift + done + + ensure_dirs + stock_bin="$(codex_auth_standalone_bin || true)" + [[ -n "$stock_bin" ]] || return 0 + standalone_root="$CODEX_HOME/packages/standalone" + + # The official installer uses either this flock or install.lock.d. Never + # race its atomic current/visible-command publication. + [[ ! -d "$standalone_root/install.lock.d" ]] || return 0 + mkdir -p "$standalone_root" + exec {install_lock_fd}>"$standalone_root/install.lock" + flock -n "$install_lock_fd" || return 0 + codex_auth_restore_shim_if_needed "$stock_bin" || true + flock -u "$install_lock_fd" 2>/dev/null || true + exec {install_lock_fd}>&- + + CODEX_AUTH_STOCK_CODEX_BIN="$stock_bin" cmd_patch_codex --background --quiet + (( quiet )) || printf 'codex-auth maintenance queued for %s\n' "$stock_bin" +} diff --git a/lib/codex-auth/patch.sh b/lib/codex-auth/patch.sh index bad7523..137b8c5 100644 --- a/lib/codex-auth/patch.sh +++ b/lib/codex-auth/patch.sh @@ -1,6 +1,7 @@ # shellcheck shell=bash -PATCH_CODEX_PATCH_VERSION=1 +PATCH_CODEX_PATCH_VERSION=2 +PATCH_CODEX_LAYOUT_VERSION=1 patch_codex_root() { printf '%s\n' "${CODEX_AUTH_PATCH_CODEX_DIR:-$CODEX_HOME/patched-codex}" @@ -18,18 +19,284 @@ patch_codex_marker_value() { return 0 } +patch_codex_generation_dir() { + local stock_key="$1" + printf '%s/generations/%s\n' "$(patch_codex_root)" "$stock_key" +} + +patch_codex_generation_marker() { + local stock_key="$1" + printf '%s/current.env\n' "$(patch_codex_generation_dir "$stock_key")" +} + +patch_codex_bin_for_key() { + local stock_key="$1" + local generation_dir generation_marker generation_bin marker_key marker_patch_version + + generation_dir="$(patch_codex_generation_dir "$stock_key")" + generation_marker="$generation_dir/current.env" + generation_bin="$generation_dir/codex" + marker_key="$(patch_codex_marker_value stock_key "$generation_marker" || true)" + marker_patch_version="$(patch_codex_marker_value patch_version "$generation_marker" || true)" + if [[ -x "$generation_bin" \ + && "$marker_key" == "$stock_key" \ + && "$marker_patch_version" == "$PATCH_CODEX_PATCH_VERSION" ]]; then + printf '%s\n' "$generation_bin" + return 0 + fi + + # Read the pre-generation layout without ever publishing to it again. This + # keeps the currently installed patch usable until maintenance migrates it. + generation_marker="$(patch_codex_root)/current.env" + generation_bin="$(patch_codex_root)/bin/codex" + marker_key="$(patch_codex_marker_value stock_key "$generation_marker" || true)" + marker_patch_version="$(patch_codex_marker_value patch_version "$generation_marker" || true)" + if [[ -x "$generation_bin" \ + && "$marker_key" == "$stock_key" \ + && "$marker_patch_version" == "$PATCH_CODEX_PATCH_VERSION" ]]; then + printf '%s\n' "$generation_bin" + return 0 + fi + return 1 +} + patch_codex_ready_for_key() { local stock_key="$1" - local patched_bin marker_key marker_patch_version + patch_codex_bin_for_key "$stock_key" >/dev/null +} - patched_bin="$(patch_codex_root)/bin/codex" - [[ -x "$patched_bin" ]] || return 1 - marker_key="$(patch_codex_marker_value stock_key || true)" - marker_patch_version="$(patch_codex_marker_value patch_version || true)" - [[ "$marker_key" == "$stock_key" && "$marker_patch_version" == "$PATCH_CODEX_PATCH_VERSION" ]] +patch_codex_stock_identity() { + local stock_bin="$1" + local stock_real stock_version stock_stat hash_line checksum size stock_key + + stock_real="$(realpath "$stock_bin" 2>/dev/null || printf '%s' "$stock_bin")" + stock_version="$("$stock_bin" --version 2>/dev/null || true)" + stock_version="${stock_version%%$'\n'*}" + stock_version="${stock_version%"${stock_version##*[![:space:]]}"}" + [[ -n "$stock_version" ]] || stock_version="unknown" + if command -v stat >/dev/null 2>&1; then + stock_stat="$(stat -Lc '%d:%i:%s:%y:%z' "$stock_bin" 2>/dev/null || true)" + fi + [[ -n "${stock_stat:-}" ]] || stock_stat="$(wc -c < "$stock_bin" 2>/dev/null || printf 'unknown')" + if command -v sha256sum >/dev/null 2>&1; then + hash_line="$(printf 'layout=%s\037%s\037%s\037%s\n' \ + "$PATCH_CODEX_LAYOUT_VERSION" "$stock_real" "$stock_version" "$stock_stat" | sha256sum)" + stock_key="${hash_line%% *}" + else + read -r checksum size _ <<<"$(printf 'layout=%s\037%s\037%s\037%s\n' \ + "$PATCH_CODEX_LAYOUT_VERSION" "$stock_real" "$stock_version" "$stock_stat" | cksum)" + stock_key="$checksum-$size" + fi + printf '%s\037%s\037%s\n' "$stock_version" "$stock_stat" "$stock_key" } -patch_codex_write_source_patch() { +patch_codex_stock_hash() { + local stock_bin="$1" + local stock_key="$2" + local cache_dir cache_file hash_line stock_hash tmp + + cache_dir="$(patch_codex_root)/stock-hashes" + cache_file="$cache_dir/$stock_key.sha256" + if [[ -f "$cache_file" ]]; then + IFS= read -r stock_hash < "$cache_file" || true + [[ "$stock_hash" =~ ^[0-9a-f]{64}$ ]] && { printf '%s\n' "$stock_hash"; return 0; } + fi + if command -v sha256sum >/dev/null 2>&1; then + hash_line="$(sha256sum "$stock_bin")" + stock_hash="${hash_line%% *}" + else + read -r stock_hash _ < <(cksum "$stock_bin") + fi + mkdir -p "$cache_dir" "$(patch_codex_root)/.tmp" + tmp="$(mktemp "$(patch_codex_root)/.tmp/stock-hash.XXXXXX")" + printf '%s\n' "$stock_hash" > "$tmp" + chmod 0600 "$tmp" + mv -f "$tmp" "$cache_file" + printf '%s\n' "$stock_hash" +} + +patch_codex_failure_active() { + local stock_key="$1" + local failure_file patch_version retry_after now + + failure_file="$(patch_codex_root)/failures/$stock_key.env" + [[ -f "$failure_file" ]] || return 1 + patch_version="$(patch_codex_marker_value patch_version "$failure_file" || true)" + retry_after="$(patch_codex_marker_value retry_after "$failure_file" || true)" + now="$(date +%s)" + [[ "$patch_version" == "$PATCH_CODEX_PATCH_VERSION" \ + && "$retry_after" =~ ^[0-9]+$ \ + && "$retry_after" -gt "$now" ]] +} + +patch_codex_record_failure() { + local stock_key="$1" + local status="$2" + local failure_dir failure_file tmp now retry_secs + + failure_dir="$(patch_codex_root)/failures" + failure_file="$failure_dir/$stock_key.env" + retry_secs="${CODEX_AUTH_PATCH_RETRY_SECS:-900}" + [[ "$retry_secs" =~ ^[0-9]+$ ]] || retry_secs=900 + now="$(date +%s)" + mkdir -p "$failure_dir" "$(patch_codex_root)/.tmp" + tmp="$(mktemp "$(patch_codex_root)/.tmp/patch-failure.XXXXXX")" + { + printf 'patch_version=%s\n' "$PATCH_CODEX_PATCH_VERSION" + printf 'status=%s\n' "$status" + printf 'failed_at=%s\n' "$now" + printf 'retry_after=%s\n' "$((now + retry_secs))" + } > "$tmp" + chmod 0600 "$tmp" + mv -f "$tmp" "$failure_file" +} + +patch_codex_clear_failure() { + local stock_key="$1" + rm -f "$(patch_codex_root)/failures/$stock_key.env" +} + +patch_codex_write_generation_marker() { + local marker="$1" + local stock_key="$2" + local stock_version="$3" + local stock_stat="$4" + local stock_hash="$5" + local source_commit="$6" + local source_ref="$7" + local package_version="$8" + local profile="$9" + + { + printf 'patch_version=%s\n' "$PATCH_CODEX_PATCH_VERSION" + printf 'layout_version=%s\n' "$PATCH_CODEX_LAYOUT_VERSION" + printf 'stock_key=%s\n' "$stock_key" + printf 'stock_version=%s\n' "$stock_version" + printf 'stock_stat=%s\n' "$stock_stat" + printf 'stock_hash=%s\n' "$stock_hash" + printf 'source_commit=%s\n' "$source_commit" + printf 'source_ref=%s\n' "$source_ref" + printf 'package_version=%s\n' "$package_version" + printf 'profile=%s\n' "$profile" + printf 'built_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$marker" + chmod 0600 "$marker" +} + +patch_codex_publish_generation() { + local source_bin="$1" + local stock_key="$2" + local stock_version="$3" + local stock_stat="$4" + local stock_hash="$5" + local source_commit="$6" + local source_ref="$7" + local package_version="$8" + local profile="$9" + local replace_existing="${10:-0}" + local generation_dir generation_parent generation_tmp + + generation_dir="$(patch_codex_generation_dir "$stock_key")" + generation_parent="${generation_dir%/*}" + if [[ "$replace_existing" != "1" ]] && patch_codex_ready_for_key "$stock_key"; then + return 0 + fi + mkdir -p "$generation_parent" "$(patch_codex_root)/.tmp" + generation_tmp="$(mktemp -d "$(patch_codex_root)/.tmp/generation.XXXXXX")" + if ! install -m 0755 "$source_bin" "$generation_tmp/codex"; then + rm -rf "$generation_tmp" + return 1 + fi + if [[ "$profile" == "release" \ + && "${CODEX_AUTH_PATCH_STRIP:-1}" != "0" \ + && -x "$(command -v strip 2>/dev/null || true)" ]]; then + strip --strip-debug "$generation_tmp/codex" 2>/dev/null || true + fi + if ! "$generation_tmp/codex" --version >/dev/null 2>&1; then + rm -rf "$generation_tmp" + die "patched codex smoke failed" + fi + patch_codex_write_generation_marker \ + "$generation_tmp/current.env" \ + "$stock_key" "$stock_version" "$stock_stat" "$stock_hash" \ + "$source_commit" "$source_ref" "$package_version" "$profile" + if [[ -e "$generation_dir" ]]; then + rm -rf "$generation_dir" + fi + mv "$generation_tmp" "$generation_dir" +} + +patch_codex_migrate_legacy_generation() { + local stock_key="$1" + local stock_version="$2" + local stock_stat="$3" + local stock_hash="$4" + local legacy_marker legacy_bin legacy_patch_version legacy_version legacy_hash + local source_commit source_ref package_version profile + + legacy_marker="$(patch_codex_root)/current.env" + legacy_bin="$(patch_codex_root)/bin/codex" + [[ -x "$legacy_bin" && -f "$legacy_marker" ]] || return 1 + legacy_patch_version="$(patch_codex_marker_value patch_version "$legacy_marker" || true)" + legacy_version="$(patch_codex_marker_value stock_version "$legacy_marker" || true)" + legacy_hash="$(patch_codex_marker_value stock_hash "$legacy_marker" || true)" + [[ "$legacy_patch_version" == "$PATCH_CODEX_PATCH_VERSION" \ + && "$legacy_version" == "$stock_version" \ + && "$legacy_hash" == "$stock_hash" ]] || return 1 + source_commit="$(patch_codex_marker_value source_commit "$legacy_marker" || true)" + source_ref="$(patch_codex_marker_value source_ref "$legacy_marker" || true)" + package_version="$(patch_codex_marker_value package_version "$legacy_marker" || true)" + profile="$(patch_codex_marker_value profile "$legacy_marker" || true)" + [[ -n "$profile" ]] || profile=release + patch_codex_publish_generation \ + "$legacy_bin" "$stock_key" "$stock_version" "$stock_stat" "$stock_hash" \ + "$source_commit" "$source_ref" "$package_version" "$profile" +} + +patch_codex_prune_cache() { + local current_key="$1" + local keep="${CODEX_AUTH_PATCH_KEEP_GENERATIONS:-2}" + local root generations_dir sources_dir entry key kept=0 + local generation_entries=() keep_keys=() + + [[ "$keep" =~ ^[0-9]+$ && "$keep" -ge 1 ]] || keep=2 + root="$(patch_codex_root)" + generations_dir="$root/generations" + sources_dir="$root/sources" + [[ -d "$generations_dir" ]] || return 0 + + mapfile -t generation_entries < <( + find "$generations_dir" -mindepth 1 -maxdepth 1 -type d -printf '%T@\t%p\n' 2>/dev/null \ + | sort -rn + ) + for entry in "${generation_entries[@]}"; do + entry="${entry#*$'\t'}" + key="${entry##*/}" + if [[ "$key" == "$current_key" || "$kept" -lt "$keep" ]]; then + keep_keys+=("$key") + kept=$((kept + 1)) + continue + fi + rm -rf "$entry" + done + + [[ -d "$sources_dir" ]] || return 0 + shopt -s nullglob + for entry in "$sources_dir"/*; do + [[ -d "$entry" ]] || continue + key="${entry##*/}" + if [[ "$key" == "$current_key" || " ${keep_keys[*]} " == *" $key "* ]]; then + continue + fi + rm -rf "$entry" + rm -f "$root/stock-hashes/$key.sha256" "$root/failures/$key.env" + done + shopt -u nullglob +} + +# Historical v1 patch retained as build provenance. It is never selected: v2 +# fixes the account-mismatch window and is the only patch installed below. +patch_codex_write_source_patch_v1() { local patch_file="$1" cat > "$patch_file" <<'PATCH' @@ -191,27 +458,46 @@ PATCH sed -i 's/^__PATCH_BLANK__$/ /' "$patch_file" } +patch_codex_write_source_patch() { + local patch_file="$1" + local bundled_patch="$CODEX_AUTH_LIB_DIR/rolling-auth-v2.patch" + + [[ -r "$bundled_patch" ]] || die "missing patched-Codex source patch: $bundled_patch" + install -m 0600 "$bundled_patch" "$patch_file" +} + patch_codex_status() { local stock_bin="$1" local stock_version="$2" - local stock_hash="$3" - local stock_key="$4" - local marker_key marker_version package_version source_commit state - - marker_key="$(patch_codex_marker_value stock_key || true)" - marker_version="$(patch_codex_marker_value stock_version || true)" - package_version="$(patch_codex_marker_value package_version || true)" - source_commit="$(patch_codex_marker_value source_commit || true)" + local stock_key="$3" + local marker marker_key marker_version marker_hash package_version source_commit source_ref state patched_bin + + marker="$(patch_codex_generation_marker "$stock_key")" + [[ -f "$marker" ]] || marker="$(patch_codex_root)/current.env" + marker_key="$(patch_codex_marker_value stock_key "$marker" || true)" + marker_version="$(patch_codex_marker_value stock_version "$marker" || true)" + marker_hash="$(patch_codex_marker_value stock_hash "$marker" || true)" + package_version="$(patch_codex_marker_value package_version "$marker" || true)" + source_commit="$(patch_codex_marker_value source_commit "$marker" || true)" + source_ref="$(patch_codex_marker_value source_ref "$marker" || true)" state="stale" - patch_codex_ready_for_key "$stock_key" && state="ready" + if patched_bin="$(patch_codex_bin_for_key "$stock_key" 2>/dev/null)"; then + state="ready" + elif patch_codex_failure_active "$stock_key"; then + state="failed" + patched_bin="$(patch_codex_generation_dir "$stock_key")/codex" + else + patched_bin="$(patch_codex_generation_dir "$stock_key")/codex" + fi printf 'patched codex: %s\n' "$state" printf 'stock bin: %s\n' "$stock_bin" printf 'stock version: %s\n' "$stock_version" - printf 'stock hash: %s\n' "$stock_hash" - printf 'patched bin: %s/bin/codex\n' "$(patch_codex_root)" + [[ -n "$marker_hash" ]] && printf 'stock hash: %s\n' "$marker_hash" + printf 'patched bin: %s\n' "$patched_bin" [[ -n "$marker_version" ]] && printf 'patched stock version: %s\n' "$marker_version" [[ -n "$package_version" ]] && printf 'patched package version: %s\n' "$package_version" + [[ -n "$source_ref" ]] && printf 'source ref: %s\n' "$source_ref" [[ -n "$source_commit" ]] && printf 'source commit: %s\n' "$source_commit" [[ -n "$marker_key" && "$marker_key" != "$stock_key" ]] && printf 'reason: stock key changed\n' return 0 @@ -222,7 +508,7 @@ cmd_patch_codex() { local background=0 quiet=0 status=0 print_bin=0 print_key=0 force=0 no_fetch=0 check_login=0 local profile="${CODEX_AUTH_PATCH_PROFILE:-release}" - local ref="${CODEX_AUTH_PATCH_REF:-origin/main}" + local ref="${CODEX_AUTH_PATCH_REF:-}" local source_dir="" while (( $# > 0 )); do case "$1" in @@ -277,7 +563,7 @@ cmd_patch_codex() { done [[ "$profile" == "release" || "$profile" == "debug" ]] || profile=release - local stock_bin stock_version stock_hash stock_key hash_line checksum size + local stock_bin stock_version stock_stat stock_hash="" stock_key stock_identity local self log if [[ -n "${CODEX_AUTH_STOCK_CODEX_BIN:-}" && -x "${CODEX_AUTH_STOCK_CODEX_BIN:-}" ]]; then stock_bin="$CODEX_AUTH_STOCK_CODEX_BIN" @@ -286,43 +572,41 @@ cmd_patch_codex() { fi stock_bin="$(canonical_codex_bin "$stock_bin")" require_codex_launcher "$stock_bin" - stock_version="$("$stock_bin" --version 2>/dev/null || true)" - stock_version="${stock_version%%$'\n'*}" - stock_version="${stock_version%"${stock_version##*[![:space:]]}"}" - [[ -n "$stock_version" ]] || stock_version="unknown" - if command -v sha256sum >/dev/null 2>&1 && [[ -f "$stock_bin" ]]; then - hash_line="$(sha256sum "$stock_bin")"; stock_hash="${hash_line%% *}" - else - read -r checksum size _ <<<"$(printf '%s' "$stock_bin" | cksum)"; stock_hash="$checksum-$size" - fi - if command -v sha256sum >/dev/null 2>&1; then - hash_line="$(printf '%s\037%s\n' "$stock_version" "$stock_hash" | sha256sum)"; stock_key="${hash_line%% *}" - else - read -r checksum size _ <<<"$(printf '%s\037%s\n' "$stock_version" "$stock_hash" | cksum)"; stock_key="$checksum-$size" - fi + stock_identity="$(patch_codex_stock_identity "$stock_bin")" + IFS=$'\037' read -r stock_version stock_stat stock_key <<<"$stock_identity" if (( print_key )); then printf '%s\n' "$stock_key" return 0 fi - if (( print_bin )); then - patch_codex_ready_for_key "$stock_key" || return 1 - printf '%s/bin/codex\n' "$(patch_codex_root)" + if (( print_bin )) && patch_codex_ready_for_key "$stock_key"; then + patch_codex_bin_for_key "$stock_key" return 0 fi + if (( print_bin && ! background )); then + return 1 + fi if (( status )); then - patch_codex_status "$stock_bin" "$stock_version" "$stock_hash" "$stock_key" + patch_codex_status "$stock_bin" "$stock_version" "$stock_key" return 0 fi if (( ! force )) && patch_codex_ready_for_key "$stock_key"; then - [[ "$quiet" == "1" ]] || patch_codex_status "$stock_bin" "$stock_version" "$stock_hash" "$stock_key" + [[ "$quiet" == "1" ]] || patch_codex_status "$stock_bin" "$stock_version" "$stock_key" return 0 fi if (( background )); then + local launch_lock_fd + (( force )) || ! patch_codex_failure_active "$stock_key" || return 0 self="$(realpath "$CODEX_AUTH_SELF" 2>/dev/null || printf '%s\n' "$CODEX_AUTH_SELF")" log="$(patch_codex_root)/build.log" mkdir -p "$(dirname "$log")" "$(patch_codex_root)/.tmp" - nohup env CODEX_AUTH_STOCK_CODEX_BIN="$stock_bin" CODEX_AUTH_PATCH_BACKGROUND_CHILD=1 "$self" patch-codex --quiet --foreground >"$log" 2>&1 & + exec {launch_lock_fd}>"$(patch_codex_root)/build.lock" + flock -n "$launch_lock_fd" || return 0 + nohup env \ + CODEX_AUTH_STOCK_CODEX_BIN="$stock_bin" \ + CODEX_AUTH_PATCH_BACKGROUND_CHILD=1 \ + CODEX_AUTH_PATCH_LOCK_FD="$launch_lock_fd" \ + "$self" patch-codex --quiet --foreground "$log" 2>&1 & [[ "$quiet" == "1" ]] || print_status_note patch "background build started" return 0 fi @@ -330,23 +614,41 @@ cmd_patch_codex() { command -v git >/dev/null 2>&1 || die "git is required for patch-codex" command -v cargo >/dev/null 2>&1 || die "cargo is required for patch-codex" - local lock_wait lock_fd source_commit package_base package_version package_suffix repo_url clone_tmp manager_file patch_file cargo_toml built_bin target_dir target_bin tmp_bin marker tmp + local lock_wait lock_fd source_commit package_base package_version package_suffix repo_url clone_tmp manager_file patch_file cargo_toml built_bin target_dir tmp local cargo_line in_workspace_package package_version_written local build_args=() + mkdir -p "$(patch_codex_root)" "$(patch_codex_root)/.tmp" lock_wait="${CODEX_AUTH_PATCH_LOCK_WAIT:-0}" [[ "$lock_wait" =~ ^[0-9]+$ ]] || lock_wait=0 - exec {lock_fd}>"$(patch_codex_root)/build.lock" - if [[ "${CODEX_AUTH_PATCH_BACKGROUND_CHILD:-}" == "1" || "$lock_wait" == "0" ]]; then + if [[ "${CODEX_AUTH_PATCH_LOCK_FD:-}" =~ ^[0-9]+$ ]]; then + lock_fd="$CODEX_AUTH_PATCH_LOCK_FD" flock -n "$lock_fd" || return 0 else - flock -w "$lock_wait" "$lock_fd" || die "patched Codex build already running" + exec {lock_fd}>"$(patch_codex_root)/build.lock" + if [[ "${CODEX_AUTH_PATCH_BACKGROUND_CHILD:-}" == "1" || "$lock_wait" == "0" ]]; then + flock -n "$lock_fd" || return 0 + else + flock -w "$lock_wait" "$lock_fd" || die "patched Codex build already running" + fi fi if (( ! force )) && patch_codex_ready_for_key "$stock_key"; then return 0 fi - [[ -n "$source_dir" ]] || source_dir="$(patch_codex_root)/sources/$stock_key/source" + local patch_build_succeeded=0 + trap 'patch_status=$?; if (( ! patch_build_succeeded )); then patch_codex_record_failure "$stock_key" "$patch_status"; fi; exit "$patch_status"' EXIT + stock_hash="$(patch_codex_stock_hash "$stock_bin" "$stock_key")" + if (( ! force )) && patch_codex_migrate_legacy_generation \ + "$stock_key" "$stock_version" "$stock_stat" "$stock_hash"; then + patch_codex_clear_failure "$stock_key" + patch_codex_prune_cache "$stock_key" || true + patch_build_succeeded=1 + trap - EXIT + (( print_bin )) && patch_codex_bin_for_key "$stock_key" + return 0 + fi + package_base="${stock_version##* }" if [[ ! "$package_base" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then package_base="0.0.0" @@ -360,7 +662,7 @@ cmd_patch_codex() { package_version="$package_base+$package_suffix" fi - [[ "$quiet" == "1" ]] || print_status_note patch "source $ref" + [[ -n "$source_dir" ]] || source_dir="$(patch_codex_root)/sources/$stock_key/v$PATCH_CODEX_PATCH_VERSION/source" repo_url="${CODEX_AUTH_PATCH_REPO_URL:-https://github.com/openai/codex.git}" if [[ -d "$source_dir/.git" ]] \ && ! git -C "$source_dir" rev-parse --verify HEAD >/dev/null 2>&1 \ @@ -376,17 +678,23 @@ cmd_patch_codex() { git clone "$repo_url" "$clone_tmp" mv "$clone_tmp" "$source_dir" fi + if [[ "$no_fetch" != "1" ]]; then + git -C "$source_dir" fetch origin + fi + if [[ -z "$ref" ]]; then + ref="rust-v${package_base%%+*}" + git -C "$source_dir" rev-parse --verify --quiet "$ref^{commit}" >/dev/null \ + || die "exact Codex source tag is not available yet: $ref" + fi + [[ "$quiet" == "1" ]] || print_status_note patch "source $ref" if [[ ! -f "$source_dir/codex-rs/login/src/auth/manager.rs" ]] \ - || ! grep -Fq 'CODEX_AUTH_ROLLING_ENV_VAR' "$source_dir/codex-rs/login/src/auth/manager.rs"; then - if [[ "$no_fetch" != "1" ]]; then - git -C "$source_dir" fetch origin - fi + || ! grep -Fq 'maybe_sync_rolling_auth' "$source_dir/codex-rs/login/src/auth/manager.rs"; then git -C "$source_dir" checkout --detach "$ref" fi source_commit="$(git -C "$source_dir" rev-parse HEAD)" manager_file="$source_dir/codex-rs/login/src/auth/manager.rs" [[ -f "$manager_file" ]] || die "missing $manager_file" - if ! grep -Fq 'CODEX_AUTH_ROLLING_ENV_VAR' "$manager_file"; then + if ! grep -Fq 'maybe_sync_rolling_auth' "$manager_file"; then patch_file="$(mktemp "$(patch_codex_root)/.tmp/codex-source.XXXXXX.patch")" patch_codex_write_source_patch "$patch_file" git -C "$source_dir" apply "$patch_file" @@ -418,38 +726,32 @@ cmd_patch_codex() { [[ "$quiet" == "1" ]] || print_status_note patch "build $profile" [[ "$profile" == "release" ]] && build_args=(--release) + target_dir="${CARGO_TARGET_DIR:-$(patch_codex_root)/cargo-target}" if [[ "$check_login" == "1" ]]; then - (cd "$source_dir/codex-rs" && cargo check -p codex-login) + (cd "$source_dir/codex-rs" && \ + CARGO_TARGET_DIR="$target_dir" \ + CARGO_PROFILE_RELEASE_DEBUG=0 \ + cargo check -p codex-login) fi - (cd "$source_dir/codex-rs" && cargo build -p codex-cli --bin codex "${build_args[@]}") - target_dir="${CARGO_TARGET_DIR:-$source_dir/codex-rs/target}" + (cd "$source_dir/codex-rs" && \ + CARGO_TARGET_DIR="$target_dir" \ + CARGO_PROFILE_RELEASE_DEBUG=0 \ + CARGO_PROFILE_RELEASE_STRIP=debuginfo \ + cargo build -p codex-cli --bin codex "${build_args[@]}") if [[ "$profile" == "release" ]]; then built_bin="$target_dir/release/codex" else built_bin="$target_dir/debug/codex" fi [[ -x "$built_bin" ]] || die "build did not produce $built_bin" - target_bin="$(patch_codex_root)/bin/codex" - mkdir -p "$(dirname "$target_bin")" - tmp_bin="$(mktemp "$(patch_codex_root)/.tmp/codex.XXXXXX")" - install -m 0755 "$built_bin" "$tmp_bin" - "$tmp_bin" --version >/dev/null 2>&1 || die "patched codex smoke failed" - mv "$tmp_bin" "$target_bin" - - marker="$(patch_codex_root)/current.env" - tmp="$(mktemp "$(patch_codex_root)/.tmp/current.env.XXXXXX")" - { - printf 'patch_version=%s\n' "$PATCH_CODEX_PATCH_VERSION" - printf 'stock_key=%s\n' "$stock_key" - printf 'stock_version=%s\n' "$stock_version" - printf 'stock_hash=%s\n' "$stock_hash" - printf 'source_commit=%s\n' "$source_commit" - printf 'package_version=%s\n' "$package_version" - printf 'profile=%s\n' "$profile" - printf 'built_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - } > "$tmp" - mv "$tmp" "$marker" + patch_codex_publish_generation \ + "$built_bin" "$stock_key" "$stock_version" "$stock_stat" "$stock_hash" \ + "$source_commit" "$ref" "$package_version" "$profile" "$force" + patch_codex_clear_failure "$stock_key" + patch_codex_prune_cache "$stock_key" || true + patch_build_succeeded=1 + trap - EXIT - [[ "$quiet" == "1" ]] || patch_codex_status "$stock_bin" "$stock_version" "$stock_hash" "$stock_key" + (( print_bin )) && patch_codex_bin_for_key "$stock_key" + [[ "$quiet" == "1" ]] || patch_codex_status "$stock_bin" "$stock_version" "$stock_key" } - diff --git a/lib/codex-auth/profiles.sh b/lib/codex-auth/profiles.sh index 01cfbb6..3c3b3b6 100644 --- a/lib/codex-auth/profiles.sh +++ b/lib/codex-auth/profiles.sh @@ -1,7 +1,154 @@ # shellcheck shell=bash +resolve_active_profile_for_auth() { + local auth_path="$1" + local live_fp live_kind live_identity marker_name marker_kind marker_identity marker_fp marker_revision + local path name profile_fp profile_kind profile_identity profile_revision matched="" identity_match="" identity_count=0 + + auth_file_is_valid "$auth_path" || return 1 + live_fp="$(credential_fingerprint "$auth_path" || true)" + live_kind="$(auth_file_kind "$auth_path" || true)" + live_identity="$(auth_file_account_identity "$auth_path" || true)" + + marker_name="$(active_profile_marker_read || true)" + if [[ -n "$marker_name" ]]; then + path="$(profile_path "$marker_name")" + marker_fp="$(active_profile_marker_field profile_fingerprint || true)" + profile_fp="$(credential_fingerprint "$path" || true)" + if [[ -n "$live_fp" && "$profile_fp" == "$live_fp" && "$marker_fp" == "$live_fp" ]]; then + printf '%s\n' "$marker_name" + return 0 + fi + fi + + shopt -s nullglob + for path in "$PROFILE_DIR"/*.json; do + profile_fp="$(credential_fingerprint "$path" || true)" + if [[ -n "$live_fp" && "$profile_fp" == "$live_fp" ]]; then + matched="$(basename "$path" .json)" + break + fi + done + shopt -u nullglob + if [[ -n "$matched" ]]; then + printf '%s\n' "$matched" + return 0 + fi + + [[ "$live_kind" == "chatgpt" && -n "$live_identity" ]] || return 1 + if [[ -n "$marker_name" ]]; then + path="$(profile_path "$marker_name")" + if [[ -f "$path" ]]; then + marker_kind="$(active_profile_marker_field kind || true)" + marker_identity="$(active_profile_marker_field account_identity || true)" + marker_fp="$(active_profile_marker_field profile_fingerprint || true)" + marker_revision="$(active_profile_marker_field profile_revision || true)" + profile_kind="$(auth_file_kind "$path" || true)" + profile_identity="$(auth_file_account_identity "$path" || true)" + profile_fp="$(credential_fingerprint "$path" || true)" + profile_revision="$(auth_file_revision "$path" || true)" + if [[ "$marker_kind" == "chatgpt" \ + && "$profile_kind" == "chatgpt" \ + && -n "$marker_identity" \ + && "$marker_identity" == "$live_identity" \ + && "$profile_identity" == "$live_identity" \ + && -n "$marker_fp" \ + && "$profile_fp" == "$marker_fp" \ + && -n "$marker_revision" \ + && "$profile_revision" == "$marker_revision" ]]; then + printf '%s\n' "$marker_name" + return 0 + fi + fi + fi + + shopt -s nullglob + for path in "$PROFILE_DIR"/*.json; do + profile_kind="$(auth_file_kind "$path" || true)" + [[ "$profile_kind" == "chatgpt" ]] || continue + profile_identity="$(auth_file_account_identity "$path" || true)" + [[ -n "$profile_identity" && "$profile_identity" == "$live_identity" ]] || continue + identity_match="$(basename "$path" .json)" + identity_count=$((identity_count + 1)) + done + shopt -u nullglob + [[ "$identity_count" == "1" ]] || return 1 + printf '%s\n' "$identity_match" +} + +sync_active_profile_from_live() ( + local snapshot name profile live_fp profile_fp live_revision profile_revision current_revision live_kind profile_kind + local live_identity profile_identity marker_name marker_fp marker_identity marker_revision + + [[ -f "$AUTH_FILE" ]] && auth_file_is_valid "$AUTH_FILE" || return 0 + command -v jq >/dev/null 2>&1 || return 0 + ensure_dirs + acquire_mutation_lock + + snapshot="$(mktemp "$CODEX_HOME/.tmp/active-auth.XXXXXX.json")" + if ! copy_auth_file_atomic "$AUTH_FILE" "$snapshot"; then + rm -f "$snapshot" + return 0 + fi + name="$(resolve_active_profile_for_auth "$snapshot" || true)" + if [[ -z "$name" ]]; then + rm -f "$snapshot" + return 0 + fi + profile="$(profile_path "$name")" + if [[ ! -f "$profile" ]] || ! auth_file_is_valid "$profile"; then + rm -f "$snapshot" + return 0 + fi + + live_fp="$(credential_fingerprint "$snapshot" || true)" + profile_fp="$(credential_fingerprint "$profile" || true)" + live_revision="$(auth_file_revision "$snapshot" || true)" + profile_revision="$(auth_file_revision "$profile" || true)" + current_revision="$(auth_file_revision "$AUTH_FILE" || true)" + if [[ -z "$live_fp" || -z "$live_revision" || "$current_revision" != "$live_revision" ]]; then + rm -f "$snapshot" + return 0 + fi + if [[ "$live_revision" == "$profile_revision" ]]; then + active_profile_marker_write "$name" "$profile" || true + rm -f "$snapshot" + return 0 + fi + + live_kind="$(auth_file_kind "$snapshot" || true)" + profile_kind="$(auth_file_kind "$profile" || true)" + live_identity="$(auth_file_account_identity "$snapshot" || true)" + profile_identity="$(auth_file_account_identity "$profile" || true)" + [[ "$live_kind" == "chatgpt" \ + && "$profile_kind" == "chatgpt" \ + && -n "$live_identity" \ + && "$live_identity" == "$profile_identity" ]] || { rm -f "$snapshot"; return 0; } + + marker_name="$(active_profile_marker_read || true)" + if [[ -n "$marker_name" ]]; then + marker_fp="$(active_profile_marker_field profile_fingerprint || true)" + marker_identity="$(active_profile_marker_field account_identity || true)" + marker_revision="$(active_profile_marker_field profile_revision || true)" + [[ "$marker_name" == "$name" \ + && -n "$marker_fp" \ + && "$marker_fp" == "$profile_fp" \ + && "$marker_identity" == "$live_identity" \ + && -n "$marker_revision" \ + && "$marker_revision" == "$profile_revision" ]] || { rm -f "$snapshot"; return 0; } + fi + + [[ "$(auth_file_revision "$profile" || true)" == "$profile_revision" \ + && "$(auth_file_revision "$AUTH_FILE" || true)" == "$live_revision" ]] \ + || { rm -f "$snapshot"; return 0; } + copy_auth_file_atomic "$snapshot" "$profile" || { rm -f "$snapshot"; return 0; } + active_profile_marker_write "$name" "$profile" || true + rm -f "$snapshot" +) + cmd_list() { ensure_dirs + sync_active_profile_from_live local list_verbose=0 while (( $# > 0 )); do @@ -214,6 +361,7 @@ cmd_list() { cmd_current() { ensure_dirs + sync_active_profile_from_live local cols label_w value_w total_w status_text cols="$(terminal_width)" IFS=$'\t' read -r label_w value_w <<<"$(detail_widths "$cols")" @@ -346,6 +494,9 @@ cmd_save() { require_name "$name" ensure_dirs acquire_mutation_lock + if [[ "$source" == "$AUTH_FILE" ]]; then + sync_active_profile_from_live + fi if [[ "$source" == "$AUTH_FILE" && ! -f "$source" ]]; then die "no active auth to save" fi @@ -354,6 +505,9 @@ cmd_save() { local dest dest="$(profile_path "$name")" copy_auth_file_atomic "$source" "$dest" + if [[ "$source" == "$AUTH_FILE" ]]; then + active_profile_marker_write "$name" "$dest" || true + fi print_result_block "$summary" \ "profile"$'\t'"$(display_path "$dest")"$'\t'"active" \ "source"$'\t'"$(display_path "$source")"$'\t'"muted" @@ -364,6 +518,7 @@ cmd_use() { require_name "$name" ensure_dirs acquire_mutation_lock + sync_active_profile_from_live local source source_fp="" active_fp="" source="$(profile_path "$name")" @@ -375,6 +530,7 @@ cmd_use() { active_fp="$(credential_fingerprint "$AUTH_FILE" || true)" fi if [[ -n "$source_fp" && -n "$active_fp" && "$source_fp" == "$active_fp" ]]; then + active_profile_marker_write "$name" "$source" || true print_result_block "active $name" \ "auth"$'\t'"$(display_path "$AUTH_FILE")"$'\t'"active" \ "profile"$'\t'"$(display_path "$source")"$'\t'"muted" \ @@ -385,6 +541,7 @@ cmd_use() { local backup="" backup="$(backup_current "pre-use-$name" || true)" copy_auth_file_atomic "$source" "$AUTH_FILE" + active_profile_marker_write "$name" "$source" || true if [[ -n "$backup" ]]; then print_result_block "active $name" \ @@ -398,6 +555,54 @@ cmd_use() { fi } +cmd_use_if_current() { + local expected="$1" + local target="$2" + local refresh_generation="${3:-}" + require_name "$expected" + require_name "$target" + ensure_dirs + acquire_mutation_lock + sync_active_profile_from_live + + local expected_source expected_fp active_fp="" target_source target_fp + expected_source="$(profile_path "$expected")" + [[ -f "$expected_source" ]] || die "profile not found: $expected" + require_auth_file "$expected_source" + expected_fp="$(credential_fingerprint "$expected_source" || true)" + if [[ -f "$AUTH_FILE" ]]; then + active_fp="$(credential_fingerprint "$AUTH_FILE" || true)" + fi + if [[ -z "$expected_fp" || -z "$active_fp" || "$expected_fp" != "$active_fp" ]]; then + print_error "active profile changed; expected $expected" + return 75 + fi + + if [[ -n "$refresh_generation" ]]; then + target_source="$(profile_path "$target")" + [[ -f "$target_source" ]] || die "profile not found: $target" + require_auth_file "$target_source" + target_fp="$(credential_fingerprint "$target_source" || true)" + if [[ -z "$target_fp" ]] || ! jq -e \ + --arg expected "$expected" \ + --arg expected_fp "$expected_fp" \ + --arg target "$target" \ + --arg target_fp "$target_fp" \ + --arg generation "$refresh_generation" ' + .profiles[$expected].fingerprint == $expected_fp + and .profiles[$expected].refresh_generation == $generation + and .profiles[$target].fingerprint == $target_fp + and .profiles[$target].refresh_generation == $generation + ' "$STATE_FILE" >/dev/null 2>&1 + then + print_error "profile changed or was not refreshed; expected generation $refresh_generation" + return 75 + fi + fi + + cmd_use "$target" +} + cmd_login() { local name="$1" shift || true @@ -408,6 +613,7 @@ cmd_login() { require_codex_launcher "$codex_cli" ensure_dirs acquire_mutation_lock + sync_active_profile_from_live backup_current "pre-login-$name" >/dev/null || true @@ -511,10 +717,11 @@ cmd_remove() { ensure_dirs acquire_mutation_lock - local path active_matches=0 active_fp="" profile_fp="" active_name="" remaining_name="" active_value="unsaved auth" + local path active_matches=0 active_fp="" profile_fp="" active_name="" remaining_name="" active_value="unsaved auth" marker_name="" local other other_name other_fp path="$(profile_path "$name")" [[ -f "$path" ]] || die "profile not found: $name" + marker_name="$(active_profile_marker_read || true)" if [[ -f "$AUTH_FILE" ]]; then active_fp="$(credential_fingerprint "$AUTH_FILE" || true)" profile_fp="$(credential_fingerprint "$path" || true)" @@ -532,6 +739,13 @@ cmd_remove() { fi rm -f "$path" + if [[ "$marker_name" == "$name" ]]; then + if [[ -n "$remaining_name" && -f "$(profile_path "$remaining_name")" ]]; then + active_profile_marker_write "$remaining_name" "$(profile_path "$remaining_name")" || active_profile_marker_clear "$name" + else + active_profile_marker_clear "$name" + fi + fi if (( active_matches )); then [[ -n "$remaining_name" ]] && active_value="saved $remaining_name" print_result_block "removed active $name" \ @@ -575,5 +789,3 @@ cmd_paths() { print_path_line "undo" "backups" "$BACKUP_DIR" "$role_w" "$target_w" "$path_w" warn print_path_line "cache" "usage" "$STATE_FILE" "$role_w" "$target_w" "$path_w" muted } - - diff --git a/lib/codex-auth/rolling-auth-v2.patch b/lib/codex-auth/rolling-auth-v2.patch new file mode 100644 index 0000000..b73fd03 --- /dev/null +++ b/lib/codex-auth/rolling-auth-v2.patch @@ -0,0 +1,182 @@ +diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs +index 9509efc348..3c82e7dcbe 100644 +--- a/codex-rs/login/src/auth/manager.rs ++++ b/codex-rs/login/src/auth/manager.rs +@@ -191,6 +191,10 @@ pub(super) const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke" + pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; + pub const REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REVOKE_TOKEN_URL_OVERRIDE"; + pub const CLIENT_ID_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_CLIENT_ID"; ++const CODEX_AUTH_ROLLING_ENV_VAR: &str = "CODEX_AUTH_ROLLING"; ++const CODEX_AUTH_ROLLING_INTERVAL_MS_ENV_VAR: &str = "CODEX_AUTH_ROLLING_INTERVAL_MS"; ++const CODEX_AUTH_ROLLING_TTL_ENV_VAR: &str = "CODEX_AUTH_ROLLING_TTL"; ++const DEFAULT_ROLLING_AUTH_INTERVAL_MS: u64 = 60_000; + static NEXT_DUMMY_AUTH_ID: AtomicU64 = AtomicU64::new(1); + + #[derive(Debug, Error)] +@@ -1779,6 +1783,7 @@ pub struct AuthManager { + agent_identity_bootstrap_cooldown: Mutex, + external_auth: RwLock>>, + auth_route_config: Option, ++ rolling_auth_last_check: Mutex>, + } + + /// Configuration view required to construct a shared [`AuthManager`]. +@@ -1881,6 +1886,7 @@ impl AuthManager { + agent_identity_bootstrap_cooldown: Mutex::default(), + external_auth: RwLock::new(None), + auth_route_config, ++ rolling_auth_last_check: Mutex::new(None), + } + } + +@@ -1907,6 +1913,7 @@ impl AuthManager { + agent_identity_bootstrap_cooldown: Mutex::default(), + external_auth: RwLock::new(None), + auth_route_config: None, ++ rolling_auth_last_check: Mutex::new(None), + }) + } + +@@ -1932,6 +1939,7 @@ impl AuthManager { + agent_identity_bootstrap_cooldown: Mutex::default(), + external_auth: RwLock::new(None), + auth_route_config: None, ++ rolling_auth_last_check: Mutex::new(None), + }) + } + +@@ -1965,6 +1973,7 @@ impl AuthManager { + agent_identity_bootstrap_cooldown: Mutex::default(), + external_auth: RwLock::new(None), + auth_route_config: None, ++ rolling_auth_last_check: Mutex::new(None), + }) + } + +@@ -1990,6 +1999,7 @@ impl AuthManager { + Arc::new(BearerTokenRefresher::new(config)) as Arc + )), + auth_route_config: None, ++ rolling_auth_last_check: Mutex::new(None), + }) + } + +@@ -2026,6 +2036,8 @@ impl AuthManager { + return self.auth_cached(); + } + ++ self.maybe_sync_rolling_auth().await; ++ + let auth = self.auth_cached()?; + if Self::should_refresh_proactively(&auth) + && let Err(err) = self.refresh_token().await +@@ -2036,6 +2048,85 @@ impl AuthManager { + self.auth_cached() + } + ++ fn rolling_auth_enabled() -> bool { ++ env::var(CODEX_AUTH_ROLLING_ENV_VAR) ++ .ok() ++ .map(|value| { ++ matches!( ++ value.trim().to_ascii_lowercase().as_str(), ++ "1" | "true" | "yes" | "on" ++ ) ++ }) ++ .unwrap_or(false) ++ } ++ ++ fn rolling_auth_interval() -> Duration { ++ env::var(CODEX_AUTH_ROLLING_INTERVAL_MS_ENV_VAR) ++ .ok() ++ .and_then(|value| value.trim().parse::().ok()) ++ .map(Duration::from_millis) ++ .unwrap_or(Duration::from_millis(DEFAULT_ROLLING_AUTH_INTERVAL_MS)) ++ } ++ ++ fn rolling_auth_ttl_secs() -> Option { ++ env::var(CODEX_AUTH_ROLLING_TTL_ENV_VAR) ++ .ok() ++ .and_then(|value| value.trim().parse::().ok()) ++ .map(|value| value.to_string()) ++ } ++ ++ fn rolling_auth_auto_due(&self) -> bool { ++ let interval = Self::rolling_auth_interval(); ++ let now = Instant::now(); ++ let Ok(mut last_check) = self.rolling_auth_last_check.lock() else { ++ return true; ++ }; ++ ++ if let Some(last) = *last_check ++ && now.duration_since(last) < interval ++ { ++ return false; ++ } ++ ++ *last_check = Some(now); ++ true ++ } ++ ++ async fn maybe_sync_rolling_auth(&self) { ++ if !Self::rolling_auth_enabled() { ++ return; ++ } ++ ++ // A profile switch replaces auth.json while Codex still has the old ++ // account cached. Reload first so a refresh never tries to use account ++ // A after the switcher has deliberately activated account B. ++ self.reload().await; ++ ++ if !self.rolling_auth_auto_due() { ++ return; ++ } ++ ++ let mut command = tokio::process::Command::new("codex-auth"); ++ command.arg("auto").arg("--quiet").arg("--no-background"); ++ if let Some(ttl) = Self::rolling_auth_ttl_secs() { ++ command.arg("--ttl").arg(ttl); ++ } ++ command.env("CODEX_AUTH_NO_BACKGROUND", "1"); ++ command.env("CODEX_HOME", &self.codex_home); ++ ++ match command.status().await { ++ Ok(status) if status.success() => { ++ self.reload().await; ++ } ++ Ok(status) => { ++ tracing::warn!("rolling auth hook exited with status {status}"); ++ } ++ Err(err) => { ++ tracing::warn!("failed to run rolling auth hook: {err}"); ++ } ++ } ++ } ++ + pub async fn agent_identity_auth( + &self, + policy: AgentIdentityAuthPolicy, +@@ -2122,6 +2213,23 @@ impl AuthManager { + let new_account_id = new_auth.as_ref().and_then(CodexAuth::get_account_id); + + if new_account_id.as_deref() != Some(expected_account_id) { ++ if Self::rolling_auth_enabled() && new_auth.is_some() { ++ tracing::info!( ++ "Reloading deliberate rolling-auth account change (expected: {expected_account_id}, found: {})", ++ new_account_id.as_deref().unwrap_or("unknown") ++ ); ++ let cached_before_reload = self.auth_cached(); ++ let auth_changed = !Self::auths_equal_for_refresh( ++ cached_before_reload.as_ref(), ++ new_auth.as_ref(), ++ ); ++ self.set_cached_auth(new_auth); ++ return if auth_changed { ++ ReloadOutcome::ReloadedChanged ++ } else { ++ ReloadOutcome::ReloadedNoChange ++ }; ++ } + let found_account_id = new_account_id.as_deref().unwrap_or("unknown"); + tracing::info!( + "Skipping auth reload due to account id mismatch (expected: {expected_account_id}, found: {found_account_id})" diff --git a/lib/codex-auth/run.sh b/lib/codex-auth/run.sh index 6f9a145..5e61d5f 100644 --- a/lib/codex-auth/run.sh +++ b/lib/codex-auth/run.sh @@ -113,6 +113,7 @@ cmd_run() { local args=() local auto_args=() local codex_cli attempt status log_file run_file watch_pid monitor_pid target_pid launcher_arg + local stdout_fd stderr_fd stdout_tee_pid stderr_tee_pid [[ "$max_attempts" =~ ^[1-9][0-9]*$ ]] || max_attempts=5 [[ -z "$auto_ttl" || "$auto_ttl" =~ ^[0-9]+$ ]] || auto_ttl="" @@ -210,7 +211,15 @@ cmd_run() { status=$? elif [[ "$log_enabled" != "0" && -n "$log_file" && ! -t 1 ]]; then set +e - CODEX_AUTH_RUNNER=1 "$codex_cli" "${run_args[@]}" > >(tee "$log_file") 2> >(tee -a "$log_file" >&2) & + # Truncate exactly once, then append from two owned tee processes. Bash + # process substitutions are asynchronous; keep their PIDs and wait for + # both writers before scanning the log for a usage-limit retry. + : > "$log_file" + exec {stdout_fd}> >(tee -a "$log_file") + stdout_tee_pid=$! + exec {stderr_fd}> >(tee -a "$log_file" >&2) + stderr_tee_pid=$! + CODEX_AUTH_RUNNER=1 "$codex_cli" "${run_args[@]}" 1>&"$stdout_fd" 2>&"$stderr_fd" & target_pid=$! if [[ "${CODEX_AUTH_ROLL_LIVE_MONITOR:-1}" != "0" && -n "$log_file" ]]; then codex_run_limit_monitor_loop "$log_file" "$target_pid" & @@ -218,6 +227,22 @@ cmd_run() { fi wait "$target_pid" status=$? + # The monitor was forked after these writer FDs opened, so stop it before + # waiting for tee EOF; otherwise its inherited descriptors keep both + # pipes alive forever. + if [[ "$monitor_pid" =~ ^[0-9]+$ ]]; then + kill "$monitor_pid" 2>/dev/null || true + wait "$monitor_pid" 2>/dev/null || true + monitor_pid="" + fi + exec {stdout_fd}>&- + exec {stderr_fd}>&- + wait "$stderr_tee_pid" 2>/dev/null || true + wait "$stdout_tee_pid" 2>/dev/null || true + stdout_fd="" + stderr_fd="" + stdout_tee_pid="" + stderr_tee_pid="" else set +e CODEX_AUTH_RUNNER=1 "$codex_cli" "${run_args[@]}" @@ -302,5 +327,3 @@ cmd_recover() { print_status_note recover "session $*" exec "$codex_cli" resume "$@" } - - diff --git a/lib/codex-auth/selector.sh b/lib/codex-auth/selector.sh index cf7395a..2a6a83a 100644 --- a/lib/codex-auth/selector.sh +++ b/lib/codex-auth/selector.sh @@ -132,6 +132,17 @@ selector_palette_status_compact() { esac } +selector_status_can_relogin() { + case "${1:-}" in + ""|offline|"no data"|"no usage") + return 1 + ;; + *) + return 0 + ;; + esac +} + selector_palette_table_widths_into() { local -n primary_ref="$1" local -n week_ref="$2" @@ -200,14 +211,14 @@ selector_palette_table_widths_into() { } selector_fast_fit_into() { - local -n out_ref="$1" + local -n fit_out_ref="$1" local text="$2" local width="$3" local align="${4:-left}" local trunc="…" local pad left right - out_ref="" + fit_out_ref="" (( width <= 0 )) && return 0 [[ "${CODEX_AUTH_ASCII:-0}" == "1" ]] && trunc="~" if (( ${#text} > width )); then @@ -221,21 +232,21 @@ selector_fast_fit_into() { (( pad < 0 )) && pad=0 case "$align" in right) - printf -v out_ref '%*s%s' "$pad" '' "$text" + printf -v fit_out_ref '%*s%s' "$pad" '' "$text" ;; center) left=$(((pad + 1) / 2)) right=$((pad - left)) - printf -v out_ref '%*s%s%*s' "$left" '' "$text" "$right" '' + printf -v fit_out_ref '%*s%s%*s' "$left" '' "$text" "$right" '' ;; *) - printf -v out_ref '%s%*s' "$text" "$pad" '' + printf -v fit_out_ref '%s%*s' "$text" "$pad" '' ;; esac } selector_usage_display_profile_name_into() { - local -n out_ref="$1" + local -n profile_name_out_ref="$1" local profile="$2" local role="${3:-}" local width="${4:-0}" @@ -243,102 +254,102 @@ selector_usage_display_profile_name_into() { if [[ "$profile" == "current" && "$role" != "active" && "$role" != "●" && "$role" != "*" ]]; then if [[ "$width" =~ ^[0-9]+$ && "$width" -gt 0 && "$width" -lt 7 ]]; then - out_ref="cur" + profile_name_out_ref="cur" else - out_ref="current" + profile_name_out_ref="current" fi elif [[ "$profile" == "Layth" && "$width" =~ ^[0-9]+$ && "$width" -gt 0 && "$width" -lt 5 ]]; then - out_ref="Lay" + profile_name_out_ref="Lay" elif [[ "$width" =~ ^[0-9]+$ && "$width" -gt 0 && ${#profile} -gt width && "$profile" =~ ^Layth([0-9]+)$ ]]; then suffix="${BASH_REMATCH[1]}" compact="L$suffix" - if (( ${#compact} <= width )); then out_ref="$compact"; else out_ref="${suffix:0:width}"; fi + if (( ${#compact} <= width )); then profile_name_out_ref="$compact"; else profile_name_out_ref="${suffix:0:width}"; fi elif [[ "$width" =~ ^[0-9]+$ && "$width" -gt 0 && ${#profile} -gt width && "$profile" == Layth.* ]]; then suffix="${profile#Layth.}" - if (( ${#suffix} <= width )); then out_ref="$suffix"; else out_ref="$profile"; fi + if (( ${#suffix} <= width )); then profile_name_out_ref="$suffix"; else profile_name_out_ref="$profile"; fi else - out_ref="$profile" + profile_name_out_ref="$profile" fi } selector_fast_display_status_into() { - local -n out_ref="$1" + local -n display_status_out_ref="$1" local status="$2" if [[ "$status" == stale\ * ]]; then status="${status#stale }" - if [[ "$status" == *cap ]]; then out_ref="old cap"; else out_ref="stale"; fi + if [[ "$status" == *cap ]]; then display_status_out_ref="old cap"; else display_status_out_ref="stale"; fi elif [[ "$status" == "week+5h cap" || "$status" == "week cap" || "$status" == "5h cap" ]]; then - out_ref="cap" + display_status_out_ref="cap" else case "$status" in - ok) out_ref="ready" ;; - login) out_ref="login" ;; - *) out_ref="$status" ;; + ok) display_status_out_ref="ready" ;; + login) display_status_out_ref="login" ;; + *) display_status_out_ref="$status" ;; esac fi } selector_fast_role_tone_into() { - local -n out_ref="$1" + local -n role_tone_out_ref="$1" case "${2:-}" in - best|b|◆|+) out_ref="accent" ;; - active|stay|s|●|\*) out_ref="active" ;; - use|u) out_ref="good" ;; - cap|c) out_ref="bad" ;; - login|l) out_ref="warn" ;; - warn) out_ref="warn" ;; - *) out_ref="muted" ;; + best|b|◆|+) role_tone_out_ref="accent" ;; + active|stay|s|●|\*) role_tone_out_ref="active" ;; + use|u) role_tone_out_ref="good" ;; + cap|c) role_tone_out_ref="bad" ;; + login|l) role_tone_out_ref="warn" ;; + warn) role_tone_out_ref="warn" ;; + *) role_tone_out_ref="muted" ;; esac } selector_fast_limit_tone_into() { - local -n out_ref="$1" + local -n limit_tone_out_ref="$1" local percent="${2:-}" local percent_value percent="${percent%\%}" if [[ ! "$percent" =~ ^-?[0-9]+$ ]]; then - out_ref="muted" + limit_tone_out_ref="muted" return 0 fi percent_value="$percent" if (( percent_value < 25 )); then - out_ref="bad" + limit_tone_out_ref="bad" elif (( percent_value < 50 )); then - out_ref="warn" + limit_tone_out_ref="warn" else - out_ref="good" + limit_tone_out_ref="good" fi } selector_fast_status_tone_into() { - local -n out_ref="$1" + local -n status_tone_out_ref="$1" local status="${2:-}" local valid="${3:-0}" if [[ "$status" == "offline" || "$status" == "refresh timeout" || "$status" == "refresh unavailable" ]]; then - out_ref="warn" + status_tone_out_ref="warn" elif [[ "$valid" != "0" || "$status" == "login" ]]; then - out_ref="muted" + status_tone_out_ref="muted" elif [[ "$status" == stale* ]]; then - out_ref="warn" + status_tone_out_ref="warn" elif [[ "$status" == "ok" ]]; then - out_ref="good" + status_tone_out_ref="good" else - out_ref="bad" + status_tone_out_ref="bad" fi } selector_fast_tone_code_into() { - local -n out_ref="$1" + local -n tone_code_out_ref="$1" local mode="$2" local tone="${3:-}" local color="" - out_ref="" + tone_code_out_ref="" usage_color_active || return 0 case "$tone" in good) color="166;227;161" ;; @@ -351,23 +362,23 @@ selector_fast_tone_code_into() { 38:muted) color="128;128;128" ;; 48:track) color="35;35;35" ;; esac - [[ -n "$color" ]] && printf -v out_ref '\033[%s;2;%sm' "$mode" "$color" + [[ -n "$color" ]] && printf -v tone_code_out_ref '\033[%s;2;%sm' "$mode" "$color" } selector_fast_tone_text_into() { - local -n out_ref="$1" + local -n tone_text_out_ref="$1" local text="$2" local tone="${3:-}" local prefix="" reset="" - [[ -n "$text" ]] || { out_ref=""; return 0; } + [[ -n "$text" ]] || { tone_text_out_ref=""; return 0; } selector_fast_tone_code_into prefix 38 "$tone" usage_color_active && reset=$'\033[0m' - out_ref="${prefix}${text}${reset}" + tone_text_out_ref="${prefix}${text}${reset}" } selector_fast_repeat_glyph_into() { - local -n out_ref="$1" + local -n repeat_out_ref="$1" local glyph="$2" local width="${3:-0}" local i value="" @@ -375,11 +386,11 @@ selector_fast_repeat_glyph_into() { for ((i = 0; i < width; i++)); do value+="$glyph" done - out_ref="$value" + repeat_out_ref="$value" } selector_fast_clamped_percent_into() { - local -n out_ref="$1" + local -n clamped_percent_out_ref="$1" local percent="$2" local percent_value @@ -388,11 +399,11 @@ selector_fast_clamped_percent_into() { percent_value="$percent" (( percent_value < 0 )) && percent_value=0 (( percent_value > 100 )) && percent_value=100 - out_ref="$percent_value" + clamped_percent_out_ref="$percent_value" } selector_fast_metric_cell_into() { - local -n out_ref="$1" + local -n metric_cell_out_ref="$1" local percent="$2" local width="$3" local tone label label_w=4 bar_w value units full partial used bg_style @@ -400,7 +411,7 @@ selector_fast_metric_cell_into() { local partial_glyphs=("" "▏" "▎" "▍" "▌" "▋" "▊" "▉") local label_cell bar_cell spaces prefix edge reset="" - out_ref="" + metric_cell_out_ref="" (( width <= 0 )) && return 0 [[ -n "$percent" ]] || percent="-" label="${percent%\%}" @@ -414,13 +425,13 @@ selector_fast_metric_cell_into() { printf -v spaces '%*s' "$((width - 3))" '' label_cell+="$spaces" fi - selector_fast_tone_text_into out_ref "$label_cell" muted + selector_fast_tone_text_into metric_cell_out_ref "$label_cell" muted return 0 fi if (( width < 8 )); then selector_fast_fit_into label_cell "$label" "$width" center - selector_fast_tone_text_into out_ref "$label_cell" "$tone" + selector_fast_tone_text_into metric_cell_out_ref "$label_cell" "$tone" return 0 fi @@ -480,49 +491,49 @@ selector_fast_metric_cell_into() { bar_cell+="$spaces" fi fi - out_ref="${label_cell} ${bar_cell}" + metric_cell_out_ref="${label_cell} ${bar_cell}" return 0 fi printf -v spaces '%*s' "$bar_w" '' - out_ref="${label_cell} ${spaces}" + metric_cell_out_ref="${label_cell} ${spaces}" } selector_fast_compact_status_into() { - local -n out_ref="$1" + local -n compact_status_out_ref="$1" local status="$2" local width="$3" case "$status" in "no data"|"no usage"|"n/a") - if (( width > 0 && width < 3 )); then out_ref="na"; else out_ref="$status"; fi + if (( width > 0 && width < 3 )); then compact_status_out_ref="na"; else compact_status_out_ref="$status"; fi ;; "login needed") - if (( width > 0 && width < 4 )); then out_ref="log"; elif (( width > 0 && width < 6 )); then out_ref="auth"; elif (( width > 0 && width < 13 )); then out_ref="login"; else out_ref="$status"; fi + if (( width > 0 && width < 4 )); then compact_status_out_ref="log"; elif (( width > 0 && width < 6 )); then compact_status_out_ref="auth"; elif (( width > 0 && width < 13 )); then compact_status_out_ref="login"; else compact_status_out_ref="$status"; fi ;; cap) - if (( width > 0 && width < 3 )); then out_ref="cp"; else out_ref="$status"; fi + if (( width > 0 && width < 3 )); then compact_status_out_ref="cp"; else compact_status_out_ref="$status"; fi ;; "old cap"|"both cap") - if (( width > 0 && width < 3 )); then out_ref="cp"; elif (( width > 0 && width < 7 )); then out_ref="cap"; else out_ref="$status"; fi + if (( width > 0 && width < 3 )); then compact_status_out_ref="cp"; elif (( width > 0 && width < 7 )); then compact_status_out_ref="cap"; else compact_status_out_ref="$status"; fi ;; ready) - if (( width > 0 && width < 5 )); then out_ref="ok"; else out_ref="$status"; fi + if (( width > 0 && width < 5 )); then compact_status_out_ref="ok"; else compact_status_out_ref="$status"; fi ;; offline) - if (( width > 0 && width < 3 )); then out_ref="of"; elif (( width > 0 && width < 7 )); then out_ref="off"; else out_ref="$status"; fi + if (( width > 0 && width < 3 )); then compact_status_out_ref="of"; elif (( width > 0 && width < 7 )); then compact_status_out_ref="off"; else compact_status_out_ref="$status"; fi ;; same) - if (( width > 0 && width < 4 )); then out_ref="="; else out_ref="$status"; fi + if (( width > 0 && width < 4 )); then compact_status_out_ref="="; else compact_status_out_ref="$status"; fi ;; *) - out_ref="$status" + compact_status_out_ref="$status" ;; esac } selector_palette_row_into() { - local -n out_ref="$1" + local -n palette_row_out_ref="$1" local primary="$2" local weekly="$3" local short="$4" @@ -564,15 +575,15 @@ selector_palette_row_into() { selector_fast_status_tone_into status_tone "$_raw_status" "$valid" selector_fast_tone_text_into status_cell "$status_cell" "$status_tone" printf -v spaces '%*s' "$metric_gap" '' - out_ref="${primary_cell} ${week_cell}${spaces}${short_cell}" + palette_row_out_ref="${primary_cell} ${week_cell}${spaces}${short_cell}" if (( status_w > 0 )); then printf -v spaces '%*s' "$status_gap" '' - out_ref+="${spaces}${status_cell}" + palette_row_out_ref+="${spaces}${status_cell}" fi } selector_menu_row_for_record_into() { - local -n out_ref="$1" + local -n menu_row_out_ref="$1" shift local default_profile="$1" local selector_palette_mode="$2" @@ -588,7 +599,7 @@ selector_menu_row_for_record_into() { local display display_status palette_profile action_role row_action row_profile_field active_row=0 if (( ! selector_palette_mode )); then - out_ref="$(selector_menu_row_for_record "$default_profile" "$selector_palette_mode" "$mode" "$role_w" "$profile_w" "$cell_w" "$status_w" "$bar_width" "$total_w" "$record")" + menu_row_out_ref="$(selector_menu_row_for_record "$default_profile" "$selector_palette_mode" "$mode" "$role_w" "$profile_w" "$cell_w" "$status_w" "$bar_width" "$total_w" "$record")" return $? fi @@ -602,8 +613,10 @@ selector_menu_row_for_record_into() { palette_profile="$profile" fi - if [[ "$valid" != "0" && "$status" == "login" ]]; then - selector_palette_row_into display "login $palette_profile" "-" "-" "login needed" "$status" "$valid" "$total_w" + if [[ "$valid" != "0" ]] && selector_status_can_relogin "$status"; then + selector_fast_display_status_into display_status "$status" + [[ "$status" == "login" ]] && display_status="login needed" + selector_palette_row_into display "login $palette_profile" "-" "-" "$display_status" "$status" "$valid" "$total_w" row_action="login" elif [[ "$valid" == "0" ]]; then selector_fast_display_status_into display_status "$status" @@ -634,7 +647,7 @@ selector_menu_row_for_record_into() { row_profile_field="-" fi - printf -v out_ref '%s\037action\037%s\037%s\037%s' "$active_row" "$row_action" "$row_profile_field" "$display" + printf -v menu_row_out_ref '%s\037action\037%s\037%s\037%s' "$active_row" "$row_action" "$row_profile_field" "$display" } selector_palette_metric_cell() { @@ -1375,11 +1388,13 @@ selector_menu_row_for_record() { fi fi - if [[ "$valid" != "0" && "$status" == "login" ]]; then + if [[ "$valid" != "0" ]] && selector_status_can_relogin "$status"; then + display_status="$(usage_display_status "$status" "normal")" + [[ "$status" == "login" ]] && display_status="login needed" if (( selector_palette_mode )); then - display="$(print_selector_palette_table_row "login $palette_profile" "-" "-" "login needed" "$status" "$valid" "$total_w")" + display="$(print_selector_palette_table_row "login $palette_profile" "-" "-" "$display_status" "$status" "$valid" "$total_w")" else - detail="needed" + detail="$display_status" display="$(print_selector_row "login" "$(usage_display_profile_name "$profile" "" "$selector_profile_w")" "$detail" "$action_w" "$selector_profile_w" "$total_w")" fi row_action="login" diff --git a/lib/codex-auth/usage.sh b/lib/codex-auth/usage.sh index 7fe1353..b07f749 100644 --- a/lib/codex-auth/usage.sh +++ b/lib/codex-auth/usage.sh @@ -65,7 +65,7 @@ state_update_profile() { local profile_name="$1" local fingerprint="$2" local payload="$3" - local updated tmp source + local updated tmp refresh_generation="${CODEX_AUTH_REFRESH_GENERATION:-}" updated="$(now_epoch)" if ! jq -e . >/dev/null 2>&1 <<<"$payload"; then @@ -73,21 +73,19 @@ state_update_profile() { fi tmp="$(mktemp "$CODEX_HOME/.tmp/auth-state.XXXXXX")" - if [[ -f "$STATE_FILE" ]] && jq -e . "$STATE_FILE" >/dev/null 2>&1; then - source="$STATE_FILE" - else - source="/dev/null" - fi - { flock -x 9 - if [[ "$source" == "/dev/null" ]]; then - printf '{}\n' + # Decide which source to merge only after taking the lock. Parallel + # writers on a brand-new cache must see the profile written just before + # them instead of each starting from an empty object and overwriting it. + if [[ -f "$STATE_FILE" ]] && jq -e . "$STATE_FILE" >/dev/null 2>&1; then + cat "$STATE_FILE" else - cat "$source" + printf '{}\n' fi | jq -c \ --arg name "$profile_name" \ --arg fp "$fingerprint" \ + --arg generation "$refresh_generation" \ --argjson updated "$updated" \ --argjson payload "$payload" ' .version = 1 @@ -98,6 +96,9 @@ state_update_profile() { fingerprint: $fp, payload: $payload } + | if $generation == "" then . else + .profiles[$name].refresh_generation = $generation + end ' > "$tmp" chmod 600 "$tmp" mv "$tmp" "$STATE_FILE" @@ -175,7 +176,7 @@ usage_payload_still_blocked() { if .error? != null then ((.error.message // .error.data.message // .error.data.error.message // .error // "") | tostring - | test("token has been invalidated|token_invalidated|invalidated oauth token|token_revoked"; "i")) + | test("token has been invalidated|token_invalidated|invalidated oauth token|token_revoked|access token could not be refreshed because you have since logged out or signed in to another account|please sign in again"; "i")) else (.rateLimitsByLimitId.codex // .rateLimits // null) as $r | if $r == null then @@ -198,10 +199,118 @@ usage_payload_transient_error() { ' <<<"$payload" >/dev/null 2>&1 } +usage_error_requires_login() { + local error_text="${1,,}" + + [[ "$error_text" == *"token has been invalidated"* \ + || "$error_text" == *"token_invalidated"* \ + || "$error_text" == *"invalidated oauth token"* \ + || "$error_text" == *"token_revoked"* \ + || "$error_text" == *"access token could not be refreshed because you have since logged out or signed in to another account"* \ + || "$error_text" == *"please sign in again"* ]] +} + +usage_auth_file_has_credential() { + local path="$1" + + auth_file_is_valid "$path" || return 1 + jq -e ' + (((.OPENAI_API_KEY? | type) == "string") and ((.OPENAI_API_KEY | length) > 0)) + or (((.tokens.refresh_token? | type) == "string") and ((.tokens.refresh_token | length) > 0)) + or (((.tokens.access_token? | type) == "string") and ((.tokens.access_token | length) > 0)) + ' "$path" >/dev/null 2>&1 +} + +usage_sync_probed_auth() { + local profile_file="$1" + local temp_auth="$2" + local expected_fingerprint="$3" + local expected_identity="${4:-}" + local expected_revision="${5:-}" + local current_fingerprint refreshed_fingerprint live_fingerprint="" + local current_revision refreshed_revision live_revision="" + local refreshed_identity="" profile_name marker_name marker_fp marker_identity marker_revision + local update_live=0 + + [[ -n "$expected_fingerprint" && -n "$expected_revision" ]] || return 0 + usage_auth_file_has_credential "$temp_auth" || return 0 + refreshed_fingerprint="$(credential_fingerprint "$temp_auth" || true)" + refreshed_revision="$(auth_file_revision "$temp_auth" || true)" + [[ -n "$refreshed_fingerprint" && -n "$refreshed_revision" ]] || return 0 + refreshed_identity="$(auth_file_account_identity "$temp_auth" || true)" + if [[ -n "$expected_identity" ]]; then + [[ "$refreshed_identity" == "$expected_identity" ]] || return 0 + else + [[ "$refreshed_fingerprint" == "$expected_fingerprint" ]] || return 0 + fi + + # Most usage probes do not rotate auth. Nothing can be copied in that case, + # so avoid serializing every parallel refresh on the global mutation lock. + # Usage state remains bound to the credential fingerprint below; the full + # revision CAS is only needed when the probe actually changed auth content. + if [[ "$refreshed_revision" == "$expected_revision" ]]; then + printf '%s\n' "$refreshed_fingerprint" + return 0 + fi + + acquire_mutation_lock + + # The app-server probe started from expected_fingerprint. Only copy its + # refreshed auth back while the saved profile still points at that exact + # credential. A profile switch or a separate token rotation wins the race. + usage_auth_file_has_credential "$profile_file" || return 0 + current_fingerprint="$(credential_fingerprint "$profile_file" || true)" + current_revision="$(auth_file_revision "$profile_file" || true)" + [[ "$current_fingerprint" == "$expected_fingerprint" \ + && "$current_revision" == "$expected_revision" ]] || return 0 + + # Live auth follows the refresh only when it still points at the same + # pre-probe credential. Never pull a concurrently switched session back. + if usage_auth_file_has_credential "$AUTH_FILE"; then + live_fingerprint="$(credential_fingerprint "$AUTH_FILE" || true)" + live_revision="$(auth_file_revision "$AUTH_FILE" || true)" + [[ "$live_fingerprint" == "$expected_fingerprint" \ + && "$live_revision" == "$expected_revision" ]] && update_live=1 + fi + + [[ "$(credential_fingerprint "$profile_file" || true)" == "$expected_fingerprint" \ + && "$(auth_file_revision "$profile_file" || true)" == "$expected_revision" ]] || return 0 + if ! cmp -s "$profile_file" "$temp_auth"; then + copy_auth_file_atomic "$temp_auth" "$profile_file" || return 1 + fi + if (( update_live )); then + if [[ "$(credential_fingerprint "$AUTH_FILE" || true)" == "$expected_fingerprint" \ + && "$(auth_file_revision "$AUTH_FILE" || true)" == "$expected_revision" ]] \ + && ! cmp -s "$AUTH_FILE" "$temp_auth" + then + copy_auth_file_atomic "$temp_auth" "$AUTH_FILE" || return 1 + fi + fi + + profile_name="$(basename "$profile_file" .json)" + marker_name="$(active_profile_marker_read || true)" + if [[ "$marker_name" == "$profile_name" ]]; then + marker_fp="$(active_profile_marker_field profile_fingerprint || true)" + marker_identity="$(active_profile_marker_field account_identity || true)" + marker_revision="$(active_profile_marker_field profile_revision || true)" + if [[ "$marker_fp" == "$expected_fingerprint" \ + && "$marker_revision" == "$expected_revision" \ + && ( -z "$expected_identity" || "$marker_identity" == "$expected_identity" ) ]]; then + active_profile_marker_write "$profile_name" "$profile_file" || true + fi + fi + + # The caller uses this receipt when updating usage state. No receipt means + # the profile changed during the probe and the payload must not be attached + # to the concurrently selected credential. + printf '%s\n' "$refreshed_fingerprint" +} + usage_payload_for_profile() { local profile_file="$1" local fingerprint="$2" - local profile_name payload cached_payload age + local profile_name payload cached_payload age error_text + local probe_fingerprint_file probe_fingerprint="" profile_name="$(basename "$profile_file" .json)" cached_payload="" @@ -232,7 +341,12 @@ usage_payload_for_profile() { return 0 fi - payload="$(usage_json_for_profile "$profile_file")" + probe_fingerprint_file="$(mktemp "$CODEX_HOME/.tmp/auth-probe-fingerprint.XXXXXX")" + payload="$(usage_json_for_profile "$profile_file" "$probe_fingerprint_file")" + if [[ -s "$probe_fingerprint_file" ]]; then + IFS= read -r probe_fingerprint < "$probe_fingerprint_file" || true + fi + rm -f "$probe_fingerprint_file" if [[ -n "$payload" && "$payload" != "null" ]] && usage_payload_transient_error "$payload"; then if [[ -n "$cached_payload" ]]; then @@ -264,21 +378,47 @@ usage_payload_for_profile() { return 0 fi - if [[ -n "$fingerprint" ]] && ! jq -e '.error? != null' >/dev/null 2>&1 <<<"$payload"; then - state_update_profile "$profile_name" "$fingerprint" "$payload" + if [[ -n "$probe_fingerprint" ]]; then + if ! jq -e '.error? != null' >/dev/null 2>&1 <<<"$payload"; then + state_update_profile "$profile_name" "$probe_fingerprint" "$payload" + else + # A revoked/invalidated session is an authoritative probe result, not a + # transport failure. Persist it so consumers stop presenting an old + # usage bar as though the refresh never happened. Transient failures + # above continue to serve stale cache without advancing the generation. + error_text="$(jq -r ' + .error.message // .error.data.message // .error.data.error.message // .error // "" + | if type == "string" then . else tostring end + ' <<<"$payload" 2>/dev/null || true)" + if usage_error_requires_login "$error_text"; then + state_update_profile "$profile_name" "$probe_fingerprint" "$payload" + fi + fi fi printf '%s\n' "$payload" } usage_json_for_profile() ( local profile_file="$1" - local temp_home payload + local probe_fingerprint_file="${2:-}" + local temp_home payload expected_fingerprint expected_identity="" expected_revision="" synced_fingerprint="" temp_home="$(mktemp -d "$CODEX_HOME/.tmp/auth-usage.XXXXXX")" trap '[[ -z "${temp_home:-}" ]] || rm -rf "$temp_home" 2>/dev/null || true' EXIT HUP INT TERM copy_auth_file_atomic "$profile_file" "$temp_home/auth.json" + if usage_auth_file_has_credential "$temp_home/auth.json"; then + expected_fingerprint="$(credential_fingerprint "$temp_home/auth.json" || true)" + expected_identity="$(auth_file_account_identity "$temp_home/auth.json" || true)" + expected_revision="$(auth_file_revision "$temp_home/auth.json" || true)" + else + expected_fingerprint="" + fi payload="$(usage_json_from_home "$temp_home")" + synced_fingerprint="$(usage_sync_probed_auth "$profile_file" "$temp_home/auth.json" "$expected_fingerprint" "$expected_identity" "$expected_revision" || true)" + if [[ -n "$probe_fingerprint_file" && -n "$synced_fingerprint" ]]; then + printf '%s\n' "$synced_fingerprint" > "$probe_fingerprint_file" + fi rm -rf "$temp_home" 2>/dev/null || true temp_home="" printf '%s\n' "$payload" @@ -322,8 +462,11 @@ usage_json_from_home() { if codex_launcher_needs_node "$codex_cli" && ! command -v node >/dev/null 2>&1; then return 0 fi - timeout_sec="${CODEX_AUTH_USAGE_TIMEOUT:-4}" - [[ "$timeout_sec" =~ ^[0-9]+$ && "$timeout_sec" -gt 0 ]] || timeout_sec=4 + # Current Codex may spend several seconds completing the reset-credit part + # of account/rateLimits/read. Keep headroom for that response before + # treating the probe as unavailable and falling back to cached usage. + timeout_sec="${CODEX_AUTH_USAGE_TIMEOUT:-12}" + [[ "$timeout_sec" =~ ^[0-9]+$ && "$timeout_sec" -gt 0 ]] || timeout_sec=12 if ! coproc CODEX_RATE { CODEX_AUTH_RUNNER=1 CODEX_HOME="$home_dir" "$codex_cli" app-server --listen stdio:// 2>/dev/null; }; then printf '%s\n' '{"error":{"message":"refresh unavailable"}}' @@ -373,6 +516,226 @@ usage_json_from_home() { printf '%s\n' "$payload" } +reset_idempotency_key() { + if command -v uuidgen >/dev/null 2>&1; then + uuidgen | tr '[:upper:]' '[:lower:]' + elif [[ -r /proc/sys/kernel/random/uuid ]]; then + IFS= read -r REPLY < /proc/sys/kernel/random/uuid + printf '%s\n' "$REPLY" + elif command -v python3 >/dev/null 2>&1; then + python3 -c 'import uuid; print(uuid.uuid4())' + else + printf 'codex-auth-%s-%s-%s-%s\n' "$(now_epoch)" "$$" "$RANDOM" "$RANDOM" + fi +} + +reset_credit_json_from_home() { + local home_dir="$1" + local idempotency_key="$2" + local codex_cli line line_id now payload rate_payload rate_in rate_out rate_pid + local outcome="" requested=0 consume_received=0 done=0 + local start timeout_sec + + codex_cli="$(codex_bin)" || { + printf '%s\n' '{"error":{"message":"reset unavailable"}}' + return 0 + } + if codex_launcher_needs_node "$codex_cli" && ! command -v node >/dev/null 2>&1; then + printf '%s\n' '{"error":{"message":"reset unavailable"}}' + return 0 + fi + timeout_sec="${CODEX_AUTH_RESET_TIMEOUT:-10}" + [[ "$timeout_sec" =~ ^[0-9]+$ && "$timeout_sec" -gt 0 ]] || timeout_sec=10 + + if ! coproc CODEX_RESET { CODEX_AUTH_RUNNER=1 CODEX_HOME="$home_dir" "$codex_cli" app-server --listen stdio:// 2>/dev/null; }; then + printf '%s\n' '{"error":{"message":"reset unavailable"}}' + return 0 + fi + + rate_out="${CODEX_RESET[0]}" + rate_in="${CODEX_RESET[1]}" + rate_pid="$CODEX_RESET_PID" + start="$(now_epoch)" + + if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.1.0"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 2>/dev/null >&"$rate_in"; then + usage_json_cleanup_coproc "$rate_in" "$rate_out" "$rate_pid" + printf '%s\n' '{"error":{"message":"reset unavailable"}}' + return 0 + fi + + while true; do + if [[ ! "$rate_out" =~ ^[0-9]+$ || ! -e "/proc/self/fd/$rate_out" ]]; then + if (( consume_received )); then + payload="$(jq -cn --arg outcome "$outcome" '{outcome:$outcome,refreshError:{message:"usage refresh unavailable"}}')" + else + payload='{"error":{"message":"reset unavailable"}}' + fi + break + fi + if IFS= read -r -t 0.25 line 2>/dev/null <&"$rate_out"; then + line_id="$(jq -r '.id // empty' <<<"$line" 2>/dev/null || true)" + if [[ "$line_id" == "1" && "$requested" == "0" ]]; then + printf '%s\n' '{"method":"initialized"}' 2>/dev/null >&"$rate_in" || true + jq -cn --arg key "$idempotency_key" \ + '{id:2,method:"account/rateLimitResetCredit/consume",params:{idempotencyKey:$key}}' \ + 2>/dev/null >&"$rate_in" || true + requested=1 + elif [[ "$line_id" == "2" ]]; then + outcome="$(jq -r '.result.outcome // empty' <<<"$line" 2>/dev/null || true)" + if [[ -n "$outcome" ]]; then + consume_received=1 + printf '%s\n' '{"id":3,"method":"account/rateLimits/read"}' 2>/dev/null >&"$rate_in" || true + else + payload="$(jq -c 'if has("error") then {error:.error} else {error:{message:"invalid reset response"}} end' <<<"$line" 2>/dev/null || true)" + [[ -n "$payload" ]] || payload='{"error":{"message":"invalid reset response"}}' + done=1 + fi + elif [[ "$line_id" == "3" && "$consume_received" == "1" ]]; then + if jq -e 'has("result")' <<<"$line" >/dev/null 2>&1; then + rate_payload="$(jq -c '.result' <<<"$line" 2>/dev/null || printf '{}')" + payload="$(jq -cn --arg outcome "$outcome" --argjson rateLimits "$rate_payload" \ + '{outcome:$outcome,rateLimits:$rateLimits}')" + else + payload="$(jq -cn --arg outcome "$outcome" --argjson error "$(jq -c '.error // {message:"usage refresh failed"}' <<<"$line")" \ + '{outcome:$outcome,refreshError:$error}')" + fi + done=1 + fi + else + now="$(now_epoch)" + if (( now - start >= timeout_sec )); then + if (( consume_received )); then + payload="$(jq -cn --arg outcome "$outcome" '{outcome:$outcome,refreshError:{message:"usage refresh timeout"}}')" + else + payload='{"error":{"message":"reset timeout"}}' + fi + done=1 + elif ! kill -0 "$rate_pid" 2>/dev/null; then + if (( consume_received )); then + payload="$(jq -cn --arg outcome "$outcome" '{outcome:$outcome,refreshError:{message:"usage refresh unavailable"}}')" + else + payload='{"error":{"message":"reset unavailable"}}' + fi + done=1 + fi + fi + (( done )) && break + done + + usage_json_cleanup_coproc "$rate_in" "$rate_out" "$rate_pid" + printf '%s\n' "${payload:-{\"error\":{\"message\":\"reset unavailable\"}}}" +} + +reset_credit_for_profile() ( + local profile_file="$1" + local idempotency_key="$2" + local profile_name temp_home result rate_payload + local expected_fingerprint="" expected_identity="" expected_revision="" + local synced_fingerprint="" + + profile_name="$(basename "$profile_file" .json)" + temp_home="$(mktemp -d "$CODEX_HOME/.tmp/auth-reset.XXXXXX")" + trap '[[ -z "${temp_home:-}" ]] || rm -rf "$temp_home" 2>/dev/null || true' EXIT HUP INT TERM + copy_auth_file_atomic "$profile_file" "$temp_home/auth.json" + expected_fingerprint="$(credential_fingerprint "$temp_home/auth.json" || true)" + expected_identity="$(auth_file_account_identity "$temp_home/auth.json" || true)" + expected_revision="$(auth_file_revision "$temp_home/auth.json" || true)" + + result="$(reset_credit_json_from_home "$temp_home" "$idempotency_key")" + synced_fingerprint="$(usage_sync_probed_auth \ + "$profile_file" "$temp_home/auth.json" "$expected_fingerprint" \ + "$expected_identity" "$expected_revision" || true)" + + rate_payload="$(jq -c '.rateLimits // empty' <<<"$result" 2>/dev/null || true)" + if [[ -n "$synced_fingerprint" && -n "$rate_payload" ]] \ + && ! jq -e '.error? != null' <<<"$rate_payload" >/dev/null 2>&1 + then + state_update_profile "$profile_name" "$synced_fingerprint" "$rate_payload" + fi + + rm -rf "$temp_home" 2>/dev/null || true + temp_home="" + printf '%s\n' "$result" +) + +cmd_reset() { + local name="$1" yes="${2:-}" source kind pending pending_tmp idempotency_key + local result outcome remaining error profile_fingerprint state_fingerprint count + + require_name "$name" + [[ "$yes" == "--yes" ]] || die "usage: codex-auth reset --yes" + ensure_dirs + source="$(profile_path "$name")" + [[ -f "$source" ]] || die "profile not found: $name" + require_auth_file "$source" + kind="$(auth_file_kind "$source" || true)" + [[ "$kind" == "chatgpt" ]] || die "earned resets require a ChatGPT profile: $name" + acquire_mutation_lock + + profile_fingerprint="$(credential_fingerprint "$source" || true)" + state_fingerprint="$(jq -r --arg name "$name" '.profiles[$name].fingerprint // empty' "$STATE_FILE" 2>/dev/null || true)" + [[ -n "$profile_fingerprint" && "$profile_fingerprint" == "$state_fingerprint" ]] \ + || die "profile changed since its usage check; refresh before using a reset: $name" + count="$(jq -r --arg name "$name" ' + .profiles[$name].payload.rateLimitResetCredits.availableCount + | numbers | select(. >= 0) | floor + ' "$STATE_FILE" 2>/dev/null || true)" + [[ "$count" =~ ^[0-9]+$ ]] \ + || die "reset availability is unknown; refresh usage first: $name" + if (( count == 0 )); then + print_error "no earned resets available for profile: $name" + return 3 + fi + + pending="$CODEX_HOME/.tmp/reset-$name.pending" + idempotency_key="" + if [[ -s "$pending" ]]; then + IFS= read -r idempotency_key < "$pending" || true + fi + if [[ -z "$idempotency_key" ]]; then + idempotency_key="$(reset_idempotency_key)" + pending_tmp="$(mktemp "$CODEX_HOME/.tmp/reset-$name.pending.XXXXXX")" + printf '%s\n' "$idempotency_key" > "$pending_tmp" + chmod 600 "$pending_tmp" + mv -f "$pending_tmp" "$pending" + fi + + result="$(reset_credit_for_profile "$source" "$idempotency_key")" + outcome="$(jq -r '.outcome // empty' <<<"$result" 2>/dev/null || true)" + case "$outcome" in + reset|alreadyRedeemed) + rm -f "$pending" + remaining="$(jq -r '.rateLimits.rateLimitResetCredits.availableCount // empty' <<<"$result" 2>/dev/null || true)" + [[ -n "$remaining" ]] || remaining="refresh pending" + if [[ "$outcome" == "alreadyRedeemed" ]]; then + print_result_block "reset already applied $name" \ + "profile"$'\t'"$name"$'\t'"active" \ + "remaining"$'\t'"$remaining"$'\t'"muted" + else + print_result_block "used reset $name" \ + "profile"$'\t'"$name"$'\t'"active" \ + "remaining"$'\t'"$remaining"$'\t'"muted" + fi + ;; + nothingToReset) + rm -f "$pending" + print_error "nothing eligible to reset for profile: $name" + return 3 + ;; + noCredit) + rm -f "$pending" + print_error "no earned resets available for profile: $name" + return 3 + ;; + *) + error="$(jq -r '.error.message // .error.data.message // .error // "reset failed" | if type == "string" then . else tostring end' <<<"$result" 2>/dev/null || true)" + [[ -n "$error" ]] || error="reset failed" + print_error "$error" + return 1 + ;; + esac +} + usage_limit_remaining_fields() { local used="$1" local window="$2" @@ -418,7 +781,7 @@ usage_record_for_profile() { IFS=$'\037' read -r kind err cache_age stale plan short_used short_window short_reset weekly_used weekly_window weekly_reset credits reached <<<"$extracted" if [[ "$kind" == "error" ]]; then - if [[ "$err" == *"token has been invalidated"* || "$err" == *"token_invalidated"* || "$err" == *"invalidated oauth token"* || "$err" == *"token_revoked"* ]]; then + if usage_error_requires_login "$err"; then err="login" elif [[ "$err" == "refresh timeout" || "$err" == "refresh unavailable" || "$err" == "no response" ]]; then err="offline" @@ -471,10 +834,16 @@ usage_record_for_profile() { canonical_usage_active_marks() { local seen_active=0 + local active_name="" local valid weekly_used short_used mark profile _plan weekly short status short_label weekly_reset short_reset _cache_age + active_name="$(resolve_active_profile_for_auth "$AUTH_FILE" || true)" + while IFS=$'\t' read -r valid weekly_used short_used mark profile _plan weekly short status short_label weekly_reset short_reset _cache_age; do [[ -n "$profile" ]] || continue + if [[ -n "$active_name" ]]; then + [[ "$profile" == "$active_name" ]] && mark="*" || mark=" " + fi if [[ "$mark" == "*" ]]; then if (( seen_active )); then mark="=" @@ -618,7 +987,15 @@ def record_for(profile, payload, mark, age): return ["1", "-1", "-1", mark, profile, "-", "-", "-", "no usage", "5h", "-", "-", age] if payload.get("error") is not None: err = nested_error(payload) - if any(part in err for part in ("token has been invalidated", "token_invalidated", "invalidated oauth token", "token_revoked")): + login_error = err.casefold() + if any(part in login_error for part in ( + "token has been invalidated", + "token_invalidated", + "invalidated oauth token", + "token_revoked", + "access token could not be refreshed because you have since logged out or signed in to another account", + "please sign in again", + )): err = "login" elif err in ("refresh timeout", "refresh unavailable", "no response"): err = "offline" @@ -783,6 +1160,7 @@ collect_usage_records() { require_auth_file "$profile_file" fingerprint="$(credential_fingerprint "$profile_file" || true)" payload="$(usage_payload_for_profile "$profile_file" "$fingerprint")" + fingerprint="$(credential_fingerprint "$profile_file" || true)" usage_record_for_profile "$profile_name" "$profile_file" "$payload" "$active_fp" "$fingerprint" > "$records_dir/$i.record" ) & active_jobs=$((active_jobs + 1)) @@ -884,6 +1262,7 @@ collect_usage_records_synced() { local profile_files=("$@") local records="" local profile_file profile_name fingerprint payload fallback_payload + local probe_fingerprint_file probe_fingerprint for profile_file in "${profile_files[@]}"; do require_auth_file "$profile_file" @@ -899,7 +1278,13 @@ collect_usage_records_synced() { fi if [[ -z "$payload" ]]; then - payload="$(usage_json_for_profile "$profile_file")" + probe_fingerprint="" + probe_fingerprint_file="$(mktemp "$CODEX_HOME/.tmp/auth-probe-fingerprint.XXXXXX")" + payload="$(usage_json_for_profile "$profile_file" "$probe_fingerprint_file")" + if [[ -s "$probe_fingerprint_file" ]]; then + IFS= read -r probe_fingerprint < "$probe_fingerprint_file" || true + fi + rm -f "$probe_fingerprint_file" if [[ -n "$payload" && "$payload" != "null" ]] && usage_payload_transient_error "$payload"; then payload="" fi @@ -911,11 +1296,12 @@ collect_usage_records_synced() { else payload='{"error":{"message":"no response"}}' fi - elif [[ -n "$fingerprint" ]]; then - state_update_profile "$profile_name" "$fingerprint" "$payload" + elif [[ -n "$probe_fingerprint" ]]; then + state_update_profile "$profile_name" "$probe_fingerprint" "$payload" fi fi + fingerprint="$(credential_fingerprint "$profile_file" || true)" records+="$(usage_record_for_profile "$profile_name" "$profile_file" "$payload" "$active_fp" "$fingerprint")"$'\n' done @@ -1119,20 +1505,6 @@ active_auth_fingerprint() { credential_fingerprint "$AUTH_FILE" || true } -normalize_codex_status_text() { - local text="$1" line normalized="" - - while IFS= read -r line || [[ -n "$line" ]]; do - [[ "$line" == "WARNING: proceeding, even though we could not update PATH:"* ]] && continue - line="${line//$'\t'/ }" - normalized+=" $line" - done <<<"$text" - while [[ "$normalized" == *" "* ]]; do normalized="${normalized// / }"; done - while [[ "$normalized" == " "* ]]; do normalized="${normalized# }"; done - while [[ "$normalized" == *" " ]]; do normalized="${normalized% }"; done - printf '%s\n' "$normalized" -} - acquire_refresh_lock_into() { local -n fd_ref="$1" local mode="${2:-wait}" refresh_lock_wait @@ -1205,8 +1577,10 @@ usage_best_selection_into() { cmd_usage() { ensure_dirs command -v jq >/dev/null 2>&1 || die "jq is required for usage output" + sync_active_profile_from_live local auto_switch=0 + local usage_sync_refresh=0 local args=() local refresh_lock_held=0 local refresh_lock_fd="" @@ -1234,9 +1608,16 @@ cmd_usage() { shift ;; --sync) - USAGE_FAST_REFRESH=0 + usage_sync_refresh=1 + USAGE_REFRESH=1 + USAGE_FAST_REFRESH=1 shift ;; + --help|-h) + source_codex_auth_libs help.sh + usage + return 0 + ;; --quiet|-q) USAGE_QUIET=1 shift @@ -1270,14 +1651,27 @@ cmd_usage() { done set -- "${args[@]}" + if [[ "$usage_sync_refresh" == "1" && "$auto_switch" != "1" && "$USAGE_QUIET" != "1" ]]; then + USAGE_SELECT=1 + fi + local profile_files=() profile_files_for_args_into profile_files 0 "$@" || return 0 if (( ${#profile_files[@]} == 0 )); then [[ "$USAGE_QUIET" == "1" ]] || print_empty_profiles return 0 fi + if [[ "$usage_sync_refresh" == "1" && "$USAGE_REFRESH" == "1" ]]; then + local sync_jobs="${CODEX_AUTH_SYNC_REFRESH_JOBS:-${#profile_files[@]}}" + [[ "$sync_jobs" =~ ^[0-9]+$ && "$sync_jobs" -gt 0 ]] || sync_jobs="${#profile_files[@]}" + (( sync_jobs > 12 )) && sync_jobs=12 + export CODEX_AUTH_REFRESH_JOBS="${CODEX_AUTH_REFRESH_JOBS:-$sync_jobs}" + export CODEX_AUTH_REFRESH_JOBS_MAX="${CODEX_AUTH_REFRESH_JOBS_MAX:-${CODEX_AUTH_SYNC_REFRESH_JOBS_MAX:-$sync_jobs}}" + fi if [[ "$USAGE_SELECT" == "1" && "$USAGE_REFRESH" == "1" && "$USAGE_FAST_REFRESH" == "1" ]] \ && selector_prompt_available \ + && [[ "${CODEX_AUTH_SELECT_BACKGROUND_REFRESH:-1}" == "1" ]] \ + && [[ "$usage_sync_refresh" != "1" ]] \ && [[ "${CODEX_AUTH_SELECT_SYNC_REFRESH:-0}" != "1" ]]; then start_usage_background_refresh "${profile_files[@]}" USAGE_REFRESH=0 @@ -1384,6 +1778,7 @@ cmd_usage() { cmd_refresh() { ensure_dirs command -v jq >/dev/null 2>&1 || die "jq is required for usage refresh" + sync_active_profile_from_live USAGE_QUIET=0 USAGE_CACHED=0 @@ -1454,6 +1849,7 @@ cmd_refresh() { cmd_auto() { ensure_dirs command -v jq >/dev/null 2>&1 || return 0 + sync_active_profile_from_live USAGE_QUIET=0 USAGE_CACHED=1 diff --git a/scripts/capture_tui.py b/scripts/capture_tui.py new file mode 100755 index 0000000..e9ef734 --- /dev/null +++ b/scripts/capture_tui.py @@ -0,0 +1,781 @@ +#!/usr/bin/env python3 +"""Generate sanitized, deterministic media from the real Textual application. + +The capture never reads a Codex auth file. It runs the production Textual app +against an in-memory backend, under a temporary HOME/CODEX_HOME, and renders +Rich's exported SVG through an isolated Chromium page with network access +blocked. All visible names, usage values, reset credits, and event times are +fixed demo data. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +from contextlib import ExitStack +from dataclasses import replace +from io import BytesIO +import json +import math +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import threading +import time +from typing import Callable +from unittest.mock import patch + +from PIL import Image +from playwright.async_api import Route, async_playwright +from textual.widgets import Button + +from codex_auth_tui.backend import OperationResult +from codex_auth_tui.engine import AutoEvent, Decision +from codex_auth_tui.models import ( + AccountSnapshot, + AccountsSnapshot, + AccountUsage, + UsageWindow, +) +from codex_auth_tui.paths import CodexPaths, resolve_paths +from codex_auth_tui.settings import AutoSettings +from codex_auth_tui.tui.app import CodexAuthApp +from codex_auth_tui.tui.autoview import AutoScreen +from codex_auth_tui.tui.dashboard import ResetScreen, WatchScreen +from codex_auth_tui.tui.modals import ConfirmModal + + +ROOT = Path(__file__).resolve().parents[1] +ASSETS_DIR = ROOT / "assets" +GRID_SIZE = (104, 20) +DEMO_NOW = 1_893_456_000.0 +FPS = 5 +FRAME_MS = 1_000 // FPS +BACKGROUND = "#292929" + +OUTPUT_NAMES = ( + "codex-auth-watch.png", + "codex-auth-auto.png", + "codex-auth-reset.png", + "codex-auth-demo.gif", + "codex-auth-demo.mp4", +) + +TIMELINE = ( + ("watch", 1_000), + ("reset-picker", 600), + ("reset-confirm", 1_400), + ("watch-return", 600), + ("switch-armed", 600), + ("switch-target", 800), + ("switched", 1_000), + ("auto", 1_600), +) + +FORBIDDEN_TEXT = ( + re.compile(r"sk-[A-Za-z0-9_-]{8,}"), + re.compile(r"eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), + re.compile(r"(?:access|refresh)[_-]?token", re.IGNORECASE), + re.compile(r"openai[_-]?api[_-]?key", re.IGNORECASE), + re.compile(r"authorization\s*[:=]", re.IGNORECASE), + re.compile(r"bearer\s+[A-Za-z0-9._-]+", re.IGNORECASE), + re.compile(r"chatgpt_(?:account|user)_id", re.IGNORECASE), + re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.IGNORECASE), + re.compile(r"/(?:home|Users)/[^\s<]+"), + re.compile(r"[A-Za-z]:\\Users\\[^\s<]+", re.IGNORECASE), + re.compile(r"\b(?:Layth|Qassem|lqassem)\b", re.IGNORECASE), +) + +FONT_FACE_BLOCK = re.compile(r"\s*@font-face\s*\{.*?\}\s*", re.DOTALL) +EXTERNAL_RESOURCE = re.compile( + r"""(?:url\(\s*["']?|(?:href|src)=["'])https?://""", + re.IGNORECASE, +) +VIEWBOX = re.compile( + r'viewBox="0 0 (?P[0-9]+(?:\.[0-9]+)?) ' + r'(?P[0-9]+(?:\.[0-9]+)?)"' +) + + +def _usage( + short: float, + weekly: float, + *, + short_reset: float, + weekly_reset: float, + reset_credits: int, +) -> AccountUsage: + return AccountUsage( + windows=( + UsageWindow("5h", short, 300, DEMO_NOW + short_reset), + UsageWindow("7d", weekly, 10_080, DEMO_NOW + weekly_reset), + ), + plan_type="pro", + fetched_at=DEMO_NOW, + age_s=0, + reset_credits_available=reset_credits, + ) + + +def _account( + name: str, + short: float, + weekly: float, + *, + active: bool = False, + reset_credits: int = 0, + short_reset: float = 3_600, + weekly_reset: float = 172_800, +) -> AccountSnapshot: + return AccountSnapshot( + name=name, + is_active=active, + kind="chatgpt", + switchable=True, + usage=_usage( + short, + weekly, + short_reset=short_reset, + weekly_reset=weekly_reset, + reset_credits=reset_credits, + ), + ) + + +class DemoBackend: + """Credential-free backend implementing the app's stable shell boundary.""" + + def __init__(self, paths: CodexPaths) -> None: + self.paths = paths + self.active = "primary" + self.accounts = [ + _account( + "primary", + 96, + 72, + active=True, + reset_credits=2, + short_reset=2_100, + weekly_reset=187_200, + ), + _account( + "backup", + 34, + 28, + reset_credits=1, + short_reset=5_400, + weekly_reset=432_000, + ), + _account( + "research", + 61, + 48, + reset_credits=0, + short_reset=3_000, + weekly_reset=345_600, + ), + ] + + def snapshot(self, now: float | None = None) -> AccountsSnapshot: + accounts = tuple( + replace(account, is_active=account.name == self.active) + for account in self.accounts + ) + return AccountsSnapshot( + active_name=self.active, + accounts=accounts, + taken_at=DEMO_NOW if now is None else float(now), + ) + + def refresh(self, names=None) -> OperationResult: + return OperationResult(True) + + def switch( + self, + name: str, + *, + expected_current: str | None = None, + expected_generation: str | None = None, + ) -> OperationResult: + if expected_current is not None and expected_current != self.active: + return OperationResult(False, 75, "active profile changed") + if not any(account.name == name for account in self.accounts): + return OperationResult(False, 64, "unknown demo profile") + self.active = name + return OperationResult(True) + + def save_current(self, name: str) -> OperationResult: + return OperationResult(False, 64, "saving is disabled in demo capture") + + def consume_reset(self, name: str) -> OperationResult: + # The recorded flow stops at the Cancel-default confirmation. This + # implementation is defensive in case a future capture confirms it. + updated: list[AccountSnapshot] = [] + for account in self.accounts: + if account.name != name: + updated.append(account) + continue + count = account.usage.reset_credits_available or 0 + usage = replace( + account.usage, + reset_credits_available=max(0, count - 1), + windows=tuple( + replace(window, pct=0.0) for window in account.usage.windows + ), + ) + updated.append(replace(account, usage=usage)) + self.accounts = updated + return OperationResult(True) + + def patched_ready(self) -> bool: + return True + + +class DemoEngine: + """Deterministic in-memory engine adapter for the real Auto screen.""" + + def __init__( + self, + backend, + settings, + on_event: Callable[[AutoEvent], None] | None = None, + dry_run: bool = True, + **_kwargs, + ) -> None: + self.backend = backend + self.settings = settings + self.on_event = on_event + self.dry_run = dry_run + self._stop = threading.Event() + + def run_loop(self) -> None: + if self.on_event is not None: + self.on_event( + AutoEvent( + "decision", + "hold: backup has the most available capacity", + DEMO_NOW, + ) + ) + self._stop.wait() + + def tick(self) -> Decision: + return Decision("hold", "best_available", current="backup") + + def stop(self) -> None: + self._stop.set() + + def wait_stopped(self, timeout=None) -> bool: + return self._stop.wait(timeout) + + +async def _settle(app: CodexAuthApp, pilot) -> None: + pending = [worker for worker in app.workers if worker.group != "engine"] + if pending: + await app.workers.wait_for_complete(pending) + await pilot.pause() + await pilot.pause() + + +def _assert_sanitized(label: str, text: str, temporary_root: Path) -> None: + candidates = [*FORBIDDEN_TEXT, re.compile(re.escape(str(temporary_root)))] + for pattern in candidates: + match = pattern.search(text) + if match is not None: + raise RuntimeError( + f"{label} contains forbidden capture text matching {pattern.pattern!r}" + ) + + +def _clean_svg(svg: str, temporary_root: Path, label: str) -> str: + _assert_sanitized(label, svg, temporary_root) + cleaned = FONT_FACE_BLOCK.sub("\n", svg) + if EXTERNAL_RESOURCE.search(cleaned): + raise RuntimeError( + f"{label} retained an external resource after font sanitization" + ) + return cleaned + + +async def _capture_states(paths: CodexPaths, temporary_root: Path) -> dict[str, str]: + import codex_auth_tui.tui.autoview as autoview + + backend = DemoBackend(paths) + app = CodexAuthApp( + backend, + settings=AutoSettings( + threshold=90, + interval_s=3_600, + cooldown_s=300, + hysteresis=10, + ), + ) + app.REDRAW_INTERVAL_S = 3_600 + app.PATCH_POLL_INTERVAL_S = 3_600 + app.MIN_NETWORK_INTERVAL_S = 3_600 + states: dict[str, str] = {} + + with ExitStack() as stack: + stack.enter_context(patch.object(autoview, "AutoSwitchEngine", DemoEngine)) + stack.enter_context(patch("time.time", return_value=DEMO_NOW)) + async with app.run_test(size=GRID_SIZE) as pilot: + await _settle(app, pilot) + + def capture(name: str) -> None: + svg = app.export_screenshot(title="Codex Rolling Auth", simplify=True) + states[name] = _clean_svg(svg, temporary_root, name) + + capture("watch") + + await pilot.press("u") + await _settle(app, pilot) + if not isinstance(app.screen, ResetScreen): + raise RuntimeError("reset picker did not open") + capture("reset-picker") + + await pilot.press("enter") + await _settle(app, pilot) + if not isinstance(app.screen, ConfirmModal): + raise RuntimeError("reset confirmation did not open") + if not app.screen.query_one("#no", Button).has_focus: + raise RuntimeError("reset confirmation did not default to Cancel") + capture("reset-confirm") + + await pilot.press("escape") + await pilot.pause() + await pilot.press("escape") + await _settle(app, pilot) + if not isinstance(app.screen, WatchScreen): + raise RuntimeError("capture did not return to Watch") + capture("watch-return") + + await pilot.press("s") + await pilot.pause() + capture("switch-armed") + + await pilot.press("down") + await pilot.pause() + capture("switch-target") + + await pilot.press("enter") + await _settle(app, pilot) + if backend.active != "backup": + raise RuntimeError("synthetic switch did not activate backup") + capture("switched") + + await pilot.press("a") + await _settle(app, pilot) + if not isinstance(app.screen, AutoScreen): + raise RuntimeError("Auto dry-run screen did not open") + capture("auto") + + if set(states) != {name for name, _duration in TIMELINE}: + raise RuntimeError("capture state set does not match the media timeline") + return states + + +def _find_local_font() -> tuple[Path, str]: + configured = os.environ.get("CODEX_AUTH_CAPTURE_FONT") + if configured: + font_path = Path(configured).expanduser().resolve() + else: + fc_match = shutil.which("fc-match") + if fc_match is None: + raise RuntimeError( + "fc-match is required, or set CODEX_AUTH_CAPTURE_FONT to a local mono font" + ) + result = subprocess.run( + [fc_match, "-f", "%{file}\n", "DejaVu Sans Mono"], + check=True, + capture_output=True, + text=True, + ) + font_path = Path(result.stdout.splitlines()[0]).resolve() + if not font_path.is_file(): + raise RuntimeError(f"capture font does not exist: {font_path}") + suffix = font_path.suffix.lower() + mime = { + ".otf": "font/otf", + ".ttf": "font/ttf", + ".woff": "font/woff", + ".woff2": "font/woff2", + }.get(suffix) + if mime is None: + raise RuntimeError(f"unsupported capture font type: {suffix}") + return font_path, mime + + +def _viewbox_size(svg: str) -> tuple[int, int]: + match = VIEWBOX.search(svg) + if match is None: + raise RuntimeError("Textual screenshot SVG has no numeric viewBox") + width = math.ceil(float(match.group("width"))) + height = math.ceil(float(match.group("height"))) + return width, height + + +def _even_canvas(image: Image.Image) -> Image.Image: + source = image.convert("RGB") + width = source.width + source.width % 2 + height = source.height + source.height % 2 + if source.size == (width, height): + return source + canvas = Image.new("RGB", (width, height), BACKGROUND) + canvas.paste(source, (0, 0)) + return canvas + + +async def _render_states(states: dict[str, str]) -> dict[str, Image.Image]: + font_path, font_mime = _find_local_font() + font_data = base64.b64encode(font_path.read_bytes()).decode("ascii") + rendered: dict[str, Image.Image] = {} + attempted_requests: list[str] = [] + + async with async_playwright() as playwright: + browser = await playwright.chromium.launch( + headless=True, + args=["--disable-lcd-text", "--font-render-hinting=none"], + ) + try: + for name, svg in states.items(): + width, height = _viewbox_size(svg) + context = await browser.new_context( + viewport={"width": width, "height": height}, + device_scale_factor=1, + color_scheme="dark", + locale="en-US", + timezone_id="UTC", + reduced_motion="reduce", + ) + page = await context.new_page() + + async def block_network(route: Route) -> None: + attempted_requests.append(route.request.url) + await route.abort() + + await page.route("**/*", block_network) + html = f""" +
{svg}
""" + await page.set_content(html, wait_until="load") + await page.evaluate("document.fonts.ready") + png = await page.locator("#capture").screenshot( + type="png", + animations="disabled", + caret="hide", + scale="css", + ) + with Image.open(BytesIO(png)) as decoded: + rendered[name] = _even_canvas(decoded.copy()) + await context.close() + finally: + await browser.close() + + if attempted_requests: + attempted = ", ".join(sorted(set(attempted_requests))) + raise RuntimeError(f"capture attempted a blocked external request: {attempted}") + sizes = {image.size for image in rendered.values()} + if len(sizes) != 1: + raise RuntimeError(f"capture frames have inconsistent sizes: {sorted(sizes)}") + return rendered + + +def _save_png(image: Image.Image, target: Path) -> None: + image.save(target, format="PNG", optimize=True, compress_level=9) + + +def _save_gif(images: dict[str, Image.Image], target: Path) -> None: + ordered = [images[name] for name, _duration in TIMELINE] + durations = [duration for _name, duration in TIMELINE] + width, height = ordered[0].size + atlas = Image.new("RGB", (width, height * len(ordered)), BACKGROUND) + for index, image in enumerate(ordered): + atlas.paste(image, (0, index * height)) + palette = atlas.quantize( + colors=128, + method=Image.Quantize.MEDIANCUT, + dither=Image.Dither.NONE, + ) + frames = [ + image.quantize(palette=palette, dither=Image.Dither.NONE) for image in ordered + ] + frames[0].save( + target, + format="GIF", + save_all=True, + append_images=frames[1:], + duration=durations, + loop=0, + disposal=2, + optimize=False, + ) + + +def _expanded_timeline(images: dict[str, Image.Image]) -> list[Image.Image]: + frames: list[Image.Image] = [] + for name, duration in TIMELINE: + if duration % FRAME_MS: + raise RuntimeError( + f"timeline duration is not aligned to {FPS} fps: {duration}" + ) + frames.extend([images[name]] * (duration // FRAME_MS)) + return frames + + +def _save_mp4(images: dict[str, Image.Image], target: Path) -> None: + ffmpeg = shutil.which("ffmpeg") + if ffmpeg is None: + raise RuntimeError("ffmpeg is required to generate codex-auth-demo.mp4") + frames = _expanded_timeline(images) + width, height = frames[0].size + command = [ + ffmpeg, + "-y", + "-hide_banner", + "-loglevel", + "error", + "-f", + "rawvideo", + "-pix_fmt", + "rgb24", + "-video_size", + f"{width}x{height}", + "-framerate", + str(FPS), + "-i", + "pipe:0", + "-an", + "-c:v", + "libx264", + "-preset", + "medium", + "-tune", + "stillimage", + "-crf", + "18", + "-pix_fmt", + "yuv420p", + "-threads", + "1", + "-map_metadata", + "-1", + "-movflags", + "+faststart", + "-f", + "mp4", + str(target), + ] + process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + assert process.stdin is not None + assert process.stderr is not None + try: + for frame in frames: + process.stdin.write(frame.tobytes()) + process.stdin.close() + error = process.stderr.read().decode("utf-8", errors="replace") + status = process.wait() + except BaseException: + process.kill() + process.wait() + raise + if status != 0: + raise RuntimeError(f"ffmpeg failed with status {status}: {error.strip()}") + + +def _ffprobe(path: Path) -> dict: + ffprobe = shutil.which("ffprobe") + if ffprobe is None: + raise RuntimeError("ffprobe is required to verify codex-auth-demo.mp4") + result = subprocess.run( + [ + ffprobe, + "-v", + "error", + "-show_entries", + "stream=codec_type,codec_name,width,height,pix_fmt,nb_frames", + "-show_entries", + "format=duration", + "-of", + "json", + str(path), + ], + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +def _verify_outputs(directory: Path, expected_size: tuple[int, int]) -> dict[str, str]: + details: dict[str, str] = {} + for name in ("codex-auth-watch.png", "codex-auth-auto.png", "codex-auth-reset.png"): + path = directory / name + with Image.open(path) as image: + if image.format != "PNG" or image.size != expected_size: + raise RuntimeError( + f"invalid PNG artifact {name}: {image.format} {image.size}" + ) + if path.stat().st_size > 500_000: + raise RuntimeError(f"PNG artifact is unexpectedly large: {name}") + details[name] = f"{expected_size[0]}x{expected_size[1]}" + + gif_path = directory / "codex-auth-demo.gif" + with Image.open(gif_path) as gif: + frame_count = getattr(gif, "n_frames", 1) + durations = [] + for index in range(frame_count): + gif.seek(index) + durations.append(int(gif.info.get("duration", 0))) + if gif.size != expected_size or frame_count != len(TIMELINE): + raise RuntimeError( + f"invalid GIF artifact: size={gif.size} frames={frame_count}" + ) + if durations != [duration for _name, duration in TIMELINE]: + raise RuntimeError(f"GIF timing changed: {durations}") + if gif.info.get("loop") != 0: + raise RuntimeError("GIF is not configured to loop") + if gif_path.stat().st_size > 5_000_000: + raise RuntimeError("GIF artifact exceeds 5 MB") + details[gif_path.name] = f"{frame_count} frames, {sum(durations) / 1000:.1f}s" + + mp4_path = directory / "codex-auth-demo.mp4" + probe = _ffprobe(mp4_path) + streams = probe.get("streams", []) + video = [stream for stream in streams if stream.get("codec_type") == "video"] + audio = [stream for stream in streams if stream.get("codec_type") == "audio"] + if len(video) != 1 or audio: + raise RuntimeError("MP4 must contain exactly one video stream and no audio") + stream = video[0] + expected_frames = len( + _expanded_timeline( + {name: Image.new("RGB", expected_size) for name, _ in TIMELINE} + ) + ) + if ( + stream.get("codec_name") != "h264" + or stream.get("pix_fmt") != "yuv420p" + or (int(stream.get("width", 0)), int(stream.get("height", 0))) != expected_size + or int(stream.get("nb_frames", 0)) != expected_frames + ): + raise RuntimeError(f"invalid MP4 video stream: {stream}") + if mp4_path.stat().st_size > 10_000_000: + raise RuntimeError("MP4 artifact exceeds 10 MB") + duration = float(probe.get("format", {}).get("duration", 0.0)) + expected_duration = expected_frames / FPS + if abs(duration - expected_duration) > 0.05: + raise RuntimeError(f"MP4 duration changed: {duration}") + details[mp4_path.name] = ( + f"{expected_frames} frames, {duration:.1f}s, H.264/yuv420p, no audio" + ) + return details + + +async def _build(directory: Path) -> dict[str, str]: + with tempfile.TemporaryDirectory(prefix="codex-auth-capture-home-") as raw_home: + temporary_root = Path(raw_home).resolve() + safe_home = temporary_root / "home" + codex_home = temporary_root / "codex-home" + safe_home.mkdir(mode=0o700) + codex_home.mkdir(mode=0o700) + + old_home = os.environ.get("HOME") + old_codex_home = os.environ.get("CODEX_HOME") + old_tz = os.environ.get("TZ") + try: + os.environ["HOME"] = str(safe_home) + os.environ["CODEX_HOME"] = str(codex_home) + os.environ["TZ"] = "UTC" + if hasattr(time, "tzset"): + time.tzset() + paths = resolve_paths() + if temporary_root not in paths.home.parents: + raise RuntimeError("capture CODEX_HOME escaped the temporary root") + paths.tmp_dir.mkdir(parents=True, mode=0o700) + states = await _capture_states(paths, temporary_root) + finally: + if old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = old_home + if old_codex_home is None: + os.environ.pop("CODEX_HOME", None) + else: + os.environ["CODEX_HOME"] = old_codex_home + if old_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = old_tz + if hasattr(time, "tzset"): + time.tzset() + + images = await _render_states(states) + expected_size = images["watch"].size + directory.mkdir(parents=True, exist_ok=True) + _save_png(images["watch"], directory / "codex-auth-watch.png") + _save_png(images["auto"], directory / "codex-auth-auto.png") + _save_png(images["reset-confirm"], directory / "codex-auth-reset.png") + _save_gif(images, directory / "codex-auth-demo.gif") + _save_mp4(images, directory / "codex-auth-demo.mp4") + return _verify_outputs(directory, expected_size) + + +def _publish(build_dir: Path, *, check: bool) -> None: + mismatches = [] + for name in OUTPUT_NAMES: + generated = build_dir / name + published = ASSETS_DIR / name + if check: + if ( + not published.is_file() + or generated.read_bytes() != published.read_bytes() + ): + mismatches.append(name) + continue + temporary = ASSETS_DIR / f".{name}.tmp" + shutil.copyfile(generated, temporary) + os.replace(temporary, published) + if mismatches: + raise RuntimeError("generated media differs: " + ", ".join(mismatches)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="regenerate in a temporary directory and compare with assets/", + ) + args = parser.parse_args() + + with tempfile.TemporaryDirectory(prefix="codex-auth-media-build-") as raw_build: + build_dir = Path(raw_build) + details = asyncio.run(_build(build_dir)) + _publish(build_dir, check=args.check) + + action = "verified" if args.check else "generated" + for name in OUTPUT_NAMES: + print(f"{action} {name}: {details[name]}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs index e632466..dfcc0bd 100755 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -12,13 +12,13 @@ rmSync(framesDir, { recursive: true, force: true }); mkdirSync(framesDir, { recursive: true }); const rows = [ - { action: "stay", profile: "Layth08", wk: 43, h5: 100, status: "ready", selected: true }, - { action: "use", profile: "Layth48", wk: 76, h5: 86, status: "ready" }, - { action: "use", profile: "Layth18", wk: 64, h5: 31, status: "ready" }, - { action: "cap", profile: "Layth38", wk: 0, h5: 100, status: "old cap" }, - { action: "cap", profile: "Layth", wk: 0, h5: 100, status: "cap" }, - { action: "cap", profile: "Layth.qassem", wk: 0, h5: 100, status: "cap" }, - { action: "cap", profile: "Layth28", wk: 0, h5: 100, status: "cap" }, + { action: "stay", profile: "work", wk: 43, h5: 100, status: "ready", selected: true }, + { action: "use", profile: "backup", wk: 76, h5: 86, status: "ready" }, + { action: "use", profile: "personal", wk: 64, h5: 31, status: "ready" }, + { action: "cap", profile: "lab", wk: 0, h5: 100, status: "old cap" }, + { action: "cap", profile: "spare-1", wk: 0, h5: 100, status: "cap" }, + { action: "cap", profile: "spare-2", wk: 0, h5: 100, status: "cap" }, + { action: "cap", profile: "spare-3", wk: 0, h5: 100, status: "cap" }, { action: "login", profile: "current", wk: null, h5: null, status: "login needed" }, ]; diff --git a/tests/run.sh b/tests/run.sh index 2e95eb7..5050707 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -3,6 +3,8 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" unset CODEX_AUTH_RUNNER CODEX_AUTH_CODEX_BIN +export CODEX_AUTH_TUI_SKIP_BOOTSTRAP=1 +export CODEX_AUTH_INSTALL_MAINTAIN_CRON=0 fail() { printf 'not ok - %s\n' "$*" >&2 @@ -39,6 +41,155 @@ write_fake_codex() { chmod 0755 "$path" } +write_named_fake_codex() { + local path="$1" + local name="$2" + local version="$3" + mkdir -p "$(dirname "$path")" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [[ "${1:-}" == "--version" ]]; then' \ + " printf '%s\\n' 'codex-cli $version'" \ + ' exit 0' \ + 'fi' \ + "printf '%s:%s\\n' '$name' \"\$*\" >> \"\$CODEX_TEST_LOG\"" > "$path" + chmod 0755 "$path" +} + +write_rate_limit_codex() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "--version" ]]; then + printf "codex-cli 9.8.7\n" + exit 0 +fi +printf "real:%s\n" "$*" >> "$CODEX_TEST_LOG" +if [[ "${1:-}" == "app-server" ]]; then + primary_used="${CODEX_TEST_PRIMARY_USED:-31}" + secondary_used="${CODEX_TEST_SECONDARY_USED:-22}" + reset_credits="${CODEX_TEST_RESET_CREDITS:-2}" + reset_credits_after="${CODEX_TEST_RESET_CREDITS_AFTER:-1}" + reset_outcome="${CODEX_TEST_RESET_OUTCOME:-reset}" + rate_error="${CODEX_TEST_RATE_ERROR:-}" + rate_sleep="${CODEX_TEST_RATE_SLEEP:-0}" + now="$(date +%s)" + primary_reset=$((now + 604800)) + secondary_reset=$((now + 18000)) + while IFS= read -r line; do + printf 'rpc:%s\n' "$line" >> "$CODEX_TEST_LOG" + if [[ "$line" == *'"id":1'* ]]; then + printf '%s\n' '{"id":1,"result":{}}' + elif [[ "$line" == *'"method":"account/rateLimitResetCredit/consume"'* ]]; then + if [[ "${CODEX_TEST_RESET_NO_RESPONSE:-0}" == "1" ]]; then + sleep "${CODEX_TEST_RESET_NO_RESPONSE_SLEEP:-2}" + exit 0 + fi + printf '{"id":2,"result":{"outcome":"%s"}}\n' "$reset_outcome" + elif [[ "$line" == *'"method":"account/rateLimits/read"'* ]]; then + [[ "$rate_sleep" == "0" ]] || sleep "$rate_sleep" + response_id="$(jq -r '.id' <<<"$line")" + if [[ -n "$rate_error" ]]; then + jq -cn --argjson id "$response_id" --arg message "$rate_error" \ + '{id:$id,error:{code:-32000,message:$message}}' + sleep "${CODEX_TEST_RESPONSE_HOLD:-0.05}" + [[ "$response_id" == "2" ]] && exit 0 + continue + fi + response_credits="$reset_credits" + [[ "$response_id" == "3" ]] && response_credits="$reset_credits_after" + printf '{"id":%s,"result":{"rateLimitsByLimitId":{"codex":{"planType":"pro","primary":{"usedPercent":%s,"windowDurationMins":10080,"resetsAt":%s},"secondary":{"usedPercent":%s,"windowDurationMins":300,"resetsAt":%s}}},"rateLimitResetCredits":{"availableCount":%s,"credits":null}}}\n' \ + "$response_id" "$primary_used" "$primary_reset" "$secondary_used" "$secondary_reset" "$response_credits" + # Keep the coprocess pipes alive long enough for the parent reader to + # consume the response before Bash reaps and unsets the managed FDs. + sleep "${CODEX_TEST_RESPONSE_HOLD:-0.05}" + [[ "$response_id" == "2" ]] && exit 0 + fi + done + exit 0 +fi +EOF + chmod 0755 "$path" +} + +write_chatgpt_auth() { + local path="$1" + local refresh_token="$2" + local access_token="$3" + local account_id="${4:-acct-test}" + local last_refresh="${5:-2026-07-10T00:00:00Z}" + local id_payload id_token + + mkdir -p "$(dirname "$path")" + id_payload="$(jq -cn --arg account "$account_id" --arg user "user-$account_id" \ + '{sub:$user,"https://api.openai.com/auth":{chatgpt_account_id:$account,chatgpt_user_id:$user}}' \ + | base64 -w0 | tr '+/' '-_' | tr -d '=')" + id_token="eyJhbGciOiJub25lIn0.$id_payload." + jq -cn \ + --arg refresh "$refresh_token" \ + --arg access "$access_token" \ + --arg account "$account_id" \ + --arg id_token "$id_token" \ + --arg last_refresh "$last_refresh" \ + '{auth_mode:"chatgpt",tokens:{refresh_token:$refresh,access_token:$access,account_id:$account,id_token:$id_token},last_refresh:$last_refresh}' \ + > "$path" + chmod 0600 "$path" +} + +write_rotating_rate_limit_codex() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "--version" ]]; then + printf "codex-cli 9.8.7\n" + exit 0 +fi +printf "real:%s\n" "$*" >> "$CODEX_TEST_LOG" +if [[ "${1:-}" == "app-server" ]]; then + replace_auth() { + local source="$1" + local target="$2" + local target_dir target_base tmp + target_dir="$(dirname "$target")" + target_base="$(basename "$target")" + mkdir -p "$target_dir" + tmp="$(mktemp "$target_dir/.${target_base}.test.XXXXXX")" + cp "$source" "$tmp" + chmod 0600 "$tmp" + mv -f "$tmp" "$target" + } + + now="$(date +%s)" + primary_reset=$((now + 604800)) + secondary_reset=$((now + 18000)) + while IFS= read -r line; do + if [[ "$line" == *'"id":1'* ]]; then + printf '%s\n' '{"id":1,"result":{}}' + elif [[ "$line" == *'"id":2'* ]]; then + replace_auth "$CODEX_TEST_ROTATED_AUTH_FILE" "$CODEX_HOME/auth.json" + if [[ -n "${CODEX_TEST_CONCURRENT_PROFILE_SOURCE:-}" && -n "${CODEX_TEST_CONCURRENT_PROFILE_TARGET:-}" ]]; then + replace_auth "$CODEX_TEST_CONCURRENT_PROFILE_SOURCE" "$CODEX_TEST_CONCURRENT_PROFILE_TARGET" + fi + if [[ -n "${CODEX_TEST_CONCURRENT_LIVE_SOURCE:-}" && -n "${CODEX_TEST_CONCURRENT_LIVE_TARGET:-}" ]]; then + replace_auth "$CODEX_TEST_CONCURRENT_LIVE_SOURCE" "$CODEX_TEST_CONCURRENT_LIVE_TARGET" + fi + if [[ "${CODEX_TEST_PROBE_FAIL:-0}" == "1" ]]; then + exit 42 + fi + printf '{"id":2,"result":{"rateLimitsByLimitId":{"codex":{"planType":"pro","primary":{"usedPercent":31,"windowDurationMins":10080,"resetsAt":%s},"secondary":{"usedPercent":22,"windowDurationMins":300,"resetsAt":%s}}}}}\n' \ + "$primary_reset" "$secondary_reset" + sleep "${CODEX_TEST_RESPONSE_HOLD:-0.05}" + exit 0 + fi + done + exit 0 +fi +EOF + chmod 0755 "$path" +} + write_fake_patched_codex() { local path="$1" mkdir -p "$(dirname "$path")" @@ -52,6 +203,24 @@ write_fake_patched_codex() { chmod 0755 "$path" } +write_updating_fake_codex() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "--version" ]]; then + printf "codex-cli 9.8.7\n" + exit 0 +fi +printf "real:%s\n" "$*" >> "$CODEX_TEST_LOG" +if [[ "${1:-}" == "update" ]]; then + printf '%s\n' '#!/usr/bin/env bash' 'printf "official replacement\n"' > "$CODEX_TEST_SHIM_PATH" + chmod 0755 "$CODEX_TEST_SHIM_PATH" +fi +EOF + chmod 0755 "$path" +} + write_fake_auth() { local path="$1" mkdir -p "$(dirname "$path")" @@ -93,23 +262,47 @@ test_shim_auto_execs_real_codex() { assert_contains 'auth:auto --quiet --no-background' "$log" assert_not_contains 'auth:run' "$log" - assert_not_contains 'auth:patch-codex --background --quiet' "$log" + assert_contains 'auth:patch-codex --print-bin --background --quiet' "$log" assert_contains 'real:--yolo resume abc' "$log" } -test_shim_background_build_is_opt_in() { +test_shim_background_build_can_be_disabled() { local tmp log tmp="$(mktemp -d)" log="$tmp/calls.log" write_fake_codex "$tmp/real-codex" write_fake_auth "$tmp/bin/codex-auth" - PATH="$tmp/bin:$PATH" CODEX_TEST_LOG="$log" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" CODEX_AUTH_PATCH_BUILD_AUTO=1 "$REPO_ROOT/bin/codex" resume abc + PATH="$tmp/bin:$PATH" CODEX_TEST_LOG="$log" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" CODEX_AUTH_PATCH_BUILD_AUTO=0 "$REPO_ROOT/bin/codex" resume abc - assert_contains 'auth:patch-codex --background --quiet' "$log" + assert_contains 'auth:patch-codex --print-bin --quiet' "$log" + assert_not_contains 'auth:patch-codex --print-bin --background --quiet' "$log" assert_contains 'real:--yolo resume abc' "$log" } +test_shim_update_restores_wrapper_and_starts_rebuild() { + local tmp log shim + tmp="$(mktemp -d)" + log="$tmp/calls.log" + shim="$tmp/bin/codex" + mkdir -p "$tmp/bin" + cp "$REPO_ROOT/bin/codex" "$shim" + chmod 0755 "$shim" + write_updating_fake_codex "$tmp/real-codex" + write_fake_auth "$tmp/auth-bin/codex-auth" + + PATH="$tmp/auth-bin:$PATH" \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_SHIM_PATH="$shim" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$shim" update + + assert_contains 'real:update' "$log" + assert_contains 'auth:patch-codex --print-bin --background --quiet' "$log" + assert_contains 'CODEX_AUTH_SHIM=1' "$shim" + assert_not_contains 'official replacement' "$shim" +} + test_shim_bypasses_auto_for_app_server() { local tmp log tmp="$(mktemp -d)" @@ -149,14 +342,70 @@ test_shim_uses_matching_patched_codex() { mkdir -p "$(dirname "$marker")" key="$(CODEX_HOME="$home" CODEX_AUTH_STOCK_CODEX_BIN="$tmp/real-codex" "$REPO_ROOT/bin/codex-auth" patch-codex --print-key)" - printf 'patch_version=1\nstock_key=%s\n' "$key" > "$marker" + printf 'patch_version=2\nstock_key=%s\n' "$key" > "$marker" PATH="$REPO_ROOT/bin:/usr/bin:/bin" CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" "$REPO_ROOT/bin/codex" resume abc + PATH="$REPO_ROOT/bin:/usr/bin:/bin" CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" "$REPO_ROOT/bin/codex" + PATH="$REPO_ROOT/bin:/usr/bin:/bin" CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" "$REPO_ROOT/bin/codex" --yolo + PATH="$REPO_ROOT/bin:/usr/bin:/bin" CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" "$REPO_ROOT/bin/codex" resume def --yolo assert_contains 'patched:--yolo resume abc rolling=1' "$log" + assert_contains 'patched:--yolo rolling=1' "$log" + assert_contains 'patched:resume def --yolo rolling=1' "$log" + assert_not_contains 'patched:--yolo resume def --yolo' "$log" assert_not_contains 'real:--yolo resume abc' "$log" } +test_patch_background_build_is_single_flight() { + local tmp home log harness clone_count i + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/git.log" + harness="$tmp/codex-auth-harness" + write_fake_codex "$tmp/real-codex" + mkdir -p "$tmp/bin" + cat > "$tmp/bin/git" <<'EOF' +#!/usr/bin/env bash +printf 'git:%s\n' "$*" >> "$CODEX_TEST_GIT_LOG" +if [[ "${1:-}" == "clone" ]]; then + sleep 1 +fi +exit 42 +EOF + cat > "$tmp/bin/cargo" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + cat > "$harness" </dev/null || true)" + [[ "$clone_count" == "1" ]] || fail "expected one detached patch build, got $clone_count" +} + test_patch_codex_keys_forwarding_wrapper_to_target() { local tmp home target wrapper target_key wrapper_key tmp="$(mktemp -d)" @@ -176,23 +425,301 @@ test_patch_codex_keys_forwarding_wrapper_to_target() { [[ "$wrapper_key" == "$target_key" ]] || fail "forwarding wrapper produced a different patch key" } +test_patch_codex_keys_native_binary_without_script_scan() { + local tmp home fast_key full_key native_bin + tmp="$(mktemp -d)" + home="$tmp/home" + native_bin="/bin/echo" + [[ -x "$native_bin" ]] || native_bin="/usr/bin/echo" + if ! command -v timeout >/dev/null 2>&1; then + printf 'skip - test_patch_codex_keys_native_binary_without_script_scan\n' + return 0 + fi + + fast_key="$(timeout 3 env HOME="$home" CODEX_HOME="$home" CODEX_AUTH_STOCK_CODEX_BIN="$native_bin" "$REPO_ROOT/bin/codex-auth" patch-codex --print-key)" + full_key="$(timeout 3 env HOME="$home" CODEX_HOME="$home" CODEX_AUTH_FAST_PATCH=0 CODEX_AUTH_STOCK_CODEX_BIN="$native_bin" "$REPO_ROOT/bin/codex-auth" patch-codex --print-key)" + + [[ -n "$fast_key" ]] || fail "native Codex fast patch key was empty" + [[ "$fast_key" == "$full_key" ]] || fail "native Codex fast and full patch keys differed" +} + +test_shim_prefers_standalone_current_and_tracks_repoint() { + local tmp home codex_home release_one release_two log + tmp="$(mktemp -d)" + home="$tmp/home" + codex_home="$home/custom-codex-home" + release_one="$codex_home/packages/standalone/releases/1.0.0-test" + release_two="$codex_home/packages/standalone/releases/2.0.0-test" + log="$tmp/calls.log" + write_named_fake_codex "$release_one/bin/codex" standalone-one 1.0.0 + write_named_fake_codex "$release_two/bin/codex" standalone-two 2.0.0 + write_named_fake_codex "$home/.bun/bin/codex-real" stale-bun 0.1.0 + mkdir -p "$codex_home/packages/standalone" + ln -s "$release_one" "$codex_home/packages/standalone/current" + + HOME="$home" CODEX_HOME="$codex_home" PATH="/usr/bin:/bin" CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 CODEX_AUTH_PATCH_AUTO=0 "$REPO_ROOT/bin/codex" ping + assert_contains 'standalone-one:--yolo ping' "$log" + assert_not_contains 'stale-bun:' "$log" + + rm -f "$codex_home/packages/standalone/current" + ln -s "$release_two" "$codex_home/packages/standalone/current" + HOME="$home" CODEX_HOME="$codex_home" PATH="/usr/bin:/bin" CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 CODEX_AUTH_PATCH_AUTO=0 "$REPO_ROOT/bin/codex" pong + assert_contains 'standalone-two:--yolo pong' "$log" + assert_not_contains 'stale-bun:' "$log" +} + +test_shim_supports_legacy_standalone_current_layout() { + local tmp home codex_home release log + tmp="$(mktemp -d)" + home="$tmp/home" + codex_home="$home/.codex" + release="$codex_home/packages/standalone/releases/legacy-test" + log="$tmp/calls.log" + write_named_fake_codex "$release/codex" standalone-legacy 0.99.0 + mkdir -p "$codex_home/packages/standalone" + ln -s "$release" "$codex_home/packages/standalone/current" + + HOME="$home" CODEX_HOME="$codex_home" PATH="/usr/bin:/bin" CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 CODEX_AUTH_PATCH_AUTO=0 "$REPO_ROOT/bin/codex" legacy + + assert_contains 'standalone-legacy:--yolo legacy' "$log" +} + +test_shim_skips_other_codex_auth_shim() { + local tmp home bad good log + tmp="$(mktemp -d)" + home="$tmp/home" + bad="$tmp/bad/codex" + good="$tmp/good/codex" + log="$tmp/calls.log" + mkdir -p "$(dirname "$bad")" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'CODEX_AUTH_SHIM=1' \ + 'exit 91' > "$bad" + chmod 0755 "$bad" + write_named_fake_codex "$good" fallback-real 3.0.0 + + HOME="$home" PATH="$tmp/good:/usr/bin:/bin" CODEX_TEST_LOG="$log" CODEX_AUTH_CODEX_BIN="$bad" CODEX_AUTH_AUTO=0 CODEX_AUTH_PATCH_AUTO=0 "$REPO_ROOT/bin/codex" recurse-check + + assert_contains 'fallback-real:--yolo recurse-check' "$log" +} + +test_shim_uses_matching_patched_standalone_codex() { + local tmp home codex_home release log key marker patched_bin + tmp="$(mktemp -d)" + home="$tmp/home" + codex_home="$home/.codex" + release="$codex_home/packages/standalone/releases/9.8.7-test" + log="$tmp/calls.log" + marker="$codex_home/patched-codex/current.env" + patched_bin="$codex_home/patched-codex/bin/codex" + write_fake_codex "$release/bin/codex" + write_fake_patched_codex "$patched_bin" + mkdir -p "$codex_home/packages/standalone" "$(dirname "$marker")" + ln -s "$release" "$codex_home/packages/standalone/current" + key="$(CODEX_HOME="$codex_home" CODEX_AUTH_STOCK_CODEX_BIN="$release/bin/codex" "$REPO_ROOT/bin/codex-auth" patch-codex --print-key)" + printf 'patch_version=2\nstock_key=%s\n' "$key" > "$marker" + + HOME="$home" CODEX_HOME="$codex_home" PATH="$REPO_ROOT/bin:/usr/bin:/bin" CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 "$REPO_ROOT/bin/codex" resume standalone + + assert_contains 'patched:--yolo resume standalone rolling=1' "$log" + assert_not_contains 'real:--yolo resume standalone' "$log" +} + +test_patch_codex_discovers_standalone_current() { + local tmp home codex_home release implicit_fast implicit_full explicit + tmp="$(mktemp -d)" + home="$tmp/home" + codex_home="$home/.codex" + release="$codex_home/packages/standalone/releases/9.8.7-test" + write_fake_codex "$release/bin/codex" + write_named_fake_codex "$home/.bun/bin/codex-real" stale-bun 0.1.0 + mkdir -p "$codex_home/packages/standalone" + ln -s "$release" "$codex_home/packages/standalone/current" + + explicit="$(HOME="$home" CODEX_HOME="$codex_home" CODEX_AUTH_STOCK_CODEX_BIN="$release/bin/codex" "$REPO_ROOT/bin/codex-auth" patch-codex --print-key)" + implicit_fast="$(HOME="$home" CODEX_HOME="$codex_home" PATH="/usr/bin:/bin" "$REPO_ROOT/bin/codex-auth" patch-codex --print-key)" + implicit_full="$(HOME="$home" CODEX_HOME="$codex_home" PATH="/usr/bin:/bin" CODEX_AUTH_FAST_PATCH=0 "$REPO_ROOT/bin/codex-auth" patch-codex --print-key)" + + [[ "$implicit_fast" == "$explicit" ]] || fail "fast patch lookup did not select standalone current" + [[ "$implicit_full" == "$explicit" ]] || fail "full patch lookup did not select standalone current" +} + +test_patch_generation_selection_is_key_specific() { + local tmp home root stock_a stock_b key_a key_b path_a path_b + tmp="$(mktemp -d)" + home="$tmp/home" + root="$home/patched" + stock_a="$tmp/stock-a" + stock_b="$tmp/stock-b" + write_named_fake_codex "$stock_a" stock-a 1.2.3 + write_named_fake_codex "$stock_b" stock-b 1.2.4 + key_a="$(CODEX_HOME="$home" CODEX_AUTH_PATCH_CODEX_DIR="$root" CODEX_AUTH_STOCK_CODEX_BIN="$stock_a" "$REPO_ROOT/bin/codex-auth" patch-codex --print-key)" + key_b="$(CODEX_HOME="$home" CODEX_AUTH_PATCH_CODEX_DIR="$root" CODEX_AUTH_STOCK_CODEX_BIN="$stock_b" "$REPO_ROOT/bin/codex-auth" patch-codex --print-key)" + mkdir -p "$root/generations/$key_a" "$root/generations/$key_b" + write_fake_patched_codex "$root/generations/$key_a/codex" + write_fake_patched_codex "$root/generations/$key_b/codex" + printf 'patch_version=2\nstock_key=%s\n' "$key_a" > "$root/generations/$key_a/current.env" + printf 'patch_version=2\nstock_key=%s\n' "$key_b" > "$root/generations/$key_b/current.env" + + path_a="$(CODEX_HOME="$home" CODEX_AUTH_PATCH_CODEX_DIR="$root" CODEX_AUTH_STOCK_CODEX_BIN="$stock_a" "$REPO_ROOT/bin/codex-auth" patch-codex --print-bin --quiet)" + path_b="$(CODEX_HOME="$home" CODEX_AUTH_PATCH_CODEX_DIR="$root" CODEX_AUTH_STOCK_CODEX_BIN="$stock_b" "$REPO_ROOT/bin/codex-auth" patch-codex --print-bin --quiet)" + + [[ "$path_a" == "$root/generations/$key_a/codex" ]] || fail "stock A did not select its immutable generation" + [[ "$path_b" == "$root/generations/$key_b/codex" ]] || fail "stock B did not select its immutable generation" + [[ "$path_a" != "$path_b" ]] || fail "different stock keys shared one patched binary" +} + +test_patch_missing_exact_tag_fails_closed() { + local tmp home root source stock key + tmp="$(mktemp -d)" + home="$tmp/home" + root="$home/patched" + source="$tmp/source" + stock="$tmp/stock" + write_named_fake_codex "$stock" stock 9.8.7 + mkdir -p "$source/codex-rs/login/src/auth" + : > "$source/codex-rs/login/src/auth/manager.rs" + git init -q "$source" + key="$(CODEX_HOME="$home" CODEX_AUTH_PATCH_CODEX_DIR="$root" CODEX_AUTH_STOCK_CODEX_BIN="$stock" "$REPO_ROOT/bin/codex-auth" patch-codex --print-key)" + + if CODEX_HOME="$home" \ + CODEX_AUTH_PATCH_CODEX_DIR="$root" \ + CODEX_AUTH_PATCH_RETRY_SECS=60 \ + CODEX_AUTH_STOCK_CODEX_BIN="$stock" \ + CODEX_AUTH_FAST_PATCH=0 \ + "$REPO_ROOT/bin/codex-auth" patch-codex --foreground --source-dir "$source" --no-fetch --quiet \ + > "$tmp/out" 2>&1 + then + fail "automatic patch build accepted source without the exact release tag" + fi + + assert_contains 'exact Codex source tag is not available yet: rust-v9.8.7' "$tmp/out" + [[ ! -e "$root/generations/$key/codex" ]] || fail "missing exact tag published a patched generation" + [[ -f "$root/failures/$key.env" ]] || fail "missing exact tag did not record retry backoff" +} + +test_maintain_waits_for_installer_then_restores_shim() { + local tmp home codex_home root release stock target key lock_fd + tmp="$(mktemp -d)" + home="$tmp/home" + codex_home="$home/.codex" + root="$codex_home/patched-codex" + release="$codex_home/packages/standalone/releases/9.8.7-test" + stock="$release/bin/codex" + target="$tmp/bin/codex" + write_fake_codex "$stock" + mkdir -p "$codex_home/packages/standalone" "$tmp/bin" + ln -s "$release" "$codex_home/packages/standalone/current" + ln -s "$codex_home/packages/standalone/current/bin/codex" "$target" + key="$(HOME="$home" CODEX_HOME="$codex_home" CODEX_AUTH_STOCK_CODEX_BIN="$stock" "$REPO_ROOT/bin/codex-auth" patch-codex --print-key)" + mkdir -p "$root/generations/$key" + write_fake_patched_codex "$root/generations/$key/codex" + printf 'patch_version=2\nstock_key=%s\n' "$key" > "$root/generations/$key/current.env" + + exec {lock_fd}>"$codex_home/packages/standalone/install.lock" + flock -n "$lock_fd" + HOME="$home" CODEX_HOME="$codex_home" CODEX_AUTH_SHIM_PATH="$target" CODEX_AUTH_SHIM_TEMPLATE="$REPO_ROOT/bin/codex" \ + "$REPO_ROOT/bin/codex-auth" maintain --quiet + [[ -L "$target" ]] || fail "maintainer rewrote codex while the official installer lock was held" + flock -u "$lock_fd" + exec {lock_fd}>&- + + HOME="$home" CODEX_HOME="$codex_home" CODEX_AUTH_SHIM_PATH="$target" CODEX_AUTH_SHIM_TEMPLATE="$REPO_ROOT/bin/codex" \ + "$REPO_ROOT/bin/codex-auth" maintain --quiet + [[ ! -L "$target" ]] || fail "maintainer did not replace the official standalone link" + assert_contains 'CODEX_AUTH_SHIM=1' "$target" +} + +test_install_maintenance_cron_is_idempotent() { + local tmp home prefix fake_bin cron_state begin_count + tmp="$(mktemp -d)" + home="$tmp/home" + prefix="$tmp/prefix" + fake_bin="$tmp/fake-bin" + cron_state="$tmp/crontab" + mkdir -p "$fake_bin" + printf '%s\n' '0 9 * * 0 /existing/weekly-job' > "$cron_state" + cat > "$fake_bin/crontab" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "-l" ]]; then + cat "$CODEX_TEST_CRONTAB_FILE" +else + cp "$1" "$CODEX_TEST_CRONTAB_FILE" +fi +EOF + chmod 0755 "$fake_bin/crontab" + write_fake_codex "$prefix/bin/codex" + + for _ in 1 2; do + HOME="$home" CODEX_HOME="$home/.codex" PREFIX="$prefix" PATH="$fake_bin:$PATH" \ + CODEX_TEST_CRONTAB_FILE="$cron_state" CODEX_AUTH_TUI_SKIP_BOOTSTRAP=1 \ + CODEX_AUTH_INSTALL_MAINTAIN_CRON=1 "$REPO_ROOT/install.sh" --wrap-codex >/dev/null + done + + assert_contains '0 9 * * 0 /existing/weekly-job' "$cron_state" + assert_contains "'$prefix/bin/codex-auth' maintain --quiet" "$cron_state" + begin_count="$(grep -Fc '# BEGIN codex-auth maintain' "$cron_state")" + [[ "$begin_count" == "1" ]] || fail "installer added duplicate codex-auth cron entries" +} + test_install_promotes_existing_codex_to_real() { - local tmp prefix log + local tmp home prefix log tmp="$(mktemp -d)" + home="$tmp/home" prefix="$tmp/prefix" log="$tmp/calls.log" write_fake_codex "$prefix/bin/codex" - PREFIX="$prefix" "$REPO_ROOT/install.sh" --wrap-codex >/dev/null + HOME="$home" CODEX_HOME="$home/.codex" PREFIX="$prefix" "$REPO_ROOT/install.sh" --wrap-codex >/dev/null [[ -x "$prefix/bin/codex-real" ]] || fail "codex-real was not installed" - CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 "$prefix/bin/codex" ping + HOME="$home" CODEX_HOME="$home/.codex" CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 CODEX_AUTH_PATCH_AUTO=0 "$prefix/bin/codex" ping assert_contains 'real:--yolo ping' "$log" } +test_install_refreshes_stale_real_from_standalone_current() { + local tmp home codex_home prefix release log backup real_target + local backups=() + tmp="$(mktemp -d)" + home="$tmp/home" + codex_home="$home/.codex" + prefix="$home/.local" + release="$codex_home/packages/standalone/releases/4.5.6-test" + log="$tmp/calls.log" + write_named_fake_codex "$release/bin/codex" standalone-current 4.5.6 + write_named_fake_codex "$prefix/bin/codex-real" stale-real 0.1.0 + mkdir -p "$codex_home/packages/standalone" "$prefix/bin" + ln -s "$release" "$codex_home/packages/standalone/current" + ln -s "$codex_home/packages/standalone/current/bin/codex" "$prefix/bin/codex" + + HOME="$home" CODEX_HOME="$codex_home" PREFIX="$prefix" "$REPO_ROOT/install.sh" --wrap-codex >/dev/null + + if [[ -L "$prefix/bin/codex-real" ]]; then + real_target="$(readlink "$prefix/bin/codex-real")" + [[ "$real_target" == "$codex_home/packages/standalone/current/bin/codex" ]] || fail "codex-real did not preserve standalone current" + [[ "$real_target" != "$release/bin/codex" ]] || fail "codex-real pinned one standalone release" + else + assert_contains "$codex_home/packages/standalone/current/bin/codex" "$prefix/bin/codex-real" + assert_not_contains "$release/bin/codex" "$prefix/bin/codex-real" + fi + shopt -s nullglob + backups=("$prefix/bin"/codex.backup.*) + shopt -u nullglob + [[ "${#backups[@]}" == "1" ]] || fail "standalone wrap did not create one backup link" + backup="${backups[0]}" + [[ -L "$backup" ]] || fail "standalone backup was dereferenced instead of preserved as a symlink" + + HOME="$home" CODEX_HOME="$codex_home" PATH="$prefix/bin:/usr/bin:/bin" CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 CODEX_AUTH_PATCH_AUTO=0 "$prefix/bin/codex" installed + assert_contains 'standalone-current:--yolo installed' "$log" + assert_not_contains 'stale-real:' "$log" +} + test_install_recovers_real_from_old_backup() { - local tmp prefix log + local tmp home prefix log tmp="$(mktemp -d)" + home="$tmp/home" prefix="$tmp/prefix" log="$tmp/calls.log" mkdir -p "$prefix/bin" @@ -203,10 +730,10 @@ test_install_recovers_real_from_old_backup() { chmod 0755 "$prefix/bin/codex" write_fake_codex "$prefix/bin/codex.backup.202605180001" - PREFIX="$prefix" "$REPO_ROOT/install.sh" --wrap-codex >/dev/null + HOME="$home" CODEX_HOME="$home/.codex" PREFIX="$prefix" "$REPO_ROOT/install.sh" --wrap-codex >/dev/null [[ -x "$prefix/bin/codex-real" ]] || fail "codex-real was not recovered from backup" - CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 "$prefix/bin/codex" ping + HOME="$home" CODEX_HOME="$home/.codex" CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 CODEX_AUTH_PATCH_AUTO=0 "$prefix/bin/codex" ping assert_contains 'real:--yolo ping' "$log" assert_not_contains 'codex-auth run' "$prefix/bin/codex-real" } @@ -228,7 +755,7 @@ test_install_recovers_real_from_path() { HOME="$tmp/home" PATH="$realbin:/usr/bin:/bin" PREFIX="$prefix" "$REPO_ROOT/install.sh" --wrap-codex >/dev/null [[ -x "$prefix/bin/codex-real" ]] || fail "codex-real was not recovered from PATH" - CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 "$prefix/bin/codex" ping + HOME="$tmp/home" CODEX_HOME="$tmp/home/.codex" CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 CODEX_AUTH_PATCH_AUTO=0 "$prefix/bin/codex" ping assert_contains 'real:--yolo ping' "$log" } @@ -249,7 +776,7 @@ test_install_recovers_real_from_home_bun() { HOME="$fake_home" PATH="/usr/bin:/bin" PREFIX="$prefix" "$REPO_ROOT/install.sh" --wrap-codex >/dev/null [[ -x "$prefix/bin/codex-real" ]] || fail "codex-real was not recovered from HOME bun" - CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 "$prefix/bin/codex" ping + HOME="$fake_home" CODEX_HOME="$fake_home/.codex" CODEX_TEST_LOG="$log" CODEX_AUTH_AUTO=0 CODEX_AUTH_PATCH_AUTO=0 "$prefix/bin/codex" ping assert_contains 'real:--yolo ping' "$log" } @@ -263,12 +790,299 @@ test_install_installs_codex_auth_libs() { PREFIX="$prefix" "$REPO_ROOT/install.sh" >/dev/null [[ -r "$prefix/lib/codex-auth/core.sh" ]] || fail "core lib was not installed" [[ -r "$prefix/lib/codex-auth/usage.sh" ]] || fail "usage lib was not installed" + [[ -r "$prefix/lib/codex-auth/rolling-auth-v2.patch" ]] || fail "rolling auth source patch was not installed" + [[ -x "$prefix/bin/codex-auth-tui" ]] || fail "TUI launcher was not installed" + [[ -r "$prefix/lib/codex-auth/tui/pyproject.toml" ]] || fail "TUI project was not installed" + [[ -r "$prefix/lib/codex-auth/tui/src/codex_auth_tui/__init__.py" ]] || fail "TUI package was not installed" + [[ ! -d "$prefix/lib/codex-auth/tui/.venv" ]] || fail "TUI bootstrap opt-out created a venv" TERM=dumb CODEX_HOME="$home" "$prefix/bin/codex-auth" paths >"$output" assert_contains 'auth' "$output" assert_contains 'profiles' "$output" } +test_install_bootstraps_tui_with_private_uv_project() { + local tmp prefix fake_bin uv_log + tmp="$(mktemp -d)" + prefix="$tmp/prefix" + fake_bin="$tmp/bin" + uv_log="$tmp/uv.log" + mkdir -p "$fake_bin" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "%s\\n" "$@" > "$CODEX_TEST_UV_LOG"' \ + '[[ "${1:-}" == "--native-tls" && "${2:-}" == "sync" && "${3:-}" == "--project" && -n "${4:-}" && "${5:-}" == "--no-dev" && "${6:-}" == "--locked" ]] || exit 64' \ + 'mkdir -p "$4/.venv/bin"' \ + 'printf "#!/usr/bin/env bash\\nexit 0\\n" > "$4/.venv/bin/python"' \ + 'chmod 0755 "$4/.venv/bin/python"' \ + 'printf "#!/usr/bin/env bash\\n" > "$4/.venv/bin/codex-auth-tui"' \ + 'chmod 0755 "$4/.venv/bin/codex-auth-tui"' > "$fake_bin/uv" + chmod 0755 "$fake_bin/uv" + + PATH="$fake_bin:$PATH" CODEX_TEST_UV_LOG="$uv_log" CODEX_AUTH_TUI_SKIP_BOOTSTRAP=0 PREFIX="$prefix" "$REPO_ROOT/install.sh" >/dev/null + + assert_contains '--native-tls' "$uv_log" + assert_contains 'sync' "$uv_log" + assert_contains "$prefix/lib/codex-auth/tui" "$uv_log" + assert_contains '--no-dev' "$uv_log" + assert_contains '--locked' "$uv_log" + [[ -x "$prefix/lib/codex-auth/tui/.venv/bin/codex-auth-tui" ]] || fail "private TUI venv was not bootstrapped" +} + +test_failed_tui_bootstrap_keeps_previous_install() { + local tmp prefix fake_bin status + tmp="$(mktemp -d)" + prefix="$tmp/prefix" + fake_bin="$tmp/bin" + mkdir -p "$fake_bin" + + CODEX_AUTH_TUI_SKIP_BOOTSTRAP=1 PREFIX="$prefix" "$REPO_ROOT/install.sh" >/dev/null + printf '%s\n' '#!/usr/bin/env bash' 'printf "previous-shell\n"' > "$prefix/bin/codex-auth" + chmod 0755 "$prefix/bin/codex-auth" + printf '%s\n' 'previous-tui' > "$prefix/lib/codex-auth/tui/previous.marker" + printf '%s\n' '#!/usr/bin/env bash' 'exit 42' > "$fake_bin/uv" + chmod 0755 "$fake_bin/uv" + + set +e + PATH="$fake_bin:$PATH" CODEX_AUTH_TUI_SKIP_BOOTSTRAP=0 PREFIX="$prefix" "$REPO_ROOT/install.sh" >/dev/null 2>&1 + status=$? + set -e + + [[ "$status" != "0" ]] || fail "failed uv bootstrap reported success" + assert_contains 'previous-shell' "$prefix/bin/codex-auth" + assert_contains 'previous-tui' "$prefix/lib/codex-auth/tui/previous.marker" + if find "$prefix/lib/codex-auth" -maxdepth 1 -type d -name '.codex-auth-install.*' | grep -q .; then + fail "failed bootstrap left an install staging directory" + fi +} + +test_watch_and_tui_forward_exact_args() { + local tmp fake watch_log tui_log watch_expected tui_expected + tmp="$(mktemp -d)" + fake="$tmp/fake-tui" + watch_log="$tmp/watch.log" + tui_log="$tmp/tui.log" + watch_expected="$tmp/watch.expected" + tui_expected="$tmp/tui.expected" + + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "%s\\n" "$#" > "$CODEX_TEST_LOG"' \ + 'printf "%s\\n" "$@" >> "$CODEX_TEST_LOG"' > "$fake" + chmod 0755 "$fake" + + CODEX_TEST_LOG="$watch_log" CODEX_AUTH_TUI_PATCH_AUTO=0 CODEX_AUTH_TUI_BIN="$fake" "$REPO_ROOT/bin/codex-auth" watch --auto --live --threshold 0 + printf '%s\n' 4 --auto --live --threshold 0 > "$watch_expected" + cmp -s "$watch_expected" "$watch_log" || fail "watch did not forward exact TUI arguments" + + CODEX_TEST_LOG="$tui_log" CODEX_AUTH_TUI_PATCH_AUTO=0 CODEX_AUTH_TUI_BIN="$fake" "$REPO_ROOT/bin/codex-auth" tui --interval 15 + printf '%s\n' 2 --interval 15 > "$tui_expected" + cmp -s "$tui_expected" "$tui_log" || fail "tui did not forward exact TUI arguments" +} + +test_tui_dispatches_patch_check_without_blocking() { + local tmp fake_tui fake_lib stock tui_log patch_log started finished elapsed deadline count + tmp="$(mktemp -d)" + fake_tui="$tmp/fake-tui" + fake_lib="$tmp/lib" + stock="$tmp/stock-codex" + tui_log="$tmp/tui.log" + patch_log="$tmp/patch.log" + mkdir -p "$fake_lib" + + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "%s\\n" "$@" > "$CODEX_TEST_TUI_LOG"' > "$fake_tui" + chmod 0755 "$fake_tui" + write_fake_codex "$stock" + : > "$fake_lib/core.sh" + : > "$fake_lib/render.sh" + : > "$fake_lib/output.sh" + printf '%s\n' \ + 'cmd_patch_codex() {' \ + ' printf "%s\\n" "$*" >> "$CODEX_TEST_PATCH_LOG"' \ + ' sleep 2' \ + '}' > "$fake_lib/patch.sh" + + started="$(date +%s%N)" + CODEX_HOME="$tmp/home" \ + CODEX_AUTH_CODEX_BIN="$stock" \ + CODEX_AUTH_LIB_DIR="$fake_lib" \ + CODEX_AUTH_TUI_BIN="$fake_tui" \ + CODEX_TEST_LOG="$tmp/codex.log" \ + CODEX_TEST_TUI_LOG="$tui_log" \ + CODEX_TEST_PATCH_LOG="$patch_log" \ + "$REPO_ROOT/bin/codex-auth" tui --interval 15 + finished="$(date +%s%N)" + elapsed=$((finished - started)) + (( elapsed < 1000000000 )) || fail "TUI waited for the detached patch check" + assert_contains '--interval' "$tui_log" + + deadline=$((SECONDS + 2)) + while [[ ! -s "$patch_log" && "$SECONDS" -lt "$deadline" ]]; do + sleep 0.02 + done + assert_contains '--background --quiet' "$patch_log" + count="$(wc -l < "$patch_log")" + [[ "$count" == "1" ]] || fail "TUI dispatched $count patch checks instead of one" +} + +test_use_if_current_is_atomic_compare_and_switch() { + local tmp home status expected_fp target_fp + tmp="$(mktemp -d)" + home="$tmp/home" + mkdir -p "$home/auth-profiles" + printf '%s\n' '{"OPENAI_API_KEY":"a"}' > "$home/auth-profiles/a.json" + printf '%s\n' '{"OPENAI_API_KEY":"b"}' > "$home/auth-profiles/b.json" + printf '%s\n' '{"OPENAI_API_KEY":"c"}' > "$home/auth-profiles/c.json" + cp "$home/auth-profiles/a.json" "$home/auth.json" + + TERM=dumb CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use-if-current a b >/dev/null + cmp -s "$home/auth.json" "$home/auth-profiles/b.json" || fail "compare-and-switch did not activate b" + + set +e + TERM=dumb CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use-if-current a c >/dev/null 2>&1 + status=$? + set -e + [[ "$status" == "75" ]] || fail "stale compare-and-switch returned $status instead of 75" + cmp -s "$home/auth.json" "$home/auth-profiles/b.json" || fail "stale compare-and-switch overwrote the newer active profile" + + cp "$home/auth-profiles/a.json" "$home/auth.json" + expected_fp="$(printf '%s\n' 'api:a' | sha256sum)" + expected_fp="${expected_fp%% *}" + target_fp="$(printf '%s\n' 'api:b' | sha256sum)" + target_fp="${target_fp%% *}" + cat > "$home/auth-state.json" </dev/null + cmp -s "$home/auth.json" "$home/auth-profiles/b.json" || fail "generation-bound compare-and-switch did not activate b" + + cp "$home/auth-profiles/a.json" "$home/auth.json" + printf '%s\n' '{"OPENAI_API_KEY":"b-changed"}' > "$home/auth-profiles/b.json" + set +e + TERM=dumb CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use-if-current a b generation-1 >/dev/null 2>&1 + status=$? + set -e + [[ "$status" == "75" ]] || fail "changed target generation guard returned $status instead of 75" + cmp -s "$home/auth.json" "$home/auth-profiles/a.json" || fail "changed unmeasured target was activated" + + printf '%s\n' '{"OPENAI_API_KEY":"b"}' > "$home/auth-profiles/b.json" + printf '%s\n' '{"OPENAI_API_KEY":"a-changed"}' > "$home/auth-profiles/a.json" + cp "$home/auth-profiles/a.json" "$home/auth.json" + set +e + TERM=dumb CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use-if-current a b generation-1 >/dev/null 2>&1 + status=$? + set -e + [[ "$status" == "75" ]] || fail "changed current generation guard returned $status instead of 75" + cmp -s "$home/auth.json" "$home/auth-profiles/a.json" || fail "changed unmeasured current profile was switched" +} + +test_tui_launcher_resolves_source_and_installed_layouts() { + local tmp source_root installed_root source_log installed_log runner + tmp="$(mktemp -d)" + source_root="$tmp/source" + installed_root="$tmp/installed" + source_log="$tmp/source.log" + installed_log="$tmp/installed.log" + + mkdir -p \ + "$source_root/bin" \ + "$source_root/tui/src/codex_auth_tui" \ + "$source_root/tui/.venv/bin" \ + "$installed_root/bin" \ + "$installed_root/lib/codex-auth/tui/src/codex_auth_tui" \ + "$installed_root/lib/codex-auth/tui/.venv/bin" + install -m 0755 "$REPO_ROOT/bin/codex-auth-tui" "$source_root/bin/codex-auth-tui" + install -m 0755 "$REPO_ROOT/bin/codex-auth-tui" "$installed_root/bin/codex-auth-tui" + : > "$source_root/tui/pyproject.toml" + : > "$installed_root/lib/codex-auth/tui/pyproject.toml" + + for runner in \ + "$source_root/tui/.venv/bin/codex-auth-tui" \ + "$installed_root/lib/codex-auth/tui/.venv/bin/codex-auth-tui" + do + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "%s\\n" "$@" > "$CODEX_TEST_LOG"' > "$runner" + chmod 0755 "$runner" + done + for runner in \ + "$source_root/tui/.venv/bin/python" \ + "$installed_root/lib/codex-auth/tui/.venv/bin/python" + do + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$runner" + chmod 0755 "$runner" + done + + CODEX_TEST_LOG="$source_log" "$source_root/bin/codex-auth-tui" --auto --threshold 0 + CODEX_TEST_LOG="$installed_log" "$installed_root/bin/codex-auth-tui" --auto --live + [[ "$(tr '\n' ' ' < "$source_log")" == "--auto --threshold 0 " ]] || fail "source TUI layout was not resolved" + [[ "$(tr '\n' ' ' < "$installed_log")" == "--auto --live " ]] || fail "installed TUI layout was not resolved" +} + +test_tui_launcher_falls_back_to_uv_project() { + local tmp root fake_bin uv_log expected + tmp="$(mktemp -d)" + root="$tmp/source" + fake_bin="$tmp/bin" + uv_log="$tmp/uv.log" + expected="$tmp/expected.log" + mkdir -p "$root/bin" "$root/tui/src/codex_auth_tui" "$fake_bin" + install -m 0755 "$REPO_ROOT/bin/codex-auth-tui" "$root/bin/codex-auth-tui" + : > "$root/tui/pyproject.toml" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "%s\\n" "$@" > "$CODEX_TEST_UV_LOG"' > "$fake_bin/uv" + chmod 0755 "$fake_bin/uv" + + PATH="$fake_bin:/usr/bin:/bin" CODEX_TEST_UV_LOG="$uv_log" "$root/bin/codex-auth-tui" --auto --live >/dev/null 2>"$tmp/stderr" + printf '%s\n' --native-tls run --project "$root/tui" --no-dev --locked codex-auth-tui --auto --live > "$expected" + cmp -s "$expected" "$uv_log" || fail "TUI launcher did not fall back to the isolated uv project" + assert_contains 'environment is missing' "$tmp/stderr" +} + +test_help_defaults_to_daily_selector_surface() { + local tmp home output full_output usage_output + tmp="$(mktemp -d)" + home="$tmp/home" + output="$tmp/help.txt" + full_output="$tmp/help-all.txt" + usage_output="$tmp/usage-help.txt" + + TERM=dumb COLUMNS=100 CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" help >"$output" + assert_contains 'usage --sync' "$output" + assert_contains 'usage --refresh --select' "$output" + assert_contains 'usage --cached --select' "$output" + assert_contains 'watch --auto --live' "$output" + assert_contains 'help --all' "$output" + assert_not_contains 'patch-codex' "$output" + assert_not_contains 'run -- ' "$output" + + TERM=dumb COLUMNS=100 CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" help --all >"$full_output" + assert_contains 'patch-codex' "$full_output" + assert_contains 'run -- ' "$full_output" + + TERM=dumb COLUMNS=100 CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" usage --help >"$usage_output" + assert_contains 'usage --sync' "$usage_output" + assert_contains 'usage --refresh --select' "$usage_output" +} + +test_current_does_not_need_usage_library() { + local tmp home output log + tmp="$(mktemp -d)" + home="$tmp/home" + output="$tmp/current.txt" + log="$tmp/calls.log" + write_fake_codex "$tmp/real-codex" + mkdir -p "$home" + printf '%s\n' '{"OPENAI_API_KEY":"test"}' > "$home/auth.json" + + TERM=dumb COLUMNS=100 CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" "$REPO_ROOT/bin/codex-auth" current >"$output" + + assert_contains 'Active profile' "$output" +} + test_run_uses_cached_auto_without_app_server_refresh() { local tmp home log tmp="$(mktemp -d)" @@ -285,14 +1099,18 @@ test_run_uses_cached_auto_without_app_server_refresh() { } test_run_retries_usage_limit_without_leftover_logs() { - local tmp home log count + local tmp home log count iteration tmp="$(mktemp -d)" home="$tmp/home" log="$tmp/calls.log" count="$tmp/count" write_retry_codex "$tmp/retry-codex" - CODEX_TEST_LOG="$log" CODEX_TEST_COUNT="$count" CODEX_HOME="$home" CODEX_AUTH_RUN_AUTO=0 CODEX_AUTH_CODEX_BIN="$tmp/retry-codex" "$REPO_ROOT/bin/codex-auth" run -- prompt >/dev/null 2>"$tmp/stderr" + for iteration in 1 2 3 4 5; do + rm -f "$count" + : > "$log" + CODEX_TEST_LOG="$log" CODEX_TEST_COUNT="$count" CODEX_HOME="$home" CODEX_AUTH_RUN_AUTO=0 CODEX_AUTH_CODEX_BIN="$tmp/retry-codex" "$REPO_ROOT/bin/codex-auth" run -- prompt >/dev/null 2>"$tmp/stderr" + done assert_contains 'real:prompt' "$log" assert_contains 'real:resume --last' "$log" @@ -320,6 +1138,24 @@ test_recover_does_not_force_yolo() { assert_not_contains '--yolo' "$log" } +test_recover_without_session_resumes_last() { + local tmp home log + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + write_fake_codex "$tmp/real-codex" + + if ! command -v script >/dev/null 2>&1; then + printf 'skip - test_recover_without_session_resumes_last\n' + return 0 + fi + + CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" script -qec "$REPO_ROOT/bin/codex-auth recover" /dev/null >/dev/null + + assert_contains 'real:resume --last' "$log" + assert_not_contains '--yolo' "$log" +} + test_dumb_selector_renders_without_fzf_prompt() { local tmp home output tmp="$(mktemp -d)" @@ -378,6 +1214,7 @@ EOF assert_contains $'\033[' "$fzf_input" assert_contains $'\033[48;2;' "$fzf_input" assert_not_contains '1. ' "$fzf_input" + assert_not_contains 'circular name reference' "$output" assert_contains 'active a' "$output" } @@ -391,6 +1228,8 @@ test_refresh_select_uses_cached_selector_without_blocking() { log="$tmp/calls.log" mkdir -p "$home/auth-profiles" "$tmp/bin" printf '%s\n' '{"OPENAI_API_KEY":"a"}' > "$home/auth-profiles/a.json" + printf '%s\n' '{"OPENAI_API_KEY":"b"}' > "$home/auth-profiles/b.json" + printf '%s\n' '{"OPENAI_API_KEY":"c"}' > "$home/auth-profiles/c.json" write_fake_codex "$tmp/real-codex" cat > "$tmp/bin/fzf" <<'EOF' #!/usr/bin/env bash @@ -410,12 +1249,678 @@ EOF assert_contains '--height=~' "$fzf_args" assert_not_contains '--height=100%' "$fzf_args" assert_contains $'action\tswitch\ta' "$fzf_input" + assert_not_contains 'circular name reference' "$output" assert_contains 'active a' "$output" } +test_failed_usage_rows_offer_login() { + local tmp home output fzf_input fzf_args fp now + tmp="$(mktemp -d)" + home="$tmp/home" + output="$tmp/out.txt" + fzf_input="$tmp/fzf-input.txt" + fzf_args="$tmp/fzf-args.txt" + mkdir -p "$home/auth-profiles" "$tmp/bin" + printf '%s\n' '{"OPENAI_API_KEY":"a"}' > "$home/auth-profiles/a.json" + fp="$(printf '%s\n' 'api:a' | sha256sum)" + fp="${fp%% *}" + now="$(date +%s)" + cat > "$home/auth-state.json" < "$tmp/bin/fzf" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$CODEX_TEST_FZF_ARGS" +cat > "$CODEX_TEST_FZF_INPUT" +exit 130 +EOF + chmod 0755 "$tmp/bin/fzf" + + if ! command -v script >/dev/null 2>&1 || ! command -v timeout >/dev/null 2>&1; then + printf 'skip - test_failed_usage_rows_offer_login\n' + return 0 + fi + + env -u NO_COLOR PATH="$tmp/bin:$PATH" CODEX_TEST_FZF_INPUT="$fzf_input" CODEX_TEST_FZF_ARGS="$fzf_args" TERM=xterm COLUMNS=120 CODEX_HOME="$home" timeout 2 script -qec "$REPO_ROOT/bin/codex-auth usage --cached --select" /dev/null >"$output" + + assert_contains $'action\tlogin\ta' "$fzf_input" + assert_contains 'failed to fe' "$fzf_input" + assert_not_contains $'action\tswitch\ta' "$fzf_input" +} + +test_account_switch_refresh_errors_require_login() { + local tmp home log shell_output python_output now + local mismatch_fp signin_fp near_miss_fp + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + shell_output="$tmp/shell.txt" + python_output="$tmp/python.txt" + mkdir -p "$home/auth-profiles" + printf '%s\n' '{"OPENAI_API_KEY":"mismatch"}' > "$home/auth-profiles/account-mismatch.json" + printf '%s\n' '{"OPENAI_API_KEY":"signin"}' > "$home/auth-profiles/signin-again.json" + printf '%s\n' '{"OPENAI_API_KEY":"near"}' > "$home/auth-profiles/near-miss.json" + mismatch_fp="$(printf '%s\n' 'api:mismatch' | sha256sum)" + mismatch_fp="${mismatch_fp%% *}" + signin_fp="$(printf '%s\n' 'api:signin' | sha256sum)" + signin_fp="${signin_fp%% *}" + near_miss_fp="$(printf '%s\n' 'api:near' | sha256sum)" + near_miss_fp="${near_miss_fp%% *}" + now="$(date +%s)" + cat > "$home/auth-state.json" < "$shell_output" + grep -F 'account-mismatch' "$shell_output" | grep -Fq 'login' || fail "shell usage did not classify the account-mismatch refresh error as login" + grep -F 'signin-again' "$shell_output" | grep -Fq 'login' || fail "shell usage did not classify the sign-in-again error as login" + if grep -F 'near-miss' "$shell_output" | grep -Fq 'login'; then + fail "shell usage classified a broader refresh failure as login" + fi + + TERM=dumb COLUMNS=200 CODEX_AUTH_FAST_CACHED_PYTHON=1 CODEX_HOME="$home" \ + "$REPO_ROOT/bin/codex-auth" usage --cached > "$python_output" + grep -F 'account-mismatch' "$python_output" | grep -Fq 'login' || fail "Python usage did not classify the account-mismatch refresh error as login" + grep -F 'signin-again' "$python_output" | grep -Fq 'login' || fail "Python usage did not classify the sign-in-again error as login" + if grep -F 'near-miss' "$python_output" | grep -Fq 'login'; then + fail "Python usage classified a broader refresh failure as login" + fi + + : > "$log" + write_fake_codex "$tmp/real-codex" + CODEX_AUTH_REFRESH_BLOCKED=1 \ + CODEX_AUTH_REFRESH_JOBS=1 \ + CODEX_TEST_LOG="$log" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet --fast account-mismatch signin-again + assert_not_contains 'app-server' "$log" +} + +test_fast_refresh_persists_definitive_auth_error_generation() { + local tmp home log profile fingerprint now error_message + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + profile="$home/auth-profiles/work.json" + error_message='Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.' + write_chatgpt_auth "$profile" rt-work at-work acct-work + write_rate_limit_codex "$tmp/real-codex" + fingerprint="$(printf '%s\n' 'chatgpt:rt-work' | sha256sum)" + fingerprint="${fingerprint%% *}" + now="$(date +%s)" + cat > "$home/auth-state.json" <"$tmp/unconfirmed.out" 2>&1 + then + fail "reset credit was consumed without --yes" + fi + assert_not_contains 'account/rateLimitResetCredit/consume' "$tmp/unconfirmed.out" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_RESET_CREDITS_AFTER=1 \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reset work --yes >"$output" + + assert_contains 'account/rateLimitResetCredit/consume' "$log" + assert_contains 'idempotencyKey' "$log" + assert_contains 'used reset work' "$output" + [[ "$(jq -r '.profiles.work.payload.rateLimitResetCredits.availableCount' "$home/auth-state.json")" == "1" ]] \ + || fail "reset readback did not update the remaining count" + [[ ! -e "$home/.tmp/reset-work.pending" ]] || fail "successful reset left a pending idempotency key" + [[ ! -e "$home/auth.json" ]] || fail "inactive reset consumption switched live auth" +} + +test_reset_credit_retry_reuses_idempotency_key() { + local tmp home log profile keys + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + profile="$home/auth-profiles/work.json" + write_chatgpt_auth "$profile" rt-work at-work acct-work + write_rate_limit_codex "$tmp/real-codex" + + CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet work + + if CODEX_TEST_LOG="$log" \ + CODEX_TEST_RESET_NO_RESPONSE=1 \ + CODEX_AUTH_RESET_TIMEOUT=1 \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reset work --yes >"$tmp/first.out" 2>&1 + then + fail "ambiguous reset timeout reported success" + fi + [[ -s "$home/.tmp/reset-work.pending" ]] || fail "ambiguous reset lost its idempotency key" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_RESET_OUTCOME=alreadyRedeemed \ + CODEX_TEST_RESET_CREDITS_AFTER=1 \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reset work --yes >"$tmp/retry.out" + + keys="$(grep -F 'account/rateLimitResetCredit/consume' "$log" \ + | sed 's/^rpc://' \ + | jq -r '.params.idempotencyKey')" + [[ "$(wc -l <<<"$keys" | tr -d ' ')" == "2" ]] || fail "expected two reset attempts" + [[ "$(sort -u <<<"$keys" | wc -l | tr -d ' ')" == "1" ]] \ + || fail "reset retry minted a different idempotency key" + assert_contains 'reset already applied work' "$tmp/retry.out" + [[ ! -e "$home/.tmp/reset-work.pending" ]] || fail "idempotent success left a pending key" +} + +test_reset_credit_non_consuming_outcomes_fail_closed() { + local tmp home log profile + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + profile="$home/auth-profiles/work.json" + write_chatgpt_auth "$profile" rt-work at-work acct-work + write_rate_limit_codex "$tmp/real-codex" + + CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet work + + if CODEX_TEST_LOG="$log" \ + CODEX_TEST_RESET_OUTCOME=nothingToReset \ + CODEX_TEST_RESET_CREDITS_AFTER=2 \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reset work --yes >"$tmp/nothing.out" 2>&1 + then + fail "nothingToReset reported a consumed reset" + fi + assert_contains 'nothing eligible to reset' "$tmp/nothing.out" + [[ ! -e "$home/.tmp/reset-work.pending" ]] || fail "nothingToReset left a pending key" + + if CODEX_TEST_LOG="$log" \ + CODEX_TEST_RESET_OUTCOME=noCredit \ + CODEX_TEST_RESET_CREDITS_AFTER=0 \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reset work --yes >"$tmp/no-credit.out" 2>&1 + then + fail "noCredit reported a consumed reset" + fi + assert_contains 'no earned resets available' "$tmp/no-credit.out" + [[ "$(jq -r '.profiles.work.payload.rateLimitResetCredits.availableCount' "$home/auth-state.json")" == "0" ]] \ + || fail "noCredit readback did not zero the cached count" + [[ ! -e "$home/.tmp/reset-work.pending" ]] || fail "noCredit left a pending key" +} + +test_sync_opens_fresh_tui() { + local tmp home output fzf_input fzf_args log name + tmp="$(mktemp -d)" + home="$tmp/home" + output="$tmp/out.txt" + fzf_input="$tmp/fzf-input.txt" + fzf_args="$tmp/fzf-args.txt" + log="$tmp/calls.log" + mkdir -p "$home/auth-profiles" "$tmp/bin" + for name in a b c d e f g h i j; do + printf '{"OPENAI_API_KEY":"%s"}\n' "$name" > "$home/auth-profiles/$name.json" + done + write_rate_limit_codex "$tmp/real-codex" + cat > "$tmp/bin/fzf" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$CODEX_TEST_FZF_ARGS" +cat > "$CODEX_TEST_FZF_INPUT" +grep -m1 '^action' "$CODEX_TEST_FZF_INPUT" +EOF + chmod 0755 "$tmp/bin/fzf" + + if ! command -v script >/dev/null 2>&1 || ! command -v timeout >/dev/null 2>&1; then + printf 'skip - test_sync_opens_fresh_tui\n' + return 0 + fi + + # Ten 0.8s fake refreshes run concurrently. Six seconds still catches a + # serial regression (about eight seconds) without racing PTY setup on a busy + # machine. + env -u CODEX_AUTH_REFRESH_JOBS -u CODEX_AUTH_REFRESH_JOBS_MAX PATH="$tmp/bin:$PATH" CODEX_TEST_RATE_SLEEP=0.8 CODEX_TEST_LOG="$log" CODEX_TEST_FZF_INPUT="$fzf_input" CODEX_TEST_FZF_ARGS="$fzf_args" TERM=xterm CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" timeout 6 script -qec "$REPO_ROOT/bin/codex-auth usage --sync" /dev/null >"$output" + + assert_contains 'real:app-server --listen stdio://' "$log" + assert_contains '--height=~' "$fzf_args" + assert_contains $'action\tswitch\ta' "$fzf_input" + assert_contains '78' "$fzf_input" + assert_not_contains 'circular name reference' "$output" + assert_contains 'active a' "$output" +} + +test_usage_probe_persists_rotated_auth_to_profile_and_live() { + local tmp home log profile rotated expected_fingerprint actual_fingerprint + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + profile="$home/auth-profiles/a.json" + rotated="$tmp/rotated.json" + write_chatgpt_auth "$profile" rt-old at-old acct-a 2026-07-10T00:00:00Z + cp "$profile" "$home/auth.json" + write_chatgpt_auth "$rotated" rt-new at-new acct-a 2026-07-10T00:01:00Z + write_rotating_rate_limit_codex "$tmp/real-codex" + + CODEX_AUTH_REFRESH_JOBS=1 \ + CODEX_AUTH_USAGE_TIMEOUT=2 \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_ROTATED_AUTH_FILE="$rotated" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet --fast a + + cmp -s "$rotated" "$profile" || fail "usage probe did not persist the rotated profile credential" + cmp -s "$rotated" "$home/auth.json" || fail "usage probe did not advance matching live auth" + expected_fingerprint="$(printf '%s\n' 'chatgpt:rt-new' | sha256sum)" + expected_fingerprint="${expected_fingerprint%% *}" + actual_fingerprint="$(jq -r '.profiles.a.fingerprint // empty' "$home/auth-state.json")" + [[ "$actual_fingerprint" == "$expected_fingerprint" ]] || fail "usage state kept the discarded pre-probe credential" + [[ "$(jq -r '.profile_fingerprint // empty' "$home/active-profile.json")" == "$expected_fingerprint" ]] \ + || fail "active-profile marker kept the discarded pre-probe credential" +} + +test_usage_probe_persists_rotation_when_rate_limit_read_fails() { + local tmp home log profile rotated + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + profile="$home/auth-profiles/a.json" + rotated="$tmp/rotated.json" + write_chatgpt_auth "$profile" rt-old at-old acct-a + cp "$profile" "$home/auth.json" + write_chatgpt_auth "$rotated" rt-new at-new acct-a + write_rotating_rate_limit_codex "$tmp/real-codex" + + CODEX_AUTH_REFRESH_JOBS=1 \ + CODEX_AUTH_USAGE_TIMEOUT=2 \ + CODEX_TEST_PROBE_FAIL=1 \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_ROTATED_AUTH_FILE="$rotated" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet --fast a + + cmp -s "$rotated" "$profile" || fail "failed rate-limit read discarded the rotated profile credential" + cmp -s "$rotated" "$home/auth.json" || fail "failed rate-limit read discarded matching live auth" +} + +test_usage_probe_rejects_auth_without_a_credential() { + local tmp home log profile invalid + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + profile="$home/auth-profiles/a.json" + invalid="$tmp/invalid.json" + write_chatgpt_auth "$profile" rt-old at-old acct-a + cp "$profile" "$home/auth.json" + printf '%s\n' '{"tokens":{}}' > "$invalid" + write_rotating_rate_limit_codex "$tmp/real-codex" + + CODEX_AUTH_REFRESH_JOBS=1 \ + CODEX_AUTH_USAGE_TIMEOUT=2 \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_ROTATED_AUTH_FILE="$invalid" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet --fast a + + [[ "$(jq -r '.tokens.refresh_token' "$profile")" == "rt-old" ]] || fail "invalid temp auth replaced the saved profile" + [[ "$(jq -r '.tokens.refresh_token' "$home/auth.json")" == "rt-old" ]] || fail "invalid temp auth replaced live auth" +} + +test_usage_probe_rejects_cross_account_auth() { + local tmp home log profile switched + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + profile="$home/auth-profiles/a.json" + switched="$tmp/switched.json" + write_chatgpt_auth "$profile" rt-old at-old acct-a + cp "$profile" "$home/auth.json" + write_chatgpt_auth "$switched" rt-other at-other acct-b + write_rotating_rate_limit_codex "$tmp/real-codex" + + CODEX_AUTH_REFRESH_JOBS=1 \ + CODEX_AUTH_USAGE_TIMEOUT=2 \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_ROTATED_AUTH_FILE="$switched" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet --fast a + + [[ "$(jq -r '.tokens.refresh_token' "$profile")" == "rt-old" ]] || fail "cross-account temp auth replaced the saved profile" + [[ "$(jq -r '.tokens.refresh_token' "$home/auth.json")" == "rt-old" ]] || fail "cross-account temp auth replaced live auth" + if [[ -f "$home/auth-state.json" ]] && jq -e '.profiles.a? != null' "$home/auth-state.json" >/dev/null; then + fail "cross-account probe payload was attached to the saved profile" + fi +} + +test_usage_probe_does_not_overwrite_concurrent_profile_switch() { + local tmp home log profile rotated switched + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + profile="$home/auth-profiles/a.json" + rotated="$tmp/rotated.json" + switched="$tmp/switched.json" + write_chatgpt_auth "$profile" rt-old at-old acct-a + cp "$profile" "$home/auth.json" + write_chatgpt_auth "$rotated" rt-new at-new acct-a + write_chatgpt_auth "$switched" rt-other at-other acct-b + write_rotating_rate_limit_codex "$tmp/real-codex" + + CODEX_AUTH_REFRESH_JOBS=1 \ + CODEX_AUTH_USAGE_TIMEOUT=2 \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_ROTATED_AUTH_FILE="$rotated" \ + CODEX_TEST_CONCURRENT_PROFILE_SOURCE="$switched" \ + CODEX_TEST_CONCURRENT_PROFILE_TARGET="$profile" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet --fast a + + cmp -s "$switched" "$profile" || fail "probe overwrote a concurrently switched profile" + [[ "$(jq -r '.tokens.refresh_token' "$home/auth.json")" == "rt-old" ]] || fail "profile CAS failure still advanced live auth" + if [[ -f "$home/auth-state.json" ]] && jq -e '.profiles.a? != null' "$home/auth-state.json" >/dev/null; then + fail "probe payload was cached against a concurrently switched profile" + fi +} + +test_usage_probe_does_not_overwrite_concurrent_live_switch() { + local tmp home log profile rotated switched + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + profile="$home/auth-profiles/a.json" + rotated="$tmp/rotated.json" + switched="$tmp/switched.json" + write_chatgpt_auth "$profile" rt-old at-old acct-a + cp "$profile" "$home/auth.json" + write_chatgpt_auth "$rotated" rt-new at-new acct-a + write_chatgpt_auth "$switched" rt-other at-other acct-b + write_rotating_rate_limit_codex "$tmp/real-codex" + + CODEX_AUTH_REFRESH_JOBS=1 \ + CODEX_AUTH_USAGE_TIMEOUT=2 \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_ROTATED_AUTH_FILE="$rotated" \ + CODEX_TEST_CONCURRENT_LIVE_SOURCE="$switched" \ + CODEX_TEST_CONCURRENT_LIVE_TARGET="$home/auth.json" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet --fast a + + cmp -s "$rotated" "$profile" || fail "live switch prevented safe profile rotation persistence" + cmp -s "$switched" "$home/auth.json" || fail "probe overwrote a concurrently switched live session" +} + +test_usage_probe_does_not_overwrite_same_refresh_concurrent_profile_update() { + local tmp home log profile rotated concurrent + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + profile="$home/auth-profiles/a.json" + rotated="$tmp/rotated.json" + concurrent="$tmp/concurrent.json" + write_chatgpt_auth "$profile" rt-same at-old acct-a + cp "$profile" "$home/auth.json" + write_chatgpt_auth "$rotated" rt-new at-probe acct-a 2026-07-10T00:02:00Z + write_chatgpt_auth "$concurrent" rt-same at-concurrent acct-a 2026-07-10T00:03:00Z + write_rotating_rate_limit_codex "$tmp/real-codex" + + CODEX_AUTH_REFRESH_JOBS=1 \ + CODEX_AUTH_USAGE_TIMEOUT=2 \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_ROTATED_AUTH_FILE="$rotated" \ + CODEX_TEST_CONCURRENT_PROFILE_SOURCE="$concurrent" \ + CODEX_TEST_CONCURRENT_PROFILE_TARGET="$profile" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet --fast a + + cmp -s "$concurrent" "$profile" || fail "probe overwrote a same-refresh concurrent profile update" + [[ "$(jq -r '.tokens.access_token' "$home/auth.json")" == "at-old" ]] \ + || fail "profile revision CAS failure still advanced live auth" +} + +test_usage_probe_does_not_overwrite_same_refresh_concurrent_live_update() { + local tmp home log profile rotated concurrent + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + profile="$home/auth-profiles/a.json" + rotated="$tmp/rotated.json" + concurrent="$tmp/concurrent.json" + write_chatgpt_auth "$profile" rt-same at-old acct-a + cp "$profile" "$home/auth.json" + write_chatgpt_auth "$rotated" rt-new at-probe acct-a 2026-07-10T00:02:00Z + write_chatgpt_auth "$concurrent" rt-same at-concurrent acct-a 2026-07-10T00:03:00Z + write_rotating_rate_limit_codex "$tmp/real-codex" + + CODEX_AUTH_REFRESH_JOBS=1 \ + CODEX_AUTH_USAGE_TIMEOUT=2 \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_ROTATED_AUTH_FILE="$rotated" \ + CODEX_TEST_CONCURRENT_LIVE_SOURCE="$concurrent" \ + CODEX_TEST_CONCURRENT_LIVE_TARGET="$home/auth.json" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet --fast a + + cmp -s "$rotated" "$profile" || fail "safe profile rotation was not persisted" + cmp -s "$concurrent" "$home/auth.json" || fail "probe overwrote a same-refresh concurrent live update" +} + +test_live_rotation_syncs_back_to_marked_profile() { + local tmp home profile rotated log expected_fingerprint + tmp="$(mktemp -d)" + home="$tmp/home" + profile="$home/auth-profiles/a.json" + rotated="$tmp/rotated.json" + log="$tmp/calls.log" + write_chatgpt_auth "$profile" rt-old at-old acct-a + write_chatgpt_auth "$rotated" rt-new at-new acct-a 2026-07-10T00:01:00Z + write_fake_codex "$tmp/real-codex" + + CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use a >/dev/null + cp "$rotated" "$home/auth.json" + TERM=dumb CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" current > "$tmp/current.txt" + + cmp -s "$rotated" "$profile" || fail "live token rotation was not saved back to its marked profile" + expected_fingerprint="$(printf '%s\n' 'chatgpt:rt-new' | sha256sum)" + expected_fingerprint="${expected_fingerprint%% *}" + [[ "$(jq -r '.profile // empty' "$home/active-profile.json")" == "a" ]] || fail "active marker lost its profile" + [[ "$(jq -r '.profile_fingerprint // empty' "$home/active-profile.json")" == "$expected_fingerprint" ]] \ + || fail "active marker did not advance with the live token" + assert_not_contains 'acct-a' "$home/active-profile.json" + assert_not_contains 'user-acct-a' "$home/active-profile.json" + assert_contains 'active a' "$tmp/current.txt" +} + +test_live_rotation_with_same_refresh_token_syncs_full_auth() { + local tmp home profile rotated log expected_revision + tmp="$(mktemp -d)" + home="$tmp/home" + profile="$home/auth-profiles/a.json" + rotated="$tmp/rotated.json" + log="$tmp/calls.log" + write_chatgpt_auth "$profile" rt-same at-old acct-a 2026-07-10T00:00:00Z + write_chatgpt_auth "$rotated" rt-same at-new acct-a 2026-07-10T00:01:00Z + write_fake_codex "$tmp/real-codex" + + CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use a >/dev/null + cp "$rotated" "$home/auth.json" + TERM=dumb CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" current > "$tmp/current.txt" + + cmp -s "$rotated" "$profile" || fail "same-refresh live auth update was not saved to the marked profile" + expected_revision="$(jq -cS . "$profile" | sha256sum)" + expected_revision="${expected_revision%% *}" + [[ "$(jq -r '.profile_revision // empty' "$home/active-profile.json")" == "$expected_revision" ]] \ + || fail "active marker did not advance its full-auth revision" + assert_contains 'active a' "$tmp/current.txt" +} + +test_live_account_switch_never_overwrites_marked_profile() { + local tmp home profile switched log + tmp="$(mktemp -d)" + home="$tmp/home" + profile="$home/auth-profiles/a.json" + switched="$tmp/switched.json" + log="$tmp/calls.log" + write_chatgpt_auth "$profile" rt-a at-a acct-a + write_chatgpt_auth "$switched" rt-b at-b acct-b + write_fake_codex "$tmp/real-codex" + + CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use a >/dev/null + cp "$switched" "$home/auth.json" + TERM=dumb CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" current > "$tmp/current.txt" + + [[ "$(jq -r '.tokens.refresh_token' "$profile")" == "rt-a" ]] || fail "a real account switch overwrote the old profile" + cmp -s "$switched" "$home/auth.json" || fail "profile sync pulled a switched live session backward" + assert_contains 'active unsaved' "$tmp/current.txt" +} + +test_live_rotation_does_not_guess_between_account_aliases() { + local tmp home first second rotated log + tmp="$(mktemp -d)" + home="$tmp/home" + first="$home/auth-profiles/a.json" + second="$home/auth-profiles/a-alias.json" + rotated="$home/auth.json" + log="$tmp/calls.log" + write_chatgpt_auth "$first" rt-old-1 at-old-1 acct-a + write_chatgpt_auth "$second" rt-old-2 at-old-2 acct-a + write_chatgpt_auth "$rotated" rt-new at-new acct-a + write_fake_codex "$tmp/real-codex" + + TERM=dumb CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" current > "$tmp/current.txt" + + [[ "$(jq -r '.tokens.refresh_token' "$first")" == "rt-old-1" ]] || fail "ambiguous identity rewrote the first alias" + [[ "$(jq -r '.tokens.refresh_token' "$second")" == "rt-old-2" ]] || fail "ambiguous identity rewrote the second alias" + [[ ! -e "$home/active-profile.json" ]] || fail "ambiguous aliases created an active marker" + assert_contains 'active unsaved' "$tmp/current.txt" +} + +test_api_key_mismatch_is_never_lineage_synced() { + local tmp home profile log + tmp="$(mktemp -d)" + home="$tmp/home" + profile="$home/auth-profiles/key.json" + log="$tmp/calls.log" + mkdir -p "$home/auth-profiles" + printf '%s\n' '{"OPENAI_API_KEY":"api-a"}' > "$profile" + write_fake_codex "$tmp/real-codex" + + CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use key >/dev/null + printf '%s\n' '{"OPENAI_API_KEY":"api-b"}' > "$home/auth.json" + TERM=dumb CODEX_TEST_LOG="$log" CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" current > "$tmp/current.txt" + + [[ "$(jq -r '.OPENAI_API_KEY' "$profile")" == "api-a" ]] || fail "API-key mismatch was lineage-synced" + [[ "$(jq -r '.OPENAI_API_KEY' "$home/auth.json")" == "api-b" ]] || fail "API-key mismatch changed live auth" +} + +test_parallel_refresh_preserves_one_complete_generation() { + local tmp home log name + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + mkdir -p "$home/auth-profiles" + for name in a b c d e f g h i j; do + printf '{"OPENAI_API_KEY":"%s"}\n' "$name" > "$home/auth-profiles/$name.json" + done + write_rate_limit_codex "$tmp/real-codex" + + CODEX_AUTH_REFRESH_GENERATION=test-generation \ + CODEX_AUTH_REFRESH_JOBS=10 \ + CODEX_AUTH_REFRESH_JOBS_MAX=10 \ + CODEX_TEST_RATE_SLEEP=0.05 \ + CODEX_TEST_LOG="$log" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" refresh --quiet --fast + + [[ "$(jq -r '.profiles | length' "$home/auth-state.json")" == "10" ]] || fail "parallel refresh lost a profile state row" + [[ "$(jq -r '[.profiles[].refresh_generation == "test-generation"] | all' "$home/auth-state.json")" == "true" ]] || fail "parallel refresh mixed generations" +} + test_rolling_hook_keeps_inline_auto_cached_by_default() { assert_contains 'CODEX_AUTH_ROLLING_TTL_ENV_VAR' "$REPO_ROOT/lib/codex-auth/patch.sh" assert_not_contains 'DEFAULT_ROLLING_AUTH_TTL_SECS' "$REPO_ROOT/lib/codex-auth/patch.sh" + assert_contains 'PATCH_CODEX_PATCH_VERSION=2' "$REPO_ROOT/lib/codex-auth/patch.sh" + assert_contains 'rust-v${package_base%%+*}' "$REPO_ROOT/lib/codex-auth/patch.sh" + assert_contains 'maybe_sync_rolling_auth' "$REPO_ROOT/lib/codex-auth/rolling-auth-v2.patch" + assert_contains 'Self::rolling_auth_enabled() && new_auth.is_some()' "$REPO_ROOT/lib/codex-auth/rolling-auth-v2.patch" + assert_contains 'self.reload().await;' "$REPO_ROOT/lib/codex-auth/rolling-auth-v2.patch" } test_doctor_reports_legacy_sidecars() { @@ -456,22 +1961,67 @@ main() { local test_name for test_name in \ test_shim_auto_execs_real_codex \ - test_shim_background_build_is_opt_in \ + test_shim_background_build_can_be_disabled \ + test_shim_update_restores_wrapper_and_starts_rebuild \ test_shim_bypasses_auto_for_app_server \ test_shim_honors_auto_bypass \ test_shim_uses_matching_patched_codex \ + test_patch_background_build_is_single_flight \ test_patch_codex_keys_forwarding_wrapper_to_target \ + test_patch_codex_keys_native_binary_without_script_scan \ + test_shim_prefers_standalone_current_and_tracks_repoint \ + test_shim_supports_legacy_standalone_current_layout \ + test_shim_skips_other_codex_auth_shim \ + test_shim_uses_matching_patched_standalone_codex \ + test_patch_codex_discovers_standalone_current \ + test_patch_generation_selection_is_key_specific \ + test_patch_missing_exact_tag_fails_closed \ + test_maintain_waits_for_installer_then_restores_shim \ + test_install_maintenance_cron_is_idempotent \ test_install_promotes_existing_codex_to_real \ + test_install_refreshes_stale_real_from_standalone_current \ test_install_recovers_real_from_old_backup \ test_install_recovers_real_from_path \ test_install_recovers_real_from_home_bun \ test_install_installs_codex_auth_libs \ + test_install_bootstraps_tui_with_private_uv_project \ + test_failed_tui_bootstrap_keeps_previous_install \ + test_watch_and_tui_forward_exact_args \ + test_tui_dispatches_patch_check_without_blocking \ + test_use_if_current_is_atomic_compare_and_switch \ + test_tui_launcher_resolves_source_and_installed_layouts \ + test_tui_launcher_falls_back_to_uv_project \ + test_help_defaults_to_daily_selector_surface \ + test_current_does_not_need_usage_library \ test_run_uses_cached_auto_without_app_server_refresh \ test_run_retries_usage_limit_without_leftover_logs \ test_recover_does_not_force_yolo \ + test_recover_without_session_resumes_last \ test_dumb_selector_renders_without_fzf_prompt \ test_selector_uses_fzf_by_default_when_available \ test_refresh_select_uses_cached_selector_without_blocking \ + test_failed_usage_rows_offer_login \ + test_account_switch_refresh_errors_require_login \ + test_fast_refresh_persists_definitive_auth_error_generation \ + test_default_usage_timeout_accepts_delayed_rate_limit_response \ + test_reset_credit_count_and_confirmed_consume \ + test_reset_credit_retry_reuses_idempotency_key \ + test_reset_credit_non_consuming_outcomes_fail_closed \ + test_sync_opens_fresh_tui \ + test_usage_probe_persists_rotated_auth_to_profile_and_live \ + test_usage_probe_persists_rotation_when_rate_limit_read_fails \ + test_usage_probe_rejects_auth_without_a_credential \ + test_usage_probe_rejects_cross_account_auth \ + test_usage_probe_does_not_overwrite_concurrent_profile_switch \ + test_usage_probe_does_not_overwrite_concurrent_live_switch \ + test_usage_probe_does_not_overwrite_same_refresh_concurrent_profile_update \ + test_usage_probe_does_not_overwrite_same_refresh_concurrent_live_update \ + test_live_rotation_syncs_back_to_marked_profile \ + test_live_rotation_with_same_refresh_token_syncs_full_auth \ + test_live_account_switch_never_overwrites_marked_profile \ + test_live_rotation_does_not_guess_between_account_aliases \ + test_api_key_mismatch_is_never_lineage_synced \ + test_parallel_refresh_preserves_one_complete_generation \ test_rolling_hook_keeps_inline_auto_cached_by_default \ test_doctor_reports_legacy_sidecars \ test_doctor_refuses_kill_without_yes diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000..495118b --- /dev/null +++ b/tui/README.md @@ -0,0 +1,28 @@ +# codex-auth-tui + +Textual front end for [codex-rolling-auth](../README.md). It renders structured +snapshots read from `CODEX_HOME/auth-state.json` (plus profile fingerprints) and +typed auto-switch engine events; it never parses the shell CLI's rendered +table/fzf output. All sensitive writes stay in the shell CLI — this package only +*reads* state and *calls* `codex-auth refresh`, the guarded shell switch +transaction, `codex-auth add --current`, or the confirmed earned-reset +command as short-lived subprocesses, so the mutation/refresh locks stay scoped +to each call. + +Usually launched through the shell entrypoint: + +```bash +codex-auth watch # passive full-screen account monitor +codex-auth watch --auto # open the auto-switch view (dry-run) +codex-auth watch --auto --live # start the auto-switch engine live +``` + +In the watcher, press `n` to capture the current `auth.json` as a named profile. +The shell command still owns validation, locking, and the atomic credential copy. + +Press `u` to select a ChatGPT profile, fresh-check its earned reset count, and +open a Cancel-default confirmation. A confirmed redemption uses Codex app-server +without switching the active profile, then publishes the refreshed count and +usage bars. + +Run the tests with `uv run --project tui pytest`. diff --git a/tui/pyproject.toml b/tui/pyproject.toml new file mode 100644 index 0000000..d3815c4 --- /dev/null +++ b/tui/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "codex-auth-tui" +version = "0.1.0" +description = "Textual monitor and auto-switch view for codex-rolling-auth" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "textual>=8.2.8,<9", +] +license = {text = "MIT"} + +[project.scripts] +codex-auth-tui = "codex_auth_tui.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/codex_auth_tui"] + +[dependency-groups] +dev = [ + "pillow>=10,<13", + "playwright==1.59.0", + "pytest>=8.0", + "pytest-asyncio>=0.24", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/tui/src/codex_auth_tui/__init__.py b/tui/src/codex_auth_tui/__init__.py new file mode 100644 index 0000000..ac59ecb --- /dev/null +++ b/tui/src/codex_auth_tui/__init__.py @@ -0,0 +1,11 @@ +"""Textual monitor and auto-switch view for codex-rolling-auth. + +This package ports the *architecture* of claude-swap's TUI (paced snapshot +source, UI-agnostic threshold engine with typed events, passive watch screen, +confirm-to-go-live auto view) onto Codex's native profile/auth-state format. It +owns no credential logic: the shell CLI (`codex-auth`) keeps every sensitive +write, and this package reads structured state and shells out for refresh, +switching, profile capture, and confirmed earned-reset redemption. +""" + +__version__ = "0.1.0" diff --git a/tui/src/codex_auth_tui/__main__.py b/tui/src/codex_auth_tui/__main__.py new file mode 100644 index 0000000..af93592 --- /dev/null +++ b/tui/src/codex_auth_tui/__main__.py @@ -0,0 +1,6 @@ +"""Allow ``python -m codex_auth_tui`` to launch the TUI.""" + +from codex_auth_tui.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tui/src/codex_auth_tui/backend.py b/tui/src/codex_auth_tui/backend.py new file mode 100644 index 0000000..4197dd9 --- /dev/null +++ b/tui/src/codex_auth_tui/backend.py @@ -0,0 +1,586 @@ +"""Read-only snapshots plus short-lived calls into the existing shell CLI. + +The shell implementation remains the only credential writer. This module +reads profile/cache JSON for the TUI and invokes the existing shell commands +as bounded subprocesses. It never copies or rewrites an ``auth.json`` itself. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import math +import os +import signal +import shutil +import subprocess +import threading +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +from codex_auth_tui.models import AccountSnapshot, AccountsSnapshot, AccountUsage +from codex_auth_tui.paths import CodexPaths, resolve_paths + +REFRESH_TIMEOUT_S = 90.0 +SWITCH_TIMEOUT_S = 30.0 +SAVE_TIMEOUT_S = 45.0 +RESET_TIMEOUT_S = 60.0 +PATCH_CHECK_TIMEOUT_S = 15.0 + +_EMPTY_SHA256 = hashlib.sha256(b"").hexdigest() + + +@dataclass(frozen=True) +class OperationResult: + """Result of one shell operation.""" + + ok: bool + returncode: int = 0 + output: str = "" + generation: str | None = None + + +def _load_json_object(path: Path) -> tuple[dict | None, bool]: + """Return one parsed object and whether the file itself existed. + + Each call performs at most one content read. The existence bit lets a + snapshot distinguish "no active auth" from an unrecognised/unmanaged auth. + """ + + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError: + return None, False + except (OSError, UnicodeDecodeError): + return None, path.exists() + try: + value = json.loads(raw) + except json.JSONDecodeError: + return None, True + return (value if isinstance(value, dict) else None), True + + +def _credential_line(data: dict | None) -> str | None: + if not isinstance(data, dict): + return None + api_key = data.get("OPENAI_API_KEY") + if isinstance(api_key, str) and api_key: + return f"api:{api_key}" + tokens = data.get("tokens") + if not isinstance(tokens, dict): + return None + refresh = tokens.get("refresh_token") + if isinstance(refresh, str) and refresh: + return f"chatgpt:{refresh}" + access = tokens.get("access_token") + if isinstance(access, str) and access: + return f"chatgpt-access:{access}" + return None + + +def _fingerprint(data: dict | None) -> str | None: + """Match the shell's ``jq -r ... | sha256sum`` byte-for-byte.""" + + line = _credential_line(data) + if line is None: + return None + return hashlib.sha256((line + "\n").encode("utf-8")).hexdigest() + + +def _auth_revision(data: dict | None) -> str | None: + """Hash the complete canonical auth object for compare-and-swap checks.""" + + if not isinstance(data, dict): + return None + canonical = json.dumps( + data, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ) + return hashlib.sha256((canonical + "\n").encode("utf-8")).hexdigest() + + +def credential_fingerprint(path: Path) -> str | None: + """Public test/helper form of :func:`_fingerprint` for one auth file.""" + + data, exists = _load_json_object(path) + if not exists: + return None + line = _credential_line(data) + if line is None: + # This mirrors the shell helper's empty jq output, while snapshot + # matching still refuses to treat an invalid profile as switchable. + return _EMPTY_SHA256 + return hashlib.sha256((line + "\n").encode("utf-8")).hexdigest() + + +def _account_identity(data: dict | None) -> str | None: + """Hash the stable user + account claims without exposing either value.""" + + if not isinstance(data, dict): + return None + tokens = data.get("tokens") + if not isinstance(tokens, dict): + return None + encoded = tokens.get("id_token") or tokens.get("access_token") + if not isinstance(encoded, str): + return None + parts = encoded.split(".") + if len(parts) < 2: + return None + try: + payload = parts[1] + "=" * (-len(parts[1]) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload).decode("utf-8")) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error): + return None + if not isinstance(claims, dict): + return None + auth_claims = claims.get("https://api.openai.com/auth") + if not isinstance(auth_claims, dict): + auth_claims = {} + account_id = auth_claims.get("chatgpt_account_id") or tokens.get("account_id") + user_id = ( + auth_claims.get("chatgpt_user_id") + or auth_claims.get("user_id") + or claims.get("sub") + ) + if not isinstance(account_id, str) or not isinstance(user_id, str): + return None + account_id = "".join(account_id.split()) + user_id = "".join(user_id.split()) + if not account_id or not user_id: + return None + return hashlib.sha256(f"{account_id}\x1f{user_id}\n".encode()).hexdigest() + + +def _profile_kind(data: dict | None) -> str: + if not isinstance(data, dict): + return "unknown" + api_key = data.get("OPENAI_API_KEY") + if isinstance(api_key, str) and api_key: + return "api_key" + tokens = data.get("tokens") + if isinstance(tokens, dict) and ( + (isinstance(tokens.get("refresh_token"), str) and tokens["refresh_token"]) + or (isinstance(tokens.get("access_token"), str) and tokens["access_token"]) + ): + return "chatgpt" + return "unknown" + + +def _numeric_timestamp(value: object) -> float | None: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + number = float(value) + elif isinstance(value, str): + try: + number = float(value) + except ValueError: + return None + else: + return None + return number if math.isfinite(number) else None + + +class ShellBackend: + """Coherent snapshot reader and serialized shell-command adapter.""" + + def __init__( + self, + paths: CodexPaths | None = None, + cli: str | os.PathLike[str] | None = None, + env: Mapping[str, str] | None = None, + **legacy: object, + ) -> None: + # ``codex_auth_bin`` was the name used by the first TUI spike. Keep it + # as a harmless adapter while the public constructor stays ``cli=``. + if cli is None and legacy.get("codex_auth_bin") is not None: + cli = str(legacy["codex_auth_bin"]) + + process_env = os.environ.copy() + if env is not None: + process_env.update({str(k): str(v) for k, v in env.items()}) + self.paths = paths or resolve_paths(process_env) + + # Force the child to use the exact paths this reader uses. This keeps + # explicit test/custom overrides intact instead of silently falling + # back to the caller's real ~/.codex. + process_env["CODEX_HOME"] = str(self.paths.home) + process_env["CODEX_AUTH_PROFILES_DIR"] = str(self.paths.profiles_dir) + process_env["CODEX_AUTH_STATE_FILE"] = str(self.paths.state_file) + process_env["CODEX_AUTH_ACTIVE_PROFILE_FILE"] = str( + self.paths.active_profile_file + ) + self.env = process_env + + configured = ( + cli + or process_env.get("CODEX_AUTH_BIN") + or process_env.get("CODEX_AUTH_CLI") + ) + self.cli = ( + str(configured) + if configured + else shutil.which("codex-auth", path=process_env.get("PATH")) + ) + self.refresh_timeout_s = REFRESH_TIMEOUT_S + self._subprocess_lock = threading.Lock() + + @property + def backup_dir(self) -> Path: + return self.paths.backup_dir + + def _run( + self, + args: list[str], + *, + timeout_s: float, + env_overrides: Mapping[str, str] | None = None, + ) -> OperationResult: + if not self.cli: + return OperationResult(False, 127, "codex-auth executable not found") + with self._subprocess_lock: + child_env = self.env.copy() + if env_overrides: + child_env.update(env_overrides) + try: + proc = subprocess.Popen( + [self.cli, *args], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=child_env, + start_new_session=os.name == "posix", + ) + except subprocess.TimeoutExpired as exc: + # Popen itself does not wait, so this branch is defensive for + # alternate subprocess implementations used by embedders. + return OperationResult(False, 124, str(exc)) + except OSError as exc: + return OperationResult(False, 127, f"could not run codex-auth: {exc}") + + try: + stdout, stderr = proc.communicate(timeout=timeout_s) + except subprocess.TimeoutExpired as initial_timeout: + stdout, stderr = initial_timeout.output, initial_timeout.stderr + _terminate_process_tree(proc) + try: + stdout, stderr = proc.communicate(timeout=2.0) + except subprocess.TimeoutExpired as terminate_timeout: + stdout, stderr = terminate_timeout.output, terminate_timeout.stderr + _kill_process_tree(proc) + try: + stdout, stderr = proc.communicate(timeout=2.0) + except subprocess.TimeoutExpired as kill_timeout: + # A daemon that escaped the process group can still + # retain an inherited pipe. Never turn a bounded + # backend call into an unbounded UI worker. + stdout, stderr = kill_timeout.output, kill_timeout.stderr + for pipe in (proc.stdout, proc.stderr): + if pipe is not None: + try: + pipe.close() + except OSError: + pass + label = args[0] if args else "command" + output = _combine_output(stdout, stderr) + return OperationResult( + False, + 124, + _join_output(output, f"codex-auth {label} timed out"), + ) + return OperationResult( + proc.returncode == 0, + proc.returncode, + _combine_output(stdout, stderr), + ) + + # -- stable shell interface ------------------------------------------- + + def refresh( + self, + names: list[str] | tuple[str, ...] | None = None, + *, + timeout_s: float | None = None, + timeout: float | None = None, + ) -> OperationResult: + """Complete one shell refresh before returning. + + ``names`` is retained only for the paced dashboard adapter. The stable + engine API calls this with no arguments and refreshes every profile. + """ + + selected = list(names or ()) + limit = timeout_s if timeout_s is not None else timeout + if limit is None: + limit = self.refresh_timeout_s + generation = uuid.uuid4().hex + result = self._run( + ["refresh", "--quiet", "--fast", *selected], + timeout_s=float(limit), + env_overrides={"CODEX_AUTH_REFRESH_GENERATION": generation}, + ) + return OperationResult( + result.ok, + result.returncode, + result.output, + generation, + ) + + def switch( + self, + name: str, + *, + expected_current: str | None = None, + expected_generation: str | None = None, + ) -> OperationResult: + """Activate ``name`` with optional current and refreshed-target guards.""" + + if expected_generation is not None and expected_current is None: + return OperationResult( + False, 64, "target generation requires an expected current profile" + ) + if expected_current is not None: + args = ["use-if-current", expected_current, name] + if expected_generation is not None: + args.append(expected_generation) + else: + args = ["use", name] + return self._run(args, timeout_s=SWITCH_TIMEOUT_S) + + def save_current(self, name: str) -> OperationResult: + """Save the live Codex auth as ``name`` through the guarded shell path.""" + + return self._run( + ["add", name, "--current"], + # The shell mutation lock may itself wait for up to 30 seconds. + # Leave time for validation and the atomic profile publication. + timeout_s=SAVE_TIMEOUT_S, + ) + + def consume_reset(self, name: str) -> OperationResult: + """Use one earned rate-limit reset for ``name`` through the shell CLI.""" + + return self._run( + ["reset", name, "--yes"], + timeout_s=RESET_TIMEOUT_S, + ) + + def patched_ready(self) -> bool: + """Whether the shell reports a current patched Codex binary.""" + + result = self._run( + ["patch-codex", "--print-bin", "--quiet"], + timeout_s=PATCH_CHECK_TIMEOUT_S, + ) + return result.ok + + # -- stable read interface -------------------------------------------- + + def snapshot(self, now: float | None = None) -> AccountsSnapshot: + """Read every source once and return one credential-safe snapshot.""" + + taken_at = time.time() if now is None else float(now) + try: + profile_paths = sorted( + ( + path + for path in self.paths.profiles_dir.iterdir() + if path.suffix == ".json" and path.is_file() + ), + key=lambda path: path.name, + ) + except OSError: + profile_paths = [] + + profiles: list[ + tuple[str, str, str | None, str | None, str | None] + ] = [] + for path in profile_paths: + data, _ = _load_json_object(path) + kind = _profile_kind(data) + # Invalid profiles never participate in identity matching. Their + # shell-compatible empty hash is not a credential identity. + fingerprint = _fingerprint(data) if kind != "unknown" else None + profiles.append( + ( + path.stem, + kind, + fingerprint, + _account_identity(data), + _auth_revision(data), + ) + ) + + active_data, active_exists = _load_json_object(self.paths.auth_file) + active_fp = _fingerprint(active_data) + active_identity = _account_identity(active_data) + matched_names = { + name + for name, kind, fingerprint, _identity, _revision in profiles + if kind != "unknown" + and active_fp is not None + and fingerprint == active_fp + } + if not matched_names and active_identity is not None: + marker, _ = _load_json_object(self.paths.active_profile_file) + marker_name = marker.get("profile") if isinstance(marker, dict) else None + marker_identity = ( + marker.get("account_identity") if isinstance(marker, dict) else None + ) + marker_fingerprint = ( + marker.get("profile_fingerprint") if isinstance(marker, dict) else None + ) + marker_revision = ( + marker.get("profile_revision") if isinstance(marker, dict) else None + ) + marker_version = marker.get("version") if isinstance(marker, dict) else None + for name, kind, fingerprint, identity, revision in profiles: + if ( + marker_version == 2 + and isinstance(marker_name, str) + and name == marker_name + and kind == "chatgpt" + and identity == active_identity + and marker_identity == active_identity + and fingerprint is not None + and marker_fingerprint == fingerprint + and revision is not None + and marker_revision == revision + ): + matched_names.add(name) + break + active_name = next( + ( + name + for name, _kind, _fingerprint, _identity, _revision in profiles + if name in matched_names + ), + None, + ) + + state_data, _ = _load_json_object(self.paths.state_file) + state_profiles = ( + state_data.get("profiles") if isinstance(state_data, dict) else None + ) + if not isinstance(state_profiles, dict): + state_profiles = {} + + accounts: list[AccountSnapshot] = [] + for name, kind, fingerprint, _identity, _revision in profiles: + usage = _usage_from_state( + state_profiles.get(name), + fingerprint=fingerprint, + kind=kind, + now=taken_at, + ) + accounts.append( + AccountSnapshot( + name=name, + is_active=name in matched_names, + kind=kind, + # API-key profiles remain visible but are outside the + # quota-driven auto-switch policy by default. + switchable=kind == "chatgpt" and fingerprint is not None, + usage=usage, + ) + ) + + return AccountsSnapshot( + active_name=active_name, + accounts=tuple(accounts), + taken_at=taken_at, + fetched=frozenset(), + active_unmanaged=active_exists and active_name is None, + ) + +def _usage_from_state( + entry: object, + *, + fingerprint: str | None, + kind: str, + now: float, +) -> AccountUsage: + sentinel = "api key" if kind == "api_key" else None + if kind == "unknown": + return AccountUsage( + sentinel="invalid profile", fingerprint_match=False + ) + if not isinstance(entry, dict): + return AccountUsage.from_payload( + None, + age_s=None, + fetched_at=None, + sentinel=sentinel, + fingerprint_match=False, + ) + + fetched_at = _numeric_timestamp(entry.get("updated_at")) + age_s = now - fetched_at if fetched_at is not None else None + # Small clock skew is harmless. A materially future timestamp is not proof + # that a measurement is fresh. + if age_s is not None: + age_s = max(0.0, age_s) if age_s >= -5.0 else None + stored_fp = entry.get("fingerprint") + fingerprint_match = ( + fingerprint is not None + and isinstance(stored_fp, str) + and stored_fp == fingerprint + ) + payload = entry.get("payload") + generation = entry.get("refresh_generation") + return AccountUsage.from_payload( + payload if isinstance(payload, dict) else None, + age_s=age_s, + fetched_at=fetched_at, + sentinel=sentinel, + fingerprint_match=fingerprint_match, + refresh_generation=generation if isinstance(generation, str) else None, + ) + + +def _combine_output(stdout: object, stderr: object) -> str: + def text(value: object) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value if isinstance(value, str) else "" + + return _join_output(text(stdout), text(stderr)) + + +def _join_output(first: str, second: str) -> str: + if not first: + return second + if not second: + return first + return first + ("" if first.endswith("\n") else "\n") + second + + +def _terminate_process_tree(proc: subprocess.Popen[str]) -> None: + try: + if os.name == "posix": + # The group can outlive its leader while retaining our pipes. + os.killpg(proc.pid, signal.SIGTERM) + elif proc.poll() is None: # pragma: no cover - POSIX runtime + proc.terminate() + except ProcessLookupError: + pass + + +def _kill_process_tree(proc: subprocess.Popen[str]) -> None: + try: + if os.name == "posix": + # The group can outlive its leader while retaining our pipes. + os.killpg(proc.pid, signal.SIGKILL) + elif proc.poll() is None: # pragma: no cover + proc.kill() + except ProcessLookupError: + pass diff --git a/tui/src/codex_auth_tui/cli.py b/tui/src/codex_auth_tui/cli.py new file mode 100644 index 0000000..2c7d94f --- /dev/null +++ b/tui/src/codex_auth_tui/cli.py @@ -0,0 +1,163 @@ +"""Command-line entry for the codex-auth Textual monitor. + +Usually launched through the shell dispatch (``codex-auth watch``), which sets +``CODEX_AUTH_BIN`` so the backend can call back into the shell CLI. Directly: + + codex-auth-tui watch # passive monitor + codex-auth-tui --auto # auto-switch view (dry-run) + codex-auth-tui --live # implies --auto, engine live + codex-auth-tui watch --auto --threshold 85 --interval 30 + +The passive monitor never switches. ``--auto`` opens the auto-switch view in +dry-run; ``--live`` is the only way the CLI starts the engine switching, and it +implies ``--auto``. The four policy knobs override the on-disk settings for this +session only — they are not written back. +""" + +from __future__ import annotations + +import argparse +from dataclasses import replace +import math +import sys + +from codex_auth_tui.paths import resolve_paths +from codex_auth_tui.settings import load_settings + + +def _number(raw: str) -> float: + try: + value = float(raw) + except ValueError: + raise argparse.ArgumentTypeError(f"expected a number, got {raw!r}") from None + if not math.isfinite(value): + raise argparse.ArgumentTypeError("value must be finite") + return value + + +def _threshold(raw: str) -> float: + # Utilization trigger line; 100 would never fire, so the ceiling is 99.9. + value = _number(raw) + if not 0.0 <= value <= 99.9: + raise argparse.ArgumentTypeError("threshold must be between 0 and 99.9") + return value + + +def _interval(raw: str) -> float: + # Floor of 15s keeps the network refresh off the usage endpoint's throttle. + value = _number(raw) + if value < 15.0: + raise argparse.ArgumentTypeError("interval must be at least 15 seconds") + return value + + +def _nonnegative(raw: str) -> float: + value = _number(raw) + if value < 0.0: + raise argparse.ArgumentTypeError("value must be zero or greater") + return value + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="codex-auth-tui", + description="Live account monitor and auto-switch console for codex-rolling-auth", + ) + sub = parser.add_subparsers(dest="command") + watch = sub.add_parser("watch", help="open the full-screen account monitor") + watch.add_argument( + "--auto", + action="store_true", + help="open the auto-switch view in dry-run instead of the passive monitor", + ) + watch.add_argument( + "--live", + action="store_true", + help="start the engine live (switches profiles); implies --auto", + ) + watch.add_argument( + "--threshold", + type=_threshold, + metavar="PCT", + help="binding-window utilization trigger, 0 through 99.9", + ) + watch.add_argument( + "--interval", + type=_interval, + metavar="SECONDS", + help="network refresh interval, at least 15", + ) + watch.add_argument( + "--cooldown", + type=_nonnegative, + metavar="SECONDS", + help="minimum seconds between proactive switches, >= 0", + ) + watch.add_argument( + "--hysteresis", + type=_nonnegative, + metavar="PCT", + help="headroom a candidate must beat the active profile by, >= 0", + ) + return parser + + +def settings_overrides(args: argparse.Namespace) -> dict: + """CLI knobs → AutoSettings field names (only the ones supplied).""" + mapping = { + "threshold": "threshold", + "interval": "interval_s", + "cooldown": "cooldown_s", + "hysteresis": "hysteresis", + } + overrides: dict = {} + for arg_name, field in mapping.items(): + value = getattr(args, arg_name, None) + if value is not None: + overrides[field] = value + return overrides + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + # The shell dispatch (`codex-auth watch …`) may strip the leading "watch" + # token, so treat "watch" as the default command: `codex-auth-tui --auto`, + # `codex-auth-tui watch --auto`, and a bare `codex-auth-tui` all open the + # monitor. + if not argv or argv[0] != "watch": + argv = ["watch", *argv] + parser = build_parser() + args = parser.parse_args(argv) + if args.command != "watch": + parser.print_help() + return 2 + + # --live implies --auto: passing it is an explicit request to run live. + live = bool(getattr(args, "live", False)) + auto = bool(getattr(args, "auto", False)) or live + overrides = settings_overrides(args) + + # Import Textual lazily so `--help` works even without the dependency. + try: + from codex_auth_tui.tui.app import CodexAuthApp + except ModuleNotFoundError as exc: # pragma: no cover - environment guard + print( + f"codex-auth-tui: missing dependency ({exc}). Run 'uv sync' in tui/.", + file=sys.stderr, + ) + return 1 + + paths = resolve_paths() + settings = replace(load_settings(paths), **overrides).validated() + app = CodexAuthApp( + paths=paths, + settings=settings, + start="auto" if auto else "watch", + start_live=live, + ) + app.run() + return app.return_code or 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/tui/src/codex_auth_tui/engine.py b/tui/src/codex_auth_tui/engine.py new file mode 100644 index 0000000..42314a5 --- /dev/null +++ b/tui/src/codex_auth_tui/engine.py @@ -0,0 +1,618 @@ +"""Pure auto-switch policy plus a short-lived, lock-scoped watch engine.""" + +from __future__ import annotations + +import json +import math +import os +import threading +import time +from contextlib import contextmanager +from dataclasses import asdict, dataclass, field, replace +from pathlib import Path +from typing import Callable, Iterator + +from codex_auth_tui.backend import OperationResult, ShellBackend +from codex_auth_tui.models import AccountSnapshot, AccountsSnapshot +from codex_auth_tui.settings import AutoSettings, atomic_write_json + +try: # Linux/macOS. Tests and the supported live watcher run on POSIX. + import fcntl +except ImportError: # pragma: no cover - Windows falls back to the thread guard + fcntl = None # type: ignore[assignment] + +AUTO_STATE_VERSION = 1 + + +@dataclass(frozen=True) +class AutoEvent: + """One typed engine event; ``data`` is additive for UI consumers.""" + + kind: str + message: str + ts: float + data: dict = field(default_factory=dict) + + def human(self) -> str: + return self.message + + def __getattr__(self, name: str): + # Compatibility for the first TUI renderer, which read event-specific + # attributes directly. New consumers should use ``data``. + data = object.__getattribute__(self, "data") + if name in data: + return data[name] + raise AttributeError(name) + + +@dataclass(frozen=True) +class Decision: + """The complete, serializable result of one pure policy evaluation.""" + + action: str # "switch" | "hold" | "blocked" + reason: str + current: str | None = None + target: str | None = None + current_pct: float | None = None + target_pct: float | None = None + + +def _trusted_pct(account: AccountSnapshot, now: float) -> float | None: + if not account.usage.known or account.usage.stale: + return None + value = account.usage.effective_binding_pct(now) + if value is None or not math.isfinite(value): + return None + return max(0.0, float(value)) + + +def rank_candidates( + snapshot: AccountsSnapshot, + current: str | None = None, + now: float | None = None, +) -> tuple[AccountSnapshot, ...]: + """Return ready candidates from most to least headroom. + + This is deliberately policy-light: threshold, hysteresis, and cooldown are + applied by :func:`decide`. It only enforces readiness and deterministic + ranking. + """ + + moment = snapshot.taken_at if now is None else float(now) + active = snapshot.active_name if current is None else current + active_account = next( + (account for account in snapshot.accounts if account.name == active), None + ) + required_coverage = ( + active_account.usage.coverage if active_account is not None else frozenset() + ) + ranked: list[tuple[float, str, AccountSnapshot]] = [] + for account in snapshot.accounts: + if ( + account.name == active + or account.is_active + or not account.switchable + or account.kind != "chatgpt" + or not account.usage.coverage.issuperset(required_coverage) + ): + continue + pct = _trusted_pct(account, moment) + if pct is None or pct >= 100.0: + continue + ranked.append((pct, account.name, account)) + ranked.sort(key=lambda item: (item[0], item[1])) + return tuple(item[2] for item in ranked) + + +def decide( + snapshot: AccountsSnapshot, + settings: AutoSettings, + state: dict, + now: float, +) -> Decision: + """Choose an action without performing I/O or mutating its inputs.""" + + settings = settings.validated() + moment = float(now) + if snapshot.active_unmanaged: + return Decision("blocked", "unmanaged_active") + if snapshot.active_name is None: + return Decision("hold", "no_active_profile") + + current = next( + (account for account in snapshot.accounts if account.name == snapshot.active_name), + None, + ) + if current is None: + return Decision("blocked", "active_not_in_snapshot", snapshot.active_name) + if current.kind != "chatgpt" or not current.switchable: + return Decision("hold", "active_not_switchable", current.name) + + if not current.usage.known and current.usage.requires_login: + candidates = rank_candidates(snapshot, current.name, moment) + if not candidates: + return Decision("blocked", "no_viable_target", current.name) + target = candidates[0] + return Decision( + "switch", + "active_login_required", + current.name, + target.name, + target_pct=_trusted_pct(target, moment), + ) + if not current.usage.known: + return Decision("hold", "active_usage_unknown", current.name) + if current.usage.stale: + return Decision("hold", "active_usage_stale", current.name) + current_pct = _trusted_pct(current, moment) + if current_pct is None: + reason = "active_usage_unknown" + return Decision("hold", reason, current.name) + + hard_cap = current_pct >= 100.0 + if settings.threshold > 0.0 and current_pct < settings.threshold and not hard_cap: + return Decision( + "hold", "below_threshold", current.name, current_pct=current_pct + ) + + candidates = rank_candidates(snapshot, current.name, moment) + target: AccountSnapshot | None = None + target_pct: float | None = None + for candidate in candidates: + candidate_pct = _trusted_pct(candidate, moment) + if candidate_pct is None: # rank_candidates already excludes this + continue + if hard_cap: + target, target_pct = candidate, candidate_pct + break + if settings.threshold == 0.0: + # "Any better account" mode intentionally has no hysteresis floor. + if candidate_pct < current_pct: + target, target_pct = candidate, candidate_pct + break + continue + improvement = current_pct - candidate_pct + if ( + candidate_pct < settings.threshold + and candidate_pct < current_pct + and improvement >= settings.hysteresis + ): + target, target_pct = candidate, candidate_pct + break + + if target is None: + return Decision( + "blocked", + "no_viable_target", + current.name, + current_pct=current_pct, + ) + + if not hard_cap and _cooldown_active(state, settings, moment): + return Decision( + "hold", + "cooldown", + current.name, + target.name, + current_pct, + target_pct, + ) + + return Decision( + "switch", + "hard_cap" if hard_cap else "better_candidate", + current.name, + target.name, + current_pct, + target_pct, + ) + + +def _cooldown_active(state: dict, settings: AutoSettings, now: float) -> bool: + raw = state.get("last_switch_at", state.get("lastSwitchAt")) + if isinstance(raw, bool) or not isinstance(raw, (int, float)): + return False + last = float(raw) + if not math.isfinite(last): + return False + elapsed = now - last + # A future timestamp is corrupt/clock-skewed state, not an indefinite ban. + return 0.0 <= elapsed < settings.cooldown_s + + +class AutoSwitchEngine: + """Refresh, snapshot, decide, and optionally switch as one locked tick.""" + + def __init__( + self, + backend, + settings: AutoSettings, + on_event: Callable[[AutoEvent], None] | None = None, + dry_run: bool = True, + *, + state_path: Path | None = None, + clock: Callable[[], float] = time.time, + ) -> None: + self.backend = backend + self.settings = settings.validated() + self.on_event = on_event + self.dry_run = dry_run + self.clock = clock + self.state_path = state_path or backend.paths.autoswitch_state_file + self.lock_path = backend.paths.tmp_dir / "codex-auth-tui-auto.lock" + self._stop = threading.Event() + self._stopped = threading.Event() + self._tick_guard = threading.Lock() + self._commit_guard = threading.Lock() + + def tick(self) -> Decision: + """Run exactly one transaction; failures become decisions/events.""" + + if not self._tick_guard.acquire(blocking=False): + decision = Decision("hold", "lock_busy") + self._emit("lock_busy", "another auto tick is already running") + return decision + try: + with _nonblocking_flock(self.lock_path) as acquired: + if not acquired: + decision = Decision("hold", "lock_busy") + self._emit("lock_busy", "another auto watcher owns this tick") + return decision + return self._tick_locked() + except Exception as exc: + decision = Decision("hold", "engine_error") + self._emit("error", f"auto tick failed: {exc}", error=type(exc).__name__) + return decision + finally: + self._tick_guard.release() + + def _tick_locked(self) -> Decision: + if self._stop.is_set(): + return Decision("hold", "stopping") + self._emit("refresh_started", "refreshing profile usage") + if isinstance(self.backend, ShellBackend): + refresh_result = self.backend.refresh( + timeout_s=self.settings.refresh_timeout_s + ) + else: + refresh_result = self.backend.refresh() + refresh_result = _operation_result(refresh_result) + if not refresh_result.ok: + self._emit( + "refresh_failed", + "usage refresh failed", + returncode=refresh_result.returncode, + output=_event_output(refresh_result.output), + ) + return Decision("hold", "refresh_failed") + if self._stop.is_set(): + self._emit("stopping", "live engine stopped before decision") + return Decision("hold", "stopping") + self._emit("refresh_finished", "usage refresh finished") + + now = self.clock() + snapshot = self.backend.snapshot(now=now) + if refresh_result.generation: + snapshot, missing, active_missing = _scope_refresh_generation( + snapshot, refresh_result.generation + ) + if active_missing: + self._emit( + "refresh_incomplete", + "active profile did not return current usage", + profiles=[snapshot.active_name], + ) + return Decision("hold", "refresh_incomplete") + if missing: + self._emit( + "refresh_partial", + "some profiles did not return current usage; skipping them this tick", + profiles=missing, + ) + # Manual and automatic switches share this transaction lock. It keeps + # the cooldown receipt ordered with the credential CAS and prevents an + # older auto receipt from overwriting a newer manual one. + with switch_receipt_transaction(self.state_path): + if self._stop.is_set(): + return Decision("hold", "stopping") + state = _read_state(self.state_path) + decision = decide(snapshot, self.settings, state, now) + self._emit( + "decision", + _decision_message(decision, self.settings), + decision=asdict(decision), + ) + if decision.action != "switch" or decision.target is None: + return decision + + if self.dry_run or self._stop.is_set(): + if self._stop.is_set(): + self._emit("stopping", "live engine stopped before switch") + return replace(decision, action="hold", reason="stopping") + self._emit( + "switch_dry_run", + f"would switch {decision.current} -> {decision.target}", + dry_run=True, + current=decision.current, + target=decision.target, + reason=decision.reason, + ) + return decision + + self._emit( + "switch_started", + f"switching {decision.current} -> {decision.target}", + current=decision.current, + target=decision.target, + ) + # The event callback can synchronously request a stop. Check once + # more at the commit edge before launching the shell CAS. + # Linearize switch commitment against stop(). Once this short + # critical section completes, the CAS is in flight and + # wait_stopped() remains the completion barrier. + with self._commit_guard: + if self._stop.is_set(): + self._emit("stopping", "live engine stopped before switch") + return replace(decision, action="hold", reason="stopping") + raw_result = self.backend.switch( + decision.target, + expected_current=decision.current, + expected_generation=refresh_result.generation, + ) + result = _operation_result(raw_result) + if not result.ok: + failed = replace(decision, action="blocked", reason="switch_failed") + self._emit( + "switch_failed", + f"switch to {decision.target} failed", + returncode=result.returncode, + output=_event_output(result.output), + current=decision.current, + target=decision.target, + ) + return failed + + switched_at = self.clock() + try: + record_switch_state_locked( + self.state_path, + switched_at, + decision.target, + reason=decision.reason, + previous=decision.current, + ) + except OSError as exc: + # The auth switch already succeeded. Report the missing + # cooldown receipt plainly, but never pretend the switch failed. + self._emit( + "state_write_failed", + f"switched, but could not save cooldown state: {exc}", + target=decision.target, + ) + self._emit( + "switch_succeeded", + f"switched {decision.current} -> {decision.target}", + dry_run=False, + current=decision.current, + target=decision.target, + output=_event_output(result.output), + ) + return decision + + def run_loop(self) -> None: + # An engine instance is single-use. In particular, never clear a stop + # request here: Textual may ask a just-scheduled worker to stop before + # its thread has actually entered run_loop(). + try: + while not self._stop.is_set(): + self.tick() + self._stop.wait(self.settings.interval_s) + finally: + self._stopped.set() + + def stop(self) -> None: + with self._commit_guard: + self._stop.set() + + def wait_stopped(self, timeout: float | None = None) -> bool: + deadline = None if timeout is None else time.monotonic() + timeout + if not self._stopped.wait(timeout): + return False + remaining = None if deadline is None else max(0.0, deadline - time.monotonic()) + if remaining is None: + acquired = self._tick_guard.acquire() + else: + acquired = self._tick_guard.acquire(timeout=remaining) + if not acquired: + return False + self._tick_guard.release() + return True + + def _emit(self, kind: str, message: str, **data: object) -> None: + if self.on_event is None: + return + event = AutoEvent(kind, message, self.clock(), dict(data)) + try: + self.on_event(event) + except Exception: + # A renderer being torn down must not terminate live watch mode. + pass + + +def _operation_result(value: object) -> OperationResult: + if isinstance(value, OperationResult): + return value + # Compatibility with simple fakes and the pre-refactor backend. + if value is None: + return OperationResult(False, 1, "backend returned no operation result") + ok = bool(getattr(value, "ok", getattr(value, "switched", False))) + return OperationResult( + ok, + int(getattr(value, "returncode", 0 if ok else 1)), + str(getattr(value, "output", "")), + getattr(value, "generation", None), + ) + + +def _read_state(path: Path) -> dict: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} + + +@contextmanager +def _nonblocking_flock(path: Path) -> Iterator[bool]: + if fcntl is None: # pragma: no cover - shell runtime is POSIX/WSL only + yield False + return + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + descriptor = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + acquired = True + try: + os.fchmod(descriptor, 0o600) + if fcntl is not None: + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + acquired = False + yield acquired + finally: + if acquired and fcntl is not None: + fcntl.flock(descriptor, fcntl.LOCK_UN) + os.close(descriptor) + + +def _decision_message(decision: Decision, settings: AutoSettings) -> str: + if decision.action == "switch": + if decision.current_pct is not None and decision.target_pct is not None: + return ( + f"switch {decision.current} → {decision.target}: " + f"{decision.current_pct:g}% → {decision.target_pct:g}%" + ) + return f"switch {decision.current} → {decision.target}" + if decision.reason == "below_threshold": + return ( + f"stay on {decision.current}: {decision.current_pct:g}% is below " + f"the {settings.threshold:g}% trigger" + ) + reasons = { + "cooldown": "cooldown active", + "no_viable_target": "no better ready profile", + "active_usage_unknown": "active usage is unknown", + "active_usage_stale": "active usage is stale", + "active_not_switchable": "active profile is not switchable", + "no_active_profile": "no active profile", + "unmanaged_active": "active auth is not a saved profile", + "refresh_incomplete": "refresh was incomplete", + "stopping": "engine is stopping", + } + detail = reasons.get(decision.reason, decision.reason.replace("_", " ")) + prefix = f"stay on {decision.current}" if decision.current else "stay" + return f"{prefix}: {detail}" + + +def _scope_refresh_generation( + snapshot: AccountsSnapshot, generation: str +) -> tuple[AccountsSnapshot, list[str], bool]: + """Exclude incomplete candidates while keeping the active CAS strict.""" + + missing = [ + account.name + for account in snapshot.accounts + if account.kind == "chatgpt" + and account.switchable + and account.usage.refresh_generation != generation + ] + active_missing = snapshot.active_name in missing + if active_missing or not missing: + return snapshot, missing, active_missing + scoped = replace( + snapshot, + accounts=tuple( + account for account in snapshot.accounts if account.name not in missing + ), + ) + return scoped, missing, False + + +def _event_output(output: str, limit: int = 500) -> str: + """Keep subprocess diagnostics bounded in long-lived UI event logs.""" + + compact = " ".join(output.split()) + if len(compact) <= limit: + return compact + return compact[: limit - 1] + "…" + + +@contextmanager +def switch_receipt_transaction(path: Path) -> Iterator[None]: + """Serialize decision/CAS/receipt work across auto and manual paths.""" + + lock_path = path.with_name(f".{path.name}.lock") + lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) + try: + os.fchmod(descriptor, 0o600) + if fcntl is not None: + fcntl.flock(descriptor, fcntl.LOCK_EX) + yield + finally: + if fcntl is not None: + fcntl.flock(descriptor, fcntl.LOCK_UN) + os.close(descriptor) + + +def record_switch_state_locked( + path: Path, + switched_at: float, + target: str, + *, + reason: str, + previous: str | None = None, +) -> bool: + """Write one receipt while ``switch_receipt_transaction`` is held. + + A receipt with an older completion timestamp is ignored. This is a final + ordering guard for callers recovering from an interrupted transaction. + """ + + updated = dict(_read_state(path)) + existing = updated.get("last_switch_at", updated.get("lastSwitchAt")) + if ( + not isinstance(existing, bool) + and isinstance(existing, (int, float)) + and math.isfinite(float(existing)) + and float(existing) > switched_at + ): + return False + updated.update( + { + "version": AUTO_STATE_VERSION, + "last_switch_at": switched_at, + "last_switch_from": previous, + "last_switch_to": target, + "last_switch_reason": reason, + } + ) + atomic_write_json(path, updated) + return True + + +def record_switch_state( + path: Path, + switched_at: float, + target: str, + *, + reason: str, + previous: str | None = None, +) -> bool: + with switch_receipt_transaction(path): + return record_switch_state_locked( + path, + switched_at, + target, + reason=reason, + previous=previous, + ) diff --git a/tui/src/codex_auth_tui/models.py b/tui/src/codex_auth_tui/models.py new file mode 100644 index 0000000..cf33820 --- /dev/null +++ b/tui/src/codex_auth_tui/models.py @@ -0,0 +1,319 @@ +"""Immutable snapshot models — the coherent view the TUI and engine consume. + +These are read models only: the shell CLI owns every write. ``AccountUsage`` is +built from one ``auth-state.json`` profile payload (the app-server +``rateLimitsByLimitId.codex`` object, or an ``error``) plus the age of that +measurement. Freshness is explicit: ``age_s`` and ``stale`` travel with the +data so display can show a last-known value while marking it old, and the engine +can refuse to decide on it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +import math + +# Freshness thresholds, mirrored from the reference usage store. +SERVE_TTL_S = 30.0 # fresher than this → no re-fetch needed +STALE_OK_S = 300.0 # trusted for switch decisions; older → mark stale + + +def parse_reset_ts(resets_at: object) -> float | None: + """Epoch seconds for a Codex ``resetsAt`` (ISO-8601 or numeric epoch).""" + if isinstance(resets_at, bool): + return None + if isinstance(resets_at, (int, float)): + value = float(resets_at) + return value if math.isfinite(value) else None + if not isinstance(resets_at, str) or not resets_at.strip(): + return None + text = resets_at.strip() + try: + value = float(text) + return value if math.isfinite(value) else None + except ValueError: + pass + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + + +@dataclass(frozen=True) +class UsageWindow: + """One rate-limit window ("5h"/"7d") for an account.""" + + label: str + pct: float + window_mins: int + resets_at: object = None + + @property + def reset_ts(self) -> float | None: + return parse_reset_ts(self.resets_at) + + +# A short window is anything under a day; longer is the weekly/secondary limit. +_SHORT_WINDOW_MAX_MINS = 1440 + + +def _window_label(window_mins: int) -> str: + if window_mins <= 0: + return "-" + if window_mins < 60: + return f"{window_mins}m" + if window_mins < _SHORT_WINDOW_MAX_MINS: + return f"{window_mins // 60}h" + return f"{window_mins // _SHORT_WINDOW_MAX_MINS}d" + + +def _windows_from_rate_limits(rate: dict) -> tuple[UsageWindow, ...]: + windows: list[UsageWindow] = [] + for key in ("primary", "secondary"): + raw = rate.get(key) + if not isinstance(raw, dict): + continue + pct = raw.get("usedPercent") + mins = raw.get("windowDurationMins") + if not isinstance(pct, (int, float)) or not isinstance(mins, (int, float)): + continue + mins = int(mins) + if mins <= 0: + continue + windows.append( + UsageWindow( + label=_window_label(mins), + pct=float(pct), + window_mins=mins, + resets_at=raw.get("resetsAt"), + ) + ) + # Short window first, weekly last (sorted by duration) — the order the CLI + # renders and the engine reasons about. + windows.sort(key=lambda w: w.window_mins) + return tuple(windows) + + +def _reset_credit_count(payload: dict) -> int | None: + """Return the authoritative earned-reset count when the service provides it.""" + + bank = payload.get("rateLimitResetCredits") + if not isinstance(bank, dict): + return None + count = bank.get("availableCount") + if isinstance(count, bool) or not isinstance(count, (int, float)): + return None + if not math.isfinite(float(count)): + return None + return max(0, int(count)) + + +@dataclass(frozen=True) +class AccountUsage: + """One account's usage read model at snapshot time.""" + + windows: tuple[UsageWindow, ...] = () + plan_type: str | None = None + fetched_at: float | None = None + age_s: float | None = None + last_error: str | None = None + # Derived overlay states that replace the bars entirely ("api key", ...). + sentinel: str | None = None + # Whether the credential the cache was keyed to still matches the profile. + fingerprint_match: bool = True + # Set only by a successful profile probe from one coordinated refresh run. + refresh_generation: str | None = None + # Earned server-side reset bank. None means the service omitted the field. + reset_credits_available: int | None = None + + @classmethod + def from_payload( + cls, + payload: dict | None, + *, + age_s: float | None, + fetched_at: float | None, + sentinel: str | None = None, + fingerprint_match: bool = True, + refresh_generation: str | None = None, + ) -> AccountUsage: + if sentinel is not None: + return cls( + sentinel=sentinel, + age_s=age_s, + fetched_at=fetched_at, + fingerprint_match=fingerprint_match, + refresh_generation=refresh_generation, + ) + if not isinstance(payload, dict): + return cls( + age_s=age_s, + fetched_at=fetched_at, + fingerprint_match=fingerprint_match, + refresh_generation=refresh_generation, + ) + reset_credits_available = _reset_credit_count(payload) + error = payload.get("error") + if error is not None: + return cls( + last_error=_error_label(error), + age_s=age_s, + fetched_at=fetched_at, + fingerprint_match=fingerprint_match, + refresh_generation=refresh_generation, + reset_credits_available=reset_credits_available, + ) + rate = payload.get("rateLimitsByLimitId") + if isinstance(rate, dict): + rate = rate.get("codex") + if not isinstance(rate, dict): + rate = payload.get("rateLimits") + if not isinstance(rate, dict): + return cls( + age_s=age_s, + fetched_at=fetched_at, + fingerprint_match=fingerprint_match, + refresh_generation=refresh_generation, + reset_credits_available=reset_credits_available, + ) + plan = rate.get("planType") + return cls( + windows=_windows_from_rate_limits(rate), + plan_type=plan if isinstance(plan, str) else None, + age_s=age_s, + fetched_at=fetched_at, + fingerprint_match=fingerprint_match, + refresh_generation=refresh_generation, + reset_credits_available=reset_credits_available, + ) + + @property + def stale(self) -> bool: + """Older than the decision-trust window (or keyed to a different cred).""" + if not self.fingerprint_match: + return True + if self.age_s is None or not math.isfinite(self.age_s): + return True + return self.age_s < -5.0 or self.age_s > STALE_OK_S + + @property + def known(self) -> bool: + """Whether a real utilization measurement is present.""" + return self.sentinel is None and bool(self.windows) + + @property + def requires_login(self) -> bool: + """Whether a completed probe says this saved session is unusable.""" + + if not self.last_error: + return False + error = self.last_error.lower() + return any( + phrase in error + for phrase in ( + "token has been invalidated", + "token_invalidated", + "invalidated oauth token", + "token_revoked", + "access token could not be refreshed because you have since " + "logged out or signed in to another account", + "please sign in again", + ) + ) + + def binding_pct(self) -> float | None: + """Utilization of the worst (binding) window, or None if unknown.""" + if self.sentinel is not None or not self.windows: + return None + return max(w.pct for w in self.windows) + + def headroom(self) -> float | None: + """Remaining headroom on the binding window (100 - binding pct).""" + pct = self.binding_pct() + return None if pct is None else 100.0 - pct + + def decision_value(self) -> float | None: + """Binding pct the engine decides on, only while trusted; else None.""" + if self.stale: + return None + return self.binding_pct() + + def effective_binding_pct(self, now: float) -> float | None: + """Binding utilization for policy decisions. + + A reset timestamp in the past makes a refresh urgent; it does not prove + the server has reopened the window. The engine only trusts a completed + post-reset refresh generation, so this method deliberately returns the + measured percentage unchanged. + """ + + return self.binding_pct() + + @property + def coverage(self) -> frozenset[str]: + """Quota-window classes present in this measurement.""" + + return frozenset( + "short" if window.window_mins < _SHORT_WINDOW_MAX_MINS else "long" + for window in self.windows + ) + + def limiting_reset_ts(self) -> float | None: + """Latest reset among the >=100% windows (when the account frees up).""" + latest: float | None = None + for w in self.windows: + if w.pct < 100.0: + continue + ts = w.reset_ts + if ts is not None and (latest is None or ts > latest): + latest = ts + return latest + + +def _error_label(error: object) -> str: + if isinstance(error, dict): + for key in ("message",): + value = error.get(key) + if isinstance(value, str) and value: + return value + data = error.get("data") + if isinstance(data, dict): + value = data.get("message") + if isinstance(value, str) and value: + return value + return "error" + return str(error) if error else "error" + + +@dataclass(frozen=True) +class AccountSnapshot: + """One managed Codex profile as the UI/engine see it.""" + + name: str + is_active: bool + kind: str # "chatgpt" | "api_key" | "unknown" + switchable: bool + usage: AccountUsage + + @property + def display_tag(self) -> str: + if self.kind == "api_key": + return "api key" + if self.kind == "unknown": + return "invalid" + return self.usage.plan_type or "chatgpt" + + +@dataclass(frozen=True) +class AccountsSnapshot: + """Coherent one-pass view of every managed profile.""" + + active_name: str | None + accounts: tuple[AccountSnapshot, ...] + taken_at: float + # Names whose usage this pass came from the network (vs. served from store). + fetched: frozenset[str] = field(default_factory=frozenset) + # ``auth.json`` existed but no valid saved profile shared its credential. + # This is deliberately a boolean: fingerprints never leave the backend. + active_unmanaged: bool = False diff --git a/tui/src/codex_auth_tui/paths.py b/tui/src/codex_auth_tui/paths.py new file mode 100644 index 0000000..0cd9e24 --- /dev/null +++ b/tui/src/codex_auth_tui/paths.py @@ -0,0 +1,59 @@ +"""Resolve Codex auth paths from the environment. + +Mirrors the derivation in ``bin/codex-auth`` exactly, including the path +override envs the shell honors (``CODEX_AUTH_STATE_FILE``, +``CODEX_AUTH_PROFILES_DIR``, and ``CODEX_AUTH_ACTIVE_PROFILE_FILE``). Every +path is derived from ``CODEX_HOME`` so a +test that points ``CODEX_HOME`` at a temp dir is fully isolated from the real +``~/.codex`` — the same guarantee the shell test suite relies on. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class CodexPaths: + """The auth-related paths under one ``CODEX_HOME``.""" + + home: Path + auth_file: Path + profiles_dir: Path + backup_dir: Path + state_file: Path + active_profile_file: Path + + @property + def tmp_dir(self) -> Path: + return self.home / ".tmp" + + @property + def autoswitch_state_file(self) -> Path: + """Engine cooldown/quarantine state, beside the usage state file.""" + return self.home / "autoswitch-state.json" + + @property + def settings_file(self) -> Path: + return self.home / "auth-settings.json" + + +def resolve_paths(env: dict[str, str] | None = None) -> CodexPaths: + """Resolve paths from ``env`` (defaults to ``os.environ``).""" + env = os.environ if env is None else env + home = Path(env.get("CODEX_HOME") or (Path.home() / ".codex")) + profiles = env.get("CODEX_AUTH_PROFILES_DIR") or str(home / "auth-profiles") + state = env.get("CODEX_AUTH_STATE_FILE") or str(home / "auth-state.json") + active_profile = env.get("CODEX_AUTH_ACTIVE_PROFILE_FILE") or str( + home / "active-profile.json" + ) + return CodexPaths( + home=home, + auth_file=home / "auth.json", + profiles_dir=Path(profiles), + backup_dir=home / "auth-backups", + state_file=Path(state), + active_profile_file=Path(active_profile), + ) diff --git a/tui/src/codex_auth_tui/settings.py b/tui/src/codex_auth_tui/settings.py new file mode 100644 index 0000000..c56e975 --- /dev/null +++ b/tui/src/codex_auth_tui/settings.py @@ -0,0 +1,167 @@ +"""Validated auto-switch settings under one ``CODEX_HOME``.""" + +from __future__ import annotations + +import json +import math +import os +import tempfile +from dataclasses import dataclass, replace +from pathlib import Path + +from codex_auth_tui.paths import CodexPaths, resolve_paths + +SETTINGS_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class AutoSettings: + """Small policy surface intentionally aligned with live watch mode.""" + + threshold: float = 0.0 + interval_s: float = 60.0 + cooldown_s: float = 300.0 + hysteresis: float = 10.0 + refresh_timeout_s: float = 90.0 + + def validated(self) -> "AutoSettings": + defaults = AutoSettings() + return replace( + self, + threshold=_bounded(self.threshold, defaults.threshold, 0.0, 99.9), + interval_s=_bounded(self.interval_s, defaults.interval_s, 15.0, 3600.0), + cooldown_s=_bounded( + self.cooldown_s, defaults.cooldown_s, 0.0, 86400.0 + ), + hysteresis=_bounded(self.hysteresis, defaults.hysteresis, 0.0, 100.0), + refresh_timeout_s=_bounded( + self.refresh_timeout_s, defaults.refresh_timeout_s, 1.0, 900.0 + ), + ) + + @classmethod + def load(cls, paths: CodexPaths | Path | None = None) -> "AutoSettings": + return load_settings(paths) + + def save(self, paths: CodexPaths | Path | None = None) -> None: + save_settings(paths, self) + + # Compatibility names used by the first Textual pass. + @property + def interval_seconds(self) -> float: + return self.interval_s + + @property + def cooldown_seconds(self) -> float: + return self.cooldown_s + + @property + def hysteresis_pct(self) -> float: + return self.hysteresis + + @property + def include_api_key_accounts(self) -> bool: + return False + + @property + def unhealthy_ticks(self) -> int: + return 1 + + +# Import compatibility while the UI moves to the shorter stable name. +AutoSwitchSettings = AutoSettings + +_KEYS = { + "threshold": "threshold", + "interval_s": "intervalSeconds", + "cooldown_s": "cooldownSeconds", + "hysteresis": "hysteresisPct", + "refresh_timeout_s": "refreshTimeoutSeconds", +} + + +def _bounded(value: object, default: float, low: float, high: float) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return default + number = float(value) + if not math.isfinite(number): + return default + return min(max(number, low), high) + + +def _path(target: CodexPaths | Path | None) -> Path: + if target is None: + return resolve_paths().settings_file + if isinstance(target, CodexPaths): + return target.settings_file + candidate = getattr(target, "settings_file", None) + return Path(candidate) if candidate is not None else Path(target) + + +def _read_raw(path: Path) -> dict: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} + + +def load_settings(target: CodexPaths | Path | None = None) -> AutoSettings: + raw = _read_raw(_path(target)) + section = raw.get("autoswitch") + if not isinstance(section, dict): + return AutoSettings() + values = { + field: section[key] + for field, key in _KEYS.items() + if key in section + } + try: + return AutoSettings(**values).validated() + except TypeError: + return AutoSettings() + + +def save_settings( + target: CodexPaths | Path | None, settings: AutoSettings +) -> None: + path = _path(target) + normalized = settings.validated() + raw = _read_raw(path) + raw["schemaVersion"] = SETTINGS_SCHEMA_VERSION + section = raw.get("autoswitch") + if not isinstance(section, dict): + section = {} + for field, key in _KEYS.items(): + section[key] = getattr(normalized, field) + # Old experimental settings must not accidentally re-enable API-key + # switching after an upgrade. + section["includeApiKeyAccounts"] = False + raw["autoswitch"] = section + atomic_write_json(path, raw) + + +def atomic_write_json(path: Path, data: dict) -> None: + """Atomic JSON replace with private directory/file permissions.""" + + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + try: + os.chmod(path.parent, 0o700) + except OSError: + pass + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(data, stream, sort_keys=True, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, 0o600) + os.replace(temporary, path) + os.chmod(path, 0o600) + except BaseException: + try: + os.unlink(temporary) + except OSError: + pass + raise diff --git a/tui/src/codex_auth_tui/tui/__init__.py b/tui/src/codex_auth_tui/tui/__init__.py new file mode 100644 index 0000000..a16c0f5 --- /dev/null +++ b/tui/src/codex_auth_tui/tui/__init__.py @@ -0,0 +1 @@ +"""Textual screens for the codex-auth monitor and auto-switch view.""" diff --git a/tui/src/codex_auth_tui/tui/app.py b/tui/src/codex_auth_tui/tui/app.py new file mode 100644 index 0000000..4d71612 --- /dev/null +++ b/tui/src/codex_auth_tui/tui/app.py @@ -0,0 +1,543 @@ +"""The codex-auth Textual application. + +The screen redraws from the local state store every three seconds. Network +refreshes are a separate cadence, controlled by :class:`AutoSettings`, so an +open monitor never turns into a three-second app-server loop. All backend +calls run in thread workers and manual switches never overlap refreshes. +""" + +from __future__ import annotations + +from functools import partial +import time +from typing import Any + +from textual.app import App +from textual.reactive import reactive +from textual.worker import WorkerState + +from codex_auth_tui.backend import OperationResult, ShellBackend +from codex_auth_tui.engine import ( + record_switch_state_locked, + switch_receipt_transaction, +) +from codex_auth_tui.models import AccountsSnapshot +from codex_auth_tui.paths import CodexPaths, resolve_paths +from codex_auth_tui.settings import AutoSettings, load_settings +from codex_auth_tui.tui.autoview import AutoScreen +from codex_auth_tui.tui.dashboard import DashboardScreen, ResetScreen, WatchScreen +from codex_auth_tui.tui.modals import ConfirmModal, OutputModal, ProfileNameModal +from codex_auth_tui.tui.theme import CODEX_AUTH_DARK + + +class CodexAuthApp(App): + """Full-screen profile monitor and auto-switch console.""" + + TITLE = "codex-auth" + CSS_PATH = "codex_auth_tui.tcss" + ENABLE_COMMAND_PALETTE = False + + REDRAW_INTERVAL_S = 3.0 + PATCH_POLL_INTERVAL_S = 15.0 + MIN_NETWORK_INTERVAL_S = 15.0 + + snapshot: reactive[AccountsSnapshot | None] = reactive(None) + busy: reactive[bool] = reactive(False) + patched_ready_state: reactive[bool | None] = reactive(None) + + def __init__( + self, + backend: ShellBackend | None = None, + *, + paths: CodexPaths | None = None, + settings: AutoSettings | None = None, + start: str = "watch", + start_live: bool = False, + ) -> None: + super().__init__() + self.paths = paths or getattr(backend, "paths", None) or resolve_paths() + self.backend = backend or ShellBackend(self.paths) + self.settings = (settings or load_settings(self.paths)).validated() + self.threshold_pct: float | None = self.settings.threshold + self._start = start + self._start_live = start_live + self._store_only = False + self._refreshing = False + self._last_refresh_error = "" + self._pending_network = False + self._pending_patch_check = False + self._shutting_down = False + + def on_mount(self) -> None: + self.register_theme(CODEX_AUTH_DARK) + self.theme = "codex-auth-dark" + self.push_screen(DashboardScreen()) + self.push_screen(WatchScreen()) + if self._start == "auto": + self.push_screen(AutoScreen(start_live=self._start_live)) + + self.set_interval(self.REDRAW_INTERVAL_S, self._tick) + self.set_interval(self.PATCH_POLL_INTERVAL_S, self._patch_tick) + network_interval = max( + self.MIN_NETWORK_INTERVAL_S, float(self.settings.interval_s) + ) + self.set_interval(network_interval, self._network_tick) + + # Publish the local cache first so cold/offline startup never sits on a + # blank "loading" screen behind network and patch-check timeouts. The + # second request is queued behind that fast snapshot read. + self._start_refresh(network=False) + self._start_refresh( + network=self._start != "auto", + check_patch=True, + ) + + # -- snapshots and refresh cadence ------------------------------------- + + def _tick(self) -> None: + """Redraw from disk. This path never calls the network.""" + self._start_refresh(network=False) + + def _network_tick(self) -> None: + """Refresh usage at the configured cadence while passively watching.""" + if not self._store_only: + self._start_refresh(network=True) + + def _patch_tick(self) -> None: + """Follow detached patch completion using the cheap local marker check.""" + self._start_refresh(network=False, check_patch=True) + + def request_refresh( + self, *, full: bool = False, check_patch: bool = False + ) -> None: + """Request a store redraw or an explicit network refresh. + + ``full`` retains the first TUI pass's public spelling. In Auto view the + engine owns network I/O, so even a full app refresh remains store-only. + """ + self._start_refresh( + network=bool(full and not self._store_only), + check_patch=check_patch, + ) + + def _start_refresh(self, *, network: bool, check_patch: bool = False) -> None: + if self._refreshing or self.busy: + self._pending_network = self._pending_network or network + self._pending_patch_check = self._pending_patch_check or check_patch + return + self._refreshing = True + self.run_worker( + partial(self._refresh_blocking, network, check_patch), + thread=True, + group="refresh", + exit_on_error=False, + name="network-refresh" if network else "snapshot-read", + ) + + def _refresh_blocking(self, network: bool, check_patch: bool) -> None: + result = OperationResult(True) + if network: + raw = self.backend.refresh() + result = _operation_result(raw) + snap = self.backend.snapshot() + patched: bool | None = None + if check_patch: + patched = _patched_ready(self.backend) + self.call_from_thread(self._apply_refresh, snap, result, patched) + + def _apply_refresh( + self, + snap: AccountsSnapshot, + result: OperationResult, + patched: bool | None, + ) -> None: + self._refreshing = False + self.snapshot = snap + if patched is not None: + self.patched_ready_state = patched + if result.ok: + self._last_refresh_error = "" + else: + message = _first_line(result.output) or "usage refresh failed" + if message != self._last_refresh_error: + self._last_refresh_error = message + self.notify(message, severity="warning", timeout=6) + self._drain_pending() + + def _drain_pending(self) -> None: + if self._refreshing or self.busy: + return + network = self._pending_network and not self._store_only + check_patch = self._pending_patch_check + self._pending_network = False + self._pending_patch_check = False + if network or check_patch: + self._start_refresh(network=network, check_patch=check_patch) + + def set_store_only(self, value: bool) -> None: + self._store_only = value + if value: + self._pending_network = False + self.request_refresh() + else: + # Leaving Auto view returns ownership of usage fetching to watch. + self.request_refresh(full=True) + + def exit(self, *args, **kwargs) -> None: + self._shutting_down = True + super().exit(*args, **kwargs) + + # -- worker failures ---------------------------------------------------- + + def on_worker_state_changed(self, event) -> None: + if event.state is not WorkerState.ERROR: + return + if event.worker.group == "refresh": + self._refreshing = False + message = str(event.worker.error) + if message != self._last_refresh_error: + self._last_refresh_error = message + self.notify(f"Refresh failed: {message}", severity="warning", timeout=6) + self._drain_pending() + elif event.worker.group == "action": + self.busy = False + self.notify(f"Switch failed: {event.worker.error}", severity="error") + self._drain_pending() + elif event.worker.group == "save": + self.busy = False + self.notify(f"Save failed: {event.worker.error}", severity="error") + self._drain_pending() + elif event.worker.group in {"reset-check", "reset"}: + self.busy = False + self.notify(f"Reset failed: {event.worker.error}", severity="error") + self._drain_pending() + elif event.worker.group in {"engine", "engine-refresh"}: + self.notify( + f"Auto-switch engine stopped: {event.worker.error}", + severity="error", + ) + + # -- manual switching -------------------------------------------------- + + def do_switch(self, name: str) -> None: + account = next( + ( + item + for item in (self.snapshot.accounts if self.snapshot else ()) + if item.name == name + ), + None, + ) + if account is not None and not account.switchable: + self.notify(f"{name} is not switchable", severity="warning") + return + if self.busy or self._refreshing: + self.notify("Usage refresh is still running", severity="warning") + return + self.busy = True + before = self.snapshot.active_name if self.snapshot else None + self.run_worker( + partial(self._switch_blocking, name, before), + thread=True, + group="action", + exit_on_error=False, + name=f"switch-{name}", + ) + + def _switch_blocking(self, name: str, before: str | None) -> None: + with switch_receipt_transaction(self.paths.autoswitch_state_file): + raw_result = self.backend.switch(name, expected_current=before) + result = _operation_result(raw_result) + snap = self.backend.snapshot() + if result.ok and snap.active_name == name and before != name: + try: + record_switch_state_locked( + self.paths.autoswitch_state_file, + time.time(), + name, + reason="manual", + previous=before, + ) + except OSError: + # The credential transaction succeeded. A missing cooldown + # receipt is non-fatal and the next live tick remains safe. + pass + self.call_from_thread(self._switch_done, name, before, result, snap) + + def _switch_done( + self, + name: str, + before: str | None, + result: OperationResult, + snap: AccountsSnapshot, + ) -> None: + self.busy = False + self.snapshot = snap + if result.ok and snap.active_name == name: + if before == name: + self.notify(f"{name} already active", title="No switch") + else: + self.notify(f"Switched to {name}", title="Switch") + else: + message = _first_line(result.output) or "switch did not take effect" + self.notify(message, severity="warning") + if result.output.strip(): + self.push_screen(OutputModal("Switch: details", result.output)) + self._drain_pending() + + # -- saving the current auth ------------------------------------------ + + def action_save_current(self) -> None: + if self.busy: + self.notify("Another auth change is still running", severity="warning") + return + if self.snapshot is None: + self.notify("Current auth is still loading", severity="warning") + return + if self.snapshot.active_name is None and not self.snapshot.active_unmanaged: + self.notify( + "No current Codex auth found. Sign in first with codex login.", + severity="warning", + ) + return + self.push_screen(ProfileNameModal(), self._save_current_named) + + def _save_current_named(self, name: str | None) -> None: + if name is None: + return + existing = any( + account.name == name + for account in (self.snapshot.accounts if self.snapshot else ()) + ) + if existing: + self.push_screen( + ConfirmModal( + f"{name} already exists. Replace it with the current Codex auth?", + title="Replace saved profile?", + yes_label="Replace", + ), + partial(self._save_current_confirmed, name), + ) + return + self._start_save_current(name) + + def _save_current_confirmed(self, name: str, confirmed: bool) -> None: + if confirmed: + self._start_save_current(name) + + def _start_save_current(self, name: str) -> None: + if self.busy: + self.notify("Another auth change is still running", severity="warning") + return + self.busy = True + self.notify(f"Saving current auth as {name}…", timeout=3) + self.run_worker( + partial(self._save_current_blocking, name), + thread=True, + group="save", + exit_on_error=False, + name=f"save-{name}", + ) + + def _save_current_blocking(self, name: str) -> None: + result = _operation_result(self.backend.save_current(name)) + snap = self.backend.snapshot() + self.call_from_thread(self._save_current_done, name, result, snap) + + def _save_current_done( + self, + name: str, + result: OperationResult, + snap: AccountsSnapshot, + ) -> None: + self.busy = False + self.snapshot = snap + saved = any(account.name == name for account in snap.accounts) + if result.ok and saved: + self.notify(f"Saved current auth as {name}", title="Profile saved") + else: + message = _first_line(result.output) or "profile was not saved" + self.notify(message, severity="warning") + if result.output.strip(): + self.push_screen(OutputModal("Save profile: details", result.output)) + self._drain_pending() + + # -- earned rate-limit resets ----------------------------------------- + + def prepare_reset(self, name: str) -> None: + if self.busy or self._refreshing: + self.notify("Usage refresh is still running", severity="warning") + return + self.busy = True + self.notify(f"Checking reset credits for {name}…", timeout=3) + self.run_worker( + partial(self._check_reset_blocking, name), + thread=True, + group="reset-check", + exit_on_error=False, + name=f"reset-check-{name}", + ) + + def _check_reset_blocking(self, name: str) -> None: + result = _operation_result(self.backend.refresh([name])) + snap = self.backend.snapshot() + self.call_from_thread(self._reset_checked, name, result, snap) + + def _reset_checked( + self, + name: str, + result: OperationResult, + snap: AccountsSnapshot, + ) -> None: + self.busy = False + self.snapshot = snap + if not result.ok: + message = _first_line(result.output) or "could not refresh reset credits" + self.notify(message, severity="warning") + if result.output.strip(): + self.push_screen(OutputModal("Reset check: details", result.output)) + self._drain_pending() + return + account = next((item for item in snap.accounts if item.name == name), None) + count = account.usage.reset_credits_available if account is not None else None + if count is None: + self.notify( + f"Codex did not report earned reset credits for {name}", + severity="warning", + ) + self._drain_pending() + return + if count <= 0: + self.notify( + f"No earned resets are available for {name}", + severity="warning", + ) + self._drain_pending() + return + if not isinstance(self.screen, ResetScreen): + self._drain_pending() + return + noun = "reset" if count == 1 else "resets" + remainder = count - 1 + remaining_copy = ( + "This is the last available reset." + if remainder == 0 + else f"{remainder} will remain if it succeeds." + ) + self.push_screen( + ConfirmModal( + f"Use 1 of {count} earned {noun} on {name}?\n\n" + f"This resets an eligible Codex usage window. {remaining_copy}", + title="Use earned reset?", + yes_label="Use reset", + default_cancel=True, + ), + partial(self._reset_confirmed, name), + ) + self._drain_pending() + + def _reset_confirmed(self, name: str, confirmed: bool) -> None: + if not confirmed: + return + if self.busy: + self.notify("Another auth change is still running", severity="warning") + return + self.busy = True + self.notify(f"Using one earned reset on {name}…", timeout=3) + self.run_worker( + partial(self._consume_reset_blocking, name), + thread=True, + group="reset", + exit_on_error=False, + name=f"reset-{name}", + ) + + def _consume_reset_blocking(self, name: str) -> None: + result = _operation_result(self.backend.consume_reset(name)) + refresh_result = OperationResult(True) + if result.ok: + refresh_result = _operation_result(self.backend.refresh([name])) + snap = self.backend.snapshot() + self.call_from_thread( + self._reset_done, + name, + result, + refresh_result, + snap, + ) + + def _reset_done( + self, + name: str, + result: OperationResult, + refresh_result: OperationResult, + snap: AccountsSnapshot, + ) -> None: + self.busy = False + self.snapshot = snap + if result.ok: + account = next((item for item in snap.accounts if item.name == name), None) + remaining = ( + account.usage.reset_credits_available if account is not None else None + ) + if not refresh_result.ok: + suffix = " · usage refresh pending" + else: + suffix = ( + f" · {remaining} remaining" if remaining is not None else "" + ) + self.notify(f"Reset applied to {name}{suffix}", title="Earned reset") + if isinstance(self.screen, ResetScreen): + self.pop_screen() + else: + message = _first_line(result.output) or "reset was not applied" + self.notify(message, severity="warning") + if result.output.strip(): + self.push_screen(OutputModal("Use reset: details", result.output)) + self._drain_pending() + + # -- navigation -------------------------------------------------------- + + def action_refresh_full(self) -> None: + self.request_refresh(full=True, check_patch=True) + label = "Reading engine state…" if self._store_only else "Refreshing usage…" + self.notify(label, timeout=2) + + def action_open_auto(self) -> None: + if not isinstance(self.screen, AutoScreen): + self.push_screen(AutoScreen()) + + def action_open_watch(self) -> None: + if not isinstance(self.screen, WatchScreen): + self.push_screen(WatchScreen()) + + def action_open_resets(self) -> None: + if not isinstance(self.screen, ResetScreen): + self.push_screen(ResetScreen()) + + +def _operation_result(value: Any) -> OperationResult: + if isinstance(value, OperationResult): + return value + if value is None: + return OperationResult(True) + ok = bool(getattr(value, "ok", getattr(value, "switched", False))) + return OperationResult( + ok=ok, + returncode=int(getattr(value, "returncode", 0 if ok else 1)), + output=str(getattr(value, "output", "")), + ) + + +def _patched_ready(backend: object) -> bool | None: + value = getattr(backend, "patched_ready", None) + if value is None: + return None + try: + return bool(value() if callable(value) else value) + except Exception: + return False + + +def _first_line(text: str) -> str: + return next((line.strip() for line in text.splitlines() if line.strip()), "") diff --git a/tui/src/codex_auth_tui/tui/autoview.py b/tui/src/codex_auth_tui/tui/autoview.py new file mode 100644 index 0000000..b9a7ff1 --- /dev/null +++ b/tui/src/codex_auth_tui/tui/autoview.py @@ -0,0 +1,279 @@ +"""Live auto-switch screen: the real engine, visualized. + +Runs :class:`AutoSwitchEngine` in a thread worker and renders its typed events. +Opens in **dry-run** unless the CLI passed ``--live`` — opening the view must +never start switching on its own. The in-TUI toggle (``l``) always confirms +before going live. While this screen is up the app's snapshot poller is +store-only: the engine is the sole fetcher. +""" + +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Footer, RichLog, Static + +from codex_auth_tui.engine import AutoEvent, AutoSwitchEngine, rank_candidates +from codex_auth_tui.models import AccountsSnapshot +from codex_auth_tui.tui import data +from codex_auth_tui.tui.modals import ConfirmModal +from codex_auth_tui.tui.theme import ( + ACCENT, + FOREGROUND, + MUTED, + SEV_WARN, + severity_color, +) +from codex_auth_tui.tui.widgets import AccountsPanel, RuntimeStatus + +if TYPE_CHECKING: + from codex_auth_tui.tui.app import CodexAuthApp + +# Engine event kinds → log style. Switches stand out; failures warn; the +# routine refresh/decision chatter stays muted. +_EVENT_STYLES = { + "switch_succeeded": ACCENT, + "switch_dry_run": ACCENT, + "switch_started": ACCENT, + "refresh_failed": SEV_WARN, + "switch_failed": SEV_WARN, + "state_write_failed": SEV_WARN, + "error": SEV_WARN, +} +_QUIET_KINDS = { + "refresh_started", + "refresh_finished", + "decision", + "lock_busy", +} + + +def event_text(event: AutoEvent) -> Text: + style = _EVENT_STYLES.get(event.kind) + if style is None: + style = MUTED if event.kind in _QUIET_KINDS else FOREGROUND + text = Text() + text.append(f"{data.clock_stamp(event.ts)} ", style=MUTED) + message = event.human() if hasattr(event, "human") else str(event) + text.append(message, style=style) + return text + + +class AutoScreen(Screen): + BINDINGS = [ + Binding("l", "toggle_live", "Go live / dry-run"), + Binding("r", "refresh_now", "Refresh"), + Binding("escape,q", "back", "Back"), + ] + + app: "CodexAuthApp" + + def __init__(self, *, start_live: bool = False) -> None: + super().__init__() + self._engine: AutoSwitchEngine | None = None + self._settings = None + self._start_live = start_live + self._transitioning = False + self._back_after_stop = False + + def compose(self) -> ComposeResult: + yield AccountsPanel(show_minis=False, id="auto-active-panel") + yield RuntimeStatus(id="runtime-status") + with Vertical(id="auto-top"): + with Horizontal(id="auto-title-row"): + yield Static(" DRY-RUN ", id="mode-badge", classes="dry") + yield Static("", id="auto-summary") + yield Static("", id="candidates") + yield RichLog(id="event-log", highlight=False, markup=False, wrap=True) + yield Footer() + + # -- lifecycle ---------------------------------------------------------- + + def on_mount(self) -> None: + self.app.set_store_only(True) + self._settings = self.app.settings + trigger = ( + "any better" + if self._settings.threshold == 0 + else f"at {self._settings.threshold:g}%" + ) + self.query_one("#auto-summary", Static).update( + f"{trigger} · {self._settings.interval_seconds:.0f}s poll · " + f"{self._settings.cooldown_seconds:.0f}s cooldown" + ) + self.watch(self.app, "snapshot", self._on_snapshot) + self._start_engine(dry_run=not self._start_live) + + def on_unmount(self) -> None: + if self._engine is not None: + self._engine.stop() + if not getattr(self.app, "_shutting_down", False): + self.app.set_store_only(False) + + def action_back(self) -> None: + if self._transitioning: + return + if self._engine is None or self._engine.wait_stopped(0): + self.app.pop_screen() + return + self._back_after_stop = True + self._stop_engine_then(dry_run=True) + + # -- engine ------------------------------------------------------------- + + def _start_engine(self, *, dry_run: bool) -> None: + engine = AutoSwitchEngine( + self.app.backend, + self._settings, + self._emit_from_thread, + dry_run=dry_run, + state_path=self.app.paths.autoswitch_state_file, + ) + self._engine = engine + self._transitioning = False + self.run_worker( + engine.run_loop, + thread=True, + group="engine", + exit_on_error=False, + name=f"auto-engine-{'dry' if dry_run else 'live'}", + ) + self._update_badge() + mode = "DRY-RUN (watching only)" if dry_run else "LIVE (will switch profiles)" + self.query_one("#event-log", RichLog).write( + Text(f"engine started · {mode}", style=MUTED) + ) + + def _emit_from_thread(self, event: AutoEvent) -> None: + try: + self.app.call_from_thread(self._on_engine_event, event) + except Exception: + pass # app/screen tearing down mid-tick + + def _on_engine_event(self, event: AutoEvent) -> None: + if not self.is_attached: + return + self.query_one("#event-log", RichLog).write(event_text(event)) + if event.kind in {"refresh_finished", "switch_succeeded"}: + self.app.request_refresh() + + def action_refresh_now(self) -> None: + """Ask the engine for one immediate, lock-guarded policy tick.""" + if self._engine is None or self._transitioning: + return + self.run_worker( + self._engine.tick, + thread=True, + group="engine-refresh", + exclusive=True, + exit_on_error=False, + name="auto-refresh-now", + ) + self.app.notify("Checking usage…", timeout=2) + + def action_toggle_live(self) -> None: + if self._engine is None or self._transitioning: + return + if self._engine.dry_run: + self.app.push_screen( + ConfirmModal( + "Go live? codex-auth will switch your active profile " + "automatically when the threshold is reached.\n\n" + "(Same policy as running the engine with --live.)", + title="Go live", + yes_label="Go live", + ), + self._on_live_confirm, + ) + else: + self._restart_engine(dry_run=True) + + def _on_live_confirm(self, confirmed: bool | None) -> None: + if confirmed: + self._restart_engine(dry_run=False) + + def _restart_engine(self, *, dry_run: bool) -> None: + self._stop_engine_then(dry_run=dry_run) + + def _stop_engine_then(self, *, dry_run: bool) -> None: + old = self._engine + if old is None: + if self._back_after_stop: + self._back_after_stop = False + self.app.pop_screen() + else: + self._start_engine(dry_run=dry_run) + return + self._transitioning = True + old.stop() + badge = self.query_one("#mode-badge", Static) + badge.update(" STOPPING ") + badge.set_classes("dry") + self.run_worker( + partial(self._wait_for_engine, old, dry_run), + thread=True, + group="engine-transition", + exclusive=True, + exit_on_error=False, + name="auto-engine-transition", + ) + + def _wait_for_engine(self, engine: AutoSwitchEngine, dry_run: bool) -> None: + timeout = float(self._settings.refresh_timeout_s) + 5.0 + stopped = engine.wait_stopped(timeout) + self.app.call_from_thread(self._finish_transition, dry_run, stopped) + + def _finish_transition(self, dry_run: bool, stopped: bool) -> None: + if not self.is_attached: + return + if not stopped: + self._transitioning = False + self.app.notify( + "Live engine is still stopping; no new mode was started", + severity="warning", + ) + self._update_badge() + return + if self._back_after_stop: + self._back_after_stop = False + self._transitioning = False + self.app.pop_screen() + return + self._start_engine(dry_run=dry_run) + + def _update_badge(self) -> None: + badge = self.query_one("#mode-badge", Static) + if self._engine is not None and not self._engine.dry_run: + badge.update(" LIVE ") + badge.set_classes("live") + else: + badge.update(" DRY-RUN ") + badge.set_classes("dry") + + # -- candidates --------------------------------------------------------- + + def _on_snapshot(self, snap: AccountsSnapshot | None) -> None: + if snap is None: + return + self.query_one("#candidates", Static).update(self._candidates_text(snap)) + + def _candidates_text(self, snap: AccountsSnapshot) -> Text: + text = Text() + text.append("Next ready", style=MUTED) + ranked = rank_candidates(snap, now=snap.taken_at) + if not ranked: + text.append("\n no ready profile with trusted usage", style=MUTED) + return text + for account in ranked: + pct = account.usage.effective_binding_pct(snap.taken_at) + text.append("\n ") + text.append(account.name, style=FOREGROUND) + if pct is not None: + text.append(f" {pct:3.0f}% used", style=severity_color(pct)) + return text diff --git a/tui/src/codex_auth_tui/tui/codex_auth_tui.tcss b/tui/src/codex_auth_tui/tui/codex_auth_tui.tcss new file mode 100644 index 0000000..6e5237c --- /dev/null +++ b/tui/src/codex_auth_tui/tui/codex_auth_tui.tcss @@ -0,0 +1,196 @@ +/* codex-auth TUI — flat, low-chrome dark layout. Colors come from the + registered "codex-auth-dark" theme (theme.py); this file handles layout. */ + +Screen { + background: $background; +} + +#accounts-panel { + height: auto; + padding: 1 3; + background: $background; + border-bottom: solid $panel; +} + +#auto-active-panel { + height: auto; + padding: 1 3 0 3; + background: $background; +} + +#menu-title, #list-title { + height: 1; + margin-top: 1; + padding: 0 3; + color: $secondary; +} + +#runtime-status { + height: auto; + padding: 0 3; + color: $secondary; + background: $background; +} + +#menu, #accounts { + background: $background; + padding: 1 2 0 1; + scrollbar-size-vertical: 1; +} + +#menu:focus, #accounts:focus { + background-tint: $foreground 0%; +} + +#menu ListItem, #accounts ListItem { + background: transparent; + padding: 0 1; +} + +#accounts ListItem { + margin-bottom: 1; +} + +#menu ListItem.-highlight, +#menu:focus ListItem.-highlight, +#accounts ListItem.-highlight, +#accounts:focus ListItem.-highlight { + color: $foreground; + background: $surface; + text-style: bold; +} + +#accounts ListItem.flash { + background: $panel; +} + +/* -- auto screen ---------------------------------------------------------- */ + +#auto-top { + height: auto; + background: $background; + padding: 1 2; +} + +#auto-title-row { + height: auto; + margin-bottom: 1; +} + +#mode-badge { + width: auto; + text-style: bold; +} + +#mode-badge.dry { + background: $panel; + color: $warning; +} + +#mode-badge.live { + background: $primary; + color: $background; +} + +#auto-summary { + width: 1fr; + color: $secondary; + padding-left: 2; +} + +#candidates { + height: auto; +} + +#event-log { + background: $background; + border-top: solid $panel; + padding: 0 1; + scrollbar-size-vertical: 1; +} + +/* -- modals --------------------------------------------------------------- */ + +ProfileNameModal, ConfirmModal, OutputModal { + align: center middle; + background: $background 60%; +} + +.modal-box { + width: 64; + max-width: 90%; + height: auto; + max-height: 80%; + background: $surface; + border: round $panel; + padding: 1 2; +} + +.modal-box-wide { + width: 90; +} + +.modal-title { + text-style: bold; + color: $primary; + margin-bottom: 1; +} + +.modal-body { + color: $foreground; + margin-bottom: 1; +} + +#profile-name { + border: tall $panel; + background: $background; +} + +#profile-name:focus { + border: tall $primary; +} + +#profile-name-error { + height: 1; + color: $warning; +} + +.modal-buttons { + height: auto; + align-horizontal: right; + margin-top: 1; +} + +.modal-buttons Button { + border: none; + height: 1; + min-width: 12; + padding: 0 2; + margin-left: 2; + background: $panel; + color: $foreground; + text-style: none; +} + +.modal-buttons Button:focus { + background: $primary; + color: $background; + text-style: bold; +} + +.modal-buttons Button:hover { + background: $panel-lighten-1; +} + +.modal-hint { + height: 1; + margin-top: 1; + color: $secondary; +} + +.modal-output { + height: auto; + max-height: 20; + background: $background; + padding: 1; +} diff --git a/tui/src/codex_auth_tui/tui/dashboard.py b/tui/src/codex_auth_tui/tui/dashboard.py new file mode 100644 index 0000000..eaefef2 --- /dev/null +++ b/tui/src/codex_auth_tui/tui/dashboard.py @@ -0,0 +1,341 @@ +"""Dashboard + watch/switch screens. + +The accounts panel is the monitor; arrow keys drive the *menu*, not the +accounts. Account-targeted work opens its own context: + +- ``s`` / "Switch profile" → :class:`SwitchScreen` — every profile full-size, + Enter switches, pops back. +- ``w`` / "Watch profiles" / ``codex-auth watch`` → :class:`WatchScreen` — the + same cards read-only: a live monitor. ``s`` arms selection (a cursor appears), + Enter switches and *stays watching*, Esc disarms. No accidental switch cursor + while passively watching. +""" + +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, Callable + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.screen import Screen +from textual.widgets import Footer, ListView, Static + +from codex_auth_tui.models import AccountsSnapshot +from codex_auth_tui.tui.widgets import ( + AccountItem, + AccountsPanel, + MenuItem, + RuntimeStatus, +) + +if TYPE_CHECKING: + from codex_auth_tui.tui.app import CodexAuthApp + +FLASH_S = 1.5 + +MenuEntries = list[tuple[str, str]] + + +class DashboardScreen(Screen): + BINDINGS = [ + Binding("s", "open_switch", "Switch"), + Binding("n", "app.save_current", "Save"), + Binding("u", "app.open_resets", "Use reset"), + Binding("w", "app.open_watch", "Watch"), + Binding("a", "app.open_auto", "Auto"), + Binding("r", "app.refresh_full", "Refresh"), + Binding("q", "app.quit", "Quit"), + Binding("j", "cursor_down", show=False), + Binding("k", "cursor_up", show=False), + ] + + app: "CodexAuthApp" + + def compose(self) -> ComposeResult: + yield AccountsPanel(id="accounts-panel") + yield RuntimeStatus(id="runtime-status") + yield Static("", id="menu-title") + yield ListView(id="menu") + yield Footer() + + async def on_mount(self) -> None: + self.query_one("#menu-title", Static).update("codex-auth") + menu = self.query_one("#menu", ListView) + await menu.extend( + MenuItem(label, action_id) + for label, action_id in ( + ("Watch profiles", "watch"), + ("Switch profile…", "switch"), + ("Save current auth…", "save-current"), + ("Use earned reset…", "reset"), + ("Auto-switch view", "auto"), + ("Quit", "quit"), + ) + ) + menu.index = 0 + menu.focus() + + async def on_list_view_selected(self, event: ListView.Selected) -> None: + item = event.item + if isinstance(item, MenuItem): + self._dispatch(item.action_id) + + def _dispatch(self, action_id: str) -> None: + app = self.app + actions: dict[str, Callable[[], None]] = { + "watch": app.action_open_watch, + "switch": self.action_open_switch, + "save-current": app.action_save_current, + "reset": app.action_open_resets, + "auto": app.action_open_auto, + "quit": app.exit, + } + actions[action_id]() + + def action_open_switch(self) -> None: + if not isinstance(self.app.screen, SwitchScreen): + self.app.push_screen(SwitchScreen()) + + def action_cursor_down(self) -> None: + self.query_one("#menu", ListView).action_cursor_down() + + def action_cursor_up(self) -> None: + self.query_one("#menu", ListView).action_cursor_up() + + +class AccountListScreen(Screen): + """Shared machinery: a live ListView of full account cards.""" + + app: "CodexAuthApp" + + def __init__(self) -> None: + super().__init__() + self._names: list[str] = [] + self._stamps: dict[str, float | None] = {} + + def compose(self) -> ComposeResult: + yield Static("", id="list-title") + yield RuntimeStatus(id="runtime-status") + yield ListView(id="accounts") + yield Footer() + + def on_mount(self) -> None: + self.watch(self.app, "snapshot", self._on_snapshot) + + async def _on_snapshot(self, snap: AccountsSnapshot | None) -> None: + if snap is None: + return + listview = self.query_one("#accounts", ListView) + names = [acc.name for acc in snap.accounts] + if names != self._names: + first_build = not self._names + previous = listview.index + await listview.clear() + await listview.extend(AccountItem(acc) for acc in snap.accounts) + self._names = names + listview.index = ( + self._index_after_build(snap, first_build, previous) if names else None + ) + else: + for item, acc in zip(listview.query(AccountItem), snap.accounts): + item.set_account(acc) + self._flash_updated(snap, listview) + + def _index_after_build(self, snap, first_build, previous) -> int | None: + if first_build: + return self._active_index(snap) + return min(previous or 0, len(snap.accounts) - 1) + + def _active_index(self, snap: AccountsSnapshot) -> int: + return next( + (i for i, acc in enumerate(snap.accounts) if acc.name == snap.active_name), + 0, + ) + + def _flash_updated(self, snap: AccountsSnapshot, listview: ListView) -> None: + new_stamps = {acc.name: acc.usage.fetched_at for acc in snap.accounts} + if self._stamps: + changed = { + name + for name, ts in new_stamps.items() + if ts is not None and ts != self._stamps.get(name) + } + for item in listview.query(AccountItem): + if item.name_ in changed and not item.has_class("flash"): + item.add_class("flash") + self.set_timer(FLASH_S, partial(item.remove_class, "flash")) + self._stamps = new_stamps + + def action_cursor_down(self) -> None: + self.query_one("#accounts", ListView).action_cursor_down() + + def action_cursor_up(self) -> None: + self.query_one("#accounts", ListView).action_cursor_up() + + +class SwitchScreen(AccountListScreen): + """All profiles, full-size: arrows pick, Enter switches, pops back.""" + + BINDINGS = [ + Binding("enter", "select_highlighted", "Switch", priority=True), + Binding("escape,q,s", "back", "Back"), + Binding("j", "cursor_down", show=False), + Binding("k", "cursor_up", show=False), + ] + + def on_mount(self) -> None: + self.query_one("#list-title", Static).update("switch to which profile?") + self.query_one("#accounts", ListView).focus() + super().on_mount() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + item = event.item + if isinstance(item, AccountItem): + if not item.switchable: + self.app.notify( + f"{item.name_} is not switchable", severity="warning" + ) + return + self.app.do_switch(item.name_) + self.app.pop_screen() + + def action_select_highlighted(self) -> None: + listview = self.query_one("#accounts", ListView) + if listview.display: + listview.action_select_cursor() + + def action_back(self) -> None: + self.app.pop_screen() + + +class ResetScreen(AccountListScreen): + """Pick a ChatGPT profile, fresh-check its bank, then confirm redemption.""" + + BINDINGS = [ + Binding("enter", "select_highlighted", "Check", priority=True), + Binding("escape,q,u", "back", "Back"), + Binding("j", "cursor_down", show=False), + Binding("k", "cursor_up", show=False), + ] + + def on_mount(self) -> None: + self.query_one("#list-title", Static).update( + "use an earned reset · select a profile to check" + ) + self.query_one("#accounts", ListView).focus() + super().on_mount() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + item = event.item + if isinstance(item, AccountItem): + if not item.switchable: + self.app.notify( + f"{item.name_} is not a ChatGPT profile", + severity="warning", + ) + return + self.app.prepare_reset(item.name_) + + def action_select_highlighted(self) -> None: + self.query_one("#accounts", ListView).action_select_cursor() + + def action_back(self) -> None: + self.app.pop_screen() + + +class WatchScreen(AccountListScreen): + """Live monitor of every profile, hands-off by default. + + ``s`` arms selection (a cursor appears on the active profile); Enter then + switches and stays here. Esc disarms selection first, then leaves. + """ + + _WATCH_TITLE = "watching all profiles" + _SELECT_TITLE = "switch to which profile? · enter confirm · esc cancel" + + BINDINGS = [ + Binding("s", "toggle_select", "Switch"), + Binding("enter", "select_highlighted", "Confirm", priority=True), + Binding("n", "app.save_current", "Save"), + Binding("u", "app.open_resets", "Use reset"), + Binding("a", "app.open_auto", "Auto"), + Binding("r", "app.refresh_full", "Refresh"), + Binding("escape,q", "back", "Back"), + Binding("down,j", "nav_down", show=False), + Binding("up,k", "nav_up", show=False), + ] + + def __init__(self) -> None: + super().__init__() + self._selecting = False + + def on_mount(self) -> None: + self.query_one("#list-title", Static).update(self._WATCH_TITLE) + super().on_mount() + + def check_action(self, action: str, parameters: tuple) -> bool | None: + if action == "select_highlighted" and not self._selecting: + return False # hidden and inert until selection is armed + return True + + def _index_after_build(self, snap, first_build, previous) -> int | None: + if not self._selecting: + return None # monitor mode: no cursor at all + return super()._index_after_build(snap, first_build, previous) + + def _set_selecting(self, on: bool) -> None: + self._selecting = on + listview = self.query_one("#accounts", ListView) + title = self.query_one("#list-title", Static) + if on: + snap = self.app.snapshot + if snap is not None and snap.accounts: + listview.index = self._active_index(snap) + listview.focus() + title.update(self._SELECT_TITLE) + else: + listview.index = None + self.set_focus(None) + title.update(self._WATCH_TITLE) + self.refresh_bindings() + + def action_toggle_select(self) -> None: + self._set_selecting(not self._selecting) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + if not self._selecting: + return + item = event.item + if isinstance(item, AccountItem): + if not item.switchable: + self.app.notify( + f"{item.name_} is not switchable", severity="warning" + ) + return + self.app.do_switch(item.name_) + self._set_selecting(False) # stay here, keep watching + + def action_select_highlighted(self) -> None: + if self._selecting: + self.query_one("#accounts", ListView).action_select_cursor() + + def action_back(self) -> None: + if self._selecting: + self._set_selecting(False) + else: + self.app.pop_screen() + + def action_nav_down(self) -> None: + listview = self.query_one("#accounts", ListView) + if self._selecting: + listview.action_cursor_down() + else: + listview.scroll_down(animate=False) + + def action_nav_up(self) -> None: + listview = self.query_one("#accounts", ListView) + if self._selecting: + listview.action_cursor_up() + else: + listview.scroll_up(animate=False) diff --git a/tui/src/codex_auth_tui/tui/data.py b/tui/src/codex_auth_tui/tui/data.py new file mode 100644 index 0000000..b97c962 --- /dev/null +++ b/tui/src/codex_auth_tui/tui/data.py @@ -0,0 +1,54 @@ +"""Display helpers for the TUI (duration/age formatting, reset countdowns).""" + +from __future__ import annotations + +import time + +from codex_auth_tui.models import SERVE_TTL_S, AccountUsage, UsageWindow + + +def format_duration(seconds: float) -> str: + """Compact duration: "45s", "12m", "2h 13m", "3d 4h".""" + s = int(seconds) + if s < 60: + return f"{s}s" + if s < 3600: + return f"{s // 60}m" + if s < 86400: + h, m = divmod(s // 60, 60) + return f"{h}h {m}m" if m else f"{h}h" + d, h = divmod(s // 3600, 24) + return f"{d}d {h}h" if h else f"{d}d" + + +def format_age(age_s: float | None) -> str | None: + """Measurement age note ("· 2m ago"); None while comfortably fresh.""" + if age_s is None or age_s < SERVE_TTL_S: + return None + return f"· {format_duration(age_s)} ago" + + +def reset_text(window: UsageWindow, now: float) -> str | None: + """Live countdown to a window's reset ("resets 2h 13m"), if known.""" + ts = window.reset_ts + if ts is None: + return None + remaining = ts - now + if remaining <= 0: + return "resets now" + return f"resets {format_duration(remaining)}" + + +def last_seen_note(usage: AccountUsage) -> str | None: + """Age of the last good measurement behind a sentinel/error, if any.""" + if usage.fetched_at is None: + return None + return f"last seen {format_duration(usage.age_s or 0.0)} ago" + + +def clock_stamp(timestamp: float | None = None) -> str: + """Local HH:MM:SS for an event timestamp (or now when omitted).""" + return time.strftime( + "%H:%M:%S", + time.localtime(timestamp) if timestamp is not None else time.localtime(), + ) diff --git a/tui/src/codex_auth_tui/tui/modals.py b/tui/src/codex_auth_tui/tui/modals.py new file mode 100644 index 0000000..c4591c4 --- /dev/null +++ b/tui/src/codex_auth_tui/tui/modals.py @@ -0,0 +1,152 @@ +"""Modal screens: profile naming, confirmation, and captured output.""" + +from __future__ import annotations + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.screen import ModalScreen +from textual.widgets import Button, Input, Label, Static + + +def _profile_name_error(value: str) -> str: + if not value: + return "Enter a profile name." + if any( + not (char.isascii() and (char.isalnum() or char in "._-")) + for char in value + ): + return "Use only A-Z, a-z, 0-9, dot, underscore, or dash." + return "" + + +class ProfileNameModal(ModalScreen[str | None]): + """Collect a shell-safe name for the current live auth.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)] + + def compose(self) -> ComposeResult: + with Vertical(classes="modal-box"): + yield Label("Save current auth", classes="modal-title") + yield Static( + "Name the Codex session currently stored in auth.json.", + classes="modal-body", + ) + yield Input( + placeholder="profile name", + id="profile-name", + select_on_focus=False, + ) + yield Static("Enter a profile name.", id="profile-name-error") + with Horizontal(classes="modal-buttons"): + yield Button("Save", id="save", disabled=True) + yield Button("Cancel", id="cancel") + yield Static("enter save · esc cancel", classes="modal-hint") + + def on_mount(self) -> None: + self.query_one("#profile-name", Input).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + self._show_validation(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + self._submit(event.value) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "save": + self._submit(self.query_one("#profile-name", Input).value) + else: + self.action_cancel() + + def _show_validation(self, value: str) -> str: + error = _profile_name_error(value) + self.query_one("#profile-name-error", Static).update(error) + self.query_one("#save", Button).disabled = bool(error) + return error + + def _submit(self, value: str) -> None: + name = value.strip() + if self._show_validation(name): + return + self.dismiss(name) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class ConfirmModal(ModalScreen[bool]): + """Yes/No confirmation. Dismisses with True only on explicit confirm.""" + + BINDINGS = [ + Binding("y", "confirm", "Yes", show=False), + Binding("n,escape", "cancel", "No", show=False), + Binding("left", "app.focus_previous", show=False), + Binding("right", "app.focus_next", show=False), + ] + + def __init__( + self, + message: str, + *, + title: str = "Confirm", + yes_label: str = "Yes", + default_cancel: bool = False, + ) -> None: + super().__init__() + self._title = title + self._message = message + self._yes_label = yes_label + self._default_cancel = default_cancel + + def compose(self) -> ComposeResult: + with Vertical(classes="modal-box"): + yield Label(self._title, classes="modal-title") + yield Static(self._message, classes="modal-body") + with Horizontal(classes="modal-buttons"): + yield Button(self._yes_label, id="yes") + yield Button("Cancel", id="no") + yield Static( + f"← → · enter · y {self._yes_label.lower()} · n / esc cancel", + classes="modal-hint", + ) + + def on_mount(self) -> None: + if self._default_cancel: + self.query_one("#no", Button).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(event.button.id == "yes") + + def action_confirm(self) -> None: + self.dismiss(True) + + def action_cancel(self) -> None: + self.dismiss(False) + + +class OutputModal(ModalScreen[None]): + """Scrollable display of captured (ANSI-colored) action output.""" + + BINDINGS = [Binding("escape,q,enter", "dismiss_modal", "Close", show=False)] + + def __init__(self, title: str, output: str) -> None: + super().__init__() + self._title = title + self._output = output + + def compose(self) -> ComposeResult: + with Vertical(classes="modal-box modal-box-wide"): + yield Label(self._title, classes="modal-title") + with VerticalScroll(classes="modal-output"): + yield Static(Text.from_ansi(self._output.rstrip() or "(no output)")) + with Horizontal(classes="modal-buttons"): + yield Button("Close", id="close") + yield Static("esc close", classes="modal-hint") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(None) + + def action_dismiss_modal(self) -> None: + self.dismiss(None) diff --git a/tui/src/codex_auth_tui/tui/theme.py b/tui/src/codex_auth_tui/tui/theme.py new file mode 100644 index 0000000..32dc665 --- /dev/null +++ b/tui/src/codex_auth_tui/tui/theme.py @@ -0,0 +1,52 @@ +"""The "codex-auth-dark" Textual theme and shared color constants.""" + +from __future__ import annotations + +from textual.theme import Theme + +ACCENT = "#d7875f" # warm terracotta +FOREGROUND = "#e8e4de" +MUTED = "#8a8a8a" +BACKGROUND = "#141414" +SURFACE = "#1e1e1e" +PANEL = "#262626" + +SEV_OK = "#87af87" # plenty of headroom +SEV_WARN = "#d7af5f" # climbing (>= 70%) +SEV_CRIT = "#d75f5f" # near the limit (>= 90%) +TRACK = "#3a3a3a" # unfilled bar track + +WARN_PCT = 70.0 +CRIT_PCT = 90.0 + + +def severity_color(pct: float | None) -> str: + if pct is None: + return MUTED + if pct >= CRIT_PCT: + return SEV_CRIT + if pct >= WARN_PCT: + return SEV_WARN + return SEV_OK + + +CODEX_AUTH_DARK = Theme( + name="codex-auth-dark", + primary=ACCENT, + secondary=MUTED, + accent=ACCENT, + foreground=FOREGROUND, + background=BACKGROUND, + surface=SURFACE, + panel=PANEL, + success=SEV_OK, + warning=SEV_WARN, + error=SEV_CRIT, + dark=True, + variables={ + "footer-key-foreground": ACCENT, + "block-cursor-background": PANEL, + "block-cursor-foreground": FOREGROUND, + "block-cursor-text-style": "none", + }, +) diff --git a/tui/src/codex_auth_tui/tui/widgets.py b/tui/src/codex_auth_tui/tui/widgets.py new file mode 100644 index 0000000..7f06850 --- /dev/null +++ b/tui/src/codex_auth_tui/tui/widgets.py @@ -0,0 +1,292 @@ +"""Shared render widgets: usage bars, account cards, and the accounts panel. + +Custom bar renderers (not Textual's ProgressBar) so the design can carry a +severity color ramp, an optional threshold tick (the auto-switch trigger line), +and stale-measurement dimming. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from rich.text import Text +from textual.widgets import ListItem, Static + +from codex_auth_tui.models import AccountSnapshot +from codex_auth_tui.tui import data +from codex_auth_tui.tui.theme import ( + ACCENT, + FOREGROUND, + MUTED, + SEV_OK, + SEV_WARN, + TRACK, + severity_color, +) + +if TYPE_CHECKING: + from codex_auth_tui.tui.app import CodexAuthApp + +_BAR_FILLED = "━" +_BAR_HALF = "╸" +_BAR_EMPTY = "─" +_BAR_TICK = "┃" + + +def bar_cells( + pct: float | None, + width: int, + *, + stale: bool = False, + threshold: float | None = None, +) -> Text: + text = Text() + if pct is None: + text.append(_BAR_EMPTY * width, style=TRACK) + return text + frac = min(max(pct, 0.0), 100.0) / 100.0 + cells = frac * width + full = int(cells) + half = (cells - full) >= 0.5 and full < width + tick_at: int | None = None + if threshold is not None and threshold > 0: + tick_at = min(width - 1, max(0, round(threshold / 100.0 * width))) + color = severity_color(pct) + fill_style = f"{color} dim" if stale else color + for i in range(width): + if tick_at is not None and i == tick_at: + text.append(_BAR_TICK, style=SEV_WARN) + elif i < full: + text.append(_BAR_FILLED, style=fill_style) + elif i == full and half: + text.append(_BAR_HALF, style=fill_style) + else: + text.append(_BAR_EMPTY, style=TRACK) + return text + + +def usage_bar( + label: str, + pct: float | None, + suffix: str | None, + width: int, + *, + stale: bool = False, + threshold: float | None = None, +) -> Text: + text = Text() + text.append(f"{label} ", style=MUTED) + text.append(bar_cells(pct, width, stale=stale, threshold=threshold)) + if pct is None: + text.append(" usage unknown", style=MUTED) + else: + color = severity_color(pct) + text.append(f" {pct:3.0f}%", style=f"{color} dim" if stale else color) + if suffix: + text.append(f" {suffix}", style=MUTED) + return text + + +def account_card_text( + acc: AccountSnapshot, + width: int, + *, + threshold: float | None = None, + now: float | None = None, +) -> Text: + now = now if now is not None else time.time() + text = Text() + text.append(f"{acc.name}", style=f"bold {FOREGROUND}") + text.append(f" [{acc.display_tag}]", style=MUTED) + if acc.is_active: + text.append(" ● active", style=f"bold {ACCENT}") + if not acc.switchable: + text.append(" not switchable", style=MUTED) + reset_count = acc.usage.reset_credits_available + if reset_count is not None: + reset_label = "reset" if reset_count == 1 else "resets" + reset_style = ACCENT if reset_count > 0 else MUTED + text.append(f" ↻ {reset_count} earned {reset_label}", style=reset_style) + age = data.format_age(acc.usage.age_s) + if age: + text.append(f" {age}", style=MUTED) + if acc.usage.stale and acc.usage.windows: + text.append(" stale", style=SEV_WARN) + + if acc.usage.sentinel is not None: + text.append("\n ") + text.append(f"· {acc.usage.sentinel}", style=MUTED) + return text + + if not acc.usage.windows: + text.append("\n ") + text.append("usage unavailable", style=MUTED) + if acc.usage.last_error: + error = ( + "sign in again" + if acc.usage.requires_login + else acc.usage.last_error + ) + text.append(f" · {error}", style=MUTED) + return text + + stale = acc.usage.stale + label_width = max(len(w.label) for w in acc.usage.windows) + bar_width = max(12, min(30, width - 42 - label_width)) + for window in acc.usage.windows: + suffix = data.reset_text(window, now) or "" + text.append("\n ") + text.append( + usage_bar( + f"{window.label:<{label_width}}", + window.pct, + suffix or None, + bar_width, + stale=stale, + threshold=threshold, + ) + ) + return text + + +def mini_account_text(acc: AccountSnapshot, now: float) -> Text: + text = Text(no_wrap=True, overflow="ellipsis") + text.append(f"{acc.name}", style=f"bold {MUTED}") + text.append(f" [{acc.display_tag}]", style=MUTED) + reset_count = acc.usage.reset_credits_available + if reset_count is not None: + reset_label = "reset" if reset_count == 1 else "resets" + reset_style = ACCENT if reset_count > 0 else MUTED + text.append(f" ↻ {reset_count} {reset_label}", style=reset_style) + text.append(" ") + if acc.usage.sentinel is not None: + text.append(acc.usage.sentinel, style=MUTED) + return text + if not acc.usage.windows: + text.append("usage unknown", style=MUTED) + return text + stale = acc.usage.stale + for i, window in enumerate(acc.usage.windows): + if i: + text.append(" · ", style=TRACK) + color = severity_color(window.pct) + text.append(f"{window.label} ", style=MUTED) + text.append(f"{window.pct:.0f}%", style=f"{color} dim" if stale else color) + if window.pct >= 100: + reset = data.reset_text(window, now) + if reset: + text.append(f" ({reset})", style=MUTED) + return text + + +class AccountsPanel(Static): + """Always-visible monitor: active profile full-size, others as minis.""" + + def __init__(self, *, show_minis: bool = True, id: str | None = None) -> None: + super().__init__(id=id) + self._show_minis = show_minis + + def on_mount(self) -> None: + self.watch(self.app, "snapshot", lambda _snap: self.refresh(layout=True)) + + def render(self) -> Text: + app: "CodexAuthApp" = self.app # type: ignore[assignment] + snap = app.snapshot + if snap is None: + return Text("loading…", style=MUTED) + if not snap.accounts: + return Text( + "No saved profiles yet.\n" + "Press n to save the current Codex auth as a profile.", + style=MUTED, + ) + now = time.time() + width = (self.size.width or 80) - 2 + blocks: list[Text] = [] + for acc in snap.accounts: + if acc.is_active: + blocks.append( + account_card_text(acc, width, threshold=app.threshold_pct, now=now) + ) + elif self._show_minis: + blocks.append(mini_account_text(acc, now)) + if not blocks: + return Text("no active managed profile", style=MUTED) + text = Text() + previous_multiline = False + for i, block in enumerate(blocks): + multiline = "\n" in block.plain + if i: + text.append("\n\n" if (multiline or previous_multiline) else "\n") + text.append(block) + previous_multiline = multiline + return text + + +class AccountCard(Static): + """One account rendered full-size (used by the switch/watch list).""" + + def __init__(self, acc: AccountSnapshot, *, threshold: float | None = None) -> None: + super().__init__() + self._acc = acc + self._threshold = threshold + + def set_account(self, acc: AccountSnapshot) -> None: + self._acc = acc + self.refresh(layout=True) + + def render(self) -> Text: + threshold = self._threshold + if threshold is None: + threshold = getattr(self.app, "threshold_pct", None) + return account_card_text( + self._acc, self.size.width or 80, threshold=threshold + ) + + +class AccountItem(ListItem): + """ListView row wrapping an :class:`AccountCard`; remembers its profile.""" + + def __init__(self, acc: AccountSnapshot) -> None: + super().__init__(AccountCard(acc)) + self.name_ = acc.name + self.switchable = acc.switchable + self.reset_credits_available = acc.usage.reset_credits_available + + def set_account(self, acc: AccountSnapshot) -> None: + self.name_ = acc.name + self.switchable = acc.switchable + self.reset_credits_available = acc.usage.reset_credits_available + self.query_one(AccountCard).set_account(acc) + + +class MenuItem(ListItem): + def __init__(self, label: str, action_id: str, *, muted: bool = False) -> None: + style = MUTED if muted else FOREGROUND + super().__init__(Static(Text(label, style=style))) + self.action_id = action_id + + +class RuntimeStatus(Static): + """Makes the live-reload boundary visible without adding dashboard chrome.""" + + def on_mount(self) -> None: + self.watch( + self.app, + "patched_ready_state", + lambda _ready: self.refresh(layout=True), + ) + + def render(self) -> Text: + ready = getattr(self.app, "patched_ready_state", None) + text = Text() + if ready is None: + text.append("○ patched Codex · checking", style=MUTED) + elif ready: + text.append("● patched ready", style=f"bold {SEV_OK}") + text.append(" · reloads this session", style=MUTED) + else: + text.append("● patched stale", style=f"bold {SEV_WARN}") + text.append(" · switches apply next session", style=MUTED) + return text diff --git a/tui/tests/conftest.py b/tui/tests/conftest.py new file mode 100644 index 0000000..46de7be --- /dev/null +++ b/tui/tests/conftest.py @@ -0,0 +1,152 @@ +"""Shared fixtures. Every test that touches auth state runs under a temp +CODEX_HOME so it can never read or write the real ~/.codex.""" + +from __future__ import annotations + +import base64 +import json +import os +import stat +import time +from pathlib import Path + +import pytest + +from codex_auth_tui.paths import CodexPaths, resolve_paths + + +@pytest.fixture +def codex_home(tmp_path, monkeypatch) -> CodexPaths: + """Isolated CODEX_HOME with the profile/backup/tmp dirs created.""" + home = tmp_path / "codex-home" + home.mkdir() + monkeypatch.setenv("CODEX_HOME", str(home)) + monkeypatch.setenv("HOME", str(tmp_path / "safe-home")) + monkeypatch.delenv("CODEX_AUTH_STATE_FILE", raising=False) + monkeypatch.delenv("CODEX_AUTH_PROFILES_DIR", raising=False) + paths = resolve_paths() + paths.profiles_dir.mkdir(parents=True, exist_ok=True) + paths.backup_dir.mkdir(parents=True, exist_ok=True) + paths.tmp_dir.mkdir(parents=True, exist_ok=True) + return paths + + +def write_profile(paths: CodexPaths, name: str, data: dict) -> Path: + path = paths.profiles_dir / f"{name}.json" + path.write_text(json.dumps(data), encoding="utf-8") + return path + + +def chatgpt_profile(name: str, *, refresh_token: str | None = None) -> dict: + claims = { + "sub": f"user-{name}", + "https://api.openai.com/auth": { + "chatgpt_account_id": f"account-{name}", + "chatgpt_user_id": f"user-{name}", + }, + } + header = base64.urlsafe_b64encode(b'{"alg":"none"}').decode().rstrip("=") + payload = ( + base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=") + ) + return { + "tokens": { + "refresh_token": refresh_token or f"rt-{name}", + "access_token": f"at-{name}", + "account_id": f"account-{name}", + "id_token": f"{header}.{payload}.", + } + } + + +def api_key_profile() -> dict: + return {"OPENAI_API_KEY": "sk-test-key"} + + +def seed_state(paths: CodexPaths, profiles: dict[str, dict], *, now: float | None = None) -> None: + """Write auth-state.json entries. ``profiles`` maps name -> {fingerprint, payload, age}.""" + now = time.time() if now is None else now + out = {"version": 1, "updated_at": now, "profiles": {}} + for name, spec in profiles.items(): + out["profiles"][name] = { + "updated_at": now - spec.get("age", 0.0), + "fingerprint": spec["fingerprint"], + "payload": spec["payload"], + } + if spec.get("generation") is not None: + out["profiles"][name]["refresh_generation"] = spec["generation"] + paths.state_file.write_text(json.dumps(out), encoding="utf-8") + + +def rate_payload( + primary_pct: float, + secondary_pct: float, + *, + reset_in: float = 3600.0, + reset_credits: int | None = None, +) -> dict: + reset = time.time() + reset_in + payload = { + "rateLimitsByLimitId": { + "codex": { + "planType": "pro", + "primary": { + "usedPercent": primary_pct, + "windowDurationMins": 300, + "resetsAt": reset, + }, + "secondary": { + "usedPercent": secondary_pct, + "windowDurationMins": 10080, + "resetsAt": reset, + }, + } + } + } + if reset_credits is not None: + payload["rateLimitResetCredits"] = { + "availableCount": reset_credits, + "credits": None, + } + return payload + + +@pytest.fixture +def fake_codex_auth(codex_home: CodexPaths, tmp_path, monkeypatch) -> Path: + """A fake ``codex-auth`` that logs args and performs save/switch copies.""" + log = tmp_path / "codex-auth.log" + script = tmp_path / "codex-auth" + script.write_text( + "#!/usr/bin/env bash\n" + 'echo "$@" >> "%s"\n' + 'cmd="$1"; shift || true\n' + 'case "$cmd" in\n' + " use)\n" + ' name="$1"\n' + ' cp "$CODEX_HOME/auth-profiles/$name.json" "$CODEX_HOME/auth.json"\n' + ' echo "active $name"\n' + " ;;\n" + " use-if-current)\n" + ' expected="$1"; target="$2"\n' + ' cmp -s "$CODEX_HOME/auth.json" "$CODEX_HOME/auth-profiles/$expected.json" || exit 75\n' + ' cp "$CODEX_HOME/auth-profiles/$target.json" "$CODEX_HOME/auth.json"\n' + ' echo "active $target"\n' + " ;;\n" + " add)\n" + ' name="$1"; mode="$2"\n' + ' [[ "$mode" == "--current" ]] || exit 64\n' + ' cp "$CODEX_HOME/auth.json" "$CODEX_HOME/auth-profiles/$name.json" || exit 1\n' + ' echo "saved $name"\n' + " ;;\n" + " reset)\n" + ' name="$1"; yes="$2"\n' + ' [[ "$yes" == "--yes" ]] || exit 64\n' + ' echo "used reset $name"\n' + " ;;\n" + " refresh) : ;;\n" + "esac\n" % log, + encoding="utf-8", + ) + script.chmod(script.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + monkeypatch.setenv("CODEX_AUTH_BIN", str(script)) + return log diff --git a/tui/tests/test_backend.py b/tui/tests/test_backend.py new file mode 100644 index 0000000..05dea09 --- /dev/null +++ b/tui/tests/test_backend.py @@ -0,0 +1,279 @@ +"""Shell backend tests; every path is rooted in a temporary CODEX_HOME.""" + +from __future__ import annotations + +import hashlib +import json +import time + +from codex_auth_tui.backend import ( + ShellBackend, + _account_identity, + _auth_revision, + credential_fingerprint, +) +from codex_auth_tui.models import AccountUsage +from conftest import ( + api_key_profile, + chatgpt_profile, + rate_payload, + seed_state, + write_profile, +) + + +def test_fingerprints_match_shell_selector_with_trailing_newline(codex_home): + chat = write_profile(codex_home, "chat", {"tokens": {"refresh_token": "RT"}}) + access = write_profile(codex_home, "access", {"tokens": {"access_token": "AT"}}) + key = write_profile(codex_home, "key", {"OPENAI_API_KEY": "sk-test"}) + + assert credential_fingerprint(chat) == hashlib.sha256(b"chatgpt:RT\n").hexdigest() + assert credential_fingerprint(access) == hashlib.sha256( + b"chatgpt-access:AT\n" + ).hexdigest() + assert credential_fingerprint(key) == hashlib.sha256(b"api:sk-test\n").hexdigest() + + +def test_snapshot_reads_structured_state_and_identifies_active(codex_home): + work = write_profile(codex_home, "work", chatgpt_profile("work")) + alt = write_profile(codex_home, "alt", chatgpt_profile("alt")) + write_profile(codex_home, "key", api_key_profile()) + write_profile(codex_home, "broken", {"tokens": {}}) + codex_home.auth_file.write_bytes(work.read_bytes()) + + seed_state( + codex_home, + { + "work": { + "fingerprint": credential_fingerprint(work), + "payload": rate_payload(41, 12, reset_credits=2), + }, + "alt": { + "fingerprint": credential_fingerprint(alt), + "payload": rate_payload(20, 30), + }, + }, + ) + + auth_before = codex_home.auth_file.read_bytes() + snap = ShellBackend(codex_home, cli="/does/not/run").snapshot() + accounts = {account.name: account for account in snap.accounts} + + assert codex_home.auth_file.read_bytes() == auth_before + assert snap.active_name == "work" + assert snap.active_unmanaged is False + assert accounts["work"].is_active is True + assert accounts["work"].usage.binding_pct() == 41 + assert accounts["work"].usage.reset_credits_available == 2 + assert [window.window_mins for window in accounts["work"].usage.windows] == [ + 300, + 10080, + ] + assert accounts["alt"].switchable is True + assert accounts["key"].usage.sentinel == "api key" + assert accounts["key"].switchable is False + assert accounts["broken"].usage.sentinel == "invalid profile" + assert accounts["broken"].switchable is False + + +def test_snapshot_rejects_state_for_a_different_credential(codex_home): + work = write_profile(codex_home, "work", chatgpt_profile("work")) + codex_home.auth_file.write_bytes(work.read_bytes()) + seed_state( + codex_home, + { + "work": { + "fingerprint": "not-the-current-credential", + "payload": rate_payload(10, 20), + } + }, + ) + + account = ShellBackend(codex_home).snapshot().accounts[0] + assert account.usage.fingerprint_match is False + assert account.usage.stale is True + assert account.usage.decision_value() is None + + +def test_reset_credit_count_distinguishes_unknown_zero_and_positive(): + missing = rate_payload(10, 20) + zero = rate_payload(10, 20, reset_credits=0) + positive = rate_payload(10, 20, reset_credits=3) + malformed = rate_payload(10, 20) + malformed["rateLimitResetCredits"] = {"availableCount": -4} + + def usage(payload): + return AccountUsage.from_payload( + payload, + age_s=0, + fetched_at=time.time(), + ) + + assert usage(missing).reset_credits_available is None + assert usage(zero).reset_credits_available == 0 + assert usage(positive).reset_credits_available == 3 + assert usage(malformed).reset_credits_available == 0 + + +def test_snapshot_marks_old_or_future_cache_stale(codex_home): + work = write_profile(codex_home, "work", chatgpt_profile("work")) + codex_home.auth_file.write_bytes(work.read_bytes()) + seed_state( + codex_home, + { + "work": { + "fingerprint": credential_fingerprint(work), + "payload": rate_payload(10, 20), + } + }, + now=1000.0, + ) + + backend = ShellBackend(codex_home) + assert backend.snapshot(now=1401.0).accounts[0].usage.stale is True + future = backend.snapshot(now=900.0).accounts[0].usage + assert future.age_s is None + assert future.stale is True + + +def test_snapshot_distinguishes_unmanaged_active_auth(codex_home): + write_profile(codex_home, "work", chatgpt_profile("work")) + codex_home.auth_file.write_text( + json.dumps({"tokens": {"refresh_token": "outside-profile-set"}}), + encoding="utf-8", + ) + + snap = ShellBackend(codex_home).snapshot() + assert snap.active_name is None + assert snap.active_unmanaged is True + + +def test_snapshot_uses_validated_marker_after_live_token_rotation(codex_home): + saved_data = chatgpt_profile("work") + live_data = chatgpt_profile("work", refresh_token="rt-work-rotated") + work = write_profile(codex_home, "work", saved_data) + codex_home.auth_file.write_text(json.dumps(live_data), encoding="utf-8") + identity = _account_identity(live_data) + codex_home.active_profile_file.write_text( + json.dumps( + { + "version": 2, + "profile": "work", + "kind": "chatgpt", + "account_identity": identity, + "profile_fingerprint": credential_fingerprint(work), + "profile_revision": _auth_revision(saved_data), + } + ), + encoding="utf-8", + ) + + snap = ShellBackend(codex_home).snapshot() + + assert snap.active_name == "work" + assert snap.active_unmanaged is False + assert snap.accounts[0].is_active is True + + +def test_snapshot_rejects_tampered_or_cross_account_marker(codex_home): + saved_data = chatgpt_profile("work") + live_data = chatgpt_profile("other", refresh_token="rt-other-rotated") + work = write_profile(codex_home, "work", saved_data) + codex_home.auth_file.write_text(json.dumps(live_data), encoding="utf-8") + codex_home.active_profile_file.write_text( + json.dumps( + { + "version": 2, + "profile": "work", + "kind": "chatgpt", + "account_identity": _account_identity(saved_data), + "profile_fingerprint": credential_fingerprint(work), + "profile_revision": _auth_revision(saved_data), + } + ), + encoding="utf-8", + ) + + snap = ShellBackend(codex_home).snapshot() + + assert snap.active_name is None + assert snap.active_unmanaged is True + assert snap.accounts[0].is_active is False + + +def test_refresh_switch_and_patch_check_use_short_lived_fake_cli( + codex_home, fake_codex_auth +): + work = write_profile(codex_home, "work", chatgpt_profile("work")) + write_profile(codex_home, "alt", chatgpt_profile("alt")) + codex_home.auth_file.write_bytes(work.read_bytes()) + backend = ShellBackend(codex_home) + + refreshed = backend.refresh() + saved = backend.save_current("captured") + reset = backend.consume_reset("alt") + switched = backend.switch( + "alt", expected_current="work", expected_generation="generation-1" + ) + + assert refreshed.ok is True + assert refreshed.generation + assert saved.ok is True + assert reset.ok is True + assert (codex_home.profiles_dir / "captured.json").read_bytes() == work.read_bytes() + assert switched.ok is True + assert backend.snapshot().active_name == "alt" + assert backend.patched_ready() is True + log = fake_codex_auth.read_text(encoding="utf-8") + assert "refresh --quiet --fast" in log + assert "add captured --current" in log + assert "reset alt --yes" in log + assert "use-if-current work alt generation-1" in log + assert "patch-codex --print-bin --quiet" in log + + +def test_backend_preserves_explicit_path_overrides(codex_home, tmp_path): + profiles = tmp_path / "custom-profiles" + state = tmp_path / "custom-state.json" + profiles.mkdir() + backend = ShellBackend( + codex_home, + cli=tmp_path / "missing-cli", + env={ + "CODEX_AUTH_PROFILES_DIR": str(profiles), + "CODEX_AUTH_STATE_FILE": str(state), + "EXTRA_TEST_VALUE": "kept", + }, + ) + + # Explicit CodexPaths are authoritative, while unrelated injected env is + # retained for the child process. + assert backend.env["CODEX_HOME"] == str(codex_home.home) + assert backend.env["CODEX_AUTH_PROFILES_DIR"] == str(codex_home.profiles_dir) + assert backend.env["CODEX_AUTH_STATE_FILE"] == str(codex_home.state_file) + assert backend.env["CODEX_AUTH_ACTIVE_PROFILE_FILE"] == str( + codex_home.active_profile_file + ) + assert backend.env["EXTRA_TEST_VALUE"] == "kept" + + +def test_timeout_kills_descendants_even_after_cli_leader_exits( + codex_home, tmp_path +): + cli = tmp_path / "forking-codex-auth" + cli.write_text( + "#!/usr/bin/env bash\n" + "(sleep 30) &\n" + "exit 0\n", + encoding="utf-8", + ) + cli.chmod(0o755) + backend = ShellBackend(codex_home, cli=cli) + + started = time.monotonic() + result = backend.refresh(timeout_s=0.1) + elapsed = time.monotonic() - started + + assert result.ok is False + assert result.returncode == 124 + assert elapsed < 3.0 diff --git a/tui/tests/test_engine.py b/tui/tests/test_engine.py new file mode 100644 index 0000000..9ffcc8f --- /dev/null +++ b/tui/tests/test_engine.py @@ -0,0 +1,663 @@ +"""Pure policy and transaction tests for live watch mode.""" + +from __future__ import annotations + +import dataclasses +import json +import os +import threading + +import pytest + +try: # POSIX-only; the supported live watcher and tests run on POSIX. + import fcntl +except ImportError: # pragma: no cover - non-POSIX + fcntl = None + +from codex_auth_tui.backend import OperationResult +from codex_auth_tui.engine import ( + AutoSwitchEngine, + decide, + rank_candidates, + record_switch_state, +) +from codex_auth_tui.models import ( + AccountSnapshot, + AccountsSnapshot, + AccountUsage, + UsageWindow, +) +from codex_auth_tui.settings import AutoSettings, load_settings, save_settings + +NOW = 1_000.0 + + +def _account( + name: str, + pct: float | None, + *, + active: bool = False, + age_s: float = 0.0, + switchable: bool = True, + kind: str = "chatgpt", + reset_at: float = NOW + 3600.0, +) -> AccountSnapshot: + if pct is None: + usage = AccountUsage(age_s=age_s, fetched_at=NOW - age_s) + elif kind == "api_key": + usage = AccountUsage(sentinel="api key") + else: + usage = AccountUsage( + windows=(UsageWindow("5h", pct, 300, reset_at),), + age_s=age_s, + fetched_at=NOW - age_s, + fingerprint_match=True, + ) + return AccountSnapshot(name, active, kind, switchable, usage) + + +def _with_generation( + account: AccountSnapshot, generation: str | None +) -> AccountSnapshot: + return dataclasses.replace( + account, + usage=dataclasses.replace( + account.usage, refresh_generation=generation + ), + ) + + +def _error_account( + name: str, + message: str, + *, + active: bool = False, + generation: str | None = None, +) -> AccountSnapshot: + return AccountSnapshot( + name, + active, + "chatgpt", + True, + AccountUsage( + age_s=0, + fetched_at=NOW, + last_error=message, + fingerprint_match=True, + refresh_generation=generation, + ), + ) + + +def _snapshot( + current_pct: float | None = 50.0, + *candidates: AccountSnapshot, + unmanaged: bool = False, + current_kind: str = "chatgpt", + current_switchable: bool = True, + current_age: float = 0.0, + current_reset: float = NOW + 3600.0, +) -> AccountsSnapshot: + current = _account( + "work", + current_pct, + active=not unmanaged, + kind=current_kind, + switchable=current_switchable, + age_s=current_age, + reset_at=current_reset, + ) + return AccountsSnapshot( + None if unmanaged else "work", + (current, *candidates), + NOW, + active_unmanaged=unmanaged, + ) + + +def test_threshold_zero_uses_any_strictly_better_ready_candidate(): + snap = _snapshot(50, _account("worse", 60), _account("better", 20)) + + decision = decide(snap, AutoSettings(threshold=0, hysteresis=99), {}, NOW) + + assert decision.action == "switch" + assert decision.target == "better" + assert decision.current_pct == 50 + assert decision.target_pct == 20 + + +def test_threshold_zero_does_not_switch_to_equal_or_worse_account(): + snap = _snapshot(50, _account("equal", 50), _account("worse", 60)) + assert decide(snap, AutoSettings(threshold=0), {}, NOW).reason == "no_viable_target" + + +def test_positive_threshold_applies_threshold_and_hysteresis(): + snap = _snapshot(92, _account("marginal", 85), _account("clear", 70)) + settings = AutoSettings(threshold=90, hysteresis=10) + + decision = decide(snap, settings, {}, NOW) + + assert decision.target == "clear" + below = _snapshot(89, _account("clear", 10)) + assert decide(below, settings, {}, NOW).reason == "below_threshold" + + +def test_cooldown_blocks_only_proactive_switch(): + state = {"last_switch_at": NOW - 30} + settings = AutoSettings(threshold=0, cooldown_s=300) + proactive = decide(_snapshot(50, _account("alt", 10)), settings, state, NOW) + capped = decide(_snapshot(100, _account("alt", 99)), settings, state, NOW) + + assert proactive.reason == "cooldown" + assert proactive.action == "hold" + assert capped.action == "switch" + assert capped.reason == "hard_cap" + + +def test_stale_or_unknown_active_never_drives_a_switch(): + stale = _snapshot(95, _account("alt", 10), current_age=301) + unknown = _snapshot(None, _account("alt", 10)) + + assert decide(stale, AutoSettings(), {}, NOW).reason == "active_usage_stale" + assert decide(unknown, AutoSettings(), {}, NOW).reason == "active_usage_unknown" + + +def test_stale_candidate_is_never_ranked(): + snap = _snapshot(90, _account("stale", 1, age_s=301), _account("fresh", 20)) + assert [account.name for account in rank_candidates(snap, now=NOW)] == ["fresh"] + + +def test_candidate_missing_an_active_quota_window_is_not_ranked(): + active_usage = AccountUsage( + windows=( + UsageWindow("5h", 90, 300, NOW + 3600), + UsageWindow("7d", 40, 10080, NOW + 86400), + ), + age_s=0, + fetched_at=NOW, + ) + active = AccountSnapshot("work", True, "chatgpt", True, active_usage) + partial = _account("partial", 10) + snap = AccountsSnapshot("work", (active, partial), NOW) + + assert rank_candidates(snap, now=NOW) == () + + +def test_unmanaged_active_is_blocked_and_api_key_is_not_switchable(): + unmanaged = _snapshot(50, _account("alt", 10), unmanaged=True) + api_active = _snapshot( + None, + _account("alt", 10), + current_kind="api_key", + current_switchable=False, + ) + + assert decide(unmanaged, AutoSettings(), {}, NOW).reason == "unmanaged_active" + assert decide(api_active, AutoSettings(), {}, NOW).reason == "active_not_switchable" + + +def test_no_viable_target_is_explicitly_blocked(): + decision = decide(_snapshot(95, _account("full", 100)), AutoSettings(90), {}, NOW) + assert decision.action == "blocked" + assert decision.reason == "no_viable_target" + + +def test_elapsed_candidate_reset_requires_a_successful_refresh(): + candidate = _account("reset", 100, reset_at=NOW - 1) + decision = decide(_snapshot(95, candidate), AutoSettings(threshold=0), {}, NOW) + assert decision.action == "blocked" + assert decision.reason == "no_viable_target" + + +def test_settings_round_trip_is_validated_and_private(codex_home): + save_settings( + codex_home, + AutoSettings( + threshold=-5, + interval_s=0, + cooldown_s=-1, + hysteresis=500, + refresh_timeout_s=0, + ), + ) + + loaded = load_settings(codex_home) + assert loaded == AutoSettings( + threshold=0, + interval_s=15, + cooldown_s=0, + hysteresis=100, + refresh_timeout_s=1, + ) + assert os.stat(codex_home.settings_file).st_mode & 0o777 == 0o600 + + +class FakeBackend: + def __init__(self, paths, snapshot, *, refresh_ok=True, switch_ok=True): + self.paths = paths + self._snapshot = snapshot + self.refresh_ok = refresh_ok + self.switch_ok = switch_ok + self.calls: list[str] = [] + + def refresh(self): + self.calls.append("refresh") + return OperationResult(self.refresh_ok, 0 if self.refresh_ok else 1, "refresh") + + def snapshot(self, now=None): + self.calls.append("snapshot") + return self._snapshot + + def switch( + self, name, *, expected_current=None, expected_generation=None + ): + self.calls.append( + f"switch:{name}:expected={expected_current}:generation={expected_generation}" + ) + return OperationResult(self.switch_ok, 0 if self.switch_ok else 1, "switch") + + +def test_engine_is_dry_run_by_default(codex_home): + # Safe-by-default: an engine built without opting into live mode must not + # switch or write cooldown state. + backend = FakeBackend(codex_home, _snapshot(50, _account("alt", 10))) + engine = AutoSwitchEngine(backend, AutoSettings(threshold=0), clock=lambda: NOW) + + assert engine.dry_run is True + decision = engine.tick() + + assert decision.action == "switch" + assert "switch:alt" not in backend.calls + assert not codex_home.autoswitch_state_file.exists() + + +def test_dry_run_waits_for_refresh_and_writes_no_auth_or_auto_state(codex_home): + codex_home.auth_file.write_text("sentinel", encoding="utf-8") + backend = FakeBackend(codex_home, _snapshot(50, _account("alt", 10))) + events = [] + engine = AutoSwitchEngine( + backend, + AutoSettings(threshold=0), + events.append, + dry_run=True, + clock=lambda: NOW, + ) + + decision = engine.tick() + + assert decision.action == "switch" + assert backend.calls == ["refresh", "snapshot"] + assert codex_home.auth_file.read_text(encoding="utf-8") == "sentinel" + assert not codex_home.autoswitch_state_file.exists() + assert [event.kind for event in events][:3] == [ + "refresh_started", + "refresh_finished", + "decision", + ] + assert events[-1].kind == "switch_dry_run" + + +def test_live_success_switches_then_records_cooldown_state(codex_home): + backend = FakeBackend(codex_home, _snapshot(50, _account("alt", 10))) + engine = AutoSwitchEngine( + backend, + AutoSettings(threshold=0), + dry_run=False, + clock=lambda: NOW, + ) + + decision = engine.tick() + + assert decision.action == "switch" + assert backend.calls == [ + "refresh", + "snapshot", + "switch:alt:expected=work:generation=None", + ] + state = json.loads(codex_home.autoswitch_state_file.read_text(encoding="utf-8")) + assert state["last_switch_at"] == NOW + assert state["last_switch_to"] == "alt" + + +def test_failed_refresh_or_switch_does_not_write_cooldown_state(codex_home): + snap = _snapshot(50, _account("alt", 10)) + refresh_failed = FakeBackend(codex_home, snap, refresh_ok=False) + switch_failed = FakeBackend(codex_home, snap, switch_ok=False) + + first = AutoSwitchEngine(refresh_failed, AutoSettings(), dry_run=False).tick() + second = AutoSwitchEngine(switch_failed, AutoSettings(), dry_run=False).tick() + + assert first.reason == "refresh_failed" + assert refresh_failed.calls == ["refresh"] + assert second.reason == "switch_failed" + assert not codex_home.autoswitch_state_file.exists() + + +def test_stop_requested_before_loop_start_is_not_cleared(codex_home): + backend = FakeBackend(codex_home, _snapshot(50, _account("alt", 10))) + engine = AutoSwitchEngine( + backend, AutoSettings(threshold=0), dry_run=False, clock=lambda: NOW + ) + + engine.stop() + engine.run_loop() + + assert engine.wait_stopped(0) + assert backend.calls == [] + + +def test_stop_during_refresh_prevents_a_live_switch(codex_home): + refresh_started = threading.Event() + release_refresh = threading.Event() + + class BlockingBackend(FakeBackend): + def refresh(self): + self.calls.append("refresh") + refresh_started.set() + assert release_refresh.wait(2) + return OperationResult(True) + + backend = BlockingBackend(codex_home, _snapshot(50, _account("alt", 10))) + engine = AutoSwitchEngine( + backend, AutoSettings(threshold=0), dry_run=False, clock=lambda: NOW + ) + worker = threading.Thread(target=engine.run_loop) + worker.start() + assert refresh_started.wait(2) + + engine.stop() + release_refresh.set() + worker.join(2) + + assert not worker.is_alive() + assert engine.wait_stopped(0) + assert backend.calls == ["refresh"] + assert not codex_home.autoswitch_state_file.exists() + + +def test_stop_from_switch_started_event_cancels_commit(codex_home): + backend = FakeBackend(codex_home, _snapshot(50, _account("alt", 10))) + engine = None + + def on_event(event): + if event.kind == "switch_started": + engine.stop() + + engine = AutoSwitchEngine( + backend, + AutoSettings(threshold=0), + on_event, + dry_run=False, + clock=lambda: NOW, + ) + + decision = engine.tick() + + assert decision.reason == "stopping" + assert not any(call.startswith("switch:") for call in backend.calls) + + +def test_wait_stopped_includes_a_concurrent_explicit_tick(codex_home): + refresh_started = threading.Event() + release_refresh = threading.Event() + + class BlockingBackend(FakeBackend): + def refresh(self): + self.calls.append("refresh") + refresh_started.set() + assert release_refresh.wait(2) + return OperationResult(True) + + backend = BlockingBackend(codex_home, _snapshot(50, _account("alt", 10))) + engine = AutoSwitchEngine(backend, AutoSettings(), dry_run=False) + explicit = threading.Thread(target=engine.tick) + explicit.start() + assert refresh_started.wait(2) + loop = threading.Thread(target=engine.run_loop) + loop.start() + engine.stop() + loop.join(2) + + assert not loop.is_alive() + assert engine.wait_stopped(0.05) is False + release_refresh.set() + explicit.join(2) + assert engine.wait_stopped(1) is True + + +def test_newer_switch_receipt_cannot_be_overwritten(codex_home): + path = codex_home.autoswitch_state_file + assert record_switch_state( + path, 200.0, "new", reason="manual", previous="old" + ) + assert not record_switch_state( + path, 100.0, "stale", reason="better_candidate", previous="older" + ) + + state = json.loads(path.read_text(encoding="utf-8")) + assert state["last_switch_at"] == 200.0 + assert state["last_switch_to"] == "new" + + +def test_missing_backend_result_fails_closed(codex_home): + backend = FakeBackend(codex_home, _snapshot(50, _account("alt", 10))) + backend.refresh = lambda: None + + decision = AutoSwitchEngine(backend, AutoSettings(), dry_run=False).tick() + + assert decision.reason == "refresh_failed" + assert not any(call.startswith("switch:") for call in backend.calls) + + +def test_current_generation_profiles_can_switch_with_unrelated_old_generation( + codex_home, +): + generation = "run-1" + current = _with_generation(_account("work", 50, active=True), generation) + target = _with_generation(_account("fresh", 10), generation) + unrelated = _with_generation(_account("unrelated", 80), "older") + backend = FakeBackend( + codex_home, + AccountsSnapshot("work", (current, unrelated, target), NOW), + ) + + def refresh(): + backend.calls.append("refresh") + return OperationResult(True, generation=generation) + + backend.refresh = refresh + events = [] + engine = AutoSwitchEngine( + backend, + AutoSettings(threshold=0), + events.append, + dry_run=False, + clock=lambda: NOW, + ) + + decision = engine.tick() + + assert decision.action == "switch" + assert decision.target == "fresh" + assert backend.calls[-1] == ( + "switch:fresh:expected=work:generation=run-1" + ) + assert "refresh_partial" in [event.kind for event in events] + assert "refresh_incomplete" not in [event.kind for event in events] + + +def test_old_generation_best_candidate_is_excluded(codex_home): + generation = "run-1" + current = _with_generation(_account("work", 50, active=True), generation) + old_best = _with_generation(_account("old-best", 1), "older") + fresh_next = _with_generation(_account("fresh-next", 20), generation) + backend = FakeBackend( + codex_home, + AccountsSnapshot("work", (current, old_best, fresh_next), NOW), + ) + + def refresh(): + backend.calls.append("refresh") + return OperationResult(True, generation=generation) + + backend.refresh = refresh + decision = AutoSwitchEngine( + backend, AutoSettings(threshold=0), dry_run=False, clock=lambda: NOW + ).tick() + + assert decision.action == "switch" + assert decision.target == "fresh-next" + assert backend.calls[-1] == ( + "switch:fresh-next:expected=work:generation=run-1" + ) + + +def test_active_profile_missing_refresh_generation_holds(codex_home): + generation = "run-1" + # Even a cached definitive login error cannot authorize recovery unless + # this refresh run actually produced it. + current = _error_account( + "work", + "Your access token could not be refreshed because you have since " + "logged out or signed in to another account. Please sign in again.", + active=True, + generation="older", + ) + target = _with_generation(_account("fresh", 10), generation) + backend = FakeBackend( + codex_home, AccountsSnapshot("work", (current, target), NOW) + ) + backend.refresh = lambda: OperationResult(True, generation=generation) + + decision = AutoSwitchEngine( + backend, AutoSettings(threshold=0), dry_run=False, clock=lambda: NOW + ).tick() + + assert decision.action == "hold" + assert decision.reason == "refresh_incomplete" + assert not any(call.startswith("switch:") for call in backend.calls) + + +def test_fresh_login_required_error_recovers_even_during_cooldown(codex_home): + generation = "run-1" + current = _error_account( + "work", + "Your access token could not be refreshed because you have since " + "logged out or signed in to another account. Please sign in again.", + active=True, + generation=generation, + ) + target = _with_generation(_account("fresh", 10), generation) + backend = FakeBackend( + codex_home, AccountsSnapshot("work", (current, target), NOW) + ) + + def refresh(): + backend.calls.append("refresh") + return OperationResult(True, generation=generation) + + backend.refresh = refresh + assert record_switch_state( + codex_home.autoswitch_state_file, + NOW - 30, + "prior", + reason="better_candidate", + ) + + decision = AutoSwitchEngine( + backend, + AutoSettings(threshold=90, cooldown_s=300), + dry_run=False, + clock=lambda: NOW, + ).tick() + + assert decision.action == "switch" + assert decision.reason == "active_login_required" + assert decision.current == "work" + assert decision.target == "fresh" + assert backend.calls[-1] == ( + "switch:fresh:expected=work:generation=run-1" + ) + + +def test_generic_unknown_active_still_holds(codex_home): + generation = "run-1" + current = _error_account( + "work", + "access token could not be refreshed after a network error", + active=True, + generation=generation, + ) + target = _with_generation(_account("fresh", 10), generation) + backend = FakeBackend( + codex_home, AccountsSnapshot("work", (current, target), NOW) + ) + backend.refresh = lambda: OperationResult(True, generation=generation) + + decision = AutoSwitchEngine( + backend, AutoSettings(threshold=0), dry_run=False, clock=lambda: NOW + ).tick() + + assert decision.action == "hold" + assert decision.reason == "active_usage_unknown" + assert not any(call.startswith("switch:") for call in backend.calls) + + +def test_live_commit_binds_target_to_refresh_generation(codex_home): + generation = "run-1" + current = _account("work", 50, active=True) + target = _account("alt", 10) + current = dataclasses.replace( + current, + usage=dataclasses.replace( + current.usage, refresh_generation=generation + ), + ) + target = dataclasses.replace( + target, + usage=dataclasses.replace(target.usage, refresh_generation=generation), + ) + backend = FakeBackend( + codex_home, AccountsSnapshot("work", (current, target), NOW) + ) + + def refresh(): + backend.calls.append("refresh") + return OperationResult(True, generation=generation) + + backend.refresh = refresh + decision = AutoSwitchEngine( + backend, AutoSettings(threshold=0), dry_run=False, clock=lambda: NOW + ).tick() + + assert decision.action == "switch" + assert backend.calls[-1] == ( + "switch:alt:expected=work:generation=run-1" + ) + + +@pytest.mark.skipif(fcntl is None, reason="cross-process flock is POSIX-only") +def test_contended_flock_holds_the_whole_tick(codex_home): + # A second holder of the engine's cross-process lock must make the tick a + # benign hold: no refresh, no snapshot, no switch, no cooldown write. flock + # is per open file description, so a separate fd on the same path contends + # even within one process. + backend = FakeBackend(codex_home, _snapshot(50, _account("alt", 10))) + engine = AutoSwitchEngine( + backend, AutoSettings(threshold=0), dry_run=False, clock=lambda: NOW + ) + + lock_path = codex_home.tmp_dir / "codex-auth-tui-auto.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + holder = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) + fcntl.flock(holder, fcntl.LOCK_EX) + try: + decision = engine.tick() + finally: + fcntl.flock(holder, fcntl.LOCK_UN) + os.close(holder) + + assert decision.action == "hold" + assert decision.reason == "lock_busy" + assert backend.calls == [] + assert not codex_home.autoswitch_state_file.exists() diff --git a/tui/tests/test_policy.py b/tui/tests/test_policy.py new file mode 100644 index 0000000..83fb38b --- /dev/null +++ b/tui/tests/test_policy.py @@ -0,0 +1,250 @@ +"""Policy tests for the pure ``decide`` function. + +``decide`` is I/O-free: it takes a snapshot, settings, cooldown state and a +clock, and returns a :class:`Decision`. That makes the whole policy surface — +threshold 0, threshold+hysteresis, cooldown, stale data, invalid/capped active, +and no-viable-target — directly assertable without any subprocess. +""" + +from __future__ import annotations + +from codex_auth_tui.engine import decide +from codex_auth_tui.models import ( + AccountSnapshot, + AccountsSnapshot, + AccountUsage, + UsageWindow, +) +from codex_auth_tui.settings import AutoSettings + +NOW = 1000.0 + + +def usage(pct: float | None, *, stale: bool = False, reset_in: float = 3600.0) -> AccountUsage: + if pct is None: + return AccountUsage() # unknown: no windows + age = 400.0 if stale else 0.0 # 400s > STALE_OK_S (300s) + return AccountUsage( + windows=(UsageWindow("5h", pct, 300, NOW + reset_in),), + age_s=age, + fetched_at=NOW - age, + fingerprint_match=True, + ) + + +def account(name, pct=None, *, active=False, kind="chatgpt", stale=False, usage_obj=None): + return AccountSnapshot( + name=name, + is_active=active, + kind=kind, + switchable=(kind == "chatgpt"), + usage=usage_obj if usage_obj is not None else usage(pct, stale=stale), + ) + + +def snap(accounts, *, active, unmanaged=False): + return AccountsSnapshot( + active_name=active, + accounts=tuple(accounts), + taken_at=NOW, + active_unmanaged=unmanaged, + ) + + +# -- threshold 0 ("always prefer any genuinely better ready account") ------- + +def test_threshold_zero_switches_to_any_better_ready_account(): + s = snap( + [account("work", 50, active=True), account("alt", 20)], + active="work", + ) + d = decide(s, AutoSettings(threshold=0), {}, NOW) + assert d.action == "switch" + assert d.target == "alt" + assert d.reason == "better_candidate" + + +def test_threshold_zero_does_not_switch_to_a_worse_account(): + s = snap( + [account("work", 50, active=True), account("alt", 60)], + active="work", + ) + d = decide(s, AutoSettings(threshold=0), {}, NOW) + assert d.action == "blocked" + assert d.reason == "no_viable_target" + + +def test_threshold_zero_ignores_equal_utilization(): + s = snap( + [account("work", 50, active=True), account("alt", 50)], + active="work", + ) + d = decide(s, AutoSettings(threshold=0), {}, NOW) + assert d.action == "blocked" + + +# -- threshold + hysteresis ------------------------------------------------- + +def test_threshold_hysteresis_picks_best_qualifying_candidate(): + s = snap( + [ + account("work", 92, active=True), + account("a", 85), # improvement 7 < hysteresis 10 + account("b", 70), # improvement 22, below threshold + ], + active="work", + ) + d = decide(s, AutoSettings(threshold=90, hysteresis=10), {}, NOW) + assert d.action == "switch" + assert d.target == "b" + + +def test_hysteresis_blocks_marginal_candidate(): + s = snap( + [account("work", 92, active=True), account("a", 85)], + active="work", + ) + d = decide(s, AutoSettings(threshold=90, hysteresis=10), {}, NOW) + assert d.action == "blocked" + assert d.reason == "no_viable_target" + + +def test_below_threshold_holds(): + s = snap( + [account("work", 50, active=True), account("alt", 20)], + active="work", + ) + d = decide(s, AutoSettings(threshold=90, hysteresis=10), {}, NOW) + assert d.action == "hold" + assert d.reason == "below_threshold" + + +# -- cooldown --------------------------------------------------------------- + +def test_cooldown_holds_a_proactive_switch(): + s = snap( + [account("work", 50, active=True), account("alt", 20)], + active="work", + ) + state = {"last_switch_at": NOW - 10} # 10s ago, cooldown 300s + d = decide(s, AutoSettings(threshold=0, cooldown_s=300), state, NOW) + assert d.action == "hold" + assert d.reason == "cooldown" + + +def test_cooldown_does_not_block_hard_cap(): + s = snap( + [account("work", 100, active=True), account("alt", 20)], + active="work", + ) + state = {"last_switch_at": NOW - 10} + d = decide(s, AutoSettings(threshold=90, cooldown_s=300), state, NOW) + assert d.action == "switch" + assert d.reason == "hard_cap" + + +# -- stale data ------------------------------------------------------------- + +def test_stale_active_usage_holds_rather_than_deciding(): + s = snap( + [account("work", 95, active=True, stale=True), account("alt", 20)], + active="work", + ) + d = decide(s, AutoSettings(threshold=90), {}, NOW) + assert d.action == "hold" + assert d.reason == "active_usage_stale" + + +def test_stale_candidate_is_not_a_target(): + s = snap( + [account("work", 95, active=True), account("alt", 20, stale=True)], + active="work", + ) + d = decide(s, AutoSettings(threshold=90, hysteresis=10), {}, NOW) + assert d.action == "blocked" + assert d.reason == "no_viable_target" + + +def test_unknown_active_usage_holds(): + s = snap( + [account("work", None, active=True), account("alt", 20)], + active="work", + ) + d = decide(s, AutoSettings(threshold=90), {}, NOW) + assert d.action == "hold" + assert d.reason == "active_usage_unknown" + + +# -- invalid / capped active ------------------------------------------------ + +def test_no_active_profile_holds(): + s = snap([account("work", 20)], active=None) + d = decide(s, AutoSettings(), {}, NOW) + assert d.action == "hold" + assert d.reason == "no_active_profile" + + +def test_unmanaged_active_is_blocked(): + s = snap([account("work", 20)], active=None, unmanaged=True) + d = decide(s, AutoSettings(), {}, NOW) + assert d.action == "blocked" + assert d.reason == "unmanaged_active" + + +def test_active_not_in_snapshot_is_blocked(): + s = snap([account("work", 20)], active="ghost") + d = decide(s, AutoSettings(), {}, NOW) + assert d.action == "blocked" + assert d.reason == "active_not_in_snapshot" + + +def test_capped_active_switches_ignoring_hysteresis(): + s = snap( + [account("work", 100, active=True), account("alt", 95)], + active="work", + ) + d = decide(s, AutoSettings(threshold=90, hysteresis=10), {}, NOW) + assert d.action == "switch" + assert d.reason == "hard_cap" + assert d.target == "alt" + + +def test_active_api_key_is_not_switchable(): + s = snap( + [ + account("key", active=True, kind="api_key", usage_obj=AccountUsage(sentinel="api key")), + account("alt", 20), + ], + active="key", + ) + d = decide(s, AutoSettings(threshold=90), {}, NOW) + assert d.action == "hold" + assert d.reason == "active_not_switchable" + + +# -- no viable target ------------------------------------------------------- + +def test_all_candidates_exhausted_blocks(): + s = snap( + [account("work", 95, active=True), account("alt", 100)], + active="work", + ) + d = decide(s, AutoSettings(threshold=90, hysteresis=10), {}, NOW) + assert d.action == "blocked" + assert d.reason == "no_viable_target" + + +def test_candidate_past_reset_stays_blocked_until_refresh_confirms_it(): + reset_passed = AccountUsage( + windows=(UsageWindow("5h", 100.0, 300, NOW - 10),), + age_s=0.0, + fetched_at=NOW, + fingerprint_match=True, + ) + s = snap( + [account("work", 95, active=True), account("alt", None, usage_obj=reset_passed)], + active="work", + ) + d = decide(s, AutoSettings(threshold=90, hysteresis=10), {}, NOW) + assert d.action == "blocked" + assert d.reason == "no_viable_target" diff --git a/tui/tests/test_tui.py b/tui/tests/test_tui.py new file mode 100644 index 0000000..fd56cc3 --- /dev/null +++ b/tui/tests/test_tui.py @@ -0,0 +1,535 @@ +"""Pilot smoke tests for the structured Textual surface.""" + +from __future__ import annotations + +import asyncio +import dataclasses +import threading +import time + +import pytest +from textual.widgets import Button, ListView, RichLog, Static + +from codex_auth_tui.backend import OperationResult +from codex_auth_tui.engine import AutoEvent, Decision +from codex_auth_tui.models import ( + AccountSnapshot, + AccountsSnapshot, + AccountUsage, + UsageWindow, +) +from codex_auth_tui.settings import AutoSettings +from codex_auth_tui.tui.app import CodexAuthApp +from codex_auth_tui.tui.autoview import AutoScreen +from codex_auth_tui.tui.dashboard import ResetScreen, WatchScreen +from codex_auth_tui.tui.modals import ConfirmModal, ProfileNameModal +from codex_auth_tui.tui.widgets import ( + AccountCard, + AccountItem, + RuntimeStatus, + bar_cells, +) + + +def _usage( + short: float, + weekly: float, + *, + reset_credits: int | None = None, +) -> AccountUsage: + now = time.time() + return AccountUsage( + windows=( + UsageWindow("5h", short, 300, now + 3600), + UsageWindow("7d", weekly, 10080, now + 86400), + ), + plan_type="pro", + fetched_at=now, + age_s=0, + reset_credits_available=reset_credits, + ) + + +def _account( + name: str, + short: float, + weekly: float, + *, + active=False, + reset_credits: int | None = None, +): + return AccountSnapshot( + name=name, + is_active=active, + kind="chatgpt", + switchable=True, + usage=_usage(short, weekly, reset_credits=reset_credits), + ) + + +class FakeBackend: + def __init__(self, paths) -> None: + self.paths = paths + self.active = "work" + self.accounts = [ + _account("work", 78, 42, active=True), + _account("personal", 22, 18), + ] + self.calls: list[str] = [] + + def snapshot(self, now=None) -> AccountsSnapshot: + self.calls.append("snapshot") + accounts = tuple( + dataclasses.replace(account, is_active=account.name == self.active) + for account in self.accounts + ) + return AccountsSnapshot( + active_name=self.active, + accounts=accounts, + taken_at=time.time() if now is None else now, + ) + + def refresh(self, names=None) -> OperationResult: + self.calls.append("refresh") + if names: + self.calls.append(f"refresh:{','.join(names)}") + return OperationResult(True) + + def switch( + self, + name: str, + *, + expected_current: str | None = None, + expected_generation: str | None = None, + ) -> OperationResult: + self.calls.append(f"switch:{name}") + self.active = name + return OperationResult(True) + + def save_current(self, name: str) -> OperationResult: + self.calls.append(f"save:{name}") + current = next(account for account in self.accounts if account.name == self.active) + saved = dataclasses.replace(current, name=name, is_active=False) + self.accounts = [account for account in self.accounts if account.name != name] + self.accounts.append(saved) + return OperationResult(True) + + def consume_reset(self, name: str) -> OperationResult: + self.calls.append(f"reset:{name}") + updated = [] + for account in self.accounts: + if account.name != name: + updated.append(account) + continue + count = account.usage.reset_credits_available + usage = dataclasses.replace( + account.usage, + reset_credits_available=max(0, (count or 0) - 1), + windows=tuple( + dataclasses.replace(window, pct=0.0) + for window in account.usage.windows + ), + ) + updated.append(dataclasses.replace(account, usage=usage)) + self.accounts = updated + return OperationResult(True) + + def patched_ready(self) -> bool: + self.calls.append("patched_ready") + return True + + +def make_app(backend: FakeBackend, *, start="watch", start_live=False): + return CodexAuthApp( + backend, + settings=AutoSettings( + threshold=90, + interval_s=3600, + cooldown_s=300, + hysteresis=10, + ), + start=start, + start_live=start_live, + ) + + +async def settle(pilot) -> None: + pending = [ + worker + for worker in pilot.app.workers + if worker.group not in {"engine"} + ] + if pending: + await pilot.app.workers.wait_for_complete(pending) + await pilot.pause() + await pilot.pause() + + +class FakeEngine: + instances: list["FakeEngine"] = [] + + def __init__( + self, + backend, + settings, + on_event=None, + dry_run=True, + **_kwargs, + ) -> None: + self.backend = backend + self.settings = settings + self.on_event = on_event + self.dry_run = dry_run + self.stopped = False + self._stop = threading.Event() + self.instances.append(self) + + def run_loop(self) -> None: + if self.on_event: + self.on_event( + AutoEvent("decision", "hold: below_threshold", time.time()) + ) + self._stop.wait(30) + + def tick(self) -> Decision: + if self.on_event: + self.on_event( + AutoEvent("refresh_finished", "usage refresh finished", time.time()) + ) + return Decision("hold", "below_threshold", current="work") + + def stop(self) -> None: + self.stopped = True + self._stop.set() + + def wait_stopped(self, timeout=None) -> bool: + return self._stop.wait(timeout) + + +@pytest.fixture +def fake_engine(monkeypatch): + FakeEngine.instances = [] + monkeypatch.setattr( + "codex_auth_tui.tui.autoview.AutoSwitchEngine", FakeEngine + ) + return FakeEngine + + +@pytest.mark.asyncio +async def test_mounts_passive_watch_with_full_cards_and_patch_boundary(codex_home): + backend = FakeBackend(codex_home) + app = make_app(backend) + + async with app.run_test(size=(104, 34)) as pilot: + await settle(pilot) + + assert isinstance(app.screen, WatchScreen) + accounts = app.screen.query_one("#accounts", ListView) + assert len(list(accounts.query(AccountItem))) == 2 + assert accounts.index is None + status = app.screen.query_one(RuntimeStatus).render().plain + assert "patched ready" in status + assert backend.calls.count("refresh") == 1 + + +@pytest.mark.asyncio +async def test_patch_poll_observes_detached_build_completion(codex_home): + class TransitionBackend(FakeBackend): + patch_ready = False + + def patched_ready(self) -> bool: + self.calls.append("patched_ready") + return self.patch_ready + + backend = TransitionBackend(codex_home) + app = make_app(backend) + + async with app.run_test(size=(104, 34)) as pilot: + await settle(pilot) + assert app.patched_ready_state is False + + backend.patch_ready = True + app._patch_tick() + await settle(pilot) + + assert app.patched_ready_state is True + assert backend.calls.count("patched_ready") >= 2 + + +@pytest.mark.asyncio +async def test_cached_snapshot_is_published_before_network_refresh(codex_home): + refresh_started = threading.Event() + release_refresh = threading.Event() + + class BlockingBackend(FakeBackend): + def refresh(self) -> OperationResult: + self.calls.append("refresh") + refresh_started.set() + assert release_refresh.wait(2) + return OperationResult(True) + + backend = BlockingBackend(codex_home) + app = make_app(backend) + + async with app.run_test(size=(70, 24)) as pilot: + try: + for _ in range(100): + if app.snapshot is not None and refresh_started.is_set(): + break + await asyncio.sleep(0.01) + assert app.snapshot is not None + assert app.snapshot.active_name == "work" + assert refresh_started.is_set() + finally: + release_refresh.set() + await settle(pilot) + + +@pytest.mark.asyncio +async def test_passive_selection_switches_and_stays_watching(codex_home): + backend = FakeBackend(codex_home) + app = make_app(backend) + + async with app.run_test(size=(104, 34)) as pilot: + await settle(pilot) + accounts = app.screen.query_one("#accounts", ListView) + + await pilot.press("s") + await pilot.pause() + assert accounts.index == 0 + + await pilot.press("down", "enter") + await settle(pilot) + + assert "switch:personal" in backend.calls + assert backend.active == "personal" + assert isinstance(app.screen, WatchScreen) + assert app.screen.query_one("#accounts", ListView).index is None + + +@pytest.mark.asyncio +async def test_escape_disarms_before_leaving_watch(codex_home): + app = make_app(FakeBackend(codex_home)) + + async with app.run_test(size=(104, 34)) as pilot: + await settle(pilot) + await pilot.press("s", "escape") + await pilot.pause() + assert isinstance(app.screen, WatchScreen) + assert app.screen.query_one("#accounts", ListView).index is None + + +@pytest.mark.asyncio +async def test_watch_saves_current_auth_as_a_named_profile(codex_home): + backend = FakeBackend(codex_home) + app = make_app(backend) + + async with app.run_test(size=(104, 34)) as pilot: + await settle(pilot) + await pilot.press("n") + await pilot.pause() + + assert isinstance(app.screen, ProfileNameModal) + await pilot.press(*tuple("captured"), "enter") + await settle(pilot) + + assert "save:captured" in backend.calls + assert app.snapshot is not None + assert any(account.name == "captured" for account in app.snapshot.accounts) + assert isinstance(app.screen, WatchScreen) + + +@pytest.mark.asyncio +async def test_save_current_validates_names_and_confirms_replacement(codex_home): + backend = FakeBackend(codex_home) + app = make_app(backend) + + async with app.run_test(size=(104, 34)) as pilot: + await settle(pilot) + await pilot.press("n") + await pilot.pause() + await pilot.press("b", "a", "d", "space", "n", "a", "m", "e", "enter") + await pilot.pause() + + assert isinstance(app.screen, ProfileNameModal) + error = app.screen.query_one("#profile-name-error", Static).render().plain + assert "Use only" in error + assert not any(call.startswith("save:") for call in backend.calls) + + await pilot.press("escape", "n") + await pilot.pause() + await pilot.press("w", "o", "r", "k", "enter") + await pilot.pause() + assert isinstance(app.screen, ConfirmModal) + + await pilot.press("y") + await settle(pilot) + assert "save:work" in backend.calls + + +@pytest.mark.asyncio +async def test_watch_checks_and_uses_an_earned_reset_without_switching(codex_home): + backend = FakeBackend(codex_home) + backend.accounts[0] = _account( + "work", + 100, + 100, + active=True, + reset_credits=2, + ) + app = make_app(backend) + + async with app.run_test(size=(104, 34)) as pilot: + await settle(pilot) + card = app.screen.query_one(AccountCard).render() + assert "2 earned resets" in card.plain + + await pilot.press("u") + await pilot.pause() + assert isinstance(app.screen, ResetScreen) + + await pilot.press("enter") + await settle(pilot) + assert "refresh:work" in backend.calls + assert isinstance(app.screen, ConfirmModal) + assert app.screen.query_one("#no", Button).has_focus + + await pilot.press("y") + await settle(pilot) + + assert backend.calls.count("reset:work") == 1 + assert backend.active == "work" + assert app.snapshot is not None + account = next(item for item in app.snapshot.accounts if item.name == "work") + assert account.usage.reset_credits_available == 1 + assert isinstance(app.screen, WatchScreen) + + +@pytest.mark.asyncio +async def test_reset_confirmation_defaults_to_cancel(codex_home): + backend = FakeBackend(codex_home) + backend.accounts[0] = _account( + "work", + 100, + 100, + active=True, + reset_credits=1, + ) + app = make_app(backend) + + async with app.run_test(size=(104, 34)) as pilot: + await settle(pilot) + await pilot.press("u", "enter") + await settle(pilot) + assert isinstance(app.screen, ConfirmModal) + + await pilot.press("enter") + await pilot.pause() + + assert isinstance(app.screen, ResetScreen) + assert "reset:work" not in backend.calls + + +@pytest.mark.asyncio +async def test_auto_opens_dry_then_confirms_live_and_cleans_up( + codex_home, fake_engine +): + app = make_app(FakeBackend(codex_home)) + + async with app.run_test(size=(104, 38)) as pilot: + await settle(pilot) + await pilot.press("a") + await pilot.pause() + + assert isinstance(app.screen, AutoScreen) + assert len(fake_engine.instances) == 1 + assert fake_engine.instances[0].dry_run is True + assert app._store_only is True + await pilot.pause() + assert app.screen.query_one("#event-log", RichLog).lines + + await pilot.press("l") + await pilot.pause() + assert isinstance(app.screen, ConfirmModal) + await pilot.press("y") + await settle(pilot) + + assert fake_engine.instances[0].stopped is True + assert fake_engine.instances[-1].dry_run is False + + await pilot.press("escape") + await settle(pilot) + assert isinstance(app.screen, WatchScreen) + assert fake_engine.instances[-1].stopped is True + assert app._store_only is False + + +def test_cli_watch_option_validation_and_live_implication(monkeypatch, codex_home): + from codex_auth_tui import cli + + launched = {} + + class FakeApp: + return_code = 0 + + def __init__(self, **kwargs): + launched.update(kwargs) + + def run(self): + launched["ran"] = True + + monkeypatch.setattr("codex_auth_tui.tui.app.CodexAuthApp", FakeApp) + code = cli.main( + [ + "watch", + "--live", + "--threshold", + "87.5", + "--interval", + "30", + "--cooldown", + "20", + "--hysteresis", + "5", + ] + ) + + assert code == 0 + assert launched["start"] == "auto" + assert launched["start_live"] is True + assert launched["settings"].threshold == 87.5 + assert launched["settings"].interval_s == 30 + + +def test_bare_help_is_watch_help(capsys): + from codex_auth_tui import cli + + with pytest.raises(SystemExit) as stopped: + cli.main(["--help"]) + + assert stopped.value.code == 0 + output = capsys.readouterr().out + assert "--auto" in output + assert "--live" in output + assert "--threshold" in output + + +def test_any_better_mode_does_not_draw_a_zero_percent_tick(): + assert "┃" not in bar_cells(40, 20, threshold=0).plain + assert "┃" in bar_cells(40, 20, threshold=90).plain + + +@pytest.mark.parametrize( + "args", + [ + ["watch", "--threshold", "100"], + ["watch", "--interval", "14"], + ["watch", "--cooldown", "-1"], + ["watch", "--hysteresis", "-1"], + ], +) +def test_cli_rejects_unsafe_watch_values(args): + from codex_auth_tui.cli import build_parser + + with pytest.raises(SystemExit): + build_parser().parse_args(args) diff --git a/tui/uv.lock b/tui/uv.lock new file mode 100644 index 0000000..18143eb --- /dev/null +++ b/tui/uv.lock @@ -0,0 +1,380 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "codex-auth-tui" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "textual" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pillow" }, + { name = "playwright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [{ name = "textual", specifier = ">=8.2.8,<9" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pillow", specifier = ">=10,<13" }, + { name = "playwright", specifier = "==1.59.0" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "playwright" +version = "1.59.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/48/abab23f40643b4de8f2665816f0a1bf0994eeecda39d6d62f0f292b2ad01/playwright-1.59.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e", size = 43156922, upload-time = "2026-04-29T08:11:08.921Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/5e4d98b2ce3641b4343623c6450ff33b9de1c979d12a957505e392338b07/playwright-1.59.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47", size = 41947177, upload-time = "2026-04-29T08:11:12.867Z" }, + { url = "https://files.pythonhosted.org/packages/80/91/fd219aa78ca03d37e93aaedaed4e224131e3090a9264f9bb773c8271d67e/playwright-1.59.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6", size = 43156922, upload-time = "2026-04-29T08:11:16.595Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/1e513d37c5be07d12829ebce93dbfe7baee230084cb66966c423432799c4/playwright-1.59.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d", size = 47151793, upload-time = "2026-04-29T08:11:19.979Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2d/15f72288cb65d690134e18fefb9483cc4976f7579b580648c45e494481a7/playwright-1.59.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100", size = 46877615, upload-time = "2026-04-29T08:11:23.863Z" }, + { url = "https://files.pythonhosted.org/packages/72/a1/717ac5bc99f387c0f60def91271ea4262125c0815d764a5d1776a272275c/playwright-1.59.0-py3-none-win32.whl", hash = "sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119", size = 37713698, upload-time = "2026-04-29T08:11:27.251Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a5/4e630ee05d8b46b840f943268e86d6063703e8dadb2d3eb405c7b9b2e48c/playwright-1.59.0-py3-none-win_amd64.whl", hash = "sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c", size = 37713704, upload-time = "2026-04-29T08:11:30.41Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0c/3ece41761ba13c8321009aefcaec7a016eb42799c42eef5e03ace7f2de5b/playwright-1.59.0-py3-none-win_arm64.whl", hash = "sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9", size = 33956745, upload-time = "2026-04-29T08:11:34.738Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +]