From d2885b620763773e6efa19dad524a3e83d990afa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:34:12 -0700 Subject: [PATCH] fix(codex): tell the user when a resumed pane is pinned to another account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An automatic session resume overrides the current account selection on purpose: `prepareCodexSessionResumeForLaunch` pins CODEX_HOME to the home that owns the rollout so Orca never resumes a session under a different account. But `recordCodexPaneAccountForSpawn` read that pin as "intentional, nothing to say" and forgot the pane, so a cold-restored pane silently came back on the account the user had already switched away from — the reporter's `0% left` TUI under a `78% left` status bar (#10757). Record the account that actually owns the pinned home instead of forgetting it. The existing stale-pane sweep and CodexRestartChip then name it without any new UI: "Codex is still signed in as A. Restart this session to use B." A home is attributed only when Orca can prove who owns it — the real system home maps to the system-default account, a managed home maps to its account on the same selection lane, and anything else (notably the shared runtime mirror, which hot-swaps to whichever account is selected) stays unrecorded. That asymmetry is deliberate: a restart notice blocks every keystroke in its pane, so a guess would turn a correctly-signed-in terminal into one that silently ignores typing. --- .../codex/codex-pane-launch-account.test.ts | 167 ++++++++++++++++++ src/main/codex/codex-pane-launch-account.ts | 73 ++++++++ src/main/ipc/pty.test.ts | 93 ++++++++++ src/main/ipc/pty.ts | 30 ++-- 4 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 src/main/codex/codex-pane-launch-account.test.ts create mode 100644 src/main/codex/codex-pane-launch-account.ts diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts new file mode 100644 index 00000000000..f7cb49eb95b --- /dev/null +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest' +import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' +import { resolveCodexPaneLaunchAccount } from './codex-pane-launch-account' + +const SYSTEM_HOME = '/Users/example/.codex' + +function managedAccount(overrides: Partial): CodexManagedAccount { + return { + id: 'account-a', + email: 'a@example.com', + managedHomePath: '/data/codex-accounts/account-a/home', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0, + ...overrides + } +} + +function settings(args: { + host?: string | null + wsl?: Record + accounts?: CodexManagedAccount[] +}): GlobalSettings { + return { + activeCodexManagedAccountId: args.host ?? null, + activeCodexManagedAccountIdsByRuntime: { host: args.host ?? null, wsl: args.wsl ?? {} }, + codexManagedAccounts: args.accounts ?? [] + } as GlobalSettings +} + +describe('resolveCodexPaneLaunchAccount', () => { + it('records the selected account for an ordinary spawn', () => { + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: false, + launchCodexHomePath: '/data/codex-accounts/account-b/home', + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ host: 'account-b' }), + target: { runtime: 'host' } + }) + ).toEqual({ selectionKey: 'host', accountId: 'account-b' }) + }) + + it('records the origin account a resume pinned the pane to, not the selection', () => { + const accounts = [ + managedAccount({ id: 'account-a' }), + managedAccount({ id: 'account-b', managedHomePath: '/data/codex-accounts/account-b/home' }) + ] + + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: true, + launchCodexHomePath: '/data/codex-accounts/account-a/home', + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ host: 'account-b', accounts }), + target: { runtime: 'host' } + }) + ).toEqual({ selectionKey: 'host', accountId: 'account-a' }) + }) + + it('records the same account a resume pinned to when it is already selected', () => { + const accounts = [managedAccount({ id: 'account-a' })] + + // Why: the sweep compares this against the live selection, so an equal + // account must still be recorded — it is simply not reported stale. + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: true, + launchCodexHomePath: '/data/codex-accounts/account-a/home', + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ host: 'account-a', accounts }), + target: { runtime: 'host' } + }) + ).toEqual({ selectionKey: 'host', accountId: 'account-a' }) + }) + + it('maps a resume redirected to the real system home to the system-default account', () => { + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: true, + launchCodexHomePath: SYSTEM_HOME, + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ + host: 'account-a', + accounts: [managedAccount({ id: 'account-a' })] + }), + target: { runtime: 'host' } + }) + ).toEqual({ selectionKey: 'host', accountId: null }) + }) + + it('maps a resume that injects no CODEX_HOME to the system-default account', () => { + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: true, + launchCodexHomePath: null, + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ host: 'account-a', accounts: [managedAccount({ id: 'account-a' })] }), + target: { runtime: 'host' } + }) + ).toEqual({ selectionKey: 'host', accountId: null }) + }) + + it('refuses to attribute a resume home no account owns', () => { + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: true, + launchCodexHomePath: '/data/codex-runtime-home/home', + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ host: 'account-a', accounts: [managedAccount({ id: 'account-a' })] }), + target: { runtime: 'host' } + }) + ).toBeNull() + }) + + it('does not let a host account answer for a WSL pane on the same path', () => { + const accounts = [ + managedAccount({ + id: 'host-account', + managedHomePath: '//wsl.localhost/Ubuntu/home/u/.codex' + }) + ] + + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: true, + launchCodexHomePath: '//wsl.localhost/Ubuntu/home/u/.codex', + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ wsl: { Ubuntu: 'wsl-account' }, accounts }), + target: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }) + ).toBeNull() + }) + + it('attributes a WSL resume home to the account on that distro lane', () => { + const accounts = [ + managedAccount({ + id: 'wsl-account', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + managedHomePath: '\\\\wsl$\\Ubuntu\\home\\u\\.codex-a' + }) + ] + + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: true, + launchCodexHomePath: '//wsl.localhost/Ubuntu/home/u/.codex-a', + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ wsl: { Ubuntu: 'other-wsl-account' }, accounts }), + target: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }) + ).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account' }) + }) + + it('tolerates settings that carry no managed account roster', () => { + expect( + resolveCodexPaneLaunchAccount({ + pinnedByResume: true, + launchCodexHomePath: '/data/codex-accounts/account-a/home', + systemCodexHomePath: SYSTEM_HOME, + settings: { activeCodexManagedAccountId: null } as GlobalSettings, + target: { runtime: 'host' } + }) + ).toBeNull() + }) +}) diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts new file mode 100644 index 00000000000..a59703752f4 --- /dev/null +++ b/src/main/codex/codex-pane-launch-account.ts @@ -0,0 +1,73 @@ +import type { GlobalSettings } from '../../shared/types' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + getCodexSelectionLaneKey, + getCodexSelectionTargetForAccount, + getSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from '../codex-accounts/runtime-selection' +import type { CodexPaneAccountRecord } from './codex-pane-account-registry' + +type CodexPaneLaunchAccountSettings = Pick< + GlobalSettings, + 'activeCodexManagedAccountId' | 'activeCodexManagedAccountIdsByRuntime' | 'codexManagedAccounts' +> + +/** + * Resolves which Codex account a PTY is actually launching under. + * + * Why: an automatic session resume deliberately pins CODEX_HOME to the home + * that owns the session rather than to the current selection, so a cold-restored + * pane can come back on an account the user already switched away from. Naming + * that real account — instead of the selection Orca ignored — is what lets the + * restart prompt tell the user which account the pane is stuck on. + * + * Returns null when the launch cannot be attributed, which keeps the pane out of + * the stale-pane report entirely. + */ +export function resolveCodexPaneLaunchAccount(args: { + pinnedByResume: boolean + launchCodexHomePath: string | null + systemCodexHomePath: string + settings: CodexPaneLaunchAccountSettings + target: CodexAccountSelectionTarget +}): CodexPaneAccountRecord | null { + const selectionKey = getCodexSelectionLaneKey(args.target) + if (!args.pinnedByResume) { + return { + selectionKey, + accountId: getSelectedCodexAccountIdForTarget(args.settings, args.target) + } + } + const accountId = resolveCodexHomeOwnerAccountId(args) + return accountId === undefined ? null : { selectionKey, accountId } +} + +/** undefined when no account owns the home; null means the system-default account. */ +function resolveCodexHomeOwnerAccountId(args: { + launchCodexHomePath: string | null + systemCodexHomePath: string + settings: CodexPaneLaunchAccountSettings + target: CodexAccountSelectionTarget +}): string | null | undefined { + // Why: no injected CODEX_HOME means Codex reads the user's own home. + if (!args.launchCodexHomePath) { + return null + } + const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) + if (launchHome === normalizeRuntimePathForComparison(args.systemCodexHomePath)) { + return null + } + const laneKey = getCodexSelectionLaneKey(args.target) + const owner = args.settings.codexManagedAccounts?.find( + (account) => + // Why: a WSL pane resolves its account from its own per-distro lane, so a + // host account's home must never answer for it (and vice versa). + getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) === laneKey && + normalizeRuntimePathForComparison(account.managedHomePath) === launchHome + ) + // Why: the shared runtime mirror hot-swaps to whichever account is selected, so + // an unattributable home is no evidence the pane is on a different one. A + // wrong restart notice silently drops every keystroke in that terminal. + return owner ? owner.id : undefined +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 6991294da5a..10f17505981 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -13545,6 +13545,99 @@ describe('registerPtyHandlers', () => { ]) }) + it('records the origin account a resumed Codex pane is pinned to', async () => { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'pty-resumed' })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const getSettings = vi.fn().mockReturnValue({ + activeCodexManagedAccountId: 'account-b', + codexManagedAccounts: [ + { id: 'account-a', managedHomePath: '/managed/origin/home' }, + { id: 'account-b', managedHomePath: '/managed/current/home' } + ] + }) + registerPtyHandlers( + mainWindow as never, + undefined, + vi.fn(() => '/managed/current/home'), + getSettings as never, + undefined, + undefined, + { prepareCodexSessionResume: async () => ({ codexHomePath: '/managed/origin/home' }) } + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + } + }) + + // Why: the resume deliberately overrides the selection, so the pane really + // is on account-a. Recording that is what makes the restart prompt appear. + expect(recordCodexPaneAccountMock.mock.calls).toEqual([ + ['pty-resumed', { selectionKey: 'host', accountId: 'account-a' }] + ]) + expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled() + }) + + it('leaves a resumed Codex pane unattributed when no account owns its home', async () => { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'pty-resumed' })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const getSettings = vi.fn().mockReturnValue({ + activeCodexManagedAccountId: 'account-b', + codexManagedAccounts: [{ id: 'account-b', managedHomePath: '/managed/current/home' }] + }) + registerPtyHandlers( + mainWindow as never, + undefined, + vi.fn(() => '/managed/current/home'), + getSettings as never, + undefined, + undefined, + { prepareCodexSessionResume: async () => ({ codexHomePath: '/managed/shared-mirror/home' }) } + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: '/managed/shared-mirror/home/sessions/2026/07/20/rollout-a.jsonl' + } + }) + + // Why: the shared mirror hot-swaps to the selected account, so guessing here + // would raise a restart notice that blocks a correctly-signed-in pane's input. + expect(recordCodexPaneAccountMock).not.toHaveBeenCalled() + expect(forgetCodexPaneAccountMock).toHaveBeenCalledWith('pty-resumed') + }) + it('seeds cold restore at recovered dimensions with a legacy dimensionless fallback', async () => { const oscLinks = [{ row: 0, startCol: 0, endCol: 8, uri: 'https://example.com/restored' }] const coldRestore = { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 8c846c3d5af..c2a10c03cb6 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -160,15 +160,13 @@ import { import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store' import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes' import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' -import { - getCodexSelectionLaneKey, - getSelectedCodexAccountIdForTarget, - type CodexAccountSelectionTarget -} from '../codex-accounts/runtime-selection' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { forgetCodexPaneAccount, recordCodexPaneAccount } from '../codex/codex-pane-account-registry' +import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' @@ -832,27 +830,33 @@ function getCompatibleSelectedCodexHomePath( // way to tell later that a pane still runs Codex as the previously selected // account. A reattach inherits that baked environment rather than choosing one, // so re-recording it under the current selection would erase the very evidence -// that the pane is stale. A resume-pinned pane is deliberately on its session's -// own account, so it is forgotten rather than recorded as stale. +// that the pane is stale. function recordCodexPaneAccountForSpawn(args: { ptyId: string | undefined isDaemonHostSpawn: boolean isReattach: boolean pinnedByResume: boolean + launchCodexHomePath: string | null target: CodexAccountSelectionTarget settings: GlobalSettings | undefined }): void { if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { return } - if (args.pinnedByResume || !args.settings) { + const record = args.settings + ? resolveCodexPaneLaunchAccount({ + pinnedByResume: args.pinnedByResume, + launchCodexHomePath: args.launchCodexHomePath, + systemCodexHomePath: getSystemCodexHomePath(), + settings: args.settings, + target: args.target + }) + : null + if (!record) { forgetCodexPaneAccount(args.ptyId) return } - recordCodexPaneAccount(args.ptyId, { - selectionKey: getCodexSelectionLaneKey(args.target), - accountId: getSelectedCodexAccountIdForTarget(args.settings, args.target) - }) + recordCodexPaneAccount(args.ptyId, record) } function readEnvWithProcessFallback( @@ -3699,6 +3703,7 @@ export function registerPtyHandlers( isDaemonHostSpawn, isReattach: result.isReattach === true, pinnedByResume: Boolean(codexResumeHome), + launchCodexHomePath: selectedCodexHomePath, target: codexSelectionTarget, settings: getSettings?.() }) @@ -4741,6 +4746,7 @@ export function registerPtyHandlers( isDaemonHostSpawn, isReattach: result.isReattach === true, pinnedByResume: Boolean(codexResumeHome), + launchCodexHomePath: selectedCodexHomePath, target: codexSelectionTarget, settings: getSettings?.() })