feat(codex-oauth): manual callback URL paste for SSH / remote sessions (#1288) (#1414)

* feat(codex-oauth): manual callback paste for SSH / remote sessions

Codex OAuth required the browser to reach the openclaude host's
localhost:1455 callback. On SSH / containerized installs that callback
resolves to the user's workstation instead of the openclaude host, so
the redirect lands on a dead URL and the CLI hangs.

Add a manual-paste fallback (mirrors the xAI OAuth recovery path):
after authorizing in the browser, the user copies the full redirected
URL from the address bar and pastes it into the CLI. CodexOAuthService
validates the state parameter against the in-flight flow, races the
manual code against the loopback listener, and reuses the same
authorization-code → token exchange.

SSH_CONNECTION / SSH_CLIENT triggers a warning banner explaining why
the loopback redirect failed; non-SSH sessions get a dim hint covering
containerized / remote setups.

Closes #1288

* test(codex-oauth): type manual-paste fetch mock via asMockFetch

The raw `mock(...) as typeof fetch` cast no longer typechecks against the
base branch's stricter `fetch` type (now requires `preconnect`). Route the
manual-callback-paste test's mock through the shared `asMockFetch` helper,
matching the other fetch mocks in this file.

* fix(codex-oauth): mask the pasted manual callback URL input

The manual-recovery field echoed the redirected callback URL verbatim, which
carries the OAuth code and state query params — enough to complete the
in-flight exchange. Mask it with mask="*", matching the adjacent xAI
manual-code field, so it stays out of terminal scrollback, recordings, and
shared sessions.

* test(codex-oauth): bound state wait and cover hook manual-callback contract

- Bound the while (!capturedState) wait in the manual-callback test with a 5s
  deadline so a regression fails with a clear assertion instead of hanging the
  suite.
- Add a useCodexOAuthFlow test asserting the waiting status exposes
  submitManualCallback and delegates both success and failure results from the
  service back to the caller.

* test(codex-oauth): stabilize onAuthenticated in the manual-callback test

The new hook test passed a fresh inline `onAuthenticated: async () => {}` on
every render, so the hook effect re-ran each render, restarting the flow and
looping setStatus → render ("Maximum update depth exceeded" when run alongside
ProviderManager.test.tsx). Hoist the callback to a stable reference, matching
the other tests in the file.

* test(codex-oauth): cover the ProviderManager manual-callback UI

Add focused coverage for the waiting-state paste surface: the masked callback
input renders, a good callback delegates to status.submitManualCallback with no
inline error, the SSH banner appears when SSH_CONNECTION is set, and a rejected
callback surfaces the hook's inline error.
This commit is contained in:
0xfandom
2026-07-07 21:59:49 +08:00
committed by GitHub
parent 5afd4f4d10
commit c3db07f1f2
6 changed files with 540 additions and 9 deletions
+141
View File
@@ -342,6 +342,10 @@ function mockProviderManagerDependencies(
authUrl?: string
browserOpened?: boolean | null
message?: string
submitManualCallback?: (input: string) => {
ok: boolean
error?: string
}
}
},
): void {
@@ -1586,6 +1590,143 @@ test('ProviderManager first-run Codex OAuth switches the current session after l
await mounted.dispose()
})
test('ProviderManager Codex OAuth waiting state masks the paste field and delegates a good callback', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
delete process.env.SSH_CONNECTION
delete process.env.SSH_CLIENT
const onDone = mock(() => {})
const submitManualCallback = mock((_input: string) => ({ ok: true }))
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
// Stay in `waiting` (never call onAuthenticated) so the manual-paste UI
// renders. The hook returns a spy submitManualCallback.
useCodexOAuthFlow: () => ({
state: 'waiting',
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
submitManualCallback,
}),
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
)
await navigateToPreset(mounted.stdin, 'Codex OAuth')
mounted.stdin.write('\r')
// Non-SSH session shows the generic "paste the callback URL" hint and the input.
await waitForFrameOutput(
mounted.getOutput,
frame =>
frame.includes('Callback URL') &&
frame.includes('paste the full callback URL'),
)
const callbackUrl =
'http://localhost:41100/auth/callback?code=goodsecret&state=s'
mounted.stdin.write(callbackUrl)
// The pasted secret must be masked — the raw code must never reach the frame.
await waitForFrameOutput(
mounted.getOutput,
frame => !frame.includes('goodsecret') && frame.includes('Callback URL'),
)
mounted.stdin.write('\r')
await waitForCondition(() => submitManualCallback.mock.calls.length > 0)
expect(submitManualCallback).toHaveBeenCalledWith(callbackUrl)
// A successful submit leaves no inline error on screen.
expect(
stripAnsi(extractLastFrame(mounted.getOutput())),
).not.toContain('State mismatch')
await mounted.dispose()
})
test('ProviderManager Codex OAuth waiting state shows the SSH banner and surfaces a bad-callback error', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
process.env.SSH_CONNECTION = '10.0.0.1 22 10.0.0.2 22'
delete process.env.SSH_CLIENT
const onDone = mock(() => {})
const submitManualCallback = mock((_input: string) => ({
ok: false,
error: 'State mismatch',
}))
try {
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
useCodexOAuthFlow: () => ({
state: 'waiting',
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
submitManualCallback,
}),
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame =>
frame.includes('Set up provider') && frame.includes('Codex OAuth'),
)
await navigateToPreset(mounted.stdin, 'Codex OAuth')
mounted.stdin.write('\r')
// SSH session shows the dedicated banner instead of the generic hint.
await waitForFrameOutput(
mounted.getOutput,
frame =>
frame.includes('SSH session detected') &&
frame.includes('Callback URL'),
)
mounted.stdin.write('http://localhost:41100/auth/callback?code=x&state=s')
mounted.stdin.write('\r')
// A rejected callback renders the inline error returned by the hook.
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('State mismatch'),
)
expect(submitManualCallback).toHaveBeenCalledTimes(1)
await mounted.dispose()
} finally {
delete process.env.SSH_CONNECTION
}
})
test('ProviderManager first-run Codex OAuth surfaces credential storage warnings', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
+66
View File
@@ -602,6 +602,40 @@ function XaiManualCodeInput({
)
}
function CodexManualCallbackInput({
onSubmit,
}: {
onSubmit: (input: string) => void
}): React.ReactNode {
const [value, setValue] = React.useState('')
const [cursorOffset, setCursorOffset] = React.useState(0)
const { columns: terminalColumns } = useTerminalSize()
const inputColumns = Math.max(20, Math.min(120, terminalColumns - 12))
return (
<Box>
<Text>Callback URL </Text>
<TextInput
value={value}
onChange={setValue}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
columns={inputColumns}
onSubmit={submitted => {
const trimmed = submitted.trim()
if (trimmed) onSubmit(trimmed)
}}
// The pasted callback URL carries the OAuth `code` and `state` query
// params — enough to complete the in-flight exchange — so mask it the
// same way the xAI manual-code field above does, to keep it out of
// terminal scrollback, recordings, and shared sessions.
mask="*"
// The parent `CodexOAuthSetup` owns Esc via `useKeybinding('confirm:no')`.
disableEscapeDoublePress
/>
</Box>
)
}
function CodexOAuthSetup({
onBack,
onConfigured,
@@ -638,6 +672,10 @@ function CodexOAuthSetup({
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
})
const [pasteError, setPasteError] = React.useState<string | undefined>()
const isRemoteSession = Boolean(
process.env['SSH_CONNECTION'] || process.env['SSH_CLIENT'],
)
if (status.state === 'error') {
return (
@@ -693,6 +731,34 @@ function CodexOAuthSetup({
) : (
<Text dimColor>Opening your browser...</Text>
)}
{status.state === 'waiting' ? (
<>
{isRemoteSession ? (
<Text color="warning">
SSH session detected the browser cannot reach this host's
localhost callback. After signing in, copy the full URL your
browser was redirected to (it starts with http://localhost:) and
paste it below.
</Text>
) : (
<Text dimColor>
If the browser cannot reach localhost (remote / containerized
session), paste the full callback URL it was redirected to:
</Text>
)}
<CodexManualCallbackInput
onSubmit={input => {
const result = status.submitManualCallback(input)
if (!result.ok) {
setPasteError(result.error)
} else {
setPasteError(undefined)
}
}}
/>
{pasteError ? <Text color="error">{pasteError}</Text> : null}
</>
) : null}
<Text dimColor>Press Esc to cancel and go back.</Text>
</Box>
)
+95
View File
@@ -122,6 +122,7 @@ test('does not persist credentials when downstream setup rejects', async () => {
return TOKENS
},
cleanup,
submitManualCallback: () => ({ ok: true as const }),
}),
openBrowser: async () => true,
saveCodexCredentials,
@@ -192,6 +193,7 @@ test('persists credentials with profile linkage after downstream setup succeeds'
return TOKENS
},
cleanup,
submitManualCallback: () => ({ ok: true as const }),
}),
openBrowser: async () => true,
saveCodexCredentials,
@@ -271,6 +273,7 @@ test('returns a successful storage warning without entering the error state', as
return TOKENS
},
cleanup,
submitManualCallback: () => ({ ok: true as const }),
}),
openBrowser: async () => true,
saveCodexCredentials,
@@ -343,6 +346,7 @@ test('reports credential persistence failures without token values', async () =>
return TOKENS
},
cleanup,
submitManualCallback: () => ({ ok: true as const }),
}),
openBrowser: async () => true,
saveCodexCredentials,
@@ -389,3 +393,94 @@ test('reports credential persistence failures without token values', async () =>
await Bun.sleep(0)
}
})
test('exposes submitManualCallback in the waiting state and delegates success and failure results', async () => {
// The service's manual-callback result is what the ProviderManager UI relies
// on; verify the hook surfaces it in the waiting status and passes both the
// raw input and the service's result straight back to the caller.
const submitManualCallback = mock((input: string) =>
input.includes('code=good')
? ({ ok: true as const })
: ({ ok: false as const, error: 'State mismatch' }),
)
const cleanup = mock(() => {})
const deps = {
createOAuthService: () => ({
async startOAuthFlow(
onAuthorizationUrl: (authUrl: string) => void | Promise<void>,
) {
await onAuthorizationUrl('https://chatgpt.com/codex')
// Stay in the waiting state so the manual-callback path is reachable.
return new Promise<typeof TOKENS>(() => {})
},
cleanup,
submitManualCallback,
}),
openBrowser: async () => false,
saveCodexCredentials: mock(() => ({ success: true })),
isBareMode: () => false,
}
const { useCodexOAuthFlow } = await import(
`./useCodexOAuthFlow.js?waiting-manual-${Date.now()}-${Math.random()}`
)
let latestStatus:
| {
state: string
submitManualCallback?: (
input: string,
) => { ok: boolean; error?: string }
}
| undefined
// Stable reference: a fresh inline `onAuthenticated` on every render would
// re-run the hook's effect each render, restarting the OAuth flow and
// looping setStatus → render → setStatus ("Maximum update depth exceeded").
// The other tests in this file memoize the callback for the same reason.
const onAuthenticated = async (): Promise<void> => {}
function Harness(): React.ReactNode {
const status = useCodexOAuthFlow({
onAuthenticated,
deps,
})
latestStatus = status as typeof latestStatus
return <Text>{status.state}</Text>
}
const streams = createTestStreams()
const root = await createRoot({
stdout: streams.stdout as unknown as NodeJS.WriteStream,
stdin: streams.stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<Harness />)
try {
await waitForCondition(() => latestStatus?.state === 'waiting')
expect(latestStatus?.state).toBe('waiting')
expect(typeof latestStatus?.submitManualCallback).toBe('function')
const okResult = latestStatus?.submitManualCallback?.(
'http://localhost:41100/auth/callback?code=good&state=s',
)
expect(okResult).toEqual({ ok: true })
const failResult = latestStatus?.submitManualCallback?.(
'http://localhost:41100/auth/callback?code=bad&state=s',
)
expect(failResult?.ok).toBe(false)
expect(failResult?.error).toBe('State mismatch')
expect(submitManualCallback).toHaveBeenCalledTimes(2)
expect(submitManualCallback).toHaveBeenCalledWith(
'http://localhost:41100/auth/callback?code=good&state=s',
)
} finally {
root.unmount()
streams.stdin.end()
streams.stdout.end()
await Bun.sleep(0)
}
})
+14 -8
View File
@@ -2,6 +2,7 @@ import * as React from 'react'
import {
CodexOAuthService,
type CodexManualCallbackResult,
type CodexOAuthTokens,
} from '../services/api/codexOAuth.js'
import { openBrowser } from '../utils/browser.js'
@@ -14,6 +15,7 @@ export type CodexOAuthFlowStatus =
state: 'waiting'
authUrl: string
browserOpened: boolean | null
submitManualCallback: (input: string) => CodexManualCallbackResult
}
| {
state: 'error'
@@ -24,20 +26,19 @@ type PersistCodexOAuthCredentials = (options?: {
profileId?: string
}) => { warning?: string }
type CodexOAuthServiceLike = Pick<
CodexOAuthService,
'startOAuthFlow' | 'cleanup' | 'submitManualCallback'
>
type CodexOAuthFlowDependencies = {
createOAuthService?: () => Pick<
CodexOAuthService,
'startOAuthFlow' | 'cleanup'
>
createOAuthService?: () => CodexOAuthServiceLike
openBrowser?: typeof openBrowser
saveCodexCredentials?: typeof saveCodexCredentials
isBareMode?: typeof isBareMode
}
function createDefaultOAuthService(): Pick<
CodexOAuthService,
'startOAuthFlow' | 'cleanup'
> {
function createDefaultOAuthService(): CodexOAuthServiceLike {
return new CodexOAuthService()
}
@@ -71,6 +72,9 @@ export function useCodexOAuthFlow(options: {
let cancelled = false
const oauthService = createOAuthService()
const submitManualCallback = (
input: string,
): CodexManualCallbackResult => oauthService.submitManualCallback(input)
void oauthService
.startOAuthFlow(async authUrl => {
@@ -79,6 +83,7 @@ export function useCodexOAuthFlow(options: {
state: 'waiting',
authUrl,
browserOpened: null,
submitManualCallback,
})
const browserOpened = await openBrowserFn(authUrl)
if (cancelled) return
@@ -86,6 +91,7 @@ export function useCodexOAuthFlow(options: {
state: 'waiting',
authUrl,
browserOpened,
submitManualCallback,
})
})
.then(async tokens => {
+115
View File
@@ -257,6 +257,121 @@ test('serves updated success copy after a successful Codex OAuth flow', async ()
}
})
test('manual callback paste completes the flow when the loopback is unreachable', async () => {
await acquireCodexOAuthTestIsolation()
try {
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
globalThis.fetch = asMockFetch(
mock(async () => {
return new Response(
JSON.stringify({
access_token: 'manual-access-token',
refresh_token: 'manual-refresh-token',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
)
}),
)
// Hanging listener — never resolves on its own. The manual paste path
// must be what completes the flow.
let capturedState = ''
let pending = false
const hangingListenerFactory = ((callbackPath: string) => ({
callbackPath,
async start(): Promise<number> {
return 41100
},
hasPendingResponse(): boolean {
return pending
},
async waitForAuthorization(
state: string,
onReady: () => Promise<void>,
): Promise<string> {
capturedState = state
pending = true
await onReady()
return new Promise<string>(() => {
/* never resolves */
})
},
handleSuccessRedirect(): void {
pending = false
},
handleErrorRedirect(): void {
pending = false
},
cancelPendingAuthorization(): void {
pending = false
},
})) as unknown as NonNullable<
ConstructorParameters<typeof CodexOAuthService>[0]
>['createAuthCodeListener']
const service = new CodexOAuthService({
callbackPort: 0,
callbackHost: '127.0.0.1',
createAuthCodeListener: hangingListenerFactory,
})
const flowPromise = service.startOAuthFlow(async () => {})
// Wait until startOAuthFlow has populated expectedState via the listener.
// Bound the wait so a regression that never captures the state fails with a
// clear assertion instead of hanging the suite indefinitely.
const stateDeadline = Date.now() + 5_000
while (!capturedState) {
if (Date.now() > stateDeadline) {
throw new Error(
'startOAuthFlow did not capture the OAuth state within 5s',
)
}
await Bun.sleep(0)
}
const stateMismatch = service.submitManualCallback(
'http://localhost:41100/auth/callback?code=foo&state=wrong',
)
expect(stateMismatch.ok).toBe(false)
if (!stateMismatch.ok) {
expect(stateMismatch.error).toContain('State mismatch')
}
const missingCode = service.submitManualCallback(
`http://localhost:41100/auth/callback?state=${capturedState}`,
)
expect(missingCode.ok).toBe(false)
if (!missingCode.ok) {
expect(missingCode.error).toContain('`code`')
}
const errorRedirect = service.submitManualCallback(
`http://localhost:41100/auth/callback?error=access_denied&state=${capturedState}`,
)
expect(errorRedirect.ok).toBe(false)
if (!errorRedirect.ok) {
expect(errorRedirect.error).toContain('access_denied')
}
const success = service.submitManualCallback(
`http://localhost:41100/auth/callback?code=manual-auth-code&state=${capturedState}`,
)
expect(success.ok).toBe(true)
const tokens = await flowPromise
expect(tokens.accessToken).toBe('manual-access-token')
expect(tokens.refreshToken).toBe('manual-refresh-token')
} finally {
restoreCodexOAuthTestIsolation()
}
})
test('cancellation during token exchange returns a cancelled page and rejects the flow', async () => {
await acquireCodexOAuthTestIsolation()
+109 -1
View File
@@ -203,10 +203,17 @@ type CodexOAuthListener = Pick<
| 'cancelPendingAuthorization'
>
export type CodexManualCallbackResult =
| { ok: true }
| { ok: false; error: string }
export class CodexOAuthService {
private authCodeListener: CodexOAuthListener | null = null
private port: number | null = null
private tokenExchangeAbortController: AbortController | null = null
private manualResolver: ((authorizationCode: string) => void) | null = null
private manualRejecter: ((error: Error) => void) | null = null
private expectedState: string | null = null
constructor(private readonly options: CodexOAuthServiceOptions = {}) {}
@@ -214,6 +221,83 @@ export class CodexOAuthService {
return new Error('Codex OAuth flow was cancelled.')
}
/**
* Recover the flow when the loopback callback is unreachable — typically a
* remote SSH session where the user's browser redirects to a localhost URL
* that resolves to their workstation, not the openclaude host. The user
* pastes the full redirected URL (or just its query string), we validate
* the state parameter against the in-flight flow, and resolve the same
* authorization code the loopback path would have produced.
*
* Returns a structured outcome instead of throwing so the UI layer can
* surface parse / state errors inline without unmounting the flow.
*/
submitManualCallback(input: string): CodexManualCallbackResult {
const trimmed = input.trim()
if (!trimmed) {
return { ok: false, error: 'Paste the callback URL or its query string.' }
}
if (!this.manualResolver || !this.expectedState) {
return {
ok: false,
error: 'No Codex OAuth flow is waiting for a manual callback.',
}
}
let searchParams: URLSearchParams
try {
if (trimmed.includes('://')) {
searchParams = new URL(trimmed).searchParams
} else {
searchParams = new URLSearchParams(
trimmed.startsWith('?') ? trimmed.slice(1) : trimmed,
)
}
} catch {
return {
ok: false,
error: 'Could not parse the callback URL — paste the full address.',
}
}
const code = asTrimmedString(searchParams.get('code') ?? undefined)
const state = asTrimmedString(searchParams.get('state') ?? undefined)
const errorParam = asTrimmedString(searchParams.get('error') ?? undefined)
if (errorParam) {
const description = asTrimmedString(
searchParams.get('error_description') ?? undefined,
)
return {
ok: false,
error: description
? `Authorization failed: ${errorParam}${description}`
: `Authorization failed: ${errorParam}`,
}
}
if (!code) {
return { ok: false, error: 'Callback URL is missing the `code` parameter.' }
}
if (!state) {
return {
ok: false,
error: 'Callback URL is missing the `state` parameter.',
}
}
if (state !== this.expectedState) {
return {
ok: false,
error:
'State mismatch — the URL is from a different login attempt. Start over.',
}
}
const resolver = this.manualResolver
this.manualResolver = null
this.manualRejecter = null
resolver(code)
return { ok: true }
}
async startOAuthFlow(
authURLHandler: (authUrl: string) => Promise<void>,
): Promise<CodexOAuthTokens> {
@@ -242,13 +326,29 @@ export class CodexOAuthService {
state,
})
this.expectedState = state
const manualPromise = new Promise<string>((resolve, reject) => {
this.manualResolver = resolve
this.manualRejecter = reject
})
// Manual path may never be taken; swallow its rejection (raised from
// cleanup()) so it doesn't bubble as an unhandledRejection when the
// loopback wins the race.
manualPromise.catch(() => undefined)
try {
const authorizationCode = await authCodeListener.waitForAuthorization(
const loopbackPromise = authCodeListener.waitForAuthorization(
state,
async () => {
await authURLHandler(authUrl)
},
)
loopbackPromise.catch(() => undefined)
const authorizationCode = await Promise.race([
loopbackPromise,
manualPromise,
])
const tokenExchangeAbortController = new AbortController()
this.tokenExchangeAbortController = tokenExchangeAbortController
@@ -344,5 +444,13 @@ export class CodexOAuthService {
this.authCodeListener?.cancelPendingAuthorization(cancellationError)
this.authCodeListener = null
this.port = null
// Unblock any caller awaiting Promise.race against the manual path so the
// race resolves to the listener's rejection (or to this cancellation)
// instead of hanging forever.
this.manualRejecter?.(cancellationError)
this.manualResolver = null
this.manualRejecter = null
this.expectedState = null
}
}