diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts index 211f769b3b6..aa7f2fa905c 100644 --- a/src/main/claude-accounts/claude-account-service.test.ts +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -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) => { diff --git a/src/main/claude-accounts/claude-login-session.test.ts b/src/main/claude-accounts/claude-login-session.test.ts new file mode 100644 index 00000000000..3cc4ccdd049 --- /dev/null +++ b/src/main/claude-accounts/claude-login-session.test.ts @@ -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 +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(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(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(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(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(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 }) + ]) + }) +}) diff --git a/src/main/claude-accounts/claude-login-session.ts b/src/main/claude-accounts/claude-login-session.ts index e2e2c3230cb..8027a0217fd 100644 --- a/src/main/claude-accounts/claude-login-session.ts +++ b/src/main/claude-accounts/claude-login-session.ts @@ -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 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 { + try { + return await prepareClaudeSignInBrowser(folder) + } catch { + return null + } +} diff --git a/src/main/claude-accounts/claude-sign-in-browser.ts b/src/main/claude-accounts/claude-sign-in-browser.ts index b48a1db209f..8db9b85cae1 100644 --- a/src/main/claude-accounts/claude-sign-in-browser.ts +++ b/src/main/claude-accounts/claude-sign-in-browser.ts @@ -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 + nextLink: (signal: AbortSignal, waitMs?: number) => Promise dispose: () => Promise } @@ -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 { - const deadline = Date.now() + LINK_WAIT_MS +async function pollForLink( + linkPath: string, + signal: AbortSignal, + waitMs: number +): Promise { + const deadline = Date.now() + waitMs while (!signal.aborted && Date.now() < deadline) { const signInLink = await readFile(linkPath, 'utf8').then( (text) => text.trim(), diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index ac4ee0748ed..ad031491dd9 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -19,6 +19,7 @@ export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() private cancelPendingClaudeLogin: (() => boolean) | null = null private pendingSignInLink: Promise | 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 { 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 ) diff --git a/src/main/codex-accounts/codex-login-auth-url.ts b/src/main/codex-accounts/codex-login-auth-url.ts index 024f0b6e73e..e0c9885d516 100644 --- a/src/main/codex-accounts/codex-login-auth-url.ts +++ b/src/main/codex-accounts/codex-login-auth-url.ts @@ -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 diff --git a/src/main/codex-accounts/codex-login-session-wsl-open.test.ts b/src/main/codex-accounts/codex-login-session-wsl-open.test.ts new file mode 100644 index 00000000000..c8a0428abd4 --- /dev/null +++ b/src/main/codex-accounts/codex-login-session-wsl-open.test.ts @@ -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, + 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) + } + ) +}) diff --git a/src/main/codex-accounts/codex-login-session.ts b/src/main/codex-accounts/codex-login-session.ts index 3595351ef37..911ffbc386c 100644 --- a/src/main/codex-accounts/codex-login-session.ts +++ b/src/main/codex-accounts/codex-login-session.ts @@ -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((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) } } diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 67f8ebb218a..8e810f3fb7c 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -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 diff --git a/src/main/codex-accounts/wsl-codex-command.test.ts b/src/main/codex-accounts/wsl-codex-command.test.ts index d32619e84e2..fbbdc35ac41 100644 --- a/src/main/codex-accounts/wsl-codex-command.test.ts +++ b/src/main/codex-accounts/wsl-codex-command.test.ts @@ -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', () => { diff --git a/src/main/codex-accounts/wsl-codex-command.ts b/src/main/codex-accounts/wsl-codex-command.ts index 1831a2f215c..ad1f5e915a7 100644 --- a/src/main/codex-accounts/wsl-codex-command.ts +++ b/src/main/codex-accounts/wsl-codex-command.ts @@ -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) diff --git a/src/main/ipc/account-sign-in-link-opener.test.ts b/src/main/ipc/account-sign-in-link-opener.test.ts new file mode 100644 index 00000000000..e4bce61ffcc --- /dev/null +++ b/src/main/ipc/account-sign-in-link-opener.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { openExternalMock } = vi.hoisted(() => ({ + openExternalMock: vi.fn<(url: string) => Promise>() +})) + +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() + }) +}) diff --git a/src/main/ipc/account-sign-in-link-opener.ts b/src/main/ipc/account-sign-in-link-opener.ts new file mode 100644 index 00000000000..8fa29f62a4d --- /dev/null +++ b/src/main/ipc/account-sign-in-link-opener.ts @@ -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)' + } +} diff --git a/src/main/ipc/claude-accounts.ts b/src/main/ipc/claude-accounts.ts index a5f4a797f51..07fb93911fc 100644 --- a/src/main/ipc/claude-accounts.ts +++ b/src/main/ipc/claude-accounts.ts @@ -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', diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index fc1ec0bcb70..10014baf4b3 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -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 }) =>