Skip to content
Closed
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
94 changes: 75 additions & 19 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -941,8 +941,8 @@ def _load_global_auth_store() -> Dict[str, Any]:
return {}


def _auth_lock_path() -> Path:
return _auth_file_path().with_suffix(".lock")
def _auth_lock_path(auth_file: Optional[Path] = None) -> Path:
return (auth_file or _auth_file_path()).with_suffix(".lock")


_auth_lock_holder = threading.local()
Expand Down Expand Up @@ -1021,17 +1021,23 @@ def _file_lock(


@contextmanager
def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
def _auth_store_lock(
auth_file: Optional[Path] = None,
timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS,
):
"""Cross-process advisory lock for auth.json reads+writes. Reentrant.

Lock ordering invariant: when this lock is held together with
``_nous_shared_store_lock``, acquire ``_auth_store_lock`` FIRST
(outer) and the shared Nous lock SECOND (inner). All runtime
refresh paths follow this order; violating it risks deadlock
against a concurrent import on the shared store.

``auth_file`` lets shared provider pools (currently OpenAI Codex) lock the
root/default auth store instead of a profile-local lock path.
"""
with _file_lock(
_auth_lock_path(),
_auth_lock_path(auth_file),
_auth_lock_holder,
timeout_seconds,
"Timed out waiting for auth store lock",
Expand Down Expand Up @@ -1079,8 +1085,22 @@ 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()
if os.environ.get("PYTEST_CURRENT_TEST"):
real_home_env = os.environ.get("HOME", "")
if real_home_env:
real_auth = (Path(real_home_env) / ".hermes" / "auth.json").resolve(strict=False)
try:
if auth_file.resolve(strict=False) == real_auth:
raise RuntimeError(
f"Refusing to touch real user auth store during test run: {auth_file}. "
"Set HERMES_HOME/Path.home() to a tmp_path in your test fixture."
)
except RuntimeError:
raise
except Exception:
pass
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 @@ -1194,21 +1214,42 @@ def get_auth_provider_display_name(provider_id: str) -> str:
return SERVICE_PROVIDER_NAMES.get(normalized, provider_id)


# Providers whose pooled OAuth credentials are shared operational state across
# profiles. Most providers keep per-profile shadowing semantics, but Codex
# OAuth refresh/exhaustion state must be rooted at the default/global auth
# store so specialist profiles do not fork stale token pools.
_SHARED_CREDENTIAL_POOL_PROVIDERS = {"openai-codex"}


def _is_shared_credential_pool_provider(provider_id: Optional[str]) -> bool:
return (provider_id or "").strip().lower() in _SHARED_CREDENTIAL_POOL_PROVIDERS


def _shared_credential_pool_auth_file_path(provider_id: Optional[str]) -> Optional[Path]:
if not _is_shared_credential_pool_provider(provider_id):
return None
return _global_auth_file_path()


def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
"""Return the persisted credential pool, or one provider slice.

In profile mode, the profile's credential pool is authoritative. If a
provider has no entries in the profile, entries from the global-root
``auth.json`` are used as a read-only fallback — so workers spawned in a
profile can see providers that were only authenticated at global scope.
In profile mode, the profile's credential pool is authoritative for most
providers. If a provider has no entries in the profile, entries from the
global-root ``auth.json`` are used as a read-only fallback — so workers
spawned in a profile can see providers that were only authenticated at
global scope.

Profile entries always win: the global fallback only applies per-provider
when the profile has zero entries for that provider. Once the user runs
``hermes auth add <provider>`` inside the profile, profile entries
fully shadow global for that provider on the next read.
Profile entries normally win: the global fallback only applies
per-provider when the profile has zero entries for that provider.

Writes always go to the profile (``write_credential_pool`` is unchanged).
See issue #18594 follow-up.
Exception: providers in ``_SHARED_CREDENTIAL_POOL_PROVIDERS`` (currently
``openai-codex``) prefer the root/default pool whenever it has entries.
Codex OAuth refresh/exhaustion state is shared operational state across
specialist profiles; allowing stale profile-local entries to shadow root
makes workers continue using dead tokens after root has been refreshed.

See issue #18594 follow-up and the Codex shared-root pool regression.
"""
auth_store = _load_auth_store()
pool = auth_store.get("credential_pool")
Expand All @@ -1226,13 +1267,23 @@ def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
for gp_key, gp_entries in global_pool.items():
if not isinstance(gp_entries, list) or not gp_entries:
continue
# Shared-pool providers use root/default as source of truth in
# profile mode. This intentionally overrides stale local slices.
if _is_shared_credential_pool_provider(gp_key):
merged[gp_key] = list(gp_entries)
continue
# Per-provider shadowing: profile wins whenever it has ANY entries.
existing = merged.get(gp_key)
if isinstance(existing, list) and existing:
continue
merged[gp_key] = list(gp_entries)
return merged

if _is_shared_credential_pool_provider(provider_id):
global_entries = global_pool.get(provider_id)
if isinstance(global_entries, list) and global_entries:
return list(global_entries)

provider_entries = pool.get(provider_id)
if isinstance(provider_entries, list) and provider_entries:
return list(provider_entries)
Expand All @@ -1247,9 +1298,14 @@ def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Pa
This is the final disk-boundary guard for borrowed/reference-only
credentials. Callers may pass raw dictionaries, so sanitize here even when
``PooledCredential.to_dict()`` already did the same work upstream.

Shared-pool providers (currently ``openai-codex``) write to the root/default
auth store in profile mode so runtime refresh/exhaustion updates do not
silently fork profile-local OAuth state.
"""
with _auth_store_lock():
auth_store = _load_auth_store()
target_auth_file = _shared_credential_pool_auth_file_path(provider_id)
with _auth_store_lock(target_auth_file):
auth_store = _load_auth_store(target_auth_file) if target_auth_file else _load_auth_store()
pool = auth_store.get("credential_pool")
if not isinstance(pool, dict):
pool = {}
Expand All @@ -1259,7 +1315,7 @@ def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Pa
if isinstance(entry, dict) else entry
for entry in entries
]
return _save_auth_store(auth_store)
return _save_auth_store(auth_store, target_auth_file)


def suppress_credential_source(provider_id: str, source: str) -> None:
Expand Down
124 changes: 124 additions & 0 deletions tests/hermes_cli/test_auth_profile_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,130 @@ def test_write_credential_pool_targets_profile_not_global(profile_env):
assert [e["id"] for e in read_credential_pool("openrouter")] == ["prof-new"]



# ---------------------------------------------------------------------------
# Shared Codex pool — root/default is source of truth in profile mode
# ---------------------------------------------------------------------------


def test_codex_pool_prefers_global_even_when_profile_has_stale_entries(profile_env):
"""Codex is a shared OAuth pool; stale profile-local entries must not shadow root."""
from hermes_cli.auth import read_credential_pool

_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openai-codex": [{
"id": "glob-codex",
"label": "root-codex",
"auth_type": "oauth",
"priority": 0,
"source": "device_code",
"access_token": "root-access",
"refresh_token": "root-refresh",
}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
"openai-codex": [{
"id": "prof-stale",
"label": "stale-profile-codex",
"auth_type": "oauth",
"priority": 0,
"source": "device_code",
"access_token": "stale-access",
"refresh_token": "stale-refresh",
"last_status": "exhausted",
"last_error_code": 429,
}],
}))

entries = read_credential_pool("openai-codex")
assert [e["id"] for e in entries] == ["glob-codex"]
assert entries[0]["access_token"] == "root-access"


def test_whole_pool_uses_global_codex_even_when_profile_has_stale_codex(profile_env):
"""Whole-pool reads should not reintroduce stale profile Codex entries."""
from hermes_cli.auth import read_credential_pool

_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openai-codex": [{
"id": "glob-codex",
"label": "root-codex",
"auth_type": "oauth",
"priority": 0,
"source": "device_code",
"access_token": "root-access",
"refresh_token": "root-refresh",
}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
"openai-codex": [{
"id": "prof-stale",
"label": "stale-profile-codex",
"auth_type": "oauth",
"priority": 0,
"source": "device_code",
"access_token": "stale-access",
"refresh_token": "stale-refresh",
}],
"openrouter": [{
"id": "prof-or",
"label": "profile-or",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-profile",
}],
}))

pool = read_credential_pool(None)
assert [e["id"] for e in pool["openai-codex"]] == ["glob-codex"]
assert [e["id"] for e in pool["openrouter"]] == ["prof-or"]


def test_write_credential_pool_writes_shared_codex_to_global_not_profile(profile_env):
"""Pool refresh/exhaustion updates for shared Codex must mutate root, not fork profile auth."""
from hermes_cli.auth import read_credential_pool, write_credential_pool

_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openai-codex": [{
"id": "glob-old",
"label": "root-old",
"auth_type": "oauth",
"priority": 0,
"source": "device_code",
"access_token": "old-access",
"refresh_token": "old-refresh",
}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
"openai-codex": [{
"id": "prof-stale",
"label": "stale-profile-codex",
"auth_type": "oauth",
"priority": 0,
"source": "device_code",
"access_token": "stale-access",
"refresh_token": "stale-refresh",
}],
}))

write_credential_pool("openai-codex", [{
"id": "glob-new",
"label": "root-new",
"auth_type": "oauth",
"priority": 0,
"source": "device_code",
"access_token": "new-access",
"refresh_token": "new-refresh",
}])

global_data = json.loads((profile_env["global"] / "auth.json").read_text())
profile_data = json.loads((profile_env["profile"] / "auth.json").read_text())
assert global_data["credential_pool"]["openai-codex"][0]["id"] == "glob-new"
assert profile_data["credential_pool"]["openai-codex"][0]["id"] == "prof-stale"
assert [e["id"] for e in read_credential_pool("openai-codex")] == ["glob-new"]


# ---------------------------------------------------------------------------
# get_active_provider — global active_provider fallback (issue #18594 follow-up)
#
Expand Down