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).
This commit is contained in:
Jason
2026-04-20 10:28:51 +08:00
parent 185ac2be9b
commit e10a300c80
9 changed files with 327 additions and 10 deletions
+1 -1
View File
@@ -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")
+123 -2
View File
@@ -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<String> {
}
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"));
}
}
+145 -2
View File
@@ -414,6 +414,43 @@ fn models_dict_to_array(dict: serde_json::Map<String, serde_json::Value>) -> 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<serde_json::Map<String, serde_json::Value>, 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<HermesHealthWarning> {
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]
+46
View File
@@ -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!({
+1 -1
View File
@@ -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;
+5 -1
View File
@@ -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 {
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -1653,6 +1653,7 @@
"apiModeAuto": "自動検出",
"apiModeChatCompletions": "OpenAI Chat Completions",
"apiModeAnthropicMessages": "Anthropic Messages",
"apiModeCodexResponses": "Codex ResponsesCopilot / 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": "メモリ",
+2 -1
View File
@@ -1653,6 +1653,7 @@
"apiModeAuto": "自动检测",
"apiModeChatCompletions": "OpenAI Chat Completions",
"apiModeAnthropicMessages": "Anthropic Messages",
"apiModeCodexResponses": "Codex ResponsesCopilot / 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": "记忆管理",