From 0ef8c127c551c50a7e4b20f284e142fd4493a81a Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sat, 31 Jan 2026 20:39:28 +0800 Subject: [PATCH] fix(common-config): improve error handling, layer separation, and reliability across sync system P0 Fixes: - Enable toml `preserve_order` feature for reliable key ordering - Extract pure detection functions to utils/commonConfigDetection.ts (fix layer inversion) - Return structured result from preserveCodexConfigFormat to prevent data loss P1 Fixes: - Handle pure TOML strings in Codex config instead of silent skip - Surface extractDiff errors in useCodexCommonConfig instead of swallowing - Log parse errors in detectCommonConfigEnabledByContent for debugging P2 Fixes: - Use parsing-based assertions in Rust TOML ordering tests - Add schema warning when Codex config field is wrong type - Add depth/node limits to isSubset function (prevent stack overflow) - Replace hardcoded "{}" check with adapter.hasValidContent() P3 Fixes: - Refactor Gemini errors to structured GeminiConfigErrorInfo format - Add mapGeminiErrorToI18n for type-safe error-to-i18n mapping --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 2 +- src-tauri/src/config_merge.rs | 31 ++- .../forms/hooks/useCodexCommonConfig.ts | 47 +++- src/hooks/commonConfigAdapters.ts | 169 ++++++------ src/hooks/useCommonConfigBase.ts | 6 +- src/lib/api/config.ts | 38 ++- src/utils/commonConfigDetection.ts | 245 ++++++++++++++++++ src/utils/configMerge.ts | 34 ++- src/utils/providerConfigUtils.ts | 157 +++++++++-- 10 files changed, 576 insertions(+), 154 deletions(-) create mode 100644 src/utils/commonConfigDetection.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c2e244f5f..8f0a0751e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5633,6 +5633,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ + "indexmap 2.11.4", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f79966dcd..e4b134f76 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,7 +35,7 @@ tauri-plugin-dialog = "2" tauri-plugin-store = "2" tauri-plugin-deep-link = "2" dirs = "5.0" -toml = "0.8" +toml = { version = "0.8", features = ["preserve_order"] } toml_edit = "0.22" reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } diff --git a/src-tauri/src/config_merge.rs b/src-tauri/src/config_merge.rs index 032ef3ee4..3e1fbcf5c 100644 --- a/src-tauri/src/config_merge.rs +++ b/src-tauri/src/config_merge.rs @@ -837,17 +837,32 @@ shared_key = "shared" assert!(result.contains("custom-model")); // custom wins assert!(result.contains("shared_key")); // from common - // Verify ordering: custom keys appear before common-only keys - let model_pos = result.find("model").unwrap(); - let custom_section_pos = result.find("custom_section").unwrap(); - let shared_key_pos = result.find("shared_key").unwrap(); + // With preserve_order feature enabled, verify key ordering via parsing + // instead of relying on string position (which is fragile) + let parsed: TomlValue = result.parse().expect("result should be valid TOML"); + let table = parsed.as_table().expect("result should be a table"); + let keys: Vec<&String> = table.keys().collect(); + + // Custom keys should appear before common-only keys + let model_idx = keys.iter().position(|k| *k == "model"); + let custom_section_idx = keys.iter().position(|k| *k == "custom_section"); + let shared_key_idx = keys.iter().position(|k| *k == "shared_key"); + + assert!(model_idx.is_some(), "model key should exist in result"); assert!( - model_pos < shared_key_pos, - "custom 'model' should appear before common-only 'shared_key'" + custom_section_idx.is_some(), + "custom_section key should exist in result" ); assert!( - custom_section_pos < shared_key_pos || model_pos < shared_key_pos, - "custom keys should appear before common-only keys" + shared_key_idx.is_some(), + "shared_key key should exist in result" + ); + + // With preserve_order, custom keys (model, custom_section) should come before common-only keys (shared_key) + assert!( + model_idx.unwrap() < shared_key_idx.unwrap(), + "custom 'model' should appear before common-only 'shared_key' (got model_idx={:?}, shared_key_idx={:?})", + model_idx, shared_key_idx ); } diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts index 0e7c0d763..1c46036c4 100644 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -120,25 +120,48 @@ export function useCodexCommonConfig({ return; } - // 更新 snippet 状态(通过 base 的 handler) - base.handleCommonConfigSnippetChange(extracted); - // 从 config 中移除与 extracted 相同的部分 const customToml = codexAdapter.parseInput(codexConfig); const diffResult = extractTomlDifference(customToml, extracted); - if (!diffResult.error) { - // 使用共享的格式保留函数写回 - onConfigChange( - preserveCodexConfigFormat(codexConfig, diffResult.customToml), - ); - - toast.success( - t("codexConfig.extractSuccessNeedSave", { - defaultValue: "已提取通用配置,点击保存按钮完成保存", + if (diffResult.error) { + // 差异提取失败,显示错误而不是静默吞掉 + setLocalExtractError( + t("codexConfig.extractDiffFailed", { + error: diffResult.error, + defaultValue: `差异提取失败: ${diffResult.error}`, }), ); + return; } + + // 更新 snippet 状态(在差异提取成功后再更新,避免状态不一致) + base.handleCommonConfigSnippetChange(extracted); + + // 使用共享的格式保留函数写回 + const preserveResult = preserveCodexConfigFormat( + codexConfig, + diffResult.customToml, + ); + + if (preserveResult.error) { + // 格式保留失败,显示错误 + setLocalExtractError( + t("codexConfig.preserveFormatFailed", { + error: preserveResult.error, + defaultValue: `配置格式保留失败: ${preserveResult.error}`, + }), + ); + return; + } + + onConfigChange(preserveResult.config); + + toast.success( + t("codexConfig.extractSuccessNeedSave", { + defaultValue: "已提取通用配置,点击保存按钮完成保存", + }), + ); } catch (error) { console.error("提取 Codex 通用配置失败:", error); setLocalExtractError( diff --git a/src/hooks/commonConfigAdapters.ts b/src/hooks/commonConfigAdapters.ts index 9e65b7f81..0a3532244 100644 --- a/src/hooks/commonConfigAdapters.ts +++ b/src/hooks/commonConfigAdapters.ts @@ -25,6 +25,10 @@ import type { ExtractResult, } from "./useCommonConfigBase"; +// Re-export from utils for backward compatibility +export { hasContentByAppType } from "@/utils/commonConfigDetection"; +import { hasGeminiContent } from "@/utils/commonConfigDetection"; + // ============================================================================ // Claude Adapter (JSON) // ============================================================================ @@ -163,20 +167,30 @@ function validateTomlFormat(tomlText: string): string | null { * 支持两种格式: * 1. 直接的 TOML 字符串 * 2. JSON 字符串 { auth: {...}, config: "TOML" } + * + * 注意:如果 config 字段存在但不是 string,这表示 schema 异常 + * 此时返回空字符串并打印警告,避免静默处理错误数据 */ function extractConfigToml(configInput: string): string { if (!configInput || !configInput.trim()) { return ""; } - // 尝试解析为 JSON(旧格式) + // 尝试解析为 JSON(wrapper 格式) try { const parsed = JSON.parse(configInput); if (typeof parsed?.config === "string") { return parsed.config; } - // 如果是 JSON 对象但没有 config 字段,返回空 + // 如果是 JSON 对象 if (typeof parsed === "object" && parsed !== null) { + // config 字段存在但不是 string,这是 schema 异常 + if ("config" in parsed && typeof parsed.config !== "string") { + console.warn( + `[extractConfigToml] config field is ${typeof parsed.config}, expected string`, + ); + } + // JSON 对象没有有效的 config 字段,返回空(无 TOML 可提取) return ""; } } catch { @@ -192,13 +206,15 @@ function extractConfigToml(configInput: string): string { */ function detectJsonWrapperFormat( codexConfig: string, -): { auth?: unknown; config?: string } | null { +): { auth?: unknown; config?: unknown; [key: string]: unknown } | null { try { const parsed = JSON.parse(codexConfig); - if (typeof parsed?.config === "string") { - return parsed; - } - if (typeof parsed === "object" && parsed !== null) { + // 只有当解析结果是对象时才认为是 JSON wrapper + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { return parsed; } } catch { @@ -207,6 +223,16 @@ function detectJsonWrapperFormat( return null; } +/** + * Codex 配置格式保留结果 + */ +export interface PreserveCodexConfigResult { + /** 格式化后的配置字符串 */ + config: string; + /** 错误信息(如果有) */ + error?: string; +} + /** * 保留原始格式写回 Codex 配置 * @@ -215,20 +241,39 @@ function detectJsonWrapperFormat( * * @param originalConfig - 原始配置字符串 * @param updatedToml - 更新后的 TOML 内容 - * @returns 格式化后的配置字符串 + * @returns 格式化后的配置字符串和可能的错误 */ export function preserveCodexConfigFormat( originalConfig: string, updatedToml: string, -): string { +): PreserveCodexConfigResult { const jsonWrapper = detectJsonWrapperFormat(originalConfig); - if (jsonWrapper && typeof jsonWrapper.config === "string") { - // JSON wrapper 格式,更新 config 字段 - jsonWrapper.config = updatedToml; - return JSON.stringify(jsonWrapper, null, 2); + + if (jsonWrapper) { + // 是 JSON wrapper 格式 + if ("config" in jsonWrapper) { + // 存在 config 字段 + if (typeof jsonWrapper.config !== "string") { + // config 字段不是 string,这是意外的 schema + // 返回原配置并报错,避免数据丢失 + return { + config: originalConfig, + error: `Codex config field is ${typeof jsonWrapper.config}, expected string. Cannot update safely.`, + }; + } + // 正常更新 config 字段 + jsonWrapper.config = updatedToml; + return { config: JSON.stringify(jsonWrapper, null, 2) }; + } else { + // 没有 config 字段,但是是 JSON 对象(可能包含 auth 等其他字段) + // 添加 config 字段 + jsonWrapper.config = updatedToml; + return { config: JSON.stringify(jsonWrapper, null, 2) }; + } } + // 纯 TOML 格式 - return updatedToml; + return { config: updatedToml }; } export const codexAdapter: CommonConfigAdapter = { @@ -353,7 +398,7 @@ export function createGeminiAdapter( }, hasContent: (configStr: string, snippetStr: string): boolean => { - return geminiHasContentStatic(configStr, snippetStr); + return hasGeminiContent(configStr, snippetStr).hasContent; }, getApplyError: (snippet: string, t): string => { @@ -361,16 +406,24 @@ export function createGeminiAdapter( strictForbiddenKeys: true, }); + if (result.errorInfo) { + // Use structured error info for type-safe handling + switch (result.errorInfo.code) { + case GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS: + return t("geminiConfig.commonConfigInvalidKeys", { + keys: result.errorInfo.keys?.join(", ") ?? "", + }); + case GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING: + return t("geminiConfig.commonConfigInvalidValues"); + default: + return t("geminiConfig.invalidEnvFormat", { + defaultValue: "配置格式错误", + }); + } + } + + // Fallback for legacy error string (backward compatibility) if (result.error) { - if (result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) { - const keys = result.error.split(": ")[1] ?? result.error; - return t("geminiConfig.commonConfigInvalidKeys", { keys }); - } - if ( - result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING) - ) { - return t("geminiConfig.commonConfigInvalidValues"); - } return t("geminiConfig.invalidEnvFormat", { defaultValue: "配置格式错误", }); @@ -449,71 +502,3 @@ export function createGeminiAdapter( }, }; } - -// ============================================================================ -// 静态 hasContent 检查(用于 config.ts 同步检测) -// ============================================================================ - -/** - * 检查配置是否包含通用配置片段(按 appType 分发) - * - * 用于 config.ts 中的 detectCommonConfigEnabledByContent, - * 替代原有的三个独立函数(hasCommonConfigSnippet, hasTomlCommonConfigSnippet, hasGeminiCommonConfigSnippet) - * - * @param appType - 应用类型 - * @param configStr - 供应商的 settingsConfig 字符串 - * @param snippetStr - 通用配置片段字符串 - * @returns 是否包含片段内容 - */ -export function hasContentByAppType( - appType: "claude" | "codex" | "gemini", - configStr: string, - snippetStr: string, -): boolean { - switch (appType) { - case "claude": - return claudeAdapter.hasContent(configStr, snippetStr); - case "codex": - return codexAdapter.hasContent(configStr, snippetStr); - case "gemini": - // Gemini 使用静态实现(不需要 envStringToObj/envObjToString) - return geminiHasContentStatic(configStr, snippetStr); - default: - return false; - } -} - -/** - * Gemini hasContent 的静态实现(不依赖 adapter options) - */ -function geminiHasContentStatic( - configStr: string, - snippetStr: string, -): boolean { - try { - if (!snippetStr.trim()) return false; - - const config = configStr ? JSON.parse(configStr) : {}; - if (!isPlainObject(config)) return false; - const envValue = (config as Record).env; - if (envValue !== undefined && !isPlainObject(envValue)) return false; - const env = (isPlainObject(envValue) ? envValue : {}) as Record< - string, - unknown - >; - - const parseResult = parseGeminiCommonConfigSnippet(snippetStr, { - strictForbiddenKeys: false, - }); - if (parseResult.error || Object.keys(parseResult.env).length === 0) { - return false; - } - - return Object.entries(parseResult.env).every(([key, value]) => { - const current = env[key]; - return typeof current === "string" && current === value.trim(); - }); - } catch { - return false; - } -} diff --git a/src/hooks/useCommonConfigBase.ts b/src/hooks/useCommonConfigBase.ts index 415b96678..d5f739564 100644 --- a/src/hooks/useCommonConfigBase.ts +++ b/src/hooks/useCommonConfigBase.ts @@ -407,7 +407,11 @@ export function useCommonConfigBase({ request, ); - if (!extracted || extracted === "{}" || !extracted.trim()) { + if ( + !extracted || + !extracted.trim() || + !adapter.hasValidContent(extracted) + ) { setCommonConfigError( t(`${adapter.appKey}Config.extractNoCommonConfig`, { defaultValue: "无法提取通用配置", diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts index 48f7f1bdb..27d138944 100644 --- a/src/lib/api/config.ts +++ b/src/lib/api/config.ts @@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { Provider } from "@/types"; import { providersApi } from "./providers"; -import { hasContentByAppType } from "@/hooks/commonConfigAdapters"; +import { detectContent } from "@/utils/commonConfigDetection"; export type AppType = "claude" | "codex" | "gemini"; @@ -354,10 +354,21 @@ function detectCommonConfigEnabledByContent( ); if (candidates.length === 0) return false; - // 使用统一的 adapter-based hasContent 检查 - return candidates.some((snippet) => - hasContentByAppType(appType, settingsConfigStr, snippet), - ); + // 使用 detectContent 获取完整结果(包含解析错误信息) + for (const snippet of candidates) { + const result = detectContent(appType, settingsConfigStr, snippet); + if (result.hasContent) { + return true; + } + // 如果解析失败,记录错误(便于排障) + if (result.parseError) { + console.warn( + `[detectCommonConfigEnabledByContent] Parse error for ${appType}: ${result.parseError}`, + ); + } + } + + return false; } /** 更新供应商配置的结果 */ @@ -373,7 +384,8 @@ function getSettingsConfigString( provider: Provider, appType: AppType, ): string | null { - const config = provider.settingsConfig; + // Runtime type may be string (JSON-serialized) despite Record declaration + const config = provider.settingsConfig as Record | string; if (!config) return null; switch (appType) { @@ -385,8 +397,7 @@ function getSettingsConfigString( // Codex: settingsConfig.config 是 TOML 字符串 // 先校验 settingsConfig 是对象类型 if (typeof config === "string") { - // settingsConfig 是字符串(可能是之前保存失败的情况) - // 尝试解析为 JSON + // settingsConfig 是字符串,尝试解析为 JSON try { const parsed = JSON.parse(config); if ( @@ -396,10 +407,15 @@ function getSettingsConfigString( ) { return parsed.config; } + // JSON 对象但没有 string config 字段 + // 可能是历史数据或异常保存的情况 + return null; } catch { - console.warn( - `[getSettingsConfigString] Codex provider settingsConfig 是无效字符串,跳过`, - ); + // JSON 解析失败,可能是纯 TOML 字符串 + // 返回原字符串当 TOML 处理,而不是静默跳过 + if (config.trim()) { + return config; + } } return null; } diff --git a/src/utils/commonConfigDetection.ts b/src/utils/commonConfigDetection.ts new file mode 100644 index 000000000..8b113abe7 --- /dev/null +++ b/src/utils/commonConfigDetection.ts @@ -0,0 +1,245 @@ +/** + * 通用配置内容检测工具 + * + * 这些是纯函数,不依赖 React/hooks,可以安全地被 lib/api 层使用。 + * 解决层级反转问题:lib/api 不应依赖 hooks 层。 + */ + +import { isPlainObject, isSubset } from "@/utils/configMerge"; +import { safeParseToml } from "@/utils/tomlConfigMerge"; +import { + parseGeminiCommonConfigSnippet, + GEMINI_COMMON_ENV_FORBIDDEN_KEYS, +} from "@/utils/providerConfigUtils"; + +// ============================================================================ +// 类型定义 +// ============================================================================ + +export type CommonConfigAppType = "claude" | "codex" | "gemini"; + +/** + * 内容检测结果 + */ +export interface ContentDetectionResult { + /** 是否包含通用配置内容 */ + hasContent: boolean; + /** 检测失败原因(如果有) */ + parseError?: string; +} + +// ============================================================================ +// Claude 内容检测 +// ============================================================================ + +/** + * 检查 Claude 配置是否包含通用配置片段 + */ +export function hasClaudeContent( + configStr: string, + snippetStr: string, +): ContentDetectionResult { + try { + if (!snippetStr.trim()) { + return { hasContent: false }; + } + const config = configStr ? JSON.parse(configStr) : {}; + const snippet = JSON.parse(snippetStr); + if (!isPlainObject(snippet)) { + return { hasContent: false, parseError: "snippet is not a plain object" }; + } + return { hasContent: isSubset(config, snippet) }; + } catch (e) { + return { + hasContent: false, + parseError: e instanceof Error ? e.message : String(e), + }; + } +} + +// ============================================================================ +// Codex 内容检测 +// ============================================================================ + +/** + * 从 Codex 配置中提取 TOML 配置字符串 + * 支持两种格式:纯 TOML 或 JSON wrapper { config: "TOML" } + */ +export function extractCodexConfigToml(configInput: string): { + toml: string; + parseError?: string; +} { + if (!configInput || !configInput.trim()) { + return { toml: "" }; + } + + // 尝试解析为 JSON(wrapper 格式) + try { + const parsed = JSON.parse(configInput); + if (typeof parsed?.config === "string") { + return { toml: parsed.config }; + } + // 是 JSON 对象但没有 string config 字段 + // 这可能是未知 schema,返回原字符串当 TOML 处理 + if (typeof parsed === "object" && parsed !== null) { + // 如果有 config 但不是 string,返回错误 + if ("config" in parsed && typeof parsed.config !== "string") { + return { + toml: "", + parseError: `config field exists but is ${typeof parsed.config}, expected string`, + }; + } + // 没有 config 字段的 JSON 对象,可能是纯 JSON 配置 + // 返回空,因为无法当 TOML 处理 + return { toml: "" }; + } + } catch { + // JSON 解析失败,说明是直接的 TOML 字符串 + } + + return { toml: configInput }; +} + +/** + * 检查 Codex 配置是否包含通用配置片段 + */ +export function hasCodexContent( + configStr: string, + snippetStr: string, +): ContentDetectionResult { + if (!snippetStr.trim()) { + return { hasContent: false }; + } + + // 解析配置(可能是纯 TOML 或 JSON wrapper 格式) + const { toml: configToml, parseError: configError } = + extractCodexConfigToml(configStr); + if (configError) { + return { hasContent: false, parseError: configError }; + } + + const configParsed = safeParseToml(configToml); + const snippetParsed = safeParseToml(snippetStr); + + if (configParsed.error || !configParsed.config) { + return { + hasContent: false, + parseError: configParsed.error || "failed to parse config TOML", + }; + } + if (snippetParsed.error || !snippetParsed.config) { + return { + hasContent: false, + parseError: snippetParsed.error || "failed to parse snippet TOML", + }; + } + + // 使用 isSubset 检查 snippet 是否是 config 的子集 + return { hasContent: isSubset(configParsed.config, snippetParsed.config) }; +} + +// ============================================================================ +// Gemini 内容检测 +// ============================================================================ + +/** + * 检查 Gemini 配置是否包含通用配置片段 + */ +export function hasGeminiContent( + configStr: string, + snippetStr: string, +): ContentDetectionResult { + try { + if (!snippetStr.trim()) { + return { hasContent: false }; + } + + const config = configStr ? JSON.parse(configStr) : {}; + if (!isPlainObject(config)) { + return { hasContent: false, parseError: "config is not a plain object" }; + } + const envValue = (config as Record).env; + if (envValue !== undefined && !isPlainObject(envValue)) { + return { + hasContent: false, + parseError: "env field is not a plain object", + }; + } + const env = (isPlainObject(envValue) ? envValue : {}) as Record< + string, + unknown + >; + + const parseResult = parseGeminiCommonConfigSnippet(snippetStr, { + strictForbiddenKeys: false, + }); + if (parseResult.error) { + return { hasContent: false, parseError: parseResult.error }; + } + if (Object.keys(parseResult.env).length === 0) { + return { hasContent: false }; + } + + const hasContent = Object.entries(parseResult.env).every(([key, value]) => { + // 跳过禁用的键 + if (GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as any)) { + return true; + } + const current = env[key]; + return typeof current === "string" && current === value.trim(); + }); + + return { hasContent }; + } catch (e) { + return { + hasContent: false, + parseError: e instanceof Error ? e.message : String(e), + }; + } +} + +// ============================================================================ +// 统一入口 +// ============================================================================ + +/** + * 检查配置是否包含通用配置片段(按 appType 分发) + * + * 这是给 lib/api/config.ts 使用的入口函数。 + * 返回结构化结果,包含检测结果和可能的错误信息。 + * + * @param appType - 应用类型 + * @param configStr - 供应商的 settingsConfig 字符串 + * @param snippetStr - 通用配置片段字符串 + * @returns 检测结果 + */ +export function detectContent( + appType: CommonConfigAppType, + configStr: string, + snippetStr: string, +): ContentDetectionResult { + switch (appType) { + case "claude": + return hasClaudeContent(configStr, snippetStr); + case "codex": + return hasCodexContent(configStr, snippetStr); + case "gemini": + return hasGeminiContent(configStr, snippetStr); + default: + return { hasContent: false }; + } +} + +/** + * 简化版:仅返回 boolean(向后兼容) + * + * 注意:此函数会吞掉解析错误,仅用于向后兼容。 + * 新代码应使用 detectContent 获取完整结果。 + */ +export function hasContentByAppType( + appType: CommonConfigAppType, + configStr: string, + snippetStr: string, +): boolean { + return detectContent(appType, configStr, snippetStr).hasContent; +} diff --git a/src/utils/configMerge.ts b/src/utils/configMerge.ts index ba0431473..7ea03229c 100644 --- a/src/utils/configMerge.ts +++ b/src/utils/configMerge.ts @@ -65,18 +65,46 @@ export const deepEqual = (a: unknown, b: unknown): boolean => { /** * 检查 source 是否是 target 的子集 * 即 source 中的所有键值对都存在于 target 中 + * + * 注意:此函数有深度和节点数限制,避免大对象导致的性能问题或栈溢出 */ -export const isSubset = (target: unknown, source: unknown): boolean => { +const IS_SUBSET_MAX_DEPTH = 20; +const IS_SUBSET_MAX_NODES = 10000; + +export const isSubset = ( + target: unknown, + source: unknown, + _depth: number = 0, + _nodeCounter: { count: number } = { count: 0 }, +): boolean => { + // 超过最大深度或节点数限制,返回 false(保守判断) + if (_depth > IS_SUBSET_MAX_DEPTH) { + console.warn( + `[isSubset] Max depth (${IS_SUBSET_MAX_DEPTH}) exceeded, returning false`, + ); + return false; + } + + _nodeCounter.count++; + if (_nodeCounter.count > IS_SUBSET_MAX_NODES) { + console.warn( + `[isSubset] Max nodes (${IS_SUBSET_MAX_NODES}) exceeded, returning false`, + ); + return false; + } + if (isPlainObject(source)) { if (!isPlainObject(target)) return false; return Object.entries(source).every(([key, value]) => - isSubset(target[key], value), + isSubset(target[key], value, _depth + 1, _nodeCounter), ); } if (Array.isArray(source)) { if (!Array.isArray(target) || target.length !== source.length) return false; - return source.every((item, index) => isSubset(target[index], item)); + return source.every((item, index) => + isSubset(target[index], item, _depth + 1, _nodeCounter), + ); } return target === source; diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index a40d6e988..44fda02e7 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -327,22 +327,49 @@ export const setCodexModelName = ( * 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", + NOT_OBJECT: "NOT_OBJECT", + ENV_NOT_OBJECT: "ENV_NOT_OBJECT", + VALUE_NOT_STRING: "VALUE_NOT_STRING", + FORBIDDEN_KEYS: "FORBIDDEN_KEYS", } as const; +export type GeminiConfigErrorCode = + (typeof GEMINI_CONFIG_ERROR_CODES)[keyof typeof GEMINI_CONFIG_ERROR_CODES]; + +/** + * Structured error info for Gemini common config parsing. + * Replaces fragile string prefix matching with type-safe error handling. + */ +export interface GeminiConfigErrorInfo { + /** Error code for programmatic handling */ + code: GeminiConfigErrorCode; + /** Human-readable error message */ + message: string; + /** For FORBIDDEN_KEYS: the list of forbidden keys found */ + keys?: string[]; + /** For VALUE_NOT_STRING: the key with invalid value */ + key?: string; + /** For VALUE_NOT_STRING: the actual type of the value */ + valueType?: string; +} + /** * Result of parsing Gemini common config snippet */ export interface GeminiCommonConfigParseResult { /** Parsed env key-value pairs (empty if invalid) */ env: Record; - /** Error message if parsing/validation failed (starts with error code) */ + /** + * Error message if parsing/validation failed. + * @deprecated Use errorInfo for type-safe error handling + */ error?: string; + /** Structured error info for type-safe handling */ + errorInfo?: GeminiConfigErrorInfo; /** Warning message (non-fatal, config still usable) */ warning?: string; + /** Structured warning info */ + warningInfo?: GeminiConfigErrorInfo; } /** @@ -385,9 +412,14 @@ export function parseGeminiCommonConfigSnippet( // Must be a plain object (not array, null, etc.) if (!isPlainObject(parsed)) { + const errorInfo: GeminiConfigErrorInfo = { + code: GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT, + message: "must be a JSON object, not array or primitive", + }; return { env: {}, - error: `${GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT}: must be a JSON object, not array or primitive`, + error: `${errorInfo.code}: ${errorInfo.message}`, + errorInfo, }; } @@ -397,9 +429,14 @@ export function parseGeminiCommonConfigSnippet( if ("env" in parsed) { const envField = parsed.env; if (!isPlainObject(envField)) { + const errorInfo: GeminiConfigErrorInfo = { + code: GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT, + message: "'env' field must be a plain object", + }; return { env: {}, - error: `${GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT}: 'env' field must be a plain object`, + error: `${errorInfo.code}: ${errorInfo.message}`, + errorInfo, }; } rawEnv = envField as Record; @@ -429,7 +466,6 @@ export function parseGeminiCommonConfigSnippet( // Validate and filter entries const env: Record = {}; - const warnings: string[] = []; const forbiddenKeysFound: string[] = []; for (const [key, value] of Object.entries(rawEnv)) { @@ -444,9 +480,16 @@ export function parseGeminiCommonConfigSnippet( // Must be string if (typeof value !== "string") { if (isJson) { + const errorInfo: GeminiConfigErrorInfo = { + code: GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING, + message: `value for '${key}' must be a string, got ${typeof value}`, + key, + valueType: typeof value, + }; return { env: {}, - error: `${GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING}: value for '${key}' must be a string, got ${typeof value}`, + error: `${errorInfo.code}: ${errorInfo.message}`, + errorInfo, }; } // For ENV format, skip non-strings silently (shouldn't happen) @@ -464,21 +507,92 @@ export function parseGeminiCommonConfigSnippet( // Handle forbidden keys if (forbiddenKeysFound.length > 0) { - const msg = `${GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS}: ${forbiddenKeysFound.join(", ")}`; + const errorInfo: GeminiConfigErrorInfo = { + code: GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS, + message: forbiddenKeysFound.join(", "), + keys: forbiddenKeysFound, + }; + const msg = `${errorInfo.code}: ${errorInfo.message}`; if (strictForbiddenKeys) { - return { env: {}, error: msg }; + return { env: {}, error: msg, errorInfo }; } - warnings.push(msg); + // Return as warning instead of error + return { + env, + warning: msg, + warningInfo: errorInfo, + }; } - return { - env, - warning: warnings.length > 0 ? warnings.join("; ") : undefined, - }; + return { env }; +} + +/** + * Map Gemini common config error/warning info to i18n-friendly message. + * Prefers structured errorInfo, falls back to string parsing for backward compatibility. + * + * @param info - Structured error info or raw warning string + * @param t - The i18n translation function + * @returns Translated message + */ +export function mapGeminiErrorToI18n( + info: GeminiConfigErrorInfo | string, + t: ( + key: string, + options?: { keys?: string; key?: string; defaultValue?: string }, + ) => string, +): string { + // Handle structured error info (preferred) + if (typeof info === "object") { + switch (info.code) { + case GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS: + return t("geminiConfig.forbiddenKeysWarning", { + keys: info.keys?.join(", ") ?? info.message, + }); + case GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING: + return t("geminiConfig.commonConfigInvalidValues", { + key: info.key, + defaultValue: info.message, + }); + case GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT: + case GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT: + return t("geminiConfig.invalidEnvFormat", { + defaultValue: info.message, + }); + default: + return info.message; + } + } + + // Backward compatibility: parse string format + if (info.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) { + const keys = info.replace( + `${GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS}: `, + "", + ); + return t("geminiConfig.forbiddenKeysWarning", { keys }); + } + if (info.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING)) { + return t("geminiConfig.commonConfigInvalidValues", { + defaultValue: "Invalid value type", + }); + } + if ( + info.startsWith(GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT) || + info.startsWith(GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT) + ) { + return t("geminiConfig.invalidEnvFormat", { + defaultValue: "Invalid format", + }); + } + + // Unknown format, return as-is + return info; } /** * Map Gemini common config warning to i18n-friendly message. + * @deprecated Use mapGeminiErrorToI18n with structured errorInfo/warningInfo instead * * @param warning - The raw warning string from parseGeminiCommonConfigSnippet * @param t - The i18n translation function @@ -491,14 +605,5 @@ export function mapGeminiWarningToI18n( options?: { keys?: string; defaultValue?: string }, ) => string, ): string { - if (warning.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) { - // Extract key list: "GEMINI_CONFIG_FORBIDDEN_KEYS: KEY1, KEY2" -> "KEY1, KEY2" - const keys = warning.replace( - `${GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS}: `, - "", - ); - return t("geminiConfig.forbiddenKeysWarning", { keys }); - } - // Other warnings: return as-is - return warning; + return mapGeminiErrorToI18n(warning, t); }