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
+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#"