From 48ee682b3ef51c9806d7f3cc2dd8b6faae424477 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Tue, 26 May 2026 01:20:14 +0900 Subject: [PATCH] feat: add repo evidence mcp tool --- runners/agent-runner/src/ipc-mcp-stdio.ts | 3 + .../src/ipc-repo-evidence-tool.ts | 53 ++++ runners/agent-runner/src/repo-evidence.ts | 232 ++++++++++++++++++ .../agent-runner/test/repo-evidence.test.ts | 144 +++++++++++ 4 files changed, 432 insertions(+) create mode 100644 runners/agent-runner/src/ipc-repo-evidence-tool.ts create mode 100644 runners/agent-runner/src/repo-evidence.ts create mode 100644 runners/agent-runner/test/repo-evidence.test.ts diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index 66fb8a7..6e633b9 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -30,6 +30,7 @@ import { } from './verification.js'; import { resolveIpcDirectories } from './ipc-paths.js'; import { buildSendMessageIpcPayload } from './ipc-message.js'; +import { registerRepoEvidenceTool } from './ipc-repo-evidence-tool.js'; const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } = resolveIpcDirectories( process.env, @@ -536,6 +537,8 @@ server.tool( }, ); +registerRepoEvidenceTool(server, REPO_ROOT); + server.tool( 'list_tasks', "List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.", diff --git a/runners/agent-runner/src/ipc-repo-evidence-tool.ts b/runners/agent-runner/src/ipc-repo-evidence-tool.ts new file mode 100644 index 0000000..66ec2a6 --- /dev/null +++ b/runners/agent-runner/src/ipc-repo-evidence-tool.ts @@ -0,0 +1,53 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { + formatRepoEvidenceResponse, + REPO_EVIDENCE_ACTIONS, + runRepoEvidenceRequestDirect, +} from './repo-evidence.js'; + +export function registerRepoEvidenceTool( + server: McpServer, + repoRoot: string, +): void { + server.tool( + 'read_repo_evidence', + 'Read fixed git evidence directly from EJCLAW_WORK_DIR without broad shell access. Use this when reviewer/arbiter needs current branch, dirty state, recent commits, or a specific commit/ref.', + { + action: z + .enum(REPO_EVIDENCE_ACTIONS) + .describe( + 'git_status=current branch and dirty state, git_head=current HEAD, git_recent_log=recent commits, git_show_ref=show a specific ref/commit', + ), + ref: z + .string() + .optional() + .describe('Only for git_show_ref. Defaults to HEAD.'), + limit: z + .number() + .int() + .min(1) + .max(30) + .optional() + .describe('Only for git_recent_log. Defaults to 10, max 30.'), + }, + async (args) => { + const response = await runRepoEvidenceRequestDirect(repoRoot, { + action: args.action, + ref: args.ref, + limit: args.limit, + }); + + return { + content: [ + { + type: 'text' as const, + text: formatRepoEvidenceResponse(response), + }, + ], + isError: !response.ok, + }; + }, + ); +} diff --git a/runners/agent-runner/src/repo-evidence.ts b/runners/agent-runner/src/repo-evidence.ts new file mode 100644 index 0000000..9780d4d --- /dev/null +++ b/runners/agent-runner/src/repo-evidence.ts @@ -0,0 +1,232 @@ +import { execFile } from 'child_process'; + +export const REPO_EVIDENCE_ACTIONS = [ + 'git_status', + 'git_head', + 'git_recent_log', + 'git_show_ref', +] as const; + +export type RepoEvidenceAction = (typeof REPO_EVIDENCE_ACTIONS)[number]; + +export interface RepoEvidenceRequest { + action: RepoEvidenceAction; + ref?: string; + limit?: number; +} + +export interface RepoEvidenceResponse { + ok: boolean; + action: RepoEvidenceAction; + command: string; + stdout: string; + stderr: string; + exitCode: number; + workdir: string; + error?: string; +} + +interface RepoEvidenceCommandSpec { + file: string; + args: string[]; + commandText: string; +} + +const DEFAULT_LOG_LIMIT = 10; +const MAX_LOG_LIMIT = 30; +const MAX_OUTPUT_CHARS = 16_000; +const COMMAND_TIMEOUT_MS = 5_000; +const COMMAND_MAX_BUFFER = 1024 * 1024; +const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/@:{}~^+-]{1,160}$/; + +export function normalizeRepoEvidenceLimit(value?: number): number { + if (!Number.isFinite(value)) { + return DEFAULT_LOG_LIMIT; + } + const normalized = Math.trunc(value as number); + return Math.min(Math.max(normalized, 1), MAX_LOG_LIMIT); +} + +export function normalizeRepoEvidenceRef(value?: string): string { + const ref = value?.trim() || 'HEAD'; + if ( + ref.startsWith('-') || + ref.includes('..') || + !SAFE_GIT_REF_PATTERN.test(ref) + ) { + throw new Error(`Unsupported git ref for read-only evidence: ${ref}`); + } + return ref; +} + +export function buildRepoEvidenceCommand( + repoRoot: string, + request: RepoEvidenceRequest, +): RepoEvidenceCommandSpec { + const gitArgs = ['-C', repoRoot]; + switch (request.action) { + case 'git_status': { + const args = [...gitArgs, 'status', '--short', '--branch']; + return { + file: 'git', + args, + commandText: `git ${args.join(' ')}`, + }; + } + case 'git_head': { + const args = [...gitArgs, 'log', '-1', '--oneline', '--decorate']; + return { + file: 'git', + args, + commandText: `git ${args.join(' ')}`, + }; + } + case 'git_recent_log': { + const limit = normalizeRepoEvidenceLimit(request.limit); + const args = [...gitArgs, 'log', `-${limit}`, '--oneline', '--decorate']; + return { + file: 'git', + args, + commandText: `git ${args.join(' ')}`, + }; + } + case 'git_show_ref': { + const ref = normalizeRepoEvidenceRef(request.ref); + const args = [ + ...gitArgs, + 'show', + '--stat', + '--oneline', + '--decorate', + '--no-ext-diff', + '--no-renames', + ref, + ]; + return { + file: 'git', + args, + commandText: `git ${args.join(' ')}`, + }; + } + } +} + +function truncateRepoEvidenceText(value: string | undefined): string { + if (!value) return ''; + if (value.length <= MAX_OUTPUT_CHARS) { + return value; + } + return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`; +} + +function execFileCapture( + file: string, + args: string[], +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile( + file, + args, + { + encoding: 'utf8', + timeout: COMMAND_TIMEOUT_MS, + maxBuffer: COMMAND_MAX_BUFFER, + }, + (error, stdout, stderr) => { + if (error) { + reject( + Object.assign(error, { + stdout, + stderr, + }), + ); + return; + } + resolve({ stdout, stderr }); + }, + ); + }); +} + +function extractExitCode(error: unknown): number { + if (typeof error === 'object' && error !== null && 'code' in error) { + const code = (error as { code?: unknown }).code; + if (typeof code === 'number') { + return code; + } + } + return 1; +} + +export async function runRepoEvidenceRequestDirect( + repoRoot: string, + request: RepoEvidenceRequest, +): Promise { + let command: RepoEvidenceCommandSpec; + try { + command = buildRepoEvidenceCommand(repoRoot, request); + } catch (error) { + return { + ok: false, + action: request.action, + command: '', + stdout: '', + stderr: '', + exitCode: 1, + workdir: repoRoot, + error: error instanceof Error ? error.message : String(error), + }; + } + + try { + const { stdout, stderr } = await execFileCapture( + command.file, + command.args, + ); + return { + ok: true, + action: request.action, + command: command.commandText, + stdout: truncateRepoEvidenceText(stdout), + stderr: truncateRepoEvidenceText(stderr), + exitCode: 0, + workdir: repoRoot, + }; + } catch (error) { + const stdout = + typeof error === 'object' && error !== null && 'stdout' in error + ? String((error as { stdout?: unknown }).stdout ?? '') + : ''; + const stderr = + typeof error === 'object' && error !== null && 'stderr' in error + ? String((error as { stderr?: unknown }).stderr ?? '') + : ''; + + return { + ok: false, + action: request.action, + command: command.commandText, + stdout: truncateRepoEvidenceText(stdout), + stderr: truncateRepoEvidenceText(stderr), + exitCode: extractExitCode(error), + workdir: repoRoot, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function formatRepoEvidenceResponse( + response: RepoEvidenceResponse, +): string { + const parts = [ + `Repo evidence action: ${response.action}`, + `Workdir: ${response.workdir}`, + `Exit code: ${response.exitCode}`, + response.command ? `$ ${response.command}` : null, + response.stdout ? response.stdout.trimEnd() : null, + response.stderr ? `[stderr]\n${response.stderr.trimEnd()}` : null, + response.error ? `[error] ${response.error}` : null, + ].filter((value): value is string => Boolean(value)); + + return parts.join('\n'); +} diff --git a/runners/agent-runner/test/repo-evidence.test.ts b/runners/agent-runner/test/repo-evidence.test.ts new file mode 100644 index 0000000..99090e3 --- /dev/null +++ b/runners/agent-runner/test/repo-evidence.test.ts @@ -0,0 +1,144 @@ +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + buildRepoEvidenceCommand, + formatRepoEvidenceResponse, + normalizeRepoEvidenceLimit, + normalizeRepoEvidenceRef, + runRepoEvidenceRequestDirect, +} from '../src/repo-evidence.js'; + +describe('repo evidence helpers', () => { + let repoDir: string; + + beforeEach(() => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-repo-evidence-')); + execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { + cwd: repoDir, + stdio: 'ignore', + }); + execFileSync('git', ['config', 'user.name', 'Test User'], { + cwd: repoDir, + stdio: 'ignore', + }); + fs.writeFileSync(path.join(repoDir, 'README.md'), 'initial\n'); + execFileSync('git', ['add', 'README.md'], { + cwd: repoDir, + stdio: 'ignore', + }); + execFileSync('git', ['commit', '-m', 'initial commit'], { + cwd: repoDir, + stdio: 'ignore', + }); + }); + + afterEach(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('normalizes bounded log limits', () => { + expect(normalizeRepoEvidenceLimit()).toBe(10); + expect(normalizeRepoEvidenceLimit(0)).toBe(1); + expect(normalizeRepoEvidenceLimit(3.8)).toBe(3); + expect(normalizeRepoEvidenceLimit(100)).toBe(30); + }); + + it('rejects refs that can change command behavior or request ranges', () => { + expect(normalizeRepoEvidenceRef()).toBe('HEAD'); + expect(normalizeRepoEvidenceRef(' HEAD~1 ')).toBe('HEAD~1'); + expect(() => normalizeRepoEvidenceRef('--help')).toThrow( + 'Unsupported git ref', + ); + expect(() => normalizeRepoEvidenceRef('main..HEAD')).toThrow( + 'Unsupported git ref', + ); + expect(() => normalizeRepoEvidenceRef('HEAD;rm')).toThrow( + 'Unsupported git ref', + ); + }); + + it('builds fixed git commands without invoking a shell', () => { + expect(buildRepoEvidenceCommand(repoDir, { action: 'git_status' })).toEqual( + { + file: 'git', + args: ['-C', repoDir, 'status', '--short', '--branch'], + commandText: `git -C ${repoDir} status --short --branch`, + }, + ); + + expect( + buildRepoEvidenceCommand(repoDir, { + action: 'git_recent_log', + limit: 100, + }).args, + ).toContain('-30'); + + expect( + buildRepoEvidenceCommand(repoDir, { + action: 'git_show_ref', + ref: 'HEAD', + }).args, + ).toEqual([ + '-C', + repoDir, + 'show', + '--stat', + '--oneline', + '--decorate', + '--no-ext-diff', + '--no-renames', + 'HEAD', + ]); + }); + + it('reads read-only git evidence from the target repository', async () => { + fs.appendFileSync(path.join(repoDir, 'README.md'), 'dirty\n'); + + const status = await runRepoEvidenceRequestDirect(repoDir, { + action: 'git_status', + }); + expect(status.ok).toBe(true); + expect(status.stdout).toContain('README.md'); + + const head = await runRepoEvidenceRequestDirect(repoDir, { + action: 'git_head', + }); + expect(head.ok).toBe(true); + expect(head.stdout).toContain('initial commit'); + + const log = await runRepoEvidenceRequestDirect(repoDir, { + action: 'git_recent_log', + limit: 2, + }); + expect(log.ok).toBe(true); + expect(log.stdout).toContain('initial commit'); + + const shown = await runRepoEvidenceRequestDirect(repoDir, { + action: 'git_show_ref', + ref: 'HEAD', + }); + expect(shown.ok).toBe(true); + expect(shown.stdout).toContain('initial commit'); + expect(shown.stdout).toContain('README.md'); + }); + + it('formats failures into MCP-friendly evidence', async () => { + const response = await runRepoEvidenceRequestDirect(repoDir, { + action: 'git_show_ref', + ref: '--help', + }); + + expect(response.ok).toBe(false); + const text = formatRepoEvidenceResponse(response); + expect(text).toContain('Repo evidence action: git_show_ref'); + expect(text).toContain(`Workdir: ${repoDir}`); + expect(text).toContain('Exit code: 1'); + expect(text).toContain('[error] Unsupported git ref'); + }); +});