diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 9b1340b3e70..ee172a81e20 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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. diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts index f2bf77107a6..05f9ff85ff3 100644 --- a/src/main/codex/codex-session-resume-home.test.ts +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -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 { + yield join(sessionsRoot, '2026', '07', '20', `rollout-2026-07-20T15-50-19-${sessionId}.jsonl`) + } + + const listRolloutOnlyIn = (homePath: string) => + async function* (sessionsRoot: string): AsyncIterable { + 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 => { + 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( diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts index 639d2620eb7..79038f1b592 100644 --- a/src/main/codex/codex-session-resume-home.ts +++ b/src/main/codex/codex-session-resume-home.ts @@ -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 }): 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() - for (const homePath of args.trustedCodexHomes) { + for (const homePath of rankTrustedCodexHomesForRescan(args)) { const comparisonHome = normalizeRuntimePathForComparison(homePath) if (seenHomes.has(comparisonHome)) { continue diff --git a/src/main/index.ts b/src/main/index.ts index ec2dbdc1b63..30a0f1ea01e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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