Fix managed Codex account switching on shared CODEX_HOME (#4862)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-07 21:20:08 -07:00
committed by GitHub
co-authored by Orca
parent cacd48da72
commit bb21366a14
3 changed files with 394 additions and 0 deletions
@@ -724,6 +724,328 @@ describe('CodexRuntimeHomeService', () => {
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
})
it('uses the same host CODEX_HOME after switching managed Codex accounts', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one')
const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two')
const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'one@example.com',
managedHomePath: managedHomePath1,
providerAccountId: 'acct-1',
workspaceLabel: null,
workspaceAccountId: 'acct-1',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-2',
email: 'two@example.com',
managedHomePath: managedHomePath2,
providerAccountId: 'acct-2',
workspaceLabel: null,
workspaceAccountId: 'acct-2',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const account1Home = service.prepareForCodexLaunch()
settings.activeCodexManagedAccountId = 'account-2'
settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} }
const account2Home = service.prepareForCodexLaunch()
expect(account1Home).toBe(getRuntimeCodexHomePath())
expect(account2Home).toBe(getRuntimeCodexHomePath())
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth)
})
it('new host Codex launches get the selected account after preserving outgoing refreshes', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1)
const account1RefreshedAuth = createCodexAuthJson(
'one@example.com',
'acct-1',
'one-refreshed',
2
)
const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two', 1)
const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'one@example.com',
managedHomePath: managedHomePath1,
providerAccountId: 'acct-1',
workspaceLabel: null,
workspaceAccountId: 'acct-1',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-2',
email: 'two@example.com',
managedHomePath: managedHomePath2,
providerAccountId: 'acct-2',
workspaceLabel: null,
workspaceAccountId: 'acct-2',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8')
settings.activeCodexManagedAccountId = 'account-2'
settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} }
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth)
})
it('reads back selected-account refreshes without ambiguity from duplicate identities', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1)
const account1RefreshedAuth = createCodexAuthJson(
'same@example.com',
'acct-same',
'one-refreshed',
2
)
const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1)
const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'same@example.com',
managedHomePath: managedHomePath1,
providerAccountId: 'acct-same',
workspaceLabel: null,
workspaceAccountId: 'acct-same',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-2',
email: 'same@example.com',
managedHomePath: managedHomePath2,
providerAccountId: 'acct-same',
workspaceLabel: null,
workspaceAccountId: 'acct-same',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8')
service.syncForCurrentSelection()
expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth)
expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth)
})
it('keeps fresher selected-account startup refreshes when duplicate identities exist', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1)
const account1RefreshedAuth = createCodexAuthJson(
'same@example.com',
'acct-same',
'one-refreshed',
2
)
const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1)
const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'same@example.com',
managedHomePath: managedHomePath1,
providerAccountId: 'acct-same',
workspaceLabel: null,
workspaceAccountId: 'acct-same',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-2',
email: 'same@example.com',
managedHomePath: managedHomePath2,
providerAccountId: 'acct-same',
workspaceLabel: null,
workspaceAccountId: 'acct-same',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8')
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth)
expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth)
})
it('routes stale live-pane startup refreshes to the matching account before restoring selected auth', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1)
const account1RefreshedAuth = createCodexAuthJson(
'one@example.com',
'acct-1',
'one-refreshed',
2
)
const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two', 1)
const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'one@example.com',
managedHomePath: managedHomePath1,
providerAccountId: 'acct-1',
workspaceLabel: null,
workspaceAccountId: 'acct-1',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-2',
email: 'two@example.com',
managedHomePath: managedHomePath2,
providerAccountId: 'acct-2',
workspaceLabel: null,
workspaceAccountId: 'acct-2',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'account-2',
activeCodexManagedAccountIdsByRuntime: { host: 'account-2', wsl: {} }
})
writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8')
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth)
expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth)
})
it('preserves duplicate-identity outgoing refreshes before switching to another account', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1)
const account1RefreshedAuth = createCodexAuthJson(
'same@example.com',
'acct-same',
'one-refreshed',
2
)
const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1)
const account3Auth = createCodexAuthJson('three@example.com', 'acct-3', 'three', 1)
const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth)
const managedHomePath3 = createManagedAuth(testState.userDataDir, 'account-3', account3Auth)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'same@example.com',
managedHomePath: managedHomePath1,
providerAccountId: 'acct-same',
workspaceLabel: null,
workspaceAccountId: 'acct-same',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-2',
email: 'same@example.com',
managedHomePath: managedHomePath2,
providerAccountId: 'acct-same',
workspaceLabel: null,
workspaceAccountId: 'acct-same',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
},
{
id: 'account-3',
email: 'three@example.com',
managedHomePath: managedHomePath3,
providerAccountId: 'acct-3',
workspaceLabel: null,
workspaceAccountId: 'acct-3',
createdAt: 3,
updatedAt: 3,
lastAuthenticatedAt: 3
}
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8')
settings.activeCodexManagedAccountId = 'account-3'
settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-3', wsl: {} }
service.syncForCurrentSelection()
expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth)
expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account3Auth)
})
it('mirrors later system Codex config changes before launch', async () => {
const systemCodexHome = getSystemCodexHomePath()
mkdirSync(systemCodexHome, { recursive: true })
@@ -313,6 +313,20 @@ export class CodexRuntimeHomeService {
private readBackRefreshedTokens(options: {
updateLastWrittenAuthJson: boolean
}): CodexReadBackResult {
const selectedAccountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host
if (selectedAccountId) {
const selectedAccountResult = this.readBackRefreshedTokensFromPath(
this.getRuntimeAuthPath(),
{
...options,
expectedAccountId: selectedAccountId
}
)
if (selectedAccountResult !== 'rejected') {
return selectedAccountResult
}
}
return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), options)
}
@@ -86,6 +86,64 @@ describe('markLiveCodexSessionsForRestart', () => {
})
})
it('marks every live Codex split pane and ignores non-Codex panes', async () => {
useAppStore.setState({
tabsByWorktree: {
wt1: [
{
id: 'tab-1',
ptyId: 'pty-1',
worktreeId: 'wt1',
title: 'orca-1',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
},
{
id: 'tab-2',
ptyId: 'pty-3',
worktreeId: 'wt1',
title: 'orca-2',
customTitle: null,
color: null,
sortOrder: 1,
createdAt: 2
}
]
},
ptyIdsByTabId: {
'tab-1': ['pty-1', 'pty-2'],
'tab-2': ['pty-3']
}
})
vi.mocked(window.api.pty.getForegroundProcess).mockImplementation((ptyId) => {
if (ptyId === 'pty-1') {
return Promise.resolve('codex')
}
if (ptyId === 'pty-3') {
return Promise.resolve('codex-aarch64-ap')
}
return Promise.resolve('zsh')
})
await markLiveCodexSessionsForRestart({
previousAccountLabel: ACCOUNT_A,
nextAccountLabel: ACCOUNT_B
})
expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({
'pty-1': {
previousAccountLabel: ACCOUNT_A,
nextAccountLabel: ACCOUNT_B
},
'pty-3': {
previousAccountLabel: ACCOUNT_A,
nextAccountLabel: ACCOUNT_B
}
})
})
it('does not mark non-codex foreground processes', async () => {
vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('zsh')