diff --git a/.commitlintrc.json b/.commitlintrc.json index d42e91489..4bc3e4519 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -1,7 +1,7 @@ { "extends": ["@commitlint/config-conventional"], "rules": { - "body-max-line-length": [2, "always", 200], + "body-max-line-length": [0], "footer-leading-blank": [0], "subject-case": [0], "type-enum": [ @@ -12,6 +12,7 @@ "chore", "ci", "docs", + "deps", "feat", "fix", "parity", @@ -23,4 +24,4 @@ ] ] } -} \ No newline at end of file +} diff --git a/Cargo.lock b/Cargo.lock index 3daec4d06..2da32bedf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,9 +194,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -260,9 +260,9 @@ dependencies = [ [[package]] name = "aws-config" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "701418aa459dac33e50a0f8e818e5662a16bc018a6ac7423659b70f3799d67a8" +checksum = "1b180a3c8b55960db3426d8964b8745e652466a1a49fe1a2eda828046d30b5e4" dependencies = [ "aws-credential-types", "aws-runtime", @@ -281,7 +281,7 @@ dependencies = [ "fastrand", "hex", "http 1.5.0", - "sha1", + "sha1 0.10.6", "time", "tokio", "tracing", @@ -325,9 +325,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6b50a43f3ccdf331521c6d6c68b7cc9668b6e09d439ebda9569df5722324d76" +checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -350,9 +350,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.104.0" +version = "1.105.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b53416d16c278234845392e38d93bd4481d2f09daa0f005a2277f0aa91f59c22" +checksum = "6ffd0fbe7873cb548a7aa60f9573c268fff94155397fd4f14dc9f1ecaaab8516" dependencies = [ "arc-swap", "aws-credential-types", @@ -376,9 +376,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.106.0" +version = "1.107.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc9b706c3305ed0285d5b1b696c747aa34950f830fb03e3e6c76890f99b9f188" +checksum = "175763eb222a46377df7aa257a3bca980ab3e96703fefc8f4d0b8da6ad2e254c" dependencies = [ "arc-swap", "aws-credential-types", @@ -402,9 +402,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.109.0" +version = "1.110.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d214cdfa5bbe17f117e76a7643fadf32a5234fb597322ef8b1fb4b2f17dbbd" +checksum = "dd8b14781dfbff48984017d57167b6ea0b6471c6920ec52b44a2677c7feb3c13" dependencies = [ "arc-swap", "aws-credential-types", @@ -540,9 +540,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045" +checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -682,10 +682,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tower", "tower-layer", "tower-service", @@ -894,9 +894,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "jobserver", @@ -916,6 +916,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1782,6 +1793,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1954,7 +1966,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tokio-stream", - "tokio-tungstenite", + "tokio-tungstenite 0.30.0", "tokio-util", "tower", "tower-http 0.7.0", @@ -2552,9 +2564,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.38.1" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -3402,6 +3414,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3440,6 +3463,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -3681,9 +3710,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.40.1" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags", "fallible-iterator", @@ -3955,6 +3984,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4396,6 +4436,18 @@ name = "tokio-tungstenite" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", @@ -4403,7 +4455,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.30.0", "webpki-roots 0.26.11", ] @@ -4729,13 +4781,29 @@ dependencies = [ "httparse", "log", "rand 0.8.6", - "rustls", - "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 1.0.69", "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http 1.5.0", + "httparse", + "log", + "rand 0.10.2", + "rustls", + "rustls-pki-types", + "sha1 0.11.0", + "thiserror 2.0.20", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/crates/headroom-proxy/Cargo.toml b/crates/headroom-proxy/Cargo.toml index 813b826a7..b1f5088f1 100644 --- a/crates/headroom-proxy/Cargo.toml +++ b/crates/headroom-proxy/Cargo.toml @@ -23,7 +23,7 @@ tower-http = { version = "0.7", features = ["trace", "request-id", "util"] } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["json", "env-filter", "fmt"] } reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls", "http2"] } -tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } +tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } clap = { workspace = true, features = ["derive", "env"] } serde = { workspace = true } serde_json = { workspace = true } @@ -98,7 +98,7 @@ md-5 = "0.10" tower = { workspace = true, features = ["util"] } wiremock = "0.6" reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls", "http2", "json"] } -tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } +tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } futures-util = "0.3" tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "net", "io-util", "time", "test-util", "process"] } hyper = { version = "1", features = ["server", "http1", "http2"] } diff --git a/crates/headroom-proxy/src/websocket.rs b/crates/headroom-proxy/src/websocket.rs index c3a6358c6..509cd6bfd 100644 --- a/crates/headroom-proxy/src/websocket.rs +++ b/crates/headroom-proxy/src/websocket.rs @@ -218,10 +218,10 @@ async fn run_ws_pump( fn ax_to_tg(m: AxMsg) -> Option { Some(match m { - AxMsg::Text(t) => TgMsg::Text(t.to_string()), - AxMsg::Binary(b) => TgMsg::Binary(b.to_vec()), - AxMsg::Ping(p) => TgMsg::Ping(p.to_vec()), - AxMsg::Pong(p) => TgMsg::Pong(p.to_vec()), + AxMsg::Text(t) => TgMsg::Text(t.to_string().into()), + AxMsg::Binary(b) => TgMsg::Binary(b.to_vec().into()), + AxMsg::Ping(p) => TgMsg::Ping(p.to_vec().into()), + AxMsg::Pong(p) => TgMsg::Pong(p.to_vec().into()), AxMsg::Close(Some(cf)) => TgMsg::Close(Some(TgCloseFrame { code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::from(cf.code), reason: cf.reason.to_string().into(), diff --git a/crates/headroom-proxy/tests/integration_ws.rs b/crates/headroom-proxy/tests/integration_ws.rs index 7f9688d87..1c29aaf3a 100644 --- a/crates/headroom-proxy/tests/integration_ws.rs +++ b/crates/headroom-proxy/tests/integration_ws.rs @@ -53,7 +53,7 @@ async fn ws_text_and_binary_round_trip() { for i in 0..5 { let m = format!("hello-{i}"); - ws.send(Message::Text(m.clone())).await.unwrap(); + ws.send(Message::Text(m.clone().into())).await.unwrap(); let echoed = ws.next().await.unwrap().unwrap(); match echoed { Message::Text(t) => assert_eq!(t.as_str(), m), @@ -62,7 +62,7 @@ async fn ws_text_and_binary_round_trip() { } for i in 0..5u8 { let m: Vec = (0..32u8).map(|b| b ^ i).collect(); - ws.send(Message::Binary(m.clone())).await.unwrap(); + ws.send(Message::Binary(m.clone().into())).await.unwrap(); let echoed = ws.next().await.unwrap().unwrap(); match echoed { Message::Binary(b) => assert_eq!(b.to_vec(), m), diff --git a/headroom/cli/doctor.py b/headroom/cli/doctor.py index ae8e4cc55..a45a0cbf1 100644 --- a/headroom/cli/doctor.py +++ b/headroom/cli/doctor.py @@ -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). diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index eebd2fa70..e015dae8b 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -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) @@ -4761,6 +4787,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) diff --git a/headroom/providers/claude/__init__.py b/headroom/providers/claude/__init__.py index 9fad1fbfb..e44238ebc 100644 --- a/headroom/providers/claude/__init__.py +++ b/headroom/providers/claude/__init__.py @@ -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", diff --git a/headroom/providers/claude/runtime.py b/headroom/providers/claude/runtime.py index 3bd6fca91..1c7643606 100644 --- a/headroom/providers/claude/runtime.py +++ b/headroom/providers/claude/runtime.py @@ -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. diff --git a/tests/test_cli/test_wrap_claude_vertex_proxy_env.py b/tests/test_cli/test_wrap_claude_vertex_proxy_env.py index e4ec5ea14..62efa7308 100644 --- a/tests/test_cli/test_wrap_claude_vertex_proxy_env.py +++ b/tests/test_cli/test_wrap_claude_vertex_proxy_env.py @@ -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: diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py index 6b4837418..6fe42581d 100644 --- a/tests/test_cli_doctor.py +++ b/tests/test_cli_doctor.py @@ -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") diff --git a/tests/test_issue_1779_remote_control_gate.py b/tests/test_issue_1779_remote_control_gate.py index 0a7a17e5b..a1ac9ab99 100644 --- a/tests/test_issue_1779_remote_control_gate.py +++ b/tests/test_issue_1779_remote_control_gate.py @@ -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" # ---------------------------------------------------------------------------