diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 754418a60..6235c1f85 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -244,8 +244,23 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result serde_json::Value { - let mut env = serde_json::Map::new(); + // Start from the full env block in the inline config (if present), so any + // custom env vars the user passed via `config=` survive the + // import. Falling back to an empty map keeps the previous behavior for + // deeplinks that don't carry a config field. + let mut env = extract_claude_config_env(request).unwrap_or_default(); + + // Now overwrite / fill in the standard fields from URL params. URL params + // are authoritative because they're what the deeplink builder put on the + // wire — for Claude these are: ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, + // ANTHROPIC_MODEL, and the haiku/sonnet/opus model aliases. env.insert( "ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key.clone().unwrap_or_default()), @@ -283,6 +298,44 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value { json!({ "env": env }) } +/// Decode and extract the `env` object from the deeplink's inline config payload. +/// +/// Returns `None` when no config is attached, when the payload can't be +/// decoded/parsed, or when it doesn't contain a Claude-style `env` object. +/// This is a best-effort accessor — we deliberately don't surface parse +/// errors here because `parse_and_merge_config` will have already validated +/// the payload during the merge phase; any failure at this point just means +/// "fall back to URL-param-only behavior". +fn extract_claude_config_env( + request: &DeepLinkImportRequest, +) -> Option> { + // Only the inline base64 config carries an env block. Remote config_url + // is not implemented yet (see parse_and_merge_config), so nothing else to + // try here. + let config_b64 = request.config.as_ref()?; + + // Honor the declared format; default to JSON like parse_and_merge_config does. + let format = request.config_format.as_deref().unwrap_or("json"); + if format != "json" { + // Claude config is always JSON in practice. TOML/other formats aren't + // expected on this app path, so don't try to handle them — safer to + // fall back than to risk silently producing the wrong shape. + return None; + } + + // Decode the base64 payload. We re-decode here rather than threading the + // already-decoded value through every build_* function, because the call + // graph (parse_and_merge_config is pub and called separately for preview) + // makes signature changes invasive. Decode cost is negligible on this + // one-shot import path. + let decoded = decode_base64_param("config", config_b64).ok()?; + let json_str = std::str::from_utf8(&decoded).ok()?; + let value: serde_json::Value = serde_json::from_str(json_str).ok()?; + + // Pull out the env object — same shape as Claude's own settings.json. + value.get("env").and_then(|v| v.as_object()).cloned() +} + /// Build Codex settings configuration fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value { let provider_display_name = request diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 5b128be26..7d814e794 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -357,6 +357,122 @@ fn test_parse_and_merge_config_url_override() { ); } +#[test] +fn test_build_claude_provider_preserves_custom_env_fields() { + // Regression test for: deeplink import dropped non-standard env fields + // such as ANTHROPIC_CUSTOM_HEADERS, even though the preview dialog + // showed them. The preview and the actual persisted provider must + // contain the same env keys. + use super::provider::build_provider_from_request; + + let config_json = r#"{"env":{ + "ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx", + "ANTHROPIC_BASE_URL":"https://api.example.com", + "ANTHROPIC_CUSTOM_HEADERS":"Cookie: session=abc", + "API_TIMEOUT_MS":"3000000", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS":"1", + "ANTHROPIC_DEFAULT_HAIKU_MODEL":"haiku-from-config" + }}"#; + let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes()); + + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: Some("claude".to_string()), + name: Some("My Provider".to_string()), + homepage: Some("https://example.com".to_string()), + endpoint: Some("https://api.example.com".to_string()), + api_key: Some("sk-ant-xxx".to_string()), + icon: None, + // URL param: must win over the same key in config (haiku-from-config) + model: Some("main-model".to_string()), + notes: None, + haiku_model: Some("haiku-from-url".to_string()), + sonnet_model: None, + opus_model: None, + config: Some(config_b64), + config_format: Some("json".to_string()), + config_url: None, + apps: None, + repo: None, + directory: None, + branch: None, + content: None, + description: None, + enabled: None, + usage_enabled: None, + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, + }; + + let provider = build_provider_from_request(&AppType::Claude, &request).unwrap(); + let env = provider.settings_config["env"].as_object().unwrap(); + + // Custom env fields from `config` must survive import + assert_eq!(env["ANTHROPIC_CUSTOM_HEADERS"], "Cookie: session=abc"); + assert_eq!(env["API_TIMEOUT_MS"], "3000000"); + assert_eq!(env["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"], "1"); + + // Standard fields from URL params win over config + assert_eq!(env["ANTHROPIC_AUTH_TOKEN"], "sk-ant-xxx"); + assert_eq!(env["ANTHROPIC_BASE_URL"], "https://api.example.com"); + assert_eq!(env["ANTHROPIC_MODEL"], "main-model"); + assert_eq!(env["ANTHROPIC_DEFAULT_HAIKU_MODEL"], "haiku-from-url"); +} + +#[test] +fn test_build_claude_provider_without_config_unchanged() { + // Backward compatibility: deeplinks without a `config` field still + // produce exactly the same env shape as before — only the standard + // ANTHROPIC_* keys, nothing else. + use super::provider::build_provider_from_request; + + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: Some("claude".to_string()), + name: Some("Plain".to_string()), + homepage: Some("https://example.com".to_string()), + endpoint: Some("https://api.example.com".to_string()), + api_key: Some("sk".to_string()), + icon: None, + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: None, + config_format: None, + config_url: None, + apps: None, + repo: None, + directory: None, + branch: None, + content: None, + description: None, + enabled: None, + usage_enabled: None, + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, + }; + + let provider = build_provider_from_request(&AppType::Claude, &request).unwrap(); + let env = provider.settings_config["env"].as_object().unwrap(); + + assert_eq!(env["ANTHROPIC_AUTH_TOKEN"], "sk"); + assert_eq!(env["ANTHROPIC_BASE_URL"], "https://api.example.com"); + // No extras leaked in + assert_eq!(env.len(), 2); +} + // ============================================================================= // Prompt Tests // =============================================================================