v0.12.3: 全面审计修复 + 稳定性加固 + 文档更新

P0 安全/稳定性修复 (6项):
- sqlite persist 改用临时文件+rename,I/O错误日志
- IPC fs 处理器加入 checkPathAllowed 路径验证
- handleCompress 命令注入修复 + 跨平台 (powershell/tar/zip/unzip)
- browser 全页截图简化,使用 capturePage full rect
- workspace 竞态条件修复
- editFile 5MB 大小限制

P1 安全/稳定性修复 (7项):
- 大小写不敏感搜索: query 同步转换小写
- web_fetch 无 content-length 时流式读取+10MB限制防OOM
- Windows SIGTERM 改用 taskkill /PID /T /F 强制终止
- startsWith 路径检查 Windows 大小写不敏感
- 安全黑名单扩展: 33个目录路径 + 30条危险命令 (Linux+Windows)
- plan_track stepIndex 改用 typeof === 'number' 精确检查
- MCP 前缀改用双下划线分隔 mcp_{server}__{tool} 防歧义

P2 代码质量修复 (8项):
- 修复 EXECUTING→THINKING 死循环Bug (转换表缺失)
- COMPRESSING 硬上限绕过时同步 _loopState 全局状态
- Abort 路径补齐 onDone 回调, 防止UI状态残留
- 幻觉检测从4条扩展到16条规则, 覆盖全部工具类别
- 移除 getTokenEfficiency 死代码
- 跨平台路径清理 (replace(/[\/]+$/, ''))
- 动态 import 加 try/catch, 失败自动批准不阻断
- #modelSelect 加 null 守卫, 防止运行时崩溃

文档 & UI 更新:
- README.md: 版本号/特性表/架构图/安全机制/工具清单全面刷新
- 帮助面板: 新增 Plan Mode、抗幻觉&稳定性章节, Agent Loop补8状态机
- 工具面板: plan_track卡片 + plan-mode徽章样式, 标题更新为42+1
- 工作空间面板宽度: 480px → 400px
- 源码注释移除所有版本标签, 仅保留4处必要位置
- 版本号 0.12.2 → 0.12.3
This commit is contained in:
紫影233
2026-06-23 17:06:43 +08:00
parent 20cbfbd169
commit 042e00e4a6
35 changed files with 3692 additions and 784 deletions
+11 -25
View File
@@ -149,37 +149,23 @@ export async function browserScreenshot(params: { full_page?: boolean; selector?
}
if (params.full_page) {
// 全页面截图:先滚动获取完整高度,截完恢复
// 全页面截图:一次性捕获整个页面矩形区域
const pageSize = await win.webContents.executeJavaScript(`
(() => {
const w = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, window.innerWidth);
const h = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, window.innerHeight);
const origScroll = { x: window.scrollX, y: window.scrollY };
window.scrollTo(0, 0);
return { width: w, height: h, origScroll };
return { width: w, height: h };
})()
`, true) as { width: number; height: number; origScroll: { x: number; y: number } };
`, true) as { width: number; height: number };
// 分块截图拼接(超过窗口高度的页面)
const viewHeight = 800;
const chunks: Buffer[] = [];
for (let y = 0; y < pageSize.height; y += viewHeight) {
await win.webContents.executeJavaScript(`window.scrollTo(0, ${y})`, true);
await new Promise(r => setTimeout(r, 200));
const image = await win.webContents.capturePage({
x: 0, y: 0,
width: pageSize.width,
height: Math.min(viewHeight, pageSize.height - y)
});
chunks.push(image.toPNG());
}
// 恢复滚动位置
await win.webContents.executeJavaScript(`window.scrollTo(${pageSize.origScroll.x}, ${pageSize.origScroll.y})`, true);
// 拼接所有截图(简单合并 base64)
const fullPng = Buffer.concat(chunks).toString('base64');
sendLog('success', `🌐 browser 截图(全页)`, `${pageSize.width}x${pageSize.height}, ${fullPng.length} chars`);
return { success: true, png: fullPng, width: pageSize.width, height: pageSize.height };
const image = await win.webContents.capturePage({
x: 0, y: 0,
width: pageSize.width,
height: pageSize.height
});
const buffer = image.toPNG();
sendLog('success', `🌐 browser 截图(全页)`, `${pageSize.width}x${pageSize.height}, ${buffer.length} bytes`);
return { success: true, png: buffer.toString('base64'), width: pageSize.width, height: pageSize.height };
}
// 视口截图(默认)
+15 -6
View File
@@ -87,8 +87,14 @@ function persist(): void {
try {
const data = db.export();
const buf = Buffer.from(data);
fs.writeFileSync(dbPath, buf);
} catch { /* 静默失败,不阻断主流程 */ }
// 先写临时文件再 rename,避免写一半崩溃导致数据库损坏
const tmpPath = dbPath + '.tmp';
fs.writeFileSync(tmpPath, buf);
fs.renameSync(tmpPath, dbPath);
} catch (err) {
// 记录到启动日志文件(console.error 会被 main.ts 的 uncaughtException 捕获)
console.error(`[SQLite persist] 写入失败: ${(err as Error).message}`);
}
}
/** 初始化数据库(异步,需加载 WASM) */
@@ -200,6 +206,7 @@ export async function initDatabase(): Promise<SQL.Database> {
action_input TEXT,
observation TEXT,
loop_count INTEGER,
error_pattern TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
@@ -210,7 +217,8 @@ export async function initDatabase(): Promise<SQL.Database> {
// 兼容迁移:为已有 messages 表补充 attachments 列(文件/视频等附件 JSON)
try { db.run('ALTER TABLE messages ADD COLUMN attachments TEXT'); } catch { /* 列已存在,忽略 */ }
// 兼容迁移:为已有 messages 表补充 prompt_eval_count
// 兼容迁移:v0.12.0 — 为已有 traces 表补充 error_pattern
try { db.run('ALTER TABLE traces ADD COLUMN error_pattern TEXT'); } catch { /* 列已存在,忽略 */ }
// 尝试创建 FTS5 全文搜索(可选,sql.js 默认 WASM 可能不包含 FTS5
@@ -305,6 +313,7 @@ export interface TraceRow {
action_input: string | null;
observation: string | null;
loop_count: number | null;
error_pattern: string | null;
created_at: number;
}
@@ -481,9 +490,9 @@ export function getToolCallsBySession(sessionId: string): ToolCallRow[] {
// ─── Traces CRUD ───
export function saveTrace(trace: TraceRow): string {
runExec(getDb(), `INSERT OR REPLACE INTO traces (id, session_id, step_index, thought, action, action_input, observation, loop_count, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[trace.id, trace.session_id, trace.step_index, trace.thought, trace.action, trace.action_input, trace.observation, trace.loop_count, trace.created_at]
runExec(getDb(), `INSERT OR REPLACE INTO traces (id, session_id, step_index, thought, action, action_input, observation, loop_count, error_pattern, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[trace.id, trace.session_id, trace.step_index, trace.thought, trace.action, trace.action_input, trace.observation, trace.loop_count, trace.error_pattern, trace.created_at]
);
persist();
return trace.id;
+7 -1
View File
@@ -55,7 +55,7 @@ import {
} from './tool-handlers.js';
import { browserOpen, browserScreenshot, browserEvaluate, browserExtract, browserClick, browserType, browserScroll, browserClose, browserWait } from './browser.js';
import { startServer, stopServer, stopAllServers, callTool, getAllTools, getServerStatuses, refreshTools, setMCPTimeout } from './mcp-manager.js';
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
import { getAllowedDirs, getBlockedDirs, setAllowedDirs, checkPathAllowed } from './tool-security.js';
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
import { setHTTPTimeout } from './tool-handlers-system.js';
import { execFile } from 'child_process';
@@ -132,6 +132,8 @@ export async function setupIPC(): Promise<void> {
ipcMain.handle('fs:readFile', async (_, filePath: string) => {
try {
const check = checkPathAllowed(path.resolve(filePath), 'read');
if (!check.ok) return { success: false, error: check.reason };
const content = await fs.promises.readFile(filePath, 'utf-8');
return { success: true, content, name: path.basename(filePath) };
} catch (err) {
@@ -141,6 +143,8 @@ export async function setupIPC(): Promise<void> {
ipcMain.handle('fs:readFileBase64', async (_, filePath: string) => {
try {
const check = checkPathAllowed(path.resolve(filePath), 'read');
if (!check.ok) return { success: false, error: check.reason };
const buf = await fs.promises.readFile(filePath);
return { success: true, content: buf.toString('base64'), size: buf.length, name: path.basename(filePath) };
} catch (err) {
@@ -150,6 +154,8 @@ export async function setupIPC(): Promise<void> {
ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string, encoding?: string) => {
try {
const check = checkPathAllowed(path.resolve(filePath), 'write');
if (!check.ok) return { success: false, error: check.reason };
await fs.promises.writeFile(filePath, content, (encoding as BufferEncoding) || 'utf-8');
return { success: true };
} catch (err) {
+1 -1
View File
@@ -277,7 +277,7 @@ export function getAllTools(): MCPToolInfo[] {
tools.push({
serverName,
name: tool.name,
fullName: `mcp_${serverName}_${tool.name}`,
fullName: `mcp_${serverName}__${tool.name}`,
description: `[MCP:${serverName}] ${tool.description || ''}`,
inputSchema: tool.inputSchema
});
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v0.11.11',
message: 'Metona Ollama Desktop v0.12.3',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
+5 -1
View File
@@ -357,7 +357,8 @@ export async function handleSearchFiles(params: { path: string; query: string; s
if (searchType === 'filename' || searchType === 'both') {
const fileName = path.basename(filePath);
const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase();
if (nameToCheck.includes(query)) {
const queryToCheck = caseSensitive ? query : query.toLowerCase();
if (nameToCheck.includes(queryToCheck)) {
results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] });
totalMatches++;
continue;
@@ -471,6 +472,9 @@ export async function handleEditFile(params: { path: string; old_text: string; n
if (!allowed.ok) return { success: false, error: allowed.reason };
const content = await fs.readFile(filePath, 'utf-8');
if (content.length > 5 * 1024 * 1024) {
return { success: false, error: `文件过大 (${(content.length / 1024 / 1024).toFixed(1)}MB),最大支持 5MB` };
}
let replaceCount: number;
let newContent: string;
+69 -15
View File
@@ -375,7 +375,34 @@ export async function handleWebFetch(params: { url: string; max_chars?: number;
return { success: false, error: `页面过大 (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` };
}
let text = await resp.text();
// 无 content-length 头时,流式读取并限制最大体积(防 OOM)
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
let text: string;
if (!contentLength && resp.body) {
const reader = resp.body.getReader();
const chunks: Uint8Array[] = [];
let totalSize = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.length;
if (totalSize > MAX_BODY_SIZE) {
reader.cancel();
return { success: false, error: `页面过大 (超过 10MB),已中止下载` };
}
chunks.push(value);
}
const decoder = new TextDecoder();
text = decoder.decode(Buffer.concat(chunks));
} else if (!contentLength) {
// 无 body stream 回退,但仍需限制
text = await resp.text();
if (text.length > MAX_BODY_SIZE) {
return { success: false, error: `页面过大 (${(text.length / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` };
}
} else {
text = await resp.text();
}
const isHTML = contentType.includes('html');
if (isHTML) {
text = htmlToText(text);
@@ -1163,6 +1190,7 @@ export async function handleDownloadFile(params: { url: string; destination: str
export async function handleCompress(params: { action: string; path: string; destination?: string; format?: string }): Promise<ToolResult> {
try {
const format = params.format || 'tar.gz';
const isWin = process.platform === 'win32';
if (params.action === 'create') {
const srcPath = resolvePath(params.path);
@@ -1176,13 +1204,28 @@ export async function handleCompress(params: { action: string; path: string; des
if (!destCheck.ok) return { success: false, error: destCheck.reason };
return new Promise((resolve) => {
const cmd = format === 'zip'
? `zip -r "${destPath}" "${path.basename(srcPath)}"`
: `tar czf "${destPath}" "${path.basename(srcPath)}"`;
const proc = spawn('bash', ['-c', cmd], { cwd: path.dirname(srcPath), stdio: ['pipe', 'pipe', 'pipe'] });
let child;
const srcName = path.basename(srcPath);
const cwd = path.dirname(srcPath);
if (format === 'zip') {
if (isWin) {
// Windows: PowerShell Compress-Archive
child = spawn('powershell', [
'-NoProfile', '-Command',
`Compress-Archive -Path '${srcName}' -DestinationPath '${destPath}' -Force`
], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
} else {
child = spawn('zip', ['-r', destPath, srcName], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
}
} else {
// tar.gz: Windows 10+ has tar, Unix has tar
child = spawn('tar', ['czf', destPath, srcName], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
}
let stderr = '';
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => {
child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
child.on('close', (code) => {
if (code === 0) {
fs.stat(destPath).then(s => {
resolve({ success: true, action: 'create', archive: destPath, size: s.size });
@@ -1191,7 +1234,7 @@ export async function handleCompress(params: { action: string; path: string; des
resolve({ success: false, error: stderr || `压缩失败 (exit ${code})` });
}
});
proc.on('error', (err) => resolve({ success: false, error: err.message }));
child.on('error', (err) => resolve({ success: false, error: err.message }));
});
} else {
// extract
@@ -1206,20 +1249,31 @@ export async function handleCompress(params: { action: string; path: string; des
await fs.mkdir(destDir, { recursive: true });
return new Promise((resolve) => {
let child;
const isZip = archivePath.endsWith('.zip');
const cmd = isZip
? `unzip -o "${archivePath}" -d "${destDir}"`
: `tar xzf "${archivePath}" -C "${destDir}"`;
const proc = spawn('bash', ['-c', cmd], { stdio: ['pipe', 'pipe', 'pipe'] });
if (isZip) {
if (isWin) {
child = spawn('powershell', [
'-NoProfile', '-Command',
`Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`
], { stdio: ['pipe', 'pipe', 'pipe'] });
} else {
child = spawn('unzip', ['-o', archivePath, '-d', destDir], { stdio: ['pipe', 'pipe', 'pipe'] });
}
} else {
child = spawn('tar', ['xzf', archivePath, '-C', destDir], { stdio: ['pipe', 'pipe', 'pipe'] });
}
let stderr = '';
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => {
child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
child.on('close', (code) => {
resolve(code === 0
? { success: true, action: 'extract', destination: destDir }
: { success: false, error: stderr || `解压失败 (exit ${code})` }
);
});
proc.on('error', (err) => resolve({ success: false, error: err.message }));
child.on('error', (err) => resolve({ success: false, error: err.message }));
});
}
} catch (err) {
+23 -1
View File
@@ -21,15 +21,28 @@ let allowedDirs: string[] = [
/** 永久禁止的目录 */
const BLOCKED_DIRS: string[] = [
// Linux/macOS 系统目录
'/etc', '/sys', '/proc', '/dev', '/boot', '/root',
'C:\\Windows', 'C:\\Program Files', 'C:\\ProgramData',
'/bin', '/sbin', '/usr/bin', '/usr/sbin', '/usr/lib',
'/var/log', '/var/run', '/var/spool', '/var/mail',
'/tmp/.X11-unix', '/tmp/.font-unix',
// Windows 系统目录
'C:\\Windows', 'C:\\Windows\\System32', 'C:\\Windows\\SysWOW64',
'C:\\Windows\\System', 'C:\\Windows\\WinSxS',
'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\ProgramData',
// 用户敏感目录
path.join(HOME, '.ssh'),
path.join(HOME, '.gnupg'),
path.join(HOME, '.aws'),
path.join(HOME, '.azure'),
path.join(HOME, '.config'),
path.join(HOME, '.kube'),
path.join(HOME, 'AppData'),
];
/** 命令黑名单 */
const BLOCKED_COMMANDS: string[] = [
// POSIX 危险命令
'rm -rf /', 'rm -rf /*', ':(){ :|:& };:',
'mkfs', 'dd if=', 'wipefs', 'shred',
'shutdown', 'reboot', 'poweroff', 'halt',
@@ -39,6 +52,15 @@ const BLOCKED_COMMANDS: string[] = [
'systemctl enable', 'systemctl disable',
'curl | sh', 'wget | sh', 'curl | bash', 'wget | bash',
'eval', 'exec >',
// Windows 危险命令
'rmdir /s', 'del /f', 'del /s', 'del /q',
'format', 'diskpart',
'reg add', 'reg delete', 'reg import', 'reg export',
'sc stop', 'sc delete', 'sc config',
'net user', 'net localgroup', 'net share',
'icacls', 'takeown', 'cacls',
'bcdedit', 'wmic', 'rundll32',
'schtasks /create', 'schtasks /delete',
];
export interface CheckResult {
+19 -4
View File
@@ -142,7 +142,12 @@ export function killProcess(id: string): boolean {
return false;
}
try {
proc.kill('SIGTERM');
// Windows 不支持 POSIX 信号,需用 taskkill 强制终止进程树
if (process.platform === 'win32' && proc.pid) {
spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F']);
} else {
proc.kill('SIGTERM');
}
activeProcesses.delete(id);
sendLog('info', `🖥️ 进程已终止`, `ID: ${id}`);
return true;
@@ -156,7 +161,13 @@ export function killProcess(id: string): boolean {
/** 停止所有进程(窗口关闭时调用) */
export function killAllProcesses(): void {
for (const [id, proc] of activeProcesses) {
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
try {
if (process.platform === 'win32' && proc.pid) {
spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F']);
} else {
proc.kill('SIGTERM');
}
} catch { /* ignore */ }
}
activeProcesses.clear();
}
@@ -166,8 +177,12 @@ export function listWorkspaceDir(dirPath?: string): { success: boolean; entries?
try {
const targetDir = dirPath ? path.resolve(dirPath) : _workspaceDir;
// 安全检查:必须在 workspace 范围内
if (!targetDir.startsWith(_workspaceDir) && !targetDir.startsWith(DEFAULT_WORKSPACE_DIR)) {
// 安全检查:必须在 workspace 范围内Windows 路径大小写不敏感)
const isWin = process.platform === 'win32';
const targetCheck = isWin ? targetDir.toLowerCase() : targetDir;
const wsCheck = isWin ? _workspaceDir.toLowerCase() : _workspaceDir;
const defaultCheck = isWin ? DEFAULT_WORKSPACE_DIR.toLowerCase() : DEFAULT_WORKSPACE_DIR;
if (!targetCheck.startsWith(wsCheck) && !targetCheck.startsWith(defaultCheck)) {
return { success: false, error: '只能浏览工作空间目录内的文件' };
}