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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user