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"));
}
}