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:
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user