mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
fix(codex): verify the recorded table bytes and guard same-owner removals
Coderabbit review, three findings:
- Ledger reads now narrow an `unknown` payload at runtime (plain-object
`created`, values being `{path, table}` arrays) and fail closed to an
empty ledger; `{"created":null}` no longer slips through `typeof ===
'object'` into `Object.entries(null)`.
- Each record vouches for the exact table bytes the grant wrote. Removal
compares the table currently at the path against that snapshot (modulo
the trailing separator, which belongs to what follows the table) and, on
any mismatch, skips the deletion and forgets the record — a table the
user wrote to replace ours is never deleted on the strength of an old
record.
- The trust cleanup moves below the `preservesSameIdOwner` guard, so a
stale host-scoped removal can no longer drop the new owner's pre-trust
entry while sparing its metadata.
Generated-by: GLM-5.3-Flash (ZCode)
Rebase note (upstream #23952): the removal-target file now sits at the oxlint
max-lines cap, so the removal-time cleanup call moves into
runtime/worktree-removal-codex-pretrust-cleanup.ts instead of suppressing the
rule; behavior and the same-owner guard are unchanged.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,29 +8,62 @@ import { normalizeCodexTrustProjectPath } from './codex-trust-identity'
|
||||
/**
|
||||
* Ownership records for the `[projects."<path>"]` 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<string, string[]>
|
||||
/** Config file path → the tables Orca's grant created there, with the bytes it wrote. */
|
||||
created: Record<string, CreatedProjectTrustEntry[]>
|
||||
}
|
||||
|
||||
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<string, CreatedProjectTrustEntry[]> {
|
||||
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<CreatedProjectTrustLedger>
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:<uuid> 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
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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}"]`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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:<uuid> suffix must not hide it.
|
||||
*/
|
||||
export function dropOrcaCreatedCodexPretrustForRemovedWorktree(worktreeId: string): void {
|
||||
const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath
|
||||
if (worktreePath) {
|
||||
void removeOrcaCreatedProjectTrustEntries(worktreePath)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user