Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.1.1 - 2026-07-10

- Added `codex-auth reauth <name>` 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.
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ _Synthetic demo data. No live credentials or account identifiers._

</details>

### 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) |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Binary file modified assets/codex-auth-auto.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/codex-auth-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/codex-auth-demo.mp4
Binary file not shown.
Binary file modified assets/codex-auth-reset.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/codex-auth-sign-in.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/codex-auth-watch.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions bin/codex-auth
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>"; 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 <expected> <target> [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 <name> [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 <name> [codex login args...]"; cmd_reauth "$@" ;;
auto) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}" profiles.sh; cmd_auto "$@" ;;
patch-codex)
case " $* " in
Expand Down
3 changes: 3 additions & 0 deletions lib/codex-auth/help.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ usage() {
print_help_section "profiles" "$width"
print_help_item "add <name>" "Browser login" "$width"
print_help_item "use <name>" "Select profile" "$width"
print_help_item "reauth <name>" "Repair saved login" "$width"
print_help_item "add <name> --current" "Save current auth" "$width"
print_help_item "reset <name> --yes" "Use earned reset" "$width"
print_help_item "remove <name> --yes" "Delete profile" "$width"
Expand Down Expand Up @@ -73,6 +74,7 @@ usage_all() {
print_help_item "add <name> --current" "Save current auth" "$width"
print_help_item "add <name> --file <file>" "Import auth file" "$width"
print_help_item "login <name>" "Login profile" "$width"
print_help_item "reauth <name>" "Repair without switching" "$width"
print_help_section "maintenance" "$width"
print_help_item "refresh" "Refresh usage" "$width"
print_help_item "reset <name> --yes" "Use earned reset" "$width"
Expand Down Expand Up @@ -124,6 +126,7 @@ print_help_item() {
"add <name> --file <file>"$'\t'"import"
"import*"$'\t'"load"
"login <name>"$'\t'"auth"
"reauth <name>"$'\t'"auth"
"refresh"$'\t'"sync"
"reset <name> --yes"$'\t'"reset"
"auto"$'\t'"best"
Expand Down
142 changes: 142 additions & 0 deletions lib/codex-auth/profiles.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> [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 <name> [--current | --file <file> | codex login args...]"
Expand Down
4 changes: 2 additions & 2 deletions lib/codex-auth/usage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
60 changes: 58 additions & 2 deletions scripts/capture_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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",
Expand All @@ -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),
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading