fix(codex): keep an unreadable managed home from dropping another account's mirrored refresh

An account whose auth.json cannot be read aborted the whole read-back scan,
so removing the runtime mirror for an unproven selection destroyed a token
refresh it held for a different account. Skip the unreadable home instead.
This commit is contained in:
Brennan Benson
2026-08-03 20:07:35 -07:00
parent 5a958f111e
commit af3cfead4d
2 changed files with 84 additions and 62 deletions
@@ -543,68 +543,82 @@ describe('CodexRuntimeHomeService', () => {
}
)
it('preserves the mirrored refresh of the account the runtime home actually holds', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(
getSystemCodexAuthPath(),
createCodexAuthJson('system@example.com', 'acct-system', 'system'),
'utf-8'
)
const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a', 1)
const authARefreshed = createCodexAuthJson('a@example.com', 'acct-a', 'a-refreshed', 2)
const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA)
const homeB = createManagedAuth(
testState.userDataDir,
'account-b',
createCodexAuthJson('b@example.com', 'acct-b', 'b')
)
// A crash left account A's refreshed tokens in the mirror while settings
// already select account B, whose auth.json was never written.
writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8')
writeFileSync(
getSharedRuntimeAuthProvenancePath(),
`${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`,
'utf-8'
)
rmSync(join(homeB, 'auth.json'), { force: true })
const store = createStore(
createSettings({
codexManagedAccounts: [
{
id: 'account-a',
email: 'a@example.com',
managedHomePath: homeA,
providerAccountId: 'acct-a',
workspaceLabel: null,
workspaceAccountId: 'acct-a',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-b',
email: 'b@example.com',
managedHomePath: homeB,
providerAccountId: 'acct-b',
workspaceLabel: null,
workspaceAccountId: 'acct-b',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'account-b',
activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
it.each([
['missing', (authPath: string) => rmSync(authPath, { force: true })],
[
'unreadable',
(authPath: string) => {
chmodSync(authPath, 0o000)
}
]
])(
'preserves the mirrored refresh of the account the runtime home actually holds when the selected account auth is %s',
async (label, breakAuth) => {
if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) {
return
}
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(
getSystemCodexAuthPath(),
createCodexAuthJson('system@example.com', 'acct-system', 'system'),
'utf-8'
)
const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a', 1)
const authARefreshed = createCodexAuthJson('a@example.com', 'acct-a', 'a-refreshed', 2)
const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA)
const homeB = createManagedAuth(
testState.userDataDir,
'account-b',
createCodexAuthJson('b@example.com', 'acct-b', 'b')
)
// A crash left account A's refreshed tokens in the mirror while settings
// already select account B, whose own auth.json cannot be read.
writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8')
writeFileSync(
getSharedRuntimeAuthProvenancePath(),
`${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`,
'utf-8'
)
breakAuth(join(homeB, 'auth.json'))
const store = createStore(
createSettings({
codexManagedAccounts: [
{
id: 'account-a',
email: 'a@example.com',
managedHomePath: homeA,
providerAccountId: 'acct-a',
workspaceLabel: null,
workspaceAccountId: 'acct-a',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-b',
email: 'b@example.com',
managedHomePath: homeB,
providerAccountId: 'acct-b',
workspaceLabel: null,
workspaceAccountId: 'acct-b',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'account-b',
activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
expect(existsSync(runtimeAuthPath)).toBe(false)
expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed)
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b')
})
expect(existsSync(runtimeAuthPath)).toBe(false)
expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed)
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b')
}
)
it('keeps the runtime auth through the grace window while the selected account rotates', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
@@ -1281,7 +1281,15 @@ export class CodexRuntimeHomeService {
if (!existsSync(managedAuthPath)) {
continue
}
const managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
let managedAuthContents: string
try {
managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
} catch {
// Why: an unreadable home can never be the match, but letting the read
// throw abandons the scan for every other account — dropping a refresh
// the runtime home holds for one of them.
continue
}
if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) {
matches.push({ account, managedAuthPath, managedAuthContents })
}