feat: 升级至 v0.3.1 — 全量代码审计修复 + 安全增强
本次升级基于完整代码审查,修复 Critical/High/Medium/Low 四级共 96 项问题, 并通过返工审计修复 10 项遗留问题,tsc 双端类型检查零错误。 Critical (10/10 完成): - C-4: command.ts 接入 shell-quote 进行 token-level 注入检测,替代原有正则匹配 可防御 r"m" -rf /、$'rm'、$(echo rm) 等字符串拼接绕过 High (11/11 完成): - 竞态保护、Promise.allSettled、AbortController 资源泄漏、IPC 参数校验等 Medium (55/55 完成): - 事务保护、敏感数据脱敏、枚举校验、MUI v9 Stack prop 迁移、 React 组件 cancelled 标志、类型收窄等 Low (20/20 完成): - 辅助方法提取(flushToolCallBuffer/scoreAndPushMemory/tryAddColumn 等) - nanoid 统一替代 Date.now()+Math.random() - confirm() 替换为 MUI Dialog、useMemo 缓存、魔法数字命名化等 返工审计修复 (10/10 完成): - L-11: LogsSettings 残留的原生 confirm()/alert() 全部替换为 MUI Dialog/Alert - M-53: MemoryViewer handleSearch 独立 ref,修复 searching 状态卡死 - M-42: 脱敏短值(length <= 4)泄露修复 - M-47: tasks:update 补全 title/description 类型校验 - L-9: ollama.adapter 非流式路径 nanoid 统一 - M-45: audit:query limit 策略与 memory:listAll 一致化 - SettingsModal handleConfirmRemove 补全 try/catch + loadServers cleanup - L-15: CommandPalette useMemo 补全 sessions 响应式依赖 - useAgentStream 事件类型补全 seq/timestamp 字段 新增依赖: shell-quote + @types/shell-quote 版本号: 0.3.0 -> 0.3.1
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { resolve } from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
AgentLoopState,
|
||||
@@ -33,7 +34,7 @@ import type {
|
||||
IMetonaProviderAdapter,
|
||||
MetonaToolDef,
|
||||
} from '../types';
|
||||
import { MetonaStreamEventType, MetonaFinishReason } from '../types';
|
||||
import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types';
|
||||
import { estimateMessagesTokens } from '../utils/token-estimator';
|
||||
import log from 'electron-log';
|
||||
|
||||
@@ -175,6 +176,11 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
this.currentSessionId = sessionId;
|
||||
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
||||
this.abortController = new AbortController();
|
||||
// C-2 修复: 将 abortController 的 signal 注入到 adapter,
|
||||
// 使正在进行的 fetch 可被用户中断,避免资源泄漏
|
||||
if (this.adapter.setAbortSignal) {
|
||||
this.adapter.setAbortSignal(this.abortController.signal);
|
||||
}
|
||||
this.eventSeq = 0;
|
||||
// v0.3.0: 重置工具调用历史(用于死循环检测)
|
||||
this.toolCallHistory = [];
|
||||
@@ -297,6 +303,10 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
this.aborted = true;
|
||||
this.abortController?.abort();
|
||||
this.abortController = null;
|
||||
// C-2 修复: 清除 adapter 的 abort signal,防止旧的已 abort signal 影响后续请求
|
||||
if (this.adapter.setAbortSignal) {
|
||||
this.adapter.setAbortSignal(undefined);
|
||||
}
|
||||
this.emit('aborted');
|
||||
}
|
||||
|
||||
@@ -348,7 +358,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
|
||||
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
|
||||
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支)
|
||||
if (event.type === MetonaStreamEventType.ERROR && (event.error?.code as string) === 'RETRY') {
|
||||
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'as string' 强制转换,确保类型安全
|
||||
if (event.type === MetonaStreamEventType.ERROR && event.error?.code === MetonaErrorCode.RETRY) {
|
||||
// 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收)
|
||||
fullContent = '';
|
||||
reasoningContent = '';
|
||||
@@ -410,26 +421,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 ===
|
||||
await this.transitionTo(AgentLoopState.PARSING);
|
||||
|
||||
// 处理缓冲区中的工具调用(TOOL_CALL_DELTA 拼接)
|
||||
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
|
||||
step.toolCalls = [];
|
||||
for (const [, buf] of toolCallsBuffer) {
|
||||
let args: Record<string, unknown>;
|
||||
try {
|
||||
args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {};
|
||||
} catch {
|
||||
// JSON 解析失败,使用空参数(LLM 仍请求了该工具调用)
|
||||
args = {};
|
||||
}
|
||||
step.toolCalls.push({
|
||||
id: `tc_${nanoid(8)}`,
|
||||
name: buf.name,
|
||||
args,
|
||||
iteration: this.currentIteration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段)
|
||||
this.finalizeToolCallsFromBuffer(step, toolCallsBuffer);
|
||||
|
||||
// 记录 Thought
|
||||
if (fullContent || reasoningContent) {
|
||||
@@ -470,49 +463,9 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// transitionTo 已发射 stateChange 事件,无需重复 emit
|
||||
await this.transitionTo(AgentLoopState.EXECUTING);
|
||||
|
||||
// 并行执行所有工具调用(独立工具之间无依赖,可安全并发)
|
||||
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
|
||||
const result = await this.executeToolSafely(tc);
|
||||
|
||||
// H-5: abort 后不转发工具结果(避免 DONE 后到达的残留事件污染新流)
|
||||
if (!this.aborted) {
|
||||
// 立即转发工具结果到渲染进程(不等其他工具完成)
|
||||
this.emit('streamEvent', {
|
||||
type: MetonaStreamEventType.TOOL_RESULT,
|
||||
requestId: request.meta.requestId,
|
||||
sessionId,
|
||||
iteration: this.currentIteration,
|
||||
seq: this.nextSeq(),
|
||||
timestamp: Date.now(),
|
||||
toolResult: result,
|
||||
runId: this.runId,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// H-5: abort 时提前退出工具执行等待,不再阻塞至所有工具完成
|
||||
const toolsPromise = Promise.allSettled(
|
||||
step.toolCalls.map((tc) => executeAndForward(tc)),
|
||||
);
|
||||
const controller = this.abortController;
|
||||
let onAbort: (() => void) | null = null;
|
||||
const abortPromise = new Promise<null>((resolve) => {
|
||||
if (this.aborted) return resolve(null);
|
||||
if (controller) {
|
||||
onAbort = () => resolve(null);
|
||||
controller.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
const raceResult = await Promise.race([toolsPromise, abortPromise]) as
|
||||
| PromiseSettledResult<MetonaToolResult>[]
|
||||
| null;
|
||||
|
||||
// v0.3.0 修复: 若 toolsPromise 先完成(正常执行),手动移除 abort 监听器,避免监听器堆积
|
||||
if (onAbort && controller && raceResult !== null) {
|
||||
controller.signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
// L-19 修复: 提取 executeToolCallsParallel 子方法(EXECUTING 阶段)
|
||||
// 返回 null 表示被 abort 中断
|
||||
const raceResult = await this.executeToolCallsParallel(step.toolCalls, request.meta.requestId, sessionId);
|
||||
|
||||
if (raceResult === null) {
|
||||
// 被 abort 中断,标记步骤并退出
|
||||
@@ -521,8 +474,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
return step;
|
||||
}
|
||||
|
||||
const settledResults = raceResult;
|
||||
step.toolResults = settledResults.map((result, idx) => {
|
||||
step.toolResults = raceResult.map((result, idx) => {
|
||||
const tc = step.toolCalls![idx];
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
// rejected:构造失败 result
|
||||
@@ -600,6 +552,95 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L-19 修复: PARSING 阶段 — 解析流式接收期间累积的工具调用缓冲区,
|
||||
* 构造 MetonaToolCall[] 写入 step.toolCalls。
|
||||
*
|
||||
* 仅在缓冲区非空且 step 尚未通过 TOOL_CALL_COMPLETE 接收到完整调用时生效,
|
||||
* 避免覆盖已就绪的 toolCalls。
|
||||
*/
|
||||
private finalizeToolCallsFromBuffer(
|
||||
step: IterationStep,
|
||||
toolCallsBuffer: Map<number, { name: string; argsBuffer: string }>,
|
||||
): void {
|
||||
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
|
||||
step.toolCalls = [];
|
||||
for (const [, buf] of toolCallsBuffer) {
|
||||
let args: Record<string, unknown>;
|
||||
try {
|
||||
args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {};
|
||||
} catch {
|
||||
args = {};
|
||||
}
|
||||
step.toolCalls.push({
|
||||
id: `tc_${nanoid(8)}`,
|
||||
name: buf.name,
|
||||
args,
|
||||
iteration: this.currentIteration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L-19 修复: EXECUTING 阶段 — 并行执行工具调用,转发结果到 UI,
|
||||
* 并与 abort 信号竞速。返回 null 表示被 abort 中断。
|
||||
*
|
||||
* 注意:rejected 的工具结果由调用方处理(构造失败 result 并转发),
|
||||
* 此处只负责转发 fulfilled 的结果。
|
||||
*/
|
||||
private async executeToolCallsParallel(
|
||||
toolCalls: MetonaToolCall[],
|
||||
requestId: string,
|
||||
sessionId: string,
|
||||
): Promise<PromiseSettledResult<MetonaToolResult>[] | null> {
|
||||
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
|
||||
const result = await this.executeToolSafely(tc);
|
||||
// 执行成功后转发结果到 UI(abort 后不再转发,避免污染新会话的流)
|
||||
if (!this.aborted) {
|
||||
this.emit('streamEvent', {
|
||||
type: MetonaStreamEventType.TOOL_RESULT,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration: this.currentIteration,
|
||||
seq: this.nextSeq(),
|
||||
timestamp: Date.now(),
|
||||
toolResult: result,
|
||||
runId: this.runId,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// C-2 修复: 使用 Promise.allSettled 而非 Promise.all,确保单个工具失败不影响其他工具
|
||||
const toolsPromise = Promise.allSettled(toolCalls.map((tc) => executeAndForward(tc)));
|
||||
|
||||
const controller = this.abortController;
|
||||
let onAbort: (() => void) | null = null;
|
||||
const abortPromise = new Promise<null>((resolve) => {
|
||||
if (this.aborted) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
if (controller) {
|
||||
onAbort = () => resolve(null);
|
||||
controller.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
|
||||
const raceResult = (await Promise.race([toolsPromise, abortPromise])) as
|
||||
| PromiseSettledResult<MetonaToolResult>[]
|
||||
| null;
|
||||
|
||||
// 清理 abort 监听器,避免事件循环中残留
|
||||
if (onAbort && controller && raceResult !== null) {
|
||||
controller.signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
return raceResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全执行工具调用(经过 Hook 管道)
|
||||
*/
|
||||
@@ -627,12 +668,32 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// H-5 修复: 精确保护工作空间根目录的 MEMORY.md
|
||||
// @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected;
|
||||
// subdirectory MEMORY.md files are unrestricted
|
||||
// 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
|
||||
// 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
|
||||
// run_command 由 permissions.ts 的粗粒度正则保留保护(命令解析复杂)。
|
||||
if (['read_file', 'write_file', 'file_editor'].includes(toolCall.name)) {
|
||||
if (this.isTargetingRootMemoryMd(toolCall)) {
|
||||
return {
|
||||
toolCallId: toolCall.id, toolName: toolCall.name,
|
||||
result: null, success: false,
|
||||
error: 'Access to workspace root MEMORY.md is protected by security policy',
|
||||
durationMs: Date.now() - startTs, timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 执行工具(带超时)
|
||||
// 兜底超时取 max(配置值, 工具自定义 timeoutMs),确保工具能跑满自己声明的超时
|
||||
const configuredTimeout = this.config.toolExecutionTimeoutMs ?? 120_000;
|
||||
const toolDef = this.toolRegistry.get(toolCall.name)?.definition;
|
||||
const toolTimeout = Math.max(configuredTimeout, toolDef?.timeoutMs ?? 0);
|
||||
let toolResult: MetonaToolResult;
|
||||
// M-16 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
|
||||
// 默认 120 秒超时下,多轮迭代会堆积大量未触发 timer
|
||||
let engineTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
toolResult = await Promise.race([
|
||||
this.toolRegistry.execute(toolCall, {
|
||||
@@ -641,9 +702,12 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
iteration: this.currentIteration,
|
||||
requestId: this.currentRequestId,
|
||||
}),
|
||||
new Promise<MetonaToolResult>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)), toolTimeout),
|
||||
),
|
||||
new Promise<MetonaToolResult>((_, reject) => {
|
||||
engineTimer = setTimeout(
|
||||
() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)),
|
||||
toolTimeout,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
toolResult = {
|
||||
@@ -660,6 +724,9 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
await hook.afterExecute(toolCall, toolResult, this.currentSessionId);
|
||||
}
|
||||
return toolResult;
|
||||
} finally {
|
||||
// M-16 修复: 清理未触发的 timeout timer
|
||||
if (engineTimer) clearTimeout(engineTimer);
|
||||
}
|
||||
|
||||
// 后置 Hook 管道
|
||||
@@ -670,6 +737,38 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
return toolResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* H-5 修复: 检查工具调用是否针对工作空间根目录的 MEMORY.md
|
||||
*
|
||||
* @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected;
|
||||
* subdirectory MEMORY.md files are unrestricted
|
||||
*
|
||||
* 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
|
||||
* 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
|
||||
*
|
||||
* @param toolCall 工具调用
|
||||
* @returns 是否指向工作空间根目录的 MEMORY.md
|
||||
*/
|
||||
private isTargetingRootMemoryMd(toolCall: MetonaToolCall): boolean {
|
||||
if (!this.workspacePath) return false;
|
||||
|
||||
// 提取工具参数中的路径(不同工具使用不同的参数名)
|
||||
const args = toolCall.args;
|
||||
const pathStr = (args.path as string) || (args.file_path as string) ||
|
||||
(args.filePath as string) || (args.file as string) ||
|
||||
(args.target as string) || (args.destination as string);
|
||||
|
||||
if (!pathStr || typeof pathStr !== 'string') return false;
|
||||
|
||||
// 解析路径,判断是否指向工作空间根目录的 MEMORY.md
|
||||
// 使用 toLowerCase 处理 Windows 不区分大小写的文件系统
|
||||
const resolved = resolve(pathStr).toLowerCase();
|
||||
const rootMemoryPath = resolve(this.workspacePath, 'MEMORY.md').toLowerCase();
|
||||
|
||||
// 精确匹配:路径必须等于 {workspacePath}/MEMORY.md
|
||||
return resolved === rootMemoryPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的流式调用(v0.2.0: 指数退避)
|
||||
*
|
||||
@@ -687,10 +786,11 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
try {
|
||||
// 首次尝试直接 yield
|
||||
if (attempt === 0) {
|
||||
yield* this.adapter.chatStream(request);
|
||||
yield* this.adapter.sendStream(request);
|
||||
return;
|
||||
}
|
||||
// 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta
|
||||
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'RETRY' as never,移除不安全的类型断言
|
||||
yield {
|
||||
type: MetonaStreamEventType.ERROR,
|
||||
requestId: request.meta.requestId,
|
||||
@@ -699,12 +799,12 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
seq: 0,
|
||||
timestamp: Date.now(),
|
||||
error: {
|
||||
code: 'RETRY' as never,
|
||||
code: MetonaErrorCode.RETRY,
|
||||
message: `Retrying after error (attempt ${attempt + 1}/${this.config.retryCount + 1})`,
|
||||
retryable: true,
|
||||
},
|
||||
} as MetonaStreamEvent;
|
||||
yield* this.adapter.chatStream(request);
|
||||
};
|
||||
yield* this.adapter.sendStream(request);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -861,7 +961,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口
|
||||
const conversationText = toCompress.map((m) => {
|
||||
const role = m.role.toUpperCase();
|
||||
return `[${role}] ${m.content}`;
|
||||
return `[${role}] ${m.content ?? ''}`;
|
||||
}).join('\n\n');
|
||||
|
||||
const summaryRequest: MetonaRequest = {
|
||||
@@ -892,7 +992,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.adapter.chat(summaryRequest);
|
||||
const response = await this.adapter.send(summaryRequest);
|
||||
const summary = response.content.trim();
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
Reference in New Issue
Block a user