fix(workspace-trust): canonicalize git worktree trust roots
This commit is contained in:
parent
86e700f031
commit
63e16d1043
7 changed files with 350 additions and 7 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue