diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index e474dbf9cc0..0b18bd65cf4 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -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() diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 59c1a6fd89a..2eab5a7ce6a 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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 }) }