OAuth token rotation persistence and cross-process sync

NousResearch/hermes-agent · cluster 10147 · agent

Data as of

5 open items. Fix merged in #15111. Check it covers your case.

The short answer

3open issues
2open PRs
16items grouped
Known?
Yes. 16 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #15111. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #15111, #46614. That is separate from the fix event.
Still open
3 issues and 2 PRs.

These issues and PRs address problems where rotated OAuth refresh tokens (for Nous, xAI/Codex, and related providers) are not written back to a shared global store, causing token reuse and session revocation across profiles and concurrent processes. A recurring theme is coordinating credential pool access in multi-profile and multi-process deployments.

Open issues3

  • Issue#29530Profiled workers need a shared auth home separate from isolated HERMES_HOME
  • Issue#30286Document and detect shared Nous OAuth auth state for profiled / multi-process deployments
  • Issue#68699[Bug]: shared Nous token store never created — nous_auth.json.tmp.* left unrenamed, silently disabling cross-profile token sharing (causes #43589 revocations)

Open PRs2

  • PR#68780fix(auth): surface + recover shared Nous store rename failures (#68699)
  • PR#74639feat(auth): isolate provider credentials in launcher-owned home

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #15111 fix(nous-oauth): preserve obtained_at in pool + actionable message on RT reuse

    merged to main

    files: agent/credential_pool.py, hermes_cli/auth.py, tests/agent/test_credential_pool.py, tests/hermes_cli/test_auth_nous_provider.py

    Show the diff
    • agent/credential_pool.py
      @@ -1056,6 +1056,18 @@ def _is_suppressed(_p, _s):  # type: ignore[misc]
                           "inference_base_url": state.get("inference_base_url"),
                           "agent_key": state.get("agent_key"),
                           "agent_key_expires_at": state.get("agent_key_expires_at"),
      +                    # Carry the mint/refresh timestamps into the pool so
      +                    # freshness-sensitive consumers (self-heal hooks, pool
      +                    # pruning by age) can distinguish just-minted credentials
      +                    # from stale ones.  Without these, fresh device_code
      +                    # entries get obtained_at=None and look older than they
      +                    # are (#15099).
      +                    "obtained_at": state.get("obtained_at"),
      +                    "expires_in": state.get("expires_in"),
      +                    "agent_key_id": state.get("agent_key_id"),
      +                    "agent_key_expires_in": state.get("agent_key_expires_in"),
      +                    "agent_key_reused": state.get("agent_key_reused"),
      +                    "agent_key_obtained_at": state.get("agent_key_obtained_at"),
                           "tls": state.get("tls") if isinstance(state.get("tls"), dict) else None,
                           "label": seeded_label,
                       },
    • hermes_cli/auth.py
      @@ -1889,6 +1889,28 @@ def _refresh_access_token(
           code = str(error_payload.get("error", "invalid_grant"))
           description = str(error_payload.get("error_description") or "Refresh token exchange failed")
           relogin = code in {"invalid_grant", "invalid_token"}
      +
      +    # Detect the OAuth 2.1 "refresh token reuse" signal from the Nous portal
      +    # server and surface an actionable message.  This fires when an external
      +    # process (health-check script, monitoring tool, custom self-heal hook)
      +    # called POST /api/oauth/token with Hermes's refresh_token without
      +    # persisting the rotated token back to auth.json — the server then
      +    # retires the original RT, Hermes's next refresh uses it, and the whole
      +    # session chain gets revoked as a token-theft signal (#15099).
      +    lowered = description.lower()
      +    if "reuse" in lowered or "reuse detected" in lowered:
      +        description = (
      +            "Nous Portal detected refresh-token reuse and revoked this session.\n"
      +            "This usually means an external process (monitoring script, "
      +            "custom self-heal hook, or another Hermes install sharing "
      +            "~/.hermes/auth.json) called POST /api/oauth/token with Hermes's "
      +            "refresh token without persisting the rotated token back.\n"
      +            "Nous refresh tokens are single-use — only Hermes may call the "
      +            "refresh endpoint. For health checks, use `hermes auth status` "
      +            "instead.\n"
      +            "Re-authenticate with: hermes auth add nous"
      +        )
      +
           raise AuthError(description, provider="nous", code=code, relogin_required=relogin)
       
       
    • tests/agent/test_credential_pool.py
      @@ -1102,3 +1102,72 @@ def test_load_pool_does_not_seed_qwen_oauth_when_no_token(tmp_path, monkeypatch)
       
           assert not pool.has_credentials()
           assert pool.entries() == []
      +
      +
      +def test_nous_seed_from_singletons_preserves_obtained_at_timestamps(tmp_path, monkeypatch):
      +    """Regression test for #15099 secondary issue.
      +
      +    When ``_seed_from_singletons`` materialises a device_code pool entry from
      +    the ``providers.nous`` singleton, it must carry the mint/refresh
      +    timestamps (``obtained_at``, ``agent_key_obtained_at``, ``expires_in``,
      +    etc.) into the pool entry.  Without them, freshness-sensitive consumers
      +    (self-heal hooks, pool pruning by age) treat just-minted credentials as
      +    older than they actually are and evict them.
      +    """
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    _write_auth_store(
      +        tmp_path,
      +        {
      +            "version": 1,
      +            "providers": {
      +                "nous": {
      +                    "access_token": "at_XXXXXXXX",
      +                    "refresh_token": "rt_YYYYYYYY",
      +                    "client_id": "hermes-cli",
      +                    "portal_base_url": "https://portal.nousresearch.com",
      +                    "inference_base_url": "https://inference.nousresearch.com/v1",
      +                    "token_type": "Bearer",
      +                    "scope": "openid profile",
      +                    "obtained_at": "2026-04-24T10:00:00+00:00",
      +                    "expires_at": "2026-04-24T11:00:00+00:00",
      +                    "expires_in": 3600,
      +                    "agent_key": "sk-nous-AAAA",
      +                    "agent_key_id": "ak_123",
      +                    "agent_key_expires_at": "2026-04-25T10:00:00+00:00",
      +                    "agent_key_expires_in": 86400,
      +                    "agent_key_reused": False,
      +                    "agent_key_obtained_at": "2026-04-24T10:00:05+00:00",
      +                    "tls": {"insecure": False, "ca_bundle": None},
      +                },
      +            },
      +        },
      +    )
      +
      +    from agent.credential_pool import load_pool
      +
      +    pool = load_pool("nous")
      +    entries = pool.entries()
      +
      +    device_entries = [e for e in entries if e.source == "device_code"]
      +    assert len(device_entries) == 1, f"expected single device_code entry; got {len(device_entries)}"
      +    e = device_entries[0]
      +
      +    # Direct dataclass fields — must survive the singleton → pool copy.
      +    assert e.access_token == "at_XXXXXXXX"
      +    assert e.refresh_token == "rt_YYYYYYYY"
      +    assert e.expires_at == "2026-04-24T11:00:00+00:00"
      +    assert e.agent_key == "sk-nous-AAAA"
      +    assert e.agent_key_expires_at == "2026-04-25T10:00:00+00:00"
      +
      +    # Extra fields — this is what regressed.  These must be carried through
      +    # via ``extra`` dict or __getattr__, NOT silently dropped.
      +    assert e.obtained_at == "2026-04-24T10:00:00+00:00", (
      +        f"obtained_at was dropped during seed; got {e.obtained_at!r}. This breaks "
      +        f"downstream pool-freshness consumers (#15099)."
      +    )
      +    assert e.agent_key_obtained_at == "2026-04-24T10:00:05+00:00"
      +    assert e.expires_in == 3600
      +    assert e.agent_key_id == "ak_123"
      +    assert e.agent_key_expires_in == 86400
      +    assert e.agent_key_reused is False
      +
    • tests/hermes_cli/test_auth_nous_provider.py
      @@ -732,3 +732,83 @@ def test_persist_nous_credentials_no_label_uses_auto_derived(tmp_path, monkeypat
           # No "label" key embedded in providers.nous when the caller didn't supply one.
           payload = json.loads((hermes_home / "auth.json").read_text())
           assert "label" not in payload["providers"]["nous"]
      +
      +
      +def test_refresh_token_reuse_detection_surfaces_actionable_message():
      +    """Regression for #15099.
      +
      +    When the Nous Portal server returns ``invalid_grant`` with
      +    ``error_description`` containing "reuse detected", Hermes must surface an
      +    actionable message explaining that an external process consumed the
      +    refresh token.  The default opaque "Refresh token reuse detected; please
      +    re-authenticate" string led users to report this as a Hermes persistence
      +    bug when the true cause is external RT consumption (monitoring scripts,
      +    custom self-heal hooks).
      +    """
      +    from hermes_cli.auth import _refresh_access_token
      +
      +    class _FakeResponse:
      +        status_code = 400
      +
      +        def json(self):
      +            return {
      +                "error": "invalid_grant",
      +                "error_description": "Refresh token reuse detected; please re-authenticate",
      +            }
      +
      +    class _FakeClient:
      +        def post(self, *args, **kwargs):
      +            return _FakeResponse()
      +
      +    with pytest.raises(AuthError) as exc_info:
      +        _refresh_access_token(
      +            client=_FakeClient(),
      +            portal_base_url="https://portal.nousresearch.com",
      +            client_id="hermes-cli",
      +            refresh_token="[redacted:secret-assignment]",
      +        )
      +
      +    message = str(exc_info.value)
      +    assert "refresh-token reuse" in message.lower() or "refresh token reuse" in message.lower()
      +    # The message must mention the external-process cause and give next steps.
      +    assert "external process" in message.lower() or "monitoring script" in message.lower()
      +    assert "hermes auth add nous" in message.lower()
      +    # Must still be classified as invalid_grant + relogin_required.
      +    assert exc_info.value.code == "invalid_grant"
      +    assert exc_info.value.relogin_required is True
      +
      +
      +def test_refresh_non_reuse_error_keeps_original_description():
      +    """Non-reuse invalid_grant errors must keep their original description untouched.
      +
      +    Only the "reuse detected" signature should trigger the actionable message;
      +    generic ``invalid_grant: Refresh session has been revoked`` (the
      +    downstream consequence) keeps its original text so we don't overwrite
      +    useful server context for unrelated failure modes.
      +    """
      +    from hermes_cli.auth import _refresh_access_token
      +
      +    class _FakeResponse:
      +        status_code = 400
      +
      +        def json(self):
      +            return {
      +                "error": "invalid_grant",
      +                "error_description": "Refresh session has been revoked",
      +            }
      +
      +    class _FakeClient:
      +        def post(self, *args, **kwargs):
      +            return _FakeResponse()
      +
      +    with pytest.raises(AuthError) as exc_info:
      +        _refresh_access_token(
      +            client=_FakeClient(),
      +            portal_base_url="https://portal.nousresearch.com",
      +            client_id="hermes-cli",
      +            refresh_token="[redacted:secret-assignment]",
      +        )
      +
      +    assert "Refresh session has been revoked" in str(exc_info.value)
      +    # Must not have been rewritten with the reuse message.
      +    assert "external process" not in str(exc_info.value).lower()
  • #46614 fix(auth): resolve xAI OAuth credentials across profiles + write rotated tokens back to root

    merged to main

    files: hermes_cli/auth.py, scripts/release.py, tests/hermes_cli/test_xai_oauth_profile_auth.py, tests/hermes_cli/test_xai_oauth_writethrough.py

    Show the diff
    • hermes_cli/auth.py
      @@ -1079,8 +1079,13 @@ 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], target_path: Optional[Path] = None) -> Path:
      +    # target_path=None preserves the existing contract (write the active
      +    # store at _auth_file_path()). An explicit path lets callers persist a
      +    # specific store — e.g. the global-root write-through for rotating xAI
      +    # OAuth grants (#43589) — reusing this function's atomic O_EXCL + 0o600
      +    # write so the root auth.json gets the same TOCTOU-safe treatment.
      +    auth_file = target_path if target_path is not None else _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.
      @@ -3886,13 +3891,64 @@ def _entry_usable(entry: Dict[str, Any]) -> bool:
       # xAI Grok OAuth — tokens stored in ~/.hermes/auth.json
       # =============================================================================
       
      +def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]:
      +    """Return usable xAI OAuth state from provider state or credential pool."""
      +    state = _load_provider_state(auth_store, "xai-oauth")
      +    tokens = [redacted:secret-assignment]"tokens") if isinstance(state, dict) else None
      +    if isinstance(tokens, dict):
      +        access_token = [redacted:secret-assignment]"access_token", "") or "").strip()
      +        refresh_token = [redacted:secret-assignment]"refresh_token", "") or "").strip()
      +        if access_token and refresh_token:
      +            return state
      +
      +    credential_pool = [redacted:secret-assignment]"credential_pool")
      +    entries = (
      +        credential_pool.get("xai-oauth")
      +        if isinstance(credential_pool, dict)
      +        else None
      +    )
      +    if isinstance(entries, list):
      +        for entry in entries:
      +            if not isinstance(entry, dict):
      +                continue
      +            access_token = [redacted:secret-assignment]"access_token", "") or "").strip()
      +            refresh_token = [redacted:secret-assignment]"refresh_token", "") or "").strip()
      +            if not access_token or not refresh_token:
      +                continue
      +            merged = dict(state or {})
      +            merged["tokens"] = {
      +                "access_token": access_token,
      +                "refresh_token": refresh_token,
      +                "token_type": str(entry.get("token_type") or "Bearer"),
      +            }
      +            if entry.get("last_refresh"):
      +                merged["last_refresh"] = entry.get("last_refresh")
      +            merged.setdefault("auth_mode", "oauth_pkce")
      +            return merged
      +
      +    return state if isinstance(state, dict) else None
      +
      +
      +def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool:
      +    tokens = [redacted:secret-assignment]"tokens") if isinstance(state, dict) else None
      +    return (
      +        isinstance(tokens, dict)
      +        and bool(str(tokens.get("access_token", "") or "").strip())
      +        and bool(str(tokens.get("refresh_token", "") or "").strip())
      +    )
      +
      +
       def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
           if _lock:
               with _auth_store_lock():
                   auth_store = _load_auth_store()
           else:
               auth_store = _load_auth_store()
      -    state = _load_provider_state(auth_store, "xai-oauth")
      +    state = _xai_oauth_state_from_store(auth_store)
      +    if not _xai_oauth_state_has_usable_tokens(state):
      +        global_state = _xai_oauth_state_from_store(_load_global_auth_store())
      +        if _xai_oauth_state_has_usable_tokens(global_state):
      +            state = global_state
           if not state:
               raise AuthError(
                   "No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
      @@ -3932,6 +3988,62 @@ def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
           }
       
       
      +def _profile_has_own_xai_oauth_state(auth_store: Dict[str, Any]) -> bool:
      +    """True when this store has its OWN ``providers.xai-oauth`` block.
      +
      +    Distinguishes a profile that genuinely shadows the root xAI grant from
      +    one that only *reads* root via ``_load_provider_state``'s fallback. Only
      +    the latter needs the refresh write-through below.
      +    """
      +    providers = auth_store.get("providers")
      +    return isinstance(providers, dict) and isinstance(providers.get("xai-oauth"), dict)
      +
      +
      +def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None:
      +    """Persist a rotated xAI OAuth ``state`` into the global-root auth.json.
      +
      +    Best-effort write-through for the multi-profile rotation hazard (#43589):
      +    xAI rotates the refresh_token on every refresh, so when a profile session
      +    refreshes a grant it resolved from the root fallback, the rotated chain
      +    must land back in root. Otherwise root keeps a now-revoked refresh token
      +    and every other profile reading the stale root grant dies with
      +    ``invalid_grant`` once its access token expires.
      +
      +    Only updates ``providers.xai-oauth`` in the root store; never touches the
      +    profile store (the caller already saved that). Swallows all errors — a
      +    failed write-through degrades to the pre-existing behavior (root stale),
      +    it must never break the profile's own successful save.
      +    """
      +    global_path = _global_auth_file_path()
      +    if global_path is None:
      +        # Classic mode (profile == root); the profile save already hit root.
      +        return
      +    # Seat belt: under pytest, refuse to write the real user's
      +    # ~/.hermes/auth.json even when HERMES_HOME points at a profile path
      +    # (mirrors the read-side guard in _load_global_auth_store). Uses the
      +    # unmodified HOME env, not Path.home() which fixtures may monkeypatch.
      +    if os.environ.get("PYTEST_CURRENT_TEST"):
      +        real_home_env = os.environ.get("HOME", "")
      +        if real_home_env:
      +            real_root = Path(real_home_env) / ".hermes" / "auth.json"
      +            try:
      +                if global_path.resolve(strict=False) == real_root.resolve(strict=False):
      +                    return
      +            except Exception:
      +                return
      +    try:
      +        if global_path.exists():
      +            global_store = _load_auth_store(global_path)
      +        else:
      +            global_store = {}
      +        if not isinstance(global_store, dict):
      +            return
      +        _store_provider_state(global_store, "xai-oauth", dict(state), set_active=False)
      +        _save_auth_store(global_store, global_path)
      +    except Exception as exc:  # pragma: no cover - best effort
      +        logger.debug("xAI OAuth: write-through to global root failed: %s", exc)
      +
      +
       def _save_xai_oauth_tokens(
           tokens: [redacted:secret-assignment] Any],
           *,
      @@ -3943,6 +4055,11 @@ def _save_xai_oauth_tokens(
               last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
           with _auth_store_lock():
               auth_store = _load_auth_store()
      +        # A profile that lacks its own xai-oauth block is reading the root
      +        # grant through _load_provider_state's fallback. When such a profile
      +        # refreshes the (rotating) grant, we must write the rotated chain back
      +        # to root too, or root is left holding a revoked refresh token (#43589).
      +        write_through_to_root = not _profile_has_own_xai_oauth_state(auth_store)
               state = _load_provider_state(auth_store, "xai-oauth") or {}
               state["tokens"] = tokens
               state["last_refresh"] = last_refresh
      @@ -3953,6 +4070,8 @@ def _save_xai_oauth_tokens(
                   state["redirect_uri"] = redirect_uri
               _save_provider_state(auth_store, "xai-oauth", state)
               _save_auth_store(auth_store)
      +        if write_through_to_root:
      +            _write_through_xai_oauth_to_global_root(state)
       
       
       def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:
    • scripts/release.py
      @@ -1541,6 +1541,7 @@
           "[redacted:email]": "dschnurbusch",  # PR #42373 salvage (archive compressed conversation lineages)
           "[redacted:email]": "bcsmith528",  # PR #20589 salvage (register_slack_action_handler plugin API)
           "[redacted:email]": "liuhao1024",  # PR #45494 salvage (claim session slot before auto-resume task; #45456)
      +    "[redacted:email]": "capt-marbles",  # PR #38440 salvage (resolve xAI OAuth credentials across profiles; #43589)
       }
       
       
    • tests/hermes_cli/test_xai_oauth_profile_auth.py
      @@ -0,0 +1,79 @@
      +"""Regression tests for xAI OAuth auth resolution in profile/cron contexts."""
      +
      +import pytest
      +
      +from hermes_cli import auth
      +from hermes_cli.auth import AuthError
      +
      +
      +def test_read_xai_oauth_tokens_uses_credential_pool_when_provider_tokens_empty(monkeypatch):
      +    """Profile auth can have fresh pool tokens while singleton provider state is empty.
      +
      +    This mirrors profiled cron after re-auth/credential-pool sync: the xAI
      +    OAuth credential is usable, but `providers.xai-oauth.tokens` may be empty
      +    or stale. Treating that as missing auth makes cron keep failing after the
      +    user has successfully re-authenticated.
      +    """
      +    store = {
      +        "providers": {"xai-oauth": {"tokens": {}, "last_auth_error": {}}},
      +        "credential_pool": {
      +            "xai-oauth": [
      +                {
      +                    "access_token": "pool-access",
      +                    "refresh_token": "pool-refresh",
      +                    "token_type": "Bearer",
      +                    "last_refresh": "2026-06-03T19:00:00Z",
      +                }
      +            ]
      +        },
      +    }
      +    monkeypatch.setattr(auth, "_load_auth_store", lambda: store)
      +    monkeypatch.setattr(auth, "_load_global_auth_store", lambda: {})
      +
      +    resolved = auth._read_xai_oauth_tokens(_lock=False)
      +
      +    assert resolved["tokens"]["access_token"] == "pool-access"
      +    assert resolved["tokens"]["refresh_token"] == "pool-refresh"
      +    assert resolved["tokens"]["token_type"] == "Bearer"
      +    assert resolved["last_refresh"] == "2026-06-03T19:00:00Z"
      +
      +
      +def test_read_xai_oauth_tokens_uses_global_store_when_profile_state_empty(monkeypatch):
      +    """A profile/cron process should see root xAI auth after user re-auths there."""
      +    profile_store = {"providers": {"xai-oauth": {"tokens": {}}}}
      +    global_store = {
      +        "providers": {
      +            "xai-oauth": {
      +                "tokens": {
      +                    "access_token": "global-access",
      +                    "refresh_token": "global-refresh",
      +                    "token_type": "Bearer",
      +                },
      +                "last_refresh": "2026-06-03T19:05:00Z",
      +            }
      +        }
      +    }
      +    monkeypatch.setattr(auth, "_load_auth_store", lambda: profile_store)
      +    monkeypatch.setattr(auth, "_load_global_auth_store", lambda: global_store)
      +
      +    resolved = auth._read_xai_oauth_tokens(_lock=False)
      +
      +    assert resolved["tokens"]["access_token"] == "global-access"
      +    assert resolved["tokens"]["refresh_token"] == "global-refresh"
      +    assert resolved["last_refresh"] == "2026-06-03T19:05:00Z"
      +
      +
      +def test_read_xai_oauth_tokens_still_requires_usable_tokens(monkeypatch):
      +    """Fallback should not hide genuinely broken xAI auth state."""
      +    store = {
      +        "providers": {"xai-oauth": {"tokens": {}}},
      +        "credential_pool": {"xai-oauth": [{"access_token": "", "refresh_token": ""}]},
      +    }
      +    monkeypatch.setattr(auth, "_load_auth_store", lambda: store)
      +    monkeypatch.setattr(auth, "_load_global_auth_store", lambda: {})
      +
      +    with pytest.raises(AuthError) as exc:
      +        auth._read_xai_oauth_tokens(_lock=False)
      +
      +    assert exc.value.code == "xai_auth_missing_access_token"
      +    assert exc.value.relogin_required is True
    • tests/hermes_cli/test_xai_oauth_writethrough.py
      @@ -0,0 +1,169 @@
      +"""Regression tests for xAI OAuth refresh write-through to the global root.
      +
      +Companion to ``test_xai_oauth_profile_auth.py``. That file covers the READ
      +fallback (profile -> credential pool -> global root). These cover the WRITE
      +side: when a profile that has no own ``providers.xai-oauth`` block refreshes
      +the (rotating) grant it resolved from the root fallback, the rotated tokens
      +must be written back to the global root too. Otherwise root keeps a revoked
      +refresh token and every other profile reading root's stale grant dies with
      +``invalid_grant`` once its access token expires (issue #43589).
      +
      +The tests drive the real ``_save_xai_oauth_tokens`` against real on-disk auth
      +stores (profile + root under ``tmp_path``) rather than mocking the save
      +boundary, so they exercise the actual atomic write path.
      +"""
      +
      +import json
      +
      +import pytest
      +
      +from hermes_cli import auth
      +
      +
      +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"))
      +
      +
      [redacted:email]
      +def profile_and_root(tmp_path, monkeypatch):
      +    """Wire a profile auth store + a distinct global-root auth store on disk.
      +
      +    Returns (profile_path, root_path). The pytest seat belt in
      +    ``_write_through_xai_oauth_to_global_root`` only refuses the *real* user's
      +    ``$HOME/.hermes/auth.json``; a tmp_path root is allowed, so we point HOME
      +    away from the tmp root to keep the guard from tripping on these fixtures.
      +    """
      +    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_refresh_writes_through_to_root_when_profile_has_no_own_state(profile_and_root):
      +    """Profile reading root's grant must push rotated tokens back to root."""
      +    profile_path, root_path = profile_and_root
      +    # Profile has NO own xai-oauth block (reads root via fallback).
      +    _write_store(profile_path, {"version": 1, "providers": {}})
      +    _write_store(
      +        root_path,
      +        {
      +            "version": 1,
      +            "providers": {
      +                "xai-oauth": {
      +                    "tokens": {
      +                        "access_token": "old-access",
      +                        "refresh_token": "old-refresh",
      +                    }
      +                }
      +            },
      +        },
      +    )
      +
      +    rotated = {
      +        "access_token": "new-access",
      +        "refresh_token": "new-refresh",
      +        "token_type": "Bearer",
      +    }
      +    auth._save_xai_oauth_tokens(rotated)
      +
      +    # Profile got the rotated chain (existing behavior).
      +    profile = _read_store(profile_path)
      +    assert profile["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "new-refresh"
      +
      +    # AND the global root no longer holds the revoked refresh token (#43589).
      +    root = _read_store(root_path)
      +    assert root["providers"]["xai-oauth"]["tokens"]["access_token"] == "new-access"
      +    assert root["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "new-refresh"
      +
      +
      +def test_refresh_does_not_touch_root_when_profile_has_own_state(profile_and_root):
      +    """A profile that genuinely shadows root must NOT clobber the root grant."""
      +    profile_path, root_path = profile_and_root
      +    # Profile has its OWN xai-oauth block: it shadows root legitimately.
      +    _write_store(
      +        profile_path,
      +        {
      +            "version": 1,
      +            "providers": {
      +                "xai-oauth": {
      +                    "tokens": {
      +                        "access_token": "profile-old",
      +                        "refresh_token": "profile-old-refresh",
      +                    }
      +                }
      +            },
      +        },
      +    )
      +    _write_store(
      +        root_path,
      +        {
      +            "version": 1,
      +            "providers": {
      +                "xai-oauth": {
      +                    "tokens": {
      +                        "access_token": "root-untouched",
      +                        "refresh_token": "root-untouched-refresh",
      +                    }
      +                }
      +            },
      +        },
      +    )
      +
      +    auth._save_xai_oauth_tokens(
      +        {"access_token": "profile-new", "refresh_token": "profile-new-refresh"}
      +    )
      +
      +    profile = _read_store(profile_path)
      +    assert profile["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "profile-new-refresh"
      +
      +    # Root is a separate grant chain — must be left exactly as-is.
      +    root = _read_store(root_path)
      +    assert root["providers"]["xai-oauth"]["tokens"]["access_token"] == "root-untouched"
      +    assert root["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "root-untouched-refresh"
      +
      +
      +def test_write_through_is_noop_in_classic_mode(tmp_path, monkeypatch):
      +    """Classic mode (profile == root) already saves to root; no double write."""
      +    profile_path = tmp_path / "auth.json"
      +    monkeypatch.setattr(auth, "_auth_file_path", lambda: profile_path)
      +    # Classic mode: _global_auth_file_path returns None.
      +    monkeypatch.setattr(auth, "_global_auth_file_path", lambda: None)
      +    _write_store(profile_path, {"version": 1, "providers": {}})
      +
      +    # Should not raise and should persist to the single store.
      +    auth._save_xai_oauth_tokens(
      +        {"access_token": "a", "refresh_token": "r"}
      +    )
      +    store = _read_store(profile_path)
      +    assert store["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "r"
      +
      +
      +def test_write_through_failure_does_not_break_profile_save(profile_and_root, monkeypatch):
      +    """A failed root write-through must not break the profile's own save."""
      +    profile_path, root_path = profile_and_root
      +    _write_store(profile_path, {"version": 1, "providers": {}})
      +    _write_store(root_path, {"version": 1, "providers": {}})
      +
      +    # Make the root write blow up; the profile save must still succeed.
      +    real_save = auth._save_auth_store
      +
      +    def _exploding_save(store, target_path=None):
      +        if target_path is not None and target_path == root_path:
      +            raise OSError("simulated root write failure")
      +        return real_save(store, target_path)
      +
      +    monkeypatch.setattr(auth, "_save_auth_store", _exploding_save)
      +
      +    auth._save_xai_oauth_tokens({"access_token": "a", "refresh_token": "r"})
      +
      +    profile = _read_store(profile_path)
      +    assert profile["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "r"
  • #52760 fix(auth): write rotated Codex/xAI pool grant through to global root (#48415)

    merged to main

    files: agent/credential_pool.py, tests/agent/test_credential_pool_oauth_writethrough.py

4 PRs closed without merging

From the close comments: done elsewhere 1 · partial fix 1 · other 2

  • #10160 fix(credential_pool): add Nous OAuth cross-process sync to prevent session revocation · partial fix → 785d168, #15120
  • #43749 fix(auth): propagate rotated xAI tokens back to global root (#43589) · other
  • #48416 fix(codex): write rotated OAuth grant through to global root (multi-profile rotation race) · other
  • #51753 fix(auth): write rotated Codex/xAI pool grant through to global root (#48415) · done elsewhere → a4091e4, #52760
Closed issues · 4
  • Closed#10147[Bug]: Nous OAuth refresh in credential pool lacks cross-process sync — concurrent crons revoke session
  • Closed#15099[Bug][auth] Refresh token rotation not persisted — RT reuse triggers session revocation across all profiles (v0.11.0)
  • Closed#43589Bug: profile auth shadowing + rotating refresh tokens guarantees grant revocation on multi-profile installs
  • Closed#48415[Bug]: openai-codex multi-profile rotation race — rotated grant not written through to global root (Codex analog of #43589)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.09: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#15111, #46614, #52760
Closed issues
4
Attempts
4 PRs closed without merging
Fix link checked
26 Jun 2026

For your agent

The same facts, typed.