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
69 changes: 57 additions & 12 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1672,21 +1672,39 @@ def remove_index(self, index: int) -> Optional[PooledCredential]:
if index < 1 or index > len(self._entries):
return None
removed = self._entries.pop(index - 1)
remove_codex_family = (
self.provider == "openai-codex"
and removed.source == "device_code"
)
removed_entry_ids = {removed.id}
if remove_codex_family and removed.refresh_token:
retained_entries = []
for entry in self._entries:
if (
entry.source == "manual:device_code"
and entry.refresh_token == removed.refresh_token
):
removed_entry_ids.add(entry.id)
continue
retained_entries.append(entry)
self._entries = retained_entries
self._entries = [
replace(entry, priority=new_priority)
for new_priority, entry in enumerate(self._entries)
]
self._persist(
replace_shared_entries=(
self.provider == "openai-codex" and removed.source == "device_code"
),
remove_entry_ids={removed.id},
update_order_entry_ids={entry.id for entry in self._entries},
clear_shared_provider_state=(
self.provider == "openai-codex" and removed.source == "device_code"
),
)
if self._current_id == removed.id:
persist_kwargs = {
"replace_shared_entries": remove_codex_family,
"remove_entry_ids": removed_entry_ids,
"update_order_entry_ids": {entry.id for entry in self._entries},
"clear_shared_provider_state": remove_codex_family,
}
if remove_codex_family:
with auth_mod._codex_auth_store_lock():
auth_mod._remove_codex_linked_legacy_aliases(removed.refresh_token)
self._persist(**persist_kwargs)
else:
self._persist(**persist_kwargs)
if self._current_id in removed_entry_ids:
self._current_id = None
return removed

Expand Down Expand Up @@ -2331,6 +2349,11 @@ def _is_suppressed(_p, _s): # type: ignore[misc]
def load_pool(provider: str) -> CredentialPool:
provider = (provider or "").strip().lower()
raw_entries = read_credential_pool(provider)
raw_entries_by_id = {
payload["id"]: payload
for payload in raw_entries
if isinstance(payload, dict) and isinstance(payload.get("id"), str)
}
raw_needs_sanitization = any(
isinstance(payload, dict)
and sanitize_borrowed_credential_payload(payload, provider) != payload
Expand All @@ -2351,10 +2374,32 @@ def load_pool(provider: str) -> CredentialPool:
changed |= _normalize_pool_priorities(provider, entries)

if changed:
serialized_entries = [
entry.to_dict()
for entry in sorted(entries, key=lambda item: item.priority)
]
serialized_entries_by_id = {
payload["id"]: payload
for payload in serialized_entries
if isinstance(payload.get("id"), str)
}
entry_ids = set(serialized_entries_by_id)
raw_entry_ids = set(raw_entries_by_id)
write_credential_pool(
provider,
[entry.to_dict() for entry in sorted(entries, key=lambda item: item.priority)],
serialized_entries,
preserve_shared_entries=True,
preserve_profile_entries=True,
add_entry_ids=frozenset(entry_ids - raw_entry_ids),
replace_entry_ids=frozenset(
entry_id
for entry_id in entry_ids & raw_entry_ids
if is_borrowed_credential_source(
serialized_entries_by_id[entry_id].get("source"),
provider,
)
and serialized_entries_by_id[entry_id] != raw_entries_by_id[entry_id]
),
remove_entry_ids=frozenset(raw_entry_ids - entry_ids),
)
return CredentialPool(provider, entries)
17 changes: 11 additions & 6 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non

from gateway.config import Platform, PlatformConfig
from gateway.session import SessionSource, build_session_key
from hermes_constants import get_hermes_dir, get_hermes_home
from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home


GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
Expand Down Expand Up @@ -954,11 +954,16 @@ def _media_delivery_denied_paths() -> List[Path]:
home = Path(os.path.expanduser("~"))
for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS:
denied.append(home / sub)
# The Hermes home itself contains credentials (auth.json, .env) — only the
# cache subdirectories under it are explicitly allowlisted above.
denied.append(_HERMES_HOME / ".env")
denied.append(_HERMES_HOME / "auth.json")
denied.append(_HERMES_HOME / "credentials")
# In profile mode, both the active profile and the canonical root contain
# credentials. Only cache subdirectories are explicitly allowlisted above.
hermes_homes = [_HERMES_HOME]
root = get_default_hermes_root()
if root not in hermes_homes:
hermes_homes.append(root)
for hermes_home in hermes_homes:
denied.append(hermes_home / ".env")
denied.append(hermes_home / "auth.json")
denied.append(hermes_home / "credentials")
return denied


Expand Down
89 changes: 68 additions & 21 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -1601,11 +1601,14 @@ def write_credential_pool(
credentials. Callers may pass raw dictionaries, so sanitize here even when
``PooledCredential.to_dict()`` already did the same work upstream.
"""
sanitized_entries = [
sanitize_borrowed_credential_payload(entry, provider_id)
if isinstance(entry, dict) else entry
for entry in entries
]
def _sanitize_entries(payloads: List[Any]) -> List[Any]:
return [
sanitize_borrowed_credential_payload(entry, provider_id)
if isinstance(entry, dict) else entry
for entry in payloads
]

sanitized_entries = _sanitize_entries(entries)
if provider_id in SHARED_CREDENTIAL_POOL_PROVIDERS:
shared_auth_file = _codex_auth_file_path()
profile_auth_file = _auth_file_path()
Expand Down Expand Up @@ -1670,7 +1673,7 @@ def write_credential_pool(
update_order_entry_ids=set(update_order_entry_ids),
update_status_entry_ids=set(update_status_entry_ids),
)
shared_pool[provider_id] = (
shared_pool[provider_id] = _sanitize_entries(
root_profile_entries + shared_entries
if split_shared_store
else profile_entries + shared_entries
Expand Down Expand Up @@ -1720,7 +1723,7 @@ def write_credential_pool(
update_order_entry_ids=set(update_order_entry_ids),
clear=not shared_entries,
)
profile_pool[provider_id] = profile_entries
profile_pool[provider_id] = _sanitize_entries(profile_entries)
return _save_auth_store(profile_auth_store, auth_file=profile_auth_file)
return shared_auth_file

Expand Down Expand Up @@ -3832,28 +3835,21 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
}


def _codex_profiles_exist() -> bool:
"""Return whether this Hermes root contains named profiles."""
return (_codex_auth_file_path().parent / "profiles").is_dir()


def _require_codex_refresh_owner(state: Optional[Dict[str, Any]] = None) -> None:
"""Refuse ambiguous pre-upgrade profile refreshes.
"""Refuse ambiguous pre-upgrade refreshes.

Older Hermes versions could copy one Codex refresh-token family into
profile-local stores. The canonical root store cannot know which copy won
the last rotation, so spending its token could replay an already-consumed
value. A fresh Hermes device-code login claims the canonical family.
Older Hermes versions could import Codex CLI credentials or copy one
refresh-token family into profile-local stores. Hermes cannot know which
client or copy won the last rotation, so spending its token could replay an
already-consumed value. A fresh Hermes device-code login claims the family.
"""
if not _codex_profiles_exist():
return
if state is None:
auth_store = _load_auth_store(_codex_auth_file_path())
state = _load_provider_state(auth_store, "openai-codex")
if isinstance(state, dict) and state.get("refresh_owner") == CODEX_REFRESH_OWNER:
return
raise AuthError(
"Codex credentials predate profile-safe refresh ownership. "
"Codex credentials predate Hermes-safe refresh ownership. "
"Run `hermes model`, choose OpenAI Codex, and reauthenticate to create "
"a fresh Hermes-owned Codex session.",
provider="openai-codex",
Expand Down Expand Up @@ -4050,6 +4046,54 @@ def _sync_codex_profile_legacy_aliases(
)


def _remove_codex_linked_legacy_aliases(refresh_token: Optional[str]) -> None:
"""Remove manual aliases that still reference a canonical token family."""
if not isinstance(refresh_token, str) or not refresh_token:
return

def _remove_from_store(auth_store: Dict[str, Any]) -> bool:
pool = auth_store.get("credential_pool")
if not isinstance(pool, dict):
return False
entries = pool.get("openai-codex")
if not isinstance(entries, list):
return False
filtered = [
entry for entry in entries
if not (
isinstance(entry, dict)
and entry.get("source") == "manual:device_code"
and entry.get("refresh_token") == refresh_token
)
]
if len(filtered) == len(entries):
return False
pool["openai-codex"] = filtered
return True

with _codex_auth_store_lock():
auth_file = _codex_auth_file_path()
profiles_dir = auth_file.parent / "profiles"
if profiles_dir.is_dir():
for profile_dir in sorted(profiles_dir.iterdir()):
profile_auth_file = profile_dir / "auth.json"
if not profile_dir.is_dir() or not profile_auth_file.exists():
continue
with _file_lock(
profile_auth_file.with_suffix(".lock"),
threading.local(),
AUTH_LOCK_TIMEOUT_SECONDS,
f"Timed out waiting for Codex profile auth lock: {profile_auth_file}",
):
auth_store = _load_auth_store(profile_auth_file)
if _remove_from_store(auth_store):
_save_auth_store(auth_store, auth_file=profile_auth_file)

auth_store = _load_auth_store(auth_file)
if _remove_from_store(auth_store):
_save_auth_store(auth_store, auth_file=auth_file)


def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
"""Save Codex OAuth tokens to Hermes's canonical auth store."""
if last_refresh is None:
Expand Down Expand Up @@ -5209,14 +5253,17 @@ def _is_terminal_codex_oauth_refresh_error(exc: Exception) -> bool:
(invalid_grant, token revoked, refresh_token_reused).
``codex_auth_missing_refresh_token`` means the pool entry has no refresh
token at all — retrying will never work.
Both carry ``relogin_required=True``; transient failures (429, 5xx) do not.
``codex_auth_refresh_owner_unclaimed`` means Hermes cannot safely spend a
legacy token family. These carry ``relogin_required=True``; transient
failures (429, 5xx) do not.
"""
return (
isinstance(exc, AuthError)
and exc.provider == "openai-codex"
and exc.code in {
"codex_refresh_failed",
"codex_auth_missing_refresh_token",
"codex_auth_refresh_owner_unclaimed",
"invalid_grant",
"invalid_token",
"refresh_token_reused",
Expand Down
14 changes: 11 additions & 3 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2121,9 +2121,17 @@ def _credential_fingerprint(provider: str) -> str:

# OAuth / external-file mtimes that change on re-auth
try:
from hermes_constants import get_hermes_home
for rel in ("auth.json", "credentials.json"):
p = get_hermes_home() / rel
from hermes_constants import get_default_hermes_root, get_hermes_home
hermes_home = get_hermes_home()
credential_files = [
("auth.json", hermes_home / "auth.json"),
("credentials.json", hermes_home / "credentials.json"),
]
if provider == "openai-codex":
codex_auth_file = get_default_hermes_root() / "auth.json"
if codex_auth_file != hermes_home / "auth.json":
credential_files.append(("codex-root-auth.json", codex_auth_file))
for rel, p in credential_files:
try:
parts.append(f"{rel}@{p.stat().st_mtime_ns}")
except FileNotFoundError:
Expand Down
Loading
Loading