{"repo":"NousResearch/hermes-agent","public_id":"10147","display_label":"OAuth token rotation persistence and cross-process sync","summary":"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.","subsystem":"agent","counts":{"members":16,"open_issues":3,"open_issues_distinct":3,"open_prs":2,"closed_issues":4,"merged_prs":3,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15111},"resolution":{"state":"partially_resolved","by":"pr-15111,pr-46614"},"freshness":{"state":"verified","last_verified_at":"2026-06-26T02:14:06+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.09299999999999999},"fix_edges":[{"issue_number":15099,"pr_number":15111,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":43589,"pr_number":46614,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":48415,"pr_number":52760,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":10147,"title":"[Bug]: Nous OAuth refresh in credential pool lacks cross-process sync — concurrent crons revoke session","url":null,"state":"closed"},{"kind":"issue","number":15099,"title":"[Bug][auth] Refresh token rotation not persisted — RT reuse triggers session revocation across all profiles (v0.11.0)","url":null,"state":"closed"},{"kind":"issue","number":29530,"title":"Profiled workers need a shared auth home separate from isolated HERMES_HOME","url":null,"state":"open"},{"kind":"issue","number":30286,"title":"Document and detect shared Nous OAuth auth state for profiled / multi-process deployments","url":null,"state":"open"},{"kind":"issue","number":43589,"title":"Bug: profile auth shadowing + rotating refresh tokens guarantees grant revocation on multi-profile installs","url":null,"state":"closed"},{"kind":"issue","number":48415,"title":"[Bug]: openai-codex multi-profile rotation race — rotated grant not written through to global root (Codex analog of #43589)","url":null,"state":"closed"},{"kind":"issue","number":68699,"title":"[Bug]: shared Nous token store never created — nous_auth.json.tmp.* left unrenamed, silently disabling cross-profile token sharing (causes #43589 revocations)","url":null,"state":"open"},{"kind":"pr","number":10160,"title":"fix(credential_pool): add Nous OAuth cross-process sync to prevent session revocation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"785d168d5","number":0},{"kind":"pr","value":"#15120","number":15120}],"category":"partial_fix","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":15111,"title":"fix(nous-oauth): preserve obtained_at in pool + actionable message on RT reuse","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/credential_pool.py","hermes_cli/auth.py","tests/agent/test_credential_pool.py","tests/hermes_cli/test_auth_nous_provider.py"],"fix_hunks":[{"file":"agent/credential_pool.py","hunks":["@@ -1056,6 +1056,18 @@ def _is_suppressed(_p, _s):  # type: ignore[misc]\n                     \"inference_base_url\": state.get(\"inference_base_url\"),\n                     \"agent_key\": state.get(\"agent_key\"),\n                     \"agent_key_expires_at\": state.get(\"agent_key_expires_at\"),\n+                    # Carry the mint/refresh timestamps into the pool so\n+                    # freshness-sensitive consumers (self-heal hooks, pool\n+                    # pruning by age) can distinguish just-minted credentials\n+                    # from stale ones.  Without these, fresh device_code\n+                    # entries get obtained_at=None and look older than they\n+                    # are (#15099).\n+                    \"obtained_at\": state.get(\"obtained_at\"),\n+                    \"expires_in\": state.get(\"expires_in\"),\n+                    \"agent_key_id\": state.get(\"agent_key_id\"),\n+                    \"agent_key_expires_in\": state.get(\"agent_key_expires_in\"),\n+                    \"agent_key_reused\": state.get(\"agent_key_reused\"),\n+                    \"agent_key_obtained_at\": state.get(\"agent_key_obtained_at\"),\n                     \"tls\": state.get(\"tls\") if isinstance(state.get(\"tls\"), dict) else None,\n                     \"label\": seeded_label,\n                 },"]},{"file":"hermes_cli/auth.py","hunks":["@@ -1889,6 +1889,28 @@ def _refresh_access_token(\n     code = str(error_payload.get(\"error\", \"invalid_grant\"))\n     description = str(error_payload.get(\"error_description\") or \"Refresh token exchange failed\")\n     relogin = code in {\"invalid_grant\", \"invalid_token\"}\n+\n+    # Detect the OAuth 2.1 \"refresh token reuse\" signal from the Nous portal\n+    # server and surface an actionable message.  This fires when an external\n+    # process (health-check script, monitoring tool, custom self-heal hook)\n+    # called POST /api/oauth/token with Hermes's refresh_token without\n+    # persisting the rotated token back to auth.json — the server then\n+    # retires the original RT, Hermes's next refresh uses it, and the whole\n+    # session chain gets revoked as a token-theft signal (#15099).\n+    lowered = description.lower()\n+    if \"reuse\" in lowered or \"reuse detected\" in lowered:\n+        description = (\n+            \"Nous Portal detected refresh-token reuse and revoked this session.\\n\"\n+            \"This usually means an external process (monitoring script, \"\n+            \"custom self-heal hook, or another Hermes install sharing \"\n+            \"~/.hermes/auth.json) called POST /api/oauth/token with Hermes's \"\n+            \"refresh token without persisting the rotated token back.\\n\"\n+            \"Nous refresh tokens are single-use — only Hermes may call the \"\n+            \"refresh endpoint. For health checks, use `hermes auth status` \"\n+            \"instead.\\n\"\n+            \"Re-authenticate with: hermes auth add nous\"\n+        )\n+\n     raise AuthError(description, provider=\"nous\", code=code, relogin_required=relogin)\n \n "]},{"file":"tests/agent/test_credential_pool.py","hunks":["@@ -1102,3 +1102,72 @@ def test_load_pool_does_not_seed_qwen_oauth_when_no_token(tmp_path, monkeypatch)\n \n     assert not pool.has_credentials()\n     assert pool.entries() == []\n+\n+\n+def test_nous_seed_from_singletons_preserves_obtained_at_timestamps(tmp_path, monkeypatch):\n+    \"\"\"Regression test for #15099 secondary issue.\n+\n+    When ``_seed_from_singletons`` materialises a device_code pool entry from\n+    the ``providers.nous`` singleton, it must carry the mint/refresh\n+    timestamps (``obtained_at``, ``agent_key_obtained_at``, ``expires_in``,\n+    etc.) into the pool entry.  Without them, freshness-sensitive consumers\n+    (self-heal hooks, pool pruning by age) treat just-minted credentials as\n+    older than they actually are and evict them.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    _write_auth_store(\n+        tmp_path,\n+        {\n+            \"version\": 1,\n+            \"providers\": {\n+                \"nous\": {\n+                    \"access_token\": \"at_XXXXXXXX\",\n+                    \"refresh_token\": \"rt_YYYYYYYY\",\n+                    \"client_id\": \"hermes-cli\",\n+                    \"portal_base_url\": \"https://portal.nousresearch.com\",\n+                    \"inference_base_url\": \"https://inference.nousresearch.com/v1\",\n+                    \"token_type\": \"Bearer\",\n+                    \"scope\": \"openid profile\",\n+                    \"obtained_at\": \"2026-04-24T10:00:00+00:00\",\n+                    \"expires_at\": \"2026-04-24T11:00:00+00:00\",\n+                    \"expires_in\": 3600,\n+                    \"agent_key\": \"sk-nous-AAAA\",\n+                    \"agent_key_id\": \"ak_123\",\n+                    \"agent_key_expires_at\": \"2026-04-25T10:00:00+00:00\",\n+                    \"agent_key_expires_in\": 86400,\n+                    \"agent_key_reused\": False,\n+                    \"agent_key_obtained_at\": \"2026-04-24T10:00:05+00:00\",\n+                    \"tls\": {\"insecure\": False, \"ca_bundle\": None},\n+                },\n+            },\n+        },\n+    )\n+\n+    from agent.credential_pool import load_pool\n+\n+    pool = load_pool(\"nous\")\n+    entries = pool.entries()\n+\n+    device_entries = [e for e in entries if e.source == \"device_code\"]\n+    assert len(device_entries) == 1, f\"expected single device_code entry; got {len(device_entries)}\"\n+    e = device_entries[0]\n+\n+    # Direct dataclass fields — must survive the singleton → pool copy.\n+    assert e.access_token == \"at_XXXXXXXX\"\n+    assert e.refresh_token == \"rt_YYYYYYYY\"\n+    assert e.expires_at == \"2026-04-24T11:00:00+00:00\"\n+    assert e.agent_key == \"sk-nous-AAAA\"\n+    assert e.agent_key_expires_at == \"2026-04-25T10:00:00+00:00\"\n+\n+    # Extra fields — this is what regressed.  These must be carried through\n+    # via ``extra`` dict or __getattr__, NOT silently dropped.\n+    assert e.obtained_at == \"2026-04-24T10:00:00+00:00\", (\n+        f\"obtained_at was dropped during seed; got {e.obtained_at!r}. This breaks \"\n+        f\"downstream pool-freshness consumers (#15099).\"\n+    )\n+    assert e.agent_key_obtained_at == \"2026-04-24T10:00:05+00:00\"\n+    assert e.expires_in == 3600\n+    assert e.agent_key_id == \"ak_123\"\n+    assert e.agent_key_expires_in == 86400\n+    assert e.agent_key_reused is False\n+"]},{"file":"tests/hermes_cli/test_auth_nous_provider.py","hunks":["@@ -732,3 +732,83 @@ def test_persist_nous_credentials_no_label_uses_auto_derived(tmp_path, monkeypat\n     # No \"label\" key embedded in providers.nous when the caller didn't supply one.\n     payload = json.loads((hermes_home / \"auth.json\").read_text())\n     assert \"label\" not in payload[\"providers\"][\"nous\"]\n+\n+\n+def test_refresh_token_reuse_detection_surfaces_actionable_message():\n+    \"\"\"Regression for #15099.\n+\n+    When the Nous Portal server returns ``invalid_grant`` with\n+    ``error_description`` containing \"reuse detected\", Hermes must surface an\n+    actionable message explaining that an external process consumed the\n+    refresh token.  The default opaque \"Refresh token reuse detected; please\n+    re-authenticate\" string led users to report this as a Hermes persistence\n+    bug when the true cause is external RT consumption (monitoring scripts,\n+    custom self-heal hooks).\n+    \"\"\"\n+    from hermes_cli.auth import _refresh_access_token\n+\n+    class _FakeResponse:\n+        status_code = 400\n+\n+        def json(self):\n+            return {\n+                \"error\": \"invalid_grant\",\n+                \"error_description\": \"Refresh token reuse detected; please re-authenticate\",\n+            }\n+\n+    class _FakeClient:\n+        def post(self, *args, **kwargs):\n+            return _FakeResponse()\n+\n+    with pytest.raises(AuthError) as exc_info:\n+        _refresh_access_token(\n+            client=_FakeClient(),\n+            portal_base_url=\"https://portal.nousresearch.com\",\n+            client_id=\"hermes-cli\",\n+            refresh_token=\"[redacted:secret-assignment]\",\n+        )\n+\n+    message = str(exc_info.value)\n+    assert \"refresh-token reuse\" in message.lower() or \"refresh token reuse\" in message.lower()\n+    # The message must mention the external-process cause and give next steps.\n+    assert \"external process\" in message.lower() or \"monitoring script\" in message.lower()\n+    assert \"hermes auth add nous\" in message.lower()\n+    # Must still be classified as invalid_grant + relogin_required.\n+    assert exc_info.value.code == \"invalid_grant\"\n+    assert exc_info.value.relogin_required is True\n+\n+\n+def test_refresh_non_reuse_error_keeps_original_description():\n+    \"\"\"Non-reuse invalid_grant errors must keep their original description untouched.\n+\n+    Only the \"reuse detected\" signature should trigger the actionable message;\n+    generic ``invalid_grant: Refresh session has been revoked`` (the\n+    downstream consequence) keeps its original text so we don't overwrite\n+    useful server context for unrelated failure modes.\n+    \"\"\"\n+    from hermes_cli.auth import _refresh_access_token\n+\n+    class _FakeResponse:\n+        status_code = 400\n+\n+        def json(self):\n+            return {\n+                \"error\": \"invalid_grant\",\n+                \"error_description\": \"Refresh session has been revoked\",\n+            }\n+\n+    class _FakeClient:\n+        def post(self, *args, **kwargs):\n+            return _FakeResponse()\n+\n+    with pytest.raises(AuthError) as exc_info:\n+        _refresh_access_token(\n+            client=_FakeClient(),\n+            portal_base_url=\"https://portal.nousresearch.com\",\n+            client_id=\"hermes-cli\",\n+            refresh_token=\"[redacted:secret-assignment]\",\n+        )\n+\n+    assert \"Refresh session has been revoked\" in str(exc_info.value)\n+    # Must not have been rewritten with the reuse message.\n+    assert \"external process\" not in str(exc_info.value).lower()\n"]}]},{"kind":"pr","number":43749,"title":"fix(auth): propagate rotated xAI tokens back to global root (#43589)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":46614,"title":"fix(auth): resolve xAI OAuth credentials across profiles + write rotated tokens back to root","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/auth.py","scripts/release.py","tests/hermes_cli/test_xai_oauth_profile_auth.py","tests/hermes_cli/test_xai_oauth_writethrough.py"],"fix_hunks":[{"file":"hermes_cli/auth.py","hunks":["@@ -1079,8 +1079,13 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:\n     return {\"version\": AUTH_STORE_VERSION, \"providers\": {}}\n \n \n-def _save_auth_store(auth_store: Dict[str, Any]) -> Path:\n-    auth_file = _auth_file_path()\n+def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = None) -> Path:\n+    # target_path=None preserves the existing contract (write the active\n+    # store at _auth_file_path()). An explicit path lets callers persist a\n+    # specific store — e.g. the global-root write-through for rotating xAI\n+    # OAuth grants (#43589) — reusing this function's atomic O_EXCL + 0o600\n+    # write so the root auth.json gets the same TOCTOU-safe treatment.\n+    auth_file = target_path if target_path is not None else _auth_file_path()\n     auth_file.parent.mkdir(parents=True, exist_ok=True)\n     # Tighten parent dir to 0o700 so siblings can't traverse to creds.\n     # No-op on Windows (POSIX mode bits not enforced); ignore failures.","@@ -3886,13 +3891,64 @@ def _entry_usable(entry: Dict[str, Any]) -> bool:\n # xAI Grok OAuth — tokens stored in ~/.hermes/auth.json\n # =============================================================================\n \n+def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]:\n+    \"\"\"Return usable xAI OAuth state from provider state or credential pool.\"\"\"\n+    state = _load_provider_state(auth_store, \"xai-oauth\")\n+    tokens = [redacted:secret-assignment]\"tokens\") if isinstance(state, dict) else None\n+    if isinstance(tokens, dict):\n+        access_token = [redacted:secret-assignment]\"access_token\", \"\") or \"\").strip()\n+        refresh_token = [redacted:secret-assignment]\"refresh_token\", \"\") or \"\").strip()\n+        if access_token and refresh_token:\n+            return state\n+\n+    credential_pool = [redacted:secret-assignment]\"credential_pool\")\n+    entries = (\n+        credential_pool.get(\"xai-oauth\")\n+        if isinstance(credential_pool, dict)\n+        else None\n+    )\n+    if isinstance(entries, list):\n+        for entry in entries:\n+            if not isinstance(entry, dict):\n+                continue\n+            access_token = [redacted:secret-assignment]\"access_token\", \"\") or \"\").strip()\n+            refresh_token = [redacted:secret-assignment]\"refresh_token\", \"\") or \"\").strip()\n+            if not access_token or not refresh_token:\n+                continue\n+            merged = dict(state or {})\n+            merged[\"tokens\"] = {\n+                \"access_token\": access_token,\n+                \"refresh_token\": refresh_token,\n+                \"token_type\": str(entry.get(\"token_type\") or \"Bearer\"),\n+            }\n+            if entry.get(\"last_refresh\"):\n+                merged[\"last_refresh\"] = entry.get(\"last_refresh\")\n+            merged.setdefault(\"auth_mode\", \"oauth_pkce\")\n+            return merged\n+\n+    return state if isinstance(state, dict) else None\n+\n+\n+def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool:\n+    tokens = [redacted:secret-assignment]\"tokens\") if isinstance(state, dict) else None\n+    return (\n+        isinstance(tokens, dict)\n+        and bool(str(tokens.get(\"access_token\", \"\") or \"\").strip())\n+        and bool(str(tokens.get(\"refresh_token\", \"\") or \"\").strip())\n+    )\n+\n+\n def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:\n     if _lock:\n         with _auth_store_lock():\n             auth_store = _load_auth_store()\n     else:\n         auth_store = _load_auth_store()\n-    state = _load_provider_state(auth_store, \"xai-oauth\")\n+    state = _xai_oauth_state_from_store(auth_store)\n+    if not _xai_oauth_state_has_usable_tokens(state):\n+        global_state = _xai_oauth_state_from_store(_load_global_auth_store())\n+        if _xai_oauth_state_has_usable_tokens(global_state):\n+            state = global_state\n     if not state:\n         raise AuthError(\n             \"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]:\n     }\n \n \n+def _profile_has_own_xai_oauth_state(auth_store: Dict[str, Any]) -> bool:\n+    \"\"\"True when this store has its OWN ``providers.xai-oauth`` block.\n+\n+    Distinguishes a profile that genuinely shadows the root xAI grant from\n+    one that only *reads* root via ``_load_provider_state``'s fallback. Only\n+    the latter needs the refresh write-through below.\n+    \"\"\"\n+    providers = auth_store.get(\"providers\")\n+    return isinstance(providers, dict) and isinstance(providers.get(\"xai-oauth\"), dict)\n+\n+\n+def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None:\n+    \"\"\"Persist a rotated xAI OAuth ``state`` into the global-root auth.json.\n+\n+    Best-effort write-through for the multi-profile rotation hazard (#43589):\n+    xAI rotates the refresh_token on every refresh, so when a profile session\n+    refreshes a grant it resolved from the root fallback, the rotated chain\n+    must land back in root. Otherwise root keeps a now-revoked refresh token\n+    and every other profile reading the stale root grant dies with\n+    ``invalid_grant`` once its access token expires.\n+\n+    Only updates ``providers.xai-oauth`` in the root store; never touches the\n+    profile store (the caller already saved that). Swallows all errors — a\n+    failed write-through degrades to the pre-existing behavior (root stale),\n+    it must never break the profile's own successful save.\n+    \"\"\"\n+    global_path = _global_auth_file_path()\n+    if global_path is None:\n+        # Classic mode (profile == root); the profile save already hit root.\n+        return\n+    # Seat belt: under pytest, refuse to write the real user's\n+    # ~/.hermes/auth.json even when HERMES_HOME points at a profile path\n+    # (mirrors the read-side guard in _load_global_auth_store). Uses the\n+    # unmodified HOME env, not Path.home() which fixtures may monkeypatch.\n+    if os.environ.get(\"PYTEST_CURRENT_TEST\"):\n+        real_home_env = os.environ.get(\"HOME\", \"\")\n+        if real_home_env:\n+            real_root = Path(real_home_env) / \".hermes\" / \"auth.json\"\n+            try:\n+                if global_path.resolve(strict=False) == real_root.resolve(strict=False):\n+                    return\n+            except Exception:\n+                return\n+    try:\n+        if global_path.exists():\n+            global_store = _load_auth_store(global_path)\n+        else:\n+            global_store = {}\n+        if not isinstance(global_store, dict):\n+            return\n+        _store_provider_state(global_store, \"xai-oauth\", dict(state), set_active=False)\n+        _save_auth_store(global_store, global_path)\n+    except Exception as exc:  # pragma: no cover - best effort\n+        logger.debug(\"xAI OAuth: write-through to global root failed: %s\", exc)\n+\n+\n def _save_xai_oauth_tokens(\n     tokens: [redacted:secret-assignment] Any],\n     *,","@@ -3943,6 +4055,11 @@ def _save_xai_oauth_tokens(\n         last_refresh = datetime.now(timezone.utc).isoformat().replace(\"+00:00\", \"Z\")\n     with _auth_store_lock():\n         auth_store = _load_auth_store()\n+        # A profile that lacks its own xai-oauth block is reading the root\n+        # grant through _load_provider_state's fallback. When such a profile\n+        # refreshes the (rotating) grant, we must write the rotated chain back\n+        # to root too, or root is left holding a revoked refresh token (#43589).\n+        write_through_to_root = not _profile_has_own_xai_oauth_state(auth_store)\n         state = _load_provider_state(auth_store, \"xai-oauth\") or {}\n         state[\"tokens\"] = tokens\n         state[\"last_refresh\"] = last_refresh","@@ -3953,6 +4070,8 @@ def _save_xai_oauth_tokens(\n             state[\"redirect_uri\"] = redirect_uri\n         _save_provider_state(auth_store, \"xai-oauth\", state)\n         _save_auth_store(auth_store)\n+        if write_through_to_root:\n+            _write_through_xai_oauth_to_global_root(state)\n \n \n def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:"]},{"file":"scripts/release.py","hunks":["@@ -1541,6 +1541,7 @@\n     \"[redacted:email]\": \"dschnurbusch\",  # PR #42373 salvage (archive compressed conversation lineages)\n     \"[redacted:email]\": \"bcsmith528\",  # PR #20589 salvage (register_slack_action_handler plugin API)\n     \"[redacted:email]\": \"liuhao1024\",  # PR #45494 salvage (claim session slot before auto-resume task; #45456)\n+    \"[redacted:email]\": \"capt-marbles\",  # PR #38440 salvage (resolve xAI OAuth credentials across profiles; #43589)\n }\n \n "]},{"file":"tests/hermes_cli/test_xai_oauth_profile_auth.py","hunks":["@@ -0,0 +1,79 @@\n+\"\"\"Regression tests for xAI OAuth auth resolution in profile/cron contexts.\"\"\"\n+\n+import pytest\n+\n+from hermes_cli import auth\n+from hermes_cli.auth import AuthError\n+\n+\n+def test_read_xai_oauth_tokens_uses_credential_pool_when_provider_tokens_empty(monkeypatch):\n+    \"\"\"Profile auth can have fresh pool tokens while singleton provider state is empty.\n+\n+    This mirrors profiled cron after re-auth/credential-pool sync: the xAI\n+    OAuth credential is usable, but `providers.xai-oauth.tokens` may be empty\n+    or stale. Treating that as missing auth makes cron keep failing after the\n+    user has successfully re-authenticated.\n+    \"\"\"\n+    store = {\n+        \"providers\": {\"xai-oauth\": {\"tokens\": {}, \"last_auth_error\": {}}},\n+        \"credential_pool\": {\n+            \"xai-oauth\": [\n+                {\n+                    \"access_token\": \"pool-access\",\n+                    \"refresh_token\": \"pool-refresh\",\n+                    \"token_type\": \"Bearer\",\n+                    \"last_refresh\": \"2026-06-03T19:00:00Z\",\n+                }\n+            ]\n+        },\n+    }\n+    monkeypatch.setattr(auth, \"_load_auth_store\", lambda: store)\n+    monkeypatch.setattr(auth, \"_load_global_auth_store\", lambda: {})\n+\n+    resolved = auth._read_xai_oauth_tokens(_lock=False)\n+\n+    assert resolved[\"tokens\"][\"access_token\"] == \"pool-access\"\n+    assert resolved[\"tokens\"][\"refresh_token\"] == \"pool-refresh\"\n+    assert resolved[\"tokens\"][\"token_type\"] == \"Bearer\"\n+    assert resolved[\"last_refresh\"] == \"2026-06-03T19:00:00Z\"\n+\n+\n+def test_read_xai_oauth_tokens_uses_global_store_when_profile_state_empty(monkeypatch):\n+    \"\"\"A profile/cron process should see root xAI auth after user re-auths there.\"\"\"\n+    profile_store = {\"providers\": {\"xai-oauth\": {\"tokens\": {}}}}\n+    global_store = {\n+        \"providers\": {\n+            \"xai-oauth\": {\n+                \"tokens\": {\n+                    \"access_token\": \"global-access\",\n+                    \"refresh_token\": \"global-refresh\",\n+                    \"token_type\": \"Bearer\",\n+                },\n+                \"last_refresh\": \"2026-06-03T19:05:00Z\",\n+            }\n+        }\n+    }\n+    monkeypatch.setattr(auth, \"_load_auth_store\", lambda: profile_store)\n+    monkeypatch.setattr(auth, \"_load_global_auth_store\", lambda: global_store)\n+\n+    resolved = auth._read_xai_oauth_tokens(_lock=False)\n+\n+    assert resolved[\"tokens\"][\"access_token\"] == \"global-access\"\n+    assert resolved[\"tokens\"][\"refresh_token\"] == \"global-refresh\"\n+    assert resolved[\"last_refresh\"] == \"2026-06-03T19:05:00Z\"\n+\n+\n+def test_read_xai_oauth_tokens_still_requires_usable_tokens(monkeypatch):\n+    \"\"\"Fallback should not hide genuinely broken xAI auth state.\"\"\"\n+    store = {\n+        \"providers\": {\"xai-oauth\": {\"tokens\": {}}},\n+        \"credential_pool\": {\"xai-oauth\": [{\"access_token\": \"\", \"refresh_token\": \"\"}]},\n+    }\n+    monkeypatch.setattr(auth, \"_load_auth_store\", lambda: store)\n+    monkeypatch.setattr(auth, \"_load_global_auth_store\", lambda: {})\n+\n+    with pytest.raises(AuthError) as exc:\n+        auth._read_xai_oauth_tokens(_lock=False)\n+\n+    assert exc.value.code == \"xai_auth_missing_access_token\"\n+    assert exc.value.relogin_required is True"]},{"file":"tests/hermes_cli/test_xai_oauth_writethrough.py","hunks":["@@ -0,0 +1,169 @@\n+\"\"\"Regression tests for xAI OAuth refresh write-through to the global root.\n+\n+Companion to ``test_xai_oauth_profile_auth.py``. That file covers the READ\n+fallback (profile -> credential pool -> global root). These cover the WRITE\n+side: when a profile that has no own ``providers.xai-oauth`` block refreshes\n+the (rotating) grant it resolved from the root fallback, the rotated tokens\n+must be written back to the global root too. Otherwise root keeps a revoked\n+refresh token and every other profile reading root's stale grant dies with\n+``invalid_grant`` once its access token expires (issue #43589).\n+\n+The tests drive the real ``_save_xai_oauth_tokens`` against real on-disk auth\n+stores (profile + root under ``tmp_path``) rather than mocking the save\n+boundary, so they exercise the actual atomic write path.\n+\"\"\"\n+\n+import json\n+\n+import pytest\n+\n+from hermes_cli import auth\n+\n+\n+def _write_store(path, store):\n+    path.parent.mkdir(parents=True, exist_ok=True)\n+    path.write_text(json.dumps(store), encoding=\"utf-8\")\n+\n+\n+def _read_store(path):\n+    return json.loads(path.read_text(encoding=\"utf-8\"))\n+\n+\n[redacted:email]\n+def profile_and_root(tmp_path, monkeypatch):\n+    \"\"\"Wire a profile auth store + a distinct global-root auth store on disk.\n+\n+    Returns (profile_path, root_path). The pytest seat belt in\n+    ``_write_through_xai_oauth_to_global_root`` only refuses the *real* user's\n+    ``$HOME/.hermes/auth.json``; a tmp_path root is allowed, so we point HOME\n+    away from the tmp root to keep the guard from tripping on these fixtures.\n+    \"\"\"\n+    profile_path = tmp_path / \"profiles\" / \"work\" / \"auth.json\"\n+    root_path = tmp_path / \"root\" / \"auth.json\"\n+\n+    monkeypatch.setattr(auth, \"_auth_file_path\", lambda: profile_path)\n+    monkeypatch.setattr(auth, \"_global_auth_file_path\", lambda: root_path)\n+    # Keep the pytest write seat belt from matching our tmp root.\n+    monkeypatch.setenv(\"HOME\", str(tmp_path / \"not-the-root\"))\n+    return profile_path, root_path\n+\n+\n+def test_refresh_writes_through_to_root_when_profile_has_no_own_state(profile_and_root):\n+    \"\"\"Profile reading root's grant must push rotated tokens back to root.\"\"\"\n+    profile_path, root_path = profile_and_root\n+    # Profile has NO own xai-oauth block (reads root via fallback).\n+    _write_store(profile_path, {\"version\": 1, \"providers\": {}})\n+    _write_store(\n+        root_path,\n+        {\n+            \"version\": 1,\n+            \"providers\": {\n+                \"xai-oauth\": {\n+                    \"tokens\": {\n+                        \"access_token\": \"old-access\",\n+                        \"refresh_token\": \"old-refresh\",\n+                    }\n+                }\n+            },\n+        },\n+    )\n+\n+    rotated = {\n+        \"access_token\": \"new-access\",\n+        \"refresh_token\": \"new-refresh\",\n+        \"token_type\": \"Bearer\",\n+    }\n+    auth._save_xai_oauth_tokens(rotated)\n+\n+    # Profile got the rotated chain (existing behavior).\n+    profile = _read_store(profile_path)\n+    assert profile[\"providers\"][\"xai-oauth\"][\"tokens\"][\"refresh_token\"] == \"new-refresh\"\n+\n+    # AND the global root no longer holds the revoked refresh token (#43589).\n+    root = _read_store(root_path)\n+    assert root[\"providers\"][\"xai-oauth\"][\"tokens\"][\"access_token\"] == \"new-access\"\n+    assert root[\"providers\"][\"xai-oauth\"][\"tokens\"][\"refresh_token\"] == \"new-refresh\"\n+\n+\n+def test_refresh_does_not_touch_root_when_profile_has_own_state(profile_and_root):\n+    \"\"\"A profile that genuinely shadows root must NOT clobber the root grant.\"\"\"\n+    profile_path, root_path = profile_and_root\n+    # Profile has its OWN xai-oauth block: it shadows root legitimately.\n+    _write_store(\n+        profile_path,\n+        {\n+            \"version\": 1,\n+            \"providers\": {\n+                \"xai-oauth\": {\n+                    \"tokens\": {\n+                        \"access_token\": \"profile-old\",\n+                        \"refresh_token\": \"profile-old-refresh\",\n+                    }\n+                }\n+            },\n+        },\n+    )\n+    _write_store(\n+        root_path,\n+        {\n+            \"version\": 1,\n+            \"providers\": {\n+                \"xai-oauth\": {\n+                    \"tokens\": {\n+                        \"access_token\": \"root-untouched\",\n+                        \"refresh_token\": \"root-untouched-refresh\",\n+                    }\n+                }\n+            },\n+        },\n+    )\n+\n+    auth._save_xai_oauth_tokens(\n+        {\"access_token\": \"profile-new\", \"refresh_token\": \"profile-new-refresh\"}\n+    )\n+\n+    profile = _read_store(profile_path)\n+    assert profile[\"providers\"][\"xai-oauth\"][\"tokens\"][\"refresh_token\"] == \"profile-new-refresh\"\n+\n+    # Root is a separate grant chain — must be left exactly as-is.\n+    root = _read_store(root_path)\n+    assert root[\"providers\"][\"xai-oauth\"][\"tokens\"][\"access_token\"] == \"root-untouched\"\n+    assert root[\"providers\"][\"xai-oauth\"][\"tokens\"][\"refresh_token\"] == \"root-untouched-refresh\"\n+\n+\n+def test_write_through_is_noop_in_classic_mode(tmp_path, monkeypatch):\n+    \"\"\"Classic mode (profile == root) already saves to root; no double write.\"\"\"\n+    profile_path = tmp_path / \"auth.json\"\n+    monkeypatch.setattr(auth, \"_auth_file_path\", lambda: profile_path)\n+    # Classic mode: _global_auth_file_path returns None.\n+    monkeypatch.setattr(auth, \"_global_auth_file_path\", lambda: None)\n+    _write_store(profile_path, {\"version\": 1, \"providers\": {}})\n+\n+    # Should not raise and should persist to the single store.\n+    auth._save_xai_oauth_tokens(\n+        {\"access_token\": \"a\", \"refresh_token\": \"r\"}\n+    )\n+    store = _read_store(profile_path)\n+    assert store[\"providers\"][\"xai-oauth\"][\"tokens\"][\"refresh_token\"] == \"r\"\n+\n+\n+def test_write_through_failure_does_not_break_profile_save(profile_and_root, monkeypatch):\n+    \"\"\"A failed root write-through must not break the profile's own save.\"\"\"\n+    profile_path, root_path = profile_and_root\n+    _write_store(profile_path, {\"version\": 1, \"providers\": {}})\n+    _write_store(root_path, {\"version\": 1, \"providers\": {}})\n+\n+    # Make the root write blow up; the profile save must still succeed.\n+    real_save = auth._save_auth_store\n+\n+    def _exploding_save(store, target_path=None):\n+        if target_path is not None and target_path == root_path:\n+            raise OSError(\"simulated root write failure\")\n+        return real_save(store, target_path)\n+\n+    monkeypatch.setattr(auth, \"_save_auth_store\", _exploding_save)\n+\n+    auth._save_xai_oauth_tokens({\"access_token\": \"a\", \"refresh_token\": \"r\"})\n+\n+    profile = _read_store(profile_path)\n+    assert profile[\"providers\"][\"xai-oauth\"][\"tokens\"][\"refresh_token\"] == \"r\"\n"]}]},{"kind":"pr","number":48416,"title":"fix(codex): write rotated OAuth grant through to global root (multi-profile rotation race)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":51753,"title":"fix(auth): write rotated Codex/xAI pool grant through to global root (#48415)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a4091e49f","number":0},{"kind":"pr","value":"#52760","number":52760}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":52760,"title":"fix(auth): write rotated Codex/xAI pool grant through to global root (#48415)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/credential_pool.py","tests/agent/test_credential_pool_oauth_writethrough.py"]},{"kind":"pr","number":68780,"title":"fix(auth): surface + recover shared Nous store rename failures (#68699)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"55f94faf51e4","number":0},{"kind":"pr","value":"#68699","number":68699},{"kind":"pr","value":"#68780","number":68780},{"kind":"pr","value":"#68780","number":68780},{"kind":"pr","value":"#68780","number":68780},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#68699","number":68699},{"kind":"pr","value":"#68780","number":68780}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":74639,"title":"feat(auth): isolate provider credentials in launcher-owned home","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"f538470cf4","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}