Files
cc-switch/src-tauri/tests/provider_service.rs
T
Jasonandallenxu09 3d7cf6aa32 refactor(omp): port #8308's text-level models.yml editor and share Pi plumbing
Replace the passthrough/splice YAML layer with a single omp_config.rs:
#6747's verified agent-dir resolution (profiles, PI_CONFIG_DIR, XDG data
dir) plus #8308's block-level editor that rewrites only the one provider
entry, refuses anchors/tags/flow style instead of re-emitting them, and
rolls the file back byte for byte when the database commit fails.

- Validate only the node being written; model-level `api` also accepts
  omp's runner APIs.
- Drop ProviderMeta.ompPassthroughYaml, validation.rs and omp_state; the
  current-state command lives in commands/omp.rs and reuses PiCurrentState.
- Usage scripts go through the Pi command with an `app` argument.
- MCP reuses Pi's transport helpers; mcp.json writes are revision checked.
- Skills follow Pi: no DB column, state derived from <agent>/skills, and
  uninstall keeps an unverifiable same-name directory in either app.
- Move enabled_omp to a new v22 -> v23 migration (v22 shipped in 4.0.19)
  and add it to mcp_servers only; revert the .gitignore additions.

Co-authored-by: allenxu09 <s23372.xu@stu.scie.com.cn>
2026-10-24 15:30:32 +08:00

3745 lines
130 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde_json::json;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,
McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
};
#[path = "support.rs"]
mod support;
use support::{
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
ensure_test_home, reset_test_fs, test_mutex,
};
fn sanitize_provider_name(name: &str) -> String {
name.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
_ => c,
})
.collect::<String>()
.to_lowercase()
}
#[test]
fn migrate_legacy_common_config_usage_marks_historical_provider_enabled() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "legacy-provider".to_string();
manager.providers.insert(
"legacy-provider".to_string(),
Provider::with_id(
"legacy-provider".to_string(),
"Legacy".to_string(),
json!({
"includeCoAuthoredBy": false,
"env": {
"ANTHROPIC_API_KEY": "legacy-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#"{ "includeCoAuthoredBy": false }"#.to_string()),
)
.expect("set common config snippet");
ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude)
.expect("migrate legacy common config");
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get providers after migration");
let provider = providers
.get("legacy-provider")
.expect("legacy provider exists");
assert_eq!(
provider
.meta
.as_ref()
.and_then(|meta| meta.common_config_enabled),
Some(true),
"historical provider should be explicitly marked as using common config"
);
assert!(
provider
.settings_config
.get("includeCoAuthoredBy")
.is_none(),
"common config fields should be stripped from provider storage after migration"
);
assert_eq!(
provider
.settings_config
.get("env")
.and_then(|v| v.get("ANTHROPIC_API_KEY"))
.and_then(|v| v.as_str()),
Some("legacy-key"),
"provider-specific auth should remain untouched"
);
}
#[test]
fn provider_service_switch_codex_updates_live_and_config() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
enable_codex_official_auth_preservation();
let _home = ensure_test_home();
let legacy_auth = json!({ "OPENAI_API_KEY": "legacy-key" });
let legacy_config = r#"[mcp_servers.legacy]
type = "stdio"
command = "echo"
"#;
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
.expect("seed existing codex live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "old-provider".to_string();
manager.providers.insert(
"old-provider".to_string(),
Provider::with_id(
"old-provider".to_string(),
"Legacy".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "stale"},
"config": "stale-config"
}),
None,
),
);
manager.providers.insert(
"new-provider".to_string(),
Provider::with_id(
"new-provider".to_string(),
"Latest".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "fresh-key"},
"config": r#"[mcp_servers.latest]
type = "stdio"
command = "say"
"#
}),
None,
),
);
}
// 使用新的统一 MCP 结构(v3.7.0+)
let servers = initial_config
.mcp
.servers
.get_or_insert_with(Default::default);
servers.insert(
"echo-server".into(),
McpServer {
id: "echo-server".into(),
name: "Echo Server".into(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
agy: false,
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
mcode: false,
kimi: false,
pi: false,
omp: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "new-provider")
.expect("switch provider should succeed");
let auth_value: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()),
Some("legacy-key"),
"Codex provider switching should preserve the existing live auth.json"
);
let config_text =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
// 只替换关键字段:live 里用户的 MCP 原样留着;行里的 MCP 不是关键字段,不投影。
assert!(
config_text.contains("[mcp_servers.legacy]"),
"live settings outside the key fields stay: {config_text}"
);
assert!(
!config_text.contains("mcp_servers.latest"),
"the row's own MCP section is not projected: {config_text}"
);
// 这张卡没有路由(只有 MCP),Key 没有第三方地址可发,不写进 live。
assert!(
!config_text.contains("experimental_bearer_token"),
"{config_text}"
);
let current_id = state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("read current provider after switch");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch");
let legacy = providers
.get("old-provider")
.expect("legacy provider still exists");
assert_eq!(
legacy.settings_config,
json!({ "auth": {"OPENAI_API_KEY": "stale"}, "config": "stale-config" }),
"switching away never writes live content back into the row"
);
}
#[test]
/// 第三方路由一律写成 `custom`:行里用什么 id 都一样,行本身不改写。
fn provider_service_switch_codex_writes_every_third_party_route_as_custom() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" });
let legacy_config = r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
.expect("seed existing codex live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "old-provider".to_string();
manager.providers.insert(
"old-provider".to_string(),
Provider::with_id(
"old-provider".to_string(),
"RightCode".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "stale"},
"config": legacy_config
}),
None,
),
);
manager.providers.insert(
"new-provider".to_string(),
Provider::with_id(
"new-provider".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "fresh-key"},
"config": r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "new-provider")
.expect("switch provider should succeed");
let config_text =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
let parsed: toml::Value = toml::from_str(&config_text).expect("parse config.toml");
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("custom"),
"every third-party route is written as the custom table"
);
let model_providers = parsed
.get("model_providers")
.and_then(|v| v.as_table())
.expect("model_providers table exists");
assert_eq!(
model_providers
.get("custom")
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://aihubmix.example/v1"),
"the custom table points at the newly selected supplier endpoint"
);
assert!(
model_providers.get("aihubmix").is_none(),
"the row's own table id is not written"
);
assert!(
model_providers.get("rightcode").is_none(),
"the old version's table for old-provider (same id and address as its row) is retired"
);
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch");
let new_config_text = providers
.get("new-provider")
.expect("new provider exists")
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
new_config_text.contains("[model_providers.aihubmix]"),
"stored provider template stays as it was"
);
}
#[test]
fn provider_service_switch_codex_preserves_oauth_and_keeps_rows_untouched() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
enable_codex_official_auth_preservation();
let _home = ensure_test_home();
let live_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "oauth-token",
"account_id": "acct-1"
}
});
let legacy_config = r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
write_codex_live_atomic(&live_auth, Some(legacy_config))
.expect("seed existing Codex OAuth live config");
let bridge_provider = Provider::with_id(
"bridge-provider".to_string(),
"Bridge Provider".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "bridge-key"},
"config": r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
);
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "legacy-provider".to_string();
manager.providers.insert(
"legacy-provider".to_string(),
Provider::with_id(
"legacy-provider".to_string(),
"RightCode".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "rightcode-key"},
"config": legacy_config
}),
None,
),
);
manager
.providers
.insert("bridge-provider".to_string(), bridge_provider);
manager.providers.insert(
"plain-provider".to_string(),
Provider::with_id(
"plain-provider".to_string(),
"Plain Provider".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "plain-key"},
"config": r#"model_provider = "plain"
model = "gpt-5.4"
[model_providers.plain]
name = "Plain"
base_url = "https://plain.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
let bridge_before = state
.db
.get_provider_by_id("bridge-provider", AppType::Codex.as_str())
.expect("read bridge row")
.expect("bridge row");
ProviderService::switch(&state, AppType::Codex, "bridge-provider")
.expect("switch to bridge provider should succeed");
let auth_value: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_value.get("auth_mode").and_then(|v| v.as_str()),
Some("chatgpt")
);
assert!(
auth_value
.get("OPENAI_API_KEY")
.is_some_and(|v| v.is_null()),
"provider switching should keep OPENAI_API_KEY null in live auth.json"
);
assert_eq!(
auth_value
.pointer("/tokens/access_token")
.and_then(|v| v.as_str()),
Some("oauth-token"),
"existing ChatGPT OAuth token should be preserved"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
let parsed_live: toml::Value = toml::from_str(&live_config).expect("parse live config");
assert_eq!(
parsed_live.get("model_provider").and_then(|v| v.as_str()),
Some("custom"),
"third-party routes are always written as the custom table"
);
assert_eq!(
parsed_live
.get("model_providers")
.and_then(|v| v.get("custom"))
.and_then(|v| v.get("experimental_bearer_token"))
.and_then(|v| v.as_str()),
Some("bridge-key"),
"third-party key should be injected into the selected live provider table"
);
assert_eq!(
parsed_live
.get("model_providers")
.and_then(|v| v.get("custom"))
.and_then(|v| v.get("requires_openai_auth"))
.and_then(|v| v.as_bool()),
Some(true)
);
// 旧版按行的 id 写进 live 的表(id 和地址都对得上 legacy-provider 的投影)被清掉,
// 里面的真实 Key 不再留在 live 里。
assert!(
parsed_live
.get("model_providers")
.and_then(|v| v.get("rightcode"))
.is_none(),
"the table an old version wrote for legacy-provider is retired: {live_config}"
);
ProviderService::switch(&state, AppType::Codex, "plain-provider")
.expect("switch away from the bridge provider");
let bridge_after = state
.db
.get_provider_by_id("bridge-provider", AppType::Codex.as_str())
.expect("read bridge row")
.expect("bridge row");
assert_eq!(
bridge_after.settings_config, bridge_before.settings_config,
"switching away never writes live content back into the row"
);
}
#[tokio::test(flavor = "current_thread")]
#[allow(
clippy::await_holding_lock,
reason = "this integration-style test must serialize global test HOME and settings mutations across async takeover calls"
)]
async fn codex_official_to_deepseek_then_takeover_enters_and_restores_proxy_managed_live_config() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
enable_codex_official_auth_preservation();
let _home = ensure_test_home();
let oauth_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"access_token": "oauth-access",
"id_token": "oauth-id"
}
});
let official_config = r#"model_provider = "openai"
model = "gpt-5"
[model_providers.openai]
name = "OpenAI"
wire_api = "responses"
"#;
write_codex_live_atomic(&oauth_auth, Some(official_config))
.expect("seed official Codex OAuth live config");
let deepseek_provider_config = r#"model_provider = "deepseek"
model = "deepseek-chat"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
"#;
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "official-provider".to_string();
let mut official_provider = Provider::with_id(
"official-provider".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": oauth_auth,
"config": official_config
}),
None,
);
official_provider.category = Some("official".to_string());
manager
.providers
.insert("official-provider".to_string(), official_provider);
let mut deepseek_provider = Provider::with_id(
"deepseek-provider".to_string(),
"DeepSeek".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "deepseek-key"},
"config": deepseek_provider_config
}),
None,
);
deepseek_provider.category = Some("custom".to_string());
manager
.providers
.insert("deepseek-provider".to_string(), deepseek_provider);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
let mut proxy_config = state.db.get_proxy_config().await.expect("get proxy config");
proxy_config.listen_port = 0;
state
.db
.update_proxy_config(proxy_config)
.await
.expect("use ephemeral proxy port");
ProviderService::switch(&state, AppType::Codex, "deepseek-provider")
.expect("switch from official subscription to DeepSeek");
let auth_after_switch: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after switch");
assert_eq!(
auth_after_switch, oauth_auth,
"normal provider switch with Codex preservation enabled must keep OAuth auth.json"
);
let config_after_switch =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config");
assert!(
config_after_switch.contains("https://api.deepseek.com/v1"),
"normal switch should write the DeepSeek endpoint before takeover"
);
assert!(
config_after_switch.contains("deepseek-key"),
"normal switch should inject the DeepSeek key into config.toml"
);
cc_switch_lib::mode::controller::enter(&state, &AppType::Codex, false)
.await
.expect("enter Codex routing mode");
let proxy_status = state
.proxy_service
.get_status()
.await
.expect("read proxy status after takeover");
let codex_proxy_base_url = format!("http://127.0.0.1:{}/v1", proxy_status.port);
let auth_after_takeover: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after takeover");
assert_eq!(
auth_after_takeover, oauth_auth,
"enabling takeover must not rewrite Codex OAuth auth.json"
);
let config_after_takeover =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config");
assert!(
config_after_takeover.contains(&codex_proxy_base_url),
"enabling takeover should point Codex config.toml at the local proxy"
);
assert!(
config_after_takeover.contains("PROXY_MANAGED"),
"enabling takeover should move the proxy placeholder into config.toml"
);
assert!(
!config_after_takeover.contains("https://api.deepseek.com/v1"),
"takeover live config should not keep the upstream DeepSeek endpoint"
);
cc_switch_lib::mode::controller::exit(&state, &AppType::Codex)
.await
.expect("leave Codex routing mode");
let restored_auth: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read restored auth");
assert_eq!(
restored_auth, oauth_auth,
"disabling takeover should restore without replacing OAuth auth.json"
);
let restored_config = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
.expect("read restored config");
assert!(
restored_config.contains("https://api.deepseek.com/v1")
&& restored_config.contains("deepseek-key"),
"disabling takeover should restore the selected DeepSeek live config"
);
assert!(
!restored_config.contains("PROXY_MANAGED"),
"restored live config must not keep the proxy placeholder"
);
}
#[test]
fn provider_service_switch_codex_default_removes_auth_json_when_preservation_off() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
// Intentionally do NOT enable preservation: this locks the default opt-out
// behavior where a third-party switch deletes auth.json outright — the
// official OAuth login is not preserved, and the third-party key never
// lands there either (it travels as the provider-scoped bearer token in
// config.toml). It is the dual of
// `provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token`.
let _home = ensure_test_home();
let live_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "official-oauth-token",
"account_id": "acct-1"
}
});
let legacy_config = r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
write_codex_live_atomic(&live_auth, Some(legacy_config))
.expect("seed existing Codex OAuth live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "legacy-provider".to_string();
manager.providers.insert(
"legacy-provider".to_string(),
Provider::with_id(
"legacy-provider".to_string(),
"RightCode".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "rightcode-key"},
"config": legacy_config
}),
None,
),
);
manager.providers.insert(
"third-party".to_string(),
Provider::with_id(
"third-party".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "third-party-key"},
"config": r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "third-party")
.expect("switch to third-party provider should succeed");
assert!(
!cc_switch_lib::get_codex_auth_path().exists(),
"default (preservation off) must delete auth.json on a third-party switch — \
the official login goes away and the key rides in config.toml instead"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
live_config.contains("experimental_bearer_token = \"third-party-key\""),
"the third-party key must be injected as the provider-scoped bearer token; got:\n{live_config}"
);
}
#[test]
fn provider_service_switch_codex_default_injects_bearer_token_into_config() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
// Preservation stays OFF (default). Since Codex 0.149 (openai/codex#39214)
// custom providers no longer inherit ambient auth, so third-party switches
// are config-only on every path: the key travels as a provider-scoped
// `experimental_bearer_token` and auth.json is removed.
let _home = ensure_test_home();
let third_party_config = r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = false
"#;
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(
"third-party".to_string(),
Provider::with_id(
"third-party".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "third-party-key"},
"config": third_party_config
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "third-party")
.expect("switch to third-party provider should succeed");
assert!(
!cc_switch_lib::get_codex_auth_path().exists(),
"third-party switches are config-only: no auth.json is written"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
live_config.contains("experimental_bearer_token = \"third-party-key\""),
"default switch must inject the API key into config.toml so Codex >= 0.149 \
custom providers authenticate (openai/codex#39214); got:\n{live_config}"
);
}
#[test]
fn provider_service_switch_codex_preserved_login_rejects_empty_third_party_config() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
// Preservation ON + third-party provider with an empty config: auth.json is
// not written, and an empty config.toml has no provider table to carry the
// bearer token, so the API key has nowhere to land while the official
// OAuth login stays live — Codex would silently fall back to the official
// provider and bill the ChatGPT account. The switch must be refused, as it
// was before the bearer-token injection change.
let _home = ensure_test_home();
enable_codex_official_auth_preservation();
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(
"empty-config".to_string(),
Provider::with_id(
"empty-config".to_string(),
"EmptyConfig".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "third-party-key"},
"config": ""
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
let err = ProviderService::switch(&state, AppType::Codex, "empty-config").expect_err(
"switching to an empty-config third-party provider with preservation on must fail",
);
assert!(
err.to_string().contains("config.toml"),
"error should explain the missing config.toml, got: {err}"
);
}
#[test]
fn provider_service_switch_codex_preserved_login_normalizes_legacy_reroute_config() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
// Preservation ON + a legacy-shape third-party config (top-level
// openai_base_url rerouting the built-in `openai` provider): the shape
// has no provider table to carry the bearer token — since 0.149 the
// built-in provider would keep using the preserved official OAuth from
// auth.json and send it to the third-party base URL. The switch must
// normalize the config into the custom table with the key
// injected, leaving the official login untouched.
let _home = ensure_test_home();
enable_codex_official_auth_preservation();
let live_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "official-oauth-token",
"account_id": "acct-1"
}
});
write_codex_live_atomic(&live_auth, Some("")).expect("seed official OAuth live config");
let legacy_shape_config = r#"model_provider = "openai"
model = "gpt-5.4"
openai_base_url = "https://relay.example/v1"
"#;
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(
"legacy-shape".to_string(),
Provider::with_id(
"legacy-shape".to_string(),
"LegacyShape".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "third-party-key"},
"config": legacy_shape_config
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "legacy-shape")
.expect("legacy reroute shape must be normalized, not rejected");
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
!live_config.contains("openai_base_url"),
"the top-level reroute must be rewritten away; got:\n{live_config}"
);
assert!(
live_config.contains("[model_providers.custom]")
&& live_config.contains("base_url = \"https://relay.example/v1\"")
&& live_config.contains("experimental_bearer_token = \"third-party-key\""),
"routing and key must move into the custom provider table; got:\n{live_config}"
);
let auth_value: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_value
.pointer("/tokens/access_token")
.and_then(|v| v.as_str()),
Some("official-oauth-token"),
"the preserved official OAuth login must stay untouched"
);
}
#[test]
fn provider_service_switch_codex_preserved_login_normalizes_config_carried_token() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
// Same legacy reroute shape, but the key sits in the config text itself
// (raw-edited provider with `auth = {}`): normalization must see
// config-carried tokens too, not only auth.OPENAI_API_KEY, and the
// injected token must land inside the rewritten provider table.
let _home = ensure_test_home();
enable_codex_official_auth_preservation();
let raw_edited_config = r#"model_provider = "openai"
model = "gpt-5.4"
openai_base_url = "https://relay.example/v1"
experimental_bearer_token = "config-carried-key"
"#;
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(
"raw-edited".to_string(),
Provider::with_id(
"raw-edited".to_string(),
"RawEdited".to_string(),
json!({
"auth": {},
"config": raw_edited_config
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "raw-edited")
.expect("legacy reroute with a config-carried token must be normalized");
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
!live_config.contains("openai_base_url"),
"the top-level reroute must be rewritten away; got:\n{live_config}"
);
assert!(
live_config.contains("[model_providers.custom]"),
"the custom provider table must be created; got:\n{live_config}"
);
assert_eq!(
cc_switch_lib::extract_codex_experimental_bearer_token(&live_config).as_deref(),
Some("config-carried-key"),
"the config-carried key must resolve for the rewritten provider; got:\n{live_config}"
);
}
#[test]
fn provider_service_switch_codex_default_normalizes_legacy_reroute_config() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
// Same legacy shape with preservation OFF (default): the switch is
// config-only on every path, so instead of feeding the built-in
// provider's ambient auth through auth.json the shape is normalized into
// a custom table and auth.json is removed.
let _home = ensure_test_home();
let legacy_shape_config = r#"model_provider = "openai"
model = "gpt-5.4"
openai_base_url = "https://relay.example/v1"
"#;
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(
"legacy-shape".to_string(),
Provider::with_id(
"legacy-shape".to_string(),
"LegacyShape".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "third-party-key"},
"config": legacy_shape_config
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "legacy-shape")
.expect("default-path switch must normalize the legacy ambient-auth shape");
assert!(
!cc_switch_lib::get_codex_auth_path().exists(),
"third-party switches are config-only: no auth.json is written"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
!live_config.contains("openai_base_url")
&& live_config.contains("[model_providers.custom]")
&& live_config.contains("experimental_bearer_token = \"third-party-key\""),
"routing and key must move into the custom provider table; got:\n{live_config}"
);
}
#[test]
fn provider_service_switch_codex_preserved_login_rejects_keyless_official_auth_fallback() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
// Preservation ON + a header-auth card with NO API key anywhere
// (`auth = {}`) whose config also sets `requires_openai_auth = true`:
// there is no token to inject, so Codex 0.149 resolves auth from the
// preserved official OAuth in auth.json and applies it AFTER provider
// headers — the explicit Authorization header is overwritten and the
// ChatGPT access token + account id go to the third-party endpoint.
// The switch must be refused (fail closed).
let _home = ensure_test_home();
enable_codex_official_auth_preservation();
let header_auth_with_fallback = r#"model_provider = "custom"
model = "gpt-5.4"
[model_providers.custom]
name = "Custom"
base_url = "https://relay.example/v1"
wire_api = "responses"
requires_openai_auth = true
http_headers = { Authorization = "Bearer explicit-header-token" }
"#;
let good_config = r#"model_provider = "good"
model = "gpt-5.4"
[model_providers.good]
name = "Good"
base_url = "https://good.example/v1"
wire_api = "responses"
"#;
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(
"good".to_string(),
Provider::with_id(
"good".to_string(),
"Good".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "sk-good"},
"config": good_config
}),
None,
),
);
manager.providers.insert(
"header-auth".to_string(),
Provider::with_id(
"header-auth".to_string(),
"HeaderAuth".to_string(),
json!({
"auth": {},
"config": header_auth_with_fallback
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "good").expect("switch to the good provider");
ProviderService::switch(&state, AppType::Codex, "header-auth").expect_err(
"preservation-on switch must fail when a keyless config falls back to the official auth",
);
// The refusal happens in the pre-commit preflight: current must not move,
// otherwise the next switch would backfill the good provider's live
// config into the refused card's DB row.
let current = state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("read current provider");
assert_eq!(
current.as_deref(),
Some("good"),
"a refused switch must leave current on the previous provider"
);
}
#[test]
fn provider_service_switch_codex_preserved_login_allows_keyless_header_auth_provider() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
// Same keyless header-auth card WITHOUT the fallback flag: 0.149 resolves
// this provider as unauthenticated, provider headers survive untouched,
// and the third-party key in http_headers.Authorization does the auth.
// This legitimate shape must keep switching under preservation.
let _home = ensure_test_home();
enable_codex_official_auth_preservation();
let header_auth_config = r#"model_provider = "custom"
model = "gpt-5.4"
[model_providers.custom]
name = "Custom"
base_url = "https://relay.example/v1"
wire_api = "responses"
http_headers = { Authorization = "Bearer explicit-header-token" }
"#;
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(
"header-auth".to_string(),
Provider::with_id(
"header-auth".to_string(),
"HeaderAuth".to_string(),
json!({
"auth": {},
"config": header_auth_config
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "header-auth")
.expect("preservation-on switch must keep supporting keyless header-auth providers");
let config_text =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
config_text.contains("Authorization = \"Bearer explicit-header-token\""),
"the provider's own Authorization header must be written verbatim; got:\n{config_text}"
);
assert!(
!config_text.contains("experimental_bearer_token"),
"no token exists, nothing must be injected; got:\n{config_text}"
);
}
#[test]
fn provider_service_switch_codex_supports_official_login_provider_without_auth_write() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let live_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "official-oauth-token",
"account_id": "acct-official"
}
});
write_codex_live_atomic(&live_auth, Some("")).expect("seed official OAuth live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "legacy-provider".to_string();
manager.providers.insert(
"legacy-provider".to_string(),
Provider::with_id(
"legacy-provider".to_string(),
"Legacy".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "legacy-key"},
"config": r#"model_provider = "legacy"
[model_providers.legacy]
name = "Legacy"
base_url = "https://legacy.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
let official_provider = Provider::with_id(
"codex-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
manager
.providers
.insert("codex-official".to_string(), official_provider);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "codex-official")
.expect("switch to official provider should succeed without API key");
let auth_value: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_value.get("auth_mode").and_then(|v| v.as_str()),
Some("chatgpt")
);
assert!(
auth_value
.get("OPENAI_API_KEY")
.is_some_and(|v| v.is_null()),
"official provider switching should keep OPENAI_API_KEY null"
);
assert_eq!(
auth_value
.pointer("/tokens/access_token")
.and_then(|v| v.as_str()),
Some("official-oauth-token"),
"official provider should preserve the existing ChatGPT OAuth token"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
!live_config.contains("experimental_bearer_token"),
"official login provider has no API key to inject"
);
}
#[test]
fn provider_service_switch_codex_official_clears_stale_third_party_auth() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
// preservation stays OFF (default). Versions before the key moved into
// config.toml wrote the third-party key into live auth.json; that residue
// is what this test expects the official switch to clean up.
let _home = ensure_test_home();
let third_party_config = r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
// The residue is the row's own key: only a key some third-party row
// carries is provably CC Switch's to delete (a user's own
// `codex login --api-key` login is left alone).
let live_auth = json!({ "OPENAI_API_KEY": "old-db-key" });
write_codex_live_atomic(&live_auth, Some(third_party_config))
.expect("seed third-party live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "third-party".to_string();
manager.providers.insert(
"third-party".to_string(),
Provider::with_id(
"third-party".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "old-db-key"},
"config": third_party_config
}),
None,
),
);
let mut official_provider = Provider::with_id(
"official-provider".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
official_provider.category = Some("official".to_string());
manager
.providers
.insert("official-provider".to_string(), official_provider);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "official-provider")
.expect("switch to official provider should succeed");
assert!(
!cc_switch_lib::get_codex_auth_path().exists(),
"switching to a material-less official provider must delete the stale \
third-party auth.json so Codex shows its login screen"
);
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch");
assert_eq!(
providers
.get("third-party")
.expect("third-party provider exists")
.settings_config
.pointer("/auth/OPENAI_API_KEY")
.and_then(|v| v.as_str()),
Some("old-db-key"),
"the outgoing row is never rewritten from live"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
!live_config.contains("experimental_bearer_token"),
"official provider has no API key to inject"
);
}
#[test]
fn provider_service_reswitch_current_official_keeps_live_auth() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
// Re-selecting the already-current provider performs no backfill, so the
// cleanup must not run either: without a fresh DB copy of whatever sits
// in live auth.json, deleting it would destroy the only copy.
let live_auth = json!({ "OPENAI_API_KEY": "residue-key" });
write_codex_live_atomic(&live_auth, Some("")).expect("seed live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "official-provider".to_string();
let mut official_provider = Provider::with_id(
"official-provider".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
official_provider.category = Some("official".to_string());
manager
.providers
.insert("official-provider".to_string(), official_provider);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "official-provider")
.expect("re-switch to current official provider should succeed");
let auth_value: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("auth.json must survive");
assert_eq!(
auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()),
Some("residue-key"),
"no backfill happened, so live auth.json must be left untouched"
);
}
#[test]
fn read_codex_live_settings_tolerates_missing_auth_when_config_file_exists() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
assert!(
cc_switch_lib::read_codex_live_settings().is_err(),
"both files missing is still 'no live install'"
);
// auth.json deleted + empty config.toml is the exact state the official
// switch cleanup leaves behind; it must stay readable or the next
// backfill / hot switch would treat Codex as uninstalled.
let config_path = cc_switch_lib::get_codex_config_path();
std::fs::create_dir_all(config_path.parent().expect("codex dir")).expect("create codex dir");
std::fs::write(&config_path, "").expect("write empty config.toml");
let live = cc_switch_lib::read_codex_live_settings()
.expect("config file present but empty must be readable");
assert_eq!(live.get("auth"), Some(&json!({})));
assert_eq!(live.get("config").and_then(|v| v.as_str()), Some(""));
}
#[test]
fn reapply_codex_official_live_rewrites_only_the_session_routing() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let live_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": { "access_token": "official-oauth-token", "account_id": "acct" }
});
// live 里已有用户的 MCP 和其他设置:开关只改选路,其余字节不碰。
let user_part =
"approval_policy = \"on-request\"\n\n[mcp_servers.echo-server]\ncommand = \"echo\"\n";
write_codex_live_atomic(&live_auth, Some(user_part)).expect("seed official live");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
let mut official = Provider::with_id(
"official-provider".to_string(),
"Official".to_string(),
json!({ "auth": {}, "config": "" }),
None,
);
official.category = Some("official".to_string());
manager
.providers
.insert("official-provider".to_string(), official);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "official-provider")
.expect("switch to official provider");
// 坏掉的 ~/.claude.json 和 Codex 无关,不能挡住开关,也不能被碰。
let claude_json = cc_switch_lib::get_claude_mcp_path();
std::fs::write(&claude_json, "{ not valid json").expect("seed broken claude json");
let set_unify = |on: bool| {
cc_switch_lib::update_settings(cc_switch_lib::AppSettings {
unify_codex_session_history: on,
..Default::default()
})
.expect("update settings");
};
let read_live = || {
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml")
};
set_unify(true);
assert!(cc_switch_lib::reapply_current_codex_official_live(&state).expect("reapply"));
let unified = read_live();
let doc: toml::Value = toml::from_str(&unified).expect("parse");
assert_eq!(doc["model_provider"].as_str(), Some("custom"), "{unified}");
assert_eq!(
doc["model_providers"]["custom"]["requires_openai_auth"].as_bool(),
Some(true),
"the unified bucket is the official mirror: {unified}"
);
assert!(
unified.contains("[mcp_servers.echo-server]") && unified.contains("approval_policy"),
"{unified}"
);
set_unify(false);
assert!(cc_switch_lib::reapply_current_codex_official_live(&state).expect("reapply"));
let direct = read_live();
let doc: toml::Value = toml::from_str(&direct).expect("parse");
assert!(doc.get("model_provider").is_none(), "{direct}");
assert!(direct.contains("[mcp_servers.echo-server]"), "{direct}");
assert_eq!(
std::fs::read_to_string(&claude_json).expect("read claude json"),
"{ not valid json"
);
assert_eq!(
read_json_file::<serde_json::Value>(&cc_switch_lib::get_codex_auth_path())
.expect("read auth.json"),
live_auth,
"the official login is never touched"
);
}
/// 切换供应商与 reapply 是同一类场景:live 整体重写后只需重投影本应用
/// 的 MCP。历史实现走全量 sync_all_enabled + `?`,损坏的 ~/.claude.json
/// 会让"切 Codex"直接报切换失败——而此时 DB is_current 与 live 都已
/// 落盘,切换事实上成功,报错只制造分裂假象。
#[test]
fn switch_codex_ignores_a_broken_claude_json() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk-old" }), Some(""))
.expect("seed codex live");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(
"p".to_string(),
Provider::with_id(
"p".to_string(),
"P".to_string(),
json!({
"auth": { "OPENAI_API_KEY": "sk-p" },
"config": "model = \"gpt-5.5\"\n"
}),
None,
),
);
}
let servers = config.mcp.servers.get_or_insert_with(Default::default);
servers.insert(
"echo-server".into(),
McpServer {
id: "echo-server".into(),
name: "Echo Server".into(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
agy: false,
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
mcode: false,
kimi: false,
pi: false,
omp: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let state = create_test_state_with_config(&config).expect("create test state");
// 坏 JSON 能通过 should_sync_claude_mcp 门控(文件存在即过),
// 但 read_mcp_servers_map 解析必然报错;codex-only 服务器也会
// 触发 claude 的 remove 分支去读这个文件。
let claude_json = cc_switch_lib::get_claude_mcp_path();
std::fs::write(&claude_json, "{ not valid json").expect("seed broken claude json");
ProviderService::switch(&state, AppType::Codex, "p")
.expect("broken ~/.claude.json must not fail an unrelated codex switch");
let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
.expect("read config.toml after switch");
assert!(live.contains("gpt-5.5"), "{live}");
let claude_after = std::fs::read_to_string(&claude_json).expect("read claude json");
assert_eq!(
claude_after, "{ not valid json",
"a Codex switch must not touch claude's live file"
);
}
/// sync_all_enabled 的全量语义(配置导入 / 云同步恢复):单个应用的
/// live 损坏不阻断其余应用的投影,但失败必须聚合上报——调用方需要
/// 知道结果不完整。历史实现按 AppType::all() 顺序 `?` 短路,Claude
/// 排在 Codex 前面,一份坏 ~/.claude.json 会让所有后续应用的 MCP
/// 状态永远陈旧。
#[test]
fn sync_all_enabled_reports_broken_app_but_projects_the_rest() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk" }), Some("")).expect("seed codex live");
let mut config = MultiAppConfig::default();
let servers = config.mcp.servers.get_or_insert_with(Default::default);
servers.insert(
"echo-server".into(),
McpServer {
id: "echo-server".into(),
name: "Echo Server".into(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
agy: false,
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
mcode: false,
kimi: false,
pi: false,
omp: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let state = create_test_state_with_config(&config).expect("create test state");
let claude_json = cc_switch_lib::get_claude_mcp_path();
std::fs::write(&claude_json, "{ not valid json").expect("seed broken claude json");
let err = cc_switch_lib::McpService::sync_all_enabled(&state)
.expect_err("broken claude live must surface as an aggregated error");
let message = err.to_string();
assert!(
message.contains("claude"),
"aggregated error should name the failing app, got: {message}"
);
// Claude 的失败不能阻断 Codex:best-effort 必须继续投影其余应用。
let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
.expect("read config.toml after sync_all_enabled");
assert!(
live.contains("mcp_servers.echo-server"),
"codex projection must proceed despite the broken claude file, got: {live}"
);
}
#[test]
fn provider_service_switch_codex_official_accounts_write_auth_json() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let live_auth_a = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "official-a-live-token",
"account_id": "acct-a"
}
});
write_codex_live_atomic(&live_auth_a, Some("")).expect("seed official account A live auth");
let mut official_a = Provider::with_id(
"official-a".to_string(),
"Official A".to_string(),
json!({
"auth": {
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "stale-a-token",
"account_id": "acct-a"
}
},
"config": ""
}),
None,
);
official_a.category = Some("official".to_string());
let mut official_b = Provider::with_id(
"official-b".to_string(),
"Official B".to_string(),
json!({
"auth": {
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "official-b-token",
"account_id": "acct-b"
}
},
"config": ""
}),
None,
);
official_b.category = Some("official".to_string());
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "official-a".to_string();
manager
.providers
.insert("official-a".to_string(), official_a);
manager
.providers
.insert("official-b".to_string(), official_b);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "official-b")
.expect("switch to official account B should write auth.json");
let auth_b: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth B");
assert_eq!(
auth_b
.pointer("/tokens/access_token")
.and_then(|v| v.as_str()),
Some("official-b-token"),
"switching official accounts must replace auth.json with the selected account"
);
ProviderService::switch(&state, AppType::Codex, "official-a")
.expect("switch back to official account A should use backfilled live auth");
let auth_a: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth A");
assert_eq!(
auth_a
.pointer("/tokens/access_token")
.and_then(|v| v.as_str()),
Some("official-a-live-token"),
"backfill should preserve account A's latest live token for later official switches"
);
}
#[test]
fn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider_id() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" });
let provider_a_config = r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
write_codex_live_atomic(&legacy_auth, Some(provider_a_config))
.expect("seed existing codex live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "provider-a".to_string();
manager.providers.insert(
"provider-a".to_string(),
Provider::with_id(
"provider-a".to_string(),
"RightCode".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "rightcode-key"},
"config": provider_a_config
}),
None,
),
);
manager.providers.insert(
"provider-b".to_string(),
Provider::with_id(
"provider-b".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "aihubmix-key"},
"config": r#"model_provider = "aihubmix"
model = "gpt-5.4"
profile = "work"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
[profiles.work]
model_provider = "aihubmix"
model = "gpt-5.4"
"#
}),
None,
),
);
manager.providers.insert(
"provider-c".to_string(),
Provider::with_id(
"provider-c".to_string(),
"Vendor C".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "vendor-c-key"},
"config": r#"model_provider = "vendor_c"
model = "gpt-5.4"
[model_providers.vendor_c]
name = "Vendor C"
base_url = "https://vendor-c.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "provider-b")
.expect("switch to provider b should succeed");
ProviderService::switch(&state, AppType::Codex, "provider-c")
.expect("switch to provider c should succeed");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switches");
let provider_b_config = providers
.get("provider-b")
.expect("provider b exists")
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("provider b config");
let parsed: toml::Value = toml::from_str(provider_b_config).expect("parse provider b config");
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("aihubmix"),
"backfill should restore provider b's storage-specific model_provider id"
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("aihubmix"))
.is_some(),
"provider b should keep its own model_providers table after backfill"
);
assert_eq!(
parsed
.get("profiles")
.and_then(|v| v.get("work"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("aihubmix"),
"profile overrides should be restored to provider b's storage-specific id"
);
}
#[test]
fn sync_current_provider_for_app_leaves_the_proxy_contract_alone() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "current-provider".to_string();
let mut provider = Provider::with_id(
"current-provider".to_string(),
"Current".to_string(),
json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "real-token",
"ANTHROPIC_BASE_URL": "https://claude.example"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
manager
.providers
.insert("current-provider".to_string(), provider);
}
let state = create_test_state_with_config(&config).expect("create test state");
state
.db
.set_config_snippet(
AppType::Claude.as_str(),
Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()),
)
.expect("set common config snippet");
let settings_path = get_claude_settings_path();
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
std::fs::write(
&settings_path,
r#"{"env":{"ANTHROPIC_BASE_URL":"https://claude.example","ANTHROPIC_AUTH_TOKEN":"real-token"}}"#,
)
.expect("seed live settings");
let rt = tokio::runtime::Runtime::new().expect("create tokio runtime");
rt.block_on(async {
let mut proxy_config = state.db.get_proxy_config().await.expect("get proxy config");
proxy_config.listen_port = 0;
state
.db
.update_proxy_config(proxy_config)
.await
.expect("use ephemeral proxy port");
cc_switch_lib::mode::controller::enter(&state, &AppType::Claude, false)
.await
.expect("enter routing mode");
});
let contract_bytes = std::fs::read(&settings_path).expect("read proxy contract");
ProviderService::sync_current_provider_for_app(&state, AppType::Claude)
.expect("sync current provider should succeed");
assert_eq!(
std::fs::read(&settings_path).expect("read live settings after sync"),
contract_bytes,
"routing mode: syncing the routed provider must not rewrite live with its direct projection"
);
rt.block_on(cc_switch_lib::mode::controller::exit(
&state,
&AppType::Claude,
))
.expect("leave routing mode");
}
#[test]
fn switch_codex_in_direct_mode_replaces_leftover_proxy_placeholders() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
enable_codex_official_auth_preservation();
let _home = ensure_test_home();
let oauth_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"access_token": "oauth-access",
"id_token": "oauth-id"
}
});
let old_provider_config = r#"model_provider = "deepseek"
model = "deepseek-chat"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
experimental_bearer_token = "old-key"
"#;
let proxy_live_config = r#"model_provider = "deepseek"
model = "deepseek-chat"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "http://127.0.0.1:15721/v1"
wire_api = "responses"
experimental_bearer_token = "PROXY_MANAGED"
"#;
write_codex_live_atomic(&oauth_auth, Some(proxy_live_config))
.expect("seed taken-over Codex live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "old-provider".to_string();
let mut old_provider = Provider::with_id(
"old-provider".to_string(),
"DeepSeek Old".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "old-key"},
"config": old_provider_config
}),
None,
);
old_provider.category = Some("custom".to_string());
manager
.providers
.insert("old-provider".to_string(), old_provider);
let mut new_provider = Provider::with_id(
"new-provider".to_string(),
"DeepSeek New".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "new-key"},
"config": r#"model_provider = "deepseek-new"
model = "deepseek-reasoner"
[model_providers.deepseek-new]
name = "DeepSeek New"
base_url = "https://new.deepseek.example/v1"
wire_api = "responses"
"#
}),
None,
);
new_provider.category = Some("custom".to_string());
manager
.providers
.insert("new-provider".to_string(), new_provider);
}
let state = create_test_state_with_config(&config).expect("create test state");
assert!(!cc_switch_lib::mode::current::is_proxy(&AppType::Codex));
ProviderService::switch(&state, AppType::Codex, "new-provider")
.expect("switch in direct mode writes the new provider");
let auth_after: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_after, oauth_auth,
"preserving the official login keeps OAuth auth.json"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
live_config.contains("https://new.deepseek.example/v1")
&& !live_config.contains("PROXY_MANAGED"),
"a direct switch replaces leftover proxy placeholders: {live_config}"
);
let old_row = state
.db
.get_provider_by_id("old-provider", AppType::Codex.as_str())
.expect("read old provider")
.expect("old provider exists");
assert!(
!old_row
.settings_config
.to_string()
.contains("PROXY_MANAGED"),
"leftover placeholders must not be backfilled into the outgoing provider"
);
}
#[test]
fn explicitly_cleared_common_snippet_is_not_auto_extracted() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
state
.db
.set_config_snippet_cleared(AppType::Claude.as_str(), true)
.expect("mark snippet explicitly cleared");
assert!(
!state
.db
.should_auto_extract_config_snippet(AppType::Claude.as_str())
.expect("check auto-extract eligibility"),
"explicitly cleared snippets should block auto-extraction"
);
state
.db
.set_config_snippet(AppType::Claude.as_str(), Some("{}".to_string()))
.expect("set snippet");
state
.db
.set_config_snippet_cleared(AppType::Claude.as_str(), false)
.expect("clear explicit-empty marker");
assert!(
!state
.db
.should_auto_extract_config_snippet(AppType::Claude.as_str())
.expect("check auto-extract after snippet saved"),
"existing snippets should also block auto-extraction"
);
}
#[test]
fn legacy_common_config_migration_flag_roundtrip() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
assert!(
!state
.db
.is_legacy_common_config_migrated()
.expect("initial migration flag"),
"migration flag should default to false"
);
state
.db
.set_legacy_common_config_migrated(true)
.expect("set migration flag");
assert!(
state
.db
.is_legacy_common_config_migrated()
.expect("read migration flag"),
"migration flag should persist once set"
);
state
.db
.set_legacy_common_config_migrated(false)
.expect("clear migration flag");
assert!(
!state
.db
.is_legacy_common_config_migrated()
.expect("read migration flag after clear"),
"migration flag should be removable for tests/debugging"
);
}
#[test]
fn switch_packycode_gemini_updates_security_selected_type() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-gemini".to_string();
manager.providers.insert(
"packy-gemini".to_string(),
Provider::with_id(
"packy-gemini".to_string(),
"PackyCode".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-key",
"GOOGLE_GEMINI_BASE_URL": "https://www.packyapi.com"
}
}),
Some("https://www.packyapi.com".to_string()),
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
.expect("switching to PackyCode Gemini should succeed");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
assert!(
settings_path.exists(),
"Gemini settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"PackyCode Gemini should set security.auth.selectedType"
);
}
#[test]
fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-meta".to_string();
let mut provider = Provider::with_id(
"packy-meta".to_string(),
"Generic Gemini".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-meta",
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com"
}
}),
Some("https://example.com".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("packycode".to_string()),
..ProviderMeta::default()
});
manager.providers.insert("packy-meta".to_string(), provider);
}
let state = create_test_state_with_config(&config).expect("create test state");
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
.expect("switching to partner meta provider should succeed");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
assert!(
settings_path.exists(),
"Gemini settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"Partner meta should set security.auth.selectedType even without packy keywords"
);
}
#[test]
fn switch_google_official_gemini_preserves_env_vars() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "google-official".to_string();
let mut provider = Provider::with_id(
"google-official".to_string(),
"Google".to_string(),
json!({
"env": {
"GEMINI_MODEL": "gemini-2.5-pro"
}
}),
Some("https://ai.google.dev".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("google-official".to_string()),
..ProviderMeta::default()
});
manager
.providers
.insert("google-official".to_string(), provider);
}
let state = create_test_state_with_config(&config).expect("create test state");
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
// Verify env vars are preserved in ~/.gemini/.env
let env_path = home.join(".gemini").join(".env");
assert!(
env_path.exists(),
"Gemini .env should exist at {}",
env_path.display()
);
let env_content = std::fs::read_to_string(&env_path).expect("read gemini .env");
assert!(
env_content.contains("GEMINI_MODEL=gemini-2.5-pro"),
"GEMINI_MODEL should be preserved in .env, got: {env_content}"
);
// Verify OAuth security flag is still set correctly
let gemini_settings = home.join(".gemini").join("settings.json");
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
let gemini_value: serde_json::Value =
serde_json::from_str(&gemini_raw).expect("parse gemini settings");
assert_eq!(
gemini_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"OAuth security flag should still be set"
);
}
#[test]
fn provider_service_switch_claude_updates_live_and_state() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
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 legacy_live = json!({
"env": {
"ANTHROPIC_API_KEY": "legacy-key"
},
"workspace": {
"path": "/tmp/workspace"
}
});
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"),
)
.expect("seed claude live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "old-provider".to_string();
manager.providers.insert(
"old-provider".to_string(),
Provider::with_id(
"old-provider".to_string(),
"Legacy Claude".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "stale-key" }
}),
None,
),
);
manager.providers.insert(
"new-provider".to_string(),
Provider::with_id(
"new-provider".to_string(),
"Fresh Claude".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "fresh-key" },
"workspace": { "path": "/tmp/new-workspace" }
}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
ProviderService::switch(&state, AppType::Claude, "new-provider")
.expect("switch provider should succeed");
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read claude live settings");
assert_eq!(
live_after
.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.and_then(|key| key.as_str()),
Some("fresh-key"),
"live settings.json should reflect new provider auth"
);
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let current_id = state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let legacy_provider = providers
.get("old-provider")
.expect("legacy provider still exists");
// 不再回填:用户在 live 里的改动留在 live,上一家的行不变。
assert_eq!(
legacy_provider.settings_config,
json!({ "env": { "ANTHROPIC_API_KEY": "stale-key" } }),
"switching away must not copy live into the previous provider"
);
assert_eq!(
live_after["workspace"], legacy_live["workspace"],
"non-key settings in live stay where they are"
);
}
/// Claude 供应商都勾选了通用配置,`current` 的 live 是原样的 `live` 文本。
fn seed_claude_switch_state(
providers: &[(&str, serde_json::Value)],
current: &str,
live: &str,
) -> cc_switch_lib::AppState {
let settings_path = get_claude_settings_path();
std::fs::create_dir_all(settings_path.parent().expect("settings dir"))
.expect("create claude settings dir");
std::fs::write(&settings_path, live).expect("seed claude live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = current.to_string();
for (id, settings) in providers {
let mut provider =
Provider::with_id(id.to_string(), id.to_string(), settings.clone(), None);
provider.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
manager.providers.insert(id.to_string(), provider);
}
}
create_test_state_with_config(&config).expect("create test state")
}
fn claude_row(state: &cc_switch_lib::AppState, id: &str) -> serde_json::Value {
state
.db
.get_provider_by_id(id, AppType::Claude.as_str())
.expect("read provider")
.expect("provider exists")
.settings_config
}
fn claude_live_text() -> String {
std::fs::read_to_string(get_claude_settings_path()).expect("read claude live")
}
fn claude_live() -> serde_json::Value {
read_json_file(&get_claude_settings_path()).expect("read claude live")
}
/// 切换只替换关键字段:hooks、插件、权限、状态栏和用户自己的 env 键原样留在 live,
/// 顺序不变;A→B→A 之后整份文件和切换前逐字节相同。不回填,也不碰通用配置片段。
#[test]
fn switch_claude_only_replaces_key_fields() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let live = r#"{
"env": {
"ANTHROPIC_BASE_URL": "https://a.example",
"OPENROUTER_API_KEY": "sk-or-user",
"ANTHROPIC_AUTH_TOKEN": "sk-a",
"CLAUDE_CODE_USE_POWERSHELL_TOOL": "1"
},
"permissions": {
"allow": [
"Bash(git status)"
]
},
"hooks": {
"Stop": []
},
"enabledPlugins": {
"example@marketplace": true
},
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh"
}
}
"#;
let a = json!({ "env": {
"ANTHROPIC_BASE_URL": "https://a.example",
"ANTHROPIC_AUTH_TOKEN": "sk-a"
}});
let b = json!({ "env": {
"ANTHROPIC_BASE_URL": "https://b.example",
"ANTHROPIC_AUTH_TOKEN": "sk-b"
}});
let state = seed_claude_switch_state(&[("a", a.clone()), ("b", b.clone())], "a", live);
let snippet = r#"{"theme":"dark"}"#;
state
.db
.set_config_snippet(AppType::Claude.as_str(), Some(snippet.to_string()))
.expect("seed snippet");
ProviderService::switch(&state, AppType::Claude, "b").expect("switch to b");
let after = claude_live_text();
assert_eq!(
after,
live.replace("https://a.example", "https://b.example")
.replace("sk-a", "sk-b"),
"only the key fields change, in place"
);
assert_eq!(
claude_row(&state, "a"),
a,
"no backfill into the previous row"
);
assert_eq!(
state
.db
.get_config_snippet(AppType::Claude.as_str())
.expect("read snippet")
.as_deref(),
Some(snippet),
"the frozen snippet is neither read nor written"
);
assert!(
claude_live().get("theme").is_none(),
"the snippet is not merged into live"
);
ProviderService::switch(&state, AppType::Claude, "a").expect("switch back to a");
assert_eq!(claude_live_text(), live, "A→B→A is byte-identical");
}
/// 独有字段跟着供应商走:切入时写,切走时只删上一家带进来、值没被改过的。
#[test]
fn switch_claude_moves_compat_switches_with_their_provider() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let deepseek = json!({ "env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.example/anthropic",
"ANTHROPIC_AUTH_TOKEN": "sk-ds",
"CLAUDE_CODE_DISABLE_ARTIFACT": "1"
}});
let state = seed_claude_switch_state(
&[
("deepseek", deepseek.clone()),
("claude-official", json!({ "env": {} })),
],
"deepseek",
&serde_json::to_string_pretty(&deepseek).expect("serialize"),
);
ProviderService::switch(&state, AppType::Claude, "claude-official").expect("to official");
assert_eq!(claude_live(), json!({ "env": {} }));
ProviderService::switch(&state, AppType::Claude, "deepseek").expect("back to deepseek");
assert_eq!(claude_live(), deepseek);
// 用户在 live 里手动改成了 0:不是 CC Switch 写的,切走时保留。
let mut edited = claude_live();
edited["env"]["CLAUDE_CODE_DISABLE_ARTIFACT"] = json!("0");
std::fs::write(get_claude_settings_path(), edited.to_string()).expect("edit live");
ProviderService::switch(&state, AppType::Claude, "claude-official").expect("to official");
assert_eq!(
claude_live(),
json!({ "env": { "CLAUDE_CODE_DISABLE_ARTIFACT": "0" } })
);
}
/// 窗口值按供应商走:千问 → Kimi 换值,Kimi → 官方删掉;旧版给 Kimi 注入、行里
/// 没有的值由残留清理兜住。
#[test]
fn switch_claude_window_values_follow_the_provider() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let qwen = json!({ "env": {
"ANTHROPIC_BASE_URL": "https://qwen.example",
"ANTHROPIC_AUTH_TOKEN": "sk-qwen",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "983616"
}});
let kimi = json!({ "env": {
"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding/",
"ANTHROPIC_AUTH_TOKEN": "sk-kimi",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "262144"
}});
let old_kimi = json!({ "env": {
"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding/",
"ANTHROPIC_AUTH_TOKEN": "sk-old-kimi"
}});
let state = seed_claude_switch_state(
&[
("qwen", qwen.clone()),
("kimi", kimi),
("old-kimi", old_kimi),
("claude-official", json!({ "env": {} })),
],
"qwen",
&serde_json::to_string_pretty(&qwen).expect("serialize"),
);
ProviderService::switch(&state, AppType::Claude, "kimi").expect("to kimi");
assert_eq!(
claude_live()["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"],
json!("262144")
);
ProviderService::switch(&state, AppType::Claude, "claude-official").expect("to official");
assert_eq!(claude_live(), json!({ "env": {} }));
// 旧版切到早期的 Kimi 行时注入的默认值:上一家的行里查不到。
ProviderService::switch(&state, AppType::Claude, "old-kimi").expect("to old kimi");
let mut live = claude_live();
live["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"] = json!("262144");
live["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = json!("262144");
std::fs::write(get_claude_settings_path(), live.to_string()).expect("seed injected");
ProviderService::switch(&state, AppType::Claude, "claude-official").expect("to official");
assert_eq!(claude_live(), json!({ "env": {} }));
}
/// live 解析不了:切换报错,文件字节、mtime 和当前供应商都不变。
#[test]
fn switch_claude_refuses_a_settings_file_it_cannot_parse() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let broken = "{\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://a.example\",\n}\n";
let state = seed_claude_switch_state(
&[
(
"a",
json!({ "env": { "ANTHROPIC_BASE_URL": "https://a.example" } }),
),
(
"b",
json!({ "env": { "ANTHROPIC_BASE_URL": "https://b.example" } }),
),
],
"a",
broken,
);
let path = get_claude_settings_path();
let before = std::fs::metadata(&path)
.expect("stat")
.modified()
.expect("mtime");
let err = ProviderService::switch(&state, AppType::Claude, "b").expect_err("must refuse");
assert!(err.to_string().contains("第 4 行"), "{err}");
assert_eq!(claude_live_text(), broken);
assert_eq!(
std::fs::metadata(&path)
.expect("stat")
.modified()
.expect("mtime"),
before
);
assert_eq!(
state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("current")
.as_deref(),
Some("a")
);
}
#[test]
fn provider_service_switch_missing_provider_returns_error() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
let err = ProviderService::switch(&state, AppType::Claude, "missing")
.expect_err("switching missing provider should fail");
match err {
AppError::Message(msg) => {
assert!(
msg.contains("不存在") || msg.contains("not found"),
"expected provider not found message, got {msg}"
);
}
other => panic!("expected Message error for provider not found, got {other:?}"),
}
}
#[test]
fn provider_service_switch_codex_missing_auth_returns_error() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(
"invalid".to_string(),
Provider::with_id(
"invalid".to_string(),
"Broken Codex".to_string(),
json!({
"config": "[mcp_servers.test]\ncommand = \"noop\""
}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
let err = ProviderService::switch(&state, AppType::Codex, "invalid")
.expect_err("switching should fail without auth");
match err {
AppError::Config(msg) => assert!(
msg.contains("auth"),
"expected auth related message, got {msg}"
),
other => panic!("expected config error, got {other:?}"),
}
}
#[test]
fn provider_service_delete_codex_removes_provider_and_files() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "keep".to_string();
manager.providers.insert(
"keep".to_string(),
Provider::with_id(
"keep".to_string(),
"Keep".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "keep-key"},
"config": ""
}),
None,
),
);
manager.providers.insert(
"to-delete".to_string(),
Provider::with_id(
"to-delete".to_string(),
"DeleteCodex".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "delete-key"},
"config": ""
}),
None,
),
);
}
let sanitized = sanitize_provider_name("DeleteCodex");
let codex_dir = home.join(".codex");
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
let auth_path = codex_dir.join(format!("auth-{sanitized}.json"));
let cfg_path = codex_dir.join(format!("config-{sanitized}.toml"));
std::fs::write(&auth_path, "{}").expect("seed auth file");
std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file");
let app_state = create_test_state_with_config(&config).expect("create test state");
ProviderService::delete(&app_state, AppType::Codex, "to-delete")
.expect("delete provider should succeed");
let providers = app_state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get all providers");
assert!(
!providers.contains_key("to-delete"),
"provider entry should be removed"
);
// v3.7.0+ 不再使用供应商特定文件(如 auth-*.json, config-*.toml)
// 删除供应商只影响数据库记录,不清理这些旧格式文件
}
#[test]
fn provider_service_delete_claude_removes_provider_files() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "keep".to_string();
manager.providers.insert(
"keep".to_string(),
Provider::with_id(
"keep".to_string(),
"Keep".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "keep-key" }
}),
None,
),
);
manager.providers.insert(
"delete".to_string(),
Provider::with_id(
"delete".to_string(),
"DeleteClaude".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "delete-key" }
}),
None,
),
);
}
let sanitized = sanitize_provider_name("DeleteClaude");
let claude_dir = home.join(".claude");
std::fs::create_dir_all(&claude_dir).expect("create claude dir");
let by_name = claude_dir.join(format!("settings-{sanitized}.json"));
let by_id = claude_dir.join("settings-delete.json");
std::fs::write(&by_name, "{}").expect("seed settings by name");
std::fs::write(&by_id, "{}").expect("seed settings by id");
let app_state = create_test_state_with_config(&config).expect("create test state");
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
let providers = app_state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
assert!(
!providers.contains_key("delete"),
"claude provider should be removed"
);
// v3.7.0+ 不再使用供应商特定文件(如 settings-*.json)
// 删除供应商只影响数据库记录,不清理这些旧格式文件
}
#[test]
fn provider_service_delete_current_provider_returns_error() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "keep".to_string();
manager.providers.insert(
"keep".to_string(),
Provider::with_id(
"keep".to_string(),
"Keep".to_string(),
json!({
"env": { "ANTHROPIC_API_KEY": "keep-key" }
}),
None,
),
);
}
let app_state = create_test_state_with_config(&config).expect("create test state");
let err = ProviderService::delete(&app_state, AppType::Claude, "keep")
.expect_err("deleting current provider should fail");
match err {
AppError::Localized { zh, .. } => assert!(
zh.contains("不能删除当前正在使用的供应商")
|| zh.contains("无法删除当前正在使用的供应商"),
"unexpected message: {zh}"
),
AppError::Config(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
AppError::Message(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
other => panic!("expected Config/Message error, got {other:?}"),
}
}
#[test]
fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_back() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
// 接管态 Claude Live,且 DB 中无备份(模拟切换 app_config_dir 后新库首启的场景)
let taken_over_live = json!({
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
}
});
let settings_path = get_claude_settings_path();
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"),
)
.expect("write taken over live");
let state = create_test_state().expect("create test state");
// 模拟历史异常:接管态 Live 已被导入成 current provider(SSOT 被污染)
let provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
taken_over_live.clone(),
None,
);
state
.db
.save_provider(AppType::Claude.as_str(), &provider)
.expect("save placeholder provider");
state
.db
.set_current_provider(AppType::Claude.as_str(), "default")
.expect("set current provider");
// 启动时处理旧版遗留的接管态:没开代理(enabled=0),写回直连。
futures::executor::block_on(cc_switch_lib::mode::controller::startup(&state));
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read live settings after recovery");
let env = live_after.get("env").cloned().unwrap_or_else(|| json!({}));
assert_ne!(
env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()),
Some("PROXY_MANAGED"),
"recovery must not write the placeholder back to live"
);
assert!(
env.get("ANTHROPIC_BASE_URL")
.and_then(|v| v.as_str())
.map(|url| !url.starts_with("http://127.0.0.1"))
.unwrap_or(true),
"recovery must drop the local proxy base URL"
);
}
/// 切换写出的 live 文件里有 Key(Codex 的 auth.json 与 config.toml、Claude Code 的
/// settings.json、Grok Build 的 config.toml),新建或替换时都只给本人读写:普通写入
/// 新建文件按 umask 落成 0644,同机其他用户就能读到 Key。
#[cfg(unix)]
#[test]
fn switch_writes_credential_files_owner_only() {
use std::os::unix::fs::PermissionsExt;
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
for dir in [".claude", ".codex", ".grok"] {
std::fs::create_dir_all(home.join(dir)).expect("create client dir");
}
// 已有的 settings.json 是 0644:替换写入后也收紧。
let claude_settings = get_claude_settings_path();
std::fs::write(&claude_settings, "{}").expect("seed claude settings");
std::fs::set_permissions(&claude_settings, std::fs::Permissions::from_mode(0o644))
.expect("chmod 644");
let mut config = MultiAppConfig::default();
{
let claude = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
claude.current = "claude-a".to_string();
for (id, key) in [("claude-a", "sk-a"), ("claude-b", "sk-b")] {
claude.providers.insert(
id.to_string(),
Provider::with_id(
id.to_string(),
id.to_string(),
json!({ "env": { "ANTHROPIC_AUTH_TOKEN": key } }),
None,
),
);
}
}
{
let codex = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
codex.current = "codex-official".to_string();
let mut official = Provider::with_id(
"codex-official".to_string(),
"OpenAI".to_string(),
json!({ "auth": { "OPENAI_API_KEY": "sk-official" }, "config": "" }),
None,
);
official.category = Some("official".to_string());
codex
.providers
.insert("codex-official".to_string(), official);
codex.providers.insert(
"codex-relay".to_string(),
Provider::with_id(
"codex-relay".to_string(),
"Relay".to_string(),
json!({
"auth": { "OPENAI_API_KEY": "sk-relay" },
"config": "model_provider = \"custom\"\n\n[model_providers.custom]\nname = \"Relay\"\nbase_url = \"https://relay.example/v1\"\nwire_api = \"responses\"\n"
}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
state
.db
.save_provider(
AppType::GrokBuild.as_str(),
&Provider::with_id(
"grok-relay".to_string(),
"Relay".to_string(),
json!({ "config": "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"https://relay.example/v1\"\nname = \"Relay\"\napi_key = \"xai-relay\"\napi_backend = \"responses\"\ncontext_window = 500000\n" }),
None,
),
)
.expect("save grok provider");
let mode = |path: &std::path::Path| {
std::fs::metadata(path)
.unwrap_or_else(|e| panic!("stat {}: {e}", path.display()))
.permissions()
.mode()
& 0o777
};
ProviderService::switch(&state, AppType::Codex, "codex-official").expect("codex official");
assert_eq!(mode(&cc_switch_lib::get_codex_auth_path()), 0o600);
ProviderService::switch(&state, AppType::Codex, "codex-relay").expect("codex relay");
assert_eq!(mode(&cc_switch_lib::get_codex_config_path()), 0o600);
ProviderService::switch(&state, AppType::Claude, "claude-b").expect("claude b");
assert_eq!(mode(&claude_settings), 0o600);
ProviderService::switch(&state, AppType::GrokBuild, "grok-relay").expect("grok relay");
assert_eq!(mode(&cc_switch_lib::get_grok_config_path()), 0o600);
}
fn editor_save(base: &serde_json::Value, on_conflict: &str) -> cc_switch_lib::EditorSave {
serde_json::from_value(json!({ "base": base, "onConflict": on_conflict })).expect("editor save")
}
/// 打开编辑器:显示的就是切到这个供应商之后的 settings.json。
fn open_claude_editor(state: &cc_switch_lib::AppState, id: &str) -> (Provider, serde_json::Value) {
let row = state
.db
.get_provider_by_id(id, AppType::Claude.as_str())
.expect("read provider")
.expect("provider exists");
let view = ProviderService::editor_view(state, AppType::Claude, &row.settings_config, None)
.expect("editor view");
(row, view.settings)
}
fn save_claude_editor(
state: &cc_switch_lib::AppState,
row: &Provider,
base: &serde_json::Value,
edited: serde_json::Value,
on_conflict: &str,
) -> Result<bool, AppError> {
let mut provider = row.clone();
provider.settings_config = edited;
ProviderService::update_from_editor(
state,
AppType::Claude,
None,
provider,
Some(editor_save(base, on_conflict)),
)
}
/// 编辑器里改全局设置(加 hook、写 `alwaysThinkingEnabled: false`):保存后立刻进 live,
/// 切换到别的供应商后仍在;供应商行里不会多出这些键。编辑的是非当前供应商也一样。
#[test]
fn claude_editor_global_settings_go_to_live() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let a = json!({ "env": { "ANTHROPIC_BASE_URL": "https://a.example" } });
let b = json!({ "env": { "ANTHROPIC_BASE_URL": "https://b.example" } });
let state = seed_claude_switch_state(
&[("a", a.clone()), ("b", b.clone())],
"a",
&serde_json::to_string_pretty(&a).expect("serialize"),
);
let (row, base) = open_claude_editor(&state, "b");
assert_eq!(base, b, "b's key fields over the live file");
let mut edited = base.clone();
edited["hooks"] =
json!({ "Stop": [{ "hooks": [{ "type": "command", "command": "say done" }] }] });
edited["alwaysThinkingEnabled"] = json!(false);
save_claude_editor(&state, &row, &base, edited, "refuse").expect("save");
let live = claude_live();
assert_eq!(
live["env"]["ANTHROPIC_BASE_URL"],
json!("https://a.example")
);
assert_eq!(live["alwaysThinkingEnabled"], json!(false));
assert!(live.get("hooks").is_some());
assert_eq!(
claude_row(&state, "b"),
b,
"global settings stay out of the row"
);
ProviderService::switch(&state, AppType::Claude, "b").expect("switch");
let live = claude_live();
assert_eq!(
live["env"]["ANTHROPIC_BASE_URL"],
json!("https://b.example")
);
assert!(live.get("hooks").is_some(), "the hook survives the switch");
}
/// 关键字段、独有字段存进供应商行:非当前供应商改地址、写窗口值,live 不变;
/// 切过去之后才生效。当前供应商取消「禁用 Artifact」,live 里立刻删掉。
#[test]
fn claude_editor_provider_fields_go_to_the_row() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let deepseek = json!({ "env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.example/anthropic",
"CLAUDE_CODE_DISABLE_ARTIFACT": "1"
}});
let qwen = json!({ "env": { "ANTHROPIC_BASE_URL": "https://qwen.example" } });
let state = seed_claude_switch_state(
&[("deepseek", deepseek.clone()), ("qwen", qwen)],
"deepseek",
&serde_json::to_string_pretty(&deepseek).expect("serialize"),
);
// 当前带着禁用 Artifact,编辑另一家:不显示这个键。
let (row, base) = open_claude_editor(&state, "qwen");
assert!(base["env"].get("CLAUDE_CODE_DISABLE_ARTIFACT").is_none());
let mut edited = base.clone();
edited["env"]["ANTHROPIC_BASE_URL"] = json!("https://qwen-new.example");
edited["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"] = json!("983616");
save_claude_editor(&state, &row, &base, edited, "refuse").expect("save qwen");
assert_eq!(
claude_live(),
deepseek,
"a non-current edit leaves live alone"
);
assert_eq!(
claude_row(&state, "qwen"),
json!({ "env": {
"ANTHROPIC_BASE_URL": "https://qwen-new.example",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "983616"
}})
);
let (row, base) = open_claude_editor(&state, "deepseek");
let mut edited = base.clone();
edited["env"]
.as_object_mut()
.expect("env")
.remove("CLAUDE_CODE_DISABLE_ARTIFACT");
save_claude_editor(&state, &row, &base, edited, "refuse").expect("save deepseek");
assert_eq!(
claude_live(),
json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.deepseek.example/anthropic" } })
);
ProviderService::switch(&state, AppType::Claude, "qwen").expect("switch");
assert_eq!(
claude_live()["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"],
json!("983616")
);
}
/// 深链带进来的非关键字段:编辑器提示它不随切换生效;加进全局设置后写进 live,
/// 行里的原值还在。
#[test]
fn claude_editor_lists_row_fields_that_never_reach_live() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let imported = json!({ "env": {
"ANTHROPIC_BASE_URL": "https://relay.example",
"API_TIMEOUT_MS": "3000000"
}});
let state = seed_claude_switch_state(
&[("relay", imported.clone())],
"relay",
r#"{ "env": { "ANTHROPIC_BASE_URL": "https://relay.example" } }"#,
);
let view =
ProviderService::editor_view(&state, AppType::Claude, &imported, None).expect("view");
assert_eq!(
serde_json::to_value(&view.inactive).expect("serialize"),
json!([{ "path": ["env", "API_TIMEOUT_MS"], "value": "3000000" }])
);
let row = state
.db
.get_provider_by_id("relay", AppType::Claude.as_str())
.expect("read")
.expect("row");
let mut edited = view.settings.clone();
edited["env"]["API_TIMEOUT_MS"] = json!("3000000");
save_claude_editor(&state, &row, &view.settings, edited, "refuse").expect("save");
assert_eq!(claude_live()["env"]["API_TIMEOUT_MS"], json!("3000000"));
assert_eq!(claude_row(&state, "relay"), imported);
}
/// 窗口打开后外部把某个键从 1 改成 3,用户在窗口里改成 2:保存报冲突、什么都不写;
/// 选「用我的」覆盖成 2,选「保留外部的」留着 3,其余改动照常写。
#[test]
fn claude_editor_refuses_to_silently_overwrite_external_edits() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let a = json!({ "env": { "ANTHROPIC_BASE_URL": "https://a.example" } });
let state = seed_claude_switch_state(
&[("a", a.clone())],
"a",
r#"{ "env": { "ANTHROPIC_BASE_URL": "https://a.example" }, "x": 1 }"#,
);
let (row, base) = open_claude_editor(&state, "a");
let mut external = claude_live();
external["x"] = json!(3);
std::fs::write(get_claude_settings_path(), external.to_string()).expect("external edit");
let untouched = claude_live_text();
let mut edited = base.clone();
edited["x"] = json!(2);
edited["y"] = json!("mine");
let err =
save_claude_editor(&state, &row, &base, edited.clone(), "refuse").expect_err("conflict");
let detail: serde_json::Value =
serde_json::from_str(&err.to_string()).expect("structured error");
assert_eq!(detail["code"], json!("LIVE_EDIT_CONFLICT"));
assert_eq!(detail["keys"], json!(["x"]));
assert_eq!(claude_live_text(), untouched, "nothing written");
save_claude_editor(&state, &row, &base, edited.clone(), "keepTheirs").expect("theirs");
assert_eq!(claude_live()["x"], json!(3));
assert_eq!(claude_live()["y"], json!("mine"));
save_claude_editor(&state, &row, &base, edited, "keepMine").expect("mine");
assert_eq!(claude_live()["x"], json!(2));
}
/// 新增第一个供应商:用户已有的 settings.json 不被覆盖,只写关键字段,并设为当前。
#[test]
fn claude_editor_first_provider_keeps_the_existing_settings() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let settings_path = get_claude_settings_path();
std::fs::create_dir_all(settings_path.parent().expect("dir")).expect("mkdir");
let existing = r#"{
"permissions": {
"allow": [
"Bash(git status)"
]
},
"model": "opus"
}"#;
std::fs::write(&settings_path, existing).expect("seed live");
let state = create_test_state().expect("state");
let base = ProviderService::editor_view(&state, AppType::Claude, &json!({}), None)
.expect("view")
.settings;
assert_eq!(
base,
json!({ "permissions": { "allow": ["Bash(git status)"] } }),
"a new provider starts from live without the key fields"
);
let mut edited = base.clone();
edited["env"] = json!({
"ANTHROPIC_BASE_URL": "https://relay.example",
"ANTHROPIC_AUTH_TOKEN": "sk-relay"
});
let provider = Provider::with_id("relay".into(), "Relay".into(), edited, None);
ProviderService::add_from_editor(
&state,
AppType::Claude,
provider,
true,
Some(editor_save(&base, "refuse")),
)
.expect("add");
assert_eq!(
claude_live(),
json!({
"permissions": { "allow": ["Bash(git status)"] },
"env": {
"ANTHROPIC_BASE_URL": "https://relay.example",
"ANTHROPIC_AUTH_TOKEN": "sk-relay"
}
})
);
let row = state
.db
.get_provider_by_id("relay", AppType::Claude.as_str())
.expect("read")
.expect("row");
assert_eq!(
row.settings_config,
json!({ "env": {
"ANTHROPIC_BASE_URL": "https://relay.example",
"ANTHROPIC_AUTH_TOKEN": "sk-relay"
}})
);
assert_eq!(
row.meta.and_then(|meta| meta.common_config_enabled),
Some(true)
);
assert_eq!(
state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("current")
.as_deref(),
Some("relay")
);
}
/// live 里用户自己写的独有字段(`ENABLE_TOOL_SEARCH` 这类)不归当前供应商:原样保存不会
/// 把它收进行,切走时也就不会删掉;在编辑器里删掉它就从 live 删;新加的独有字段归供应商。
#[test]
fn claude_editor_leaves_exclusive_fields_from_live_to_the_user() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let a = json!({ "env": { "ANTHROPIC_BASE_URL": "https://a.example" } });
let b = json!({ "env": { "ANTHROPIC_BASE_URL": "https://b.example" } });
let state = seed_claude_switch_state(
&[("a", a.clone()), ("b", b)],
"a",
r#"{ "env": { "ANTHROPIC_BASE_URL": "https://a.example", "ENABLE_TOOL_SEARCH": "true", "DISABLE_INTERLEAVED_THINKING": "1" } }"#,
);
// 只改名、配置原样保存。
let (mut row, base) = open_claude_editor(&state, "a");
assert_eq!(base["env"]["ENABLE_TOOL_SEARCH"], json!("true"));
row.name = "renamed".into();
save_claude_editor(&state, &row, &base, base.clone(), "refuse").expect("save as is");
assert_eq!(
claude_row(&state, "a"),
a,
"live's exclusive fields stay out of the row"
);
// 删掉一个从 live 带进来的,再加一个供应商自己的。
let (row, base) = open_claude_editor(&state, "a");
let mut edited = base.clone();
edited["env"]
.as_object_mut()
.unwrap()
.remove("DISABLE_INTERLEAVED_THINKING");
edited["env"]["CLAUDE_CODE_DISABLE_ARTIFACT"] = json!("1");
save_claude_editor(&state, &row, &base, edited, "refuse").expect("save edits");
let live = claude_live();
assert!(
live["env"].get("DISABLE_INTERLEAVED_THINKING").is_none(),
"{live}"
);
assert_eq!(live["env"]["CLAUDE_CODE_DISABLE_ARTIFACT"], json!("1"));
let a_row = claude_row(&state, "a");
assert_eq!(a_row["env"]["CLAUDE_CODE_DISABLE_ARTIFACT"], json!("1"));
assert!(a_row["env"].get("ENABLE_TOOL_SEARCH").is_none(), "{a_row}");
ProviderService::switch(&state, AppType::Claude, "b").expect("switch to b");
let live = claude_live();
assert_eq!(live["env"]["ENABLE_TOOL_SEARCH"], json!("true"), "{live}");
assert!(
live["env"].get("CLAUDE_CODE_DISABLE_ARTIFACT").is_none(),
"{live}"
);
}
/// 存量 Bedrock API Key 行在编辑器里保存后,Key 还在顶层 `apiKey`:旧版的代理只从那里
/// 读,降级后照样能用。
#[test]
fn claude_editor_keeps_a_legacy_bedrock_key_where_older_versions_read_it() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let legacy = json!({
"apiKey": "bedrock-key",
"env": { "CLAUDE_CODE_USE_BEDROCK": "1", "AWS_REGION": "us-east-1" }
});
let state = seed_claude_switch_state(&[("bedrock", legacy.clone())], "bedrock", "{}");
ProviderService::switch(&state, AppType::Claude, "bedrock").expect("switch");
let (row, base) = open_claude_editor(&state, "bedrock");
assert_eq!(
base["env"]["AWS_BEARER_TOKEN_BEDROCK"],
json!("bedrock-key")
);
save_claude_editor(&state, &row, &base, base.clone(), "refuse").expect("save as is");
assert_eq!(claude_row(&state, "bedrock"), legacy);
let (row, base) = open_claude_editor(&state, "bedrock");
let mut edited = base.clone();
edited["env"]["AWS_BEARER_TOKEN_BEDROCK"] = json!("rotated-key");
save_claude_editor(&state, &row, &base, edited, "refuse").expect("rotate the key");
let stored = claude_row(&state, "bedrock");
assert_eq!(stored["apiKey"], json!("rotated-key"), "{stored}");
assert!(stored["env"].get("AWS_BEARER_TOKEN_BEDROCK").is_none());
assert_eq!(
claude_live()["env"]["AWS_BEARER_TOKEN_BEDROCK"],
json!("rotated-key")
);
}
/// 旧版没有 `category` 的 Google 卡:编辑器按库里那一行认出官方卡,预览的登录方式和切换
/// 写的相同;新增时没有行,只看草稿的 `category`。
#[test]
fn gemini_editor_recognizes_a_legacy_google_card_like_switching_does() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
let mut google = Provider::with_id(
"google".to_string(),
"Google".to_string(),
json!({ "env": {} }),
None,
);
google.category = None;
state
.db
.save_provider(AppType::Gemini.as_str(), &google)
.expect("save legacy google card");
let category = ProviderService::editor_category(&state, &AppType::Gemini, Some("google"), None)
.expect("category");
assert_eq!(category.as_deref(), Some("official"));
let shown = ProviderService::editor_view(
&state,
AppType::Gemini,
&google.settings_config,
category.as_deref(),
)
.expect("view")
.settings;
ProviderService::switch(&state, AppType::Gemini, "google").expect("switch");
let live = read_json_file::<serde_json::Value>(&home.join(".gemini/settings.json"))
.expect("read gemini settings");
assert_eq!(
shown.pointer("/config/security/auth/selectedType"),
live.pointer("/security/auth/selectedType"),
);
assert_eq!(
live.pointer("/security/auth/selectedType"),
Some(&json!("oauth-personal"))
);
assert_eq!(
ProviderService::editor_category(&state, &AppType::Gemini, None, Some("third".into()))
.expect("category for a draft"),
Some("third".to_string())
);
}
/// 新增对话框:底是还没套预设的 live,预设带的独有字段归新供应商,live 里用户自己的
/// 独有字段不归它。
#[test]
fn claude_add_dialog_keeps_the_users_exclusive_fields_out_of_the_new_row() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let a = json!({ "env": { "ANTHROPIC_BASE_URL": "https://a.example" } });
let state = seed_claude_switch_state(
&[("a", a)],
"a",
r#"{ "env": { "ANTHROPIC_BASE_URL": "https://a.example", "ENABLE_TOOL_SEARCH": "true" } }"#,
);
let base = ProviderService::editor_view(&state, AppType::Claude, &json!({}), None)
.expect("view")
.settings;
let mut edited = base.clone();
edited["env"]["ANTHROPIC_BASE_URL"] = json!("https://relay.example");
edited["env"]["CLAUDE_CODE_DISABLE_ARTIFACT"] = json!("1");
let provider = Provider::with_id("relay".into(), "Relay".into(), edited, None);
ProviderService::add_from_editor(
&state,
AppType::Claude,
provider,
true,
Some(editor_save(&base, "refuse")),
)
.expect("add");
let row = claude_row(&state, "relay");
assert_eq!(row["env"]["CLAUDE_CODE_DISABLE_ARTIFACT"], json!("1"));
assert!(row["env"].get("ENABLE_TOOL_SEARCH").is_none(), "{row}");
}
/// 编辑当前供应商:先存行再写 live。存行失败时 live 不动;写 live 被冲突拒绝时撤回刚存
/// 的行。两种情况行和 live 都还是保存前的样子。
#[test]
fn claude_editor_never_leaves_the_row_and_live_apart() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let a = json!({ "env": { "ANTHROPIC_BASE_URL": "https://a.example", "ANTHROPIC_AUTH_TOKEN": "sk-old" } });
let state = seed_claude_switch_state(
&[("a", a.clone())],
"a",
r#"{ "env": { "ANTHROPIC_BASE_URL": "https://a.example", "ANTHROPIC_AUTH_TOKEN": "sk-old" }, "x": 1 }"#,
);
let before = claude_live_text();
// 行存不进去。
let (row, base) = open_claude_editor(&state, "a");
let mut edited = base.clone();
edited["env"]["ANTHROPIC_AUTH_TOKEN"] = json!("sk-new");
let db = rusqlite::Connection::open(home.join(".cc-switch/cc-switch.db")).expect("open db");
db.execute_batch(
"CREATE TRIGGER fail_edit BEFORE UPDATE OF settings_config ON providers \
BEGIN SELECT RAISE(ABORT, 'injected save failure'); END;",
)
.expect("trigger");
save_claude_editor(&state, &row, &base, edited.clone(), "refuse").expect_err("save fails");
db.execute_batch("DROP TRIGGER fail_edit;")
.expect("drop trigger");
assert_eq!(claude_live_text(), before, "live untouched");
assert_eq!(claude_row(&state, "a"), a);
// live 写不进去(外部改了同一个全局键)。
std::fs::write(
get_claude_settings_path(),
before.replace("\"x\": 1", "\"x\": 3"),
)
.expect("external edit");
edited["x"] = json!(2);
save_claude_editor(&state, &row, &base, edited, "refuse").expect_err("conflict");
assert_eq!(claude_row(&state, "a"), a, "the saved row is taken back");
assert_eq!(
claude_live()["env"]["ANTHROPIC_AUTH_TOKEN"],
json!("sk-old")
);
}
/// Codex 官方卡的编辑器:auth.json 框显示盘上真实的登录(Codex 轮换过 token,行里旧版
/// 回填的快照早就对不上了);不改直接保存,行里原来的 auth 不变、盘上的登录也不动。
#[test]
fn codex_editor_official_auth_shows_disk_login_and_keeps_row_auth() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let login = |access: &str| {
json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": access,
"refresh_token": format!("{access}-refresh"),
"account_id": "acct-official"
}
})
};
write_codex_live_atomic(&login("fresh"), Some("")).expect("seed live login");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "codex-official".to_string();
for (id, auth) in [("codex-official", login("stale")), ("follow", json!({}))] {
let mut provider = Provider::with_id(
id.to_string(),
id.to_string(),
json!({ "auth": auth, "config": "" }),
None,
);
provider.category = Some("official".to_string());
manager.providers.insert(id.to_string(), provider);
}
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
for (id, stored_auth) in [("codex-official", login("stale")), ("follow", json!({}))] {
let row = state
.db
.get_provider_by_id(id, AppType::Codex.as_str())
.expect("read provider")
.expect("provider exists");
let view = ProviderService::editor_view(
&state,
AppType::Codex,
&row.settings_config,
Some("official"),
)
.expect("editor view");
assert_eq!(
view.settings.pointer("/auth/tokens/access_token"),
Some(&json!("fresh")),
"{id}: the auth.json box shows the login on disk, not the row snapshot"
);
let mut provider = row.clone();
provider.settings_config = view.settings.clone();
ProviderService::update_from_editor(
&state,
AppType::Codex,
None,
provider,
Some(editor_save(&view.settings, "refuse")),
)
.expect("save unchanged editor");
let saved = state
.db
.get_provider_by_id(id, AppType::Codex.as_str())
.expect("read provider")
.expect("provider exists");
assert_eq!(
saved.settings_config.get("auth"),
Some(&stored_auth),
"{id}: an untouched auth box must not capture the disk login into the row"
);
let on_disk: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
on_disk,
login("fresh"),
"{id}: saving leaves auth.json alone"
);
}
}
#[test]
fn omp_edit_save_keeps_every_byte_outside_the_edited_entry() {
use cc_switch_lib::update_settings;
use std::fs;
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let agent_dir = home.join(".omp/agent");
fs::create_dir_all(&agent_dir).unwrap();
let models_path = agent_dir.join("models.yml");
let sibling = " # hand-written sibling\n other:\n baseUrl: https://other.test/v1 # keep\n";
fs::write(
&models_path,
format!("# managed by me\nproviders:\n duolalab:\n baseUrl: https://api.duolalab.test/v1\n api: openai-completions\n apiKey: sk-live\n models:\n - id: glm-4.7\n{sibling}"),
)
.unwrap();
update_settings(cc_switch_lib::AppSettings {
omp_config_dir: Some(agent_dir.to_string_lossy().into_owned()),
..Default::default()
})
.unwrap();
let state = create_test_state().expect("create test state");
let mut edited = ProviderService::list(&state, AppType::Omp)
.expect("list")
.get("duolalab")
.cloned()
.expect("provider imported from models.yml");
edited.settings_config["name"] = json!("Duolalab");
ProviderService::update(&state, AppType::Omp, Some("duolalab"), edited)
.expect("edit save must not conflict");
let after = fs::read_to_string(&models_path).unwrap();
assert!(
after.starts_with("# managed by me\nproviders:\n duolalab:\n"),
"{after}"
);
assert!(after.contains("name: Duolalab"), "{after}");
assert!(after.ends_with(sibling), "{after}");
}