runners: share protocol and reviewer policy
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -8,6 +8,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"discord.js": "^14.18.0",
|
"discord.js": "^14.18.0",
|
||||||
|
"ejclaw-runners-shared": "file:./runners/shared",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
@@ -275,6 +276,8 @@
|
|||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"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=="],
|
"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=="],
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"discord.js": "^14.18.0",
|
"discord.js": "^14.18.0",
|
||||||
|
"ejclaw-runners-shared": "file:./runners/shared",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ import {
|
|||||||
PreCompactHookInput,
|
PreCompactHookInput,
|
||||||
PreToolUseHookInput,
|
PreToolUseHookInput,
|
||||||
} from '@anthropic-ai/claude-agent-sdk';
|
} 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 { fileURLToPath } from 'url';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -33,9 +42,11 @@ import {
|
|||||||
buildClaudeReadonlySandboxSettings,
|
buildClaudeReadonlySandboxSettings,
|
||||||
buildReviewerGitGuardEnv,
|
buildReviewerGitGuardEnv,
|
||||||
getClaudeReadonlySandboxMode,
|
getClaudeReadonlySandboxMode,
|
||||||
|
isArbiterRuntimeEnvEnabled,
|
||||||
isClaudeReadonlyReviewerRuntime,
|
isClaudeReadonlyReviewerRuntime,
|
||||||
isReviewerMutatingShellCommand,
|
isReviewerMutatingShellCommand,
|
||||||
isReviewerRuntime,
|
isReviewerRuntime,
|
||||||
|
isReviewerRuntimeEnvEnabled,
|
||||||
} from './reviewer-runtime.js';
|
} from './reviewer-runtime.js';
|
||||||
import { selectCompactMemoriesFromSummary } from './memory-selection.js';
|
import { selectCompactMemoriesFromSummary } from './memory-selection.js';
|
||||||
|
|
||||||
@@ -59,11 +70,7 @@ interface RunnerOutput {
|
|||||||
agentLabel?: string;
|
agentLabel?: string;
|
||||||
agentDone?: boolean;
|
agentDone?: boolean;
|
||||||
result: string | null;
|
result: string | null;
|
||||||
output?: {
|
output?: RunnerStructuredOutput;
|
||||||
visibility: 'public' | 'silent';
|
|
||||||
text?: string;
|
|
||||||
verdict?: 'done' | 'done_with_concerns' | 'blocked' | 'silent';
|
|
||||||
};
|
|
||||||
newSessionId?: string;
|
newSessionId?: string;
|
||||||
error?: 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 WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
|
||||||
const GROUP_FOLDER = process.env.EJCLAW_GROUP_FOLDER || '';
|
const GROUP_FOLDER = process.env.EJCLAW_GROUP_FOLDER || '';
|
||||||
|
|
||||||
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
|
const IPC_INPUT_DIR = path.join(IPC_DIR, IPC_INPUT_SUBDIR);
|
||||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, IPC_CLOSE_SENTINEL);
|
||||||
const IPC_POLL_MS = 500;
|
|
||||||
const HOST_TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
|
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> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
'.jpg': 'image/jpeg',
|
'.jpg': 'image/jpeg',
|
||||||
'.jpeg': '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.
|
* Returns a plain string if no images found, or ContentBlock[] with text + image blocks.
|
||||||
*/
|
*/
|
||||||
function buildMultimodalContent(text: string): string | ContentBlock[] {
|
function buildMultimodalContent(text: string): string | ContentBlock[] {
|
||||||
const imagePaths: string[] = [];
|
const { cleanText, imagePaths } = extractImageTagPaths(text);
|
||||||
let match;
|
|
||||||
while ((match = IMAGE_TAG_RE.exec(text)) !== null) {
|
|
||||||
imagePaths.push(match[1].trim());
|
|
||||||
}
|
|
||||||
IMAGE_TAG_RE.lastIndex = 0; // reset regex state
|
|
||||||
|
|
||||||
if (imagePaths.length === 0) return text;
|
if (imagePaths.length === 0) return text;
|
||||||
|
|
||||||
const blocks: ContentBlock[] = [];
|
const blocks: ContentBlock[] = [];
|
||||||
const cleanText = text.replace(IMAGE_TAG_RE, '').trim();
|
|
||||||
if (cleanText) {
|
if (cleanText) {
|
||||||
blocks.push({ type: 'text', text: 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 {
|
function writeOutput(output: RunnerOutput): void {
|
||||||
console.log(OUTPUT_START_MARKER);
|
writeProtocolOutput(output);
|
||||||
console.log(JSON.stringify(output));
|
|
||||||
console.log(OUTPUT_END_MARKER);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStructuredOutput(result: string | null): {
|
function normalizeStructuredOutput(result: string | null): {
|
||||||
result: string | null;
|
result: string | null;
|
||||||
output?: RunnerOutput['output'];
|
output?: RunnerOutput['output'];
|
||||||
} {
|
} {
|
||||||
if (typeof result !== 'string' || result.length === 0) {
|
return normalizePublicTextOutput(result);
|
||||||
return { result };
|
|
||||||
}
|
|
||||||
// All output is public — silent suppression was removed.
|
|
||||||
return { result, output: { visibility: 'public', text: result } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function log(message: string): void {
|
function log(message: string): void {
|
||||||
@@ -1137,7 +1124,7 @@ async function main(): Promise<void> {
|
|||||||
sdkEnv[key] = value;
|
sdkEnv[key] = value;
|
||||||
}
|
}
|
||||||
const reviewerRuntime =
|
const reviewerRuntime =
|
||||||
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
|
isReviewerRuntimeEnvEnabled(process.env) ||
|
||||||
isReviewerRuntime(runnerInput.roomRoleContext);
|
isReviewerRuntime(runnerInput.roomRoleContext);
|
||||||
const claudeReadonlyReviewerRuntime = isClaudeReadonlyReviewerRuntime(
|
const claudeReadonlyReviewerRuntime = isClaudeReadonlyReviewerRuntime(
|
||||||
runnerInput.roomRoleContext,
|
runnerInput.roomRoleContext,
|
||||||
@@ -1148,7 +1135,7 @@ async function main(): Promise<void> {
|
|||||||
const readonlyRuntime =
|
const readonlyRuntime =
|
||||||
reviewerRuntime ||
|
reviewerRuntime ||
|
||||||
claudeReadonlyReviewerRuntime ||
|
claudeReadonlyReviewerRuntime ||
|
||||||
process.env.EJCLAW_ARBITER_RUNTIME === '1';
|
isArbiterRuntimeEnvEnabled(process.env);
|
||||||
const guardedSdkEnv = buildReviewerGitGuardEnv(
|
const guardedSdkEnv = buildReviewerGitGuardEnv(
|
||||||
sdkEnv,
|
sdkEnv,
|
||||||
reviewerRuntime || claudeReadonlyReviewerRuntime,
|
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 {
|
export {
|
||||||
assertReadonlyWorkspaceRepoConnectivity,
|
assertReadonlyWorkspaceRepoConnectivity,
|
||||||
|
buildClaudeReadonlySandboxSettings,
|
||||||
buildReviewerGitGuardEnv,
|
buildReviewerGitGuardEnv,
|
||||||
|
canUseLinuxBubblewrapReadonlySandbox,
|
||||||
|
CLAUDE_REVIEWER_READONLY_ENV,
|
||||||
|
getClaudeReadonlySandboxMode,
|
||||||
|
getReviewerRuntimeCapabilities,
|
||||||
|
isArbiterRuntimeEnvEnabled,
|
||||||
|
isClaudeReadonlyReviewerRuntime,
|
||||||
|
isReviewerMutatingShellCommand,
|
||||||
isReviewerRuntime,
|
isReviewerRuntime,
|
||||||
|
isReviewerRuntimeEnvEnabled,
|
||||||
|
isUnsafeHostPairedModeEnabled,
|
||||||
|
REVIEWER_RUNTIME_ENV,
|
||||||
|
type ClaudeReadonlySandboxMode,
|
||||||
} from 'ejclaw-runners-shared';
|
} 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));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractImageTagPaths,
|
||||||
|
IPC_CLOSE_SENTINEL,
|
||||||
|
IPC_INPUT_SUBDIR,
|
||||||
|
IPC_POLL_MS,
|
||||||
|
normalizeEjclawStructuredOutput,
|
||||||
|
writeProtocolOutput,
|
||||||
|
type RunnerStructuredOutput,
|
||||||
|
} from 'ejclaw-runners-shared';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CodexAppServerClient,
|
CodexAppServerClient,
|
||||||
type AppServerInputItem,
|
type AppServerInputItem,
|
||||||
@@ -26,7 +36,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
assertReadonlyWorkspaceRepoConnectivity,
|
assertReadonlyWorkspaceRepoConnectivity,
|
||||||
buildReviewerGitGuardEnv,
|
buildReviewerGitGuardEnv,
|
||||||
|
isArbiterRuntimeEnvEnabled,
|
||||||
isReviewerRuntime,
|
isReviewerRuntime,
|
||||||
|
isReviewerRuntimeEnvEnabled,
|
||||||
} from './reviewer-runtime.js';
|
} from './reviewer-runtime.js';
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────
|
||||||
@@ -46,11 +58,7 @@ interface RunnerInput {
|
|||||||
interface RunnerOutput {
|
interface RunnerOutput {
|
||||||
status: 'success' | 'error';
|
status: 'success' | 'error';
|
||||||
result: string | null;
|
result: string | null;
|
||||||
output?: {
|
output?: RunnerStructuredOutput;
|
||||||
visibility: 'public' | 'silent';
|
|
||||||
text?: string;
|
|
||||||
verdict?: 'done' | 'done_with_concerns' | 'blocked' | 'silent';
|
|
||||||
};
|
|
||||||
phase?: 'progress' | 'final';
|
phase?: 'progress' | 'final';
|
||||||
newSessionId?: string;
|
newSessionId?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -61,13 +69,8 @@ interface RunnerOutput {
|
|||||||
const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group';
|
const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group';
|
||||||
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
|
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
|
||||||
const WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
|
const WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
|
||||||
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
|
const IPC_INPUT_DIR = path.join(IPC_DIR, IPC_INPUT_SUBDIR);
|
||||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, IPC_CLOSE_SENTINEL);
|
||||||
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 EFFECTIVE_CWD = WORK_DIR || GROUP_DIR;
|
const EFFECTIVE_CWD = WORK_DIR || GROUP_DIR;
|
||||||
const CODEX_MODEL = process.env.CODEX_MODEL || '';
|
const CODEX_MODEL = process.env.CODEX_MODEL || '';
|
||||||
@@ -78,82 +81,14 @@ let closeRequested = false;
|
|||||||
// ── Helpers ────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function writeOutput(output: RunnerOutput): void {
|
function writeOutput(output: RunnerOutput): void {
|
||||||
console.log(OUTPUT_START_MARKER);
|
writeProtocolOutput(output);
|
||||||
console.log(JSON.stringify(output));
|
|
||||||
console.log(OUTPUT_END_MARKER);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStructuredOutput(result: string | null): {
|
function normalizeStructuredOutput(result: string | null): {
|
||||||
result: string | null;
|
result: string | null;
|
||||||
output?: RunnerOutput['output'];
|
output?: RunnerOutput['output'];
|
||||||
} {
|
} {
|
||||||
if (typeof result !== 'string' || result.length === 0) {
|
return normalizeEjclawStructuredOutput(result);
|
||||||
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 },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function log(message: string): void {
|
function log(message: string): void {
|
||||||
@@ -226,18 +161,7 @@ function extractImagePaths(text: string): {
|
|||||||
cleanText: string;
|
cleanText: string;
|
||||||
imagePaths: string[];
|
imagePaths: string[];
|
||||||
} {
|
} {
|
||||||
/** SSOT: src/agent-protocol.ts — keep in sync */
|
return extractImageTagPaths(text);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAppServerInput(text: string): AppServerInputItem[] {
|
function parseAppServerInput(text: string): AppServerInputItem[] {
|
||||||
@@ -409,10 +333,10 @@ async function runAppServerSession(
|
|||||||
prompt: string,
|
prompt: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const reviewerRuntime =
|
const reviewerRuntime =
|
||||||
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
|
isReviewerRuntimeEnvEnabled(process.env) ||
|
||||||
isReviewerRuntime(runnerInput.roomRoleContext);
|
isReviewerRuntime(runnerInput.roomRoleContext);
|
||||||
const readonlyRuntime =
|
const readonlyRuntime =
|
||||||
reviewerRuntime || process.env.EJCLAW_ARBITER_RUNTIME === '1';
|
reviewerRuntime || isArbiterRuntimeEnvEnabled(process.env);
|
||||||
const clientEnv = buildReviewerGitGuardEnv(process.env, reviewerRuntime);
|
const clientEnv = buildReviewerGitGuardEnv(process.env, reviewerRuntime);
|
||||||
assertReadonlyWorkspaceRepoConnectivity(clientEnv, readonlyRuntime);
|
assertReadonlyWorkspaceRepoConnectivity(clientEnv, readonlyRuntime);
|
||||||
const client = new CodexAppServerClient({
|
const client = new CodexAppServerClient({
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
export {
|
export {
|
||||||
assertReadonlyWorkspaceRepoConnectivity,
|
assertReadonlyWorkspaceRepoConnectivity,
|
||||||
buildReviewerGitGuardEnv,
|
buildReviewerGitGuardEnv,
|
||||||
|
getReviewerRuntimeCapabilities,
|
||||||
|
isArbiterRuntimeEnvEnabled,
|
||||||
isReviewerRuntime,
|
isReviewerRuntime,
|
||||||
|
isReviewerRuntimeEnvEnabled,
|
||||||
} from 'ejclaw-runners-shared';
|
} 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.
|
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Shared runner utilities for EJClaw",
|
"description": "Shared runner utilities for EJClaw",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"default": "./src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc"
|
"build": "tsc"
|
||||||
},
|
},
|
||||||
|
|||||||
148
runners/shared/src/agent-protocol.ts
Normal file
148
runners/shared/src/agent-protocol.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -2,8 +2,44 @@ export {
|
|||||||
prependRoomRoleHeader,
|
prependRoomRoleHeader,
|
||||||
type RoomRoleContext,
|
type RoomRoleContext,
|
||||||
} from './room-role-context.js';
|
} 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 {
|
export {
|
||||||
assertReadonlyWorkspaceRepoConnectivity,
|
assertReadonlyWorkspaceRepoConnectivity,
|
||||||
buildReviewerGitGuardEnv,
|
buildReviewerGitGuardEnv,
|
||||||
isReviewerRuntime,
|
isReviewerRuntime,
|
||||||
} from './reviewer-git-guard.js';
|
} 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';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os from 'os';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import type { RoomRoleContext } from './room-role-context.js';
|
import type { RoomRoleContext } from './room-role-context.js';
|
||||||
|
import { isUnsafeHostPairedModeEnabled } from './reviewer-runtime-policy.js';
|
||||||
|
|
||||||
const BLOCKED_GIT_SUBCOMMANDS = new Set([
|
const BLOCKED_GIT_SUBCOMMANDS = new Set([
|
||||||
'add',
|
'add',
|
||||||
@@ -26,7 +27,7 @@ const BLOCKED_GIT_SUBCOMMANDS = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export function isReviewerRuntime(roomRoleContext?: RoomRoleContext): boolean {
|
export function isReviewerRuntime(roomRoleContext?: RoomRoleContext): boolean {
|
||||||
if (process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1') {
|
if (isUnsafeHostPairedModeEnabled()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return roomRoleContext?.role === 'reviewer';
|
return roomRoleContext?.role === 'reviewer';
|
||||||
|
|||||||
188
runners/shared/src/reviewer-runtime-policy.ts
Normal file
188
runners/shared/src/reviewer-runtime-policy.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import type { RunnerAgentType } from './reviewer-runtime-policy.js';
|
||||||
|
|
||||||
export interface RoomRoleContext {
|
export interface RoomRoleContext {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
role: 'owner' | 'reviewer';
|
role: 'owner' | 'reviewer' | 'arbiter';
|
||||||
ownerServiceId: string;
|
ownerServiceId: string;
|
||||||
reviewerServiceId: string;
|
reviewerServiceId: string;
|
||||||
|
ownerAgentType?: RunnerAgentType;
|
||||||
|
reviewerAgentType?: RunnerAgentType | null;
|
||||||
failoverOwner: boolean;
|
failoverOwner: boolean;
|
||||||
|
arbiterServiceId?: string;
|
||||||
|
arbiterAgentType?: RunnerAgentType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prependRoomRoleHeader(
|
export function prependRoomRoleHeader(
|
||||||
|
|||||||
78
runners/shared/test/agent-protocol.test.ts
Normal file
78
runners/shared/test/agent-protocol.test.ts
Normal 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---',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
runners/shared/test/reviewer-runtime-policy.test.ts
Normal file
77
runners/shared/test/reviewer-runtime-policy.test.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
/**
|
export {
|
||||||
* Agent Protocol Constants (SSOT).
|
extractImageTagPaths,
|
||||||
*
|
IMAGE_TAG_RE,
|
||||||
* Shared constants for host ↔ runner communication.
|
IPC_CLOSE_SENTINEL,
|
||||||
* Runners are separate packages and can't import this directly —
|
IPC_INPUT_SUBDIR,
|
||||||
* they define local copies with comments referencing this file.
|
IPC_POLL_MS,
|
||||||
*/
|
normalizeEjclawStructuredOutput,
|
||||||
|
normalizePublicTextOutput,
|
||||||
/** Sentinel markers for robust stdout output parsing. */
|
OUTPUT_END_MARKER,
|
||||||
export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---';
|
OUTPUT_START_MARKER,
|
||||||
export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
|
writeProtocolOutput,
|
||||||
|
type NormalizedRunnerOutput,
|
||||||
/** Regex to extract [Image: /path/to/file] tags from agent text. */
|
type RunnerOutputPhase,
|
||||||
export const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
|
type RunnerOutputVerdict,
|
||||||
|
type RunnerOutputVisibility,
|
||||||
/** IPC polling interval (ms) used by runners to check for follow-up messages. */
|
type RunnerStructuredOutput,
|
||||||
export const IPC_POLL_MS = 500;
|
} from 'ejclaw-runners-shared';
|
||||||
export const IPC_INPUT_SUBDIR = 'input';
|
|
||||||
export const IPC_CLOSE_SENTINEL = '_close';
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import fs from 'fs';
|
|||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
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';
|
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('./db.js', () => {
|
vi.mock('./db.js', () => {
|
||||||
@@ -88,9 +92,9 @@ const failoverOwnerContext: RoomRoleContext = {
|
|||||||
|
|
||||||
const ORIGINAL_UNSAFE_HOST_PAIRED_MODE =
|
const ORIGINAL_UNSAFE_HOST_PAIRED_MODE =
|
||||||
process.env.EJCLAW_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 =
|
const ORIGINAL_CLAUDE_REVIEWER_READONLY =
|
||||||
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
|
process.env[CLAUDE_REVIEWER_READONLY_ENV];
|
||||||
|
|
||||||
function createCanonicalRepoWithCommit(commitMessage: string): string {
|
function createCanonicalRepoWithCommit(commitMessage: string): string {
|
||||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-'));
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-'));
|
||||||
@@ -165,8 +169,8 @@ function buildWorkspace(
|
|||||||
describe('paired execution context', () => {
|
describe('paired execution context', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
|
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
|
||||||
delete process.env.EJCLAW_REVIEWER_RUNTIME;
|
delete process.env[REVIEWER_RUNTIME_ENV];
|
||||||
delete process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
|
delete process.env[CLAUDE_REVIEWER_READONLY_ENV];
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
|
||||||
vi.mocked(db.getPairedTaskById).mockReturnValue(undefined);
|
vi.mocked(db.getPairedTaskById).mockReturnValue(undefined);
|
||||||
@@ -202,15 +206,15 @@ describe('paired execution context', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ORIGINAL_REVIEWER_RUNTIME == null) {
|
if (ORIGINAL_REVIEWER_RUNTIME == null) {
|
||||||
delete process.env.EJCLAW_REVIEWER_RUNTIME;
|
delete process.env[REVIEWER_RUNTIME_ENV];
|
||||||
} else {
|
} else {
|
||||||
process.env.EJCLAW_REVIEWER_RUNTIME = ORIGINAL_REVIEWER_RUNTIME;
|
process.env[REVIEWER_RUNTIME_ENV] = ORIGINAL_REVIEWER_RUNTIME;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ORIGINAL_CLAUDE_REVIEWER_READONLY == null) {
|
if (ORIGINAL_CLAUDE_REVIEWER_READONLY == null) {
|
||||||
delete process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
|
delete process.env[CLAUDE_REVIEWER_READONLY_ENV];
|
||||||
} else {
|
} else {
|
||||||
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY =
|
process.env[CLAUDE_REVIEWER_READONLY_ENV] =
|
||||||
ORIGINAL_CLAUDE_REVIEWER_READONLY;
|
ORIGINAL_CLAUDE_REVIEWER_READONLY;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import crypto from 'crypto';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import { buildPairedReadonlyRuntimeEnvOverrides } from 'ejclaw-runners-shared';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ARBITER_DEADLOCK_THRESHOLD,
|
ARBITER_DEADLOCK_THRESHOLD,
|
||||||
ARBITER_AGENT_TYPE,
|
ARBITER_AGENT_TYPE,
|
||||||
@@ -337,14 +339,14 @@ export function preparePairedExecutionContext(args: {
|
|||||||
);
|
);
|
||||||
fs.mkdirSync(reviewerSessionDir, { recursive: true });
|
fs.mkdirSync(reviewerSessionDir, { recursive: true });
|
||||||
envOverrides.CLAUDE_CONFIG_DIR = reviewerSessionDir;
|
envOverrides.CLAUDE_CONFIG_DIR = reviewerSessionDir;
|
||||||
if (unsafeHostPairedMode) {
|
Object.assign(
|
||||||
envOverrides.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
|
envOverrides,
|
||||||
if (REVIEWER_AGENT_TYPE === 'claude-code') {
|
buildPairedReadonlyRuntimeEnvOverrides({
|
||||||
envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY = '1';
|
role: 'reviewer',
|
||||||
}
|
agentType: REVIEWER_AGENT_TYPE,
|
||||||
} else {
|
unsafeHostPairedMode,
|
||||||
envOverrides.EJCLAW_REVIEWER_RUNTIME = '1';
|
}),
|
||||||
}
|
);
|
||||||
} else if (roomRoleContext.role === 'arbiter') {
|
} else if (roomRoleContext.role === 'arbiter') {
|
||||||
const arbiterSessionDir = path.join(
|
const arbiterSessionDir = path.join(
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
@@ -356,11 +358,14 @@ export function preparePairedExecutionContext(args: {
|
|||||||
fs.rmSync(arbiterSessionDir, { recursive: true, force: true });
|
fs.rmSync(arbiterSessionDir, { recursive: true, force: true });
|
||||||
fs.mkdirSync(arbiterSessionDir, { recursive: true });
|
fs.mkdirSync(arbiterSessionDir, { recursive: true });
|
||||||
envOverrides.CLAUDE_CONFIG_DIR = arbiterSessionDir;
|
envOverrides.CLAUDE_CONFIG_DIR = arbiterSessionDir;
|
||||||
if (unsafeHostPairedMode) {
|
Object.assign(
|
||||||
envOverrides.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
|
envOverrides,
|
||||||
} else {
|
buildPairedReadonlyRuntimeEnvOverrides({
|
||||||
envOverrides.EJCLAW_ARBITER_RUNTIME = '1';
|
role: 'arbiter',
|
||||||
}
|
agentType: ARBITER_AGENT_TYPE ?? REVIEWER_AGENT_TYPE,
|
||||||
|
unsafeHostPairedMode,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user