Fix reviewer canonical repo mounts
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -76,6 +77,25 @@ function makeGroup(): RegisteredGroup {
|
||||
};
|
||||
}
|
||||
|
||||
function initGitRepo(repoDir: string): void {
|
||||
fs.mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync('git', ['init'], {
|
||||
cwd: repoDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], {
|
||||
cwd: repoDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
execFileSync('git', ['config', 'user.name', 'EJClaw Test'], {
|
||||
cwd: repoDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
describe('container-runner path compatibility', () => {
|
||||
const previousCodeXHome = process.env.CODEX_HOME;
|
||||
const previousOauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
@@ -143,6 +163,41 @@ describe('container-runner path compatibility', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('mounts a local absolute origin target and shadows its .env', () => {
|
||||
const group = makeGroup();
|
||||
const canonicalRepoDir = path.join(TEST_ROOT, 'canonical');
|
||||
const ownerWorkspaceDir = path.join(TEST_ROOT, 'workspace', 'owner');
|
||||
initGitRepo(canonicalRepoDir);
|
||||
initGitRepo(ownerWorkspaceDir);
|
||||
execFileSync('git', ['remote', 'add', 'origin', canonicalRepoDir], {
|
||||
cwd: ownerWorkspaceDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
fs.writeFileSync(path.join(canonicalRepoDir, '.env'), 'CANONICAL=1\n');
|
||||
fs.mkdirSync(path.join(TEST_GROUPS_DIR, group.folder), { recursive: true });
|
||||
fs.mkdirSync(path.join(TEST_DATA_DIR, 'ipc', group.folder), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const mounts = buildReviewerMounts(group, ownerWorkspaceDir);
|
||||
|
||||
expect(mounts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
hostPath: canonicalRepoDir,
|
||||
containerPath: canonicalRepoDir,
|
||||
readonly: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
hostPath: '/dev/null',
|
||||
containerPath: path.join(canonicalRepoDir, '.env'),
|
||||
readonly: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps dynamic workdir and role env on docker exec instead of baking them into container creation', () => {
|
||||
const createArgs = buildCreateArgs([], 'ejclaw-reviewer-brain');
|
||||
const createText = createArgs.join(' ');
|
||||
|
||||
@@ -85,6 +85,82 @@ function pushMountOnce(mounts: VolumeMount[], mount: VolumeMount): void {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLocalGitPath(remoteUrl: string): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
}
|
||||
if (path.isAbsolute(remoteUrl)) {
|
||||
return path.resolve(remoteUrl);
|
||||
}
|
||||
if (remoteUrl.startsWith('file://')) {
|
||||
try {
|
||||
const parsed = new URL(remoteUrl);
|
||||
if (parsed.protocol === 'file:') {
|
||||
return path.resolve(parsed.pathname);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLocalOriginTarget(workspaceDir: string): string | null {
|
||||
try {
|
||||
const remoteUrl = execFileSync(
|
||||
'git',
|
||||
['config', '--get', 'remote.origin.url'],
|
||||
{
|
||||
cwd: workspaceDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 5000,
|
||||
},
|
||||
).trim();
|
||||
const localPath = normalizeLocalGitPath(remoteUrl);
|
||||
if (!localPath || !fs.existsSync(localPath)) {
|
||||
return null;
|
||||
}
|
||||
return localPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pushWorktreeGitMetadataMounts(
|
||||
mounts: VolumeMount[],
|
||||
repoDir: string,
|
||||
): void {
|
||||
const dotGitPath = path.join(repoDir, '.git');
|
||||
try {
|
||||
const stat = fs.statSync(dotGitPath);
|
||||
if (!stat.isFile()) {
|
||||
return;
|
||||
}
|
||||
const content = fs.readFileSync(dotGitPath, 'utf-8').trim();
|
||||
const match = content.match(/^gitdir:\s*(.+)$/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
const worktreeGitDir = path.resolve(repoDir, match[1]);
|
||||
const parentGitDir = path.resolve(worktreeGitDir, '..', '..');
|
||||
if (!fs.existsSync(parentGitDir)) {
|
||||
return;
|
||||
}
|
||||
pushMountOnce(mounts, {
|
||||
hostPath: parentGitDir,
|
||||
containerPath: parentGitDir,
|
||||
readonly: true,
|
||||
});
|
||||
logger.debug(
|
||||
{ parentGitDir, worktreeGitDir, repoDir },
|
||||
'Mounting parent .git for worktree resolution',
|
||||
);
|
||||
} catch {
|
||||
// Not a git repo or .git missing — skip
|
||||
}
|
||||
}
|
||||
|
||||
// ── pnpm store detection ─────────────────────────────────────────
|
||||
|
||||
function detectPnpmStorePath(workspaceDir: string): string | null {
|
||||
@@ -282,6 +358,18 @@ export function buildReviewerMounts(
|
||||
containerPath: PRIMARY_PROJECT_MOUNT,
|
||||
readonly: true,
|
||||
});
|
||||
const canonicalRepoDir = resolveLocalOriginTarget(ownerWorkspaceDir);
|
||||
if (
|
||||
canonicalRepoDir &&
|
||||
canonicalRepoDir !== ownerWorkspaceDir &&
|
||||
canonicalRepoDir !== PRIMARY_PROJECT_MOUNT
|
||||
) {
|
||||
pushMountOnce(mounts, {
|
||||
hostPath: canonicalRepoDir,
|
||||
containerPath: canonicalRepoDir,
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
// Compatibility mount: expose the owner workspace at the same absolute path
|
||||
// inside the container so owner-authored absolute paths still resolve.
|
||||
if (ownerWorkspaceDir !== PRIMARY_PROJECT_MOUNT) {
|
||||
@@ -295,32 +383,9 @@ export function buildReviewerMounts(
|
||||
// Git worktree support: worktree's .git file references the parent repo's
|
||||
// .git directory via absolute path. Mount the parent .git at the same host
|
||||
// path so git commands resolve inside the container.
|
||||
const dotGitPath = path.join(ownerWorkspaceDir, '.git');
|
||||
try {
|
||||
const stat = fs.statSync(dotGitPath);
|
||||
if (stat.isFile()) {
|
||||
// Worktree: .git is a file containing "gitdir: /path/to/.git/worktrees/name"
|
||||
const content = fs.readFileSync(dotGitPath, 'utf-8').trim();
|
||||
const match = content.match(/^gitdir:\s*(.+)$/);
|
||||
if (match) {
|
||||
const worktreeGitDir = path.resolve(ownerWorkspaceDir, match[1]);
|
||||
// worktreeGitDir = /parent/.git/worktrees/name → parent .git = ../../
|
||||
const parentGitDir = path.resolve(worktreeGitDir, '..', '..');
|
||||
if (fs.existsSync(parentGitDir)) {
|
||||
pushMountOnce(mounts, {
|
||||
hostPath: parentGitDir,
|
||||
containerPath: parentGitDir,
|
||||
readonly: true,
|
||||
});
|
||||
logger.debug(
|
||||
{ parentGitDir, worktreeGitDir },
|
||||
'Mounting parent .git for worktree resolution',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not a git repo or .git missing — skip
|
||||
pushWorktreeGitMetadataMounts(mounts, ownerWorkspaceDir);
|
||||
if (canonicalRepoDir) {
|
||||
pushWorktreeGitMetadataMounts(mounts, canonicalRepoDir);
|
||||
}
|
||||
|
||||
// pnpm global store: mount at the same host path so hardlinks resolve.
|
||||
@@ -336,18 +401,23 @@ export function buildReviewerMounts(
|
||||
|
||||
// Shadow .env so reviewer cannot read secrets from mounted project
|
||||
const envFile = path.join(ownerWorkspaceDir, '.env');
|
||||
const shadowPaths = new Set<string>();
|
||||
if (fs.existsSync(envFile)) {
|
||||
const shadowPaths = new Set<string>([
|
||||
path.join(PRIMARY_PROJECT_MOUNT, '.env'),
|
||||
path.join(ownerWorkspaceDir, '.env'),
|
||||
]);
|
||||
for (const shadowPath of shadowPaths) {
|
||||
pushMountOnce(mounts, {
|
||||
hostPath: '/dev/null',
|
||||
containerPath: shadowPath,
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
shadowPaths.add(path.join(PRIMARY_PROJECT_MOUNT, '.env'));
|
||||
shadowPaths.add(path.join(ownerWorkspaceDir, '.env'));
|
||||
}
|
||||
const canonicalEnvFile = canonicalRepoDir
|
||||
? path.join(canonicalRepoDir, '.env')
|
||||
: null;
|
||||
if (canonicalEnvFile && fs.existsSync(canonicalEnvFile)) {
|
||||
shadowPaths.add(canonicalEnvFile);
|
||||
}
|
||||
for (const shadowPath of shadowPaths) {
|
||||
pushMountOnce(mounts, {
|
||||
hostPath: '/dev/null',
|
||||
containerPath: shadowPath,
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Attachments directory: read-only (Discord file uploads downloaded here)
|
||||
|
||||
Reference in New Issue
Block a user