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:
Jason
2026-05-18 22:46:18 +08:00
parent 76b4c8b509
commit 61e68d754c
3 changed files with 343 additions and 17 deletions
+71 -1
View File
@@ -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();
+96
View File
@@ -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
View File
@@ -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(&current_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#"