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

When Orca auto-resumes a Codex session belonging to a different account than
the one selected, it deliberately pins CODEX_HOME to the ORIGINATING account so
it never continues a conversation under someone else's credentials. That pin
was silent: the pane came back on the old account while the status bar showed
the new one, which is one of the mechanisms behind #10757's "stuck on the
previous account".

The pin stays — it is correct under concurrent multi-account. This records the
pane's real launch account instead of forgetting it, so the existing chip says
"Codex is still signed in as A. Restart this session to use B."

Review (2 rounds) found three things: the branch was rebased off a stale fork
point where a sibling's notice still muted an innocent split-tab pane — and
since this PR strictly increases panes carrying notices it was amplifying that
keyboard-death path; the runtime-controller spawn site (pty.ts:3705) had ZERO
coverage, with a full behavioural revert leaving all 392 tests green despite
being reachable via the CLI/relay/orchestration resume path; and a comment
claimed the shared runtime mirror "hot-swaps to whichever account is selected",
which is untrue under the shipped config.

Live-validated in a real Orca dev build: all five cases pass, with every
CODEX_HOME claim read off the SPAWNED PROCESS via a shim binary (SIP blocks
ps eww). Restart relaunches under the newly selected account with input
restored and no prompt loop; a same-account resume produces no chip; in a split
tab exactly one pane is noticed while the sibling types normally; and the
runtime-controller path records the launch account when driven over the RPC
socket.

Not verified: WSL is not run and not claimed — prepareCodexSessionResumeForLaunch
returns null for WSL targets, so this mechanism is HOST-LANE ONLY.

Known follow-ups, deliberately not fixed here: a pane resumed into the legacy
mirror is genuinely stale but unnameable in the current record shape, and a
dismissal does not survive a cold restore for a resume-pinned pane (correct
as-is — the new shell really is on the old account).
This commit is contained in:
Brennan Benson
2026-07-26 21:46:52 -07:00
committed by GitHub
parent 74e6478e85
commit fef8717a50
4 changed files with 474 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,76 @@
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: an unowned home cannot be named, and naming the account a pane is stuck
// on is the prompt's whole job — so decline rather than guess. A wrong notice
// silently drops every keystroke in that terminal. Note the shared runtime
// mirror only hot-swaps to the current selection on the legacy flag-OFF lane;
// a pane resumed into it after the per-account rollout is genuinely stale but
// still unnameable, so that cohort stays unreported.
return owner ? owner.id : undefined
}
+213
View File
@@ -13545,6 +13545,219 @@ 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: an unowned home cannot be named, 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')
})
// Why: the runtime controller is the CLI/relay resume path, and it repeats the
// same recording call the ipc handler makes. Without its own coverage a revert
// there is invisible.
it('records the origin account for a resumed Codex pane spawned by the runtime controller', async () => {
type RuntimeSpawnController = {
spawn(args: Record<string, unknown>): Promise<{ id: string }>
}
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'pty-runtime-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 runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
const getSettings = vi.fn().mockReturnValue({
activeCodexManagedAccountId: 'account-b',
codexManagedAccounts: [
{ id: 'account-a', managedHomePath: '/managed/origin/home' },
{ id: 'account-b', managedHomePath: '/managed/current/home' }
]
})
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
vi.fn(() => '/managed/current/home'),
getSettings as never,
undefined,
undefined,
{ prepareCodexSessionResume: async () => ({ codexHomePath: '/managed/origin/home' }) }
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-runtime',
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'
}
})
expect(recordCodexPaneAccountMock.mock.calls).toEqual([
['pty-runtime-resumed', { selectionKey: 'host', accountId: 'account-a' }]
])
expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled()
})
it('leaves a runtime-controller resumed Codex pane unattributed when no account owns its home', async () => {
type RuntimeSpawnController = {
spawn(args: Record<string, unknown>): Promise<{ id: string }>
}
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'pty-runtime-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 runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
const getSettings = vi.fn().mockReturnValue({
activeCodexManagedAccountId: 'account-b',
codexManagedAccounts: [{ id: 'account-b', managedHomePath: '/managed/current/home' }]
})
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
vi.fn(() => '/managed/current/home'),
getSettings as never,
undefined,
undefined,
{ prepareCodexSessionResume: async () => ({ codexHomePath: '/managed/shared-mirror/home' }) }
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-runtime',
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'
}
})
expect(recordCodexPaneAccountMock).not.toHaveBeenCalled()
expect(forgetCodexPaneAccountMock).toHaveBeenCalledWith('pty-runtime-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?.()
})