fix(codex): tell the user when a resumed pane is pinned to another account

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.
This commit is contained in:
Brennan Benson
2026-07-26 18:28:26 -07:00
parent 841c7882a8
commit d2885b6207
4 changed files with 351 additions and 12 deletions
@@ -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>): 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<string, string | null>
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()
})
})
@@ -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
}
+93
View File
@@ -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 = {
+18 -12
View File
@@ -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?.()
})