diff --git a/bun.lock b/bun.lock index 495edcc..c559ad9 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@modelcontextprotocol/sdk": "^1.27.1", "cron-parser": "^5.5.0", "discord.js": "^14.18.0", + "ejclaw-runners-shared": "file:./runners/shared", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.8.2", @@ -275,6 +276,8 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "ejclaw-runners-shared": ["ejclaw-runners-shared@file:runners/shared", { "devDependencies": { "@types/node": "^22.10.7", "typescript": "^5.7.3" } }], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], diff --git a/package.json b/package.json index 98d5e85..eeeb1e0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@modelcontextprotocol/sdk": "^1.27.1", "cron-parser": "^5.5.0", "discord.js": "^14.18.0", + "ejclaw-runners-shared": "file:./runners/shared", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.8.2", diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index a6a8750..092e23e 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -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 = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', @@ -130,17 +133,10 @@ const MIME_TYPES: Record = { * 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 { }); } -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 { 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 { const readonlyRuntime = reviewerRuntime || claudeReadonlyReviewerRuntime || - process.env.EJCLAW_ARBITER_RUNTIME === '1'; + isArbiterRuntimeEnvEnabled(process.env); const guardedSdkEnv = buildReviewerGitGuardEnv( sdkEnv, reviewerRuntime || claudeReadonlyReviewerRuntime, diff --git a/runners/agent-runner/src/reviewer-runtime.ts b/runners/agent-runner/src/reviewer-runtime.ts index a0d2ed2..97928b0 100644 --- a/runners/agent-runner/src/reviewer-runtime.ts +++ b/runners/agent-runner/src/reviewer-runtime.ts @@ -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)); -} diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index 40cf957..de859ae 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -15,6 +15,16 @@ import fs from 'fs'; import path from 'path'; +import { + extractImageTagPaths, + IPC_CLOSE_SENTINEL, + IPC_INPUT_SUBDIR, + IPC_POLL_MS, + normalizeEjclawStructuredOutput, + writeProtocolOutput, + type RunnerStructuredOutput, +} from 'ejclaw-runners-shared'; + import { CodexAppServerClient, type AppServerInputItem, @@ -26,7 +36,9 @@ import { import { assertReadonlyWorkspaceRepoConnectivity, buildReviewerGitGuardEnv, + isArbiterRuntimeEnvEnabled, isReviewerRuntime, + isReviewerRuntimeEnvEnabled, } from './reviewer-runtime.js'; // ── Types ────────────────────────────────────────────────────────── @@ -46,11 +58,7 @@ interface RunnerInput { interface RunnerOutput { status: 'success' | 'error'; result: string | null; - output?: { - visibility: 'public' | 'silent'; - text?: string; - verdict?: 'done' | 'done_with_concerns' | 'blocked' | 'silent'; - }; + output?: RunnerStructuredOutput; phase?: 'progress' | 'final'; newSessionId?: string; error?: string; @@ -61,13 +69,8 @@ interface RunnerOutput { const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group'; const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc'; const WORK_DIR = process.env.EJCLAW_WORK_DIR || ''; -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; - -/** SSOT: src/agent-protocol.ts — keep in sync */ -const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---'; +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 EFFECTIVE_CWD = WORK_DIR || GROUP_DIR; const CODEX_MODEL = process.env.CODEX_MODEL || ''; @@ -78,82 +81,14 @@ let closeRequested = false; // ── Helpers ──────────────────────────────────────────────────────── 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 }; - } - - 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 { - result, - output: { visibility: 'public', text: 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 && - envelope.verdict !== 'done' && - envelope.verdict !== 'done_with_concerns' && - envelope.verdict !== 'blocked' - ) { - return { - result, - output: { visibility: 'public', text: result }, - }; - } - return { - result: envelope.text, - output: { - visibility: 'public', - text: envelope.text, - verdict: - typeof envelope.verdict === 'string' - ? (envelope.verdict as - | 'done' - | 'done_with_concerns' - | 'blocked') - : undefined, - }, - }; - } - } - } catch { - // fall through to legacy string output - } - - return { - result, - output: { visibility: 'public', text: result }, - }; + return normalizeEjclawStructuredOutput(result); } function log(message: string): void { @@ -226,18 +161,7 @@ function extractImagePaths(text: string): { cleanText: string; imagePaths: string[]; } { - /** SSOT: src/agent-protocol.ts — keep in sync */ - const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g; - const imagePaths: string[] = []; - let match: RegExpExecArray | null; - while ((match = imagePattern.exec(text)) !== null) { - imagePaths.push(match[1].trim()); - } - - return { - cleanText: text.replace(imagePattern, '').trim(), - imagePaths, - }; + return extractImageTagPaths(text); } function parseAppServerInput(text: string): AppServerInputItem[] { @@ -409,10 +333,10 @@ async function runAppServerSession( prompt: string, ): Promise { const reviewerRuntime = - process.env.EJCLAW_REVIEWER_RUNTIME === '1' || + isReviewerRuntimeEnvEnabled(process.env) || isReviewerRuntime(runnerInput.roomRoleContext); const readonlyRuntime = - reviewerRuntime || process.env.EJCLAW_ARBITER_RUNTIME === '1'; + reviewerRuntime || isArbiterRuntimeEnvEnabled(process.env); const clientEnv = buildReviewerGitGuardEnv(process.env, reviewerRuntime); assertReadonlyWorkspaceRepoConnectivity(clientEnv, readonlyRuntime); const client = new CodexAppServerClient({ diff --git a/runners/codex-runner/src/reviewer-runtime.ts b/runners/codex-runner/src/reviewer-runtime.ts index e19efb5..aa0059e 100644 --- a/runners/codex-runner/src/reviewer-runtime.ts +++ b/runners/codex-runner/src/reviewer-runtime.ts @@ -1,9 +1,8 @@ export { assertReadonlyWorkspaceRepoConnectivity, buildReviewerGitGuardEnv, + getReviewerRuntimeCapabilities, + isArbiterRuntimeEnvEnabled, isReviewerRuntime, + isReviewerRuntimeEnvEnabled, } from 'ejclaw-runners-shared'; - -// Codex app-server does not expose a BashTool-style pre-use hook, so reviewer -// mode can only hard-block mutating git via PATH interception here. Non-git -// shell mutation commands remain a known gap when REVIEWER_AGENT_TYPE=codex. diff --git a/runners/shared/package.json b/runners/shared/package.json index 51ac14e..e5c843c 100644 --- a/runners/shared/package.json +++ b/runners/shared/package.json @@ -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" }, diff --git a/runners/shared/src/agent-protocol.ts b/runners/shared/src/agent-protocol.ts new file mode 100644 index 0000000..89aa8f0 --- /dev/null +++ b/runners/shared/src/agent-protocol.ts @@ -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; + } + | { + 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( + 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 { + 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); +} diff --git a/runners/shared/src/index.ts b/runners/shared/src/index.ts index 4068126..eb7cd92 100644 --- a/runners/shared/src/index.ts +++ b/runners/shared/src/index.ts @@ -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'; diff --git a/runners/shared/src/reviewer-git-guard.ts b/runners/shared/src/reviewer-git-guard.ts index 3ee6432..3fedb8d 100644 --- a/runners/shared/src/reviewer-git-guard.ts +++ b/runners/shared/src/reviewer-git-guard.ts @@ -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'; diff --git a/runners/shared/src/reviewer-runtime-policy.ts b/runners/shared/src/reviewer-runtime-policy.ts new file mode 100644 index 0000000..5f846eb --- /dev/null +++ b/runners/shared/src/reviewer-runtime-policy.ts @@ -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; + +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 { + const { role, agentType, unsafeHostPairedMode } = args; + const env: Record = {}; + + 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)); +} diff --git a/runners/shared/src/room-role-context.ts b/runners/shared/src/room-role-context.ts index 19d9381..eceabf3 100644 --- a/runners/shared/src/room-role-context.ts +++ b/runners/shared/src/room-role-context.ts @@ -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( diff --git a/runners/shared/test/agent-protocol.test.ts b/runners/shared/test/agent-protocol.test.ts new file mode 100644 index 0000000..37adb62 --- /dev/null +++ b/runners/shared/test/agent-protocol.test.ts @@ -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---', + ]); + }); +}); diff --git a/runners/shared/test/reviewer-runtime-policy.test.ts b/runners/shared/test/reviewer-runtime-policy.test.ts new file mode 100644 index 0000000..521b123 --- /dev/null +++ b/runners/shared/test/reviewer-runtime-policy.test.ts @@ -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', + }); + }); +}); diff --git a/src/agent-protocol.ts b/src/agent-protocol.ts index 4ade8e7..53db438 100644 --- a/src/agent-protocol.ts +++ b/src/agent-protocol.ts @@ -1,19 +1,17 @@ -/** - * Agent Protocol Constants (SSOT). - * - * Shared constants for host ↔ runner communication. - * Runners are separate packages and can't import this directly — - * they define local copies with comments referencing this file. - */ - -/** Sentinel markers for robust stdout output parsing. */ -export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---'; -export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---'; - -/** Regex to extract [Image: /path/to/file] tags from agent text. */ -export const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g; - -/** IPC polling interval (ms) used by runners to check for follow-up messages. */ -export const IPC_POLL_MS = 500; -export const IPC_INPUT_SUBDIR = 'input'; -export const IPC_CLOSE_SENTINEL = '_close'; +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 'ejclaw-runners-shared'; diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index a7568b3..389f08b 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -3,6 +3,10 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { + CLAUDE_REVIEWER_READONLY_ENV, + REVIEWER_RUNTIME_ENV, +} from 'ejclaw-runners-shared'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('./db.js', () => { @@ -88,9 +92,9 @@ const failoverOwnerContext: RoomRoleContext = { const ORIGINAL_UNSAFE_HOST_PAIRED_MODE = process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE; -const ORIGINAL_REVIEWER_RUNTIME = process.env.EJCLAW_REVIEWER_RUNTIME; +const ORIGINAL_REVIEWER_RUNTIME = process.env[REVIEWER_RUNTIME_ENV]; const ORIGINAL_CLAUDE_REVIEWER_READONLY = - process.env.EJCLAW_CLAUDE_REVIEWER_READONLY; + process.env[CLAUDE_REVIEWER_READONLY_ENV]; function createCanonicalRepoWithCommit(commitMessage: string): string { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-')); @@ -165,8 +169,8 @@ function buildWorkspace( describe('paired execution context', () => { beforeEach(() => { delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE; - delete process.env.EJCLAW_REVIEWER_RUNTIME; - delete process.env.EJCLAW_CLAUDE_REVIEWER_READONLY; + delete process.env[REVIEWER_RUNTIME_ENV]; + delete process.env[CLAUDE_REVIEWER_READONLY_ENV]; vi.resetAllMocks(); vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined); vi.mocked(db.getPairedTaskById).mockReturnValue(undefined); @@ -202,15 +206,15 @@ describe('paired execution context', () => { } if (ORIGINAL_REVIEWER_RUNTIME == null) { - delete process.env.EJCLAW_REVIEWER_RUNTIME; + delete process.env[REVIEWER_RUNTIME_ENV]; } else { - process.env.EJCLAW_REVIEWER_RUNTIME = ORIGINAL_REVIEWER_RUNTIME; + process.env[REVIEWER_RUNTIME_ENV] = ORIGINAL_REVIEWER_RUNTIME; } if (ORIGINAL_CLAUDE_REVIEWER_READONLY == null) { - delete process.env.EJCLAW_CLAUDE_REVIEWER_READONLY; + delete process.env[CLAUDE_REVIEWER_READONLY_ENV]; } else { - process.env.EJCLAW_CLAUDE_REVIEWER_READONLY = + process.env[CLAUDE_REVIEWER_READONLY_ENV] = ORIGINAL_CLAUDE_REVIEWER_READONLY; } }); diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index d4d0d78..d769808 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -2,6 +2,8 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; +import { buildPairedReadonlyRuntimeEnvOverrides } from 'ejclaw-runners-shared'; + import { ARBITER_DEADLOCK_THRESHOLD, ARBITER_AGENT_TYPE, @@ -337,14 +339,14 @@ export function preparePairedExecutionContext(args: { ); fs.mkdirSync(reviewerSessionDir, { recursive: true }); envOverrides.CLAUDE_CONFIG_DIR = reviewerSessionDir; - if (unsafeHostPairedMode) { - envOverrides.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1'; - if (REVIEWER_AGENT_TYPE === 'claude-code') { - envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY = '1'; - } - } else { - envOverrides.EJCLAW_REVIEWER_RUNTIME = '1'; - } + Object.assign( + envOverrides, + buildPairedReadonlyRuntimeEnvOverrides({ + role: 'reviewer', + agentType: REVIEWER_AGENT_TYPE, + unsafeHostPairedMode, + }), + ); } else if (roomRoleContext.role === 'arbiter') { const arbiterSessionDir = path.join( DATA_DIR, @@ -356,11 +358,14 @@ export function preparePairedExecutionContext(args: { fs.rmSync(arbiterSessionDir, { recursive: true, force: true }); fs.mkdirSync(arbiterSessionDir, { recursive: true }); envOverrides.CLAUDE_CONFIG_DIR = arbiterSessionDir; - if (unsafeHostPairedMode) { - envOverrides.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1'; - } else { - envOverrides.EJCLAW_ARBITER_RUNTIME = '1'; - } + Object.assign( + envOverrides, + buildPairedReadonlyRuntimeEnvOverrides({ + role: 'arbiter', + agentType: ARBITER_AGENT_TYPE ?? REVIEWER_AGENT_TYPE, + unsafeHostPairedMode, + }), + ); } return {