feat: add paired review workspace flow

This commit is contained in:
Eyejoker
2026-03-28 21:14:25 +09:00
parent 79a8a2639e
commit e7f49d77da
19 changed files with 2719 additions and 20 deletions

View File

@@ -23,6 +23,10 @@ import {
prependRoomRoleHeader,
type RoomRoleContext,
} from './room-role-context.js';
import {
buildReviewerGitGuardEnv,
isReviewerRuntime,
} from './reviewer-runtime.js';
// ── Types ──────────────────────────────────────────────────────────
@@ -358,9 +362,13 @@ async function runAppServerSession(
containerInput: ContainerInput,
prompt: string,
): Promise<void> {
const reviewerRuntime =
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
isReviewerRuntime(containerInput.roomRoleContext);
const clientEnv = buildReviewerGitGuardEnv(process.env, reviewerRuntime);
const client = new CodexAppServerClient({
cwd: EFFECTIVE_CWD,
env: process.env,
env: clientEnv,
log,
});

View File

@@ -0,0 +1,105 @@
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';
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 {
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();
}
export function buildReviewerGitGuardEnv(
baseEnv: NodeJS.ProcessEnv,
reviewerRuntime: boolean,
): NodeJS.ProcessEnv {
if (!reviewerRuntime) {
return baseEnv;
}
const realGitPath = resolveGitBinary(baseEnv);
const wrapperDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-reviewer-git-'),
);
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)}
blocked_subcommands=(${blocked})
subcmd=""
skip_next=0
for arg in "$@"; do
if [[ "$skip_next" == "1" ]]; then
skip_next=0
continue
fi
case "$arg" in
-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
for blocked in "\${blocked_subcommands[@]}"; do
if [[ "$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,
PATH: `${wrapperDir}:${baseEnv.PATH || ''}`,
};
}

View File

@@ -0,0 +1,54 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it } from 'vitest';
import {
buildReviewerGitGuardEnv,
isReviewerRuntime,
} from '../src/reviewer-runtime.js';
describe('codex reviewer runtime guard', () => {
it('detects reviewer room metadata', () => {
expect(
isReviewerRuntime({
serviceId: 'codex-review',
role: 'reviewer',
ownerServiceId: 'codex-main',
reviewerServiceId: 'codex-review',
failoverOwner: false,
}),
).toBe(true);
});
it('prepends a git wrapper to PATH for reviewer runtimes', () => {
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
expect(env.PATH).toContain('ejclaw-reviewer-git-');
expect(env.EJCLAW_REAL_GIT).toBeTruthy();
});
it('blocks mutating git subcommands even when git options come first', () => {
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-reviewer-test-'));
try {
execFileSync('git', ['-c', 'color.ui=false', 'commit', '-m', 'x'], {
cwd,
env,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
throw new Error('expected git wrapper to block commit');
} catch (error) {
const stderr =
error instanceof Error && 'stderr' in error
? String((error as Error & { stderr?: string | Buffer }).stderr ?? '')
: '';
expect(stderr).toContain(
'EJClaw reviewer runtime blocks mutating git subcommands: commit',
);
}
});
});