mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(provider): re-project Codex MCP after unified-session toggle rewrites live config
Toggling unify_codex_session_history rewrites the current official provider's live config.toml in full (intended design), which drops the [mcp_servers] projection -- and nothing put it back, so enabled MCP servers silently vanished until the next provider switch (#C2). Re-project after the rewrite, with two deliberate choices: - Project Codex only (new McpService::sync_enabled_for_app) instead of sync_all_enabled: the all-apps sync short-circuits in AppType::all() order, so a corrupt ~/.claude.json would error before Codex is ever reached and the freshly wiped [mcp_servers] would stay missing. Only Codex's live file was rewritten here, so only Codex needs re-projection. - Degrade projection failure to a warning: by this point the live file already carries the new bucket state, so the toggle has taken effect. Propagating the error would make save_settings roll back the setting, creating the exact "setting=old, live=new bucket" session split the rollback exists to prevent. The projection self-heals on the next switch or any MCP toggle.
This commit is contained in:
@@ -55,6 +55,7 @@ pub use prompt::Prompt;
|
|||||||
pub use provider::{Provider, ProviderMeta};
|
pub use provider::{Provider, ProviderMeta};
|
||||||
pub use services::{
|
pub use services::{
|
||||||
profile::{ProfilePayload, ProfileScope, ProfileService},
|
profile::{ProfilePayload, ProfileScope, ProfileService},
|
||||||
|
provider::reapply_current_codex_official_live,
|
||||||
skill::{migrate_skills_to_ssot, ImportSkillSelection},
|
skill::{migrate_skills_to_ssot, ImportSkillSelection},
|
||||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
||||||
SkillService, SpeedtestService,
|
SkillService, SpeedtestService,
|
||||||
|
|||||||
@@ -181,16 +181,35 @@ impl McpService {
|
|||||||
let servers = Self::get_all_servers(state)?;
|
let servers = Self::get_all_servers(state)?;
|
||||||
|
|
||||||
for app in AppType::all() {
|
for app in AppType::all() {
|
||||||
if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) {
|
Self::project_servers_to_app(state, &servers, &app)?;
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for server in servers.values() {
|
Ok(())
|
||||||
if server.apps.is_enabled_for(&app) {
|
}
|
||||||
Self::sync_server_to_app(state, server, &app)?;
|
|
||||||
} else {
|
/// 只把启用状态投影到单个应用。某个应用的 live 被整体重写后用它做
|
||||||
Self::remove_server_from_app(state, &server.id, &app)?;
|
/// 定向重投影:sync_all_enabled 按 AppType::all() 顺序逐应用短路,
|
||||||
}
|
/// 排在前面的无关应用 live 损坏(如 ~/.claude.json 坏 JSON)会阻断
|
||||||
|
/// 后面应用的投影。
|
||||||
|
pub fn sync_enabled_for_app(state: &AppState, app: &AppType) -> Result<(), AppError> {
|
||||||
|
let servers = Self::get_all_servers(state)?;
|
||||||
|
Self::project_servers_to_app(state, &servers, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_servers_to_app(
|
||||||
|
state: &AppState,
|
||||||
|
servers: &IndexMap<String, McpServer>,
|
||||||
|
app: &AppType,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for server in servers.values() {
|
||||||
|
if server.apps.is_enabled_for(app) {
|
||||||
|
Self::sync_server_to_app(state, server, app)?;
|
||||||
|
} else {
|
||||||
|
Self::remove_server_from_app(state, &server.id, app)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ pub fn reapply_current_codex_official_live(state: &AppState) -> Result<bool, App
|
|||||||
}
|
}
|
||||||
|
|
||||||
live::write_live_with_common_config(&state.db, &AppType::Codex, provider)?;
|
live::write_live_with_common_config(&state.db, &AppType::Codex, provider)?;
|
||||||
|
// 重写 live 会整体替换 config.toml(有意设计),[mcp_servers] 随之丢失,
|
||||||
|
// 写完必须立刻从 DB 重新投影启用的 MCP。只投影 Codex 而非
|
||||||
|
// sync_all_enabled:后者按 AppType::all() 顺序逐应用短路,排在 Codex
|
||||||
|
// 前面的无关应用 live 损坏(如 ~/.claude.json 坏 JSON)会阻断 Codex
|
||||||
|
// 的重投影,让刚被清掉的 [mcp_servers] 无人补回。
|
||||||
|
// 投影失败降级为警告:走到这里 live 已按新开关状态落盘,开关事实上
|
||||||
|
// 已生效;若把错误上抛,save_settings 会回滚开关设置,制造"设置=旧值、
|
||||||
|
// live=新桶"的会话分裂——正是该回滚要防止的状态。MCP 投影可自愈
|
||||||
|
// (下次切换 / 任一 MCP 启停操作都会重新投影)。
|
||||||
|
if let Err(err) = McpService::sync_enabled_for_app(state, &AppType::Codex) {
|
||||||
|
log::warn!("统一会话开关重写 live 后重投影 Codex MCP 失败(将在下次同步时自愈): {err}");
|
||||||
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -881,6 +881,203 @@ requires_openai_auth = true
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reapply_codex_official_live_resyncs_mcp_servers() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
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" }
|
||||||
|
});
|
||||||
|
write_codex_live_atomic(&live_auth, Some("")).expect("seed official live auth");
|
||||||
|
|
||||||
|
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": {
|
||||||
|
"auth_mode": "chatgpt",
|
||||||
|
"OPENAI_API_KEY": null,
|
||||||
|
"tokens": { "access_token": "official-oauth-token", "account_id": "acct" }
|
||||||
|
},
|
||||||
|
"config": ""
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
official.category = Some("official".to_string());
|
||||||
|
manager
|
||||||
|
.providers
|
||||||
|
.insert("official-provider".to_string(), official);
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
claude: false,
|
||||||
|
codex: true,
|
||||||
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
|
hermes: 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, "official-provider")
|
||||||
|
.expect("switch to official provider");
|
||||||
|
let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
|
||||||
|
.expect("read config.toml after switch");
|
||||||
|
assert!(
|
||||||
|
live.contains("mcp_servers.echo-server"),
|
||||||
|
"switch should sync enabled MCP servers into live"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 统一会话开关变更触发的 reapply 会整体重写 live config.toml(有意设计),
|
||||||
|
// 写完必须重新投影 DB 里启用的 MCP,否则用户的 MCP 会静默失效。
|
||||||
|
let reapplied =
|
||||||
|
cc_switch_lib::reapply_current_codex_official_live(&state).expect("reapply official live");
|
||||||
|
assert!(
|
||||||
|
reapplied,
|
||||||
|
"current provider is official, reapply should run"
|
||||||
|
);
|
||||||
|
|
||||||
|
let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
|
||||||
|
.expect("read config.toml after reapply");
|
||||||
|
assert!(
|
||||||
|
live.contains("mcp_servers.echo-server"),
|
||||||
|
"reapply must re-project enabled MCP servers after the full live rewrite, got: {live}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// reapply 走到 MCP 投影时 live 已按新开关状态落盘、开关事实上已生效:
|
||||||
|
/// ① 投影失败若上抛,save_settings 会回滚开关设置,制造"设置=旧值、
|
||||||
|
/// live=新桶"的会话分裂——必须降级为警告而不是失败;
|
||||||
|
/// ② 投影必须只针对 Codex:sync_all_enabled 按 AppType::all() 顺序短路,
|
||||||
|
/// Claude 排在 Codex 前面,损坏的 ~/.claude.json 会在轮到 Codex 之前
|
||||||
|
/// 报错——若吞错了事,刚被整体重写清掉的 [mcp_servers] 就无人补回,
|
||||||
|
/// Codex MCP 静默消失。这里用坏 JSON 的 ~/.claude.json 复现该场景。
|
||||||
|
#[test]
|
||||||
|
fn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
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" }
|
||||||
|
});
|
||||||
|
write_codex_live_atomic(&live_auth, Some("")).expect("seed official live auth");
|
||||||
|
|
||||||
|
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": {
|
||||||
|
"auth_mode": "chatgpt",
|
||||||
|
"OPENAI_API_KEY": null,
|
||||||
|
"tokens": { "access_token": "official-oauth-token", "account_id": "acct" }
|
||||||
|
},
|
||||||
|
"config": ""
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
official.category = Some("official".to_string());
|
||||||
|
manager
|
||||||
|
.providers
|
||||||
|
.insert("official-provider".to_string(), official);
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
claude: false,
|
||||||
|
codex: true,
|
||||||
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
|
hermes: false,
|
||||||
|
},
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||||||
|
|
||||||
|
// 切换路径仍走 sync_all_enabled(会碰 claude),所以先切换、后破坏。
|
||||||
|
ProviderService::switch(&state, AppType::Codex, "official-provider")
|
||||||
|
.expect("switch to official provider");
|
||||||
|
|
||||||
|
// 破坏 ~/.claude.json:坏 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");
|
||||||
|
|
||||||
|
let reapplied = cc_switch_lib::reapply_current_codex_official_live(&state)
|
||||||
|
.expect("MCP projection failure must degrade to a warning, not fail the toggle");
|
||||||
|
assert!(
|
||||||
|
reapplied,
|
||||||
|
"current provider is official, reapply should run"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 定向投影不碰 claude:Codex 的 MCP 投影必须完成,不能被
|
||||||
|
// 无关应用的损坏文件阻断后静默丢失。
|
||||||
|
let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
|
||||||
|
.expect("read config.toml after reapply");
|
||||||
|
assert!(
|
||||||
|
live.contains("mcp_servers.echo-server"),
|
||||||
|
"codex MCP projection must not be blocked by a broken ~/.claude.json, got: {live}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 顺带锁定:定向投影不应把手改坏的 ~/.claude.json 触碰或"修复"。
|
||||||
|
let claude_after = std::fs::read_to_string(&claude_json).expect("read claude json");
|
||||||
|
assert_eq!(
|
||||||
|
claude_after, "{ not valid json",
|
||||||
|
"codex-only projection must not touch claude's live file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_service_switch_codex_official_accounts_write_auth_json() {
|
fn provider_service_switch_codex_official_accounts_write_auth_json() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
|||||||
Reference in New Issue
Block a user