fix(claude): reject conflicting auth before proxy startup (#2993)

## Description

Fixes #1443.

Claude Code rejects an effective configuration containing both
ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN before any request reaches
Headroom. The existing wrapper started the proxy and mutated project
settings before Claude surfaced its generic Invalid API key message,
leaving users to guess which credential came from their shell, global
settings, or project settings.

Headroom does not own either credential, and both represent legitimate
but different auth/billing modes, so automatically deleting one would be
destructive. This PR detects the contradiction before any proxy/config
mutation and tells the user which source contains each key without
exposing credential values.

## Changes Made

- Add a pure Claude auth-conflict classifier with explicit
settings-layer precedence.
- Cover user settings, project .claude/settings.json, project
.claude/settings.local.json, and shell environment.
- Treat higher-precedence empty values as clearing inherited
credentials.
- Abort wrap claude before proxy registration/startup when both keys
remain effective.
- Add a headroom doctor failure with the same source-aware,
value-redacted remediation.
- Preserve both user credentials and require an explicit choice between
API-key billing and token/gateway auth.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
151 Claude runtime, wrap, doctor, Remote Control, and MCP dependency-contract tests passed
ruff check and format checks passed
git diff --check passed
```

Branch contains current main, including the MCP v1 cap and the five
just-merged blocker PRs.

## Real Behavior Proof

- Environment: isolated local worktree on current `main` with Claude
wrapper and doctor fixtures.
- Exact command / steps: exercised conflicting and non-conflicting
shell, user, project, and local-project credential layers through the
focused wrap and doctor test suites.
- Observed result: conflicting effective credentials fail before proxy
startup or settings mutation, report only credential sources, and never
expose values.
- Not tested: a live Claude Code login with production credentials;
credential precedence and side-effect boundaries are covered by
fixtures.

## Runtime Rollout Safety

- Rollout-managed feature(s): Claude authentication-conflict preflight.
- Minimum rollout channel: normal patch release.
- Stable/default behavior changed: only configurations with both
effective credentials now stop early with actionable diagnostics.
- Kill switch / disable path: remove or clear either conflicting
credential in its reported source.
- Unsafe override required: none; Headroom deliberately does not choose
or delete a user credential.
- Qualification impact: Claude wrap, doctor, Remote Control, and MCP
dependency-contract tests must remain green.
- Rollback path: human revert restores the previous late Claude Code
rejection; no persisted migration is involved.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Safety

No credential value is returned by the classifier, printed by wrap, or
emitted in doctor JSON. The preflight runs before
_register_proxy_client, proxy startup, MCP registration, or settings
writes.
This commit is contained in:
JD Davis
2026-08-13 23:01:59 -05:00
committed by GitHub
parent aa811fa91f
commit 2d88e31a40
7 changed files with 223 additions and 1 deletions
+46 -1
View File
@@ -30,6 +30,8 @@ from headroom.paths import savings_path
from headroom.providers.claude import (
REMOTE_CONTROL_BASE_URL_ENV,
REMOTE_CONTROL_SIBLING_GATE_NOTE,
claude_auth_conflict_message,
claude_auth_conflict_sources,
detect_claude_code_version,
is_custom_anthropic_base_url,
remote_control_applies_to_auth,
@@ -189,6 +191,39 @@ def check_claude_routing(settings_path: Path, port: int) -> CheckResult:
return _classify_routing_url(name, base_url, port, source=str(settings_path))
def check_claude_auth_conflict(
settings_path: Path,
project_settings_path: Path,
project_local_settings_path: Path,
environ: Mapping[str, str],
) -> CheckResult | None:
"""Report contradictory effective Claude credentials without their values."""
def settings_env(path: Path) -> dict[str, object]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
env = payload.get("env") if isinstance(payload, dict) else None
return dict(env) if isinstance(env, dict) else {}
conflict = claude_auth_conflict_sources(
(str(settings_path), settings_env(settings_path)),
(str(project_settings_path), settings_env(project_settings_path)),
(str(project_local_settings_path), settings_env(project_local_settings_path)),
("shell environment", environ),
)
if conflict is None:
return None
return CheckResult(
name="claude auth",
status=FAIL,
summary=claude_auth_conflict_message(conflict),
)
def check_claude_remote_control_gate(
settings_path: Path,
environ: Mapping[str, str],
@@ -567,16 +602,26 @@ def doctor(port: int, emit_json: bool) -> None:
stats = probe_json(f"{base_url}/stats", timeout=5.0) if livez else None
installed = get_version()
project_claude_settings = Path.cwd() / ".claude" / "settings.json"
project_local_claude_settings = Path.cwd() / ".claude" / "settings.local.json"
checks = [
check_proxy_liveness(livez, base_url),
check_version_drift(livez, installed),
check_claude_routing(claude_settings_path(), port),
check_wrap_marker_staleness(Path.cwd() / ".claude" / "settings.local.json"),
check_wrap_marker_staleness(project_local_claude_settings),
check_codex_routing(codex_config_path(), port),
check_shell_env(os.environ, port),
check_savings(stats, savings_path()),
check_budget(stats),
]
auth_conflict_check = check_claude_auth_conflict(
claude_settings_path(),
project_claude_settings,
project_local_claude_settings,
os.environ,
)
if auth_conflict_check is not None:
checks.append(auth_conflict_check)
# Lazy resolver: `claude --version` is a Node CLI subprocess (seconds of
# cold start, 10s worst-case timeout) — only pay for it when the RC gate
# is actually plausible (custom base URL + subscription auth).
+32
View File
@@ -77,6 +77,8 @@ from headroom.providers.claude import (
REMOTE_CONTROL_BASE_URL_ENV,
TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV,
claude_auth_conflict_message,
claude_auth_conflict_sources,
claude_user_settings_path,
configure_vscode_claude_settings,
detect_claude_code_version,
@@ -260,6 +262,30 @@ def _read_settings_for_write(path: Path) -> dict[str, Any]:
return cast("dict[str, Any]", payload)
def _claude_settings_env(path: Path) -> dict[str, object]:
"""Read a Claude settings env block for preflight validation."""
env = _read_settings_for_write(path).get("env")
return dict(env) if isinstance(env, dict) else {}
def _raise_on_claude_auth_conflict(
*,
user_settings_path: Path,
project_settings_path: Path,
project_local_settings_path: Path,
environ: dict[str, str],
) -> None:
"""Refuse an auth state Claude Code rejects before mutating wrap state."""
conflict = claude_auth_conflict_sources(
(str(user_settings_path), _claude_settings_env(user_settings_path)),
(str(project_settings_path), _claude_settings_env(project_settings_path)),
(str(project_local_settings_path), _claude_settings_env(project_local_settings_path)),
("shell environment", environ),
)
if conflict is not None:
raise click.ClickException(claude_auth_conflict_message(conflict))
def _append_text(path: Path, content: str) -> None:
"""Append to a text file as UTF-8 without translating line endings."""
fsutil.append_text(path, content)
@@ -4745,6 +4771,12 @@ def claude(
# early proxy-start failure would make the finally raise UnboundLocalError,
# masking the real error and skipping cleanup(). Mirrors the holders above.
_wrap_settings_path = Path.cwd() / ".claude" / "settings.local.json"
_raise_on_claude_auth_conflict(
user_settings_path=claude_user_settings_path(),
project_settings_path=Path.cwd() / ".claude" / "settings.json",
project_local_settings_path=_wrap_settings_path,
environ=dict(os.environ),
)
cleanup = _make_cleanup(proxy_holder, port_holder)
signal.signal(signal.SIGINT, _ignore_child_sigint)
signal.signal(signal.SIGTERM, cleanup)
+6
View File
@@ -1,6 +1,7 @@
"""Claude-specific provider helpers."""
from .runtime import (
CLAUDE_AUTH_KEYS,
DEFAULT_API_URL,
REMOTE_CONTROL_BASE_URL_ENV,
REMOTE_CONTROL_GATED_MIN_VERSION,
@@ -8,6 +9,8 @@ from .runtime import (
REMOTE_CONTROL_SIBLING_GATE_NOTE,
TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV,
claude_auth_conflict_message,
claude_auth_conflict_sources,
detect_claude_code_version,
is_custom_anthropic_base_url,
parse_claude_code_version,
@@ -25,6 +28,7 @@ from .vscode import (
)
__all__ = [
"CLAUDE_AUTH_KEYS",
"claude_user_settings_path",
"configure_vscode_claude_settings",
"remove_vscode_claude_settings",
@@ -36,6 +40,8 @@ __all__ = [
"REMOTE_CONTROL_SIBLING_GATE_NOTE",
"TOOL_SEARCH_DEFAULT",
"TOOL_SEARCH_ENV",
"claude_auth_conflict_message",
"claude_auth_conflict_sources",
"detect_claude_code_version",
"is_custom_anthropic_base_url",
"parse_claude_code_version",
+41
View File
@@ -18,6 +18,7 @@ TOOL_SEARCH_DEFAULT = "true"
TOOL_SEARCH_FOUNDRY_DEFAULT = "false"
REMOTE_CONTROL_BASE_URL_ENV = "ANTHROPIC_BASE_URL"
REMOTE_CONTROL_FEATURE = "Remote Control"
CLAUDE_AUTH_KEYS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
# GH #1779: Claude Code v2.1.196 added a client-side eligibility check that
# DISABLES first-party Remote Control (`/remote-control` / `/rc`, which mirrors a
@@ -186,6 +187,46 @@ def remote_control_applies_to_auth(environ: Mapping[str, object]) -> bool:
)
def claude_auth_conflict_sources(
*layers: tuple[str, Mapping[str, object]],
) -> dict[str, str] | None:
"""Return source labels when both mutually exclusive Claude auth keys are effective.
Layers are ordered from lowest to highest precedence. Empty values clear an
inherited value, matching environment overlay semantics. Credential values
are deliberately never returned so callers cannot leak them in diagnostics.
"""
effective: dict[str, str] = {}
sources: dict[str, str] = {}
for source, values in layers:
for key in CLAUDE_AUTH_KEYS:
if key not in values:
continue
value = str(values.get(key) or "").strip()
if value:
effective[key] = value
sources[key] = source
else:
effective.pop(key, None)
sources.pop(key, None)
if all(key in effective for key in CLAUDE_AUTH_KEYS):
return {key: sources[key] for key in CLAUDE_AUTH_KEYS}
return None
def claude_auth_conflict_message(sources: Mapping[str, str]) -> str:
"""Format a value-free remediation for contradictory Claude credentials."""
api_source = sources.get("ANTHROPIC_API_KEY", "effective configuration")
token_source = sources.get("ANTHROPIC_AUTH_TOKEN", "effective configuration")
return (
"Claude Code has both ANTHROPIC_API_KEY "
f"({api_source}) and ANTHROPIC_AUTH_TOKEN ({token_source}) set. "
"Claude rejects this ambiguous auth state before Headroom can proxy a request. "
"Keep ANTHROPIC_API_KEY for API-key billing, or keep ANTHROPIC_AUTH_TOKEN "
"for token/gateway auth; remove the other key from the named source and retry."
)
def parse_claude_code_version(text: str | None) -> tuple[int, int, int] | None:
"""Parse a ``MAJOR.MINOR.PATCH`` version out of ``claude --version`` output.
@@ -148,6 +148,57 @@ def test_wrap_claude_plain_mode_api_key_auth_skips_remote_control_warning(
assert "Remote Control" not in output
def test_wrap_claude_rejects_conflicting_auth_before_proxy_mutation(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
user_settings = tmp_path / "user-settings.json"
user_settings.write_text('{"env":{"ANTHROPIC_AUTH_TOKEN":"token-value"}}', encoding="utf-8")
monkeypatch.setattr(wrap_mod, "claude_user_settings_path", lambda: user_settings)
monkeypatch.setattr(wrap_mod.shutil, "which", lambda _name: "/usr/bin/claude")
proxy_calls: list[int] = []
monkeypatch.setattr(wrap_mod, "_register_proxy_client", lambda port: proxy_calls.append(port))
result = runner.invoke(
main,
["wrap", "claude", "--no-mcp", "--no-tokensave", "--no-serena"],
env={"ANTHROPIC_API_KEY": "api-value"},
)
assert result.exit_code != 0
assert "both ANTHROPIC_API_KEY" in result.output
assert "shell environment" in result.output
assert str(user_settings) in result.output
assert "api-value" not in result.output
assert "token-value" not in result.output
assert proxy_calls == []
def test_wrap_claude_includes_shared_project_settings_in_auth_precedence(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
user_settings = tmp_path / "user-settings.json"
user_settings.write_text("{}", encoding="utf-8")
project_dir = tmp_path / ".claude"
project_dir.mkdir()
shared_settings = project_dir / "settings.json"
shared_settings.write_text('{"env":{"ANTHROPIC_AUTH_TOKEN":"token-value"}}', encoding="utf-8")
monkeypatch.setattr(wrap_mod, "claude_user_settings_path", lambda: user_settings)
monkeypatch.setattr(wrap_mod.shutil, "which", lambda _name: "/usr/bin/claude")
result = runner.invoke(
main,
["wrap", "claude", "--no-mcp", "--no-tokensave", "--no-serena"],
env={"ANTHROPIC_API_KEY": "api-value"},
)
assert result.exit_code != 0
assert str(shared_settings) in result.output
assert "api-value" not in result.output
assert "token-value" not in result.output
def test_wrap_claude_sibling_note_accurate_under_1m_and_tool_search_optouts(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
) -> None:
+17
View File
@@ -575,6 +575,23 @@ class TestDoctorCommand:
assert result.exit_code == 2
assert "not reachable" in result.output
def test_conflicting_claude_auth_is_a_redacted_failure(self, runner, isolated, monkeypatch):
settings = isolated / "settings.json"
settings.write_text('{"env":{"ANTHROPIC_AUTH_TOKEN":"token-value"}}', encoding="utf-8")
monkeypatch.setenv("ANTHROPIC_API_KEY", "api-value")
monkeypatch.setattr(doctor_mod, "probe_json", self._probe(None, None))
result = runner.invoke(main, ["doctor", "--json"])
assert result.exit_code == 2
payload = json.loads(result.output)
auth = next(check for check in payload["checks"] if check["name"] == "claude auth")
assert auth["status"] == "fail"
assert "shell environment" in auth["summary"]
assert str(settings) in auth["summary"]
assert "api-value" not in result.output
assert "token-value" not in result.output
def test_warnings_only_exits_1(self, runner, isolated, monkeypatch):
monkeypatch.setattr(doctor_mod, "probe_json", self._probe(LIVEZ_OK, STATS_OK))
monkeypatch.setattr(doctor_mod, "get_version", lambda: "0.26.0")
@@ -19,6 +19,8 @@ import pytest
from headroom.providers.claude.runtime import (
REMOTE_CONTROL_GATED_MIN_VERSION,
REMOTE_CONTROL_SIBLING_GATE_NOTE,
claude_auth_conflict_message,
claude_auth_conflict_sources,
detect_claude_code_version,
is_custom_anthropic_base_url,
parse_claude_code_version,
@@ -34,6 +36,34 @@ _GATED = REMOTE_CONTROL_GATED_MIN_VERSION # (2, 1, 196)
_OLD = (2, 1, 195)
def test_claude_auth_conflict_tracks_precedence_without_returning_values() -> None:
conflict = claude_auth_conflict_sources(
("user settings", {"ANTHROPIC_AUTH_TOKEN": "secret-token"}),
("project settings", {"ANTHROPIC_API_KEY": "secret-api"}),
("shell environment", {}),
)
assert conflict == {
"ANTHROPIC_API_KEY": "project settings",
"ANTHROPIC_AUTH_TOKEN": "user settings",
}
message = claude_auth_conflict_message(conflict)
assert "secret-token" not in message
assert "secret-api" not in message
assert "project settings" in message
assert "user settings" in message
def test_claude_auth_conflict_higher_precedence_empty_value_clears_key() -> None:
assert (
claude_auth_conflict_sources(
("settings", {"ANTHROPIC_AUTH_TOKEN": "token", "ANTHROPIC_API_KEY": "key"}),
("shell", {"ANTHROPIC_API_KEY": ""}),
)
is None
)
# ---------------------------------------------------------------------------
# Message accuracy — deterministic wording, not "may"
# ---------------------------------------------------------------------------