{"repo":"NousResearch/hermes-agent","public_id":"29530","display_label":"Multi-profile auth credential sharing and rotated token propagation","summary":"Members mostly concern how profiles or isolated homes handle provider credentials, including resolving xAI OAuth credentials across profiles and writing rotated tokens back to a global root. One member proposes a launcher-owned or shared auth home, which is a related but distinct approach from the token write-back fixes.","subsystem":"agent/secret_sources","counts":{"members":5,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":46614,"merged_at":"2026-06-15T11:46:19+00:00"},"resolution":{"state":"partially_resolved","by":"pr-46614"},"freshness":{"state":"verified","last_verified_at":"2026-06-15T11:46:19+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":43589,"pr_number":46614,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-15T11:46:19+00:00"}],"members":[{"kind":"issue","number":29530,"title":"Profiled workers need a shared auth home separate from isolated HERMES_HOME","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":"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"},"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 = state.get(\"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 = auth_store.get(\"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\": [redacted:secret-assignment]\n+                \"refresh_token\": [redacted:secret-assignment]\n+                \"token_type\": [redacted:secret-assignment]\"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 = state.get(\"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\": \"[redacted:secret-assignment]\",\n+                    \"refresh_token\": \"[redacted:secret-assignment]\",\n+                    \"token_type\": \"[redacted:secret-assignment]\",\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\": \"[redacted:secret-assignment]\",\n+                    \"refresh_token\": \"[redacted:secret-assignment]\",\n+                    \"token_type\": \"[redacted:secret-assignment]\",\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\": \"[redacted:secret-assignment]\",\n+                        \"refresh_token\": \"[redacted:secret-assignment]\",\n+                    }\n+                }\n+            },\n+        },\n+    )\n+\n+    rotated = {\n+        \"access_token\": \"[redacted:secret-assignment]\",\n+        \"refresh_token\": \"[redacted:secret-assignment]\",\n+        \"token_type\": \"[redacted:secret-assignment]\",\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\": \"[redacted:secret-assignment]\",\n+                        \"refresh_token\": \"[redacted:secret-assignment]\",\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\": \"[redacted:secret-assignment]\",\n+                        \"refresh_token\": \"[redacted:secret-assignment]\",\n+                    }\n+                }\n+            },\n+        },\n+    )\n+\n+    auth._save_xai_oauth_tokens(\n+        {\"access_token\": \"[redacted:secret-assignment]\", \"refresh_token\": \"[redacted:secret-assignment]\"}\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":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}}