fix(codex): rank the Codex resume rescan by selected account, not settings order

The legacy session-id rescan returned the first trusted home whose sessions
tree held a rollout ending in the id, and that list carries per-account homes
in settings insertion order. The winning home becomes the resumed pane's
CODEX_HOME, so the account was decided by which one the user added first.

Rank the homes instead: the currently selected account's own home, then the
real system home, then the rest by normalized path. Only the legacy branch is
reordered; the non-empty transcriptPath refusal is untouched.
This commit is contained in:
Brennan Benson
2026-07-26 16:04:09 -07:00
parent fd7ebe3f47
commit 4027e19e50
4 changed files with 171 additions and 2 deletions
@@ -362,6 +362,22 @@ export class CodexRuntimeHomeService {
return homes.filter((home, index) => homes.indexOf(home) === index)
}
/**
* The account-owned CODEX_HOME the current HOST selection runs against, or
* null when the selection is not routed to one (system default, or the
* flag-OFF shared mirror, which every account hot-swaps and so names no
* account).
*
* Read-only on purpose: session discovery ranks homes with this before any
* launch prep, so it must create no directories and sync no auth.
*/
getSelectedHostAccountCodexHomePath(): string | null {
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
return selfContainedAccount
? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount)
: null
}
// Why: the real-home hook installer flips this gate off when the trust-grant
// client reports the host incapable, keeping that host byte-identical to the
// managed lane instead of shipping status-blind panes.
@@ -226,6 +226,111 @@ describe('resolveTrustedCodexSessionResumeHome', () => {
})
})
describe('findTrustedCodexSessionResume legacy-rescan home ranking', () => {
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
const systemHome = join('/Users', 'example', '.codex')
const sharedMirror = join('/userData', 'codex-runtime-home', 'home')
const accountAHome = join('/userData', 'codex-accounts', 'account-a', 'home')
const accountBHome = join('/userData', 'codex-accounts', 'account-b', 'home')
const rolloutIn = (homePath: string): string =>
join(homePath, 'sessions', '2026', '07', '20', `rollout-2026-07-20T15-50-19-${sessionId}.jsonl`)
// Why: PR #10770 hardlinks one rollout into every managed home, so the id alone stops naming an account.
const listRolloutInEveryHome = async function* (sessionsRoot: string): AsyncIterable<string> {
yield join(sessionsRoot, '2026', '07', '20', `rollout-2026-07-20T15-50-19-${sessionId}.jsonl`)
}
const listRolloutOnlyIn = (homePath: string) =>
async function* (sessionsRoot: string): AsyncIterable<string> {
if (sessionsRoot === join(homePath, 'sessions')) {
yield* listRolloutInEveryHome(sessionsRoot)
}
}
it('resumes into the selected account home whatever order the homes arrive in', async () => {
for (const trustedCodexHomes of [
[systemHome, sharedMirror, accountAHome, accountBHome],
[systemHome, sharedMirror, accountBHome, accountAHome],
[accountBHome, accountAHome, sharedMirror, systemHome]
]) {
await expect(
findTrustedCodexSessionResume({
sessionId,
transcriptPath: undefined,
trustedCodexHomes,
selectedAccountCodexHome: accountBHome,
systemCodexHomePath: systemHome,
listSessionFiles: listRolloutInEveryHome
})
).resolves.toEqual({ homePath: accountBHome, transcriptPath: rolloutIn(accountBHome) })
}
})
it('falls back to the real system home when no account home is selected', async () => {
await expect(
findTrustedCodexSessionResume({
sessionId,
transcriptPath: undefined,
trustedCodexHomes: [sharedMirror, accountAHome, systemHome],
selectedAccountCodexHome: null,
systemCodexHomePath: systemHome,
listSessionFiles: listRolloutInEveryHome
})
).resolves.toEqual({ homePath: systemHome, transcriptPath: rolloutIn(systemHome) })
})
it('orders the remaining homes by path so insertion order never decides', async () => {
for (const trustedCodexHomes of [
[accountBHome, sharedMirror, accountAHome],
[accountAHome, accountBHome, sharedMirror]
]) {
await expect(
findTrustedCodexSessionResume({
sessionId,
transcriptPath: undefined,
trustedCodexHomes,
selectedAccountCodexHome: null,
systemCodexHomePath: systemHome,
listSessionFiles: listRolloutInEveryHome
})
).resolves.toEqual({ homePath: accountAHome, transcriptPath: rolloutIn(accountAHome) })
}
})
it('still resumes the only home holding the id, selected or not', async () => {
await expect(
findTrustedCodexSessionResume({
sessionId,
transcriptPath: undefined,
trustedCodexHomes: [systemHome, sharedMirror, accountAHome, accountBHome],
selectedAccountCodexHome: accountAHome,
systemCodexHomePath: systemHome,
listSessionFiles: listRolloutOnlyIn(accountBHome)
})
).resolves.toEqual({ homePath: accountBHome, transcriptPath: rolloutIn(accountBHome) })
})
it('does not rescan into the selected account home when transcript provenance was rejected', async () => {
const listSessionFiles = vi.fn((): AsyncIterable<string> => {
throw new Error('must not scan')
})
await expect(
findTrustedCodexSessionResume({
sessionId,
transcriptPath: rolloutIn(accountBHome),
trustedCodexHomes: [systemHome, accountAHome, accountBHome],
selectedAccountCodexHome: accountAHome,
systemCodexHomePath: systemHome,
fileIsRegular: () => false,
listSessionFiles
})
).resolves.toBeNull()
expect(listSessionFiles).not.toHaveBeenCalled()
})
})
describe('claimsCodexRolloutLayout', () => {
it('is true for a rollout path even if the file is missing', () => {
expect(
+45 -1
View File
@@ -93,10 +93,54 @@ export function claimsCodexRolloutLayout(transcriptPath: string | undefined): bo
return CODEX_ROLLOUT_LAYOUT_PATH.test(persistedPath.replace(/\\/g, '/'))
}
/**
* Orders trusted homes for the legacy id rescan, lowest wins.
*
* The winning home becomes the resumed pane's CODEX_HOME, so it picks the
* account. Rank the currently selected account's own home first — once a
* rollout is hardlinked into every managed home the id alone no longer names
* an account, and resuming under the account the user has selected is what
* they asked for. The real system home ranks next because codex refreshes it
* directly. Everything else is ordered by normalized path so no winner ever
* depends on the order accounts happen to sit in settings.
*/
function rankTrustedCodexHomesForRescan(args: {
trustedCodexHomes: readonly string[]
selectedAccountCodexHome?: string | null
systemCodexHomePath?: string | null
}): string[] {
const selected = args.selectedAccountCodexHome?.trim()
const system = args.systemCodexHomePath?.trim()
const selectedComparison = selected ? normalizeRuntimePathForComparison(selected) : null
const systemComparison = system ? normalizeRuntimePathForComparison(system) : null
const rankOf = (comparisonHome: string): number => {
if (selectedComparison && comparisonHome === selectedComparison) {
return 0
}
return systemComparison && comparisonHome === systemComparison ? 1 : 2
}
return args.trustedCodexHomes
.map((homePath) => ({ homePath, comparisonHome: normalizeRuntimePathForComparison(homePath) }))
.sort((left, right) => {
const rankDelta = rankOf(left.comparisonHome) - rankOf(right.comparisonHome)
if (rankDelta !== 0) {
return rankDelta
}
return left.comparisonHome < right.comparisonHome
? -1
: left.comparisonHome > right.comparisonHome
? 1
: 0
})
.map((entry) => entry.homePath)
}
export async function findTrustedCodexSessionResume(args: {
sessionId: string
transcriptPath: string | undefined
trustedCodexHomes: readonly string[]
selectedAccountCodexHome?: string | null
systemCodexHomePath?: string | null
fileIsRegular?: (filePath: string) => boolean
listSessionFiles?: (sessionsRoot: string) => AsyncIterable<string>
}): Promise<{ homePath: string; transcriptPath: string } | null> {
@@ -118,7 +162,7 @@ export async function findTrustedCodexSessionResume(args: {
listCodexSessionRolloutFilesIncrementally(sessionsRoot, { batchSize: 64, yieldMs: 0 }))
const expectedSuffix = `-${args.sessionId}.jsonl`.toLowerCase()
const seenHomes = new Set<string>()
for (const homePath of args.trustedCodexHomes) {
for (const homePath of rankTrustedCodexHomesForRescan(args)) {
const comparisonHome = normalizeRuntimePathForComparison(homePath)
if (seenHomes.has(comparisonHome)) {
continue
+5 -1
View File
@@ -852,7 +852,11 @@ async function prepareCodexSessionResumeForLaunch(args: {
const sessionSource = await findTrustedCodexSessionResume({
sessionId: args.providerSession.id,
transcriptPath: args.providerSession.transcriptPath,
trustedCodexHomes: trustedHomes
trustedCodexHomes: trustedHomes,
// Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account;
// rank it by the current selection so settings insertion order can never decide the account.
selectedAccountCodexHome: codexRuntimeHome.getSelectedHostAccountCodexHomePath(),
systemCodexHomePath: systemHomePath
})
if (!sessionSource) {
// Why: an unverifiable Codex rollout still blocks; only paths that never claimed Codex