mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)'
|
||||
}
|
||||
}
|
||||
@@ -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,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 }) =>
|
||||
|
||||
Reference in New Issue
Block a user