diff --git a/src/main/codex/codex-project-trust-ownership.test.ts b/src/main/codex/codex-project-trust-ownership.test.ts index 3ce3052f8a4..c1a6b99cc06 100644 --- a/src/main/codex/codex-project-trust-ownership.test.ts +++ b/src/main/codex/codex-project-trust-ownership.test.ts @@ -96,4 +96,53 @@ describe('Orca-created project trust lifecycle', () => { const ledger = readFileSync(join(userDataDir, 'codex-project-trust-created.json'), 'utf-8') expect(ledger).not.toContain(PROJECT) }) + + it('treats a malformed ledger as empty and never deletes on its say-so', async () => { + upsertOrcaCreatedProjectTrustLevel(managedConfigPath, PROJECT, 'trusted') + const ledgerPath = join(userDataDir, 'codex-project-trust-created.json') + const malformedLedgers = [ + '{"created": null}', + '{"created": []}', + '{"created": {"cfg.toml": "not-an-array"}}', + '{"created": {"cfg.toml": [42]}}', + `{"created": {"cfg.toml": [{"path": "${PROJECT}"}]}}`, + 'not json' + ] + + for (const malformed of malformedLedgers) { + writeFileSync(ledgerPath, malformed) + await expect(removeOrcaCreatedProjectTrustEntries(PROJECT)).resolves.toBeUndefined() + expect(readFileSync(managedConfigPath, 'utf-8')).toContain(`[projects."${PROJECT}"]`) + } + }) + + it('does not delete a table the user replaced after Orca created the original', async () => { + upsertOrcaCreatedProjectTrustLevel(managedConfigPath, PROJECT, 'trusted') + const replacement = [ + `[projects."${PROJECT}"]`, + 'trust_level = "trusted"', + 'followup = "user-added"', + '' + ].join('\n') + writeFileSync(managedConfigPath, replacement) + + await removeOrcaCreatedProjectTrustEntries(PROJECT) + + expect(readFileSync(managedConfigPath, 'utf-8')).toBe(replacement) + // The spent record is dropped, so a later pass cannot delete the user's table either. + await removeOrcaCreatedProjectTrustEntries(PROJECT) + expect(readFileSync(managedConfigPath, 'utf-8')).toBe(replacement) + const ledger = readFileSync(join(userDataDir, 'codex-project-trust-created.json'), 'utf-8') + expect(ledger).not.toContain(PROJECT) + }) + + it('treats a trust line rewritten after the grant as a table Orca no longer owns', async () => { + upsertOrcaCreatedProjectTrustLevel(managedConfigPath, PROJECT, 'trusted') + const rewritten = [`[projects."${PROJECT}"]`, 'trust_level = "untrusted"', ''].join('\n') + writeFileSync(managedConfigPath, rewritten) + + await removeOrcaCreatedProjectTrustEntries(PROJECT) + + expect(readFileSync(managedConfigPath, 'utf-8')).toBe(rewritten) + }) }) diff --git a/src/main/codex/codex-project-trust-ownership.ts b/src/main/codex/codex-project-trust-ownership.ts index 597e3631cb1..9beda5f8ead 100644 --- a/src/main/codex/codex-project-trust-ownership.ts +++ b/src/main/codex/codex-project-trust-ownership.ts @@ -8,29 +8,62 @@ import { normalizeCodexTrustProjectPath } from './codex-trust-identity' /** * Ownership records for the `[projects.""]` tables Orca's preflight trust * created, so a removed worktree's entry can be deleted without ever touching a - * table the user (or an older Orca build) had at that path. Same shape of - * bookkeeping as the managed-home resource-copy markers: Orca-side state that - * distinguishes what Orca wrote from what it only found. + * table the user (or an older Orca build) had at that path. Each record carries + * the exact bytes the grant wrote, so a table replaced afterwards is no longer + * Orca's to delete. Same shape of bookkeeping as the managed-home resource-copy + * markers: Orca-side state that distinguishes what Orca wrote from what it only + * found. */ const CREATED_PROJECT_TRUST_LEDGER_FILE = 'codex-project-trust-created.json' +type CreatedProjectTrustEntry = { + path: string + /** The table bytes the grant wrote; compared at removal modulo the trailing separator. */ + table: string +} + type CreatedProjectTrustLedger = { version: 1 - /** Config file path → project paths whose table Orca's grant created there. */ - created: Record + /** Config file path → the tables Orca's grant created there, with the bytes it wrote. */ + created: Record } function getLedgerPath(): string { return join(getOrcaUserDataPath(), CREATED_PROJECT_TRUST_LEDGER_FILE) } +function isCreatedTrustEntry(entry: unknown): entry is CreatedProjectTrustEntry { + return ( + typeof entry === 'object' && + entry !== null && + 'path' in entry && + typeof entry.path === 'string' && + 'table' in entry && + typeof entry.table === 'string' + ) +} + +function isCreatedTrustEntryMap( + value: unknown +): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + return Object.values(value).every( + (entries) => Array.isArray(entries) && entries.every(isCreatedTrustEntry) + ) +} + // Why: the ledger only decides what extra cleanup may happen, so any unreadable // state degrades to "nothing recorded", never to deleting an unrecorded entry. function readLedger(): CreatedProjectTrustLedger { try { - const parsed = readAgentStateJsonFileSync(getLedgerPath()) as Partial - return parsed && typeof parsed === 'object' && typeof parsed.created === 'object' + const parsed: unknown = readAgentStateJsonFileSync(getLedgerPath()) + return typeof parsed === 'object' && + parsed !== null && + 'created' in parsed && + isCreatedTrustEntryMap(parsed.created) ? { version: 1, created: parsed.created } : { version: 1, created: {} } } catch { @@ -44,47 +77,65 @@ function writeLedger(ledger: CreatedProjectTrustLedger): void { writeFileAtomically(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 }) } -function isRecordedProjectTrust(recorded: string, projectPath: string): boolean { - return normalizeCodexTrustProjectPath(recorded) === normalizeCodexTrustProjectPath(projectPath) +function isRecordedProjectTrust(recorded: CreatedProjectTrustEntry, projectPath: string): boolean { + return ( + normalizeCodexTrustProjectPath(recorded.path) === normalizeCodexTrustProjectPath(projectPath) + ) } -function hasRecord( +function findRecord( ledger: CreatedProjectTrustLedger, configPath: string, projectPath: string -): boolean { - return (ledger.created[configPath] ?? []).some((recorded) => +): CreatedProjectTrustEntry | undefined { + return (ledger.created[configPath] ?? []).find((recorded) => isRecordedProjectTrust(recorded, projectPath) ) } -/** Records that Orca's grant created the project table at `projectPath` in `configPath`. */ -export function recordOrcaCreatedProjectTrust(configPath: string, projectPath: string): void { +/** + * Records that Orca's grant created the project table at `projectPath` in + * `configPath`, vouching for exactly the `table` bytes it wrote. + */ +export function recordOrcaCreatedProjectTrust( + configPath: string, + projectPath: string, + table: string +): void { const ledger = readLedger() - if (hasRecord(ledger, configPath, projectPath)) { + if (findRecord(ledger, configPath, projectPath)) { return } - ledger.created[configPath] = [...(ledger.created[configPath] ?? []), projectPath] + ledger.created[configPath] = [...(ledger.created[configPath] ?? []), { path: projectPath, table }] writeLedger(ledger) } +/** The table bytes Orca's grant recorded for `projectPath` in `configPath`, if any. */ +export function getOrcaCreatedProjectTrustTable( + configPath: string, + projectPath: string +): string | null { + return findRecord(readLedger(), configPath, projectPath)?.table ?? null +} + /** Config files whose project table at `projectPath` was created by Orca's grant. */ export function listOrcaCreatedProjectTrustConfigFiles(projectPath: string): string[] { const ledger = readLedger() return Object.entries(ledger.created) - .filter(([, paths]) => paths.some((recorded) => isRecordedProjectTrust(recorded, projectPath))) + .filter(([, entries]) => + entries.some((recorded) => isRecordedProjectTrust(recorded, projectPath)) + ) .map(([configPath]) => configPath) } /** Drops the ownership record once the entry is gone (or its config file is). */ export function forgetOrcaCreatedProjectTrust(configPath: string, projectPath: string): void { const ledger = readLedger() - if (!hasRecord(ledger, configPath, projectPath)) { + const entries = ledger.created[configPath] ?? [] + const remaining = entries.filter((recorded) => !isRecordedProjectTrust(recorded, projectPath)) + if (remaining.length === entries.length) { return } - const remaining = (ledger.created[configPath] ?? []).filter( - (recorded) => !isRecordedProjectTrust(recorded, projectPath) - ) if (remaining.length === 0) { delete ledger.created[configPath] } else { diff --git a/src/main/codex/trust/toml/config-toml-project-trust.ts b/src/main/codex/trust/toml/config-toml-project-trust.ts index 95d941126be..001becab76c 100644 --- a/src/main/codex/trust/toml/config-toml-project-trust.ts +++ b/src/main/codex/trust/toml/config-toml-project-trust.ts @@ -54,15 +54,32 @@ export function hasProjectTrustEntry(content: string, projectPath: string): bool * bytes untouched: only whole tables go, never a sibling key or table. */ export function removeProjectTrustContent(content: string, projectPath: string): string { + return spliceProjectTrustTables(content, projectPath).updated +} + +/** + * The exact bytes removeProjectTrustContent would delete for the path, or null + * when no table exists — the ownership check compares this to what Orca wrote. + */ +export function findProjectTrustRemovedBytes(content: string, projectPath: string): string | null { + return spliceProjectTrustTables(content, projectPath).removed +} + +function spliceProjectTrustTables( + content: string, + projectPath: string +): { updated: string; removed: string | null } { let updated = content + let removed: string | null = null for (;;) { const headerLineEnd = findProjectHeaderLineEnd(updated, projectPath) if (headerLineEnd === null) { - return updated + return { updated, removed } } const headerLineStart = updated.lastIndexOf('\n', Math.max(0, headerLineEnd - 1)) + 1 const nextHeaderOffset = findNextTomlTableHeader(updated.slice(headerLineEnd)) const blockEnd = nextHeaderOffset === -1 ? updated.length : headerLineEnd + nextHeaderOffset + removed = (removed ?? '') + updated.slice(headerLineStart, blockEnd) updated = updated.slice(0, headerLineStart) + updated.slice(blockEnd) } } diff --git a/src/main/codex/trust/toml/config-toml-trust-project.test.ts b/src/main/codex/trust/toml/config-toml-trust-project.test.ts index cbb80e535b2..f99a04c3841 100644 --- a/src/main/codex/trust/toml/config-toml-trust-project.test.ts +++ b/src/main/codex/trust/toml/config-toml-trust-project.test.ts @@ -8,6 +8,8 @@ import { normalizeCodexTrustProjectRevocationPath as normalizeCodexProjectPathForRevocationLookup } from '../identity/codex-trust-identity' import { upsertProjectTrustContent as upsertProjectTrustLevelInContent } from './config-toml-project-trust' +import { hasProjectTrustEntry, removeProjectTrustContent } from './config-toml-project-trust' +import { escapeTomlBasicString as escapeTomlString } from '../../configuration/toml/config-toml-syntax' import { createTrustConfigFixture, removeTrustConfigFixture @@ -228,6 +230,127 @@ describe('upsertProjectTrustLevel', () => { }) }) +describe('removeProjectTrustContent', () => { + const ORIGINAL = [ + 'model = "gpt-5.5"', + '', + '[projects."/tmp/codex-ws"]', + 'trust_level = "trusted"', + '', + '[projects."/tmp/other"]', + 'trust_level = "untrusted"', + '', + '[profiles.default]', + 'sandbox_mode = "workspace-write"', + '' + ].join('\n') + + it('removes the whole table and keeps sibling tables and top-level keys byte-identical', () => { + const updated = removeProjectTrustContent(ORIGINAL, '/tmp/codex-ws') + + expect(updated).toBe( + [ + 'model = "gpt-5.5"', + '', + '[projects."/tmp/other"]', + 'trust_level = "untrusted"', + '', + '[profiles.default]', + 'sandbox_mode = "workspace-write"', + '' + ].join('\n') + ) + }) + + it('removes every duplicate table for the path (#22592) and leaves one table alone', () => { + const duplicated = [ + '[projects."/tmp/codex-ws"]', + 'trust_level = "trusted"', + '', + '[projects."/tmp/codex-ws"]', + 'trust_level = "untrusted"', + '', + '[projects."/tmp/other"]', + 'trust_level = "trusted"', + '' + ].join('\n') + + const updated = removeProjectTrustContent(duplicated, '/tmp/codex-ws') + + expect(updated).toBe(['[projects."/tmp/other"]', 'trust_level = "trusted"', ''].join('\n')) + }) + + it('removes a table at the start of the file and one at the end', () => { + const updated = removeProjectTrustContent( + ['[projects."/tmp/codex-ws"]', 'trust_level = "trusted"', ''].join('\n'), + '/tmp/codex-ws' + ) + expect(updated).toBe('') + + const trailing = removeProjectTrustContent( + [ + '[profiles.default]', + 'sandbox_mode = "off"', + '', + '[projects."/tmp/codex-ws"]', + 'trust_level = "trusted"' + ].join('\n'), + '/tmp/codex-ws' + ) + // Why: like the hook-trust removal, the separator above the table is not the table's byte. + expect(trailing).toBe(['[profiles.default]', 'sandbox_mode = "off"', '', ''].join('\n')) + }) + + it('preserves CRLF endings of the surviving content', () => { + const original = [ + '[projects."/tmp/codex-ws"]', + 'trust_level = "trusted"', + '', + '[projects."/tmp/other"]', + 'trust_level = "trusted"', + '' + ].join('\r\n') + + expect(removeProjectTrustContent(original, '/tmp/codex-ws')).toBe( + ['[projects."/tmp/other"]', 'trust_level = "trusted"', ''].join('\r\n') + ) + }) + + it('returns content unchanged when the path has no table', () => { + expect(removeProjectTrustContent(ORIGINAL, '/tmp/absent')).toBe(ORIGINAL) + }) + + it('removes a table whose header uses another recognised spelling', () => { + const original = [ + "[projects.'/tmp/codex-ws']", + 'trust_level = "trusted"', + '', + '[projects."/tmp/other"]', + '' + ].join('\n') + + expect(removeProjectTrustContent(original, '/tmp/codex-ws')).toBe( + ['[projects."/tmp/other"]', ''].join('\n') + ) + }) +}) + +describe('hasProjectTrustEntry', () => { + it('sees a table written by the upsert and none after removing it', () => { + const content = upsertProjectTrustLevelInContent('', '/tmp/codex-ws', 'trusted') + + expect(hasProjectTrustEntry(content, '/tmp/codex-ws')).toBe(true) + expect( + hasProjectTrustEntry(removeProjectTrustContent(content, '/tmp/codex-ws'), '/tmp/codex-ws') + ).toBe(false) + }) + + it('matches a legacy Windows separator variant without touching either table', () => { + expect(hasProjectTrustEntry('[projects."C:/Users/nw/repo"]', 'C:\\Users\\nw\\repo')).toBe(true) + expect(hasProjectTrustEntry('[projects."/tmp/codex-ws"]', '/tmp/other')).toBe(false) + }) +}) + describe('normalizeCodexProjectPathForLookup', () => { it('folds drvfs automount tails case-insensitively like the native drive path', () => { expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\mnt\\c\\Users\\Bob\\Repo')).toBe( diff --git a/src/main/codex/trust/toml/config-toml-trust.ts b/src/main/codex/trust/toml/config-toml-trust.ts index 989a7f3d9df..a3e3a7361fb 100644 --- a/src/main/codex/trust/toml/config-toml-trust.ts +++ b/src/main/codex/trust/toml/config-toml-trust.ts @@ -16,12 +16,14 @@ import { readHookTrustContent as readHookTrustEntriesFromContent } from './config-toml-hook-trust-read' import { + findProjectTrustRemovedBytes, hasProjectTrustEntry, removeProjectTrustContent, upsertProjectTrustContent as upsertProjectTrustLevelInContent } from './config-toml-project-trust' import { forgetOrcaCreatedProjectTrust, + getOrcaCreatedProjectTrustTable, listOrcaCreatedProjectTrustConfigFiles, recordOrcaCreatedProjectTrust } from './codex-project-trust-ownership' @@ -187,13 +189,21 @@ export function upsertOrcaCreatedProjectTrustLevel( projectPath: string, trustLevel: CodexProjectTrustLevel ): void { - const created = !hasProjectTrustEntry(readTomlForMutation(configPath), projectPath) - upsertProjectTrustLevel(configPath, projectPath, trustLevel) + const existing = readTomlForMutation(configPath) + const created = !hasProjectTrustEntry(existing, projectPath) + const updated = upsertProjectTrustLevelInContent(existing, projectPath, trustLevel) + if (updated !== existing) { + writeConfigAtomically(configPath, updated) + } if (!created) { return } try { - recordOrcaCreatedProjectTrust(configPath, projectPath) + recordOrcaCreatedProjectTrust( + configPath, + projectPath, + findProjectTrustRemovedBytes(updated, projectPath) ?? '' + ) } catch (error) { // Why: the grant already landed; an unrecorded entry only loses cleanup, never safety. console.warn('[codex-config] Failed to record the created project trust entry:', error) @@ -218,6 +228,11 @@ export async function removeOrcaCreatedProjectTrustEntries(projectPath: string): } } +// Why: the separator below a table belongs to what follows it, not to the table Orca wrote. +function withoutTrailingSeparator(bytes: string): string { + return bytes.replace(/[\r\n]+$/, '') +} + function removeOrcaCreatedProjectTrustEntry(configPath: string, projectPath: string): void { // Why: same lane as the grant writers (#16441), so a queued writer cannot rewrite the entry back. const observation = observe(() => readTomlFile(configPath)) @@ -228,6 +243,18 @@ function removeOrcaCreatedProjectTrustEntry(configPath: string, projectPath: str forgetOrcaCreatedProjectTrust(configPath, projectPath) return } + const ownedTable = getOrcaCreatedProjectTrustTable(configPath, projectPath) + const currentTable = findProjectTrustRemovedBytes(observation.value, projectPath) + if ( + ownedTable === null || + currentTable === null || + withoutTrailingSeparator(currentTable) !== withoutTrailingSeparator(ownedTable) + ) { + // Why: the bytes at the path are no longer what Orca wrote, so the record no longer vouches + // for the table — dropping it keeps a later pass from deleting someone else's replacement. + forgetOrcaCreatedProjectTrust(configPath, projectPath) + return + } const updated = removeProjectTrustContent(observation.value, projectPath) if (updated !== observation.value) { writeConfigAtomically(configPath, updated) diff --git a/src/main/runtime/workspaces/workspace-removal-target.ts b/src/main/runtime/workspaces/workspace-removal-target.ts index 4d03e861856..51ea3e41849 100644 --- a/src/main/runtime/workspaces/workspace-removal-target.ts +++ b/src/main/runtime/workspaces/workspace-removal-target.ts @@ -34,7 +34,8 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { invalidateAuthorizedRootsCacheForRepo } from '../../ipc/worktrees/removal/registered-worktree-roots-scoped-invalidation' -import { removeOrcaCreatedProjectTrustEntries } from '../../codex/trust/toml/config-toml-trust' +import { removeOrcaCreatedProjectTrustEntries } from '../../codex/trust/toml/config-toml-trust' +import { dropOrcaCreatedCodexPretrustForRemovedWorktree } from '../worktree-removal-codex-pretrust-cleanup' import { resumeInterruptedWorktreeRemovals, retryFailedWorktreeRemoval, @@ -160,13 +161,6 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu hostId?: ExecutionHostId ): void { // Purge history/caches before path-derived ids can name a recreated workspace. - // The Codex pretrust entry Orca wrote for the path is the same hazard: drop - // it so a recreated worktree is not born pre-trusted (#24697). The grant - // keyed the bare path, so a folder session's ::workspace: suffix must not hide it. - const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath - if (worktreePath) { - void removeOrcaCreatedProjectTrustEntries(worktreePath) - } const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId const repoId = splitWorktreeId(worktreeId)?.repoId const preservesSameIdOwner = Boolean( @@ -180,6 +174,9 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu // Retire only this host's panes even when a same-id owner survives. this.dropAgentStatusForRemovedWorktreeFn?.(worktreeId, hostId ?? persistedHostId) if (!preservesSameIdOwner) { + // Why: worktree IDs are path-derived and can be recreated; a stale Codex + // pretrust entry must not pre-trust the next occupant of the path. + dropOrcaCreatedCodexPretrustForRemovedWorktree(worktreeId) // Do not rescue surviving paired PTYs into a recreated occupant of the same path-derived id. for (const ptyId of this.pairedRendererSessionOwnedPtyIds) { const ptyWorktreeId = this.ptysById.get(ptyId)?.worktreeId diff --git a/src/main/runtime/worktree-removal-codex-pretrust-cleanup.test.ts b/src/main/runtime/worktree-removal-codex-pretrust-cleanup.test.ts index c7e2378736d..9576805ec88 100644 --- a/src/main/runtime/worktree-removal-codex-pretrust-cleanup.test.ts +++ b/src/main/runtime/worktree-removal-codex-pretrust-cleanup.test.ts @@ -15,20 +15,25 @@ const PROJECT = '/tmp/worktrees/feature-r1' // Why: folder-project workspace sessions carry this suffix on an otherwise path-derived id. const FOLDER_SUFFIX = '::workspace:5e8cc6b2-1d3f-4a5b-9c2d-7f6e5a4b3c2d' -function makeRuntimePurge() { +function makeRuntimePurge(meta: Record = {}) { const store = { - getWorktreeMeta: () => ({ instanceId: 'old-instance' }), + getWorktreeMeta: () => meta, + getRepos: () => [] as never[], removeWorktreeMeta: () => {}, getRepo: () => undefined, getSettings: () => ({}) } const runtime = new OrcaRuntimeService(store as never) - return (worktreeId: string) => + return (worktreeId: string, hostId?: string) => ( runtime as unknown as { - removeWorktreeMetadataAndHistory: (store: unknown, worktreeId: string) => void + removeWorktreeMetadataAndHistory: ( + store: unknown, + worktreeId: string, + hostId?: string + ) => void } - ).removeWorktreeMetadataAndHistory(store, worktreeId) + ).removeWorktreeMetadataAndHistory(store, worktreeId, hostId) } describe('worktree removal cleans up the Codex pretrust Orca wrote', () => { @@ -76,4 +81,26 @@ describe('worktree removal cleans up the Codex pretrust Orca wrote', () => { expect(readFileSync(configPath, 'utf-8')).not.toContain(`[projects."${PROJECT}"]`) }) }) + + it('keeps the entry while another host still owns the same path-derived id', async () => { + upsertOrcaCreatedProjectTrustLevel(configPath, PROJECT, 'trusted') + const purge = makeRuntimePurge({ hostId: 'host-b' }) + + purge(WORKTREE_ID, 'host-a') + + // Why: the cleanup is fire-and-forget, so the negative needs a settle window to be meaningful. + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(readFileSync(configPath, 'utf-8')).toContain(`[projects."${PROJECT}"]`) + }) + + it('still removes the entry when the removal host owns the metadata row', async () => { + upsertOrcaCreatedProjectTrustLevel(configPath, PROJECT, 'trusted') + const purge = makeRuntimePurge({ hostId: 'host-a' }) + + purge(WORKTREE_ID, 'host-a') + + await vi.waitFor(() => { + expect(readFileSync(configPath, 'utf-8')).not.toContain(`[projects."${PROJECT}"]`) + }) + }) }) diff --git a/src/main/runtime/worktree-removal-codex-pretrust-cleanup.ts b/src/main/runtime/worktree-removal-codex-pretrust-cleanup.ts new file mode 100644 index 00000000000..b405275c6de --- /dev/null +++ b/src/main/runtime/worktree-removal-codex-pretrust-cleanup.ts @@ -0,0 +1,18 @@ +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { removeOrcaCreatedProjectTrustEntries } from '../codex/config-toml-trust' + +/** + * Drops the Codex pretrust entries Orca wrote for a removed worktree's path. + * + * Why: worktree IDs are path-derived and can be recreated, so removal must purge + * history and process-local caches before the ID points at new state. The Codex + * pretrust entry Orca wrote for the path is the same hazard: drop it so a + * recreated worktree is not born pre-trusted (#24697). The grant keyed the bare + * path, so a folder session's ::workspace: suffix must not hide it. + */ +export function dropOrcaCreatedCodexPretrustForRemovedWorktree(worktreeId: string): void { + const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + if (worktreePath) { + void removeOrcaCreatedProjectTrustEntries(worktreePath) + } +}