diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 0d6435a5..2e0bd5ce 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -187,11 +187,13 @@ async function createAuthorizationFlow(): Promise<{ verifier: string; state: str type OAuthServerInfo = { close: () => void; + cancelWait: () => void; waitForCode: () => Promise<{ code: string } | null>; }; function startLocalOAuthServer(state: string): Promise { let lastCode: string | null = null; + let cancelled = false; const server = http.createServer((req, res) => { try { const url = new URL(req.url || "", "http://localhost"); @@ -226,10 +228,14 @@ function startLocalOAuthServer(state: string): Promise { .listen(1455, "127.0.0.1", () => { resolve({ close: () => server.close(), + cancelWait: () => { + cancelled = true; + }, waitForCode: async () => { const sleep = () => new Promise((r) => setTimeout(r, 100)); for (let i = 0; i < 600; i += 1) { if (lastCode) return { code: lastCode }; + if (cancelled) return null; await sleep(); } return null; @@ -250,6 +256,7 @@ function startLocalOAuthServer(state: string): Promise { // ignore } }, + cancelWait: () => {}, waitForCode: async () => null, }); }); @@ -265,11 +272,19 @@ function getAccountId(accessToken: string): string | null { /** * Login with OpenAI Codex OAuth + * + * @param options.onAuth - Called with URL and instructions when auth starts + * @param options.onPrompt - Called to prompt user for manual code paste (fallback if no onManualCodeInput) + * @param options.onProgress - Optional progress messages + * @param options.onManualCodeInput - Optional promise that resolves with user-pasted code. + * Races with browser callback - whichever completes first wins. + * Useful for showing paste input immediately alongside browser flow. */ export async function loginOpenAICodex(options: { onAuth: (info: { url: string; instructions?: string }) => void; onPrompt: (prompt: OAuthPrompt) => Promise; onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; }): Promise { const { verifier, state, url } = await createAuthorizationFlow(); const server = await startLocalOAuthServer(state); @@ -278,11 +293,50 @@ export async function loginOpenAICodex(options: { let code: string | undefined; try { - const result = await server.waitForCode(); - if (result?.code) { - code = result.code; + if (options.onManualCodeInput) { + // Race between browser callback and manual input + let manualCode: string | undefined; + const manualPromise = options + .onManualCodeInput() + .then((input) => { + manualCode = input; + server.cancelWait(); + }) + .catch(() => {}); // Ignore rejection + + const result = await server.waitForCode(); + if (result?.code) { + // Browser callback won + code = result.code; + } else if (manualCode) { + // Manual input won (or callback timed out and user had entered code) + const parsed = parseAuthorizationInput(manualCode); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + + // If still no code, wait for manual promise to complete and try that + if (!code) { + await manualPromise; + if (manualCode) { + const parsed = parseAuthorizationInput(manualCode); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + } + } else { + // Original flow: wait for callback, then prompt if needed + const result = await server.waitForCode(); + if (result?.code) { + code = result.code; + } } + // Fallback to onPrompt if still no code if (!code) { const input = await options.onPrompt({ message: "Paste the authorization code (or full redirect URL):", diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 78b1029e..60f77392 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -161,6 +161,8 @@ export class AuthStorage { onAuth: (info: { url: string; instructions?: string }) => void; onPrompt: (prompt: { message: string; placeholder?: string }) => Promise; onProgress?: (message: string) => void; + /** For providers with local callback servers (e.g., openai-codex), races with browser callback */ + onManualCodeInput?: () => Promise; }, ): Promise { let credentials: OAuthCredentials; @@ -186,7 +188,12 @@ export class AuthStorage { credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress); break; case "openai-codex": - credentials = await loginOpenAICodex(callbacks); + credentials = await loginOpenAICodex({ + onAuth: callbacks.onAuth, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + onManualCodeInput: callbacks.onManualCodeInput, + }); break; default: throw new Error(`Unknown OAuth provider: ${provider}`); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 7bae033f..683be474 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2124,7 +2124,15 @@ export class InteractiveMode { if (mode === "login") { this.showStatus(`Logging in to ${providerId}...`); + // For openai-codex: promise that resolves when user pastes code manually + let manualCodeResolve: ((code: string) => void) | undefined; + const manualCodePromise = new Promise((resolve) => { + manualCodeResolve = resolve; + }); + let manualCodeInput: Input | undefined; + try { + await this.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { onAuth: (info: { url: string; instructions?: string }) => { this.chatContainer.addChild(new Spacer(1)); @@ -2155,8 +2163,39 @@ export class InteractiveMode { ? "start" : "xdg-open"; exec(`${openCmd} "${info.url}"`); + + // For openai-codex: show paste input immediately (races with browser callback) + if (providerId === "openai-codex") { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild( + new Text( + theme.fg("dim", "Alternatively, paste the authorization code or redirect URL below:"), + 1, + 0, + ), + ); + manualCodeInput = new Input(); + manualCodeInput.onSubmit = () => { + const code = manualCodeInput!.getValue(); + if (code && manualCodeResolve) { + manualCodeResolve(code); + manualCodeResolve = undefined; + } + }; + this.editorContainer.clear(); + this.editorContainer.addChild(manualCodeInput); + this.ui.setFocus(manualCodeInput); + this.ui.requestRender(); + } }, onPrompt: async (prompt: { message: string; placeholder?: string }) => { + // Clean up manual code input if it exists (fallback case) + if (manualCodeInput) { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + manualCodeInput = undefined; + } + this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0)); if (prompt.placeholder) { @@ -2183,7 +2222,17 @@ export class InteractiveMode { this.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); this.ui.requestRender(); }, + onManualCodeInput: () => manualCodePromise, }); + + // Clean up manual code input if browser callback succeeded + if (manualCodeInput) { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + manualCodeInput = undefined; + } + // Refresh models to pick up new baseUrl (e.g., github-copilot) this.session.modelRegistry.refresh(); this.chatContainer.addChild(new Spacer(1)); @@ -2195,6 +2244,13 @@ export class InteractiveMode { ); this.ui.requestRender(); } catch (error: unknown) { + // Clean up manual code input on error + if (manualCodeInput) { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + manualCodeInput = undefined; + } this.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`); } } else {