Files
metona-ai-desktop/electron/harness/tools/built-in/dev-tools.ts
T
thzxx 2230bcec3f feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复:
- API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容)
- 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级)
- error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计)
- abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止)
- run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化)
- .env 真实生效(dotenv 回退加载,应用内配置优先)

P1 工程基础:
- ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线)
- 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层)
- test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行)
- SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end)
- Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知)
- MCP 真就绪(等待全部连接完成再广播 tools:ready)
- SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态)
- CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移)

P2 架构升级:
- handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播)
- AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号)
- TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent
- 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染)
- 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI)
- Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入

P3 能力扩展:
- OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens)
- Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机)
- 设置页/Onboarding 六 Provider 全链路接入
2026-08-20 23:17:02 +08:00

528 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 开发工具集(3 个)
*
* lint_code, run_tests, project_info
*
* 用于代码检查、测试运行和项目结构分析。
* 使用 execFile 执行命令,防止命令注入。
*/
import { execFile } from 'child_process';
import { promisify } from 'util';
import { readFile, readdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
const execFileAsync = promisify(execFile);
/** 输出最大长度(50KB */
const MAX_OUTPUT_LENGTH = 50 * 1024;
/** Windows 平台检测 */
const isWindows = process.platform === 'win32';
/** ESLint 配置文件列表 */
const ESLINT_CONFIG_FILES = [
'.eslintrc',
'.eslintrc.js',
'.eslintrc.json',
'.eslintrc.yml',
'.eslintrc.yaml',
'.eslintrc.cjs',
'eslint.config.js',
'eslint.config.mjs',
'eslint.config.cjs',
];
/** 检测工作空间是否存在 ESLint 配置 */
function hasEslintConfig(workspacePath: string): boolean {
return ESLINT_CONFIG_FILES.some((f) => existsSync(join(workspacePath, f)));
}
/** 读取并解析 package.json */
async function readPackageJson(workspacePath: string): Promise<Record<string, unknown> | null> {
try {
const content = await readFile(join(workspacePath, 'package.json'), 'utf-8');
return JSON.parse(content) as Record<string, unknown>;
} catch {
return null;
}
}
/** 截断输出到 50KB */
function truncateOutput(output: string): { output: string; truncated: boolean } {
if (output.length > MAX_OUTPUT_LENGTH) {
return { output: output.slice(0, MAX_OUTPUT_LENGTH), truncated: true };
}
return { output, truncated: false };
}
/** 从 execFile 错误对象中提取 stdout/stderr 字符串 */
function extractOutput(err: { stdout?: string | Buffer; stderr?: string | Buffer }): { stdout: string; stderr: string } {
return {
stdout: typeof err.stdout === 'string' ? err.stdout : '',
stderr: typeof err.stderr === 'string' ? err.stderr : '',
};
}
// ===== 1. lint_code =====
export class LintCodeTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'lint_code',
description:
'Run code linting: TypeScript type checking (tsc) or ESLint code style checking. Auto-detects project configuration when type is "auto".',
parameters: {
type: 'object',
properties: {
type: {
type: 'string',
description:
'Check type: "tsc" (TypeScript type check), "eslint" (ESLint), or "auto" (auto-detect, prioritizes tsc then eslint). Defaults to "tsc".',
enum: ['tsc', 'eslint', 'auto'],
},
},
},
category: MetonaToolCategory.CODE_EXECUTION,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 60_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const type = (args.type as string) ?? 'tsc';
try {
let actualType: 'tsc' | 'eslint';
if (type === 'auto') {
// 自动检测:优先 tsc,然后 eslint
if (existsSync(join(context.workspacePath, 'tsconfig.json'))) {
actualType = 'tsc';
} else if (hasEslintConfig(context.workspacePath)) {
actualType = 'eslint';
} else {
return {
type: 'none',
success: true,
errorCount: 0,
warningCount: 0,
output: 'No TypeScript or ESLint configuration found in workspace.',
truncated: false,
};
}
} else if (type === 'tsc') {
if (!existsSync(join(context.workspacePath, 'tsconfig.json'))) {
return {
type: 'tsc',
success: true,
errorCount: 0,
warningCount: 0,
output: 'No tsconfig.json found, skipping TypeScript type check.',
truncated: false,
};
}
actualType = 'tsc';
} else {
// eslint
if (!hasEslintConfig(context.workspacePath)) {
return {
type: 'eslint',
success: true,
errorCount: 0,
warningCount: 0,
output: 'No ESLint configuration found, skipping ESLint check.',
truncated: false,
};
}
actualType = 'eslint';
}
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
if (actualType === 'tsc') {
const result = await execFileAsync('npx', ['tsc', '--noEmit'], {
cwd: context.workspacePath,
maxBuffer: 5 * 1024 * 1024,
timeout: 60_000,
shell: isWindows,
encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码
signal: context.signal, // P0-4: 用户中断时终止子进程
});
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execFileAsync('npx', ['eslint', '--ext', '.ts,.tsx', '.'], {
cwd: context.workspacePath,
maxBuffer: 5 * 1024 * 1024,
timeout: 60_000,
shell: isWindows,
encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码
signal: context.signal, // P0-4: 用户中断时终止子进程
});
stdout = result.stdout;
stderr = result.stderr;
}
} catch (error) {
// lint 命令退出码非 0 是正常情况(代码有错误时),需要捕获输出用于解析
const err = error as { stdout?: string | Buffer; stderr?: string | Buffer; code?: number };
const extracted = extractOutput(err);
stdout = extracted.stdout;
stderr = extracted.stderr;
exitCode = err.code ?? 1;
}
const output = (stdout + (stderr ? '\n' + stderr : '')).trim();
const { errorCount, warningCount } = this.parseCounts(output, actualType);
const { output: finalOutput, truncated } = truncateOutput(output);
return {
type: actualType,
success: exitCode === 0,
errorCount,
warningCount,
output: finalOutput,
truncated,
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return { error: errMsg, success: false };
}
}
/** 解析 lint 输出中的错误和警告数量 */
private parseCounts(output: string, type: 'tsc' | 'eslint'): { errorCount: number; warningCount: number } {
if (type === 'tsc') {
// tsc 输出格式: "file.ts(line,col): error TS1234: message"
const errorMatches = output.match(/error TS\d+:/g);
return { errorCount: errorMatches?.length ?? 0, warningCount: 0 };
}
// eslint 输出末尾汇总: "✖ X problems (Y errors, Z warnings)"
const match = output.match(/(\d+)\s+problems?\s*\((\d+)\s+errors?,\s*(\d+)\s+warnings?\)/);
if (match) {
return {
errorCount: parseInt(match[2], 10),
warningCount: parseInt(match[3], 10),
};
}
return { errorCount: 0, warningCount: 0 };
}
}
// ===== 2. run_tests =====
export class RunTestsTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'run_tests',
description:
'Run the project test suite. Auto-detects the test command from package.json scripts, or uses a custom command. Supports test file filtering.',
parameters: {
type: 'object',
properties: {
filter: {
type: 'string',
description: 'Test file filter pattern (e.g., "auth" or "utils/*"). Passed to the test runner.',
},
watch: {
type: 'boolean',
description: 'Watch mode (not applicable in this context, ignored).',
},
},
},
category: MetonaToolCategory.CODE_EXECUTION,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 120_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const filter = args.filter as string | undefined;
// v0.3.1 修复 WARN-2: 移除 customCommand — LLM 输出不可信,shell:true 有注入风险
// watch 参数不适用,忽略
// v0.3.1 修复 WARN-1: filter 字符白名单校验,防止 Windows shell:true 下的 cmd 元字符注入
// 允许字母/数字/连字符/下划线/斜杠/星号/点(常见测试文件路径模式)
const FILTER_ALLOWED_RE = /^[A-Za-z0-9_\-/. *]+$/;
if (filter && !FILTER_ALLOWED_RE.test(filter)) {
return {
command: 'npm test',
success: false,
passed: 0,
failed: 0,
duration: '0s',
output: `Invalid filter: contains disallowed characters. Only letters, digits, '-', '_', '/', '.', '*', ' ' are allowed.`,
truncated: false,
};
}
try {
// 检测 package.json 的 scripts.test
const pkg = await readPackageJson(context.workspacePath);
const scripts = (pkg?.scripts as Record<string, string> | undefined) ?? {};
const testScript = scripts.test;
if (!testScript || testScript === 'no test specified') {
return {
command: 'npm test',
success: false,
passed: 0,
failed: 0,
duration: '0s',
output: 'No test script found in package.json.',
truncated: false,
};
}
const actualCommand = filter ? `npm test -- ${filter}` : 'npm test';
const execCmd = 'npm';
const execArgs = filter ? ['test', '--', filter] : ['test'];
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
const result = await execFileAsync(execCmd, execArgs, {
cwd: context.workspacePath,
maxBuffer: 5 * 1024 * 1024,
timeout: 120_000,
shell: isWindows,
encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码
signal: context.signal, // P0-4: 用户中断时终止子进程
});
stdout = result.stdout;
stderr = result.stderr;
} catch (error) {
// 测试失败(exitCode !== 0)是正常情况,需要捕获输出解析结果
const err = error as { stdout?: string | Buffer; stderr?: string | Buffer; code?: number };
const extracted = extractOutput(err);
stdout = extracted.stdout;
stderr = extracted.stderr;
exitCode = err.code ?? 1;
}
const output = (stdout + (stderr ? '\n' + stderr : '')).trim();
const { passed, failed, duration } = this.parseTestResults(output);
const { output: finalOutput, truncated } = truncateOutput(output);
return {
command: actualCommand,
success: exitCode === 0,
passed,
failed,
duration,
output: finalOutput,
truncated,
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return { error: errMsg, success: false };
}
}
/** 从测试输出中解析通过/失败数量和耗时(支持 jest/vitest/mocha 格式) */
private parseTestResults(output: string): { passed: number; failed: number; duration: string } {
let passed = 0;
let failed = 0;
let duration = '0s';
// jest/vitest 格式: "Tests: 5 passed, 2 failed, 7 total"
const jestMatch = output.match(/(\d+)\s+passed(?:,\s*(\d+)\s+failed)?/);
if (jestMatch) {
passed = parseInt(jestMatch[1], 10);
failed = jestMatch[2] ? parseInt(jestMatch[2], 10) : 0;
}
// mocha 格式: "X passing (Ys)" / "X failing"
const mochaPass = output.match(/(\d+)\s+passing/);
const mochaFail = output.match(/(\d+)\s+failing/);
if (mochaPass) passed = parseInt(mochaPass[1], 10);
if (mochaFail) failed = parseInt(mochaFail[1], 10);
// 耗时解析: "Time: 3.5 s" / "Duration: 120ms" / "(3.5s)"
const timeMatch = output.match(/(?:Time|Duration|耗时)[:\s]+([\d.]+\s*(?:ms|s|m|h|seconds?|minutes?|hours?)?)/i);
if (timeMatch) {
duration = timeMatch[1];
} else {
const parenTime = output.match(/\(([\d.]+\s*(?:ms|s|m|h))\)/);
if (parenTime) duration = parenTime[1];
}
return { passed, failed, duration };
}
}
// ===== 3. project_info =====
export class ProjectInfoTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'project_info',
description:
'Analyze project structure and dependencies. Returns package.json overview, full dependency list, npm scripts, or directory structure (2 levels deep).',
parameters: {
type: 'object',
properties: {
detail: {
type: 'string',
description:
'Detail level: "overview" (project summary), "dependencies" (full dependency list), "scripts" (npm scripts), or "structure" (directory tree, 2 levels). Defaults to "overview".',
enum: ['overview', 'dependencies', 'scripts', 'structure'],
},
},
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 10_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const detail = (args.detail as string) ?? 'overview';
try {
switch (detail) {
case 'dependencies':
return await this.getDependencies(context.workspacePath);
case 'scripts':
return await this.getScripts(context.workspacePath);
case 'structure':
return await this.getStructure(context.workspacePath);
default:
return await this.getOverview(context.workspacePath);
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return { error: errMsg, success: false };
}
}
private async getOverview(workspacePath: string): Promise<unknown> {
const pkg = await readPackageJson(workspacePath);
if (!pkg) {
return { error: 'package.json not found', success: false };
}
const deps = (pkg.dependencies as Record<string, string> | undefined) ?? {};
const devDeps = (pkg.devDependencies as Record<string, string> | undefined) ?? {};
const scripts = (pkg.scripts as Record<string, string> | undefined) ?? {};
return {
name: (pkg.name as string) ?? 'unknown',
version: (pkg.version as string) ?? '0.0.0',
description: (pkg.description as string) ?? '',
type: pkg.type === 'module' ? 'module' : 'commonjs',
mainEntry: (pkg.module as string) ?? (pkg.main as string) ?? 'index.js',
dependencyCount: Object.keys(deps).length,
devDependencyCount: Object.keys(devDeps).length,
scriptCount: Object.keys(scripts).length,
hasTypeScript: existsSync(join(workspacePath, 'tsconfig.json')),
hasEslint: hasEslintConfig(workspacePath),
};
}
private async getDependencies(workspacePath: string): Promise<unknown> {
const pkg = await readPackageJson(workspacePath);
if (!pkg) {
return { error: 'package.json not found', success: false };
}
const deps = (pkg.dependencies as Record<string, string> | undefined) ?? {};
const devDeps = (pkg.devDependencies as Record<string, string> | undefined) ?? {};
const dependencies = Object.entries(deps).map(([name, version]) => ({ name, version }));
const devDependencies = Object.entries(devDeps).map(([name, version]) => ({ name, version }));
return {
dependencies,
devDependencies,
total: dependencies.length + devDependencies.length,
};
}
private async getScripts(workspacePath: string): Promise<unknown> {
const pkg = await readPackageJson(workspacePath);
if (!pkg) {
return { error: 'package.json not found', success: false };
}
const scripts = (pkg.scripts as Record<string, string> | undefined) ?? {};
const scriptList = Object.entries(scripts).map(([name, command]) => ({ name, command }));
return {
scripts: scriptList,
count: scriptList.length,
};
}
private async getStructure(workspacePath: string): Promise<unknown> {
const directories = await this.listDirectory(workspacePath, 0, 2);
return {
root: workspacePath,
directories,
};
}
/** 递归列出目录(最多 maxDepth 层),跳过 node_modules/.git/dist */
private async listDirectory(
dirPath: string,
currentDepth: number,
maxDepth: number,
): Promise<Array<{ name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> }>> {
const results: Array<{ name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> }> = [];
try {
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (this.shouldSkip(entry.name)) continue;
if (entry.isDirectory()) {
const item: { name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> } = {
name: entry.name,
type: 'dir',
};
if (currentDepth < maxDepth - 1) {
item.children = await this.listChildren(join(dirPath, entry.name));
}
results.push(item);
} else {
results.push({ name: entry.name, type: 'file' });
}
}
} catch {
// 目录读取失败,跳过
}
return results;
}
/** 列出子目录内容(仅 1 层,不再递归) */
private async listChildren(dirPath: string): Promise<Array<{ name: string; type: string }>> {
const results: Array<{ name: string; type: string }> = [];
try {
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (this.shouldSkip(entry.name)) continue;
results.push({ name: entry.name, type: entry.isDirectory() ? 'dir' : 'file' });
}
} catch {
// 目录读取失败,跳过
}
return results;
}
/** 判断目录/文件是否应跳过(node_modules/.git/dist 不递归) */
private shouldSkip(name: string): boolean {
return ['node_modules', '.git', 'dist'].includes(name);
}
}