Files
metona-ai-desktop/electron/utils/network-proxy.ts
T
thzxx 9b45c445bf
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s
feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
2026-09-08 09:35:58 +08:00

135 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 网络代理应用工具(v0.6.4 P4-5
*
* 目标:为 Electron 侧的全部网络出口提供统一的代理配置入口:
*
* 1. Chromium session 栈(session.setProxy):
* - defaultSession(渲染页面自身请求)
* - persist:metona-agent-browserweb_fetch 浏览器回退 / web_browser 隐藏窗口)
* 该分区由 BrowserWindowManager 在首次使用时创建 —— 本工具在启动期与配置
* 变更时直接 fromPartition 预配置,先于任何窗口创建生效。
*
* 2. 主进程 Node fetchLLM adapters / web_fetch HTTP 阶段 / SearXNG 测试 /
* 更新检查):Node 的 global fetch 与 undici 通过 globalThis 上的
* Symbol.for('undici.globalDispatcher.1') 共享调度器 —— 这里用 npm 安装的
* undici 调 setGlobalDispatcher 即可令原生 fetch 走 ProxyAgent(连接池首次
* 触发后对全部后续请求生效)。
*
* 配置来源:app_config 的 `network.proxyUrl`;未配置时回退环境变量
* HTTPS_PROXY/HTTP_PROXY;两者皆空则显式恢复直连。
*
* 失败语义:任一通道设置失败仅记录警告并继续,绝不阻断主流程 —— 网络不可达类
* 故障由各调用方的既有超时/重试机制兜底。
*/
import { session } from 'electron';
import log from 'electron-log';
// v0.8.0 P1-4: Agent 浏览器分区的 Pinned 代理状态探测(避免覆盖浏览器通道
// 的 SSRF pinning 代理设置)
import { isPinnedProxyActive } from './pinned-proxy';
const AGENT_BROWSER_PARTITION = 'persist:metona-agent-browser';
/**
* v0.7.3 P2-1: 代理激活标志(模块级状态,由 applySessionProxy 维护)。
*
* SSRF DNS pinningssrf-dispatcher)在此标志为 true 时必须退化为"仅入口校验":
* 代理模式下 DNS 解析发生在代理服务端,本地无法感知/约束最终连接的 IP,
* per-request dispatcher 也会旁路用户配置的 ProxyAgent(破坏代理语义)。
* 已知边界与 M7 结论一致,显式暴露而非隐藏。
*/
let proxyActive = false;
export function isProxyActive(): boolean {
return proxyActive;
}
export async function applySessionProxy(proxyUrl: string | null | undefined): Promise<void> {
const envProxy = process.env['HTTPS_PROXY'] || process.env['HTTP_PROXY'] || '';
const rules = (typeof proxyUrl === 'string' ? proxyUrl.trim() : '') || envProxy;
proxyActive = rules !== '';
// ===== 通道一:Chromium sessions =====
try {
const config = rules !== '' ? { proxyRules: rules } : { mode: 'direct' as const };
await session.defaultSession.setProxy(config);
// v0.8.0 P1-4: agent-browser 分区的代理优先级 ——
// ① 用户显式配置了代理(rules 非空)→ 用户代理语义优先,覆盖 Pinned 代理
// (M7 已知限制口径:该模式下浏览器通道无 pinning);
// ② 未配置代理但 Pinned 代理已激活 → 不动该分区(保留浏览器通道 SSRF pinning);
// ③ 其余 → 显式直连。
if (rules !== '' || !isPinnedProxyActive()) {
await session.fromPartition(AGENT_BROWSER_PARTITION).setProxy(config);
log.info(
`[Network] agent-browser partition proxy ${rules === '' ? 'set to direct' : `rules: ${rules}`}`,
);
} else {
log.info(
'[Network] agent-browser partition left on SSRF pinned proxy (no user proxy configured)',
);
}
log.info(
`[Network] Chromium default session proxy ${rules === '' ? 'set to direct (no proxy)' : `rules: ${rules}`}`,
);
} catch (err) {
log.warn(
`[Network] Failed to apply Chromium session proxy "${rules}": ${(err as Error).message}`,
);
}
// ===== 通道二:主进程 Node fetchundici 全局 dispatcher=====
// 动态 import:主进程所有 fetch 出口共享该调度器;setGlobalDispatcher 写入的
// 全局符号对所有引用同一 undici registry 的 fetch 实例生效。失败不阻断主流程。
//
// v0.8.1 根治: 代理 dispatcher 必须放行回环目标(127.0.0.1 / ::1 / localhost)。
// 此前全局 ProxyAgent 会把发往本机的请求(本地 Ollama / SearXNG / E2E mock LLM
// / 指向 localhost 的任意 Provider)也交给系统代理 —— 代理不可用或拒绝回环时
// 这类请求全部 "fetch failed"。现用组合 dispatcher:回环直连、其余走代理
// (与 NO_PROXY=127.0.0.1,localhost 的通用语义一致)。
await (async () => {
try {
const undici = await import('undici');
const { Agent, ProxyAgent, Dispatcher, setGlobalDispatcher } = undici;
if (rules !== '') {
const direct = new Agent({ connectTimeout: 15_000 });
const proxy = new ProxyAgent({ uri: rules, connectTimeout: 15_000 });
type DispatchArgs = Parameters<InstanceType<typeof Dispatcher>['dispatch']>;
class LoopbackBypassDispatcher extends Dispatcher {
override dispatch(...args: DispatchArgs): boolean {
const opts = args[0];
let host = '';
if (typeof opts.origin === 'string') {
host = new URL(opts.origin).hostname;
} else if (opts.origin instanceof URL) {
host = opts.origin.hostname;
}
const isLoopback =
host === '127.0.0.1' ||
host === '::1' ||
host === '[::1]' ||
host === 'localhost' ||
host.endsWith('.localhost');
return (isLoopback ? direct : proxy).dispatch(...args);
}
override async close(): Promise<void> {
await Promise.all([direct.close(), proxy.close()]);
}
override async destroy(): Promise<void> {
await Promise.all([direct.destroy(), proxy.destroy()]);
}
}
setGlobalDispatcher(new LoopbackBypassDispatcher());
log.info(`[Network] Node fetch dispatcher → ProxyAgent(${rules}) + loopback bypass`);
} else {
setGlobalDispatcher(new Agent());
log.info('[Network] Node fetch dispatcher → direct Agent');
}
} catch (err) {
// 理论仅在 undici 原生绑定异常时发生;fetch 将保持默认直连行为
log.warn(
`[Network] Failed to set undici dispatcher for proxy "${rules}": ${(err as Error).message}`,
);
}
})();
}