diff --git a/CHANGELOG.md b/CHANGELOG.md index a442b87..3a2b4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.1.1 - 2026-07-10 + +- Added `codex-auth reauth ` and a clickable, keyboard-accessible TUI sign-in flow. +- Kept the active profile unchanged while replacing only the selected saved login; active-target repairs retain the same profile name. +- Rejected wrong-account logins and concurrent profile edits, and stopped stale credential errors from showing another false sign-in prompt. +- Updated the synthetic screenshots, GIF, and video to show the Cancel-default sign-in confirmation. + ## 0.1.0 - 2026-07-10 - Added the persistent Textual watch and autoswitch UI. diff --git a/README.md b/README.md index 5f70de1..094252a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ _Synthetic demo data. No live credentials or account identifiers._ +### Sign in again without switching profiles + +![Sign-in confirmation for the selected saved profile, with the current active profile unchanged](assets/codex-auth-sign-in.png) + | Dry-run autoswitch | Earned reset confirmation | | --- | --- | | ![Dry-run autoswitch screen](assets/codex-auth-auto.png) | ![Earned reset confirmation](assets/codex-auth-reset.png) | @@ -90,6 +94,7 @@ Watcher keys: - `s` arms or disarms manual selection. - `n` saves the current Codex auth as a named profile. +- `i` opens saved-profile sign-in without switching the active 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. @@ -117,6 +122,14 @@ codex-auth add work --current From `codex-auth tui`, press `n`, enter the profile name, and press `Enter`. Replacing an existing profile requires confirmation. +Repair an expired or invalid saved login without selecting it as the active profile: + +```bash +codex-auth reauth work +``` + +In the TUI, click `sign in again` on the profile or press `i` and choose it. Codex opens its browser login outside the full-screen UI, then writes the new credential only to that saved profile. If the profile is already active, its credential is refreshed in place and the active profile name stays the same. + ### 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. @@ -196,6 +209,7 @@ This project was directly inspired by [claude-swap](https://github.com/realiti4/ - 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. +- Saved-profile sign-in runs in a private temporary Codex home with [file-backed credentials](https://learn.chatgpt.com/docs/auth#credential-storage). It rejects a different known account identity and compare-and-swaps the result so a concurrent profile update wins. - `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. diff --git a/assets/codex-auth-auto.png b/assets/codex-auth-auto.png index dcd39b2..ec09d4a 100644 Binary files a/assets/codex-auth-auto.png and b/assets/codex-auth-auto.png differ diff --git a/assets/codex-auth-demo.gif b/assets/codex-auth-demo.gif index 617c1af..e2677b4 100644 Binary files a/assets/codex-auth-demo.gif and b/assets/codex-auth-demo.gif differ diff --git a/assets/codex-auth-demo.mp4 b/assets/codex-auth-demo.mp4 index ec222a0..69930f7 100644 Binary files a/assets/codex-auth-demo.mp4 and b/assets/codex-auth-demo.mp4 differ diff --git a/assets/codex-auth-reset.png b/assets/codex-auth-reset.png index 10bc5f4..1c34691 100644 Binary files a/assets/codex-auth-reset.png and b/assets/codex-auth-reset.png differ diff --git a/assets/codex-auth-sign-in.png b/assets/codex-auth-sign-in.png new file mode 100644 index 0000000..6080192 Binary files /dev/null and b/assets/codex-auth-sign-in.png differ diff --git a/assets/codex-auth-watch.png b/assets/codex-auth-watch.png index 5f36d0e..2b5a579 100644 Binary files a/assets/codex-auth-watch.png and b/assets/codex-auth-watch.png differ diff --git a/bin/codex-auth b/bin/codex-auth index 3ff7a0b..0e948f9 100755 --- a/bin/codex-auth +++ b/bin/codex-auth @@ -294,6 +294,7 @@ main() { 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 "$@" ;; + reauth) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; require_arg_count_between "$#" 1 999999 "usage: codex-auth reauth [codex login args...]"; cmd_reauth "$@" ;; auto) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}" profiles.sh; cmd_auto "$@" ;; patch-codex) case " $* " in diff --git a/lib/codex-auth/help.sh b/lib/codex-auth/help.sh index ccfbe2e..68d8513 100644 --- a/lib/codex-auth/help.sh +++ b/lib/codex-auth/help.sh @@ -43,6 +43,7 @@ usage() { print_help_section "profiles" "$width" print_help_item "add " "Browser login" "$width" print_help_item "use " "Select profile" "$width" + print_help_item "reauth " "Repair saved login" "$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" @@ -73,6 +74,7 @@ usage_all() { print_help_item "add --current" "Save current auth" "$width" print_help_item "add --file " "Import auth file" "$width" print_help_item "login " "Login profile" "$width" + print_help_item "reauth " "Repair without switching" "$width" print_help_section "maintenance" "$width" print_help_item "refresh" "Refresh usage" "$width" print_help_item "reset --yes" "Use earned reset" "$width" @@ -124,6 +126,7 @@ print_help_item() { "add --file "$'\t'"import" "import*"$'\t'"load" "login "$'\t'"auth" + "reauth "$'\t'"auth" "refresh"$'\t'"sync" "reset --yes"$'\t'"reset" "auto"$'\t'"best" diff --git a/lib/codex-auth/profiles.sh b/lib/codex-auth/profiles.sh index 3c3b3b6..03d2ce9 100644 --- a/lib/codex-auth/profiles.sh +++ b/lib/codex-auth/profiles.sh @@ -642,6 +642,148 @@ cmd_login() { rm -f "$hidden_auth" } +reauth_prepare_private_config() { + local source="$1" + local dest="$2" + + if [[ -f "$source" ]]; then + cp -p "$source" "$dest" || return 1 + else + : > "$dest" || return 1 + fi + chmod 600 "$dest" +} + +cmd_reauth() ( + local name="${1:-}" + [[ -n "$name" ]] || die "usage: codex-auth reauth [codex login args...]" + shift || true + require_name "$name" + [[ -t 0 && -t 1 ]] || die "reauth needs tty" + local login_arg + for login_arg in "$@"; do + [[ "$login_arg" != *cli_auth_credentials_store* ]] \ + || die "reauth controls cli_auth_credentials_store for isolated login" + done + + local codex_cli profile expected_auth expected_revision expected_kind expected_identity + local login_home login_auth login_kind login_identity active_name live_before marker_before marker_existed=0 + codex_cli="$(codex_bin)" || die "codex command not found" + require_codex_launcher "$codex_cli" + ensure_dirs + + profile="$(profile_path "$name")" + [[ -f "$profile" ]] || die "profile not found: $name" + require_auth_file "$profile" + expected_kind="$(auth_file_kind "$profile" || true)" + [[ "$expected_kind" == "chatgpt" ]] || die "reauth only supports ChatGPT profiles" + + login_home="$(mktemp -d "$CODEX_HOME/.tmp/reauth-${name}.XXXXXX")" + chmod 700 "$login_home" || die "could not secure private login home" + trap 'rm -rf "${login_home:-}"' EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + + expected_auth="$login_home/expected-auth.json" + copy_auth_file_atomic "$profile" "$expected_auth" || die "could not snapshot profile: $name" + expected_revision="$(auth_file_revision "$expected_auth" || true)" + [[ -n "$expected_revision" ]] || die "could not snapshot profile revision: $name" + expected_identity="$(auth_file_account_identity "$expected_auth" || true)" + [[ -n "$expected_identity" ]] || die "saved profile has no stable account identity; cannot safely reauthenticate" + + reauth_prepare_private_config "$CODEX_HOME/config.toml" "$login_home/config.toml" \ + || die "could not prepare private login config" + + if ! CODEX_HOME="$login_home" CODEX_AUTH_RUNNER=1 "$codex_cli" login \ + -c "cli_auth_credentials_store=\"file\"" "$@"; then + print_error "login failed; saved profile was not changed" + return 1 + fi + + login_auth="$login_home/auth.json" + if ! auth_file_is_valid "$login_auth"; then + print_error "login did not produce valid file-based auth; saved profile was not changed" + return 1 + fi + login_kind="$(auth_file_kind "$login_auth" || true)" + [[ "$login_kind" == "chatgpt" ]] || { + print_error "login did not produce ChatGPT auth; saved profile was not changed" + return 1 + } + if ! jq -e ' + (.tokens | type == "object") + and ([.tokens.refresh_token?, .tokens.access_token?] + | any(type == "string" and length > 0)) + ' "$login_auth" >/dev/null 2>&1; then + print_error "login did not produce a ChatGPT credential; saved profile was not changed" + return 1 + fi + login_identity="$(auth_file_account_identity "$login_auth" || true)" + if [[ "$login_identity" != "$expected_identity" ]]; then + print_error "login account did not match saved profile; saved profile was not changed" + return 1 + fi + + acquire_mutation_lock + if [[ "$(auth_file_revision "$profile" || true)" != "$expected_revision" ]]; then + print_error "profile changed while login was open; saved login was not applied" + return 75 + fi + + active_name="" + if [[ -f "$AUTH_FILE" ]] && auth_file_is_valid "$AUTH_FILE"; then + active_name="$(resolve_active_profile_for_auth "$AUTH_FILE" || true)" + fi + + if [[ "$active_name" != "$name" ]]; then + copy_auth_file_atomic "$login_auth" "$profile" || die "could not update profile: $name" + print_result_block "reauthenticated $name" \ + "profile"$'\t'"$(display_path "$profile")"$'\t'"active" \ + "active"$'\t'"unchanged"$'\t'"muted" + return 0 + fi + + if [[ ! -f "$AUTH_FILE" ]] || ! auth_file_is_valid "$AUTH_FILE"; then + print_error "active auth changed while login was open; saved login was not applied" + return 75 + fi + local live_identity + live_identity="$(auth_file_account_identity "$AUTH_FILE" || true)" + if [[ -n "$login_identity" && "$live_identity" != "$login_identity" ]]; then + print_error "active account changed while login was open; saved login was not applied" + return 75 + fi + + live_before="$login_home/live-before.json" + copy_auth_file_atomic "$AUTH_FILE" "$live_before" || die "could not snapshot active auth" + marker_before="$login_home/active-profile-before.json" + if [[ -f "$ACTIVE_PROFILE_FILE" ]]; then + cp -p "$ACTIVE_PROFILE_FILE" "$marker_before" || die "could not snapshot active profile marker" + chmod 600 "$marker_before" + marker_existed=1 + fi + + if ! copy_auth_file_atomic "$login_auth" "$profile" \ + || ! copy_auth_file_atomic "$login_auth" "$AUTH_FILE" \ + || ! active_profile_marker_write "$name" "$profile" + then + copy_auth_file_atomic "$expected_auth" "$profile" || true + copy_auth_file_atomic "$live_before" "$AUTH_FILE" || true + if (( marker_existed )); then + copy_auth_file_atomic "$marker_before" "$ACTIVE_PROFILE_FILE" || true + else + rm -f "$ACTIVE_PROFILE_FILE" + fi + print_error "could not update active profile; previous auth was restored" + return 1 + fi + + print_result_block "reauthenticated $name" \ + "profile"$'\t'"$(display_path "$profile")"$'\t'"active" \ + "active"$'\t'"kept $name"$'\t'"active" +) + cmd_add() { local name="${1:-}" [[ -n "$name" ]] || die "usage: codex-auth add [--current | --file | codex login args...]" diff --git a/lib/codex-auth/usage.sh b/lib/codex-auth/usage.sh index b07f749..2f631af 100644 --- a/lib/codex-auth/usage.sh +++ b/lib/codex-auth/usage.sh @@ -478,7 +478,7 @@ usage_json_from_home() { rate_pid="$CODEX_RATE_PID" start="$(now_epoch)" - if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.1.0"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 2>/dev/null >&"$rate_in"; then + if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.1.1"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 2>/dev/null >&"$rate_in"; then usage_json_cleanup_coproc "$rate_in" "$rate_out" "$rate_pid" printf '%s\n' '{"error":{"message":"refresh unavailable"}}' return 0 @@ -557,7 +557,7 @@ reset_credit_json_from_home() { rate_pid="$CODEX_RESET_PID" start="$(now_epoch)" - if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.1.0"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 2>/dev/null >&"$rate_in"; then + if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.1.1"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 2>/dev/null >&"$rate_in"; then usage_json_cleanup_coproc "$rate_in" "$rate_out" "$rate_pid" printf '%s\n' '{"error":{"message":"reset unavailable"}}' return 0 diff --git a/scripts/capture_tui.py b/scripts/capture_tui.py index e9ef734..f034909 100755 --- a/scripts/capture_tui.py +++ b/scripts/capture_tui.py @@ -45,7 +45,7 @@ from codex_auth_tui.settings import AutoSettings from codex_auth_tui.tui.app import CodexAuthApp from codex_auth_tui.tui.autoview import AutoScreen -from codex_auth_tui.tui.dashboard import ResetScreen, WatchScreen +from codex_auth_tui.tui.dashboard import ReauthScreen, ResetScreen, WatchScreen from codex_auth_tui.tui.modals import ConfirmModal @@ -59,6 +59,7 @@ OUTPUT_NAMES = ( "codex-auth-watch.png", + "codex-auth-sign-in.png", "codex-auth-auto.png", "codex-auth-reset.png", "codex-auth-demo.gif", @@ -67,6 +68,8 @@ TIMELINE = ( ("watch", 1_000), + ("reauth-picker", 800), + ("reauth-confirm", 1_400), ("reset-picker", 600), ("reset-confirm", 1_400), ("watch-return", 600), @@ -146,6 +149,23 @@ def _account( ) +def _login_required_account(name: str) -> AccountSnapshot: + return AccountSnapshot( + name=name, + is_active=False, + kind="chatgpt", + switchable=True, + usage=AccountUsage( + fetched_at=DEMO_NOW, + age_s=0, + last_error=( + "Your access token could not be refreshed because you have since " + "logged out or signed in to another account. Please sign in again." + ), + ), + ) + + class DemoBackend: """Credential-free backend implementing the app's stable shell boundary.""" @@ -178,6 +198,7 @@ def __init__(self, paths: CodexPaths) -> None: short_reset=3_000, weekly_reset=345_600, ), + _login_required_account("personal"), ] def snapshot(self, now: float | None = None) -> AccountsSnapshot: @@ -211,6 +232,12 @@ def switch( def save_current(self, name: str) -> OperationResult: return OperationResult(False, 64, "saving is disabled in demo capture") + def reauth(self, name: str) -> OperationResult: + # The deterministic timeline always cancels before the browser-login + # boundary. Keep this defensive failure here so a future timeline edit + # cannot accidentally turn the media build into an interactive login. + return OperationResult(False, 64, "sign-in is disabled in demo capture") + def consume_reset(self, name: str) -> OperationResult: # The recorded flow stops at the Cancel-default confirmation. This # implementation is defensive in case a future capture confirms it. @@ -331,6 +358,29 @@ def capture(name: str) -> None: capture("watch") + await pilot.press("i") + await _settle(app, pilot) + if not isinstance(app.screen, ReauthScreen): + raise RuntimeError("sign-in picker did not open") + capture("reauth-picker") + + await pilot.press("enter") + await _settle(app, pilot) + if not isinstance(app.screen, ConfirmModal): + raise RuntimeError("sign-in confirmation did not open") + if not app.screen.query_one("#no", Button).has_focus: + raise RuntimeError("sign-in confirmation did not default to Cancel") + capture("reauth-confirm") + + await pilot.press("escape") + await pilot.pause() + if not isinstance(app.screen, ReauthScreen): + raise RuntimeError("sign-in confirmation did not cancel to picker") + await pilot.press("escape") + await _settle(app, pilot) + if not isinstance(app.screen, WatchScreen): + raise RuntimeError("capture did not return to Watch after sign-in") + await pilot.press("u") await _settle(app, pilot) if not isinstance(app.screen, ResetScreen): @@ -627,7 +677,12 @@ def _ffprobe(path: Path) -> dict: def _verify_outputs(directory: Path, expected_size: tuple[int, int]) -> dict[str, str]: details: dict[str, str] = {} - for name in ("codex-auth-watch.png", "codex-auth-auto.png", "codex-auth-reset.png"): + for name in ( + "codex-auth-watch.png", + "codex-auth-sign-in.png", + "codex-auth-auto.png", + "codex-auth-reset.png", + ): path = directory / name with Image.open(path) as image: if image.format != "PNG" or image.size != expected_size: @@ -731,6 +786,7 @@ async def _build(directory: Path) -> dict[str, str]: expected_size = images["watch"].size directory.mkdir(parents=True, exist_ok=True) _save_png(images["watch"], directory / "codex-auth-watch.png") + _save_png(images["reauth-confirm"], directory / "codex-auth-sign-in.png") _save_png(images["auto"], directory / "codex-auth-auto.png") _save_png(images["reset-confirm"], directory / "codex-auth-reset.png") _save_gif(images, directory / "codex-auth-demo.gif") diff --git a/tests/run.sh b/tests/run.sh index 5050707..3346295 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -137,6 +137,41 @@ write_chatgpt_auth() { chmod 0600 "$path" } +write_reauth_codex() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "--version" ]]; then + printf 'codex-cli 9.8.7\n' + exit 0 +fi +printf 'args:%s\n' "$*" >> "$CODEX_TEST_LOG" +printf '%s\n' "$CODEX_HOME" > "$CODEX_TEST_LOGIN_HOME_FILE" +grep -E '^[[:space:]]*cli_auth_credentials_store[[:space:]]*=' "$CODEX_HOME/config.toml" \ + >> "$CODEX_TEST_LOG" 2>/dev/null || true +grep -F 'model = "gpt-5"' "$CODEX_HOME/config.toml" >> "$CODEX_TEST_LOG" 2>/dev/null || true +[[ "${1:-}" == "login" ]] || exit 0 +mkdir -p "$CODEX_HOME" +cp "$CODEX_TEST_LOGIN_AUTH" "$CODEX_HOME/auth.json" +chmod 0600 "$CODEX_HOME/auth.json" +if [[ -n "${CODEX_TEST_CONCURRENT_PROFILE_SOURCE:-}" && -n "${CODEX_TEST_CONCURRENT_PROFILE_TARGET:-}" ]]; then + cp "$CODEX_TEST_CONCURRENT_PROFILE_SOURCE" "$CODEX_TEST_CONCURRENT_PROFILE_TARGET" + chmod 0600 "$CODEX_TEST_CONCURRENT_PROFILE_TARGET" +fi +exit "${CODEX_TEST_LOGIN_STATUS:-0}" +EOF + chmod 0755 "$path" +} + +run_reauth_tty() { + local output="$1" + local command="" + shift + printf -v command '%q ' "$@" + script -qefc "$command" /dev/null > "$output" 2>&1 +} + write_rotating_rate_limit_codex() { local path="$1" mkdir -p "$(dirname "$path")" @@ -1889,6 +1924,213 @@ test_api_key_mismatch_is_never_lineage_synced() { [[ "$(jq -r '.OPENAI_API_KEY' "$home/auth.json")" == "api-b" ]] || fail "API-key mismatch changed live auth" } +test_reauth_inactive_profile_preserves_active_state() { + local tmp home active target login_auth log login_home_file live_before marker_before login_home + tmp="$(mktemp -d)" + home="$tmp/home" + active="$home/auth-profiles/work.json" + target="$home/auth-profiles/repair.json" + login_auth="$tmp/login-auth.json" + log="$tmp/calls.log" + login_home_file="$tmp/login-home" + mkdir -p "$home/auth-profiles" + write_chatgpt_auth "$active" rt-work at-work acct-work + write_chatgpt_auth "$target" rt-repair-old at-repair-old acct-repair + write_chatgpt_auth "$login_auth" rt-repair-new at-repair-new acct-repair + write_reauth_codex "$tmp/real-codex" + printf '%s\n' 'model = "gpt-5"' 'cli_auth_credentials_store = "keyring"' '[features]' 'shell_tool = true' > "$home/config.toml" + + CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use work >/dev/null + live_before="$(sha256sum "$home/auth.json")" + marker_before="$(sha256sum "$home/active-profile.json")" + + run_reauth_tty "$tmp/out.txt" env \ + TERM=dumb \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_LOGIN_HOME_FILE="$login_home_file" \ + CODEX_TEST_LOGIN_AUTH="$login_auth" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reauth repair --device-auth + + [[ "$(jq -r '.tokens.refresh_token' "$target")" == "rt-repair-new" ]] || fail "reauth did not update the selected inactive profile" + [[ "$(sha256sum "$home/auth.json")" == "$live_before" ]] || fail "inactive reauth changed live auth" + [[ "$(sha256sum "$home/active-profile.json")" == "$marker_before" ]] || fail "inactive reauth changed the active marker" + [[ "$(jq -r '.profile' "$home/active-profile.json")" == "work" ]] || fail "inactive reauth changed the active profile name" + assert_contains 'args:login -c cli_auth_credentials_store="file" --device-auth' "$log" + assert_contains 'cli_auth_credentials_store = "keyring"' "$log" + assert_contains 'model = "gpt-5"' "$log" + assert_contains 'unchanged' "$tmp/out.txt" + login_home="$(cat "$login_home_file")" + [[ "$login_home" != "$home" ]] || fail "reauth used the live Codex home" + [[ ! -e "$login_home" ]] || fail "reauth left its private Codex home behind" +} + +test_reauth_rejects_wrong_account_without_mutation() { + local tmp home target login_auth log target_before live_before marker_before + tmp="$(mktemp -d)" + home="$tmp/home" + target="$home/auth-profiles/repair.json" + login_auth="$tmp/login-auth.json" + log="$tmp/calls.log" + mkdir -p "$home/auth-profiles" + write_chatgpt_auth "$home/auth-profiles/work.json" rt-work at-work acct-work + write_chatgpt_auth "$target" rt-repair-old at-repair-old acct-repair + write_chatgpt_auth "$login_auth" rt-wrong at-wrong acct-wrong + write_reauth_codex "$tmp/real-codex" + CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use work >/dev/null + target_before="$(sha256sum "$target")" + live_before="$(sha256sum "$home/auth.json")" + marker_before="$(sha256sum "$home/active-profile.json")" + + if run_reauth_tty "$tmp/out.txt" env \ + TERM=dumb \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_LOGIN_HOME_FILE="$tmp/login-home" \ + CODEX_TEST_LOGIN_AUTH="$login_auth" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reauth repair + then + fail "reauth accepted a different ChatGPT account" + fi + + assert_contains 'login account did not match saved profile' "$tmp/out.txt" + [[ "$(sha256sum "$target")" == "$target_before" ]] || fail "wrong-account reauth changed the target profile" + [[ "$(sha256sum "$home/auth.json")" == "$live_before" ]] || fail "wrong-account reauth changed live auth" + [[ "$(sha256sum "$home/active-profile.json")" == "$marker_before" ]] || fail "wrong-account reauth changed the active marker" +} + +test_reauth_rejects_concurrent_target_change() { + local tmp home target login_auth concurrent_auth log live_before marker_before status + tmp="$(mktemp -d)" + home="$tmp/home" + target="$home/auth-profiles/repair.json" + login_auth="$tmp/login-auth.json" + concurrent_auth="$tmp/concurrent-auth.json" + log="$tmp/calls.log" + mkdir -p "$home/auth-profiles" + write_chatgpt_auth "$home/auth-profiles/work.json" rt-work at-work acct-work + write_chatgpt_auth "$target" rt-repair-old at-repair-old acct-repair + write_chatgpt_auth "$login_auth" rt-login at-login acct-repair + write_chatgpt_auth "$concurrent_auth" rt-concurrent at-concurrent acct-repair + write_reauth_codex "$tmp/real-codex" + CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use work >/dev/null + live_before="$(sha256sum "$home/auth.json")" + marker_before="$(sha256sum "$home/active-profile.json")" + + set +e + run_reauth_tty "$tmp/out.txt" env \ + TERM=dumb \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_LOGIN_HOME_FILE="$tmp/login-home" \ + CODEX_TEST_LOGIN_AUTH="$login_auth" \ + CODEX_TEST_CONCURRENT_PROFILE_SOURCE="$concurrent_auth" \ + CODEX_TEST_CONCURRENT_PROFILE_TARGET="$target" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reauth repair + status=$? + set -e + + [[ "$status" == "75" ]] || fail "concurrent target change returned $status instead of 75" + assert_contains 'profile changed while login was open' "$tmp/out.txt" + [[ "$(jq -r '.tokens.refresh_token' "$target")" == "rt-concurrent" ]] || fail "reauth overwrote the concurrent target update" + [[ "$(sha256sum "$home/auth.json")" == "$live_before" ]] || fail "concurrent reauth changed live auth" + [[ "$(sha256sum "$home/active-profile.json")" == "$marker_before" ]] || fail "concurrent reauth changed the active marker" +} + +test_reauth_requires_tty_and_nonempty_chatgpt_credential() { + local tmp home target empty_auth unknown_profile log target_before + tmp="$(mktemp -d)" + home="$tmp/home" + target="$home/auth-profiles/repair.json" + empty_auth="$tmp/empty-auth.json" + log="$tmp/calls.log" + mkdir -p "$home/auth-profiles" + write_chatgpt_auth "$target" rt-repair-old at-repair-old acct-repair + printf '%s\n' '{"auth_mode":"chatgpt","tokens":{}}' > "$empty_auth" + chmod 0600 "$empty_auth" + write_reauth_codex "$tmp/real-codex" + target_before="$(sha256sum "$target")" + + if TERM=dumb CODEX_HOME="$home" CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reauth repair > "$tmp/no-tty.txt" 2>&1 + then + fail "reauth allowed a browser login without a tty" + fi + assert_contains 'reauth needs tty' "$tmp/no-tty.txt" + + if run_reauth_tty "$tmp/store-override.txt" env \ + TERM=dumb \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reauth repair \ + -c 'cli_auth_credentials_store="keyring"' + then + fail "reauth allowed its isolated credential store to be overridden" + fi + assert_contains 'reauth controls cli_auth_credentials_store' "$tmp/store-override.txt" + + if run_reauth_tty "$tmp/empty.txt" env \ + TERM=dumb \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_LOGIN_HOME_FILE="$tmp/login-home" \ + CODEX_TEST_LOGIN_AUTH="$empty_auth" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reauth repair + then + fail "reauth accepted an empty ChatGPT token object" + fi + assert_contains 'login did not produce a ChatGPT credential' "$tmp/empty.txt" + [[ "$(sha256sum "$target")" == "$target_before" ]] || fail "invalid login credential changed the target profile" + + unknown_profile="$home/auth-profiles/unknown.json" + printf '%s\n' '{"auth_mode":"chatgpt","tokens":{"refresh_token":"rt-unknown","access_token":"at-unknown"}}' > "$unknown_profile" + chmod 0600 "$unknown_profile" + if run_reauth_tty "$tmp/unknown.txt" env \ + TERM=dumb \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reauth unknown + then + fail "reauth allowed a saved profile without a stable account identity" + fi + assert_contains 'saved profile has no stable account identity' "$tmp/unknown.txt" + [[ "$(jq -r '.tokens.refresh_token' "$unknown_profile")" == "rt-unknown" ]] || fail "identity-less reauth changed the target profile" +} + +test_reauth_active_profile_keeps_active_name() { + local tmp home target login_auth log + tmp="$(mktemp -d)" + home="$tmp/home" + target="$home/auth-profiles/work.json" + login_auth="$tmp/login-auth.json" + log="$tmp/calls.log" + mkdir -p "$home/auth-profiles" + write_chatgpt_auth "$target" rt-work-old at-work-old acct-work + write_chatgpt_auth "$login_auth" rt-work-new at-work-new acct-work + write_reauth_codex "$tmp/real-codex" + CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use work >/dev/null + + run_reauth_tty "$tmp/out.txt" env \ + TERM=dumb \ + CODEX_TEST_LOG="$log" \ + CODEX_TEST_LOGIN_HOME_FILE="$tmp/login-home" \ + CODEX_TEST_LOGIN_AUTH="$login_auth" \ + CODEX_HOME="$home" \ + CODEX_AUTH_CODEX_BIN="$tmp/real-codex" \ + "$REPO_ROOT/bin/codex-auth" reauth work + + [[ "$(jq -r '.tokens.refresh_token' "$target")" == "rt-work-new" ]] || fail "active reauth did not update the saved profile" + [[ "$(jq -r '.tokens.refresh_token' "$home/auth.json")" == "rt-work-new" ]] || fail "active reauth did not update live auth" + [[ "$(jq -r '.profile' "$home/active-profile.json")" == "work" ]] || fail "active reauth changed the active profile name" + [[ "$(jq -r '.profile_revision' "$home/active-profile.json")" == "$(jq -cS . "$target" | sha256sum | cut -d' ' -f1)" ]] \ + || fail "active reauth left an incoherent active marker" + assert_contains 'kept work' "$tmp/out.txt" +} + test_parallel_refresh_preserves_one_complete_generation() { local tmp home log name tmp="$(mktemp -d)" @@ -2021,6 +2263,11 @@ main() { test_live_account_switch_never_overwrites_marked_profile \ test_live_rotation_does_not_guess_between_account_aliases \ test_api_key_mismatch_is_never_lineage_synced \ + test_reauth_inactive_profile_preserves_active_state \ + test_reauth_rejects_wrong_account_without_mutation \ + test_reauth_rejects_concurrent_target_change \ + test_reauth_requires_tty_and_nonempty_chatgpt_credential \ + test_reauth_active_profile_keeps_active_name \ test_parallel_refresh_preserves_one_complete_generation \ test_rolling_hook_keeps_inline_auto_cached_by_default \ test_doctor_reports_legacy_sidecars \ diff --git a/tui/README.md b/tui/README.md index 495118b..42c62b6 100644 --- a/tui/README.md +++ b/tui/README.md @@ -7,7 +7,8 @@ table/fzf output. All sensitive writes stay in the shell CLI — this package on *reads* state and *calls* `codex-auth refresh`, the guarded shell switch transaction, `codex-auth add --current`, or the confirmed earned-reset command as short-lived subprocesses, so the mutation/refresh locks stay scoped -to each call. +to each call. Interactive saved-profile sign-in is the deliberate exception: +the app suspends its full-screen terminal while `codex-auth reauth` owns the TTY. Usually launched through the shell entrypoint: @@ -25,4 +26,8 @@ open a Cancel-default confirmation. A confirmed redemption uses Codex app-server without switching the active profile, then publishes the refreshed count and usage bars. +Invalid saved sessions expose a clickable `sign in again` action. The same flow +is available from the keyboard with `i`; it suspends the full-screen terminal +while Codex runs an isolated browser login, then refreshes only that saved profile. + Run the tests with `uv run --project tui pytest`. diff --git a/tui/pyproject.toml b/tui/pyproject.toml index d3815c4..e353f6c 100644 --- a/tui/pyproject.toml +++ b/tui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-auth-tui" -version = "0.1.0" +version = "0.1.1" description = "Textual monitor and auto-switch view for codex-rolling-auth" readme = "README.md" requires-python = ">=3.12" diff --git a/tui/src/codex_auth_tui/__init__.py b/tui/src/codex_auth_tui/__init__.py index ac59ecb..b83b6d8 100644 --- a/tui/src/codex_auth_tui/__init__.py +++ b/tui/src/codex_auth_tui/__init__.py @@ -8,4 +8,4 @@ switching, profile capture, and confirmed earned-reset redemption. """ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/tui/src/codex_auth_tui/backend.py b/tui/src/codex_auth_tui/backend.py index 4197dd9..c045c99 100644 --- a/tui/src/codex_auth_tui/backend.py +++ b/tui/src/codex_auth_tui/backend.py @@ -366,6 +366,40 @@ def save_current(self, name: str) -> OperationResult: timeout_s=SAVE_TIMEOUT_S, ) + def reauth(self, name: str) -> OperationResult: + """Interactively replace one saved login without capturing its terminal. + + The Textual app suspends before calling this method, so the shell command + must inherit the real terminal. The same backend lock used by refresh and + switch calls keeps another shell operation from interleaving with login. + """ + + if not self.cli: + return OperationResult(False, 127, "codex-auth executable not found") + with self._subprocess_lock: + try: + completed = subprocess.run( + [self.cli, "reauth", name], + stdin=None, + stdout=None, + stderr=None, + env=self.env.copy(), + check=False, + ) + except KeyboardInterrupt: + return OperationResult(False, 130, "sign-in canceled") + except OSError as exc: + return OperationResult(False, 127, f"could not run codex-auth: {exc}") + if completed.returncode == 0: + return OperationResult(True) + if completed.returncode in {130, -signal.SIGINT}: + return OperationResult(False, 130, "sign-in canceled") + return OperationResult( + False, + completed.returncode, + f"codex-auth reauth exited with status {completed.returncode}", + ) + def consume_reset(self, name: str) -> OperationResult: """Use one earned rate-limit reset for ``name`` through the shell CLI.""" @@ -536,6 +570,15 @@ def _usage_from_state( and stored_fp == fingerprint ) payload = entry.get("payload") + # A definitive auth error belongs to the credential that was probed. Once + # reauth replaces that credential, retaining the old error would falsely + # offer another sign-in against a session that has never been checked. + if ( + not fingerprint_match + and isinstance(payload, dict) + and payload.get("error") is not None + ): + payload = None generation = entry.get("refresh_generation") return AccountUsage.from_payload( payload if isinstance(payload, dict) else None, diff --git a/tui/src/codex_auth_tui/models.py b/tui/src/codex_auth_tui/models.py index cf33820..d19f67f 100644 --- a/tui/src/codex_auth_tui/models.py +++ b/tui/src/codex_auth_tui/models.py @@ -157,7 +157,7 @@ def from_payload( error = payload.get("error") if error is not None: return cls( - last_error=_error_label(error), + last_error=_error_label(error) if fingerprint_match else None, age_s=age_s, fetched_at=fetched_at, fingerprint_match=fingerprint_match, @@ -206,7 +206,7 @@ def known(self) -> bool: def requires_login(self) -> bool: """Whether a completed probe says this saved session is unusable.""" - if not self.last_error: + if not self.fingerprint_match or not self.last_error: return False error = self.last_error.lower() return any( diff --git a/tui/src/codex_auth_tui/tui/app.py b/tui/src/codex_auth_tui/tui/app.py index 4d71612..d7d501c 100644 --- a/tui/src/codex_auth_tui/tui/app.py +++ b/tui/src/codex_auth_tui/tui/app.py @@ -12,9 +12,9 @@ import time from typing import Any -from textual.app import App +from textual.app import App, SuspendNotSupported from textual.reactive import reactive -from textual.worker import WorkerState +from textual.worker import WorkerCancelled, WorkerFailed, WorkerState from codex_auth_tui.backend import OperationResult, ShellBackend from codex_auth_tui.engine import ( @@ -25,7 +25,12 @@ from codex_auth_tui.paths import CodexPaths, resolve_paths from codex_auth_tui.settings import AutoSettings, load_settings from codex_auth_tui.tui.autoview import AutoScreen -from codex_auth_tui.tui.dashboard import DashboardScreen, ResetScreen, WatchScreen +from codex_auth_tui.tui.dashboard import ( + DashboardScreen, + ReauthScreen, + ResetScreen, + WatchScreen, +) from codex_auth_tui.tui.modals import ConfirmModal, OutputModal, ProfileNameModal from codex_auth_tui.tui.theme import CODEX_AUTH_DARK @@ -362,6 +367,186 @@ def _save_current_done( self.push_screen(OutputModal("Save profile: details", result.output)) self._drain_pending() + # -- isolated browser sign-in ----------------------------------------- + + def action_reauth(self, name: str) -> None: + """Rich ``@click`` target for an account's "sign in again" link.""" + + self.prepare_reauth(name) + + def prepare_reauth(self, name: str) -> None: + if isinstance(self.screen, AutoScreen): + self.notify( + "Leave Auto view before signing in again", + severity="warning", + ) + return + if self.busy or self._refreshing: + self.notify("Another auth operation is still running", severity="warning") + return + snap = self.snapshot + if snap is None: + self.notify("Saved profiles are still loading", severity="warning") + return + account = next((item for item in snap.accounts if item.name == name), None) + if account is None: + self.notify(f"Saved profile {name} was not found", severity="warning") + return + if not account.switchable: + self.notify( + f"{name} is not a saved ChatGPT profile", + severity="warning", + ) + return + + active_before = snap.active_name + if active_before is None: + active_copy = ( + "No profile will be activated, and the live Codex auth stays as it is." + ) + else: + active_copy = ( + f"The current active profile ({active_before}) stays active. " + "No profile switch is performed." + ) + if name == active_before: + active_copy += " Its live credential is refreshed in place." + self.push_screen( + ConfirmModal( + f"Open browser sign-in for {name}?\n\n" + f"Only the selected saved profile ({name}) is reauthenticated. " + f"{active_copy}", + title="Sign in again?", + yes_label="Sign in", + default_cancel=True, + ), + partial(self._reauth_confirmed, name, active_before), + ) + + async def _reauth_confirmed( + self, + name: str, + active_before: str | None, + confirmed: bool | None, + ) -> None: + if not confirmed: + return + if self.busy or self._refreshing: + self.notify("Another auth operation is still running", severity="warning") + return + + self.busy = True + self.notify(f"Opening browser sign-in for {name}…", timeout=3) + worker = None + try: + # Restore the caller's terminal while codex login owns stdin/stdout. + with self.suspend(): + worker = self.run_worker( + partial(self._reauth_blocking, name), + thread=True, + group="reauth", + exit_on_error=False, + name=f"reauth-{name}", + ) + result, refresh_result, snap = await worker.wait() + except SuspendNotSupported: + self.busy = False + self.notify( + "Interactive sign-in needs a local terminal", + severity="warning", + ) + self._drain_pending() + return + except (KeyboardInterrupt, WorkerCancelled): + if worker is not None: + worker.cancel() + self.busy = False + self.notify("Sign-in canceled", title="Saved profile unchanged") + self._drain_pending() + return + except WorkerFailed as exc: + self.busy = False + self.notify(f"Sign-in failed: {exc.error}", severity="error") + self._drain_pending() + return + + self._reauth_done( + name, + active_before, + result, + refresh_result, + snap, + ) + + def _reauth_blocking( + self, name: str + ) -> tuple[OperationResult, OperationResult, AccountsSnapshot]: + result = _operation_result(self.backend.reauth(name)) + refresh_result = OperationResult(True) + if result.ok: + refresh_result = _operation_result(self.backend.refresh([name])) + snap = self.backend.snapshot() + if result.ok and refresh_result.ok: + refreshed = next( + (account for account in snap.accounts if account.name == name), + None, + ) + if ( + refreshed is None + or refresh_result.generation is None + or refreshed.usage.refresh_generation != refresh_result.generation + ): + refresh_result = OperationResult( + False, + 75, + f"fresh usage was not confirmed for {name}", + refresh_result.generation, + ) + return result, refresh_result, snap + + def _reauth_done( + self, + name: str, + active_before: str | None, + result: OperationResult, + refresh_result: OperationResult, + snap: AccountsSnapshot, + ) -> None: + self.busy = False + self.snapshot = snap + if result.ok: + if snap.active_name == active_before: + active_note = ( + f" · {active_before} stayed active" + if active_before is not None + else " · active auth unchanged" + ) + severity = "information" + else: + current = snap.active_name or "unmanaged auth" + active_note = f" · active state is now {current}" + severity = "warning" + if refresh_result.ok: + refresh_note = "" + else: + refresh_note = " · fresh usage not confirmed" + severity = "warning" + self.notify( + f"Updated saved login for {name}{active_note}{refresh_note}", + title="Sign-in complete", + severity=severity, + ) + if isinstance(self.screen, ReauthScreen): + self.pop_screen() + elif result.returncode == 130: + self.notify("Sign-in canceled", title="Saved profile unchanged") + else: + message = _first_line(result.output) or "saved profile was not updated" + self.notify(message, severity="warning") + if result.output.strip(): + self.push_screen(OutputModal("Sign in again: details", result.output)) + self._drain_pending() + # -- earned rate-limit resets ----------------------------------------- def prepare_reset(self, name: str) -> None: @@ -515,6 +700,16 @@ def action_open_resets(self) -> None: if not isinstance(self.screen, ResetScreen): self.push_screen(ResetScreen()) + def action_open_reauth(self) -> None: + if isinstance(self.screen, AutoScreen): + self.notify( + "Leave Auto view before signing in again", + severity="warning", + ) + return + if not isinstance(self.screen, ReauthScreen): + self.push_screen(ReauthScreen()) + def _operation_result(value: Any) -> OperationResult: if isinstance(value, OperationResult): diff --git a/tui/src/codex_auth_tui/tui/autoview.py b/tui/src/codex_auth_tui/tui/autoview.py index b9a7ff1..a3c0590 100644 --- a/tui/src/codex_auth_tui/tui/autoview.py +++ b/tui/src/codex_auth_tui/tui/autoview.py @@ -68,6 +68,7 @@ def event_text(event: AutoEvent) -> Text: class AutoScreen(Screen): BINDINGS = [ Binding("l", "toggle_live", "Go live / dry-run"), + Binding("i", "app.open_reauth", "Sign in"), Binding("r", "refresh_now", "Refresh"), Binding("escape,q", "back", "Back"), ] diff --git a/tui/src/codex_auth_tui/tui/dashboard.py b/tui/src/codex_auth_tui/tui/dashboard.py index eaefef2..2e6b823 100644 --- a/tui/src/codex_auth_tui/tui/dashboard.py +++ b/tui/src/codex_auth_tui/tui/dashboard.py @@ -9,6 +9,8 @@ same cards read-only: a live monitor. ``s`` arms selection (a cursor appears), Enter switches and *stays watching*, Esc disarms. No accidental switch cursor while passively watching. +- ``i`` / "Sign in again" → :class:`ReauthScreen` — browser login updates the + chosen saved profile without switching the active profile. """ from __future__ import annotations @@ -41,6 +43,7 @@ class DashboardScreen(Screen): BINDINGS = [ Binding("s", "open_switch", "Switch"), Binding("n", "app.save_current", "Save"), + Binding("i", "app.open_reauth", "Sign in"), Binding("u", "app.open_resets", "Use reset"), Binding("w", "app.open_watch", "Watch"), Binding("a", "app.open_auto", "Auto"), @@ -68,6 +71,7 @@ async def on_mount(self) -> None: ("Watch profiles", "watch"), ("Switch profile…", "switch"), ("Save current auth…", "save-current"), + ("Sign in again…", "reauth"), ("Use earned reset…", "reset"), ("Auto-switch view", "auto"), ("Quit", "quit"), @@ -87,6 +91,7 @@ def _dispatch(self, action_id: str) -> None: "watch": app.action_open_watch, "switch": self.action_open_switch, "save-current": app.action_save_current, + "reauth": app.action_open_reauth, "reset": app.action_open_resets, "auto": app.action_open_auto, "quit": app.exit, @@ -244,6 +249,53 @@ def action_back(self) -> None: self.app.pop_screen() +class ReauthScreen(AccountListScreen): + """Choose one saved ChatGPT profile for an isolated browser sign-in.""" + + BINDINGS = [ + Binding("enter", "select_highlighted", "Sign in", priority=True), + Binding("escape,q,i", "back", "Back"), + Binding("j", "cursor_down", show=False), + Binding("k", "cursor_up", show=False), + ] + + def on_mount(self) -> None: + self.query_one("#list-title", Static).update( + "sign in again · choose a saved ChatGPT profile" + ) + self.query_one("#accounts", ListView).focus() + super().on_mount() + + def _index_after_build(self, snap, first_build, previous) -> int | None: + if first_build: + return next( + ( + index + for index, account in enumerate(snap.accounts) + if account.usage.requires_login + ), + self._active_index(snap), + ) + return super()._index_after_build(snap, first_build, previous) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + item = event.item + if isinstance(item, AccountItem): + if not item.switchable: + self.app.notify( + f"{item.name_} is not a saved ChatGPT profile", + severity="warning", + ) + return + self.app.prepare_reauth(item.name_) + + def action_select_highlighted(self) -> None: + self.query_one("#accounts", ListView).action_select_cursor() + + def action_back(self) -> None: + self.app.pop_screen() + + class WatchScreen(AccountListScreen): """Live monitor of every profile, hands-off by default. @@ -258,6 +310,7 @@ class WatchScreen(AccountListScreen): Binding("s", "toggle_select", "Switch"), Binding("enter", "select_highlighted", "Confirm", priority=True), Binding("n", "app.save_current", "Save"), + Binding("i", "app.open_reauth", "Sign in"), Binding("u", "app.open_resets", "Use reset"), Binding("a", "app.open_auto", "Auto"), Binding("r", "app.refresh_full", "Refresh"), diff --git a/tui/src/codex_auth_tui/tui/widgets.py b/tui/src/codex_auth_tui/tui/widgets.py index 7f06850..d8468c4 100644 --- a/tui/src/codex_auth_tui/tui/widgets.py +++ b/tui/src/codex_auth_tui/tui/widgets.py @@ -10,6 +10,7 @@ import time from typing import TYPE_CHECKING +from rich.style import Style from rich.text import Text from textual.widgets import ListItem, Static @@ -34,6 +35,20 @@ _BAR_TICK = "┃" +def _reauth_link_style(name: str) -> Style: + """Return a real Textual click action without putting markup in profile data.""" + + return Style( + color=ACCENT, + underline=True, + meta={"@click": ("app.reauth", (name,))}, + ) + + +def _append_reauth_link(text: Text, acc: AccountSnapshot) -> None: + text.append("sign in again", style=_reauth_link_style(acc.name)) + + def bar_cells( pct: float | None, width: int, @@ -122,13 +137,12 @@ def account_card_text( if not acc.usage.windows: text.append("\n ") text.append("usage unavailable", style=MUTED) - if acc.usage.last_error: - error = ( - "sign in again" - if acc.usage.requires_login - else acc.usage.last_error - ) - text.append(f" · {error}", style=MUTED) + if acc.usage.last_error and acc.usage.fingerprint_match: + text.append(" · ", style=MUTED) + if acc.usage.requires_login: + _append_reauth_link(text, acc) + else: + text.append(acc.usage.last_error, style=MUTED) return text stale = acc.usage.stale @@ -165,6 +179,12 @@ def mini_account_text(acc: AccountSnapshot, now: float) -> Text: return text if not acc.usage.windows: text.append("usage unknown", style=MUTED) + if acc.usage.last_error and acc.usage.fingerprint_match: + text.append(" · ", style=MUTED) + if acc.usage.requires_login: + _append_reauth_link(text, acc) + else: + text.append(acc.usage.last_error, style=MUTED) return text stale = acc.usage.stale for i, window in enumerate(acc.usage.windows): @@ -252,11 +272,13 @@ def __init__(self, acc: AccountSnapshot) -> None: super().__init__(AccountCard(acc)) self.name_ = acc.name self.switchable = acc.switchable + self.requires_login = acc.usage.requires_login self.reset_credits_available = acc.usage.reset_credits_available def set_account(self, acc: AccountSnapshot) -> None: self.name_ = acc.name self.switchable = acc.switchable + self.requires_login = acc.usage.requires_login self.reset_credits_available = acc.usage.reset_credits_available self.query_one(AccountCard).set_account(acc) diff --git a/tui/tests/test_backend.py b/tui/tests/test_backend.py index 05dea09..90aa27b 100644 --- a/tui/tests/test_backend.py +++ b/tui/tests/test_backend.py @@ -4,6 +4,7 @@ import hashlib import json +from types import SimpleNamespace import time from codex_auth_tui.backend import ( @@ -95,6 +96,38 @@ def test_snapshot_rejects_state_for_a_different_credential(codex_home): assert account.usage.decision_value() is None +def test_reauth_does_not_reuse_old_credentials_login_error(codex_home): + work = write_profile(codex_home, "work", chatgpt_profile("work")) + old_fingerprint = credential_fingerprint(work) + seed_state( + codex_home, + { + "work": { + "fingerprint": old_fingerprint, + "payload": { + "error": ( + "Your access token could not be refreshed because you have " + "since logged out. Please sign in again." + ) + }, + "generation": "old-login-error", + } + }, + ) + write_profile( + codex_home, + "work", + chatgpt_profile("work", refresh_token="rt-work-after-reauth"), + ) + + usage = ShellBackend(codex_home).snapshot().accounts[0].usage + + assert usage.fingerprint_match is False + assert usage.last_error is None + assert usage.requires_login is False + assert usage.refresh_generation == "old-login-error" + + def test_reset_credit_count_distinguishes_unknown_zero_and_positive(): missing = rate_payload(10, 20) zero = rate_payload(10, 20, reset_credits=0) @@ -211,6 +244,7 @@ def test_refresh_switch_and_patch_check_use_short_lived_fake_cli( refreshed = backend.refresh() saved = backend.save_current("captured") + reauthed = backend.reauth("alt") reset = backend.consume_reset("alt") switched = backend.switch( "alt", expected_current="work", expected_generation="generation-1" @@ -219,6 +253,7 @@ def test_refresh_switch_and_patch_check_use_short_lived_fake_cli( assert refreshed.ok is True assert refreshed.generation assert saved.ok is True + assert reauthed.ok is True assert reset.ok is True assert (codex_home.profiles_dir / "captured.json").read_bytes() == work.read_bytes() assert switched.ok is True @@ -227,11 +262,55 @@ def test_refresh_switch_and_patch_check_use_short_lived_fake_cli( log = fake_codex_auth.read_text(encoding="utf-8") assert "refresh --quiet --fast" in log assert "add captured --current" in log + assert "reauth alt" in log assert "reset alt --yes" in log assert "use-if-current work alt generation-1" in log assert "patch-codex --print-bin --quiet" in log +def test_reauth_inherits_terminal_and_holds_backend_lock( + codex_home, monkeypatch +): + backend = ShellBackend(codex_home, cli="/fake/codex-auth") + seen = {} + + def fake_run(args, **kwargs): + seen["args"] = args + seen["kwargs"] = kwargs + acquired = backend._subprocess_lock.acquire(blocking=False) + seen["lock_was_held"] = not acquired + if acquired: + backend._subprocess_lock.release() + return SimpleNamespace(returncode=0) + + monkeypatch.setattr("codex_auth_tui.backend.subprocess.run", fake_run) + + result = backend.reauth("work") + + assert result.ok is True + assert seen["args"] == ["/fake/codex-auth", "reauth", "work"] + assert seen["kwargs"]["stdin"] is None + assert seen["kwargs"]["stdout"] is None + assert seen["kwargs"]["stderr"] is None + assert seen["kwargs"]["check"] is False + assert seen["lock_was_held"] is True + + +def test_reauth_treats_ctrl_c_as_cancel(codex_home, monkeypatch): + backend = ShellBackend(codex_home, cli="/fake/codex-auth") + + def interrupted(*_args, **_kwargs): + raise KeyboardInterrupt + + monkeypatch.setattr("codex_auth_tui.backend.subprocess.run", interrupted) + + result = backend.reauth("work") + + assert result.ok is False + assert result.returncode == 130 + assert result.output == "sign-in canceled" + + def test_backend_preserves_explicit_path_overrides(codex_home, tmp_path): profiles = tmp_path / "custom-profiles" state = tmp_path / "custom-state.json" diff --git a/tui/tests/test_tui.py b/tui/tests/test_tui.py index fd56cc3..06f4e01 100644 --- a/tui/tests/test_tui.py +++ b/tui/tests/test_tui.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from contextlib import nullcontext import dataclasses import threading import time @@ -21,13 +22,15 @@ from codex_auth_tui.settings import AutoSettings from codex_auth_tui.tui.app import CodexAuthApp from codex_auth_tui.tui.autoview import AutoScreen -from codex_auth_tui.tui.dashboard import ResetScreen, WatchScreen +from codex_auth_tui.tui.dashboard import ReauthScreen, ResetScreen, WatchScreen from codex_auth_tui.tui.modals import ConfirmModal, ProfileNameModal from codex_auth_tui.tui.widgets import ( AccountCard, AccountItem, RuntimeStatus, + account_card_text, bar_cells, + mini_account_text, ) @@ -67,6 +70,33 @@ def _account( ) +def _login_required_account(name: str, *, active: bool = False) -> AccountSnapshot: + now = time.time() + return AccountSnapshot( + name=name, + is_active=active, + kind="chatgpt", + switchable=True, + usage=AccountUsage( + fetched_at=now, + age_s=0, + last_error=( + "Your access token could not be refreshed because you have since " + "logged out or signed in to another account. Please sign in again." + ), + ), + ) + + +def _reauth_click_meta(rendered) -> tuple[str, tuple[str, ...]]: + start = rendered.plain.index("sign in again") + end = start + len("sign in again") + for span in rendered.spans: + if span.start == start and span.end == end and hasattr(span.style, "meta"): + return span.style.meta["@click"] + raise AssertionError("sign-in link has no @click metadata") + + class FakeBackend: def __init__(self, paths) -> None: self.paths = paths @@ -91,9 +121,23 @@ def snapshot(self, now=None) -> AccountsSnapshot: def refresh(self, names=None) -> OperationResult: self.calls.append("refresh") + generation = f"fake-generation-{len(self.calls)}" if names: self.calls.append(f"refresh:{','.join(names)}") - return OperationResult(True) + selected = set(names) + self.accounts = [ + dataclasses.replace( + account, + usage=dataclasses.replace( + account.usage, + refresh_generation=generation, + ), + ) + if account.name in selected + else account + for account in self.accounts + ] + return OperationResult(True, generation=generation) def switch( self, @@ -114,6 +158,16 @@ def save_current(self, name: str) -> OperationResult: self.accounts.append(saved) return OperationResult(True) + def reauth(self, name: str) -> OperationResult: + self.calls.append(f"reauth:{name}") + self.accounts = [ + dataclasses.replace(account, usage=_usage(12, 8)) + if account.name == name + else account + for account in self.accounts + ] + return OperationResult(True) + def consume_reset(self, name: str) -> OperationResult: self.calls.append(f"reset:{name}") updated = [] @@ -367,6 +421,160 @@ async def test_save_current_validates_names_and_confirms_replacement(codex_home) assert "save:work" in backend.calls +def test_full_and_mini_login_errors_expose_safe_click_actions(): + name = "odd');app.quit()" + account = _login_required_account(name) + + for rendered in ( + account_card_text(account, 90), + mini_account_text(account, time.time()), + ): + assert "sign in again" in rendered.plain + assert _reauth_click_meta(rendered) == ("app.reauth", (name,)) + + mismatched = dataclasses.replace( + account, + usage=dataclasses.replace(account.usage, fingerprint_match=False), + ) + assert mismatched.usage.requires_login is False + assert "sign in again" not in account_card_text(mismatched, 90).plain + assert "sign in again" not in mini_account_text(mismatched, time.time()).plain + + +@pytest.mark.asyncio +async def test_login_link_targets_the_selected_saved_profile(codex_home): + backend = FakeBackend(codex_home) + backend.accounts[1] = _login_required_account("personal") + app = make_app(backend) + + async with app.run_test(size=(104, 34)) as pilot: + await settle(pilot) + account = next(item for item in app.snapshot.accounts if item.name == "personal") + action_name, params = _reauth_click_meta(account_card_text(account, 90)) + namespace, action = action_name.split(".", 1) + + await app.run_action((namespace, action, params)) + await pilot.pause() + + assert isinstance(app.screen, ConfirmModal) + body = app.screen.query_one(".modal-body", Static).render().plain + assert "Only the selected saved profile (personal) is reauthenticated" in body + assert "current active profile (work) stays active" in body + assert not any(call.startswith("reauth:") for call in backend.calls) + await pilot.press("n") + await pilot.pause() + + app.prepare_reauth("work") + await pilot.pause() + active_body = app.screen.query_one(".modal-body", Static).render().plain + assert "current active profile (work) stays active" in active_body + assert "live credential is refreshed in place" in active_body + await pilot.press("n") + + +@pytest.mark.asyncio +async def test_keyboard_reauth_cancels_safely_then_refreshes_only_selected_profile( + codex_home, monkeypatch +): + monkeypatch.setattr(CodexAuthApp, "suspend", lambda self: nullcontext()) + backend = FakeBackend(codex_home) + backend.accounts[1] = _login_required_account("personal") + app = make_app(backend) + + async with app.run_test(size=(104, 34)) as pilot: + await settle(pilot) + await pilot.press("i") + await pilot.pause() + + assert isinstance(app.screen, ReauthScreen) + assert app.screen.query_one("#accounts", ListView).index == 1 + + await pilot.press("enter") + await pilot.pause() + assert isinstance(app.screen, ConfirmModal) + assert app.screen.query_one("#no", Button).has_focus + + await pilot.press("n") + await pilot.pause() + assert isinstance(app.screen, ReauthScreen) + assert "reauth:personal" not in backend.calls + assert backend.active == "work" + + await pilot.press("enter") + await pilot.pause() + await pilot.press("y") + await settle(pilot) + + assert "reauth:personal" in backend.calls + assert "refresh:personal" in backend.calls + assert backend.calls.index("reauth:personal") < backend.calls.index( + "refresh:personal" + ) + assert backend.active == "work" + assert app.snapshot is not None + assert app.snapshot.active_name == "work" + personal = next( + item for item in app.snapshot.accounts if item.name == "personal" + ) + assert personal.usage.requires_login is False + assert personal.usage.refresh_generation is not None + assert isinstance(app.screen, WatchScreen) + + +@pytest.mark.asyncio +async def test_reauth_worker_error_returns_to_the_tui( + codex_home, monkeypatch +): + monkeypatch.setattr(CodexAuthApp, "suspend", lambda self: nullcontext()) + + class ErrorBackend(FakeBackend): + def reauth(self, name: str) -> OperationResult: + self.calls.append(f"reauth:{name}") + raise RuntimeError("browser login worker failed") + + backend = ErrorBackend(codex_home) + backend.accounts[1] = _login_required_account("personal") + app = make_app(backend) + + async with app.run_test(size=(104, 34)) as pilot: + await settle(pilot) + await pilot.press("i", "enter") + await pilot.pause() + await pilot.press("y") + for _ in range(50): + await pilot.pause() + if not app.busy: + break + + assert app.busy is False + assert backend.active == "work" + assert "reauth:personal" in backend.calls + assert "refresh:personal" not in backend.calls + assert isinstance(app.screen, ReauthScreen) + + +def test_reauth_does_not_call_cached_fallback_a_fresh_usage_result(codex_home): + class CachedFallbackBackend(FakeBackend): + def refresh(self, names=None) -> OperationResult: + self.calls.append("refresh") + if names: + self.calls.append(f"refresh:{','.join(names)}") + return OperationResult(True, generation="new-login-generation") + + backend = CachedFallbackBackend(codex_home) + backend.accounts[1] = _login_required_account("personal") + app = make_app(backend) + + result, refresh_result, snap = app._reauth_blocking("personal") + + personal = next(item for item in snap.accounts if item.name == "personal") + assert result.ok is True + assert refresh_result.ok is False + assert refresh_result.generation == "new-login-generation" + assert "fresh usage was not confirmed" in refresh_result.output + assert personal.usage.refresh_generation is None + + @pytest.mark.asyncio async def test_watch_checks_and_uses_an_earned_reset_without_switching(codex_home): backend = FakeBackend(codex_home) @@ -434,7 +642,8 @@ async def test_reset_confirmation_defaults_to_cancel(codex_home): async def test_auto_opens_dry_then_confirms_live_and_cleans_up( codex_home, fake_engine ): - app = make_app(FakeBackend(codex_home)) + backend = FakeBackend(codex_home) + app = make_app(backend) async with app.run_test(size=(104, 38)) as pilot: await settle(pilot) @@ -448,6 +657,16 @@ async def test_auto_opens_dry_then_confirms_live_and_cleans_up( await pilot.pause() assert app.screen.query_one("#event-log", RichLog).lines + dry_engine = fake_engine.instances[0] + await pilot.press("i") + await pilot.pause() + await app.run_action(("app", "reauth", ("personal",))) + await pilot.pause() + assert isinstance(app.screen, AutoScreen) + assert fake_engine.instances == [dry_engine] + assert dry_engine.stopped is False + assert not any(call.startswith("reauth:") for call in backend.calls) + await pilot.press("l") await pilot.pause() assert isinstance(app.screen, ConfirmModal) diff --git a/tui/uv.lock b/tui/uv.lock index 18143eb..7e0e4b4 100644 --- a/tui/uv.lock +++ b/tui/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [[package]] name = "codex-auth-tui" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "textual" },