From 4e3170a76e1ea01fc888bc6f3abf214987c38618 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:08:59 -0700 Subject: [PATCH] fix(accounts): free the account queue when a sign-in is abandoned, and show the Codex sign-in link (#21372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(accounts): free the account queue when a sign-in is abandoned Closing Settings mid sign-in left the `codex login` / `claude auth login` child running, and every account mutation shares one FIFO queue, so the next Add Account sat behind it for the login's whole deadline and then inherited the abandoned call's timeout toast. Cancel the pending login before enqueueing the next add or reauth (never inside the queue the abandoned login owns), give Codex the cancel handle and Cancel button Claude already had, and stop reporting a cancellation as a failure. Also surface the sign-in link Codex prints, with copy and open, so the flow can be finished in a private window or another browser profile. * test(accounts): drop the bare casts CI's changed-code gate rejects The service doubles still need a cast; one documented helper per file carries the SAFETY rationale instead of nine bare `as never`s. * fix(codex): a cancel must not discard a sign-in that already succeeded The Windows post-auth watcher gives a lingering codex login five seconds to exit after it writes auth.json. A cancel arriving in that window rejected the login, and the caller's rollback then deleted the managed home that had just authenticated. Refuse the cancel once new credential bytes exist: there is nothing left to cancel, and the close handler already treats that state as success. Found by review of #21372. * fix(codex): keep a refused cancel cancellable, and require the sign-in notice Review of the auth-aware cancel guard found two holes it opened: - The outer handle latched `cancelled` before asking the session, so a refusal killed cancellation for the rest of the deadline. On a host with no post-auth watcher that reinstated the very stall this PR removes. Latch only when the cancel is accepted. - WSL never reads a pre-spawn baseline, so the guard read the auth.json that was already there and refused from the first click, making a WSL reauthentication uncancellable. Require a baseline before refusing. Also from review: publish the sign-in link from a stdout-only buffer, so an interleaved stderr chunk cannot truncate it; require codex's own "navigate to this URL" notice rather than offering the first link in the output; hide the notice in a remote account scope, where it would name a login running on this desktop; and share the cancellation message instead of matching a duplicated literal. The Claude case joins the login-process suite that already owns the two neighbouring cancel cases, and the auth-snapshot helpers move out of the session file, which the additions pushed over the line cap. * refactor(codex): cut the sign-in-link plumbing to its smallest form Review found the change correct but larger than it needs to be: - The pending-link store was a class with one permanent subscriber, a never-called unsubscribe and a try/catch that could not fire. It is a field and a listener set on the service, beside the cancel handle it already owned — and the service now clears both in one place. - The optional login-session dependencies were always supplied. - The parser's https check could not fail; the pattern already fixed the scheme. The renderer's unmount guard inside a synchronous IPC listener could not fire either. - The broadcast channel and the cancellation message are single sources of truth in src/shared now, rather than exported next to a hardcoded copy of themselves. - The duplicated seven-line rationale in both services says the same thing in three, including why only add and reauthenticate supersede. - The codex suite reuses its own factory, and unmocks once. Also reverts four reformat hunks the formatter pulled in around edits. * fix(accounts): free the queue for a switch, not only for another add Switching or removing an account shares the mutation queue an abandoned sign-in was holding, so the commonest thing a user does after giving up — pick a different account — still spun for the whole deadline while Add recovered instantly. Both now supersede, as does the Claude side. Every caller is a person: the two IPC handlers and the mobile RPC methods. No poll, sync or CLI path reaches them, and a sign-in that already wrote credentials refuses the cancel, so a switch cannot discard one that succeeded. Also from review: the Cancel button regains the gap its Claude twin has (layout is allowed by the design-system rule; only the colour override was not), and the URL subscription says what it is — registration for the process's lifetime, with no teardown to hand back. --- ...aude-account-service-login-process.test.ts | 76 +++++++ src/main/claude-accounts/service.ts | 16 ++ .../codex-login-auth-snapshot.ts | 27 +++ .../codex-login-auth-url.test.ts | 51 +++++ .../codex-accounts/codex-login-auth-url.ts | 32 +++ .../codex-accounts/codex-login-session.ts | 106 +++++++--- .../service-abandoned-login-supersede.test.ts | 192 ++++++++++++++++++ .../service-login-process-teardown.test.ts | 20 +- src/main/codex-accounts/service.ts | 77 ++++++- src/main/ipc/codex-accounts.ts | 6 + .../ipc/codex-pending-login-url-broadcast.ts | 15 ++ src/preload/api/agent-account-api.ts | 4 + src/preload/api/codex-accounts-bridge.ts | 10 + .../settings/CodexLoginLinkNotice.test.tsx | 93 +++++++++ .../settings/CodexLoginLinkNotice.tsx | 50 +++++ .../settings/accounts-pane-account-actions.ts | 8 +- .../settings/accounts-pane-action-errors.ts | 9 + .../settings/accounts-pane-codex-section.tsx | 73 ++++--- .../settings/use-codex-pending-login-url.ts | 37 ++++ src/renderer/src/i18n/locales/en.json | 5 + .../web/preload-api/web-agent-accounts-api.ts | 30 ++- src/renderer/src/web/web-preload-api.ts | 7 +- src/shared/codex-auth-errors.ts | 6 + 23 files changed, 875 insertions(+), 75 deletions(-) create mode 100644 src/main/codex-accounts/codex-login-auth-snapshot.ts create mode 100644 src/main/codex-accounts/codex-login-auth-url.test.ts create mode 100644 src/main/codex-accounts/codex-login-auth-url.ts create mode 100644 src/main/codex-accounts/service-abandoned-login-supersede.test.ts create mode 100644 src/main/ipc/codex-pending-login-url-broadcast.ts create mode 100644 src/renderer/src/components/settings/CodexLoginLinkNotice.test.tsx create mode 100644 src/renderer/src/components/settings/CodexLoginLinkNotice.tsx create mode 100644 src/renderer/src/components/settings/use-codex-pending-login-url.ts 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( + `navigate to this URL to authenticate:\n\n${AUTH_URL}\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 )}

- {codexAction === 'adding' ? ( - - ) : ( - - )} - {translate('auto.components.settings.AccountsPane.b0e948a4f9', 'Add Account')} - + + ) : null} + {remoteAccountScopeNotice} + {/* Why not in a remote scope: the link belongs to a login running on + this desktop, which has nothing to do with the server named above. */} + {isRemoteAccountScope ? null : }