mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(providers): auto-sync Claude common config from live on switch
When switching away from a Claude provider that opted into common config, re-extract the shareable portion of its live settings.json and replace the stored snippet. This captures config the user added directly in the app (plugins/hooks/shared prefs) so it isn't lost on switch, and propagates deletions so a removed key isn't re-injected on the next switch. Scoped to Claude providers with common_config_enabled, skipped when the snippet was explicitly cleared. All failures are non-fatal (warn only) and never block the switch. Also harden extract_claude_common_config to strip ALL credential-like keys via pattern match (*_API_KEY / *_AUTH_TOKEN / secret / token / etc.), not just ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN. Claude providers legitimately use OPENROUTER_API_KEY / GOOGLE_API_KEY (and may carry OpenAI/Gemini/AWS Bedrock/Vertex creds), which previously could leak into the shared snippet and be injected into other providers. This also fixes the pre-existing leak in the manual Extract and one-time auto-extract paths. Plural _TOKENS (e.g. MAX_OUTPUT_TOKENS) is preserved as shareable. Tests: 4 integration (capture / delete-sync / opt-out / cleared) + 1 unit (credential stripping).
This commit is contained in:
@@ -1696,6 +1696,367 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 切走勾选了通用配置的 Claude 供应商时,应把它 live 里新增的可共享键
|
||||
/// (用户直接在应用内装插件/改偏好)捕获进通用配置片段,并带到下一个供应商。
|
||||
#[test]
|
||||
fn switch_claude_syncs_new_shared_keys_from_live_into_common_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||
}
|
||||
// A 的 live = A 私有密钥(含非 Anthropic 的 OpenRouter 凭据)+ 已共享的 theme
|
||||
// + 用户刚在应用内新增的 enableAllProjectMcpServers
|
||||
let live = json!({
|
||||
"env": { "ANTHROPIC_API_KEY": "a-key", "OPENROUTER_API_KEY": "sk-or-leak" },
|
||||
"theme": "dark",
|
||||
"enableAllProjectMcpServers": true
|
||||
});
|
||||
std::fs::write(
|
||||
&settings_path,
|
||||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||||
)
|
||||
.expect("seed claude live config");
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.current = "a".to_string();
|
||||
let mut provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_a.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("a".to_string(), provider_a);
|
||||
let mut provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_b.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("b".to_string(), provider_b);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&config).expect("create test state");
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(
|
||||
AppType::Claude.as_str(),
|
||||
Some(r#"{"theme":"dark"}"#.to_string()),
|
||||
)
|
||||
.expect("seed common config snippet");
|
||||
|
||||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||||
|
||||
// 片段应捕获到新增键,并保留已有共享键,且绝不含密钥
|
||||
let snippet = state
|
||||
.db
|
||||
.get_config_snippet(AppType::Claude.as_str())
|
||||
.expect("read snippet")
|
||||
.expect("snippet present");
|
||||
let snippet_value: serde_json::Value =
|
||||
serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||||
assert_eq!(
|
||||
snippet_value.get("enableAllProjectMcpServers"),
|
||||
Some(&json!(true)),
|
||||
"newly added shared key should be captured into common config"
|
||||
);
|
||||
assert_eq!(
|
||||
snippet_value.get("theme").and_then(|v| v.as_str()),
|
||||
Some("dark"),
|
||||
"previously shared key should be preserved"
|
||||
);
|
||||
assert!(
|
||||
snippet_value
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.is_none(),
|
||||
"secrets must never leak into the shared snippet"
|
||||
);
|
||||
assert!(
|
||||
snippet_value
|
||||
.get("env")
|
||||
.and_then(|env| env.get("OPENROUTER_API_KEY"))
|
||||
.is_none(),
|
||||
"non-Anthropic Claude credentials must never leak into the shared snippet"
|
||||
);
|
||||
|
||||
// 新增键应通过通用配置带到 B 的 live
|
||||
let live_after: serde_json::Value =
|
||||
read_json_file(&settings_path).expect("read live after switch");
|
||||
assert_eq!(
|
||||
live_after.get("enableAllProjectMcpServers"),
|
||||
Some(&json!(true)),
|
||||
"shared key should propagate to the next provider's live config"
|
||||
);
|
||||
assert!(
|
||||
live_after
|
||||
.get("env")
|
||||
.and_then(|env| env.get("OPENROUTER_API_KEY"))
|
||||
.is_none(),
|
||||
"leaked credential must not be injected into the next provider's live"
|
||||
);
|
||||
assert_eq!(
|
||||
live_after
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("b-key"),
|
||||
"live should reflect new provider's own auth"
|
||||
);
|
||||
}
|
||||
|
||||
/// 用户在应用内删掉一个已共享的键后,切换应把删除同步进通用配置,
|
||||
/// 且不会在切到下一个供应商时被重新注入(否则会"删不掉")。
|
||||
#[test]
|
||||
fn switch_claude_syncs_deletions_from_live_into_common_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||
}
|
||||
// live 里 theme 还在,但用户已删掉 enableAllProjectMcpServers
|
||||
let live = json!({
|
||||
"env": { "ANTHROPIC_API_KEY": "a-key" },
|
||||
"theme": "dark"
|
||||
});
|
||||
std::fs::write(
|
||||
&settings_path,
|
||||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||||
)
|
||||
.expect("seed claude live config");
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.current = "a".to_string();
|
||||
let mut provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_a.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("a".to_string(), provider_a);
|
||||
let mut provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_b.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("b".to_string(), provider_b);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&config).expect("create test state");
|
||||
// 片段里仍残留 enableAllProjectMcpServers(上次共享的)
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(
|
||||
AppType::Claude.as_str(),
|
||||
Some(r#"{"theme":"dark","enableAllProjectMcpServers":true}"#.to_string()),
|
||||
)
|
||||
.expect("seed common config snippet");
|
||||
|
||||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||||
|
||||
let snippet = state
|
||||
.db
|
||||
.get_config_snippet(AppType::Claude.as_str())
|
||||
.expect("read snippet")
|
||||
.expect("snippet present");
|
||||
let snippet_value: serde_json::Value =
|
||||
serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||||
assert!(
|
||||
snippet_value.get("enableAllProjectMcpServers").is_none(),
|
||||
"deleted key should be removed from common config"
|
||||
);
|
||||
assert_eq!(
|
||||
snippet_value.get("theme").and_then(|v| v.as_str()),
|
||||
Some("dark"),
|
||||
"untouched shared key should remain"
|
||||
);
|
||||
|
||||
// 切到 B 后 live 不应再出现被删除的键
|
||||
let live_after: serde_json::Value =
|
||||
read_json_file(&settings_path).expect("read live after switch");
|
||||
assert!(
|
||||
live_after.get("enableAllProjectMcpServers").is_none(),
|
||||
"deleted shared key must not be re-injected into the next provider"
|
||||
);
|
||||
}
|
||||
|
||||
/// 未勾选"写入通用配置"的供应商,其 live 改动不应自动污染通用配置片段。
|
||||
#[test]
|
||||
fn switch_claude_does_not_sync_common_config_for_opted_out_provider() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||
}
|
||||
let live = json!({
|
||||
"env": { "ANTHROPIC_API_KEY": "a-key" },
|
||||
"providerSpecific": "x"
|
||||
});
|
||||
std::fs::write(
|
||||
&settings_path,
|
||||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||||
)
|
||||
.expect("seed claude live config");
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.current = "a".to_string();
|
||||
// A 未勾选通用配置(meta = None)
|
||||
manager.providers.insert(
|
||||
"a".to_string(),
|
||||
Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
),
|
||||
);
|
||||
manager.providers.insert(
|
||||
"b".to_string(),
|
||||
Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&config).expect("create test state");
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(
|
||||
AppType::Claude.as_str(),
|
||||
Some(r#"{"theme":"dark"}"#.to_string()),
|
||||
)
|
||||
.expect("seed common config snippet");
|
||||
|
||||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||||
|
||||
let snippet = state
|
||||
.db
|
||||
.get_config_snippet(AppType::Claude.as_str())
|
||||
.expect("read snippet")
|
||||
.expect("snippet present");
|
||||
let snippet_value: serde_json::Value =
|
||||
serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||||
assert!(
|
||||
snippet_value.get("providerSpecific").is_none(),
|
||||
"opted-out provider's live changes must not pollute the shared snippet"
|
||||
);
|
||||
assert_eq!(
|
||||
snippet_value.get("theme").and_then(|v| v.as_str()),
|
||||
Some("dark"),
|
||||
"snippet should stay unchanged for opted-out providers"
|
||||
);
|
||||
}
|
||||
|
||||
/// 用户显式清空过通用配置(_cleared)后,切换不应把片段重新塞回来。
|
||||
#[test]
|
||||
fn switch_claude_respects_explicitly_cleared_common_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||
}
|
||||
let live = json!({
|
||||
"env": { "ANTHROPIC_API_KEY": "a-key" },
|
||||
"theme": "dark"
|
||||
});
|
||||
std::fs::write(
|
||||
&settings_path,
|
||||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||||
)
|
||||
.expect("seed claude live config");
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.current = "a".to_string();
|
||||
let mut provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_a.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("a".to_string(), provider_a);
|
||||
let mut provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
provider_b.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
manager.providers.insert("b".to_string(), provider_b);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&config).expect("create test state");
|
||||
state
|
||||
.db
|
||||
.set_config_snippet_cleared(AppType::Claude.as_str(), true)
|
||||
.expect("mark snippet cleared");
|
||||
|
||||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||||
|
||||
assert!(
|
||||
state
|
||||
.db
|
||||
.get_config_snippet(AppType::Claude.as_str())
|
||||
.expect("read snippet")
|
||||
.is_none(),
|
||||
"explicitly cleared snippet must not be resurrected by switch-away sync"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_service_switch_missing_provider_returns_error() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
Reference in New Issue
Block a user