Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 50 additions & 16 deletions src-tauri/src/live/project/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand All @@ -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"));
}
}
62 changes: 55 additions & 7 deletions src-tauri/src/services/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -239,7 +239,7 @@ impl ProxyService {
Self::apply_claude_takeover_fields_with_policy(
config,
proxy_url,
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,
ClaudeTakeoverAuthPolicy::AuthToken,
);
}

Expand All @@ -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),
Expand All @@ -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);
Expand Down Expand Up @@ -3342,7 +3342,7 @@ impl ProxyService {
Self::apply_claude_takeover_fields_with_policy(
live,
proxy_url,
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,
ClaudeTakeoverAuthPolicy::AuthToken,
);
}
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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!({
Expand Down Expand Up @@ -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"));
}

Expand Down
5 changes: 4 additions & 1 deletion src-tauri/tests/provider_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/tests/proxy_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions src-tauri/tests/proxy_takeover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading