From 95fa78eb6c9ca364034356e54d1b050b9eee83fe Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 02:05:20 -0700 Subject: [PATCH] fix: write refreshed Codex tokens back to ~/.codex/auth.json (#8277) OpenAI OAuth refresh tokens are single-use and rotate on every refresh. When Hermes refreshes a Codex token, it consumed the old refresh_token but never wrote the new pair back to ~/.codex/auth.json. This caused Codex CLI and VS Code to fail with 'refresh_token_reused' on their next refresh attempt. This mirrors the existing Anthropic write-back pattern where refreshed tokens are written to ~/.claude/.credentials.json via _write_claude_code_credentials(). Changes: - Add _write_codex_cli_tokens() in hermes_cli/auth.py (parallel to _write_claude_code_credentials in anthropic_adapter.py) - Call it from _refresh_codex_auth_tokens() (non-pool refresh path) - Call it from credential_pool._refresh_entry() (pool happy path + retry) - Add tests for the new write-back behavior - Update existing test docstring to clarify _save_codex_tokens vs _write_codex_cli_tokens separation Fixes refresh token conflict reported by @ec12edfae2cb221 --- agent/credential_pool.py | 20 ++++ hermes_cli/auth.py | 49 ++++++++++ tests/hermes_cli/test_auth_codex_provider.py | 97 +++++++++++++++++++- 3 files changed, 164 insertions(+), 2 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 07afa96099..e067fb9014 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -24,6 +24,7 @@ from hermes_cli.auth import ( _codex_access_token_is_expiring, _decode_jwt_claims, _import_codex_cli_tokens, + _write_codex_cli_tokens, _load_auth_store, _load_provider_state, _resolve_kimi_base_url, @@ -693,6 +694,14 @@ class CredentialPool: self._replace_entry(synced, updated) self._persist() self._sync_device_code_entry_to_auth_store(updated) + try: + _write_codex_cli_tokens( + updated.access_token, + updated.refresh_token, + last_refresh=updated.last_refresh, + ) + except Exception as wexc: + logger.debug("Failed to write refreshed Codex tokens to CLI file (retry): %s", wexc) return updated except Exception as retry_exc: logger.debug("Codex retry refresh also failed: %s", retry_exc) @@ -718,6 +727,17 @@ class CredentialPool: # _seed_from_singletons() on the next load_pool() sees fresh state # instead of re-seeding stale/consumed tokens. self._sync_device_code_entry_to_auth_store(updated) + # Write refreshed tokens back to ~/.codex/auth.json so Codex CLI + # and VS Code don't hit "refresh_token_reused" on their next refresh. + if self.provider == "openai-codex": + try: + _write_codex_cli_tokens( + updated.access_token, + updated.refresh_token, + last_refresh=updated.last_refresh, + ) + except Exception as wexc: + logger.debug("Failed to write refreshed Codex tokens to CLI file: %s", wexc) return updated def _entry_needs_refresh(self, entry: PooledCredential) -> bool: diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 56b9fb63c2..04a7d0c137 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1303,6 +1303,49 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: } +def _write_codex_cli_tokens( + access_token: str, + refresh_token: str, + *, + last_refresh: Optional[str] = None, +) -> None: + """Write refreshed tokens back to ~/.codex/auth.json. + + OpenAI OAuth refresh tokens are single-use and rotate on every refresh. + When Hermes refreshes a token it consumes the old refresh_token; if we + don't write the new pair back, the Codex CLI (or VS Code extension) will + fail with ``refresh_token_reused`` on its next refresh attempt. + + This mirrors the Anthropic write-back to ~/.claude/.credentials.json + via ``_write_claude_code_credentials()``. + """ + codex_home = os.getenv("CODEX_HOME", "").strip() + if not codex_home: + codex_home = str(Path.home() / ".codex") + auth_path = Path(codex_home).expanduser() / "auth.json" + try: + existing: Dict[str, Any] = {} + if auth_path.is_file(): + existing = json.loads(auth_path.read_text(encoding="utf-8")) + if not isinstance(existing, dict): + existing = {} + + tokens_dict = existing.get("tokens") + if not isinstance(tokens_dict, dict): + tokens_dict = {} + tokens_dict["access_token"] = access_token + tokens_dict["refresh_token"] = refresh_token + existing["tokens"] = tokens_dict + if last_refresh is not None: + existing["last_refresh"] = last_refresh + + auth_path.parent.mkdir(parents=True, exist_ok=True) + auth_path.write_text(json.dumps(existing, indent=2), encoding="utf-8") + auth_path.chmod(0o600) + except (OSError, IOError) as exc: + logger.debug("Failed to write refreshed tokens to %s: %s", auth_path, exc) + + def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None: """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json).""" if last_refresh is None: @@ -1425,6 +1468,12 @@ def _refresh_codex_auth_tokens( updated_tokens["refresh_token"] = refreshed["refresh_token"] _save_codex_tokens(updated_tokens) + # Write back to ~/.codex/auth.json so Codex CLI / VS Code stay in sync. + _write_codex_cli_tokens( + refreshed["access_token"], + refreshed["refresh_token"], + last_refresh=refreshed.get("last_refresh"), + ) return updated_tokens diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index 4119126e66..f05a80b6ac 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -14,6 +14,7 @@ from hermes_cli.auth import ( PROVIDER_REGISTRY, _read_codex_tokens, _save_codex_tokens, + _write_codex_cli_tokens, _import_codex_cli_tokens, get_codex_auth_status, get_provider_auth_state, @@ -161,7 +162,7 @@ def test_import_codex_cli_tokens_missing(tmp_path, monkeypatch): def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch): - """Verify Hermes never writes to ~/.codex/auth.json.""" + """Verify _save_codex_tokens writes only to Hermes auth store, not ~/.codex/.""" hermes_home = tmp_path / "hermes" codex_home = tmp_path / "codex-cli" hermes_home.mkdir(parents=True, exist_ok=True) @@ -173,7 +174,7 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch): _save_codex_tokens({"access_token": "hermes-at", "refresh_token": "hermes-rt"}) - # ~/.codex/auth.json should NOT exist + # ~/.codex/auth.json should NOT exist — _save_codex_tokens only touches Hermes store assert not (codex_home / "auth.json").exists() # Hermes auth store should have the tokens @@ -181,6 +182,98 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch): assert data["tokens"]["access_token"] == "hermes-at" +def test_write_codex_cli_tokens_creates_file(tmp_path, monkeypatch): + """_write_codex_cli_tokens creates ~/.codex/auth.json with refreshed tokens.""" + codex_home = tmp_path / "codex-cli" + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + _write_codex_cli_tokens("new-access", "new-refresh", last_refresh="2026-04-12T00:00:00Z") + + auth_path = codex_home / "auth.json" + assert auth_path.exists() + data = json.loads(auth_path.read_text()) + assert data["tokens"]["access_token"] == "new-access" + assert data["tokens"]["refresh_token"] == "new-refresh" + assert data["last_refresh"] == "2026-04-12T00:00:00Z" + # Verify file permissions are restricted + assert (auth_path.stat().st_mode & 0o777) == 0o600 + + +def test_write_codex_cli_tokens_preserves_existing(tmp_path, monkeypatch): + """_write_codex_cli_tokens preserves extra fields in existing auth.json.""" + codex_home = tmp_path / "codex-cli" + codex_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + existing = { + "tokens": { + "access_token": "old-access", + "refresh_token": "old-refresh", + "extra_field": "preserved", + }, + "last_refresh": "2026-01-01T00:00:00Z", + "custom_key": "keep_me", + } + (codex_home / "auth.json").write_text(json.dumps(existing)) + + _write_codex_cli_tokens("updated-access", "updated-refresh") + + data = json.loads((codex_home / "auth.json").read_text()) + assert data["tokens"]["access_token"] == "updated-access" + assert data["tokens"]["refresh_token"] == "updated-refresh" + assert data["tokens"]["extra_field"] == "preserved" + assert data["custom_key"] == "keep_me" + # last_refresh not updated since we didn't pass it + assert data["last_refresh"] == "2026-01-01T00:00:00Z" + + +def test_write_codex_cli_tokens_handles_missing_dir(tmp_path, monkeypatch): + """_write_codex_cli_tokens creates parent directories if missing.""" + codex_home = tmp_path / "does" / "not" / "exist" + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + _write_codex_cli_tokens("at", "rt") + + assert (codex_home / "auth.json").exists() + data = json.loads((codex_home / "auth.json").read_text()) + assert data["tokens"]["access_token"] == "at" + + +def test_refresh_codex_auth_tokens_writes_back_to_cli(tmp_path, monkeypatch): + """After refreshing, _refresh_codex_auth_tokens writes back to ~/.codex/auth.json.""" + from hermes_cli.auth import _refresh_codex_auth_tokens + + hermes_home = tmp_path / "hermes" + codex_home = tmp_path / "codex-cli" + hermes_home.mkdir(parents=True, exist_ok=True) + codex_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + # Write initial CLI tokens + (codex_home / "auth.json").write_text(json.dumps({ + "tokens": {"access_token": "old-at", "refresh_token": "old-rt"}, + })) + + # Mock the pure refresh to return new tokens + monkeypatch.setattr("hermes_cli.auth.refresh_codex_oauth_pure", lambda *a, **kw: { + "access_token": "refreshed-at", + "refresh_token": "refreshed-rt", + "last_refresh": "2026-04-12T01:00:00Z", + }) + + _refresh_codex_auth_tokens( + {"access_token": "old-at", "refresh_token": "old-rt"}, + timeout_seconds=10, + ) + + # Verify CLI file was updated + cli_data = json.loads((codex_home / "auth.json").read_text()) + assert cli_data["tokens"]["access_token"] == "refreshed-at" + assert cli_data["tokens"]["refresh_token"] == "refreshed-rt" + + def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" _setup_hermes_auth(hermes_home)