Merge branch 'main' into fix/copilot-native-enterprise-routing

This commit is contained in:
JD Davis 2026-08-14 12:16:15 -05:00 committed by GitHub
commit 0aca48c096
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 330 additions and 39 deletions

View file

@ -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 @@
]
]
}
}
}

124
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -218,10 +218,10 @@ async fn run_ws_pump(
fn ax_to_tg(m: AxMsg) -> Option<TgMsg> {
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(),

View file

@ -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<u8> = (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),

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).

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)
@ -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)

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",

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.

View file

@ -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:

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")

View file

@ -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"
# ---------------------------------------------------------------------------