refactor: decompose runtime coordination and runner internals (PR6)
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"name": "ejclaw-codex-runner",
|
||||
"dependencies": {
|
||||
"@openai/codex": "^0.117.0",
|
||||
"ejclaw-runners-shared": "file:../shared",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
@@ -30,6 +31,8 @@
|
||||
|
||||
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||
|
||||
"ejclaw-runners-shared": ["ejclaw-runners-shared@file:../shared", { "devDependencies": { "@types/node": "^22.10.7", "typescript": "^5.7.3" } }],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"start": "bun dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ejclaw-runners-shared": "file:../shared",
|
||||
"@openai/codex": "^0.117.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,259 +1,9 @@
|
||||
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';
|
||||
|
||||
// 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.
|
||||
const BLOCKED_GIT_SUBCOMMANDS = new Set([
|
||||
'add',
|
||||
'am',
|
||||
'apply',
|
||||
'branch',
|
||||
'checkout',
|
||||
'cherry-pick',
|
||||
'clean',
|
||||
'commit',
|
||||
'merge',
|
||||
'push',
|
||||
'rebase',
|
||||
'reset',
|
||||
'restore',
|
||||
'stash',
|
||||
'switch',
|
||||
'tag',
|
||||
'worktree',
|
||||
]);
|
||||
|
||||
export function isReviewerRuntime(
|
||||
roomRoleContext?: RoomRoleContext,
|
||||
): boolean {
|
||||
if (process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1') {
|
||||
return false;
|
||||
}
|
||||
return roomRoleContext?.role === 'reviewer';
|
||||
}
|
||||
|
||||
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}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user