diff --git a/runners/agent-runner/src/host-evidence.ts b/runners/agent-runner/src/host-evidence.ts new file mode 100644 index 0000000..be9bad6 --- /dev/null +++ b/runners/agent-runner/src/host-evidence.ts @@ -0,0 +1,80 @@ +import fs from 'fs'; +import path from 'path'; + +export const HOST_EVIDENCE_ACTIONS = [ + 'ejclaw_service_status', + 'ejclaw_service_logs', + 'reviewer_image_inspect', +] as const; + +export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number]; + +export interface HostEvidenceResponse { + requestId: string; + ok: boolean; + action: HostEvidenceAction; + command: string; + stdout: string; + stderr: string; + exitCode: number; + error?: string; +} + +const DEFAULT_LOG_TAIL_LINES = 20; +const MAX_LOG_TAIL_LINES = 200; + +export function normalizeHostEvidenceTailLines(value?: number): number { + if (!Number.isFinite(value)) { + return DEFAULT_LOG_TAIL_LINES; + } + const normalized = Math.trunc(value as number); + return Math.min(Math.max(normalized, 1), MAX_LOG_TAIL_LINES); +} + +export function resolveHostEvidenceResponsesDir(hostIpcDir: string): string { + return path.join(hostIpcDir, 'host-evidence-responses'); +} + +export async function waitForHostEvidenceResponse( + responseDir: string, + requestId: string, + options?: { + timeoutMs?: number; + pollMs?: number; + }, +): Promise { + const timeoutMs = options?.timeoutMs ?? 7_000; + const pollMs = options?.pollMs ?? 100; + const responsePath = path.join(responseDir, `${requestId}.json`); + const deadline = Date.now() + timeoutMs; + + while (Date.now() <= deadline) { + if (fs.existsSync(responsePath)) { + const response = JSON.parse( + fs.readFileSync(responsePath, 'utf-8'), + ) as HostEvidenceResponse; + fs.unlinkSync(responsePath); + return response; + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + + throw new Error( + `Timed out waiting for host evidence response: ${requestId}`, + ); +} + +export function formatHostEvidenceResponse( + response: HostEvidenceResponse, +): string { + const parts = [ + `Host evidence action: ${response.action}`, + `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/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index 7717f53..a696181 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -15,12 +15,31 @@ import { DEFAULT_WATCH_CI_CONTEXT_MODE, normalizeWatchCiIntervalSeconds, } from './watch-ci.js'; +import { + formatHostEvidenceResponse, + HOST_EVIDENCE_ACTIONS, + normalizeHostEvidenceTailLines, + resolveHostEvidenceResponsesDir, + waitForHostEvidenceResponse, +} from './host-evidence.js'; +import { + computeVerificationSnapshotId, + formatVerificationResponse, + resolveVerificationResponsesDir, + VERIFICATION_PROFILES, + waitForVerificationResponse, +} from './verification.js'; import { resolveIpcDirectories } from './ipc-paths.js'; const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } = resolveIpcDirectories(process.env); const MESSAGES_DIR = path.join(HOST_IPC_DIR, 'messages'); const TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks'); +const HOST_EVIDENCE_RESPONSES_DIR = + resolveHostEvidenceResponsesDir(HOST_IPC_DIR); +const VERIFICATION_RESPONSES_DIR = + resolveVerificationResponsesDir(HOST_IPC_DIR); +const REPO_ROOT = process.env.EJCLAW_WORK_DIR || process.cwd(); // Context from environment variables (set by the agent runner) const chatJid = process.env.EJCLAW_CHAT_JID!; @@ -336,6 +355,142 @@ server.tool( }, ); +server.tool( + 'read_host_evidence', + 'Read host-side deployment evidence through a narrow allowlist. Use this instead of broad shell access when reviewer/arbiter needs service status, recent logs, or reviewer image metadata.', + { + action: z + .enum(HOST_EVIDENCE_ACTIONS) + .describe( + 'ejclaw_service_status=systemctl --user show ejclaw, ejclaw_service_logs=recent journalctl lines, reviewer_image_inspect=docker image inspect for the reviewer image', + ), + tail_lines: z + .number() + .int() + .min(1) + .max(200) + .optional() + .describe( + 'Only for ejclaw_service_logs. Number of recent journal lines to fetch. Defaults to 20.', + ), + }, + async (args) => { + const requestId = `host-evidence-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 8)}`; + + writeIpcFile(TASKS_DIR, { + type: 'host_evidence_request', + requestId, + action: args.action, + tail_lines: + args.action === 'ejclaw_service_logs' + ? normalizeHostEvidenceTailLines(args.tail_lines) + : undefined, + groupFolder, + timestamp: new Date().toISOString(), + }); + + try { + const response = await waitForHostEvidenceResponse( + HOST_EVIDENCE_RESPONSES_DIR, + requestId, + ); + return { + content: [ + { + type: 'text' as const, + text: formatHostEvidenceResponse(response), + }, + ], + isError: !response.ok, + }; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: + error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, +); + +server.tool( + 'run_verification', + 'Run a fixed verification profile against the current repo snapshot using an isolated scratch workspace and the reviewer runtime image. Use this instead of broad shell write access for test/typecheck/build verification.', + { + profile: z + .enum(VERIFICATION_PROFILES) + .describe( + 'Fixed verification profile. test=npm test, typecheck=npm run typecheck, build=npm run build.', + ), + }, + async (args) => { + let snapshotId = ''; + try { + snapshotId = computeVerificationSnapshotId(REPO_ROOT); + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: + error instanceof Error + ? error.message + : String(error), + }, + ], + isError: true, + }; + } + + const requestId = `verification-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 8)}`; + + writeIpcFile(TASKS_DIR, { + type: 'verification_request', + requestId, + profile: args.profile, + expected_snapshot_id: snapshotId, + groupFolder, + timestamp: new Date().toISOString(), + }); + + try { + const response = await waitForVerificationResponse( + VERIFICATION_RESPONSES_DIR, + requestId, + ); + return { + content: [ + { + type: 'text' as const, + text: formatVerificationResponse(response), + }, + ], + isError: !response.ok, + }; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: + error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, +); + 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/verification.ts b/runners/agent-runner/src/verification.ts new file mode 100644 index 0000000..eebf243 --- /dev/null +++ b/runners/agent-runner/src/verification.ts @@ -0,0 +1,124 @@ +import { createHash } from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +export const VERIFICATION_PROFILES = [ + 'test', + 'typecheck', + 'build', +] as const; + +export type VerificationProfile = (typeof VERIFICATION_PROFILES)[number]; + +export interface VerificationResponse { + requestId: string; + ok: boolean; + profile: VerificationProfile; + command: string; + stdout: string; + stderr: string; + exitCode: number; + snapshotId: string; + runtimeVersion: string; + workdir?: string; + error?: string; +} + +const SNAPSHOT_EXCLUDE_NAMES = new Set(['.git', 'node_modules', '.env']); + +export function isVerificationProfile( + value: unknown, +): value is VerificationProfile { + return ( + typeof value === 'string' && + VERIFICATION_PROFILES.includes(value as VerificationProfile) + ); +} + +function updateSnapshotHash( + hash: ReturnType, + repoDir: string, + currentPath: string, +): void { + const relPath = path.relative(repoDir, currentPath) || '.'; + const stat = fs.lstatSync(currentPath); + + if (stat.isDirectory()) { + if (relPath !== '.') { + hash.update(`dir\0${relPath}\0`); + } + for (const entry of fs.readdirSync(currentPath).sort()) { + if (SNAPSHOT_EXCLUDE_NAMES.has(entry)) continue; + updateSnapshotHash(hash, repoDir, path.join(currentPath, entry)); + } + return; + } + + if (stat.isSymbolicLink()) { + hash.update(`symlink\0${relPath}\0${fs.readlinkSync(currentPath)}\0`); + return; + } + + if (stat.isFile()) { + hash.update(`file\0${relPath}\0`); + hash.update(fs.readFileSync(currentPath)); + hash.update('\0'); + } +} + +export function computeVerificationSnapshotId(repoDir: string): string { + const hash = createHash('sha256'); + updateSnapshotHash(hash, repoDir, repoDir); + return `fs:${hash.digest('hex').slice(0, 24)}`; +} + +export function resolveVerificationResponsesDir(hostIpcDir: string): string { + return path.join(hostIpcDir, 'verification-responses'); +} + +export async function waitForVerificationResponse( + responseDir: string, + requestId: string, + options?: { + timeoutMs?: number; + pollMs?: number; + }, +): Promise { + const timeoutMs = options?.timeoutMs ?? 20 * 60 * 1000; + const pollMs = options?.pollMs ?? 100; + const responsePath = path.join(responseDir, `${requestId}.json`); + const deadline = Date.now() + timeoutMs; + + while (Date.now() <= deadline) { + if (fs.existsSync(responsePath)) { + const response = JSON.parse( + fs.readFileSync(responsePath, 'utf-8'), + ) as VerificationResponse; + fs.unlinkSync(responsePath); + return response; + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + + throw new Error( + `Timed out waiting for verification response: ${requestId}`, + ); +} + +export function formatVerificationResponse( + response: VerificationResponse, +): string { + const parts = [ + `Verification profile: ${response.profile}`, + `Snapshot: ${response.snapshotId}`, + `Runtime: ${response.runtimeVersion}`, + `Exit code: ${response.exitCode}`, + response.workdir ? `Workdir: ${response.workdir}` : null, + 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/host-evidence.test.ts b/runners/agent-runner/test/host-evidence.test.ts new file mode 100644 index 0000000..d9ca55a --- /dev/null +++ b/runners/agent-runner/test/host-evidence.test.ts @@ -0,0 +1,69 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { describe, expect, it } from 'vitest'; + +import { + formatHostEvidenceResponse, + normalizeHostEvidenceTailLines, + resolveHostEvidenceResponsesDir, + waitForHostEvidenceResponse, +} from '../src/host-evidence.js'; + +describe('runner host evidence helpers', () => { + it('normalizes log tail lines for the MCP request', () => { + expect(normalizeHostEvidenceTailLines(undefined)).toBe(20); + expect(normalizeHostEvidenceTailLines(0)).toBe(1); + expect(normalizeHostEvidenceTailLines(999)).toBe(200); + expect(normalizeHostEvidenceTailLines(25)).toBe(25); + }); + + it('reads and removes the host evidence response file', async () => { + const ipcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-host-ipc-')); + const responseDir = resolveHostEvidenceResponsesDir(ipcDir); + fs.mkdirSync(responseDir, { recursive: true }); + + const responsePath = path.join(responseDir, 'req-1.json'); + fs.writeFileSync( + responsePath, + JSON.stringify({ + requestId: 'req-1', + ok: true, + action: 'ejclaw_service_status', + command: 'systemctl --user show ejclaw', + stdout: 'ActiveState=active\n', + stderr: '', + exitCode: 0, + }), + ); + + const response = await waitForHostEvidenceResponse(responseDir, 'req-1', { + timeoutMs: 100, + pollMs: 10, + }); + + expect(response.ok).toBe(true); + expect(response.stdout).toContain('ActiveState=active'); + expect(fs.existsSync(responsePath)).toBe(false); + }); + + it('formats the response into a compact MCP-friendly text block', () => { + const text = formatHostEvidenceResponse({ + requestId: 'req-2', + ok: false, + action: 'ejclaw_service_logs', + command: 'journalctl --user -u ejclaw --no-pager -n 10', + stdout: 'line 1\nline 2\n', + stderr: 'No journal files were found.\n', + exitCode: 1, + error: 'command failed', + }); + + expect(text).toContain('Host evidence action: ejclaw_service_logs'); + expect(text).toContain('Exit code: 1'); + expect(text).toContain('$ journalctl --user -u ejclaw --no-pager -n 10'); + expect(text).toContain('[stderr]'); + expect(text).toContain('[error] command failed'); + }); +}); diff --git a/runners/agent-runner/test/verification.test.ts b/runners/agent-runner/test/verification.test.ts new file mode 100644 index 0000000..1f31241 --- /dev/null +++ b/runners/agent-runner/test/verification.test.ts @@ -0,0 +1,84 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { describe, expect, it } from 'vitest'; + +import { + computeVerificationSnapshotId, + formatVerificationResponse, + resolveVerificationResponsesDir, + waitForVerificationResponse, +} from '../src/verification.js'; + +describe('runner verification helpers', () => { + it('computes the same snapshot when excluded files change', () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-runner-snapshot-')); + fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true }); + fs.mkdirSync(path.join(repoDir, 'node_modules'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'src', 'index.ts'), 'export const x = 1;\n'); + fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=1\n'); + + const first = computeVerificationSnapshotId(repoDir); + fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=2\n'); + const second = computeVerificationSnapshotId(repoDir); + + expect(second).toBe(first); + }); + + it('reads and removes the verification response file', async () => { + const ipcDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-verification-ipc-'), + ); + const responseDir = resolveVerificationResponsesDir(ipcDir); + fs.mkdirSync(responseDir, { recursive: true }); + + const responsePath = path.join(responseDir, 'req-1.json'); + fs.writeFileSync( + responsePath, + JSON.stringify({ + requestId: 'req-1', + ok: true, + profile: 'typecheck', + command: 'npm run typecheck', + stdout: '', + stderr: '', + exitCode: 0, + snapshotId: 'fs:abc123', + runtimeVersion: 'ejclaw-reviewer:latest@sha256:test', + }), + ); + + const response = await waitForVerificationResponse(responseDir, 'req-1', { + timeoutMs: 100, + pollMs: 10, + }); + + expect(response.ok).toBe(true); + expect(response.snapshotId).toBe('fs:abc123'); + expect(fs.existsSync(responsePath)).toBe(false); + }); + + it('formats the response into a compact MCP-friendly text block', () => { + const text = formatVerificationResponse({ + requestId: 'req-2', + ok: false, + profile: 'build', + command: 'npm run build', + stdout: 'tsc output\n', + stderr: 'build failed\n', + exitCode: 1, + snapshotId: 'fs:def456', + runtimeVersion: 'ejclaw-reviewer:latest@sha256:test', + error: 'command failed', + }); + + expect(text).toContain('Verification profile: build'); + expect(text).toContain('Snapshot: fs:def456'); + expect(text).toContain('Runtime: ejclaw-reviewer:latest@sha256:test'); + expect(text).toContain('Exit code: 1'); + expect(text).toContain('$ npm run build'); + expect(text).toContain('[stderr]'); + expect(text).toContain('[error] command failed'); + }); +}); diff --git a/src/agent-runner-environment.test.ts b/src/agent-runner-environment.test.ts index ff1200f..7f91cb4 100644 --- a/src/agent-runner-environment.test.ts +++ b/src/agent-runner-environment.test.ts @@ -374,13 +374,34 @@ describe('prepareContainerSessionEnvironment codex compatibility', () => { const promptsDir = path.join(tempRoot, 'prompts'); fs.mkdirSync(promptsDir, { recursive: true }); + const mcpServerPath = path.join( + tempRoot, + 'runners', + 'agent-runner', + 'dist', + 'ipc-mcp-stdio.js', + ); + fs.mkdirSync(path.dirname(mcpServerPath), { recursive: true }); + fs.writeFileSync(mcpServerPath, '// test mcp server\n'); fs.writeFileSync( path.join(process.env.EJ_TEST_HOME!, '.codex', 'auth.json'), '{"auth_mode":"chatgpt"}\n', ); fs.writeFileSync( path.join(process.env.EJ_TEST_HOME!, '.codex', 'config.toml'), - 'model = "gpt-5.4"\n', + `model = "gpt-5.4" + +[mcp_servers.ejclaw] +command = "node" +args = ["old-ipc.js"] + +[mcp_servers.ejclaw.env] +EJCLAW_IPC_DIR = "/old/ipc" + +[mcp_servers.other] +command = "node" +args = ["other.js"] +`, ); const sessionDir = path.join(tempRoot, 'container-reviewer-session'); @@ -388,6 +409,8 @@ describe('prepareContainerSessionEnvironment codex compatibility', () => { sessionDir, chatJid: 'dc:test', isMain: false, + groupFolder: 'codex-test-group', + agentType: 'codex', memoryBriefing: 'memory briefing', role: 'reviewer', }); @@ -402,8 +425,19 @@ describe('prepareContainerSessionEnvironment codex compatibility', () => { expect( fs.readFileSync(path.join(sessionDir, '.codex', 'auth.json'), 'utf-8'), ).toContain('"auth_mode":"chatgpt"'); - expect( - fs.readFileSync(path.join(sessionDir, '.codex', 'config.toml'), 'utf-8'), - ).toContain('model = "gpt-5.4"'); + const toml = fs.readFileSync( + path.join(sessionDir, '.codex', 'config.toml'), + 'utf-8', + ); + expect(toml).toContain('model = "gpt-5.4"'); + expect(toml).toContain('[mcp_servers.other]'); + expect(toml).toContain('[mcp_servers.ejclaw]'); + expect(toml).toContain('EJCLAW_IPC_DIR = "/workspace/ipc"'); + expect(toml).toContain('EJCLAW_GROUP_FOLDER = "codex-test-group"'); + expect(toml).toContain('EJCLAW_WORK_DIR = "/workspace/project"'); + expect(toml).not.toContain('old-ipc.js'); + expect(toml).not.toContain('"/old/ipc"'); + expect(toml.match(/\[mcp_servers\.ejclaw\]/g)).toHaveLength(1); + expect(toml.match(/\[mcp_servers\.ejclaw\.env\]/g)).toHaveLength(1); }); }); diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 2af7575..49e18ba 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -105,6 +105,68 @@ function syncHostCodexSessionFiles(sessionCodexDir: string): void { } } +function upsertEjclawMcpServerSection(args: { + sessionConfigPath: string; + mcpServerPath: string; + ipcDir: string; + hostIpcDir: string; + chatJid: string; + groupFolder: string; + isMain: boolean; + agentType: AgentType; + workDir?: string; +}): void { + const stripEjclawMcpServerSections = (input: string): string => { + const lines = input.split('\n'); + const kept: string[] = []; + let skipping = false; + + for (const line of lines) { + const trimmed = line.trim(); + const isSectionHeader = trimmed.startsWith('[') && trimmed.endsWith(']'); + const isEjclawSection = /^\[mcp_servers\.ejclaw(?:\.[^\]]+)?\]$/.test( + trimmed, + ); + + if (isSectionHeader) { + if (isEjclawSection) { + skipping = true; + continue; + } + if (skipping) { + skipping = false; + } + } + + if (!skipping) { + kept.push(line); + } + } + + return kept.join('\n').replace(/^\n+/, ''); + }; + + let toml = fs.existsSync(args.sessionConfigPath) + ? fs.readFileSync(args.sessionConfigPath, 'utf-8') + : ''; + toml = stripEjclawMcpServerSections(toml); + const mcpSection = ` +[mcp_servers.ejclaw] +command = "node" +args = [${JSON.stringify(args.mcpServerPath)}] + +[mcp_servers.ejclaw.env] +EJCLAW_IPC_DIR = ${JSON.stringify(args.ipcDir)} +EJCLAW_HOST_IPC_DIR = ${JSON.stringify(args.hostIpcDir)} +EJCLAW_CHAT_JID = ${JSON.stringify(args.chatJid)} +EJCLAW_GROUP_FOLDER = ${JSON.stringify(args.groupFolder)} +EJCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')} +EJCLAW_AGENT_TYPE = ${JSON.stringify(args.agentType)} +${args.workDir ? `EJCLAW_WORK_DIR = ${JSON.stringify(args.workDir)}\n` : ''} +`; + fs.writeFileSync(args.sessionConfigPath, toml.trimEnd() + '\n' + mcpSection); +} + function buildBaseRunnerEnv(args: { group: RegisteredGroup; chatJid: string; @@ -316,24 +378,18 @@ function prepareCodexSessionEnvironment(args: { 'ipc-mcp-stdio.js', ); if (fs.existsSync(mcpServerPath)) { - let toml = fs.existsSync(sessionConfigPath) - ? fs.readFileSync(sessionConfigPath, 'utf-8') - : ''; - toml = toml.replace(/\n?\[mcp_servers\.ejclaw\][\s\S]*?(?=\n\[|$)/, ''); - const mcpSection = ` -[mcp_servers.ejclaw] -command = "node" -args = [${JSON.stringify(mcpServerPath)}] - -[mcp_servers.ejclaw.env] -EJCLAW_IPC_DIR = ${JSON.stringify(args.env.EJCLAW_IPC_DIR)} -EJCLAW_HOST_IPC_DIR = ${JSON.stringify(args.env.EJCLAW_HOST_IPC_DIR)} -EJCLAW_CHAT_JID = ${JSON.stringify(args.chatJid)} -EJCLAW_GROUP_FOLDER = ${JSON.stringify(args.group.folder)} -EJCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')} -EJCLAW_AGENT_TYPE = ${JSON.stringify(args.env.EJCLAW_AGENT_TYPE)} -`; - fs.writeFileSync(sessionConfigPath, toml.trimEnd() + '\n' + mcpSection); + upsertEjclawMcpServerSection({ + sessionConfigPath, + mcpServerPath, + ipcDir: args.env.EJCLAW_IPC_DIR, + hostIpcDir: args.env.EJCLAW_HOST_IPC_DIR, + chatJid: args.chatJid, + groupFolder: args.group.folder, + isMain: args.isMain, + agentType: args.group.agentType || 'claude-code', + workDir: + args.env.EJCLAW_WORK_DIR || args.group.workDir || args.projectRoot, + }); } delete args.env.ANTHROPIC_API_KEY; @@ -506,15 +562,25 @@ export function prepareContainerSessionEnvironment(args: { sessionDir: string; chatJid: string; isMain: boolean; + groupFolder: string; + agentType: AgentType; memoryBriefing?: string; role?: 'reviewer' | 'arbiter'; + ipcDir?: string; + hostIpcDir?: string; + workDir?: string; }): void { const { sessionDir, chatJid, isMain, + groupFolder, + agentType, memoryBriefing, role = 'reviewer', + ipcDir = '/workspace/ipc', + hostIpcDir = ipcDir, + workDir = '/workspace/project', } = args; const projectRoot = process.cwd(); @@ -561,6 +627,27 @@ export function prepareContainerSessionEnvironment(args: { path.join(sessionCodexDir, 'AGENTS.md'), sessionClaudeMd + '\n', ); + const sessionConfigPath = path.join(sessionCodexDir, 'config.toml'); + const mcpServerPath = path.join( + projectRoot, + 'runners', + 'agent-runner', + 'dist', + 'ipc-mcp-stdio.js', + ); + if (fs.existsSync(mcpServerPath)) { + upsertEjclawMcpServerSection({ + sessionConfigPath, + mcpServerPath, + ipcDir, + hostIpcDir, + chatJid, + groupFolder, + isMain, + agentType, + workDir, + }); + } logger.info( { sessionDir, diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 174a12b..cf1d218 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -61,6 +61,25 @@ export interface AgentOutput { error?: string; } +export function mirrorContainerCodexSessionFiles( + sourceSessionDir: string, + mountedSessionDir: string, +): void { + const sourceCodexDir = path.join(sourceSessionDir, '.codex'); + const mountedCodexDir = path.join(mountedSessionDir, '.codex'); + if (!fs.existsSync(sourceCodexDir) || !fs.existsSync(mountedSessionDir)) { + return; + } + + fs.mkdirSync(mountedCodexDir, { recursive: true }); + for (const file of ['AGENTS.md', 'config.toml', 'config.json', 'auth.json']) { + const sourcePath = path.join(sourceCodexDir, file); + if (fs.existsSync(sourcePath)) { + fs.copyFileSync(sourcePath, path.join(mountedCodexDir, file)); + } + } +} + export async function runAgentProcess( group: RegisteredGroup, input: AgentInput, @@ -94,28 +113,21 @@ export async function runAgentProcess( sessionDir, chatJid: input.chatJid, isMain: input.isMain, + groupFolder: group.folder, + agentType: group.agentType || 'claude-code', memoryBriefing: input.memoryBriefing, role: containerRole, }); - // For codex: also write AGENTS.md to the reviewer session dir, because - // the container's /home/node/.claude always mounts the reviewer session. - // Arbiter and reviewer never run simultaneously, so this is safe. + // The persistent container always mounts the reviewer session path at + // /home/node/.claude. When the arbiter stages a separate session dir, + // mirror the Codex session files into the mounted reviewer dir so the + // container sees the arbiter prompt/config, including MCP servers. if (containerRole === 'arbiter') { const reviewerSessionDir = path.join( path.dirname(sessionDir), `${group.folder}-reviewer`, ); - const reviewerCodexDir = path.join(reviewerSessionDir, '.codex'); - if (fs.existsSync(reviewerSessionDir)) { - fs.mkdirSync(reviewerCodexDir, { recursive: true }); - const arbiterAgentsMd = path.join(sessionDir, '.codex', 'AGENTS.md'); - if (fs.existsSync(arbiterAgentsMd)) { - fs.copyFileSync( - arbiterAgentsMd, - path.join(reviewerCodexDir, 'AGENTS.md'), - ); - } - } + mirrorContainerCodexSessionFiles(sessionDir, reviewerSessionDir); } } diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts new file mode 100644 index 0000000..4796fde --- /dev/null +++ b/src/container-runner.test.ts @@ -0,0 +1,195 @@ +import fs from 'fs'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { TEST_ROOT, TEST_DATA_DIR, TEST_GROUPS_DIR, mockDetectAuthMode } = + vi.hoisted(() => { + const testRoot = '/tmp/ejclaw-container-runner-test'; + return { + TEST_ROOT: testRoot, + TEST_DATA_DIR: `${testRoot}/data`, + TEST_GROUPS_DIR: `${testRoot}/groups`, + mockDetectAuthMode: vi.fn<() => 'oauth' | 'api-key'>(), + }; + }); + +vi.mock('./config.js', () => ({ + AGENT_MAX_OUTPUT_SIZE: 1024 * 1024, + AGENT_TIMEOUT: 60_000, + DATA_DIR: TEST_DATA_DIR, + GROUPS_DIR: TEST_GROUPS_DIR, + IDLE_TIMEOUT: 1_000, + REVIEWER_CONTAINER_IMAGE: 'ejclaw-reviewer:latest', + TIMEZONE: 'Asia/Seoul', +})); + +vi.mock('./container-runtime.js', () => ({ + CONTAINER_RUNTIME_BIN: 'docker', + ensureContainerRuntimeRunning: vi.fn(), + hostGatewayArgs: () => ['--add-host=host.docker.internal:host-gateway'], + readonlyMountArgs: (hostPath: string, containerPath: string) => [ + '-v', + `${hostPath}:${containerPath}:ro`, + ], + writableMountArgs: (hostPath: string, containerPath: string) => [ + '-v', + `${hostPath}:${containerPath}`, + ], + tmpfsMountArgs: (containerPath: string) => ['--tmpfs', containerPath], +})); + +vi.mock('./credential-proxy.js', () => ({ + detectAuthMode: mockDetectAuthMode, +})); + +vi.mock('./group-folder.js', () => ({ + resolveGroupFolderPath: (folder: string) => + path.join(TEST_GROUPS_DIR, folder), + resolveGroupIpcPath: (folder: string) => + path.join(TEST_DATA_DIR, 'ipc', folder), +})); + +vi.mock('./logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { + appendExecEnvArgs, + buildCreateArgs, + buildReviewerMounts, +} from './container-runner.js'; +import type { RegisteredGroup } from './types.js'; + +function makeGroup(): RegisteredGroup { + return { + name: 'Test Group', + folder: 'brain', + trigger: '@Codex', + added_at: new Date().toISOString(), + agentType: 'codex', + }; +} + +describe('container-runner path compatibility', () => { + const previousCodeXHome = process.env.CODEX_HOME; + const previousOauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; + + beforeEach(() => { + fs.rmSync(TEST_ROOT, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + fs.mkdirSync(TEST_GROUPS_DIR, { recursive: true }); + process.env.CODEX_HOME = path.join(TEST_ROOT, 'codex-home'); + fs.mkdirSync(process.env.CODEX_HOME, { recursive: true }); + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-token'; + mockDetectAuthMode.mockReset(); + mockDetectAuthMode.mockReturnValue('oauth'); + }); + + afterEach(() => { + fs.rmSync(TEST_ROOT, { recursive: true, force: true }); + if (previousCodeXHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodeXHome; + } + if (previousOauthToken === undefined) { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + } else { + process.env.CLAUDE_CODE_OAUTH_TOKEN = previousOauthToken; + } + }); + + it('mounts the owner workspace at both canonical and host absolute paths and shadows both .env paths', () => { + const group = makeGroup(); + const ownerWorkspaceDir = path.join(TEST_ROOT, 'workspace', 'owner'); + fs.mkdirSync(ownerWorkspaceDir, { recursive: true }); + fs.writeFileSync(path.join(ownerWorkspaceDir, '.env'), 'SECRET=1\n'); + fs.mkdirSync(path.join(TEST_GROUPS_DIR, group.folder), { recursive: true }); + fs.mkdirSync(path.join(TEST_DATA_DIR, 'ipc', group.folder), { + recursive: true, + }); + + const mounts = buildReviewerMounts(group, ownerWorkspaceDir); + + expect(mounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + hostPath: ownerWorkspaceDir, + containerPath: '/workspace/project', + readonly: true, + }), + expect.objectContaining({ + hostPath: ownerWorkspaceDir, + containerPath: ownerWorkspaceDir, + readonly: true, + }), + expect.objectContaining({ + hostPath: '/dev/null', + containerPath: '/workspace/project/.env', + readonly: true, + }), + expect.objectContaining({ + hostPath: '/dev/null', + containerPath: path.join(ownerWorkspaceDir, '.env'), + readonly: true, + }), + ]), + ); + }); + + it('keeps dynamic workdir and role env on docker exec instead of baking them into container creation', () => { + const createArgs = buildCreateArgs([], 'ejclaw-reviewer-brain'); + const createText = createArgs.join(' '); + + expect(createText).not.toContain('EJCLAW_WORK_DIR='); + expect(createText).not.toContain('EJCLAW_PAIRED_ROLE='); + expect(createText).not.toContain('CLAUDE_CONFIG_DIR='); + expect(createText).not.toContain('EJCLAW_REVIEWER_RUNTIME='); + + const reviewerExecArgs = ['exec', '-i']; + appendExecEnvArgs( + reviewerExecArgs, + { + EJCLAW_WORK_DIR: '/home/clone-ej/project', + EJCLAW_PAIRED_TASK_ID: 'task-1', + EJCLAW_PAIRED_ROLE: 'reviewer', + EJCLAW_REVIEWER_RUNTIME: '1', + CLAUDE_CONFIG_DIR: '/host/reviewer-session', + CLAUDE_MODEL: 'claude-sonnet', + CODEX_MODEL: 'gpt-5-codex', + }, + false, + ); + + const reviewerExecText = reviewerExecArgs.join(' '); + expect(reviewerExecText).toContain( + 'EJCLAW_WORK_DIR=/home/clone-ej/project', + ); + expect(reviewerExecText).toContain('EJCLAW_PAIRED_TASK_ID=task-1'); + expect(reviewerExecText).toContain('EJCLAW_PAIRED_ROLE=reviewer'); + expect(reviewerExecText).toContain('EJCLAW_REVIEWER_RUNTIME=1'); + expect(reviewerExecText).toContain('CLAUDE_CONFIG_DIR=/home/node/.claude'); + expect(reviewerExecText).toContain('CLAUDE_MODEL=claude-sonnet'); + expect(reviewerExecText).not.toContain('CODEX_MODEL=gpt-5-codex'); + + const codexExecArgs = ['exec', '-i']; + appendExecEnvArgs( + codexExecArgs, + { + CODEX_MODEL: 'gpt-5-codex', + CLAUDE_MODEL: 'claude-sonnet', + }, + true, + ); + + const codexExecText = codexExecArgs.join(' '); + expect(codexExecText).toContain('CODEX_MODEL=gpt-5-codex'); + expect(codexExecText).not.toContain('CLAUDE_MODEL=claude-sonnet'); + }); +}); diff --git a/src/container-runner.ts b/src/container-runner.ts index b81b0db..41c9afa 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -61,6 +61,30 @@ interface VolumeMount { readonly: boolean; } +interface InspectedContainerMount { + Source?: string; + Destination?: string; + RW?: boolean; +} + +interface InspectedContainer { + Mounts?: InspectedContainerMount[]; +} + +const PRIMARY_PROJECT_MOUNT = '/workspace/project'; + +function pushMountOnce(mounts: VolumeMount[], mount: VolumeMount): void { + const exists = mounts.some( + (entry) => + entry.hostPath === mount.hostPath && + entry.containerPath === mount.containerPath && + entry.readonly === mount.readonly, + ); + if (!exists) { + mounts.push(mount); + } +} + // ── pnpm store detection ───────────────────────────────────────── function detectPnpmStorePath(workspaceDir: string): string | null { @@ -131,15 +155,57 @@ function isContainerRunning(name: string): boolean { } } +function inspectContainer(name: string): InspectedContainer | null { + try { + const output = execFileSync(CONTAINER_RUNTIME_BIN, ['inspect', name], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000, + }); + const parsed = JSON.parse(output) as InspectedContainer[]; + return parsed[0] ?? null; + } catch { + return null; + } +} + +function containerHasExpectedMounts( + name: string, + expectedMounts: VolumeMount[], +): boolean { + const inspection = inspectContainer(name); + const actualMounts = inspection?.Mounts ?? []; + + return expectedMounts.every((expected) => + actualMounts.some( + (actual) => + actual.Source === expected.hostPath && + actual.Destination === expected.containerPath && + Boolean(actual.RW) === !expected.readonly, + ), + ); +} + function ensurePersistentContainer( group: RegisteredGroup, ownerWorkspaceDir: string, envOverrides?: Record, ): string { const containerName = getContainerName(group.folder); + const mounts = buildReviewerMounts(group, ownerWorkspaceDir); if (isContainerRunning(containerName)) { - return containerName; + if (containerHasExpectedMounts(containerName, mounts)) { + return containerName; + } + logger.info( + { + containerName, + group: group.name, + groupFolder: group.folder, + }, + 'Recreating persistent reviewer container because mount layout changed', + ); } // Remove stale stopped container with same name @@ -152,8 +218,7 @@ function ensurePersistentContainer( /* doesn't exist */ } - const mounts = buildReviewerMounts(group, ownerWorkspaceDir); - const args = buildCreateArgs(mounts, containerName, envOverrides); + const args = buildCreateArgs(mounts, containerName); logger.info( { @@ -212,11 +277,20 @@ export function buildReviewerMounts( const groupIpcDir = resolveGroupIpcPath(group.folder); // Source code: READ-ONLY (kernel-level protection) - mounts.push({ + pushMountOnce(mounts, { hostPath: ownerWorkspaceDir, - containerPath: '/workspace/project', + containerPath: PRIMARY_PROJECT_MOUNT, readonly: true, }); + // Compatibility mount: expose the owner workspace at the same absolute path + // inside the container so owner-authored absolute paths still resolve. + if (ownerWorkspaceDir !== PRIMARY_PROJECT_MOUNT) { + pushMountOnce(mounts, { + hostPath: ownerWorkspaceDir, + containerPath: ownerWorkspaceDir, + readonly: true, + }); + } // Git worktree support: worktree's .git file references the parent repo's // .git directory via absolute path. Mount the parent .git at the same host @@ -233,7 +307,7 @@ export function buildReviewerMounts( // worktreeGitDir = /parent/.git/worktrees/name → parent .git = ../../ const parentGitDir = path.resolve(worktreeGitDir, '..', '..'); if (fs.existsSync(parentGitDir)) { - mounts.push({ + pushMountOnce(mounts, { hostPath: parentGitDir, containerPath: parentGitDir, readonly: true, @@ -252,7 +326,7 @@ export function buildReviewerMounts( // pnpm global store: mount at the same host path so hardlinks resolve. const pnpmStore = detectPnpmStorePath(ownerWorkspaceDir); if (pnpmStore) { - mounts.push({ + pushMountOnce(mounts, { hostPath: pnpmStore, containerPath: pnpmStore, readonly: true, @@ -263,17 +337,23 @@ export function buildReviewerMounts( // Shadow .env so reviewer cannot read secrets from mounted project const envFile = path.join(ownerWorkspaceDir, '.env'); if (fs.existsSync(envFile)) { - mounts.push({ - hostPath: '/dev/null', - containerPath: '/workspace/project/.env', - readonly: true, - }); + const shadowPaths = new Set([ + path.join(PRIMARY_PROJECT_MOUNT, '.env'), + path.join(ownerWorkspaceDir, '.env'), + ]); + for (const shadowPath of shadowPaths) { + pushMountOnce(mounts, { + hostPath: '/dev/null', + containerPath: shadowPath, + readonly: true, + }); + } } // Attachments directory: read-only (Discord file uploads downloaded here) const attachmentsDir = path.join(DATA_DIR, 'attachments'); if (fs.existsSync(attachmentsDir)) { - mounts.push({ + pushMountOnce(mounts, { hostPath: attachmentsDir, containerPath: attachmentsDir, readonly: true, @@ -281,7 +361,7 @@ export function buildReviewerMounts( } // Group folder: writable (logs, session data) - mounts.push({ + pushMountOnce(mounts, { hostPath: groupDir, containerPath: '/workspace/group', readonly: false, @@ -290,7 +370,7 @@ export function buildReviewerMounts( // IPC directory: writable (output messages, task results) fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); - mounts.push({ + pushMountOnce(mounts, { hostPath: groupIpcDir, containerPath: '/workspace/ipc', readonly: false, @@ -299,7 +379,7 @@ export function buildReviewerMounts( // Global memory: read-only const globalDir = path.join(GROUPS_DIR, 'global'); if (fs.existsSync(globalDir)) { - mounts.push({ + pushMountOnce(mounts, { hostPath: globalDir, containerPath: '/workspace/global', readonly: true, @@ -315,7 +395,7 @@ export function buildReviewerMounts( `${group.folder}-reviewer`, ); fs.mkdirSync(groupSessionsDir, { recursive: true }); - mounts.push({ + pushMountOnce(mounts, { hostPath: groupSessionsDir, containerPath: '/home/node/.claude', readonly: false, @@ -325,7 +405,7 @@ export function buildReviewerMounts( // files (cron state, configs, etc.) that the owner references by absolute path. const ownerSessionDir = path.join(DATA_DIR, 'sessions', group.folder); if (fs.existsSync(ownerSessionDir)) { - mounts.push({ + pushMountOnce(mounts, { hostPath: ownerSessionDir, containerPath: ownerSessionDir, readonly: true, @@ -336,7 +416,7 @@ export function buildReviewerMounts( const hostCodexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); if (fs.existsSync(hostCodexHome)) { - mounts.push({ + pushMountOnce(mounts, { hostPath: hostCodexHome, containerPath: '/home/node/.codex', readonly: true, @@ -349,10 +429,9 @@ export function buildReviewerMounts( // ── Container args builders ────────────────────────────────────── /** Build args for `docker run -d` (create persistent container). */ -function buildCreateArgs( +export function buildCreateArgs( mounts: VolumeMount[], containerName: string, - envOverrides?: Record, ): string[] { // Start detached with sleep infinity — turns run via docker exec const args: string[] = [ @@ -381,40 +460,11 @@ function buildCreateArgs( args.push('-e', `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`); } - // Reviewer runtime flag - args.push('-e', 'EJCLAW_REVIEWER_RUNTIME=1'); - // Sentry read-only token for reviewer to verify error fixes if (process.env.SENTRY_AUTH_TOKEN) { args.push('-e', `SENTRY_AUTH_TOKEN=${process.env.SENTRY_AUTH_TOKEN}`); } - // Extra env overrides from paired-execution-context. - // Model/effort keys are excluded here — they are injected per-exec so the - // correct agent-type-specific values are used (claude vs codex). - const perExecKeys = new Set([ - 'CLAUDE_MODEL', - 'CLAUDE_EFFORT', - 'CODEX_MODEL', - 'CODEX_EFFORT', - ]); - if (envOverrides) { - for (const [key, value] of Object.entries(envOverrides)) { - if (key === 'ANTHROPIC_API_KEY' || key === 'CLAUDE_CODE_OAUTH_TOKEN') - continue; - if (perExecKeys.has(key)) continue; - if (key === 'EJCLAW_WORK_DIR') { - args.push('-e', 'EJCLAW_WORK_DIR=/workspace/project'); - continue; - } - if (key === 'CLAUDE_CONFIG_DIR') { - args.push('-e', 'CLAUDE_CONFIG_DIR=/home/node/.claude'); - continue; - } - args.push('-e', `${key}=${value}`); - } - } - // Host gateway args.push(...hostGatewayArgs()); @@ -447,6 +497,32 @@ function buildCreateArgs( return args; } +export function appendExecEnvArgs( + execArgs: string[], + envOverrides: Record | undefined, + isCodexAgent: boolean, +): void { + if (!envOverrides) { + return; + } + + const ignoredKeys = new Set(['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']); + const incompatibleModelKeys = isCodexAgent + ? new Set(['CLAUDE_MODEL', 'CLAUDE_EFFORT']) + : new Set(['CODEX_MODEL', 'CODEX_EFFORT']); + + for (const [key, value] of Object.entries(envOverrides)) { + if (!value || ignoredKeys.has(key) || incompatibleModelKeys.has(key)) { + continue; + } + if (key === 'CLAUDE_CONFIG_DIR') { + execArgs.push('-e', 'CLAUDE_CONFIG_DIR=/home/node/.claude'); + continue; + } + execArgs.push('-e', `${key}=${value}`); + } +} + // ── Main runner ─────────────────────────────────────────────────── export async function runReviewerContainer(args: { @@ -511,17 +587,7 @@ export async function runReviewerContainer(args: { }, 'Container exec runner selection', ); - // Inject only agent-type-appropriate model/effort overrides per exec. - if (envOverrides) { - const modelEnvKeys = isCodexAgent - ? ['CODEX_MODEL', 'CODEX_EFFORT'] - : ['CLAUDE_MODEL', 'CLAUDE_EFFORT']; - for (const key of modelEnvKeys) { - if (envOverrides[key]) { - execArgs.push('-e', `${key}=${envOverrides[key]}`); - } - } - } + appendExecEnvArgs(execArgs, envOverrides, isCodexAgent); if (isCodexAgent) { // Use session-local .codex dir (contains AGENTS.md with role prompts) // instead of the host-mounted ~/.codex (which has owner-only config). diff --git a/src/host-evidence-ipc.test.ts b/src/host-evidence-ipc.test.ts new file mode 100644 index 0000000..bd8826d --- /dev/null +++ b/src/host-evidence-ipc.test.ts @@ -0,0 +1,124 @@ +import fs from 'fs'; +import path from 'path'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { runHostEvidenceRequestMock } = vi.hoisted(() => ({ + runHostEvidenceRequestMock: vi.fn(), +})); + +vi.mock('./host-evidence.js', async () => { + const actual = + await vi.importActual( + './host-evidence.js', + ); + return { + ...actual, + runHostEvidenceRequest: runHostEvidenceRequestMock, + }; +}); + +import { _initTestDatabase, _setRegisteredGroupForTests } from './db.js'; +import { resolveGroupIpcPath } from './group-folder.js'; +import { processTaskIpc, type IpcDeps } from './ipc.js'; +import type { RegisteredGroup } from './types.js'; + +const OTHER_GROUP: RegisteredGroup = { + name: 'Other', + folder: 'other-group', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', +}; + +describe('host evidence IPC', () => { + let deps: IpcDeps; + + beforeEach(() => { + _initTestDatabase(); + _setRegisteredGroupForTests('other@g.us', OTHER_GROUP); + runHostEvidenceRequestMock.mockReset(); + + deps = { + sendMessage: async () => {}, + registeredGroups: () => ({ 'other@g.us': OTHER_GROUP }), + assignRoom: () => {}, + syncGroups: async () => {}, + getAvailableGroups: () => [], + writeGroupsSnapshot: () => {}, + }; + + fs.rmSync(resolveGroupIpcPath('other-group'), { + recursive: true, + force: true, + }); + }); + + it('writes host evidence responses into the source group namespace', async () => { + runHostEvidenceRequestMock.mockResolvedValue({ + ok: true, + action: 'ejclaw_service_status', + command: 'systemctl --user show ejclaw', + stdout: 'ActiveState=active\nSubState=running\n', + stderr: '', + exitCode: 0, + }); + + await processTaskIpc( + { + type: 'host_evidence_request', + requestId: 'req-1', + action: 'ejclaw_service_status', + }, + 'other-group', + false, + deps, + ); + + const responsePath = path.join( + resolveGroupIpcPath('other-group'), + 'host-evidence-responses', + 'req-1.json', + ); + const response = JSON.parse(fs.readFileSync(responsePath, 'utf-8')) as { + ok: boolean; + requestId: string; + stdout: string; + }; + + expect(runHostEvidenceRequestMock).toHaveBeenCalledWith({ + requestId: 'req-1', + action: 'ejclaw_service_status', + tailLines: undefined, + }); + expect(response.requestId).toBe('req-1'); + expect(response.ok).toBe(true); + expect(response.stdout).toContain('ActiveState=active'); + }); + + it('returns an error response for unsupported actions without shelling out', async () => { + await processTaskIpc( + { + type: 'host_evidence_request', + requestId: 'req-2', + action: 'cat /etc/shadow', + }, + 'other-group', + false, + deps, + ); + + const responsePath = path.join( + resolveGroupIpcPath('other-group'), + 'host-evidence-responses', + 'req-2.json', + ); + const response = JSON.parse(fs.readFileSync(responsePath, 'utf-8')) as { + ok: boolean; + error?: string; + }; + + expect(runHostEvidenceRequestMock).not.toHaveBeenCalled(); + expect(response.ok).toBe(false); + expect(response.error).toContain('Unsupported host evidence action'); + }); +}); diff --git a/src/host-evidence.test.ts b/src/host-evidence.test.ts new file mode 100644 index 0000000..ac3b6f3 --- /dev/null +++ b/src/host-evidence.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildHostEvidenceCommand, + clampHostEvidenceTailLines, + isHostEvidenceAction, +} from './host-evidence.js'; + +describe('host evidence helpers', () => { + it('recognizes only allowlisted actions', () => { + expect(isHostEvidenceAction('ejclaw_service_status')).toBe(true); + expect(isHostEvidenceAction('ejclaw_service_logs')).toBe(true); + expect(isHostEvidenceAction('reviewer_image_inspect')).toBe(true); + expect(isHostEvidenceAction('rm -rf /')).toBe(false); + }); + + it('clamps journal tail lines to a safe range', () => { + expect(clampHostEvidenceTailLines(undefined)).toBe(20); + expect(clampHostEvidenceTailLines(0)).toBe(1); + expect(clampHostEvidenceTailLines(500)).toBe(200); + expect(clampHostEvidenceTailLines(15)).toBe(15); + }); + + it('builds deterministic commands for each allowlisted action', () => { + const status = buildHostEvidenceCommand({ + requestId: 'req-1', + action: 'ejclaw_service_status', + }); + expect(status.file).toBe('systemctl'); + expect(status.args).toContain('show'); + expect(status.args).toContain('ActiveState'); + + const logs = buildHostEvidenceCommand({ + requestId: 'req-2', + action: 'ejclaw_service_logs', + tailLines: 42, + }); + expect(logs.file).toBe('journalctl'); + expect(logs.args).toEqual( + expect.arrayContaining(['--user', '-u', 'ejclaw', '-n', '42']), + ); + + const inspect = buildHostEvidenceCommand({ + requestId: 'req-3', + action: 'reviewer_image_inspect', + }); + expect(inspect.file).toBe('docker'); + expect(inspect.args.slice(0, 3)).toEqual([ + 'image', + 'inspect', + 'ejclaw-reviewer:latest', + ]); + }); +}); diff --git a/src/host-evidence.ts b/src/host-evidence.ts new file mode 100644 index 0000000..de7f5ec --- /dev/null +++ b/src/host-evidence.ts @@ -0,0 +1,238 @@ +import { execFile } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { REVIEWER_CONTAINER_IMAGE } from './config.js'; +import { resolveGroupIpcPath } from './group-folder.js'; + +export const HOST_EVIDENCE_ACTIONS = [ + 'ejclaw_service_status', + 'ejclaw_service_logs', + 'reviewer_image_inspect', +] as const; + +export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number]; + +export interface HostEvidenceRequest { + requestId: string; + action: HostEvidenceAction; + tailLines?: number; +} + +export interface HostEvidenceResult { + ok: boolean; + action: HostEvidenceAction; + command: string; + stdout: string; + stderr: string; + exitCode: number; + error?: string; +} + +export interface HostEvidenceResponse extends HostEvidenceResult { + requestId: string; +} + +interface HostEvidenceCommandSpec { + file: string; + args: string[]; + commandText: string; +} + +const DEFAULT_LOG_TAIL_LINES = 20; +const MAX_LOG_TAIL_LINES = 200; +const MAX_OUTPUT_CHARS = 16_000; +const COMMAND_TIMEOUT_MS = 5_000; +const COMMAND_MAX_BUFFER = 1024 * 1024; + +export function isHostEvidenceAction( + value: unknown, +): value is HostEvidenceAction { + return ( + typeof value === 'string' && + HOST_EVIDENCE_ACTIONS.includes(value as HostEvidenceAction) + ); +} + +export function clampHostEvidenceTailLines(value?: number): number { + if (!Number.isFinite(value)) { + return DEFAULT_LOG_TAIL_LINES; + } + const normalized = Math.trunc(value as number); + return Math.min(Math.max(normalized, 1), MAX_LOG_TAIL_LINES); +} + +export function buildHostEvidenceCommand( + request: HostEvidenceRequest, +): HostEvidenceCommandSpec { + switch (request.action) { + case 'ejclaw_service_status': { + const args = [ + '--user', + 'show', + 'ejclaw', + '-p', + 'Id', + '-p', + 'LoadState', + '-p', + 'ActiveState', + '-p', + 'SubState', + '-p', + 'ExecMainCode', + '-p', + 'ExecMainStatus', + '-p', + 'ExecMainStartTimestamp', + '-p', + 'ActiveEnterTimestamp', + ]; + return { + file: 'systemctl', + args, + commandText: `systemctl ${args.join(' ')}`, + }; + } + case 'ejclaw_service_logs': { + const tailLines = clampHostEvidenceTailLines(request.tailLines); + const args = [ + '--user', + '-u', + 'ejclaw', + '--no-pager', + '-n', + String(tailLines), + ]; + return { + file: 'journalctl', + args, + commandText: `journalctl ${args.join(' ')}`, + }; + } + case 'reviewer_image_inspect': { + const args = [ + 'image', + 'inspect', + REVIEWER_CONTAINER_IMAGE, + '--format', + 'Id={{.Id}}\nRepoTags={{json .RepoTags}}\nCreated={{.Created}}\nSize={{.Size}}', + ]; + return { + file: 'docker', + args, + commandText: `docker ${args + .map((part) => (part.includes(' ') ? JSON.stringify(part) : part)) + .join(' ')}`, + }; + } + } +} + +export function truncateHostEvidenceText(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 runHostEvidenceRequest( + request: HostEvidenceRequest, +): Promise { + const command = buildHostEvidenceCommand(request); + + try { + const { stdout, stderr } = await execFileCapture( + command.file, + command.args, + ); + return { + ok: true, + action: request.action, + command: command.commandText, + stdout: truncateHostEvidenceText(stdout), + stderr: truncateHostEvidenceText(stderr), + exitCode: 0, + }; + } 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: truncateHostEvidenceText(stdout), + stderr: truncateHostEvidenceText(stderr), + exitCode: extractExitCode(error), + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function resolveHostEvidenceResponseDir(groupFolder: string): string { + return path.join(resolveGroupIpcPath(groupFolder), 'host-evidence-responses'); +} + +export function writeHostEvidenceResponse( + groupFolder: string, + response: HostEvidenceResponse, +): string { + const responseDir = resolveHostEvidenceResponseDir(groupFolder); + fs.mkdirSync(responseDir, { recursive: true }); + + const outputPath = path.join(responseDir, `${response.requestId}.json`); + const tempPath = `${outputPath}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(response, null, 2)); + fs.renameSync(tempPath, outputPath); + return outputPath; +} diff --git a/src/ipc.ts b/src/ipc.ts index 3c9a981..4daba58 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -4,6 +4,16 @@ import path from 'path'; import { CronExpressionParser } from 'cron-parser'; import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js'; +import { + isHostEvidenceAction, + runHostEvidenceRequest, + writeHostEvidenceResponse, +} from './host-evidence.js'; +import { + isVerificationProfile, + runVerificationRequest, + writeVerificationResponse, +} from './verification.js'; import { readJsonFile } from './utils.js'; import { AvailableGroup } from './agent-runner.js'; import { @@ -311,6 +321,11 @@ export async function processTaskIpc( memory_kind?: string | null; source_kind?: string; source_ref?: string | null; + requestId?: string; + action?: string; + tail_lines?: number; + profile?: string; + expected_snapshot_id?: string; }, sourceGroup: string, // Verified identity from IPC directory isMain: boolean, // Verified from directory path @@ -524,6 +539,118 @@ export async function processTaskIpc( } break; + case 'host_evidence_request': + if (!data.requestId) { + logger.warn( + { sourceGroup }, + 'Ignoring host_evidence_request without requestId', + ); + break; + } + + if (!isHostEvidenceAction(data.action)) { + writeHostEvidenceResponse(sourceGroup, { + requestId: data.requestId, + ok: false, + action: 'ejclaw_service_status', + command: '', + stdout: '', + stderr: '', + exitCode: 1, + error: `Unsupported host evidence action: ${String(data.action)}`, + }); + logger.warn( + { sourceGroup, requestId: data.requestId, action: data.action }, + 'Rejected unsupported host evidence action', + ); + break; + } + + { + const result = await runHostEvidenceRequest({ + requestId: data.requestId, + action: data.action, + tailLines: + typeof data.tail_lines === 'number' ? data.tail_lines : undefined, + }); + + writeHostEvidenceResponse(sourceGroup, { + requestId: data.requestId, + ...result, + }); + + logger.info( + { + sourceGroup, + requestId: data.requestId, + action: data.action, + ok: result.ok, + exitCode: result.exitCode, + }, + 'Processed host evidence request via IPC', + ); + } + break; + + case 'verification_request': + if (!data.requestId) { + logger.warn( + { sourceGroup }, + 'Ignoring verification_request without requestId', + ); + break; + } + + if (!isVerificationProfile(data.profile)) { + writeVerificationResponse(sourceGroup, { + requestId: data.requestId, + ok: false, + profile: 'test', + command: '', + stdout: '', + stderr: '', + exitCode: 1, + snapshotId: 'unknown', + runtimeVersion: '', + workdir: process.cwd(), + error: `Unsupported verification profile: ${String(data.profile)}`, + }); + logger.warn( + { sourceGroup, requestId: data.requestId, profile: data.profile }, + 'Rejected unsupported verification profile', + ); + break; + } + + { + const result = await runVerificationRequest({ + requestId: data.requestId, + profile: data.profile, + expectedSnapshotId: + typeof data.expected_snapshot_id === 'string' + ? data.expected_snapshot_id + : undefined, + }); + + writeVerificationResponse(sourceGroup, { + requestId: data.requestId, + ...result, + }); + + logger.info( + { + sourceGroup, + requestId: data.requestId, + profile: data.profile, + ok: result.ok, + exitCode: result.exitCode, + snapshotId: result.snapshotId, + }, + 'Processed verification request via IPC', + ); + } + break; + case 'update_task': if (data.taskId) { const task = getTaskById(data.taskId); diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 2de721d..6c4490a 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -1090,6 +1090,108 @@ describe('createMessageRuntime', () => { expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, '최종 정리 완료'); }); + it('uses the finalize prompt for merge_ready bot-only reviewer follow-ups', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + const saveState = vi.fn(); + const lastAgentTimestamps: Record = {}; + + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-merge-ready-bot-follow-up', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-03-30T00:00:00.000Z', + round_trip_count: 1, + status: 'merge_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:00.000Z', + }); + vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ + { + id: 1, + task_id: 'task-merge-ready-bot-follow-up', + turn_number: 1, + role: 'reviewer', + output_text: '리뷰 승인 요약', + created_at: '2026-03-30T00:00:03.000Z', + }, + ]); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'reviewer-bot-message', + chat_jid: chatJid, + sender: 'reviewer-bot@test', + sender_name: '리뷰어', + content: 'DONE\n승인합니다.', + timestamp: '2026-03-30T00:00:04.000Z', + seq: 42, + is_bot_message: true, + } as any, + ]); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, input, _onProcess, onOutput) => { + expect(input.prompt).toBe( + "The reviewer approved your work (DONE). Finalize and report the result.\n\nReviewer's final assessment:\n리뷰 승인 요약", + ); + expect(input.prompt).not.toContain('DONE\n승인합니다.'); + await onOutput?.({ + status: 'success', + phase: 'final', + result: '최종 정리 완료', + newSessionId: 'session-finalize-bot-follow-up', + }); + return { + status: 'success', + result: '최종 정리 완료', + newSessionId: 'session-finalize-bot-follow-up', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-merge-ready-bot-follow-up', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); + expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, '최종 정리 완료'); + expect(lastAgentTimestamps[chatJid]).toBe('42'); + expect(saveState).toHaveBeenCalled(); + }); + it('reuses the shared arbiter prompt builder for pending arbiter turns', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index f849544..e4ed7ca 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -906,6 +906,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { return true; } + const botOnlyPendingTask = hasReviewerLease(chatJid) + ? getLatestOpenPairedTaskForChat(chatJid) + : null; + const botOnlyPendingTurn = + botOnlyPendingTask && isBotOnlyPairedRoomTurn(chatJid, missedMessages) + ? buildPendingPairedTurn(botOnlyPendingTask, rawMissedMessages) + : null; + if (botOnlyPendingTurn) { + return executePendingPairedTurn(botOnlyPendingTurn); + } + if (shouldSkipBotOnlyCollaboration(chatJid, missedMessages)) { const lastMessage = missedMessages[missedMessages.length - 1]; if (lastMessage?.seq != null) { @@ -1257,6 +1268,59 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { chatJid, messagesToSend, ); + const pendingCursorSource = + rawPendingMessages.length > 0 + ? rawPendingMessages[rawPendingMessages.length - 1] + : messagesToSend[messagesToSend.length - 1]; + const botOnlyPendingTurn = + loopPendingTask && + isBotOnlyPairedFollowUp && + (loopPendingTask.status === 'merge_ready' || + loopPendingTask.status === 'review_ready' || + loopPendingTask.status === 'in_review' || + loopPendingTask.status === 'arbiter_requested' || + loopPendingTask.status === 'in_arbitration') + ? { + cursor: + pendingCursorSource?.seq ?? + pendingCursorSource?.timestamp ?? + null, + cursorKey: + loopPendingTask.status === 'merge_ready' + ? undefined + : resolveCursorKey(chatJid, loopPendingTask.status), + } + : null; + + if (botOnlyPendingTurn) { + if (botOnlyPendingTurn.cursor != null) { + advanceLastAgentCursor( + deps.getLastAgentTimestamps(), + deps.saveState, + chatJid, + botOnlyPendingTurn.cursor, + botOnlyPendingTurn.cursorKey, + ); + } + deps.queue.closeStdin(chatJid, { + reason: 'paired-pending-turn-follow-up', + }); + deps.queue.enqueueMessageCheck( + chatJid, + resolveGroupIpcPath(group.folder), + ); + logger.info( + { + chatJid, + group: group.name, + groupFolder: group.folder, + taskId: loopPendingTask?.id ?? null, + taskStatus: loopPendingTask?.status ?? null, + }, + 'Queued fresh paired pending turn instead of piping bot-only follow-up into the active agent', + ); + continue; + } if (deps.queue.sendMessage(chatJid, formatted)) { const endSeq = messagesToSend[messagesToSend.length - 1]?.seq; diff --git a/src/verification-ipc.test.ts b/src/verification-ipc.test.ts new file mode 100644 index 0000000..33eade0 --- /dev/null +++ b/src/verification-ipc.test.ts @@ -0,0 +1,127 @@ +import fs from 'fs'; +import path from 'path'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { runVerificationRequestMock } = vi.hoisted(() => ({ + runVerificationRequestMock: vi.fn(), +})); + +vi.mock('./verification.js', async () => { + const actual = + await vi.importActual( + './verification.js', + ); + return { + ...actual, + runVerificationRequest: runVerificationRequestMock, + }; +}); + +import { _initTestDatabase, _setRegisteredGroupForTests } from './db.js'; +import { resolveGroupIpcPath } from './group-folder.js'; +import { processTaskIpc, type IpcDeps } from './ipc.js'; +import type { RegisteredGroup } from './types.js'; + +const VERIFICATION_GROUP: RegisteredGroup = { + name: 'Verification', + folder: 'verification-group', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', +}; + +describe('verification IPC', () => { + let deps: IpcDeps; + + beforeEach(() => { + _initTestDatabase(); + _setRegisteredGroupForTests('verification@g.us', VERIFICATION_GROUP); + runVerificationRequestMock.mockReset(); + + deps = { + sendMessage: async () => {}, + registeredGroups: () => ({ 'verification@g.us': VERIFICATION_GROUP }), + assignRoom: () => {}, + syncGroups: async () => {}, + getAvailableGroups: () => [], + writeGroupsSnapshot: () => {}, + }; + + fs.rmSync(resolveGroupIpcPath('verification-group'), { + recursive: true, + force: true, + }); + }); + + it('writes verification responses into the source group namespace', async () => { + runVerificationRequestMock.mockResolvedValue({ + ok: true, + profile: 'typecheck', + command: 'npm run typecheck', + stdout: '', + stderr: '', + exitCode: 0, + snapshotId: 'fs:abc123', + runtimeVersion: 'ejclaw-reviewer:latest@sha256:test', + }); + + await processTaskIpc( + { + type: 'verification_request', + requestId: 'req-1', + profile: 'typecheck', + expected_snapshot_id: 'fs:abc123', + }, + 'verification-group', + false, + deps, + ); + + const responsePath = path.join( + resolveGroupIpcPath('verification-group'), + 'verification-responses', + 'req-1.json', + ); + const response = JSON.parse(fs.readFileSync(responsePath, 'utf-8')) as { + ok: boolean; + requestId: string; + snapshotId: string; + }; + + expect(runVerificationRequestMock).toHaveBeenCalledWith({ + requestId: 'req-1', + profile: 'typecheck', + expectedSnapshotId: 'fs:abc123', + }); + expect(response.requestId).toBe('req-1'); + expect(response.ok).toBe(true); + expect(response.snapshotId).toBe('fs:abc123'); + }); + + it('returns an error response for unsupported profiles without executing', async () => { + await processTaskIpc( + { + type: 'verification_request', + requestId: 'req-2', + profile: 'rm -rf /', + }, + 'verification-group', + false, + deps, + ); + + const responsePath = path.join( + resolveGroupIpcPath('verification-group'), + 'verification-responses', + 'req-2.json', + ); + const response = JSON.parse(fs.readFileSync(responsePath, 'utf-8')) as { + ok: boolean; + error?: string; + }; + + expect(runVerificationRequestMock).not.toHaveBeenCalled(); + expect(response.ok).toBe(false); + expect(response.error).toContain('Unsupported verification profile'); + }); +}); diff --git a/src/verification.test.ts b/src/verification.test.ts new file mode 100644 index 0000000..35b02f9 --- /dev/null +++ b/src/verification.test.ts @@ -0,0 +1,61 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { describe, expect, it } from 'vitest'; + +import { + buildVerificationCommand, + computeVerificationSnapshot, + isVerificationProfile, +} from './verification.js'; + +describe('verification helpers', () => { + it('recognizes only fixed verification profiles', () => { + expect(isVerificationProfile('test')).toBe(true); + expect(isVerificationProfile('typecheck')).toBe(true); + expect(isVerificationProfile('build')).toBe(true); + expect(isVerificationProfile('lint')).toBe(false); + }); + + it('builds deterministic commands for each profile', () => { + expect(buildVerificationCommand('test')).toMatchObject({ + file: 'npm', + args: ['test'], + requiredScript: 'test', + }); + expect(buildVerificationCommand('typecheck')).toMatchObject({ + file: 'npm', + args: ['run', 'typecheck'], + requiredScript: 'typecheck', + }); + expect(buildVerificationCommand('build')).toMatchObject({ + file: 'npm', + args: ['run', 'build'], + requiredScript: 'build', + }); + }); + + it('computes a stable snapshot over the readable workspace inputs', () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-snapshot-')); + fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true }); + fs.mkdirSync(path.join(repoDir, 'node_modules'), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, 'src', 'index.ts'), + 'export const x = 1;\n', + ); + fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=1\n'); + + const first = computeVerificationSnapshot(repoDir).snapshotId; + fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=2\n'); + const second = computeVerificationSnapshot(repoDir).snapshotId; + fs.writeFileSync( + path.join(repoDir, 'src', 'index.ts'), + 'export const x = 2;\n', + ); + const third = computeVerificationSnapshot(repoDir).snapshotId; + + expect(second).toBe(first); + expect(third).not.toBe(first); + }); +}); diff --git a/src/verification.ts b/src/verification.ts new file mode 100644 index 0000000..5f42ce0 --- /dev/null +++ b/src/verification.ts @@ -0,0 +1,451 @@ +import { execFile, execFileSync } from 'child_process'; +import { createHash } from 'crypto'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { REVIEWER_CONTAINER_IMAGE, TIMEZONE } from './config.js'; +import { + CONTAINER_RUNTIME_BIN, + hostGatewayArgs, + readonlyMountArgs, + tmpfsMountArgs, + writableMountArgs, +} from './container-runtime.js'; +import { resolveGroupIpcPath } from './group-folder.js'; + +export const VERIFICATION_PROFILES = ['test', 'typecheck', 'build'] as const; + +export type VerificationProfile = (typeof VERIFICATION_PROFILES)[number]; + +export interface VerificationRequest { + requestId: string; + profile: VerificationProfile; + expectedSnapshotId?: string; +} + +export interface VerificationResult { + ok: boolean; + profile: VerificationProfile; + command: string; + stdout: string; + stderr: string; + exitCode: number; + snapshotId: string; + runtimeVersion: string; + workdir?: string; + error?: string; +} + +export interface VerificationResponse extends VerificationResult { + requestId: string; +} + +export interface VerificationSnapshot { + snapshotId: string; +} + +interface VerificationCommandSpec { + file: string; + args: string[]; + commandText: string; + requiredScript: string; +} + +const PRIMARY_PROJECT_MOUNT = '/workspace/project'; +const SNAPSHOT_EXCLUDE_NAMES = new Set(['.git', 'node_modules', '.env']); +const MAX_OUTPUT_CHARS = 24_000; +const COMMAND_TIMEOUT_MS = 20 * 60 * 1000; +const COMMAND_MAX_BUFFER = 20 * 1024 * 1024; + +export function isVerificationProfile( + value: unknown, +): value is VerificationProfile { + return ( + typeof value === 'string' && + VERIFICATION_PROFILES.includes(value as VerificationProfile) + ); +} + +export function buildVerificationCommand( + profile: VerificationProfile, +): VerificationCommandSpec { + switch (profile) { + case 'test': + return { + file: 'npm', + args: ['test'], + commandText: 'npm test', + requiredScript: 'test', + }; + case 'typecheck': + return { + file: 'npm', + args: ['run', 'typecheck'], + commandText: 'npm run typecheck', + requiredScript: 'typecheck', + }; + case 'build': + return { + file: 'npm', + args: ['run', 'build'], + commandText: 'npm run build', + requiredScript: 'build', + }; + } +} + +function shouldExcludePath(name: string): boolean { + return SNAPSHOT_EXCLUDE_NAMES.has(name); +} + +function updateSnapshotHash( + hash: ReturnType, + repoDir: string, + currentPath: string, +): void { + const relPath = path.relative(repoDir, currentPath) || '.'; + const stat = fs.lstatSync(currentPath); + + if (stat.isDirectory()) { + if (relPath !== '.') { + hash.update(`dir\0${relPath}\0`); + } + for (const entry of fs.readdirSync(currentPath).sort()) { + if (shouldExcludePath(entry)) continue; + updateSnapshotHash(hash, repoDir, path.join(currentPath, entry)); + } + return; + } + + if (stat.isSymbolicLink()) { + hash.update(`symlink\0${relPath}\0${fs.readlinkSync(currentPath)}\0`); + return; + } + + if (stat.isFile()) { + hash.update(`file\0${relPath}\0`); + hash.update(fs.readFileSync(currentPath)); + hash.update('\0'); + } +} + +export function computeVerificationSnapshot( + repoDir: string, +): VerificationSnapshot { + const hash = createHash('sha256'); + updateSnapshotHash(hash, repoDir, repoDir); + return { + snapshotId: `fs:${hash.digest('hex').slice(0, 24)}`, + }; +} + +function truncateOutput(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 detectPnpmStorePath(workspaceDir: string): string | null { + if (!fs.existsSync(path.join(workspaceDir, 'pnpm-lock.yaml'))) { + return null; + } + if (process.env.PNPM_STORE_DIR && fs.existsSync(process.env.PNPM_STORE_DIR)) { + return process.env.PNPM_STORE_DIR; + } + try { + const storePath = execFileSync('pnpm', ['store', 'path'], { + cwd: workspaceDir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }).trim(); + if (storePath && fs.existsSync(storePath)) return storePath; + } catch { + /* ignore */ + } + + const defaultStore = path.join( + os.homedir(), + '.local', + 'share', + 'pnpm', + 'store', + ); + return fs.existsSync(defaultStore) ? defaultStore : null; +} + +function readPackageScripts(repoDir: string): Record { + const packageJsonPath = path.join(repoDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return {}; + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as { + scripts?: Record; + }; + return packageJson.scripts || {}; +} + +function copyWorkspaceToScratch(repoDir: string, scratchDir: string): void { + fs.cpSync(repoDir, scratchDir, { + recursive: true, + filter: (source) => { + if (source === repoDir) return true; + return !shouldExcludePath(path.basename(source)); + }, + }); +} + +function detectRuntimeVersion(): string { + try { + const imageId = execFileSync( + CONTAINER_RUNTIME_BIN, + ['image', 'inspect', REVIEWER_CONTAINER_IMAGE, '--format', '{{.Id}}'], + { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }, + ).trim(); + return `${REVIEWER_CONTAINER_IMAGE}@${imageId}`; + } catch { + return REVIEWER_CONTAINER_IMAGE; + } +} + +function buildDockerRunArgs( + scratchWorkspace: string, + sourceRepoDir: string, +): string[] { + const args = [ + 'run', + '--rm', + '-i', + '--workdir', + PRIMARY_PROJECT_MOUNT, + '-e', + `TZ=${TIMEZONE}`, + '-e', + 'CI=1', + '-e', + 'VITEST_CACHE_DIR=/tmp/.vitest', + '-e', + 'JEST_CACHE_DIR=/tmp/.jest', + '-e', + 'npm_config_cache=/tmp/.npm', + ]; + + args.push(...hostGatewayArgs()); + + const hostUid = process.getuid?.(); + const hostGid = process.getgid?.(); + if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { + args.push('--user', `${hostUid}:${hostGid}`); + args.push('-e', 'HOME=/home/node'); + } + + args.push(...writableMountArgs(scratchWorkspace, PRIMARY_PROJECT_MOUNT)); + + const sourceNodeModulesDir = path.join(sourceRepoDir, 'node_modules'); + if (fs.existsSync(sourceNodeModulesDir)) { + args.push( + ...readonlyMountArgs( + sourceNodeModulesDir, + path.join(PRIMARY_PROJECT_MOUNT, 'node_modules'), + ), + ); + } + + const pnpmStore = detectPnpmStorePath(sourceRepoDir); + if (pnpmStore) { + args.push(...readonlyMountArgs(pnpmStore, pnpmStore)); + } + + args.push(...tmpfsMountArgs('/tmp')); + + return args; +} + +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 runVerificationRequest( + request: VerificationRequest, + options?: { + repoDir?: string; + }, +): Promise { + const repoDir = options?.repoDir || process.cwd(); + const runtimeVersion = detectRuntimeVersion(); + const command = buildVerificationCommand(request.profile); + const scripts = readPackageScripts(repoDir); + + if (!scripts[command.requiredScript]) { + return { + ok: false, + profile: request.profile, + command: command.commandText, + stdout: '', + stderr: '', + exitCode: 1, + snapshotId: 'unknown', + runtimeVersion, + error: `Verification profile "${request.profile}" is not configured in package.json scripts.`, + }; + } + + if (!fs.existsSync(path.join(repoDir, 'node_modules'))) { + return { + ok: false, + profile: request.profile, + command: command.commandText, + stdout: '', + stderr: '', + exitCode: 1, + snapshotId: 'unknown', + runtimeVersion, + error: + 'Verification shim requires an installed node_modules tree in the workspace.', + }; + } + + const beforeSnapshot = computeVerificationSnapshot(repoDir); + if ( + request.expectedSnapshotId && + beforeSnapshot.snapshotId !== request.expectedSnapshotId + ) { + return { + ok: false, + profile: request.profile, + command: command.commandText, + stdout: '', + stderr: '', + exitCode: 1, + snapshotId: beforeSnapshot.snapshotId, + runtimeVersion, + error: `Snapshot mismatch before verification. expected=${request.expectedSnapshotId} current=${beforeSnapshot.snapshotId}`, + }; + } + + const scratchRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-verification-'), + ); + const scratchWorkspace = path.join(scratchRoot, 'workspace'); + + try { + copyWorkspaceToScratch(repoDir, scratchWorkspace); + + const afterSnapshot = computeVerificationSnapshot(repoDir); + if (afterSnapshot.snapshotId !== beforeSnapshot.snapshotId) { + return { + ok: false, + profile: request.profile, + command: command.commandText, + stdout: '', + stderr: '', + exitCode: 1, + snapshotId: afterSnapshot.snapshotId, + runtimeVersion, + error: `Workspace changed while preparing verification scratch. expected=${beforeSnapshot.snapshotId} current=${afterSnapshot.snapshotId}`, + }; + } + + const dockerArgs = buildDockerRunArgs(scratchWorkspace, repoDir); + dockerArgs.push(REVIEWER_CONTAINER_IMAGE, command.file, ...command.args); + + try { + const { stdout, stderr } = await execFileCapture( + CONTAINER_RUNTIME_BIN, + dockerArgs, + ); + return { + ok: true, + profile: request.profile, + command: command.commandText, + stdout: truncateOutput(stdout), + stderr: truncateOutput(stderr), + exitCode: 0, + snapshotId: beforeSnapshot.snapshotId, + runtimeVersion, + }; + } 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, + profile: request.profile, + command: command.commandText, + stdout: truncateOutput(stdout), + stderr: truncateOutput(stderr), + exitCode: extractExitCode(error), + snapshotId: beforeSnapshot.snapshotId, + runtimeVersion, + error: error instanceof Error ? error.message : String(error), + }; + } + } finally { + fs.rmSync(scratchRoot, { recursive: true, force: true }); + } +} + +export function resolveVerificationResponseDir(groupFolder: string): string { + return path.join(resolveGroupIpcPath(groupFolder), 'verification-responses'); +} + +export function writeVerificationResponse( + groupFolder: string, + response: VerificationResponse, +): string { + const responseDir = resolveVerificationResponseDir(groupFolder); + fs.mkdirSync(responseDir, { recursive: true }); + + const outputPath = path.join(responseDir, `${response.requestId}.json`); + const tempPath = `${outputPath}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(response, null, 2)); + fs.renameSync(tempPath, outputPath); + return outputPath; +}