mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:20:43 +00:00
feat(openai): add codex device-code auth and fix login options in menu (#69557)
Merged via squash.
Prepared head SHA: 4918ed69f1
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
This commit is contained in:
@@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Models/CLI: show bundled provider-owned static catalog rows in `models list --all` before auth is configured, including Kimi K2.6 rows for Moonshot, OpenRouter, and Vercel AI Gateway, while keeping local-only and workspace plugin catalog paths isolated. (#69909) Thanks @shakkernerd.
|
||||
- Configure: skip generic CLI startup bootstrap for `openclaw configure` and bound hint-only gateway probes so the onboarding TUI reaches its first prompt faster when the Gateway is unavailable. (#69984) Thanks @obviyus.
|
||||
- Agents/harness: surface selected plugin harness failures directly instead of replaying the same turn through embedded PI, preventing misleading secondary PI auth errors and avoiding duplicate side effects.
|
||||
- OpenAI Codex: add a ChatGPT device-code auth option beside browser OAuth, so headless or callback-hostile setups can sign in without relying on the localhost browser callback. (#69557) Thanks @vincentkoc.
|
||||
|
||||
## 2026.4.21
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ type CodexJwtPayload = {
|
||||
email?: unknown;
|
||||
};
|
||||
"https://api.openai.com/auth"?: {
|
||||
chatgpt_account_id?: unknown;
|
||||
chatgpt_account_user_id?: unknown;
|
||||
chatgpt_user_id?: unknown;
|
||||
user_id?: unknown;
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
|
||||
import {
|
||||
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
hasOpenAICodexCliOAuthCredential,
|
||||
readOpenAICodexCliOAuthProfile,
|
||||
} from "./openai-codex-cli-auth.js";
|
||||
|
||||
@@ -80,6 +81,20 @@ describe("readOpenAICodexCliOAuthProfile", () => {
|
||||
expect(parsed?.credential.expires).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("detects an existing Codex CLI chatgpt login for setup labeling", () => {
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue(
|
||||
JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hasOpenAICodexCliOAuthCredential()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not override a locally managed OpenAI Codex profile", () => {
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -66,6 +66,18 @@ function readCodexCliAuthFile(env: NodeJS.ProcessEnv): CodexCliAuthFile | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function hasOpenAICodexCliOAuthCredential(params?: { env?: NodeJS.ProcessEnv }): boolean {
|
||||
const authFile = readCodexCliAuthFile(params?.env ?? process.env);
|
||||
if (!authFile || authFile.auth_mode !== "chatgpt") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
trimNonEmptyString(authFile.tokens?.access_token) &&
|
||||
trimNonEmptyString(authFile.tokens?.refresh_token),
|
||||
);
|
||||
}
|
||||
|
||||
function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean {
|
||||
return (
|
||||
a.type === b.type &&
|
||||
|
||||
215
extensions/openai/openai-codex-device-code.test.ts
Normal file
215
extensions/openai/openai-codex-device-code.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveCodexAccessTokenExpiry } from "./openai-codex-auth-identity.js";
|
||||
import { loginOpenAICodexDeviceCode } from "./openai-codex-device-code.js";
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
function createJsonResponse(body: unknown, init?: { status?: number }) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: init?.status ?? 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("loginOpenAICodexDeviceCode", () => {
|
||||
it("requests a device code, polls for authorization, and exchanges OAuth tokens", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
device_auth_id: "device-auth-123",
|
||||
user_code: "CODE-12345",
|
||||
interval: "0",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
authorization_code: "authorization-code-123",
|
||||
code_challenge: "ignored",
|
||||
code_verifier: "code-verifier-123",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
access_token: createJwt({
|
||||
exp: Math.floor(Date.now() / 1000) + 600,
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct_123",
|
||||
},
|
||||
"https://api.openai.com/profile": {
|
||||
email: "codex@example.com",
|
||||
},
|
||||
}),
|
||||
refresh_token: "refresh-token-123",
|
||||
id_token: createJwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct_123",
|
||||
},
|
||||
}),
|
||||
expires_in: 600,
|
||||
}),
|
||||
);
|
||||
const onVerification = vi.fn(async () => {});
|
||||
const onProgress = vi.fn();
|
||||
|
||||
const credentialsPromise = loginOpenAICodexDeviceCode({
|
||||
fetchFn: fetchMock as typeof fetch,
|
||||
onVerification,
|
||||
onProgress,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const credentials = await credentialsPromise;
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://auth.openai.com/api/accounts/deviceauth/usercode",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
expect(onVerification).toHaveBeenCalledWith({
|
||||
verificationUrl: "https://auth.openai.com/codex/device",
|
||||
userCode: "CODE-12345",
|
||||
expiresInMs: 900_000,
|
||||
});
|
||||
expect(onProgress).toHaveBeenNthCalledWith(1, "Requesting device code…");
|
||||
expect(onProgress).toHaveBeenNthCalledWith(2, "Waiting for device authorization…");
|
||||
expect(onProgress).toHaveBeenNthCalledWith(3, "Exchanging device code…");
|
||||
expect(credentials).toMatchObject({
|
||||
access: expect.any(String),
|
||||
refresh: "refresh-token-123",
|
||||
});
|
||||
expect(credentials).not.toHaveProperty("accountId");
|
||||
expect(credentials.expires).toBeGreaterThan(Date.now());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats JWT-derived expiry fallback as an absolute timestamp", async () => {
|
||||
const accessToken = createJwt({
|
||||
exp: Math.floor(Date.now() / 1000) + 600,
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct_123",
|
||||
},
|
||||
});
|
||||
const expectedExpiry = resolveCodexAccessTokenExpiry(accessToken);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
device_auth_id: "device-auth-123",
|
||||
user_code: "CODE-12345",
|
||||
interval: "0",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
authorization_code: "authorization-code-123",
|
||||
code_verifier: "code-verifier-123",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
access_token: accessToken,
|
||||
refresh_token: "refresh-token-123",
|
||||
}),
|
||||
);
|
||||
|
||||
const credentials = await loginOpenAICodexDeviceCode({
|
||||
fetchFn: fetchMock as typeof fetch,
|
||||
onVerification: async () => {},
|
||||
});
|
||||
|
||||
expect(expectedExpiry).toBeDefined();
|
||||
expect(credentials.expires).toBe(expectedExpiry);
|
||||
});
|
||||
|
||||
it("surfaces user-code request failures", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(`down\r\n\u001B[31mnow\u001B[0m`, {
|
||||
status: 503,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loginOpenAICodexDeviceCode({
|
||||
fetchFn: fetchMock as typeof fetch,
|
||||
onVerification: async () => {},
|
||||
}),
|
||||
).rejects.toThrow("OpenAI device code request failed: HTTP 503 down now");
|
||||
});
|
||||
|
||||
it("surfaces device authorization failures with sanitized payload details", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
device_auth_id: "device-auth-123",
|
||||
user_code: "CODE-12345",
|
||||
interval: "0",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse(
|
||||
{
|
||||
error: "authorization_declined\r\n\u001B[31mspoofed\u001B[0m",
|
||||
error_description: "Denied\r\nnext line",
|
||||
},
|
||||
{ status: 401 },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loginOpenAICodexDeviceCode({
|
||||
fetchFn: fetchMock as typeof fetch,
|
||||
onVerification: async () => {},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"OpenAI device authorization failed: authorization_declined spoofed (Denied next line)",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips C1 terminal controls from reflected device-code errors", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
device_auth_id: "device-auth-123",
|
||||
user_code: "CODE-12345",
|
||||
interval: "0",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse(
|
||||
{
|
||||
error: `authorization_declined${String.fromCharCode(0x9b)}spoofed`,
|
||||
error_description: `Denied${String.fromCharCode(0x9d)}next line`,
|
||||
},
|
||||
{ status: 401 },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loginOpenAICodexDeviceCode({
|
||||
fetchFn: fetchMock as typeof fetch,
|
||||
onVerification: async () => {},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"OpenAI device authorization failed: authorization_declined spoofed (Denied next line)",
|
||||
);
|
||||
});
|
||||
});
|
||||
305
extensions/openai/openai-codex-device-code.ts
Normal file
305
extensions/openai/openai-codex-device-code.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { resolveCodexAccessTokenExpiry } from "./openai-codex-auth-identity.js";
|
||||
import { trimNonEmptyString } from "./openai-codex-shared.js";
|
||||
|
||||
const OPENAI_AUTH_BASE_URL = "https://auth.openai.com";
|
||||
const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const OPENAI_CODEX_DEVICE_CODE_TIMEOUT_MS = 15 * 60_000;
|
||||
const OPENAI_CODEX_DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000;
|
||||
const OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS = 1_000;
|
||||
const OPENAI_CODEX_DEVICE_CALLBACK_URL = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`;
|
||||
|
||||
type OpenAICodexDeviceCodePrompt = {
|
||||
verificationUrl: string;
|
||||
userCode: string;
|
||||
expiresInMs: number;
|
||||
};
|
||||
|
||||
type OpenAICodexDeviceCodeCredentials = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
};
|
||||
|
||||
type DeviceCodeUserCodePayload = {
|
||||
device_auth_id?: unknown;
|
||||
user_code?: unknown;
|
||||
usercode?: unknown;
|
||||
interval?: unknown;
|
||||
};
|
||||
|
||||
type DeviceCodeTokenPayload = {
|
||||
authorization_code?: unknown;
|
||||
code_challenge?: unknown;
|
||||
code_verifier?: unknown;
|
||||
};
|
||||
|
||||
type OAuthTokenPayload = {
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
expires_in?: unknown;
|
||||
};
|
||||
|
||||
type RequestedDeviceCode = {
|
||||
deviceAuthId: string;
|
||||
userCode: string;
|
||||
verificationUrl: string;
|
||||
intervalMs: number;
|
||||
};
|
||||
|
||||
type DeviceCodeAuthorizationCode = {
|
||||
authorizationCode: string;
|
||||
codeVerifier: string;
|
||||
};
|
||||
|
||||
function normalizePositiveMilliseconds(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.trunc(value * 1000);
|
||||
}
|
||||
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
||||
const seconds = Number.parseInt(value.trim(), 10);
|
||||
return seconds > 0 ? seconds * 1000 : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeTokenLifetimeMs(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.trunc(value * 1000);
|
||||
}
|
||||
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
||||
return Number.parseInt(value.trim(), 10) * 1000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeDeviceCodeErrorText(value: string): string {
|
||||
const esc = String.fromCharCode(0x1b);
|
||||
const ansiCsiRegex = new RegExp(`${esc}\\[[\\u0020-\\u003f]*[\\u0040-\\u007e]`, "g");
|
||||
const osc8Regex = new RegExp(`${esc}\\]8;;.*?${esc}\\\\|${esc}\\]8;;${esc}\\\\`, "g");
|
||||
const c0Start = String.fromCharCode(0x00);
|
||||
const c0End = String.fromCharCode(0x1f);
|
||||
const del = String.fromCharCode(0x7f);
|
||||
const c1Start = String.fromCharCode(0x80);
|
||||
const c1End = String.fromCharCode(0x9f);
|
||||
const controlCharsRegex = new RegExp(`[${c0Start}-${c0End}${del}${c1Start}-${c1End}]`, "g");
|
||||
return value
|
||||
.replace(osc8Regex, "")
|
||||
.replace(ansiCsiRegex, "")
|
||||
.replace(controlCharsRegex, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveNextDeviceCodePollDelayMs(intervalMs: number, deadlineMs: number): number {
|
||||
const remainingMs = Math.max(0, deadlineMs - Date.now());
|
||||
return Math.min(Math.max(intervalMs, OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS), remainingMs);
|
||||
}
|
||||
|
||||
function formatDeviceCodeError(params: {
|
||||
prefix: string;
|
||||
status: number;
|
||||
bodyText: string;
|
||||
}): string {
|
||||
const body = parseJsonObject(params.bodyText);
|
||||
const error = trimNonEmptyString(body?.error);
|
||||
const description = trimNonEmptyString(body?.error_description);
|
||||
const safeError = error ? sanitizeDeviceCodeErrorText(error) : undefined;
|
||||
const safeDescription = description ? sanitizeDeviceCodeErrorText(description) : undefined;
|
||||
if (safeError && safeDescription) {
|
||||
return `${params.prefix}: ${safeError} (${safeDescription})`;
|
||||
}
|
||||
if (safeError) {
|
||||
return `${params.prefix}: ${safeError}`;
|
||||
}
|
||||
const bodyText = sanitizeDeviceCodeErrorText(params.bodyText);
|
||||
return bodyText
|
||||
? `${params.prefix}: HTTP ${params.status} ${bodyText}`
|
||||
: `${params.prefix}: HTTP ${params.status}`;
|
||||
}
|
||||
|
||||
async function requestOpenAICodexDeviceCode(fetchFn: typeof fetch): Promise<RequestedDeviceCode> {
|
||||
const response = await fetchFn(`${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/usercode`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
}),
|
||||
});
|
||||
|
||||
const bodyText = await response.text();
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(
|
||||
"OpenAI Codex device code login is not enabled for this server. Use ChatGPT OAuth instead.",
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
formatDeviceCodeError({
|
||||
prefix: "OpenAI device code request failed",
|
||||
status: response.status,
|
||||
bodyText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const body = parseJsonObject(bodyText) as DeviceCodeUserCodePayload | null;
|
||||
const deviceAuthId = trimNonEmptyString(body?.device_auth_id);
|
||||
const userCode = trimNonEmptyString(body?.user_code) ?? trimNonEmptyString(body?.usercode);
|
||||
if (!deviceAuthId || !userCode) {
|
||||
throw new Error("OpenAI device code response was missing the device code or user code.");
|
||||
}
|
||||
|
||||
return {
|
||||
deviceAuthId,
|
||||
userCode,
|
||||
verificationUrl: `${OPENAI_AUTH_BASE_URL}/codex/device`,
|
||||
intervalMs:
|
||||
normalizePositiveMilliseconds(body?.interval) ?? OPENAI_CODEX_DEVICE_CODE_DEFAULT_INTERVAL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
async function pollOpenAICodexDeviceCode(params: {
|
||||
fetchFn: typeof fetch;
|
||||
deviceAuthId: string;
|
||||
userCode: string;
|
||||
intervalMs: number;
|
||||
}): Promise<DeviceCodeAuthorizationCode> {
|
||||
const deadline = Date.now() + OPENAI_CODEX_DEVICE_CODE_TIMEOUT_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const response = await params.fetchFn(`${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
device_auth_id: params.deviceAuthId,
|
||||
user_code: params.userCode,
|
||||
}),
|
||||
});
|
||||
|
||||
const bodyText = await response.text();
|
||||
if (response.ok) {
|
||||
const body = parseJsonObject(bodyText) as DeviceCodeTokenPayload | null;
|
||||
const authorizationCode = trimNonEmptyString(body?.authorization_code);
|
||||
const codeVerifier = trimNonEmptyString(body?.code_verifier);
|
||||
if (!authorizationCode || !codeVerifier) {
|
||||
throw new Error("OpenAI device authorization response was missing the exchange code.");
|
||||
}
|
||||
return {
|
||||
authorizationCode,
|
||||
codeVerifier,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 403 || response.status === 404) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, resolveNextDeviceCodePollDelayMs(params.intervalMs, deadline)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
formatDeviceCodeError({
|
||||
prefix: "OpenAI device authorization failed",
|
||||
status: response.status,
|
||||
bodyText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("OpenAI device authorization timed out after 15 minutes.");
|
||||
}
|
||||
|
||||
async function exchangeOpenAICodexDeviceCode(params: {
|
||||
fetchFn: typeof fetch;
|
||||
authorizationCode: string;
|
||||
codeVerifier: string;
|
||||
}): Promise<OpenAICodexDeviceCodeCredentials> {
|
||||
const response = await params.fetchFn(`${OPENAI_AUTH_BASE_URL}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: params.authorizationCode,
|
||||
redirect_uri: OPENAI_CODEX_DEVICE_CALLBACK_URL,
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
code_verifier: params.codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
const bodyText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
formatDeviceCodeError({
|
||||
prefix: "OpenAI device token exchange failed",
|
||||
status: response.status,
|
||||
bodyText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const body = parseJsonObject(bodyText) as OAuthTokenPayload | null;
|
||||
const access = trimNonEmptyString(body?.access_token);
|
||||
const refresh = trimNonEmptyString(body?.refresh_token);
|
||||
if (!access || !refresh) {
|
||||
throw new Error("OpenAI token exchange succeeded but did not return OAuth tokens.");
|
||||
}
|
||||
|
||||
const expiresInMs = normalizeTokenLifetimeMs(body?.expires_in);
|
||||
const expires =
|
||||
expiresInMs !== undefined
|
||||
? Date.now() + expiresInMs
|
||||
: (resolveCodexAccessTokenExpiry(access) ?? Date.now());
|
||||
|
||||
return {
|
||||
access,
|
||||
refresh,
|
||||
expires,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginOpenAICodexDeviceCode(params: {
|
||||
fetchFn?: typeof fetch;
|
||||
onVerification: (prompt: OpenAICodexDeviceCodePrompt) => Promise<void> | void;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<OpenAICodexDeviceCodeCredentials> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
|
||||
params.onProgress?.("Requesting device code…");
|
||||
const deviceCode = await requestOpenAICodexDeviceCode(fetchFn);
|
||||
|
||||
await params.onVerification({
|
||||
verificationUrl: deviceCode.verificationUrl,
|
||||
userCode: deviceCode.userCode,
|
||||
expiresInMs: OPENAI_CODEX_DEVICE_CODE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
params.onProgress?.("Waiting for device authorization…");
|
||||
const authorization = await pollOpenAICodexDeviceCode({
|
||||
fetchFn,
|
||||
deviceAuthId: deviceCode.deviceAuthId,
|
||||
userCode: deviceCode.userCode,
|
||||
intervalMs: deviceCode.intervalMs,
|
||||
});
|
||||
|
||||
params.onProgress?.("Exchanging device code…");
|
||||
return await exchangeOpenAICodexDeviceCode({
|
||||
fetchFn,
|
||||
authorizationCode: authorization.authorizationCode,
|
||||
codeVerifier: authorization.codeVerifier,
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
|
||||
|
||||
const refreshOpenAICodexTokenMock = vi.hoisted(() => vi.fn());
|
||||
const readOpenAICodexCliOAuthProfileMock = vi.hoisted(() => vi.fn());
|
||||
const hasOpenAICodexCliOAuthCredentialMock = vi.hoisted(() => vi.fn());
|
||||
const loginOpenAICodexDeviceCodeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./openai-codex-provider.runtime.js", () => ({
|
||||
refreshOpenAICodexToken: refreshOpenAICodexTokenMock,
|
||||
@@ -14,10 +16,15 @@ vi.mock("./openai-codex-cli-auth.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./openai-codex-cli-auth.js")>();
|
||||
return {
|
||||
...actual,
|
||||
hasOpenAICodexCliOAuthCredential: hasOpenAICodexCliOAuthCredentialMock,
|
||||
readOpenAICodexCliOAuthProfile: readOpenAICodexCliOAuthProfileMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./openai-codex-device-code.js", () => ({
|
||||
loginOpenAICodexDeviceCode: loginOpenAICodexDeviceCodeMock,
|
||||
}));
|
||||
|
||||
let buildOpenAICodexProviderPlugin: typeof import("./openai-codex-provider.js").buildOpenAICodexProviderPlugin;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -61,6 +68,9 @@ describe("openai codex provider", () => {
|
||||
beforeEach(() => {
|
||||
refreshOpenAICodexTokenMock.mockReset();
|
||||
readOpenAICodexCliOAuthProfileMock.mockReset();
|
||||
hasOpenAICodexCliOAuthCredentialMock.mockReset();
|
||||
hasOpenAICodexCliOAuthCredentialMock.mockReturnValue(false);
|
||||
loginOpenAICodexDeviceCodeMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -139,15 +149,198 @@ describe("openai codex provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers explicit browser and one-time Codex CLI import auth methods", () => {
|
||||
it("offers OpenAI menu auth methods for login, import, and device pairing", () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
|
||||
expect(provider.auth?.map((method) => method.id)).toEqual(["oauth", "import-codex-cli"]);
|
||||
expect(provider.auth?.find((method) => method.id === "import-codex-cli")).toMatchObject({
|
||||
label: "Import Codex CLI login",
|
||||
hint: "Use existing .codex auth once",
|
||||
kind: "oauth",
|
||||
expect(provider.auth?.map((method) => method.id)).toEqual([
|
||||
"oauth",
|
||||
"device-code",
|
||||
"import-codex-cli",
|
||||
]);
|
||||
expect(provider.auth?.find((method) => method.id === "oauth")).toMatchObject({
|
||||
label: "OpenAI Codex Browser Login",
|
||||
hint: "Sign in with OpenAI in your browser",
|
||||
wizard: {
|
||||
choiceId: "openai-codex",
|
||||
choiceLabel: "OpenAI Codex Browser Login",
|
||||
assistantPriority: -30,
|
||||
},
|
||||
});
|
||||
expect(provider.auth?.find((method) => method.id === "device-code")).toMatchObject({
|
||||
label: "OpenAI Codex Device Pairing",
|
||||
hint: "Pair in browser with a device code",
|
||||
kind: "device_code",
|
||||
wizard: {
|
||||
choiceId: "openai-codex-device-code",
|
||||
choiceLabel: "OpenAI Codex Device Pairing",
|
||||
assistantPriority: -10,
|
||||
},
|
||||
});
|
||||
expect(provider.auth?.find((method) => method.id === "import-codex-cli")).toMatchObject({
|
||||
label: "Import Existing Codex Login",
|
||||
hint: "Import an existing ~/.codex login",
|
||||
kind: "oauth",
|
||||
wizard: {
|
||||
choiceId: "openai-codex-import",
|
||||
choiceLabel: "Import Existing Codex Login",
|
||||
assistantPriority: -20,
|
||||
assistantVisibility: "manual-only",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("annotates the import option when ~/.codex auth is detected", () => {
|
||||
hasOpenAICodexCliOAuthCredentialMock.mockReturnValueOnce(true);
|
||||
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
|
||||
expect(provider.auth?.find((method) => method.id === "import-codex-cli")).toMatchObject({
|
||||
label: "Import Existing Codex Login (~/.codex detected)",
|
||||
wizard: {
|
||||
choiceLabel: "Import Existing Codex Login (~/.codex detected)",
|
||||
assistantVisibility: "visible",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("soft-fails import when no compatible ~/.codex login exists", async () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
const importMethod = provider.auth?.find((method) => method.id === "import-codex-cli");
|
||||
const note = vi.fn(async () => {});
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
readOpenAICodexCliOAuthProfileMock.mockReturnValueOnce(null);
|
||||
|
||||
const result = await importMethod?.run({
|
||||
config: {},
|
||||
env: process.env,
|
||||
prompter: {
|
||||
note,
|
||||
progress: vi.fn(),
|
||||
} as never,
|
||||
runtime: runtime as never,
|
||||
isRemote: false,
|
||||
openUrl: async () => {},
|
||||
oauth: { createVpsAwareHandlers: (() => ({})) as never },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ profiles: [] });
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"No compatible ~/.codex ChatGPT login found. Use Browser Login or Device Pairing instead.",
|
||||
);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"No compatible ~/.codex ChatGPT login found. Use Browser Login or Device Pairing instead.",
|
||||
"Import Existing Codex Login",
|
||||
);
|
||||
});
|
||||
|
||||
it("stores device-code logins as OpenAI Codex oauth profiles", async () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
const deviceCodeMethod = provider.auth?.find((method) => method.id === "device-code");
|
||||
const note = vi.fn(async () => {});
|
||||
const progress = { update: vi.fn(), stop: vi.fn() };
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
loginOpenAICodexDeviceCodeMock.mockResolvedValueOnce({
|
||||
access:
|
||||
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC1kZXZpY2UtMTIzIn19.signature",
|
||||
refresh: "device-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
const result = await deviceCodeMethod?.run({
|
||||
config: {},
|
||||
env: process.env,
|
||||
prompter: {
|
||||
note,
|
||||
progress: vi.fn(() => progress),
|
||||
} as never,
|
||||
runtime: runtime as never,
|
||||
isRemote: false,
|
||||
openUrl: async () => {},
|
||||
oauth: { createVpsAwareHandlers: (() => ({})) as never },
|
||||
});
|
||||
|
||||
expect(loginOpenAICodexDeviceCodeMock).toHaveBeenCalledOnce();
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(note).not.toHaveBeenCalledWith(
|
||||
"Trouble with device code login? See https://docs.openclaw.ai/start/faq",
|
||||
"OAuth help",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
profiles: [
|
||||
{
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access:
|
||||
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC1kZXZpY2UtMTIzIn19.signature",
|
||||
refresh: "device-refresh-token",
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: "openai-codex/gpt-5.4",
|
||||
});
|
||||
expect(result?.profiles[0]?.credential).not.toHaveProperty("idToken");
|
||||
expect(result?.profiles[0]?.credential).not.toHaveProperty("accountId");
|
||||
});
|
||||
|
||||
it("does not log the device pairing code in remote mode", async () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
const deviceCodeMethod = provider.auth?.find((method) => method.id === "device-code");
|
||||
const note = vi.fn(async () => {});
|
||||
const progress = { update: vi.fn(), stop: vi.fn() };
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
loginOpenAICodexDeviceCodeMock.mockImplementationOnce(async ({ onVerification }) => {
|
||||
await onVerification({
|
||||
verificationUrl: "https://auth.openai.com/codex/device",
|
||||
userCode: "CODE-12345",
|
||||
expiresInMs: 900_000,
|
||||
});
|
||||
return {
|
||||
access:
|
||||
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC1kZXZpY2UtMTIzIn19.signature",
|
||||
refresh: "device-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
deviceCodeMethod?.run({
|
||||
config: {},
|
||||
env: process.env,
|
||||
prompter: {
|
||||
note,
|
||||
progress: vi.fn(() => progress),
|
||||
} as never,
|
||||
runtime: runtime as never,
|
||||
isRemote: true,
|
||||
openUrl: async () => {},
|
||||
oauth: { createVpsAwareHandlers: (() => ({})) as never },
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
const logOutput = runtime.log.mock.calls.flat().join("\n");
|
||||
expect(logOutput).toContain("https://auth.openai.com/codex/device");
|
||||
expect(logOutput).not.toContain("CODE-12345");
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Code: [shown on the local device only]"),
|
||||
"OpenAI Codex device code",
|
||||
);
|
||||
expect(note).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Code: CODE-12345"),
|
||||
"OpenAI Codex device code",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes Codex CLI auth as a runtime-only external profile", () => {
|
||||
|
||||
@@ -24,7 +24,12 @@ import { isOpenAIApiBaseUrl, isOpenAICodexBaseUrl } from "./base-url.js";
|
||||
import { OPENAI_CODEX_DEFAULT_MODEL } from "./default-models.js";
|
||||
import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js";
|
||||
import { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
|
||||
import { CODEX_CLI_PROFILE_ID, readOpenAICodexCliOAuthProfile } from "./openai-codex-cli-auth.js";
|
||||
import {
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
hasOpenAICodexCliOAuthCredential,
|
||||
readOpenAICodexCliOAuthProfile,
|
||||
} from "./openai-codex-cli-auth.js";
|
||||
import { loginOpenAICodexDeviceCode } from "./openai-codex-device-code.js";
|
||||
import {
|
||||
buildOpenAIResponsesProviderHooks,
|
||||
buildOpenAISyntheticCatalogEntry,
|
||||
@@ -35,6 +40,21 @@ import {
|
||||
|
||||
const PROVIDER_ID = "openai-codex";
|
||||
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex";
|
||||
const OPENAI_WIZARD_GROUP = {
|
||||
groupId: "openai",
|
||||
groupLabel: "OpenAI",
|
||||
groupHint: "API key + Codex auth",
|
||||
} as const;
|
||||
const OPENAI_CODEX_LOGIN_ASSISTANT_PRIORITY = -30;
|
||||
const OPENAI_CODEX_IMPORT_ASSISTANT_PRIORITY = -20;
|
||||
const OPENAI_CODEX_DEVICE_PAIRING_ASSISTANT_PRIORITY = -10;
|
||||
const OPENAI_CODEX_LOGIN_LABEL = "OpenAI Codex Browser Login";
|
||||
const OPENAI_CODEX_LOGIN_HINT = "Sign in with OpenAI in your browser";
|
||||
const OPENAI_CODEX_IMPORT_LABEL = "Import Existing Codex Login";
|
||||
const OPENAI_CODEX_IMPORT_HINT = "Import an existing ~/.codex login";
|
||||
const OPENAI_CODEX_IMPORT_DETECTED_SUFFIX = "~/.codex detected";
|
||||
const OPENAI_CODEX_DEVICE_PAIRING_LABEL = "OpenAI Codex Device Pairing";
|
||||
const OPENAI_CODEX_DEVICE_PAIRING_HINT = "Pair in browser with a device code";
|
||||
const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4";
|
||||
const OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID = "gpt-5.4-codex";
|
||||
const OPENAI_CODEX_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro";
|
||||
@@ -292,15 +312,76 @@ async function runOpenAICodexOAuth(ctx: ProviderAuthContext) {
|
||||
});
|
||||
}
|
||||
|
||||
async function runOpenAICodexDeviceCode(ctx: ProviderAuthContext) {
|
||||
const spin = ctx.prompter.progress("Starting device code flow…");
|
||||
try {
|
||||
const creds = await loginOpenAICodexDeviceCode({
|
||||
onProgress: (message) => spin.update(message),
|
||||
onVerification: async ({ verificationUrl, userCode, expiresInMs }) => {
|
||||
const expiresInMinutes = Math.max(1, Math.round(expiresInMs / 60_000));
|
||||
const codeLine = ctx.isRemote
|
||||
? "Code: [shown on the local device only]"
|
||||
: `Code: ${userCode}`;
|
||||
await ctx.prompter.note(
|
||||
[
|
||||
ctx.isRemote
|
||||
? "Open this URL in your LOCAL browser and enter the code below."
|
||||
: "Open this URL in your browser and enter the code below.",
|
||||
`URL: ${verificationUrl}`,
|
||||
codeLine,
|
||||
`Code expires in ${expiresInMinutes} minutes. Never share it.`,
|
||||
].join("\n"),
|
||||
"OpenAI Codex device code",
|
||||
);
|
||||
if (ctx.isRemote) {
|
||||
ctx.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${verificationUrl}\n`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ctx.openUrl(verificationUrl);
|
||||
ctx.runtime.log(`Open: ${verificationUrl}`);
|
||||
} catch {
|
||||
ctx.runtime.log(`Open manually: ${verificationUrl}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
spin.stop("OpenAI device code complete");
|
||||
|
||||
const identity = resolveCodexAuthIdentity({
|
||||
accessToken: creds.access,
|
||||
});
|
||||
|
||||
return buildOauthProviderAuthResult({
|
||||
providerId: PROVIDER_ID,
|
||||
defaultModel: OPENAI_CODEX_DEFAULT_MODEL,
|
||||
access: creds.access,
|
||||
refresh: creds.refresh,
|
||||
expires: creds.expires,
|
||||
email: identity.email,
|
||||
profileName: identity.profileName,
|
||||
});
|
||||
} catch (error) {
|
||||
spin.stop("OpenAI device code failed");
|
||||
ctx.runtime.error(formatErrorMessage(error));
|
||||
await ctx.prompter.note(
|
||||
"Trouble with device code login? See https://docs.openclaw.ai/start/faq",
|
||||
"OAuth help",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runImportOpenAICodexCliAuth(ctx: ProviderAuthContext) {
|
||||
const profile = readOpenAICodexCliOAuthProfile({
|
||||
env: ctx.env ?? process.env,
|
||||
store: ensureAuthProfileStoreForLocalUpdate(ctx.agentDir),
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error(
|
||||
"No compatible Codex CLI OAuth login found. Sign in with `codex` first or use ChatGPT OAuth instead.",
|
||||
);
|
||||
const message =
|
||||
"No compatible ~/.codex ChatGPT login found. Use Browser Login or Device Pairing instead.";
|
||||
ctx.runtime.error(message);
|
||||
await ctx.prompter.note(message, OPENAI_CODEX_IMPORT_LABEL);
|
||||
return { profiles: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -344,7 +425,16 @@ function buildOpenAICodexAuthDoctorHint(ctx: { profileId?: string }) {
|
||||
return "Deprecated profile. Run `openclaw models auth login --provider openai-codex` or `openclaw configure`.";
|
||||
}
|
||||
|
||||
function buildOpenAICodexImportWizardLabel(hasCodexCliCredential: boolean) {
|
||||
if (!hasCodexCliCredential) {
|
||||
return OPENAI_CODEX_IMPORT_LABEL;
|
||||
}
|
||||
return `${OPENAI_CODEX_IMPORT_LABEL} (${OPENAI_CODEX_IMPORT_DETECTED_SUFFIX})`;
|
||||
}
|
||||
|
||||
export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
|
||||
const hasCodexCliCredential = hasOpenAICodexCliOAuthCredential();
|
||||
const importWizardLabel = buildOpenAICodexImportWizardLabel(hasCodexCliCredential);
|
||||
return {
|
||||
id: PROVIDER_ID,
|
||||
label: "OpenAI Codex",
|
||||
@@ -352,27 +442,54 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "ChatGPT OAuth",
|
||||
hint: "Browser sign-in",
|
||||
label: OPENAI_CODEX_LOGIN_LABEL,
|
||||
hint: OPENAI_CODEX_LOGIN_HINT,
|
||||
kind: "oauth",
|
||||
wizard: {
|
||||
choiceId: "openai-codex",
|
||||
choiceLabel: OPENAI_CODEX_LOGIN_LABEL,
|
||||
choiceHint: OPENAI_CODEX_LOGIN_HINT,
|
||||
assistantPriority: OPENAI_CODEX_LOGIN_ASSISTANT_PRIORITY,
|
||||
...OPENAI_WIZARD_GROUP,
|
||||
},
|
||||
run: async (ctx) => await runOpenAICodexOAuth(ctx),
|
||||
},
|
||||
{
|
||||
id: "device-code",
|
||||
label: OPENAI_CODEX_DEVICE_PAIRING_LABEL,
|
||||
hint: OPENAI_CODEX_DEVICE_PAIRING_HINT,
|
||||
kind: "device_code",
|
||||
wizard: {
|
||||
choiceId: "openai-codex-device-code",
|
||||
choiceLabel: OPENAI_CODEX_DEVICE_PAIRING_LABEL,
|
||||
choiceHint: OPENAI_CODEX_DEVICE_PAIRING_HINT,
|
||||
assistantPriority: OPENAI_CODEX_DEVICE_PAIRING_ASSISTANT_PRIORITY,
|
||||
...OPENAI_WIZARD_GROUP,
|
||||
},
|
||||
run: async (ctx) => {
|
||||
try {
|
||||
return await runOpenAICodexDeviceCode(ctx);
|
||||
} catch {
|
||||
return { profiles: [] };
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "import-codex-cli",
|
||||
label: "Import Codex CLI login",
|
||||
hint: "Use existing .codex auth once",
|
||||
label: importWizardLabel,
|
||||
hint: OPENAI_CODEX_IMPORT_HINT,
|
||||
kind: "oauth",
|
||||
wizard: {
|
||||
choiceId: "openai-codex-import",
|
||||
choiceLabel: importWizardLabel,
|
||||
choiceHint: OPENAI_CODEX_IMPORT_HINT,
|
||||
assistantPriority: OPENAI_CODEX_IMPORT_ASSISTANT_PRIORITY,
|
||||
assistantVisibility: hasCodexCliCredential ? "visible" : "manual-only",
|
||||
...OPENAI_WIZARD_GROUP,
|
||||
},
|
||||
run: async (ctx) => await runImportOpenAICodexCliAuth(ctx),
|
||||
},
|
||||
],
|
||||
wizard: {
|
||||
setup: {
|
||||
choiceId: "openai-codex",
|
||||
choiceLabel: "OpenAI Codex (ChatGPT OAuth)",
|
||||
choiceHint: "Browser sign-in",
|
||||
methodId: "oauth",
|
||||
},
|
||||
},
|
||||
catalog: {
|
||||
order: "profile",
|
||||
run: async (ctx) => {
|
||||
|
||||
@@ -185,8 +185,8 @@ export function buildOpenAIProvider(): ProviderPlugin {
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "OpenAI API key",
|
||||
hint: "Direct OpenAI API key",
|
||||
label: "OpenAI API Key",
|
||||
hint: "Use your OpenAI API key directly",
|
||||
optionKey: "openaiApiKey",
|
||||
flagName: "--openai-api-key",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
@@ -196,10 +196,10 @@ export function buildOpenAIProvider(): ProviderPlugin {
|
||||
applyConfig: (cfg) => applyOpenAIConfig(cfg),
|
||||
wizard: {
|
||||
choiceId: "openai-api-key",
|
||||
choiceLabel: "OpenAI API key",
|
||||
choiceLabel: "OpenAI API Key",
|
||||
groupId: "openai",
|
||||
groupLabel: "OpenAI",
|
||||
groupHint: "Codex OAuth + API key",
|
||||
groupHint: "API key + Codex auth",
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -15,24 +15,48 @@
|
||||
"method": "oauth",
|
||||
"choiceId": "openai-codex",
|
||||
"deprecatedChoiceIds": ["codex-cli"],
|
||||
"choiceLabel": "OpenAI Codex (ChatGPT OAuth)",
|
||||
"choiceHint": "Browser sign-in",
|
||||
"choiceLabel": "OpenAI Codex Browser Login",
|
||||
"choiceHint": "Sign in with OpenAI in your browser",
|
||||
"assistantPriority": -30,
|
||||
"groupId": "openai",
|
||||
"groupLabel": "OpenAI",
|
||||
"groupHint": "Codex OAuth + API key"
|
||||
"groupHint": "API key + Codex auth"
|
||||
},
|
||||
{
|
||||
"provider": "openai-codex",
|
||||
"method": "device-code",
|
||||
"choiceId": "openai-codex-device-code",
|
||||
"choiceLabel": "OpenAI Codex Device Pairing",
|
||||
"choiceHint": "Pair in browser with a device code",
|
||||
"assistantPriority": -10,
|
||||
"groupId": "openai",
|
||||
"groupLabel": "OpenAI",
|
||||
"groupHint": "API key + Codex auth"
|
||||
},
|
||||
{
|
||||
"provider": "openai-codex",
|
||||
"method": "import-codex-cli",
|
||||
"choiceId": "openai-codex-import",
|
||||
"choiceLabel": "Import Existing Codex Login",
|
||||
"choiceHint": "Import an existing ~/.codex login",
|
||||
"assistantPriority": -20,
|
||||
"groupId": "openai",
|
||||
"groupLabel": "OpenAI",
|
||||
"groupHint": "API key + Codex auth"
|
||||
},
|
||||
{
|
||||
"provider": "openai",
|
||||
"method": "api-key",
|
||||
"choiceId": "openai-api-key",
|
||||
"choiceLabel": "OpenAI API key",
|
||||
"choiceLabel": "OpenAI API Key",
|
||||
"assistantPriority": -40,
|
||||
"groupId": "openai",
|
||||
"groupLabel": "OpenAI",
|
||||
"groupHint": "Codex OAuth + API key",
|
||||
"groupHint": "API key + Codex auth",
|
||||
"optionKey": "openaiApiKey",
|
||||
"cliFlag": "--openai-api-key",
|
||||
"cliOption": "--openai-api-key <key>",
|
||||
"cliDescription": "OpenAI API key"
|
||||
"cliDescription": "OpenAI API Key"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const noopAuth = async () => ({ profiles: [] });
|
||||
const OPENAI_WIZARD_GROUP = {
|
||||
groupId: "openai",
|
||||
groupLabel: "OpenAI",
|
||||
groupHint: "API key + Codex auth",
|
||||
} as const;
|
||||
|
||||
export function createOpenAICodexProvider(): ProviderPlugin {
|
||||
return {
|
||||
@@ -11,19 +16,46 @@ export function createOpenAICodexProvider(): ProviderPlugin {
|
||||
{
|
||||
id: "oauth",
|
||||
kind: "oauth",
|
||||
label: "ChatGPT OAuth",
|
||||
hint: "Browser sign-in",
|
||||
label: "OpenAI Codex Browser Login",
|
||||
hint: "Sign in with OpenAI in your browser",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "openai-codex",
|
||||
choiceLabel: "OpenAI Codex Browser Login",
|
||||
choiceHint: "Sign in with OpenAI in your browser",
|
||||
assistantPriority: -30,
|
||||
...OPENAI_WIZARD_GROUP,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "device-code",
|
||||
kind: "device_code",
|
||||
label: "OpenAI Codex Device Pairing",
|
||||
hint: "Pair in browser with a device code",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "openai-codex-device-code",
|
||||
choiceLabel: "OpenAI Codex Device Pairing",
|
||||
choiceHint: "Pair in browser with a device code",
|
||||
assistantPriority: -10,
|
||||
...OPENAI_WIZARD_GROUP,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "import-codex-cli",
|
||||
kind: "oauth",
|
||||
label: "Import Existing Codex Login",
|
||||
hint: "Import an existing ~/.codex login",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "openai-codex-import",
|
||||
choiceLabel: "Import Existing Codex Login",
|
||||
choiceHint: "Import an existing ~/.codex login",
|
||||
assistantPriority: -20,
|
||||
...OPENAI_WIZARD_GROUP,
|
||||
},
|
||||
},
|
||||
],
|
||||
wizard: {
|
||||
setup: {
|
||||
choiceId: "openai-codex",
|
||||
choiceLabel: "OpenAI Codex (ChatGPT OAuth)",
|
||||
choiceHint: "Browser sign-in",
|
||||
methodId: "oauth",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,15 +70,14 @@ export function createOpenAIProvider(): ProviderPlugin {
|
||||
{
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
label: "OpenAI API key",
|
||||
hint: "Direct OpenAI API key",
|
||||
label: "OpenAI API Key",
|
||||
hint: "Use your OpenAI API key directly",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "openai-api-key",
|
||||
choiceLabel: "OpenAI API key",
|
||||
groupId: "openai",
|
||||
groupLabel: "OpenAI",
|
||||
groupHint: "Codex OAuth + API key",
|
||||
choiceLabel: "OpenAI API Key",
|
||||
assistantPriority: -40,
|
||||
...OPENAI_WIZARD_GROUP,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -447,6 +447,53 @@ describe("buildAuthChoiceOptions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders OpenAI auth methods as api key, login, import, then device pairing", () => {
|
||||
resolveProviderWizardOptions.mockReturnValue([
|
||||
{
|
||||
value: "openai-api-key",
|
||||
label: "OpenAI API Key",
|
||||
groupId: "openai",
|
||||
groupLabel: "OpenAI",
|
||||
assistantPriority: -40,
|
||||
},
|
||||
{
|
||||
value: "openai-codex",
|
||||
label: "OpenAI Codex Browser Login",
|
||||
groupId: "openai",
|
||||
groupLabel: "OpenAI",
|
||||
assistantPriority: -30,
|
||||
},
|
||||
{
|
||||
value: "openai-codex-import",
|
||||
label: "Import Existing Codex Login (~/.codex detected)",
|
||||
groupId: "openai",
|
||||
groupLabel: "OpenAI",
|
||||
assistantPriority: -20,
|
||||
},
|
||||
{
|
||||
value: "openai-codex-device-code",
|
||||
label: "OpenAI Codex Device Pairing",
|
||||
groupId: "openai",
|
||||
groupLabel: "OpenAI",
|
||||
assistantPriority: -10,
|
||||
},
|
||||
]);
|
||||
|
||||
const { groups } = buildAuthChoiceGroups({
|
||||
store: EMPTY_STORE,
|
||||
includeSkip: false,
|
||||
});
|
||||
const openAIGroup = groups.find((group) => group.value === "openai");
|
||||
|
||||
expect(openAIGroup).toBeDefined();
|
||||
expect(openAIGroup?.options.map((option) => option.value)).toEqual([
|
||||
"openai-api-key",
|
||||
"openai-codex",
|
||||
"openai-codex-import",
|
||||
"openai-codex-device-code",
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups OpenCode Zen and Go under one OpenCode entry", () => {
|
||||
resolveManifestProviderAuthChoices.mockReturnValue([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user