diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts index c08b6823ebc..e6846aabcf7 100644 --- a/src/main/codex/codex-session-resume-home.test.ts +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -13,7 +13,8 @@ const tempRoots: string[] = [] // Why: the ranking inputs are required by design; cases below that never reach the rescan stay neutral. const withoutHomeRanking = { getSelectedAccountCodexHome: (): string | null => null, - systemCodexHomePath: null + systemCodexHomePath: null, + sharedRuntimeCodexHomePath: null } afterEach(() => { @@ -274,6 +275,7 @@ describe('findTrustedCodexSessionResume legacy-rescan home ranking', () => { trustedCodexHomes, getSelectedAccountCodexHome: () => accountBHome, systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, listSessionFiles: listRolloutInEveryHome }) ).resolves.toEqual({ homePath: accountBHome, transcriptPath: rolloutIn(accountBHome) }) @@ -288,15 +290,36 @@ describe('findTrustedCodexSessionResume legacy-rescan home ranking', () => { trustedCodexHomes: [sharedMirror, accountAHome, systemHome], getSelectedAccountCodexHome: () => null, systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, listSessionFiles: listRolloutInEveryHome }) ).resolves.toEqual({ homePath: systemHome, transcriptPath: rolloutIn(systemHome) }) }) + // Why: `/Users/…` already wins the tier-3 byte order, so the case above cannot tell the + // system-home tier apart from the path tie-break. Pin it with a home that sorts last. + it('ranks the real system home above the others even when its path sorts last', async () => { + const lateSortingSystemHome = join('/var', 'lib', 'orca', '.codex') + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [sharedMirror, accountAHome, lateSortingSystemHome], + getSelectedAccountCodexHome: () => null, + systemCodexHomePath: lateSortingSystemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutInEveryHome + }) + ).resolves.toEqual({ + homePath: lateSortingSystemHome, + transcriptPath: rolloutIn(lateSortingSystemHome) + }) + }) + it('orders the remaining homes by path so insertion order never decides', async () => { for (const trustedCodexHomes of [ - [accountBHome, sharedMirror, accountAHome], - [accountAHome, accountBHome, sharedMirror] + [accountBHome, accountAHome], + [accountAHome, accountBHome] ]) { await expect( findTrustedCodexSessionResume({ @@ -305,25 +328,30 @@ describe('findTrustedCodexSessionResume legacy-rescan home ranking', () => { trustedCodexHomes, getSelectedAccountCodexHome: () => null, systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, listSessionFiles: listRolloutInEveryHome }) ).resolves.toEqual({ homePath: accountAHome, transcriptPath: rolloutIn(accountAHome) }) } }) - it('prefers a per-account home over the shared mirror when the system home lacks the id', async () => { - // Why: 'codex-accounts' sorts before 'codex-runtime-home', so tier 3 flips the pre-ranking - // winner here. Deliberate: the mirror is hot-swapped and names no account, the account home does. + // Why: the mirror is the only home whose win migrates the rollout into ~/.codex + // (prepareLegacySharedCodexSessionResume), which is how a system-default selection resumes on the + // real home. 'codex-accounts' sorts before 'codex-runtime-home', so path order alone would hand + // that selection to an arbitrary account instead. One-shot legacy migration already copies + // per-account rollouts into the mirror, so both really do hold the id. + it('prefers the shared mirror over a per-account home when the system home lacks the id', async () => { await expect( findTrustedCodexSessionResume({ sessionId, transcriptPath: undefined, - trustedCodexHomes: [systemHome, sharedMirror, accountAHome], + trustedCodexHomes: [systemHome, accountAHome, sharedMirror], getSelectedAccountCodexHome: () => null, systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, listSessionFiles: listRolloutIn(sharedMirror, accountAHome) }) - ).resolves.toEqual({ homePath: accountAHome, transcriptPath: rolloutIn(accountAHome) }) + ).resolves.toEqual({ homePath: sharedMirror, transcriptPath: rolloutIn(sharedMirror) }) }) it('ranks Windows homes case-insensitively and keeps the caller path spelling', async () => { @@ -345,6 +373,7 @@ describe('findTrustedCodexSessionResume legacy-rescan home ranking', () => { // Why: settings and discovery can disagree on drive/segment case; the selection must still match. getSelectedAccountCodexHome: () => windowsAccountBHome.toLowerCase(), systemCodexHomePath: windowsSystemHome, + sharedRuntimeCodexHomePath: null, listSessionFiles }) ).resolves.toEqual({ @@ -361,6 +390,7 @@ describe('findTrustedCodexSessionResume legacy-rescan home ranking', () => { trustedCodexHomes: [systemHome, sharedMirror, accountAHome, accountBHome], getSelectedAccountCodexHome: () => accountAHome, systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, listSessionFiles: listRolloutIn(accountBHome) }) ).resolves.toEqual({ homePath: accountBHome, transcriptPath: rolloutIn(accountBHome) }) @@ -378,6 +408,7 @@ describe('findTrustedCodexSessionResume legacy-rescan home ranking', () => { trustedCodexHomes: [systemHome, accountAHome, accountBHome], getSelectedAccountCodexHome: () => accountAHome, systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, fileIsRegular: () => false, listSessionFiles }) diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts index e8f2c9596d5..f7f2da05fd4 100644 --- a/src/main/codex/codex-session-resume-home.ts +++ b/src/main/codex/codex-session-resume-home.ts @@ -104,7 +104,14 @@ export function claimsCodexRolloutLayout(transcriptPath: string | undefined): bo * directly. Everything else is ordered by normalized path so no winner ever * depends on the order accounts happen to sit in settings. * - * Both inputs are required, not optional: a caller that forgot one would + * The shared runtime mirror ranks above the remaining homes because winning is + * not inert for it: it is the only home that triggers the legacy migration into + * the real system home (prepareLegacySharedCodexSessionResume), which is how a + * system-default selection resumes on ~/.codex instead of some account's home. + * Letting a per-account home outrank it by mere path order would silently route + * that selection to an account the user did not pick. + * + * All inputs are required, not optional: a caller that forgot one would * silently degrade to pure path order, which is the accident this exists to * remove. The selection arrives as a thunk because resolving it stats the * account's ownership marker, and the far more common provenance-present @@ -114,16 +121,23 @@ function rankTrustedCodexHomesForRescan(args: { trustedCodexHomes: readonly string[] getSelectedAccountCodexHome: () => string | null systemCodexHomePath: string | null + sharedRuntimeCodexHomePath: string | null }): string[] { - const selected = args.getSelectedAccountCodexHome()?.trim() - const system = args.systemCodexHomePath?.trim() - const selectedComparison = selected ? normalizeRuntimePathForComparison(selected) : null - const systemComparison = system ? normalizeRuntimePathForComparison(system) : null + const toComparisonHome = (value: string | null | undefined): string | null => { + const trimmed = value?.trim() + return trimmed ? normalizeRuntimePathForComparison(trimmed) : null + } + const selectedComparison = toComparisonHome(args.getSelectedAccountCodexHome()) + const systemComparison = toComparisonHome(args.systemCodexHomePath) + const sharedRuntimeComparison = toComparisonHome(args.sharedRuntimeCodexHomePath) const rankOf = (comparisonHome: string): number => { if (selectedComparison && comparisonHome === selectedComparison) { return 0 } - return systemComparison && comparisonHome === systemComparison ? 1 : 2 + if (systemComparison && comparisonHome === systemComparison) { + return 1 + } + return sharedRuntimeComparison && comparisonHome === sharedRuntimeComparison ? 2 : 3 } return args.trustedCodexHomes .map((homePath) => ({ homePath, comparisonHome: normalizeRuntimePathForComparison(homePath) })) @@ -147,6 +161,7 @@ export async function findTrustedCodexSessionResume(args: { trustedCodexHomes: readonly string[] getSelectedAccountCodexHome: () => string | null systemCodexHomePath: string | null + sharedRuntimeCodexHomePath: string | null fileIsRegular?: (filePath: string) => boolean listSessionFiles?: (sessionsRoot: string) => AsyncIterable }): Promise<{ homePath: string; transcriptPath: string } | null> { diff --git a/src/main/index.ts b/src/main/index.ts index c5139557ee1..98b694f5128 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -176,7 +176,7 @@ import { claimsCodexRolloutLayout, findTrustedCodexSessionResume } from './codex/codex-session-resume-home' -import { getSystemCodexHomePath } from './codex/codex-home-paths' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex/codex-home-paths' import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' import { getDefaultWslDistro } from './wsl' @@ -857,7 +857,10 @@ async function prepareCodexSessionResumeForLaunch(args: { // rank it by the current selection so settings insertion order can never decide the account. // Lazy: only the legacy branch ranks, so a provenance-present resume never stats the marker. getSelectedAccountCodexHome: () => codexRuntimeHome!.getSelectedHostAccountCodexHomePath(), - systemCodexHomePath: systemHomePath + systemCodexHomePath: systemHomePath, + // Why: the mirror winning is what triggers the migration into ~/.codex below, so it must + // outrank the path-sorted account homes or a system-default selection resumes as an account. + sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath() }) if (!sessionSource) { // Why: an unverifiable Codex rollout still blocks; only paths that never claimed Codex