Add reviewer host evidence and verification shims

This commit is contained in:
Eyejoker
2026-04-04 20:51:24 +09:00
parent 3019d9bf31
commit e9c400424d
19 changed files with 2351 additions and 97 deletions

View File

@@ -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<HostEvidenceResponse> {
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');
}

View File

@@ -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.",

View File

@@ -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<typeof createHash>,
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<VerificationResponse> {
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');
}

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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');
});
});

View File

@@ -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, string>,
): 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<string>([
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, string>,
): 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<string, string> | 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).

View File

@@ -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<typeof import('./host-evidence.js')>(
'./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');
});
});

54
src/host-evidence.test.ts Normal file
View File

@@ -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',
]);
});
});

238
src/host-evidence.ts Normal file
View File

@@ -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<HostEvidenceResult> {
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;
}

View File

@@ -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);

View File

@@ -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<string, string> = {};
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');

View File

@@ -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;

View File

@@ -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<typeof import('./verification.js')>(
'./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');
});
});

61
src/verification.test.ts Normal file
View File

@@ -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);
});
});

451
src/verification.ts Normal file
View File

@@ -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<typeof createHash>,
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<string, string> {
const packageJsonPath = path.join(repoDir, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
return {};
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as {
scripts?: Record<string, string>;
};
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<VerificationResult> {
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;
}