mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge branch 'main' into fix/copilot-native-enterprise-routing
This commit is contained in:
commit
0aca48c096
12 changed files with 330 additions and 39 deletions
|
|
@ -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
124
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue