硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(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 全项留档。
195 lines
7.2 KiB
TypeScript
195 lines
7.2 KiB
TypeScript
/**
|
||
* Secure Config 测试(v0.7.2 覆盖补齐 —— 此前零测试)
|
||
*
|
||
* 锁定敏感配置加密存储契约(P0-1 的回归防线):
|
||
* 1. 敏感 key 判定模式(apikey/token/secret/password/auth_key)
|
||
* 2. safeStorage 可用 → 加密前缀格式 + 解密还原
|
||
* 3. safeStorage 不可用 → 明文降级(可用性优先)+ WARN
|
||
* 4. 解密失败(跨机器/重装)→ 返回空串(引导重录而非崩溃)
|
||
* 5. 加密幂等(已加密值不二次加密)与非字符串透传
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
||
// 可控的 safeStorage 桩:可逆 fake 加密(enc: 前缀),可用性开关可编程
|
||
const mockState = vi.hoisted(() => ({
|
||
encryptionAvailable: true,
|
||
failDecrypt: false,
|
||
}));
|
||
|
||
vi.mock('electron', () => ({
|
||
safeStorage: {
|
||
isEncryptionAvailable: () => mockState.encryptionAvailable,
|
||
encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'),
|
||
decryptString: (buffer: Buffer) => {
|
||
const raw = buffer.toString('utf-8');
|
||
if (mockState.failDecrypt || !raw.startsWith('enc:')) {
|
||
throw new Error('decryption failed');
|
||
}
|
||
return raw.slice(4);
|
||
},
|
||
},
|
||
}));
|
||
|
||
vi.mock('electron-log', () => ({
|
||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||
}));
|
||
|
||
import {
|
||
isSensitiveConfigKey,
|
||
isEncryptedValue,
|
||
encryptConfigValue,
|
||
decryptConfigValue,
|
||
resetEncryptionUsableForTests,
|
||
} from '../secure-config';
|
||
|
||
beforeEach(() => {
|
||
resetEncryptionUsableForTests();
|
||
mockState.encryptionAvailable = true;
|
||
mockState.failDecrypt = false;
|
||
});
|
||
|
||
describe('isSensitiveConfigKey — 敏感 key 判定', () => {
|
||
it.each([
|
||
['llm.apiKey', true],
|
||
['llm.api_key', true],
|
||
['searxng.auth_key', true],
|
||
// v0.7.4 P2-5: 驼峰/连字符/点分隔的 authKey 类命名全部命中(去分隔符归一化)
|
||
['searxng.authKey', true],
|
||
['searxng.auth-key', true],
|
||
['mcp.server.authToken', true],
|
||
['db.api-key', true],
|
||
['llm.fallbackApiKey', true],
|
||
['proxy.token', true],
|
||
['db.secret', true],
|
||
['GITHUB_PASSWORD', true],
|
||
['ui.theme', false],
|
||
['llm.model', false],
|
||
['agent.maxIterations', false],
|
||
['network.proxyUrl', false],
|
||
['searxng.language', false],
|
||
])('%s → %j', (key, expected) => {
|
||
expect(isSensitiveConfigKey(key)).toBe(expected);
|
||
});
|
||
});
|
||
|
||
describe('encrypt/decrypt — 加密回环', () => {
|
||
it('加密值带版本化前缀(metona-enc:v1:),解密还原原文', () => {
|
||
const encrypted = encryptConfigValue('sk-my-secret-key');
|
||
expect(typeof encrypted).toBe('string');
|
||
expect(isEncryptedValue(encrypted)).toBe(true);
|
||
expect(String(encrypted)).toMatch(/^metona-enc:v1:/);
|
||
|
||
const decrypted = decryptConfigValue(encrypted);
|
||
expect(decrypted).toBe('sk-my-secret-key');
|
||
});
|
||
|
||
it('非字符串值原样透传(number/boolean/null 不加密)', () => {
|
||
expect(encryptConfigValue(42)).toBe(42);
|
||
expect(encryptConfigValue(true)).toBe(true);
|
||
expect(encryptConfigValue(null)).toBe(null);
|
||
expect(decryptConfigValue(42)).toBe(42);
|
||
});
|
||
|
||
it('空字符串不加密(避免无意义前缀包裹)', () => {
|
||
expect(encryptConfigValue('')).toBe('');
|
||
});
|
||
|
||
it('已加密值幂等 —— 二次加密不再包裹前缀', () => {
|
||
const once = encryptConfigValue('sk-key') as string;
|
||
const twice = encryptConfigValue(once);
|
||
expect(twice).toBe(once);
|
||
expect(decryptConfigValue(twice)).toBe('sk-key');
|
||
});
|
||
|
||
it('非加密格式的值解密时原样返回(历史明文平滑兼容)', () => {
|
||
expect(decryptConfigValue('plain-old-key')).toBe('plain-old-key');
|
||
});
|
||
});
|
||
|
||
describe('加密降级与失败语义', () => {
|
||
it('safeStorage 不可用 → 明文存储(可用性优先 + WARN)', () => {
|
||
mockState.encryptionAvailable = false;
|
||
const value = encryptConfigValue('sk-plaintext-fallback');
|
||
expect(value).toBe('sk-plaintext-fallback');
|
||
expect(isEncryptedValue(value)).toBe(false);
|
||
});
|
||
|
||
it('解密失败(跨机器/重装)→ 返回空串(不阻断,引导重录)', () => {
|
||
// v0.8.1: roundtrip probe 需要一次可用加解密 —— 先完成加密,再注入解密失败
|
||
const encrypted = encryptConfigValue('sk-x') as string;
|
||
expect(isEncryptedValue(encrypted)).toBe(true);
|
||
mockState.failDecrypt = true;
|
||
expect(decryptConfigValue(encrypted)).toBe(''); // 失败 → 空串(createAdapter 判定未配置,引导重录)
|
||
});
|
||
|
||
it('roundtrip 探测失败 → 会话级降级明文存储(v0.8.1 新增)', () => {
|
||
// failDecrypt 令 probe 失败 → usable=false → 加密直接降级明文
|
||
mockState.failDecrypt = true;
|
||
const value = encryptConfigValue('sk-probe-fail') as string;
|
||
expect(value).toBe('sk-probe-fail');
|
||
expect(isEncryptedValue(value)).toBe(false);
|
||
});
|
||
|
||
it('safeStorage 不可用时已加密值仍可识别且不被二次"加密"', () => {
|
||
const once = encryptConfigValue('sk-again') as string;
|
||
mockState.encryptionAvailable = false;
|
||
// 已加密值幂等:不重新走明文降级
|
||
expect(encryptConfigValue(once)).toBe(once);
|
||
});
|
||
|
||
it('前缀残缺的加密值(metona-enc:v1: 后无 base64)解密返回空串', () => {
|
||
expect(decryptConfigValue('metona-enc:v1:')).toBe('');
|
||
});
|
||
|
||
it('非 base64 内容的加密值解密失败返回空串(不抛错)', () => {
|
||
expect(decryptConfigValue('metona-enc:v1:@@@not-base64@@@')).toBe('');
|
||
});
|
||
|
||
it('不同版本前缀(metona-enc:v2:)不被识别为加密值(原样返回)', () => {
|
||
expect(isEncryptedValue('metona-enc:v2:abc')).toBe(false);
|
||
expect(decryptConfigValue('metona-enc:v2:abc')).toBe('metona-enc:v2:abc');
|
||
});
|
||
|
||
it('含非敏感子串的 key 不加密(大写化归一后仍不命中)', () => {
|
||
expect(isSensitiveConfigKey('ui.theme')).toBe(false);
|
||
expect(isSensitiveConfigKey('network.proxyUrl')).toBe(false);
|
||
expect(isSensitiveConfigKey('logging.traceEnabled')).toBe(false);
|
||
});
|
||
|
||
it('敏感 key 判定覆盖 token 类与 secret 类更多形态', () => {
|
||
expect(isSensitiveConfigKey('mcp.githubToken')).toBe(true);
|
||
expect(isSensitiveConfigKey('db.clientSecret')).toBe(true);
|
||
expect(isSensitiveConfigKey('registry.authToken')).toBe(true);
|
||
expect(isSensitiveConfigKey('SMTP_PASSWORD')).toBe(true);
|
||
expect(isSensitiveConfigKey('sshPrivateKey')).toBe(false); // 无分隔符且不含模式
|
||
expect(isSensitiveConfigKey('keyboard.layout')).toBe(false);
|
||
});
|
||
|
||
it('encrypt/decrypt 往返多种敏感字符串(含特殊字符/中文/emoji)', () => {
|
||
const samples = [
|
||
'sk-hello world',
|
||
'含中文密钥值',
|
||
'with,comma"quotes"',
|
||
'emoji-🔑-key',
|
||
'a'.repeat(5000),
|
||
];
|
||
for (const s of samples) {
|
||
const encrypted = encryptConfigValue(s) as string;
|
||
expect(isEncryptedValue(encrypted)).toBe(true);
|
||
expect(decryptConfigValue(encrypted)).toBe(s);
|
||
}
|
||
});
|
||
|
||
it('decrypt 对 undefined/null 原样透传', () => {
|
||
expect(decryptConfigValue(undefined)).toBeUndefined();
|
||
expect(decryptConfigValue(null)).toBeNull();
|
||
});
|
||
|
||
it('encrypt 对 0/空串/false 等 falsy 非字符串原样透传', () => {
|
||
expect(encryptConfigValue(0)).toBe(0);
|
||
expect(encryptConfigValue('')).toBe('');
|
||
expect(encryptConfigValue(false)).toBe(false);
|
||
});
|
||
});
|