diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 5266aa80ee5..a8aee0423b7 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -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 }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 6d4c9e9fa44..013ae41d5c2 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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) } diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index e3ec0811c8e..d7074e8edd9 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -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')