From 4ae407050ddb968a12982ee87115323c2382949e Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Thu, 12 Nov 2026 16:16:49 +0800 Subject: [PATCH] fix(claude): align proxy placeholder authentication with upstream --- src-tauri/src/live/project/claude.rs | 66 +++++++++++++++++++++------- src-tauri/src/services/proxy.rs | 62 +++++++++++++++++++++++--- src-tauri/tests/provider_commands.rs | 5 ++- src-tauri/tests/proxy_service.rs | 3 +- src-tauri/tests/proxy_takeover.rs | 14 ++++-- 5 files changed, 121 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/live/project/claude.rs b/src-tauri/src/live/project/claude.rs index b289f270d..e509e2b71 100644 --- a/src-tauri/src/live/project/claude.rs +++ b/src-tauri/src/live/project/claude.rs @@ -209,9 +209,12 @@ pub(crate) const ONE_M_MARKER_FOR_CLIENT: &str = "[1M]"; /// 代理契约里怎么写凭据。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProxyAuth { - /// 路由供应商的行里有 `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_API_KEY` 就沿用同名键写占位 - /// 符,都没有就写 `ANTHROPIC_AUTH_TOKEN`。 - FollowRow, + /// 非托管路由:本地跳(客户端 → 本地代理)只写 `ANTHROPIC_AUTH_TOKEN` 占位符。 + /// 行里即便配的是 `ANTHROPIC_API_KEY`(如 OpenCode Go 预设,供上游跳用 x-api-key), + /// 本地配置也不能带这个键——Claude Code 会把它当成用户自定义 API key 弹确认框, + /// 默认拒绝后就是未登录会话;两个键都写还会触发「Both ... set」警告(#4919)。 + /// 上游跳用哪种头由路由按行内凭据注入(proxy/providers/claude.rs),与占位符键名无关。 + AuthToken, /// 托管账号(Copilot、Codex、xAI):只写一个键,两个都在会触发 Claude Code 的 /// 「Both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY set」警告(#4919)。 /// - Codex 系要 `ANTHROPIC_AUTH_TOKEN`,缺了会弹登录提示(#3784); @@ -259,17 +262,8 @@ pub fn proxy_projection( } let placeholder = Value::String(PROXY_TOKEN_PLACEHOLDER.to_string()); match auth { - ProxyAuth::FollowRow => { - let mut wrote_any = false; - for key in ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] { - if route.env.contains_key(key) { - env.insert(key.to_string(), placeholder.clone()); - wrote_any = true; - } - } - if !wrote_any { - env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), placeholder); - } + ProxyAuth::AuthToken => { + env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), placeholder); } ProxyAuth::Managed { auth_token } => { let key = if auth_token { @@ -719,7 +713,7 @@ mod tests { let stacked = proxy_projection( &route, "http://127.0.0.1:15721", - ProxyAuth::FollowRow, + ProxyAuth::AuthToken, Some(StackRoleModel { id: "ccs-claude-z--glm-5.2[1M]", name: "GLM 5.2", @@ -746,11 +740,51 @@ mod tests { assert_eq!(env("ANTHROPIC_AUTH_TOKEN"), Some(PROXY_TOKEN_PLACEHOLDER)); // 路由模式照旧写 `claude-*` 别名和行里的子代理模型。 - let routed = proxy_projection(&route, "http://127.0.0.1:15721", ProxyAuth::FollowRow, None); + let routed = proxy_projection(&route, "http://127.0.0.1:15721", ProxyAuth::AuthToken, None); assert_eq!( routed.env["ANTHROPIC_DEFAULT_SONNET_MODEL"], "claude-sonnet-5[1M]" ); assert_eq!(routed.env["CLAUDE_CODE_SUBAGENT_MODEL"], "glm-4.7-air"); } + #[test] + fn route_mode_always_places_the_placeholder_in_auth_token() { + // #8104:行里只有 `ANTHROPIC_API_KEY`(如 OpenCode Go 预设,供上游跳用 + // x-api-key)时,本地占位符也不能落在这个键上——Claude Code 会把它当成 + // 用户自定义 API key 弹确认框,默认拒绝后就是未登录会话。上游跳用哪种 + // 头由路由按行内凭据注入,与占位符键名无关。 + let api_key_only = ClaudeProjection::of(&json!({ "env": { + "ANTHROPIC_BASE_URL": "https://opencode.ai/zen/go", + "ANTHROPIC_API_KEY": "sk-go" + }})); + let projected = proxy_projection( + &api_key_only, + "http://127.0.0.1:15721", + ProxyAuth::AuthToken, + None, + ); + assert_eq!( + projected.env["ANTHROPIC_AUTH_TOKEN"], + PROXY_TOKEN_PLACEHOLDER + ); + assert!( + !projected.env.contains_key("ANTHROPIC_API_KEY"), + "route mode must not present the placeholder as a custom API key" + ); + + // 两个键都在也不写 API_KEY:避开 Claude Code 的 + // 「Both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY set」警告(#4919)。 + let both = ClaudeProjection::of(&json!({ "env": { + "ANTHROPIC_BASE_URL": "https://relay.example", + "ANTHROPIC_AUTH_TOKEN": "sk-bearer", + "ANTHROPIC_API_KEY": "sk-x-api" + }})); + let projected = + proxy_projection(&both, "http://127.0.0.1:15721", ProxyAuth::AuthToken, None); + assert_eq!( + projected.env["ANTHROPIC_AUTH_TOKEN"], + PROXY_TOKEN_PLACEHOLDER + ); + assert!(!projected.env.contains_key("ANTHROPIC_API_KEY")); + } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index abcbc54fb..8d4884b69 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -37,7 +37,7 @@ const PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY: &str = "CC_SWITCH_PROXY_SESSION_TOKEN #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ClaudeTakeoverAuthPolicy { - PreserveExistingOrAuthToken, + AuthToken, } #[derive(Debug, Clone, Copy, Default)] @@ -239,7 +239,7 @@ impl ProxyService { Self::apply_claude_takeover_fields_with_policy( config, proxy_url, - ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken, + ClaudeTakeoverAuthPolicy::AuthToken, ); } @@ -256,7 +256,7 @@ impl ProxyService { auth_token: !provider.is_github_copilot() || !provider.claude_uses_api_key_field(), } } else { - ProxyAuth::FollowRow + ProxyAuth::AuthToken }; let target = proxy_projection( &ClaudeProjection::of(&provider.settings_config), @@ -276,7 +276,7 @@ impl ProxyService { proxy_projection, store_into_row, ClaudeProjection, ProxyAuth, }; let auth = match auth_policy { - ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => ProxyAuth::FollowRow, + ClaudeTakeoverAuthPolicy::AuthToken => ProxyAuth::AuthToken, }; let target = proxy_projection(&ClaudeProjection::of(config), proxy_url, auth, None); *config = store_into_row(config, &target); @@ -3342,7 +3342,7 @@ impl ProxyService { Self::apply_claude_takeover_fields_with_policy( live, proxy_url, - ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken, + ClaudeTakeoverAuthPolicy::AuthToken, ); } } @@ -4367,7 +4367,7 @@ mod tests { } #[test] - fn copilot_claude_takeover_defaults_to_auth_token_placeholder() { + fn copilot_claude_takeover_defaults_to_auth_token_and_honors_explicit_api_key() { let mut provider = Provider::with_id( "copilot".to_string(), "GitHub Copilot".to_string(), @@ -4403,6 +4403,16 @@ mod tests { assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER)); assert_env_str(env, "OPENROUTER_API_KEY", Some("stale-openrouter-key")); assert_env_str(env, "OPENAI_API_KEY", Some("stale-openai-key")); + + provider.meta.as_mut().unwrap().api_key_field = Some("ANTHROPIC_API_KEY".into()); + ProxyService::apply_claude_takeover_fields_for_provider( + &mut live_config, + "http://127.0.0.1:15721", + &provider, + ); + let env = env_object(&live_config); + assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); + assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None); } #[test] @@ -4616,6 +4626,43 @@ mod tests { assert_env_str(env, "CLAUDE_CODE_SUBAGENT_MODEL", Some("provider-subagent")); } + #[test] + fn normal_claude_takeover_keeps_api_key_rows_off_the_local_hop() { + // #8104:OpenCode Go 这类预设把凭据存在 `ANTHROPIC_API_KEY`(上游跳要 + // x-api-key),路由模式的本地占位符只能写 `ANTHROPIC_AUTH_TOKEN`; + // 残留的 `ANTHROPIC_API_KEY` 一并清掉,否则 Claude Code 弹自定义 key + // 确认框,默认拒绝后就是未登录会话。 + let provider = Provider::with_id( + "opencode-go".to_string(), + "OpenCode Go".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://opencode.ai/zen/go", + "ANTHROPIC_API_KEY": "sk-go" + } + }), + None, + ); + let mut live_config = json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://stale.example.com", + "ANTHROPIC_API_KEY": "stale-key" + } + }); + ProxyService::apply_claude_takeover_fields_for_provider( + &mut live_config, + "http://127.0.0.1:15721", + &provider, + ); + + let env = live_config + .get("env") + .and_then(|value| value.as_object()) + .expect("env should exist"); + assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER)); + assert_env_str(env, "ANTHROPIC_API_KEY", None); + } + #[test] fn normal_claude_takeover_without_token_keeps_auth_token_fallback() { let mut live_config = json!({ @@ -9402,7 +9449,8 @@ requires_openai_auth = true "ANTHROPIC_BASE_URL", Some(format!("http://127.0.0.1:{preferred_port}").as_str()), ); - assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); + assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER)); + assert_env_str(env, "ANTHROPIC_API_KEY", None); assert_env_str(env, "LOCAL_ONLY", Some("kept")); } diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 490625752..635251551 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -1906,11 +1906,14 @@ async fn switch_provider_under_takeover_refreshes_claude_restore_backup_to_selec assert_eq!( live_during_takeover .get("env") - .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) .and_then(|value| value.as_str()), Some("PROXY_MANAGED"), "provider switch under takeover should keep the managed placeholder in Claude live config" ); + assert!(live_during_takeover + .pointer("/env/ANTHROPIC_API_KEY") + .is_none()); let backup_after_switch = state .db diff --git a/src-tauri/tests/proxy_service.rs b/src-tauri/tests/proxy_service.rs index 64d89b70f..a33a2ccd0 100644 --- a/src-tauri/tests/proxy_service.rs +++ b/src-tauri/tests/proxy_service.rs @@ -1042,11 +1042,12 @@ async fn proxy_service_stop_preserves_takeover_state_until_explicit_restore() { assert_eq!( live_after_stop .get("env") - .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) .and_then(|value| value.as_str()), Some("PROXY_MANAGED"), "stop should not restore the original Claude token" ); + assert!(live_after_stop.pointer("/env/ANTHROPIC_API_KEY").is_none()); state .proxy_service diff --git a/src-tauri/tests/proxy_takeover.rs b/src-tauri/tests/proxy_takeover.rs index 33c9ae5d6..7eaff3c74 100644 --- a/src-tauri/tests/proxy_takeover.rs +++ b/src-tauri/tests/proxy_takeover.rs @@ -189,11 +189,12 @@ async fn manual_takeover_can_rewrite_and_restore_claude_live_config() { assert_eq!( taken_over .get("env") - .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) .and_then(|value| value.as_str()), Some("PROXY_MANAGED"), "takeover should replace the live Claude token with a managed placeholder" ); + assert!(taken_over.pointer("/env/ANTHROPIC_API_KEY").is_none()); let app_proxy = db .get_proxy_config_for_app("claude") @@ -743,11 +744,12 @@ async fn disabling_managed_proxy_session_restores_current_app_takeover_state() { assert_eq!( taken_over .get("env") - .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) .and_then(|value| value.as_str()), Some("PROXY_MANAGED"), "managed proxy start should rewrite Claude live config into takeover mode" ); + assert!(taken_over.pointer("/env/ANTHROPIC_API_KEY").is_none()); state .proxy_service @@ -825,11 +827,12 @@ async fn startup_recovery_skips_owned_managed_session_when_probe_is_unreachable( assert_eq!( taken_over .get("env") - .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) .and_then(|value| value.as_str()), Some("PROXY_MANAGED"), "managed startup should place Claude in proxy takeover mode" ); + assert!(taken_over.pointer("/env/ANTHROPIC_API_KEY").is_none()); let runtime_session_raw = state .db @@ -880,11 +883,14 @@ async fn startup_recovery_skips_owned_managed_session_when_probe_is_unreachable( assert_eq!( live_after_restart .get("env") - .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) .and_then(|value| value.as_str()), Some("PROXY_MANAGED"), "startup recovery must not restore live config while managed runtime still exists" ); + assert!(live_after_restart + .pointer("/env/ANTHROPIC_API_KEY") + .is_none()); assert!( recovered .db