fix(auth): persist first profile OAuth row with empty root

(cherry picked from commit 5293489825af98e3e3f0e6cb485ffffa72d14833)
(cherry picked from commit 4144e14bfa7c021a92db5b0f66a75e2e0ac767d1)
This commit is contained in:
Lluis Faus
2026-09-02 22:49:57 -04:00
committed by Mark S.
parent f383e66b3e
commit c5087bad02
2 changed files with 55 additions and 10 deletions

View File

@@ -108,22 +108,37 @@ class CredentialPoolAdminMixin:
return None, None, f'No credential matching "{raw}".'
def add_entry(self, entry: PooledCredential) -> PooledCredential:
from agent.credential_pool import _next_priority, write_credential_pool
from agent.credential_pool import (
SINGLE_USE_REFRESH_POOL_PROVIDERS, _borrowed_single_use_pool_root, _next_priority,
_profile_owns_pool_provider, write_credential_pool,
)
from hermes_cli import auth as auth_mod
with self._lock:
entry = replace(entry, priority=_next_priority(self._entries))
self._entries.append(entry)
borrowed_ids = getattr(self, "_borrowed_root_ids", None)
if borrowed_ids is not None:
borrowed_ids = set(getattr(self, "_borrowed_root_ids", ()) or ())
# A named profile adding its FIRST single-use OAuth credential
# must claim the row in its own store even when root has no row to
# borrow. In that empty-root case ``_borrowed_root_ids`` is empty,
# but the generic ``_persist`` path still classifies the profile as
# a borrower and sends the payload through persist_pool_entries'
# update-only root merge. With no matching root id the new row is
# dropped while ``hermes auth add`` reports success.
profile_claims_first_single_use_row = (
self.provider in SINGLE_USE_REFRESH_POOL_PROVIDERS
and not _profile_owns_pool_provider(self.provider)
and _borrowed_single_use_pool_root() is not None
)
if borrowed_ids or profile_claims_first_single_use_row:
# ``hermes -p <profile> auth add <single-use provider>``: the
# profile claims its OWN credential. Persist only profile-owned
# rows — copying any borrowed root grant alongside would fork
# its single-use refresh token (#100339). Once the profile owns
# rows, the root fallback for this provider is shadowed.
# When root has no rows yet (borrowed_ids is empty set), this
# fresh grant must persist to the profile store rather than
# routing to the update-only root merge (#103694).
# profile is claiming its OWN credential. Persist only the
# profile-owned rows locally — copying the borrowed root
# grant alongside them would fork its single-use refresh
# token (#100339). The same local-claim path is required when
# root and profile are both empty. Once the profile
# owns rows, the root fallback for this provider is shadowed
# (existing contract).
self._entries = [e for e in self._entries if e.id not in borrowed_ids]
written = write_credential_pool(
self.provider, [e.to_dict() for e in self._entries],

View File

@@ -393,6 +393,36 @@ def test_profile_auth_add_on_empty_root_persists_to_profile(fleet):
assert fleet["rows"](root) is None
def test_profile_auth_add_persists_when_root_and_profile_are_empty(fleet):
"""A named-profile OAuth login must not report success then
discard the new row when neither the root nor profile has that provider.
With no borrowed root ids, add_entry previously used _persist(); the
borrowed-root safety path is update-only and therefore ignored the brand-
new id. This is the exact empty-store state after a lost shared auth.json.
"""
from agent.credential_pool import AUTH_TYPE_OAUTH, PooledCredential, load_pool
root = fleet["root"]
store = json.loads((root / "auth.json").read_text())
del store["credential_pool"]["anthropic"]
(root / "auth.json").write_text(json.dumps(store))
kid = _profile(fleet, "empty-kid")
fleet["use"](kid)
pool = load_pool("anthropic")
assert pool.entries() == []
assert pool._borrowed_root_ids == set()
pool.add_entry(PooledCredential(
provider="anthropic", id="own-empty", label="mine", auth_type=AUTH_TYPE_OAUTH,
priority=0, source="manual:hermes_pkce", access_token="at-mine-empty",
refresh_token="rt-mine-empty",
))
assert [e["id"] for e in fleet["rows"](kid)] == ["own-empty"]
assert fleet["rows"](root) is None
def test_classic_mode_persist_is_unchanged(fleet):
from agent.credential_pool import load_pool