fix(config): improve Gemini common config parsing robustness

- Add ENV format quote stripping (KEY="value" -> value)
- Add toast warnings for filtered forbidden keys
- Unify error codes with GEMINI_CONFIG_ERROR_CODES constants
- Remove duplicate isPlainObject, use shared implementation
- Fix type guard for filter entries
This commit is contained in:
YoVinchen
2026-01-26 22:46:24 +08:00
parent 4e4a445922
commit 542d4635c4
6 changed files with 343 additions and 148 deletions
+75 -35
View File
@@ -620,7 +620,7 @@ fn merge_claude_config(custom_config: &JsonValue, common_snippet: &str) -> Merge
// Return warning without logging - caller will log if needed
return MergeResult {
config: custom_config.clone(),
warning: Some(format!("Claude common config parse error: {e}")),
warning: Some(format!("COMMON_CONFIG_PARSE_ERROR: {e}")),
};
}
};
@@ -642,7 +642,7 @@ fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeR
compute_final_toml_config_str(config_str, common_snippet, true);
if let Some(e) = error {
// Return warning without logging - caller will log if needed
warning = Some(format!("Codex TOML merge warning: {e}"));
warning = Some(format!("CODEX_TOML_MERGE_ERROR: {e}"));
}
obj.insert("config".to_string(), JsonValue::String(merged_toml));
}
@@ -656,44 +656,25 @@ fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeR
/// Merge Gemini config (JSON format for env field).
///
/// Gemini common config can be stored in two formats:
/// - Wrapped: `{"env": {"KEY": "VALUE", ...}}` (matches provider settings_config structure)
/// - Flat: `{"KEY": "VALUE", ...}` (simpler format used by frontend)
/// Gemini common config can be stored in three formats:
/// - ENV format: KEY=VALUE lines (one per line)
/// - Flat JSON: `{"KEY": "VALUE", ...}`
/// - Wrapped JSON: `{"env": {"KEY": "VALUE", ...}}`
///
/// This function supports both formats for backward compatibility.
/// This function supports all formats for backward compatibility.
fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
let common_value: JsonValue = match serde_json::from_str(common_snippet) {
Ok(v) => v,
Err(e) => {
// Return warning without logging - caller will log if needed
return MergeResult {
config: custom_config.clone(),
warning: Some(format!("Gemini common config parse error: {e}")),
};
}
};
// Parse common config (try JSON first, then ENV format)
let common_env = parse_gemini_common_snippet(common_snippet);
if common_env.is_empty() {
return MergeResult {
config: custom_config.clone(),
warning: None,
};
}
let mut merged_config = custom_config.clone();
// Get the common env object (support both wrapped and flat formats)
let common_env = match common_value.as_object() {
Some(obj) => {
// Check if it's wrapped format {"env": {...}}
if let Some(env_value) = obj.get("env").and_then(|v| v.as_object()) {
env_value.clone()
} else {
// Flat format {"KEY": "VALUE", ...}
obj.clone()
}
}
None => {
return MergeResult {
config: custom_config.clone(),
warning: Some("COMMON_CONFIG_NOT_OBJECT".to_string()),
};
}
};
// Merge only the env field
// If custom config has env, merge common into it; otherwise initialize with common env
if let Some(merged_obj) = merged_config.as_object_mut() {
@@ -718,6 +699,65 @@ fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> Merge
}
}
/// Parse Gemini common config snippet supporting multiple formats.
///
/// Formats supported:
/// - ENV format: KEY=VALUE lines
/// - Flat JSON: {"KEY": "VALUE", ...}
/// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map<String, JsonValue> {
let trimmed = snippet.trim();
if trimmed.is_empty() {
return serde_json::Map::new();
}
// Try JSON first
if let Ok(parsed) = serde_json::from_str::<JsonValue>(trimmed) {
if let Some(obj) = parsed.as_object() {
// Check if it's wrapped format {"env": {...}}
if let Some(env_value) = obj.get("env").and_then(|v| v.as_object()) {
return env_value.clone();
}
// Flat format
return obj.clone();
}
}
// Parse as ENV format (KEY=VALUE lines)
let mut result = serde_json::Map::new();
for line in trimmed.lines() {
let line_trimmed = line.trim();
if line_trimmed.is_empty() || line_trimmed.starts_with('#') {
continue;
}
if let Some(equal_index) = line_trimmed.find('=') {
let key = line_trimmed[..equal_index].trim();
let raw_value = line_trimmed[equal_index + 1..].trim();
// Strip surrounding quotes (single or double) from value
// e.g., KEY="value" or KEY='value' -> value
let value = strip_env_quotes(raw_value);
if !key.is_empty() {
result.insert(key.to_string(), JsonValue::String(value.to_string()));
}
}
}
result
}
/// Strip surrounding quotes from ENV value.
/// Supports both single and double quotes: "value" -> value, 'value' -> value
fn strip_env_quotes(s: &str) -> &str {
let bytes = s.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return &s[1..s.len() - 1];
}
}
s
}
// ============================================================================
// Unit Tests
// ============================================================================
+7 -4
View File
@@ -167,15 +167,18 @@ fn write_live_snapshot_internal(
}
AppType::Codex => {
let obj = config_to_write.as_object().ok_or_else(|| {
AppError::Config("Codex provider settings_config must be a JSON object".to_string())
AppError::Config(
"CODEX_CONFIG_NOT_OBJECT: settings_config must be a JSON object".to_string(),
)
})?;
let auth = obj.get("auth").ok_or_else(|| {
AppError::Config("Codex provider settings_config missing 'auth' field".to_string())
AppError::Config(
"CODEX_CONFIG_MISSING_AUTH: settings_config missing 'auth' field".to_string(),
)
})?;
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
AppError::Config(
"Codex provider settings_config missing 'config' field or not a string"
.to_string(),
"CODEX_CONFIG_MISSING_CONFIG: settings_config missing 'config' field or not a string".to_string(),
)
})?;