Simplify verification runtime
This commit is contained in:
@@ -1,11 +0,0 @@
|
|||||||
*
|
|
||||||
!container/
|
|
||||||
!container/Dockerfile
|
|
||||||
!shared/
|
|
||||||
!shared/verification-snapshot.d.ts
|
|
||||||
!shared/verification-snapshot.js
|
|
||||||
!runners/
|
|
||||||
!runners/agent-runner/
|
|
||||||
!runners/agent-runner/**
|
|
||||||
!runners/codex-runner/
|
|
||||||
!runners/codex-runner/**
|
|
||||||
@@ -38,7 +38,6 @@ Run commands directly—don't tell the user to run them.
|
|||||||
bun run build # Build main project
|
bun run build # Build main project
|
||||||
bun run build:runners # Install + build both runners
|
bun run build:runners # Install + build both runners
|
||||||
bun run build:runtime # Build host runtime only
|
bun run build:runtime # Build host runtime only
|
||||||
bun run build:container # Optional: rebuild reviewer Docker image
|
|
||||||
bun run dev # Dev mode with hot reload
|
bun run dev # Dev mode with hot reload
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -54,8 +53,8 @@ Deploy:
|
|||||||
bun run deploy
|
bun run deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
`deploy` rebuilds only the host runtime. Rebuild the reviewer Docker image
|
`deploy` rebuilds only the host runtime, and verification also runs directly on
|
||||||
separately with `bun run build:container` only when you need the container path.
|
the host runtime.
|
||||||
|
|
||||||
## Service Stack Architecture
|
## Service Stack Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho
|
|||||||
|
|
||||||
- Linux (Ubuntu 22.04+) or macOS
|
- Linux (Ubuntu 22.04+) or macOS
|
||||||
- [Bun](https://bun.sh/) 1.3+
|
- [Bun](https://bun.sh/) 1.3+
|
||||||
- Docker (currently used for isolated verification runs)
|
|
||||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
|
||||||
- [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`)
|
- [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`)
|
||||||
- Discord bot tokens (3: owner, reviewer, arbiter)
|
- Discord bot tokens (3: owner, reviewer, arbiter)
|
||||||
@@ -153,7 +152,6 @@ cd EJClaw
|
|||||||
bun install
|
bun install
|
||||||
bun run build:runners
|
bun run build:runners
|
||||||
bun run build
|
bun run build
|
||||||
bun run build:container # Build verification container image
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
@@ -213,7 +211,6 @@ bun run deploy
|
|||||||
```bash
|
```bash
|
||||||
bun run build # Build main project
|
bun run build # Build main project
|
||||||
bun run build:runners # Install + build both runners
|
bun run build:runners # Install + build both runners
|
||||||
bun run build:container # Rebuild verification container image
|
|
||||||
bun run dev # Dev mode with hot reload
|
bun run dev # Dev mode with hot reload
|
||||||
bun test # Run tests
|
bun test # Run tests
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
# EJClaw Reviewer Container
|
|
||||||
# Runs Claude/Codex agent in read-only isolated environment for code review.
|
|
||||||
# Build from project root: docker build -f container/Dockerfile -t ejclaw-reviewer:latest .
|
|
||||||
|
|
||||||
FROM node:22-slim
|
|
||||||
|
|
||||||
# System dependencies — verification/review tools included
|
|
||||||
RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \
|
|
||||||
chromium \
|
|
||||||
fonts-liberation \
|
|
||||||
fonts-noto-cjk \
|
|
||||||
fonts-noto-color-emoji \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
# Media
|
|
||||||
ffmpeg \
|
|
||||||
sox \
|
|
||||||
mediainfo \
|
|
||||||
imagemagick \
|
|
||||||
# Build
|
|
||||||
make \
|
|
||||||
gcc \
|
|
||||||
g++ \
|
|
||||||
python3 \
|
|
||||||
python3-pip \
|
|
||||||
# Utils
|
|
||||||
jq \
|
|
||||||
file \
|
|
||||||
sqlite3 \
|
|
||||||
rsync \
|
|
||||||
zip \
|
|
||||||
unzip \
|
|
||||||
strace \
|
|
||||||
ripgrep \
|
|
||||||
fd-find \
|
|
||||||
bat \
|
|
||||||
tree \
|
|
||||||
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
||||||
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
|
||||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
|
||||||
> /etc/apt/sources.list.d/github-cli.list \
|
|
||||||
&& apt-get update && apt-get install -y --no-install-recommends gh \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Chromium paths for Playwright/agent-browser
|
|
||||||
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
|
||||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
|
|
||||||
|
|
||||||
# Install Claude Code and Codex CLI globally (requires Node.js)
|
|
||||||
RUN npm install -g @anthropic-ai/claude-code @openai/codex
|
|
||||||
|
|
||||||
# Install Bun runtime (EJClaw runner uses bun:sqlite and runs via bun)
|
|
||||||
ENV BUN_INSTALL="/usr/local"
|
|
||||||
RUN curl -fsSL https://bun.sh/install | bash
|
|
||||||
|
|
||||||
# Create app directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy shared verification snapshot helper used by agent-runner
|
|
||||||
COPY shared/verification-snapshot.* ./shared/
|
|
||||||
|
|
||||||
# Copy agent-runner (Claude Code)
|
|
||||||
COPY runners/agent-runner/package*.json ./runners/agent-runner/
|
|
||||||
RUN bun install --cwd ./runners/agent-runner
|
|
||||||
COPY runners/agent-runner/ ./runners/agent-runner/
|
|
||||||
RUN bun run --cwd ./runners/agent-runner build
|
|
||||||
|
|
||||||
# Copy codex-runner
|
|
||||||
COPY runners/codex-runner/package*.json ./runners/codex-runner/
|
|
||||||
RUN bun install --cwd ./runners/codex-runner
|
|
||||||
COPY runners/codex-runner/ ./runners/codex-runner/
|
|
||||||
RUN bun run --cwd ./runners/codex-runner build
|
|
||||||
|
|
||||||
# Create workspace directories
|
|
||||||
RUN mkdir -p /workspace/project /workspace/group /workspace/global \
|
|
||||||
/workspace/ipc/messages /workspace/ipc/input
|
|
||||||
|
|
||||||
# Entrypoint (fallback — persistent containers use docker exec with explicit runner path)
|
|
||||||
RUN printf '#!/bin/bash\nset -e\ncat > /tmp/input.json\nbun /app/runners/agent-runner/dist/index.js < /tmp/input.json\n' \
|
|
||||||
> /app/entrypoint.sh && chmod +x /app/entrypoint.sh
|
|
||||||
|
|
||||||
# Set ownership to node user (non-root)
|
|
||||||
RUN chown -R node:node /workspace /home/node
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER node
|
|
||||||
|
|
||||||
# Working directory is the read-only project mount
|
|
||||||
WORKDIR /workspace/project
|
|
||||||
|
|
||||||
# Entry point reads JSON from stdin, outputs JSON to stdout
|
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Build the EJClaw reviewer container image
|
|
||||||
# Runs from project root so Dockerfile can COPY runners/agent-runner/
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
|
|
||||||
echo "Building ejclaw-reviewer container image..."
|
|
||||||
docker build -f "$SCRIPT_DIR/Dockerfile" -t ejclaw-reviewer:latest "$PROJECT_ROOT"
|
|
||||||
echo "Done. Image: ejclaw-reviewer:latest"
|
|
||||||
@@ -80,12 +80,13 @@ User message
|
|||||||
## Verification Isolation
|
## Verification Isolation
|
||||||
|
|
||||||
Reviewer and arbiter now run as host processes with role-scoped read-only
|
Reviewer and arbiter now run as host processes with role-scoped read-only
|
||||||
guards and sandbox settings. Docker remains in use for verification profiles:
|
guards and sandbox settings. Verification profiles run directly on the host with
|
||||||
|
restricted environment variables and snapshot checks:
|
||||||
|
|
||||||
- Scratch workspace copied before execution
|
- Fixed-profile direct execution (`test`, `typecheck`, `build`)
|
||||||
- Verification command run inside the container image
|
- Snapshot comparison before/after execution
|
||||||
- Isolated filesystem view for test/typecheck/build checks
|
- Root-level runtime/build directories excluded from verification snapshot
|
||||||
- Runtime image shared with host tooling and evidence inspection
|
- Role-scoped reviewer safeguards remain independent of verification execution
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
@@ -93,8 +94,7 @@ guards and sandbox settings. Docker remains in use for verification profiles:
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `src/index.ts` | Orchestrator: state, message loop, agent invocation |
|
| `src/index.ts` | Orchestrator: state, message loop, agent invocation |
|
||||||
| `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills |
|
| `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills |
|
||||||
| `src/verification.ts` | Verification execution using the container image |
|
| `src/verification.ts` | Fixed-profile verification execution with snapshot checks |
|
||||||
| `src/container-runtime.ts` | Shared Docker runtime helpers for verification |
|
|
||||||
| `src/channels/discord.ts` | Discord channel (8s typing refresh, Whisper transcription) |
|
| `src/channels/discord.ts` | Discord channel (8s typing refresh, Whisper transcription) |
|
||||||
| `src/ipc.ts` | IPC watcher and task processing |
|
| `src/ipc.ts` | IPC watcher and task processing |
|
||||||
| `src/router.ts` | Message formatting and outbound routing |
|
| `src/router.ts` | Message formatting and outbound routing |
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"build:runners": "bun install --cwd runners/agent-runner && bun run --cwd runners/agent-runner build && bun install --cwd runners/codex-runner && bun run --cwd runners/codex-runner build",
|
"build:runners": "bun install --cwd runners/agent-runner && bun run --cwd runners/agent-runner build && bun install --cwd runners/codex-runner && bun run --cwd runners/codex-runner build",
|
||||||
"build:runtime": "bun run build && bun run build:runners",
|
"build:runtime": "bun run build && bun run build:runners",
|
||||||
"build:container": "docker build -f container/Dockerfile -t ejclaw-reviewer:latest .",
|
|
||||||
"build:all": "bun run build:runtime && bun run build:container",
|
|
||||||
"deploy": "git pull --ff-only && bun run build:runtime && systemctl --user restart ejclaw",
|
"deploy": "git pull --ff-only && bun run build:runtime && systemctl --user restart ejclaw",
|
||||||
"start": "bun dist/index.js",
|
"start": "bun dist/index.js",
|
||||||
"dev": "bun --watch src/index.ts",
|
"dev": "bun --watch src/index.ts",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import path from 'path';
|
|||||||
export const HOST_EVIDENCE_ACTIONS = [
|
export const HOST_EVIDENCE_ACTIONS = [
|
||||||
'ejclaw_service_status',
|
'ejclaw_service_status',
|
||||||
'ejclaw_service_logs',
|
'ejclaw_service_logs',
|
||||||
'reviewer_image_inspect',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
|
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
|
||||||
|
|||||||
@@ -357,12 +357,12 @@ server.tool(
|
|||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'read_host_evidence',
|
'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.',
|
'Read host-side deployment evidence through a narrow allowlist. Use this instead of broad shell access when reviewer/arbiter needs service status or recent logs.',
|
||||||
{
|
{
|
||||||
action: z
|
action: z
|
||||||
.enum(HOST_EVIDENCE_ACTIONS)
|
.enum(HOST_EVIDENCE_ACTIONS)
|
||||||
.describe(
|
.describe(
|
||||||
'ejclaw_service_status=systemctl --user show ejclaw, ejclaw_service_logs=recent journalctl lines, reviewer_image_inspect=docker image inspect for the reviewer image',
|
'ejclaw_service_status=systemctl --user show ejclaw, ejclaw_service_logs=recent journalctl lines',
|
||||||
),
|
),
|
||||||
tail_lines: z
|
tail_lines: z
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe('runner verification helpers', () => {
|
|||||||
stderr: '',
|
stderr: '',
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
snapshotId: 'fs:abc123',
|
snapshotId: 'fs:abc123',
|
||||||
runtimeVersion: 'ejclaw-reviewer:latest@sha256:test',
|
runtimeVersion: 'host:bun@test',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,13 +69,13 @@ describe('runner verification helpers', () => {
|
|||||||
stderr: 'build failed\n',
|
stderr: 'build failed\n',
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
snapshotId: 'fs:def456',
|
snapshotId: 'fs:def456',
|
||||||
runtimeVersion: 'ejclaw-reviewer:latest@sha256:test',
|
runtimeVersion: 'host:bun@test',
|
||||||
error: 'command failed',
|
error: 'command failed',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(text).toContain('Verification profile: build');
|
expect(text).toContain('Verification profile: build');
|
||||||
expect(text).toContain('Snapshot: fs:def456');
|
expect(text).toContain('Snapshot: fs:def456');
|
||||||
expect(text).toContain('Runtime: ejclaw-reviewer:latest@sha256:test');
|
expect(text).toContain('Runtime: host:bun@test');
|
||||||
expect(text).toContain('Exit code: 1');
|
expect(text).toContain('Exit code: 1');
|
||||||
expect(text).toContain('$ npm run build');
|
expect(text).toContain('$ npm run build');
|
||||||
expect(text).toContain('[stderr]');
|
expect(text).toContain('[stderr]');
|
||||||
|
|||||||
@@ -99,8 +99,7 @@ describe('credentials detection', () => {
|
|||||||
describe('platform command detection', () => {
|
describe('platform command detection', () => {
|
||||||
it('commandExists returns boolean', async () => {
|
it('commandExists returns boolean', async () => {
|
||||||
const { commandExists } = await import('./platform.js');
|
const { commandExists } = await import('./platform.js');
|
||||||
expect(typeof commandExists('docker')).toBe('boolean');
|
expect(typeof commandExists('git')).toBe('boolean');
|
||||||
expect(typeof commandExists('nonexistent_binary_xyz')).toBe('boolean');
|
expect(typeof commandExists('nonexistent_binary_xyz')).toBe('boolean');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const VERIFICATION_SNAPSHOT_EXCLUDE_NAMES = new Set([
|
|||||||
const VERIFICATION_SNAPSHOT_ROOT_EXCLUDE_NAMES = new Set([
|
const VERIFICATION_SNAPSHOT_ROOT_EXCLUDE_NAMES = new Set([
|
||||||
'.git',
|
'.git',
|
||||||
'.env',
|
'.env',
|
||||||
|
'dist',
|
||||||
'data',
|
'data',
|
||||||
'logs',
|
'logs',
|
||||||
'cache',
|
'cache',
|
||||||
|
|||||||
@@ -207,9 +207,6 @@ const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '1000';
|
|||||||
export const PAIRED_MAX_ROUND_TRIPS =
|
export const PAIRED_MAX_ROUND_TRIPS =
|
||||||
rawMaxRoundTrips === '0' ? Infinity : parseInt(rawMaxRoundTrips, 10) || 1000;
|
rawMaxRoundTrips === '0' ? Infinity : parseInt(rawMaxRoundTrips, 10) || 1000;
|
||||||
|
|
||||||
// ── Container isolation ───────────────────────────────────────────
|
|
||||||
export const REVIEWER_CONTAINER_IMAGE =
|
|
||||||
getEnv('REVIEWER_CONTAINER_IMAGE') || 'ejclaw-reviewer:latest';
|
|
||||||
export const RECOVERY_STAGGER_MS = parseInt(
|
export const RECOVERY_STAGGER_MS = parseInt(
|
||||||
getEnv('RECOVERY_STAGGER_MS') || '2000',
|
getEnv('RECOVERY_STAGGER_MS') || '2000',
|
||||||
10,
|
10,
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* Container runtime abstraction for EJClaw verification.
|
|
||||||
* Verification profiles and related host evidence still use Docker.
|
|
||||||
* Runtime-specific logic lives here so swapping runtimes means changing one file.
|
|
||||||
*/
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
import { logger } from './logger.js';
|
|
||||||
|
|
||||||
export const CONTAINER_RUNTIME_BIN = 'docker';
|
|
||||||
export const CONTAINER_HOST_GATEWAY = 'host.docker.internal';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Address the credential proxy binds to.
|
|
||||||
* Docker Desktop (macOS): 127.0.0.1
|
|
||||||
* Docker (Linux): docker0 bridge IP (containers can reach it via host.docker.internal)
|
|
||||||
*/
|
|
||||||
export const PROXY_BIND_HOST =
|
|
||||||
process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost();
|
|
||||||
|
|
||||||
function detectProxyBindHost(): string {
|
|
||||||
if (os.platform() === 'darwin') return '127.0.0.1';
|
|
||||||
if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1';
|
|
||||||
|
|
||||||
const ifaces = os.networkInterfaces();
|
|
||||||
const docker0 = ifaces['docker0'];
|
|
||||||
if (docker0) {
|
|
||||||
const ipv4 = docker0.find((a) => a.family === 'IPv4');
|
|
||||||
if (ipv4) return ipv4.address;
|
|
||||||
}
|
|
||||||
return '0.0.0.0';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hostGatewayArgs(): string[] {
|
|
||||||
if (os.platform() === 'linux') {
|
|
||||||
return ['--add-host=host.docker.internal:host-gateway'];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readonlyMountArgs(
|
|
||||||
hostPath: string,
|
|
||||||
containerPath: string,
|
|
||||||
): string[] {
|
|
||||||
return ['-v', `${hostPath}:${containerPath}:ro`];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writableMountArgs(
|
|
||||||
hostPath: string,
|
|
||||||
containerPath: string,
|
|
||||||
): string[] {
|
|
||||||
return ['-v', `${hostPath}:${containerPath}`];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tmpfsMountArgs(
|
|
||||||
containerPath: string,
|
|
||||||
options?: string | string[],
|
|
||||||
): string[] {
|
|
||||||
if (!options || (Array.isArray(options) && options.length === 0)) {
|
|
||||||
return ['--tmpfs', containerPath];
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionText = Array.isArray(options) ? options.join(',') : options;
|
|
||||||
return ['--tmpfs', `${containerPath}:${optionText}`];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopContainer(name: string): string {
|
|
||||||
return `${CONTAINER_RUNTIME_BIN} stop ${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureContainerRuntimeRunning(): void {
|
|
||||||
try {
|
|
||||||
execSync(`${CONTAINER_RUNTIME_BIN} info`, {
|
|
||||||
stdio: 'pipe',
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
logger.debug('Container runtime already running');
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, 'Failed to reach container runtime');
|
|
||||||
throw new Error(
|
|
||||||
'Container runtime (Docker) is required for reviewer isolation but failed to start. Run: docker info',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cleanupOrphans(): void {
|
|
||||||
try {
|
|
||||||
const output = execSync(
|
|
||||||
`${CONTAINER_RUNTIME_BIN} ps --filter name=ejclaw-reviewer- --format '{{.Names}}'`,
|
|
||||||
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
|
|
||||||
);
|
|
||||||
const orphans = output.trim().split('\n').filter(Boolean);
|
|
||||||
for (const name of orphans) {
|
|
||||||
try {
|
|
||||||
execSync(stopContainer(name), { stdio: 'pipe' });
|
|
||||||
} catch {
|
|
||||||
/* already stopped */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (orphans.length > 0) {
|
|
||||||
logger.info(
|
|
||||||
{ count: orphans.length, names: orphans },
|
|
||||||
'Stopped orphaned reviewer containers',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn({ err }, 'Failed to clean up orphaned containers');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ describe('host evidence helpers', () => {
|
|||||||
it('recognizes only allowlisted actions', () => {
|
it('recognizes only allowlisted actions', () => {
|
||||||
expect(isHostEvidenceAction('ejclaw_service_status')).toBe(true);
|
expect(isHostEvidenceAction('ejclaw_service_status')).toBe(true);
|
||||||
expect(isHostEvidenceAction('ejclaw_service_logs')).toBe(true);
|
expect(isHostEvidenceAction('ejclaw_service_logs')).toBe(true);
|
||||||
expect(isHostEvidenceAction('reviewer_image_inspect')).toBe(true);
|
|
||||||
expect(isHostEvidenceAction('rm -rf /')).toBe(false);
|
expect(isHostEvidenceAction('rm -rf /')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,16 +38,5 @@ describe('host evidence helpers', () => {
|
|||||||
expect(logs.args).toEqual(
|
expect(logs.args).toEqual(
|
||||||
expect.arrayContaining(['--user', '-u', 'ejclaw', '-n', '42']),
|
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',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import { execFile } from 'child_process';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { REVIEWER_CONTAINER_IMAGE } from './config.js';
|
|
||||||
import { resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupIpcPath } from './group-folder.js';
|
||||||
|
|
||||||
export const HOST_EVIDENCE_ACTIONS = [
|
export const HOST_EVIDENCE_ACTIONS = [
|
||||||
'ejclaw_service_status',
|
'ejclaw_service_status',
|
||||||
'ejclaw_service_logs',
|
'ejclaw_service_logs',
|
||||||
'reviewer_image_inspect',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
|
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
|
||||||
@@ -110,22 +108,6 @@ export function buildHostEvidenceCommand(
|
|||||||
commandText: `journalctl ${args.join(' ')}`,
|
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(' ')}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -778,7 +778,7 @@ export function markPairedTaskReviewReady(taskId: string): {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reviewer runs in a container with the owner workspace mounted read-only.
|
// Reviewer uses the owner workspace directly in read-only mode.
|
||||||
// No file-level snapshot copy needed — just register the path.
|
// No file-level snapshot copy needed — just register the path.
|
||||||
const reviewerWorkspace = makeWorkspaceRecord({
|
const reviewerWorkspace = makeWorkspaceRecord({
|
||||||
taskId,
|
taskId,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe('verification IPC', () => {
|
|||||||
stderr: '',
|
stderr: '',
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
snapshotId: 'fs:abc123',
|
snapshotId: 'fs:abc123',
|
||||||
runtimeVersion: 'ejclaw-reviewer:latest@sha256:test',
|
runtimeVersion: 'host:bun@test',
|
||||||
});
|
});
|
||||||
|
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import path from 'path';
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildDockerRunArgs,
|
|
||||||
buildVerificationCommand,
|
buildVerificationCommand,
|
||||||
computeVerificationSnapshot,
|
computeVerificationSnapshot,
|
||||||
isVerificationProfile,
|
isVerificationProfile,
|
||||||
@@ -72,10 +71,13 @@ describe('verification helpers', () => {
|
|||||||
path.join(repoDir, 'src', 'index.ts'),
|
path.join(repoDir, 'src', 'index.ts'),
|
||||||
'export const x = 1;\n',
|
'export const x = 1;\n',
|
||||||
);
|
);
|
||||||
|
fs.mkdirSync(path.join(repoDir, 'dist'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=1\n');
|
fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=1\n');
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'dist', 'index.js'), 'export const x = 1;\n');
|
||||||
|
|
||||||
const first = computeVerificationSnapshot(repoDir).snapshotId;
|
const first = computeVerificationSnapshot(repoDir).snapshotId;
|
||||||
fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=2\n');
|
fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=2\n');
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'dist', 'index.js'), 'export const x = 2;\n');
|
||||||
const second = computeVerificationSnapshot(repoDir).snapshotId;
|
const second = computeVerificationSnapshot(repoDir).snapshotId;
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(repoDir, 'src', 'index.ts'),
|
path.join(repoDir, 'src', 'index.ts'),
|
||||||
@@ -195,9 +197,53 @@ describe('verification helpers', () => {
|
|||||||
expect(result.error).toContain('Snapshot mismatch before verification');
|
expect(result.error).toContain('Snapshot mismatch before verification');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs verification commands via docker entrypoint override', () => {
|
it('runs typecheck directly on the host for non-build profiles', async () => {
|
||||||
const repoDir = fs.mkdtempSync(
|
const repoDir = fs.mkdtempSync(
|
||||||
path.join(os.tmpdir(), 'ejclaw-verification-docker-'),
|
path.join(os.tmpdir(), 'ejclaw-verification-direct-'),
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, 'package.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'verification-direct',
|
||||||
|
packageManager: 'bun@1.3.11',
|
||||||
|
scripts: {
|
||||||
|
typecheck:
|
||||||
|
'node -e "process.stdout.write(\'typecheck:\' + (process.env.CI || \'missing\'))"',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'bun.lock'), '');
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'node_modules', '.bin', 'placeholder'), '');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, 'src', 'index.ts'),
|
||||||
|
'export const value = 1;\n',
|
||||||
|
);
|
||||||
|
ensureWorkspaceDependenciesInstalled(repoDir);
|
||||||
|
|
||||||
|
const expectedSnapshotId = computeVerificationSnapshot(repoDir).snapshotId;
|
||||||
|
const result = await runVerificationRequest(
|
||||||
|
{
|
||||||
|
requestId: 'req-direct-typecheck',
|
||||||
|
profile: 'typecheck',
|
||||||
|
expectedSnapshotId,
|
||||||
|
},
|
||||||
|
{ repoDir },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout).toContain('typecheck:1');
|
||||||
|
expect(result.runtimeVersion).toMatch(/^host:bun@/);
|
||||||
|
expect(result.snapshotId).toBe(expectedSnapshotId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak host secrets into direct verification commands', async () => {
|
||||||
|
const repoDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ejclaw-verification-direct-secret-'),
|
||||||
);
|
);
|
||||||
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
@@ -205,27 +251,129 @@ describe('verification helpers', () => {
|
|||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(repoDir, 'package.json'),
|
path.join(repoDir, 'package.json'),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
name: 'verification-docker',
|
name: 'verification-direct-secret',
|
||||||
packageManager: 'bun@1.3.11',
|
packageManager: 'bun@1.3.11',
|
||||||
scripts: {
|
scripts: {
|
||||||
typecheck: 'tsc --noEmit',
|
typecheck:
|
||||||
|
'node -e "process.stdout.write(process.env.EJCLAW_VERIFICATION_SECRET || \'missing\')"',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(path.join(repoDir, 'bun.lock'), '');
|
fs.writeFileSync(path.join(repoDir, 'bun.lock'), '');
|
||||||
fs.writeFileSync(path.join(repoDir, 'node_modules', '.bin', 'tsc'), '');
|
fs.writeFileSync(path.join(repoDir, 'node_modules', '.bin', 'placeholder'), '');
|
||||||
ensureWorkspaceDependenciesInstalled(repoDir);
|
ensureWorkspaceDependenciesInstalled(repoDir);
|
||||||
|
|
||||||
const command = buildVerificationCommand('typecheck', repoDir);
|
const previousSecret = process.env.EJCLAW_VERIFICATION_SECRET;
|
||||||
const args = buildDockerRunArgs('/tmp/verify-workspace', repoDir, command);
|
process.env.EJCLAW_VERIFICATION_SECRET = 'top-secret';
|
||||||
const imageIndex = args.findIndex((value) => value === 'ejclaw-reviewer:latest');
|
|
||||||
|
|
||||||
expect(args).toContain('--entrypoint');
|
try {
|
||||||
expect(args[args.indexOf('--entrypoint') + 1]).toBe(command.file);
|
const expectedSnapshotId = computeVerificationSnapshot(repoDir).snapshotId;
|
||||||
expect(imageIndex).toBeGreaterThan(args.indexOf('--entrypoint'));
|
const result = await runVerificationRequest(
|
||||||
expect(args.slice(imageIndex + 1)).toEqual(command.args);
|
{
|
||||||
expect(args).toContain(
|
requestId: 'req-direct-typecheck-secret',
|
||||||
'/workspace/project/node_modules/.vite-temp:uid=1000,gid=1000,mode=1777',
|
profile: 'typecheck',
|
||||||
|
expectedSnapshotId,
|
||||||
|
},
|
||||||
|
{ repoDir },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.stdout).toContain('missing');
|
||||||
|
expect(result.stdout).not.toContain('top-secret');
|
||||||
|
} finally {
|
||||||
|
if (previousSecret == null) {
|
||||||
|
delete process.env.EJCLAW_VERIFICATION_SECRET;
|
||||||
|
} else {
|
||||||
|
process.env.EJCLAW_VERIFICATION_SECRET = previousSecret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails direct verification if the command mutates the workspace', async () => {
|
||||||
|
const repoDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ejclaw-verification-direct-mutate-'),
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, 'package.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'verification-direct-mutate',
|
||||||
|
packageManager: 'bun@1.3.11',
|
||||||
|
scripts: {
|
||||||
|
test: 'node -e "require(\'node:fs\').writeFileSync(\'src/generated.txt\', \'x\\\\n\')"',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'bun.lock'), '');
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'node_modules', '.bin', 'placeholder'), '');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, 'src', 'index.ts'),
|
||||||
|
'export const value = 1;\n',
|
||||||
|
);
|
||||||
|
ensureWorkspaceDependenciesInstalled(repoDir);
|
||||||
|
|
||||||
|
const expectedSnapshotId = computeVerificationSnapshot(repoDir).snapshotId;
|
||||||
|
const result = await runVerificationRequest(
|
||||||
|
{
|
||||||
|
requestId: 'req-direct-test-mutate',
|
||||||
|
profile: 'test',
|
||||||
|
expectedSnapshotId,
|
||||||
|
},
|
||||||
|
{ repoDir },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.error).toContain('Workspace changed during verification');
|
||||||
|
expect(result.snapshotId).not.toBe(expectedSnapshotId);
|
||||||
|
expect(fs.existsSync(path.join(repoDir, 'src', 'generated.txt'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows build verification to write excluded output directories', async () => {
|
||||||
|
const repoDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ejclaw-verification-build-direct-'),
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, 'package.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'verification-build-direct',
|
||||||
|
packageManager: 'bun@1.3.11',
|
||||||
|
scripts: {
|
||||||
|
build: 'node -e "require(\'node:fs\').mkdirSync(\'dist\', { recursive: true }); require(\'node:fs\').writeFileSync(\'dist/output.js\', \'ok\\\\n\')"',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'bun.lock'), '');
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'node_modules', '.bin', 'placeholder'), '');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, 'src', 'index.ts'),
|
||||||
|
'export const value = 1;\n',
|
||||||
|
);
|
||||||
|
ensureWorkspaceDependenciesInstalled(repoDir);
|
||||||
|
|
||||||
|
const expectedSnapshotId = computeVerificationSnapshot(repoDir).snapshotId;
|
||||||
|
const result = await runVerificationRequest(
|
||||||
|
{
|
||||||
|
requestId: 'req-direct-build',
|
||||||
|
profile: 'build',
|
||||||
|
expectedSnapshotId,
|
||||||
|
},
|
||||||
|
{ repoDir },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.runtimeVersion).toMatch(/^host:bun@/);
|
||||||
|
expect(result.snapshotId).toBe(expectedSnapshotId);
|
||||||
|
expect(fs.readFileSync(path.join(repoDir, 'dist', 'output.js'), 'utf-8')).toBe(
|
||||||
|
'ok\n',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,24 +3,15 @@ import fs from 'fs';
|
|||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { REVIEWER_CONTAINER_IMAGE, TIMEZONE } from './config.js';
|
import { TIMEZONE } from './config.js';
|
||||||
import {
|
|
||||||
CONTAINER_RUNTIME_BIN,
|
|
||||||
hostGatewayArgs,
|
|
||||||
readonlyMountArgs,
|
|
||||||
tmpfsMountArgs,
|
|
||||||
writableMountArgs,
|
|
||||||
} from './container-runtime.js';
|
|
||||||
import { resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupIpcPath } from './group-folder.js';
|
||||||
import {
|
import {
|
||||||
buildWorkspaceScriptCommand,
|
buildWorkspaceScriptCommand,
|
||||||
detectPnpmStorePath,
|
|
||||||
ensureWorkspaceDependenciesInstalled,
|
ensureWorkspaceDependenciesInstalled,
|
||||||
hasInstalledNodeModules,
|
hasInstalledNodeModules,
|
||||||
} from './workspace-package-manager.js';
|
} from './workspace-package-manager.js';
|
||||||
import {
|
import {
|
||||||
computeVerificationSnapshotId,
|
computeVerificationSnapshotId,
|
||||||
isVerificationSnapshotExcludedPath,
|
|
||||||
resolveVerificationResponsesDir,
|
resolveVerificationResponsesDir,
|
||||||
} from '../shared/verification-snapshot.js';
|
} from '../shared/verification-snapshot.js';
|
||||||
|
|
||||||
@@ -61,11 +52,39 @@ interface VerificationCommandSpec {
|
|||||||
commandText: string;
|
commandText: string;
|
||||||
requiredScript: string;
|
requiredScript: string;
|
||||||
}
|
}
|
||||||
|
type DirectExecutionEnvKey =
|
||||||
|
| 'PATH'
|
||||||
|
| 'HOME'
|
||||||
|
| 'USER'
|
||||||
|
| 'LOGNAME'
|
||||||
|
| 'SHELL'
|
||||||
|
| 'LANG'
|
||||||
|
| 'LC_ALL'
|
||||||
|
| 'LC_CTYPE'
|
||||||
|
| 'NODE_PATH'
|
||||||
|
| 'NODE_OPTIONS'
|
||||||
|
| 'TERM'
|
||||||
|
| 'COLORTERM'
|
||||||
|
| 'FORCE_COLOR';
|
||||||
|
|
||||||
const PRIMARY_PROJECT_MOUNT = '/workspace/project';
|
|
||||||
const MAX_OUTPUT_CHARS = 24_000;
|
const MAX_OUTPUT_CHARS = 24_000;
|
||||||
const COMMAND_TIMEOUT_MS = 20 * 60 * 1000;
|
const COMMAND_TIMEOUT_MS = 20 * 60 * 1000;
|
||||||
const COMMAND_MAX_BUFFER = 20 * 1024 * 1024;
|
const COMMAND_MAX_BUFFER = 20 * 1024 * 1024;
|
||||||
|
const DIRECT_EXECUTION_ENV_KEYS: readonly DirectExecutionEnvKey[] = [
|
||||||
|
'PATH',
|
||||||
|
'HOME',
|
||||||
|
'USER',
|
||||||
|
'LOGNAME',
|
||||||
|
'SHELL',
|
||||||
|
'LANG',
|
||||||
|
'LC_ALL',
|
||||||
|
'LC_CTYPE',
|
||||||
|
'NODE_PATH',
|
||||||
|
'NODE_OPTIONS',
|
||||||
|
'TERM',
|
||||||
|
'COLORTERM',
|
||||||
|
'FORCE_COLOR',
|
||||||
|
];
|
||||||
|
|
||||||
export function isVerificationProfile(
|
export function isVerificationProfile(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
@@ -95,10 +114,6 @@ export function buildVerificationCommand(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldExcludePath(repoDir: string, source: string): boolean {
|
|
||||||
return isVerificationSnapshotExcludedPath(repoDir, source);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeVerificationSnapshot(
|
export function computeVerificationSnapshot(
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
): VerificationSnapshot {
|
): VerificationSnapshot {
|
||||||
@@ -125,102 +140,73 @@ function readPackageScripts(repoDir: string): Record<string, string> {
|
|||||||
return packageJson.scripts || {};
|
return packageJson.scripts || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyWorkspaceToScratch(repoDir: string, scratchDir: string): void {
|
function detectDirectRuntimeVersion(
|
||||||
fs.cpSync(repoDir, scratchDir, {
|
command: Pick<VerificationCommandSpec, 'file'>,
|
||||||
recursive: true,
|
): string {
|
||||||
filter: (source) => {
|
|
||||||
if (source === repoDir) return true;
|
|
||||||
return !shouldExcludePath(repoDir, source);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectRuntimeVersion(): string {
|
|
||||||
try {
|
try {
|
||||||
const imageId = execFileSync(
|
switch (command.file) {
|
||||||
CONTAINER_RUNTIME_BIN,
|
case 'bun': {
|
||||||
['image', 'inspect', REVIEWER_CONTAINER_IMAGE, '--format', '{{.Id}}'],
|
const bunVersion = execFileSync('bun', ['--version'], {
|
||||||
{
|
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
},
|
}).trim();
|
||||||
).trim();
|
return `host:bun@${bunVersion}`;
|
||||||
return `${REVIEWER_CONTAINER_IMAGE}@${imageId}`;
|
}
|
||||||
|
case 'node':
|
||||||
|
return `host:node@${process.version}`;
|
||||||
|
default:
|
||||||
|
return `host:${command.file}`;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return REVIEWER_CONTAINER_IMAGE;
|
return `host:${command.file}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDockerRunArgs(
|
function buildDirectExecutionEnvironment(): {
|
||||||
scratchWorkspace: string,
|
env: NodeJS.ProcessEnv;
|
||||||
sourceRepoDir: string,
|
tempRoot: string;
|
||||||
command: Pick<VerificationCommandSpec, 'file' | 'args'>,
|
} {
|
||||||
): string[] {
|
const tempRoot = fs.mkdtempSync(
|
||||||
const args = [
|
path.join(os.tmpdir(), 'ejclaw-verification-runtime-'),
|
||||||
'run',
|
|
||||||
'--rm',
|
|
||||||
'-i',
|
|
||||||
'--workdir',
|
|
||||||
PRIMARY_PROJECT_MOUNT,
|
|
||||||
'--entrypoint',
|
|
||||||
command.file,
|
|
||||||
'-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 (
|
|
||||||
hasInstalledNodeModules(sourceRepoDir) &&
|
|
||||||
fs.existsSync(sourceNodeModulesDir)
|
|
||||||
) {
|
|
||||||
args.push(
|
|
||||||
...readonlyMountArgs(
|
|
||||||
sourceNodeModulesDir,
|
|
||||||
path.join(PRIMARY_PROJECT_MOUNT, 'node_modules'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
args.push(
|
|
||||||
...tmpfsMountArgs(
|
|
||||||
path.join(PRIMARY_PROJECT_MOUNT, 'node_modules', '.vite-temp'),
|
|
||||||
['uid=1000', 'gid=1000', 'mode=1777'],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
TZ: TIMEZONE,
|
||||||
|
CI: '1',
|
||||||
|
TMPDIR: tempRoot,
|
||||||
|
TMP: tempRoot,
|
||||||
|
TEMP: tempRoot,
|
||||||
|
VITEST_CACHE_DIR: path.join(tempRoot, '.vitest'),
|
||||||
|
JEST_CACHE_DIR: path.join(tempRoot, '.jest'),
|
||||||
|
npm_config_cache: path.join(tempRoot, '.npm'),
|
||||||
|
npm_config_userconfig: path.join(tempRoot, '.npmrc'),
|
||||||
|
BUN_INSTALL_CACHE_DIR: path.join(tempRoot, '.bun'),
|
||||||
|
XDG_CACHE_HOME: path.join(tempRoot, '.cache'),
|
||||||
|
COREPACK_HOME: path.join(tempRoot, '.corepack'),
|
||||||
|
PNPM_HOME: path.join(tempRoot, '.pnpm-home'),
|
||||||
|
YARN_CACHE_FOLDER: path.join(tempRoot, '.yarn-cache'),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of DIRECT_EXECUTION_ENV_KEYS) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (value) {
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pnpmStore = detectPnpmStorePath(sourceRepoDir);
|
return {
|
||||||
if (pnpmStore) {
|
tempRoot,
|
||||||
args.push(...readonlyMountArgs(pnpmStore, pnpmStore));
|
env,
|
||||||
}
|
};
|
||||||
|
|
||||||
args.push(...tmpfsMountArgs('/tmp'));
|
|
||||||
args.push(REVIEWER_CONTAINER_IMAGE, ...command.args);
|
|
||||||
|
|
||||||
return args;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function execFileCapture(
|
function execFileCapture(
|
||||||
file: string,
|
file: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
|
options?: {
|
||||||
|
cwd?: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
},
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
execFile(
|
execFile(
|
||||||
@@ -230,6 +216,8 @@ function execFileCapture(
|
|||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: COMMAND_TIMEOUT_MS,
|
timeout: COMMAND_TIMEOUT_MS,
|
||||||
maxBuffer: COMMAND_MAX_BUFFER,
|
maxBuffer: COMMAND_MAX_BUFFER,
|
||||||
|
cwd: options?.cwd,
|
||||||
|
env: options?.env,
|
||||||
},
|
},
|
||||||
(error, stdout, stderr) => {
|
(error, stdout, stderr) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -264,8 +252,8 @@ export async function runVerificationRequest(
|
|||||||
},
|
},
|
||||||
): Promise<VerificationResult> {
|
): Promise<VerificationResult> {
|
||||||
const repoDir = options?.repoDir || process.cwd();
|
const repoDir = options?.repoDir || process.cwd();
|
||||||
const runtimeVersion = detectRuntimeVersion();
|
|
||||||
const command = buildVerificationCommand(request.profile, repoDir);
|
const command = buildVerificationCommand(request.profile, repoDir);
|
||||||
|
const runtimeVersion = detectDirectRuntimeVersion(command);
|
||||||
const scripts = readPackageScripts(repoDir);
|
const scripts = readPackageScripts(repoDir);
|
||||||
|
|
||||||
if (!scripts[command.requiredScript]) {
|
if (!scripts[command.requiredScript]) {
|
||||||
@@ -333,36 +321,29 @@ export async function runVerificationRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const scratchRoot = fs.mkdtempSync(
|
const directExecution = buildDirectExecutionEnvironment();
|
||||||
path.join(os.tmpdir(), 'ejclaw-verification-'),
|
|
||||||
);
|
|
||||||
const scratchWorkspace = path.join(scratchRoot, 'workspace');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
copyWorkspaceToScratch(repoDir, scratchWorkspace);
|
try {
|
||||||
|
const { stdout, stderr } = await execFileCapture(command.file, command.args, {
|
||||||
|
cwd: repoDir,
|
||||||
|
env: directExecution.env,
|
||||||
|
});
|
||||||
const afterSnapshot = computeVerificationSnapshot(repoDir);
|
const afterSnapshot = computeVerificationSnapshot(repoDir);
|
||||||
if (afterSnapshot.snapshotId !== beforeSnapshot.snapshotId) {
|
if (afterSnapshot.snapshotId !== beforeSnapshot.snapshotId) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
profile: request.profile,
|
profile: request.profile,
|
||||||
command: command.commandText,
|
command: command.commandText,
|
||||||
stdout: '',
|
stdout: truncateOutput(stdout),
|
||||||
stderr: '',
|
stderr: truncateOutput(stderr),
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
snapshotId: afterSnapshot.snapshotId,
|
snapshotId: afterSnapshot.snapshotId,
|
||||||
runtimeVersion,
|
runtimeVersion,
|
||||||
error: `Workspace changed while preparing verification scratch. expected=${beforeSnapshot.snapshotId} current=${afterSnapshot.snapshotId}`,
|
error: `Workspace changed during verification. expected=${beforeSnapshot.snapshotId} current=${afterSnapshot.snapshotId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const dockerArgs = buildDockerRunArgs(scratchWorkspace, repoDir, command);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { stdout, stderr } = await execFileCapture(
|
|
||||||
CONTAINER_RUNTIME_BIN,
|
|
||||||
dockerArgs,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
profile: request.profile,
|
profile: request.profile,
|
||||||
@@ -382,6 +363,9 @@ export async function runVerificationRequest(
|
|||||||
typeof error === 'object' && error !== null && 'stderr' in error
|
typeof error === 'object' && error !== null && 'stderr' in error
|
||||||
? String((error as { stderr?: unknown }).stderr ?? '')
|
? String((error as { stderr?: unknown }).stderr ?? '')
|
||||||
: '';
|
: '';
|
||||||
|
const afterSnapshot = computeVerificationSnapshot(repoDir);
|
||||||
|
const workspaceChanged =
|
||||||
|
afterSnapshot.snapshotId !== beforeSnapshot.snapshotId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -390,13 +374,19 @@ export async function runVerificationRequest(
|
|||||||
stdout: truncateOutput(stdout),
|
stdout: truncateOutput(stdout),
|
||||||
stderr: truncateOutput(stderr),
|
stderr: truncateOutput(stderr),
|
||||||
exitCode: extractExitCode(error),
|
exitCode: extractExitCode(error),
|
||||||
snapshotId: beforeSnapshot.snapshotId,
|
snapshotId: workspaceChanged
|
||||||
|
? afterSnapshot.snapshotId
|
||||||
|
: beforeSnapshot.snapshotId,
|
||||||
runtimeVersion,
|
runtimeVersion,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: workspaceChanged
|
||||||
|
? `Workspace changed during verification. expected=${beforeSnapshot.snapshotId} current=${afterSnapshot.snapshotId}`
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(scratchRoot, { recursive: true, force: true });
|
fs.rmSync(directExecution.tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user