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

@@ -22,6 +22,15 @@ import {
PreCompactHookInput,
PreToolUseHookInput,
} from '@anthropic-ai/claude-agent-sdk';
import {
extractImageTagPaths,
IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR,
IPC_POLL_MS,
normalizePublicTextOutput,
type RunnerStructuredOutput,
writeProtocolOutput,
} from 'ejclaw-runners-shared';
import { fileURLToPath } from 'url';
import {
@@ -33,9 +42,11 @@ import {
buildClaudeReadonlySandboxSettings,
buildReviewerGitGuardEnv,
getClaudeReadonlySandboxMode,
isArbiterRuntimeEnvEnabled,
isClaudeReadonlyReviewerRuntime,
isReviewerMutatingShellCommand,
isReviewerRuntime,
isReviewerRuntimeEnvEnabled,
} from './reviewer-runtime.js';
import { selectCompactMemoriesFromSummary } from './memory-selection.js';
@@ -59,11 +70,7 @@ interface RunnerOutput {
agentLabel?: string;
agentDone?: boolean;
result: string | null;
output?: {
visibility: 'public' | 'silent';
text?: string;
verdict?: 'done' | 'done_with_concerns' | 'blocked' | 'silent';
};
output?: RunnerStructuredOutput;
newSessionId?: string;
error?: string;
}
@@ -110,13 +117,9 @@ const HOST_IPC_DIR = process.env.EJCLAW_HOST_IPC_DIR || IPC_DIR;
const WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
const GROUP_FOLDER = process.env.EJCLAW_GROUP_FOLDER || '';
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
const IPC_INPUT_DIR = path.join(IPC_DIR, IPC_INPUT_SUBDIR);
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, IPC_CLOSE_SENTINEL);
const HOST_TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
/** SSOT: src/agent-protocol.ts — keep in sync */
const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
@@ -130,17 +133,10 @@ const MIME_TYPES: Record<string, string> = {
* Returns a plain string if no images found, or ContentBlock[] with text + image blocks.
*/
function buildMultimodalContent(text: string): string | ContentBlock[] {
const imagePaths: string[] = [];
let match;
while ((match = IMAGE_TAG_RE.exec(text)) !== null) {
imagePaths.push(match[1].trim());
}
IMAGE_TAG_RE.lastIndex = 0; // reset regex state
const { cleanText, imagePaths } = extractImageTagPaths(text);
if (imagePaths.length === 0) return text;
const blocks: ContentBlock[] = [];
const cleanText = text.replace(IMAGE_TAG_RE, '').trim();
if (cleanText) {
blocks.push({ type: 'text', text: cleanText });
}
@@ -224,24 +220,15 @@ async function readStdin(): Promise<string> {
});
}
const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
function writeOutput(output: RunnerOutput): void {
console.log(OUTPUT_START_MARKER);
console.log(JSON.stringify(output));
console.log(OUTPUT_END_MARKER);
writeProtocolOutput(output);
}
function normalizeStructuredOutput(result: string | null): {
result: string | null;
output?: RunnerOutput['output'];
} {
if (typeof result !== 'string' || result.length === 0) {
return { result };
}
// All output is public — silent suppression was removed.
return { result, output: { visibility: 'public', text: result } };
return normalizePublicTextOutput(result);
}
function log(message: string): void {
@@ -1137,7 +1124,7 @@ async function main(): Promise<void> {
sdkEnv[key] = value;
}
const reviewerRuntime =
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
isReviewerRuntimeEnvEnabled(process.env) ||
isReviewerRuntime(runnerInput.roomRoleContext);
const claudeReadonlyReviewerRuntime = isClaudeReadonlyReviewerRuntime(
runnerInput.roomRoleContext,
@@ -1148,7 +1135,7 @@ async function main(): Promise<void> {
const readonlyRuntime =
reviewerRuntime ||
claudeReadonlyReviewerRuntime ||
process.env.EJCLAW_ARBITER_RUNTIME === '1';
isArbiterRuntimeEnvEnabled(process.env);
const guardedSdkEnv = buildReviewerGitGuardEnv(
sdkEnv,
reviewerRuntime || claudeReadonlyReviewerRuntime,

View File

@@ -1,106 +1,17 @@
import { execFileSync } from 'child_process';
import os from 'os';
import path from 'path';
import type { RoomRoleContext } from './room-role-context.js';
export {
assertReadonlyWorkspaceRepoConnectivity,
buildClaudeReadonlySandboxSettings,
buildReviewerGitGuardEnv,
canUseLinuxBubblewrapReadonlySandbox,
CLAUDE_REVIEWER_READONLY_ENV,
getClaudeReadonlySandboxMode,
getReviewerRuntimeCapabilities,
isArbiterRuntimeEnvEnabled,
isClaudeReadonlyReviewerRuntime,
isReviewerMutatingShellCommand,
isReviewerRuntime,
isReviewerRuntimeEnvEnabled,
isUnsafeHostPairedModeEnabled,
REVIEWER_RUNTIME_ENV,
type ClaudeReadonlySandboxMode,
} from 'ejclaw-runners-shared';
export type ClaudeReadonlySandboxMode = 'strict' | 'best-effort';
const MUTATING_SHELL_PATTERNS = [
/\bsed\s+-i\b/i,
/\bperl\s+-i\b/i,
/(^|[;&|])\s*(cat|echo|printf)\b[^#\n]*>>?/i,
];
export function isClaudeReadonlyReviewerRuntime(
roomRoleContext?: RoomRoleContext,
): boolean {
return (
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1' &&
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY === '1' &&
roomRoleContext?.role === 'reviewer'
);
}
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));
}