From 473c2aaa9f559ada436701b3d19331b94d8f3ad5 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 8 Jul 2026 16:29:14 +0800 Subject: [PATCH] fix(provider): exclude injected artifacts and routing fields from Codex common-config extraction extract_codex_common_config kept several fields in the shared snippet that must never cross providers: - [mcp_servers] and the legacy [mcp.servers] form: owned by the DB mcp_servers table; once in the snippet they get merged into every opted-in provider and no sync path can ever clean them up. - top-level experimental_bearer_token: normally lives inside [model_providers.] (stripped with the whole table), but three fallbacks write it at top level -- leaking the API key into the shared snippet. - model_catalog_json: per-provider catalog projection pointer. - web_search, only when it equals the injected "disabled" sentinel; a user-set value remains a shareable preference. - top-level wire_api: same provider-routing semantics as top-level base_url (the fallback target when no model_provider is set); leaking it would rewrite the next provider's protocol selection. This makes the extractor safe as the source for switch-time common-config autosync. --- src-tauri/src/codex_config.rs | 4 +- src-tauri/src/services/provider/mod.rs | 97 +++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index ddd2b4eb1..6f4f3aac2 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -15,14 +15,14 @@ pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom"; pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json"; /// Top-level `config.toml` key that controls Codex's built-in web-search tool. -const CODEX_WEB_SEARCH_FIELD: &str = "web_search"; +pub(crate) const CODEX_WEB_SEARCH_FIELD: &str = "web_search"; /// Value that disables the web-search tool. Some native `/responses` gateways /// reject a `web_search` tool with `responses_feature_not_supported` ("tool type /// 'web_search' is not supported by this gateway phase"), so for those we write /// this per the vendors' official Codex docs. Also doubles as cc-switch's /// ownership sentinel: we only ever remove a `web_search` key whose value equals /// this string, never a user's own setting. -const CODEX_WEB_SEARCH_DISABLED: &str = "disabled"; +pub(crate) const CODEX_WEB_SEARCH_DISABLED: &str = "disabled"; /// Native `/responses` gateways whose first-party models do NOT support the Codex /// `web_search` hosted tool. A BLACKLIST (default-on): everything not listed keeps diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 0d2451e4a..6d3368c1a 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -809,10 +809,18 @@ mod tests { } #[test] - fn extract_codex_common_config_preserves_mcp_servers_base_url() { + fn extract_codex_common_config_strips_provider_fields_and_injected_artifacts() { + // 顶层 experimental_bearer_token 模拟无活跃路由时的 fallback 注入; + // web_search = "disabled" 是 cc-switch 对黑名单网关注入的哨兵; + // 顶层 wire_api 模拟无 model_provider 时的 fallback 写法; + // [mcp.servers] 是历史错误格式,sync_all_enabled 清不掉它。 let config_toml = r#"model_provider = "azure" model = "gpt-4" +wire_api = "chat" disable_response_storage = true +experimental_bearer_token = "sk-live-secret" +model_catalog_json = "cc-switch-model-catalog.json" +web_search = "disabled" [model_providers.azure] name = "Azure OpenAI" @@ -821,6 +829,9 @@ wire_api = "responses" [mcp_servers.my_server] base_url = "http://localhost:8080" + +[mcp.servers.legacy_server] +command = "legacy-cmd" "#; let settings = json!({ "config": config_toml }); @@ -843,9 +854,51 @@ base_url = "http://localhost:8080" !extracted.contains("[model_providers"), "should remove entire model_providers table" ); + // MCP 归 DB mcp_servers 表所有,不得进共享片段(含历史错误格式 [mcp.servers]) assert!( - extracted.contains("http://localhost:8080"), - "should keep mcp_servers.* base_url" + !extracted.contains("mcp_servers") && !extracted.contains("http://localhost:8080"), + "should strip mcp_servers from the shared snippet, got: {extracted}" + ); + assert!( + !extracted.contains("[mcp") && !extracted.contains("legacy-cmd"), + "should strip the legacy [mcp.servers] form from the shared snippet, got: {extracted}" + ); + // 顶层 wire_api 是供应商路由语义(model_providers 整表已剥, + // 剩余任何 wire_api 都意味着泄漏) + assert!( + !extracted.contains("wire_api"), + "should strip top-level wire_api from the shared snippet, got: {extracted}" + ); + // 注入产物不得进共享片段(bearer token 泄漏为密钥级问题) + assert!( + !extracted.contains("experimental_bearer_token") + && !extracted.contains("sk-live-secret"), + "should strip top-level fallback bearer token, got: {extracted}" + ); + assert!( + !extracted.contains("model_catalog_json"), + "should strip catalog projection pointer, got: {extracted}" + ); + assert!( + !extracted.contains("web_search"), + "should strip the cc-switch web_search disabled sentinel, got: {extracted}" + ); + // 真正可共享的键保留 + assert!( + extracted.contains("disable_response_storage = true"), + "shareable keys must survive extraction, got: {extracted}" + ); + } + + #[test] + fn extract_codex_common_config_keeps_user_set_web_search() { + let config_toml = "web_search = \"enabled\"\ndisable_response_storage = true\n"; + let settings = json!({ "config": config_toml }); + let extracted = ProviderService::extract_codex_common_config(&settings) + .expect("extract should succeed"); + assert!( + extracted.contains("web_search = \"enabled\""), + "a user-set web_search value is a shareable preference, got: {extracted}" ); } @@ -2859,10 +2912,48 @@ impl ProviderService { root.remove("model_provider"); // Legacy/alt formats might use a top-level base_url. root.remove("base_url"); + // wire_api 与 base_url 同属供应商路由语义:无 model_provider 时 + // update_codex_toml_field / 前端 setCodexWireApi 都会把它落在顶层, + // 进了片段会改写其它供应商的协议选择(chat vs responses)。 + root.remove("wire_api"); // Remove entire model_providers table (provider-specific configuration) root.remove("model_providers"); + // MCP 服务器归 DB mcp_servers 表所有:进了共享片段会绕过按应用的 + // 启用状态被合并进所有勾选通用配置的供应商,且在通用配置编辑框里 + // 显示为一份"重复"的 MCP 配置。 + root.remove("mcp_servers"); + // 历史错误格式 [mcp.servers] 一并剥离(与 strip_codex_mcp_servers_from_settings + // 一致):sync_all_enabled 只管理 [mcp_servers.*],legacy 形态一旦进了 + // 片段就会被合并进所有供应商,且没有任何同步路径能清掉这个孤儿。 + if let Some(mcp_tbl) = root + .get_mut("mcp") + .and_then(|item| item.as_table_like_mut()) + { + mcp_tbl.remove("servers"); + if mcp_tbl.is_empty() { + root.remove("mcp"); + } + } + + // cc-switch 写 live 时注入的产物一律不进共享片段: + // - experimental_bearer_token 正常写在 [model_providers.] 内(上面 + // 整表已剥),但无活跃路由 / 内建保留 id / 路由表缺失三种 fallback + // 会落在顶层——不剥等于把 API 密钥写进共享片段。 + root.remove("experimental_bearer_token"); + // - model_catalog_json 指向按供应商生成的 catalog 投影文件(DB 为 SSOT)。 + root.remove("model_catalog_json"); + // - web_search 只剥 cc-switch 注入的 "disabled" 哨兵;用户手设的其它值 + // 属于可共享偏好,保留。 + if root + .get(crate::codex_config::CODEX_WEB_SEARCH_FIELD) + .and_then(|item| item.as_str()) + == Some(crate::codex_config::CODEX_WEB_SEARCH_DISABLED) + { + root.remove(crate::codex_config::CODEX_WEB_SEARCH_FIELD); + } + // Clean up multiple empty lines (keep at most one blank line). let mut cleaned = String::new(); let mut blank_run = 0usize;