refactor(common-config): consolidate hooks and migrate Gemini to ENV format

- Delete redundant wrapper hooks (useCommonConfigSnippet, useGeminiCommonConfig)
- Change Gemini common config from JSON to ENV format (.env style)
- Add backend validation with forbidden keys filtering (GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL)
- Fix localStorage migration to skip empty parsed snippets
- Add error handling for silent JSON parse failures
- Clean up debug logs and unused types
This commit is contained in:
YoVinchen
2026-01-30 10:16:57 +08:00
parent b8a53f9e36
commit aa1231903f
17 changed files with 198 additions and 323 deletions
+19 -3
View File
@@ -217,14 +217,30 @@ pub async fn set_common_config_snippet(
// 验证格式(根据应用类型)
if !snippet.trim().is_empty() {
match app_type.as_str() {
"claude" | "gemini" => {
"claude" => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
"gemini" => {
// 验证 ENV/JSON 格式并拒绝禁用键
let validation = crate::config_merge::validate_gemini_common_snippet(&snippet);
// 如果有禁用键,返回错误
if !validation.forbidden_keys_found.is_empty() {
return Err(format!(
"GEMINI_FORBIDDEN_KEYS:{}",
validation.forbidden_keys_found.join(",")
));
}
// 如果非空但解析后无有效内容,返回错误
if !validation.is_valid {
return Err("GEMINI_INVALID_SNIPPET".to_string());
}
}
"codex" => {
// TOML 格式暂不验证(或可使用 toml crate
// 注意:TOML 验证较为复杂,暂时跳过
// TOML 格式暂不验证(前端验证
}
_ => {}
}
+56 -4
View File
@@ -565,13 +565,24 @@ fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeR
///
/// This function supports all formats for backward compatibility.
fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
// Parse common config (try JSON first, then ENV format)
let common_env = parse_gemini_common_snippet(common_snippet);
// Parse and validate common config (filters forbidden keys)
let validation = validate_gemini_common_snippet(common_snippet);
let common_env = validation.env;
// Generate warning if forbidden keys were found
let warning = if !validation.forbidden_keys_found.is_empty() {
Some(format!(
"GEMINI_FORBIDDEN_KEYS:{}",
validation.forbidden_keys_found.join(",")
))
} else {
None
};
if common_env.is_empty() {
return MergeResult {
config: custom_config.clone(),
warning: None,
warning,
};
}
@@ -597,7 +608,7 @@ fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> Merge
MergeResult {
config: merged_config,
warning: None,
warning,
}
}
@@ -646,6 +657,47 @@ pub(crate) fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map<Stri
result
}
/// Gemini common config forbidden keys - these should never be in common config
/// as they are provider-specific credentials/endpoints.
const GEMINI_FORBIDDEN_KEYS: &[&str] = &["GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY"];
/// Result of validating Gemini common config snippet
#[derive(Debug, Clone)]
pub struct GeminiSnippetValidation {
/// Parsed environment variables (with forbidden keys filtered out)
pub env: serde_json::Map<String, JsonValue>,
/// List of forbidden keys that were found and filtered
pub forbidden_keys_found: Vec<String>,
/// Whether the snippet is valid (parseable and non-empty after filtering)
pub is_valid: bool,
}
/// Parse and validate Gemini common config snippet.
///
/// This function:
/// 1. Parses the snippet (supports ENV/JSON formats)
/// 2. Filters out forbidden keys (GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY)
/// 3. Returns validation result with filtered env and forbidden keys found
pub fn validate_gemini_common_snippet(snippet: &str) -> GeminiSnippetValidation {
let raw_env = parse_gemini_common_snippet(snippet);
let mut filtered_env = serde_json::Map::new();
let mut forbidden_keys_found = Vec::new();
for (key, value) in raw_env {
if GEMINI_FORBIDDEN_KEYS.contains(&key.as_str()) {
forbidden_keys_found.push(key);
} else {
filtered_env.insert(key, value);
}
}
GeminiSnippetValidation {
is_valid: !filtered_env.is_empty() || snippet.trim().is_empty(),
env: filtered_env,
forbidden_keys_found,
}
}
/// Strip surrounding quotes from ENV value.
/// Supports both single and double quotes: "value" -> value, 'value' -> value
fn strip_env_quotes(s: &str) -> &str {
+16 -7
View File
@@ -654,15 +654,19 @@ impl ProviderService {
Ok(cleaned.trim().to_string())
}
/// Extract common config for Gemini (JSON format)
/// Extract common config for Gemini (ENV format)
///
/// Extracts `.env` values while excluding provider-specific credentials:
/// - GOOGLE_GEMINI_BASE_URL
/// - GEMINI_API_KEY
///
/// Returns ENV format (KEY=VALUE per line) instead of JSON.
/// Values containing newlines/carriage returns are skipped to prevent
/// ENV format injection/truncation.
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
let env = settings.get("env").and_then(|v| v.as_object());
let mut snippet = serde_json::Map::new();
let mut lines: Vec<String> = Vec::new();
if let Some(env) = env {
for (key, value) in env {
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
@@ -673,17 +677,22 @@ impl ProviderService {
};
let trimmed = v.trim();
if !trimmed.is_empty() {
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
// Skip values containing newlines to prevent ENV format injection
if trimmed.contains('\n') || trimmed.contains('\r') {
continue;
}
lines.push(format!("{key}={trimmed}"));
}
}
}
if snippet.is_empty() {
return Ok("{}".to_string());
if lines.is_empty() {
return Ok(String::new());
}
serde_json::to_string_pretty(&Value::Object(snippet))
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
// Sort for consistent output
lines.sort();
Ok(lines.join("\n"))
}
/// Extract common config for OpenCode (JSON format)