Phase 1 — Strip over-engineering (-3,665 lines): - DB tables: 7 → 3 (remove executions, approvals, artifacts, events) - Task statuses: 11 → 5 (active, review_ready, in_review, merge_ready, completed) - Remove plan governance, event sourcing, gate/verdict, freshness guards - paired-execution-context: 980 → 299 lines - Session commands: remove /risk, /plan, /approve-plan, /request-plan-changes Phase 2 — Container isolation for reviewers: - Add container-runtime.ts (Docker abstraction) - Add credential-proxy.ts (API key injection without container exposure) - Add container-runner.ts (reviewer-specific read-only mount + tmpfs) - Add container/Dockerfile + agent-runner (Chromium, Claude Code, Codex) - agent-runner.ts: auto-route reviewer execution to container mode
241 lines
7.9 KiB
TypeScript
241 lines
7.9 KiB
TypeScript
import { execFileSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { RegisteredGroup, RoomRoleContext } from './types.js';
|
|
|
|
function runGit(args: string[], cwd: string): string {
|
|
return execFileSync('git', args, {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim();
|
|
}
|
|
|
|
async function loadModules() {
|
|
const db = await import('./db.js');
|
|
const executionContext = await import('./paired-execution-context.js');
|
|
return { db, executionContext };
|
|
}
|
|
|
|
const ownerContext: RoomRoleContext = {
|
|
serviceId: 'codex-main',
|
|
role: 'owner',
|
|
ownerServiceId: 'codex-main',
|
|
reviewerServiceId: 'codex-review',
|
|
failoverOwner: false,
|
|
};
|
|
|
|
const reviewerContext: RoomRoleContext = {
|
|
serviceId: 'codex-review',
|
|
role: 'reviewer',
|
|
ownerServiceId: 'codex-main',
|
|
reviewerServiceId: 'codex-review',
|
|
failoverOwner: false,
|
|
};
|
|
|
|
describe('paired /review command path', () => {
|
|
let tempRoot: string;
|
|
let previousDataDir: string | undefined;
|
|
let previousGroupsDir: string | undefined;
|
|
let previousStoreDir: string | undefined;
|
|
let previousServiceId: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-review-command-'));
|
|
previousDataDir = process.env.EJCLAW_DATA_DIR;
|
|
previousGroupsDir = process.env.EJCLAW_GROUPS_DIR;
|
|
previousStoreDir = process.env.EJCLAW_STORE_DIR;
|
|
previousServiceId = process.env.SERVICE_ID;
|
|
process.env.EJCLAW_STORE_DIR = path.join(tempRoot, 'shared-store');
|
|
process.env.EJCLAW_GROUPS_DIR = path.join(tempRoot, 'shared-groups');
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (previousDataDir === undefined) delete process.env.EJCLAW_DATA_DIR;
|
|
else process.env.EJCLAW_DATA_DIR = previousDataDir;
|
|
if (previousGroupsDir === undefined) delete process.env.EJCLAW_GROUPS_DIR;
|
|
else process.env.EJCLAW_GROUPS_DIR = previousGroupsDir;
|
|
if (previousStoreDir === undefined) delete process.env.EJCLAW_STORE_DIR;
|
|
else process.env.EJCLAW_STORE_DIR = previousStoreDir;
|
|
if (previousServiceId === undefined) delete process.env.SERVICE_ID;
|
|
else process.env.SERVICE_ID = previousServiceId;
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('records the first owner /review event with the provisioned owner workspace fingerprint', async () => {
|
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
|
runGit(['init'], canonicalDir);
|
|
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
|
|
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
|
|
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'original\n');
|
|
runGit(['add', 'README.md'], canonicalDir);
|
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
|
|
|
const group: RegisteredGroup = {
|
|
name: 'Paired Room',
|
|
folder: 'paired-room',
|
|
trigger: '@codex',
|
|
added_at: '2026-03-28T00:00:00.000Z',
|
|
agentType: 'codex',
|
|
workDir: canonicalDir,
|
|
};
|
|
|
|
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-owner');
|
|
process.env.SERVICE_ID = 'codex-main';
|
|
vi.resetModules();
|
|
const { db, executionContext } = await loadModules();
|
|
db.initDatabase();
|
|
|
|
const result = executionContext.markRoomReviewReady({
|
|
group,
|
|
chatJid: 'dc:test',
|
|
roomRoleContext: ownerContext,
|
|
});
|
|
|
|
expect(result?.status).toBe('ready');
|
|
if (!result || result.status !== 'ready') {
|
|
throw new Error('expected owner /review to prepare a ready snapshot');
|
|
}
|
|
expect(result.reviewerWorkspace.snapshot_ref).toBeTruthy();
|
|
});
|
|
|
|
it('reuses the db owner workspace when reviewer service handles /review', async () => {
|
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
|
runGit(['init'], canonicalDir);
|
|
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
|
|
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
|
|
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'original\n');
|
|
runGit(['add', 'README.md'], canonicalDir);
|
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
|
|
|
const group: RegisteredGroup = {
|
|
name: 'Paired Room',
|
|
folder: 'paired-room',
|
|
trigger: '@codex',
|
|
added_at: '2026-03-28T00:00:00.000Z',
|
|
agentType: 'codex',
|
|
workDir: canonicalDir,
|
|
};
|
|
|
|
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-owner');
|
|
process.env.SERVICE_ID = 'codex-main';
|
|
vi.resetModules();
|
|
let { db, executionContext } = await loadModules();
|
|
db.initDatabase();
|
|
|
|
const ownerResult = executionContext.preparePairedExecutionContext({
|
|
group,
|
|
chatJid: 'dc:test',
|
|
runId: 'run-owner',
|
|
roomRoleContext: ownerContext,
|
|
});
|
|
|
|
expect(ownerResult?.workspace?.workspace_dir).toBeTruthy();
|
|
const ownerWorkspaceDir = ownerResult!.workspace!.workspace_dir;
|
|
fs.writeFileSync(
|
|
path.join(ownerWorkspaceDir, 'README.md'),
|
|
'owner change\n',
|
|
);
|
|
|
|
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-review');
|
|
process.env.SERVICE_ID = 'codex-review';
|
|
vi.resetModules();
|
|
({ db, executionContext } = await loadModules());
|
|
db.initDatabase();
|
|
|
|
const result = executionContext.markRoomReviewReady({
|
|
group,
|
|
chatJid: 'dc:test',
|
|
roomRoleContext: reviewerContext,
|
|
});
|
|
|
|
const reviewerLocalOwnerDir = path.join(
|
|
tempRoot,
|
|
'data-review',
|
|
'workspaces',
|
|
'paired-room',
|
|
'tasks',
|
|
ownerResult!.task.id,
|
|
'owner',
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.status).toBe('ready');
|
|
if (!result || result.status !== 'ready') {
|
|
throw new Error('expected reviewer /review to prepare a ready snapshot');
|
|
}
|
|
expect(result.ownerWorkspace.workspace_dir).toBe(ownerWorkspaceDir);
|
|
expect(result.reviewerWorkspace.snapshot_source_dir).toBe(
|
|
ownerWorkspaceDir,
|
|
);
|
|
expect(
|
|
fs.readFileSync(
|
|
path.join(result.reviewerWorkspace.workspace_dir, 'README.md'),
|
|
'utf-8',
|
|
),
|
|
).toBe('owner change\n');
|
|
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
|
|
});
|
|
|
|
it('keeps review_pending when reviewer service handles /review before owner workspace exists', async () => {
|
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
|
runGit(['init'], canonicalDir);
|
|
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
|
|
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
|
|
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'original\n');
|
|
runGit(['add', 'README.md'], canonicalDir);
|
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
|
|
|
const group: RegisteredGroup = {
|
|
name: 'Paired Room',
|
|
folder: 'paired-room',
|
|
trigger: '@codex',
|
|
added_at: '2026-03-28T00:00:00.000Z',
|
|
agentType: 'codex',
|
|
workDir: canonicalDir,
|
|
};
|
|
|
|
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-review');
|
|
process.env.SERVICE_ID = 'codex-review';
|
|
vi.resetModules();
|
|
const { db, executionContext } = await loadModules();
|
|
db.initDatabase();
|
|
|
|
const result = executionContext.markRoomReviewReady({
|
|
group,
|
|
chatJid: 'dc:test',
|
|
roomRoleContext: reviewerContext,
|
|
});
|
|
|
|
const task = db.getLatestOpenPairedTaskForChat('dc:test');
|
|
const reviewerLocalOwnerDir = path.join(
|
|
tempRoot,
|
|
'data-review',
|
|
'workspaces',
|
|
'paired-room',
|
|
'tasks',
|
|
task!.id,
|
|
'owner',
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
status: 'pending',
|
|
task: expect.objectContaining({
|
|
id: task!.id,
|
|
status: 'review_ready',
|
|
}),
|
|
pendingReason: 'owner-workspace-not-ready',
|
|
});
|
|
expect(task?.status).toBe('review_ready');
|
|
expect(task?.review_requested_at).toBeTruthy();
|
|
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
|
|
});
|
|
});
|