From c5087bad02ca5cb88d7acef4f2ffc62eee356005 Mon Sep 17 00:00:00 2001 From: Lluis Faus <159845+lfaus@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:49:57 -0400 Subject: [PATCH] fix(auth): persist first profile OAuth row with empty root (cherry picked from commit 5293489825af98e3e3f0e6cb485ffffa72d14833) (cherry picked from commit 4144e14bfa7c021a92db5b0f66a75e2e0ac767d1) --- agent/credential_pool_admin.py | 35 +++++++++++++------ ...test_credential_pool_profile_oauth_fork.py | 30 ++++++++++++++++ 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/agent/credential_pool_admin.py b/agent/credential_pool_admin.py index a073adf325..ccf7476326 100644 --- a/agent/credential_pool_admin.py +++ b/agent/credential_pool_admin.py @@ -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 auth add ``: 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], diff --git a/tests/agent/test_credential_pool_profile_oauth_fork.py b/tests/agent/test_credential_pool_profile_oauth_fork.py index af007739d8..1ced29e942 100644 --- a/tests/agent/test_credential_pool_profile_oauth_fork.py +++ b/tests/agent/test_credential_pool_profile_oauth_fork.py @@ -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