mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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?.()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user