mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-31 02:52:35 +00:00
refactor(openai): centralize codex oauth flow (#87411)
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user