fix(auth): centralize pool auth normalization

Normalize Anthropic setup-token metadata for every PooledCredential construction path, persist corrected manual entries, heal legacy rows on load without copying global fallback credentials into profiles, and map the contributor email for release attribution.
This commit is contained in:
kshitijk4poor 2026-07-13 22:34:40 +05:30 committed by kshitij
parent 1215fbbd76
commit 0512f06a6a
3 changed files with 154 additions and 28 deletions

View file

@ -128,6 +128,17 @@ _EXTRA_KEYS = frozenset({
})
def _normalize_pool_auth_type(provider: str, token: Any, auth_type: Any) -> str:
"""Infer pool auth metadata for token formats with one unambiguous meaning."""
if (
provider == "anthropic"
and isinstance(token, str)
and token.startswith("sk-ant-oat")
):
return AUTH_TYPE_OAUTH
return str(auth_type or AUTH_TYPE_API_KEY)
@dataclass
class PooledCredential:
provider: str
@ -157,6 +168,11 @@ class PooledCredential:
def __post_init__(self):
if self.extra is None:
self.extra = {}
self.auth_type = _normalize_pool_auth_type(
self.provider,
self.access_token,
self.auth_type,
)
def __getattr__(self, name: str):
if name in _EXTRA_KEYS:
@ -175,16 +191,6 @@ class PooledCredential:
data.setdefault("id", uuid.uuid4().hex[:6])
data.setdefault("label", payload.get("source", provider))
data.setdefault("auth_type", AUTH_TYPE_API_KEY)
# An Anthropic setup-token (sk-ant-oat*) is always OAuth regardless of
# how it was ingested. The env path infers this, but manually-added
# credentials (hermes auth add / dashboard) default to api_key and are
# then sent with an x-api-key header, which Anthropic rejects for OAuth
# tokens. Normalize here so every ingest path agrees with
# _is_oauth_token()'s prefix detection. See issue #63737.
if provider == "anthropic":
_oat = data.get("access_token") or ""
if isinstance(_oat, str) and _oat.startswith("sk-ant-oat"):
data["auth_type"] = AUTH_TYPE_OAUTH
data.setdefault("priority", 0)
data.setdefault("source", SOURCE_MANUAL)
data.setdefault("access_token", "")
@ -2262,12 +2268,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
if _is_source_suppressed(provider, source):
continue
active_sources.add(source)
# Claude Code OAuth tokens are the only Anthropic credentials that should flow into the OAuth refresh path.
auth_type = (
AUTH_TYPE_OAUTH
if provider == "anthropic" and token.startswith("sk-ant-oat")
else AUTH_TYPE_API_KEY
)
base_url = env_url or pconfig.inference_base_url
if provider == "kimi-coding":
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
@ -2282,7 +2282,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
env_var=env_var,
token=token,
base_url=base_url,
auth_type=auth_type,
),
)
return changed, active_sources
@ -2410,16 +2409,37 @@ def load_pool(provider: str) -> CredentialPool:
for payload in raw_entries
)
entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]
raw_needs_auth_normalization = any(
isinstance(payload, dict)
and _normalize_pool_auth_type(
provider,
payload.get("access_token"),
payload.get("auth_type", AUTH_TYPE_API_KEY),
) != payload.get("auth_type", AUTH_TYPE_API_KEY)
for payload in raw_entries
)
if raw_needs_auth_normalization:
# A profile may be reading this provider from the global-root fallback.
# Keep that fallback read-only: only the store that owns these rows may
# rewrite them. Loading the default/root profile will heal global rows.
active_pool = _load_auth_store().get("credential_pool")
active_entries = active_pool.get(provider) if isinstance(active_pool, dict) else None
raw_needs_auth_normalization = bool(active_entries)
if provider.startswith(CUSTOM_POOL_PREFIX):
# Custom endpoint pool — seed from custom_providers config and model config
custom_changed, custom_sources = _seed_custom_pool(provider, entries)
changed = raw_needs_sanitization or custom_changed
changed = raw_needs_sanitization or raw_needs_auth_normalization or custom_changed
changed |= _prune_stale_seeded_entries(entries, custom_sources)
else:
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
env_changed, env_sources = _seed_from_env(provider, entries)
changed = raw_needs_sanitization or singleton_changed or env_changed
changed = (
raw_needs_sanitization
or raw_needs_auth_normalization
or singleton_changed
or env_changed
)
# ``load_pool()`` is a non-destructive read for env-seeded entries: a
# process missing a provider env var must not delete the persisted
# pool entry for every other process (#9331). File-backed singletons

View file

@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"m.guttmann@journaway.com": "mguttmann", # PR #63738 salvage (Anthropic setup-token pool auth normalization)
"VrtxOmega@pm.me": "VrtxOmega", # PR #43809 salvage (desktop: WSL folder-picker path bridge)
"jake.long.vu@vucar.net": "jakelongvu-bot", # PR #36683 partial salvage (approval: honor canonical approvals.timeout in gateway waits)
"luigi@users.noreply.github.com": "Tortugasaur", # PR #43205 salvage (desktop: profile-aware three-way approval mode statusbar control)

View file

@ -1,18 +1,26 @@
"""Regression test for #63737: sk-ant-oat tokens must be OAuth, not api_key."""
"""Regression tests for #63737: sk-ant-oat pool entries are OAuth."""
import json
from pathlib import Path
from agent.credential_pool import (
PooledCredential,
AUTH_TYPE_OAUTH,
AUTH_TYPE_API_KEY,
AUTH_TYPE_OAUTH,
CredentialPool,
PooledCredential,
)
def test_manual_anthropic_oat_normalized_to_oauth():
# A setup-token added manually (dashboard / hermes auth add) defaults to
# api_key; it must be normalized to OAuth so it is sent with Bearer auth.
# Pool auth_type gates OAuth-only resolver and refresh paths.
entry = PooledCredential.from_dict(
"anthropic",
{"label": "MainKey", "source": "manual",
"auth_type": "api_key", "access_token": "sk-ant-oat01-EXAMPLE"},
{
"label": "MainKey",
"source": "manual",
"auth_type": "api_key",
"access_token": "sk-ant-oat-EXAMPLE",
},
)
assert entry.auth_type == AUTH_TYPE_OAUTH
@ -20,7 +28,15 @@ def test_manual_anthropic_oat_normalized_to_oauth():
def test_anthropic_real_api_key_unchanged():
entry = PooledCredential.from_dict(
"anthropic",
{"auth_type": "api_key", "access_token": "sk-ant-api03-EXAMPLE"},
{"auth_type": "api_key", "access_token": "sk-ant-api-EXAMPLE"},
)
assert entry.auth_type == AUTH_TYPE_API_KEY
def test_anthropic_admin_key_unchanged():
entry = PooledCredential.from_dict(
"anthropic",
{"auth_type": "api_key", "access_token": "sk-ant-admin-EXAMPLE"},
)
assert entry.auth_type == AUTH_TYPE_API_KEY
@ -28,6 +44,95 @@ def test_anthropic_real_api_key_unchanged():
def test_non_anthropic_provider_unchanged():
entry = PooledCredential.from_dict(
"openrouter",
{"auth_type": "api_key", "access_token": "sk-ant-oat01-WHATEVER"},
{"auth_type": "api_key", "access_token": "sk-ant-oat-WHATEVER"},
)
assert entry.auth_type == AUTH_TYPE_API_KEY
def test_add_entry_normalizes_before_persisting(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
pool = CredentialPool("anthropic", [])
entry = pool.add_entry(PooledCredential(
provider="anthropic",
id="manual-oat",
label="Manual setup token",
auth_type=AUTH_TYPE_API_KEY,
priority=0,
source="manual",
access_token="sk-ant-oat-manual-entry",
))
persisted = json.loads((hermes_home / "auth.json").read_text())
assert entry.auth_type == AUTH_TYPE_OAUTH
assert persisted["credential_pool"]["anthropic"][0]["auth_type"] == AUTH_TYPE_OAUTH
def test_load_heals_legacy_row_and_exposes_it_to_resolver(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: None,
)
token = "sk-ant-oat-legacy-manual"
auth_file = hermes_home / "auth.json"
auth_file.write_text(json.dumps({
"version": 1,
"credential_pool": {
"anthropic": [{
"id": "legacy-oat",
"label": "Legacy setup token",
"auth_type": AUTH_TYPE_API_KEY,
"priority": 0,
"source": "manual",
"access_token": token,
}],
},
}))
from agent.anthropic_adapter import resolve_anthropic_token
from agent.credential_pool import load_pool
entry = load_pool("anthropic").entries()[0]
persisted = json.loads(auth_file.read_text())
assert entry.auth_type == AUTH_TYPE_OAUTH
assert persisted["credential_pool"]["anthropic"][0]["auth_type"] == AUTH_TYPE_OAUTH
assert resolve_anthropic_token() == token
def test_profile_global_fallback_normalizes_in_memory_without_writing(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
global_root = tmp_path / ".hermes"
global_root.mkdir()
profile_home = global_root / "profiles" / "coder"
profile_home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(profile_home))
token = "sk-ant-oat-global-fallback"
global_auth = global_root / "auth.json"
global_auth.write_text(json.dumps({
"version": 1,
"credential_pool": {
"anthropic": [{
"id": "global-oat",
"label": "Global setup token",
"auth_type": AUTH_TYPE_API_KEY,
"priority": 0,
"source": "manual",
"access_token": token,
}],
},
}))
from agent.credential_pool import load_pool
entry = load_pool("anthropic").entries()[0]
persisted = json.loads(global_auth.read_text())
assert entry.auth_type == AUTH_TYPE_OAUTH
assert persisted["credential_pool"]["anthropic"][0]["auth_type"] == AUTH_TYPE_API_KEY
assert not (profile_home / "auth.json").exists()