refactor: decompose runtime coordination and runner internals (PR6)

This commit is contained in:
ejclaw
2026-04-10 22:48:49 +09:00
parent e5bd721ac4
commit 497e6dec74
23 changed files with 1987 additions and 1804 deletions

View File

@@ -8,6 +8,7 @@
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"ejclaw-runners-shared": "file:../shared",
"zod": "^4.0.0",
},
"devDependencies": {
@@ -97,6 +98,8 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"ejclaw-runners-shared": ["ejclaw-runners-shared@file:../shared", { "devDependencies": { "@types/node": "^22.10.7", "typescript": "^5.7.3" } }],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],

View File

@@ -13,6 +13,7 @@
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"ejclaw-runners-shared": "file:../shared",
"zod": "^4.0.0"
},
"devDependencies": {

View File

@@ -1,47 +1,22 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { RoomRoleContext } from './room-role-context.js';
export {
assertReadonlyWorkspaceRepoConnectivity,
buildReviewerGitGuardEnv,
isReviewerRuntime,
} from 'ejclaw-runners-shared';
export type ClaudeReadonlySandboxMode = 'strict' | 'best-effort';
const BLOCKED_GIT_SUBCOMMANDS = new Set([
'add',
'am',
'apply',
'branch',
'checkout',
'cherry-pick',
'clean',
'commit',
'merge',
'push',
'rebase',
'reset',
'restore',
'stash',
'switch',
'tag',
'worktree',
]);
const MUTATING_SHELL_PATTERNS = [
/\bsed\s+-i\b/i,
/\bperl\s+-i\b/i,
/(^|[;&|])\s*(cat|echo|printf)\b[^#\n]*>>?/i,
];
export function isReviewerRuntime(
roomRoleContext?: RoomRoleContext,
): boolean {
if (process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1') {
return false;
}
return roomRoleContext?.role === 'reviewer';
}
export function isClaudeReadonlyReviewerRuntime(
roomRoleContext?: RoomRoleContext,
): boolean {
@@ -107,11 +82,13 @@ export function buildClaudeReadonlySandboxSettings(
platform,
),
) {
const normalizedPaths = [...new Set(
protectedPaths
.filter((value): value is string => Boolean(value))
.map((value) => path.resolve(value)),
)];
const normalizedPaths = [
...new Set(
protectedPaths
.filter((value): value is string => Boolean(value))
.map((value) => path.resolve(value)),
),
];
return {
enabled: true,
@@ -119,236 +96,11 @@ export function buildClaudeReadonlySandboxSettings(
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem:
normalizedPaths.length > 0
? { denyWrite: normalizedPaths }
: undefined,
normalizedPaths.length > 0 ? { denyWrite: normalizedPaths } : undefined,
};
}
function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
return execFileSync('bash', ['-lc', 'command -v git'], {
encoding: 'utf-8',
env: baseEnv,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function createExecutableWrapperDir(baseEnv: NodeJS.ProcessEnv): string {
const candidateRoots = [
baseEnv.EJCLAW_REVIEWER_GIT_WRAPPER_ROOT,
baseEnv.HOME ? path.join(baseEnv.HOME, '.ejclaw-reviewer-runtime') : null,
path.join(os.tmpdir(), '.ejclaw-reviewer-runtime'),
].filter((value): value is string => Boolean(value));
const probeContents = '#!/usr/bin/env bash\nexit 0\n';
const tried = new Set<string>();
for (const candidateRoot of candidateRoots) {
if (tried.has(candidateRoot)) {
continue;
}
tried.add(candidateRoot);
try {
fs.mkdirSync(candidateRoot, { recursive: true });
const wrapperDir = fs.mkdtempSync(
path.join(candidateRoot, 'ejclaw-reviewer-git-'),
);
const probePath = path.join(wrapperDir, 'probe');
fs.writeFileSync(probePath, probeContents, { mode: 0o755 });
execFileSync(probePath, [], {
stdio: ['ignore', 'ignore', 'ignore'],
});
fs.rmSync(probePath, { force: true });
return wrapperDir;
} catch {
continue;
}
}
throw new Error(
'Unable to create an executable git guard wrapper directory for reviewer runtime.',
);
}
export function buildReviewerGitGuardEnv(
baseEnv: NodeJS.ProcessEnv,
reviewerRuntime: boolean,
): NodeJS.ProcessEnv {
if (!reviewerRuntime) {
return baseEnv;
}
const realGitPath = resolveGitBinary(baseEnv);
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
const wrapperDir = createExecutableWrapperDir(baseEnv);
const wrapperPath = path.join(wrapperDir, 'git');
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
.map((value) => `'${value}'`)
.join(' ');
const script = `#!/usr/bin/env bash
set -euo pipefail
real_git=${JSON.stringify(realGitPath)}
protected_work_dir=${JSON.stringify(protectedWorkDir)}
blocked_subcommands=(${blocked})
subcmd=""
skip_next=0
target_dir="$(pwd -P)"
capture_next_dir=0
for arg in "$@"; do
if [[ "$capture_next_dir" == "1" ]]; then
if [[ "$arg" == /* ]]; then
target_dir="$arg"
else
target_dir="$target_dir/$arg"
fi
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
capture_next_dir=0
continue
fi
if [[ "$skip_next" == "1" ]]; then
skip_next=0
continue
fi
case "$arg" in
-C)
capture_next_dir=1
continue
;;
-C*)
target_dir="\${arg#-C}"
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
continue
;;
--work-tree)
capture_next_dir=1
continue
;;
--work-tree=*)
target_dir="\${arg#--work-tree=}"
target_dir="$(cd "$target_dir" 2>/dev/null && pwd -P || printf '%s' "$target_dir")"
continue
;;
-c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
skip_next=1
continue
;;
-c*|-C*|--git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
continue
;;
--*)
continue
;;
-*)
continue
;;
*)
subcmd="$arg"
break
;;
esac
done
is_protected_target=0
if [[ -n "$protected_work_dir" ]]; then
protected_real="$(cd "$protected_work_dir" 2>/dev/null && pwd -P || printf '%s' "$protected_work_dir")"
case "$target_dir" in
"$protected_real"|"$protected_real"/*)
is_protected_target=1
;;
esac
fi
for blocked in "\${blocked_subcommands[@]}"; do
if [[ "$is_protected_target" == "1" && "$subcmd" == "$blocked" ]]; then
echo "EJClaw reviewer runtime blocks mutating git subcommands: $subcmd" >&2
exit 1
fi
done
exec "$real_git" "$@"
`;
fs.writeFileSync(wrapperPath, script, { mode: 0o755 });
return {
...baseEnv,
EJCLAW_REAL_GIT: realGitPath,
EJCLAW_PROTECTED_WORK_DIR: protectedWorkDir,
PATH: `${wrapperDir}:${baseEnv.PATH || ''}`,
};
}
function readGitOutput(
args: string[],
baseEnv: NodeJS.ProcessEnv,
cwd: string,
): string {
return execFileSync('git', args, {
cwd,
env: baseEnv,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function isRemoteGitOrigin(originUrl: string): boolean {
return (
/^(?:https?|ssh|git):\/\//i.test(originUrl) ||
/^[^/\\]+@[^:]+:.+/.test(originUrl)
);
}
export function assertReadonlyWorkspaceRepoConnectivity(
baseEnv: NodeJS.ProcessEnv,
enabled: boolean,
): void {
if (!enabled) {
return;
}
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
if (!protectedWorkDir) {
return;
}
let originUrl = '';
try {
originUrl = readGitOutput(
['config', '--get', 'remote.origin.url'],
baseEnv,
protectedWorkDir,
);
} catch {
return;
}
if (isRemoteGitOrigin(originUrl)) {
return;
}
if (!path.isAbsolute(originUrl) || !fs.existsSync(originUrl)) {
throw new Error(
`EJClaw readonly runtime cannot access local git origin path: ${originUrl || '(missing)'}`,
);
}
try {
readGitOutput(['rev-parse', '--git-dir'], baseEnv, originUrl);
} catch {
throw new Error(
`EJClaw readonly runtime origin path is not mounted as a git repository: ${originUrl}`,
);
}
try {
readGitOutput(['ls-remote', 'origin', 'HEAD'], baseEnv, protectedWorkDir);
} catch {
throw new Error(
`EJClaw readonly runtime cannot resolve local git origin from ${protectedWorkDir}. Check canonical repo mount for ${originUrl}.`,
);
}
}
export function isReviewerMutatingShellCommand(command: string): boolean {
const normalized = command.trim();
return (
MUTATING_SHELL_PATTERNS.some((pattern) => pattern.test(normalized))
);
return MUTATING_SHELL_PATTERNS.some((pattern) => pattern.test(normalized));
}

View File

@@ -1,23 +1,4 @@
export interface RoomRoleContext {
serviceId: string;
role: 'owner' | 'reviewer';
ownerServiceId: string;
reviewerServiceId: string;
failoverOwner: boolean;
}
export function prependRoomRoleHeader(
prompt: string,
roomRoleContext?: RoomRoleContext,
): string {
if (!roomRoleContext) {
return prompt;
}
const header =
`[ROOM_ROLE self=${roomRoleContext.serviceId} role=${roomRoleContext.role} ` +
`owner=${roomRoleContext.ownerServiceId} reviewer=${roomRoleContext.reviewerServiceId} ` +
`failover=${roomRoleContext.failoverOwner ? 1 : 0}]`;
return `${header}\n\n${prompt}`;
}
export {
prependRoomRoleHeader,
type RoomRoleContext,
} from 'ejclaw-runners-shared';