From 860bcc106b30ec16127de915e9684b17359a03f7 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 29 Sep 2026 18:29:20 -0400 Subject: [PATCH 1/2] fix(codex): trust the path Codex checks for bare-repo worktrees Codex keys a linked worktree's trust on the main checkout only when that checkout's .git leads back to the common git dir; otherwise (bare repo, --separate-git-dir) it keys on the worktree itself. Orca always wrote the main-checkout key, so Codex showed its trust prompt and worker-start failed at agent_readiness. Mirror trust.rs exactly, and pin it with a real-binary contract that runs in the existing Codex contract CI job. Fixes #23847 --- .github/workflows/pr.yml | 12 ++ ...odex-index-heal-contract-workflow.test.mjs | 13 ++ config/scripts/pr-code-change-scope.mjs | 3 +- config/scripts/pr-code-change-scope.test.mjs | 5 + src/main/agent-trust-presets.test.ts | 159 +++++++++++++++--- src/main/agent-trust-presets.ts | 45 +++-- 6 files changed, 199 insertions(+), 38 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c961f525a38d..da6a06326a3d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -485,6 +485,18 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/main/pty/codex-no-daemon-binary-contract.test.ts + # Why the no-daemon install: it is the newest pinned Codex, and trust.rs is + # what decides whether a worktree launch stops at the trust prompt. + - name: Verify Codex project-trust contract + env: + ORCA_CODEX_TRUST_CONTRACT_REQUIRED: '1' + ORCA_CODEX_TRUST_CONTRACT_VERSION: ${{ env.CODEX_NO_DAEMON_CLI_VERSION }} + run: | + set -euo pipefail + ORCA_CODEX_TRUST_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli-no-daemon/node_modules/.bin/codex" \ + pnpm exec vitest run --config config/vitest.config.ts \ + src/main/agent-trust-presets.test.ts + xterm_patch_sync: name: xterm patch sync needs: [code_paths] diff --git a/config/scripts/codex-index-heal-contract-workflow.test.mjs b/config/scripts/codex-index-heal-contract-workflow.test.mjs index 096c4b876a8a..505151ae5af4 100644 --- a/config/scripts/codex-index-heal-contract-workflow.test.mjs +++ b/config/scripts/codex-index-heal-contract-workflow.test.mjs @@ -51,4 +51,17 @@ describe('Codex index-heal contract PR gate', () => { ) expect(verify.run).toContain('src/main/pty/codex-no-daemon-binary-contract.test.ts') }) + + it('pins the project-trust contract to the no-daemon Codex and fails when it is missing', () => { + const verify = job.steps.find((step) => step.name === 'Verify Codex project-trust contract') + + expect(verify.env.ORCA_CODEX_TRUST_CONTRACT_VERSION).toBe( + '${{ env.CODEX_NO_DAEMON_CLI_VERSION }}' + ) + expect(verify.env.ORCA_CODEX_TRUST_CONTRACT_REQUIRED).toBe('1') + expect(verify.run).toContain( + 'ORCA_CODEX_TRUST_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli-no-daemon/node_modules/.bin/codex"' + ) + expect(verify.run).toContain('src/main/agent-trust-presets.test.ts') + }) }) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 9fef4ed19a0c..838146a8b35a 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -54,8 +54,9 @@ const GIT_COMPAT_PREFIXES = [ // Why narrow: the contract pins Codex's read-repair, so it runs when the heal that // depends on it, its app-server transport, or the contract itself changes. The same -// job pins --no-daemon for Orca's codex shell wrapper. +// job pins --no-daemon for Orca's codex shell wrapper and the project-trust key. const CODEX_INDEX_HEAL_CONTRACT_PREFIXES = [ + 'src/main/agent-trust-presets', 'src/main/pty/codex-no-daemon-binary-contract', 'src/main/pty/codex-shell-launch-preflight', 'src/main/codex/codex-index-heal-binary-contract', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 1981623d2816..7567ac26cab2 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -153,6 +153,11 @@ describe('per-job path classification', () => { expectClassification(['src/main/codex/codex-index-heal-binary-contract.test.ts'], { codex_index_heal_contract: true }) + expectClassification(['src/main/agent-trust-presets.ts'], { + codex_index_heal_contract: true, + package: true, + package_windows: true + }) // Keep the real-binary gate live when a transport or launch dependency changes. for (const file of [ 'src/main/codex/codex-app-server-capability-signal.ts', diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 5e7521ae4687..1dc3a88dc2df 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -11,6 +11,8 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcessSync } from '../shared/child-process/run-process' +import { runCodexAppServerSession } from './codex/codex-app-server-session' const testState = { fakeHomeDir: '', @@ -47,6 +49,20 @@ const { const { runExclusivelyForCodexTrustConfig } = await import('./codex/codex-trust-config-mutation-queue') +// Why: fixture tests pin Orca's half; only the real binary proves Codex reads the key +// Orca writes. CI sets REQUIRED so a missing binary fails instead of skipping. +const codexTrustContract = { + binary: process.env.ORCA_CODEX_TRUST_CONTRACT_BINARY, + version: process.env.ORCA_CODEX_TRUST_CONTRACT_VERSION +} +describe.runIf( + process.env.ORCA_CODEX_TRUST_CONTRACT_REQUIRED === '1' && !codexTrustContract.binary +)('codex project-trust contract prerequisites', () => { + it('was given a Codex binary to run against', () => { + expect.fail('ORCA_CODEX_TRUST_CONTRACT_REQUIRED=1 but no binary was given') + }) +}) + beforeEach(() => { testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-')) testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-user-data-')) @@ -237,38 +253,123 @@ describe('markCodexProjectTrusted', () => { } }) - it('trusts the main repository root for a linked worktree without reading commondir', async () => { - const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-linked-ws-')) - const repository = join(fixtureRoot, 'repo') - const workspace = join(fixtureRoot, 'worktrees', 'feature') - const worktreeGitDir = join(repository, '.git', 'worktrees', 'feature') - try { - mkdirSync(worktreeGitDir, { recursive: true }) - mkdirSync(workspace, { recursive: true }) - writeFileSync(join(workspace, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf-8') - writeFileSync(join(worktreeGitDir, 'gitdir'), join(workspace, '.git'), 'utf-8') + // Why: the key must be the one Codex's trust.rs looks up, which is the main + // checkout only when that checkout's .git leads back to the common git dir. + describe('linked worktree trust key', { timeout: 120_000 }, () => { + let fixtureRoot = '' + beforeEach(() => { + fixtureRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'orca-codex-layout-'))) + }) + afterEach(() => { + rmSync(fixtureRoot, { recursive: true, force: true }) + }) - await markCodexProjectTrusted(workspace) + function git(cwd: string, ...args: string[]): void { + const result = runProcessSync({ + program: 'git', + args: ['-c', 'user.name=Orca', '-c', 'user.email=orca@example.com', ...args], + cwd + }) + expect(result.code, result.stderr).toBe(0) + } - const repositoryRoot = realpathSync.native(repository) - const workspaceRoot = realpathSync.native(workspace) - const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml') - const runtimeConfigPath = join( - testState.userDataDir, - 'codex-runtime-home', - 'home', - 'config.toml' + function initRepoWithCommit(repo: string, ...initArgs: string[]): void { + mkdirSync(repo, { recursive: true }) + git(repo, 'init', '-q', ...initArgs) + git(repo, '-c', 'commit.gpgsign=false', 'commit', '-q', '--allow-empty', '-m', 'init') + } + + async function expectTrustKey(workspace: string, expected: string): Promise { + await markCodexProjectTrusted(workspace) + const runtimeHome = join(testState.userDataDir, 'codex-runtime-home', 'home') + const [system, runtime] = [ + join(testState.fakeHomeDir, '.codex', 'config.toml'), + join(runtimeHome, 'config.toml') + ].map((path) => + [...readFileSync(path, 'utf-8').matchAll(/^\[projects\."(.*)"\]$/gm)].map((m) => + m[1].replaceAll('\\\\', '\\') + ) ) - for (const written of [ - readFileSync(configPath, 'utf-8'), - readFileSync(runtimeConfigPath, 'utf-8') - ]) { - expect(written).toContain(`[projects."${escapeTomlBasicString(repositoryRoot)}"]`) - expect(written).not.toContain(`[projects."${escapeTomlBasicString(workspaceRoot)}"]`) + expect(system).toEqual([expected]) + expect(runtime).toEqual([expected]) + if (codexTrustContract.binary) { + expect(await codexSandboxFor(workspace, runtimeHome)).toBe('workspaceWrite') + const emptyHome = join(fixtureRoot, 'empty-codex-home') + mkdirSync(emptyHome, { recursive: true }) + expect(await codexSandboxFor(workspace, emptyHome)).toBe('readOnly') } - } finally { - rmSync(fixtureRoot, { recursive: true, force: true }) } + + // Why thread/start: its default sandbox and the TUI trust prompt read the same + // project lookup, so workspaceWrite (not readOnly) means no prompt. + async function codexSandboxFor(cwd: string, codexHome: string): Promise { + const binary = codexTrustContract.binary! + const version = runProcessSync({ program: binary, args: ['--version'] }) + expect(version.stdout.trim()).toBe(`codex-cli ${codexTrustContract.version}`) + return runCodexAppServerSession( + { + command: binary, + args: ['-c', 'features.plugins=false', 'app-server'], + cliPath: binary, + env: { CODEX_HOME: codexHome, HOME: testState.fakeHomeDir }, + timeoutMs: 60_000 + }, + async (rpc) => { + const started = await rpc.request('thread/start', { cwd }) + const sandbox = isRecord(started) ? started.sandbox : undefined + return isRecord(sandbox) ? sandbox.type : undefined + } + ) + } + + it('trusts the main checkout of a standard repository', async () => { + const repo = join(fixtureRoot, 'repo') + initRepoWithCommit(repo) + git(repo, 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) + await expectTrustKey(join(fixtureRoot, 'wt'), repo) + }) + + it('trusts the main checkout when its .git points at a sibling .bare dir', async () => { + const source = join(fixtureRoot, 'source') + const project = join(fixtureRoot, 'project') + initRepoWithCommit(source) + git(fixtureRoot, 'clone', '-q', '--bare', source, join(project, '.bare')) + writeFileSync(join(project, '.git'), 'gitdir: ./.bare\n', 'utf-8') + git(project, 'worktree', 'add', '-q', join(project, 'wt')) + await expectTrustKey(join(project, 'wt'), project) + }) + + it('trusts the worktree itself for a bare repository', async () => { + const source = join(fixtureRoot, 'source') + const bare = join(fixtureRoot, 'proj.git') + initRepoWithCommit(source) + git(fixtureRoot, 'clone', '-q', '--bare', source, bare) + git(bare, 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) + await expectTrustKey(join(fixtureRoot, 'wt'), join(fixtureRoot, 'wt')) + }) + + it('trusts the worktree itself for a --separate-git-dir repository', async () => { + const repo = join(fixtureRoot, 'repo') + initRepoWithCommit(repo, `--separate-git-dir=${join(fixtureRoot, 'repo.git')}`) + git(repo, 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) + await expectTrustKey(join(fixtureRoot, 'wt'), join(fixtureRoot, 'wt')) + }) + + it('trusts the worktree itself when the main .git points at another repository', async () => { + const source = join(fixtureRoot, 'source') + initRepoWithCommit(source) + git(fixtureRoot, 'clone', '-q', '--bare', source, join(fixtureRoot, 'proj.git')) + git(fixtureRoot, 'clone', '-q', '--bare', source, join(fixtureRoot, 'other.git')) + writeFileSync(join(fixtureRoot, '.git'), 'gitdir: ./other.git\n', 'utf-8') + git(join(fixtureRoot, 'proj.git'), 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) + await expectTrustKey(join(fixtureRoot, 'wt'), join(fixtureRoot, 'wt')) + }) + + it('trusts a plain folder workspace itself', async () => { + const folder = join(fixtureRoot, 'folder') + mkdirSync(folder) + await expectTrustKey(folder, folder) + }) }) it('does not broaden trust through arbitrary or adversarial Git metadata', async () => { @@ -381,3 +482,7 @@ describe('markCodexProjectTrusted', () => { function escapeTomlBasicString(value: string): string { return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"') } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index d84fc68a4680..b307fa102ef7 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' import { writeFileAtomically } from './codex-accounts/fs-utils' @@ -179,18 +179,17 @@ export function markCodexProjectTrusted(workspacePath: string): Promise { ) } +// Mirrors resolve_root_git_project_for_trust in codex-rs/git-utils/src/trust.rs: +// when it returns no repo root, Codex keys trust on the workspace itself. function resolveCodexProjectTrustRoot(workspacePath: string): string { const absPath = canonicalize(workspacePath) try { - const gitDirReference = readFileSync(join(absPath, '.git'), 'utf-8').trim() - if (!gitDirReference.startsWith('gitdir:')) { - return absPath - } - const gitDirPath = gitDirReference.slice('gitdir:'.length).trim() + const workspaceGitFile = join(absPath, '.git') + const gitDirPath = readGitDirPointer(workspaceGitFile) if (!gitDirPath) { return absPath } - const gitDir = resolve(absPath, gitDirPath) + const gitDir = canonicalize(gitDirPath) const worktreesDir = dirname(gitDir) if (basename(worktreesDir) !== 'worktrees') { return absPath @@ -201,20 +200,46 @@ function resolveCodexProjectTrustRoot(workspacePath: string): string { return absPath } const resolvedBacklink = resolve(gitDir, gitDirBacklink) - const workspaceGitFile = join(absPath, '.git') if ( resolvedBacklink !== workspaceGitFile && canonicalize(resolvedBacklink) !== canonicalize(workspaceGitFile) ) { return absPath } - // Why: mirror Codex's validated .git/worktrees/ traversal instead of trusting arbitrary commondir contents. - return canonicalize(dirname(dirname(worktreesDir))) + const commonDir = dirname(worktreesDir) + const commonDirReference = readFileSync(join(gitDir, 'commondir'), 'utf-8').trim() + if (!commonDirReference || canonicalize(resolve(gitDir, commonDirReference)) !== commonDir) { + return absPath + } + // Why: Codex only trusts the main checkout when its own .git leads back to the + // common dir; bare repos and --separate-git-dir layouts key on the worktree. + const mainRoot = dirname(dirname(dirname(gitDirPath))) + const mainDotGit = join(mainRoot, '.git') + const mainGitDir = statSync(mainDotGit).isDirectory() + ? mainDotGit + : readGitDirPointer(mainDotGit) + if (!mainGitDir || canonicalize(mainGitDir) !== commonDir) { + return absPath + } + return canonicalize(mainRoot) } catch { return absPath } } +/** Resolves a `gitdir:` pointer file; like Codex, symlinked pointers are ignored. */ +function readGitDirPointer(dotGitPath: string): string | null { + if (!lstatSync(dotGitPath).isFile()) { + return null + } + const reference = readFileSync(dotGitPath, 'utf-8').trim() + if (!reference.startsWith('gitdir:')) { + return null + } + const target = reference.slice('gitdir:'.length).trim() + return target ? resolve(dirname(dotGitPath), target) : null +} + function canonicalize(p: string): string { // Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but // both Cursor and Copilot's trust comparators run realpath() before the From 3315c333e61aefbcf327b3332e53d458d732795b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 29 Sep 2026 19:32:13 -0400 Subject: [PATCH 2/2] fix(codex): trust the worktree path itself instead of mirroring trust.rs Codex looks up the cwd's own [projects] entry before any repo root (config_toml.rs get_active_project, loader decision_for_dir), so trusting the workspace realpath satisfies every git layout. Drops the resolve_root_git_project_for_trust mirror: simpler, cannot drift from Codex, and never widens trust past the folder Orca launched in. Cost is one config entry per worktree; entries older Orca wrote on main checkouts stay valid. The six real-git layout tests now assert the workspace key and, under the contract, that real Codex starts workspaceWrite for each. The contract probes the binary version once and fails at load when required but missing; its CI path filter now includes config-toml-trust. --- .github/workflows/pr.yml | 4 +- config/scripts/pr-code-change-scope.mjs | 1 + config/scripts/pr-code-change-scope.test.mjs | 12 +-- src/main/agent-trust-presets.test.ts | 85 ++++++------------- src/main/agent-trust-presets.ts | 67 +-------------- src/main/agent-workspace-trust.test.ts | 1 - .../execution-host-workspace-trust.test.ts | 30 +++---- src/main/execution-host-workspace-trust.ts | 13 +-- src/main/workspace-trust-test-fixtures.ts | 1 - src/relay/agent-workspace-trust-spawn.test.ts | 23 +---- 10 files changed, 58 insertions(+), 179 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index da6a06326a3d..5e06fc6869e2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -485,8 +485,8 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/main/pty/codex-no-daemon-binary-contract.test.ts - # Why the no-daemon install: it is the newest pinned Codex, and trust.rs is - # what decides whether a worktree launch stops at the trust prompt. + # Why the no-daemon install: it is the newest pinned Codex, and its project + # lookup decides whether a worktree launch stops at the trust prompt. - name: Verify Codex project-trust contract env: ORCA_CODEX_TRUST_CONTRACT_REQUIRED: '1' diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 838146a8b35a..1aba99b11697 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -57,6 +57,7 @@ const GIT_COMPAT_PREFIXES = [ // job pins --no-daemon for Orca's codex shell wrapper and the project-trust key. const CODEX_INDEX_HEAL_CONTRACT_PREFIXES = [ 'src/main/agent-trust-presets', + 'src/main/codex/config-toml-trust', 'src/main/pty/codex-no-daemon-binary-contract', 'src/main/pty/codex-shell-launch-preflight', 'src/main/codex/codex-index-heal-binary-contract', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 7567ac26cab2..6232c9a4b8e7 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -153,11 +153,13 @@ describe('per-job path classification', () => { expectClassification(['src/main/codex/codex-index-heal-binary-contract.test.ts'], { codex_index_heal_contract: true }) - expectClassification(['src/main/agent-trust-presets.ts'], { - codex_index_heal_contract: true, - package: true, - package_windows: true - }) + for (const file of ['src/main/agent-trust-presets.ts', 'src/main/codex/config-toml-trust.ts']) { + expectClassification([file], { + codex_index_heal_contract: true, + package: true, + package_windows: true + }) + } // Keep the real-binary gate live when a transport or launch dependency changes. for (const file of [ 'src/main/codex/codex-app-server-capability-signal.ts', diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 5eb99ad69188..c18499a5ae22 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -10,7 +10,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcessSync } from '../shared/child-process/run-process' import { runCodexAppServerSession } from './codex/codex-app-server-session' @@ -56,13 +56,9 @@ const codexTrustContract = { binary: process.env.ORCA_CODEX_TRUST_CONTRACT_BINARY, version: process.env.ORCA_CODEX_TRUST_CONTRACT_VERSION } -describe.runIf( - process.env.ORCA_CODEX_TRUST_CONTRACT_REQUIRED === '1' && !codexTrustContract.binary -)('codex project-trust contract prerequisites', () => { - it('was given a Codex binary to run against', () => { - expect.fail('ORCA_CODEX_TRUST_CONTRACT_REQUIRED=1 but no binary was given') - }) -}) +if (process.env.ORCA_CODEX_TRUST_CONTRACT_REQUIRED === '1' && !codexTrustContract.binary) { + throw new Error('ORCA_CODEX_TRUST_CONTRACT_REQUIRED=1 but no Codex binary was given') +} beforeEach(() => { testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-')) @@ -254,10 +250,16 @@ describe('markCodexProjectTrusted', () => { } }) - // Why: the key must be the one Codex's trust.rs looks up, which is the main - // checkout only when that checkout's .git leads back to the common git dir. + // Why: Codex checks the cwd's own entry before any repo root, so the workspace key + // must satisfy it whatever the git layout. describe('linked worktree trust key', { timeout: 120_000 }, () => { let fixtureRoot = '' + beforeAll(() => { + if (codexTrustContract.binary) { + const version = runProcessSync({ program: codexTrustContract.binary, args: ['--version'] }) + expect(version.stdout.trim()).toBe(`codex-cli ${codexTrustContract.version}`) + } + }) beforeEach(() => { fixtureRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'orca-codex-layout-'))) }) @@ -280,7 +282,7 @@ describe('markCodexProjectTrusted', () => { git(repo, '-c', 'commit.gpgsign=false', 'commit', '-q', '--allow-empty', '-m', 'init') } - async function expectTrustKey(workspace: string, expected: string): Promise { + async function expectWorkspaceTrusted(workspace: string): Promise { await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) const runtimeHome = join(testState.userDataDir, 'codex-runtime-home', 'home') const [system, runtime] = [ @@ -291,8 +293,8 @@ describe('markCodexProjectTrusted', () => { m[1].replaceAll('\\\\', '\\') ) ) - expect(system).toEqual([expected]) - expect(runtime).toEqual([expected]) + expect(system).toEqual([workspace]) + expect(runtime).toEqual([workspace]) if (codexTrustContract.binary) { expect(await codexSandboxFor(workspace, runtimeHome)).toBe('workspaceWrite') const emptyHome = join(fixtureRoot, 'empty-codex-home') @@ -305,8 +307,6 @@ describe('markCodexProjectTrusted', () => { // project lookup, so workspaceWrite (not readOnly) means no prompt. async function codexSandboxFor(cwd: string, codexHome: string): Promise { const binary = codexTrustContract.binary! - const version = runProcessSync({ program: binary, args: ['--version'] }) - expect(version.stdout.trim()).toBe(`codex-cli ${codexTrustContract.version}`) return runCodexAppServerSession( { command: binary, @@ -323,87 +323,56 @@ describe('markCodexProjectTrusted', () => { ) } - it('trusts the main checkout of a standard repository', async () => { + it('trusts a linked worktree of a standard repository', async () => { const repo = join(fixtureRoot, 'repo') initRepoWithCommit(repo) git(repo, 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) - await expectTrustKey(join(fixtureRoot, 'wt'), repo) + await expectWorkspaceTrusted(join(fixtureRoot, 'wt')) }) - it('trusts the main checkout when its .git points at a sibling .bare dir', async () => { + it('trusts a worktree whose main checkout .git points at a sibling .bare dir', async () => { const source = join(fixtureRoot, 'source') const project = join(fixtureRoot, 'project') initRepoWithCommit(source) git(fixtureRoot, 'clone', '-q', '--bare', source, join(project, '.bare')) writeFileSync(join(project, '.git'), 'gitdir: ./.bare\n', 'utf-8') git(project, 'worktree', 'add', '-q', join(project, 'wt')) - await expectTrustKey(join(project, 'wt'), project) + await expectWorkspaceTrusted(join(project, 'wt')) }) - it('trusts the worktree itself for a bare repository', async () => { + it('trusts a worktree of a bare repository', async () => { const source = join(fixtureRoot, 'source') const bare = join(fixtureRoot, 'proj.git') initRepoWithCommit(source) git(fixtureRoot, 'clone', '-q', '--bare', source, bare) git(bare, 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) - await expectTrustKey(join(fixtureRoot, 'wt'), join(fixtureRoot, 'wt')) + await expectWorkspaceTrusted(join(fixtureRoot, 'wt')) }) - it('trusts the worktree itself for a --separate-git-dir repository', async () => { + it('trusts a worktree of a --separate-git-dir repository', async () => { const repo = join(fixtureRoot, 'repo') initRepoWithCommit(repo, `--separate-git-dir=${join(fixtureRoot, 'repo.git')}`) git(repo, 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) - await expectTrustKey(join(fixtureRoot, 'wt'), join(fixtureRoot, 'wt')) + await expectWorkspaceTrusted(join(fixtureRoot, 'wt')) }) - it('trusts the worktree itself when the main .git points at another repository', async () => { + it('trusts a worktree whose parent .git points at another repository', async () => { const source = join(fixtureRoot, 'source') initRepoWithCommit(source) git(fixtureRoot, 'clone', '-q', '--bare', source, join(fixtureRoot, 'proj.git')) git(fixtureRoot, 'clone', '-q', '--bare', source, join(fixtureRoot, 'other.git')) writeFileSync(join(fixtureRoot, '.git'), 'gitdir: ./other.git\n', 'utf-8') git(join(fixtureRoot, 'proj.git'), 'worktree', 'add', '-q', join(fixtureRoot, 'wt')) - await expectTrustKey(join(fixtureRoot, 'wt'), join(fixtureRoot, 'wt')) + await expectWorkspaceTrusted(join(fixtureRoot, 'wt')) }) - it('trusts a plain folder workspace itself', async () => { + it('trusts a plain folder workspace', async () => { const folder = join(fixtureRoot, 'folder') mkdirSync(folder) - await expectTrustKey(folder, folder) + await expectWorkspaceTrusted(folder) }) }) - it('does not broaden trust through arbitrary or adversarial Git metadata', async () => { - const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-untrusted-gitdir-')) - const workspace = join(fixtureRoot, 'workspace') - const arbitraryGitDir = join(fixtureRoot, 'metadata', 'feature') - const unrelatedRoot = join(fixtureRoot, 'unrelated') - try { - mkdirSync(arbitraryGitDir, { recursive: true }) - mkdirSync(workspace, { recursive: true }) - mkdirSync(unrelatedRoot, { recursive: true }) - writeFileSync(join(workspace, '.git'), `gitdir: ${arbitraryGitDir}\n`, 'utf-8') - writeFileSync(join(arbitraryGitDir, 'commondir'), join(unrelatedRoot, '.git'), 'utf-8') - - await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) - const structuredGitDir = join(unrelatedRoot, '.git', 'worktrees', 'feature') - mkdirSync(structuredGitDir, { recursive: true }) - writeFileSync(join(workspace, '.git'), `gitdir: ${structuredGitDir}\n`, 'utf-8') - writeFileSync(join(structuredGitDir, 'gitdir'), join(unrelatedRoot, '.git'), 'utf-8') - await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) - - const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf-8') - expect(written).toContain( - `[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]` - ) - expect(written).not.toContain( - `[projects."${escapeTomlBasicString(realpathSync.native(unrelatedRoot))}"]` - ) - } finally { - rmSync(fixtureRoot, { recursive: true, force: true }) - } - }) - it('writes ~/.codex/config.toml with the project marked trusted', async () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) try { diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 965162276c9a..ffd1ca6b89a7 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -1,6 +1,6 @@ -import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import { homedir } from 'node:os' -import { basename, dirname, join, resolve } from 'node:path' +import { join } from 'node:path' import { writeFileAtomically } from './codex-accounts/fs-utils' import { upsertProjectTrustLevel } from './codex/config-toml-trust' import { runExclusivelyForCodexTrustConfig } from './codex/codex-trust-config-mutation-queue' @@ -169,7 +169,8 @@ export function markCodexProjectTrusted( workspacePath: string, configFiles: readonly string[] ): Promise { - const absPath = resolveCodexProjectTrustRoot(workspacePath) + // Why: Codex checks the cwd's own entry before the repo root, so no git-layout logic is needed. + const absPath = canonicalize(workspacePath) // Why (#16441): hook installs now await a codex app-server grant, so an // unqueued write here can land inside their capture->restore window and be // reverted. Same runtime-before-system lock order the installer takes. @@ -184,66 +185,6 @@ export function markCodexProjectTrusted( return write() } -// Mirrors resolve_root_git_project_for_trust (codex-rs/git-utils/src/trust.rs); no repo root keys trust on the workspace. -export function resolveCodexProjectTrustRoot(workspacePath: string): string { - const absPath = canonicalize(workspacePath) - try { - const workspaceGitFile = join(absPath, '.git') - const gitDirPath = readGitDirPointer(workspaceGitFile) - if (!gitDirPath) { - return absPath - } - const gitDir = canonicalize(gitDirPath) - const worktreesDir = dirname(gitDir) - if (basename(worktreesDir) !== 'worktrees') { - return absPath - } - // Why: workspace-controlled .git metadata must not broaden trust without Git's reciprocal link. - const gitDirBacklink = readFileSync(join(gitDir, 'gitdir'), 'utf-8').trim() - if (!gitDirBacklink) { - return absPath - } - const resolvedBacklink = resolve(gitDir, gitDirBacklink) - if ( - resolvedBacklink !== workspaceGitFile && - canonicalize(resolvedBacklink) !== canonicalize(workspaceGitFile) - ) { - return absPath - } - const commonDir = dirname(worktreesDir) - const commonDirReference = readFileSync(join(gitDir, 'commondir'), 'utf-8').trim() - if (!commonDirReference || canonicalize(resolve(gitDir, commonDirReference)) !== commonDir) { - return absPath - } - // Why: Codex only trusts the main checkout when its own .git leads back to the - // common dir; bare repos and --separate-git-dir layouts key on the worktree. - const mainRoot = dirname(dirname(dirname(gitDirPath))) - const mainDotGit = join(mainRoot, '.git') - const mainGitDir = statSync(mainDotGit).isDirectory() - ? mainDotGit - : readGitDirPointer(mainDotGit) - if (!mainGitDir || canonicalize(mainGitDir) !== commonDir) { - return absPath - } - return canonicalize(mainRoot) - } catch { - return absPath - } -} - -/** Resolves a `gitdir:` pointer file; like Codex, symlinked pointers are ignored. */ -function readGitDirPointer(dotGitPath: string): string | null { - if (!lstatSync(dotGitPath).isFile()) { - return null - } - const reference = readFileSync(dotGitPath, 'utf-8').trim() - if (!reference.startsWith('gitdir:')) { - return null - } - const target = reference.slice('gitdir:'.length).trim() - return target ? resolve(dirname(dotGitPath), target) : null -} - function canonicalize(p: string): string { // Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but // both Cursor and Copilot's trust comparators run realpath() before the diff --git a/src/main/agent-workspace-trust.test.ts b/src/main/agent-workspace-trust.test.ts index 97960a205f93..2ed9d1d2a156 100644 --- a/src/main/agent-workspace-trust.test.ts +++ b/src/main/agent-workspace-trust.test.ts @@ -24,7 +24,6 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('./agent-trust-presets', () => ({ - resolveCodexProjectTrustRoot: (path: string) => path, markCodexProjectTrusted: mocks.codex, markCursorWorkspaceTrusted: mocks.cursor, markCopilotFolderTrusted: mocks.copilot, diff --git a/src/main/execution-host-workspace-trust.test.ts b/src/main/execution-host-workspace-trust.test.ts index 6fdb942b57b0..c544f351ce83 100644 --- a/src/main/execution-host-workspace-trust.test.ts +++ b/src/main/execution-host-workspace-trust.test.ts @@ -135,16 +135,7 @@ describe('applyWorkspaceTrustOnThisHost', () => { ) }) - it('trusts a Codex worktree whose main checkout is the home, as Codex would on "Yes"', async () => { - const worktree = join(root, 'worktrees', 'feature') - linkGitWorktree(state.home, worktree) - await applyWorkspaceTrustOnThisHost('codex', worktree, thisHost()) - expect(readFileSync(join(state.home, '.codex', 'config.toml'), 'utf-8')).toContain( - `[projects."${state.home}"]` - ) - }) - - it.each(PRESETS.filter((preset) => preset !== 'codex'))( + it.each(PRESETS)( 'trusts a worktree whose main checkout is the home for %s, which stores the worktree path', async (preset) => { const worktree = join(root, 'worktrees', 'feature') @@ -154,6 +145,15 @@ describe('applyWorkspaceTrustOnThisHost', () => { } ) + it("stores a Codex worktree's own path, not its main checkout", async () => { + const worktree = join(root, 'worktrees', 'feature') + linkGitWorktree(state.home, worktree) + await applyWorkspaceTrustOnThisHost('codex', worktree, thisHost()) + const written = readFileSync(join(state.home, '.codex', 'config.toml'), 'utf-8') + expect(written).toContain(`[projects."${worktree}"]`) + expect(written).not.toContain(`[projects."${state.home}"]`) + }) + it('trusts the exact subfolder Codex starts in, inside a folder that is not a repo', async () => { const subfolder = join(root, 'notes', 'sub') mkdirSync(subfolder, { recursive: true }) @@ -163,16 +163,6 @@ describe('applyWorkspaceTrustOnThisHost', () => { ) }) - it("trusts a worktree's main checkout for Codex when that checkout is not a home", async () => { - const mainCheckout = join(root, 'repo') - const worktree = join(root, 'worktrees', 'feature') - linkGitWorktree(mainCheckout, worktree) - await applyWorkspaceTrustOnThisHost('codex', worktree, thisHost()) - const written = readFileSync(join(state.home, '.codex', 'config.toml'), 'utf-8') - expect(written).toContain(`[projects."${mainCheckout}"]`) - expect(written).not.toContain(`[projects."${worktree}"]`) - }) - it.each(INHERITING_PRESETS)('writes no %s trust when the host knows no home', async (preset) => { const workspace = join(root, 'projects', 'app') mkdirSync(workspace, { recursive: true }) diff --git a/src/main/execution-host-workspace-trust.ts b/src/main/execution-host-workspace-trust.ts index 5664e63d6344..42f48e918cee 100644 --- a/src/main/execution-host-workspace-trust.ts +++ b/src/main/execution-host-workspace-trust.ts @@ -6,8 +6,7 @@ import { markAntigravityWorkspaceTrusted, markCodexProjectTrusted, markCopilotFolderTrusted, - markCursorWorkspaceTrusted, - resolveCodexProjectTrustRoot + markCursorWorkspaceTrusted } from './agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from './agent-trust-write-deadline' import { @@ -69,11 +68,6 @@ function withResolvedForm(path: string): string[] { } } -/** The path the preset's writer stores: Codex trusts a linked worktree's main checkout. */ -function storedTrustPath(preset: AgentTrustPreset, workspacePath: string): string { - return preset === 'codex' ? resolveCodexProjectTrustRoot(workspacePath) : workspacePath -} - /** * Whether trust stored for `storedPath` would cover a home: it is a root, a home or a folder * above one. Both sides are compared given and resolved, since the writers store the realpath. @@ -121,14 +115,13 @@ export async function applyWorkspaceTrustOnThisHost( ): Promise { try { const host = describeHost() - const storedPath = storedTrustPath(preset, workspacePath) if (AGENT_TRUST_INHERITS_FROM_A_HOME[preset]) { const homes = host.homes.filter((home): home is string => Boolean(home)) - if (homes.length === 0 || wouldTrustAHome(storedPath, homes)) { + if (homes.length === 0 || wouldTrustAHome(workspacePath, homes)) { return } } - await awaitAgentTrustWriteWithinDeadline(writePreset(preset, storedPath, host), { + await awaitAgentTrustWriteWithinDeadline(writePreset(preset, workspacePath, host), { preset, workspacePath, deadlineMs: host.deadlineMs diff --git a/src/main/workspace-trust-test-fixtures.ts b/src/main/workspace-trust-test-fixtures.ts index 79b2d919fd3a..273c14493299 100644 --- a/src/main/workspace-trust-test-fixtures.ts +++ b/src/main/workspace-trust-test-fixtures.ts @@ -29,5 +29,4 @@ export function linkGitWorktree(mainCheckout: string, worktree: string): void { mkdirSync(worktree, { recursive: true }) writeFileSync(join(worktree, '.git'), `gitdir: ${gitDir}\n`) writeFileSync(join(gitDir, 'gitdir'), join(worktree, '.git')) - writeFileSync(join(gitDir, 'commondir'), '../..\n') } diff --git a/src/relay/agent-workspace-trust-spawn.test.ts b/src/relay/agent-workspace-trust-spawn.test.ts index ca83e5a0ee17..bc970bdf2d24 100644 --- a/src/relay/agent-workspace-trust-spawn.test.ts +++ b/src/relay/agent-workspace-trust-spawn.test.ts @@ -128,22 +128,7 @@ describe('applyRelayAgentWorkspaceTrust', () => { expect(workspaceTrustWritten(home, 'codex')).toBe(false) }) - it("trusts a worktree's main checkout for Codex, as a local launch does", async () => { - const mainCheckout = join(root, 'repo') - const worktree = join(root, 'worktrees', 'feature') - linkGitWorktree(mainCheckout, worktree) - await applyRelayAgentWorkspaceTrust( - { workspacePath: worktree }, - 'codex', - { HOME: home }, - HOST_SHELL - ) - const written = readFileSync(join(home, '.codex', 'config.toml'), 'utf-8') - expect(written).toContain(`[projects."${mainCheckout}"]`) - expect(written).not.toContain(`[projects."${worktree}"]`) - }) - - it('trusts a Codex worktree whose main checkout is the relay home, as a local launch does', async () => { + it("stores a Codex worktree's own path, not the relay home it hangs off, as a local launch does", async () => { const worktree = join(root, 'worktrees', 'feature') linkGitWorktree(home, worktree) await applyRelayAgentWorkspaceTrust( @@ -152,9 +137,9 @@ describe('applyRelayAgentWorkspaceTrust', () => { { HOME: home }, HOST_SHELL ) - expect(readFileSync(join(home, '.codex', 'config.toml'), 'utf-8')).toContain( - `[projects."${home}"]` - ) + const written = readFileSync(join(home, '.codex', 'config.toml'), 'utf-8') + expect(written).toContain(`[projects."${worktree}"]`) + expect(written).not.toContain(`[projects."${home}"]`) }) it.each([