Files
metona-ai-desktop/electron/harness/tools/built-in/dev-tools.ts
T
thzxx 0fc589e73b feat: 升级至 v0.3.2 — 工具体系扩展至 26 个 + Context Window 可配置化
## 主要变更

### 1. Context Window 可配置化(v0.3.1 延续)
- DeepSeek/Agnes contextWindow 不再写死 1M,可在设置中配置(min 4096)
- 修复 Engine 128K vs Adapter 1M 不一致 bug,Engine 从 adapter.getContextWindow() 读取
- 热重载 configSig 加入 contextWindow,配置变化即时生效

### 2. 新增 11 个工具(15 → 26 个)
- Git 工具集(4):git_status, git_diff, git_log, git_commit
- 开发工具集(3):lint_code, run_tests, project_info
- HTTP 请求(1):http_request(Node 18+ fetch + AbortController)
- TODO 管理(1):todo_write(会话级内存 + LRU 淘汰)
- 结构化思考(1):think(无副作用思考空间)
- 图片查看(1):view_image(base64 data URL,多模态 LLM 支持)

### 3. 审计修复(2 FAIL + 14 WARN)
- FAIL-1: registry.ts truncateResult 添加 dataUrl 白名单(图片不被截断)
- FAIL-2: todo.ts 添加 LRU 策略 + clearSession 静态方法
- WARN-1: run_tests filter 字符白名单校验,防 cmd 元字符注入
- WARN-2: dataUrl 检测前置到 stringify 之前,避免大图片无意义序列化
- WARN-3: todo.ts 实现真正 LRU(访问刷新位置,非 FIFO)
- WARN-4: git_diff maxBuffer 提升至 5MB,支持超大变更集
- WARN-5: log.info 移至 DelegateTaskTool 注册后,输出正确的 26
- WARN-6: riskColors 添加 critical: 'error' 键
- WARN-7: 所有 execFileAsync 显式设置 encoding: 'utf-8'
- WARN-8: git_log --author 拆分为独立参数
- WARN-9: todo_write 权限从 WRITE 改为 READ
- WARN-10: description 区分与 task_manager 的不同用途

### 4. PolicyEngine 策略
- 新增 11 条策略,26 个工具 + mcp_* 全覆盖
- git_commit: WRITE + requireConfirmation
- todo_write: READ(内存操作)
2026-07-14 21:15:54 +08:00

529 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: 显式设置编码
});
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: 显式设置编码
});
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 {
let actualCommand: string;
let execCmd: string;
let execArgs: string[];
// 检测 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,
};
}
actualCommand = filter ? `npm test -- ${filter}` : 'npm test';
execCmd = 'npm';
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: 显式设置编码
});
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);
}
}