Add reviewer host evidence and verification shims
This commit is contained in:
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