Skip to content
Open
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
60 changes: 47 additions & 13 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,7 @@ def _auth_lock_path() -> Path:


_auth_lock_holder = threading.local()
_codex_auth_lock_holder = threading.local()


@contextmanager
Expand Down Expand Up @@ -1022,8 +1023,8 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
return {"version": AUTH_STORE_VERSION, "providers": {}}


def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
auth_file = _auth_file_path()
def _save_auth_store(auth_store: Dict[str, Any], auth_file: Optional[Path] = None) -> Path:
auth_file = auth_file or _auth_file_path()
auth_file.parent.mkdir(parents=True, exist_ok=True)
# Tighten parent dir to 0o700 so siblings can't traverse to creds.
# No-op on Windows (POSIX mode bits not enforced); ignore failures.
Expand Down Expand Up @@ -2953,24 +2954,57 @@ def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None)


# =============================================================================
# OpenAI Codex auth — tokens stored in ~/.hermes/auth.json (not ~/.codex/)
# OpenAI Codex auth — tokens stored in the shared root Hermes auth store
#
# Hermes maintains its own Codex OAuth session separate from the Codex CLI
# and VS Code extension. This prevents refresh token rotation conflicts
# where one app's refresh invalidates the other's session.
#
# Codex refresh tokens rotate on use. Named Hermes profiles therefore must
# not keep independent profile-local copies: one worker can refresh and make
# every sibling profile's copy stale. In profile mode, Codex reads and writes
# the global-root auth.json so all profiles coordinate through one lock and
# one token chain.
# =============================================================================
def _codex_auth_file_path() -> Path:
"""Return the auth store path that owns the shared Codex OAuth session."""
return _global_auth_file_path() or _auth_file_path()


def _codex_auth_lock_path() -> Path:
return _codex_auth_file_path().with_suffix(".lock")


@contextmanager
def _codex_auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
with _file_lock(
_codex_auth_lock_path(),
_codex_auth_lock_holder,
timeout_seconds,
"Timed out waiting for Codex auth store lock",
):
yield


def _load_codex_auth_store() -> Dict[str, Any]:
return _load_auth_store(_codex_auth_file_path())


def _save_codex_auth_store(auth_store: Dict[str, Any]) -> Path:
return _save_auth_store(auth_store, _codex_auth_file_path())


def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
"""Read Codex OAuth tokens from Hermes auth store (~/.hermes/auth.json).
"""Read Codex OAuth tokens from the shared Hermes auth store.

Returns dict with 'tokens' (access_token, refresh_token) and 'last_refresh'.
Raises AuthError if no Codex tokens are stored.
"""
if _lock:
with _auth_store_lock():
auth_store = _load_auth_store()
with _codex_auth_store_lock():
auth_store = _load_codex_auth_store()
else:
auth_store = _load_auth_store()
auth_store = _load_codex_auth_store()
state = _load_provider_state(auth_store, "openai-codex")
if not state:
raise AuthError(
Expand Down Expand Up @@ -3010,17 +3044,17 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:


Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replacement is based on an older _save_codex_tokens body. Current main also accepts label and syncs provider updates into credential_pool.openai-codex; the shared-root write needs to preserve those newer behaviors or Codex re-auth will regress pool users.

def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
"""Save Codex OAuth tokens to the shared Hermes auth store."""
if last_refresh is None:
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
with _auth_store_lock():
auth_store = _load_auth_store()
with _codex_auth_store_lock():
auth_store = _load_codex_auth_store()
state = _load_provider_state(auth_store, "openai-codex") or {}
state["tokens"] = tokens
state["last_refresh"] = last_refresh
state["auth_mode"] = "chatgpt"
_save_provider_state(auth_store, "openai-codex", state)
_save_auth_store(auth_store)
_save_codex_auth_store(auth_store)


def refresh_codex_oauth_pure(
Expand Down Expand Up @@ -3198,8 +3232,8 @@ def resolve_codex_runtime_credentials(
if (not should_refresh) and refresh_if_expiring:
should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds)
if should_refresh:
# Re-read under lock to avoid racing with other Hermes processes
with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)):
# Re-read under the shared Codex lock to avoid racing with other Hermes processes.
with _codex_auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)):
data = _read_codex_tokens(_lock=False)
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
Expand Down
49 changes: 49 additions & 0 deletions tests/hermes_cli/test_auth_profile_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,52 @@ def test_write_credential_pool_targets_profile_not_global(profile_env):

# Subsequent read returns profile (shadows global).
assert [e["id"] for e in read_credential_pool("openrouter")] == ["prof-new"]


# ---------------------------------------------------------------------------
# OpenAI Codex OAuth — shared root store
# ---------------------------------------------------------------------------


def _codex_state(access: str, refresh: str) -> dict:
return {
"tokens": {"access_token": access, "refresh_token": refresh},
"last_refresh": "2026-01-01T00:00:00Z",
"auth_mode": "chatgpt",
}


def test_codex_tokens_use_global_store_even_when_profile_has_stale_state(profile_env):
"""Codex refresh tokens rotate, so profile-local copies must never shadow root."""
from hermes_cli.auth import _read_codex_tokens

_write(profile_env["global"] / "auth.json", _make_auth_store(providers={
"openai-codex": _codex_state("global-access", "global-refresh"),
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={
"openai-codex": _codex_state("stale-profile-access", "stale-profile-refresh"),
}))

data = _read_codex_tokens()
assert data["tokens"]["access_token"] == "global-access"
assert data["tokens"]["refresh_token"] == "global-refresh"


def test_save_codex_tokens_writes_global_store_from_profile(profile_env):
"""Refreshing Codex from a profile updates the single shared token chain."""
from hermes_cli.auth import _save_codex_tokens

_write(profile_env["global"] / "auth.json", _make_auth_store(providers={}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={
"openai-codex": _codex_state("old-profile-access", "old-profile-refresh"),
}))

_save_codex_tokens({"access_token": "new-access", "refresh_token": "new-refresh"})

global_store = json.loads((profile_env["global"] / "auth.json").read_text())
profile_store = json.loads((profile_env["profile"] / "auth.json").read_text())

assert global_store["providers"]["openai-codex"]["tokens"]["access_token"] == "new-access"
assert global_store["providers"]["openai-codex"]["tokens"]["refresh_token"] == "new-refresh"
# Stale profile-local state is deliberately not touched; it is ignored by reads.
assert profile_store["providers"]["openai-codex"]["tokens"]["access_token"] == "old-profile-access"