runners: share protocol and reviewer policy

This commit is contained in:
ejclaw
2026-04-11 05:07:05 +09:00
parent 94d53e4cc3
commit f5c3393c32
17 changed files with 648 additions and 276 deletions

View File

@@ -4,7 +4,13 @@
"type": "module",
"description": "Shared runner utilities for EJClaw",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsc"
},

View File

@@ -0,0 +1,148 @@
export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---';
export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
export const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
export const IPC_POLL_MS = 500;
export const IPC_INPUT_SUBDIR = 'input';
export const IPC_CLOSE_SENTINEL = '_close';
export type RunnerOutputPhase =
| 'progress'
| 'final'
| 'tool-activity'
| 'intermediate';
export type RunnerOutputVerdict =
| 'done'
| 'done_with_concerns'
| 'blocked'
| 'silent';
export type RunnerOutputVisibility = 'public' | 'silent';
export type RunnerStructuredOutput =
| {
visibility: 'public';
text: string;
verdict?: Exclude<RunnerOutputVerdict, 'silent'>;
}
| {
visibility: 'silent';
verdict?: 'silent';
};
export interface NormalizedRunnerOutput {
result: string | null;
output?: RunnerStructuredOutput;
}
function cloneImageTagPattern(): RegExp {
return new RegExp(IMAGE_TAG_RE.source, IMAGE_TAG_RE.flags);
}
export function writeProtocolOutput<T>(
output: T,
writeLine: (line: string) => void = console.log,
): void {
writeLine(OUTPUT_START_MARKER);
writeLine(JSON.stringify(output));
writeLine(OUTPUT_END_MARKER);
}
export function extractImageTagPaths(text: string): {
cleanText: string;
imagePaths: string[];
} {
const imagePattern = cloneImageTagPattern();
const imagePaths = [...text.matchAll(imagePattern)].map((match) =>
match[1].trim(),
);
return {
cleanText: text.replace(cloneImageTagPattern(), '').trim(),
imagePaths,
};
}
export function normalizePublicTextOutput(
result: string | null,
): NormalizedRunnerOutput {
if (typeof result !== 'string' || result.length === 0) {
return { result };
}
return {
result,
output: {
visibility: 'public',
text: result,
},
};
}
function isVisibleVerdict(
value: unknown,
): value is Exclude<RunnerOutputVerdict, 'silent'> {
return (
value === 'done' || value === 'done_with_concerns' || value === 'blocked'
);
}
export function normalizeEjclawStructuredOutput(
result: string | null,
): NormalizedRunnerOutput {
if (typeof result !== 'string' || result.length === 0) {
return { result };
}
const trimmed = result.trim();
try {
const parsed = JSON.parse(trimmed) as {
ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown };
};
const envelope = parsed?.ejclaw;
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
if (envelope.visibility === 'silent') {
if (envelope.verdict !== undefined && envelope.verdict !== 'silent') {
return normalizePublicTextOutput(result);
}
return {
result: null,
output: {
visibility: 'silent',
verdict:
envelope.verdict === 'silent' ? ('silent' as const) : undefined,
},
};
}
if (
envelope.visibility === 'public' &&
typeof envelope.text === 'string' &&
envelope.text.length > 0
) {
if (
envelope.verdict !== undefined &&
!isVisibleVerdict(envelope.verdict)
) {
return normalizePublicTextOutput(result);
}
return {
result: envelope.text,
output: {
visibility: 'public',
text: envelope.text,
verdict: isVisibleVerdict(envelope.verdict)
? envelope.verdict
: undefined,
},
};
}
}
} catch {
// Fall through to plain visible text output.
}
return normalizePublicTextOutput(result);
}

View File

@@ -2,8 +2,44 @@ export {
prependRoomRoleHeader,
type RoomRoleContext,
} from './room-role-context.js';
export {
extractImageTagPaths,
IMAGE_TAG_RE,
IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR,
IPC_POLL_MS,
normalizeEjclawStructuredOutput,
normalizePublicTextOutput,
OUTPUT_END_MARKER,
OUTPUT_START_MARKER,
writeProtocolOutput,
type NormalizedRunnerOutput,
type RunnerOutputPhase,
type RunnerOutputVerdict,
type RunnerOutputVisibility,
type RunnerStructuredOutput,
} from './agent-protocol.js';
export {
assertReadonlyWorkspaceRepoConnectivity,
buildReviewerGitGuardEnv,
isReviewerRuntime,
} from './reviewer-git-guard.js';
export {
ARBITER_RUNTIME_ENV,
CLAUDE_REVIEWER_READONLY_ENV,
REVIEWER_RUNTIME_ENV,
UNSAFE_HOST_PAIRED_MODE_ENV,
buildClaudeReadonlySandboxSettings,
buildPairedReadonlyRuntimeEnvOverrides,
canUseLinuxBubblewrapReadonlySandbox,
getClaudeReadonlySandboxMode,
getReviewerRuntimeCapabilities,
isArbiterRuntimeEnvEnabled,
isClaudeReadonlyReviewerRuntime,
isReviewerMutatingShellCommand,
isReviewerRuntimeEnvEnabled,
isUnsafeHostPairedModeEnabled,
type ClaudeReadonlySandboxMode,
type ReviewerRuntimeCapabilities,
type RunnerAgentType,
} from './reviewer-runtime-policy.js';

View File

@@ -4,6 +4,7 @@ import os from 'os';
import path from 'path';
import type { RoomRoleContext } from './room-role-context.js';
import { isUnsafeHostPairedModeEnabled } from './reviewer-runtime-policy.js';
const BLOCKED_GIT_SUBCOMMANDS = new Set([
'add',
@@ -26,7 +27,7 @@ const BLOCKED_GIT_SUBCOMMANDS = new Set([
]);
export function isReviewerRuntime(roomRoleContext?: RoomRoleContext): boolean {
if (process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1') {
if (isUnsafeHostPairedModeEnabled()) {
return false;
}
return roomRoleContext?.role === 'reviewer';

View File

@@ -0,0 +1,188 @@
import { execFileSync } from 'child_process';
import os from 'os';
import path from 'path';
import type { RoomRoleContext } from './room-role-context.js';
export type RunnerAgentType = 'claude-code' | 'codex';
export type ClaudeReadonlySandboxMode = 'strict' | 'best-effort';
export const REVIEWER_RUNTIME_ENV = 'EJCLAW_REVIEWER_RUNTIME';
export const ARBITER_RUNTIME_ENV = 'EJCLAW_ARBITER_RUNTIME';
export const UNSAFE_HOST_PAIRED_MODE_ENV = 'EJCLAW_UNSAFE_HOST_PAIRED_MODE';
export const CLAUDE_REVIEWER_READONLY_ENV = 'EJCLAW_CLAUDE_REVIEWER_READONLY';
export interface ReviewerRuntimeCapabilities {
agentType: RunnerAgentType;
supportsShellPreflightHook: boolean;
supportsReadonlySandboxing: boolean;
supportsGitWriteGuard: boolean;
supportsHardMutationBlocking: boolean;
}
const REVIEWER_RUNTIME_CAPABILITIES = {
'claude-code': {
agentType: 'claude-code',
supportsShellPreflightHook: true,
supportsReadonlySandboxing: true,
supportsGitWriteGuard: true,
supportsHardMutationBlocking: true,
},
codex: {
agentType: 'codex',
supportsShellPreflightHook: false,
supportsReadonlySandboxing: false,
supportsGitWriteGuard: true,
supportsHardMutationBlocking: false,
},
} satisfies Record<RunnerAgentType, ReviewerRuntimeCapabilities>;
const MUTATING_SHELL_PATTERNS = [
/\bsed\s+-i\b/i,
/\bperl\s+-i\b/i,
/(^|[;&|])\s*(cat|echo|printf)\b[^#\n]*>>?/i,
];
export function getReviewerRuntimeCapabilities(
agentType: RunnerAgentType,
): ReviewerRuntimeCapabilities {
return REVIEWER_RUNTIME_CAPABILITIES[agentType];
}
export function isUnsafeHostPairedModeEnabled(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return env[UNSAFE_HOST_PAIRED_MODE_ENV] === '1';
}
export function isReviewerRuntimeEnvEnabled(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return env[REVIEWER_RUNTIME_ENV] === '1';
}
export function isArbiterRuntimeEnvEnabled(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return env[ARBITER_RUNTIME_ENV] === '1';
}
export function isClaudeReadonlyReviewerRuntime(
roomRoleContext?: RoomRoleContext,
env: NodeJS.ProcessEnv = process.env,
): boolean {
return (
isUnsafeHostPairedModeEnabled(env) &&
env[CLAUDE_REVIEWER_READONLY_ENV] === '1' &&
roomRoleContext?.role === 'reviewer'
);
}
export function buildPairedReadonlyRuntimeEnvOverrides(args: {
role: 'reviewer' | 'arbiter';
agentType: RunnerAgentType;
unsafeHostPairedMode: boolean;
}): Record<string, string> {
const { role, agentType, unsafeHostPairedMode } = args;
const env: Record<string, string> = {};
if (unsafeHostPairedMode) {
env[UNSAFE_HOST_PAIRED_MODE_ENV] = '1';
}
if (role === 'arbiter') {
if (!unsafeHostPairedMode) {
env[ARBITER_RUNTIME_ENV] = '1';
}
return env;
}
if (!unsafeHostPairedMode) {
env[REVIEWER_RUNTIME_ENV] = '1';
return env;
}
if (getReviewerRuntimeCapabilities(agentType).supportsHardMutationBlocking) {
env[CLAUDE_REVIEWER_READONLY_ENV] = '1';
}
return env;
}
let cachedLinuxBubblewrapReadonlyCapability: boolean | undefined;
function commandExistsForRuntime(command: string): boolean {
try {
execFileSync('bash', ['-lc', `command -v ${command}`], {
stdio: ['ignore', 'ignore', 'ignore'],
});
return true;
} catch {
return false;
}
}
export function canUseLinuxBubblewrapReadonlySandbox(): boolean {
if (cachedLinuxBubblewrapReadonlyCapability != null) {
return cachedLinuxBubblewrapReadonlyCapability;
}
if (os.platform() !== 'linux') {
cachedLinuxBubblewrapReadonlyCapability = false;
return false;
}
if (!commandExistsForRuntime('bwrap')) {
cachedLinuxBubblewrapReadonlyCapability = false;
return false;
}
try {
execFileSync('bwrap', ['--ro-bind', '/', '/', '/bin/true'], {
stdio: ['ignore', 'ignore', 'ignore'],
});
cachedLinuxBubblewrapReadonlyCapability = true;
} catch {
cachedLinuxBubblewrapReadonlyCapability = false;
}
return cachedLinuxBubblewrapReadonlyCapability;
}
export function getClaudeReadonlySandboxMode(
platform: NodeJS.Platform = os.platform(),
linuxCapabilityProbe: () => boolean = canUseLinuxBubblewrapReadonlySandbox,
): ClaudeReadonlySandboxMode {
if (platform === 'linux') {
return linuxCapabilityProbe() ? 'strict' : 'best-effort';
}
return 'best-effort';
}
export function buildClaudeReadonlySandboxSettings(
protectedPaths: string[],
platform: NodeJS.Platform = os.platform(),
sandboxMode: ClaudeReadonlySandboxMode = getClaudeReadonlySandboxMode(
platform,
),
) {
const normalizedPaths = [
...new Set(
protectedPaths
.filter((value): value is string => Boolean(value))
.map((value) => path.resolve(value)),
),
];
return {
enabled: true,
failIfUnavailable: sandboxMode === 'strict',
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem:
normalizedPaths.length > 0 ? { denyWrite: normalizedPaths } : undefined,
};
}
export function isReviewerMutatingShellCommand(command: string): boolean {
const normalized = command.trim();
return MUTATING_SHELL_PATTERNS.some((pattern) => pattern.test(normalized));
}

View File

@@ -1,9 +1,15 @@
import type { RunnerAgentType } from './reviewer-runtime-policy.js';
export interface RoomRoleContext {
serviceId: string;
role: 'owner' | 'reviewer';
role: 'owner' | 'reviewer' | 'arbiter';
ownerServiceId: string;
reviewerServiceId: string;
ownerAgentType?: RunnerAgentType;
reviewerAgentType?: RunnerAgentType | null;
failoverOwner: boolean;
arbiterServiceId?: string;
arbiterAgentType?: RunnerAgentType | null;
}
export function prependRoomRoleHeader(

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
extractImageTagPaths,
normalizeEjclawStructuredOutput,
normalizePublicTextOutput,
writeProtocolOutput,
} from '../src/agent-protocol.js';
describe('shared agent protocol helpers', () => {
it('extracts image tags without leaking regex state', () => {
expect(extractImageTagPaths('hello [Image: /tmp/a.png]')).toEqual({
cleanText: 'hello',
imagePaths: ['/tmp/a.png'],
});
expect(extractImageTagPaths('[Image: /tmp/b.png] second')).toEqual({
cleanText: 'second',
imagePaths: ['/tmp/b.png'],
});
});
it('normalizes plain text runner output as public text', () => {
expect(normalizePublicTextOutput('DONE')).toEqual({
result: 'DONE',
output: {
visibility: 'public',
text: 'DONE',
},
});
});
it('parses silent ejclaw envelopes', () => {
expect(
normalizeEjclawStructuredOutput(
JSON.stringify({
ejclaw: { visibility: 'silent', verdict: 'silent' },
}),
),
).toEqual({
result: null,
output: {
visibility: 'silent',
verdict: 'silent',
},
});
});
it('falls back to visible raw text on invalid public verdicts', () => {
const raw = JSON.stringify({
ejclaw: {
visibility: 'public',
text: 'DONE',
verdict: 'mystery',
},
});
expect(normalizeEjclawStructuredOutput(raw)).toEqual({
result: raw,
output: {
visibility: 'public',
text: raw,
},
});
});
it('writes marker-delimited protocol output', () => {
const lines: string[] = [];
writeProtocolOutput({ status: 'success', result: 'ok' }, (line) => {
lines.push(line);
});
expect(lines).toEqual([
'---EJCLAW_OUTPUT_START---',
'{"status":"success","result":"ok"}',
'---EJCLAW_OUTPUT_END---',
]);
});
});

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import {
buildPairedReadonlyRuntimeEnvOverrides,
getReviewerRuntimeCapabilities,
} from '../src/reviewer-runtime-policy.js';
describe('shared reviewer runtime policy', () => {
it('encodes claude reviewer capabilities explicitly', () => {
expect(getReviewerRuntimeCapabilities('claude-code')).toEqual({
agentType: 'claude-code',
supportsShellPreflightHook: true,
supportsReadonlySandboxing: true,
supportsGitWriteGuard: true,
supportsHardMutationBlocking: true,
});
});
it('encodes codex reviewer limitations explicitly', () => {
expect(getReviewerRuntimeCapabilities('codex')).toEqual({
agentType: 'codex',
supportsShellPreflightHook: false,
supportsReadonlySandboxing: false,
supportsGitWriteGuard: true,
supportsHardMutationBlocking: false,
});
});
it('builds reviewer runtime env for normal isolated runs', () => {
expect(
buildPairedReadonlyRuntimeEnvOverrides({
role: 'reviewer',
agentType: 'claude-code',
unsafeHostPairedMode: false,
}),
).toEqual({
EJCLAW_REVIEWER_RUNTIME: '1',
});
});
it('builds claude host reviewer env with explicit readonly flag', () => {
expect(
buildPairedReadonlyRuntimeEnvOverrides({
role: 'reviewer',
agentType: 'claude-code',
unsafeHostPairedMode: true,
}),
).toEqual({
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
EJCLAW_CLAUDE_REVIEWER_READONLY: '1',
});
});
it('leaves codex host reviewer mode without fake hard-block flags', () => {
expect(
buildPairedReadonlyRuntimeEnvOverrides({
role: 'reviewer',
agentType: 'codex',
unsafeHostPairedMode: true,
}),
).toEqual({
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
});
});
it('keeps arbiter runtime routing unchanged', () => {
expect(
buildPairedReadonlyRuntimeEnvOverrides({
role: 'arbiter',
agentType: 'claude-code',
unsafeHostPairedMode: false,
}),
).toEqual({
EJCLAW_ARBITER_RUNTIME: '1',
});
});
});