b7a9db8b9a
Two runtime blockers from the exact-head review of c057ef5.
1. A sanitized `claude_code` pool row was treated as token authority.
`claude_code` is a borrowed source: it is absent from the owned-source
allowlist, so `sanitize_borrowed_credential_payload` strips `access_token`
and `refresh_token` before the row reaches `auth.json`. `load_pool()`
re-hydrates the live pair from the singleton on every load, which is what
makes `~/.claude/.credentials.json` — not the pool store — authoritative
for this source.
`_sync_anthropic_entry_from_pool_store()` re-read that persisted row during
refresh. Being token-less, it "differed" from the live entry, so it was
adopted as a rotation performed by another process: `_refresh_entry()`
replaced a usable credential with an empty one and returned it before
`_claude_code_credentials_lock()` and the authoritative re-read were ever
entered. The empty OAuth entry then stayed selectable, because the
empty-runtime-key guard in `_available_entries()` covered API-key rows only.
Repairs: the pool-store sync refuses borrowed sources outright (plus a
defensive refusal of any token-less row, for future sources that sanitize on
write); the `claude_code` branch of `_refresh_entry()` now runs before the
generic adopt-and-return shortcut, so the path-keyed lock and the
authoritative re-read are always entered before deciding to POST or adopt;
and an OAuth entry with no access token is never leased.
2. A failed commit still fell through to the same spent credential.
`_refresh_oauth_token()` correctly returns None when the refresh POST
rotated the single-use token but the replacement could not be committed.
That verdict did not survive the caller: `resolve_anthropic_token()`
continued to `_resolve_anthropic_pool_token()`, which enumerates read-only
(`clear_expired=False, refresh=False`) over a pool that `load_pool()` had
just re-seeded from the unchanged singleton — so the pair whose refresh half
was already spent came back as a healthy token, and
`_refresh_provider_credentials("anthropic")` reported success and evicted
its cached clients.
Repair: every commit-failure path records the consumed pre-rotation pair as
non-reversible fingerprints (bounded, process-local), and both the Claude
Code file resolver and the pool resolver refuse a credential whose
fingerprint is on that list. `_refresh_provider_credentials("anthropic")`
consequently returns False when the spent family is the only credential,
while genuinely independent pool credentials stay eligible.
Coverage: `test_anthropic_borrowed_row_authority.py` starts from
`load_pool()` reading an actually persisted, actually sanitized row, forces
a refresh, and asserts the full pair survives with exactly one POST and one
commit, that the shared-file lock is entered, and that no empty OAuth entry
can be leased. `test_anthropic_spent_rotation_verdict.py` takes the full
resolver path: successful POST plus failed commit must make
`resolve_anthropic_token()` return None, make
`_refresh_provider_credentials("anthropic")` return False, and keep the
spent fingerprint out of every lease — with a control proving a successful
commit quarantines nothing and an independent credential still resolving.
Five of the seven new borrowed-row tests fail on the previous head, and the
three resolution tests fail with the verdict disabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gcoy6nLTg5R6FHHhjcLZEC
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
|