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:
Jason
2026-06-29 23:26:01 +08:00
parent 36e0378e78
commit 16f988bd1a
2 changed files with 616 additions and 10 deletions
+361
View File
@@ -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");