Add reviewer host evidence and verification shims
This commit is contained in:
80
runners/agent-runner/src/host-evidence.ts
Normal file
80
runners/agent-runner/src/host-evidence.ts
Normal 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');
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
124
runners/agent-runner/src/verification.ts
Normal file
124
runners/agent-runner/src/verification.ts
Normal 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');
|
||||
}
|
||||
69
runners/agent-runner/test/host-evidence.test.ts
Normal file
69
runners/agent-runner/test/host-evidence.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
84
runners/agent-runner/test/verification.test.ts
Normal file
84
runners/agent-runner/test/verification.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
195
src/container-runner.test.ts
Normal file
195
src/container-runner.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
124
src/host-evidence-ipc.test.ts
Normal file
124
src/host-evidence-ipc.test.ts
Normal 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
54
src/host-evidence.test.ts
Normal 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
238
src/host-evidence.ts
Normal 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;
|
||||
}
|
||||
127
src/ipc.ts
127
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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
127
src/verification-ipc.test.ts
Normal file
127
src/verification-ipc.test.ts
Normal 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
61
src/verification.test.ts
Normal 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
451
src/verification.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user