feat: 升级至 v0.3.16 — 工单修复 51 项 + 审查修复 30 项(安全加固/适配器/工具系统/数据层/前端)
This commit is contained in:
@@ -16,6 +16,27 @@ import type Database from 'better-sqlite3';
|
||||
import { createHash } from 'crypto';
|
||||
import log from 'electron-log';
|
||||
|
||||
/**
|
||||
* #36 修复: 稳定序列化,递归按 key 字典序排序后序列化
|
||||
* JSON.stringify 对对象 key 顺序敏感({a:1,b:2} ≠ {b:2,a:1}),
|
||||
* 导致相同语义的对象产生不同 hash,审计去重失效。
|
||||
* stableStringify 保证相同内容始终产生相同字符串。
|
||||
*/
|
||||
function stableStringify(obj: unknown): string {
|
||||
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
||||
if (Array.isArray(obj)) {
|
||||
return '[' + obj.map(stableStringify).join(',') + ']';
|
||||
}
|
||||
const keys = Object.keys(obj as Record<string, unknown>).sort();
|
||||
return (
|
||||
'{' +
|
||||
keys
|
||||
.map((k) => JSON.stringify(k) + ':' + stableStringify((obj as Record<string, unknown>)[k]))
|
||||
.join(',') +
|
||||
'}'
|
||||
);
|
||||
}
|
||||
|
||||
export type AuditEventType = 'tool_call' | 'permission_check' | 'error' | 'llm_request' | 'llm_response' | 'session_start' | 'session_end' | 'config_change';
|
||||
export type AuditActor = 'agent' | 'user' | 'system';
|
||||
export type AuditOutcome = 'success' | 'denied' | 'error';
|
||||
@@ -38,6 +59,7 @@ export class AuditService {
|
||||
* 计算链式哈希
|
||||
* current_hash = SHA256(prev_hash + 所有内容字段)
|
||||
* v0.2.0: 纳入 iteration/outcome/durationMs 字段,使用 JSON.stringify 避免分隔符碰撞
|
||||
* #36 修复: 改用 stableStringify 替代 JSON.stringify,避免对象 key 顺序不稳定导致 hash 不一致
|
||||
*/
|
||||
private computeHash(
|
||||
prevHash: string,
|
||||
@@ -47,8 +69,8 @@ export class AuditService {
|
||||
outcome: string | null; durationMs: number | null; createdAt: number;
|
||||
},
|
||||
): string {
|
||||
// 使用 JSON.stringify 避免分隔符碰撞
|
||||
const content = JSON.stringify({
|
||||
// #36 修复: 使用 stableStringify 避免分隔符碰撞且保证 key 顺序稳定
|
||||
const content = stableStringify({
|
||||
prevHash,
|
||||
sessionId: entry.sessionId,
|
||||
iteration: entry.iteration,
|
||||
@@ -328,6 +350,25 @@ export class AuditService {
|
||||
});
|
||||
|
||||
if (row.current_hash !== expectedHash) {
|
||||
// 审查修复: #36 将 JSON.stringify 改为 stableStringify 后,旧记录的 hash 用旧算法生成
|
||||
// 尝试用旧算法(JSON.stringify)重新计算,如果匹配则跳过(兼容旧数据)
|
||||
const legacyContent = JSON.stringify({
|
||||
prevHash,
|
||||
sessionId: row.session_id,
|
||||
iteration: row.iteration,
|
||||
eventType: row.event_type,
|
||||
actor: row.actor,
|
||||
target: row.target,
|
||||
details: row.details,
|
||||
outcome: row.outcome,
|
||||
durationMs: row.duration_ms,
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
const legacyHash = createHash('sha256').update(legacyContent, 'utf-8').digest('hex');
|
||||
if (row.current_hash === legacyHash) {
|
||||
prevHash = row.current_hash;
|
||||
continue;
|
||||
}
|
||||
return { valid: false, totalRecords: rows.length, verifiedRecords: verified, tamperedId: row.id };
|
||||
}
|
||||
|
||||
|
||||
@@ -270,62 +270,78 @@ export class DatabaseService {
|
||||
}
|
||||
};
|
||||
|
||||
// 迁移 1: messages 表添加 attachments 列
|
||||
tryAddColumn('messages', 'attachments', 'TEXT', 'attachments');
|
||||
// 迁移 2: audit_logs 表添加 iteration 列
|
||||
tryAddColumn('audit_logs', 'iteration', 'INTEGER', 'iteration');
|
||||
// v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希)
|
||||
tryAddColumn('audit_logs', 'prev_hash', 'TEXT', 'prev_hash');
|
||||
// v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希)
|
||||
tryAddColumn('audit_logs', 'current_hash', 'TEXT', 'current_hash');
|
||||
// #33 修复: 整个迁移批次包裹在事务中,保证原子性
|
||||
// 若某个 migration 部分失败(如 ALTER TABLE 成功,CREATE INDEX 失败),
|
||||
// 事务回滚,数据库不会处于部分变更的不一致状态,下次启动可安全重试。
|
||||
// better-sqlite3 的事务是同步原子的,嵌套事务使用 SAVEPOINT 实现。
|
||||
const runAllMigrations = db.transaction(() => {
|
||||
// 迁移 1: messages 表添加 attachments 列
|
||||
tryAddColumn('messages', 'attachments', 'TEXT', 'attachments');
|
||||
// 迁移 2: audit_logs 表添加 iteration 列
|
||||
tryAddColumn('audit_logs', 'iteration', 'INTEGER', 'iteration');
|
||||
// v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希)
|
||||
tryAddColumn('audit_logs', 'prev_hash', 'TEXT', 'prev_hash');
|
||||
// v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希)
|
||||
tryAddColumn('audit_logs', 'current_hash', 'TEXT', 'current_hash');
|
||||
|
||||
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
|
||||
// @see project_memory.md — Assistant messages with tool_calls must set content to null
|
||||
// SQLite 不支持 ALTER COLUMN,需要重建表
|
||||
try {
|
||||
// 检测 content 列是否有 NOT NULL 约束
|
||||
const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string; notnull: number }>;
|
||||
const contentCol = columns.find((c) => c.name === 'content');
|
||||
if (contentCol && contentCol.notnull === 1) {
|
||||
log.info('[DB] Migration: rebuilding messages table to allow NULL content');
|
||||
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
|
||||
// @see project_memory.md — Assistant messages with tool_calls must set content to null
|
||||
// SQLite 不支持 ALTER COLUMN,需要重建表
|
||||
try {
|
||||
// 检测 content 列是否有 NOT NULL 约束
|
||||
const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string; notnull: number }>;
|
||||
const contentCol = columns.find((c) => c.name === 'content');
|
||||
if (contentCol && contentCol.notnull === 1) {
|
||||
log.info('[DB] Migration: rebuilding messages table to allow NULL content');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
|
||||
content TEXT,
|
||||
reasoning_content TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_result TEXT,
|
||||
attachments TEXT,
|
||||
iteration INTEGER,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
// #33 修复: 重建表的多步骤包裹在嵌套事务中,部分失败时回滚
|
||||
// 避免 CREATE messages_new 成功但 DROP/RENAME 失败导致数据丢失或 schema 不一致
|
||||
const rebuildMessages = db.transaction(() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
|
||||
content TEXT,
|
||||
reasoning_content TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_result TEXT,
|
||||
attachments TEXT,
|
||||
iteration INTEGER,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO messages_new (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
|
||||
SELECT id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at
|
||||
FROM messages;
|
||||
INSERT INTO messages_new (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
|
||||
SELECT id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at
|
||||
FROM messages;
|
||||
|
||||
DROP TABLE messages;
|
||||
ALTER TABLE messages_new RENAME TO messages;
|
||||
`);
|
||||
DROP TABLE messages;
|
||||
ALTER TABLE messages_new RENAME TO messages;
|
||||
`);
|
||||
|
||||
// 重建索引
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
|
||||
`);
|
||||
// 重建索引
|
||||
// #42 确认: idx_messages_session 已是 (session_id, created_at) 复合索引,
|
||||
// 覆盖 getMessages 的 WHERE session_id = ? ORDER BY created_at ASC 查询,
|
||||
// 工单描述"仅有 session_id 单字段索引"不准确,无需额外添加 idx_messages_session_timestamp
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
|
||||
`);
|
||||
});
|
||||
rebuildMessages();
|
||||
|
||||
log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)');
|
||||
log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)');
|
||||
}
|
||||
} catch (error) {
|
||||
// L-8 修复: 使用 toErrorMessage 替代重复的三元表达式
|
||||
const msg = toErrorMessage(error);
|
||||
log.warn(`[DB] Migration 5 (messages content NULL) skipped: ${msg}`);
|
||||
// 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串
|
||||
}
|
||||
} catch (error) {
|
||||
// L-8 修复: 使用 toErrorMessage 替代重复的三元表达式
|
||||
const msg = toErrorMessage(error);
|
||||
log.warn(`[DB] Migration 5 (messages content NULL) skipped: ${msg}`);
|
||||
// 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串
|
||||
}
|
||||
});
|
||||
|
||||
runAllMigrations();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,84 @@ function safeParseArgs(raw: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// #6 修复: MCP stdio 命令安全校验
|
||||
/**
|
||||
* 允许的 MCP Server 启动命令白名单。
|
||||
* 仅允许常见的 MCP Server 运行时,防止任意命令执行。
|
||||
*/
|
||||
const ALLOWED_MCP_COMMANDS = new Set([
|
||||
'npx', 'node', 'npm',
|
||||
'python', 'python3', 'uv', 'uvx',
|
||||
'bun', 'deno',
|
||||
]);
|
||||
|
||||
/**
|
||||
* #6 修复: 校验 MCP Server 的 command 和 args,防止命令注入
|
||||
*
|
||||
* 1. 命令白名单:只允许已知的运行时命令
|
||||
* 2. 参数注入检测:拒绝包含 shell 元字符的参数
|
||||
*
|
||||
* @param command MCP Server 启动命令
|
||||
* @param args MCP Server 启动参数
|
||||
* @throws 如果命令不在白名单或参数包含 shell 元字符
|
||||
*/
|
||||
function validateMcpCommand(command: string, args: string[]): void {
|
||||
// 提取命令 basename(处理 /usr/bin/node、C:\node\node.exe 等路径)
|
||||
const baseCmd = command.split(/[\\/]/).pop()?.replace(/\.exe$/i, '') ?? command;
|
||||
|
||||
if (!ALLOWED_MCP_COMMANDS.has(baseCmd)) {
|
||||
throw new Error(
|
||||
`MCP command "${baseCmd}" is not in the allowed list: ${[...ALLOWED_MCP_COMMANDS].join(', ')}. ` +
|
||||
`For security reasons, only standard MCP runtimes are permitted.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 审查修复: 移除 {}() 字符 — StdioClientTransport 用 spawn(不经 shell),
|
||||
// 这些字符无注入风险,但 MCP Server 的 args 常含 JSON 配置(如 --config {"port":3000})会被误拒。
|
||||
// 保留 ; & | ` $ < > 换行 等高危字符。
|
||||
const shellMetacharPattern = /[;&|`$<>\n\r]/;
|
||||
for (const arg of args) {
|
||||
if (shellMetacharPattern.test(arg)) {
|
||||
throw new Error(
|
||||
`MCP command argument contains shell metacharacters and was rejected: ${arg.slice(0, 100)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #6 修复 + 审查修复: 构建安全的子进程环境变量
|
||||
*
|
||||
* 审查修复: 原白名单方案过于激进,剥离了 MCP Server 运行所需的 npm_config_*、代理变量等,
|
||||
* 导致 MCP Server 无法启动。改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
|
||||
*
|
||||
* 注意: GITHUB_TOKEN / SLACK_BOT_TOKEN 等含 _TOKEN 后缀的变量也会被过滤。
|
||||
* 如果 MCP Server 需要这些凭证,应通过 MCP Server 配置文件传递,而非环境变量。
|
||||
*/
|
||||
function buildSafeEnv(): Record<string, string> {
|
||||
// 敏感变量后缀黑名单 — 匹配这些后缀的变量不会被传递给子进程
|
||||
const SENSITIVE_SUFFIXES = [
|
||||
'_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD',
|
||||
'_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY',
|
||||
];
|
||||
// 敏感变量名黑名单(精确匹配)
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
'DEEPSEEK_API_KEY', 'AGNES_API_KEY', 'MIMO_API_KEY',
|
||||
'GITEA_PASSWORD', 'DATABASE_PASSWORD',
|
||||
]);
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(process.env)) {
|
||||
if (!val) continue;
|
||||
// 跳过敏感变量名
|
||||
if (SENSITIVE_KEYS.has(key)) continue;
|
||||
// 跳过敏感后缀变量
|
||||
if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
|
||||
env[key] = val;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// ===== 类型定义 =====
|
||||
|
||||
export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
@@ -178,9 +256,13 @@ export class MCPManager {
|
||||
if (config.transport === 'stdio' && config.command) {
|
||||
// stdio 模式
|
||||
const args = config.args ?? [];
|
||||
// #6 修复: 命令白名单 + 参数元字符检测,防止命令注入
|
||||
validateMcpCommand(config.command, args);
|
||||
transport = new StdioClientTransport({
|
||||
command: config.command,
|
||||
args,
|
||||
// #6 修复: 不透传完整 process.env,仅保留 MCP Server 运行所需的最小环境变量
|
||||
env: buildSafeEnv(),
|
||||
});
|
||||
} else if (config.transport === 'sse' && config.url) {
|
||||
// SSE 模式(远程 HTTP)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
import { appendFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { appendFileSync, existsSync, mkdirSync, promises } from 'fs';
|
||||
import log from 'electron-log';
|
||||
|
||||
// ===== 事件类型 =====
|
||||
@@ -43,6 +43,10 @@ export class SessionRecorder {
|
||||
private filePath: string | null = null;
|
||||
private seq = 0;
|
||||
private sessionId: string | null = null;
|
||||
// #35 修复: 缓冲写入,避免高频 appendFileSync 阻塞主进程(30-50 次/秒 → 每 100ms 批量异步 flush)
|
||||
private buffer: string[] = [];
|
||||
private flushTimer: NodeJS.Timeout | null = null;
|
||||
private flushing = false;
|
||||
|
||||
constructor(private workspacePath: string) {}
|
||||
|
||||
@@ -91,6 +95,9 @@ export class SessionRecorder {
|
||||
terminationReason: params.terminationReason,
|
||||
});
|
||||
|
||||
// #35 修复: 同步 flush 确保最后的 session_end 事件写入文件
|
||||
this.flushSync();
|
||||
|
||||
log.info(`Session recording stopped: ${this.filePath}`);
|
||||
this.filePath = null;
|
||||
this.sessionId = null;
|
||||
@@ -222,7 +229,10 @@ export class SessionRecorder {
|
||||
// ===== 私有方法 =====
|
||||
|
||||
/**
|
||||
* 写入事件到 JSONL 文件
|
||||
* 写入事件到缓冲区
|
||||
*
|
||||
* #35 修复: 改为缓冲写入,定时异步 flush,避免每次 appendFileSync 阻塞主进程
|
||||
* 高频事件(30-50 次/秒)先 push 到内存 buffer,每 100ms 批量异步写入文件
|
||||
*/
|
||||
private writeEvent(data: Record<string, unknown>): void {
|
||||
if (!this.filePath) return;
|
||||
@@ -234,10 +244,64 @@ export class SessionRecorder {
|
||||
...data,
|
||||
} as TraceEvent;
|
||||
|
||||
const line = JSON.stringify(event);
|
||||
|
||||
// 审查修复: buffer 上限防止 OOM — 高频事件持续 flush 失败时避免内存无限增长
|
||||
const MAX_BUFFER_SIZE = 1000;
|
||||
if (this.buffer.length >= MAX_BUFFER_SIZE) {
|
||||
// 超限时强制同步写入,避免内存无限增长
|
||||
this.flushSync();
|
||||
}
|
||||
this.buffer.push(line);
|
||||
if (!this.flushTimer) {
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
void this.flush();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #35 修复: 异步 flush 缓冲区到文件
|
||||
* 审查修复: 用局部变量保存 filePath,防止 stopRecording 将 filePath 置 null 后 appendFile 抛错;
|
||||
* 失败时将数据 unshift 回 buffer 避免丢整批数据
|
||||
*/
|
||||
private async flush(): Promise<void> {
|
||||
if (this.flushing || this.buffer.length === 0 || !this.filePath) return;
|
||||
this.flushing = true;
|
||||
const filePath = this.filePath; // 局部变量,防止中途变 null
|
||||
const data = this.buffer.join('\n') + '\n';
|
||||
this.buffer = [];
|
||||
try {
|
||||
appendFileSync(this.filePath, JSON.stringify(event) + '\n', 'utf-8');
|
||||
await promises.appendFile(filePath, data, 'utf-8');
|
||||
} catch (error) {
|
||||
log.error('Trace event write failed:', error);
|
||||
log.error('Trace event flush failed:', error);
|
||||
// 审查修复: 失败时将数据放回 buffer 头部,下次 flush/flushSync 重试
|
||||
this.buffer.unshift(data.trimEnd());
|
||||
} finally {
|
||||
this.flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #35 修复: 同步 flush 缓冲区到文件
|
||||
* 用于 stopRecording 确保最后的数据(如 session_end 事件)写入文件
|
||||
* 审查修复: 如果异步 flush 正在进行(flushing=true),等待其完成后再写入,避免数据交叉/丢失
|
||||
*/
|
||||
private flushSync(): void {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
if (this.buffer.length === 0 || !this.filePath) return;
|
||||
const data = this.buffer.join('\n') + '\n';
|
||||
this.buffer = [];
|
||||
try {
|
||||
appendFileSync(this.filePath, data, 'utf-8');
|
||||
} catch (error) {
|
||||
log.error('Trace event flushSync failed:', error);
|
||||
// 审查修复: 失败时将数据放回 buffer,避免数据丢失
|
||||
this.buffer.unshift(data.trimEnd());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,15 +175,26 @@ export class SessionService {
|
||||
|
||||
/**
|
||||
* 获取会话消息列表
|
||||
*
|
||||
* #44 修复: 添加 limit/offset 参数支持分页,避免超长会话一次性加载导致 OOM
|
||||
* 默认不限制(limit=0),保持向后兼容;调用者可传 limit 限制返回条数
|
||||
*/
|
||||
getMessages(sessionId: string): MessageInfo[] {
|
||||
getMessages(
|
||||
sessionId: string,
|
||||
options: { limit?: number; offset?: number } = {},
|
||||
): MessageInfo[] {
|
||||
const db = this.getDBFn();
|
||||
const { limit = 0, offset = 0 } = options;
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`).all(sessionId) as MessageRow[];
|
||||
const rows = (limit > 0
|
||||
? db
|
||||
.prepare(
|
||||
'SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC LIMIT ? OFFSET ?',
|
||||
)
|
||||
.all(sessionId, limit, offset)
|
||||
: db
|
||||
.prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC')
|
||||
.all(sessionId)) as MessageRow[];
|
||||
|
||||
return rows.map((row) => this.toMessageInfo(row));
|
||||
}
|
||||
@@ -206,28 +217,34 @@ export class SessionService {
|
||||
const id = `msg_${nanoid(12)}`;
|
||||
const now = Date.now();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
params.sessionId,
|
||||
params.role,
|
||||
params.content,
|
||||
params.reasoningContent ?? null,
|
||||
params.toolCalls ? JSON.stringify(params.toolCalls) : null,
|
||||
params.toolResult ? JSON.stringify(params.toolResult) : null,
|
||||
params.attachments ? JSON.stringify(params.attachments) : null,
|
||||
params.iteration ?? null,
|
||||
now,
|
||||
);
|
||||
// #34 修复: 包裹事务保证 INSERT messages 和 UPDATE sessions 原子执行
|
||||
// message_count = message_count + 1 已是原子 SQL 表达式(避免读-改-写竞态)
|
||||
// 事务进一步保证消息插入和计数更新要么全部成功,要么全部回滚
|
||||
const saveMessageTxn = db.transaction(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
params.sessionId,
|
||||
params.role,
|
||||
params.content,
|
||||
params.reasoningContent ?? null,
|
||||
params.toolCalls ? JSON.stringify(params.toolCalls) : null,
|
||||
params.toolResult ? JSON.stringify(params.toolResult) : null,
|
||||
params.attachments ? JSON.stringify(params.attachments) : null,
|
||||
params.iteration ?? null,
|
||||
now,
|
||||
);
|
||||
|
||||
// 更新会话的 updated_at 和 message_count
|
||||
db.prepare(`
|
||||
UPDATE sessions
|
||||
SET updated_at = ?, message_count = message_count + 1
|
||||
WHERE id = ?
|
||||
`).run(now, params.sessionId);
|
||||
// 更新会话的 updated_at 和 message_count
|
||||
db.prepare(`
|
||||
UPDATE sessions
|
||||
SET updated_at = ?, message_count = message_count + 1
|
||||
WHERE id = ?
|
||||
`).run(now, params.sessionId);
|
||||
});
|
||||
saveMessageTxn();
|
||||
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -54,7 +54,12 @@ export class WindowManager {
|
||||
title: options.title ?? 'MetonaAI Desktop',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/preload.mjs'),
|
||||
sandbox: false,
|
||||
// #37 修复: 启用沙箱,强化渲染进程隔离
|
||||
// preload.ts 仅使用 contextBridge/ipcRenderer(sandbox 下可用)和
|
||||
// @electron-toolkit/preload(兼容 sandbox),不依赖 fs/child_process 等 Node API,
|
||||
// 因此可安全启用 sandbox: true。启用后渲染进程无法直接访问 Node API,
|
||||
// 缩小恶意网页通过 preload 漏洞访问 fs/child_process 的攻击面。
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user