feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m1s

P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照,
IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭,
与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀
按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身
(tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门
(file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断);
单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口)

P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播,
getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers
全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项
校验+设置页 JSON 输入, 远程 MCP 鉴权头可用)

P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性
前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码
激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见);
IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留)

P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层,
外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1
文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2

测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/
orchestrator/workspace.service/session-recorder/config-layering/secure-config/
network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。
测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK),
固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过;
Electron ABI 全量 737/737 零跳过
This commit is contained in:
2026-08-30 00:09:25 +08:00
parent 36e4b28d93
commit 26169b7be4
55 changed files with 5673 additions and 434 deletions
@@ -0,0 +1,257 @@
/**
* 配置分层测试(v0.7.2 覆盖补齐 —— config.service / global-config.service 此前零测试)
*
* 锁定跨工作空间配置层契约(v0.3.17 引入的分层机制的回归防线):
* 1. 读取顺序:工作空间 DB → 全局 JSON(仅全局 key
* 2. 空值回退:DB 被 seedDefaults 灌入空值/默认值时回退全局层真实值
* 3. 双写:set 同步写 DB 与全局 JSON;敏感 key 双侧密文
* 4. getAll 合并语义与 migrateFromWorkspaceDB 幂等迁移
* 5. 敏感 key 加密回环(P0-1
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { readFileSync, rmSync } from 'fs';
import { join } from 'path';
import Database from 'better-sqlite3';
// better-sqlite3 的 ABI 可用性在模块顶层探测(describe.skipIf 在注册期求值)
let dbAvailable = false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const DatabaseProbe = require('better-sqlite3');
new DatabaseProbe(':memory:').close();
dbAvailable = true;
} catch {
dbAvailable = false;
}
// 可控 safeStorage(可逆 fakeenc: 前缀)+ 可定位的 userData 目录。
// 注意:mock 工厂内不允许 require(仓库 lint 禁令),os/path/fs 经参数注入。
const mockState = vi.hoisted(() => ({
encryptionAvailable: true,
userDataDir: '',
}));
vi.mock('electron', async () => {
const { mkdtempSync } = await import('fs');
const { join } = await import('path');
const { tmpdir } = await import('os');
mockState.userDataDir = mkdtempSync(join(tmpdir(), 'metona-gcfg-'));
return {
app: { getPath: () => mockState.userDataDir },
safeStorage: {
isEncryptionAvailable: () => mockState.encryptionAvailable,
encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'),
decryptString: (buffer: Buffer) => {
const raw = buffer.toString('utf-8');
if (!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 { ConfigService } from '../config.service';
import {
GlobalConfigService,
isGlobalKey,
isUnconfiguredGlobalKey,
} from '../global-config.service';
// better-sqlite3 的默认导出同时是构造函数与命名空间 —— 实例类型经
// InstanceType<typeof Database> 获取,避免与命名空间冲突
type SqliteDatabase = InstanceType<typeof Database>;
let db: SqliteDatabase | undefined;
let globalConfig: GlobalConfigService;
let configService: ConfigService;
let tempRoots: string[] = [];
function makeDb(): SqliteDatabase {
const db = new Database(':memory:');
db.exec(`
CREATE TABLE app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
updated_at INTEGER NOT NULL DEFAULT 0
);
`);
return db;
}
beforeEach(() => {
if (!dbAvailable) return;
// 测试隔离:清除上一个用例落盘的全局 JSONGLOBAL_CONFIG_FILE 在模块导入期
// 固化为单一路径,文件内状态会跨用例泄漏 —— 迁移用例会误判"已有 key"
rmSync(join(mockState.userDataDir, 'global-config.json'), { force: true });
db = makeDb();
globalConfig = new GlobalConfigService();
globalConfig.initialize();
configService = new ConfigService(() => db!);
configService.setGlobalConfig(globalConfig);
});
afterEach(() => {
if (db) db.close();
for (const dir of tempRoots) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
}
tempRoots = [];
});
describe.skipIf(!dbAvailable)('isGlobalKey / isUnconfiguredGlobalKey — 全局 key 判定', () => {
it('llm./agent./ui./security./onboarding. 前缀为全局 keytasks/searxng 不属于', () => {
expect(isGlobalKey('llm.provider')).toBe(true);
expect(isGlobalKey('agent.maxIterations')).toBe(true);
expect(isGlobalKey('onboarding.completed')).toBe(true);
expect(isGlobalKey('searxng.enabled')).toBe(false);
expect(isGlobalKey('tools.write_file.enabled')).toBe(false);
});
it('空串/null/默认值视为未配置(触发全局回退);非默认值视为已配置', () => {
expect(isUnconfiguredGlobalKey('llm.apiKey', '')).toBe(true);
expect(isUnconfiguredGlobalKey('llm.apiKey', null)).toBe(true);
expect(isUnconfiguredGlobalKey('agent.maxIterations', 20)).toBe(true); // seedDefaults 默认值
expect(isUnconfiguredGlobalKey('agent.maxIterations', 50)).toBe(false); // 用户主动设置
expect(isUnconfiguredGlobalKey('llm.apiKey', 'sk-real')).toBe(false);
});
});
describe.skipIf(!dbAvailable)('ConfigService — 基本读写与敏感加密', () => {
it('set/get 回环(JSON 序列化语义)', () => {
configService.set('llm.model', 'deepseek-v4-pro');
expect(configService.get<string>('llm.model')).toBe('deepseek-v4-pro');
configService.set('agent.maxIterations', 33);
expect(configService.get<number>('agent.maxIterations')).toBe(33);
});
it('敏感 key 落盘为密文、读取自动解密(P0-1)', () => {
configService.set('llm.apiKey', 'sk-plain-secret');
const raw = db!.prepare("SELECT value FROM app_config WHERE key = 'llm.apiKey'").get() as {
value: string;
};
expect(raw.value).toContain('metona-enc:v1:'); // DB 内是密文
expect(raw.value).not.toContain('sk-plain-secret');
expect(configService.get<string>('llm.apiKey')).toBe('sk-plain-secret'); // 读取解密
});
it('get 未配置 key 返回 null(无全局回退对象时)', () => {
expect(configService.get<string>('nonexistent.key')).toBeNull();
});
it('set 保留已有 category(不回退默认值)', () => {
db!
.prepare(
"INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '\"dark\"', 'ui')",
)
.run();
configService.set('ui.theme', 'light');
const row = db!.prepare("SELECT category FROM app_config WHERE key = 'ui.theme'").get() as {
category: string;
};
expect(row.category).toBe('ui');
});
});
describe.skipIf(!dbAvailable)('配置分层 — DB 与全局 JSON 回退/双写', () => {
it('DB miss + 全局层有值 → 回退全局层', () => {
globalConfig.set('llm.provider', 'deepseek');
expect(configService.get<string>('llm.provider')).toBe('deepseek');
});
it('DB 有真实值 → 不回退(DB 优先)', () => {
globalConfig.set('llm.provider', 'deepseek');
configService.set('llm.provider', 'ollama');
expect(configService.get<string>('llm.provider')).toBe('ollama');
});
it('DB 有 seedDefaults 空值 + 全局层有真实值 → 回退全局层(新工作空间场景)', () => {
// 模拟新工作空间 DB 被 seedDefaults 灌入空 apiKey
db!
.prepare("INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"\"', 'llm')")
.run();
globalConfig.set('llm.apiKey', 'sk-global-real');
expect(configService.get<string>('llm.apiKey')).toBe('sk-global-real');
});
it('set 全局 key 双写:DB + 全局 JSON(跨工作空间共享)', () => {
configService.set('llm.model', 'mimo-v2.5');
// DB 有值
expect(configService.get<string>('llm.model')).toBe('mimo-v2.5');
// 全局 JSON 有值(新 ConfigService 实例 + 空 DB 可读到)
const db2 = makeDb();
const freshConfig = new ConfigService(() => db2);
freshConfig.setGlobalConfig(globalConfig);
expect(freshConfig.get<string>('llm.model')).toBe('mimo-v2.5');
db2.close();
});
it('非全局 key 不写全局层(工作空间级数据不跨空间泄漏)', () => {
configService.set('workspace.path', '/tmp/private');
expect(globalConfig.get<string>('workspace.path')).toBeNull();
});
it('getAll 合并:DB 为主,miss 的全局 key 用全局层补齐', () => {
configService.set('ui.theme', 'dark');
globalConfig.set('llm.provider', 'openai');
const all = configService.getAll();
expect(all['ui.theme']).toBe('dark');
expect(all['llm.provider']).toBe('openai');
});
it('getAll 中敏感 key 解密返回明文(配合导出层脱敏)', () => {
configService.set('llm.apiKey', 'sk-export-test');
expect(configService.getAll()['llm.apiKey']).toBe('sk-export-test');
});
});
describe.skipIf(!dbAvailable)('migrateFromWorkspaceDB — 幂等迁移', () => {
it('仅迁移已配置的全局 key;空值/默认值/既有 key 跳过', () => {
// 构造工作空间 DB 配置快照
const workspaceConfig: Record<string, unknown> = {
'llm.provider': 'deepseek', // 已配置 → 迁移
'llm.apiKey': '', // 空值 → 跳过
'agent.maxIterations': 20, // seedDefaults 默认值 → 跳过
'agent.confirmationTimeoutMs': 300000, // 用户设置的非默认值 → 迁移
'searxng.enabled': true, // 非全局 key → 跳过
};
const migrated = globalConfig.migrateFromWorkspaceDB(workspaceConfig);
expect(migrated).toBe(2);
expect(globalConfig.get('llm.provider')).toBe('deepseek');
expect(globalConfig.get('agent.confirmationTimeoutMs')).toBe(300000);
expect(globalConfig.get('llm.apiKey')).toBeNull();
// 幂等:第二次迁移 0 条(既有 key 不覆盖)
expect(globalConfig.migrateFromWorkspaceDB(workspaceConfig)).toBe(0);
});
it('全局层已有真实值时不被迁移覆盖', () => {
globalConfig.set('llm.provider', 'anthropic');
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.provider': 'deepseek' });
expect(migrated).toBe(0);
expect(globalConfig.get('llm.provider')).toBe('anthropic');
});
});
describe.skipIf(!dbAvailable)('敏感 key — 全局层密文落盘', () => {
it('全局 JSON 文件中敏感值为密文(无明文泄漏)', () => {
configService.set('llm.apiKey', 'sk-raw-in-global');
// GlobalConfigService flush 后,磁盘上的 global-config.json 不应包含明文
const rawFile = readFileSync(join(mockState.userDataDir, 'global-config.json'), 'utf-8');
expect(rawFile).not.toContain('sk-raw-in-global');
expect(rawFile).toContain('metona-enc:v1:');
});
});
@@ -13,9 +13,13 @@ vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { safeParseArgs, validateMcpCommand, buildSafeEnv } from '../mcp-manager.service';
import {
safeParseArgs,
safeParseHeaders,
validateMcpCommand,
buildSafeEnv,
} from '../mcp-manager.service';
import { SLOMonitor, HealthChecker } from '../../utils/slo';
import type { Database } from 'better-sqlite3';
// better-sqlite3 的 ABI 可用性在模块顶层探测(describe.skipIf 在注册期求值)
let dbAvailable = false;
@@ -43,6 +47,34 @@ describe('safeParseArgs — JSON args 解析', () => {
});
});
// ===== MCPsafeParseHeadersv0.7.2 P2-8 headers 列接线) =====
describe('safeParseHeaders — headers 列解析', () => {
it('合法 JSON 对象(键值均为字符串)→ Record<string,string>', () => {
expect(safeParseHeaders('{"Authorization":"Bearer t1","X-Route":"a"}')).toEqual({
Authorization: 'Bearer t1',
'X-Route': 'a',
});
});
it('空输入(null/undefined/空串)→ undefined(匿名连接)', () => {
expect(safeParseHeaders(null)).toBeUndefined();
expect(safeParseHeaders(undefined)).toBeUndefined();
expect(safeParseHeaders('')).toBeUndefined();
});
it('非对象/数组/坏 JSON → undefined(不阻断 server 连接)', () => {
expect(safeParseHeaders('["a","b"]')).toBeUndefined();
expect(safeParseHeaders('"plain"')).toBeUndefined();
expect(safeParseHeaders('not json')).toBeUndefined();
expect(safeParseHeaders('{}')).toBeUndefined(); // 空对象等价匿名
});
it('非字符串值逐项丢弃,字符串项保留(部分损坏只损失损坏项)', () => {
expect(safeParseHeaders('{"ok":"v1","bad":123,"worse":{"x":1}}')).toEqual({ ok: 'v1' });
});
});
// ===== MCPvalidateMcpCommand =====
describe('validateMcpCommand — stdio 命令白名单防线', () => {
@@ -51,7 +83,9 @@ describe('validateMcpCommand — stdio 命令白名单防线', () => {
expect(() => validateMcpCommand(cmd, [])).not.toThrow();
}
// 目录前缀被剥除 → basename 命中白名单
expect(() => validateMcpCommand('/usr/local/bin/npx', ['-y', '@modelcontextprotocol/server'])).not.toThrow();
expect(() =>
validateMcpCommand('/usr/local/bin/npx', ['-y', '@modelcontextprotocol/server']),
).not.toThrow();
// 现状锁定(比预期更严):扩展名不参与剥除 —— 'npx.cmd' 不在名单,直接拒绝。
// 这是当前安全基线的一部分:宁可收紧也不放过任何可执行变体。
expect(() => validateMcpCommand('C:\tools\npx.cmd', [])).toThrow(/allowed list/);
@@ -92,7 +126,7 @@ describe('buildSafeEnv — 子进程环境净化', () => {
ACCESS_TOKEN: 'tok',
DB_PASSWORD: 'p@ss',
AWS_SECRET_ACCESS_KEY2: 'k',
DEPLOY_PRIVATE_KEY: '----', // 后缀 _PRIVATE_KEY 命中
DEPLOY_PRIVATE_KEY: '----', // 后缀 _PRIVATE_KEY 命中
GITEA_CREDENTIALS: '{"u":"x"}', // 后缀 _CREDENTIALS 命中(真实 CI/部署泄漏形态)
SAFE_NAME: 'keepme',
};
@@ -113,7 +147,12 @@ describe('buildSafeEnv — 子进程环境净化', () => {
describe('SLOMonitor — 窗口指标 / 分位数 / 燃烧率', () => {
function makeMonitor(): SLOMonitor {
return new SLOMonitor({ target: 0.99, windowMs: 60_000, latencyPercentiles: [0.5, 0.95], latencyThresholdMs: 5_000 });
return new SLOMonitor({
target: 0.99,
windowMs: 60_000,
latencyPercentiles: [0.5, 0.95],
latencyThresholdMs: 5_000,
});
}
it('空窗口:totalRequests=0、errorRate=0、violated=false、burnRate=0', () => {
@@ -146,7 +185,12 @@ describe('SLOMonitor — 窗口指标 / 分位数 / 燃烧率', () => {
});
it('错误率超预算 → burnRate>1 且 violated=true(全错样本:burnRate=100', async () => {
const m = new SLOMonitor({ target: 0.99, windowMs: 60_000, latencyPercentiles: [0.5], latencyThresholdMs: 10_000 });
const m = new SLOMonitor({
target: 0.99,
windowMs: 60_000,
latencyPercentiles: [0.5],
latencyThresholdMs: 10_000,
});
for (let i = 0; i < 4; i++) m.recordRequest(50 + i, false);
const s = m.getStatus();
expect(s.errorRate).toBe(1);
@@ -155,7 +199,12 @@ describe('SLOMonitor — 窗口指标 / 分位数 / 燃烧率', () => {
});
it('窗口外记录被淘汰:回到基线 totalRequests=0(真实短窗口计时)', async () => {
const m = new SLOMonitor({ target: 0.99, windowMs: 50, latencyPercentiles: [0.5], latencyThresholdMs: 10_000 });
const m = new SLOMonitor({
target: 0.99,
windowMs: 50,
latencyPercentiles: [0.5],
latencyThresholdMs: 10_000,
});
m.recordRequest(100, true);
m.recordRequest(120, false);
expect(m.getStatus().totalRequests).toBe(2);
@@ -173,11 +222,12 @@ describe.skipIf(!dbAvailable)('HealthChecker — 三项健康检查', () => {
// 完整 DatabaseService.initialize() 覆盖;此处仅保留纯依赖注入的确定性用例。
it('DB ping 失败 → database check 不健康、healthy=false', async () => {
const broken = { prepare: () => { throw new Error('disk I/O error'); } } as unknown as import('better-sqlite3').Database;
const hc = new HealthChecker(
() => broken,
':memory:',
);
const broken = {
prepare: () => {
throw new Error('disk I/O error');
},
} as unknown as import('better-sqlite3').Database;
const hc = new HealthChecker(() => broken, ':memory:');
const report = await hc.check();
const dbCheck = report.checks.find((c) => c.name === 'database');
expect(dbCheck?.healthy).toBe(false);
@@ -0,0 +1,251 @@
/**
* SessionRecorder 测试(v0.7.2 覆盖补齐 —— 此前零测试)
*
* 锁定 TRACE 层录制契约(P1-6 重构的回归防线):
* 1. startRecording 建文件并写 session_startstopRecording 同步 flush + session_end
* 2. 多会话隔离(P1-6:每会话独立文件/seq,并发录制互不串扰)
* 3. setEnabled(false) 总开关丢弃事件(F-8 接通契约)
* 4. 缓冲上限(MAX_BUFFER_SIZE)强制同步落盘防 OOM
* 5. 事件 seq 递增与 9 类事件的载荷形状
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, readFileSync, existsSync, rmSync, readdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { SessionRecorder } from '../session-recorder.service';
let wsRoot: string;
let recorder: SessionRecorder;
beforeEach(() => {
wsRoot = mkdtempSync(join(tmpdir(), 'metona-rec-'));
recorder = new SessionRecorder(wsRoot);
});
afterEach(() => {
try {
rmSync(wsRoot, { recursive: true, force: true });
} catch {
/* ignore */
}
});
function readLines(sessionId: string): Array<Record<string, unknown>> {
const dir = join(wsRoot, 'logs');
const files = readdirSync(dir) as string[];
const target = files.filter((f) => f.includes(`session_${sessionId}_`));
expect(target.length).toBeGreaterThan(0);
const content = readFileSync(join(dir, target[0]), 'utf-8');
return content
.split('\n')
.filter((l) => l.trim())
.map((l) => JSON.parse(l) as Record<string, unknown>);
}
describe('SessionRecorder — 基本录制链路', () => {
it('startRecording 会话开启后首条事件即 session_startseq 0,经 flush 落盘)', () => {
recorder.startRecording('s1');
// 事件先进缓冲(100ms 定时 flush),stopRecording 的 flushSync 保证落盘后断言
recorder.stopRecording('s1', {
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'completed',
});
const lines = readLines('s1');
expect(lines).toHaveLength(2); // session_start + session_end
expect(lines[0]).toMatchObject({ event: 'session_start', sessionId: 's1' });
expect(lines[0].ts).toBeDefined();
expect(lines[0].seq).toBe(0);
expect(lines[1].event).toBe('session_end');
});
it('stopRecording 同步 flush 全部缓冲并写 session_end#35 契约)', () => {
recorder.startRecording('s1');
recorder.recordToolCall({
sessionId: 's1',
iteration: 1,
toolName: 'read_file',
args: { p: 'x' },
});
recorder.recordIterationStart('s1', 1);
recorder.stopRecording('s1', {
totalIterations: 1,
totalTokens: 100,
durationMs: 50,
terminationReason: 'completed',
});
const lines = readLines('s1');
const events = lines.map((l) => l.event);
expect(events).toEqual(['session_start', 'tool_call', 'iteration_start', 'session_end']);
const end = lines[lines.length - 1];
expect(end).toMatchObject({
totalIterations: 1,
totalTokens: 100,
terminationReason: 'completed',
});
});
it('事件 seq 在会话内单调递增', () => {
recorder.startRecording('s1');
recorder.recordIterationStart('s1', 1);
recorder.recordIterationStart('s1', 2);
recorder.stopRecording('s1', {
totalIterations: 2,
totalTokens: 0,
durationMs: 1,
terminationReason: 'completed',
});
// session_start(0) + iteration_start×2(1,2) + session_end(3)
const seqs = readLines('s1').map((l) => l.seq as number);
expect(seqs).toEqual([0, 1, 2, 3]);
});
it('recordLLMResponse 载荷:content 截断 200 字符', () => {
recorder.startRecording('s1');
recorder.recordLLMResponse({
sessionId: 's1',
iteration: 1,
content: 'y'.repeat(500),
finishReason: 'stop',
tokenUsage: { input: 10, output: 20, total: 30 },
});
recorder.stopRecording('s1', {
totalIterations: 1,
totalTokens: 30,
durationMs: 1,
terminationReason: 'completed',
});
const llm = readLines('s1').find((l) => l.event === 'llm_response') as Record<string, unknown>;
expect((llm.contentPreview as string).length).toBe(200);
expect(llm.tokenUsage).toEqual({ input: 10, output: 20, total: 30 });
});
it('recordToolResult 载荷:success/durationMs/resultPreview 截断 500/error', () => {
recorder.startRecording('s1');
recorder.recordToolResult({
sessionId: 's1',
iteration: 1,
toolName: 'web_fetch',
success: false,
durationMs: 42,
resultPreview: 'z'.repeat(800),
error: 'HTTP 403',
});
recorder.stopRecording('s1', {
totalIterations: 1,
totalTokens: 0,
durationMs: 1,
terminationReason: 'error',
});
const tr = readLines('s1').find((l) => l.event === 'tool_result') as Record<string, unknown>;
expect(tr.success).toBe(false);
expect(tr.durationMs).toBe(42);
expect((tr.resultPreview as string).length).toBe(500);
expect(tr.error).toBe('HTTP 403');
});
});
describe('SessionRecorder — 多会话隔离(P1-6', () => {
it('并发录制:每会话独立文件与独立 seq,互不串扰', () => {
recorder.startRecording('s1');
recorder.startRecording('s2');
recorder.recordToolCall({ sessionId: 's1', iteration: 1, toolName: 'tool_a', args: {} });
recorder.recordToolCall({ sessionId: 's2', iteration: 1, toolName: 'tool_b', args: {} });
recorder.stopRecording('s1', {
totalIterations: 1,
totalTokens: 0,
durationMs: 1,
terminationReason: 'completed',
});
recorder.stopRecording('s2', {
totalIterations: 1,
totalTokens: 0,
durationMs: 1,
terminationReason: 'completed',
});
const s1Tools = readLines('s1').filter((l) => l.event === 'tool_call');
const s2Tools = readLines('s2').filter((l) => l.event === 'tool_call');
expect(s1Tools[0].tool).toBe('tool_a');
expect(s2Tools[0].tool).toBe('tool_b');
// seq 各自从 0 起算(s1: start=0, tool=1, end=2s2 同构)
expect(s1Tools[0].seq).toBe(1);
expect(s2Tools[0].seq).toBe(1);
});
it('未 startRecording 的会话事件被静默丢弃', () => {
recorder.recordToolCall({ sessionId: 'ghost', iteration: 1, toolName: 'x', args: {} });
expect(recorder.getFilePath('ghost')).toBeNull();
});
it('stopRecording 幂等安全(未开始也会话状态不崩)', () => {
expect(() =>
recorder.stopRecording('ghost', {
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'error',
}),
).not.toThrow();
});
});
describe('SessionRecorder — 总开关与缓冲上限', () => {
it('setEnabled(false) 后事件全部丢弃(logging.traceEnabled 契约)', () => {
recorder.setEnabled(false);
recorder.startRecording('s1');
recorder.recordToolCall({ sessionId: 's1', iteration: 1, toolName: 'x', args: {} });
recorder.stopRecording('s1', {
totalIterations: 1,
totalTokens: 0,
durationMs: 1,
terminationReason: 'completed',
});
const dir = join(wsRoot, 'logs');
const files = existsSync(dir) ? readdirSync(dir) : [];
expect(files.filter((f) => f.startsWith('session_s1_'))).toEqual([]);
});
it('缓冲超过 MAX_BUFFER_SIZE 强制同步落盘(防 OOM', () => {
recorder.startRecording('s1');
// MAX_BUFFER_SIZE = 1000 —— 写入超限触发 flushSync(文件应提前出现在磁盘)
for (let i = 0; i < 1001; i++) {
recorder.recordIterationStart('s1', i);
}
recorder.stopRecording('s1', {
totalIterations: 1001,
totalTokens: 0,
durationMs: 1,
terminationReason: 'completed',
});
const lines = readLines('s1');
expect(lines.length).toBe(1003); // 1001 iterations + session_start + session_end
});
it('getFilePath 返回活动会话的录制文件路径;stop 后清除', () => {
recorder.startRecording('s1');
expect(recorder.getFilePath('s1')).toContain('session_s1_');
recorder.stopRecording('s1', {
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'completed',
});
expect(recorder.getFilePath('s1')).toBeNull();
});
});
@@ -336,7 +336,9 @@ describe.skipIf(!dbAvailable)('clearMessages × 摘要游标交互(B-2', ()
// 用户在长会话里攒到 summarized_until_rowid=500 后执行"清空消息"
for (let i = 1; i <= 5; i++) ins(`old_m${i}`, `旧消息 ${i}`);
const maxRowIdBefore = (
db.prepare(`SELECT MAX(rowid) AS r FROM messages WHERE session_id='s_clear'`).get() as { r: number }
db.prepare(`SELECT MAX(rowid) AS r FROM messages WHERE session_id='s_clear'`).get() as {
r: number;
}
).r;
expect(maxRowIdBefore).toBe(5);
summaryService.saveSummary('s_clear', '覆盖全部旧消息的摘要', 500);
@@ -349,7 +351,9 @@ describe.skipIf(!dbAvailable)('clearMessages × 摘要游标交互(B-2', ()
expect(summaryService.buildHistoryMessages('s_clear').length).toBe(0);
// 阶段二:用户点击"清空消息"
expect(sessionService.clearMessages('s_clear')).toBe(true);
// v0.7.2 A1: clearMessages 语义改为 void(成功判定是"操作完成"而非"有行被删除"
// 空会话清空同样成功 —— IPC 层据此统一返回 success:true
expect(() => sessionService.clearMessages('s_clear')).not.toThrow();
expect(sessionService.getMessages('s_clear')).toHaveLength(0);
// 核心契约 1:摘要记录被同步删除(原实现此处残留 cursor=5)
@@ -0,0 +1,170 @@
/**
* WorkspaceService 测试(v0.7.2 覆盖补齐 —— 此前零测试)
*
* 锁定工作空间生命周期契约:
* 1. initialize 创建目录结构(logs/.metona)与必需文件(SOUL.md/MEMORY.md
* 2. MEMORY.md 模板元数据头 + 5 分区结构
* 3. appendMemory 分区追加语义(含未知 section 兜底)
* 4. validateMemoryFormat 自动修正(缺元数据头/缺分区)
* 5. updateMemoryTimestamp / reload(外部编辑同步)
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { WorkspaceService } from '../workspace.service';
let wsRoot: string;
beforeEach(() => {
wsRoot = mkdtempSync(join(tmpdir(), 'metona-ws-'));
});
afterEach(() => {
try {
rmSync(wsRoot, { recursive: true, force: true });
} catch {
/* ignore */
}
});
describe('WorkspaceService — initialize', () => {
it('创建工作空间目录结构 + 两个必需文件 + 自动目录', () => {
const wsPath = join(wsRoot, 'ws');
const svc = new WorkspaceService(wsPath);
const info = svc.initialize();
expect(info.isValid).toBe(true);
expect(info.path).toBe(wsPath);
expect(existsSync(join(wsPath, 'SOUL.md'))).toBe(true);
expect(existsSync(join(wsPath, 'MEMORY.md'))).toBe(true);
expect(existsSync(join(wsPath, 'logs'))).toBe(true);
expect(existsSync(join(wsPath, '.metona'))).toBe(true);
// 本次启动自动创建的文件计入 missingFiles
expect(info.missingFiles).toEqual(['SOUL.md', 'MEMORY.md']);
});
it('已有完整文件的工作空间:missingFiles 为空且内容不被覆盖', () => {
const wsPath = join(wsRoot, 'ws');
mkdirSync(wsPath, { recursive: true });
writeFileSync(join(wsPath, 'SOUL.md'), '# 自定义人格', 'utf-8');
writeFileSync(
join(wsPath, 'MEMORY.md'),
'# MEMORY.md\n> 创建时间: x\n> 最后更新: y\n> 工作空间: z\n',
'utf-8',
);
const svc = new WorkspaceService(wsPath);
const info = svc.initialize();
expect(info.missingFiles).toEqual([]);
expect(svc.getFiles().soul).toBe('# 自定义人格');
});
it('MEMORY.md 模板包含元数据头与核心分区', () => {
const wsPath = join(wsRoot, 'ws');
const svc = new WorkspaceService(wsPath);
svc.initialize();
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
expect(content).toContain('# MEMORY.md');
expect(content).toContain('> 创建时间:');
expect(content).toContain('> 最后更新:');
expect(content).toContain('> 工作空间:');
for (const section of [
'## 用户偏好',
'## 项目上下文',
'## 重要决策',
'## 待办事项',
'## 已知问题',
]) {
expect(content).toContain(section);
}
});
});
describe('WorkspaceService — appendMemory', () => {
it('追加到指定 section 末尾(不侵入下一分区)', () => {
const wsPath = join(wsRoot, 'ws');
const svc = new WorkspaceService(wsPath);
svc.initialize();
svc.appendMemory('用户偏好', '偏好深色主题');
svc.appendMemory('用户偏好', '偏好简洁回复');
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
const sectionStart = content.indexOf('## 用户偏好');
const sectionEnd = content.indexOf('## 项目上下文');
const section = content.slice(sectionStart, sectionEnd);
expect(section).toContain('- 偏好深色主题');
expect(section).toContain('- 偏好简洁回复');
});
it('未知 section 追加到文件末尾并自动创建分区头', () => {
const wsPath = join(wsRoot, 'ws');
const svc = new WorkspaceService(wsPath);
svc.initialize();
svc.appendMemory('自定义分区', '条目内容');
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
expect(content).toContain('## 自定义分区');
expect(content).toContain('- 条目内容');
});
});
describe('WorkspaceService — validateMemoryFormat 自动修正', () => {
it('缺失元数据头与核心分区被自动补齐,内容不丢失', () => {
const wsPath = join(wsRoot, 'ws');
mkdirSync(wsPath, { recursive: true });
writeFileSync(join(wsPath, 'MEMORY.md'), '# MEMORY.md\n- 用户留下的重要内容', 'utf-8');
const svc = new WorkspaceService(wsPath);
expect(svc.validateMemoryFormat()).toBe(false); // false = 有修正
const content = readFileSync(join(wsPath, 'MEMORY.md'), 'utf-8');
expect(content).toContain('> 创建时间:');
expect(content).toContain('> 工作空间:');
for (const section of ['## 用户偏好', '## 项目上下文', '## 重要决策']) {
expect(content).toContain(section);
}
// 原内容保留
expect(content).toContain('- 用户留下的重要内容');
});
it('格式完整时返回 true 且不触发写盘', () => {
const wsPath = join(wsRoot, 'ws');
const svc = new WorkspaceService(wsPath);
svc.initialize();
expect(svc.validateMemoryFormat()).toBe(true);
});
});
describe('WorkspaceService — 时间戳与 reload', () => {
it('updateMemoryTimestamp 更新"最后更新"行', () => {
const wsPath = join(wsRoot, 'ws');
const svc = new WorkspaceService(wsPath);
svc.initialize();
svc.updateMemoryTimestamp();
const content = svc.getFiles().memory;
expect(content).toMatch(/> 最后更新: \d{4}-\d{2}-\d{2}T/);
});
it('reload 同步外部编辑(用户手动改文件后)', () => {
const wsPath = join(wsRoot, 'ws');
const svc = new WorkspaceService(wsPath);
svc.initialize();
writeFileSync(join(wsPath, 'SOUL.md'), '# 外部修改后的人格', 'utf-8');
const files = svc.reload();
expect(files.soul).toBe('# 外部修改后的人格');
});
it('getFiles 返回副本(外部修改引用不影响内部状态)', () => {
const wsPath = join(wsRoot, 'ws');
const svc = new WorkspaceService(wsPath);
svc.initialize();
const files = svc.getFiles();
files.soul = 'tampered';
expect(svc.getFiles().soul).not.toBe('tampered');
});
});
+64 -4
View File
@@ -137,6 +137,38 @@ export function buildSafeEnv(): Record<string, string> {
return env;
}
/**
* v0.7.2 P2-8: 安全解析 MCP headers 列(JSON 对象,键值均为字符串)。
*
* 数据库中存储为 JSON 字符串(mcp_servers.headers 列自建表起即存在,
* 此前从未被读写)。解析规则:
* - 空/null → undefined(匿名连接)
* - 非对象 / 数组 / 含非字符串键值 → 丢弃该条目并 WARN(保持可用性优先,
* 不因单列损坏阻断整个 server 连接)
*/
/** @visibleForTesting 纯函数,供安全表测直接断言 */
export function safeParseHeaders(
raw: string | null | undefined,
): Record<string, string> | undefined {
if (!raw) return undefined;
try {
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') {
out[k] = v;
} else {
log.warn(`MCP headers entry "${k}" dropped: value is not a string`);
}
}
return Object.keys(out).length > 0 ? out : undefined;
} catch {
log.warn(`MCP headers parse failed, ignoring: ${raw.slice(0, 100)}`);
return undefined;
}
}
// ===== 类型定义 =====
export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
@@ -149,6 +181,13 @@ export interface MCPServerConfig {
command?: string;
args?: string[];
url?: string;
/**
* v0.7.2 P2-8: 远程传输(sse / streamable-http)的自定义请求头。
* 用于 Bearer/Basic 鉴权或网关路由头;仅 HTTP 传输生效(stdio 走 buildSafeEnv)。
* 通过 requestInit 注入 —— MCP SDK 的 SSE/StreamableHTTP 客户端在
* SSE GET 流与 JSON-RPC POST 中统一合并 _commonHeaders(含 requestInit.headers)。
*/
headers?: Record<string, string>;
enabled: boolean;
}
@@ -278,6 +317,7 @@ export class MCPManager {
command: string | null;
args: string | null;
url: string | null;
headers: string | null;
}>;
const connectWithTimeout = (config: MCPServerConfig): Promise<unknown> =>
@@ -296,6 +336,8 @@ export class MCPManager {
command: row.command ?? undefined,
args: row.args ? safeParseArgs(row.args) : undefined,
url: row.url ?? undefined,
// v0.7.2 P2-8: headers 列接线(此前建表即存在但从未读写)
headers: safeParseHeaders(row.headers),
enabled: true,
};
return connectWithTimeout(config).catch((err) => {
@@ -342,10 +384,22 @@ export class MCPManager {
});
} else if (config.transport === 'sse' && config.url) {
// SSE 模式(远程 HTTP,旧式传输,保留向后兼容)
transport = new SSEClientTransport(new URL(config.url));
// v0.7.2 P2-8: 注入自定义请求头(非空时)—— requestInit.headers 同时
// 覆盖 SSE GET 流与 JSON-RPC POSTSDK _commonHeaders 统一合并点)
const headerInit = {
...(config.headers && Object.keys(config.headers).length > 0
? { requestInit: { headers: config.headers } }
: {}),
};
transport = new SSEClientTransport(new URL(config.url), headerInit);
} else if (config.transport === 'streamable-http' && config.url) {
// v0.4.1: streamable HTTP 模式(MCP 当前主流远程传输)
transport = new StreamableHTTPClientTransport(new URL(config.url));
const headerInit = {
...(config.headers && Object.keys(config.headers).length > 0
? { requestInit: { headers: config.headers } }
: {}),
};
transport = new StreamableHTTPClientTransport(new URL(config.url), headerInit);
} else {
throw new Error(
`Unsupported transport "${config.transport}". ` +
@@ -468,6 +522,7 @@ export class MCPManager {
command: string | null;
args: string | null;
url: string | null;
headers: string | null;
}
| undefined;
if (row) {
@@ -478,6 +533,7 @@ export class MCPManager {
command: row.command ?? undefined,
args: row.args ? safeParseArgs(row.args) : undefined,
url: row.url ?? undefined,
headers: safeParseHeaders(row.headers),
enabled: true,
});
}
@@ -490,6 +546,7 @@ export class MCPManager {
* 添加新的 MCP Server
*
* v0.4.1: 校验 transport 与对应字段匹配(stdio→commandsse/streamable-http→url
* v0.7.2 P2-8: headers 持久化(JSON 列;仅远程传输消费,stdio 忽略)
*/
async addServer(config: Omit<MCPServerConfig, 'id'>): Promise<void> {
const db = this.getDB();
@@ -505,8 +562,8 @@ export class MCPManager {
db.prepare(
`
INSERT INTO mcp_servers (id, name, transport, command, args, url, enabled)
VALUES (?, ?, ?, ?, ?, ?, 1)
INSERT INTO mcp_servers (id, name, transport, command, args, url, headers, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
`,
).run(
id,
@@ -515,6 +572,9 @@ export class MCPManager {
config.command ?? null,
config.args ? JSON.stringify(config.args) : null,
config.url ?? null,
config.headers && Object.keys(config.headers).length > 0
? JSON.stringify(config.headers)
: null,
);
if (config.enabled !== false) {
+8 -7
View File
@@ -375,16 +375,17 @@ export class SessionService {
* 对比 truncateMessagesAfter(本文件 269-271 一带)早已做了游标清理,
* 此处是同一契约的遗漏点。删除摘要后游标从零重建,历史分层自然复位。
*/
clearMessages(sessionId: string): boolean {
clearMessages(sessionId: string): void {
const db = this.getDBFn();
const result = db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
// B-2: 同步清除滚动摘要(含 summarized_until_rowid 游标)
db.prepare('DELETE FROM session_summaries WHERE session_id = ?').run(sessionId);
db.prepare('UPDATE sessions SET message_count = 0, updated_at = ? WHERE id = ?').run(
Date.now(),
sessionId,
);
return result.changes > 0;
// v0.7.2 A1 根治: 同步重置 metadata 中的 TRACE 快照(traceSteps/tokenUsage)。
// 此前只删 messages —— 前端"清空会话"后 traceSteps 残留,切换会话再切回时
// getTrace 仍返回旧 TraceTrace 面板出现"幽灵步骤"。
db.prepare(
"UPDATE sessions SET message_count = 0, metadata = '{}', updated_at = ? WHERE id = ?",
).run(Date.now(), sessionId);
}
/**
+20 -14
View File
@@ -61,7 +61,8 @@ export class WorkspaceService {
private files: WorkspaceFiles = { soul: '', memory: '' };
constructor(workspacePath?: string) {
this.workspacePath = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
this.workspacePath =
workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
}
/**
@@ -147,10 +148,7 @@ export class WorkspaceService {
let content = readFileSync(memoryPath, 'utf-8');
const now = new Date().toISOString();
content = content.replace(
/> 最后更新: .*/,
`> 最后更新: ${now}`,
);
content = content.replace(/> 最后更新: .*/, `> 最后更新: ${now}`);
writeFileSync(memoryPath, content, 'utf-8');
this.files.memory = content;
@@ -159,6 +157,13 @@ export class WorkspaceService {
/**
* 追加记忆到 MEMORY.md
*
* v0.7.2 根治: 分区头匹配此前使用 `\b` 词边界 —— JS 的 `\b` 仅对
* [A-Za-z0-9_] 有效,中文分区名(用户偏好/项目上下文/重要决策/待办事项/
* 已知问题)永远无法命中,导致 appendMemory 恒走"分区不存在"分支,
* 在文件末尾创建重复的分区头而不是插入既有分区。现改为
* `## <escaped>(?=\n|$)` 前瞻断言:分区头行必须以换行或 EOF 结束,
* 对 CJK 与拉丁分区名均正确,且防止前缀误匹配(如"用户偏好(旧)")。
*/
appendMemory(section: string, entry: string): void {
const memoryPath = join(this.workspacePath, 'MEMORY.md');
@@ -166,8 +171,9 @@ export class WorkspaceService {
let content = readFileSync(memoryPath, 'utf-8');
// 查找目标 section
const sectionRegex = new RegExp(`## ${section}\\b`);
// 查找目标 section(转义 section 名中的正则元字符)
const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const sectionRegex = new RegExp(`## ${escaped}(?=\\n|$)`);
const sectionMatch = content.match(sectionRegex);
if (sectionMatch) {
@@ -185,10 +191,7 @@ export class WorkspaceService {
}
// 更新时间戳
content = content.replace(
/> 最后更新: .*/,
`> 最后更新: ${new Date().toISOString()}`,
);
content = content.replace(/> 最后更新: .*/, `> 最后更新: ${new Date().toISOString()}`);
writeFileSync(memoryPath, content, 'utf-8');
this.files.memory = content;
@@ -279,15 +282,18 @@ export class WorkspaceService {
switch (fileName) {
case 'MEMORY.md': {
const now = new Date().toISOString();
const content = MEMORY_TEMPLATE
.replace('__WORKSPACE_PATH__', this.workspacePath)
const content = MEMORY_TEMPLATE.replace('__WORKSPACE_PATH__', this.workspacePath)
.replace('__CREATED_AT__', now)
.replace('__UPDATED_AT__', now);
writeFileSync(filePath, content, 'utf-8');
break;
}
case 'SOUL.md':
writeFileSync(filePath, '# SOUL.md — AI 灵魂定义\n\n# 用户可在此定义 Agent 的身份、性格和核心价值观\n', 'utf-8');
writeFileSync(
filePath,
'# SOUL.md — AI 灵魂定义\n\n# 用户可在此定义 Agent 的身份、性格和核心价值观\n',
'utf-8',
);
break;
}