mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(proxy): inject only ANTHROPIC_API_KEY for managed-account Claude takeover
- Provider: add uses_managed_account_auth / is_github_copilot helpers to identify managed-account providers (GitHub Copilot / Codex OAuth) - ProxyService: choose auth policy by provider type when taking over Claude Live config. Managed accounts drop token env keys and write only the ANTHROPIC_API_KEY placeholder; other providers keep the existing ANTHROPIC_AUTH_TOKEN fallback behavior - Forwarder: add outbound guard that refuses to send the PROXY_MANAGED placeholder upstream to *.githubcopilot.com and chatgpt.com /backend-api/codex - Add unit tests covering detection, injection, and the outbound guard
This commit is contained in:
@@ -67,7 +67,30 @@ impl Provider {
|
||||
}
|
||||
|
||||
pub fn is_codex_oauth(&self) -> bool {
|
||||
self.meta.as_ref().and_then(|m| m.provider_type.as_deref()) == Some("codex_oauth")
|
||||
self.provider_type() == Some("codex_oauth")
|
||||
}
|
||||
|
||||
pub fn is_github_copilot(&self) -> bool {
|
||||
self.provider_type() == Some("github_copilot")
|
||||
|| self.claude_base_url_contains("githubcopilot.com")
|
||||
}
|
||||
|
||||
pub fn uses_managed_account_auth(&self) -> bool {
|
||||
self.is_github_copilot()
|
||||
|| self.is_codex_oauth()
|
||||
|| self.claude_base_url_contains("chatgpt.com/backend-api/codex")
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> Option<&str> {
|
||||
self.meta.as_ref().and_then(|m| m.provider_type.as_deref())
|
||||
}
|
||||
|
||||
fn claude_base_url_contains(&self, needle: &str) -> bool {
|
||||
self.settings_config
|
||||
.pointer("/env/ANTHROPIC_BASE_URL")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|base_url| base_url.contains(needle))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn codex_fast_mode_enabled(&self) -> bool {
|
||||
@@ -810,6 +833,53 @@ mod tests {
|
||||
assert!(!provider.in_failover_queue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_managed_account_auth_detection_uses_type_or_known_endpoint() {
|
||||
let mut copilot = Provider::with_id(
|
||||
"copilot".to_string(),
|
||||
"Copilot".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert!(copilot.is_github_copilot());
|
||||
assert!(copilot.uses_managed_account_auth());
|
||||
|
||||
let mut codex = Provider::with_id(
|
||||
"codex".to_string(),
|
||||
"Codex".to_string(),
|
||||
json!({ "env": {} }),
|
||||
None,
|
||||
);
|
||||
codex.meta = Some(ProviderMeta {
|
||||
provider_type: Some("codex_oauth".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(codex.is_codex_oauth());
|
||||
assert!(codex.uses_managed_account_auth());
|
||||
|
||||
let codex_endpoint = Provider::with_id(
|
||||
"codex-endpoint".to_string(),
|
||||
"Codex Endpoint".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert!(codex_endpoint.uses_managed_account_auth());
|
||||
|
||||
copilot.meta = Some(ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(copilot.is_github_copilot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_manager_get_all_providers_returns_map() {
|
||||
let mut manager = ProviderManager::default();
|
||||
|
||||
@@ -32,6 +32,8 @@ use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
const PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED";
|
||||
|
||||
pub struct ForwardResult {
|
||||
pub response: ProxyResponse,
|
||||
pub provider: Provider,
|
||||
@@ -1562,6 +1564,8 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?;
|
||||
|
||||
// 输出请求信息日志
|
||||
let tag = adapter.name();
|
||||
let request_model = filtered_body
|
||||
@@ -2153,6 +2157,43 @@ fn build_codex_oauth_session_headers(
|
||||
headers
|
||||
}
|
||||
|
||||
fn reject_proxy_placeholder_for_managed_account_upstream(
|
||||
url: &str,
|
||||
headers: &http::HeaderMap,
|
||||
) -> Result<(), ProxyError> {
|
||||
if !is_managed_account_upstream_url(url) || !headers_contain_proxy_placeholder(headers) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ProxyError::AuthError(
|
||||
"Managed account proxy auth was not resolved; PROXY_MANAGED must not be sent upstream"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn is_managed_account_upstream_url(url: &str) -> bool {
|
||||
let Ok(uri) = url.parse::<http::Uri>() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(host) = uri.host().map(str::to_ascii_lowercase) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
host == "githubcopilot.com"
|
||||
|| host.ends_with(".githubcopilot.com")
|
||||
|| (host == "chatgpt.com" && uri.path().starts_with("/backend-api/codex"))
|
||||
}
|
||||
|
||||
fn headers_contain_proxy_placeholder(headers: &http::HeaderMap) -> bool {
|
||||
headers.values().any(|value| {
|
||||
value
|
||||
.to_str()
|
||||
.map(|value| value.contains(PROXY_AUTH_PLACEHOLDER))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn should_preserve_exact_header_case(
|
||||
adapter_name: &str,
|
||||
provider: &Provider,
|
||||
@@ -2595,6 +2636,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_account_upstream_rejects_proxy_managed_placeholder_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"authorization",
|
||||
HeaderValue::from_static("Bearer PROXY_MANAGED"),
|
||||
);
|
||||
|
||||
let err = reject_proxy_placeholder_for_managed_account_upstream(
|
||||
"https://api.githubcopilot.com/chat/completions",
|
||||
&headers,
|
||||
)
|
||||
.expect_err("placeholder should be rejected before upstream");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ProxyError::AuthError(message) if message.contains("PROXY_MANAGED")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_oauth_upstream_rejects_proxy_managed_placeholder_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"authorization",
|
||||
HeaderValue::from_static("Bearer PROXY_MANAGED"),
|
||||
);
|
||||
|
||||
let err = reject_proxy_placeholder_for_managed_account_upstream(
|
||||
"https://chatgpt.com/backend-api/codex/responses",
|
||||
&headers,
|
||||
)
|
||||
.expect_err("placeholder should be rejected before upstream");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ProxyError::AuthError(message) if message.contains("PROXY_MANAGED")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_managed_upstream_allows_proxy_managed_placeholder_guard() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"authorization",
|
||||
HeaderValue::from_static("Bearer PROXY_MANAGED"),
|
||||
);
|
||||
|
||||
reject_proxy_placeholder_for_managed_account_upstream(
|
||||
"https://api.example.com/v1/messages",
|
||||
&headers,
|
||||
)
|
||||
.expect("guard is scoped to managed-account upstreams");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_header_case_preserved_for_native_claude_only() {
|
||||
let provider = test_provider_with_type(None);
|
||||
|
||||
+176
-16
@@ -45,6 +45,12 @@ const CLAUDE_TAKEOVER_OPUS_MODEL: &str = "claude-opus-4-7";
|
||||
// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。
|
||||
const CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = "[1M]";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ClaudeTakeoverAuthPolicy {
|
||||
PreserveExistingOrAuthToken,
|
||||
ManagedAccount,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyService {
|
||||
db: Arc<Database>,
|
||||
@@ -69,7 +75,34 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {
|
||||
Self::apply_claude_takeover_fields_with_policy(
|
||||
config,
|
||||
proxy_url,
|
||||
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_claude_takeover_fields_for_provider(
|
||||
config: &mut Value,
|
||||
proxy_url: &str,
|
||||
provider: &Provider,
|
||||
) {
|
||||
let auth_policy = if provider.uses_managed_account_auth() {
|
||||
ClaudeTakeoverAuthPolicy::ManagedAccount
|
||||
} else {
|
||||
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken
|
||||
};
|
||||
|
||||
Self::apply_claude_takeover_fields_with_policy(config, proxy_url, auth_policy);
|
||||
}
|
||||
|
||||
fn apply_claude_takeover_fields_with_policy(
|
||||
config: &mut Value,
|
||||
proxy_url: &str,
|
||||
auth_policy: ClaudeTakeoverAuthPolicy,
|
||||
) {
|
||||
// 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。
|
||||
let takeover_model_fields = Self::build_claude_takeover_model_fields(config);
|
||||
|
||||
@@ -105,19 +138,32 @@ impl ProxyService {
|
||||
"OPENAI_API_KEY",
|
||||
];
|
||||
|
||||
let mut replaced_any = false;
|
||||
for key in token_keys {
|
||||
if env.contains_key(key) {
|
||||
env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
replaced_any = true;
|
||||
}
|
||||
}
|
||||
match auth_policy {
|
||||
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {
|
||||
let mut replaced_any = false;
|
||||
for key in token_keys {
|
||||
if env.contains_key(key) {
|
||||
env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
replaced_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced_any {
|
||||
env.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(PROXY_TOKEN_PLACEHOLDER),
|
||||
);
|
||||
if !replaced_any {
|
||||
env.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(PROXY_TOKEN_PLACEHOLDER),
|
||||
);
|
||||
}
|
||||
}
|
||||
ClaudeTakeoverAuthPolicy::ManagedAccount => {
|
||||
for key in token_keys {
|
||||
env.remove(key);
|
||||
}
|
||||
env.insert(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
json!(PROXY_TOKEN_PLACEHOLDER),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,11 +274,32 @@ impl ProxyService {
|
||||
.map_err(|e| format!("构建 claude 有效配置失败: {e}"))?;
|
||||
let (proxy_url, _) = self.build_proxy_urls().await?;
|
||||
|
||||
Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);
|
||||
Self::apply_claude_takeover_fields_for_provider(
|
||||
&mut effective_settings,
|
||||
&proxy_url,
|
||||
provider,
|
||||
);
|
||||
self.write_claude_live(&effective_settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {
|
||||
let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)
|
||||
.map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
self.db
|
||||
.get_provider_by_id(¤t_id, app_type.as_str())
|
||||
.map_err(|e| format!("读取 {app_type:?} 当前供应商失败: {e}"))
|
||||
}
|
||||
|
||||
fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {
|
||||
self.get_current_provider_for_app(app_type)?
|
||||
.ok_or_else(|| format!("{app_type:?} 当前供应商不存在,无法接管 Live 配置"))
|
||||
}
|
||||
|
||||
/// 设置 AppHandle(在应用初始化时调用)
|
||||
pub fn set_app_handle(&self, handle: tauri::AppHandle) {
|
||||
futures::executor::block_on(async {
|
||||
@@ -1007,7 +1074,12 @@ impl ProxyService {
|
||||
|
||||
// Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)
|
||||
if let Ok(mut live_config) = self.read_claude_live() {
|
||||
Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);
|
||||
let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;
|
||||
Self::apply_claude_takeover_fields_for_provider(
|
||||
&mut live_config,
|
||||
&proxy_url,
|
||||
&claude_provider,
|
||||
);
|
||||
self.write_claude_live(&live_config)?;
|
||||
log::info!("Claude Live 配置已接管,代理地址: {proxy_url}");
|
||||
}
|
||||
@@ -1057,7 +1129,12 @@ impl ProxyService {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let mut live_config = self.read_claude_live()?;
|
||||
Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);
|
||||
let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;
|
||||
Self::apply_claude_takeover_fields_for_provider(
|
||||
&mut live_config,
|
||||
&proxy_url,
|
||||
&claude_provider,
|
||||
);
|
||||
self.write_claude_live(&live_config)?;
|
||||
log::info!("Claude Live 配置已接管,代理地址: {proxy_url}");
|
||||
}
|
||||
@@ -1107,7 +1184,23 @@ impl ProxyService {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if let Ok(mut live_config) = self.read_claude_live() {
|
||||
Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);
|
||||
let claude_provider = self
|
||||
.get_current_provider_for_app(&AppType::Claude)
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some(provider) = claude_provider.as_ref() {
|
||||
Self::apply_claude_takeover_fields_for_provider(
|
||||
&mut live_config,
|
||||
&proxy_url,
|
||||
provider,
|
||||
);
|
||||
} else {
|
||||
Self::apply_claude_takeover_fields_with_policy(
|
||||
&mut live_config,
|
||||
&proxy_url,
|
||||
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,
|
||||
);
|
||||
}
|
||||
let _ = self.write_claude_live(&live_config);
|
||||
}
|
||||
}
|
||||
@@ -2083,6 +2176,73 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_account_claude_takeover_uses_api_key_placeholder() {
|
||||
let mut provider = Provider::with_id(
|
||||
"copilot".to_string(),
|
||||
"GitHub Copilot".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com",
|
||||
"ANTHROPIC_MODEL": "claude-haiku-4.5"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut live_config = provider.settings_config.clone();
|
||||
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_eq!(
|
||||
env.get("ANTHROPIC_API_KEY")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some(PROXY_TOKEN_PLACEHOLDER)
|
||||
);
|
||||
assert!(
|
||||
env.get("ANTHROPIC_AUTH_TOKEN").is_none(),
|
||||
"managed OAuth providers should avoid Claude Auth Token login semantics"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {
|
||||
let mut live_config = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com",
|
||||
"ANTHROPIC_MODEL": "claude-haiku-4.5"
|
||||
}
|
||||
});
|
||||
|
||||
ProxyService::apply_claude_takeover_fields(&mut live_config, "http://127.0.0.1:15721");
|
||||
|
||||
assert_eq!(
|
||||
live_config
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some(PROXY_TOKEN_PLACEHOLDER)
|
||||
);
|
||||
assert!(
|
||||
live_config
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.is_none(),
|
||||
"non-managed providers should retain the legacy fallback behavior"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_toml_base_url_updates_active_model_provider_base_url() {
|
||||
let input = r#"
|
||||
|
||||
Reference in New Issue
Block a user