diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 9ec61587ab..a3c6e1695d 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1263,18 +1263,17 @@ def _same_path(left: Path, right: Path) -> bool: def _is_same_auth_store(left: Path, right: Path) -> bool: """True when two auth paths name ONE store rather than two copies. - ``_same_path`` already resolves symlinks and ``..`` segments; a hardlinked - (or bind-mounted) alias keeps two distinct resolved names for one inode, so - fall back to filesystem identity. Used by the forked-grant heal: a shared - store has no "other side" to consolidate (#101356). + ``_same_path`` resolves symlinks and ``..``; ``samefile`` adds hardlinks + and bind-mounts (same inode under two resolved names). Used by the + forked-grant heal: a shared store has no "other side" to consolidate + (#101356). """ if _same_path(left, right): return True try: - left_stat, right_stat = left.stat(), right.stat() + return left.samefile(right) except OSError: return False - return (left_stat.st_dev, left_stat.st_ino) == (right_stat.st_dev, right_stat.st_ino) def _auth_lock_holder_for(target_path: Path) -> threading.local: @@ -2022,17 +2021,6 @@ def _heal_forked_single_use_oauth_grants(provider_id: str) -> Optional[Dict[str, if real_home_env and _same_path(root_path, Path(real_home_env) / ".hermes" / "auth.json"): return None profile_path = _auth_file_path() - if _is_same_auth_store(profile_path, root_path): - # The profile's auth.json IS the root store (symlink, hardlink, or any - # other alias — a deliberate way to share one grant across profiles). - # Both "sides" of the consolidation below would read the same file, so - # every OAuth row would match itself as its own fork and the strip - # would write through the alias and delete the shared credential. - # A shared store has nothing to consolidate (#101356). - logger.debug( - "%s: forked-OAuth heal skipped, %s is the root store", provider_id, profile_path - ) - return None profile_home = profile_path.parent root_home = root_path.parent profile_singleton = profile_home / ".anthropic_oauth.json" if provider_id == "anthropic" else None @@ -2052,6 +2040,16 @@ def _heal_forked_single_use_oauth_grants(provider_id: str) -> Optional[Dict[str, if fingerprint[1] is None and fingerprint[2] is None: _oauth_heal_clean_marks[provider_id] = fingerprint return None + if _is_same_auth_store(profile_path, root_path): + # The profile's auth.json IS the root store (symlink/hardlink alias — + # a deliberate way to share one grant). Both "sides" below would read + # the same file, every OAuth row would match itself, and the strip + # would write through the alias and delete the shared credential. + # Nothing to consolidate (#101356); the mtime mark keeps this off the + # per-call hot path until the shared file changes. + _oauth_heal_clean_marks[provider_id] = fingerprint + logger.debug("%s: forked-OAuth heal skipped, %s is the root store", provider_id, profile_path) + return None summary: Dict[str, Any] = {"adopted": False, "stripped_ids": [], "files": [], "providers_block": False} log_bits: List[str] = [] @@ -2142,7 +2140,13 @@ def _heal_forked_single_use_oauth_grants(provider_id: str) -> Optional[Dict[str, summary["providers_block"] = True # ── profile-local .anthropic_oauth.json singleton ─────────── - if profile_singleton is not None and profile_singleton.exists(): + if ( + profile_singleton is not None + and profile_singleton.exists() + # An aliased singleton pair is one shared grant, not a fork + # (#101356): never self-compare or unlink it. + and not (root_singleton is not None and _is_same_auth_store(profile_singleton, root_singleton)) + ): 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: diff --git a/tests/agent/test_credential_pool_profile_oauth_fork.py b/tests/agent/test_credential_pool_profile_oauth_fork.py index d21f84f05e..eec0c6c52e 100644 --- a/tests/agent/test_credential_pool_profile_oauth_fork.py +++ b/tests/agent/test_credential_pool_profile_oauth_fork.py @@ -518,3 +518,44 @@ def test_heal_skips_profile_auth_json_hardlinked_to_the_root_store(fleet): assert heal_forked_single_use_oauth_grants("openai-codex") is None assert (root / "auth.json").read_text() == before assert (shared / "auth.json").samefile(root / "auth.json") + + +def test_heal_leaves_an_aliased_anthropic_singleton_alone(fleet): + """Separate auth.jsons but a profile `.anthropic_oauth.json` symlinked to + root's: one shared grant, not a fork. The heal must not self-compare it + or unlink the alias (#101356 sibling site).""" + from hermes_cli.auth import heal_forked_single_use_oauth_grants + + root = fleet["root"] + (root / ".anthropic_oauth.json").write_text(json.dumps({ + "accessToken": "AT-shared", "refreshToken": "RT-shared", + "expiresAt": int((time.time() + 3600) * 1000), + })) + kid = _profile(fleet, "kid") + kid.mkdir(parents=True, exist_ok=True) + (kid / "auth.json").write_text(json.dumps({"providers": {}, "credential_pool": {}})) + (kid / ".anthropic_oauth.json").symlink_to(root / ".anthropic_oauth.json") + before = (root / ".anthropic_oauth.json").read_text() + + fleet["use"](kid) + assert heal_forked_single_use_oauth_grants("anthropic") is None + assert (kid / ".anthropic_oauth.json").is_symlink() + assert (root / ".anthropic_oauth.json").read_text() == before + + +def test_heal_same_store_skip_is_memoized_off_the_hot_path(fleet, monkeypatch): + """The shared-store skip must record the clean mark so load_pool()'s + per-call heal does not re-stat/resolve both paths every model call.""" + from hermes_cli import auth as auth_mod + + root = fleet["root"] + _seed_codex_grant(root) + shared = _shared_profile(fleet, "shared", link=lambda target, alias: alias.symlink_to(target)) + fleet["use"](shared) + + assert auth_mod.heal_forked_single_use_oauth_grants("openai-codex") is None + assert "openai-codex" in auth_mod._oauth_heal_clean_marks + calls = [] + monkeypatch.setattr(auth_mod, "_is_same_auth_store", lambda *a: calls.append(a) or True) + assert auth_mod.heal_forked_single_use_oauth_grants("openai-codex") is None + assert calls == [], "same-store check ran again despite the clean mark"