diff --git a/extensions/openai/api.ts b/extensions/openai/api.ts index 96f5a56e8f5..ad6753b9d5f 100644 --- a/extensions/openai/api.ts +++ b/extensions/openai/api.ts @@ -10,6 +10,8 @@ export { OPENAI_DEFAULT_TTS_VOICE, } from "./default-models.js"; export { buildOpenAICodexProvider } from "./openai-codex-catalog.js"; +export { loginOpenAICodexOAuth } from "./openai-codex-oauth.runtime.js"; +export { refreshOpenAICodexToken } from "./openai-codex-provider.runtime.js"; export { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; export { buildOpenAIProvider } from "./openai-provider.js"; export { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; diff --git a/extensions/openai/openai-codex-oauth.runtime.ts b/extensions/openai/openai-codex-oauth.runtime.ts index 8fe4d487681..eb1f1c38762 100644 --- a/extensions/openai/openai-codex-oauth.runtime.ts +++ b/extensions/openai/openai-codex-oauth.runtime.ts @@ -257,6 +257,8 @@ export async function loginOpenAICodexOAuth(params: { oauth: ProviderAuthContext["oauth"]; isRemote: boolean; openUrl: (url: string) => Promise; + signal?: AbortSignal; + onManualCodeInput?: () => Promise; localBrowserMessage?: string; }): Promise { const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params; @@ -324,16 +326,19 @@ export async function loginOpenAICodexOAuth(params: { onAuth, onPrompt, originator: openAICodexOAuthOriginator, - onManualCodeInput: createManualCodeInputHandler({ - isRemote, - onPrompt, - runtime, - updateProgress, - stopProgress, - waitForLoginToSettle, - hasBrowserAuthStarted: () => browserAuthStarted, - }), + onManualCodeInput: + params.onManualCodeInput ?? + createManualCodeInputHandler({ + isRemote, + onPrompt, + runtime, + updateProgress, + stopProgress, + waitForLoginToSettle, + hasBrowserAuthStarted: () => browserAuthStarted, + }), onProgress: (msg: string) => updateProgress(msg), + signal: params.signal, }); stopProgress("OpenAI OAuth complete"); return creds ?? null; diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 5889e05068e..87e26a3a027 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -411,13 +411,20 @@ async function refreshOpenAICodexOAuthCredential(cred: OAuthCredential) { } } -async function runOpenAICodexOAuth(ctx: ProviderAuthContext) { +type OpenAICodexOAuthContext = ProviderAuthContext & { + signal?: AbortSignal; + onManualCodeInput?: () => Promise; +}; + +async function runOpenAICodexOAuth(ctx: OpenAICodexOAuthContext) { const creds = await loginOpenAICodexOAuth({ prompter: ctx.prompter, runtime: ctx.runtime, oauth: ctx.oauth, isRemote: ctx.isRemote, openUrl: ctx.openUrl, + signal: ctx.signal, + onManualCodeInput: ctx.onManualCodeInput, localBrowserMessage: "Complete sign-in in browser…", }); if (!creds) { diff --git a/src/llm/utils/oauth/openai-codex.test.ts b/src/llm/utils/oauth/openai-codex.test.ts index 50f39a2a25c..606731ead82 100644 --- a/src/llm/utils/oauth/openai-codex.test.ts +++ b/src/llm/utils/oauth/openai-codex.test.ts @@ -1,211 +1,199 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { openaiCodexOAuthProvider, refreshOpenAICodexToken, testing } from "./openai-codex.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -function createJwt(payload: Record): string { - const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); - const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); - return `${header}.${body}.signature`; +type LoginOpenAICodexOAuth = + typeof import("../../../plugins/provider-openai-codex-oauth.js").loginOpenAICodexOAuth; + +const mocks = vi.hoisted(() => ({ + loginOpenAICodexOAuth: vi.fn(), + loadActivatedBundledPluginPublicSurfaceModuleSync: vi.fn(), + refreshOpenAICodexToken: vi.fn(), + refreshProviderOAuthCredentialWithPlugin: vi.fn(), +})); + +vi.mock("../../../plugins/provider-openai-codex-oauth.js", () => ({ + loginOpenAICodexOAuth: mocks.loginOpenAICodexOAuth, +})); + +vi.mock("../../../plugins/provider-runtime.runtime.js", () => ({ + refreshProviderOAuthCredentialWithPlugin: mocks.refreshProviderOAuthCredentialWithPlugin, +})); + +vi.mock("../../../plugin-sdk/facade-runtime.js", () => ({ + loadActivatedBundledPluginPublicSurfaceModuleSync: + mocks.loadActivatedBundledPluginPublicSurfaceModuleSync, +})); + +import { loginOpenAICodex, refreshOpenAICodexToken } from "./openai-codex.js"; + +function createCredential() { + return { + type: "oauth" as const, + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: 1_700_000_000_000, + accountId: "acct_123", + }; } -function stubTokenResponse(body: Record): void { - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })), - ); -} - -function stubHangingTokenRequest(timeoutMs: number): void { - vi.spyOn(AbortSignal, "timeout").mockImplementation((actualTimeoutMs) => { - expect(actualTimeoutMs).toBe(timeoutMs); - const controller = new AbortController(); - queueMicrotask(() => { - controller.abort(new DOMException("timed out", "TimeoutError")); +describe("OpenAI Codex OAuth compatibility provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadActivatedBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + refreshOpenAICodexToken: mocks.refreshOpenAICodexToken, }); - return controller.signal; }); - vi.stubGlobal( - "fetch", - vi.fn( - (_input: Parameters[0], init?: Parameters[1]) => - new Promise((_resolve, reject) => { - const signal = init?.signal; - if (!signal) { - reject(new Error("missing abort signal")); - return; - } + it("routes legacy login callbacks through the OpenAI provider auth hook", async () => { + const credential = createCredential(); + const onAuth = vi.fn(); + const onPrompt = vi.fn(async () => "manual-code"); + mocks.loginOpenAICodexOAuth.mockImplementationOnce(async (params) => { + await params.openUrl("https://auth.openai.com/oauth/authorize?state=abc"); + await expect(params.prompter.text({ message: "Paste code" })).resolves.toBe("manual-code"); + return credential; + }); - const abort = () => { - reject( - signal.reason instanceof Error - ? signal.reason - : new DOMException("aborted", "AbortError"), - ); - }; - if (signal.aborted) { - abort(); - return; - } - signal.addEventListener("abort", abort, { once: true }); - }), - ), - ); -} + await expect(loginOpenAICodex({ onAuth, onPrompt })).resolves.toEqual(credential); -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + expect(onAuth).toHaveBeenCalledWith({ + url: "https://auth.openai.com/oauth/authorize?state=abc", + }); + expect(onPrompt).toHaveBeenCalledWith({ message: "Paste code", placeholder: undefined }); + expect(mocks.loginOpenAICodexOAuth).toHaveBeenCalledWith({ + prompter: expect.any(Object), + runtime: expect.any(Object), + isRemote: false, + signal: undefined, + onManualCodeInput: undefined, + openUrl: expect.any(Function), + }); + }); -describe("OpenAI Codex OAuth token responses", () => { - it("cancels provider login before opening the OAuth flow", async () => { + it("passes legacy manual input through so it starts alongside browser auth", async () => { + const onManualCodeInput = vi.fn(async () => "manual-code"); + mocks.loginOpenAICodexOAuth.mockImplementationOnce(async (params) => { + await expect(params.onManualCodeInput?.()).resolves.toBe("manual-code"); + await expect(params.prompter.text({ message: "Fallback code" })).resolves.toBe( + "fallback-code", + ); + return createCredential(); + }); + + await expect( + loginOpenAICodex({ + onAuth: vi.fn(), + onPrompt: vi.fn(async () => "fallback-code"), + onManualCodeInput, + }), + ).resolves.toEqual(createCredential()); + + expect(onManualCodeInput).toHaveBeenCalledOnce(); + }); + + it("honors legacy login cancellation before opening OAuth", async () => { const controller = new AbortController(); controller.abort(); await expect( - openaiCodexOAuthProvider.login({ + loginOpenAICodex({ onAuth: vi.fn(), - onPrompt: vi.fn(async () => "unused-code"), + onPrompt: vi.fn(async () => "manual-code"), + signal: controller.signal, + }), + ).rejects.toThrow("Login cancelled"); + expect(mocks.loginOpenAICodexOAuth).not.toHaveBeenCalled(); + }); + + it("passes legacy cancellation into the provider auth hook", async () => { + const controller = new AbortController(); + mocks.loginOpenAICodexOAuth.mockImplementationOnce(async (params) => { + expect(params.signal).toBe(controller.signal); + controller.abort(); + await expect(params.onManualCodeInput?.()).rejects.toThrow("Login cancelled"); + return createCredential(); + }); + + await expect( + loginOpenAICodex({ + onAuth: vi.fn(), + onPrompt: vi.fn(async () => "manual-code"), + onManualCodeInput: vi.fn(async () => "manual-code"), signal: controller.signal, }), ).rejects.toThrow("Login cancelled"); }); - it("does not open the OAuth flow after cancellation during setup", async () => { + it("honors legacy login cancellation before invoking the auth callback", async () => { const controller = new AbortController(); const onAuth = vi.fn(); - const loginPromise = openaiCodexOAuthProvider.login({ - onAuth, - onPrompt: vi.fn(async () => "unused-code"), - signal: controller.signal, + mocks.loginOpenAICodexOAuth.mockImplementationOnce(async (params) => { + controller.abort(); + await params.openUrl("https://auth.openai.com/oauth/authorize?state=abc"); + return createCredential(); }); - controller.abort(); - - await expect(loginPromise).rejects.toThrow("Login cancelled"); + await expect( + loginOpenAICodex({ + onAuth, + onPrompt: vi.fn(async () => "manual-code"), + signal: controller.signal, + }), + ).rejects.toThrow("Login cancelled"); expect(onAuth).not.toHaveBeenCalled(); }); - it("waits for Node OAuth runtime before creating an authorization flow", async () => { - const flow = await testing.createAuthorizationFlow("openclaw-test"); - const url = new URL(flow.url); + it("refreshes through the provider runtime hook without returning auth-profile fields", async () => { + mocks.refreshProviderOAuthCredentialWithPlugin.mockResolvedValueOnce(createCredential()); - expect(flow.state).toMatch(/^[a-f0-9]{32}$/u); - expect(url.searchParams.get("state")).toBe(flow.state); - expect(url.searchParams.get("originator")).toBe("openclaw-test"); - const redirectUri = url.searchParams.get("redirect_uri"); - expect(redirectUri).toBeTruthy(); - expect(flow.redirectUri).toBe(redirectUri); - expect(testing.callbackHost).toBe(new URL(redirectUri ?? "").hostname); - }); - - it("builds callback redirect URIs from the configured loopback host", () => { - expect(testing.resolveRedirectUri("127.0.0.1")).toBe("http://127.0.0.1:1455/auth/callback"); - }); - - it("rejects non-loopback callback bind hosts", () => { - expect(() => testing.resolveCallbackHost({ OPENCLAW_OAUTH_CALLBACK_HOST: "0.0.0.0" })).toThrow( - "callback host must be localhost, 127.0.0.1, or ::1", - ); - }); - - it("does not echo token payload values when the exchange response is malformed", async () => { - stubTokenResponse({ - access_token: "secret-access-token", - expires_in: 3600, + await expect(refreshOpenAICodexToken("old-refresh-token")).resolves.toEqual({ + access: "access-token", + refresh: "refresh-token", + expires: 1_700_000_000_000, + accountId: "acct_123", }); - const result = await testing.exchangeAuthorizationCode("code", "verifier"); - - expect(result).toMatchObject({ - type: "failed", - message: "OpenAI Codex token exchange response missing fields: refresh_token", - }); - if (result.type === "failed") { - expect(result.message).not.toContain("secret-access-token"); - expect(result.message).not.toContain("access_token"); - } - }); - - it("times out token exchange requests", async () => { - stubHangingTokenRequest(5); - - const result = await testing.exchangeAuthorizationCode( - "code", - "verifier", - testing.resolveRedirectUri("localhost"), - { timeoutMs: 5 }, - ); - - expect(result).toMatchObject({ - type: "failed", - message: "OpenAI Codex token exchange timed out after 5ms", - }); - }); - - it("cancels token exchange requests with the caller signal", async () => { - const controller = new AbortController(); - controller.abort(); - - const result = await testing.exchangeAuthorizationCode( - "code", - "verifier", - testing.resolveRedirectUri("localhost"), - { signal: controller.signal, timeoutMs: 5 }, - ); - - expect(result).toMatchObject({ - type: "failed", - message: "Login cancelled", - }); - }); - - it("does not echo token payload values when the refresh response is malformed", async () => { - stubTokenResponse({ - access_token: "new-secret-access-token", - refresh_token: "new-secret-refresh-token", - }); - - const result = await testing.refreshAccessToken("old-refresh-token"); - - expect(result).toMatchObject({ - type: "failed", - message: "OpenAI Codex token refresh response missing fields: expires_in", - }); - if (result.type === "failed") { - expect(result.message).not.toContain("new-secret-access-token"); - expect(result.message).not.toContain("new-secret-refresh-token"); - expect(result.message).not.toContain("access_token"); - expect(result.message).not.toContain("refresh_token"); - } - }); - - it("times out token refresh requests", async () => { - stubHangingTokenRequest(5); - - const result = await testing.refreshAccessToken("old-refresh-token", { timeoutMs: 5 }); - - expect(result).toMatchObject({ - type: "failed", - message: "OpenAI Codex token refresh timed out after 5ms", - }); - }); - - it("extracts the account id from URL-safe base64 JWT payloads", async () => { - const accessToken = createJwt({ - "https://api.openai.com/auth": { - chatgpt_account_id: "w_ébé_1fzcswWN6Pi5zL", + expect(mocks.refreshProviderOAuthCredentialWithPlugin).toHaveBeenCalledWith({ + provider: "openai-codex", + context: { + type: "oauth", + provider: "openai-codex", + access: "", + refresh: "old-refresh-token", + expires: 0, }, }); - expect(accessToken.split(".")[1]).toContain("_"); - stubTokenResponse({ - access_token: accessToken, - refresh_token: "new-secret-refresh-token", - expires_in: 3600, + expect(mocks.loadActivatedBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); + }); + + it("falls back to the OpenAI plugin facade when provider runtime refresh is unavailable", async () => { + const credential = { + access: "facade-access-token", + refresh: "facade-refresh-token", + expires: 1_700_000_000_000, + accountId: "acct_facade", + }; + mocks.refreshProviderOAuthCredentialWithPlugin.mockResolvedValueOnce(null); + mocks.refreshOpenAICodexToken.mockResolvedValueOnce(credential); + + await expect(refreshOpenAICodexToken("old-refresh-token")).resolves.toEqual(credential); + + expect(mocks.loadActivatedBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "openai", + artifactBasename: "api.js", + }); + expect(mocks.refreshOpenAICodexToken).toHaveBeenCalledWith("old-refresh-token"); + }); + + it("preserves activated-facade failures when refresh fallback is disabled", async () => { + mocks.refreshProviderOAuthCredentialWithPlugin.mockResolvedValueOnce(null); + mocks.loadActivatedBundledPluginPublicSurfaceModuleSync.mockImplementationOnce(() => { + throw new Error("plugin runtime is not activated"); }); - await expect(refreshOpenAICodexToken("old-refresh-token")).resolves.toMatchObject({ - accountId: "w_ébé_1fzcswWN6Pi5zL", - }); + await expect(refreshOpenAICodexToken("old-refresh-token")).rejects.toThrow( + "plugin runtime is not activated", + ); + expect(mocks.refreshOpenAICodexToken).not.toHaveBeenCalled(); }); }); diff --git a/src/llm/utils/oauth/openai-codex.ts b/src/llm/utils/oauth/openai-codex.ts index d53818bb0a6..9971f128465 100644 --- a/src/llm/utils/oauth/openai-codex.ts +++ b/src/llm/utils/oauth/openai-codex.ts @@ -1,607 +1,126 @@ -/** - * OpenAI Codex (ChatGPT OAuth) flow - * - * NOTE: This module uses Node.js crypto and http for the OAuth callback. - * It is only intended for CLI use, not browser environments. - */ +import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "../../../plugin-sdk/facade-runtime.js"; +import type { RuntimeEnv } from "../../../runtime.js"; +import type { WizardPrompter } from "../../../wizard/prompts.js"; +import { throwIfOAuthLoginAborted, withOAuthLoginAbort } from "./abort.js"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; -import { - buildOAuthRequestSignal, - createOAuthLoginCancelledError, - throwIfOAuthLoginAborted, - withOAuthLoginAbort, -} from "./abort.js"; -import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js"; -import { resolveOpenAICodexAccountId } from "./openai-codex-jwt.js"; -import { generatePKCE } from "./pkce.js"; -import type { - OAuthCredentials, - OAuthLoginCallbacks, - OAuthPrompt, - OAuthProviderInterface, -} from "./types.js"; +const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; -const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; -const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; -const TOKEN_URL = "https://auth.openai.com/oauth/token"; -const CALLBACK_PORT = 1455; -const CALLBACK_PATH = "/auth/callback"; -const DEFAULT_CALLBACK_HOST = "localhost"; -const LOOPBACK_CALLBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); -const CALLBACK_HOST = resolveCallbackHost(); -const REDIRECT_URI = resolveRedirectUri(CALLBACK_HOST); -const MANUAL_PROMPT_FALLBACK_MS = 15_000; -const TOKEN_REQUEST_TIMEOUT_MS = 30_000; -const SCOPE = "openid profile email offline_access"; -type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; -type TokenFailure = { type: "failed"; message: string; status?: number }; -type TokenResult = TokenSuccess | TokenFailure; -type TokenResponseJson = { - access_token?: string; - refresh_token?: string; - expires_in?: number; -}; -type NodeOAuthRuntime = { - randomBytes: typeof import("node:crypto").randomBytes; - http: typeof import("node:http"); -}; -type TokenRequestOptions = { - signal?: AbortSignal; - timeoutMs?: number; +type OpenAICodexOAuthFacade = { + refreshOpenAICodexToken: (refreshToken: string) => Promise; }; -let nodeOAuthRuntimePromise: Promise | null = null; - -function loadNodeOAuthRuntime(): Promise { - if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) { - return Promise.reject( - new Error("OpenAI Codex OAuth is only available in Node.js environments"), - ); - } - nodeOAuthRuntimePromise ??= Promise.all([import("node:crypto"), import("node:http")]).then( - ([cryptoModule, httpModule]) => ({ - randomBytes: cryptoModule.randomBytes, - http: httpModule, - }), - ); - return nodeOAuthRuntimePromise; -} - -function resolveCallbackHost(env: NodeJS.ProcessEnv = process.env): string { - const host = env.OPENCLAW_OAUTH_CALLBACK_HOST?.trim() || DEFAULT_CALLBACK_HOST; - if (!LOOPBACK_CALLBACK_HOSTS.has(host)) { - throw new Error("OpenAI Codex OAuth callback host must be localhost, 127.0.0.1, or ::1"); - } - return host; -} - -function resolveRedirectUri(host: string = CALLBACK_HOST): string { - const hostForUrl = host === "::1" ? "[::1]" : host; - const url = new URL(`http://${hostForUrl}:${CALLBACK_PORT}`); - url.pathname = CALLBACK_PATH; - return url.toString(); -} - -function createState(randomBytes: typeof import("node:crypto").randomBytes): string { - return randomBytes(16).toString("hex"); -} - -function waitForManualPromptFallback(signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(createOAuthLoginCancelledError()); - return; - } - - const cleanup = () => { - signal?.removeEventListener("abort", abort); - }; - const abort = () => { - clearTimeout(timeout); - cleanup(); - reject(createOAuthLoginCancelledError()); - }; - const timeout = setTimeout(() => { - cleanup(); - resolve(null); - }, MANUAL_PROMPT_FALLBACK_MS); - - signal?.addEventListener("abort", abort, { once: true }); - timeout.unref?.(); +function loadOpenAICodexOAuthFacade(): OpenAICodexOAuthFacade { + return loadActivatedBundledPluginPublicSurfaceModuleSync({ + dirName: "openai", + artifactBasename: "api.js", }); } -function parseAuthorizationInput(input: string): { code?: string; state?: string } { - const value = input.trim(); - if (!value) { - return {}; - } - - try { - const url = new URL(value); - return { - code: url.searchParams.get("code") ?? undefined, - state: url.searchParams.get("state") ?? undefined, - }; - } catch { - // not a URL - } - - if (value.includes("#")) { - const [code, state] = value.split("#", 2); - return { code, state }; - } - - if (value.includes("code=")) { - const params = new URLSearchParams(value); - return { - code: params.get("code") ?? undefined, - state: params.get("state") ?? undefined, - }; - } - - return { code: value }; -} - -async function promptForAuthorizationCode( - onPrompt: (prompt: OAuthPrompt) => Promise, - state: string, -): Promise { - const input = await onPrompt({ - message: "Paste the authorization code (or full redirect URL):", - }); - const parsed = parseAuthorizationInput(input); - if (parsed.state && parsed.state !== state) { - throw new Error("State mismatch"); - } - return parsed.code; -} - -function formatMissingTokenResponseFields(json: TokenResponseJson): string { - const missing: string[] = []; - if (!json.access_token) { - missing.push("access_token"); - } - if (!json.refresh_token) { - missing.push("refresh_token"); - } - if (typeof json.expires_in !== "number") { - missing.push("expires_in"); - } - return missing.join(", "); -} - -function formatTokenRequestError( - operation: "exchange" | "refresh", - error: unknown, - timeoutMs: number, - signal?: AbortSignal, -): string { - if (signal?.aborted) { - return "Login cancelled"; - } - if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) { - return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`; - } - return `OpenAI Codex token ${operation} error: ${error instanceof Error ? error.message : String(error)}`; -} - -async function exchangeAuthorizationCode( - code: string, - verifier: string, - redirectUri: string = REDIRECT_URI, - options: TokenRequestOptions = {}, -): Promise { - const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS; - let response: Response; - try { - throwIfOAuthLoginAborted(options.signal); - response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: CLIENT_ID, - code, - code_verifier: verifier, - redirect_uri: redirectUri, - }), - signal: buildOAuthRequestSignal({ signal: options.signal, timeoutMs }), - }); - } catch (error) { - return { - type: "failed", - message: formatTokenRequestError("exchange", error, timeoutMs, options.signal), - }; - } - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - type: "failed", - status: response.status, - message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`, - }; - } - - const json = (await response.json()) as TokenResponseJson; - - if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - return { - type: "failed", - message: `OpenAI Codex token exchange response missing fields: ${formatMissingTokenResponseFields(json)}`, - }; - } - +function createLegacyRuntime(callbacks: OAuthLoginCallbacks): RuntimeEnv { return { - type: "success", - access: json.access_token, - refresh: json.refresh_token, - expires: Date.now() + json.expires_in * 1000, + log: (message) => callbacks.onProgress?.(String(message)), + error: (message) => callbacks.onProgress?.(String(message)), + exit: (code) => { + throw new Error(`exit:${code}`); + }, }; } -async function refreshAccessToken( - refreshToken: string, - options: TokenRequestOptions = {}, -): Promise { - try { - const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS; - throwIfOAuthLoginAborted(options.signal); - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLIENT_ID, - }), - signal: buildOAuthRequestSignal({ signal: options.signal, timeoutMs }), - }); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - type: "failed", - status: response.status, - message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`, - }; - } - - const json = (await response.json()) as TokenResponseJson; - - if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - return { - type: "failed", - message: `OpenAI Codex token refresh response missing fields: ${formatMissingTokenResponseFields(json)}`, - }; - } - - return { - type: "success", - access: json.access_token, - refresh: json.refresh_token, - expires: Date.now() + json.expires_in * 1000, - }; - } catch (error) { - return { - type: "failed", - message: formatTokenRequestError( - "refresh", - error, - options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS, - options.signal, - ), - }; - } -} - -async function createAuthorizationFlow( - originator: string = "openclaw", -): Promise<{ verifier: string; redirectUri: string; state: string; url: string }> { - const [{ verifier, challenge }, runtime] = await Promise.all([ - generatePKCE(), - loadNodeOAuthRuntime(), - ]); - const state = createState(runtime.randomBytes); - - const url = new URL(AUTHORIZE_URL); - url.searchParams.set("response_type", "code"); - url.searchParams.set("client_id", CLIENT_ID); - const redirectUri = REDIRECT_URI; - url.searchParams.set("redirect_uri", redirectUri); - url.searchParams.set("scope", SCOPE); - url.searchParams.set("code_challenge", challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", state); - url.searchParams.set("id_token_add_organizations", "true"); - url.searchParams.set("codex_cli_simplified_flow", "true"); - url.searchParams.set("originator", originator); - - return { verifier, redirectUri, state, url: url.toString() }; -} - -type OAuthServerInfo = { - close: () => void; - cancelWait: () => void; - waitForCode: () => Promise<{ code: string } | null>; -}; - -async function startLocalOAuthServer(state: string): Promise { - const { http } = await loadNodeOAuthRuntime(); - let settleWait: ((value: { code: string } | null) => void) | undefined; - const waitForCodePromise = new Promise<{ code: string } | null>((resolve) => { - let settled = false; - settleWait = (value) => { - if (settled) { - return; +function createLegacyPrompter(callbacks: OAuthLoginCallbacks): WizardPrompter { + const progress = { + update: (message: string) => callbacks.onProgress?.(message), + stop: (message?: string) => { + if (message) { + callbacks.onProgress?.(message); } - settled = true; - resolve(value); - }; - }); - - const server = http.createServer((req, res) => { - try { - const url = new URL(req.url || "", "http://localhost"); - if (url.pathname !== "/auth/callback") { - res.statusCode = 404; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("Callback route not found.")); - return; - } - if (url.searchParams.get("state") !== state) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("State mismatch.")); - return; - } - const code = url.searchParams.get("code"); - if (!code) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("Missing authorization code.")); - return; - } - res.statusCode = 200; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window.")); - settleWait?.({ code }); - } catch { - res.statusCode = 500; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(oauthErrorHtml("Internal error while processing OAuth callback.")); - } - }); - - return new Promise((resolve) => { - server - .listen(CALLBACK_PORT, CALLBACK_HOST, () => { - resolve({ - close: () => server.close(), - cancelWait: () => { - settleWait?.(null); - }, - waitForCode: () => waitForCodePromise, - }); - }) - .on("error", () => { - settleWait?.(null); - resolve({ - close: () => { - try { - server.close(); - } catch { - // ignore - } - }, - cancelWait: () => {}, - waitForCode: async () => null, - }); + }, + }; + return { + intro: async () => {}, + outro: async () => {}, + note: async (message) => callbacks.onProgress?.(message), + select: async (params) => params.options[0]?.value, + multiselect: async (params) => params.initialValues ?? [], + text: async (prompt) => { + const input = callbacks.onPrompt({ + message: prompt.message, + placeholder: prompt.placeholder, }); + return await withOAuthLoginAbort(input, callbacks.signal); + }, + confirm: async () => false, + progress: () => progress, + } as WizardPrompter; +} + +async function refreshViaProviderRuntime(refreshToken: string): Promise { + const { refreshProviderOAuthCredentialWithPlugin } = + await import("../../../plugins/provider-runtime.runtime.js"); + const refreshed = await refreshProviderOAuthCredentialWithPlugin({ + provider: OPENAI_CODEX_PROVIDER_ID, + context: { + type: "oauth", + provider: OPENAI_CODEX_PROVIDER_ID, + access: "", + refresh: refreshToken, + expires: 0, + }, }); -} - -function getAccountId(accessToken: string): string | null { - return resolveOpenAICodexAccountId(accessToken); -} - -/** - * 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. - * @param options.originator - OAuth originator parameter (defaults to "openclaw") - */ -export async function loginOpenAICodex(options: { - onAuth: (info: { url: string; instructions?: string }) => void; - onPrompt: (prompt: OAuthPrompt) => Promise; - onProgress?: (message: string) => void; - onManualCodeInput?: () => Promise; - originator?: string; - signal?: AbortSignal; -}): Promise { - throwIfOAuthLoginAborted(options.signal); - const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator); - const server = await startLocalOAuthServer(state); - - let code: string | undefined; - try { - throwIfOAuthLoginAborted(options.signal); - options.onAuth({ - url, - instructions: "A browser window should open. Complete login to finish.", - }); - throwIfOAuthLoginAborted(options.signal); - - if (options.onManualCodeInput) { - // Race between browser callback and manual input - let manualCode: string | undefined; - let manualError: Error | undefined; - const manualPromise = options - .onManualCodeInput() - .then((input) => { - manualCode = input; - server.cancelWait(); - }) - .catch((err) => { - manualError = err instanceof Error ? err : new Error(String(err)); - server.cancelWait(); - }); - - const result = await withOAuthLoginAbort( - server.waitForCode(), - options.signal, - server.cancelWait, - ); - - // If manual input was cancelled, throw that error - if (manualError) { - throw manualError; - } - - 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 withOAuthLoginAbort(manualPromise, options.signal, server.cancelWait); - if (manualError) { - throw manualError; - } - if (manualCode) { - const parsed = parseAuthorizationInput(manualCode); - if (parsed.state && parsed.state !== state) { - throw new Error("State mismatch"); - } - code = parsed.code; - } - } - } else { - const callbackPromise = server.waitForCode(); - const result = await withOAuthLoginAbort( - Promise.race([callbackPromise, waitForManualPromptFallback(options.signal)]), - options.signal, - server.cancelWait, - ); - if (result?.code) { - code = result.code; - } else { - const promptCodePromise = promptForAuthorizationCode(options.onPrompt, state).then( - (promptCode) => { - server.cancelWait(); - return promptCode; - }, - ); - code = await withOAuthLoginAbort( - Promise.race([callbackPromise.then((callback) => callback?.code), promptCodePromise]), - options.signal, - server.cancelWait, - ); - } - } - - // Fallback to onPrompt if still no code - if (!code) { - code = await withOAuthLoginAbort( - promptForAuthorizationCode(options.onPrompt, state), - options.signal, - server.cancelWait, - ); - } - - if (!code) { - throw new Error("Missing authorization code"); - } - - const tokenResult = await exchangeAuthorizationCode(code, verifier, redirectUri, { - signal: options.signal, - }); - if (tokenResult.type !== "success") { - throw new Error(tokenResult.message); - } - - const accountId = getAccountId(tokenResult.access); - if (!accountId) { - throw new Error("Failed to extract accountId from token"); - } - - return { - access: tokenResult.access, - refresh: tokenResult.refresh, - expires: tokenResult.expires, - accountId, - }; - } finally { - server.close(); + if (!refreshed) { + return await loadOpenAICodexOAuthFacade().refreshOpenAICodexToken(refreshToken); } + const credentials: Record = { ...refreshed }; + delete credentials.type; + delete credentials.provider; + return credentials as OAuthCredentials; +} + +export async function loginOpenAICodex(callbacks: OAuthLoginCallbacks): Promise { + throwIfOAuthLoginAborted(callbacks.signal); + const { loginOpenAICodexOAuth } = await import("../../../plugins/provider-openai-codex-oauth.js"); + const manualCodeInput = callbacks.onManualCodeInput; + const onManualCodeInput = manualCodeInput + ? async () => await withOAuthLoginAbort(manualCodeInput(), callbacks.signal) + : undefined; + const credentials = await withOAuthLoginAbort( + loginOpenAICodexOAuth({ + prompter: createLegacyPrompter(callbacks), + runtime: createLegacyRuntime(callbacks), + isRemote: false, + signal: callbacks.signal, + onManualCodeInput, + openUrl: async (url) => { + throwIfOAuthLoginAborted(callbacks.signal); + callbacks.onAuth({ url }); + }, + }), + callbacks.signal, + ); + if (!credentials) { + throw new Error("OpenAI Codex OAuth login did not return credentials."); + } + return credentials; } -/** - * Refresh OpenAI Codex OAuth token - */ export async function refreshOpenAICodexToken(refreshToken: string): Promise { - const result = await refreshAccessToken(refreshToken); - if (result.type !== "success") { - throw new Error(result.message); - } - - const accountId = getAccountId(result.access); - if (!accountId) { - throw new Error("Failed to extract accountId from token"); - } - - return { - access: result.access, - refresh: result.refresh, - expires: result.expires, - accountId, - }; + return await refreshViaProviderRuntime(refreshToken); } export const openaiCodexOAuthProvider: OAuthProviderInterface = { - id: "openai-codex", + id: OPENAI_CODEX_PROVIDER_ID, name: "ChatGPT Plus/Pro (Codex Subscription)", usesCallbackServer: true, async login(callbacks: OAuthLoginCallbacks): Promise { - return loginOpenAICodex({ - onAuth: callbacks.onAuth, - onPrompt: callbacks.onPrompt, - onProgress: callbacks.onProgress, - onManualCodeInput: callbacks.onManualCodeInput, - signal: callbacks.signal, - }); + return await loginOpenAICodex(callbacks); }, async refreshToken(credentials: OAuthCredentials): Promise { - return refreshOpenAICodexToken(credentials.refresh); + return await refreshOpenAICodexToken(credentials.refresh); }, getApiKey(credentials: OAuthCredentials): string { return credentials.access; }, }; - -export const testing = { - callbackHost: CALLBACK_HOST, - createAuthorizationFlow, - exchangeAuthorizationCode, - loginOpenAICodex, - refreshAccessToken, - resolveCallbackHost, - resolveRedirectUri, -}; diff --git a/src/plugins/provider-openai-codex-oauth.test.ts b/src/plugins/provider-openai-codex-oauth.test.ts index a7499f627c8..dc1d3cdb124 100644 --- a/src/plugins/provider-openai-codex-oauth.test.ts +++ b/src/plugins/provider-openai-codex-oauth.test.ts @@ -2,43 +2,36 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -const mocks = vi.hoisted(() => ({ - loginOpenAICodex: vi.fn(), - runOpenAIOAuthTlsPreflight: vi.fn(), - formatOpenAIOAuthTlsPreflightFix: vi.fn(), +const providerRuntimeMocks = vi.hoisted(() => ({ + loadActivatedBundledPluginPublicSurfaceModuleSync: vi.fn(), + resolveProviderRuntimePlugin: vi.fn(), + runOAuth: vi.fn(), + runFacadeOAuth: vi.fn(), })); -vi.mock("../llm/oauth.js", async () => { - const actual = await vi.importActual("../llm/oauth.js"); - return { - ...actual, - loginOpenAICodex: mocks.loginOpenAICodex, - }; -}); +vi.mock("./provider-hook-runtime.js", () => ({ + resolveProviderRuntimePlugin: providerRuntimeMocks.resolveProviderRuntimePlugin, +})); -vi.mock("./provider-openai-codex-oauth-tls.js", () => ({ - runOpenAIOAuthTlsPreflight: mocks.runOpenAIOAuthTlsPreflight, - formatOpenAIOAuthTlsPreflightFix: mocks.formatOpenAIOAuthTlsPreflightFix, +vi.mock("../plugin-sdk/facade-runtime.js", () => ({ + loadActivatedBundledPluginPublicSurfaceModuleSync: + providerRuntimeMocks.loadActivatedBundledPluginPublicSurfaceModuleSync, })); import { loginOpenAICodexOAuth } from "./provider-openai-codex-oauth.js"; -const CODEX_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize?state=abc"; - -type CodexLoginOptions = { - onAuth: (event: { url: string }) => Promise; - onManualCodeInput?: () => Promise; -}; - -function createPrompter() { +function createPrompter(): WizardPrompter { const spin = { update: vi.fn(), stop: vi.fn() }; - const text = vi.fn(async () => "http://localhost:1455/auth/callback?code=test"); - const prompter: Pick = { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), note: vi.fn(async () => {}), + select: vi.fn(), + multiselect: vi.fn(), + text: vi.fn(async () => "http://localhost:1455/auth/callback?code=test"), + confirm: vi.fn(), progress: vi.fn(() => spin), - text, - }; - return { prompter: prompter as unknown as WizardPrompter, spin, text }; + } as unknown as WizardPrompter; } function createRuntime(): RuntimeEnv { @@ -51,477 +44,151 @@ function createRuntime(): RuntimeEnv { }; } -function createCodexCredentials(extra: Record = {}) { +function createCredential() { return { - provider: "openai-codex" as const, + type: "oauth" as const, + provider: "openai-codex", access: "access-token", refresh: "refresh-token", expires: Date.now() + 60_000, email: "user@example.com", - ...extra, }; } -function expectFields(value: unknown, expected: Record): void { - if (!value || typeof value !== "object") { - throw new Error("expected fields object"); - } - const record = value as Record; - for (const [key, expectedValue] of Object.entries(expected)) { - expect(record[key], key).toEqual(expectedValue); - } -} - -function expectMockFirstArgFields(mock: unknown, expected: Record): void { - const calls = (mock as { mock?: { calls?: Array> } }).mock?.calls ?? []; - const [arg] = calls[0] ?? []; - expectFields(arg, expected); -} - -function expectRuntimeErrorContains(runtime: RuntimeEnv, fragment: string): void { - expect( - (runtime.error as unknown as { mock?: { calls?: Array> } }).mock?.calls?.some( - ([message]) => String(message).includes(fragment), - ), - `runtime.error contains ${fragment}`, - ).toBe(true); -} - -function expectPromptTextCall(prompter: WizardPrompter): void { - const textMock = prompter.text as unknown as { mock?: { calls?: Array> } }; - const [arg] = textMock.mock?.calls?.[0] ?? []; - expectFields(arg, { message: "Paste the authorization code (or full redirect URL):" }); - expect(typeof (arg as { validate?: unknown }).validate).toBe("function"); -} - -async function startCodexAuth(opts: CodexLoginOptions) { - await opts.onAuth({ url: CODEX_AUTHORIZE_URL }); - expect(opts.onManualCodeInput).toBeTypeOf("function"); -} - -async function runCodexOAuth(params: { - isRemote: boolean; - openUrl?: (url: string) => Promise; -}) { - const { prompter, spin } = createPrompter(); - const runtime = createRuntime(); - const result = await loginOpenAICodexOAuth({ - prompter, - runtime, - isRemote: params.isRemote, - openUrl: params.openUrl ?? (async () => {}), - }); - return { result, prompter, spin, runtime }; -} - describe("loginOpenAICodexOAuth", () => { beforeEach(() => { - vi.clearAllMocks(); - mocks.runOpenAIOAuthTlsPreflight.mockResolvedValue({ ok: true }); - mocks.formatOpenAIOAuthTlsPreflightFix.mockReturnValue("tls fix"); - }); - - it("returns credentials on successful oauth login", async () => { - const creds = createCodexCredentials(); - mocks.loginOpenAICodex.mockResolvedValue(creds); - - const { result, spin, runtime } = await runCodexOAuth({ isRemote: false }); - - expect(result).toEqual(creds); - expect(mocks.loginOpenAICodex).toHaveBeenCalledOnce(); - expectMockFirstArgFields(mocks.loginOpenAICodex, { originator: "openclaw" }); - expect(spin.stop).toHaveBeenCalledWith("OpenAI OAuth complete"); - expect(runtime.error).not.toHaveBeenCalled(); - }); - - it("passes through runtime-provided authorize URLs without mutation", async () => { - const creds = createCodexCredentials(); - mocks.loginOpenAICodex.mockImplementation( - async (opts: { onAuth: (event: { url: string }) => Promise }) => { - await opts.onAuth({ - url: "https://auth.openai.com/oauth/authorize?scope=openid+profile+email+offline_access&state=abc", - }); - return creds; - }, - ); - - const openUrl = vi.fn(async () => {}); - const { runtime } = await runCodexOAuth({ isRemote: false, openUrl }); - - expect(openUrl).toHaveBeenCalledWith( - "https://auth.openai.com/oauth/authorize?scope=openid+profile+email+offline_access&state=abc", - ); - expect(runtime.log).toHaveBeenCalledWith( - "Open: https://auth.openai.com/oauth/authorize?scope=openid+profile+email+offline_access&state=abc", - ); - }); - - it("preserves authorize urls that omit scope", async () => { - const creds = createCodexCredentials(); - mocks.loginOpenAICodex.mockImplementation( - async (opts: { onAuth: (event: { url: string }) => Promise }) => { - await opts.onAuth({ url: CODEX_AUTHORIZE_URL }); - return creds; - }, - ); - - const openUrl = vi.fn(async () => {}); - await runCodexOAuth({ isRemote: false, openUrl }); - - expect(openUrl).toHaveBeenCalledWith(CODEX_AUTHORIZE_URL); - }); - - it("preserves slash-terminated authorize paths too", async () => { - const creds = createCodexCredentials(); - mocks.loginOpenAICodex.mockImplementation( - async (opts: { onAuth: (event: { url: string }) => Promise }) => { - await opts.onAuth({ - url: "https://auth.openai.com/oauth/authorize/?state=abc", - }); - return creds; - }, - ); - - const openUrl = vi.fn(async () => {}); - await runCodexOAuth({ isRemote: false, openUrl }); - - expect(openUrl).toHaveBeenCalledWith("https://auth.openai.com/oauth/authorize/?state=abc"); - }); - - it("reports oauth errors and rethrows", async () => { - mocks.loginOpenAICodex.mockRejectedValue(new Error("oauth failed")); - - const { prompter, spin } = createPrompter(); - const runtime = createRuntime(); - await expect( - loginOpenAICodexOAuth({ - prompter, - runtime, - isRemote: true, - openUrl: async () => {}, - }), - ).rejects.toThrow("oauth failed"); - - expect(spin.stop).toHaveBeenCalledWith("OpenAI OAuth failed"); - expectRuntimeErrorContains(runtime, "oauth failed"); - expect(prompter.note).toHaveBeenCalledWith( - "Trouble with OAuth? See https://docs.openclaw.ai/start/faq", - "OAuth help", - ); - }); - - it("describes remote OAuth paste first while noting automatic callback completion", async () => { - const creds = createCodexCredentials(); - mocks.loginOpenAICodex.mockResolvedValue(creds); - - const { prompter } = await runCodexOAuth({ isRemote: true }); - const noteCalls = (prompter.note as unknown as { mock?: { calls?: Array> } }) - .mock?.calls; - const [message, title] = noteCalls?.[0] ?? []; - - expect(title).toBe("OpenAI Codex OAuth"); - expect(message).toContain("A URL will be shown for you to open in your LOCAL browser."); - expect(message).toContain("Open it, sign in, then paste the redirect URL here."); - expect(message).toContain( - "If this OpenClaw process can receive the browser callback, sign-in may finish automatically before you paste.", - ); - expect(message).not.toContain("After signing in, paste"); - }); - - it("explains OpenAI unsupported region token exchange failures", async () => { - mocks.loginOpenAICodex.mockRejectedValue(new Error("403 unsupported_country_region_territory")); - - const { prompter, spin } = createPrompter(); - const runtime = createRuntime(); - await expect( - loginOpenAICodexOAuth({ - prompter, - runtime, - isRemote: false, - openUrl: async () => {}, - }), - ).rejects.toThrow(/unsupported_region/i); - - expect(spin.stop).toHaveBeenCalledWith("OpenAI OAuth failed"); - expectRuntimeErrorContains(runtime, "HTTPS_PROXY"); - expect(prompter.note).toHaveBeenCalledWith( - "Trouble with OAuth? See https://docs.openclaw.ai/start/faq", - "OAuth help", - ); - }); - - it("passes manual code input hook for remote oauth flows", async () => { - const creds = createCodexCredentials(); - mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => { - await startCodexAuth(opts); - await expect(opts.onManualCodeInput?.()).resolves.toContain("code=test"); - return creds; + for (const mock of Object.values(providerRuntimeMocks)) { + mock.mockReset(); + } + providerRuntimeMocks.resolveProviderRuntimePlugin.mockReturnValue({ + auth: [ + { + id: "oauth", + run: providerRuntimeMocks.runOAuth, + }, + ], + }); + providerRuntimeMocks.loadActivatedBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + loginOpenAICodexOAuth: providerRuntimeMocks.runFacadeOAuth, }); - - const { result, prompter } = await runCodexOAuth({ isRemote: true }); - - expect(result).toEqual(creds); - expectPromptTextCall(prompter); }); - it("waits briefly before prompting for manual input after the local browser flow starts", async () => { - vi.useFakeTimers(); - const { prompter, spin, text } = createPrompter(); + it("delegates OAuth login to the OpenAI provider auth hook", async () => { + const credential = createCredential(); + const prompter = createPrompter(); const runtime = createRuntime(); - mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => { - await startCodexAuth(opts); - const manualPromise = opts.onManualCodeInput?.(); - await vi.advanceTimersByTimeAsync(14_000); - if (manualPromise === undefined) { - throw new Error("expected manual code input promise"); - } - expect(prompter.text).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1_000); - expect(prompter.text).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1_000); - return createCodexCredentials({ manualCode: await manualPromise }); + const openUrl = vi.fn(async () => {}); + const controller = new AbortController(); + const onManualCodeInput = vi.fn(async () => "manual-code"); + providerRuntimeMocks.runOAuth.mockResolvedValueOnce({ + profiles: [{ profileId: "openai-codex:user@example.com", credential }], }); const result = await loginOpenAICodexOAuth({ prompter, runtime, - isRemote: false, - openUrl: async () => {}, - }); - expectFields(result, { - access: "access-token", - refresh: "refresh-token", + isRemote: true, + openUrl, + signal: controller.signal, + onManualCodeInput, + localBrowserMessage: "Complete sign-in in browser...", }); - expectPromptTextCall(prompter); - expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required"); - expect(spin.stop.mock.invocationCallOrder[0]).toBeLessThan( - text.mock.invocationCallOrder[0] ?? 0, - ); - expect(runtime.log).toHaveBeenCalledWith( - "OpenAI Codex OAuth callback did not arrive within 15000ms; switching to manual entry (callback_timeout).", - ); - vi.useRealTimers(); - }); - - it("reuses one local manual prompt when the oauth helper repeats fallback calls", async () => { - vi.useFakeTimers(); - const { prompter, spin, text } = createPrompter(); - const runtime = createRuntime(); - mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => { - await startCodexAuth(opts); - const firstManualPromise = opts.onManualCodeInput?.(); - const secondManualPromise = opts.onManualCodeInput?.(); - await vi.advanceTimersByTimeAsync(16_000); - const [firstManualCode, secondManualCode] = await Promise.all([ - firstManualPromise, - secondManualPromise, - ]); - expect(secondManualCode).toBe(firstManualCode); - return createCodexCredentials({ manualCode: firstManualCode }); - }); - - const result = await loginOpenAICodexOAuth({ - prompter, - runtime, - isRemote: false, - openUrl: async () => {}, - }); - expectFields(result, { - access: "access-token", - refresh: "refresh-token", - }); - - expect(text).toHaveBeenCalledOnce(); - expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required"); + expect(result).toEqual(credential); + expect(providerRuntimeMocks.runOAuth).toHaveBeenCalledOnce(); expect( - spin.update.mock.calls.filter( - ([message]) => - message === "Browser callback did not finish. Paste the redirect URL to continue…", - ), - ).toHaveLength(1); - expect(runtime.log).toHaveBeenCalledTimes(2); - expect(runtime.log).toHaveBeenCalledWith( - "OpenAI Codex OAuth callback did not arrive within 15000ms; switching to manual entry (callback_timeout).", - ); - vi.useRealTimers(); + providerRuntimeMocks.loadActivatedBundledPluginPublicSurfaceModuleSync, + ).not.toHaveBeenCalled(); + expect(providerRuntimeMocks.runOAuth).toHaveBeenCalledWith({ + config: {}, + prompter, + runtime, + isRemote: true, + openUrl, + signal: controller.signal, + onManualCodeInput, + oauth: { + createVpsAwareHandlers: expect.any(Function), + }, + }); }); - it("clears the local manual fallback timer when browser callback settles first", async () => { - vi.useFakeTimers(); - mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => { - await startCodexAuth(opts); - void opts.onManualCodeInput?.(); - return createCodexCredentials(); - }); - - const callbackResult = await runCodexOAuth({ isRemote: false }); - expectFields(callbackResult.result, { - access: "access-token", - refresh: "refresh-token", - }); - - expect(vi.getTimerCount()).toBe(0); - vi.useRealTimers(); - }); - - it("continues OAuth flow on non-certificate preflight failures", async () => { - const creds = createCodexCredentials(); - mocks.runOpenAIOAuthTlsPreflight.mockResolvedValue({ - ok: false, - kind: "network", - message: "Client network socket disconnected before secure TLS connection was established", - }); - mocks.loginOpenAICodex.mockResolvedValue(creds); - - const { result, prompter, runtime } = await runCodexOAuth({ isRemote: false }); - - expect(result).toEqual(creds); - expect(mocks.loginOpenAICodex).toHaveBeenCalledOnce(); - expect(runtime.error).not.toHaveBeenCalledWith("tls fix"); - expect(prompter.note).not.toHaveBeenCalledWith("tls fix", "OAuth prerequisites"); - }); - - it("fails fast on TLS certificate preflight failures before starting OAuth login", async () => { - mocks.runOpenAIOAuthTlsPreflight.mockResolvedValue({ - ok: false, - kind: "tls-cert", - code: "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", - message: "unable to get local issuer certificate", - }); - mocks.formatOpenAIOAuthTlsPreflightFix.mockReturnValue("Run brew postinstall openssl@3"); - const creds = createCodexCredentials(); - mocks.loginOpenAICodex.mockResolvedValue(creds); - - const { prompter } = createPrompter(); - const runtime = createRuntime(); + it("returns null when the provider hook does not create an OAuth credential", async () => { + providerRuntimeMocks.runOAuth.mockResolvedValueOnce({ profiles: [] }); await expect( loginOpenAICodexOAuth({ - prompter, - runtime, + prompter: createPrompter(), + runtime: createRuntime(), isRemote: false, openUrl: async () => {}, }), - ).rejects.toThrow(/OAuth prerequisites/i); - - expect(mocks.loginOpenAICodex).not.toHaveBeenCalled(); - expect(prompter.note).toHaveBeenCalledWith( - "Run brew postinstall openssl@3", - "OAuth prerequisites", - ); + ).resolves.toBeNull(); }); - it("prompts for manual input immediately when the local callback flow never starts", async () => { - vi.useFakeTimers(); - const { prompter, spin, text } = createPrompter(); + it("falls back to the OpenAI plugin facade when the provider hook is unavailable", async () => { + const credential = { + access: "facade-access-token", + refresh: "facade-refresh-token", + expires: Date.now() + 60_000, + accountId: "acct_facade", + }; + const prompter = createPrompter(); const runtime = createRuntime(); - mocks.loginOpenAICodex.mockImplementation( - async (opts: { onManualCodeInput?: () => Promise }) => { - expect(opts.onManualCodeInput).toBeTypeOf("function"); - const manualCode = await opts.onManualCodeInput?.(); - return createCodexCredentials({ manualCode }); + const openUrl = vi.fn(async () => {}); + const controller = new AbortController(); + const onManualCodeInput = vi.fn(async () => "manual-code"); + providerRuntimeMocks.resolveProviderRuntimePlugin.mockReturnValueOnce(undefined); + providerRuntimeMocks.runFacadeOAuth.mockResolvedValueOnce(credential); + + const result = await loginOpenAICodexOAuth({ + prompter, + runtime, + isRemote: false, + openUrl, + signal: controller.signal, + onManualCodeInput, + localBrowserMessage: "Complete sign-in in browser...", + }); + + expect(result).toEqual(credential); + expect(providerRuntimeMocks.runOAuth).not.toHaveBeenCalled(); + expect( + providerRuntimeMocks.loadActivatedBundledPluginPublicSurfaceModuleSync, + ).toHaveBeenCalledWith({ + dirName: "openai", + artifactBasename: "api.js", + }); + expect(providerRuntimeMocks.runFacadeOAuth).toHaveBeenCalledWith({ + prompter, + runtime, + isRemote: false, + openUrl, + signal: controller.signal, + onManualCodeInput, + localBrowserMessage: "Complete sign-in in browser...", + oauth: { + createVpsAwareHandlers: expect.any(Function), + }, + }); + }); + + it("preserves activated-facade failures when the OpenAI plugin is disabled", async () => { + providerRuntimeMocks.resolveProviderRuntimePlugin.mockReturnValueOnce(undefined); + providerRuntimeMocks.loadActivatedBundledPluginPublicSurfaceModuleSync.mockImplementationOnce( + () => { + throw new Error("plugin runtime is not activated"); }, ); - const result = await loginOpenAICodexOAuth({ - prompter, - runtime, - isRemote: false, - openUrl: async () => {}, - }); - expectFields(result, { - access: "access-token", - refresh: "refresh-token", - }); - - expectPromptTextCall(prompter); - expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required"); - expect(spin.stop.mock.invocationCallOrder[0]).toBeLessThan( - text.mock.invocationCallOrder[0] ?? 0, - ); - expect(vi.getTimerCount()).toBe(0); - vi.useRealTimers(); - }); - - it("reuses one immediate manual prompt when the local callback flow never starts", async () => { - vi.useFakeTimers(); - const { prompter, spin, text } = createPrompter(); - const runtime = createRuntime(); - mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => { - expect(opts.onManualCodeInput).toBeTypeOf("function"); - const [firstManualCode, secondManualCode] = await Promise.all([ - opts.onManualCodeInput?.(), - opts.onManualCodeInput?.(), - ]); - expect(secondManualCode).toBe(firstManualCode); - return createCodexCredentials({ manualCode: firstManualCode }); - }); - - const result = await loginOpenAICodexOAuth({ - prompter, - runtime, - isRemote: false, - openUrl: async () => {}, - }); - expectFields(result, { - access: "access-token", - refresh: "refresh-token", - }); - - expect(text).toHaveBeenCalledOnce(); - expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required"); - expect( - spin.update.mock.calls.filter( - ([message]) => - message === "Local OAuth callback was unavailable. Paste the redirect URL to continue…", - ), - ).toHaveLength(1); - expect(runtime.log).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - vi.useRealTimers(); - }); - - it("suppresses the local manual prompt when oauth settles just after the fallback deadline", async () => { - vi.useFakeTimers(); - const { prompter } = createPrompter(); - const runtime = createRuntime(); - mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => { - await startCodexAuth(opts); - void opts.onManualCodeInput?.(); - await vi.advanceTimersByTimeAsync(15_500); - return createCodexCredentials(); - }); - - const result = await loginOpenAICodexOAuth({ - prompter, - runtime, - isRemote: false, - openUrl: async () => {}, - }); - expectFields(result, { - access: "access-token", - refresh: "refresh-token", - }); - - expect(prompter.text).not.toHaveBeenCalled(); - vi.useRealTimers(); - }); - - it("rewrites callback validation failures with a stable internal code", async () => { - mocks.loginOpenAICodex.mockRejectedValue(new Error("State mismatch")); - - const { prompter, spin } = createPrompter(); - const runtime = createRuntime(); await expect( loginOpenAICodexOAuth({ - prompter, - runtime, + prompter: createPrompter(), + runtime: createRuntime(), isRemote: false, openUrl: async () => {}, }), - ).rejects.toThrow(/callback_validation_failed/i); - - expect(spin.stop).toHaveBeenCalledWith("OpenAI OAuth failed"); + ).rejects.toThrow("plugin runtime is not activated"); + expect(providerRuntimeMocks.runFacadeOAuth).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/provider-openai-codex-oauth.ts b/src/plugins/provider-openai-codex-oauth.ts index 7fb5f6e3665..a6b23bb9f6d 100644 --- a/src/plugins/provider-openai-codex-oauth.ts +++ b/src/plugins/provider-openai-codex-oauth.ts @@ -1,239 +1,87 @@ -import { formatErrorMessage } from "../infra/errors.js"; -import { ensureGlobalUndiciEnvProxyDispatcher } from "../infra/net/undici-global-dispatcher.js"; -import { loginOpenAICodex, type OAuthCredentials } from "../llm/oauth.js"; +import type { OAuthCredentials } from "../llm/oauth.js"; +import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "../plugin-sdk/facade-runtime.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -import type { OAuthPrompt } from "./provider-oauth-flow.js"; +import { resolveProviderRuntimePlugin } from "./provider-hook-runtime.js"; import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; -import { - formatOpenAIOAuthTlsPreflightFix, - runOpenAIOAuthTlsPreflight, -} from "./provider-openai-codex-oauth-tls.js"; +import type { ProviderAuthContext } from "./types.js"; -const manualInputPromptMessage = "Paste the authorization code (or full redirect URL):"; -const openAICodexOAuthOriginator = "openclaw"; -const localManualFallbackDelayMs = 15_000; -const localManualFallbackGraceMs = 1_000; +const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; +const OPENAI_CODEX_OAUTH_METHOD_ID = "oauth"; -type OpenAICodexOAuthFailureCode = - | "callback_timeout" - | "callback_validation_failed" - | "unsupported_region"; +type OpenAICodexOAuthBridgeContext = ProviderAuthContext & { + signal?: AbortSignal; + onManualCodeInput?: () => Promise; +}; -function waitForDelayOrLoginSettle(params: { - delayMs: number; - waitForLoginToSettle: Promise; -}): Promise<"delay" | "settled"> { - return new Promise((resolve) => { - let finished = false; - const finish = (outcome: "delay" | "settled") => { - if (finished) { - return; - } - finished = true; - clearTimeout(timeoutHandle); - resolve(outcome); - }; - const timeoutHandle = setTimeout(() => finish("delay"), params.delayMs); - params.waitForLoginToSettle.then( - () => finish("settled"), - () => finish("settled"), - ); - }); -} - -function createNeverSettlingPromptResult(): Promise { - return new Promise(() => undefined); -} - -function createOpenAICodexOAuthError( - code: OpenAICodexOAuthFailureCode, - message: string, - cause?: unknown, -): Error & { code: OpenAICodexOAuthFailureCode } { - const error = new Error(`OpenAI Codex OAuth failed (${code}): ${message}`, { cause }); - return Object.assign(error, { code }); -} - -function rewriteOpenAICodexOAuthError(error: unknown): Error { - const message = formatErrorMessage(error); - if (/unsupported_country_region_territory/i.test(message)) { - return createOpenAICodexOAuthError( - "unsupported_region", - [ - "OpenAI rejected the token exchange for this country, region, or network route.", - "If you normally use a proxy, verify HTTPS_PROXY, HTTP_PROXY, or ALL_PROXY is set for the OpenClaw process and then retry `openclaw models auth login --provider openai-codex`.", - ].join(" "), - error, - ); - } - if (/state mismatch|missing authorization code/i.test(message)) { - return createOpenAICodexOAuthError("callback_validation_failed", message, error); - } - return error instanceof Error ? error : new Error(message); -} - -function createManualCodeInputHandler(params: { - isRemote: boolean; - onPrompt: (prompt: OAuthPrompt) => Promise; - runtime: RuntimeEnv; - updateProgress: (message: string) => void; - stopProgress: (message?: string) => void; - waitForLoginToSettle: Promise; - hasBrowserAuthStarted: () => boolean; -}): (() => Promise) | undefined { - let manualFallbackPromise: Promise | undefined; - if (params.isRemote) { - return async () => { - manualFallbackPromise ??= params.onPrompt({ - message: manualInputPromptMessage, - }); - return await manualFallbackPromise; - }; - } - - const runLocalManualFallback = async () => { - if (!params.hasBrowserAuthStarted()) { - params.updateProgress( - "Local OAuth callback was unavailable. Paste the redirect URL to continue…", - ); - params.runtime.log( - "OpenAI Codex OAuth local callback did not start; switching to manual entry immediately.", - ); - params.stopProgress("Manual OAuth entry required"); - return await params.onPrompt({ - message: manualInputPromptMessage, - }); - } - - const outcome = await waitForDelayOrLoginSettle({ - delayMs: localManualFallbackDelayMs, - waitForLoginToSettle: params.waitForLoginToSettle, - }); - if (outcome === "settled") { - // markLoginSettled() runs in loginOpenAICodexOAuth's finally block, so - // reaching this branch means the outer login call has already completed. - // Return a never-settling promise to suppress an unnecessary manual - // prompt without feeding placeholder input back into the upstream flow. - return await createNeverSettlingPromptResult(); - } - - const settledDuringGraceWindow = await waitForDelayOrLoginSettle({ - delayMs: localManualFallbackGraceMs, - waitForLoginToSettle: params.waitForLoginToSettle, - }); - if (settledDuringGraceWindow === "settled") { - return await createNeverSettlingPromptResult(); - } - - params.updateProgress("Browser callback did not finish. Paste the redirect URL to continue…"); - params.runtime.log( - `OpenAI Codex OAuth callback did not arrive within ${localManualFallbackDelayMs}ms; switching to manual entry (callback_timeout).`, - ); - params.stopProgress("Manual OAuth entry required"); - return await params.onPrompt({ - message: manualInputPromptMessage, - }); - }; - - return async () => { - manualFallbackPromise ??= runLocalManualFallback(); - return await manualFallbackPromise; - }; -} - -export async function loginOpenAICodexOAuth(params: { +type OpenAICodexOAuthLoginParams = { prompter: WizardPrompter; runtime: RuntimeEnv; isRemote: boolean; openUrl: (url: string) => Promise; + signal?: AbortSignal; + onManualCodeInput?: () => Promise; localBrowserMessage?: string; -}): Promise { - const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params; +}; - ensureGlobalUndiciEnvProxyDispatcher(); +type OpenAICodexOAuthFacade = { + loginOpenAICodexOAuth: ( + params: OpenAICodexOAuthLoginParams & Pick, + ) => Promise; +}; - const preflight = await runOpenAIOAuthTlsPreflight(); - if (!preflight.ok && preflight.kind === "tls-cert") { - const hint = formatOpenAIOAuthTlsPreflightFix(preflight); - await prompter.note(hint, "OAuth prerequisites"); - runtime.error(hint); - throw new Error(`OpenAI Codex OAuth prerequisites failed: ${preflight.message}`); - } - - await prompter.note( - isRemote - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "Open it, sign in, then paste the redirect URL here.", - "If this OpenClaw process can receive the browser callback, sign-in may finish automatically before you paste.", - ].join("\n") - : [ - "Browser will open for OpenAI authentication.", - "If the callback doesn't auto-complete, paste the redirect URL.", - "OpenAI OAuth uses localhost:1455 for the callback.", - ].join("\n"), - "OpenAI Codex OAuth", - ); - - const spin = prompter.progress("Starting OAuth flow…"); - let progressActive = true; - const updateProgress = (message: string) => { - if (progressActive) { - spin.update(message); - } - }; - const stopProgress = (message?: string) => { - if (progressActive) { - progressActive = false; - spin.stop(message); - } - }; - let browserAuthStarted = false; - let markLoginSettled!: () => void; - const waitForLoginToSettle = new Promise((resolve) => { - markLoginSettled = resolve; +function loadOpenAICodexOAuthFacade(): OpenAICodexOAuthFacade { + return loadActivatedBundledPluginPublicSurfaceModuleSync({ + dirName: "openai", + artifactBasename: "api.js", }); - try { - const { onAuth: baseOnAuth, onPrompt } = createVpsAwareOAuthHandlers({ - isRemote, - prompter, - runtime, - spin, - openUrl, - localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser…", - manualPromptMessage: manualInputPromptMessage, - }); - const onAuth: typeof baseOnAuth = async (event) => { - browserAuthStarted = true; - await baseOnAuth(event); - }; - - const creds = await loginOpenAICodex({ - onAuth, - onPrompt, - originator: openAICodexOAuthOriginator, - onManualCodeInput: createManualCodeInputHandler({ - isRemote, - onPrompt, - runtime, - updateProgress, - stopProgress, - waitForLoginToSettle, - hasBrowserAuthStarted: () => browserAuthStarted, - }), - onProgress: (msg: string) => updateProgress(msg), - }); - stopProgress("OpenAI OAuth complete"); - return creds ?? null; - } catch (err) { - stopProgress("OpenAI OAuth failed"); - const rewrittenError = rewriteOpenAICodexOAuthError(err); - runtime.error(String(rewrittenError)); - await prompter.note("Trouble with OAuth? See https://docs.openclaw.ai/start/faq", "OAuth help"); - throw rewrittenError; - } finally { - markLoginSettled(); - } +} + +function isOAuthCredential(value: unknown): value is OAuthCredentials { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ( + record.type === "oauth" && + record.provider === OPENAI_CODEX_PROVIDER_ID && + typeof record.access === "string" && + typeof record.refresh === "string" && + typeof record.expires === "number" + ); +} + +/** @deprecated OpenAI Codex OAuth is owned by the OpenAI plugin auth hook. */ +export async function loginOpenAICodexOAuth( + params: OpenAICodexOAuthLoginParams, +): Promise { + const oauthHandlers = { + createVpsAwareHandlers: createVpsAwareOAuthHandlers, + }; + const provider = resolveProviderRuntimePlugin({ + provider: OPENAI_CODEX_PROVIDER_ID, + config: {}, + bundledProviderVitestCompat: true, + }); + const oauth = provider?.auth?.find((method) => method.id === OPENAI_CODEX_OAUTH_METHOD_ID); + if (!oauth) { + return await loadOpenAICodexOAuthFacade().loginOpenAICodexOAuth({ + ...params, + oauth: oauthHandlers, + }); + } + + const context: OpenAICodexOAuthBridgeContext = { + config: {}, + prompter: params.prompter, + runtime: params.runtime, + isRemote: params.isRemote, + openUrl: params.openUrl, + signal: params.signal, + onManualCodeInput: params.onManualCodeInput, + oauth: oauthHandlers, + }; + const result = await oauth.run(context); + const credential = result.profiles[0]?.credential; + return isOAuthCredential(credential) ? credential : null; }