diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c961f525a38d..5e06fc6869e2 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 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' + 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..1aba99b11697 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -54,8 +54,10 @@ 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/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 1981623d2816..6232c9a4b8e7 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -153,6 +153,13 @@ describe('per-job path classification', () => { expectClassification(['src/main/codex/codex-index-heal-binary-contract.test.ts'], { codex_index_heal_contract: 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 c3f615f7bb60..c18499a5ae22 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -10,7 +10,9 @@ 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' const testState = { fakeHomeDir: '', @@ -48,6 +50,16 @@ const { runExclusivelyForCodexTrustConfig } = await import('./codex/codex-trust-config-mutation-queue') const { getLocalCodexTrustConfigFiles } = await import('./codex/codex-home-paths') +// 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 +} +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-')) testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-user-data-')) @@ -238,69 +250,127 @@ 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') - - await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) - - 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' - ) - 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)}"]`) + // 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}`) } - } finally { + }) + beforeEach(() => { + fixtureRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'orca-codex-layout-'))) + }) + afterEach(() => { rmSync(fixtureRoot, { recursive: true, force: true }) + }) + + 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) } - }) - 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') + 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 expectWorkspaceTrusted(workspace: string): Promise { 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))}"]` + 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('\\\\', '\\') + ) ) - expect(written).not.toContain( - `[projects."${escapeTomlBasicString(realpathSync.native(unrelatedRoot))}"]` + 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') + mkdirSync(emptyHome, { recursive: true }) + expect(await codexSandboxFor(workspace, emptyHome)).toBe('readOnly') + } + } + + // 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! + 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 + } ) - } finally { - rmSync(fixtureRoot, { recursive: true, force: true }) } + + 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 expectWorkspaceTrusted(join(fixtureRoot, 'wt')) + }) + + 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 expectWorkspaceTrusted(join(project, 'wt')) + }) + + 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 expectWorkspaceTrusted(join(fixtureRoot, 'wt')) + }) + + 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 expectWorkspaceTrusted(join(fixtureRoot, 'wt')) + }) + + 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 expectWorkspaceTrusted(join(fixtureRoot, 'wt')) + }) + + it('trusts a plain folder workspace', async () => { + const folder = join(fixtureRoot, 'folder') + mkdirSync(folder) + await expectWorkspaceTrusted(folder) + }) }) it('writes ~/.codex/config.toml with the project marked trusted', async () => { @@ -382,3 +452,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 22f6dcb72dd7..ffd1ca6b89a7 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -1,6 +1,6 @@ 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,43 +185,6 @@ export function markCodexProjectTrusted( return write() } -/** The folder Codex looks trust up under: a linked worktree's main checkout, else the realpath. */ -export 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() - if (!gitDirPath) { - return absPath - } - const gitDir = resolve(absPath, 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) - 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))) - } catch { - return absPath - } -} - 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/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([