P0 会话可靠性收口(根治"模型思考着会话就停止"): - P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值 - P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷 - P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示 - P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层 - P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径 FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效 P1 修复面收口: - 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫 - 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码 - 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并 - 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口 P2 能力演进: - 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板 - 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口 - electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置 - @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入 - MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图 - 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表 P3 测试基建: - 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2) - 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3 - 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强 全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
/**
|
||
* 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.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过)
|
||
import {
|
||
assertSafeConfigTargetDeep,
|
||
DeepCheckSoftFailure,
|
||
} from '../harness/tools/built-in/ssrf-guard';
|
||
|
||
/**
|
||
* 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<void> {
|
||
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',
|
||
'llm.fallbackProvider',
|
||
'llm.fallbackModel',
|
||
'llm.fallbackApiKey',
|
||
'llm.fallbackBaseURL',
|
||
'ollama.numCtx',
|
||
'deepseek.contextWindow',
|
||
'agnes.contextWindow',
|
||
'mimo.contextWindow',
|
||
'openai.contextWindow',
|
||
'anthropic.contextWindow',
|
||
];
|
||
|
||
/** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */
|
||
export function maskSensitive(key: string, value: unknown): unknown {
|
||
if (isSensitiveConfigKey(key) && typeof value === 'string' && value.length > 0) {
|
||
return value.length > 4 ? '***' + value.slice(-4) : '***';
|
||
}
|
||
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<string, unknown>): Record<string, unknown> {
|
||
const out: Record<string, unknown> = {};
|
||
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<string>('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 } = 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':
|
||
agentEngineManager.updateConfigAll({ maxTokens: Number(value) });
|
||
orchestrator.updateDefaultConfig({ maxTokens: Number(value) });
|
||
break;
|
||
case 'agent.toolExecutionTimeoutMs':
|
||
agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number });
|
||
break;
|
||
case 'agent.confirmationTimeoutMs':
|
||
confirmationHook.setConfirmationTimeout(value as number);
|
||
break;
|
||
case 'ollama.numCtx':
|
||
agentEngineManager.updateConfigAll({ contextLength: (value as number) || undefined });
|
||
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
|
||
break;
|
||
case 'deepseek.contextWindow':
|
||
case 'agnes.contextWindow':
|
||
case 'mimo.contextWindow':
|
||
case 'openai.contextWindow':
|
||
case 'anthropic.contextWindow':
|
||
// reloadAdapter 已重建 adapter 并同步 contextWindow,此处确保 Engine 配置同步(兜底)
|
||
agentEngineManager.updateConfigAll({ contextWindow: (value as number) || undefined });
|
||
orchestrator.updateDefaultConfig({ contextWindow: (value as number) || undefined });
|
||
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<string | null> {
|
||
// 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 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写';
|
||
}
|
||
}
|
||
|
||
// 2. Engine/Orchestrator/ConfirmationHook 配置同步
|
||
for (const { key, value } of entries) {
|
||
applyEngineConfigKey(ctx, key, value);
|
||
}
|
||
|
||
// 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 `工作空间路径保存失败:${(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.7.3 P4-2: MCP 自动重连开关变更 → 即时联动(关闭时取消全部已排程重连)
|
||
const autoReconnectEntry = entries.find((e) => e.key === 'mcp.autoReconnect');
|
||
if (autoReconnectEntry) {
|
||
ctx.mcpManager.setAutoReconnect(autoReconnectEntry.value !== false);
|
||
}
|
||
|
||
return null;
|
||
}
|