From e10a300c808762c8fd7c15553934bcc06992bd97 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 10:28:51 +0800 Subject: [PATCH] fix(hermes): prevent YAML pollution and drop of OAuth mcp auth DeepLink Hermes import was emitting camelCase (baseUrl / apiKey / apiMode) that the Hermes runtime does not recognise, poisoning `custom_providers:` entries on activation. The MCP sync path was also stripping `auth: oauth` on round-trip, silently downgrading OAuth-type servers to unauthenticated calls. The Hermes deeplink branch now emits snake_case via a dedicated builder; `sanitize_hermes_provider_keys` runs on both `set_provider` and `get_providers` so legacy DB records heal on next access. `HERMES_EXTRA_FIELDS` preserves `auth`. The `api_mode` dropdown gains `codex_responses` (Copilot / OpenCode), and the schema-migrated warning copy no longer hard-codes "v12" (upstream `_config_version` is now 19). --- src-tauri/src/deeplink/mod.rs | 2 +- src-tauri/src/deeplink/provider.rs | 125 +++++++++++++++- src-tauri/src/hermes_config.rs | 147 ++++++++++++++++++- src-tauri/src/mcp/hermes.rs | 46 ++++++ src/components/hermes/HermesHealthBanner.tsx | 2 +- src/config/hermesProviderPresets.ts | 6 +- src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- 9 files changed, 327 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index 4bd3c8036..f883d3b8f 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -31,7 +31,7 @@ pub use skill::import_skill_from_deeplink; /// /// Represents a parsed ccswitch:// URL ready for processing. /// This struct contains all possible fields for all resource types. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeepLinkImportRequest { /// Protocol version (e.g., "v1") diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 216e1ff04..32fa77b58 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -146,7 +146,8 @@ pub(crate) fn build_provider_from_request( AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), AppType::OpenCode => build_opencode_settings(request), - AppType::OpenClaw | AppType::Hermes => build_additive_app_settings(request), + AppType::OpenClaw => build_additive_app_settings(request), + AppType::Hermes => build_hermes_settings(request), }; // Build usage script configuration if provided @@ -393,7 +394,7 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value }) } -/// Build settings for additive-mode apps (OpenClaw, Hermes) +/// Build settings for OpenClaw (camelCase live config). /// Format: { baseUrl, apiKey, api, models } fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::Value { let endpoint = get_primary_endpoint(request); @@ -420,6 +421,42 @@ fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::V json!(config) } +/// Build Hermes provider settings (snake_case YAML-native fields). +/// +/// Hermes' `custom_providers:` entries use `base_url` / `api_key` / `api_mode` +/// (see `_VALID_CUSTOM_PROVIDER_FIELDS` in upstream `hermes_cli/config.py`). +/// Emitting camelCase here — as the OpenClaw path does — would poison the +/// YAML with unknown root fields the Hermes runtime ignores. +/// +/// `api_mode` is deliberately omitted so Hermes auto-detects the protocol +/// from the endpoint; callers can still override via the UI later. +fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value { + let endpoint = get_primary_endpoint(request); + + let mut config = serde_json::Map::new(); + + if let Some(name) = request.name.as_deref().filter(|s| !s.is_empty()) { + config.insert("name".to_string(), json!(name)); + } + + if !endpoint.is_empty() { + config.insert("base_url".to_string(), json!(endpoint)); + } + + if let Some(api_key) = &request.api_key { + config.insert("api_key".to_string(), json!(api_key)); + } + + if let Some(model) = &request.model { + config.insert( + "models".to_string(), + json!([{ "id": model, "name": model }]), + ); + } + + json!(config) +} + // ============================================================================= // Config Merge Logic // ============================================================================= @@ -709,3 +746,87 @@ fn extract_codex_base_url(toml_value: &toml::Value) -> Option { } None } + +#[cfg(test)] +mod tests { + use super::*; + + fn hermes_request() -> DeepLinkImportRequest { + DeepLinkImportRequest { + resource: "provider".to_string(), + app: Some("hermes".to_string()), + name: Some("MyHermes".to_string()), + endpoint: Some("https://api.example.com/v1".to_string()), + api_key: Some("sk-test".to_string()), + model: Some("anthropic/claude-opus-4-7".to_string()), + ..Default::default() + } + } + + #[test] + fn build_hermes_settings_emits_snake_case() { + let settings = build_hermes_settings(&hermes_request()); + let obj = settings.as_object().expect("settings must be object"); + + assert_eq!(obj.get("name").unwrap(), "MyHermes"); + assert_eq!(obj.get("base_url").unwrap(), "https://api.example.com/v1"); + assert_eq!(obj.get("api_key").unwrap(), "sk-test"); + + // camelCase and legacy fields must NOT be present + assert!(obj.get("baseUrl").is_none(), "no camelCase baseUrl"); + assert!(obj.get("apiKey").is_none(), "no camelCase apiKey"); + assert!(obj.get("api").is_none(), "no legacy 'api' field"); + + // models array with the deeplink model id + let models = obj.get("models").unwrap().as_array().unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0]["id"], "anthropic/claude-opus-4-7"); + } + + #[test] + fn build_hermes_settings_omits_api_mode_for_auto_detect() { + let settings = build_hermes_settings(&hermes_request()); + assert!( + settings.as_object().unwrap().get("api_mode").is_none(), + "api_mode must be omitted so Hermes auto-detects" + ); + } + + #[test] + fn build_hermes_settings_skips_missing_optional_fields() { + let request = DeepLinkImportRequest { + resource: "provider".to_string(), + app: Some("hermes".to_string()), + name: Some("Minimal".to_string()), + endpoint: None, + api_key: None, + model: None, + ..Default::default() + }; + let settings = build_hermes_settings(&request); + let obj = settings.as_object().unwrap(); + + assert_eq!(obj.get("name").unwrap(), "Minimal"); + assert!(obj.get("base_url").is_none()); + assert!(obj.get("api_key").is_none()); + assert!(obj.get("models").is_none()); + } + + #[test] + fn openclaw_still_uses_camel_case() { + // OpenClaw's live config natively uses camelCase; guard against a + // refactor accidentally flipping it to snake_case. + let request = DeepLinkImportRequest { + resource: "provider".to_string(), + app: Some("openclaw".to_string()), + name: Some("c".to_string()), + endpoint: Some("https://api.example.com".to_string()), + api_key: Some("k".to_string()), + ..Default::default() + }; + let settings = build_additive_app_settings(&request); + let obj = settings.as_object().unwrap(); + assert!(obj.contains_key("baseUrl")); + assert!(obj.contains_key("apiKey")); + } +} diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 594e15261..a7ab7cd8a 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -414,6 +414,43 @@ fn models_dict_to_array(dict: serde_json::Map) -> ser serde_json::Value::Array(out) } +/// Rewrite historical camelCase keys to Hermes' snake_case schema. +/// +/// Older DeepLink import paths emitted `baseUrl` / `apiKey` / `apiMode` / +/// `maxTokens` / `contextLength`, which do not belong to Hermes' +/// `_VALID_CUSTOM_PROVIDER_FIELDS` set. Writing those raw to YAML silently +/// poisons `custom_providers:` entries. This sanitiser runs defensively on +/// every `set_provider` call so stored data heals on the next activation; +/// unknown keys pass through untouched to keep forward-compat with new +/// Hermes fields (e.g. `request_timeout_seconds`). +fn sanitize_hermes_provider_keys(config: &mut serde_json::Value) { + const KEY_ALIASES: &[(&str, &str)] = &[ + ("baseUrl", "base_url"), + ("apiKey", "api_key"), + ("apiMode", "api_mode"), + ("maxTokens", "max_tokens"), + ("contextLength", "context_length"), + ]; + // Legacy DeepLink emitted `api: "openai-completions"` which is neither a + // Hermes field nor mappable to `api_mode`. + const LEGACY_FIELDS_TO_DROP: &[&str] = &["api"]; + + let Some(obj) = config.as_object_mut() else { + return; + }; + + for (from, to) in KEY_ALIASES { + if let Some(val) = obj.remove(*from) { + // snake_case wins when both are present; stale camelCase is dropped. + obj.entry((*to).to_string()).or_insert(val); + } + } + + for field in LEGACY_FIELDS_TO_DROP { + obj.remove(*field); + } +} + /// If `config.models` is a JSON array, convert it in-place to the dict shape. /// No-op when `models` is absent or already a dict. fn normalize_provider_models_for_write(config: &mut serde_json::Value) { @@ -491,6 +528,10 @@ pub fn get_providers() -> Result, App if let Some(name) = item.get("name").and_then(|n| n.as_str()) { match yaml_to_json(item) { Ok(mut json_val) => { + // Heal legacy camelCase records (from older DeepLink + // imports) before the UI sees them, so editing doesn't + // reveal stale `baseUrl` / `apiKey` fields. + sanitize_hermes_provider_keys(&mut json_val); denormalize_provider_models_for_read(&mut json_val); map.insert(name.to_string(), json_val); } @@ -536,8 +577,12 @@ pub fn set_provider( .cloned() .unwrap_or_default(); - // Normalize `models` from UI array to Hermes YAML dict before serializing. + // Rewrite any historical camelCase keys (e.g. from older DeepLink imports) + // before touching models / YAML — avoids writing non-Hermes fields back. let mut normalized = provider_config; + sanitize_hermes_provider_keys(&mut normalized); + + // Normalize `models` from UI array to Hermes YAML dict before serializing. normalize_provider_models_for_write(&mut normalized); // Extract the first model id (now a key in the normalized dict) so we can @@ -819,7 +864,7 @@ fn scan_hermes_health_internal(content: &str) -> Vec { if version >= 12 && providers_dict_populated { warnings.push(hermes_warning( "schema_migrated_v12", - "Hermes v12 moved some entries into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.".to_string(), + "Hermes' newer schema moved some entries into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.".to_string(), "providers", )); } @@ -1048,6 +1093,75 @@ mod tests { result } + // ---- sanitize_hermes_provider_keys tests ---- + + #[test] + fn sanitize_rewrites_camel_case_aliases() { + let mut v = serde_json::json!({ + "name": "test", + "baseUrl": "https://api.example.com", + "apiKey": "sk-123", + "apiMode": "chat_completions", + "maxTokens": 8192, + "contextLength": 200000, + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + assert_eq!(obj.get("base_url").unwrap(), "https://api.example.com"); + assert_eq!(obj.get("api_key").unwrap(), "sk-123"); + assert_eq!(obj.get("api_mode").unwrap(), "chat_completions"); + assert_eq!(obj.get("max_tokens").unwrap(), 8192); + assert_eq!(obj.get("context_length").unwrap(), 200000); + assert!(obj.get("baseUrl").is_none()); + assert!(obj.get("apiKey").is_none()); + } + + #[test] + fn sanitize_drops_stale_duplicate_when_snake_case_exists() { + let mut v = serde_json::json!({ + "baseUrl": "https://old.example.com", + "base_url": "https://new.example.com", + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + // snake_case wins; stale camelCase is dropped + assert_eq!(obj.get("base_url").unwrap(), "https://new.example.com"); + assert!(obj.get("baseUrl").is_none()); + } + + #[test] + fn sanitize_drops_legacy_api_field() { + let mut v = serde_json::json!({ + "base_url": "https://api.example.com", + "api": "openai-completions", + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + assert!(obj.get("api").is_none(), "legacy 'api' key must be removed"); + assert!(obj.get("base_url").is_some()); + } + + #[test] + fn sanitize_preserves_unknown_fields() { + let mut v = serde_json::json!({ + "base_url": "https://api.example.com", + "request_timeout_seconds": 300, + "rate_limit_delay": 1.5, + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + // Forward-compat: Hermes' own new fields pass through untouched + assert_eq!(obj.get("request_timeout_seconds").unwrap(), 300); + assert_eq!(obj.get("rate_limit_delay").unwrap(), 1.5); + } + + #[test] + fn sanitize_noop_on_non_object() { + let mut v = serde_json::json!(["not", "an", "object"]); + sanitize_hermes_provider_keys(&mut v); + assert!(v.is_array()); + } + // ---- find_yaml_section_range tests ---- #[test] @@ -1295,6 +1409,35 @@ custom_providers: }); } + #[test] + #[serial] + fn get_providers_heals_legacy_camel_case_on_read() { + // A DB may still hold records from older DeepLink imports that wrote + // camelCase fields into `settings_config`. The read path must surface + // them in Hermes' native snake_case so UI editors aren't lying to users. + with_test_home(|| { + let yaml = "\ +custom_providers: + - name: legacy + baseUrl: https://legacy.example.com + apiKey: sk-legacy + apiMode: chat_completions + api: openai-completions +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + let provider = get_provider("legacy").unwrap().unwrap(); + assert_eq!(provider["base_url"], "https://legacy.example.com"); + assert_eq!(provider["api_key"], "sk-legacy"); + assert_eq!(provider["api_mode"], "chat_completions"); + assert!(provider.get("baseUrl").is_none()); + assert!(provider.get("apiKey").is_none()); + assert!(provider.get("api").is_none()); + }); + } + // ---- Model config tests ---- #[test] diff --git a/src-tauri/src/mcp/hermes.rs b/src-tauri/src/mcp/hermes.rs index c2788212f..612a10973 100644 --- a/src-tauri/src/mcp/hermes.rs +++ b/src-tauri/src/mcp/hermes.rs @@ -25,6 +25,10 @@ use super::validation::validate_server_spec; /// Hermes-specific fields preserved on merge-on-write, stripped on import. /// Update this list when Hermes adds new per-server config fields. +/// +/// `auth` ("oauth" / absent) is an OAuth-type declaration read by Hermes — +/// CC Switch has no OAuth UI, but losing the field on round-trip downgrades +/// the server to unauthenticated calls. const HERMES_EXTRA_FIELDS: &[&str] = &[ "enabled", "timeout", @@ -32,6 +36,7 @@ const HERMES_EXTRA_FIELDS: &[&str] = &[ "tools", "sampling", "roots", + "auth", ]; // ============================================================================ @@ -506,6 +511,47 @@ mod tests { assert_eq!(merged["enabled"], true); } + #[test] + fn test_merge_preserves_auth_field() { + let existing = json!({ + "url": "https://mcp.example.com", + "auth": "oauth", + "enabled": true + }); + + let new_spec = json!({ + "url": "https://mcp.example.com/updated", + "headers": { "X-Trace": "abc" }, + "enabled": true + }); + + let merged = merge_hermes_spec(&existing, &new_spec); + + assert_eq!(merged["url"], "https://mcp.example.com/updated"); + assert_eq!(merged["headers"]["X-Trace"], "abc"); + assert_eq!( + merged["auth"], "oauth", + "auth declaration must survive CC Switch round-trip" + ); + } + + #[test] + fn test_convert_hermes_strips_auth_on_import() { + let spec = json!({ + "url": "https://mcp.example.com", + "auth": "oauth", + "enabled": true + }); + + let result = convert_from_hermes_format("remote", &spec).unwrap(); + assert_eq!(result["type"], "sse"); + assert_eq!(result["url"], "https://mcp.example.com"); + assert!( + result.get("auth").is_none(), + "auth stays Hermes-specific; stripped from unified format" + ); + } + #[test] fn test_merge_new_server_no_existing_extra_fields() { let existing = json!({ diff --git a/src/components/hermes/HermesHealthBanner.tsx b/src/components/hermes/HermesHealthBanner.tsx index 81723bd2c..244036803 100644 --- a/src/components/hermes/HermesHealthBanner.tsx +++ b/src/components/hermes/HermesHealthBanner.tsx @@ -63,7 +63,7 @@ function getWarningText( case "schema_migrated_v12": return t("hermes.health.schemaMigratedV12", { defaultValue: - "Hermes moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.", + "Hermes' newer schema moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.", }); default: return fallback; diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index 2acd542f2..59d048d9d 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -45,7 +45,10 @@ export interface HermesSuggestedDefaults { } /** Hermes custom_provider protocol mode (optional; auto-detected when omitted). */ -export type HermesApiMode = "chat_completions" | "anthropic_messages"; +export type HermesApiMode = + | "chat_completions" + | "anthropic_messages" + | "codex_responses"; /** * Form-facing value used by the API Mode dropdown. @@ -70,6 +73,7 @@ export const hermesApiModes: Array<{ value: "anthropic_messages", labelKey: "hermes.form.apiModeAnthropicMessages", }, + { value: "codex_responses", labelKey: "hermes.form.apiModeCodexResponses" }, ]; export interface HermesProviderPreset { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cfa420472..491806485 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1653,6 +1653,7 @@ "apiModeAuto": "Auto-detect", "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", + "apiModeCodexResponses": "Codex Responses (Copilot / OpenCode)", "models": "Models", "addModel": "Add model", "noModels": "No models configured. Switching to this provider won't change the default model.", @@ -1684,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default is not in the selected provider's models list.", "duplicateProviderName": "custom_providers contains duplicate provider names — only one entry will be used.", "duplicateProviderBaseUrl": "custom_providers contains duplicate base_urls — possible accidental copy.", - "schemaMigratedV12": "Hermes moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI." + "schemaMigratedV12": "Hermes' newer schema moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI." }, "memory": { "title": "Memory", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2c00458f5..1041f3256 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1653,6 +1653,7 @@ "apiModeAuto": "自動検出", "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", + "apiModeCodexResponses": "Codex Responses(Copilot / OpenCode)", "models": "モデル一覧", "addModel": "モデルを追加", "noModels": "モデル未設定。このプロバイダーに切り替えてもデフォルトモデルは更新されません。", @@ -1684,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default は選択中のプロバイダーのモデル一覧に含まれていません。", "duplicateProviderName": "custom_providers にプロバイダー名の重複があります。1 件のみ有効になります。", "duplicateProviderBaseUrl": "custom_providers に重複した base_url があります。誤ってコピーされた可能性があります。", - "schemaMigratedV12": "Hermes は一部のプロバイダーを 'providers:' dict に移しました。CC Switch は 'custom_providers:' のみを管理します。該当する項目は Hermes Web UI で編集・削除してください。" + "schemaMigratedV12": "Hermes の新しいスキーマでは一部のプロバイダーが 'providers:' dict に移動しました。CC Switch は 'custom_providers:' のみを管理します。該当する項目は Hermes Web UI で編集・削除してください。" }, "memory": { "title": "メモリ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0ef934332..ec1965d93 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1653,6 +1653,7 @@ "apiModeAuto": "自动检测", "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", + "apiModeCodexResponses": "Codex Responses(Copilot / OpenCode)", "models": "模型列表", "addModel": "添加模型", "noModels": "暂无模型配置。切换到此供应商时将不会更新默认模型。", @@ -1684,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default 指向的模型不在所选供应商的模型列表中。", "duplicateProviderName": "custom_providers 中存在重复的供应商名,只会有一条生效。", "duplicateProviderBaseUrl": "custom_providers 中存在重复的 base_url,可能是意外复制。", - "schemaMigratedV12": "Hermes 把部分供应商移到了 'providers:' dict。CC Switch 只管理 'custom_providers:',请在 Hermes Web UI 编辑或删除这些条目。" + "schemaMigratedV12": "Hermes 新版 schema 把部分供应商移到了 'providers:' dict。CC Switch 只管理 'custom_providers:',请在 Hermes Web UI 编辑或删除这些条目。" }, "memory": { "title": "记忆管理",