diff --git a/hermes_cli/auth_codex.py b/hermes_cli/auth_codex.py index 33afc236e1..748c232777 100644 --- a/hermes_cli/auth_codex.py +++ b/hermes_cli/auth_codex.py @@ -142,7 +142,9 @@ def _sync_codex_pool_entries( _clear_pool_entry_status(entry) -def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: str = None) -> None: +def _save_codex_tokens( + tokens: Dict[str, str], last_refresh: str = None, label: str = None, *, write_through: bool = False, +) -> None: """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 @@ -151,10 +153,13 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: 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. + + Only a token REFRESH passes ``write_through=True``: a fresh login or import under a profile + is the profile's own grant and must not overwrite the root account it was borrowing. """ from hermes_cli.auth import ( _auth_file_path, _load_auth_store, _provider_state_transaction, _same_path, - _save_auth_store, _save_provider_state, _store_provider_state, _utc_now_z) + _save_auth_store, _store_provider_state, _utc_now_z) if last_refresh is None: last_refresh = _utc_now_z() with _provider_state_transaction("openai-codex") as (auth_store, state, source_path): @@ -166,20 +171,15 @@ 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()): + target_store, target_path, set_active = auth_store, None, True + if write_through and 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) + target_store, target_path, set_active = _load_auth_store(source_path), source_path, False + _store_provider_state(target_store, "openai-codex", state, set_active=set_active) _sync_codex_pool_entries( - auth_store, tokens, last_refresh, previous_singleton_tokens=previous_singleton_tokens) - _save_auth_store(auth_store) + target_store, tokens, last_refresh, previous_singleton_tokens=previous_singleton_tokens) + _save_auth_store(target_store, target_path=target_path) def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]: @@ -396,7 +396,7 @@ def _refresh_codex_auth_tokens(tokens: Dict[str, str], timeout_seconds: float) - updated_tokens = { **tokens, "access_token": refreshed["access_token"], "refresh_token": refreshed["refresh_token"]} - _save_codex_tokens(updated_tokens) + _save_codex_tokens(updated_tokens, write_through=True) return updated_tokens diff --git a/tests/hermes_cli/test_codex_token_writethrough.py b/tests/hermes_cli/test_codex_token_writethrough.py index c9c4bf6d21..6c06581553 100644 --- a/tests/hermes_cli/test_codex_token_writethrough.py +++ b/tests/hermes_cli/test_codex_token_writethrough.py @@ -31,6 +31,7 @@ def _read(path: Path) -> dict: def profile_env(tmp_path, monkeypatch): """Global root at tmp/.hermes, active profile at tmp/.hermes/profiles/work (real on-disk layout).""" monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) # Windows resolves the native root from here root = tmp_path / ".hermes" profile = root / "profiles" / "work" profile.mkdir(parents=True) @@ -49,7 +50,7 @@ def test_profile_refresh_of_root_grant_writes_through_to_root(profile_env): _write(profile_path, {"version": 1, "providers": {}}) rotated = _pair("new") - auth._save_codex_tokens(rotated, last_refresh="2026-08-16T00:00:00Z") + auth._save_codex_tokens(rotated, last_refresh="2026-08-16T00:00:00Z", write_through=True) root = _read(root_path) assert root["providers"]["openai-codex"]["tokens"] == rotated @@ -68,7 +69,15 @@ def test_profile_owned_grant_stays_local(profile_env): _write(root_path, {"version": 1, "providers": {}}) rotated = _pair("next") - auth._save_codex_tokens(rotated, last_refresh="2026-08-16T00:00:00Z") + auth._save_codex_tokens(rotated, last_refresh="2026-08-16T00:00:00Z", write_through=True) assert _read(profile_path)["providers"]["openai-codex"]["tokens"] == rotated assert "openai-codex" not in _read(root_path).get("providers", {}) + + # A fresh login under a profile that was borrowing root's grant is the profile's own account, + # never a rewrite of root's. + _write(profile_path, {"version": 1, "providers": {}}) + _write(root_path, {"version": 1, "providers": {"openai-codex": {"auth_mode": "chatgpt", "tokens": _pair("root")}}}) + auth._save_codex_tokens(_pair("login")) + assert _read(profile_path)["providers"]["openai-codex"]["tokens"] == _pair("login") + assert _read(root_path)["providers"]["openai-codex"]["tokens"] == _pair("root")