Skip to content

[macOS 26.818.41705] Thread resume signs out desktop — rotated refresh token never persisted to auth.json, fresh login invalidated in 76s #40267

Open

Description

@ellebee2

Summary

On macOS, opening/resuming an existing thread signs the app out. The desktop auth poller attempts an OAuth refresh, receives 401 refresh_token_invalidated, and the UI drops to signed-out. Logging out, having auth.json removed, and signing in fresh does not fix it — a brand-new token family is invalidated within ~76 seconds.

The distinguishing detail: the rotated refresh token is never written to auth.json. After a fresh sign-in, auth.json mtime and last_refresh never advance, so the client keeps presenting a refresh token the server has already retired.

Environment

App ChatGPT.app 26.818.41705 (CFBundleVersion 6971), bundle id com.openai.codex
Bundled core codex-cli 0.149.0-alpha.4.1 (Contents/Resources/codex)
Workspace bundle 26.819.11345 (node v24.19.0, python 3.12.13), arch arm64
OS macOS (Darwin 24.6.0), Apple silicon
Auth mode chatgpt (ChatGPT sign-in, not API key)

Reproduction

  1. Sign out from the app.
  2. Confirm no ChatGPT/codex processes remain and ~/.codex/auth.json is gone.
  3. Relaunch, sign in. A new token family is written.
  4. Open any existing thread.
  5. Signed out.

Evidence

Fresh sign-in, then failure 76 seconds later:

14:39:01  sign-in; auth.json written; id_token iat 14:39:01, exp 15:39:01
14:40:17  ERROR codex_login::auth::manager
          Failed to refresh token: 401 Unauthorized
          {
            "error": {
              "message": "Your refresh token has been invalidated. Please try signing in again.",
              "type": "invalid_request_error",
              "code": "refresh_token_invalidated"
            }
          }

After that failure, ~/.codex/auth.json still holds the same refresh token written at sign-in, and last_refresh is unchanged at the sign-in timestamp. Nothing was persisted.

Every invalidation is attributed to the desktop auth poller — rpc.method="getAuthStatus" with rpc.request_id=desktop-auth:<uuid>:

caller          count
desktop-auth       12
other callers       0

Several fire in the same second as thread/resume, which is the user-visible trigger.

Error attribution by client version across full log history (token_invalidated):

bundled core 0.14x    668
desktop shell 26.818   12

Onset correlates exactly with the update. The previous desktop build (26.727.51351) last logged at 20:24:51; the new build's first log is 20:25:01, and the first refresh_token_invalidated is 20:49:05 the same evening.

Notably still working

The iOS remote client (codex_chatgpt_ios_remote) has zero auth errors in the entire log history, and successfully performed a thread/resume while the desktop was failing. Its requests ride the long-lived access_token (10-day expiry) rather than driving the hourly id_token refresh. This isolates the fault to the desktop shell's auth-refresh path rather than the account, the credentials, or the backend.

Ruled out locally

  • Not the workspace-settings 401 from [Windows 26.814] Opening an existing thread signs out a personal Pro account after workspace-only settings 401 #39189 — zero /backend-api/accounts/*/settings requests and zero "Must use workspace account" messages in logs.
  • Not architecture — app is arm64-only on Apple silicon, no translation involved.
  • Not filesystem/permissions — auth.json is writable, no uchg/schg flags, 173 GB free; writes succeed at sign-in and simply never happen again.
  • Not the workspace dependency bundle — intact and correctly built for arm64.
  • Not concurrent clients — 0 of 12 invalidations came from any client other than desktop-auth.
  • Not plan-related — the first failures occurred while on a paid plan.

Expected

A 401 on refresh should not drop a session whose access_token is still valid, and a successful refresh should persist the rotated token to auth.json.

Related

Workaround

None on the desktop. Driving Codex from the iOS remote client continues to work.

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    authIssues related to authentication and accounts
    sessionIssues involving session (thread) management, resuming, forking, naming, archiving
    on Aug 23, 2026
  2. github-actions commented on Aug 23, 2026

    @github-actions
    Contributor
  3. jeremiahstjames commented on Aug 24, 2026

    @jeremiahstjames

    Same exact issue. i've been totally locked out of a $200 codex account for a week due to this error. ive literally tried all of the exact steps you have, deleted auth.json, reset passwords, uninstalled Codex, changed wifi.... everything i could think of. Nothing. Im about one more Troubleshoot day from just canceling.

  4. LuoYuY commented on Aug 24, 2026

    @LuoYuY

    I can reproduce this on macOS as well, with an additional potentially useful trigger: the problem started immediately after I set a ChatGPT account password in ChatGPT Settings → Account security & login → Password. This account had previously been used primarily via Sign in with Apple.

    Environment:

    • macOS
    • Codex Desktop client version from logs: 26.818.61809
    • auth mode: chatgpt

    Observed behavior:

    1. Codex appears signed in normally.
    2. Sending a prompt can succeed / backend requests such as /backend-api/codex/models return 200 OK.
    3. Desktop then calls getAuthStatus and logs Refreshing token.
    4. POST https://auth.openai.com/oauth/token returns 401 Unauthorized:
    Failed to refresh token: 401 Unauthorized
    {
      "error": {
        "message": "Your refresh token has been invalidated. Please try signing in again.",
        "type": "invalid_request_error",
        "code": "refresh_token_invalidated"
      }
    }
    

    The access token itself is freshly issued and unexpired when this happens.

    I also ruled out stale local credentials:

    • fully quit Codex and ChatGPT Desktop
    • moved ~/.codex/auth.json aside
    • relaunched Codex
    • completed a completely fresh browser OAuth sign-in
    • confirmed a new ~/.codex/auth.json was created
    • reproduced the same refresh_token_invalidated error with the newly issued refresh token immediately afterward

    So deleting/regenerating auth.json does not fix it.

    The timing with setting a ChatGPT password may be relevant: changing/setting the password could reasonably invalidate existing sessions, but a fresh Codex OAuth login performed afterward also receives a refresh token that is immediately rejected, leaving Desktop in a repeated login loop.

    No account IDs, email addresses, access tokens, refresh tokens, or other credentials are included here.

  5. LuoYuY commented on Aug 24, 2026

    @LuoYuY

    One additional reproduction detail that may be important: setting the ChatGPT password was not an unrelated/manual account-security change. It was prompted by the Remote Connection setup flow.

    The sequence was:

    1. ChatGPT/Codex Desktop was working normally on macOS.
    2. I attempted to configure Remote Connection so I could connect to the desktop session from the ChatGPT mobile app.
    3. During Remote Connection setup I was required to authenticate with a password.
    4. My account had previously been used via Sign in with Apple, so I did not have a ChatGPT password to enter.
    5. I went to ChatGPT Settings → Account security & login → Password and set a password in order to complete the Remote Connection setup.
    6. Immediately after this account-password setup, Desktop entered the repeated login / refresh_token_invalidated state described above.
    7. Completely regenerating ~/.codex/auth.json and performing a fresh OAuth login does not recover Desktop; the newly issued refresh token is also rejected.

    So there may be a product-level reproduction path along the lines of:

    social/Apple-login account → Remote Connection setup → password authentication required → set ChatGPT password → Desktop OAuth refresh-token invalidation loop

    This currently leaves Desktop effectively unusable and also prevents completing the Remote Connection workflow that prompted the password setup in the first place.

  6. LuoYuY commented on Aug 24, 2026

    @LuoYuY

    Update: I found a workaround that restored Desktop for me, which may help narrow the root cause.

    What worked:

    1. Sign out of ChatGPT/OpenAI sessions completely.
    2. In the browser, sign back into ChatGPT first using the account's original Sign in with Apple flow (not email/password).
    3. Then reopen ChatGPT/Codex Desktop and authenticate again.
    4. Desktop resumes working normally and no longer immediately drops back into the login loop.

    This is notable because the issue had started immediately after I set a ChatGPT password for an account that had previously been used primarily via Sign in with Apple.

    So the failure may involve inconsistent server/browser session state after introducing password auth to an existing social-login account, rather than a permanently broken local Desktop installation.

    A possibly useful recovery path for affected users is therefore:

    sign out everywhere → establish a clean web session using the original social login provider → re-authenticate Desktop

    This workaround restored my Desktop session after deleting/regenerating auth.json alone had not fixed the issue.

  7. ellebee2 commented on Aug 24, 2026

    @ellebee2
    Author

    Workaround that resolved this for me on 26.818.41705 (build 6971)

    Still on the affected build — no rollback, no reinstall. The fix was clearing the
    stale credential from both places at once. Either one alone did nothing.

    What did not work: signing in from a fresh browser profile with no existing
    OpenAI session. On launch the app never opened a browser at all — it silently
    reused the refresh token already in ~/.codex/auth.json, so no new token was
    ever minted and the workaround never actually ran. auth.json was byte-identical
    before and after (4142 b, same mtime).

    What worked:

    1. Settings → Security → active sessions. Log out the Codex session
      specifically (mine was timestamped identically to auth.json's mtime, which
      is how I spotted it). Logging out of all sessions is not necessary.
    2. Fully quit the app. pgrep -fl ChatGPT to confirm — it signs out but keeps
      running, and it rewrites auth.json on exit.
    3. Move ~/.codex/auth.json aside. Note the app leaves the stale file in place
      even after being signed out server-side, so the app still has something to
      fall back on until you remove it.
    4. Relaunch and sign in. With no local credential it has to do a real OAuth.
    5. Open a previously failing thread.

    Thread resume no longer signs me out.

    Evidence the rotation is now persisting, which is the actual defect in this
    issue:

    before after
    auth.json 4142 b @ 09:55 4168 b @ 16:40
    last_refresh 2026-08-24T13:55:54Z 2026-08-24T20:40:11Z
    refresh token length 196 211

    A new file with a new refresh token and a current last_refresh — previously the
    rotated token was never written to disk.

    The important part for anyone still stuck: revoking the session server-side is
    not enough on its own
    , because the client keeps reusing the stale local
    auth.json without complaint. Both have to go.

    @jeremiahstjames — you mentioned deleting auth.json and reinstalling without
    luck. If you did those without also revoking the specific Codex session from the
    active-sessions list, that ordering may be why. Worth one more try.

  8. Polarnova commented on Aug 24, 2026

    @Polarnova

    Independent reproduction: Microsoft SSO + password/2FA variant on current macOS build

    This report is generated by 5.6 Sol Max

    I can independently reproduce the same forced sign-out loop, with a social-login variant that may help narrow the account-side trigger.

    Environment

    • ChatGPT/Codex desktop: 26.818.61809 (build 7019)
    • Bundled Codex core/app-server: 0.149.0-alpha.4.3
    • Earlier affected release observed in the same log history: desktop 26.818.41509, bundled core 0.149.0-alpha.4.1
    • macOS 15.7.9 (24G830), Apple Silicon M4, arm64, 32 GB RAM
    • ChatGPT Pro, 20x Codex tier
    • Auth mode: chatgpt; one account, no intentional account switching
    • The account was previously used through Sign in with Microsoft. A password was subsequently added and 2FA enabled on the same account. The repeated sign-outs continue after fresh browser authentication.

    Trigger

    The user-visible trigger is opening an older task, switching to a new task, or opening a task that is still running. A message does not need to be sent. Task/thread navigation is followed by loss of the authenticated account and another browser sign-in prompt.

    Log evidence

    The macOS app logs contain 26 account_info_token_unavailable records across August 23–24: 17 on August 23 and 9 on August 24. These include retry bursts, so I am not claiming 26 distinct visible sign-outs.

    First clear failure on release 26.818.41509:

    2026-08-23T18:27:31.940Z  GET /wham/tasks/list -> 401; cached token present; retry attempted
    2026-08-23T18:27:32.119Z  auth_status: hasToken=false refreshToken=true tokenExpiryState=missing
    2026-08-23T18:27:32.307Z  account lookup: authenticatedAccountPresent=false; account_info_token_unavailable
    

    Strongest sequence on current release 26.818.61809:

    2026-08-24T20:48:33.145Z  account lookup: HTTP 401; account_info_token_unavailable
    2026-08-24T20:48:35.860Z  refresh failed: refresh token was revoked; HTTP 401; code=token_revoked
                                    server message: Encountered invalidated oauth token for user
    2026-08-24T20:48:46.948Z  account lookup: authenticatedAccountPresent=true; result=succeeded
    2026-08-24T20:49:06.772Z  account lookup: authenticatedAccountPresent=false; account_info_token_unavailable
    

    The freshly restored authenticated account therefore became unavailable again about 20 seconds after a successful account lookup. Additional transitions occurred later on the same build at 22:52:25Z, 23:15:20Z, and 23:39:24Z.

    The bundled codex doctor --json currently reports:

    • ChatGPT auth configured and stored tokens present
    • provider HTTP endpoints reachable
    • Responses WebSocket handshake succeeded with HTTP 101
    • direct system proxy / no proxy environment variables
    • desktop app-server handshake successful
    • state database integrity checks successful

    There is one installed com.openai.codex desktop application. No active VPN or macOS system proxy was present at collection time.

    This extends the social-login/account-security reproduction from Apple to Microsoft SSO. A password or 2FA change can reasonably revoke older sessions once, but it does not explain a newly authenticated desktop session becoming unavailable again 20 seconds later.

    I have not compared or disclosed token values, so I am not independently asserting the rotated-token persistence mechanism described in the issue body. I am confirming the same thread-navigation trigger, auth_token_missing state while refreshToken=true, explicit server-side token_revoked response, and rapid post-login recurrence on the newest build. No email address, account ID, tokens, cookies, device serial, task/thread IDs, or raw session transcripts are included.

  9. fabiocaseri commented on Aug 26, 2026

    @fabiocaseri

    I can confirm this issue on macOS with a very similar trigger and recovery path.

    Environment:

    • macOS 26.6.2 (Apple Silicon)
    • ChatGPT.app / Codex bundle version 26.820.60940 (build 7119)
    • auth mode: chatgpt

    Trigger/context:

    • The account had historically been used via Sign in with Google.
    • After hitting weekly usage limits, ChatGPT prompted me to improve account security.
    • I then added an account password and also added a passkey.
    • After that, Codex Desktop started signing me out whenever I opened/resumed an existing thread.
    • Codex CLI could resume the same thread without errors.

    Diagnostics:

    • The affected JSONL session files were syntactically valid.
    • session_index.jsonl was also valid.
    • Desktop logs showed thread/resume succeeding first (errorCode=null, followed by maybe_resume_success).
    • Immediately afterward, authenticated requests returned 401 and token refresh failed with refresh_token_invalidated.
    • A fresh OAuth login created a new ~/.codex/auth.json, but opening the thread still caused the same logout loop.

    A partial workaround did not fix it:

    • revoke Codex sessions server-side
    • fully quit ChatGPT.app
    • move ~/.codex/auth.json aside
    • relaunch and sign in

    What finally fixed it was the full sequence:

    1. Log out of all ChatGPT/OpenAI sessions so every browser tab is signed out.
    2. Sign back into ChatGPT on the web using the account's original social-login provider (Google in my case).
      • The account still required passkey verification as a second step; that was fine.
    3. Fully quit ChatGPT.app and confirm no ChatGPT processes are still running.
    4. Move ~/.codex/auth.json aside.
    5. Relaunch ChatGPT.app and complete the Codex sign-in flow.
    6. Open a previously failing thread.

    After this, thread resume stopped signing me out.

    There was also a clear observable change in token rotation behavior: before opening the thread, auth.json had a freshly issued refresh token with length 196; after opening/resuming the thread successfully, auth.json was rewritten, last_refresh advanced, and the refresh token changed to length 211. This matches the successful persistence pattern reported above.

    So in my case the important distinction was that clearing the Codex session + local auth.json was not sufficient by itself. A complete browser logout followed by a fresh web login through the original social provider was required before recreating the Desktop OAuth state.

    No account identifiers, token values, hashes, email addresses, or local personal paths are included here.

  10. MarcTCruz commented on Oct 13, 2026

    @MarcTCruz

    By running 8 codex exec processes in parallel on one CODEX_HOME, all 8 sent the same refresh token (rt-0) to the token endpoint. Codex leaves only the last writer's rotated pair in auth.json; under the fake authority's simulated strict-family policy, reuse revoked the family and the saved rt-1 failed a probe with refresh_token_invalidated. A later refresh needs sign-in again (inferred from code; this is not a claim about OpenAI's server policy). A concurrent reader also intermittently saw an empty auth.json during the in-place write.

    bash repro.sh 8 strict-family

    Why this matters for #40267

    Observed, lenient mode: each grant using rt-0 succeeded and returned a distinct pair, but only one pair remained in the file; the other issued pairs were discarded.

    Observed with simulated strict-family: one request using rt-0 issued rt-1; later reuse of retired rt-0 revoked the simulated family. Codex persisted rt-1, then the separate authority probe received refresh_token_invalidated. This optional mode simulates family revocation and does not assert the live authority's policy. With plain strict rotation plus reuse rejection and no family revocation, the other 7 requests were rejected while the winner's rt-1 remained in auth.json and usable; as observed, the 7 rejected codex exec processes printed none of the permanent refresh-failure messages to stdout/stderr. The damaging revoked-file case therefore needs the simulated strict-family policy, while lenient mode shows the other half: N−1 issued pairs discarded.

    Inferred from the source: each process has its own AuthManager refresh semaphore. A successful response is overlaid onto the then-current file and saved without checking that the file still contains the refresh token used for that request. The reproduction shows one way Codex can reach the same stale-token state described here; it does not establish the cause of the reported macOS desktop behavior.

    Empty reads during the in-place write

    Across the five lenient --observe rounds (N=16), a parallel reader saw an empty auth.json 0, 0, 1, 4, and 3 times (8 total), and never saw a partial file; valid-read counts are in the table. A later N=8 round saw none (empty=0, partial=0, valid=32422), so this is intermittent. codex-rs/login/src/auth/storage.rs:193-211 parses the file and returns an error for empty contents; codex-rs/login/src/auth/manager.rs:2149-2152,2178-2183 documents and implements swallowing auth-load errors into None, so a process loading the file at that instant has no cached auth and appears logged out (user-visible outcome inferred from code).

    Reproduction

    Tested with the installed codex-cli 0.162.0 on Linux aarch64, against source revision 97239fd. Requires Bash and Python 3 standard library only. It creates a fresh temporary CODEX_HOME, uses fake tokens, routes the token endpoint through CODEX_REFRESH_TOKEN_URL_OVERRIDE, and routes ChatGPT and model-provider traffic to 127.0.0.1. It needs no account and no network beyond 127.0.0.1.

    The config sets both chatgpt_base_url and openai_base_url: the former does not by itself override the built-in model provider's URL in this CLI. The authority delays each token response by 0.3 seconds by default so concurrent grants overlap; use --delay SECONDS to change it.

    Copy these two files into an empty directory; the command above reproduces the strict-family case. Add --observe to collect concurrent file reads:

    fake_authority.py
    #!/usr/bin/env python3
    """Loopback-only OAuth authority for the Codex refresh-race reproduction."""
    
    import argparse
    import datetime
    import http.server
    import json
    import threading
    import time
    from pathlib import Path
    from urllib.parse import parse_qs, urlsplit
    
    
    def jwt(exp):
        import base64
    
        def part(value):
            return base64.urlsafe_b64encode(
                json.dumps(value, separators=(",", ":")).encode()
            ).rstrip(b"=").decode()
    
        payload = {
            "exp": exp,
            "email": "x@example.com",
            "https://api.openai.com/auth": {
                "chatgpt_account_id": "acct-fake",
                "chatgpt_plan_type": "plus",
            },
        }
        return part({"alg": "none", "typ": "JWT"}) + "." + part(payload) + ".sig"
    
    
    class Authority(http.server.ThreadingHTTPServer):
        daemon_threads = True
        allow_reuse_address = True
    
        def __init__(self, address, mode, delay, log_path):
            super().__init__(address, Handler)
            self.mode = mode
            self.delay = delay
            self.log = open(log_path, "a", encoding="utf-8")
            self.lock = threading.Lock()
            self.next_id = 1
            self.valid = {"rt-0"}
            self.retired = set()
            self.family_revoked = False
    
        def grant(self, token, source):
            with self.lock:
                if self.mode == "lenient":
                    outcome, code, issued = "issued", None, self.new_token()
                elif self.family_revoked:
                    outcome, code, issued = "rejected", "refresh_token_invalidated", None
                elif token in self.retired:
                    outcome, code, issued = "rejected", "refresh_token_reused", None
                    if self.mode == "strict-family":
                        self.family_revoked = True
                elif token in self.valid:
                    self.valid.remove(token)
                    self.retired.add(token)
                    outcome, code, issued = "issued", None, self.new_token()
                else:
                    outcome, code, issued = "rejected", "refresh_token_invalidated", None
    
                event = {
                    "time": datetime.datetime.now(datetime.timezone.utc).isoformat(
                        timespec="milliseconds"
                    ),
                    "refresh_token": token,
                    "outcome": outcome if code is None else f"{outcome}:{code}",
                    "issued_refresh_token": issued,
                    "source": source,
                }
                self.log.write(json.dumps(event, sort_keys=True) + "\n")
                self.log.flush()
            time.sleep(self.delay)
            if code:
                return 401, {
                    "error": {
                        "message": "The refresh token was already used or invalidated.",
                        "type": "invalid_request_error",
                        "code": code,
                    }
                }
            now = int(time.time()) + 3600
            return 200, {
                "id_token": jwt(now),
                "access_token": jwt(now),
                "refresh_token": issued,
            }
    
        def new_token(self):
            token = f"rt-{self.next_id}"
            self.next_id += 1
            if self.mode != "lenient":
                self.valid.add(token)
            return token
    
    
    class Handler(http.server.BaseHTTPRequestHandler):
        def send_json(self, status, value):
            body = json.dumps(value).encode()
            self.send_response(status)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
    
        def do_POST(self):
            if urlsplit(self.path).path != "/oauth/token":
                self.send_json(500, {"error": "fake backend: no model service"})
                return
    
            raw = self.rfile.read(int(self.headers.get("Content-Length", "0")))
            try:
                data = json.loads(raw)
            except (json.JSONDecodeError, UnicodeDecodeError):
                data = {key: values[-1] for key, values in parse_qs(raw.decode()).items()}
            token = data.get("refresh_token", "")
            source = self.headers.get("X-Repro-Source", "codex")
            status, response = self.server.grant(token, source)
            self.send_json(status, response)
    
        do_GET = do_POST
    
        def log_message(self, *_):
            pass
    
    
    def main():
        parser = argparse.ArgumentParser(description=__doc__)
        parser.add_argument("--mode", choices=("lenient", "strict", "strict-family"), required=True)
        parser.add_argument("--delay", type=float, default=0.3)
        parser.add_argument("--log", required=True)
        parser.add_argument("--port-file", required=True)
        args = parser.parse_args()
    
        server = Authority(("127.0.0.1", 0), args.mode, args.delay, args.log)
        Path(args.port_file).write_text(str(server.server_port), encoding="ascii")
        server.serve_forever()
    
    
    if __name__ == "__main__":
        main()
    repro.sh
    #!/usr/bin/env bash
    set -euo pipefail
    
    usage() {
      echo "Usage: bash repro.sh N lenient|strict|strict-family [--observe] [--delay SECONDS]" >&2
      exit 2
    }
    
    [[ $# -ge 2 ]] || usage
    N=$1
    MODE=$2
    shift 2
    [[ $N =~ ^[1-9][0-9]*$ ]] || usage
    [[ $MODE == lenient || $MODE == strict || $MODE == strict-family ]] || usage
    OBSERVE=0
    DELAY=0.3
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --observe) OBSERVE=1; shift ;;
        --delay) [[ $# -ge 2 ]] || usage; DELAY=$2; shift 2 ;;
        *) usage ;;
      esac
    done
    
    REPRO_ROOT=$(cd -- "$(dirname -- "$0")" && pwd)
    RESULT_ROOT="$REPRO_ROOT/results"
    mkdir -p "$RESULT_ROOT"
    RUN_ID="$(date -u +%Y%m%dT%H%M%S)-$$-$N-$MODE"
    [[ $OBSERVE == 0 ]] || RUN_ID="$RUN_ID-observe"
    RUN_DIR="$RESULT_ROOT/$RUN_ID"
    mkdir "$RUN_DIR"
    TMP_ROOT=$(mktemp -d "$REPRO_ROOT/tmp.XXXXXX")
    CODEX_HOME="$TMP_ROOT/codex-home"
    RUN_HOME="$TMP_ROOT/home"
    
    # The only home Codex receives is a child of this fresh mktemp directory.
    case "$CODEX_HOME" in
      "$TMP_ROOT"/*) ;;
      *) echo "Refusing CODEX_HOME outside mktemp directory: $CODEX_HOME" >&2; exit 2 ;;
    esac
    
    CODEX_BIN=$(command -v codex || true)
    [[ -n $CODEX_BIN ]] || { echo "codex not found on PATH" >&2; exit 2; }
    mkdir -p "$CODEX_HOME" "$RUN_HOME" "$TMP_ROOT/workspace" "$TMP_ROOT/tmp"
    PORT_FILE="$TMP_ROOT/port"
    STOP_FILE="$TMP_ROOT/observer.stop"
    SERVER_PID=
    OBSERVER_PID=
    
    cleanup() {
      if [[ -n $OBSERVER_PID ]]; then
        touch "$STOP_FILE"
        kill "$OBSERVER_PID" 2>/dev/null || true
        wait "$OBSERVER_PID" 2>/dev/null || true
      fi
      if [[ -n $SERVER_PID ]]; then
        kill "$SERVER_PID" 2>/dev/null || true
        wait "$SERVER_PID" 2>/dev/null || true
      fi
    }
    trap cleanup EXIT
    
    python3 "$REPRO_ROOT/fake_authority.py" \
      --mode "$MODE" --delay "$DELAY" \
      --log "$RUN_DIR/authority.jsonl" --port-file "$PORT_FILE" \
      >"$RUN_DIR/authority.stderr" 2>&1 &
    SERVER_PID=$!
    for _ in {1..100}; do
      [[ -s $PORT_FILE ]] && break
      kill -0 "$SERVER_PID" 2>/dev/null || {
        cat "$RUN_DIR/authority.stderr" >&2
        exit 1
      }
      sleep 0.05
    done
    [[ -s $PORT_FILE ]] || { echo "fake authority did not start" >&2; exit 1; }
    PORT=$(cat "$PORT_FILE")
    TOKEN_URL="http://127.0.0.1:$PORT/oauth/token"
    
    python3 - "$CODEX_HOME/auth.json" <<'PY'
    import base64, json, os, sys, time
    
    def jwt(exp):
        def part(value):
            raw = json.dumps(value, separators=(",", ":")).encode()
            return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
        payload = {
            "exp": exp,
            "email": "x@example.com",
            "https://api.openai.com/auth": {
                "chatgpt_account_id": "acct-fake",
                "chatgpt_plan_type": "plus",
            },
        }
        return part({"alg": "none", "typ": "JWT"}) + "." + part(payload) + ".sig"
    
    now = int(time.time())
    auth = {
        "auth_mode": "chatgpt",
        "OPENAI_API_KEY": None,
        "tokens": {
            "id_token": jwt(now - 60),
            "access_token": jwt(now - 60),
            "refresh_token": "rt-0",
            "account_id": "acct-fake",
        },
        "last_refresh": "2026-10-01T00:00:00Z",
    }
    fd = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, "w") as f:
        json.dump(auth, f)
    PY
    cat >"$CODEX_HOME/config.toml" <<EOF
    chatgpt_base_url = "http://127.0.0.1:$PORT/backend-api/"
    openai_base_url = "http://127.0.0.1:$PORT/backend-api/codex"
    EOF
    
    if [[ $OBSERVE == 1 ]]; then
      python3 - "$CODEX_HOME/auth.json" "$STOP_FILE" "$RUN_DIR/observer.json" <<'PY' &
    import json, pathlib, sys, time
    
    auth, stop, output = map(pathlib.Path, sys.argv[1:])
    counts = {"empty": 0, "partial": 0, "valid": 0}
    while not stop.exists():
        try:
            data = auth.read_bytes()
            if not data:
                counts["empty"] += 1
            else:
                try:
                    json.loads(data)
                    counts["valid"] += 1
                except (json.JSONDecodeError, UnicodeDecodeError):
                    counts["partial"] += 1
        except FileNotFoundError:
            counts["partial"] += 1
        time.sleep(0.0005)
    output.write_text(json.dumps(counts, sort_keys=True) + "\n")
    PY
      OBSERVER_PID=$!
    fi
    
    echo "Run: $RUN_ID"
    echo "Raw outputs: $RUN_DIR"
    echo "Starting $N codex exec processes in isolated CODEX_HOME; authority delay=$DELAY""s"
    PIDS=
    for i in $(seq 1 "$N"); do
      (
        set +e
        cd "$TMP_ROOT/workspace"
        env -i PATH="$PATH" HOME="$RUN_HOME" CODEX_HOME="$CODEX_HOME" TMPDIR="$TMP_ROOT/tmp" \
          CODEX_REFRESH_TOKEN_URL_OVERRIDE="$TOKEN_URL" \
          "$CODEX_BIN" exec --skip-git-repo-check "say hi" \
          </dev/null >"$RUN_DIR/proc-$i.out" 2>&1
        status=$?
        printf '%s\n' "$status" >"$RUN_DIR/proc-$i.exit"
        exit 0
      ) &
      PIDS="$PIDS $!"
    done
    for pid in $PIDS; do
      wait "$pid"
    done
    
    if [[ $MODE == strict || $MODE == strict-family ]]; then
      python3 - "$CODEX_HOME/auth.json" "$TOKEN_URL" "$RUN_DIR/usability_probe.json" <<'PY'
    import json, pathlib, sys, urllib.error, urllib.request
    
    auth_path, result_path = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[3])
    url = sys.argv[2]
    token = json.loads(auth_path.read_text())["tokens"]["refresh_token"]
    body = json.dumps({"grant_type": "refresh_token", "refresh_token": token}).encode()
    request = urllib.request.Request(
        str(url), data=body, method="POST",
        headers={"Content-Type": "application/json", "X-Repro-Source": "usability-probe"},
    )
    try:
        with urllib.request.urlopen(request, timeout=10) as response:
            result = {"refresh_token": token, "usable": response.status == 200, "outcome": "issued"}
    except urllib.error.HTTPError as error:
        detail = json.loads(error.read())
        code = detail.get("error", {}).get("code", "unknown")
        result = {"refresh_token": token, "usable": False, "outcome": f"rejected:{code}"}
    result_path.write_text(json.dumps(result, sort_keys=True) + "\n")
    PY
    fi
    
    if [[ -n $OBSERVER_PID ]]; then
      touch "$STOP_FILE"
      wait "$OBSERVER_PID"
      OBSERVER_PID=
    fi
    kill "$SERVER_PID"
    wait "$SERVER_PID" 2>/dev/null || true
    SERVER_PID=
    
    python3 - "$RUN_DIR" "$CODEX_HOME/auth.json" "$N" "$MODE" "$OBSERVE" "$DELAY" "$CODEX_BIN" "$RUN_HOME" "$CODEX_HOME" "$TMP_ROOT/tmp" <<'PY' >"$RUN_DIR/summary.txt"
    import collections, json, os, pathlib, stat, subprocess, sys
    
    run_dir, auth_path = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
    n, mode, observe, delay, codex, home, codex_home, tmpdir = sys.argv[3:]
    events = [json.loads(line) for line in (run_dir / "authority.jsonl").read_text().splitlines()]
    codex_events = [event for event in events if event["source"] == "codex"]
    presented = collections.Counter(event["refresh_token"] for event in codex_events)
    outcomes = collections.Counter(event["outcome"] for event in codex_events)
    version = subprocess.run(
        [codex, "--version"], text=True, capture_output=True,
        env={"PATH": os.environ["PATH"], "HOME": home, "CODEX_HOME": codex_home, "TMPDIR": tmpdir},
    ).stdout.strip()
    print(f"codex={version}; mode={mode}; N={n}; delay={delay}s")
    print(f"Codex grants={len(codex_events)}; presented=" +
          (", ".join(f"{count} x {token}" for token, count in sorted(presented.items())) or "none"))
    print("Codex authority outcomes=" +
          (", ".join(f"{key}: {value}" for key, value in sorted(outcomes.items())) or "none"))
    try:
        auth = json.loads(auth_path.read_text())
        final_token = auth["tokens"]["refresh_token"]
        mode_bits = stat.S_IMODE(auth_path.stat().st_mode)
        print(f"Final auth.json=parseable; refresh_token={final_token}; mode={mode_bits:04o}")
    except Exception as error:
        print(f"Final auth.json=parse error: {type(error).__name__}: {error}")
    
    probe_path = run_dir / "usability_probe.json"
    if probe_path.exists():
        probe = json.loads(probe_path.read_text())
        print(f"Final token usability (one script probe, not Codex): {probe['outcome']}")
    else:
        print("Final token usability: not probed (lenient mode)")
    
    permanent = (
        "Your access token could not be refreshed because your refresh token has expired.",
        "Your access token could not be refreshed because your refresh token was already used.",
        "Your access token could not be refreshed because your refresh token was revoked.",
        "Your access token could not be refreshed. Please log out and sign in again.",
    )
    for i in range(1, int(n) + 1):
        output = (run_dir / f"proc-{i}.out").read_text(errors="replace")
        code = (run_dir / f"proc-{i}.exit").read_text().strip()
        matches = [line.strip() for line in output.splitlines()
                   if any(message in line for message in permanent)]
        print(f"proc-{i:02d}: exit={code}; refresh_error=" +
              (" | ".join(matches) if matches else "none in stdout/stderr"))
    if observe == "1":
        counts = json.loads((run_dir / "observer.json").read_text())
        print("Observer reads: " +
              ", ".join(f"{key}={counts[key]}" for key in ("empty", "partial", "valid")))
    print(f"Saved raw outputs and summary: {run_dir}")
    PY
    
    cat "$RUN_DIR/summary.txt"
    rm -rf "$TMP_ROOT"

    Results

    Every row used the installed codex-cli 0.162.0 on Linux aarch64 and the source checkout at 97239fd. The observer column is empty/partial/valid reads. The strict and strict-family usability probes are each one extra authority request, excluded from Codex grant counts. All Codex processes exited 1 on the fake backend; no permanent refresh-error line appeared in captured stdout/stderr. In the three --observe rounds with only 5, 7, and 6 grants of 16, saved outputs do not record what the other processes loaded, so the cause is unverified; a likely explanation is that later starters loaded an already-refreshed, unexpired access token and skipped refresh (inferred).

    Round N Mode Codex grants (presented token) Authority outcomes Final file token / probe Process outcomes Observer E/P/V
    1 8 lenient 8: 8 x rt-0 8 issued rt-7; not probed exit 1 all; refresh line none —
    2 8 lenient 8: 8 x rt-0 8 issued rt-7; not probed exit 1 all; refresh line none —
    3 8 lenient 8: 8 x rt-0 8 issued rt-8; not probed exit 1 all; refresh line none —
    1 16 lenient 16: 16 x rt-0 16 issued rt-16; not probed exit 1 all; refresh line none —
    2 16 lenient 16: 16 x rt-0 16 issued rt-16; not probed exit 1 all; refresh line none —
    3 16 lenient 16: 16 x rt-0 16 issued rt-16; not probed exit 1 all; refresh line none —
    1 8 strict 8: 8 x rt-0 1 issued, 7 reused rt-1; probe issued exit 1 all; refresh line none —
    2 8 strict 8: 8 x rt-0 1 issued, 7 reused rt-1; probe issued exit 1 all; refresh line none —
    3 8 strict 8: 8 x rt-0 1 issued, 7 reused rt-1; probe issued exit 1 all; refresh line none —
    1 16 lenient --observe 5: 5 x rt-0 5 issued rt-5; not probed exit 1 all; refresh line none 0/0/26977
    2 16 lenient --observe 16: 16 x rt-0 16 issued rt-16; not probed exit 1 all; refresh line none 0/0/31030
    3 16 lenient --observe 16: 16 x rt-0 16 issued rt-16; not probed exit 1 all; refresh line none 1/0/23809
    4 16 lenient --observe 7: 7 x rt-0 7 issued rt-7; not probed exit 1 all; refresh line none 4/0/21870
    5 16 lenient --observe 6: 6 x rt-0 6 issued rt-6; not probed exit 1 all; refresh line none 3/0/29608
    1 8 strict-family 8: 8 x rt-0 1 issued, 6 invalidated, 1 reused rt-1; probe rejected:refresh_token_invalidated exit 1 all; refresh line none —
    2 8 strict-family 8: 8 x rt-0 1 issued, 6 invalidated, 1 reused rt-1; probe rejected:refresh_token_invalidated exit 1 all; refresh line none —
    3 8 strict-family 8: 8 x rt-0 1 issued, 6 invalidated, 1 reused rt-1; probe rejected:refresh_token_invalidated exit 1 all; refresh line none —

    Evidence in the current code

    • codex-rs/login/src/auth/manager.rs:214 defines CODEX_REFRESH_TOKEN_URL_OVERRIDE; manager.rs:1727-1729 uses it for the refresh endpoint.
    • codex-rs/login/src/oauth/error.rs:121-151 parses the nested error.code. manager.rs:1655-1667,1680-1707 treats HTTP 401 and refresh_token_reused / refresh_token_invalidated as permanent failures and selects the corresponding sign-in-again message.
    • manager.rs:2057,2214,2845-2878 shows the refresh semaphore belongs to one AuthManager; it cannot coordinate separate processes.
    • manager.rs:1600-1622,3087-3105 reloads auth after the response, overlays the returned token fields, saves, and reloads the cache without checking that the stored refresh token still matches the grant.
    • codex-rs/login/src/auth/storage.rs:193-211 parses the file; storage.rs:214-230 opens it with truncate(true), then writes and flushes in place.
    • codex-rs/login/src/gateway_auth_storage.rs:19-49 has a stable sidecar File::try_lock() pattern. codex-rs/secrets/src/local.rs:301-377 has a temp-file, sync_all, and rename helper; its Windows fallback removes the destination before rename (local.rs:345-365), so auth storage needs replacement semantics that preserve the old file until the new file is ready.
    • For endpoint isolation, codex-rs/config/src/config_toml.rs:415-416 defines chatgpt_base_url; codex-rs/core/src/config/mod.rs:3864-3876 applies openai_base_url to the built-in provider.

    Expected behavior and proposed fix

    • Hold a stable cross-process sidecar lock over re-read, refresh grant, and durable save, following the existing File::try_lock() pattern.
    • After acquiring the lock, re-read the auth store. If another process already rotated the credentials, adopt them and skip the stale grant.
    • Save through a same-directory temporary file with mode 0600, write_all, sync_all, and an atomic replacement; use the same lock for logout and other persistent auth writes.
    • Keep the lock file stable across logout so a refresh cannot recreate credentials after deletion.

    Regression test

    These files can become a regression test: launch independent workers against the same fake authority and CODEX_HOME, then assert one grant per stored refresh token and that the final file contains the authority's current token.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appauthIssues related to authentication and accountsbugSomething isn't workingsessionIssues involving session (thread) management, resuming, forking, naming, archiving

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions