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