diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts
index ec5572f5b3e..8aee047e5fa 100644
--- a/src/main/claude-accounts/claude-account-service-login-process.test.ts
+++ b/src/main/claude-accounts/claude-account-service-login-process.test.ts
@@ -40,6 +40,9 @@ vi.mock('./keychain', () => ({
writeManagedClaudeKeychainCredentials: vi.fn(async () => {})
}))
+// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the doubles below implement every member the add-account login path reaches; this case drives the service only through addAccount.
+const asServiceDouble = (double: unknown): T => double as T
+
describe('ClaudeAccountService credential capture', () => {
beforeEach(() => {
setPlatform('darwin')
@@ -498,6 +501,79 @@ describe('ClaudeAccountService credential capture', () => {
}
})
+ it('supersedes the login a closed Settings pane abandoned instead of queueing behind it', async () => {
+ setPlatform('linux')
+ vi.resetModules()
+ vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null)
+ const children: (EventEmitter & { stdin: PassThrough; kill: ReturnType })[] = []
+ // No pid: the POSIX teardown signals -pid as a process group, which must
+ // never reach a real group from a test double.
+ const spawnMock = vi.fn(() => {
+ const child = Object.assign(new EventEmitter(), {
+ stdin: new PassThrough(),
+ stdout: new PassThrough(),
+ stderr: new PassThrough(),
+ kill: vi.fn()
+ })
+ children.push(child)
+ return child
+ })
+ vi.doMock('node:child_process', () => ({ spawn: spawnMock }))
+
+ try {
+ const { ClaudeAccountService } = await import('./service')
+ let settings = {
+ claudeManagedAccounts: [],
+ activeClaudeManagedAccountId: null,
+ activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} }
+ }
+ const store = {
+ getSettings: vi.fn(() => settings),
+ updateSettings: vi.fn((updates: Partial) => {
+ settings = { ...settings, ...updates }
+ return settings
+ })
+ }
+ const runtimeAuth = {
+ clearLastWrittenCredentialsJson: vi.fn(),
+ syncForCurrentSelection: vi.fn(async () => {}),
+ forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
+ }
+ const rateLimits = {
+ evictInactiveClaudeCache: vi.fn(),
+ refreshForClaudeAccountChange: vi.fn()
+ }
+ const service = new ClaudeAccountService(
+ asServiceDouble(store),
+ asServiceDouble(rateLimits),
+ asServiceDouble(runtimeAuth)
+ )
+
+ const abandoned = service.addAccount({ runtime: 'host' })
+ const abandonedRejection = expect(abandoned).rejects.toThrow('Claude sign-in was cancelled.')
+ await vi.waitFor(() => {
+ expect(children.length).toBe(1)
+ })
+
+ // The user reopens Settings and clicks Add Account again.
+ const retry = service.addAccount({ runtime: 'host' })
+ const retryRejection = expect(retry).rejects.toThrow()
+
+ await abandonedRejection
+ expect(children[0].kill).toHaveBeenCalled()
+ // Why: the point of the fix — the second login starts now, not after the
+ // abandoned one's whole sign-in deadline elapses.
+ await vi.waitFor(() => {
+ expect(children.length).toBe(2)
+ })
+
+ children[1].emit('close', 1)
+ await retryRejection
+ } finally {
+ vi.doUnmock('node:child_process')
+ }
+ })
+
it('uses taskkill to cancel the Windows Claude login process tree', async () => {
setPlatform('win32')
vi.resetModules()
diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts
index 8f1077ba1a5..d1c79eaf17d 100644
--- a/src/main/claude-accounts/service.ts
+++ b/src/main/claude-accounts/service.ts
@@ -76,6 +76,7 @@ export class ClaudeAccountService {
}
async addAccount(target?: ClaudeAccountAddTarget): Promise {
+ this.supersedePendingLogin()
return this.serializeMutation(() => this.registration.add(target))
}
@@ -87,14 +88,17 @@ export class ClaudeAccountService {
}
async reauthenticateAccount(accountId: string): Promise {
+ this.supersedePendingLogin()
return this.serializeMutation(() => this.registration.reauthenticate(accountId))
}
async removeAccount(accountId: string): Promise {
+ this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.remove(accountId))
}
async selectAccount(accountId: string | null): Promise {
+ this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId))
}
@@ -102,6 +106,7 @@ export class ClaudeAccountService {
accountId: string | null,
target?: ClaudeAccountSelectionTarget
): Promise {
+ this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId, target))
}
@@ -109,6 +114,17 @@ export class ClaudeAccountService {
return this.cancelPendingClaudeLogin?.() ?? false
}
+ // Why before the queue, not inside it: the abandoned login owns the queue slot
+ // every later account action waits for. Called from the four the user drives,
+ // never from serializeMutation, which background work also uses.
+ private supersedePendingLogin(): void {
+ if (this.cancelPendingLogin()) {
+ console.info(
+ '[claude-accounts] Cancelled a pending Claude login superseded by a new request.'
+ )
+ }
+ }
+
getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string {
return this.runtimeAuth.getRuntimeConfigDir(target)
}
diff --git a/src/main/codex-accounts/codex-login-auth-snapshot.ts b/src/main/codex-accounts/codex-login-auth-snapshot.ts
new file mode 100644
index 00000000000..5347281f2e3
--- /dev/null
+++ b/src/main/codex-accounts/codex-login-auth-snapshot.ts
@@ -0,0 +1,27 @@
+import { readFileSync } from 'node:fs'
+
+/** The bytes of a managed home's auth.json, null when absent, undefined when unreadable. */
+export function readLoginAuthSnapshot(authJsonPath: string): string | null | undefined {
+ try {
+ return readFileSync(authJsonPath, 'utf-8')
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
+ return null
+ }
+ // Why: codex can atomically replace auth.json while the poll runs; a later
+ // poll will observe the stable credential. An unreadable initial file must
+ // disable the shortcut rather than look like a fresh login.
+ return undefined
+ }
+}
+
+/** Whether a login has written credentials of its own since the snapshot was taken. */
+export function loginAuthChanged(
+ initial: string | null | undefined,
+ current: string | null | undefined
+): boolean {
+ // Why: metadata-only touches can happen before OAuth finishes. Requiring new
+ // credential bytes prevents reauthentication from being killed prematurely.
+ return initial !== undefined && current !== undefined && current !== null && current !== initial
+}
diff --git a/src/main/codex-accounts/codex-login-auth-url.test.ts b/src/main/codex-accounts/codex-login-auth-url.test.ts
new file mode 100644
index 00000000000..9dddaef048c
--- /dev/null
+++ b/src/main/codex-accounts/codex-login-auth-url.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { parseCodexLoginAuthUrl } from './codex-login-auth-url'
+
+const AUTH_URL = 'https://auth.openai.com/oauth/authorize?client_id=orca&state=abc123'
+
+describe('parseCodexLoginAuthUrl', () => {
+ it('reads the link codex prints under its browser notice', () => {
+ expect(
+ parseCodexLoginAuthUrl(
+ `Starting local login server on http://localhost:1455.\nIf your browser did not open, navigate to this URL to authenticate:\n\n${AUTH_URL}\n`
+ )
+ ).toBe(AUTH_URL)
+ })
+
+ it('ignores the local server line, which authenticates nothing', () => {
+ expect(parseCodexLoginAuthUrl('Starting local login server on http://localhost:1455.\n')).toBe(
+ null
+ )
+ })
+
+ it('waits for a chunk boundary rather than publishing a truncated link', () => {
+ const truncated = `If your browser did not open, navigate to this URL to authenticate:\n\n${AUTH_URL.slice(0, 40)}`
+ expect(parseCodexLoginAuthUrl(truncated)).toBe(null)
+ expect(parseCodexLoginAuthUrl(`${truncated}${AUTH_URL.slice(40)}\n`)).toBe(AUTH_URL)
+ })
+
+ it('survives the escape sequences a coloured CLI writes around the link', () => {
+ expect(
+ parseCodexLoginAuthUrl(
+ `[1mnavigate to this URL to authenticate:[0m\n\n[4m${AUTH_URL}[0m\n`
+ )
+ ).toBe(AUTH_URL)
+ })
+
+ it('drops sentence punctuation that follows the link', () => {
+ expect(
+ parseCodexLoginAuthUrl(`navigate to this URL to authenticate: ${AUTH_URL}. Then return here.`)
+ ).toBe(AUTH_URL)
+ })
+
+ it('reports nothing for output without a link yet', () => {
+ expect(parseCodexLoginAuthUrl('')).toBe(null)
+ expect(parseCodexLoginAuthUrl('Codex login failed: network unreachable\n')).toBe(null)
+ })
+
+ it('offers no link at all rather than an unrelated one when the notice is missing', () => {
+ expect(
+ parseCodexLoginAuthUrl('A new version of codex is available: https://openai.com/codex\n')
+ ).toBe(null)
+ })
+})
diff --git a/src/main/codex-accounts/codex-login-auth-url.ts b/src/main/codex-accounts/codex-login-auth-url.ts
new file mode 100644
index 00000000000..024f0b6e73e
--- /dev/null
+++ b/src/main/codex-accounts/codex-login-auth-url.ts
@@ -0,0 +1,32 @@
+import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences'
+
+const AUTH_URL_MARKER = 'navigate to this url to authenticate:'
+
+/**
+ * The browser sign-in link `codex login` prints, or null while its output has
+ * not carried a complete one yet.
+ */
+export function parseCodexLoginAuthUrl(output: string): string | null {
+ const plain = stripAnsiEscapeSequences(output)
+ const markerIndex = plain.toLowerCase().indexOf(AUTH_URL_MARKER)
+ // Why the marker is required: without it the first https link codex happens to
+ // print — an update notice, a docs link — would be offered as the sign-in link.
+ // If codex rewords the line, no link beats the wrong one.
+ if (markerIndex === -1) {
+ return null
+ }
+ const searchable = plain.slice(markerIndex + AUTH_URL_MARKER.length)
+ // Why: the trailing whitespace is required, not incidental. Output arrives in
+ // chunks, and a flush that ends mid-token would otherwise publish a truncated
+ // link that authenticates nothing.
+ const match = /(https:\/\/\S+?)[.,;:)\]]*\s/.exec(searchable)
+ if (!match) {
+ return null
+ }
+ try {
+ // Only the shape is trusted here; the pattern already fixed the scheme.
+ return new URL(match[1]).toString()
+ } catch {
+ return null
+ }
+}
diff --git a/src/main/codex-accounts/codex-login-session.ts b/src/main/codex-accounts/codex-login-session.ts
index 6df83c05e18..3595351ef37 100644
--- a/src/main/codex-accounts/codex-login-session.ts
+++ b/src/main/codex-accounts/codex-login-session.ts
@@ -1,19 +1,24 @@
-import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn'
import { buildWindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn'
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
+import { CODEX_LOGIN_CANCELLED_MESSAGE } from '../../shared/codex-auth-errors'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { resolveCodexCommand } from '../codex-cli/command'
import { getSpawnArgsForWindows } from '../win32-utils'
import { runWslProcess } from '../wsl/wsl-runner'
+import { parseCodexLoginAuthUrl } from './codex-login-auth-url'
+import { loginAuthChanged, readLoginAuthSnapshot } from './codex-login-auth-snapshot'
import {
buildWslCodexAvailabilityScript,
buildWslCodexLoginArgs,
WSL_CODEX_AVAILABILITY_TIMEOUT_MS
} from './wsl-codex-command'
-const LOGIN_TIMEOUT_MS = 120_000
+// Why: matches Claude's window. Signing in through a copied link — a second
+// browser, a password manager, an incognito window — routinely outlasts two
+// minutes, and the old 120s deadline failed those users mid-flow.
+const LOGIN_TIMEOUT_MS = 180_000
const MAX_LOGIN_OUTPUT_CHARS = 4_000
const WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS = 500
const WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS = 5_000
@@ -50,35 +55,47 @@ type CodexLoginSessionDependencies = {
child: CodexLoginChild,
interactiveLogin?: WindowsHostInteractiveLoginSpawn | null
) => void
+ /** Registers the handle that abandons this login; the caller clears it. */
+ setCancel: (cancel: () => boolean) => void
+ /** The browser link codex printed, published as soon as it is complete. */
+ onAuthUrl: (url: string) => void
}
-function readLoginAuthSnapshot(authJsonPath: string): string | null | undefined {
- try {
- return readFileSync(authJsonPath, 'utf-8')
- } catch (error) {
- const code = (error as NodeJS.ErrnoException).code
- if (code === 'ENOENT' || code === 'ENOTDIR') {
- return null
- }
- // Why: codex can atomically replace auth.json while the poll runs; a later
- // poll will observe the stable credential. An unreadable initial file must
- // disable the shortcut rather than look like a fresh login.
- return undefined
- }
-}
-
-function loginAuthChanged(
- initial: string | null | undefined,
- current: string | null | undefined
-): boolean {
- // Why: metadata-only touches can happen before OAuth finishes. Requiring new
- // credential bytes prevents reauthentication from being killed prematurely.
- return initial !== undefined && current !== undefined && current !== null && current !== initial
+type LoginCancellation = {
+ isCancelled: () => boolean
+ setSpawnedCancel: (cancel: () => boolean) => void
}
export async function runCodexLoginSession(
managedHomePath: string,
dependencies: CodexLoginSessionDependencies
+): Promise {
+ let cancelSpawnedLogin: (() => boolean) | null = null
+ let cancelled = false
+ dependencies.setCancel(() => {
+ // Why: only an accepted cancel latches. A spawned login that refuses —
+ // because it already authenticated — must stay cancellable, or the Cancel
+ // button and the next add both go dead for the rest of the deadline.
+ if (cancelled || cancelSpawnedLogin?.() === false) {
+ return false
+ }
+ // A cancel before the spawn has no tree to kill; the pre-spawn probe reads
+ // this flag instead of opening a browser nobody is waiting for.
+ cancelled = true
+ return true
+ })
+ await runCodexLoginProcess(managedHomePath, dependencies, {
+ isCancelled: () => cancelled,
+ setSpawnedCancel: (cancel) => {
+ cancelSpawnedLogin = cancel
+ }
+ })
+}
+
+async function runCodexLoginProcess(
+ managedHomePath: string,
+ dependencies: CodexLoginSessionDependencies,
+ cancellation: LoginCancellation
): Promise {
const wslInfo = parseWslUncPath(managedHomePath)
if (wslInfo) {
@@ -87,9 +104,15 @@ export async function runCodexLoginSession(
// Why: reauthentication starts with an existing auth.json. Only new auth
// bytes prove this login completed; existence alone would kill the
// Windows OAuth flow five seconds after it opened.
+ // WSL keeps its baseline unread — the UNC round trip belongs nowhere in the
+ // pre-spawn path — so there is nothing to compare a WSL home against.
const initialAuthSnapshot = wslInfo
? null
: readLoginAuthSnapshot(join(managedHomePath, 'auth.json'))
+ const hasAuthBaseline = !wslInfo
+ if (cancellation.isCancelled()) {
+ throw new Error(CODEX_LOGIN_CANCELLED_MESSAGE)
+ }
await new Promise((resolvePromise, rejectPromise) => {
const spawnConfig = wslInfo
@@ -119,6 +142,22 @@ export async function runCodexLoginSession(
}
}
+ // Why its own buffer: a stderr chunk interleaved between two halves of the
+ // link would end the match early, and the published link never changes.
+ let stdoutText = ''
+ let publishedAuthUrl = false
+ const publishAuthUrl = (chunk: Buffer): void => {
+ if (publishedAuthUrl) {
+ return
+ }
+ stdoutText = `${stdoutText}${chunk.toString()}`.slice(-MAX_LOGIN_OUTPUT_CHARS)
+ const authUrl = parseCodexLoginAuthUrl(stdoutText)
+ if (authUrl) {
+ publishedAuthUrl = true
+ dependencies.onAuthUrl(authUrl)
+ }
+ }
+
let timeout: ReturnType | null = null
let authWatchInterval: ReturnType | null = null
let postAuthExitTimeout: ReturnType | null = null
@@ -138,6 +177,7 @@ export async function runCodexLoginSession(
postAuthExitTimeout = null
}
child.stdout?.off('data', appendOutput)
+ child.stdout?.off('data', publishAuthUrl)
child.stderr?.off('data', appendOutput)
child.off('error', onError)
child.off('close', onClose)
@@ -153,6 +193,23 @@ export async function runCodexLoginSession(
callback()
}
+ cancellation.setSpawnedCancel(() => {
+ // Why: once codex has written new credential bytes the sign-in already
+ // succeeded, and rejecting here would send the caller's rollback at the
+ // home it just authenticated. Nothing left to cancel — let it settle.
+ // Without a baseline (WSL) an existing auth.json says nothing about this
+ // login, so refusing on it would make a WSL reauth uncancellable.
+ const alreadyAuthenticated =
+ hasAuthBaseline &&
+ loginAuthChanged(initialAuthSnapshot, readLoginAuthSnapshot(authJsonPath))
+ if (settled || alreadyAuthenticated) {
+ return false
+ }
+ dependencies.killProcessTree(child, spawnConfig.interactiveLogin)
+ settle(() => rejectPromise(new Error(CODEX_LOGIN_CANCELLED_MESSAGE)))
+ return true
+ })
+
const timeoutError = new Error('Codex sign-in took too long to finish. Please try again.')
timeout = setTimeout(() => {
dependencies.killProcessTree(child, spawnConfig.interactiveLogin)
@@ -221,6 +278,7 @@ export async function runCodexLoginSession(
}
child.stdout?.on('data', appendOutput)
+ child.stdout?.on('data', publishAuthUrl)
child.stderr?.on('data', appendOutput)
child.on('error', onError)
child.on('close', onClose)
diff --git a/src/main/codex-accounts/service-abandoned-login-supersede.test.ts b/src/main/codex-accounts/service-abandoned-login-supersede.test.ts
new file mode 100644
index 00000000000..11743258fe5
--- /dev/null
+++ b/src/main/codex-accounts/service-abandoned-login-supersede.test.ts
@@ -0,0 +1,192 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { EventEmitter } from 'node:events'
+import { existsSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { PassThrough } from 'node:stream'
+import {
+ createCodexAuthJson,
+ createRateLimits,
+ createRuntimeHome,
+ createSettings,
+ createStore,
+ registerCodexAccountsTestHomes,
+ testState
+} from './service-test-harness'
+
+vi.mock('electron', () => ({
+ app: {
+ getPath: () => testState.userDataDir
+ }
+}))
+
+vi.mock('node:os', async () => {
+ const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
+ return {
+ ...actual,
+ homedir: () => testState.fakeHomeDir
+ }
+})
+
+// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness doubles implement every member the add-account login path reaches; this test drives the service only through addAccount, cancelPendingLogin and the login URL subscription.
+const asServiceDouble = (double: unknown): T => double as T
+
+type StubLoginChild = EventEmitter & {
+ stdout: PassThrough
+ stderr: PassThrough
+ kill: () => boolean
+ exitCode: number | null
+ signalCode: string | null
+}
+
+function createStubLoginChild(): StubLoginChild {
+ const child: StubLoginChild = Object.assign(new EventEmitter(), {
+ stdout: new PassThrough(),
+ stderr: new PassThrough(),
+ exitCode: null,
+ signalCode: null,
+ kill: () => true
+ })
+ // The real codex CLI forwards SIGTERM to its native child and exits.
+ child.kill = vi.fn(() => {
+ child.exitCode = 143
+ return true
+ })
+ return child
+}
+
+/** A service whose `codex login` never finishes on its own. */
+async function createServiceWithHangingLogin(): Promise<{
+ service: {
+ addAccount: () => Promise<{ accounts: { email: string }[] }>
+ selectAccount: (accountId: string | null) => Promise
+ cancelPendingLogin: () => boolean
+ getPendingLoginUrl: () => string | null
+ onPendingLoginUrlChanged: (listener: (url: string | null) => void) => void
+ }
+ children: StubLoginChild[]
+ /** The `CODEX_HOME` each login was spawned against. */
+ loginHomes: string[]
+}> {
+ vi.resetModules()
+ const children: StubLoginChild[] = []
+ const loginHomes: string[] = []
+ vi.doMock('node:child_process', () => ({
+ execFileSync: vi.fn(),
+ spawn: vi.fn((_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => {
+ loginHomes.push(options.env.CODEX_HOME ?? '')
+ const child = createStubLoginChild()
+ children.push(child)
+ return child
+ })
+ }))
+ vi.doMock('../codex-cli/command', () => ({
+ resolveCodexCommand: () => 'codex'
+ }))
+ const { CodexAccountService } = await import('./service')
+ const service = new CodexAccountService(
+ asServiceDouble(createStore(createSettings())),
+ asServiceDouble(createRateLimits()),
+ asServiceDouble(createRuntimeHome())
+ )
+ return { service, children, loginHomes }
+}
+
+describe('CodexAccountService abandoned login', () => {
+ registerCodexAccountsTestHomes()
+
+ afterEach(() => {
+ vi.doUnmock('node:child_process')
+ vi.doUnmock('../codex-cli/command')
+ })
+
+ it('supersedes the login a closed Settings pane abandoned instead of queueing behind it', async () => {
+ const { service, children } = await createServiceWithHangingLogin()
+ const abandoned = service.addAccount()
+ const abandonedRejection = expect(abandoned).rejects.toThrow('Codex sign-in was cancelled.')
+ await vi.waitUntil(() => children.length === 1)
+
+ // The user reopens Settings and clicks Add Account again.
+ const retry = service.addAccount()
+ const retryRejection = expect(retry).rejects.toThrow()
+
+ await abandonedRejection
+ expect(children[0].kill).toHaveBeenCalledTimes(1)
+ // Why: the point of the fix — the second login starts now, not after the
+ // abandoned one's whole sign-in deadline elapses.
+ await vi.waitUntil(() => children.length === 2)
+ expect(children[1].kill).not.toHaveBeenCalled()
+
+ service.cancelPendingLogin()
+ await retryRejection
+ })
+
+ it('frees the queue for a plain account switch too, not only for another add', async () => {
+ const { service, children } = await createServiceWithHangingLogin()
+ const abandoned = service.addAccount()
+ const abandonedRejection = expect(abandoned).rejects.toThrow('Codex sign-in was cancelled.')
+ await vi.waitUntil(() => children.length === 1)
+
+ // Why: switching to the system default is the commonest thing a user does
+ // after giving up on a sign-in, and it shares the add's mutation queue.
+ await service.selectAccount(null)
+ await abandonedRejection
+ expect(children[0].kill).toHaveBeenCalled()
+ })
+
+ it('refuses to cancel a sign-in that already wrote credentials, and keeps the account', async () => {
+ const { service, children, loginHomes } = await createServiceWithHangingLogin()
+ const pending = service.addAccount()
+ await vi.waitUntil(() => children.length === 1)
+
+ // The browser half of the OAuth flow finishes while the CLI lingers.
+ writeFileSync(
+ join(loginHomes[0], 'auth.json'),
+ createCodexAuthJson('user@example.com', 'provider-account-1', 'refresh-token'),
+ 'utf-8'
+ )
+
+ // Why: cancelling here would send the rollback at a home that just
+ // authenticated. There is nothing left to cancel.
+ expect(service.cancelPendingLogin()).toBe(false)
+ expect(children[0].kill).not.toHaveBeenCalled()
+
+ children[0].emit('close', 0)
+ const accounts = await pending
+ expect(accounts.accounts.map((account) => account.email)).toEqual(['user@example.com'])
+ expect(existsSync(join(loginHomes[0], 'auth.json'))).toBe(true)
+ })
+
+ it('reports whether a pending login was there to cancel', async () => {
+ const { service, children } = await createServiceWithHangingLogin()
+ const pending = service.addAccount()
+ const rejection = expect(pending).rejects.toThrow('Codex sign-in was cancelled.')
+ await vi.waitUntil(() => children.length === 1)
+
+ expect(service.cancelPendingLogin()).toBe(true)
+ await rejection
+ expect(service.cancelPendingLogin()).toBe(false)
+ })
+
+ it('publishes the sign-in link codex prints and drops it when the login ends', async () => {
+ const { service, children } = await createServiceWithHangingLogin()
+ const published: (string | null)[] = []
+ service.onPendingLoginUrlChanged((url) => published.push(url))
+
+ const pending = service.addAccount()
+ const rejection = expect(pending).rejects.toThrow('Codex sign-in was cancelled.')
+ await vi.waitUntil(() => children.length === 1)
+
+ const authUrl = 'https://auth.openai.com/oauth/authorize?client_id=orca&state=abc'
+ children[0].stdout.write(
+ `Starting local login server on http://localhost:1455.\nIf your browser did not open, navigate to this URL to authenticate:\n\n${authUrl}\n`
+ )
+ await vi.waitUntil(() => service.getPendingLoginUrl() === authUrl)
+ expect(published).toEqual([authUrl])
+
+ service.cancelPendingLogin()
+ await rejection
+ // Why: the link dies with the login server it points back at.
+ expect(service.getPendingLoginUrl()).toBeNull()
+ expect(published).toEqual([authUrl, null])
+ })
+})
diff --git a/src/main/codex-accounts/service-login-process-teardown.test.ts b/src/main/codex-accounts/service-login-process-teardown.test.ts
index a49877a196d..42753d4d59b 100644
--- a/src/main/codex-accounts/service-login-process-teardown.test.ts
+++ b/src/main/codex-accounts/service-login-process-teardown.test.ts
@@ -70,7 +70,7 @@ describe('CodexAccountService config sync', () => {
'Codex sign-in took too long to finish.'
)
- await vi.advanceTimersByTimeAsync(120_000)
+ await vi.advanceTimersByTimeAsync(180_000)
await rejection
expect(child.kill).toHaveBeenCalledTimes(1)
@@ -89,7 +89,10 @@ describe('CodexAccountService config sync', () => {
vi.resetModules()
vi.useFakeTimers()
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
- Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
+ Object.defineProperty(process, 'platform', {
+ value: 'win32',
+ configurable: true
+ })
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
stderr: PassThrough
@@ -162,7 +165,10 @@ describe('CodexAccountService config sync', () => {
vi.resetModules()
vi.useFakeTimers()
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
- Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
+ Object.defineProperty(process, 'platform', {
+ value: 'win32',
+ configurable: true
+ })
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
stderr: PassThrough
@@ -182,7 +188,9 @@ describe('CodexAccountService config sync', () => {
execFileSync: execFileSyncMock,
spawn: vi.fn(() => child)
}))
- vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
+ vi.doMock('../codex-cli/command', () => ({
+ resolveCodexCommand: () => 'codex'
+ }))
const authPath = join(testState.fakeHomeDir, 'auth.json')
writeFileSync(
authPath,
@@ -198,7 +206,9 @@ describe('CodexAccountService config sync', () => {
createRuntimeHome() as never
)
const loginPromise = (
- service as unknown as { runCodexLogin(managedHomePath: string): Promise }
+ service as unknown as {
+ runCodexLogin(managedHomePath: string): Promise
+ }
).runCodexLogin(testState.fakeHomeDir)
await vi.advanceTimersByTimeAsync(6_000)
diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts
index 6f5a4a9f0b7..67f8ebb218a 100644
--- a/src/main/codex-accounts/service.ts
+++ b/src/main/codex-accounts/service.ts
@@ -80,6 +80,9 @@ function killLoginProcessTree(
export class CodexAccountService {
// Why: serialize the read-modify-write of settings; overlapping calls (e.g. double-click Add) would lose updates.
private mutationQueue: Promise = Promise.resolve()
+ private cancelPendingCodexLogin: (() => boolean) | null = null
+ private pendingLoginUrl: string | null = null
+ private readonly pendingLoginUrlListeners = new Set<(url: string | null) => void>()
private readonly identity: CodexAccountIdentity
private readonly configMirror: CodexConfigMirror
private readonly managedHomePaths: CodexManagedHomePath
@@ -161,9 +164,46 @@ export class CodexAccountService {
}
async addAccount(target?: CodexAccountAddTarget): Promise {
+ this.supersedePendingLogin()
return this.serializeMutation(() => this.registration.add(target))
}
+ /** Abandons the login waiting on a browser, if any. True when one was stopped. */
+ cancelPendingLogin(): boolean {
+ return this.cancelPendingCodexLogin?.() ?? false
+ }
+
+ /** The sign-in link of the login waiting on a browser, for a late-joining renderer. */
+ getPendingLoginUrl(): string | null {
+ return this.pendingLoginUrl
+ }
+
+ /** Registration lasts the process's lifetime; there is no teardown to hand back. */
+ onPendingLoginUrlChanged(listener: (url: string | null) => void): void {
+ this.pendingLoginUrlListeners.add(listener)
+ }
+
+ private setPendingLoginUrl(url: string | null): void {
+ // Why the guard: every login that ends before printing a link clears an
+ // already-empty value, and each change reaches every window.
+ if (this.pendingLoginUrl === url) {
+ return
+ }
+ this.pendingLoginUrl = url
+ for (const listener of this.pendingLoginUrlListeners) {
+ listener(url)
+ }
+ }
+
+ // Why before the queue, not inside it: the abandoned login owns the queue slot
+ // every later account action waits for. Called from the four the user drives,
+ // never from serializeMutation, which background reset-credit work also uses.
+ private supersedePendingLogin(): void {
+ if (this.cancelPendingLogin()) {
+ console.info('[codex-accounts] Cancelled a pending Codex login superseded by a new request.')
+ }
+ }
+
/**
* Registers a managed Codex account from an already-authenticated `CODEX_HOME`
* instead of driving `codex login` here. Lets the `orca account add --agent codex`
@@ -181,14 +221,17 @@ export class CodexAccountService {
accountId: string,
options?: CodexAccountReauthenticateOptions
): Promise {
+ this.supersedePendingLogin()
return this.serializeMutation(() => this.registration.reauthenticate(accountId, options))
}
async removeAccount(accountId: string): Promise {
+ this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.remove(accountId))
}
async selectAccount(accountId: string | null): Promise {
+ this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId))
}
@@ -196,6 +239,7 @@ export class CodexAccountService {
accountId: string | null,
target?: CodexAccountSelectionTarget
): Promise {
+ this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId, target))
}
@@ -234,16 +278,27 @@ export class CodexAccountService {
}
private async runCodexLogin(managedHomePath: string): Promise {
- await runCodexLoginSession(managedHomePath, {
- wslCommand: 'wsl.exe',
- spawn: ({ command, args, env, stdio }) =>
- spawn(command, args, {
- stdio,
- // Why: hide the outer wrapper only. A dedicated login console stays visible.
- windowsHide: true,
- env
- }),
- killProcessTree: killLoginProcessTree
- })
+ try {
+ await runCodexLoginSession(managedHomePath, {
+ wslCommand: 'wsl.exe',
+ spawn: ({ command, args, env, stdio }) =>
+ spawn(command, args, {
+ stdio,
+ // Why: hide the outer wrapper only. A dedicated login console stays visible.
+ windowsHide: true,
+ env
+ }),
+ killProcessTree: killLoginProcessTree,
+ setCancel: (cancel) => {
+ this.cancelPendingCodexLogin = cancel
+ },
+ onAuthUrl: (url) => this.setPendingLoginUrl(url)
+ })
+ } finally {
+ // Why: both die with the login server — no surface may keep offering a
+ // link nothing is listening on, or a cancel with nothing to cancel.
+ this.cancelPendingCodexLogin = null
+ this.setPendingLoginUrl(null)
+ }
}
}
diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts
index e89e8bca4be..4d828ecf82b 100644
--- a/src/main/ipc/codex-accounts.ts
+++ b/src/main/ipc/codex-accounts.ts
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accounts/service'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
+import { broadcastCodexPendingLoginUrl } from './codex-pending-login-url-broadcast'
import { listRecordedCodexPaneLanes } from '../codex/codex-pane-account-registry'
import { forgetStaleCodexPanes, listStaleCodexPanes } from '../codex/codex-stale-pane-accounts'
import type { GlobalSettings } from '../../shared/global-settings-types'
@@ -38,6 +39,11 @@ export function registerCodexAccountHandlers(
ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) =>
codexAccounts.addAccount(args)
)
+ ipcMain.handle('codexAccounts:cancelPendingLogin', () => codexAccounts.cancelPendingLogin())
+ ipcMain.handle('codexAccounts:pendingLoginUrl', () => codexAccounts.getPendingLoginUrl())
+ // Why: Settings can open after the login already printed its link, so the
+ // renderer reads the current value on mount and this only carries changes.
+ codexAccounts.onPendingLoginUrlChanged(broadcastCodexPendingLoginUrl)
ipcMain.handle(
'codexAccounts:reauthenticate',
(_event, args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) =>
diff --git a/src/main/ipc/codex-pending-login-url-broadcast.ts b/src/main/ipc/codex-pending-login-url-broadcast.ts
new file mode 100644
index 00000000000..5599eb5f968
--- /dev/null
+++ b/src/main/ipc/codex-pending-login-url-broadcast.ts
@@ -0,0 +1,15 @@
+import { BrowserWindow } from 'electron'
+import { CODEX_PENDING_LOGIN_URL_CHANGED_CHANNEL } from '../../shared/codex-auth-errors'
+
+export function broadcastCodexPendingLoginUrl(url: string | null): void {
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (window.isDestroyed()) {
+ continue
+ }
+ try {
+ window.webContents.send(CODEX_PENDING_LOGIN_URL_CHANGED_CHANNEL, url)
+ } catch {
+ // A renderer can disappear between isDestroyed() and send().
+ }
+ }
+}
diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts
index ff98244299d..f041e41f8b7 100644
--- a/src/preload/api/agent-account-api.ts
+++ b/src/preload/api/agent-account-api.ts
@@ -11,6 +11,10 @@ export type CodexAccountsApi = {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}) => Promise
+ cancelPendingLogin: () => Promise
+ /** Sign-in link of the login waiting on a browser, or null when none is. */
+ getPendingLoginUrl: () => Promise
+ onPendingLoginUrlChanged: (callback: (url: string | null) => void) => () => void
reauthenticate: (args: {
accountId: string
/** Local-only: activate the re-authed account when its runtime lane had no selection. */
diff --git a/src/preload/api/codex-accounts-bridge.ts b/src/preload/api/codex-accounts-bridge.ts
index d085de45855..d5ca98cdf76 100644
--- a/src/preload/api/codex-accounts-bridge.ts
+++ b/src/preload/api/codex-accounts-bridge.ts
@@ -1,10 +1,20 @@
import { ipcRenderer } from 'electron'
+import { CODEX_PENDING_LOGIN_URL_CHANGED_CHANNEL } from '../../shared/codex-auth-errors'
import type { PreloadApi } from '../api-types'
export const codexAccountsApi = {
list: () => ipcRenderer.invoke('codexAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) =>
ipcRenderer.invoke('codexAccounts:add', args),
+ cancelPendingLogin: (): Promise =>
+ ipcRenderer.invoke('codexAccounts:cancelPendingLogin'),
+ getPendingLoginUrl: (): Promise =>
+ ipcRenderer.invoke('codexAccounts:pendingLoginUrl'),
+ onPendingLoginUrlChanged: (callback: (url: string | null) => void): (() => void) => {
+ const listener = (_event: Electron.IpcRendererEvent, url: string | null): void => callback(url)
+ ipcRenderer.on(CODEX_PENDING_LOGIN_URL_CHANGED_CHANNEL, listener)
+ return () => ipcRenderer.removeListener(CODEX_PENDING_LOGIN_URL_CHANGED_CHANNEL, listener)
+ },
reauthenticate: (args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) =>
ipcRenderer.invoke('codexAccounts:reauthenticate', args),
remove: (args: { accountId: string }) => ipcRenderer.invoke('codexAccounts:remove', args),
diff --git a/src/renderer/src/components/settings/CodexLoginLinkNotice.test.tsx b/src/renderer/src/components/settings/CodexLoginLinkNotice.test.tsx
new file mode 100644
index 00000000000..4e79b3e0951
--- /dev/null
+++ b/src/renderer/src/components/settings/CodexLoginLinkNotice.test.tsx
@@ -0,0 +1,93 @@
+// @vitest-environment happy-dom
+
+import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { i18n } from '../../i18n/i18n'
+import { CodexLoginLinkNotice } from './CodexLoginLinkNotice'
+
+const AUTH_URL = 'https://auth.openai.com/oauth/authorize?client_id=orca&state=abc123'
+
+type LinkListener = (url: string | null) => void
+
+function stubApi(pendingUrl: string | null): {
+ listeners: LinkListener[]
+ writeClipboardText: ReturnType
+ openUrl: ReturnType
+} {
+ const listeners: LinkListener[] = []
+ const writeClipboardText = vi.fn(() => Promise.resolve())
+ const openUrl = vi.fn(() => Promise.resolve())
+ Object.defineProperty(globalThis, 'api', {
+ configurable: true,
+ value: {
+ codexAccounts: {
+ getPendingLoginUrl: () => Promise.resolve(pendingUrl),
+ onPendingLoginUrlChanged: (listener: LinkListener) => {
+ listeners.push(listener)
+ return () => {
+ listeners.splice(listeners.indexOf(listener), 1)
+ }
+ }
+ },
+ ui: { writeClipboardText },
+ shell: { openUrl }
+ }
+ })
+ return { listeners, writeClipboardText, openUrl }
+}
+
+afterEach(() => {
+ cleanup()
+ Reflect.deleteProperty(globalThis, 'api')
+})
+
+describe('CodexLoginLinkNotice', () => {
+ beforeEach(async () => {
+ await i18n.changeLanguage('en')
+ })
+
+ it('renders nothing while no login is waiting on a browser', async () => {
+ stubApi(null)
+ const { container } = render()
+ await waitFor(() => expect(container.textContent).toBe(''))
+ })
+
+ it('shows the link of a login that was already running when Settings opened', async () => {
+ stubApi(AUTH_URL)
+ const { container } = render()
+ await waitFor(() => expect(container.textContent).toContain(AUTH_URL))
+ })
+
+ it('copies the link and opens it in the default browser on request', async () => {
+ const { listeners, writeClipboardText, openUrl } = stubApi(null)
+ const { container } = render()
+ await waitFor(() => expect(listeners.length).toBe(1))
+
+ listeners[0](AUTH_URL)
+ await waitFor(() => expect(container.textContent).toContain('Copy link'))
+
+ const button = (label: string): HTMLButtonElement => {
+ const found = Array.from(container.querySelectorAll('button')).find((candidate) =>
+ candidate.textContent?.includes(label)
+ )
+ expect(found).not.toBeUndefined()
+ return found!
+ }
+
+ fireEvent.click(button('Copy link'))
+ await waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith(AUTH_URL))
+ await waitFor(() => expect(container.textContent).toContain('Copied'))
+
+ fireEvent.click(button('Open'))
+ expect(openUrl).toHaveBeenCalledWith(AUTH_URL)
+ })
+
+ it('drops the link once the login ends', async () => {
+ const { listeners } = stubApi(AUTH_URL)
+ const { container } = render()
+ await waitFor(() => expect(container.textContent).toContain(AUTH_URL))
+
+ listeners[0](null)
+ await waitFor(() => expect(container.textContent).toBe(''))
+ })
+})
diff --git a/src/renderer/src/components/settings/CodexLoginLinkNotice.tsx b/src/renderer/src/components/settings/CodexLoginLinkNotice.tsx
new file mode 100644
index 00000000000..a968ac2cc42
--- /dev/null
+++ b/src/renderer/src/components/settings/CodexLoginLinkNotice.tsx
@@ -0,0 +1,50 @@
+import { Check, Copy, ExternalLink } from 'lucide-react'
+import { translate } from '@/i18n/i18n'
+import { useClipboardTextCopyFeedback } from '@/hooks/use-clipboard-text-copy-feedback'
+import { Button } from '../ui/button'
+import { useCodexPendingLoginUrl } from './use-codex-pending-login-url'
+
+/**
+ * The sign-in link of an in-flight `codex login`, so the user can finish the
+ * flow in a browser of their choosing instead of only the one Codex opened.
+ */
+export function CodexLoginLinkNotice(): React.JSX.Element | null {
+ const url = useCodexPendingLoginUrl()
+ const { copyText, status } = useClipboardTextCopyFeedback(url ?? '')
+ if (!url) {
+ return null
+ }
+ return (
+
+
+ {translate(
+ 'auto.components.settings.AccountsPane.codexLoginLinkPending',
+ 'Codex opened this sign-in link in your browser. Copy it to finish signing in somewhere else — a private window, or another profile.'
+ )}
+
+
+
+ {url}
+
+
+
+
+ {status === 'failed' ? (
+
+ {translate(
+ 'auto.components.settings.AccountsPane.codexLoginLinkCopyFailed',
+ 'Could not copy the link.'
+ )}
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/settings/accounts-pane-account-actions.ts b/src/renderer/src/components/settings/accounts-pane-account-actions.ts
index 09be1ce001b..98ba3cac3f9 100644
--- a/src/renderer/src/components/settings/accounts-pane-account-actions.ts
+++ b/src/renderer/src/components/settings/accounts-pane-account-actions.ts
@@ -25,7 +25,8 @@ import type {
import {
getClaudeAccountErrorDescription,
getCodexAccountErrorDescription,
- isClaudeAccountCancellation
+ isClaudeAccountCancellation,
+ isCodexAccountCancellation
} from './accounts-pane-action-errors'
import { getClaudeAccountLabel } from './accounts-pane-runtime'
@@ -117,6 +118,11 @@ export function createCodexAccountActionRunner(
})
}
} catch (error) {
+ // Why: cancelling — from the Cancel button, or by asking for a new login
+ // that supersedes this one — is the user's own doing, not a failure.
+ if (isCodexAccountCancellation(error)) {
+ return
+ }
toast.error(
translate(
'auto.components.settings.AccountsPane.5bf8764953',
diff --git a/src/renderer/src/components/settings/accounts-pane-action-errors.ts b/src/renderer/src/components/settings/accounts-pane-action-errors.ts
index c289e59d7df..c80a25b80bb 100644
--- a/src/renderer/src/components/settings/accounts-pane-action-errors.ts
+++ b/src/renderer/src/components/settings/accounts-pane-action-errors.ts
@@ -1,3 +1,5 @@
+import { CODEX_LOGIN_CANCELLED_MESSAGE } from '../../../../shared/codex-auth-errors'
+
export function getCodexAccountErrorDescription(error: unknown): string {
const message = String((error as Error)?.message ?? error)
.replace(/^Error occurred in handler for 'codexAccounts:[^']+':\s*/i, '')
@@ -42,6 +44,13 @@ export function getClaudeAccountErrorDescription(error: unknown): string {
)
}
+export function isCodexAccountCancellation(error: unknown): boolean {
+ return (
+ getCodexAccountErrorDescription(error).toLowerCase() ===
+ CODEX_LOGIN_CANCELLED_MESSAGE.toLowerCase()
+ )
+}
+
export function isClaudeAccountCancellation(error: unknown): boolean {
return getClaudeAccountErrorDescription(error).toLowerCase() === 'claude sign-in was cancelled.'
}
diff --git a/src/renderer/src/components/settings/accounts-pane-codex-section.tsx b/src/renderer/src/components/settings/accounts-pane-codex-section.tsx
index 61d27ce49e0..8e30f563098 100644
--- a/src/renderer/src/components/settings/accounts-pane-codex-section.tsx
+++ b/src/renderer/src/components/settings/accounts-pane-codex-section.tsx
@@ -1,10 +1,11 @@
-import { AlertTriangle, Loader2, Plus } from 'lucide-react'
+import { AlertTriangle, Loader2, Plus, X } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { selectCodexProviderAccount } from '@/runtime/runtime-provider-accounts-client'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { OpenAIIcon } from '../status-bar/icons'
+import { CodexLoginLinkNotice } from './CodexLoginLinkNotice'
import { SearchableSetting } from './SearchableSetting'
import { getAccountsCodexSearchEntries } from './accounts-search'
import { getCodexSystemDefaultSubtitle } from './accounts-pane-runtime'
@@ -152,36 +153,52 @@ export function renderCodexAccountsSection(model: AccountsPaneSectionModel): Rea
)}