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:
+46
-1
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user