diff --git a/agent/credential_pool.py b/agent/credential_pool.py
index 55db7165fa..2d8768d60b 100644
--- a/agent/credential_pool.py
+++ b/agent/credential_pool.py
@@ -3774,6 +3774,12 @@ def _seed_custom_pool(pool_key: str, entries: List[PooledCredential]) -> Tuple[b
def load_pool(provider: str) -> CredentialPool:
provider = (provider or "").strip().lower()
+ if provider in SINGLE_USE_REFRESH_POOL_PROVIDERS:
+ # One-time heal for installs that forked this grant across profiles
+ # BEFORE the clone-strip / root-write-through existed: consolidate the
+ # profile's copy into root so the read below borrows root's grant
+ # (#100339). No-op in classic mode or once the profile is clean.
+ auth_mod.heal_forked_single_use_oauth_grants(provider)
raw_entries = read_credential_pool(provider)
disk_ids = {
entry.get("id")
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index cb88847396..2c4d955f92 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -1801,6 +1801,390 @@ def strip_cloned_single_use_oauth_grants(profile_dir: Path) -> Dict[str, Any]:
return stripped
+# ── One-time heal for installs that ALREADY forked a single-use grant ────────
+#
+# Fleets created before the clone-strip / root-write-through above have
+# profile-local copies of the root grant. Those copies are the same credential
+# with several owners: whichever profile rotated last holds the only live
+# refresh token and every other copy (root included) is spent. Upgrading alone
+# does not fix that — the first load in each profile would keep using its own
+# doomed copy. ``heal_forked_single_use_oauth_grants`` runs at profile
+# ``load_pool()`` time: it finds the profile rows that share LINEAGE with a
+# root row (same pool id — clone-all and the old borrowed-persist both kept
+# it — or the same account identity / token material), keeps the copy most
+# likely to still be live (freshest rotation), writes that copy into ROOT when
+# root's is older, and strips the profile's copy so the profile borrows root
+# from then on. Idempotent (a healed profile has no matched rows), never
+# touches API-key rows, never deletes a row that has no root counterpart
+# (an independent ``hermes -p
auth add`` grant, or the only surviving
+# copy), and reads only the two auth.json files the existing root fallback
+# already reads — no environ / secret-scope reads.
+
+_OAUTH_TOKEN_FIELDS = (
+ "access_token",
+ "refresh_token",
+ "expires_at",
+ "expires_at_ms",
+ "last_refresh",
+)
+
+_oauth_heal_notices: List[str] = []
+# provider -> (profile auth.json path, auth.json mtime_ns, singleton mtime_ns)
+# of the last store verified fork-free; lets load_pool() skip the locked scan.
+_oauth_heal_clean_marks: Dict[str, Tuple[str, Optional[int], Optional[int]]] = {}
+
+
+def consume_oauth_heal_notices() -> List[str]:
+ """Return (and clear) human-readable notes about heals run in this process.
+
+ ``hermes auth list`` / ``hermes auth status`` print them so the user sees
+ that a forked grant was consolidated rather than only finding it in logs.
+ """
+ notes = list(_oauth_heal_notices)
+ _oauth_heal_notices.clear()
+ return notes
+
+
+def _oauth_identity(entry: Dict[str, Any]) -> Optional[str]:
+ """Stable account identity for an OAuth row when the token carries one.
+
+ Codex / xAI access tokens are JWTs with ``sub`` / ``email`` /
+ ``chatgpt_account_id`` claims; Anthropic ``sk-ant-oat`` tokens carry no
+ claims (returns None — lineage then rests on id / token material).
+ """
+ if not isinstance(entry, dict):
+ return None
+ for token in (entry.get("access_token"), entry.get("id_token")):
+ claims = _decode_jwt_claims(token)
+ if not claims:
+ continue
+ nested = claims.get("https://api.openai.com/auth")
+ account = nested.get("chatgpt_account_id") if isinstance(nested, dict) else None
+ for value in (account, claims.get("sub"), claims.get("email")):
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return None
+
+
+def _oauth_freshness(entry: Dict[str, Any]) -> float:
+ """Best-effort 'how recently was this pair issued' score (epoch seconds).
+
+ A rotation always issues a later-expiring access token, so ``expires_at``
+ ordering identifies the live copy; ``last_refresh`` and the JWT ``exp``
+ claim are fallbacks for rows that do not persist expiry.
+ """
+ from agent.credential_pool import _parse_absolute_timestamp
+
+ best = 0.0
+ for key in ("expires_at_ms", "expires_at", "last_refresh"):
+ ts = _parse_absolute_timestamp(entry.get(key))
+ if ts and ts > best:
+ best = ts
+ if best == 0.0:
+ exp = _decode_jwt_claims(entry.get("access_token")).get("exp")
+ ts = _parse_absolute_timestamp(exp)
+ if ts:
+ best = ts
+ return best
+
+
+def _find_root_counterpart(
+ profile_row: Dict[str, Any], root_rows: List[Dict[str, Any]]
+) -> Optional[int]:
+ """Index of the root OAuth row that shares a grant lineage with *profile_row*.
+
+ Strongest evidence first: same pool ``id`` (clone-all and the pre-fix
+ borrowed-persist both preserved it), same account identity from JWT
+ claims, same token material (an unrotated copy). Fallback per the
+ one-grant-at-root rule: same provider + same OAuth client — every
+ Anthropic ``hermes_pkce`` grant uses one client id and carries no claims,
+ so two Anthropic OAuth rows with no contrary identity are one lineage.
+ Only a row whose identity claims name a DIFFERENT account is left alone
+ (an independent ``hermes -p
auth add`` login for another account).
+ """
+ candidates = [i for i, r in enumerate(root_rows) if _is_oauth_pool_payload(r)]
+ if not candidates:
+ return None
+ pid = profile_row.get("id")
+ for i in candidates:
+ if pid and root_rows[i].get("id") == pid:
+ return i
+ p_ident = _oauth_identity(profile_row)
+ for i in candidates:
+ r_ident = _oauth_identity(root_rows[i])
+ if p_ident and r_ident and p_ident == r_ident:
+ return i
+ for key in ("refresh_token", "access_token"):
+ p_val = profile_row.get(key)
+ if not (isinstance(p_val, str) and p_val.strip()):
+ continue
+ for i in candidates:
+ if root_rows[i].get(key) == p_val:
+ return i
+ # Fallback: same provider + same client. Only a contradicting identity
+ # (both sides carry claims and they differ from every root row) blocks it.
+ if p_ident:
+ for i in candidates:
+ if not _oauth_identity(root_rows[i]):
+ return i
+ return None
+ return candidates[0]
+
+
+def _adopt_oauth_material(target: Dict[str, Any], winner: Dict[str, Any]) -> Dict[str, Any]:
+ """Return *target* carrying *winner*'s token pair, status markers cleared."""
+ merged = dict(target)
+ for key in _OAUTH_TOKEN_FIELDS:
+ if winner.get(key) is not None:
+ merged[key] = winner[key]
+ else:
+ merged.pop(key, None)
+ for status_field in _POOL_STATUS_FIELDS:
+ merged[status_field] = None
+ return merged
+
+
+def _singleton_as_row(path: Path) -> Optional[Dict[str, Any]]:
+ """Read a ``.anthropic_oauth.json`` as a pool-row-shaped dict, or None."""
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return None
+ if not isinstance(data, dict) or not str(data.get("accessToken") or "").strip():
+ return None
+ return {
+ "access_token": data.get("accessToken"),
+ "refresh_token": data.get("refreshToken"),
+ "expires_at_ms": data.get("expiresAt"),
+ }
+
+
+def heal_forked_single_use_oauth_grants(provider_id: str) -> Optional[Dict[str, Any]]:
+ """Consolidate a profile's forked copy of a single-use OAuth grant into root.
+
+ Runs only in profile mode for ``SINGLE_USE_REFRESH_POOL_PROVIDERS``.
+ Returns a summary ``{"adopted": bool, "stripped_ids": [...], "files": [...],
+ "providers_block": bool}`` when something was healed, else ``None``.
+ Never raises.
+ """
+ if provider_id not in SINGLE_USE_REFRESH_POOL_PROVIDERS:
+ return None
+ try:
+ return _heal_forked_single_use_oauth_grants(provider_id)
+ except Exception:
+ logger.debug("%s: forked-OAuth heal skipped", provider_id, exc_info=True)
+ return None
+
+
+def _heal_forked_single_use_oauth_grants(provider_id: str) -> Optional[Dict[str, Any]]:
+ root_path = _global_auth_file_path()
+ if root_path is None:
+ return None # classic mode: nothing to consolidate into
+ if os.environ.get("PYTEST_CURRENT_TEST"):
+ # Same seat belt as the write-through paths: never touch the real
+ # user's ~/.hermes/auth.json from a test that forgot to isolate HOME.
+ real_home_env = os.environ.get("HOME", "")
+ if real_home_env and _same_path(root_path, Path(real_home_env) / ".hermes" / "auth.json"):
+ return None
+ profile_path = _auth_file_path()
+ profile_home = profile_path.parent
+ root_home = root_path.parent
+ profile_singleton = profile_home / ".anthropic_oauth.json" if provider_id == "anthropic" else None
+
+ # Hot-path short-circuit: load_pool() runs per model call. Once this
+ # profile's store was verified clean for *provider_id*, skip the locked
+ # read-modify-write until the profile's own files change (mtime key).
+ def _stamp(p: Optional[Path]) -> Optional[int]:
+ try:
+ return p.stat().st_mtime_ns if p is not None else None
+ except OSError:
+ return None
+
+ fingerprint = (str(profile_path), _stamp(profile_path), _stamp(profile_singleton))
+ if _oauth_heal_clean_marks.get(provider_id) == fingerprint:
+ return None
+ if fingerprint[1] is None and fingerprint[2] is None:
+ _oauth_heal_clean_marks[provider_id] = fingerprint
+ return None
+
+ summary: Dict[str, Any] = {"adopted": False, "stripped_ids": [], "files": [], "providers_block": False}
+ log_bits: List[str] = []
+
+ # Lock order: active (profile) store first, then the root source store —
+ # the same order ``_provider_state_transaction`` uses.
+ with _auth_store_lock():
+ profile_store = _load_auth_store(profile_path) if profile_path.exists() else {"providers": {}}
+ with _auth_store_lock(target_path=root_path):
+ root_store = _load_auth_store(root_path) if root_path.exists() else {"providers": {}}
+ profile_changed = False
+ root_changed = False
+
+ p_pool = profile_store.get("credential_pool")
+ p_rows = p_pool.get(provider_id) if isinstance(p_pool, dict) else None
+ p_rows = p_rows if isinstance(p_rows, list) else []
+ r_pool = root_store.get("credential_pool")
+ r_rows = r_pool.get(provider_id) if isinstance(r_pool, dict) else None
+ r_rows = r_rows if isinstance(r_rows, list) else []
+ r_oauth = [r for r in r_rows if _is_oauth_pool_payload(r)]
+
+ root_singleton = root_home / ".anthropic_oauth.json" if provider_id == "anthropic" else None
+ root_singleton_row = (
+ _singleton_as_row(root_singleton)
+ if root_singleton is not None and root_singleton.exists() else None
+ )
+
+ # ── credential_pool rows ────────────────────────────────────
+ kept_rows: List[Any] = []
+ for row in p_rows:
+ if not _is_oauth_pool_payload(row):
+ kept_rows.append(row) # API keys are safe to duplicate
+ continue
+ match_idx = _find_root_counterpart(row, r_rows)
+ if match_idx is not None:
+ root_row = r_rows[match_idx]
+ if _oauth_freshness(row) > _oauth_freshness(root_row):
+ r_rows[match_idx] = _adopt_oauth_material(root_row, row)
+ root_changed = True
+ summary["adopted"] = True
+ summary["stripped_ids"].append(row.get("id"))
+ profile_changed = True
+ continue
+ # No root pool counterpart. Root's grant may live only in its
+ # .anthropic_oauth.json (the ``hermes auth`` PKCE shape); a
+ # profile hermes_pkce-family row is that grant's copy.
+ is_pkce = str(row.get("source") or "").endswith("hermes_pkce")
+ if is_pkce and root_singleton_row is not None and not r_oauth:
+ if _oauth_freshness(row) > _oauth_freshness(root_singleton_row):
+ root_singleton_row = _adopt_oauth_material(root_singleton_row, row)
+ summary["adopted"] = True
+ summary["stripped_ids"].append(row.get("id"))
+ profile_changed = True
+ continue
+ # Root holds no copy of this lineage (independent account, or
+ # root never had the grant): the profile's row may be the
+ # only surviving copy — leave it alone.
+ kept_rows.append(row)
+ if profile_changed and isinstance(p_pool, dict):
+ if kept_rows:
+ p_pool[provider_id] = kept_rows
+ else:
+ p_pool.pop(provider_id, None)
+
+ # ── providers. device-code blocks (Codex / xAI) ─────────
+ if provider_id in ("openai-codex", "xai-oauth"):
+ p_providers = profile_store.get("providers")
+ r_providers = root_store.get("providers")
+ if isinstance(p_providers, dict) and isinstance(r_providers, dict):
+ p_block = p_providers.get(provider_id)
+ r_block = r_providers.get(provider_id)
+ else:
+ p_block = r_block = None
+ if isinstance(p_block, dict) and p_block and isinstance(r_block, dict) and r_block:
+ p_tokens = p_block.get("tokens") if isinstance(p_block.get("tokens"), dict) else {}
+ r_tokens = r_block.get("tokens") if isinstance(r_block.get("tokens"), dict) else {}
+ p_flat = {**p_tokens, "last_refresh": p_block.get("last_refresh")}
+ r_flat = {**r_tokens, "last_refresh": r_block.get("last_refresh")}
+ p_ident, r_ident = _oauth_identity(p_flat), _oauth_identity(r_flat)
+ same_account = (p_ident == r_ident) if (p_ident and r_ident) else True
+ if same_account:
+ if _oauth_freshness(p_flat) > _oauth_freshness(r_flat):
+ r_providers[provider_id] = dict(p_block)
+ root_changed = True
+ summary["adopted"] = True
+ del p_providers[provider_id]
+ profile_changed = True
+ summary["providers_block"] = True
+
+ # ── profile-local .anthropic_oauth.json singleton ───────────
+ if profile_singleton is not None and profile_singleton.exists():
+ p_single = _singleton_as_row(profile_singleton)
+ root_has_grant = bool(r_oauth) or root_singleton_row is not None
+ if p_single is not None and root_has_grant:
+ if root_singleton_row is not None:
+ if _oauth_freshness(p_single) > _oauth_freshness(root_singleton_row):
+ root_singleton_row = _adopt_oauth_material(root_singleton_row, p_single)
+ summary["adopted"] = True
+ else:
+ # Root only has pool rows: fold the singleton's pair
+ # into the freshest-matching root pkce row, if any.
+ idx = next(
+ (i for i, r in enumerate(r_rows)
+ if _is_oauth_pool_payload(r)
+ and str(r.get("source") or "").endswith("hermes_pkce")),
+ None,
+ )
+ if idx is not None and _oauth_freshness(p_single) > _oauth_freshness(r_rows[idx]):
+ r_rows[idx] = _adopt_oauth_material(r_rows[idx], p_single)
+ root_changed = True
+ summary["adopted"] = True
+ try:
+ profile_singleton.unlink()
+ summary["files"].append(profile_singleton.name)
+ except OSError:
+ logger.debug("could not remove %s", profile_singleton, exc_info=True)
+ # Otherwise root has NO grant for this provider (or the file
+ # is not a grant): the profile's singleton may be the only
+ # surviving copy — never delete it.
+
+ if not (profile_changed or root_changed or summary["adopted"]):
+ _oauth_heal_clean_marks[provider_id] = fingerprint
+ return None
+
+ if summary["adopted"] and root_singleton is not None and root_singleton_row is not None:
+ # Keep root's singleton and its ``hermes_pkce``-seeded pool row
+ # in step: root's next load_pool() re-seeds that row FROM the
+ # singleton file, so a stale file would resurrect the spent
+ # pair (and a stale row would be overwritten by a fresh file).
+ pkce_idx = next(
+ (i for i, r in enumerate(r_rows)
+ if _is_oauth_pool_payload(r) and r.get("source") == "hermes_pkce"),
+ None,
+ )
+ if pkce_idx is not None:
+ pkce_row = r_rows[pkce_idx]
+ if _oauth_freshness(pkce_row) > _oauth_freshness(root_singleton_row):
+ root_singleton_row = _adopt_oauth_material(root_singleton_row, pkce_row)
+ elif _oauth_freshness(root_singleton_row) > _oauth_freshness(pkce_row):
+ r_rows[pkce_idx] = _adopt_oauth_material(pkce_row, root_singleton_row)
+ root_changed = True
+
+ if root_changed:
+ if isinstance(r_pool, dict):
+ r_pool[provider_id] = r_rows
+ else:
+ root_store["credential_pool"] = {provider_id: r_rows}
+ _save_auth_store(root_store, target_path=root_path)
+ if summary["adopted"] and root_singleton is not None and root_singleton_row is not None:
+ from agent.anthropic_credentials import _write_hermes_oauth_credentials
+ _write_hermes_oauth_credentials(
+ root_singleton_row.get("access_token") or "",
+ root_singleton_row.get("refresh_token"),
+ root_singleton_row.get("expires_at_ms"),
+ target=root_singleton,
+ )
+ if profile_changed and profile_path.exists():
+ _save_auth_store(profile_store, target_path=profile_path)
+
+ if summary["stripped_ids"]:
+ log_bits.append(f"pool rows {summary['stripped_ids']}")
+ if summary["providers_block"]:
+ log_bits.append(f"providers.{provider_id} block")
+ if summary["files"]:
+ log_bits.append(", ".join(summary["files"]))
+ verdict = (
+ "profile copy was the live pair; root updated"
+ if summary["adopted"] else "root copy already newest; profile copy dropped"
+ )
+ message = (
+ f"profile {profile_home.name}: consolidated forked {provider_id} OAuth grant "
+ f"({'; '.join(log_bits) or 'no-op'}) into the root grant — {verdict}; "
+ f"this profile now borrows the root grant (#100339)"
+ )
+ logger.info(message)
+ _oauth_heal_notices.append(message)
+ return summary
+
+
def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
"""Return the persisted credential pool, or one provider slice.
diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py
index 954c173cd2..3699032885 100644
--- a/hermes_cli/auth_commands.py
+++ b/hermes_cli/auth_commands.py
@@ -557,6 +557,13 @@ def auth_list_command(args) -> None:
source = _display_source(entry.source)
print(f" #{idx} {entry.label:<20} {entry.auth_type:<7} {source}{status} {marker}".rstrip())
print()
+ _print_oauth_heal_notices()
+
+
+def _print_oauth_heal_notices() -> None:
+ """Tell the user when load_pool() just consolidated a forked OAuth grant."""
+ for note in auth_mod.consume_oauth_heal_notices():
+ print(f"note: {note}")
def auth_remove_command(args) -> None:
@@ -608,7 +615,12 @@ def auth_status_command(args) -> None:
provider = _normalize_provider(getattr(args, "provider", "") or "")
if not provider:
raise SystemExit("Provider is required. Example: `hermes auth status spotify`.")
+ if provider in auth_mod.SINGLE_USE_REFRESH_POOL_PROVIDERS:
+ # load_pool() runs the forked-grant heal (#100339); do it before the
+ # status read so the report reflects the consolidated grant.
+ load_pool(provider)
status = auth_mod.get_auth_status(provider)
+ _print_oauth_heal_notices()
if not status.get("logged_in"):
reason = status.get("error")
if reason:
diff --git a/tests/agent/test_credential_pool_profile_oauth_fork.py b/tests/agent/test_credential_pool_profile_oauth_fork.py
index a0f5ecd1f2..057db3021d 100644
--- a/tests/agent/test_credential_pool_profile_oauth_fork.py
+++ b/tests/agent/test_credential_pool_profile_oauth_fork.py
@@ -96,6 +96,12 @@ def fleet(tmp_path, monkeypatch):
hermes_constants._default_hermes_root_memo = None # type: ignore[attr-defined]
import hermes_cli.auth as auth_mod
auth_mod._global_auth_store_cache = None
+ auth_mod._oauth_heal_clean_marks.clear()
+
+ # Process-wide notice buffer: start each test clean.
+ import hermes_cli.auth as _auth_mod
+ _auth_mod._oauth_heal_notices.clear()
+ _auth_mod._oauth_heal_clean_marks.clear()
def pool_rows(home):
p = home / "auth.json"
@@ -255,3 +261,192 @@ def test_classic_mode_persist_is_unchanged(fleet):
sel = load_pool("anthropic").select()
assert sel is not None and sel.access_token == "sk-ant-oat01-AT1"
assert fleet["rows"](fleet["root"])[0]["refresh_token"] == "sk-ant-ort-RT1"
+
+
+# ── C. one-time heal for installs that ALREADY forked the grant ──────────
+#
+# Fleets created on pre-fix code hold profile-local copies of the root grant
+# (verbatim --clone-all, or the old borrowed-persist). The heal runs inside
+# the profile's load_pool(): consolidate to ROOT (freshest rotation wins),
+# strip the profile copy, borrow root from then on.
+
+def _fork(fleet, name, *, rotated_to=None):
+ """Create *name* with a pre-fix style verbatim copy of root's auth.json.
+
+ ``rotated_to=N`` makes the copy the LIVE pair (RT, spent RT0 server-side)
+ to emulate a profile that already refreshed on the old code.
+ """
+ pdir = _profile(fleet, name)
+ pdir.mkdir(parents=True, exist_ok=True)
+ store = json.loads((fleet["root"] / "auth.json").read_text())
+ if rotated_to is not None:
+ row = store["credential_pool"]["anthropic"][0]
+ row["access_token"] = f"sk-ant-oat01-AT{rotated_to}"
+ row["refresh_token"] = f"sk-ant-ort-RT{rotated_to}"
+ row["expires_at_ms"] = int((time.time() - 60) * 1000) # newer, still expired
+ srv = fleet["server"]
+ srv["spent"].add("sk-ant-ort-RT0")
+ srv["valid"].discard("sk-ant-ort-RT0")
+ srv["valid"].add(f"sk-ant-ort-RT{rotated_to}")
+ srv["n"] = rotated_to
+ (pdir / "auth.json").write_text(json.dumps(store))
+ return pdir
+
+
+def test_heal_consolidates_existing_forks_to_the_live_copy(fleet, caplog):
+ """root + atlas hold spent RT0; forge already rotated to RT1 on old code."""
+ import logging
+ from agent.credential_pool import load_pool
+
+ forge = _fork(fleet, "forge", rotated_to=1)
+ atlas = _fork(fleet, "atlas")
+ assert fleet["rows"](forge)[0]["refresh_token"] == "sk-ant-ort-RT1"
+ assert fleet["rows"](atlas)[0]["refresh_token"] == "sk-ant-ort-RT0"
+
+ with caplog.at_level(logging.INFO, logger="hermes_cli.auth"):
+ fleet["use"](forge)
+ sel = load_pool("anthropic").select()
+ assert sel is not None and sel.access_token == "sk-ant-oat01-AT2"
+ # forge's live pair was adopted by ROOT, then rotated there; forge holds nothing.
+ assert fleet["rows"](forge) is None
+ assert fleet["rows"](fleet["root"])[0]["refresh_token"] == "sk-ant-ort-RT2"
+ assert fleet["rows"](fleet["root"])[0]["id"] == "abc123"
+ healed = [r.message for r in caplog.records if "consolidated forked anthropic OAuth grant" in r.message]
+ assert len(healed) == 1 and "profile forge" in healed[0] and "root updated" in healed[0]
+
+ for home in (atlas, fleet["root"], forge):
+ fleet["use"](home)
+ sel = load_pool("anthropic").select()
+ assert sel is not None and sel.access_token == "sk-ant-oat01-AT2", home
+ assert fleet["rows"](atlas) is None and fleet["rows"](forge) is None
+ # Exactly one rotation by us (RT1 -> RT2); the spent RT0 was never replayed.
+ assert [e[0] for e in fleet["server"]["log"]] == ["ROTATE"], fleet["server"]["log"]
+ # API-key rows in the profiles were not touched.
+ for home in (forge, atlas):
+ store = json.loads((home / "auth.json").read_text())
+ assert store["credential_pool"]["openai"][0]["access_token"] == "sk-static-key"
+
+
+def test_heal_is_idempotent_and_logs_once(fleet, caplog):
+ import logging
+ from agent.credential_pool import load_pool
+ from hermes_cli.auth import consume_oauth_heal_notices, heal_forked_single_use_oauth_grants
+
+ kid = _fork(fleet, "kid")
+ fleet["use"](kid)
+ with caplog.at_level(logging.INFO, logger="hermes_cli.auth"):
+ load_pool("anthropic")
+ assert fleet["rows"](kid) is None
+ notices = consume_oauth_heal_notices()
+ assert len(notices) == 1 and "profile kid" in notices[0]
+ root_before = (fleet["root"] / "auth.json").read_text()
+ # Second and third loads: nothing to do, nothing written, nothing logged.
+ assert heal_forked_single_use_oauth_grants("anthropic") is None
+ load_pool("anthropic")
+ assert consume_oauth_heal_notices() == []
+ assert (fleet["root"] / "auth.json").read_text() == root_before
+ assert sum("consolidated forked" in r.message for r in caplog.records) == 1
+
+
+def test_heal_never_deletes_the_only_surviving_copy(fleet):
+ """Root lost its grant (user ran `hermes auth remove` at root); the profile's
+ copy is the only one left — and an independent second account stays put."""
+ from agent.credential_pool import load_pool
+
+ kid = _fork(fleet, "kid", rotated_to=1)
+ store = json.loads((fleet["root"] / "auth.json").read_text())
+ del store["credential_pool"]["anthropic"]
+ (fleet["root"] / "auth.json").write_text(json.dumps(store))
+
+ fleet["use"](kid)
+ sel = load_pool("anthropic").select()
+ assert sel is not None and sel.access_token == "sk-ant-oat01-AT2"
+ assert fleet["rows"](kid) and fleet["rows"](kid)[0]["refresh_token"] == "sk-ant-ort-RT2"
+ assert "anthropic" not in (json.loads((fleet["root"] / "auth.json").read_text())["credential_pool"])
+
+
+def test_heal_leaves_a_different_account_alone(fleet):
+ """A profile row whose JWT identity names ANOTHER account is not root's grant."""
+ import base64
+ from agent.credential_pool import load_pool
+
+ def jwt(sub):
+ payload = base64.urlsafe_b64encode(json.dumps({"sub": sub, "exp": int(time.time()) + 3600}).encode()).rstrip(b"=")
+ return "h." + payload.decode() + ".s"
+
+ root_store = json.loads((fleet["root"] / "auth.json").read_text())
+ root_store["credential_pool"]["xai-oauth"] = [{
+ "id": "rootx", "auth_type": "oauth", "priority": 0, "source": "manual:device_code",
+ "access_token": jwt("alice"), "refresh_token": "xr-alice",
+ }]
+ (fleet["root"] / "auth.json").write_text(json.dumps(root_store))
+ kid = _profile(fleet, "kid")
+ kid.mkdir(parents=True, exist_ok=True)
+ (kid / "auth.json").write_text(json.dumps({
+ "version": 1, "providers": {},
+ "credential_pool": {"xai-oauth": [
+ {"id": "kidx", "auth_type": "oauth", "priority": 0, "source": "manual:device_code",
+ "access_token": jwt("bob"), "refresh_token": "xr-bob"},
+ {"id": "kidk", "auth_type": "api_key", "priority": 1, "source": "manual",
+ "access_token": "xai-static"},
+ ]},
+ }))
+ fleet["use"](kid)
+ load_pool("xai-oauth")
+ rows = (json.loads((kid / "auth.json").read_text())["credential_pool"])["xai-oauth"]
+ assert [r["id"] for r in rows] == ["kidx", "kidk"]
+ assert json.loads((fleet["root"] / "auth.json").read_text())["credential_pool"]["xai-oauth"][0]["refresh_token"] == "xr-alice"
+
+
+def test_heal_pkce_singleton_shape_commits_live_pair_to_root_singleton(fleet):
+ """`hermes auth` PKCE shape: root + profile each have .anthropic_oauth.json +
+ a hermes_pkce-seeded row; the profile's copy is the rotated (live) one."""
+ from agent.credential_pool import load_pool
+
+ root = fleet["root"]
+ store = json.loads((root / "auth.json").read_text())
+ store["active_provider"] = "anthropic"
+ del store["credential_pool"]["anthropic"]
+ (root / "auth.json").write_text(json.dumps(store))
+ (root / ".anthropic_oauth.json").write_text(json.dumps({
+ "accessToken": "sk-ant-oat01-AT0", "refreshToken": "sk-ant-ort-RT0",
+ "expiresAt": int((time.time() - 3600) * 1000),
+ }))
+ fleet["use"](root)
+ load_pool("anthropic") # seeds root's hermes_pkce row from the singleton
+
+ kid = _profile(fleet, "kid")
+ kid.mkdir(parents=True, exist_ok=True)
+ import shutil
+ shutil.copy2(root / "auth.json", kid / "auth.json")
+ (kid / ".anthropic_oauth.json").write_text(json.dumps({
+ "accessToken": "sk-ant-oat01-AT1", "refreshToken": "sk-ant-ort-RT1",
+ "expiresAt": int((time.time() - 60) * 1000),
+ }))
+ kstore = json.loads((kid / "auth.json").read_text())
+ kstore["credential_pool"]["anthropic"][0].update(
+ access_token="sk-ant-oat01-AT1", refresh_token="sk-ant-ort-RT1",
+ expires_at_ms=int((time.time() - 60) * 1000),
+ )
+ (kid / "auth.json").write_text(json.dumps(kstore))
+ srv = fleet["server"]
+ srv["spent"].add("sk-ant-ort-RT0"); srv["valid"] = {"sk-ant-ort-RT1"}; srv["n"] = 1
+
+ fleet["use"](kid)
+ sel = load_pool("anthropic").select()
+ assert sel is not None and sel.access_token == "sk-ant-oat01-AT2"
+ assert not (kid / ".anthropic_oauth.json").exists()
+ assert fleet["rows"](kid) is None
+ assert json.loads((root / ".anthropic_oauth.json").read_text())["refreshToken"] == "sk-ant-ort-RT2"
+ fleet["use"](root)
+ sel = load_pool("anthropic").select()
+ assert sel is not None and sel.access_token == "sk-ant-oat01-AT2"
+ assert [e[0] for e in srv["log"]] == ["ROTATE"], srv["log"]
+
+
+def test_heal_is_a_noop_in_classic_mode(fleet):
+ from hermes_cli.auth import heal_forked_single_use_oauth_grants
+ fleet["use"](fleet["root"])
+ before = (fleet["root"] / "auth.json").read_text()
+ assert heal_forked_single_use_oauth_grants("anthropic") is None
+ assert (fleet["root"] / "auth.json").read_text() == before