refactor(openai): centralize codex oauth flow (#87411)

This commit is contained in:
Dallin Romney
2026-05-27 22:32:08 -07:00
committed by GitHub
parent 53704b26e8
commit e805ffd2eb
7 changed files with 477 additions and 1441 deletions

View File

@@ -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";

View File

@@ -257,6 +257,8 @@ export async function loginOpenAICodexOAuth(params: {
oauth: ProviderAuthContext["oauth"];
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
signal?: AbortSignal;
onManualCodeInput?: () => Promise<string>;
localBrowserMessage?: string;
}): Promise<OAuthCredentials | null> {
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;

View File

@@ -411,13 +411,20 @@ async function refreshOpenAICodexOAuthCredential(cred: OAuthCredential) {
}
}
async function runOpenAICodexOAuth(ctx: ProviderAuthContext) {
type OpenAICodexOAuthContext = ProviderAuthContext & {
signal?: AbortSignal;
onManualCodeInput?: () => Promise<string>;
};
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) {

View File

@@ -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, unknown>): 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<LoginOpenAICodexOAuth>(),
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<string, unknown>): 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<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
new Promise<Response>((_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();
});
});

View File

@@ -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<OAuthCredentials>;
};
let nodeOAuthRuntimePromise: Promise<NodeOAuthRuntime> | null = null;
function loadNodeOAuthRuntime(): Promise<NodeOAuthRuntime> {
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<null> {
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<OpenAICodexOAuthFacade>({
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<string>,
state: string,
): Promise<string | undefined> {
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<TokenResult> {
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<TokenResult> {
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<OAuthServerInfo> {
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<OAuthCredentials> {
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<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
originator?: string;
signal?: AbortSignal;
}): Promise<OAuthCredentials> {
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<string, unknown> = { ...refreshed };
delete credentials.type;
delete credentials.provider;
return credentials as OAuthCredentials;
}
export async function loginOpenAICodex(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
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<OAuthCredentials> {
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<OAuthCredentials> {
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<OAuthCredentials> {
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,
};

View File

@@ -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<typeof import("../llm/oauth.js")>("../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<void>;
onManualCodeInput?: () => Promise<string>;
};
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<WizardPrompter, "note" | "progress" | "text"> = {
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<string, unknown> = {}) {
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<string, unknown>): void {
if (!value || typeof value !== "object") {
throw new Error("expected fields object");
}
const record = value as Record<string, unknown>;
for (const [key, expectedValue] of Object.entries(expected)) {
expect(record[key], key).toEqual(expectedValue);
}
}
function expectMockFirstArgFields(mock: unknown, expected: Record<string, unknown>): void {
const calls = (mock as { mock?: { calls?: Array<Array<unknown>> } }).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<Array<unknown>> } }).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<Array<unknown>> } };
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<void>;
}) {
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<void> }) => {
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<void> }) => {
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<void> }) => {
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<Array<unknown>> } })
.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<string> }) => {
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();
});
});

View File

@@ -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<string>;
};
function waitForDelayOrLoginSettle(params: {
delayMs: number;
waitForLoginToSettle: Promise<void>;
}): 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<string> {
return new Promise<string>(() => 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<string>;
runtime: RuntimeEnv;
updateProgress: (message: string) => void;
stopProgress: (message?: string) => void;
waitForLoginToSettle: Promise<void>;
hasBrowserAuthStarted: () => boolean;
}): (() => Promise<string>) | undefined {
let manualFallbackPromise: Promise<string> | 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<void>;
signal?: AbortSignal;
onManualCodeInput?: () => Promise<string>;
localBrowserMessage?: string;
}): Promise<OAuthCredentials | null> {
const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params;
};
ensureGlobalUndiciEnvProxyDispatcher();
type OpenAICodexOAuthFacade = {
loginOpenAICodexOAuth: (
params: OpenAICodexOAuthLoginParams & Pick<ProviderAuthContext, "oauth">,
) => Promise<OAuthCredentials | null>;
};
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<void>((resolve) => {
markLoginSettled = resolve;
function loadOpenAICodexOAuthFacade(): OpenAICodexOAuthFacade {
return loadActivatedBundledPluginPublicSurfaceModuleSync<OpenAICodexOAuthFacade>({
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<string, unknown>;
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<OAuthCredentials | null> {
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;
}