mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 04:02:02 +08:00
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:
@@ -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")
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user