diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 0020022cc67..2e8332470ae 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -80,7 +80,6 @@ class TestProviderEnvBlocklist: must also be blocked — not just the hand-written extras.""" registry_vars = { "ANTHROPIC_TOKEN": "ant-tok", - "CLAUDE_CODE_OAUTH_TOKEN": "cc-tok", "ZAI_API_KEY": "zai-key", "Z_AI_API_KEY": "z-ai-key", "GLM_API_KEY": "glm-key", @@ -326,11 +325,18 @@ class TestBlocklistCoverage: def test_registry_vars_are_in_blocklist(self): """Every api_key_env_var and base_url_env_var from PROVIDER_REGISTRY - must appear in the blocklist — ensures no drift.""" + must appear in the blocklist — ensures no drift. + + CLAUDE_CODE_OAUTH_TOKEN is the one deliberate exemption: it is owned + by the user's Claude Code install, not Hermes (#55878). + """ from hermes_cli.auth import PROVIDER_REGISTRY + exempt = {"CLAUDE_CODE_OAUTH_TOKEN"} for pconfig in PROVIDER_REGISTRY.values(): for var in pconfig.api_key_env_vars: + if var in exempt: + continue assert var in _HERMES_PROVIDER_ENV_BLOCKLIST, ( f"Registry var {var} (provider={pconfig.id}) missing from blocklist" ) @@ -371,11 +377,19 @@ class TestBlocklistCoverage: ) def test_extra_auth_vars_covered(self): - """Non-registry auth vars (ANTHROPIC_TOKEN, CLAUDE_CODE_OAUTH_TOKEN) - must also be in the blocklist.""" - extras = {"ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"} + """Non-registry auth vars (ANTHROPIC_TOKEN) must also be in the + blocklist.""" + extras = {"ANTHROPIC_TOKEN"} assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST) + def test_claude_code_oauth_token_is_inheritable(self): + """CLAUDE_CODE_OAUTH_TOKEN is owned by the user's Claude Code install + (subscription OAuth), not a Hermes inference credential. Stripping it + made agent-spawned ``claude`` fall through to the shared Keychain / + ~/.claude credential store and clobber the user's interactive login + on auth failure (#55878). It must stay inheritable.""" + assert "CLAUDE_CODE_OAUTH_TOKEN" not in _HERMES_PROVIDER_ENV_BLOCKLIST + def test_non_registry_provider_vars_are_in_blocklist(self): extras = { "GOOGLE_API_KEY", diff --git a/tools/environments/local.py b/tools/environments/local.py index 1b94fbdd000..6c518b54b64 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -152,7 +152,6 @@ def _build_provider_env_blocklist() -> frozenset: "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", - "CLAUDE_CODE_OAUTH_TOKEN", "LLM_MODEL", "GOOGLE_API_KEY", # Path to a GCP service-account JSON, not a bare key, so @@ -212,6 +211,16 @@ def _build_provider_env_blocklist() -> frozenset: "GATEWAY_RELAY_SECRET", "GATEWAY_RELAY_DELIVERY_KEY", }) + # CLAUDE_CODE_OAUTH_TOKEN is deliberately NOT stripped. It is set and + # owned by the user's Claude Code install (subscription OAuth), not a + # Hermes-managed inference credential — Claude subscription auth is not a + # working Hermes provider path. Stripping it broke agent-spawned + # ``claude`` CLIs: the child fell through to the shared macOS Keychain / + # ``~/.claude/.credentials.json`` store and, on auth failure, cleared it, + # logging the user out of their interactive Claude sessions (#55878). + # It arrives via the registry loop above (anthropic api_key_env_vars), + # so remove it explicitly. + blocked.discard("CLAUDE_CODE_OAUTH_TOKEN") return frozenset(blocked)