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:
parent
1215fbbd76
commit
0512f06a6a
3 changed files with 154 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue