282 lines
10 KiB
Python
282 lines
10 KiB
Python
"""The borrowed ``claude_code`` row is a reference, never a token authority.
|
|
|
|
``claude_code`` is absent from ``_PERSISTABLE_PROVIDER_SOURCES``, so
|
|
``sanitize_borrowed_credential_payload`` strips ``access_token`` and
|
|
``refresh_token`` before the pool row reaches ``auth.json``: what survives on
|
|
disk is provenance, status and a ``secret_fingerprint``. ``load_pool()``
|
|
re-hydrates the live pair from ``~/.claude/.credentials.json`` on every load,
|
|
which is what makes the singleton -- not the pool store -- authoritative for
|
|
this source.
|
|
|
|
Two failure modes follow from forgetting that, and both are covered here:
|
|
|
|
1. ``_sync_anthropic_entry_from_pool_store()`` re-reads the persisted row
|
|
during refresh. For a borrowed source that row has *no* tokens, so it
|
|
"differs" from the live entry and was adopted as though another process had
|
|
rotated the pair -- blanking a usable credential and returning before
|
|
``_claude_code_credentials_lock()`` and the authoritative re-read were ever
|
|
entered.
|
|
2. ``_available_entries()`` only refused to lease empty *API-key* rows, so the
|
|
blanked OAuth entry stayed selectable and would have been sent as an empty
|
|
bearer.
|
|
|
|
The existing race/write-through suites build ``CredentialPool`` objects
|
|
directly or back the store with unsanitized in-memory rows, so neither crosses
|
|
the real persistence boundary. Every test below starts from ``load_pool()``
|
|
reading an actually persisted, actually sanitized row.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from dataclasses import replace as dc_replace
|
|
|
|
import pytest
|
|
|
|
from agent import anthropic_credentials as AA
|
|
from agent.credential_persistence import sanitize_borrowed_credential_payload
|
|
from agent.credential_pool import (
|
|
AUTH_TYPE_OAUTH,
|
|
CredentialPool,
|
|
PooledCredential,
|
|
load_pool,
|
|
)
|
|
|
|
_EXPIRED_MS = 1_000
|
|
|
|
_STALE_ACCESS = "sk-ant-oat01-borrowed-stale"
|
|
_STALE_REFRESH = "sk-ant-ort01-borrowed-stale"
|
|
_ROTATED_ACCESS = "sk-ant-oat01-borrowed-rotated"
|
|
_ROTATED_REFRESH = "sk-ant-ort01-borrowed-rotated"
|
|
|
|
|
|
@pytest.fixture
|
|
def hermes_home(tmp_path, monkeypatch):
|
|
"""Real on-disk HERMES_HOME so ``load_pool()`` re-reads what it persisted."""
|
|
home = tmp_path / "hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
for var in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
(home / "auth.json").write_text(
|
|
json.dumps({"version": 1, "providers": {}}), encoding="utf-8"
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.auth.is_provider_explicitly_configured", lambda pid: True
|
|
)
|
|
return home
|
|
|
|
|
|
@pytest.fixture
|
|
def claude_credentials(tmp_path, monkeypatch):
|
|
"""Point the ``claude_code`` singleton at a tmp file holding a stale pair."""
|
|
cred_path = tmp_path / "claude" / ".credentials.json"
|
|
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
|
cred_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"claudeAiOauth": {
|
|
"accessToken": _STALE_ACCESS,
|
|
"refreshToken": _STALE_REFRESH,
|
|
"expiresAt": _EXPIRED_MS,
|
|
"scopes": ["user:inference", "user:profile"],
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(AA, "claude_code_credentials_path", lambda: cred_path)
|
|
monkeypatch.setattr(AA, "_read_claude_code_credentials_from_keychain", lambda: None)
|
|
return cred_path
|
|
|
|
|
|
def _persisted_rows(home):
|
|
store = json.loads((home / "auth.json").read_text(encoding="utf-8"))
|
|
return store.get("credential_pool", {}).get("anthropic", [])
|
|
|
|
|
|
def _claude_pair(cred_path):
|
|
data = json.loads(cred_path.read_text(encoding="utf-8"))["claudeAiOauth"]
|
|
return data["accessToken"], data["refreshToken"]
|
|
|
|
|
|
def _rotating_refresh(refresh_token, **_kw):
|
|
return {
|
|
"access_token": _ROTATED_ACCESS,
|
|
"refresh_token": _ROTATED_REFRESH,
|
|
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
|
|
}
|
|
|
|
|
|
def test_persisted_claude_code_row_carries_no_token_material(
|
|
hermes_home, claude_credentials
|
|
):
|
|
"""Baseline: the row the refresh path re-reads really is sanitized.
|
|
|
|
Every other test in this file only means something if the disk row is
|
|
token-less, so assert the boundary rather than assuming it.
|
|
"""
|
|
pool = load_pool("anthropic")
|
|
|
|
live = [e for e in pool._entries if e.source == "claude_code"]
|
|
assert len(live) == 1
|
|
assert live[0].access_token == _STALE_ACCESS, (
|
|
"load_pool must hydrate the live pair from the singleton"
|
|
)
|
|
|
|
rows = [r for r in _persisted_rows(hermes_home) if r.get("source") == "claude_code"]
|
|
assert len(rows) == 1
|
|
assert not rows[0].get("access_token")
|
|
assert not rows[0].get("refresh_token")
|
|
assert str(rows[0].get("secret_fingerprint", "")).startswith("sha256:")
|
|
assert sanitize_borrowed_credential_payload(rows[0], "anthropic") == rows[0]
|
|
|
|
|
|
def test_pool_store_sync_never_adopts_a_borrowed_row(hermes_home, claude_credentials):
|
|
"""The sanitized row must not be mistaken for a rotation by another process."""
|
|
pool = load_pool("anthropic")
|
|
entry = next(e for e in pool._entries if e.source == "claude_code")
|
|
|
|
synced = pool._sync_anthropic_entry_from_pool_store(entry)
|
|
|
|
assert synced is entry, "a borrowed row is a reference, not token authority"
|
|
assert synced.access_token == _STALE_ACCESS
|
|
assert synced.refresh_token == _STALE_REFRESH
|
|
|
|
|
|
def test_refresh_from_persisted_sanitized_row_keeps_the_full_pair(
|
|
hermes_home, claude_credentials, monkeypatch
|
|
):
|
|
"""The production ``load -> sanitize -> refresh`` path refreshes, not blanks.
|
|
|
|
Exactly one POST and one authoritative write, the returned entry carries
|
|
the complete rotated pair, and the shared credentials file is the copy that
|
|
was updated.
|
|
"""
|
|
posts = []
|
|
writes = []
|
|
|
|
def _counting_refresh(refresh_token, **kwargs):
|
|
posts.append(refresh_token)
|
|
return _rotating_refresh(refresh_token, **kwargs)
|
|
|
|
real_write = AA._write_claude_code_credentials
|
|
|
|
def _counting_write(access_token, refresh_token, expires_at_ms):
|
|
writes.append(refresh_token)
|
|
return real_write(access_token, refresh_token, expires_at_ms)
|
|
|
|
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _counting_refresh)
|
|
monkeypatch.setattr(AA, "_write_claude_code_credentials", _counting_write)
|
|
|
|
pool = load_pool("anthropic")
|
|
entry = next(e for e in pool._entries if e.source == "claude_code")
|
|
|
|
refreshed = pool._refresh_entry(entry, force=True)
|
|
|
|
assert refreshed is not None, "the refresh must not be abandoned"
|
|
assert refreshed.access_token == _ROTATED_ACCESS
|
|
assert refreshed.refresh_token == _ROTATED_REFRESH
|
|
assert posts == [_STALE_REFRESH], f"expected exactly one POST, got {posts}"
|
|
assert writes == [_ROTATED_REFRESH], f"expected exactly one commit, got {writes}"
|
|
assert _claude_pair(claude_credentials) == (_ROTATED_ACCESS, _ROTATED_REFRESH)
|
|
|
|
|
|
def test_refresh_reaches_the_shared_credentials_lock(
|
|
hermes_home, claude_credentials, monkeypatch
|
|
):
|
|
"""``claude_code`` must always take the path-keyed lock before deciding.
|
|
|
|
That lock is what serializes profiles sharing one
|
|
``~/.claude/.credentials.json``; an adopt-and-return shortcut firing first
|
|
would leave the cross-profile race exactly where it was.
|
|
"""
|
|
taken = []
|
|
real_lock = CredentialPool._claude_code_credentials_lock
|
|
|
|
def _tracking_lock(self):
|
|
taken.append(True)
|
|
return real_lock(self)
|
|
|
|
monkeypatch.setattr(CredentialPool, "_claude_code_credentials_lock", _tracking_lock)
|
|
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
|
|
|
pool = load_pool("anthropic")
|
|
entry = next(e for e in pool._entries if e.source == "claude_code")
|
|
pool._refresh_entry(entry, force=True)
|
|
|
|
assert taken, "the authoritative re-read must happen under the shared-file lock"
|
|
|
|
|
|
def test_empty_oauth_entry_is_never_leased(hermes_home, claude_credentials):
|
|
"""A token-less OAuth row must not be selectable as an empty bearer.
|
|
|
|
The pre-existing guard covered ``AUTH_TYPE_API_KEY`` only, so an OAuth row
|
|
that failed to hydrate went straight into the available list.
|
|
"""
|
|
pool = load_pool("anthropic")
|
|
entry = next(e for e in pool._entries if e.source == "claude_code")
|
|
blanked = dc_replace(entry, access_token="", refresh_token="")
|
|
pool._replace_entry(entry, blanked)
|
|
|
|
available, _pending = pool._available_entries(clear_expired=False, refresh=False)
|
|
|
|
assert all(e.access_token for e in available), (
|
|
"an OAuth entry with no access token must never be leased"
|
|
)
|
|
assert blanked.id not in {e.id for e in available}
|
|
|
|
|
|
def test_selection_after_refresh_leases_only_hydrated_entries(
|
|
hermes_home, claude_credentials, monkeypatch
|
|
):
|
|
"""End-to-end: refresh through selection leaves a usable, non-empty lease."""
|
|
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
|
|
|
pool = load_pool("anthropic")
|
|
available, _pending = pool._available_entries(clear_expired=True, refresh=True)
|
|
|
|
assert available, "the credential must survive the refresh, not be dropped"
|
|
assert all(e.access_token for e in available)
|
|
assert any(e.access_token == _ROTATED_ACCESS for e in available)
|
|
|
|
|
|
def test_hermes_pkce_row_still_syncs_from_the_pool_store(monkeypatch):
|
|
"""The borrowed-source refusal must not disable pool-owned adoption.
|
|
|
|
``hermes_pkce`` *is* pool-owned, so its persisted row keeps its tokens and
|
|
stays a legitimate rotation witness for another pool instance.
|
|
"""
|
|
rotated = {
|
|
"id": "anthropic-pkce",
|
|
"label": "anthropic oauth",
|
|
"auth_type": AUTH_TYPE_OAUTH,
|
|
"priority": 0,
|
|
"source": "hermes_pkce",
|
|
"access_token": _ROTATED_ACCESS,
|
|
"refresh_token": _ROTATED_REFRESH,
|
|
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
|
|
}
|
|
monkeypatch.setattr(
|
|
"agent.credential_pool.read_credential_pool", lambda provider=None: [rotated]
|
|
)
|
|
|
|
entry = PooledCredential(
|
|
provider="anthropic",
|
|
id="anthropic-pkce",
|
|
label="anthropic oauth",
|
|
auth_type=AUTH_TYPE_OAUTH,
|
|
priority=0,
|
|
source="hermes_pkce",
|
|
access_token=_STALE_ACCESS,
|
|
refresh_token=_STALE_REFRESH,
|
|
expires_at_ms=_EXPIRED_MS,
|
|
)
|
|
pool = CredentialPool("anthropic", [entry])
|
|
|
|
synced = pool._sync_anthropic_entry_from_pool_store(entry)
|
|
|
|
assert synced.access_token == _ROTATED_ACCESS
|
|
assert synced.refresh_token == _ROTATED_REFRESH
|