fix(deeplink): preserve custom env fields when importing Claude providers (#2928)

When importing a Claude provider via deep link, the preview dialog
correctly shows every env field carried in the `config` payload — but
on confirmation `build_claude_settings` only persisted the standard
fields mapped from URL params (`apiKey`, `endpoint`, `model`,
`haiku/sonnet/opusModel`). Any non-standard env keys such as
`ANTHROPIC_CUSTOM_HEADERS`, `API_TIMEOUT_MS`, or
`CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` were silently dropped,
making the preview and the actual write diverge.

Start the env map from the inline `config` payload (best-effort
decode via the new `extract_claude_config_env` helper) and then
overlay the URL-param-derived standard fields on top, so:

- URL params remain authoritative for standard fields (unchanged)
- Custom env keys present in `config` survive the import
- Deep links without a `config` payload behave exactly as before

Adds two regression tests:
- `test_build_claude_provider_preserves_custom_env_fields` proves
  custom fields are kept and URL params override same-named config
  fields.
- `test_build_claude_provider_without_config_unchanged` guards the
  backward-compatible path (no `config` → only standard env keys).

Closes #2927

Co-authored-by: lipeng31 <lipeng31@tanyang.co>
This commit is contained in:
lipeng
2026-05-29 22:49:02 +08:00
committed by GitHub
parent bc1467db8d
commit e605eba292
2 changed files with 170 additions and 1 deletions
+54 -1
View File
@@ -244,8 +244,23 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
}
/// Build Claude settings configuration
///
/// Merges env from the inline config (if any) with the standard fields from URL params.
/// URL params take priority — they overwrite same-named fields from the config.
/// Non-standard env fields (e.g. `ANTHROPIC_CUSTOM_HEADERS`, `API_TIMEOUT_MS`,
/// `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`, ...) are preserved as-is so that
/// providers requiring extra environment variables work after deeplink import.
fn build_claude_settings(request: &DeepLinkImportRequest) -> 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=<base64-json>` 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<serde_json::Map<String, serde_json::Value>> {
// 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
+116
View File
@@ -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
// =============================================================================