Files
metona-ai-desktop/electron/harness/tools/built-in/git.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

422 lines
14 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.
/**
* Git 工具集(4 个)
*
* git_status, git_diff, git_log, git_commit
*
* 使用 execFile 执行 git 命令,防止命令注入。
* 所有命令在 context.workspacePath 下执行。
*/
import { execFile } from 'child_process';
import { promisify } from 'util';
import { resolve } from 'path';
import { isPathWithinWorkspace } from './file-guard';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
const execFileAsync = promisify(execFile);
/** diff 输出截断阈值(50KB */
const MAX_DIFF_SIZE = 50 * 1024;
/**
* 执行 git 命令的统一入口
*
* 使用 execFile(非 exec)+ 参数数组,从底层杜绝命令注入。
* 显式设置 encoding: 'utf-8'git 默认输出 UTF-8。
*
* v0.3.1 修复 WARN-4: 允许调用方覆盖 maxBuffer,默认 1MB
* git_diff 传 5MB 以支持超大变更集(避免 maxBuffer exceeded 报错而非截断返回)。
*/
async function runGit(
args: string[],
cwd: string,
timeout = 10_000,
maxBuffer = 1024 * 1024, // 默认 1MB
): Promise<string> {
const { stdout } = await execFileAsync('git', args, {
cwd,
maxBuffer,
timeout,
encoding: 'utf-8',
});
return stdout;
}
/** 判断错误是否为 "不是 git 仓库" 或 git 不可用 */
function isNotGitRepoError(error: unknown): boolean {
if (error instanceof Error) {
const stderr = (error as Error & { stderr?: string }).stderr ?? '';
const msg = `${error.message} ${stderr}`;
return /not a git repository/i.test(msg) || /git: not found|command not found/i.test(msg);
}
return false;
}
/** 提取错误信息字符串 */
function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
const stderr = (error as Error & { stderr?: string }).stderr ?? '';
return stderr ? `${error.message}\n${stderr}` : error.message;
}
return String(error);
}
/** 获取当前分支名(失败时返回 'unknown',不抛错) */
async function getCurrentBranch(cwd: string): Promise<string> {
try {
const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)).trim();
return branch;
} catch {
return 'unknown';
}
}
/** 处理 porcelain v1 重命名格式 "old -> new" → 返回 new */
function extractRenamedFile(file: string): string {
const idx = file.indexOf(' -> ');
return idx >= 0 ? file.slice(idx + 4) : file;
}
// ===== 1. git_status =====
export class GitStatusTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'git_status',
description: 'Show the working tree status. Returns current branch, ahead/behind counts, and lists of staged, unstaged, and untracked files.',
parameters: {
type: 'object',
properties: {
pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/" or "package.json"' },
},
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 15_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const pathspec = args.pathspec as string | undefined;
const gitArgs = ['status', '--porcelain=v1', '-b'];
if (pathspec) {
gitArgs.push('--', pathspec);
}
try {
const output = await runGit(gitArgs, context.workspacePath);
return this.parseStatus(output);
} catch (error) {
if (isNotGitRepoError(error)) {
return { error: 'Not a git repository or git not available', success: false };
}
return { error: extractErrorMessage(error), success: false };
}
}
/**
* 解析 `git status --porcelain=v1 -b` 输出
*
* 分支行格式: `## <branch>...<upstream> [ahead N, behind M]`
* 文件行格式: `XY <space> <file>` (X=暂存区状态, Y=工作区状态)
*/
private parseStatus(output: string): unknown {
const lines = output.split('\n').filter((l) => l.length > 0);
const staged: Array<{ status: string; file: string }> = [];
const unstaged: Array<{ status: string; file: string }> = [];
const untracked: string[] = [];
let branch = '';
let ahead = 0;
let behind = 0;
for (const line of lines) {
// 分支行: ## main...origin/main [ahead 2, behind 1]
if (line.startsWith('## ')) {
const header = line.slice(3);
const bracketIdx = header.indexOf('[');
const branchPart = bracketIdx >= 0 ? header.slice(0, bracketIdx).trim() : header.trim();
// branchPart 形如 "main...origin/main" / "main" / "HEAD (no branch)"
const dotIdx = branchPart.indexOf('...');
branch = dotIdx >= 0 ? branchPart.slice(0, dotIdx) : branchPart.split(' ')[0];
if (bracketIdx >= 0) {
const meta = header.slice(bracketIdx + 1, header.lastIndexOf(']'));
const aheadMatch = meta.match(/ahead (\d+)/);
const behindMatch = meta.match(/behind (\d+)/);
if (aheadMatch) ahead = parseInt(aheadMatch[1], 10);
if (behindMatch) behind = parseInt(behindMatch[1], 10);
}
continue;
}
// porcelain v1 文件行: XY <space> <file>
const x = line[0];
const y = line[1];
const file = line.slice(3);
// 未跟踪: ??
if (x === '?' && y === '?') {
untracked.push(file);
continue;
}
// 暂存区变更 (X 非 ' ' 且非 '?')
if (x !== ' ' && x !== '?') {
staged.push({ status: x, file: extractRenamedFile(file) });
}
// 工作区变更 (Y 非 ' ' 且非 '?')
if (y !== ' ' && y !== '?') {
unstaged.push({ status: y, file: extractRenamedFile(file) });
}
}
const clean = staged.length === 0 && unstaged.length === 0 && untracked.length === 0;
return {
branch,
ahead,
behind,
staged,
unstaged,
untracked,
clean,
};
}
}
// ===== 2. git_diff =====
export class GitDiffTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'git_diff',
description: 'Show changes between the working tree and the index (or staged changes). Returns unified diff text. Output is truncated at 50KB.',
parameters: {
type: 'object',
properties: {
cached: { type: 'boolean', description: 'Show only staged (cached) changes (default false)' },
pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/"' },
contextLines: { type: 'number', description: 'Number of context lines (default 3)' },
},
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 15_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const cached = (args.cached as boolean) ?? false;
const pathspec = args.pathspec as string | undefined;
const contextLines = Math.max(0, (args.contextLines as number) ?? 3);
const gitArgs = ['diff'];
if (cached) gitArgs.push('--cached');
gitArgs.push(`--unified=${contextLines}`);
if (pathspec) gitArgs.push('--', pathspec);
try {
// v0.3.1 修复 WARN-4: 使用 5MB maxBuffer 支持超大 diff,避免 maxBuffer exceeded 报错
// 工具内部仍会截断到 50KB 返回,避免 LLM 上下文溢出
const diff = await runGit(gitArgs, context.workspacePath, 10_000, 5 * 1024 * 1024);
const truncated = diff.length > MAX_DIFF_SIZE;
const truncatedDiff = truncated ? diff.slice(0, MAX_DIFF_SIZE) : diff;
const filesChanged = this.countFilesChanged(diff);
return {
diff: truncatedDiff,
filesChanged,
truncated,
};
} catch (error) {
if (isNotGitRepoError(error)) {
return { error: 'Not a git repository or git not available', success: false };
}
return { error: extractErrorMessage(error), success: false };
}
}
/** 统计 diff 中变更的文件数(每变更文件一个 `diff --git` 头) */
private countFilesChanged(diff: string): number {
let count = 0;
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) count++;
}
return count;
}
}
// ===== 3. git_log =====
export class GitLogTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'git_log',
description: 'Show commit history. Returns commits with hash, message, and optionally author/date. Supports filtering by author and path.',
parameters: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Number of entries to return (default 20, max 100)' },
oneline: { type: 'boolean', description: 'Use compact one-line format (default true)' },
pathspec: { type: 'string', description: 'Limit to a path scope' },
author: { type: 'string', description: 'Filter by author name/email' },
},
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 15_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const limit = Math.min(100, Math.max(1, (args.limit as number) ?? 20));
const oneline = (args.oneline as boolean) ?? true;
const pathspec = args.pathspec as string | undefined;
const author = args.author as string | undefined;
const gitArgs = ['log', `-n${limit}`];
if (oneline) {
gitArgs.push('--oneline');
} else {
// 用 NULL 字节分隔字段,避免 author/message 含 '|' 导致解析错误
gitArgs.push('--format=%H%x00%an%x00%ad%x00%s');
}
// v0.3.1 修复 WARN-8: 拆分为独立参数,避免特殊字符干扰 git 解析
if (author) gitArgs.push('--author', author);
if (pathspec) gitArgs.push('--', pathspec);
try {
// 并行获取日志和当前分支名
const [output, branch] = await Promise.all([
runGit(gitArgs, context.workspacePath),
getCurrentBranch(context.workspacePath),
]);
const commits = this.parseLog(output, oneline);
return { commits, count: commits.length, branch };
} catch (error) {
if (isNotGitRepoError(error)) {
return { error: 'Not a git repository or git not available', success: false };
}
return { error: extractErrorMessage(error), success: false };
}
}
private parseLog(
output: string,
oneline: boolean,
): Array<{ hash: string; author?: string; date?: string; message: string }> {
const lines = output.split('\n').filter((l) => l.length > 0);
const commits: Array<{ hash: string; author?: string; date?: string; message: string }> = [];
for (const line of lines) {
if (oneline) {
// <short-hash> <space> <message>
const spaceIdx = line.indexOf(' ');
if (spaceIdx < 0) continue;
commits.push({
hash: line.slice(0, spaceIdx),
message: line.slice(spaceIdx + 1),
});
} else {
// <hash>\0<author>\0<date>\0<message>
const parts = line.split('\0');
if (parts.length < 4) continue;
commits.push({
hash: parts[0],
author: parts[1],
date: parts[2],
message: parts[3],
});
}
}
return commits;
}
}
// ===== 4. git_commit =====
export class GitCommitTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'git_commit',
description: 'Stage files and create a git commit. Supports amending the previous commit. Requires user confirmation.',
parameters: {
type: 'object',
properties: {
message: { type: 'string', description: 'Commit message' },
files: {
type: 'array',
items: { type: 'string', description: 'File path to stage' },
description: 'Files to stage (default: all changes via git add -A)',
},
amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one (default false)' },
},
required: ['message'],
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const message = args.message as string;
const files = (args.files as string[] | undefined) ?? [];
const amend = (args.amend as boolean) ?? false;
if (!message || message.trim().length === 0) {
return { success: false, error: 'Commit message is required' };
}
try {
// 1. 暂存文件(指定文件 → git add -- <files>;否则 git add -A
// v0.3.1 修复 WARN-1: 校验每个 file 路径在工作空间内
if (files.length > 0) {
for (const f of files) {
const resolved = resolve(context.workspacePath, f);
if (!isPathWithinWorkspace(resolved, context.workspacePath)) {
return {
success: false,
error: `File path outside workspace: ${f}`,
commit: '', branch: '', message: '', filesChanged: 0,
};
}
}
await runGit(['add', '--', ...files], context.workspacePath);
} else {
await runGit(['add', '-A'], context.workspacePath);
}
// 2. 统计暂存文件数(commit 前取样)
const stagedOutput = await runGit(['diff', '--cached', '--name-only'], context.workspacePath);
const filesChanged = stagedOutput.split('\n').filter((l) => l.length > 0).length;
// 3. 提交(commit 可能触发 hooks,给予更长超时)
const commitArgs = ['commit'];
if (amend) commitArgs.push('--amend');
commitArgs.push('-m', message);
await runGit(commitArgs, context.workspacePath, 20_000);
// 4. 获取提交 hash 和当前分支
const [commit, branch] = await Promise.all([
runGit(['rev-parse', 'HEAD'], context.workspacePath).then((s) => s.trim()),
getCurrentBranch(context.workspacePath),
]);
return {
success: true,
commit,
branch,
message,
filesChanged,
};
} catch (error) {
if (isNotGitRepoError(error)) {
return { error: 'Not a git repository or git not available', success: false };
}
return { success: false, error: extractErrorMessage(error) };
}
}
}