runners: share protocol and reviewer policy
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user