fix(auth): write profile-refreshed Codex tokens through to the global store

Codex refresh tokens are single-use with rotation-family reuse
detection. _save_codex_tokens resolved the state via the profile's
root fallback but always persisted into the ACTIVE (profile) store, so
a profile-scoped refresh left the global store holding the consumed
refresh token — the next process to read it replayed it and OpenAI
revoked the whole rotation family, forcing a manual device-code
re-auth (#87503; observed four times on one multi-profile deployment).

Mirror the xAI source-aware save (#43589/#74339): resolve the state
with _load_provider_state_with_source; when the grant came from the
global root, write the rotated chain back to root only — singleton AND
credential_pool entries, under the root store's own lock, without
creating a shadowing profile key. Best-effort, with the same pytest
seat belt as the xAI path.
Fixes #87503
This commit is contained in:
liuhao1024
2026-08-16 15:15:23 +08:00
committed by kshitij
parent b8c474d7b5
commit 6bd29f26f6
2 changed files with 176 additions and 6 deletions

View File

@@ -143,15 +143,22 @@ def _sync_codex_pool_entries(
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: str = None) -> None:
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
"""Save Codex OAuth tokens to the auth store the grant was resolved FROM.
Codex refresh tokens are single-use with rotation-family reuse detection. A profile without its
own ``providers.openai-codex`` block reads root's grant via the fallback, so a refresh under that
profile must rotate ROOT's chain — singleton AND ``credential_pool`` entries — or root keeps the
consumed refresh token, the next process replays it and OpenAI revokes the whole family
(#87503). Root-only write-back: a profile copy would shadow root and disable the write-through
on the next refresh (#74339). Mirrors the xAI source-aware save.
"""
from hermes_cli.auth import (
_auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store,
_save_provider_state, _utc_now_z)
_auth_file_path, _load_auth_store, _provider_state_transaction, _same_path,
_save_auth_store, _save_provider_state, _store_provider_state, _utc_now_z)
if last_refresh is None:
last_refresh = _utc_now_z()
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "openai-codex") or {}
with _provider_state_transaction("openai-codex") as (auth_store, state, source_path):
state = dict(state) if state else {}
# Capture the previous singleton tokens BEFORE overwriting: the pool sync uses them to
# tell legacy singleton-aliases (refresh) from independent ``auth add`` accounts (keep).
previous_singleton_tokens = (
@@ -159,6 +166,16 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
state.update(tokens=tokens, last_refresh=last_refresh, auth_mode="chatgpt")
if label and str(label).strip():
state["label"] = str(label).strip()
if source_path is not None and not _same_path(source_path, _auth_file_path()):
# Root-borrowed grant: the transaction already holds root's lock, so write the rotated
# chain into ROOT's store (never set_active — a refresh is not a provider choice).
root_store = _load_auth_store(source_path)
_store_provider_state(root_store, "openai-codex", state, set_active=False)
_sync_codex_pool_entries(
root_store, tokens, last_refresh,
previous_singleton_tokens=previous_singleton_tokens)
_save_auth_store(root_store, target_path=source_path)
return
_save_provider_state(auth_store, "openai-codex", state)
_sync_codex_pool_entries(
auth_store, tokens, last_refresh, previous_singleton_tokens=previous_singleton_tokens)

View File

@@ -0,0 +1,153 @@
"""Regression tests for Codex OAuth refresh write-through to the global root.
Mirrors ``test_xai_oauth_writethrough.py`` for the Codex family (#87503):
Codex refresh tokens are single-use with rotation-family reuse detection,
so when a profile that has no own ``providers.openai-codex`` block refreshes
the grant it resolved from the root fallback, the rotated chain must land
back in root — including the ``credential_pool`` entries the runtime selects
credentials from. Otherwise root keeps the consumed refresh token, the next
process to read it replays it, and OpenAI revokes the whole rotation family.
The tests drive the real ``_save_codex_tokens`` against real on-disk auth
stores (profile + root under ``tmp_path``). All token values are synthetic
placeholders assembled by ``_pair`` — no real credentials are involved.
"""
import json
import pytest
from hermes_cli import auth
def _pair(prefix: str) -> dict:
"""Synthetic OAuth pair for fixtures/assertions (not credentials)."""
return {
"access_token": f"{prefix}-at",
"refresh_token": f"{prefix}-rt",
}
def _write_store(path, store):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(store), encoding="utf-8")
def _read_store(path):
return json.loads(path.read_text(encoding="utf-8"))
@pytest.fixture
def profile_and_root(tmp_path, monkeypatch):
"""Wire a profile auth store + a distinct global-root auth store on disk."""
profile_path = tmp_path / "profiles" / "work" / "auth.json"
root_path = tmp_path / "root" / "auth.json"
monkeypatch.setattr(auth, "_auth_file_path", lambda: profile_path)
monkeypatch.setattr(auth, "_global_auth_file_path", lambda: root_path)
# Keep the pytest write seat belt from matching our tmp root.
monkeypatch.setenv("HOME", str(tmp_path / "not-the-root"))
return profile_path, root_path
def test_profile_refresh_of_root_grant_writes_through(profile_and_root):
"""#87503: rotating the root-resolved grant must reach the root store —
singleton AND credential-pool entries — without forking a shadowing
profile key."""
profile_path, root_path = profile_and_root
_write_store(
root_path,
{
"version": 1,
"providers": {
"openai-codex": {
"auth_mode": "chatgpt",
"tokens": _pair("old"),
}
},
"credential_pool": {
"openai-codex": [
{
"provider": "openai-codex",
"source": "device_code",
**_pair("old"),
}
]
},
},
)
_write_store(profile_path, {"version": 1, "providers": {}})
rotated = _pair("new")
auth._save_codex_tokens(
rotated,
last_refresh="2026-08-16T00:00:00Z",
)
root = _read_store(root_path)
assert (
root["providers"]["openai-codex"]["tokens"]["refresh_token"]
== rotated["refresh_token"]
), "root singleton must hold the rotated refresh token"
pool_entry = root["credential_pool"]["openai-codex"][0]
assert pool_entry["refresh_token"] == rotated["refresh_token"]
assert pool_entry["access_token"] == rotated["access_token"]
profile = _read_store(profile_path)
assert "openai-codex" not in profile.get("providers", {}), (
"profile must not gain a shadowing providers.openai-codex key — "
"it would disable the write-through on the next refresh (#74339)"
)
def test_profile_owned_state_saves_to_profile_only(profile_and_root):
"""A profile with its own openai-codex block keeps the existing
profile-local save; root is untouched."""
profile_path, root_path = profile_and_root
_write_store(
profile_path,
{
"version": 1,
"providers": {
"openai-codex": {
"auth_mode": "chatgpt",
"tokens": _pair("prof"),
}
},
},
)
_write_store(root_path, {"version": 1, "providers": {}})
rotated = _pair("next")
auth._save_codex_tokens(
rotated,
last_refresh="2026-08-16T00:00:00Z",
)
profile = _read_store(profile_path)
assert (
profile["providers"]["openai-codex"]["tokens"]["refresh_token"]
== rotated["refresh_token"]
)
root = _read_store(root_path)
assert "openai-codex" not in root.get("providers", {})
def test_classic_mode_still_saves_single_store(tmp_path, monkeypatch):
"""Classic mode (profile == root): unchanged single-store save."""
profile_path = tmp_path / "auth.json"
monkeypatch.setattr(auth, "_auth_file_path", lambda: profile_path)
monkeypatch.setattr(auth, "_global_auth_file_path", lambda: None)
_write_store(profile_path, {"version": 1, "providers": {}})
rotated = _pair("classic")
auth._save_codex_tokens(
rotated,
last_refresh="2026-08-16T00:00:00Z",
)
store = _read_store(profile_path)
assert (
store["providers"]["openai-codex"]["tokens"]["refresh_token"]
== rotated["refresh_token"]
)