mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user