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.
-
+[](https://github.com/editnori/codex-rolling-auth/actions/workflows/ci.yml)
-
+
+
+_Synthetic demo data. No live credentials or account identifiers._
+
+
+Watch the keyboard flow
+
+
+
+
+
+| Dry-run autoswitch | Earned reset confirmation |
+| --- | --- |
+|  |  |
+
+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
```
+
+
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