fix(accounts): open the browser for a WSL account sign-in (#29566)

* fix(accounts): open the browser for a WSL account sign-in

A WSL Add account / Sign in again ran the provider CLI inside the distro,
where stock distros have no opener, so no browser opened. Claude's link is
now caught by the same BROWSER stand-in Copy link uses, Codex's by the link
it already prints, and the desktop opens it after validation.

* fix(accounts): never open a cancelled WSL sign-in's link; wait the full login window

Addresses review: watch the login's cancel signal before opening, give auto-open
the whole login window to receive Claude's link, log the refused host, and cover
cancel-before-delivery and service wiring in tests.
This commit is contained in:
Neil
2026-10-16 02:35:04 -07:00
committed by GitHub
parent 020630822d
commit a77cf64121
15 changed files with 488 additions and 13 deletions
@@ -285,6 +285,29 @@ describe('ClaudeAccountService', () => {
await expect(adding).rejects.toThrow('cancelled')
})
it('opens the link of a WSL sign-in through the desktop opener', async () => {
const LINK =
'https://claude.com/cai/oauth/authorize?redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback'
const f = fixture()
const opened: string[] = []
f.service.setWslSignInLinkOpener((link) => opened.push(link))
f.runtimeAuth.prepareAccountFolder.mockImplementation(async (id: string) => {
mkdirSync(f.home(id), { recursive: true })
return { configDir: `/home/u/claude-profiles/${id}/home`, readPath: f.home(id) }
})
f.runLogin.mockImplementationOnce(async (_args, config, _timeoutMs, options) => {
expect(options?.browser).toBe(
`/home/u/claude-profiles/${newId(f)}/home/.orca-sign-in-browser`
)
writeFileSync(join(config.windowsPath, '.orca-sign-in-browser.link'), LINK)
await vi.waitFor(() => expect(opened).toEqual([LINK]))
f.signIn(newId(f), 'wsl@example.test')
return ''
})
await f.service.addAccount({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(opened).toEqual([LINK])
})
it('runs a WSL login against the guest folder', async () => {
const f = fixture()
f.runtimeAuth.prepareAccountFolder.mockImplementation(async (id: string) => {
@@ -0,0 +1,142 @@
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process'
import { runClaudeLoginSession } from './claude-login-session'
const LINK =
'https://claude.com/cai/oauth/authorize?code=true&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback&state=s'
const LINUX_HOME = '/home/u/claude-profiles/a/home'
const roots: string[] = []
type RunCommand = (
args: string[],
config: ClaudeCommandConfig,
timeoutMs: number,
options?: ClaudeCommandOptions
) => Promise<string>
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })))
function folder(runtime: 'wsl' | 'host'): ClaudeCommandConfig {
const windowsPath = mkdtempSync(join(tmpdir(), 'claude-login-session-'))
roots.push(windowsPath)
return runtime === 'wsl'
? { windowsPath, linuxPath: LINUX_HOME, wslDistro: 'Ubuntu' }
: { windowsPath, linuxPath: null, wslDistro: null }
}
// Stands in for Claude running BROWSER with `link`, then finishing once `done` says so.
function claudeHandsOver(config: ClaudeCommandConfig, link: string, done: () => boolean) {
return vi.fn<RunCommand>(async () => {
writeFileSync(join(config.windowsPath, '.orca-sign-in-browser.link'), link)
await vi.waitFor(() => expect(done()).toBe(true))
return ''
})
}
describe('runClaudeLoginSession', () => {
it('opens a WSL login link on this computer and cleans up the stand-in', async () => {
const config = folder('wsl')
const openWslSignInLink = vi.fn()
const runCommand = claudeHandsOver(config, LINK, () => openWslSignInLink.mock.calls.length > 0)
await runClaudeLoginSession(config, { runCommand, setCancel: vi.fn(), openWslSignInLink })
expect(runCommand.mock.calls[0][3]).toMatchObject({
browser: `${LINUX_HOME}/.orca-sign-in-browser`
})
expect(openWslSignInLink).toHaveBeenCalledExactlyOnceWith(LINK)
expect(readdirSync(config.windowsPath)).toEqual([])
})
it('copies rather than opens a WSL link when the user asked for Copy link', async () => {
const config = folder('wsl')
const openWslSignInLink = vi.fn()
const onLink = vi.fn()
const runCommand = claudeHandsOver(config, LINK, () => onLink.mock.calls.length > 0)
await runClaudeLoginSession(
config,
{ runCommand, setCancel: vi.fn(), openWslSignInLink },
onLink
)
expect(onLink).toHaveBeenCalledExactlyOnceWith(LINK)
expect(openWslSignInLink).not.toHaveBeenCalled()
})
it('leaves a local login to open its own browser', async () => {
const config = folder('host')
const openWslSignInLink = vi.fn()
const runCommand = vi.fn<RunCommand>(async (_args, _config, _ms, options) => {
expect(options?.browser).toBeUndefined()
expect(existsSync(join(config.windowsPath, '.orca-sign-in-browser'))).toBe(false)
return ''
})
await runClaudeLoginSession(config, { runCommand, setCancel: vi.fn(), openWslSignInLink })
expect(runCommand).toHaveBeenCalledTimes(1)
expect(openWslSignInLink).not.toHaveBeenCalled()
})
it('changes nothing for a WSL login where nothing can open a link', async () => {
const config = folder('wsl')
const runCommand = vi.fn<RunCommand>(async () => '')
await runClaudeLoginSession(config, { runCommand, setCancel: vi.fn() })
expect(runCommand.mock.calls[0]).toEqual([
['auth', 'login', '--claudeai'],
config,
expect.any(Number),
expect.objectContaining({ browser: undefined })
])
})
it('never opens a link that is not a Claude sign-in, and keeps the login going', async () => {
const config = folder('wsl')
const openWslSignInLink = vi.fn()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const runCommand = claudeHandsOver(config, 'http://claude.com/oauth', () =>
warn.mock.calls.some(([message]) => String(message).includes('unexpected sign-in link'))
)
await expect(
runClaudeLoginSession(config, { runCommand, setCancel: vi.fn(), openWslSignInLink })
).resolves.toBeUndefined()
expect(openWslSignInLink).not.toHaveBeenCalled()
warn.mockRestore()
})
it('never opens a link for a WSL login the user already cancelled', async () => {
const config = folder('wsl')
const openWslSignInLink = vi.fn()
let cancel: (() => boolean) | null = null
const runCommand = vi.fn<RunCommand>(async (_args, _config, _ms, options) => {
// Claude writes its link as the cancel lands.
writeFileSync(join(config.windowsPath, '.orca-sign-in-browser.link'), LINK)
expect(cancel?.()).toBe(true)
expect(options?.signal?.aborted).toBe(true)
// Give the link watcher its chance to read the file.
await new Promise((resolve) => setTimeout(resolve, 300))
throw new Error('Claude sign-in was cancelled.')
})
await expect(
runClaudeLoginSession(config, {
runCommand,
setCancel: (next) => {
cancel ??= next
},
openWslSignInLink
})
).rejects.toThrow('cancelled')
expect(openWslSignInLink).not.toHaveBeenCalled()
})
it('runs a WSL login unchanged when the stand-in cannot be written', async () => {
const config = { ...folder('wsl'), windowsPath: join(tmpdir(), 'missing-claude-folder', 'x') }
const openWslSignInLink = vi.fn()
const runCommand = vi.fn<RunCommand>(async () => '')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
await runClaudeLoginSession(config, { runCommand, setCancel: vi.fn(), openWslSignInLink })
warn.mockRestore()
expect(runCommand.mock.calls[0]).toEqual([
['auth', 'login', '--claudeai'],
config,
expect.any(Number),
expect.objectContaining({ browser: undefined })
])
})
})
@@ -1,6 +1,6 @@
import { CLAUDE_SIGN_IN_FAILED_MESSAGE } from '../../shared/claude-sign-in-link'
import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process'
import { prepareClaudeSignInBrowser } from './claude-sign-in-browser'
import { prepareClaudeSignInBrowser, type ClaudeSignInBrowser } from './claude-sign-in-browser'
// Why 10 minutes: copying the link into a private window takes longer than one click.
const LOGIN_TIMEOUT_MS = 600_000
@@ -13,6 +13,8 @@ type ClaudeLoginSessionDependencies = {
options?: ClaudeCommandOptions
) => Promise<string>
setCancel: (cancel: (() => boolean) | null) => void
/** Opens a WSL login's sign-in link on this computer; absent where Orca cannot open one. */
openWslSignInLink?: ((signInLink: string) => void) | null
}
/** Runs a hidden `claude auth login` that writes its login straight into the account's folder.
@@ -33,14 +35,36 @@ export async function runClaudeLoginSession(
return true
})
try {
// Why: stock distros have no xdg-open, wslview or BROWSER, so Claude in WSL opens nothing.
const openLink =
!onLink && folder.linuxPath !== null && folder.wslDistro !== null
? (dependencies.openWslSignInLink ?? undefined)
: undefined
// Why: if setup fails this throws; falling back would let Claude open the browser the user avoided.
const browser = onLink ? await prepareClaudeSignInBrowser(folder) : null
void browser?.nextLink(watch.signal).then((signInLink) => {
const browser = onLink
? await prepareClaudeSignInBrowser(folder)
: openLink
? await prepareOpenerBrowser(folder)
: null
const deliverLink = onLink ?? openLink
// Why both signals: a cancelled login must not open a tab for a link Claude wrote as it died.
const stopped = AbortSignal.any([watch.signal, controller.signal])
// Why the full login window: auto-open never ends the login, and a cold distro can be slow.
const waitMs = onLink ? undefined : LOGIN_TIMEOUT_MS
void browser?.nextLink(stopped, waitMs).then((signInLink) => {
if (stopped.aborted) {
return
}
if (signInLink) {
onLink?.(signInLink)
} else if (!watch.signal.aborted) {
deliverLink?.(signInLink)
return
}
if (onLink) {
linkMissing = true
controller.abort()
} else {
// Why not end it: the login stays cancellable and Copy link remains the fallback.
console.warn('[claude-accounts] Claude handed over no WSL sign-in link to open.')
}
})
try {
@@ -61,3 +85,14 @@ export async function runClaudeLoginSession(
dependencies.setCancel(null)
}
}
// Why null on failure: an auto-open that cannot be set up leaves the login as it was before.
async function prepareOpenerBrowser(
folder: ClaudeCommandConfig
): Promise<ClaudeSignInBrowser | null> {
try {
return await prepareClaudeSignInBrowser(folder)
} catch {
return null
}
}
@@ -21,7 +21,7 @@ export type ClaudeSignInBrowser = {
/** BROWSER as the Claude process sees it. */
path: string
/** The localhost-callback link Claude handed BROWSER; null if stopped, late, or not one. */
nextLink: (signal: AbortSignal) => Promise<string | null>
nextLink: (signal: AbortSignal, waitMs?: number) => Promise<string | null>
dispose: () => Promise<void>
}
@@ -67,13 +67,17 @@ export async function prepareClaudeSignInBrowser(
}
return {
path: isWsl && folder.linuxPath ? posix.join(folder.linuxPath, HELPER_NAME) : helperPath,
nextLink: (signal) => pollForLink(linkPath, signal),
nextLink: (signal, waitMs = LINK_WAIT_MS) => pollForLink(linkPath, signal, waitMs),
dispose
}
}
async function pollForLink(linkPath: string, signal: AbortSignal): Promise<string | null> {
const deadline = Date.now() + LINK_WAIT_MS
async function pollForLink(
linkPath: string,
signal: AbortSignal,
waitMs: number
): Promise<string | null> {
const deadline = Date.now() + waitMs
while (!signal.aborted && Date.now() < deadline) {
const signInLink = await readFile(linkPath, 'utf8').then(
(text) => text.trim(),
+8 -1
View File
@@ -19,6 +19,7 @@ export class ClaudeAccountService {
private mutationQueue: Promise<unknown> = Promise.resolve()
private cancelPendingClaudeLogin: (() => boolean) | null = null
private pendingSignInLink: Promise<string | null> | null = null
private openWslSignInLink: ((signInLink: string) => void) | null = null
private readonly selection: ClaudeAccountSelection
private readonly registration: ClaudeAccountRegistration
@@ -66,6 +67,11 @@ export class ClaudeAccountService {
return this.cancelPendingClaudeLogin?.() ?? false
}
/** Lets the desktop open a WSL sign-in's link; orcad never sets one. */
setWslSignInLinkOpener(opener: ((signInLink: string) => void) | null): void {
this.openWslSignInLink = opener
}
beginSignIn(request: ClaudeSignInRequest = {}): Promise<ClaudeAccountSignIn> {
return this.serializeMutation(() => this.registration.begin(request))
}
@@ -120,7 +126,8 @@ export class ClaudeAccountService {
runCommand: this.runLoginCommand,
setCancel: (cancel) => {
this.cancelPendingClaudeLogin = cancel
}
},
openWslSignInLink: this.openWslSignInLink
},
signInLink?.resolve
)
@@ -1,6 +1,16 @@
import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences'
const AUTH_URL_MARKER = 'navigate to this url to authenticate:'
const CODEX_AUTH_HOST = 'auth.openai.com'
/** OpenAI's sign-in page, the only link Orca opens for a codex login. */
export function isCodexLoginAuthUrl(authUrl: string): boolean {
try {
return new URL(authUrl).hostname === CODEX_AUTH_HOST
} catch {
return false
}
}
/**
* The browser sign-in link `codex login` prints, or null while its output has
@@ -0,0 +1,108 @@
import { EventEmitter } from 'node:events'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('../wsl/wsl-runner', () => ({
runWslProcess: vi.fn(async () => ({
environmentResolved: true,
code: 0,
stdout: '',
stderr: '',
timedOut: false
}))
}))
vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
import { runCodexLoginSession, type CodexLoginSpawnRequest } from './codex-login-session'
const AUTH_URL = 'https://auth.openai.com/oauth/authorize?client_id=orca&state=abc'
const PRINTED = `If your browser did not open, navigate to this URL to authenticate:\n\n${AUTH_URL}\n`
const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\managed-home'
const roots: string[] = []
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })))
// Stands in for `codex login`: prints its link, then exits.
function loginThatPrints(text: string) {
const requests: CodexLoginSpawnRequest[] = []
const spawn = vi.fn((request: CodexLoginSpawnRequest) => {
requests.push(request)
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
kill: () => true
})
queueMicrotask(() => {
child.stdout.write(text)
setTimeout(() => child.emit('close', 0), 10)
})
return child
})
return { spawn, requests }
}
function run(
home: string,
login: ReturnType<typeof loginThatPrints>,
openWslAuthUrl?: (url: string) => void
) {
const onAuthUrl = vi.fn()
const done = runCodexLoginSession(home, {
wslCommand: 'wsl.exe',
spawn: login.spawn,
killProcessTree: vi.fn(),
setCancel: vi.fn(),
onAuthUrl,
openWslAuthUrl
})
return { done, onAuthUrl }
}
describe('runCodexLoginSession sign-in link opening', () => {
it('opens the link a WSL login prints and keeps it for Copy link', async () => {
const login = loginThatPrints(PRINTED)
const open = vi.fn()
const { done, onAuthUrl } = run(WSL_HOME, login, open)
await done
expect(open).toHaveBeenCalledExactlyOnceWith(AUTH_URL)
expect(onAuthUrl).toHaveBeenCalledExactlyOnceWith(AUTH_URL)
// Why: Orca opens the link, so the distro's own opener must not open a second tab.
expect(login.requests[0].args.at(-1)).toContain('export BROWSER=true')
})
it('leaves a WSL login as it was where nothing can open a link', async () => {
const login = loginThatPrints(PRINTED)
const { done, onAuthUrl } = run(WSL_HOME, login)
await done
expect(onAuthUrl).toHaveBeenCalledExactlyOnceWith(AUTH_URL)
expect(login.requests[0].args.at(-1)).not.toContain('BROWSER')
})
it('never opens a link the WSL login did not print as its sign-in link', async () => {
const login = loginThatPrints(
'Update available: https://github.com/openai/codex/releases\nnavigate to this URL to authenticate: http://auth.openai.com/x\n'
)
const open = vi.fn()
await run(WSL_HOME, login, open).done
expect(open).not.toHaveBeenCalled()
})
it.skipIf(process.platform === 'win32')(
'leaves a local login to open its own browser',
async () => {
const home = mkdtempSync(join(tmpdir(), 'codex-login-session-'))
roots.push(home)
const login = loginThatPrints(PRINTED)
const open = vi.fn()
const { done, onAuthUrl } = run(home, login, open)
await done
expect(onAuthUrl).toHaveBeenCalledExactlyOnceWith(AUTH_URL)
expect(open).not.toHaveBeenCalled()
expect(login.requests[0].env.BROWSER).toBe(process.env.BROWSER)
}
)
})
@@ -59,6 +59,8 @@ type CodexLoginSessionDependencies = {
setCancel: (cancel: () => boolean) => void
/** The browser link codex printed, published as soon as it is complete. */
onAuthUrl: (url: string) => void
/** Opens a WSL login's link on this computer; absent where Orca cannot open one. */
openWslAuthUrl?: ((url: string) => void) | null
}
type LoginCancellation = {
@@ -114,11 +116,16 @@ async function runCodexLoginProcess(
throw new Error(CODEX_LOGIN_CANCELLED_MESSAGE)
}
// Why WSL only: a stock distro has no opener, so codex there cannot open a browser itself.
const openAuthUrl = wslInfo ? (dependencies.openWslAuthUrl ?? null) : null
await new Promise<void>((resolvePromise, rejectPromise) => {
const spawnConfig = wslInfo
? {
command: dependencies.wslCommand,
args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath),
args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath, {
orcaOpensSignInLink: openAuthUrl !== null
}),
env: process.env,
codexCommand: 'codex',
interactiveLogin: null
@@ -155,6 +162,7 @@ async function runCodexLoginProcess(
if (authUrl) {
publishedAuthUrl = true
dependencies.onAuthUrl(authUrl)
openAuthUrl?.(authUrl)
}
}
+8 -1
View File
@@ -83,6 +83,7 @@ export class CodexAccountService {
private cancelPendingCodexLogin: (() => boolean) | null = null
private pendingLoginUrl: string | null = null
private readonly pendingLoginUrlListeners = new Set<(url: string | null) => void>()
private openWslLoginUrl: ((url: string) => void) | null = null
private readonly identity: CodexAccountIdentity
private readonly configMirror: CodexConfigMirror
private readonly managedHomePaths: CodexManagedHomePath
@@ -183,6 +184,11 @@ export class CodexAccountService {
this.pendingLoginUrlListeners.add(listener)
}
/** Lets the desktop open a WSL login's link; orcad never sets one. */
setWslLoginUrlOpener(opener: ((url: string) => void) | null): void {
this.openWslLoginUrl = opener
}
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.
@@ -292,7 +298,8 @@ export class CodexAccountService {
setCancel: (cancel) => {
this.cancelPendingCodexLogin = cancel
},
onAuthUrl: (url) => this.setPendingLoginUrl(url)
onAuthUrl: (url) => this.setPendingLoginUrl(url),
openWslAuthUrl: this.openWslLoginUrl
})
} finally {
// Why: both die with the login server — no surface may keep offering a
@@ -27,6 +27,15 @@ describe('WSL Codex commands', () => {
expect(command).toContain('export CODEX_HOME=')
expect(command).toContain('/home/alice/managed-home')
expect(command).toContain('exec "$resolved" login')
expect(command).not.toContain('BROWSER')
})
it('stops the distro opening a second tab only when Orca opens the link', () => {
const command = buildWslCodexLoginArgs('Ubuntu', '/home/alice/managed-home', {
orcaOpensSignInLink: true
}).at(-1)
expect(command).toContain('export BROWSER=true\nexec "$resolved" login')
})
it('quotes an explicit read-only app-server contract without changing the default', () => {
+7 -1
View File
@@ -63,7 +63,11 @@ export function buildWslCodexAppServerArgs(
return buildWslCodexShellArgs(distro, command)
}
export function buildWslCodexLoginArgs(distro: string, linuxHomePath: string): string[] {
export function buildWslCodexLoginArgs(
distro: string,
linuxHomePath: string,
options: { orcaOpensSignInLink?: boolean } = {}
): string[] {
const command = [
buildCodexPathLookup(),
'if [ -z "$resolved" ]; then',
@@ -71,6 +75,8 @@ export function buildWslCodexLoginArgs(distro: string, linuxHomePath: string): s
' exit 127',
'fi',
`export CODEX_HOME=${quotePosixShell(linuxHomePath)}`,
// Why: Orca opens the printed link itself; a distro opener would open a second tab.
...(options.orcaOpensSignInLink ? ['export BROWSER=true'] : []),
'exec "$resolved" login'
].join('\n')
return buildWslCodexShellArgs(distro, command)
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { openExternalMock } = vi.hoisted(() => ({
openExternalMock: vi.fn<(url: string) => Promise<void>>()
}))
vi.mock('electron', () => ({ shell: { openExternal: openExternalMock } }))
import { isOpenableAccountSignInLink, openAccountSignInLink } from './account-sign-in-link-opener'
const CLAUDE_LINK =
'https://claude.com/cai/oauth/authorize?code=true&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback&state=s'
const CODEX_LINK =
'https://auth.openai.com/oauth/authorize?client_id=orca&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=abc'
const UNOPENABLE: [string, 'claude' | 'codex', string][] = [
['plain http', 'codex', CODEX_LINK.replace('https:', 'http:')],
['plain http', 'claude', CLAUDE_LINK.replace('https:', 'http:')],
['a file URL', 'codex', 'file:///etc/passwd'],
['a javascript URL', 'claude', 'javascript:alert(1)'],
['a custom scheme', 'codex', 'ms-settings:network'],
['malformed text', 'codex', 'not a url'],
['an empty string', 'claude', ''],
['credentials in the URL', 'codex', 'https://user:pw@auth.openai.com/oauth/authorize'],
['an explicit port', 'codex', 'https://auth.openai.com:8443/oauth/authorize'],
['a lookalike host', 'codex', 'https://auth.openai.com.evil.test/oauth/authorize'],
['another provider', 'codex', CLAUDE_LINK],
['another provider', 'claude', CODEX_LINK],
[
'a Claude link that calls back elsewhere',
'claude',
CLAUDE_LINK.replace('localhost', 'evil.test')
]
]
describe('account sign-in link opener', () => {
beforeEach(() => {
openExternalMock.mockReset()
openExternalMock.mockResolvedValue(undefined)
})
it.each([
['claude', CLAUDE_LINK],
['codex', CODEX_LINK]
] as const)('opens a %s sign-in page', (provider, link) => {
expect(isOpenableAccountSignInLink(provider, link)).toBe(true)
openAccountSignInLink(provider, link)
expect(openExternalMock).toHaveBeenCalledExactlyOnceWith(new URL(link).toString())
})
it.each(UNOPENABLE)('never opens %s (%s)', (_label, provider, link) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(isOpenableAccountSignInLink(provider, link)).toBe(false)
openAccountSignInLink(provider, link)
expect(openExternalMock).not.toHaveBeenCalled()
warn.mockRestore()
})
it('survives the OS failing to open the browser', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
openExternalMock.mockRejectedValueOnce(new Error('no handler'))
expect(() => openAccountSignInLink('codex', CODEX_LINK)).not.toThrow()
await vi.waitFor(() => expect(warn).toHaveBeenCalled())
warn.mockRestore()
})
})
@@ -0,0 +1,46 @@
import { shell } from 'electron'
import { isClaudeLocalSignInLink } from '../claude-accounts/claude-sign-in-browser'
import { isCodexLoginAuthUrl } from '../codex-accounts/codex-login-auth-url'
export type AccountSignInProvider = 'claude' | 'codex'
/** A provider's own https sign-in page, the only thing a WSL login's link may open. */
export function isOpenableAccountSignInLink(
provider: AccountSignInProvider,
signInLink: string
): boolean {
let url: URL
try {
url = new URL(signInLink)
} catch {
return false
}
if (url.protocol !== 'https:' || url.username || url.password || url.port) {
return false
}
return provider === 'claude'
? isClaudeLocalSignInLink(signInLink)
: isCodexLoginAuthUrl(signInLink)
}
/** Opens a WSL login's sign-in link in the user's browser; anything else is dropped. */
export function openAccountSignInLink(provider: AccountSignInProvider, signInLink: string): void {
if (!isOpenableAccountSignInLink(provider, signInLink)) {
// Why the host only: the query carries OAuth state; the host is what a provider change moves.
console.warn(
`[${provider}-accounts] Refused to open a sign-in link on host ${linkHost(signInLink)}.`
)
return
}
void shell.openExternal(new URL(signInLink).toString()).catch((error: unknown) => {
console.warn(`[${provider}-accounts] Could not open the sign-in link:`, error)
})
}
function linkHost(signInLink: string): string {
try {
return new URL(signInLink).host || '(none)'
} catch {
return '(unparseable)'
}
}
+2
View File
@@ -1,8 +1,10 @@
import { ipcMain } from 'electron'
import type { ClaudeAccountService } from '../claude-accounts/service'
import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection'
import { openAccountSignInLink } from './account-sign-in-link-opener'
export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountService): void {
claudeAccounts.setWslSignInLinkOpener((signInLink) => openAccountSignInLink('claude', signInLink))
ipcMain.handle('claudeAccounts:list', () => claudeAccounts.listAccounts())
ipcMain.handle(
'claudeAccounts:add',
+2
View File
@@ -2,6 +2,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 { openAccountSignInLink } from './account-sign-in-link-opener'
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'
@@ -43,6 +44,7 @@ export function registerCodexAccountHandlers(
// 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)
codexAccounts.setWslLoginUrlOpener((url) => openAccountSignInLink('codex', url))
ipcMain.handle(
'codexAccounts:reauthenticate',
(_event, args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) =>