From 41e5c3918b8d20d20ca3a8be80cdacacdc6bb435 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Sun, 28 Jun 2026 22:33:07 -0700
Subject: [PATCH] Add cancel flow for Claude account login (#6702)
Co-authored-by: Orca
---
src/main/claude-accounts/service.test.ts | 191 ++++++++++++++++++
src/main/claude-accounts/service.ts | 62 +++++-
src/main/ipc/claude-accounts.ts | 1 +
src/preload/api-types.ts | 1 +
src/preload/index.ts | 2 +
.../src/components/settings/AccountsPane.tsx | 66 +++---
src/renderer/src/web/web-preload-api.ts | 1 +
7 files changed, 297 insertions(+), 27 deletions(-)
diff --git a/src/main/claude-accounts/service.test.ts b/src/main/claude-accounts/service.test.ts
index 454067ad5..8a537140d 100644
--- a/src/main/claude-accounts/service.test.ts
+++ b/src/main/claude-accounts/service.test.ts
@@ -1099,4 +1099,195 @@ describe('ClaudeAccountService credential capture', () => {
vi.doUnmock('node:child_process')
}
})
+
+ it('cancels an in-flight Claude account add', async () => {
+ vi.resetModules()
+ const child = new EventEmitter() as EventEmitter & {
+ stdout: PassThrough
+ stderr: PassThrough
+ kill: ReturnType
+ }
+ child.stdout = new PassThrough()
+ child.stderr = new PassThrough()
+ child.kill = vi.fn()
+ const spawnMock = vi.fn(() => 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(),
+ forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
+ }
+ const rateLimits = {
+ evictInactiveClaudeCache: vi.fn(),
+ refreshForClaudeAccountChange: vi.fn()
+ }
+ const service = new ClaudeAccountService(
+ store as never,
+ rateLimits as never,
+ runtimeAuth as never
+ )
+
+ const addPromise = service.addAccount()
+ await vi.waitFor(() => {
+ expect(spawnMock).toHaveBeenCalledTimes(1)
+ })
+
+ expect(service.cancelPendingLogin()).toBe(true)
+ await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.')
+ expect(child.kill).toHaveBeenCalledTimes(1)
+ expect(service.cancelPendingLogin()).toBe(false)
+ expect(settings.claudeManagedAccounts).toEqual([])
+ expect(child.stdout.listenerCount('data')).toBe(0)
+ expect(child.stderr.listenerCount('data')).toBe(0)
+ expect(child.listenerCount('error')).toBe(0)
+ expect(child.listenerCount('close')).toBe(0)
+ } finally {
+ vi.doUnmock('node:child_process')
+ }
+ })
+
+ it('honors cancel before Claude login command starts', async () => {
+ setPlatform('linux')
+ vi.resetModules()
+ let releaseKeychainRead: (value: string | null) => void = () => {}
+ vi.mocked(readActiveClaudeKeychainCredentials).mockReturnValue(
+ new Promise((resolve) => {
+ releaseKeychainRead = resolve
+ })
+ )
+ const spawnMock = vi.fn()
+ 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(),
+ forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
+ }
+ const rateLimits = {
+ evictInactiveClaudeCache: vi.fn(),
+ refreshForClaudeAccountChange: vi.fn()
+ }
+ const service = new ClaudeAccountService(
+ store as never,
+ rateLimits as never,
+ runtimeAuth as never
+ )
+
+ const addPromise = service.addAccount()
+ await vi.waitFor(() => {
+ expect(readActiveClaudeKeychainCredentials).toHaveBeenCalled()
+ })
+
+ expect(service.cancelPendingLogin()).toBe(true)
+ expect(service.cancelPendingLogin()).toBe(false)
+ expect(spawnMock).not.toHaveBeenCalled()
+ releaseKeychainRead(null)
+ await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.')
+ expect(spawnMock).not.toHaveBeenCalled()
+ expect(service.cancelPendingLogin()).toBe(false)
+ expect(settings.claudeManagedAccounts).toEqual([])
+ } finally {
+ vi.doUnmock('node:child_process')
+ }
+ })
+
+ it('uses taskkill to cancel the Windows Claude login process tree', async () => {
+ setPlatform('win32')
+ vi.resetModules()
+ vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null)
+ const child = new EventEmitter() as EventEmitter & {
+ pid: number
+ stdout: PassThrough
+ stderr: PassThrough
+ kill: ReturnType
+ }
+ child.pid = 1234
+ child.stdout = new PassThrough()
+ child.stderr = new PassThrough()
+ child.kill = vi.fn()
+ const taskkill = new EventEmitter() as EventEmitter & {
+ unref: ReturnType
+ }
+ taskkill.unref = vi.fn()
+ const spawnMock = vi.fn((command: string) => (command === 'taskkill.exe' ? taskkill : 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(),
+ forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
+ }
+ const rateLimits = {
+ evictInactiveClaudeCache: vi.fn(),
+ refreshForClaudeAccountChange: vi.fn()
+ }
+ const service = new ClaudeAccountService(
+ store as never,
+ rateLimits as never,
+ runtimeAuth as never
+ )
+
+ const addPromise = service.addAccount()
+ await vi.waitFor(() => {
+ expect(spawnMock).toHaveBeenCalledWith(
+ 'claude',
+ ['auth', 'login', '--claudeai'],
+ expect.objectContaining({ shell: true })
+ )
+ })
+
+ expect(service.cancelPendingLogin()).toBe(true)
+ await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.')
+ expect(child.kill).not.toHaveBeenCalled()
+ expect(spawnMock).toHaveBeenCalledWith(
+ 'taskkill.exe',
+ ['/pid', '1234', '/t', '/f'],
+ expect.objectContaining({ stdio: 'ignore', windowsHide: true })
+ )
+ expect(taskkill.unref).toHaveBeenCalled()
+ expect(service.cancelPendingLogin()).toBe(false)
+ } finally {
+ vi.doUnmock('node:child_process')
+ }
+ })
})
diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts
index e225f9326..0333dc200 100644
--- a/src/main/claude-accounts/service.ts
+++ b/src/main/claude-accounts/service.ts
@@ -83,6 +83,7 @@ function shellQuote(value: string): string {
export class ClaudeAccountService {
private mutationQueue: Promise = Promise.resolve()
+ private cancelPendingClaudeLogin: (() => boolean) | null = null
constructor(
private readonly store: Store,
@@ -118,6 +119,10 @@ export class ClaudeAccountService {
return this.serializeMutation(() => this.doSelectAccount(accountId, target))
}
+ cancelPendingLogin(): boolean {
+ return this.cancelPendingClaudeLogin?.() ?? false
+ }
+
private serializeMutation(fn: () => Promise): Promise {
const next = this.mutationQueue.then(fn, fn)
this.mutationQueue = next.catch(() => {})
@@ -424,12 +429,26 @@ export class ClaudeAccountService {
}
): Promise {
const tempConfig = this.createTemporaryClaudeConfigDir(location)
+ const loginAbortController = new AbortController()
+ this.cancelPendingClaudeLogin = () => {
+ if (loginAbortController.signal.aborted) {
+ return false
+ }
+ loginAbortController.abort()
+ return true
+ }
const previousLegacyKeychain = await readActiveClaudeKeychainCredentials()
let captured: CapturedClaudeAuth | null = null
let captureError: unknown = null
let cleanupError: unknown = null
try {
- await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS)
+ if (loginAbortController.signal.aborted) {
+ throw new Error('Claude sign-in was cancelled.')
+ }
+ await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS, {
+ signal: loginAbortController.signal
+ })
+ this.cancelPendingClaudeLogin = null
const status = await this.runClaudeCommand(
['auth', 'status', '--json'],
tempConfig,
@@ -463,6 +482,7 @@ export class ClaudeAccountService {
}
}
this.removeTemporaryClaudeConfigDir(tempConfig)
+ this.cancelPendingClaudeLogin = null
}
if (captureError) {
throw captureError
@@ -862,7 +882,7 @@ export class ClaudeAccountService {
args: string[],
configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null },
timeoutMs: number,
- options?: { allowFailure?: boolean }
+ options?: { allowFailure?: boolean; signal?: AbortSignal }
): Promise {
return new Promise((resolvePromise, rejectPromise) => {
const spawnConfig =
@@ -892,7 +912,10 @@ export class ClaudeAccountService {
const child = spawn(spawnConfig.command, spawnConfig.args, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: spawnConfig.shell,
- env: spawnConfig.env
+ env: spawnConfig.env,
+ // Why: Claude auth can leave browser/login descendants alive after denial.
+ // A process group lets cancellation terminate the whole POSIX login tree.
+ detached: process.platform !== 'win32'
})
let settled = false
@@ -913,6 +936,7 @@ export class ClaudeAccountService {
child.stderr.off('data', appendOutput)
child.off('error', onError)
child.off('close', onClose)
+ options?.signal?.removeEventListener('abort', onAbort)
}
const settle = (callback: () => void): void => {
if (settled) {
@@ -923,11 +947,36 @@ export class ClaudeAccountService {
callback()
}
const timeoutError = new Error('Claude sign-in took too long to finish.')
- timeout = setTimeout(() => {
+ const cancelError = new Error('Claude sign-in was cancelled.')
+ const killChild = (): void => {
+ if (process.platform === 'win32' && child.pid) {
+ const taskkill = spawn('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], {
+ stdio: 'ignore',
+ windowsHide: true
+ })
+ taskkill.on('error', () => {})
+ taskkill.unref()
+ return
+ }
+ if (process.platform !== 'win32' && child.pid) {
+ try {
+ process.kill(-child.pid)
+ return
+ } catch {
+ // Fall back to the direct child if the process group is unavailable.
+ }
+ }
child.kill()
+ }
+ timeout = setTimeout(() => {
+ killChild()
settle(() => rejectPromise(timeoutError))
}, timeoutMs)
+ const onAbort = (): void => {
+ killChild()
+ settle(() => rejectPromise(cancelError))
+ }
const onError = (error: Error): void => {
settle(() => rejectPromise(error))
}
@@ -952,6 +1001,11 @@ export class ClaudeAccountService {
child.stderr.on('data', appendOutput)
child.on('error', onError)
child.on('close', onClose)
+ if (options?.signal?.aborted) {
+ onAbort()
+ } else {
+ options?.signal?.addEventListener('abort', onAbort, { once: true })
+ }
})
}
diff --git a/src/main/ipc/claude-accounts.ts b/src/main/ipc/claude-accounts.ts
index 2fbaff81e..f88f094e4 100644
--- a/src/main/ipc/claude-accounts.ts
+++ b/src/main/ipc/claude-accounts.ts
@@ -7,6 +7,7 @@ export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountServi
ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountAddTarget) =>
claudeAccounts.addAccount(args)
)
+ ipcMain.handle('claudeAccounts:cancelPendingLogin', () => claudeAccounts.cancelPendingLogin())
ipcMain.handle('claudeAccounts:reauthenticate', (_event, args: { accountId: string }) =>
claudeAccounts.reauthenticateAccount(args.accountId)
)
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 75b2d1cd0..a2cd5b511 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -1901,6 +1901,7 @@ export type PreloadApi = {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}) => Promise
+ cancelPendingLogin: () => Promise
reauthenticate: (args: { accountId: string }) => Promise
remove: (args: { accountId: string }) => Promise
select: (args: {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index e6c7a3079..6e4bb9884 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1705,6 +1705,8 @@ const api = {
list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise =>
ipcRenderer.invoke('claudeAccounts:add', args),
+ cancelPendingLogin: (): Promise =>
+ ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'),
reauthenticate: (args: { accountId: string }): Promise =>
ipcRenderer.invoke('claudeAccounts:reauthenticate', args),
remove: (args: { accountId: string }): Promise =>
diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx
index 35540a2b8..947ec64c2 100644
--- a/src/renderer/src/components/settings/AccountsPane.tsx
+++ b/src/renderer/src/components/settings/AccountsPane.tsx
@@ -15,7 +15,7 @@ import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
-import { AlertTriangle, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
+import { AlertTriangle, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react'
import { useAppStore } from '../../store'
import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from '../status-bar/icons'
import { toast } from 'sonner'
@@ -183,6 +183,10 @@ function getClaudeAccountErrorDescription(error: unknown): string {
)
}
+function isClaudeAccountCancellation(error: unknown): boolean {
+ return getClaudeAccountErrorDescription(error).toLowerCase() === 'claude sign-in was cancelled.'
+}
+
type LocalAccountRuntime = {
runtime: 'host' | 'wsl'
wslDistro?: string | null
@@ -550,6 +554,9 @@ export function AccountsPane({
)
}
} catch (error) {
+ if (isClaudeAccountCancellation(error)) {
+ return
+ }
toast.error(
translate(
'auto.components.settings.AccountsPane.2743cdc0af',
@@ -608,29 +615,42 @@ export function AccountsPane({
)}
-