fix(workspace-trust): canonicalize git worktree trust roots

This commit is contained in:
777genius 2026-05-25 21:28:41 +03:00
parent 86e700f031
commit 63e16d1043
7 changed files with 350 additions and 7 deletions

View file

@ -25,5 +25,9 @@ export { FileClaudeStateProbe } from './adapters/output/ClaudeStateProbe';
export { NodePtyProcessAdapter } from './adapters/output/NodePtyProcessAdapter';
export { FileTempEmptyMcpConfigStore } from './adapters/output/TempEmptyMcpConfigStore';
export { createWorkspaceTrustCoordinator } from './composition/createWorkspaceTrustCoordinator';
export {
resolveWorkspaceTrustCanonicalGitRoot,
resolveWorkspaceTrustFilesystemGitRoot,
} from './infrastructure/WorkspaceTrustCanonicalGitRoot';
export { resolveWorkspaceTrustFeatureFlags } from './infrastructure/WorkspaceTrustFeatureFlags';
export { buildWorkspaceTrustPreflightEnv } from './infrastructure/workspaceTrustPreflightEnv';

View file

@ -0,0 +1,92 @@
import fs from 'node:fs/promises';
import path from 'node:path';
async function realpathOrNull(value: string): Promise<string | null> {
try {
return await fs.realpath(value);
} catch {
return null;
}
}
async function readTrimmedFileOrNull(filePath: string): Promise<string | null> {
try {
const value = await fs.readFile(filePath, 'utf8');
return value.trim();
} catch {
return null;
}
}
export async function resolveWorkspaceTrustFilesystemGitRoot(cwd: string): Promise<string | null> {
let current = path.resolve(cwd).normalize('NFC');
const root = path.parse(current).root;
try {
const cwdStat = await fs.stat(current);
if (!cwdStat.isDirectory()) {
return null;
}
} catch {
return null;
}
while (true) {
try {
const stat = await fs.stat(path.join(current, '.git'));
if (stat.isDirectory() || stat.isFile()) {
return current;
}
} catch {
// Keep walking until the filesystem root.
}
if (current === root) {
return null;
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
export async function resolveWorkspaceTrustCanonicalGitRoot(gitRoot: string): Promise<string> {
const normalizedGitRoot = path.resolve(gitRoot).normalize('NFC');
const gitFileContent = await readTrimmedFileOrNull(path.join(normalizedGitRoot, '.git'));
if (!gitFileContent?.startsWith('gitdir:')) {
return normalizedGitRoot;
}
const worktreeGitDir = path
.resolve(normalizedGitRoot, gitFileContent.slice('gitdir:'.length).trim())
.normalize('NFC');
const commonDirRaw = await readTrimmedFileOrNull(path.join(worktreeGitDir, 'commondir'));
if (!commonDirRaw) {
return normalizedGitRoot;
}
const commonDir = path.resolve(worktreeGitDir, commonDirRaw).normalize('NFC');
// Guard against a repo borrowing another trusted repo's worktree metadata.
if (path.resolve(path.dirname(worktreeGitDir)) !== path.join(commonDir, 'worktrees')) {
return normalizedGitRoot;
}
const gitdirBacklink = await readTrimmedFileOrNull(path.join(worktreeGitDir, 'gitdir'));
if (!gitdirBacklink) {
return normalizedGitRoot;
}
const [backlink, realGitRoot] = await Promise.all([
realpathOrNull(gitdirBacklink),
realpathOrNull(normalizedGitRoot),
]);
if (!backlink || !realGitRoot || backlink !== path.join(realGitRoot, '.git')) {
return normalizedGitRoot;
}
return (path.basename(commonDir) === '.git' ? path.dirname(commonDir) : commonDir).normalize(
'NFC'
);
}

View file

@ -36,7 +36,9 @@ import {
budgetWorkspaceTrustDiagnosticsManifest,
buildWorkspaceTrustPathCandidates,
buildWorkspaceTrustPreflightEnv,
resolveWorkspaceTrustCanonicalGitRoot,
resolveWorkspaceTrustFeatureFlags,
resolveWorkspaceTrustFilesystemGitRoot,
type WorkspaceTrustArgsOnlyPlanRequest,
type WorkspaceTrustArgsOnlyPlanResult,
type WorkspaceTrustCoordinator,
@ -3764,12 +3766,12 @@ export class TeamProvisioningService {
return [...providers];
}
private resolveWorkspaceTrustGitRoot(cwd: string): Promise<string | null> {
private async resolveWorkspaceTrustGitRoot(cwd: string): Promise<string | null> {
const normalizedCwd = cwd.trim();
if (!normalizedCwd) {
return Promise.resolve(null);
return null;
}
return new Promise((resolve) => {
const gitRoot = await new Promise<string | null>((resolve) => {
execFile(
'git',
['-C', normalizedCwd, 'rev-parse', '--show-toplevel'],
@ -3789,6 +3791,7 @@ export class TeamProvisioningService {
}
);
});
return gitRoot ?? resolveWorkspaceTrustFilesystemGitRoot(normalizedCwd);
}
private async collectWorkspaceTrustWorkspaces(input: {
@ -3807,9 +3810,10 @@ export class TeamProvisioningService {
let gitRoot = gitRootCache.get(cwd);
if (gitRoot === undefined) {
const resolvedGitRoot = await this.resolveWorkspaceTrustGitRoot(cwd);
gitRoot = resolvedGitRoot
const realGitRoot = resolvedGitRoot
? await fs.promises.realpath(resolvedGitRoot).catch(() => resolvedGitRoot)
: null;
gitRoot = realGitRoot ? await resolveWorkspaceTrustCanonicalGitRoot(realGitRoot) : null;
gitRootCache.set(cwd, gitRoot);
}
candidates.push(

View file

@ -1,5 +1,3 @@
import { describe, expect, it } from 'vitest';
import {
ClaudePtyWorkspaceTrustStrategy,
DefaultWorkspaceTrustCoordinator,
@ -9,9 +7,11 @@ import {
} from '@features/workspace-trust/core/application';
import {
buildWorkspaceTrustPathCandidates,
readCodexWorkspaceTrustConfigOverridesFromSettings,
type WorkspaceTrustDiagnosticStrategyResult,
type WorkspaceTrustWorkspace,
} from '@features/workspace-trust/core/domain';
import { describe, expect, it } from 'vitest';
const featureFlags = {
enabled: true,
@ -33,6 +33,18 @@ function workspace(): WorkspaceTrustWorkspace {
})[0];
}
function codexTrustOverrides(args: string[]): string[] {
const overrides: string[] = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] === '--settings' && typeof args[index + 1] === 'string') {
overrides.push(
...readCodexWorkspaceTrustConfigOverridesFromSettings(JSON.parse(args[index + 1]))
);
}
}
return overrides;
}
class RecordingClaudeStrategy extends ClaudePtyWorkspaceTrustStrategy {
active = 0;
maxActive = 0;
@ -83,6 +95,31 @@ describe('WorkspaceTrustCoordinator', () => {
expect(plan.launchArgPatches[0].args.join(' ')).toContain('agent_teams_workspace_trust');
});
it('includes canonical git root overrides in Codex trust settings for worktree candidates', async () => {
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
const plan = await coordinator.planFull({
providers: ['codex'],
workspaces: buildWorkspaceTrustPathCandidates({
cwd: '/tmp/generated-worktrees/alice',
realCwd: '/private/tmp/generated-worktrees/alice',
gitRoot: '/Users/belief/project',
source: 'member-worktree',
memberId: 'alice',
platform: 'posix',
}),
featureFlags,
});
const overrides = codexTrustOverrides(plan.launchArgPatches[0].args);
expect(overrides).toEqual(
expect.arrayContaining([
'projects."/tmp/generated-worktrees/alice".trust_level="trusted"',
'projects."/private/tmp/generated-worktrees/alice".trust_level="trusted"',
'projects."/Users/belief/project".trust_level="trusted"',
])
);
});
it('does not emit Codex settings patches for Anthropic-only launches', async () => {
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
const plan = await coordinator.planArgsOnly({
@ -94,6 +131,23 @@ describe('WorkspaceTrustCoordinator', () => {
expect(plan.launchArgPatches).toEqual([]);
});
it('does not emit Codex workspace-trust patches for OpenCode-only launches', async () => {
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
const plan = await coordinator.planArgsOnly({
providers: ['opencode'],
workspaces: buildWorkspaceTrustPathCandidates({
cwd: '/tmp/generated-worktrees/alice',
gitRoot: '/Users/belief/project',
source: 'member-worktree',
memberId: 'alice',
platform: 'posix',
}),
featureFlags,
});
expect(plan.launchArgPatches).toEqual([]);
});
it('limits Codex settings patches to requested target surfaces', async () => {
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
const plan = await coordinator.planArgsOnly({

View file

@ -0,0 +1,109 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
resolveWorkspaceTrustCanonicalGitRoot,
resolveWorkspaceTrustFilesystemGitRoot,
} from '@features/workspace-trust/main';
import { afterEach, describe, expect, it } from 'vitest';
let tmpDir: string | null = null;
async function makeTmpDir(): Promise<string> {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'workspace-trust-git-root-'));
return tmpDir;
}
async function createSyntheticWorktree(input: {
repoDir: string;
worktreeDir: string;
name: string;
}): Promise<void> {
const worktreeGitDir = path.join(input.repoDir, '.git', 'worktrees', input.name);
await fs.mkdir(input.worktreeDir, { recursive: true });
await fs.mkdir(worktreeGitDir, { recursive: true });
await fs.writeFile(path.join(input.worktreeDir, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf8');
await fs.writeFile(path.join(worktreeGitDir, 'commondir'), '../..\n', 'utf8');
await fs.writeFile(
path.join(worktreeGitDir, 'gitdir'),
`${path.join(input.worktreeDir, '.git')}\n`,
'utf8'
);
}
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
describe('resolveWorkspaceTrustCanonicalGitRoot', () => {
it('finds a git root from nested paths without spawning git', async () => {
const dir = await makeTmpDir();
const repoDir = path.join(dir, 'repo');
const nestedDir = path.join(repoDir, 'packages', 'app');
await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
await fs.mkdir(nestedDir, { recursive: true });
await expect(resolveWorkspaceTrustFilesystemGitRoot(nestedDir)).resolves.toBe(repoDir);
});
it('does not infer a git root from a missing path', async () => {
const dir = await makeTmpDir();
const repoDir = path.join(dir, 'repo');
const missingDir = path.join(repoDir, 'packages', 'missing');
await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
await expect(resolveWorkspaceTrustFilesystemGitRoot(missingDir)).resolves.toBeNull();
});
it('resolves a valid git worktree to the canonical repository root', async () => {
const dir = await makeTmpDir();
const repoDir = path.join(dir, 'repo');
const worktreeDir = path.join(dir, 'worktrees', 'alice');
await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
await createSyntheticWorktree({ repoDir, worktreeDir, name: 'alice' });
await expect(resolveWorkspaceTrustCanonicalGitRoot(worktreeDir)).resolves.toBe(repoDir);
});
it('does not accept a forged gitdir pointer to another repository', async () => {
const dir = await makeTmpDir();
const trustedRepoDir = path.join(dir, 'trusted-repo');
const forgedDir = path.join(dir, 'forged');
await fs.mkdir(path.join(trustedRepoDir, '.git'), { recursive: true });
await fs.mkdir(forgedDir, { recursive: true });
await fs.writeFile(
path.join(forgedDir, '.git'),
`gitdir: ${path.join(trustedRepoDir, '.git')}\n`,
'utf8'
);
await expect(resolveWorkspaceTrustCanonicalGitRoot(forgedDir)).resolves.toBe(forgedDir);
});
it('does not accept borrowed worktree metadata without a backlink', async () => {
const dir = await makeTmpDir();
const trustedRepoDir = path.join(dir, 'trusted-repo');
const forgedDir = path.join(dir, 'forged');
const borrowedWorktreeGitDir = path.join(trustedRepoDir, '.git', 'worktrees', 'alice');
await fs.mkdir(path.join(trustedRepoDir, '.git'), { recursive: true });
await fs.mkdir(forgedDir, { recursive: true });
await fs.mkdir(borrowedWorktreeGitDir, { recursive: true });
await fs.writeFile(
path.join(forgedDir, '.git'),
`gitdir: ${borrowedWorktreeGitDir}\n`,
'utf8'
);
await fs.writeFile(path.join(borrowedWorktreeGitDir, 'commondir'), '../..\n', 'utf8');
await fs.writeFile(
path.join(borrowedWorktreeGitDir, 'gitdir'),
`${path.join(trustedRepoDir, '.git')}\n`,
'utf8'
);
await expect(resolveWorkspaceTrustCanonicalGitRoot(forgedDir)).resolves.toBe(forgedDir);
});
});

View file

@ -161,6 +161,47 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
);
});
it('launches isolated worktrees with the member worktree as the OpenCode project path', async () => {
const worktreePath = '/tmp/generated-worktrees/alice';
const launchOpenCodeTeam = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
>(async () => successfulOpenCodeLaunchData());
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-worktree')),
launchOpenCodeTeam,
});
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
const result = await adapter.launch(
launchInput({
cwd: worktreePath,
expectedMembers: [
{
name: 'alice',
providerId: 'opencode',
model: 'openai/gpt-5.4-mini',
cwd: worktreePath,
isolation: 'worktree',
},
],
})
);
expect(result.teamLaunchState).toBe('clean_success');
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({
projectPath: worktreePath,
selectedModel: 'openai/gpt-5.4-mini',
requireExecutionProbe: true,
});
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
expect.objectContaining({
projectPath: worktreePath,
expectedCapabilitySnapshotId: 'cap-worktree',
members: [expect.objectContaining({ name: 'alice' })],
})
);
});
it('retries transient MCP readiness transport failures before prepare succeeds', async () => {
const firstReadiness = readiness({
state: 'mcp_unavailable',

View file

@ -1,4 +1,7 @@
import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/main';
import {
buildWorkspaceTrustPathCandidates,
type WorkspaceTrustWorkspace,
} from '@features/workspace-trust/main';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import { promises as fsPromises } from 'fs';
@ -23095,6 +23098,42 @@ describe('TeamProvisioningService', () => {
).toEqual(['claude', 'codex', 'gemini', 'opencode']);
});
it('uses the canonical repository root for workspace trust git worktree candidates', async () => {
const svc = new TeamProvisioningService();
const harness = svc as unknown as {
collectWorkspaceTrustWorkspaces(input: {
cwd: string;
members: Array<{ name: string; cwd: string; isolation: 'worktree' }>;
}): Promise<WorkspaceTrustWorkspace[]>;
};
const tempRoot = fs.realpathSync(tempClaudeRoot);
const repoDir = path.join(tempRoot, 'repo');
const worktreeDir = path.join(tempRoot, 'worktrees', 'alice');
const worktreeGitDir = path.join(repoDir, '.git', 'worktrees', 'alice');
fs.mkdirSync(worktreeDir, { recursive: true });
fs.mkdirSync(worktreeGitDir, { recursive: true });
fs.writeFileSync(path.join(worktreeDir, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf8');
fs.writeFileSync(path.join(worktreeGitDir, 'commondir'), '../..\n', 'utf8');
fs.writeFileSync(path.join(worktreeGitDir, 'gitdir'), `${path.join(worktreeDir, '.git')}\n`, 'utf8');
const workspaces = await harness.collectWorkspaceTrustWorkspaces({
cwd: repoDir,
members: [{ name: 'alice', cwd: worktreeDir, isolation: 'worktree' }],
});
const memberWorktrees = workspaces.filter(
(workspace) => workspace.source === 'member-worktree'
);
expect(memberWorktrees[0]).toMatchObject({
cwd: worktreeDir,
gitRootConfigKey: repoDir,
memberId: 'alice',
});
expect(memberWorktrees.every((workspace) => workspace.gitRootConfigKey === repoDir)).toBe(
true
);
});
it('degrades workspace trust planning failures without blocking launch preparation', async () => {
const svc = new TeamProvisioningService();
const workspaces = buildWorkspaceTrustPathCandidates({