/** * IPC Shared — 配置写入的共享副作用逻辑(P2-9) * * 提取原 handlers.ts 中 config:set 与 config:setBatch 两处重复的: * 敏感值脱敏、Provider 切换清空 apiKey、Engine/Orchestrator 配置同步、 * 日志级别应用、配置变更广播、工作空间路径持久化。 */ import log from 'electron-log'; import type { IPCContext } from './context'; import { broadcast } from './context'; import { isSensitiveConfigKey } from '../utils/secure-config'; // v0.8.2 P1-5: 掩码实现单源 import { maskSensitiveValue } from '../utils/mask'; // v0.8.1 P2-1: 工具自定义策略解析 import { parseToolPolicy } from '../harness/sandbox/permissions'; // v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过) import { assertSafeConfigTargetDeep, DeepCheckSoftFailure, } from '../harness/tools/built-in/ssrf-guard'; // v0.8.1 P0-4: 主进程文案双语 import { setMainLocale, mt } from '../utils/main-locale'; /** * v0.7.4 P2-9-C: URL 类配置键 —— 写入时须过 assertSafeConfigTarget 高危目标校验。 * * 背景:llm.baseURL(adapter 携带 apiKey 请求头 POST)、searxng.url(搜索时携带 * Authorization 认证头)、app.updateFeedUrl(update.service 直接 fetch)均为渲染层 * 可控 URL。旧实现 config:set/setBatch 对 URL 类键零校验 —— XSS 或配置写入可将 * API Key 泄给云元数据(169.254.169.254)/攻击者主机。 * * 注:searxng 本地实例(127.0.0.1/192.168.x)与 Ollama 本地 baseURL 均在 * assertSafeConfigTarget 放行范围,接入无冲突。 */ const URL_CONFIG_KEYS = new Set([ 'llm.baseURL', 'llm.fallbackBaseURL', 'searxng.url', 'app.updateFeedUrl', ]); /** * 校验一批配置写入中的 URL 类键值。任一非法即抛错(调用方整体拒绝)。 * 非 URL 类键 / 空值 / 非字符串值跳过(空 baseURL 表示"未配置",允许)。 * * v0.8.0 P1-5: 升级为深校验 —— 静态规则(assertSafeConfigTarget)之外对域名 * 做真实 DNS 解析,任一解析结果命中链路本地/云元数据/组播保留段即拒绝; * DNS 解析失败视为软失败放行(离线配置合法),仅 WARN 留痕。 */ export async function assertSafeConfigUrls( entries: Array<{ key: string; value: unknown }>, ): Promise { for (const { key, value } of entries) { if (!URL_CONFIG_KEYS.has(key)) continue; if (typeof value !== 'string' || value.trim() === '') continue; await assertSafeConfigTargetDeep(value).catch((err) => { // 软失败(DNS 解析失败)放行,仅日志留痕;硬失败(高危目标)向上抛 if (err instanceof DeepCheckSoftFailure) { log.warn(`[CONFIG] URL deep check skipped for ${key}: ${(err as Error).message}`); return; } throw err; }); } } /** LLM 相关配置 key(变更时触发热重载 Adapter) */ export const LLM_CONFIG_KEYS = [ 'llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', // v0.8.1: 全局单一「上下文长度」—— 重建 adapter(携带新窗口)+ 引擎配置同步 'llm.contextWindow', 'llm.fallbackProvider', 'llm.fallbackModel', 'llm.fallbackApiKey', 'llm.fallbackBaseURL', ]; /** * 敏感配置值脱敏(长值保留后 4 位,短值完全掩码) * v0.8.2 P1-5: 掩码实现单源到 utils/mask.maskSensitiveValue * (原就地实现与审计脱敏存在漂移风险) */ export function maskSensitive(key: string, value: unknown): unknown { if (isSensitiveConfigKey(key) && typeof value === 'string' && value.length > 0) { return maskSensitiveValue(value); } return value; } /** * 导出配置脱敏(S-1 修复):对 configService.getAll() 的结果逐 key 脱敏。 * * 背景:getAll() 对敏感 key 解密后返回明文(ConfigService 的读取契约), * data:export 直接透传会把明文 API Key / 认证密钥写入用户下载的 JSON 文件, * 绕过 safeStorage 密钥链加密。此函数确保任何导出路径不泄露明文密钥。 * * @param config configService.getAll() 的完整配置快照 * @returns 脱敏后的副本(敏感值替换为掩码,原对象不修改) */ export function sanitizeExportConfig(config: Record): Record { const out: Record = {}; for (const [key, value] of Object.entries(config)) { out[key] = maskSensitive(key, value); } return out; } /** * Provider 切换时清空 API key(C-1 修复,供 set/setBatch 共用) * * 必须在写入 entries 之前执行:若前端把 llm.apiKey 放在 llm.provider 之前, * 先 set apiKey 再处理 provider 会把用户刚填的 key 清空。 */ export function clearApiKeyOnProviderChange( ctx: IPCContext, entries: Array<{ key: string; value: unknown }>, ): void { const providerEntry = entries.find((e) => e.key === 'llm.provider'); if (!providerEntry) return; const oldProvider = ctx.configService.get('llm.provider') ?? ''; const newProvider = (providerEntry.value as string) ?? ''; if (oldProvider && newProvider && oldProvider !== newProvider) { ctx.configService.set('llm.apiKey', ''); log.info( `[CONFIG] Provider changed (${oldProvider} → ${newProvider}), API key cleared to prevent incompatible key usage`, ); } } /** * 应用单条配置的引擎/编排器副作用(Engine/Orchestrator/ConfirmationHook 同步) */ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknown): void { const { agentEngineManager, orchestrator, confirmationHook, configService } = ctx; switch (key) { case 'agent.maxIterations': agentEngineManager.updateConfigAll({ maxIterations: value as number }); break; case 'agent.totalTimeoutMs': agentEngineManager.updateConfigAll({ totalTimeoutMs: value as number }); break; case 'agent.enableThinking': agentEngineManager.updateConfigAll({ thinkingEnabled: value as boolean }); orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean }); break; case 'agent.thinkingEffort': agentEngineManager.updateConfigAll({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max', }); orchestrator.updateDefaultConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max', }); break; // v0.7.3 P3-1: enableReflection 接线(此前为死配置)— 引擎 REFLECTING 状态开关 case 'agent.enableReflection': agentEngineManager.updateConfigAll({ enableReflection: value === true }); orchestrator.updateDefaultConfig({ enableReflection: value === true }); break; // v0.7.4 P3-2: temperature/maxTokens 热生效 —— 旧实现这两个 key 不在 // LLM_CONFIG_KEYS(不触发 reloadAdapter)也不在 applyEngineConfigKey(不更新 // 引擎 baseConfig),设置保存后要重启才生效("读时接入、写时死配置"不对称)。 case 'llm.temperature': agentEngineManager.updateConfigAll({ temperature: Number(value) }); orchestrator.updateDefaultConfig({ temperature: Number(value) }); break; case 'llm.maxTokens': // v0.8.1 review 修复: null/空串 = 用户清空「最大输出上限」→ 未配置语义 //(undefined 下发,由 Provider 服务端默认值决定);此前 Number(null)=0 // 会把引擎预算清零。 agentEngineManager.updateConfigAll({ maxTokens: value == null || value === '' ? undefined : Number(value) || undefined, }); orchestrator.updateDefaultConfig({ maxTokens: value == null || value === '' ? undefined : Number(value) || undefined, }); break; case 'agent.toolExecutionTimeoutMs': agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number }); break; case 'agent.confirmationTimeoutMs': confirmationHook.setConfirmationTimeout(value as number); break; case 'ollama.numCtx': // v0.8.1: ollama.numCtx 已废除 —— 与「上下文长度」合并为 llm.contextWindow。 // 保留此分支仅为兼容存量库中的遗留键写入(迁移 12 已清理),行为同 llm.contextWindow。 agentEngineManager.updateConfigAll({ contextLength: (value as number) || undefined }); orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined }); break; // v0.8.1: 全局单一「上下文长度」—— 替代旧的分 Provider contextWindow 键与 // ollama.numCtx。Ollama Provider 下同时作为 num_ctx(contextLength)下发。 case 'llm.contextWindow': { const ctx = (value as number) || undefined; const isOllama = (configService.get('llm.provider') ?? '') === 'ollama'; agentEngineManager.updateConfigAll({ contextWindow: ctx, contextLength: isOllama ? ctx : undefined, }); orchestrator.updateDefaultConfig({ contextWindow: ctx, contextLength: isOllama ? ctx : undefined, }); break; } case 'deepseek.contextWindow': case 'agnes.contextWindow': case 'mimo.contextWindow': case 'openai.contextWindow': case 'anthropic.contextWindow': // v0.8.1: 分 Provider 键已废除 —— 兼容分支收敛为空操作(迁移 12 已清理 // 存量库;若前端旧版本仍写入,静默忽略以防双源语义复活)。 log.warn(`[CONFIG] Deprecated provider contextWindow key ignored: ${key}`); break; default: break; } } /** * 配置写入后的统一副作用(供 config:set / config:setBatch 共用) * * 1. LLM key 变更 → 统一 reloadAdapter 一次(避免中间态失败) * 2. Engine/Orchestrator 配置同步 * 3. 日志级别即时应用 * 4. 广播配置变更(前端 store 实时更新) * 5. 工作空间路径写独立文件(下次启动生效) * * @returns 错误信息(成功为 null) */ export async function applyConfigSideEffects( ctx: IPCContext, entries: Array<{ key: string; value: unknown }>, ): Promise { // 1. LLM 配置变更 → 统一热重载 Adapter(一次) if (entries.some((e) => LLM_CONFIG_KEYS.includes(e.key))) { if (!ctx.reloadAdapter()) { log.warn('[CONFIG] Adapter reload failed after config save'); return mt('config.error.configIncomplete'); } } // 2. Engine/Orchestrator/ConfirmationHook 配置同步 for (const { key, value } of entries) { applyEngineConfigKey(ctx, key, value); // v0.8.1 P2-1: 工具自定义策略热加载(tools.{name}.policy,JSON 字符串) const m = /^tools\.(.+)\.policy$/.exec(key); if (m) { const toolName = m[1]; const known = ctx.toolRegistry.listAllTools().some((t) => t.name === toolName); if (known) { const parsed = typeof value === 'string' && value.trim() !== '' ? parseToolPolicy(value) : null; ctx.policyEngine.setPolicyOverride(toolName, parsed); if (typeof value === 'string' && value.trim() !== '' && !parsed) { log.warn(`[Policy] Ignored invalid policy config for tool: ${toolName}`); } } } } // 3. 日志级别即时应用 const logLevelEntry = entries.find((e) => e.key === 'logging.level'); if (logLevelEntry && typeof logLevelEntry.value === 'string') { const { transports } = await import('electron-log'); transports.file.level = logLevelEntry.value as | 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly'; transports.console.level = logLevelEntry.value as | 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly'; log.info(`[CONFIG] Log level updated to ${logLevelEntry.value}`); } // 4. 广播所有配置变更事件(前端监听后更新 store) for (const { key, value } of entries) { broadcast('config:changed', { key, value }); } // 5. 工作空间路径写入独立文件(下次启动生效) const workspaceEntry = entries.find( (e) => e.key === 'workspace.path' && typeof e.value === 'string', ); if (workspaceEntry) { try { const { writeWorkspacePathToFile } = await import('../main'); writeWorkspacePathToFile(workspaceEntry.value as string); log.info(`[CONFIG] Workspace path saved (restart required): ${workspaceEntry.value}`); } catch (err) { log.error('[CONFIG] Failed to save workspace path:', err); // v0.3.10: 写入失败必须告知用户,否则下次启动仍使用旧路径 return mt('config.error.workspaceSaveFailed', { message: (err as Error).message }); } } // v0.6.4 P4-5: 网络代理变更 → 重应用 session 级代理(default + agent-browser 分区) if (entries.some((e) => e.key === 'network.proxyUrl')) { const { applySessionProxy } = await import('../utils/network-proxy'); const proxyValue = entries.find((e) => e.key === 'network.proxyUrl')?.value; await applySessionProxy(typeof proxyValue === 'string' ? proxyValue : null); } // v0.8.1 P0-4: 界面语言变更 → 主进程 toast/通知语言热切换(无需重启) const localeEntry = entries.find((e) => e.key === 'ui.locale'); if (localeEntry && typeof localeEntry.value === 'string') { setMainLocale(localeEntry.value); } // v0.7.3 P4-2: MCP 自动重连开关变更 → 即时联动(关闭时取消全部已排程重连) const autoReconnectEntry = entries.find((e) => e.key === 'mcp.autoReconnect'); if (autoReconnectEntry) { ctx.mcpManager.setAutoReconnect(autoReconnectEntry.value !== false); } return null; }