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 warning without logging - caller will log if needed
return MergeResult { return MergeResult {
config: custom_config.clone(), 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); compute_final_toml_config_str(config_str, common_snippet, true);
if let Some(e) = error { if let Some(e) = error {
// Return warning without logging - caller will log if needed // 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)); 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). /// Merge Gemini config (JSON format for env field).
/// ///
/// Gemini common config can be stored in two formats: /// Gemini common config can be stored in three formats:
/// - Wrapped: `{"env": {"KEY": "VALUE", ...}}` (matches provider settings_config structure) /// - ENV format: KEY=VALUE lines (one per line)
/// - Flat: `{"KEY": "VALUE", ...}` (simpler format used by frontend) /// - 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 { fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
let common_value: JsonValue = match serde_json::from_str(common_snippet) { // Parse common config (try JSON first, then ENV format)
Ok(v) => v, let common_env = parse_gemini_common_snippet(common_snippet);
Err(e) => {
// Return warning without logging - caller will log if needed if common_env.is_empty() {
return MergeResult { return MergeResult {
config: custom_config.clone(), config: custom_config.clone(),
warning: Some(format!("Gemini common config parse error: {e}")), warning: None,
}; };
} }
};
let mut merged_config = custom_config.clone(); 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 // Merge only the env field
// If custom config has env, merge common into it; otherwise initialize with common env // If custom config has env, merge common into it; otherwise initialize with common env
if let Some(merged_obj) = merged_config.as_object_mut() { 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 // Unit Tests
// ============================================================================ // ============================================================================
+7 -4
View File
@@ -167,15 +167,18 @@ fn write_live_snapshot_internal(
} }
AppType::Codex => { AppType::Codex => {
let obj = config_to_write.as_object().ok_or_else(|| { 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(|| { 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(|| { let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
AppError::Config( AppError::Config(
"Codex provider settings_config missing 'config' field or not a string" "CODEX_CONFIG_MISSING_CONFIG: settings_config missing 'config' field or not a string".to_string(),
.to_string(),
) )
})?; })?;
+36 -30
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save } from "lucide-react"; import { Save } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { FullScreenPanel } from "@/components/common/FullScreenPanel";
@@ -11,6 +12,7 @@ import {
import { providersApi, vscodeApi, configApi, type AppId } from "@/lib/api"; import { providersApi, vscodeApi, configApi, type AppId } from "@/lib/api";
import { extractDifference, isPlainObject } from "@/utils/configMerge"; import { extractDifference, isPlainObject } from "@/utils/configMerge";
import { extractTomlDifference } from "@/utils/tomlConfigMerge"; import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import { parseGeminiCommonConfigSnippet } from "@/utils/providerConfigUtils";
interface EditProviderDialogProps { interface EditProviderDialogProps {
open: boolean; open: boolean;
@@ -114,42 +116,46 @@ export function EditProviderDialog({
setLiveSettings(live); setLiveSettings(live);
} }
} else if (appId === "gemini") { } 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", ...} // - Flat JSON: {"KEY": "VALUE", ...}
// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}} // - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
const liveEnv = const liveEnv =
(live as { env?: Record<string, string> }).env ?? {}; (live as { env?: Record<string, string> }).env ?? {};
// Parse common config as JSON with format detection
const parsedSnippet = JSON.parse( // Use shared parser with validation
commonSnippet.trim(), const parseResult = parseGeminiCommonConfigSnippet(
) as Record<string, unknown>; commonSnippet,
// Check if wrapped format {"env": {...}} { strictForbiddenKeys: false },
const commonEnvRaw = );
parsedSnippet.env &&
typeof parsedSnippet.env === "object" if (parseResult.error) {
? (parsedSnippet.env as Record<string, unknown>) console.warn(
: parsedSnippet; "[EditProviderDialog] Gemini common config parse error:",
// Convert to string record, filtering out non-string values parseResult.error,
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,
); );
setLiveSettings({
...live,
env: customConfig,
});
} else {
setLiveSettings(live); 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 { } else {
// Claude: 处理 JSON 格式 // Claude: 处理 JSON 格式
+28 -23
View File
@@ -38,6 +38,7 @@ import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import { extractDifference, isPlainObject } from "@/utils/configMerge"; import { extractDifference, isPlainObject } from "@/utils/configMerge";
import { extractTomlDifference } from "@/utils/tomlConfigMerge"; import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import { parseGeminiCommonConfigSnippet } from "@/utils/providerConfigUtils";
import { getCodexCustomTemplate } from "@/config/codexTemplates"; import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor"; import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor"; import { CommonConfigEditor } from "./CommonConfigEditor";
@@ -68,33 +69,33 @@ import {
import { useProvidersQuery } from "@/lib/query/queries"; import { useProvidersQuery } from "@/lib/query/queries";
/** /**
* Parse Gemini common config snippet. * Parse Gemini common config snippet for difference extraction.
* Supports two formats: * 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", ...} * - Flat JSON: {"KEY": "VALUE", ...}
* - Wrapped JSON: {"env": {"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> { function parseGeminiCommonConfig(snippet: string): {
try { env: Record<string, string>;
const parsed = JSON.parse(snippet); warning?: string;
if (!parsed || typeof parsed !== "object") { } {
return {}; const result = parseGeminiCommonConfigSnippet(snippet, {
} strictForbiddenKeys: false, // Don't fail, just filter
// Check if it's wrapped format {"env": {...}} });
const envObj =
parsed.env && typeof parsed.env === "object" ? parsed.env : parsed; if (result.error) {
// Convert to string record console.warn(
const result: Record<string, string> = {}; "[ProviderForm] Gemini common config parse error:",
for (const [key, value] of Object.entries(envObj)) { result.error,
if (typeof value === "string") { );
result[key] = value; return { env: {} };
}
}
return result;
} catch {
return {};
} }
return { env: result.env, warning: result.warning };
} }
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2); const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
@@ -973,9 +974,13 @@ export function ProviderForm({
if (useGeminiCommonConfigFlag && geminiCommonConfigSnippet.trim()) { if (useGeminiCommonConfigFlag && geminiCommonConfigSnippet.trim()) {
// Parse common config as JSON (flat {"KEY": "VALUE"} format) // Parse common config as JSON (flat {"KEY": "VALUE"} format)
// Note: geminiCommonConfigSnippet is stored as JSON by useGeminiCommonConfig hook // Note: geminiCommonConfigSnippet is stored as JSON by useGeminiCommonConfig hook
const commonEnvObj = parseGeminiCommonConfig( const { env: commonEnvObj, warning } = parseGeminiCommonConfig(
geminiCommonConfigSnippet.trim(), geminiCommonConfigSnippet.trim(),
); );
// Show warning toast if keys were filtered
if (warning) {
toast.warning(warning);
}
if (isPlainObject(envObj) && isPlainObject(commonEnvObj)) { if (isPlainObject(envObj) && isPlainObject(commonEnvObj)) {
const { customConfig } = extractDifference(envObj, commonEnvObj); const { customConfig } = extractDifference(envObj, commonEnvObj);
// Convert to string record with type guard to avoid type assertion issues // 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 { toast } from "sonner";
import { configApi } from "@/lib/api"; import { configApi } from "@/lib/api";
import { import {
GEMINI_COMMON_ENV_FORBIDDEN_KEYS, parseGeminiCommonConfigSnippet,
type GeminiForbiddenEnvKey, GEMINI_CONFIG_ERROR_CODES,
} from "@/utils/providerConfigUtils"; } from "@/utils/providerConfigUtils";
import { import { computeFinalConfig, extractDifference } from "@/utils/configMerge";
computeFinalConfig,
extractDifference,
isPlainObject,
} from "@/utils/configMerge";
import type { ProviderMeta } from "@/types"; import type { ProviderMeta } from "@/types";
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet"; const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
@@ -131,54 +127,38 @@ export function useGeminiCommonConfig({
hasInitializedEditMode.current = false; hasInitializedEditMode.current = false;
}, [selectedPresetId]); }, [selectedPresetId]);
// 解析通用配置片段 // 解析通用配置片段 - 使用共享解析器
// 支持三种格式: ENV (KEY=VALUE), 扁平 JSON, 包裹 JSON {"env":{...}}
const parseSnippetEnv = useCallback( const parseSnippetEnv = useCallback(
( (
snippetString: string, snippetString: string,
): { env: Record<string, string>; error?: string } => { ): { env: Record<string, string>; error?: string } => {
const trimmed = snippetString.trim(); const result = parseGeminiCommonConfigSnippet(snippetString, {
if (!trimmed) { strictForbiddenKeys: true,
return { env: {} }; });
}
let parsed: unknown; if (result.error) {
try { // Map error codes to i18n keys
parsed = JSON.parse(trimmed); if (result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
} catch { const keys = result.error.split(": ")[1] ?? result.error;
return { env: {}, error: t("geminiConfig.invalidJsonFormat") }; return {
} env: {},
error: t("geminiConfig.commonConfigInvalidKeys", { keys }),
if (!isPlainObject(parsed)) { };
return { env: {}, error: t("geminiConfig.invalidJsonFormat") }; }
} if (
result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING)
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") {
return { return {
env: {}, env: {},
error: t("geminiConfig.commonConfigInvalidValues"), error: t("geminiConfig.commonConfigInvalidValues"),
}; };
} }
const normalized = value.trim(); // Generic format error (NOT_OBJECT, ENV_NOT_OBJECT, or parse failure)
if (!normalized) continue; return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
env[key] = normalized;
} }
return { env }; return { env: result.env };
}, },
[t], [t],
); );
+174 -13
View File
@@ -2,6 +2,7 @@
import type { TemplateValueConfig } from "../config/claudeProviderPresets"; import type { TemplateValueConfig } from "../config/claudeProviderPresets";
import { normalizeQuotes } from "@/utils/textNormalization"; import { normalizeQuotes } from "@/utils/textNormalization";
import { isPlainObject } from "@/utils/configMerge";
// Gemini 通用配置禁止的键(共享常量,供 hook 和同步逻辑复用) // Gemini 通用配置禁止的键(共享常量,供 hook 和同步逻辑复用)
export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [ export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
@@ -11,10 +12,6 @@ export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
export type GeminiForbiddenEnvKey = export type GeminiForbiddenEnvKey =
(typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number]; (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 = ( const deepMerge = (
target: Record<string, any>, target: Record<string, any>,
source: Record<string, any>, source: Record<string, any>,
@@ -192,15 +189,20 @@ export const hasGeminiCommonConfigSnippet = (
const parsed = JSON.parse(snippetString); const parsed = JSON.parse(snippetString);
if (!isPlainObject(parsed)) return false; if (!isPlainObject(parsed)) return false;
const entries = Object.entries(parsed).filter(([key, value]) => { const entries = Object.entries(parsed).filter(
if ( (entry): entry is [string, string] => {
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey) const [key, value] = entry;
) { if (
return false; GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(
} key as GeminiForbiddenEnvKey,
if (typeof value !== "string") return false; )
return value.trim().length > 0; ) {
}); return false;
}
if (typeof value !== "string") return false;
return value.trim().length > 0;
},
);
if (entries.length === 0) return false; if (entries.length === 0) return false;
@@ -1040,3 +1042,162 @@ export const setCodexModelName = (
const lines = normalizedText.split("\n"); const lines = normalizedText.split("\n");
return `${replacementLine}\n${lines.join("\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,
};
}