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
Unverified
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(),
)
})?;
+36 -30
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
@@ -11,6 +12,7 @@ import {
import { providersApi, vscodeApi, configApi, type AppId } from "@/lib/api";
import { extractDifference, isPlainObject } from "@/utils/configMerge";
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import { parseGeminiCommonConfigSnippet } from "@/utils/providerConfigUtils";
interface EditProviderDialogProps {
open: boolean;
@@ -114,42 +116,46 @@ export function EditProviderDialog({
setLiveSettings(live);
}
} else if (appId === "gemini") {
// Gemini: common config supports two formats:
// Gemini: common config supports three formats:
// - ENV format: KEY=VALUE lines
// - Flat JSON: {"KEY": "VALUE", ...}
// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
const liveEnv =
(live as { env?: Record<string, string> }).env ?? {};
// Parse common config as JSON with format detection
const parsedSnippet = JSON.parse(
commonSnippet.trim(),
) as Record<string, unknown>;
// Check if wrapped format {"env": {...}}
const commonEnvRaw =
parsedSnippet.env &&
typeof parsedSnippet.env === "object"
? (parsedSnippet.env as Record<string, unknown>)
: parsedSnippet;
// Convert to string record, filtering out non-string values
const commonEnvStrObj: Record<string, string> = {};
for (const [key, value] of Object.entries(commonEnvRaw)) {
if (typeof value === "string") {
commonEnvStrObj[key] = value;
}
}
if (
isPlainObject(liveEnv) &&
isPlainObject(commonEnvStrObj)
) {
const { customConfig } = extractDifference(
liveEnv,
commonEnvStrObj,
// Use shared parser with validation
const parseResult = parseGeminiCommonConfigSnippet(
commonSnippet,
{ strictForbiddenKeys: false },
);
if (parseResult.error) {
console.warn(
"[EditProviderDialog] Gemini common config parse error:",
parseResult.error,
);
setLiveSettings({
...live,
env: customConfig,
});
} else {
setLiveSettings(live);
} else {
// Show warning toast if keys were filtered
if (parseResult.warning) {
toast.warning(parseResult.warning);
}
if (
isPlainObject(liveEnv) &&
Object.keys(parseResult.env).length > 0
) {
const { customConfig } = extractDifference(
liveEnv,
parseResult.env,
);
setLiveSettings({
...live,
env: customConfig,
});
} else {
setLiveSettings(live);
}
}
} else {
// Claude: 处理 JSON 格式
+28 -23
View File
@@ -38,6 +38,7 @@ import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import { extractDifference, isPlainObject } from "@/utils/configMerge";
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import { parseGeminiCommonConfigSnippet } from "@/utils/providerConfigUtils";
import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor";
@@ -68,33 +69,33 @@ import {
import { useProvidersQuery } from "@/lib/query/queries";
/**
* Parse Gemini common config snippet.
* Supports two formats:
* Parse Gemini common config snippet for difference extraction.
* Uses shared parser with non-strict forbidden keys (filter instead of reject).
*
* Supports three formats:
* - ENV format: KEY=VALUE lines (one per line)
* - Flat JSON: {"KEY": "VALUE", ...}
* - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
*
* Returns empty object if parsing fails.
* Returns { env, warning } - caller should display warning via toast if present.
*/
function parseGeminiCommonConfig(snippet: string): Record<string, string> {
try {
const parsed = JSON.parse(snippet);
if (!parsed || typeof parsed !== "object") {
return {};
}
// Check if it's wrapped format {"env": {...}}
const envObj =
parsed.env && typeof parsed.env === "object" ? parsed.env : parsed;
// Convert to string record
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(envObj)) {
if (typeof value === "string") {
result[key] = value;
}
}
return result;
} catch {
return {};
function parseGeminiCommonConfig(snippet: string): {
env: Record<string, string>;
warning?: string;
} {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: false, // Don't fail, just filter
});
if (result.error) {
console.warn(
"[ProviderForm] Gemini common config parse error:",
result.error,
);
return { env: {} };
}
return { env: result.env, warning: result.warning };
}
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
@@ -973,9 +974,13 @@ export function ProviderForm({
if (useGeminiCommonConfigFlag && geminiCommonConfigSnippet.trim()) {
// Parse common config as JSON (flat {"KEY": "VALUE"} format)
// Note: geminiCommonConfigSnippet is stored as JSON by useGeminiCommonConfig hook
const commonEnvObj = parseGeminiCommonConfig(
const { env: commonEnvObj, warning } = parseGeminiCommonConfig(
geminiCommonConfigSnippet.trim(),
);
// Show warning toast if keys were filtered
if (warning) {
toast.warning(warning);
}
if (isPlainObject(envObj) && isPlainObject(commonEnvObj)) {
const { customConfig } = extractDifference(envObj, commonEnvObj);
// Convert to string record with type guard to avoid type assertion issues
@@ -3,14 +3,10 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { configApi } from "@/lib/api";
import {
GEMINI_COMMON_ENV_FORBIDDEN_KEYS,
type GeminiForbiddenEnvKey,
parseGeminiCommonConfigSnippet,
GEMINI_CONFIG_ERROR_CODES,
} from "@/utils/providerConfigUtils";
import {
computeFinalConfig,
extractDifference,
isPlainObject,
} from "@/utils/configMerge";
import { computeFinalConfig, extractDifference } from "@/utils/configMerge";
import type { ProviderMeta } from "@/types";
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
@@ -131,54 +127,38 @@ export function useGeminiCommonConfig({
hasInitializedEditMode.current = false;
}, [selectedPresetId]);
// 解析通用配置片段
// 解析通用配置片段 - 使用共享解析器
// 支持三种格式: ENV (KEY=VALUE), 扁平 JSON, 包裹 JSON {"env":{...}}
const parseSnippetEnv = useCallback(
(
snippetString: string,
): { env: Record<string, string>; error?: string } => {
const trimmed = snippetString.trim();
if (!trimmed) {
return { env: {} };
}
const result = parseGeminiCommonConfigSnippet(snippetString, {
strictForbiddenKeys: true,
});
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
if (!isPlainObject(parsed)) {
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
const keys = Object.keys(parsed);
const forbiddenKeys = keys.filter((key) =>
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
);
if (forbiddenKeys.length > 0) {
return {
env: {},
error: t("geminiConfig.commonConfigInvalidKeys", {
keys: forbiddenKeys.join(", "),
}),
};
}
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== "string") {
if (result.error) {
// Map error codes to i18n keys
if (result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
const keys = result.error.split(": ")[1] ?? result.error;
return {
env: {},
error: t("geminiConfig.commonConfigInvalidKeys", { keys }),
};
}
if (
result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING)
) {
return {
env: {},
error: t("geminiConfig.commonConfigInvalidValues"),
};
}
const normalized = value.trim();
if (!normalized) continue;
env[key] = normalized;
// Generic format error (NOT_OBJECT, ENV_NOT_OBJECT, or parse failure)
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
return { env };
return { env: result.env };
},
[t],
);
+174 -13
View File
@@ -2,6 +2,7 @@
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
import { normalizeQuotes } from "@/utils/textNormalization";
import { isPlainObject } from "@/utils/configMerge";
// Gemini 通用配置禁止的键(共享常量,供 hook 和同步逻辑复用)
export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
@@ -11,10 +12,6 @@ export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
export type GeminiForbiddenEnvKey =
(typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
const deepMerge = (
target: Record<string, any>,
source: Record<string, any>,
@@ -192,15 +189,20 @@ export const hasGeminiCommonConfigSnippet = (
const parsed = JSON.parse(snippetString);
if (!isPlainObject(parsed)) return false;
const entries = Object.entries(parsed).filter(([key, value]) => {
if (
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey)
) {
return false;
}
if (typeof value !== "string") return false;
return value.trim().length > 0;
});
const entries = Object.entries(parsed).filter(
(entry): entry is [string, string] => {
const [key, value] = entry;
if (
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(
key as GeminiForbiddenEnvKey,
)
) {
return false;
}
if (typeof value !== "string") return false;
return value.trim().length > 0;
},
);
if (entries.length === 0) return false;
@@ -1040,3 +1042,162 @@ export const setCodexModelName = (
const lines = normalizedText.split("\n");
return `${replacementLine}\n${lines.join("\n")}`;
};
// ============================================================================
// Gemini Common Config Parsing Utilities
// ============================================================================
/**
* Error codes for Gemini common config parsing.
* These codes are used for consistent error handling and i18n mapping.
*/
export const GEMINI_CONFIG_ERROR_CODES = {
NOT_OBJECT: "GEMINI_CONFIG_NOT_OBJECT",
ENV_NOT_OBJECT: "GEMINI_CONFIG_ENV_NOT_OBJECT",
VALUE_NOT_STRING: "GEMINI_CONFIG_VALUE_NOT_STRING",
FORBIDDEN_KEYS: "GEMINI_CONFIG_FORBIDDEN_KEYS",
} as const;
/**
* Result of parsing Gemini common config snippet
*/
export interface GeminiCommonConfigParseResult {
/** Parsed env key-value pairs (empty if invalid) */
env: Record<string, string>;
/** Error message if parsing/validation failed (starts with error code) */
error?: string;
/** Warning message (non-fatal, config still usable) */
warning?: string;
}
/**
* Parse Gemini common config snippet with full validation.
*
* Supports three formats:
* - ENV format: KEY=VALUE lines (one per line, # for comments)
* - Flat JSON: {"KEY": "VALUE", ...}
* - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
*
* Validation rules:
* - Forbidden keys (GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY) are rejected
* - Non-string values are rejected
* - Empty string values are filtered out
* - Arrays and non-plain objects are rejected
*
* @param snippet - The common config snippet string
* @param options - Optional configuration
* @returns Parse result with env, error, and warning
*/
export function parseGeminiCommonConfigSnippet(
snippet: string,
options?: {
/** If true, reject forbidden keys with error; otherwise filter them with warning */
strictForbiddenKeys?: boolean;
},
): GeminiCommonConfigParseResult {
const trimmed = snippet.trim();
if (!trimmed) {
return { env: {} };
}
const strictForbiddenKeys = options?.strictForbiddenKeys ?? true;
let rawEnv: Record<string, unknown> = {};
let isJson = false;
// Try JSON first
try {
const parsed = JSON.parse(trimmed);
// Must be a plain object (not array, null, etc.)
if (!isPlainObject(parsed)) {
return {
env: {},
error: `${GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT}: must be a JSON object, not array or primitive`,
};
}
isJson = true;
// Check if wrapped format {"env": {...}}
if ("env" in parsed) {
const envField = parsed.env;
if (!isPlainObject(envField)) {
return {
env: {},
error: `${GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT}: 'env' field must be a plain object`,
};
}
rawEnv = envField as Record<string, unknown>;
} else {
// Flat format
rawEnv = parsed as Record<string, unknown>;
}
} catch {
// Not JSON, parse as ENV format (KEY=VALUE lines)
isJson = false;
for (const line of trimmed.split("\n")) {
const lineTrimmed = line.trim();
if (!lineTrimmed || lineTrimmed.startsWith("#")) continue;
const equalIndex = lineTrimmed.indexOf("=");
if (equalIndex > 0) {
const key = lineTrimmed.substring(0, equalIndex).trim();
// Strip surrounding quotes (single or double) from value
// e.g., KEY="value" or KEY='value' -> value
const rawValue = lineTrimmed.substring(equalIndex + 1).trim();
const value = rawValue.replace(/^["'](.*)["']$/, "$1");
if (key) {
rawEnv[key] = value;
}
}
}
}
// Validate and filter entries
const env: Record<string, string> = {};
const warnings: string[] = [];
const forbiddenKeysFound: string[] = [];
for (const [key, value] of Object.entries(rawEnv)) {
// Check forbidden keys
if (
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey)
) {
forbiddenKeysFound.push(key);
continue;
}
// Must be string
if (typeof value !== "string") {
if (isJson) {
return {
env: {},
error: `${GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING}: value for '${key}' must be a string, got ${typeof value}`,
};
}
// For ENV format, skip non-strings silently (shouldn't happen)
continue;
}
// Filter empty strings
const trimmedValue = value.trim();
if (!trimmedValue) {
continue;
}
env[key] = trimmedValue;
}
// Handle forbidden keys
if (forbiddenKeysFound.length > 0) {
const msg = `${GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS}: ${forbiddenKeysFound.join(", ")}`;
if (strictForbiddenKeys) {
return { env: {}, error: msg };
}
warnings.push(msg);
}
return {
env,
warning: warnings.length > 0 ? warnings.join("; ") : undefined,
};
}