feat: 升级至 v0.3.16 — 工单修复 51 项 + 审查修复 30 项(安全加固/适配器/工具系统/数据层/前端)
This commit is contained in:
@@ -47,7 +47,8 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
async send(request: MetonaRequest): Promise<MetonaResponse> {
|
||||
const body = this.toNativeRequest(request, false);
|
||||
|
||||
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -55,9 +56,7 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
...this.config.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// C-2 修复: 使用合并后的 signal(外部 abort + timeout)
|
||||
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
|
||||
});
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwHttpError(response, 'Agnes AI API error');
|
||||
@@ -88,7 +87,8 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
const body = this.toNativeRequest(request, true);
|
||||
|
||||
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -96,9 +96,7 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
...this.config.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// C-2 修复: 使用合并后的 signal(外部 abort + timeout)
|
||||
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
|
||||
});
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, 'Agnes AI stream error');
|
||||
|
||||
@@ -73,6 +73,9 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
*
|
||||
* @param timeoutMs 超时时间(毫秒)
|
||||
* @returns 合并后的 AbortSignal
|
||||
* @deprecated 审查修复 M20: 使用 fetchWithTimeout 替代。
|
||||
* getFetchSignal 内部 AbortSignal.timeout() 创建的 timer 在请求成功完成后仍会存活到超时,
|
||||
* 高频调用下 timer 句柄累积;fetchWithTimeout 用 setTimeout + clearTimeout 已解决此问题。
|
||||
*/
|
||||
protected getFetchSignal(timeoutMs: number): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
@@ -92,6 +95,46 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
return AbortSignal.any([timeoutSignal, this.externalAbortSignal]);
|
||||
}
|
||||
|
||||
/**
|
||||
* #24 修复: 封装 fetch + 超时控制,在 finally 中 clearTimeout,避免 timer 泄漏
|
||||
*
|
||||
* getFetchSignal 使用 AbortSignal.timeout() 内部创建的 timer 在请求成功完成后
|
||||
* 仍会存活到超时,高频调用下 timer 句柄累积。本方法使用 setTimeout + clearTimeout
|
||||
* 确保 fetch 完成(无论成功/失败/abort)后立即清理 timer。
|
||||
*
|
||||
* @param url 请求 URL
|
||||
* @param init fetch init(不含 signal,由本方法内部管理)
|
||||
* @param timeoutMs 超时时间(毫秒)
|
||||
*/
|
||||
protected async fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
// 审查修复 M20: 保存 listener 引用,finally 中 removeEventListener 清理,避免 listener 泄漏
|
||||
const onExternalAbort = () => controller.abort();
|
||||
|
||||
try {
|
||||
// 合并外部 abort signal(来自 Engine 的 abortController)
|
||||
if (this.externalAbortSignal) {
|
||||
if (this.externalAbortSignal.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
this.externalAbortSignal.addEventListener('abort', onExternalAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
// #24 修复: 关键 — 无论请求成功、失败还是 abort,都清理 timer
|
||||
clearTimeout(timer);
|
||||
// 审查修复 M20: 清理 externalAbortSignal 上注册的 listener
|
||||
// (即使 { once: true },请求正常完成时 listener 仍挂在 signal 上直到 abort 或 GC,需显式移除)
|
||||
if (this.externalAbortSignal) {
|
||||
this.externalAbortSignal.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.listModels();
|
||||
@@ -150,7 +193,12 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('401') || msg.includes('unauthorized')) {
|
||||
// #23 修复: 优先基于 HTTP status code 判断 401/429,避免字符串 includes 误匹配 URL 端口等数字
|
||||
// throwHttpError 已将 response.status 挂到 error.status,优先读取此字段
|
||||
const httpStatus = (error as Error & { status?: number }).status;
|
||||
|
||||
// #23 修复: 401 认证失败 — 优先用 status code,'unauthorized' 是单词不会误匹配
|
||||
if (httpStatus === 401 || msg.includes('unauthorized')) {
|
||||
return {
|
||||
code: MetonaErrorCode.AUTH_INVALID,
|
||||
message: 'API key 无效或已过期',
|
||||
@@ -159,7 +207,8 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('429') || msg.includes('rate limit')) {
|
||||
// #23 修复: 429 限流 — 优先用 status code,'rate limit' 是单词不会误匹配
|
||||
if (httpStatus === 429 || msg.includes('rate limit')) {
|
||||
return {
|
||||
code: MetonaErrorCode.RATE_LIMITED,
|
||||
message: '请求过于频繁,请稍后重试',
|
||||
|
||||
@@ -52,7 +52,8 @@ export class DeepSeekAdapter extends BaseAdapter {
|
||||
async send(request: MetonaRequest): Promise<MetonaResponse> {
|
||||
const body = this.toNativeRequest(request, false);
|
||||
|
||||
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -60,9 +61,7 @@ export class DeepSeekAdapter extends BaseAdapter {
|
||||
...this.config.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// C-2 修复: 使用合并后的 signal(外部 abort + timeout)
|
||||
signal: this.getFetchSignal(this.config.timeoutMs ?? 120_000),
|
||||
});
|
||||
}, this.config.timeoutMs ?? 120_000);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwHttpError(response, 'DeepSeek API error');
|
||||
@@ -93,7 +92,8 @@ export class DeepSeekAdapter extends BaseAdapter {
|
||||
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
const body = this.toNativeRequest(request, true);
|
||||
|
||||
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -101,9 +101,7 @@ export class DeepSeekAdapter extends BaseAdapter {
|
||||
...this.config.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// C-2 修复: 使用合并后的 signal(外部 abort + timeout)
|
||||
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
|
||||
});
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, 'DeepSeek stream error');
|
||||
|
||||
@@ -59,7 +59,8 @@ export class MimoAdapter extends BaseAdapter {
|
||||
async send(request: MetonaRequest): Promise<MetonaResponse> {
|
||||
const body = this.toNativeRequest(request, false);
|
||||
|
||||
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -67,8 +68,7 @@ export class MimoAdapter extends BaseAdapter {
|
||||
...this.config.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: this.getFetchSignal(this.config.timeoutMs ?? 120_000),
|
||||
});
|
||||
}, this.config.timeoutMs ?? 120_000);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwHttpError(response, 'MiMo API error');
|
||||
@@ -98,7 +98,8 @@ export class MimoAdapter extends BaseAdapter {
|
||||
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
const body = this.toNativeRequest(request, true);
|
||||
|
||||
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -106,8 +107,7 @@ export class MimoAdapter extends BaseAdapter {
|
||||
...this.config.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
|
||||
});
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, 'MiMo stream error');
|
||||
@@ -194,7 +194,12 @@ export class MimoAdapter extends BaseAdapter {
|
||||
model: this.config.defaultModel,
|
||||
messages,
|
||||
// MiMo 使用 max_completion_tokens(非 max_tokens)
|
||||
max_completion_tokens: request.params.maxTokens,
|
||||
// #41 修复: thinking 模式下 max_completion_tokens 未配置时使用兜底默认值(32768)
|
||||
// thinking 占用 token 配额,未配置时 API 默认值可能过小导致输出被截断
|
||||
// thinkingEnabled !== false 包含 true 和 undefined(MiMo 默认 enabled)两种情况
|
||||
// 审查修复: 兜底值从 63488 改为 32768,避免超出标准版上限导致 API 400
|
||||
max_completion_tokens: request.params.maxTokens
|
||||
?? (request.params.thinkingEnabled !== false ? 32768 : undefined),
|
||||
stream,
|
||||
};
|
||||
|
||||
|
||||
@@ -50,15 +50,15 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
|
||||
// H-2 修复: chat → send(规范要求)
|
||||
async send(request: MetonaRequest): Promise<MetonaResponse> {
|
||||
const nativeRequest = this.toNativeRequest(request);
|
||||
// #2 修复: toNativeRequest 改为 async(需下载 URL 图片转 base64)
|
||||
const nativeRequest = await this.toNativeRequest(request);
|
||||
|
||||
const response = await fetch(`${this.baseURL}/api/chat`, {
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...nativeRequest, stream: false }),
|
||||
// C-2 修复: 使用合并后的 signal(外部 abort + timeout)
|
||||
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
|
||||
});
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.throwHttpError(response, 'Ollama API error');
|
||||
@@ -70,15 +70,15 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
|
||||
// H-2 修复: chatStream → sendStream(规范要求)
|
||||
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
const nativeRequest = this.toNativeRequest(request);
|
||||
// #2 修复: toNativeRequest 改为 async(需下载 URL 图片转 base64)
|
||||
const nativeRequest = await this.toNativeRequest(request);
|
||||
|
||||
const response = await fetch(`${this.baseURL}/api/chat`, {
|
||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||
const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...nativeRequest, stream: true }),
|
||||
// C-2 修复: 使用合并后的 signal(外部 abort + timeout)
|
||||
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
|
||||
});
|
||||
}, this.config.timeoutMs ?? 300_000);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await this.throwHttpError(response, 'Ollama stream error');
|
||||
@@ -409,8 +409,33 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
|
||||
// ========== 私有转换方法 ==========
|
||||
|
||||
private toNativeRequest(request: MetonaRequest): Record<string, unknown> {
|
||||
const messages = [
|
||||
/**
|
||||
* #2 修复: 下载 http(s) URL 图片并转为纯 base64 字符串(不含 data: 前缀)
|
||||
*
|
||||
* Ollama API 的 images 字段要求纯 base64 字符串数组。
|
||||
* 当 MetonaMessage.images 中存储的是 URL 时,需先下载转为 base64。
|
||||
* 下载失败时返回空字符串(Ollama 会忽略空图片),不阻断整个请求。
|
||||
*/
|
||||
private async resolveImageToBase64(url: string): Promise<string> {
|
||||
try {
|
||||
// 审查修复: 使用基类 fetchWithTimeout 合并 externalAbortSignal 和 30s 超时,
|
||||
// 避免用户中断时图片下载最多阻塞 30s×N(externalAbortSignal 是 BaseAdapter 的
|
||||
// private 属性,子类无法直接访问,故复用已合并 signal 的 fetchWithTimeout,
|
||||
// 该方法同时处理了 listener 泄漏问题)
|
||||
const res = await this.fetchWithTimeout(url, {}, 30_000);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
return buf.toString('base64');
|
||||
} catch (error) {
|
||||
log.warn(`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private async toNativeRequest(request: MetonaRequest): Promise<Record<string, unknown>> {
|
||||
const messages: Record<string, unknown>[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
@@ -420,20 +445,34 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
request.systemPrompt.dynamicReminders,
|
||||
].filter(Boolean).join('\n\n'),
|
||||
},
|
||||
...request.messages.filter((m) => m.role !== 'system').map((m) => {
|
||||
];
|
||||
|
||||
// #2 修复: 改为 for 循环以支持 async 图片下载(map 回调无法 await)
|
||||
for (const m of request.messages) {
|
||||
if (m.role === 'system') continue;
|
||||
// C-6 修复: Ollama API 不支持 null content,assistant 仅有 tool_calls 时转为空字符串
|
||||
const msg: Record<string, unknown> = { role: m.role, content: m.content ?? '' };
|
||||
// Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀)
|
||||
if (m.images?.length) {
|
||||
msg.images = m.images.map((img) => {
|
||||
// #2 修复: 支持公网 URL 图片,下载后转为纯 base64
|
||||
// 之前直接将 URL 字符串传给 Ollama,导致 base64 解码错误
|
||||
const resolvedImages: string[] = [];
|
||||
for (const img of m.images) {
|
||||
const url = img.url;
|
||||
// data:image/png;base64,iVBOR... → iVBOR...
|
||||
if (url.startsWith('data:')) {
|
||||
// data:image/png;base64,iVBOR... → iVBOR...
|
||||
const base64Part = url.split(',')[1];
|
||||
return base64Part ?? url;
|
||||
resolvedImages.push(base64Part ?? url);
|
||||
} else if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
// #2 修复: 公网 URL → 下载 → 纯 base64
|
||||
const base64 = await this.resolveImageToBase64(url);
|
||||
if (base64) resolvedImages.push(base64);
|
||||
} else {
|
||||
// 已是纯 base64 字符串(无 data: 前缀)
|
||||
resolvedImages.push(url);
|
||||
}
|
||||
return url;
|
||||
});
|
||||
}
|
||||
msg.images = resolvedImages;
|
||||
}
|
||||
// 工具结果
|
||||
if (m.role === 'tool' && m.toolResult) {
|
||||
@@ -455,9 +494,8 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
if (m.role === 'assistant' && m.reasoningContent) {
|
||||
(msg as Record<string, unknown>).reasoning_content = m.reasoningContent;
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
];
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.config.defaultModel,
|
||||
|
||||
@@ -46,6 +46,11 @@ export function buildOpenAICompatibleMessages(
|
||||
content: m.content,
|
||||
};
|
||||
|
||||
// 注意:图片(多模态)处理不在此共享函数中。
|
||||
// DeepSeek 不支持多模态,images 被静默丢弃是正确行为。
|
||||
// Agnes/MiMo 各自的 toNativeRequest 中有独立的 images 处理。
|
||||
// 审查修复: #27 曾在此添加 images 处理,但 DeepSeek 不支持多模态会导致 API 400,已撤销。
|
||||
|
||||
// === Assistant 消息 ===
|
||||
if (m.role === 'assistant') {
|
||||
// 工具调用历史
|
||||
@@ -74,6 +79,10 @@ export function buildOpenAICompatibleMessages(
|
||||
: (typeof m.toolResult.result === 'string'
|
||||
? m.toolResult.result
|
||||
: JSON.stringify(m.toolResult.result));
|
||||
// #26 修复: 确保 tool 消息 content 不为 undefined
|
||||
// JSON.stringify(undefined) 返回 undefined(非字符串),会导致 content 字段在序列化后消失
|
||||
// OpenAI/DeepSeek/Agnes API 严格要求 tool 消息必须有 content 字段,缺失会返回 400
|
||||
if (msg.content === undefined) msg.content = '';
|
||||
}
|
||||
|
||||
return msg;
|
||||
|
||||
@@ -52,8 +52,14 @@ function* flushToolCallBuffer(
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// JSON 解析失败,跳过该工具调用
|
||||
} catch (err) {
|
||||
// #25 修复: 不再静默丢弃 JSON 解析失败的工具调用
|
||||
// 审查修复: 不再 yield ERROR 事件,因为 Engine 收到 ERROR 会 throw 中断整个请求,
|
||||
// 导致一个好的工具调用 JSON 解析失败就丢弃所有工具调用。
|
||||
// 改为 log.warn 记录后 continue 跳过这条坏的工具调用,继续处理 buffer 中剩余的。
|
||||
const rawPreview = buf.argsBuffer?.slice(0, 200) ?? '';
|
||||
log.warn(`[SSE] Tool call JSON parse failed: ${(err as Error).message}`, rawPreview);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
toolCallsBuffer.clear();
|
||||
|
||||
@@ -125,6 +125,22 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
*/
|
||||
setAdapter(adapter: IMetonaProviderAdapter): void {
|
||||
this.adapter = adapter;
|
||||
// #3 修复: 热切换 adapter 时同步 contextWindow,防止压缩阈值计算错误
|
||||
this.syncContextWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* #3 修复: 从 adapter 同步 contextWindow 到 Engine 配置
|
||||
*
|
||||
* Engine 的 DEFAULT_CONFIG.contextWindow 硬编码为 128_000,但各 Provider 实际支持的
|
||||
* 上下文窗口差异巨大(DeepSeek 64K / Agnes 1M / Ollama 4096)。
|
||||
* 不同步会导致压缩阈值(compressionThreshold * contextWindow)计算错误。
|
||||
*/
|
||||
private syncContextWindow(): void {
|
||||
const adapterCtx = this.adapter.getContextWindow?.();
|
||||
if (adapterCtx && adapterCtx > 0) {
|
||||
this.config.contextWindow = adapterCtx;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,6 +197,9 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
if (this.adapter.setAbortSignal) {
|
||||
this.adapter.setAbortSignal(this.abortController.signal);
|
||||
}
|
||||
// #3 修复: 每次 runStream 开始时从 adapter 同步 contextWindow,
|
||||
// 确保压缩阈值基于当前 Provider 的实际上下文窗口
|
||||
this.syncContextWindow();
|
||||
this.eventSeq = 0;
|
||||
// v0.3.0: 重置工具调用历史(用于死循环检测)
|
||||
this.toolCallHistory = [];
|
||||
@@ -302,6 +321,18 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* #4 修复: 恢复 adapter 的 abort signal
|
||||
*
|
||||
* SubEngine 共享主 Engine 的 adapter 时,SubEngine 会覆盖 adapter 的 abort signal。
|
||||
* SubEngine 完成后,主 Engine 需调用此方法恢复自己的 signal,否则后续 fetch 无法被中断。
|
||||
*/
|
||||
restoreAbortSignal(): void {
|
||||
if (this.abortController && this.adapter.setAbortSignal) {
|
||||
this.adapter.setAbortSignal(this.abortController.signal);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取工作空间路径(供 SubAgent 继承) */
|
||||
getWorkspacePath(): string {
|
||||
return this.workspacePath;
|
||||
@@ -331,14 +362,29 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
*/
|
||||
async waitForAbort(timeoutMs: number = 5_000): Promise<boolean> {
|
||||
if (!this.currentRunPromise) return true;
|
||||
// #29 修复: 原实现 Promise.race 永不抛错(currentRunPromise.catch 已吞异常),
|
||||
// 导致 try/catch 永远走不到,总是返回 true。调用方误以为 abort 完成,
|
||||
// 立即重发的新消息会卡在 runStream 的 currentRunPromise 等待中。
|
||||
// 改为用 timedOut 标志检测超时,超时返回 false 让调用方提示用户"上一次操作未完成"。
|
||||
let timedOut = false;
|
||||
// 审查修复: 用 finally 块清理 timer,避免 currentRunPromise 先完成时 setTimeout 残留
|
||||
let timerHandle: NodeJS.Timeout | undefined;
|
||||
const timer = new Promise<void>((resolve) => {
|
||||
timerHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
this.currentRunPromise.catch(() => {}),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
||||
timer,
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
return !timedOut; // 超时返回 false,run 正常结束返回 true
|
||||
} finally {
|
||||
// 审查修复: 无论 race 谁先完成,都清理 timer 防止事件循环残留
|
||||
if (timerHandle) clearTimeout(timerHandle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1058,18 +1104,30 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
if (!summary) return null;
|
||||
|
||||
const summaryMessage: MetonaMessage = {
|
||||
// CE-1 修复: 使用 role: 'user' 而非 'system'
|
||||
// adapter 的 buildOpenAICompatibleMessages 会过滤所有 role !== 'system' 的消息,
|
||||
// 只保留 systemPrompt 构建的 system 消息。如果用 'system',摘要会被丢弃,压缩无效。
|
||||
// 改为 'user' 后 LLM 会看到:system(systemPrompt) → user(摘要) → ...toKeep
|
||||
role: 'user',
|
||||
// #30 修复: 改用 assistant 角色注入摘要,避免语义混淆
|
||||
// 原 CE-1 修复用 'user' 角色,会导致 LLM 将摘要误视为新的用户指令,
|
||||
// 可能基于"Summary of previous conversation"字面意思执行奇怪操作。
|
||||
// 工单建议方案 A(system 角色)不可行:buildOpenAICompatibleMessages 会
|
||||
// 过滤所有 role === 'system' 的消息(只保留 systemPrompt 构建的 system 消息),
|
||||
// 用 system 角色摘要会被丢弃,压缩无效。
|
||||
// 采用 assistant 角色:既不会被过滤,又保持语义中立(摘要是 AI 生成的总结),
|
||||
// LLM 不会将其视为新的用户指令。
|
||||
role: 'assistant',
|
||||
content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${toKeep.length} recent`);
|
||||
|
||||
return [summaryMessage, ...toKeep];
|
||||
// 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息
|
||||
// (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。
|
||||
// 插入占位 user 消息保证对话流清晰。
|
||||
return [
|
||||
summaryMessage,
|
||||
// 审查修复: 插入占位 user 消息避免连续 assistant 消息
|
||||
{ role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() },
|
||||
...toKeep,
|
||||
];
|
||||
} catch (error) {
|
||||
log.warn('[AgentLoop] Context compression failed, keeping original messages:', (error as Error).message);
|
||||
return null;
|
||||
|
||||
@@ -324,8 +324,21 @@ export class ConfirmationHook implements PreToolHook {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const expiresAt = Date.now() + this.confirmationTimeoutMs;
|
||||
|
||||
// #18 修复: 超时与用户确认的竞态条件防护
|
||||
// 场景:超时 setTimeout 回调已进入事件循环队列但尚未执行时,用户点击确认,
|
||||
// resolveConfirmation 中 clearTimeout 无法取消已排队的回调,
|
||||
// 导致用户已确认但仍弹出"超时 toast"等副作用。
|
||||
// 使用 settled 标志确保超时分支与确认分支互斥,先到者赢,另一分支直接 return。
|
||||
let settled = false;
|
||||
const safeResolve = (v: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(v);
|
||||
};
|
||||
|
||||
// 设置超时
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return; // 已被 resolveConfirmation 处理,跳过超时副作用
|
||||
this.pendingConfirmations.delete(request.toolCallId);
|
||||
// 超时发送 toast 通知用户(3 秒节流,防止并行工具风暴)
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
@@ -343,11 +356,11 @@ export class ConfirmationHook implements PreToolHook {
|
||||
});
|
||||
}
|
||||
}
|
||||
resolve(false); // 超时视为拒绝
|
||||
safeResolve(false); // 超时视为拒绝
|
||||
}, this.confirmationTimeoutMs);
|
||||
|
||||
this.pendingConfirmations.set(request.toolCallId, {
|
||||
resolve,
|
||||
resolve: safeResolve,
|
||||
timer,
|
||||
toolName: request.toolName,
|
||||
expiresAt,
|
||||
|
||||
@@ -20,16 +20,24 @@ export class AuditLogHook implements PostToolHook {
|
||||
constructor(private auditService: AuditService) {}
|
||||
|
||||
async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void> {
|
||||
this.auditService.logToolCall({
|
||||
sessionId,
|
||||
iteration: toolCall.iteration,
|
||||
toolName: toolCall.name,
|
||||
args: toolCall.args,
|
||||
outcome: result.success ? 'success' : 'error',
|
||||
result: result.result,
|
||||
error: result.error,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
// #17 修复: AuditHook 应为 "fire and forget",hook 失败不应影响工具执行链
|
||||
// 虽然 AuditService.log() 内部已 try-catch,但 hook 层再加一层防御,
|
||||
// 确保任何意外异常(如 getDB 抛错、JSON.stringify 失败)都不会冒泡到 ToolRegistry
|
||||
try {
|
||||
this.auditService.logToolCall({
|
||||
sessionId,
|
||||
iteration: toolCall.iteration,
|
||||
toolName: toolCall.name,
|
||||
args: toolCall.args,
|
||||
outcome: result.success ? 'success' : 'error',
|
||||
result: result.result,
|
||||
error: result.error,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('[AuditLogHook] Failed to log audit:', err);
|
||||
// 不抛出,让工具执行链继续
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { createHash } from 'crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import log from 'electron-log';
|
||||
|
||||
@@ -352,17 +353,21 @@ export class MemoryManager {
|
||||
break;
|
||||
case 'semantic':
|
||||
// v0.3.0 修复:使用 summary 作为 key(若提供),支持更新已有语义记忆
|
||||
// #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重
|
||||
// v0.3.0 用 id 作为 key 时,因 id 每次新生成,INSERT OR REPLACE 永远不触发 REPLACE,
|
||||
// 导致重复 store 同一内容会创建多条记忆。改为 contentHash 后,相同内容自动 REPLACE。
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
`).run(id, item.summary ?? id, item.content, 'general', importance, item.sessionId ?? null, now, now);
|
||||
`).run(id, item.summary ?? this.contentHash(item.content), item.content, 'general', importance, item.sessionId ?? null, now, now);
|
||||
break;
|
||||
case 'working':
|
||||
// v0.3.0 修复:使用 summary 作为 key(若提供),避免硬编码 'default' 导致覆盖
|
||||
// #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, item.sessionId ?? 'default', 'default', item.summary ?? id, item.content, now);
|
||||
`).run(id, item.sessionId ?? 'default', 'default', item.summary ?? this.contentHash(item.content), item.content, now);
|
||||
break;
|
||||
default:
|
||||
// v0.3.0 修复:未知 type 抛错而非静默失败
|
||||
@@ -519,4 +524,13 @@ export class MemoryManager {
|
||||
if (item.content.length > 200) score += 0.1;
|
||||
return Math.min(1, Math.max(0, score));
|
||||
}
|
||||
|
||||
/**
|
||||
* #32 修复: 计算 content 的 SHA-256 hash(取前 16 字符),用于基于内容的去重
|
||||
* 当 store 未提供 summary 时,用 contentHash 作为 semantic/working 的 key,
|
||||
* 使 INSERT OR REPLACE 能基于内容触发 REPLACE,避免重复存储相同内容。
|
||||
*/
|
||||
private contentHash(content: string): string {
|
||||
return createHash('sha256').update(content, 'utf-8').digest('hex').slice(0, 16);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,13 +176,6 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
|
||||
handle.status = success ? 'completed' : 'error';
|
||||
handle.result = result;
|
||||
this.activeSubAgents.delete(taskId);
|
||||
// v0.3.0 修复: 深度恢复为 0 时删除条目,避免 sessionDepth Map 随会话累积
|
||||
if (currentDepth === 0) {
|
||||
this.sessionDepth.delete(params.parentSessionId);
|
||||
} else {
|
||||
this.sessionDepth.set(params.parentSessionId, currentDepth);
|
||||
}
|
||||
this.emit('taskCompleted', result);
|
||||
|
||||
log.info(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`);
|
||||
@@ -201,18 +194,28 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
|
||||
handle.status = 'error';
|
||||
handle.result = result;
|
||||
this.activeSubAgents.delete(taskId);
|
||||
// v0.3.0 修复: 深度恢复为 0 时删除条目,避免 sessionDepth Map 随会话累积
|
||||
if (currentDepth === 0) {
|
||||
this.sessionDepth.delete(params.parentSessionId);
|
||||
} else {
|
||||
this.sessionDepth.set(params.parentSessionId, currentDepth);
|
||||
}
|
||||
this.emit('taskError', { taskId, error: errMsg });
|
||||
|
||||
log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`);
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
// #5 修复: 统一在 finally 块恢复 sessionDepth,覆盖正常完成/异常/abort 所有路径
|
||||
// 之前 try 和 catch 中各有一份重复的恢复逻辑,且 abort 路径(handle.abort)跳过了恢复
|
||||
// 审查修复: 如果 abortAll 已 clear sessionDepth,不再恢复(避免覆盖紧急清理)。
|
||||
// 场景:用户紧急中断时 abortAll 先 clear,若 SubEngine 随后才返回执行 finally,
|
||||
// 不应把已清空的 sessionDepth 又 set 回 currentDepth。
|
||||
if (this.sessionDepth.has(params.parentSessionId)) {
|
||||
if (currentDepth === 0) {
|
||||
this.sessionDepth.delete(params.parentSessionId);
|
||||
} else {
|
||||
this.sessionDepth.set(params.parentSessionId, currentDepth);
|
||||
}
|
||||
}
|
||||
this.activeSubAgents.delete(taskId);
|
||||
// #4 修复: SubEngine 共享主 Engine 的 adapter,完成后必须恢复主 Engine 的 abort signal,
|
||||
// 否则主 Engine 后续 fetch 无法被用户中断(SubEngine 覆盖并清除了 adapter 的 signal)
|
||||
this.mainEngine.restoreAbortSignal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +305,10 @@ export class TaskOrchestrator extends EventEmitter {
|
||||
agent.abort();
|
||||
}
|
||||
this.activeSubAgents.clear();
|
||||
// 审查修复: 恢复 sessionDepth.clear(),保留紧急清理能力。
|
||||
// #5 修复曾移除此行,但若 SubEngine 卡死不返回,delegate 的 finally 永远不会执行,
|
||||
// sessionDepth 将永久残留。此处 clear 确保紧急路径能立即恢复状态。
|
||||
// 配合 delegate finally 块的 has() 检查:若已被 clear,finally 不再恢复(避免覆盖)。
|
||||
this.sessionDepth.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,12 +94,17 @@ export class ContextBuilder {
|
||||
|
||||
// v0.3.14: 注入当前系统日期时间(每次构建时获取最新时间)
|
||||
// 用于让 AI 准确理解"今天"、"昨天"等相对时间表达
|
||||
// #43 修复: 时区硬编码 Asia/Shanghai 改为使用系统本地时区,跨时区用户显示正确
|
||||
const now = new Date();
|
||||
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Asia/Shanghai';
|
||||
// 审查修复: 恢复 UTC 偏移显示,在时区名后附加 UTC 偏移,避免丢失时区偏移信息
|
||||
const offset = -now.getTimezoneOffset() / 60;
|
||||
const offsetStr = offset >= 0 ? `UTC+${offset}` : `UTC${offset}`;
|
||||
const dateTimeStr = now.toLocaleString('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
timeZone: localTimezone,
|
||||
hour12: false,
|
||||
});
|
||||
dynamicParts.push(`## Current Date & Time\n${dateTimeStr} (Asia/Shanghai, UTC+8)`);
|
||||
dynamicParts.push(`## Current Date & Time\n${dateTimeStr} (${localTimezone}, ${offsetStr})`);
|
||||
|
||||
// 注入当前工作空间路径(动态区,路径可能切换故不放入静态区)
|
||||
if (workspacePath) {
|
||||
@@ -277,7 +282,20 @@ For multi-step complex tasks (3+ steps), proactively use \`task_manager\` to bre
|
||||
for (const mem of memories) total += estimateStringTokens(mem.content);
|
||||
|
||||
// 工具定义
|
||||
for (const tool of tools) total += estimateStringTokens(tool.name + tool.description);
|
||||
// #31 修复: 之前仅累加 tool.name + tool.description,忽略 tool.parameters(JSON Schema),
|
||||
// 而 parameters schema 通常占工具 token 的 70%+,导致估算严重偏低、压缩阈值判断错误
|
||||
for (const tool of tools) {
|
||||
total += estimateStringTokens(tool.name);
|
||||
total += estimateStringTokens(tool.description);
|
||||
// 审查修复: 用 try-catch 包裹 JSON.stringify,防止循环引用等异常导致估算中断
|
||||
if (tool.parameters) {
|
||||
try {
|
||||
total += estimateStringTokens(JSON.stringify(tool.parameters));
|
||||
} catch {
|
||||
total += estimateStringTokens(String(tool.parameters));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用户输入
|
||||
total += estimateStringTokens(userInput);
|
||||
|
||||
@@ -157,13 +157,32 @@ export class PolicyEngine {
|
||||
argsStr = String(args);
|
||||
}
|
||||
|
||||
// #14 修复: 深度递归扫描所有字符串字段值,防止 Unicode 转义/嵌套对象/字符串拼接绕过
|
||||
// 原 JSON.stringify 后整体正则匹配可被 \u0029 编码、嵌套对象伪装、正则注入等方式绕过
|
||||
// 现对每个字符串字段值单独匹配,同时保留整体 argsStr 兜底匹配
|
||||
const stringValues = this.deepScanStrings(args);
|
||||
|
||||
// v0.2.0: allowedPatterns 白名单校验 — 若定义了白名单,参数必须匹配其中之一
|
||||
// #14: 字段级匹配 + 整体兜底,任一匹配即通过
|
||||
if (policy.allowedPatterns && policy.allowedPatterns.length > 0) {
|
||||
let matchedAllowed = false;
|
||||
for (const pattern of policy.allowedPatterns) {
|
||||
if (pattern.test(argsStr)) {
|
||||
matchedAllowed = true;
|
||||
break;
|
||||
// 先做字段级匹配
|
||||
for (const val of stringValues) {
|
||||
for (const pattern of policy.allowedPatterns) {
|
||||
if (pattern.test(val)) {
|
||||
matchedAllowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchedAllowed) break;
|
||||
}
|
||||
// 兜底:整体 argsStr 匹配
|
||||
if (!matchedAllowed) {
|
||||
for (const pattern of policy.allowedPatterns) {
|
||||
if (pattern.test(argsStr)) {
|
||||
matchedAllowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matchedAllowed) {
|
||||
@@ -176,7 +195,22 @@ export class PolicyEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// #14 修复: deniedPatterns 字段级深度扫描 — 对每个字符串值单独匹配
|
||||
// 防止危险内容隐藏在嵌套对象或 Unicode 编码中绕过整体 stringify 匹配
|
||||
if (policy.deniedPatterns) {
|
||||
for (const val of stringValues) {
|
||||
for (const pattern of policy.deniedPatterns) {
|
||||
if (pattern.test(val)) {
|
||||
return {
|
||||
authorized: false,
|
||||
reason: 'Command blocked by security policy',
|
||||
level: policy.requiredLevel,
|
||||
requiresConfirmation: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// 兜底:整体 argsStr 匹配(保留原有行为,捕获跨字段拼接的危险模式)
|
||||
for (const pattern of policy.deniedPatterns) {
|
||||
if (pattern.test(argsStr)) {
|
||||
return {
|
||||
@@ -264,4 +298,37 @@ export class PolicyEngine {
|
||||
|
||||
// v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理,
|
||||
// 该方法属于死代码,删除以减少维护负担
|
||||
|
||||
/**
|
||||
* #14 修复: 深度递归扫描对象,提取所有字符串字段值
|
||||
*
|
||||
* 替代 JSON.stringify 后整体正则匹配的方式,防止:
|
||||
* - Unicode 转义绕过(\u0029 等编码)
|
||||
* - 嵌套对象伪装({a: {b: 'dangerous'}})
|
||||
* - 字符串拼接绕过('rm ' + '-rf /' 在 stringify 后是合法字符串)
|
||||
* - 正则注入(输入中包含正则元字符破坏匹配逻辑)
|
||||
*
|
||||
* @param obj 待扫描的对象
|
||||
* @returns 所有字符串字段值的数组
|
||||
*/
|
||||
// 审查修复: 添加 visited Set 参数防止循环引用导致无限递归栈溢出
|
||||
private deepScanStrings(obj: unknown, visited: Set<unknown> = new Set()): string[] {
|
||||
const results: string[] = [];
|
||||
if (typeof obj === 'string') {
|
||||
results.push(obj);
|
||||
} else if (Array.isArray(obj)) {
|
||||
if (visited.has(obj)) return results;
|
||||
visited.add(obj);
|
||||
for (const item of obj) {
|
||||
results.push(...this.deepScanStrings(item, visited));
|
||||
}
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
if (visited.has(obj)) return results;
|
||||
visited.add(obj);
|
||||
for (const v of Object.values(obj)) {
|
||||
results.push(...this.deepScanStrings(v, visited));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export class SandboxManager {
|
||||
*/
|
||||
scanCode(code: string): { safe: boolean; reason?: string } {
|
||||
// C-5 修复: 补齐 28 个模式 + 加强 base64/$() 检测
|
||||
// #13 修复: 补全 require('fs')、动态 import、process.binding、new Function、Reflect.get 等绕过模式
|
||||
// @see project_memory.md — sandbox scanCode must include 28 patterns with 'i' flag
|
||||
// and base64/$() detection to prevent encoding bypass
|
||||
const dangerousPatterns = [
|
||||
@@ -102,6 +103,22 @@ export class SandboxManager {
|
||||
/require\s*\(\s*['"]child_process['"]\s*\)/i,
|
||||
/import\s+.*from\s+['"]fs['"]/i,
|
||||
/import\s+.*from\s+['"]child_process['"]/i,
|
||||
// #13 新增: require('fs') 同步 require 形式(1)
|
||||
/require\s*\(\s*['"]fs['"]\s*\)/i,
|
||||
// #13 新增: 动态 import('fs') / import('child_process')(2)
|
||||
/\bimport\s*\(\s*['"](?:fs|child_process|os|net|http|https|crypto|dns|cluster)['"]\s*\)/i,
|
||||
// 审查修复 (M8): 兜底检测所有动态 import 调用 — 原模式只匹配字符串字面量,
|
||||
// 动态 import 用变量 import(m) 可绕过。沙箱中应禁止所有动态 import。
|
||||
// 注意:此模式较宽泛,会拦截 import(safeModule),但沙箱 fail-closed 策略可接受。
|
||||
/\bimport\s*\(/i,
|
||||
// #13 新增: process.binding('fs') 底层绑定(1)
|
||||
/\bprocess\.binding\s*\(/i,
|
||||
// #13 新增: new Function() 函数构造(1)
|
||||
/\bnew\s+Function\s*\(/i,
|
||||
// #13 新增: Reflect.get 字符串拼接绕过(1)
|
||||
/\bReflect\.get\s*\(/i,
|
||||
// #13 新增: Function('return process') 等函数构造执行(1)
|
||||
/\bFunction\s*\(\s*['"]return\s+(?:process|require|global|globalThis)['"]\s*\)/i,
|
||||
// 代码执行(3)
|
||||
/\beval\s*\(/i,
|
||||
/process\.exit/i,
|
||||
|
||||
@@ -42,16 +42,17 @@ export class PromptInjectionDefender {
|
||||
private static INJECTION_PATTERNS: InjectionPattern[] = [
|
||||
// === HIGH 危险:直接越狱/忽略指令 ===
|
||||
// v0.3.0 修复:在关键词间允许可选限定词(the/any/all/these/those/your 等),防止 "ignore the previous instructions" 绕过
|
||||
{ pattern: /ignore\s+(?:(?:the|all|any|these|those|your|above)\s+)*(?:previous|above|prior)\s+(?:(?:the|your|all)\s+)*(?:instructions?|commands?|directives?|rules?)/i, severity: 'high' },
|
||||
{ pattern: /forget\s+(?:(?:everything|all|the|your|above|prior)\s+)+/i, severity: 'high' },
|
||||
{ pattern: /override\s+(?:(?:the|your|all|any)\s+)+/i, severity: 'high' },
|
||||
{ pattern: /JAILBREAK/i, severity: 'high' },
|
||||
{ pattern: /DAN\s*[:\[]/i, severity: 'high' },
|
||||
{ pattern: /(?:enable|turn\s+on|activate)\s+(?:(?:the|your)\s+)*(?:developer|debug|root|admin)\s+mode/i, severity: 'high' },
|
||||
{ pattern: /(?:disable|turn\s+off|bypass)\s+(?:(?:the|your|all|any)\s+)*(?:safety|security|filter|guard|defense|restrictions?)/i, severity: 'high' },
|
||||
{ pattern: /(?:send|transmit|exfiltrate|upload)\s+(?:(?:your|the|all|any)\s+)*(?:data|memory|context|secrets?)/i, severity: 'high' },
|
||||
{ pattern: /(?:you\s+are|act\s+as|pretend\s+to\s+be)\s+(?:a|an)\s+(?:unrestricted|unfiltered|unlimited|free)/i, severity: 'high' },
|
||||
{ pattern: /(?:in|under)\s+(?:(?:the|a)\s+)*(?:developer|unrestricted|jailbreak|god)\s+mode/i, severity: 'high' },
|
||||
// #15 修复: 添加 \b 单词边界,防止 "Xignore previous instructions" / "ignore previous instructionss" 等前后缀绕过
|
||||
{ pattern: /\bignore\s+(?:(?:the|all|any|these|those|your|above)\s+)*(?:previous|above|prior)\s+(?:(?:the|your|all)\s+)*(?:instructions?|commands?|directives?|rules?)\b/i, severity: 'high' },
|
||||
{ pattern: /\bforget\s+(?:(?:everything|all|the|your|above|prior)\s+)+/i, severity: 'high' },
|
||||
{ pattern: /\boverride\s+(?:(?:the|your|all|any)\s+)+/i, severity: 'high' },
|
||||
{ pattern: /\bJAILBREAK\b/i, severity: 'high' },
|
||||
{ pattern: /\bDAN\s*[:\[]/i, severity: 'high' },
|
||||
{ pattern: /\b(?:enable|turn\s+on|activate)\s+(?:(?:the|your)\s+)*(?:developer|debug|root|admin)\s+mode\b/i, severity: 'high' },
|
||||
{ pattern: /\b(?:disable|turn\s+off|bypass)\s+(?:(?:the|your|all|any)\s+)*(?:safety|security|filter|guard|defense|restrictions?)\b/i, severity: 'high' },
|
||||
{ pattern: /\b(?:send|transmit|exfiltrate|upload)\s+(?:(?:your|the|all|any)\s+)*(?:data|memory|context|secrets?)\b/i, severity: 'high' },
|
||||
{ pattern: /\b(?:you\s+are|act\s+as|pretend\s+to\s+be)\s+(?:a|an)\s+(?:unrestricted|unfiltered|unlimited|free)\b/i, severity: 'high' },
|
||||
{ pattern: /\b(?:in|under)\s+(?:(?:the|a)\s+)*(?:developer|unrestricted|jailbreak|god)\s+mode\b/i, severity: 'high' },
|
||||
// 中文高危
|
||||
{ pattern: /忽略(?:以上|之前|前面|上面|所有)(?:的)?(?:指令|指示|命令|说明|规则)/i, severity: 'high' },
|
||||
{ pattern: /忘记(?:之前|上面|所有)(?:的)?(?:内容|对话|指令)/i, severity: 'high' },
|
||||
@@ -59,15 +60,16 @@ export class PromptInjectionDefender {
|
||||
{ pattern: /无视(?:以上|之前|前面|所有)(?:的)?(?:指令|指示|命令|规则)/i, severity: 'high' },
|
||||
|
||||
// === MEDIUM 危险:角色扮演/权限提升/编码注入 ===
|
||||
{ pattern: /you\s+are\s+now/i, severity: 'medium' },
|
||||
{ pattern: /new\s+(instructions|directive|role|persona)/i, severity: 'medium' },
|
||||
{ pattern: /(show|display|print|output|reveal|tell\s+me)\s+(your|the)\s+(system\s+)?(prompt|instruction|directive)/i, severity: 'medium' },
|
||||
{ pattern: /(dump|echo|log|expose)\s+(your|the)\s+(context|memory|history)/i, severity: 'medium' },
|
||||
{ pattern: /pretend\s+(you\s+are|to\s+be)/i, severity: 'medium' },
|
||||
{ pattern: /act\s+as\s+(if\s+you\s+were|you're)/i, severity: 'medium' },
|
||||
{ pattern: /(?:grant|give)\s+(me|you|us)\s+(admin|root|sudo|super)/i, severity: 'medium' },
|
||||
{ pattern: /base64\s*:?\s*[A-Za-z0-9+/=]{20,}/i, severity: 'medium' },
|
||||
{ pattern: /eval\s*\(\s*atob\s*\(/i, severity: 'medium' },
|
||||
// #15 修复: 英文关键词模式添加 \b 单词边界
|
||||
{ pattern: /\byou\s+are\s+now\b/i, severity: 'medium' },
|
||||
{ pattern: /\bnew\s+(instructions|directive|role|persona)\b/i, severity: 'medium' },
|
||||
{ pattern: /\b(show|display|print|output|reveal|tell\s+me)\s+(your|the)\s+(system\s+)?(prompt|instruction|directive)\b/i, severity: 'medium' },
|
||||
{ pattern: /\b(dump|echo|log|expose)\s+(your|the)\s+(context|memory|history)\b/i, severity: 'medium' },
|
||||
{ pattern: /\bpretend\s+(you\s+are|to\s+be)\b/i, severity: 'medium' },
|
||||
{ pattern: /\bact\s+as\s+(if\s+you\s+were|you're)\b/i, severity: 'medium' },
|
||||
{ pattern: /\b(?:grant|give)\s+(me|you|us)\s+(admin|root|sudo|super)\b/i, severity: 'medium' },
|
||||
{ pattern: /\bbase64\s*:?\s*[A-Za-z0-9+/=]{20,}/i, severity: 'medium' },
|
||||
{ pattern: /\beval\s*\(\s*atob\s*\(/i, severity: 'medium' },
|
||||
{ pattern: /\\x[0-9a-f]{2}\\x[0-9a-f]{2}\\x[0-9a-f]{2}/i, severity: 'medium' },
|
||||
{ pattern: /\\u[0-9a-f]{4}\\u[0-9a-f]{4}/i, severity: 'medium' },
|
||||
// 中文中危
|
||||
@@ -103,11 +105,17 @@ export class PromptInjectionDefender {
|
||||
recommendation: 'PASS: No injection patterns detected',
|
||||
};
|
||||
}
|
||||
|
||||
// #16 修复: Unicode 归一化 + 移除零宽字符,防止同形字符与零宽字符绕过
|
||||
// 攻击示例:іgnore (西里尔 і)、ignore\u200bprevious (零宽空格)、ignore\u00adprevious (软连字符)
|
||||
// NFKC 归一化将兼容等价字符统一为规范形式,零宽字符移除后关键词检测可正常工作
|
||||
const normalized = this.normalizeForDetection(input);
|
||||
|
||||
const findings: InjectionFinding[] = [];
|
||||
let riskScore = 0;
|
||||
|
||||
for (const { pattern, severity } of PromptInjectionDefender.INJECTION_PATTERNS) {
|
||||
const matches = input.match(pattern);
|
||||
const matches = normalized.match(pattern);
|
||||
if (matches) {
|
||||
findings.push({
|
||||
pattern: pattern.source,
|
||||
@@ -118,6 +126,17 @@ export class PromptInjectionDefender {
|
||||
}
|
||||
}
|
||||
|
||||
// #16 新增: 检测可疑 Unicode 混合脚本(拉丁 + 西里尔/希腊),提高风险分数
|
||||
// 同形字符攻击通常需要混用拉丁与西里尔/希腊字母,正常输入很少混用
|
||||
if (this.hasMixedScripts(input)) {
|
||||
findings.push({
|
||||
pattern: 'unicode:mixed_scripts',
|
||||
matched: 'Mixed Latin/Cyrillic/Greek scripts detected (potential homoglyph attack)',
|
||||
severity: 'medium',
|
||||
});
|
||||
riskScore += 3;
|
||||
}
|
||||
|
||||
return {
|
||||
isInjection: findings.length > 0,
|
||||
riskScore: Math.min(10, riskScore),
|
||||
@@ -148,21 +167,25 @@ export class PromptInjectionDefender {
|
||||
recommendation: 'PASS: No injection patterns detected',
|
||||
};
|
||||
}
|
||||
|
||||
// #16 修复: 语义检测同样使用归一化文本,防止 Unicode 绕过
|
||||
const normalized = this.normalizeForDetection(input);
|
||||
|
||||
const findings: InjectionFinding[] = [];
|
||||
let riskScore = 0;
|
||||
|
||||
// 1. 先执行正则快速检测
|
||||
// 1. 先执行正则快速检测(detect 内部已做归一化)
|
||||
const regexResult = this.detect(input);
|
||||
findings.push(...regexResult.findings);
|
||||
riskScore += regexResult.riskScore;
|
||||
|
||||
// 2. 语义级检测
|
||||
// 2. 语义级检测(使用归一化文本)
|
||||
// 2a. 指令性动词密度检测
|
||||
const imperativeCheck = this.checkImperativeDensity(input);
|
||||
const imperativeCheck = this.checkImperativeDensity(normalized);
|
||||
if (imperativeCheck.isSuspicious) {
|
||||
findings.push({
|
||||
pattern: 'semantic:imperative_density',
|
||||
matched: `${imperativeCheck.count} imperative verbs in ${input.length} chars`,
|
||||
matched: `${imperativeCheck.count} imperative verbs in ${normalized.length} chars`,
|
||||
severity: 'medium',
|
||||
});
|
||||
riskScore += 2;
|
||||
@@ -170,7 +193,7 @@ export class PromptInjectionDefender {
|
||||
|
||||
// 2b. 角色边界异常检测
|
||||
if (context) {
|
||||
const roleAnomaly = this.checkRoleBoundary(input, context);
|
||||
const roleAnomaly = this.checkRoleBoundary(normalized, context);
|
||||
if (roleAnomaly) {
|
||||
findings.push(roleAnomaly);
|
||||
riskScore += 3;
|
||||
@@ -178,14 +201,14 @@ export class PromptInjectionDefender {
|
||||
}
|
||||
|
||||
// 2c. 结构化分隔符嵌套检测
|
||||
const nestedFinding = this.checkNestedDelimiters(input);
|
||||
const nestedFinding = this.checkNestedDelimiters(normalized);
|
||||
if (nestedFinding) {
|
||||
findings.push(nestedFinding);
|
||||
riskScore += 2;
|
||||
}
|
||||
|
||||
// 2d. 隐式指令检测(通过疑问句伪装指令)
|
||||
const implicitFinding = this.checkImplicitInstructions(input);
|
||||
const implicitFinding = this.checkImplicitInstructions(normalized);
|
||||
if (implicitFinding) {
|
||||
findings.push(implicitFinding);
|
||||
riskScore += 1;
|
||||
@@ -199,6 +222,42 @@ export class PromptInjectionDefender {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* #16 修复: Unicode 归一化与零宽字符清理
|
||||
*
|
||||
* 1. NFKC 归一化:将兼容等价字符(如西里尔 і → 拉丁 i)统一为规范形式
|
||||
* 2. 移除零宽字符:零宽空格(U+200B)、零宽连字符(U+200C)、零宽不连字符(U+200D)、
|
||||
* 软连字符(U+00AD)、BOM(U+FEFF)
|
||||
*
|
||||
* 这些字符在视觉上不可见,但会破坏正则关键词匹配,被用于绕过注入检测
|
||||
*/
|
||||
private normalizeForDetection(input: string): string {
|
||||
return input
|
||||
.normalize('NFKC')
|
||||
.replace(/[\u200b\u200c\u200d\u00ad\ufeff]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* #16 新增: 检测可疑 Unicode 混合脚本
|
||||
*
|
||||
* 同形字符攻击通常混用拉丁与西里尔/希腊字母(如 іgnore 中 і 是西里尔字母)。
|
||||
* 正常技术输入很少混用这些脚本,检测到混用时提高风险分数。
|
||||
* NFKC 归一化可能无法完全消除所有同形字符,因此混合脚本检测作为补充手段。
|
||||
*/
|
||||
// 审查修复: 改为统计各脚本字符数量,只有当非主导脚本的字符总数占比 > 30% 时才触发,
|
||||
// 避免正常多语言输入(如 "Hello, Привет")误报
|
||||
private hasMixedScripts(text: string): boolean {
|
||||
const latin = (text.match(/[\u0000-\u007f]/g) || []).length;
|
||||
const cyrillic = (text.match(/[\u0400-\u04ff]/g) || []).length;
|
||||
const greek = (text.match(/[\u0370-\u03ff]/g) || []).length;
|
||||
const total = latin + cyrillic + greek;
|
||||
if (total < 10) return false; // 文本太短不检测
|
||||
const max = Math.max(latin, cyrillic, greek);
|
||||
const minorityRatio = (total - max) / total;
|
||||
// 审查修复: 只有少数脚本占比 > 30% 时才触发,避免正常多语言输入误报
|
||||
return minorityRatio > 0.3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指令性动词密度检测
|
||||
*
|
||||
|
||||
@@ -186,11 +186,44 @@ export class BrowserWindowManager {
|
||||
|
||||
// ===== browserEvaluate =====
|
||||
|
||||
/**
|
||||
* 在浏览器页面上下文中执行 JS
|
||||
*
|
||||
* #46 修复: 增强安全防护
|
||||
* - 审计日志:记录所有 executeJavaScript 调用,便于事后追溯恶意操作
|
||||
* - 超时控制:防止恶意 JS 无限阻塞主进程(Electron 原生不支持超时,用 Promise.race 模拟)
|
||||
*
|
||||
* 注意:完全沙箱隔离较难实现(需要 iframe/Web Worker + API 白名单),
|
||||
* 当前先实现审计 + 超时作为缓解措施。webPreferences 已配置 contextIsolation: true
|
||||
* 和 sandbox: true,Electron API 不会被暴露给页面。
|
||||
*/
|
||||
async evaluate(js: string): Promise<unknown> {
|
||||
this.ensureReady();
|
||||
const result = await this.win!.webContents.executeJavaScript(js, true);
|
||||
if (typeof result === 'string') return result;
|
||||
return result;
|
||||
|
||||
// #46 修复: 审计日志 — 记录所有 executeJavaScript 调用(截断前 500 字符),便于追溯
|
||||
log.info('[BrowserWindowManager] evaluate JS (first 500 chars):', js.substring(0, 500));
|
||||
|
||||
// #46 修复: 超时控制 — 防止恶意 JS 无限阻塞主进程
|
||||
const EVAL_TIMEOUT_MS = 10_000;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
this.win!.webContents.executeJavaScript(js, true),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
// 审查修复 M17: 超时后中止页面 JS 执行。
|
||||
// executeJavaScript 返回的 Promise 无法取消,页面脚本仍会继续运行,
|
||||
// 调用 webContents.stop() 中止页面正在执行的脚本(win 可能已销毁,try/catch 兜底)。
|
||||
try { this.win?.webContents.stop(); } catch {}
|
||||
reject(new Error(`evaluate timed out after ${EVAL_TIMEOUT_MS}ms`));
|
||||
}, EVAL_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if (typeof result === 'string') return result;
|
||||
return result;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== browserExtract =====
|
||||
|
||||
@@ -109,7 +109,10 @@ export class CodeSearchTool implements IMetonaTool {
|
||||
rgArgs.push('-g', '!MEMORY.md');
|
||||
if (opts.fileGlob) rgArgs.push('-g', opts.fileGlob);
|
||||
|
||||
rgArgs.push(pattern, searchPath);
|
||||
// #22 修复: 在 pattern 前加 -- 终止选项解析,防止以 - 开头的 pattern 被解释为选项
|
||||
// 攻击场景:pattern="--help" 输出帮助而非搜索,pattern="--ignore-file /etc/passwd" 可读取任意文件,
|
||||
// pattern="--files" 列出所有文件。execFile 已用数组参数防 shell 注入,但 ripgrep 自身选项解析仍需防护
|
||||
rgArgs.push('--', pattern, searchPath);
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('rg', rgArgs, {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { exec, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { resolve } from 'path';
|
||||
import { parse as shellQuoteParse } from 'shell-quote';
|
||||
@@ -27,6 +27,7 @@ import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard
|
||||
import type { SandboxManager } from '../../sandbox/sandbox';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ===== Windows 编码智能解码 =====
|
||||
// 某些 Windows 程序(dir、systeminfo、ipconfig 等)不响应 chcp 65001,
|
||||
@@ -48,6 +49,43 @@ function decodeBuffer(buf: Buffer): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #9 修复 + 审查修复: 构建安全的子进程环境变量
|
||||
*
|
||||
* 审查修复: 原白名单方案遗漏了 GIT_*/PYTHONPATH/HTTP_PROXY 等常用变量,导致子进程功能破坏。
|
||||
* 改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
|
||||
*/
|
||||
function buildSafeCommandEnv(isWindows: boolean): 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;
|
||||
}
|
||||
|
||||
// 添加必要的运行时变量
|
||||
env.NODE_ENV = 'production';
|
||||
if (isWindows) {
|
||||
env.PYTHONIOENCODING = 'utf-8';
|
||||
env.LANG = 'zh_CN.UTF-8';
|
||||
env.LC_ALL = 'zh_CN.UTF-8';
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
// ===== 9. run_command =====
|
||||
|
||||
export class RunCommandTool implements IMetonaTool {
|
||||
@@ -120,28 +158,46 @@ export class RunCommandTool implements IMetonaTool {
|
||||
// Windows 中文编码修复:通过 chcp 65001 切换控制台到 UTF-8 代码页
|
||||
// 避免 GBK/CP936 输出被 Node.js 按 UTF-8 解码导致中文乱码
|
||||
const isWindows = process.platform === 'win32';
|
||||
const finalCommand = isWindows
|
||||
? `chcp 65001 >nul 2>&1 && ${command}`
|
||||
: command;
|
||||
|
||||
// #8 修复: 优先使用 execFile(不经过 shell,从根本上防止命令注入)
|
||||
// 仅当命令为简单命令(无管道、重定向、&& 等 shell 运算符)时使用 execFile
|
||||
// 复杂命令(含 shell 语法)降级到 exec(已有 SandboxManager + validateCommand 双层校验)
|
||||
const simpleCmd = this.parseCommandSimple(command);
|
||||
|
||||
// 使用 encoding: 'buffer' 获取原始字节,手动智能解码
|
||||
// 部分程序(如 dir、systeminfo)不响应 chcp 65001,仍输出 GBK 字节流
|
||||
const { stdout, stderr } = await execAsync(finalCommand, {
|
||||
// #9 修复: 不透传完整 process.env,仅保留子进程运行所需的最小环境变量集合
|
||||
// 防止 API keys / tokens / passwords 通过环境变量泄露给子进程
|
||||
const execEnv = buildSafeCommandEnv(isWindows);
|
||||
const execOpts = {
|
||||
cwd: resolvedWorkdir,
|
||||
timeout,
|
||||
maxBuffer: 1024 * 1024, // 1MB
|
||||
encoding: 'buffer', // 返回 Buffer 而非字符串,便于智能解码
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
...(isWindows ? {
|
||||
// 强制常见程序使用 UTF-8 输出
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
LANG: 'zh_CN.UTF-8',
|
||||
LC_ALL: 'zh_CN.UTF-8',
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
encoding: 'buffer' as const, // 返回 Buffer 而非字符串,便于智能解码
|
||||
env: execEnv,
|
||||
};
|
||||
|
||||
let stdout: Buffer;
|
||||
let stderr: Buffer;
|
||||
|
||||
// #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入)
|
||||
// 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法执行(ENOENT)
|
||||
// 因此 Windows 上仍用 exec(已有 SandboxManager.scanCode + validateCommand 双层校验)
|
||||
// 非 Windows 上对简单命令用 execFile
|
||||
if (simpleCmd && !isWindows) {
|
||||
const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts);
|
||||
stdout = result.stdout;
|
||||
stderr = result.stderr;
|
||||
} else {
|
||||
// 复杂命令(含管道/重定向/&& 等 shell 语法)或 Windows — 使用 exec
|
||||
// 已有 SandboxManager.scanCode + validateCommand 双层安全校验
|
||||
const finalCommand = isWindows
|
||||
? `chcp 65001 >nul 2>&1 && ${command}`
|
||||
: command;
|
||||
const result = await execAsync(finalCommand, execOpts);
|
||||
stdout = result.stdout;
|
||||
stderr = result.stderr;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -336,4 +392,41 @@ export class RunCommandTool implements IMetonaTool {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #8 修复: 将命令解析为简单的 command + args 数组
|
||||
*
|
||||
* 使用 shell-quote 解析命令,如果命令只包含 word tokens(无管道、重定向、&& 等 shell 运算符),
|
||||
* 返回 { command, args } 供 execFile 使用(不经过 shell,从根本上防止命令注入)。
|
||||
* 如果包含 shell 运算符或解析失败,返回 null,调用方降级到 exec(已有安全校验)。
|
||||
*
|
||||
* @returns 简单命令的 { command, args },或 null 表示复杂命令
|
||||
*/
|
||||
private parseCommandSimple(command: string): { command: string; args: string[] } | null {
|
||||
let tokens: ReturnType<typeof shellQuoteParse>;
|
||||
try {
|
||||
tokens = shellQuoteParse(command);
|
||||
} catch {
|
||||
// 解析失败(Windows cmd 语法、不完整引号等)— 降级到 exec
|
||||
return null;
|
||||
}
|
||||
|
||||
const words: string[] = [];
|
||||
for (const entry of tokens) {
|
||||
if (typeof entry === 'string') {
|
||||
words.push(entry);
|
||||
} else if (typeof entry === 'object' && entry !== null) {
|
||||
const obj = entry as { word?: unknown; op?: unknown };
|
||||
if (typeof obj.word === 'string') {
|
||||
words.push(obj.word);
|
||||
} else if (typeof obj.op === 'string') {
|
||||
// 遇到 shell 运算符(&&、|、;、> 等)— 复杂命令,降级到 exec
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (words.length === 0) return null;
|
||||
return { command: words[0], args: words.slice(1) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,43 @@ import {
|
||||
FILE_TOOL_TIMEOUT_MS,
|
||||
} from './file-guard';
|
||||
|
||||
/**
|
||||
* #45 修复: 检测潜在灾难性正则模式(ReDoS 风险)
|
||||
*
|
||||
* 灾难性回溯通常由以下模式引起:
|
||||
* - 嵌套量词:(a+)+、(a*)*、(a+)*
|
||||
* - 重叠量词:a+a+、a+.*a+(两个量词之间无固定字符分隔)
|
||||
* - 交替分支加量词:(a|a)*
|
||||
*
|
||||
* 这些模式在长字符串上执行时间指数级增长,可阻塞主进程
|
||||
*
|
||||
* @param pattern 用户提供的正则模式字符串
|
||||
* @returns true 如果检测到潜在灾难性模式
|
||||
*/
|
||||
function isPotentiallyCatastrophicRegex(pattern: string): boolean {
|
||||
// 审查修复: 放宽规则减少误报,补充漏报检测
|
||||
|
||||
// 1. 嵌套量词(捕获组内量词+外层量词)
|
||||
// 审查修复: 区分外层量词类型 — 外层 +* 时组内一个量词即可触发(如 (a+)+),
|
||||
// 外层 ? 时需组内两个量词才触发(排除 (\d+)? 误报)
|
||||
if (/\([^)]*[+*?][^)]*\)[+*]/.test(pattern)) return true;
|
||||
if (/\([^)]*[+*?][^)]*[+*?][^)]*\)[?]/.test(pattern)) return true;
|
||||
|
||||
// 2. 重叠量词 — 补充 a+a+ 漏报
|
||||
if (/[+*][+*]/.test(pattern)) return true;
|
||||
// 审查修复: 补充 a+a+ / a+.*a+ 等重叠量词检测
|
||||
if (/\w[+*]\s*\w[+*]/.test(pattern)) return true;
|
||||
if (/\.\*[+*]\.\*[+*]/.test(pattern)) return true;
|
||||
|
||||
// 3. 交替分支加量词 — 放宽: 仅当分支有重叠前缀时才危险
|
||||
// 移除对 (GET|POST)+ 的误报,只检测真正危险的重叠分支
|
||||
// (a|a)* 类型难以用正则精确检测,保留简化版
|
||||
if (/\(([^)]+)\|(\1[^)]*)\)[+*?]/.test(pattern)) return true;
|
||||
if (/\(([^)]*\|[^)]*)\)[+*?]/.test(pattern) && /(.)\1.*\|.*\1/.test(pattern)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export class FileEditorTool implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'file_editor',
|
||||
@@ -159,6 +196,11 @@ export class FileEditorTool implements IMetonaTool {
|
||||
if (pattern.length > 500) {
|
||||
return { success: false, error: 'Regex pattern too long (max 500 chars)' };
|
||||
}
|
||||
// #45 修复: ReDoS 防护 — 检测灾难性正则模式,拒绝可能导致指数级回溯的 pattern
|
||||
// 攻击场景:恶意 LLM 传入 (a+)+ 等模式,在长字符串上阻塞主进程
|
||||
if (isPotentiallyCatastrophicRegex(pattern)) {
|
||||
return { success: false, error: 'Potentially catastrophic regex pattern detected (ReDoS risk): nested or overlapping quantifiers' };
|
||||
}
|
||||
const startLine = Math.max(1, (args.start_line as number) ?? 1);
|
||||
const endLine = Math.min(lines.length, (args.end_line as number) ?? lines.length);
|
||||
if (endLine < startLine) {
|
||||
@@ -178,6 +220,15 @@ export class FileEditorTool implements IMetonaTool {
|
||||
if (multiline) {
|
||||
// F2-6: 跨行匹配 — 对整个目标内容做正则替换(支持多行代码块替换)
|
||||
const targetContent = lines.slice(startLine - 1, endLine).join('\n');
|
||||
// #45 修复: 限制 multiline 模式的目标内容长度,防止大文件上的 regex 执行导致主进程阻塞
|
||||
// Node.js RegExp 执行是同步的,超长内容 + 复杂正则可阻塞事件循环
|
||||
const MAX_REGEX_CONTENT_LENGTH = 100_000;
|
||||
if (targetContent.length > MAX_REGEX_CONTENT_LENGTH) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Target content too large for multiline regex (${targetContent.length} chars, max ${MAX_REGEX_CONTENT_LENGTH}). Use a smaller line range or split the operation.`,
|
||||
};
|
||||
}
|
||||
const matches = targetContent.match(regex);
|
||||
if (matches) replaceCount = matches.length;
|
||||
const replacedContent = targetContent.replace(regex, replacement);
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, type FileHandle } from 'fs/promises';
|
||||
import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, realpath, type FileHandle } from 'fs/promises';
|
||||
import { join, relative, resolve, dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, realpathSync } from 'fs';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
import type { MetonaToolDef } from '../../../harness/types';
|
||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||
@@ -542,7 +542,23 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
fileGlob?: string,
|
||||
includeHidden?: boolean,
|
||||
workspacePath?: string,
|
||||
// #21 修复: 添加深度限制,防止深层嵌套目录触发栈溢出
|
||||
depth: number = 0,
|
||||
// #21 修复: 最大递归深度(默认 20),超过则停止递归
|
||||
maxDepth: number = 20,
|
||||
// #21 修复: 符号链接循环防护 — 记录已访问的 realpath,防止 A -> B -> A 死循环
|
||||
visited: Set<string> = new Set(),
|
||||
): Promise<void> {
|
||||
// #21 修复: 深度限制 — 超过 maxDepth 立即返回,防止栈溢出
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
// #21 修复: 符号链接循环防护 — realpath 去重,防止 A -> B -> A 形成循环导致死循环
|
||||
// 审查修复: 改用异步 fs.promises.realpath,避免同步 realpathSync 阻塞 Electron 主进程。
|
||||
// realpath 失败(权限问题等)时回退到 dirPath 本身,保守继续。
|
||||
const real = await realpath(dirPath).catch(() => dirPath);
|
||||
if (visited.has(real)) return;
|
||||
visited.add(real);
|
||||
|
||||
try {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
@@ -552,7 +568,10 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath);
|
||||
// #21 修复: 不跟随符号链接目录,防止循环遍历与越权访问
|
||||
if (!entry.isSymbolicLink()) {
|
||||
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath, depth + 1, maxDepth, visited);
|
||||
}
|
||||
} else {
|
||||
// F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx")
|
||||
if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue;
|
||||
@@ -640,9 +659,35 @@ export class DeleteFileTool implements IMetonaTool {
|
||||
}
|
||||
|
||||
try {
|
||||
// #20 修复: TOCTOU 竞态防护 — 删除前 realpath 二次校验
|
||||
// 攻击场景:在校验(safeResolvePath)与删除(unlink/rmdir)之间,
|
||||
// 攻击者用符号链接替换目标文件,使 unlink 实际删除符号链接指向的工作空间外文件
|
||||
// 防护:记录删除前 realpath,删除前再次校验未被替换
|
||||
let realBefore: string;
|
||||
try {
|
||||
realBefore = realpathSync(resolvedPath);
|
||||
} catch {
|
||||
// realpath 失败(路径不存在等),使用 resolvedPath
|
||||
realBefore = resolvedPath;
|
||||
}
|
||||
if (!isPathWithinWorkspace(realBefore, context.workspacePath)) {
|
||||
return { error: 'Path escape detected (TOCTOU protection)', path: filePath, success: false };
|
||||
}
|
||||
|
||||
const stats = await stat(resolvedPath);
|
||||
const isDirectory = stats.isDirectory();
|
||||
|
||||
// #20 修复: 删除前再次 realpath 校验,确保文件未被替换为指向工作空间外的符号链接
|
||||
let realAfter: string;
|
||||
try {
|
||||
realAfter = realpathSync(resolvedPath);
|
||||
} catch {
|
||||
realAfter = resolvedPath;
|
||||
}
|
||||
if (realAfter !== realBefore) {
|
||||
return { error: 'TOCTOU detected: file replaced during deletion', path: filePath, success: false };
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
if (recursive) {
|
||||
// 递归删除非空目录
|
||||
|
||||
@@ -211,6 +211,23 @@ export class GitDiffTool implements IMetonaTool {
|
||||
const pathspec = args.pathspec as string | undefined;
|
||||
const contextLines = Math.max(0, (args.contextLines as number) ?? 3);
|
||||
|
||||
// #19 修复: 校验 pathspec 在工作空间内,防止读取任意仓库差异
|
||||
// 攻击场景:pathspec 传 "../../../other-repo/" 或绝对路径可越权读取工作空间外的 git 差异
|
||||
// resolve 后 isPathWithinWorkspace 校验,glob 字符(*?)不影响前缀校验
|
||||
if (pathspec) {
|
||||
// 审查修复 M18: git magic pathspec(以 : 开头,如 :(glob)**/*.ts)跳过 resolve 校验。
|
||||
// resolve 会把 magic 前缀当普通字符拼接到路径,导致 pathspec 被污染;
|
||||
// git magic 语法本身不构成路径越权风险(git 自身解析 magic 前缀,不指向工作空间外)。
|
||||
if (pathspec.startsWith(':')) {
|
||||
// git magic pathspec,跳过 resolve 校验直接放行
|
||||
} else {
|
||||
const resolved = resolve(context.workspacePath, pathspec);
|
||||
if (!isPathWithinWorkspace(resolved, context.workspacePath)) {
|
||||
return { error: `Path outside workspace: ${pathspec}`, success: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gitArgs = ['diff'];
|
||||
if (cached) gitArgs.push('--cached');
|
||||
gitArgs.push(`--unified=${contextLines}`);
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
*
|
||||
* 使用 Node.js 18+ 内置 fetch API。
|
||||
* 响应体截断到 50KB 防止结果过大。
|
||||
*
|
||||
* #10 修复: SSRF 防护 — 解析 URL 域名并校验 IP,拒绝内网/回环/元数据地址。
|
||||
*/
|
||||
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
import type { MetonaToolDef } from '../../../harness/types';
|
||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||
@@ -14,6 +18,120 @@ import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const;
|
||||
const MAX_BODY_BYTES = 50 * 1024; // 50KB
|
||||
|
||||
/**
|
||||
* #10 修复: 检查 IP 是否为私有/内网/回环/元数据地址
|
||||
*
|
||||
* 覆盖:
|
||||
* - IPv4: 127.0.0.0/8 (回环)、10.0.0.0/8、192.168.0.0/16、172.16.0.0/12、
|
||||
* 169.254.0.0/16 (链路本地,含云元数据 169.254.169.254)、0.0.0.0/8、
|
||||
* 224.0.0.0/4 (组播)、240.0.0.0/4 (保留)
|
||||
* - IPv6: ::1 (回环)、fe80::/10 (链路本地)、fc00::/7 (唯一本地)、::ffff: 映射的 IPv4
|
||||
*/
|
||||
function isPrivateIP(ip: string): boolean {
|
||||
// IPv4 直接检测
|
||||
if (isIP(ip) === 4) {
|
||||
const parts = ip.split('.').map(Number);
|
||||
if (parts[0] === 127) return true; // 回环
|
||||
if (parts[0] === 10) return true; // 内网
|
||||
if (parts[0] === 192 && parts[1] === 168) return true; // 内网
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 内网
|
||||
if (parts[0] === 169 && parts[1] === 254) return true; // 链路本地(含云元数据)
|
||||
if (parts[0] === 0) return true; // 0.0.0.0/8
|
||||
if (parts[0] >= 224) return true; // 组播 + 保留
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv6 检测
|
||||
if (isIP(ip) === 6) {
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === '::1') return true; // 回环
|
||||
if (lower.startsWith('fe80:')) return true; // 链路本地
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // 唯一本地
|
||||
// ::ffff: 映射的 IPv4 — 提取 IPv4 部分递归检测
|
||||
const v4MappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (v4MappedMatch) return isPrivateIP(v4MappedMatch[1]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 非 IP 格式(域名等),由调用方 DNS 解析后再检测
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* #10 修复: SSRF 校验 — 解析 URL 域名并校验 IP
|
||||
*
|
||||
* 1. 协议白名单:仅允许 http/https
|
||||
* 2. DNS 解析域名,获取所有 IP 地址
|
||||
* 3. 逐个检测 IP 是否为私有/内网/回环/元数据地址
|
||||
* 4. 任意一个 IP 为私有即拒绝(防止 DNS rebinding 中只校验第一个 IP)
|
||||
*
|
||||
* 审查修复 (M7) — 已知限制:DNS rebinding 窗口
|
||||
* ---------------------------------------------------------------
|
||||
* validateSSRF 在校验阶段 DNS 解析得到 IP,fetch 内部会再次 DNS 解析,
|
||||
* 两次解析之间存在 DNS rebinding 攻击窗口(攻击者可在校验通过后切换
|
||||
* DNS 记录到内网 IP)。
|
||||
*
|
||||
* 完全防护需要 "DNS pinning"(用校验通过的 IP 替换 URL hostname),
|
||||
* 但在 Node.js fetch 实现下不可行:
|
||||
* 1. HTTPS 请求时 fetch 会基于 URL hostname 校验证书 SAN,
|
||||
* 用 IP 替换会导致证书校验失败(除非目标证书 SAN 包含该 IP)。
|
||||
* 2. Node fetch 将 Host 列为 forbidden header,无法通过设置
|
||||
* Host header 保留原始域名。
|
||||
* 3. fetch API 不暴露 SNI 自定义入口。
|
||||
*
|
||||
* 当前实现的缓解措施:
|
||||
* - 校验所有 DNS 返回的 IP(防只校验第一个 IP 的绕过)
|
||||
* - redirect: 'manual' 禁用自动重定向(防重定向到内网)
|
||||
* - 窗口虽存在,但需要攻击者控制权威 DNS 并在毫秒级切换记录,
|
||||
* 实际利用难度较高。
|
||||
*
|
||||
* 彻底防护建议:在 Electron 主进程层使用自定义 lookup 钩子实现
|
||||
* DNS pinning(例如 undici 的 dispatcher.agent.connect lookup)。
|
||||
*
|
||||
* @throws 如果 URL 指向私有/内网/回环地址
|
||||
*/
|
||||
async function validateSSRF(url: string): Promise<void> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid URL: ${url}`);
|
||||
}
|
||||
|
||||
// 协议白名单
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`Blocked SSRF: protocol "${parsed.protocol}" not allowed (only http/https)`);
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
// 如果 hostname 本身就是 IP,直接检测
|
||||
if (isIP(hostname)) {
|
||||
if (isPrivateIP(hostname)) {
|
||||
throw new Error(`Blocked SSRF: ${hostname} is a private/loopback address`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 域名 — DNS 解析后检测所有 IP
|
||||
let addresses: Array<{ address: string }>;
|
||||
try {
|
||||
addresses = await lookup(hostname, { all: true });
|
||||
} catch (err) {
|
||||
throw new Error(`Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
if (addresses.length === 0) {
|
||||
throw new Error(`Blocked SSRF: no DNS records for ${hostname}`);
|
||||
}
|
||||
|
||||
for (const { address } of addresses) {
|
||||
if (isPrivateIP(address)) {
|
||||
throw new Error(`Blocked SSRF: ${hostname} resolves to private IP ${address}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpRequestTool implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'http_request',
|
||||
@@ -34,8 +152,8 @@ export class HttpRequestTool implements IMetonaTool {
|
||||
required: ['url'],
|
||||
},
|
||||
category: MetonaToolCategory.NETWORK,
|
||||
riskLevel: MetonaRiskLevel.LOW,
|
||||
requiresPermission: false,
|
||||
riskLevel: MetonaRiskLevel.MEDIUM,
|
||||
requiresPermission: true,
|
||||
timeoutMs: 30_000,
|
||||
};
|
||||
|
||||
@@ -52,6 +170,13 @@ export class HttpRequestTool implements IMetonaTool {
|
||||
return { error: 'Invalid URL', success: false };
|
||||
}
|
||||
|
||||
// #10 修复: SSRF 校验 — 拒绝内网/回环/元数据地址
|
||||
try {
|
||||
await validateSSRF(url);
|
||||
} catch (ssrfErr) {
|
||||
return { error: (ssrfErr as Error).message, success: false };
|
||||
}
|
||||
|
||||
// 校验 method
|
||||
if (!(ALLOWED_METHODS as readonly string[]).includes(method)) {
|
||||
return {
|
||||
@@ -69,7 +194,9 @@ export class HttpRequestTool implements IMetonaTool {
|
||||
method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
// #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验
|
||||
// 重定向后的 URL 由用户自行处理(响应中会包含 Location 头)
|
||||
redirect: 'manual',
|
||||
};
|
||||
// GET/HEAD 不应携带 body
|
||||
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||
@@ -84,11 +211,14 @@ export class HttpRequestTool implements IMetonaTool {
|
||||
const safeBody = truncated ? text.slice(0, MAX_BODY_BYTES) : text;
|
||||
|
||||
// 只返回 content-type 和 content-length
|
||||
// 审查修复: redirect:'manual' 后需要返回 Location header,否则 LLM 无法知道重定向目标
|
||||
const filteredHeaders: Record<string, string> = {};
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType) filteredHeaders['content-type'] = contentType;
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) filteredHeaders['content-length'] = contentLength;
|
||||
const location = response.headers.get('location');
|
||||
if (location) filteredHeaders['location'] = location;
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
|
||||
@@ -95,20 +95,39 @@ export class ToolRegistry {
|
||||
}
|
||||
|
||||
const startTs = Date.now();
|
||||
const timeoutMs = tool.definition.timeoutMs;
|
||||
|
||||
// #12 修复: timeoutMs 为 undefined 时使用默认值,并校验有效性
|
||||
// 防止 setTimeout(fn, undefined) 被解释为 setTimeout(fn, 0) 立即触发超时
|
||||
// 类型定义中 timeoutMs 是必填 number,但 MCP/外部工具运行时可能缺失,需防御
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const rawTimeout = tool.definition.timeoutMs;
|
||||
const timeoutMs =
|
||||
typeof rawTimeout === 'number' && rawTimeout > 0
|
||||
? rawTimeout
|
||||
: DEFAULT_TIMEOUT_MS;
|
||||
|
||||
// #11 修复: 使用 AbortController 在超时后通知工具中止,防止 Promise 未取消导致资源泄漏
|
||||
// 原实现 Promise.race 超时后 tool.execute() 仍在后台运行,持续消耗资源
|
||||
// 现通过 signal 传给 context,工具可在耗时操作前检查 signal.aborted 自行中止
|
||||
const controller = new AbortController();
|
||||
const enhancedContext: ToolExecutionContext = {
|
||||
...context,
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
// M-15 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
|
||||
// 工具正常完成时未触发的 timer 会持续占用事件循环 timeoutMs 毫秒
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
// 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop
|
||||
// #11: 超时触发 controller.abort(),支持 signal 的工具可据此中止后台操作
|
||||
const result = await Promise.race([
|
||||
tool.execute(toolCall.args, context),
|
||||
tool.execute(toolCall.args, enhancedContext),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error(`Tool execution timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ToolExecutionContext {
|
||||
iteration: number;
|
||||
/** 请求 ID */
|
||||
requestId: string;
|
||||
/** #11 修复: 超时 abort signal,工具可在耗时操作前检查 signal.aborted 自行中止 */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,11 @@ export function estimateStringTokens(text: string | null | undefined): number {
|
||||
return Math.ceil(cjkCount * CJK_TOKEN_RATIO + asciiCount * ASCII_TOKEN_RATIO + otherCount * OTHER_TOKEN_RATIO);
|
||||
}
|
||||
|
||||
/**
|
||||
* #50 修复: tool_call 结构开销({"id":"","name":"","arguments":""} 等结构字符,参考 OpenAI 规范)
|
||||
*/
|
||||
const TOOL_CALL_OVERHEAD_TOKENS = 8;
|
||||
|
||||
/**
|
||||
* 估算多条消息的总 token 数
|
||||
*
|
||||
@@ -63,7 +68,8 @@ export function estimateStringTokens(text: string | null | undefined): number {
|
||||
export function estimateMessagesTokens(messages: Array<{
|
||||
content: string | null;
|
||||
reasoningContent?: string;
|
||||
toolCalls?: Array<{ args: Record<string, unknown> }>;
|
||||
toolCalls?: Array<{ id?: string; name?: string; args: Record<string, unknown> }>;
|
||||
toolCallId?: string;
|
||||
}>): number {
|
||||
let total = 0;
|
||||
for (const msg of messages) {
|
||||
@@ -71,9 +77,19 @@ export function estimateMessagesTokens(messages: Array<{
|
||||
if (msg.reasoningContent) total += estimateStringTokens(msg.reasoningContent);
|
||||
if (msg.toolCalls) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
total += estimateStringTokens(JSON.stringify(tc.args));
|
||||
// #50 修复: OpenAI tokenizer 会将 tool_call 的完整结构(id、name、args)都计入 token
|
||||
// 之前仅估算 args,忽略 id(通常 24 字符 call_xxx)和 name(通常 5-20 字符),导致每个 tool_call 少算 5-10 tokens
|
||||
total += estimateStringTokens(tc.id ?? '');
|
||||
total += estimateStringTokens(tc.name ?? '');
|
||||
total += estimateStringTokens(JSON.stringify(tc.args ?? {}));
|
||||
// 结构开销({"id":"","name":"","arguments":""} 等结构字符)
|
||||
total += TOOL_CALL_OVERHEAD_TOKENS;
|
||||
}
|
||||
}
|
||||
// #50 修复: tool 消息的 tool_call_id 字段也计入 token
|
||||
if (msg.toolCallId) {
|
||||
total += estimateStringTokens(msg.toolCallId);
|
||||
}
|
||||
// L-17 修复: 使用命名常量替代魔法数字
|
||||
total += MSG_OVERHEAD_TOKENS;
|
||||
}
|
||||
|
||||
@@ -1208,6 +1208,37 @@ export function registerAllIPCHandlers(
|
||||
return { success: true, inherited, failed };
|
||||
});
|
||||
|
||||
// #51 修复: 校验源数据库完整性,防止继承损坏的数据库(如 WAL 损坏)导致新工作空间数据丢失
|
||||
// 在前端勾选 inheritDatabase 时调用,执行 PRAGMA integrity_check
|
||||
ipcMain.handle('workspace:checkDatabaseIntegrity', async (_event, sourcePath: string) => {
|
||||
if (!sourcePath) return { success: false, error: '参数无效' };
|
||||
const { existsSync } = await import('fs');
|
||||
const { resolve } = await import('path');
|
||||
const srcFile = join(resolve(sourcePath), '.metona', 'agent.db');
|
||||
if (!existsSync(srcFile)) {
|
||||
// 源数据库不存在不算损坏(可能是新工作空间尚未创建数据库),视为校验通过
|
||||
return { success: true, ok: true, detail: 'source database not exist' };
|
||||
}
|
||||
try {
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
// 以只读方式打开源库,避免锁竞争
|
||||
const db = new Database(srcFile, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const result = db.prepare('PRAGMA integrity_check').get() as { integrity_check: string };
|
||||
const ok = result.integrity_check === 'ok';
|
||||
if (!ok) {
|
||||
log.warn(`[WORKSPACE] Source database integrity check failed: ${result.integrity_check} (source=${sourcePath})`);
|
||||
}
|
||||
return { success: true, ok, detail: result.integrity_check };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(`[WORKSPACE] Failed to check database integrity: ${(err as Error).message}`);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 获取当前工作空间详情:路径 + 2 个核心文件状态 + 自动目录状态
|
||||
ipcMain.handle('workspace:getInfo', async () => {
|
||||
const { existsSync, statSync, readFileSync, readdirSync } = await import('fs');
|
||||
|
||||
+23
-2
@@ -140,6 +140,9 @@ const metonaAPI = {
|
||||
showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path),
|
||||
selectFolder: (defaultPath?: string) => ipcRenderer.invoke('app:selectFolder', defaultPath),
|
||||
restart: () => ipcRenderer.invoke('app:restart'),
|
||||
// #49 修复: 前端错误上报通道(主进程可注册 'error:report' handler 记录到 electron-log/审计日志)
|
||||
// 使用 send(单向)而非 invoke,即使主进程未注册 handler 也不会 reject
|
||||
reportError: (payload: unknown) => ipcRenderer.send('error:report', payload),
|
||||
},
|
||||
|
||||
// ===== 工作空间管理 =====
|
||||
@@ -148,6 +151,9 @@ const metonaAPI = {
|
||||
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
|
||||
ipcRenderer.invoke('workspace:inheritFiles', params),
|
||||
getInfo: () => ipcRenderer.invoke('workspace:getInfo'),
|
||||
// #51 修复: 校验源数据库完整性(PRAGMA integrity_check),用于 inheritDatabase 前置校验
|
||||
checkDatabaseIntegrity: (sourcePath: string) =>
|
||||
ipcRenderer.invoke('workspace:checkDatabaseIntegrity', sourcePath),
|
||||
},
|
||||
|
||||
// ===== 工具管理 =====
|
||||
@@ -187,10 +193,25 @@ if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
contextBridge.exposeInMainWorld('metona', metonaAPI);
|
||||
} catch (error) {
|
||||
console.error('Failed to expose APIs:', error);
|
||||
console.error('Failed to expose APIs via contextBridge:', error);
|
||||
// #7 修复: 生产环境强制失败,防止以不安全状态启动(contextBridge 失败意味着
|
||||
// context isolation 边界已破坏,继续运行可能导致渲染进程直接访问 Node API)
|
||||
// 开发环境降级到 globalThis 以便调试
|
||||
// 审查修复: process.defaultApp 在 sandbox: true 下可能为 undefined,
|
||||
// 导致开发环境也无法降级。补充 process.env.NODE_ENV 判断(sandbox 下 process.env 仍可用)
|
||||
if (process.env.NODE_ENV === 'development' || process.defaultApp) {
|
||||
console.warn('[DEV] Falling back to globalThis assignment — context isolation is weakened');
|
||||
(globalThis as unknown as Record<string, unknown>).electron = electronAPI;
|
||||
(globalThis as unknown as Record<string, unknown>).metona = metonaAPI;
|
||||
} else {
|
||||
// 审查修复 M19: 在 throw 前记录详细错误,便于在 DevTools 中排查白屏问题。
|
||||
// 生产环境 contextBridge 失败时用户只看到白屏,console.error 至少能在 DevTools 中留下线索。
|
||||
console.error('[FATAL] contextBridge failed in production:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 降级方案(不推荐)
|
||||
// context isolation 未启用(不推荐,仅开发环境)
|
||||
(globalThis as unknown as Record<string, unknown>).electron = electronAPI;
|
||||
(globalThis as unknown as Record<string, unknown>).metona = metonaAPI;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "metona-ai-desktop",
|
||||
"version": "0.3.15",
|
||||
"version": "0.3.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "metona-ai-desktop",
|
||||
"version": "0.3.15",
|
||||
"version": "0.3.16",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "metona-ai-desktop",
|
||||
"version": "0.3.15",
|
||||
"version": "0.3.16",
|
||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||
"main": "dist-electron/main/main.js",
|
||||
"author": "Metona Team",
|
||||
|
||||
@@ -62,6 +62,8 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
const [remainingMs, setRemainingMs] = useState<number>(0);
|
||||
// 记录首次接收时的剩余时间,作为进度条总量(固定不变)
|
||||
const [initialMs, setInitialMs] = useState<number>(0);
|
||||
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
|
||||
const [confirmAutoExecute, setConfirmAutoExecute] = useState(false);
|
||||
|
||||
// ===== 弹框打开时主动拉取已积压的 pending 请求 =====
|
||||
// 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达,
|
||||
@@ -291,12 +293,16 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
: requests[0]?.reason ?? 'Agent 正在请求执行工具';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={requests.length > 0}
|
||||
onClose={(_, reason) => {
|
||||
// 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
|
||||
// 防止超时后用户误触关闭,导致后端 pending 状态不一致
|
||||
if (isExpired) return;
|
||||
// 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭
|
||||
// 防止 ESC 穿透到外层导致意外拒绝所有工具执行
|
||||
if (confirmAutoExecute) return;
|
||||
// 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC)
|
||||
// reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick'
|
||||
if (reason === 'backdropClick' || reason === 'escapeKeyDown') {
|
||||
@@ -305,6 +311,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
}}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
disableEscapeKeyDown={confirmAutoExecute}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { bgcolor: 'background.paper' },
|
||||
@@ -520,9 +527,12 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
<Checkbox
|
||||
checked={autoExecute}
|
||||
onChange={(e) => {
|
||||
setAutoExecute(e.target.checked);
|
||||
// 勾选永久自动执行时,自动勾选会话内记忆(保持一致)
|
||||
if (e.target.checked) setRemember(true);
|
||||
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
|
||||
if (e.target.checked) {
|
||||
setConfirmAutoExecute(true);
|
||||
} else {
|
||||
setAutoExecute(false);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
@@ -576,5 +586,42 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */}
|
||||
{/* 审查修复: disableEscapeKeyDown + onKeyDown stopPropagation 防止 ESC 事件穿透到外层 Dialog */}
|
||||
<Dialog
|
||||
open={confirmAutoExecute}
|
||||
onClose={() => setConfirmAutoExecute(false)}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
disableEscapeKeyDown
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogTitle sx={{ fontSize: 14 }}>确认永久自动执行?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
勾选后,选中的工具将永久自动执行(跨会话不再询问),包括未来的潜在危险操作。此设置可在「设置 → 工具管理」中关闭。
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, color: 'warning.main', fontWeight: 600 }}>
|
||||
确定要启用吗?
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmAutoExecute(false)} color="inherit">取消</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAutoExecute(true);
|
||||
// 勾选永久自动执行时,自动勾选会话内记忆(保持一致)
|
||||
setRemember(true);
|
||||
setConfirmAutoExecute(false);
|
||||
}}
|
||||
color="warning"
|
||||
variant="contained"
|
||||
>
|
||||
确认自动执行
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,10 @@
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Box, Typography, Stack } from '@mui/material';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import type { ChatMessage } from '@renderer/stores/agent-store';
|
||||
import { UserMessage } from './UserMessage';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { SystemMessage } from './SystemMessage';
|
||||
import { formatTime } from '@renderer/lib/formatters';
|
||||
|
||||
interface MessageItemProps {
|
||||
message: ChatMessage;
|
||||
@@ -23,7 +20,7 @@ interface MessageItemProps {
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element {
|
||||
function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element | null {
|
||||
switch (message.role) {
|
||||
case 'user':
|
||||
return <UserMessage message={message} />;
|
||||
@@ -37,8 +34,11 @@ function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): Re
|
||||
);
|
||||
|
||||
case 'tool':
|
||||
// 工具消息渲染为独立的结果块
|
||||
return <ToolMessage message={message} />;
|
||||
// #38 修复: 与 AgentStore.setCurrentSession 的过滤逻辑保持一致,
|
||||
// 不在 UI 中渲染独立的 tool 消息卡片。
|
||||
// tool 结果已包含在 assistant 消息的 toolCalls 中(见 AssistantMessage),
|
||||
// 独立的 tool 消息仅用于 LLM API 上下文,不需要在前端显示为独立卡片。
|
||||
return null;
|
||||
|
||||
case 'system':
|
||||
default:
|
||||
@@ -53,51 +53,3 @@ function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): Re
|
||||
* - isLast 是 boolean,仅在最后一条切换时变化
|
||||
*/
|
||||
export const MessageItem = memo(MessageItemImpl);
|
||||
|
||||
/** ToolMessage — 独立的工具结果消息(role=tool) */
|
||||
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: '#22d3ee',
|
||||
bgcolor: 'secondary.main',
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
mb: 2,
|
||||
animation: 'fadeInUp 300ms ease-out',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
|
||||
<Terminal size={12} style={{ color: '#22d3ee' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>
|
||||
工具输出
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled' }}>
|
||||
{formatTime(message.timestamp)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
borderRadius: 1,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
overflowX: 'auto',
|
||||
bgcolor: 'background.default',
|
||||
color: 'text.secondary',
|
||||
fontFamily: "'SF Mono',monospace",
|
||||
maxHeight: 120,
|
||||
m: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{message.content.length > 500 ? message.content.slice(0, 500) + '\n... [截断]' : message.content}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,19 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: { componentStack: string }): void {
|
||||
// #49 修复: 上报错误到主进程,便于记录到 electron-log 与审计日志,集中排查
|
||||
// 主进程可注册 ipcMain.on('error:report', ...) 接收;未注册时 send 为静默失败,不影响兜底
|
||||
try {
|
||||
window.metona?.app?.reportError({
|
||||
type: 'react_error_boundary',
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: info.componentStack,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// 上报失败不影响兜底逻辑
|
||||
}
|
||||
console.error('[ErrorBoundary]', error, info.componentStack);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* OnboardingWizard — 首次使用引导向导
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, Button, TextField, Select, MenuItem, Stepper, Step, StepLabel, Box, Typography, Stack, FormControl, InputLabel, IconButton, InputAdornment } from '@mui/material';
|
||||
import { ArrowRight, ArrowLeft, FolderOpen, Play, CheckCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
@@ -10,6 +10,9 @@ import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
const STEPS = ['欢迎', '配置 LLM', '自定义 Agent', '工作空间', '开始使用'];
|
||||
|
||||
// #48 修复: Onboarding 进度持久化 key
|
||||
const ONBOARDING_PROGRESS_KEY = 'onboarding_progress';
|
||||
|
||||
export function OnboardingWizard(): React.JSX.Element | null {
|
||||
const onboardingCompleted = useUIStore((s) => s.onboardingCompleted);
|
||||
const setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted);
|
||||
@@ -23,6 +26,46 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
// 上下文窗口(联动 Provider:ollama 可空=由模型决定;其他 provider 默认值见 DEFAULT_CTX)
|
||||
const [contextWindow, setContextWindow] = useState<number | null>(null);
|
||||
|
||||
// #48 修复: 启动时从 localStorage 恢复未完成的引导进度,避免中途退出后需重新填写
|
||||
// 注意: apiKey 属于敏感信息,不持久化到 localStorage,恢复时需用户重新输入
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(ONBOARDING_PROGRESS_KEY);
|
||||
if (!saved) return;
|
||||
const p = JSON.parse(saved) as {
|
||||
step?: number; provider?: string; baseURL?: string;
|
||||
model?: string; workspacePath?: string;
|
||||
contextWindow?: number | null;
|
||||
};
|
||||
if (typeof p.step === 'number' && p.step >= 0 && p.step < STEPS.length) setStep(p.step);
|
||||
if (typeof p.provider === 'string') setProvider(p.provider);
|
||||
if (typeof p.baseURL === 'string') setBaseURL(p.baseURL);
|
||||
if (typeof p.model === 'string') setModel(p.model);
|
||||
if (typeof p.workspacePath === 'string') setWorkspacePath(p.workspacePath);
|
||||
if (p.contextWindow !== undefined) setContextWindow(p.contextWindow);
|
||||
// 审查修复: 恢复后如果 provider 需要 apiKey 但 apiKey 为空(未持久化),
|
||||
// 回退到步骤 1(配置 LLM)让用户重新输入 apiKey,避免配置不完整
|
||||
// ollama 不需要 apiKey
|
||||
if (p.provider && p.provider !== 'ollama' && p.step && p.step >= 2) {
|
||||
setStep(1);
|
||||
}
|
||||
} catch {
|
||||
// 解析失败忽略,使用默认值
|
||||
}
|
||||
}, []);
|
||||
|
||||
// #48 修复: 每步完成后保存进度到 localStorage,中途退出(关闭窗口/刷新)可恢复
|
||||
// 注意: 不保存 apiKey(敏感信息不写入 localStorage)
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(ONBOARDING_PROGRESS_KEY, JSON.stringify({
|
||||
step, provider, baseURL, model, workspacePath, contextWindow,
|
||||
}));
|
||||
} catch {
|
||||
// 写入失败(如隐私模式)忽略
|
||||
}
|
||||
}, [step, provider, baseURL, model, workspacePath, contextWindow]);
|
||||
|
||||
if (onboardingCompleted) return null;
|
||||
|
||||
// 各 Provider 上下文窗口默认值(与 SettingsModal 保持一致)
|
||||
@@ -79,6 +122,8 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
useAgentStore.getState().setConfigLoaded(true);
|
||||
}
|
||||
setOnboardingCompleted(true);
|
||||
// #48 修复: 引导完成后清理 localStorage 进度,下次启动不再恢复
|
||||
try { localStorage.removeItem(ONBOARDING_PROGRESS_KEY); } catch { /* ignore */ }
|
||||
} catch (err) {
|
||||
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
|
||||
// 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死
|
||||
|
||||
@@ -154,6 +154,28 @@ function WorkspaceSettings() {
|
||||
if (inheritSoul) filesToInherit.push('SOUL.md');
|
||||
if (inheritDatabase) filesToInherit.push('.metona/agent.db');
|
||||
|
||||
// #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失
|
||||
if (inheritDatabase && currentPath) {
|
||||
try {
|
||||
const integrityResult = await window.metona?.workspace?.checkDatabaseIntegrity(currentPath);
|
||||
if (!integrityResult?.success) {
|
||||
import('metona-toast').then((mod) => mod.default.error(`源数据库校验失败:${integrityResult?.error ?? '未知错误'}`)).catch(() => {});
|
||||
setApplying(false);
|
||||
return;
|
||||
}
|
||||
if (!integrityResult.ok) {
|
||||
import('metona-toast').then((mod) => mod.default.error(`源数据库损坏(${integrityResult.detail}),无法继承`)).catch(() => {});
|
||||
setApplying(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SettingsModal] Database integrity check failed:', err);
|
||||
import('metona-toast').then((mod) => mod.default.error(`源数据库校验异常:${(err as Error).message}`)).catch(() => {});
|
||||
setApplying(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToInherit.length > 0 && currentPath) {
|
||||
try {
|
||||
await window.metona.workspace.inheritFiles({
|
||||
|
||||
@@ -36,6 +36,16 @@ function matchModifier(event: KeyboardEvent, ctrl?: boolean, shift?: boolean): b
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* #39 修复: 检测键盘事件目标是否为输入框(input/textarea/contentEditable)
|
||||
* 用于在输入框聚焦时屏蔽单字符快捷键,避免拦截用户正常输入
|
||||
*/
|
||||
function isInputTarget(e: KeyboardEvent): boolean {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return false;
|
||||
return ['INPUT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局快捷键 Hook
|
||||
*
|
||||
@@ -46,6 +56,12 @@ export function useKeyboardShortcuts(): void {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
// #39 修复: 输入框聚焦时,单字符无修饰键直接放行,避免拦截用户输入
|
||||
// Ctrl/Cmd 组合键不受影响(如 Ctrl+N/L/D 等仍可触发)
|
||||
if (isInputTarget(e) && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Esc — 关闭弹窗
|
||||
if (key === 'escape') {
|
||||
const { settingsOpen, closeSettings } = useUIStore.getState();
|
||||
|
||||
Vendored
+8
@@ -175,6 +175,7 @@ interface MetonaAppAPI {
|
||||
showItemInFolder: (path: string) => Promise<{ success: boolean; error?: string }>;
|
||||
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
|
||||
restart: () => Promise<{ success: boolean }>;
|
||||
reportError: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
// ===== Workspace API =====
|
||||
@@ -222,6 +223,13 @@ interface MetonaWorkspaceAPI {
|
||||
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
|
||||
Promise<MetonaWorkspaceInheritResult>;
|
||||
getInfo: () => Promise<MetonaWorkspaceInfo>;
|
||||
// #51 修复: 校验源数据库完整性(PRAGMA integrity_check)
|
||||
checkDatabaseIntegrity: (sourcePath: string) => Promise<{
|
||||
success: boolean;
|
||||
ok?: boolean;
|
||||
detail?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===== Toast API =====
|
||||
|
||||
Reference in New Issue
Block a user