From 63e16d1043f854186018864acc79a17b7d58178d Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 25 May 2026 21:28:41 +0300 Subject: [PATCH] fix(workspace-trust): canonicalize git worktree trust roots --- src/features/workspace-trust/main/index.ts | 4 + .../WorkspaceTrustCanonicalGitRoot.ts | 92 +++++++++++++++ .../services/team/TeamProvisioningService.ts | 12 +- .../core/WorkspaceTrustCoordinator.test.ts | 58 +++++++++- .../WorkspaceTrustCanonicalGitRoot.test.ts | 109 ++++++++++++++++++ .../team/OpenCodeTeamRuntimeAdapter.test.ts | 41 +++++++ .../team/TeamProvisioningService.test.ts | 41 ++++++- 7 files changed, 350 insertions(+), 7 deletions(-) create mode 100644 src/features/workspace-trust/main/infrastructure/WorkspaceTrustCanonicalGitRoot.ts create mode 100644 test/features/workspace-trust/main/WorkspaceTrustCanonicalGitRoot.test.ts diff --git a/src/features/workspace-trust/main/index.ts b/src/features/workspace-trust/main/index.ts index 9a5ab0f4..1ed6f3d0 100644 --- a/src/features/workspace-trust/main/index.ts +++ b/src/features/workspace-trust/main/index.ts @@ -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'; diff --git a/src/features/workspace-trust/main/infrastructure/WorkspaceTrustCanonicalGitRoot.ts b/src/features/workspace-trust/main/infrastructure/WorkspaceTrustCanonicalGitRoot.ts new file mode 100644 index 00000000..8f0c45ab --- /dev/null +++ b/src/features/workspace-trust/main/infrastructure/WorkspaceTrustCanonicalGitRoot.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +async function realpathOrNull(value: string): Promise { + try { + return await fs.realpath(value); + } catch { + return null; + } +} + +async function readTrimmedFileOrNull(filePath: string): Promise { + try { + const value = await fs.readFile(filePath, 'utf8'); + return value.trim(); + } catch { + return null; + } +} + +export async function resolveWorkspaceTrustFilesystemGitRoot(cwd: string): Promise { + 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 { + 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' + ); +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9da14501..86390c9b 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { + private async resolveWorkspaceTrustGitRoot(cwd: string): Promise { const normalizedCwd = cwd.trim(); if (!normalizedCwd) { - return Promise.resolve(null); + return null; } - return new Promise((resolve) => { + const gitRoot = await new Promise((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( diff --git a/test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts b/test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts index aa089aa0..271af68b 100644 --- a/test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts +++ b/test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts @@ -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({ diff --git a/test/features/workspace-trust/main/WorkspaceTrustCanonicalGitRoot.test.ts b/test/features/workspace-trust/main/WorkspaceTrustCanonicalGitRoot.test.ts new file mode 100644 index 00000000..739d9c9c --- /dev/null +++ b/test/features/workspace-trust/main/WorkspaceTrustCanonicalGitRoot.test.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 01788ce2..50e98f0d 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -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 + >(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', diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 7b43ddb7..35bb481a 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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; + }; + 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({