feat(openai): add codex device-code auth

This commit is contained in:
Vincent Koc
2026-04-20 20:01:20 -07:00
committed by Val Alexander
parent d4eb236523
commit 5c8c2f48da
9 changed files with 682 additions and 34 deletions

View File

@@ -100,6 +100,7 @@ Docs: https://docs.openclaw.ai
- Terminal/logging: optimize `sanitizeForLog()` by replacing the iterative control-character stripping loop with a single regex pass while preserving the existing ANSI-first sanitization behavior. (#67205) Thanks @bulutmuf.
- QA/CI: make `openclaw qa suite` and `openclaw qa telegram` fail by default when scenarios fail, add `--allow-failures` for artifact-only runs, and tighten live-lane defaults for CI automation. (#69122) Thanks @joshavant.
- Mattermost: stream thinking, tool activity, and partial reply text into a single draft preview post that finalizes in place when safe. (#47838) thanks @ninjaa.
- 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.
### Fixes
@@ -184,17 +185,6 @@ Docs: https://docs.openclaw.ai
- Control UI/device pairing: explain scope and role approval upgrades during reconnects, and show requested versus approved access in the Control UI and `openclaw devices` so broader reconnects no longer look like lost pairings. (#69221) Thanks @obviyus.
- Gateway/Control UI: surface pending scope, role, and device-metadata pairing approvals in auth errors and Control UI hints so broader reconnects no longer look like random auth breakage. (#69226) Thanks @obviyus.
## 2026.4.19-beta.2
### Fixes
- Agents/openai-completions: always send `stream_options.include_usage` on streaming requests, so local and custom OpenAI-compatible backends report real context usage instead of showing 0%. (#68746) Thanks @kagura-agent.
- Agents/nested lanes: scope nested agent work per target session so a long-running nested run on one session no longer head-of-line blocks unrelated sessions across the gateway. (#67785) Thanks @stainlu.
- Agents/status: preserve carried-forward session token totals for providers that omit usage metadata, so `/status` and `openclaw sessions` keep showing the last known context usage instead of dropping back to unknown/0%. (#67695) Thanks @stainlu.
- Install/update: keep legacy update verification compatible with the QA Lab runtime shim, so updating older global installs to beta no longer fails after npm installs the package successfully.
## 2026.4.19-beta.1
### Fixes
- Agents/channels: route cross-agent subagent spawns through the target agent's bound channel account while preserving peer and workspace/role-scoped bindings, so child sessions no longer inherit the caller's account in shared rooms, workspaces, or multi-account setups. (#67508) Thanks @lukeboyett and @gumadeiras.

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js";
import {
resolveCodexAuthIdentity,
resolveCodexChatgptAccountId,
} from "./openai-codex-auth-identity.js";
function createJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
@@ -54,3 +57,21 @@ describe("resolveCodexAuthIdentity", () => {
expect(resolveCodexAuthIdentity({ accessToken: "not-a-jwt-token" })).toEqual({});
});
});
describe("resolveCodexChatgptAccountId", () => {
it("extracts the ChatGPT account id from the auth claim", () => {
expect(
resolveCodexChatgptAccountId(
createJwt({
"https://api.openai.com/auth": {
chatgpt_account_id: "acct_123",
},
}),
),
).toBe("acct_123");
});
it("returns undefined when the account id is missing", () => {
expect(resolveCodexChatgptAccountId(createJwt({}))).toBeUndefined();
});
});

View File

@@ -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;
@@ -59,6 +60,11 @@ export function resolveCodexStableSubject(payload: CodexJwtPayload | null): stri
return sub;
}
export function resolveCodexChatgptAccountId(token: string): string | undefined {
const auth = decodeCodexJwtPayload(token)?.["https://api.openai.com/auth"];
return trimNonEmptyString(auth?.chatgpt_account_id);
}
export function resolveCodexAccessTokenExpiry(accessToken: string): number | undefined {
const payload = decodeCodexJwtPayload(accessToken);
const exp = normalizeFutureEpochSeconds(payload?.exp);

View File

@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
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 () => {
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 credentials = await loginOpenAICodexDeviceCode({
fetchFn: fetchMock as typeof fetch,
onVerification,
onProgress,
});
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",
accountId: "acct_123",
idToken: expect.any(String),
});
expect(credentials.expires).toBeGreaterThan(Date.now());
});
it("surfaces user-code request failures", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 503 }));
await expect(
loginOpenAICodexDeviceCode({
fetchFn: fetchMock as typeof fetch,
onVerification: async () => {},
}),
).rejects.toThrow("OpenAI device code request failed: HTTP 503");
});
it("surfaces device authorization failures with 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",
error_description: "Denied",
},
{ status: 401 },
),
);
await expect(
loginOpenAICodexDeviceCode({
fetchFn: fetchMock as typeof fetch,
onVerification: async () => {},
}),
).rejects.toThrow("OpenAI device authorization failed: authorization_declined (Denied)");
});
});

View File

@@ -0,0 +1,289 @@
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env";
import {
resolveCodexAccessTokenExpiry,
resolveCodexChatgptAccountId,
} 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_CALLBACK_URL = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`;
type OpenAICodexDeviceCodePrompt = {
verificationUrl: string;
userCode: string;
expiresInMs: number;
};
type OpenAICodexDeviceCodeCredentials = {
access: string;
refresh: string;
expires: number;
accountId?: string;
idToken?: string;
};
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;
id_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())) {
return Number.parseInt(value.trim(), 10) * 1000;
}
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 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);
if (error && description) {
return `${params.prefix}: ${error} (${description})`;
}
if (error) {
return `${params.prefix}: ${error}`;
}
const bodyText = params.bodyText.trim();
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, Math.max(0, Math.min(params.intervalMs, deadline - Date.now()))),
);
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);
const idToken = trimNonEmptyString(body?.id_token);
if (!access || !refresh) {
throw new Error("OpenAI token exchange succeeded but did not return OAuth tokens.");
}
const expires =
Date.now() +
(normalizeTokenLifetimeMs(body?.expires_in) ?? resolveCodexAccessTokenExpiry(access) ?? 0);
const accountId =
resolveCodexChatgptAccountId(access) ?? (idToken && resolveCodexChatgptAccountId(idToken));
return {
access,
refresh,
expires,
...(accountId ? { accountId } : {}),
...(idToken ? { idToken } : {}),
};
}
export async function loginOpenAICodexDeviceCode(params: {
fetchFn?: typeof fetch;
onVerification: (prompt: OpenAICodexDeviceCodePrompt) => Promise<void> | void;
onProgress?: (message: string) => void;
}): Promise<OpenAICodexDeviceCodeCredentials> {
ensureGlobalUndiciEnvProxyDispatcher();
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,
});
}

View File

@@ -5,6 +5,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
const refreshOpenAICodexTokenMock = vi.hoisted(() => vi.fn());
const readOpenAICodexCliOAuthProfileMock = vi.hoisted(() => vi.fn());
const loginOpenAICodexDeviceCodeMock = vi.hoisted(() => vi.fn());
vi.mock("./openai-codex-provider.runtime.js", () => ({
refreshOpenAICodexToken: refreshOpenAICodexTokenMock,
@@ -18,6 +19,10 @@ vi.mock("./openai-codex-cli-auth.js", async (importOriginal) => {
};
});
vi.mock("./openai-codex-device-code.js", () => ({
loginOpenAICodexDeviceCode: loginOpenAICodexDeviceCodeMock,
}));
let buildOpenAICodexProviderPlugin: typeof import("./openai-codex-provider.js").buildOpenAICodexProviderPlugin;
const tempDirs: string[] = [];
@@ -61,6 +66,7 @@ describe("openai codex provider", () => {
beforeEach(() => {
refreshOpenAICodexTokenMock.mockReset();
readOpenAICodexCliOAuthProfileMock.mockReset();
loginOpenAICodexDeviceCodeMock.mockReset();
});
afterEach(async () => {
@@ -139,10 +145,22 @@ describe("openai codex provider", () => {
);
});
it("offers explicit browser and one-time Codex CLI import auth methods", () => {
it("offers browser, device-code, and one-time Codex CLI import auth methods", () => {
const provider = buildOpenAICodexProviderPlugin();
expect(provider.auth?.map((method) => method.id)).toEqual(["oauth", "import-codex-cli"]);
expect(provider.auth?.map((method) => method.id)).toEqual([
"oauth",
"device-code",
"import-codex-cli",
]);
expect(provider.auth?.find((method) => method.id === "device-code")).toMatchObject({
label: "ChatGPT device code",
hint: "Browser device-code sign-in",
kind: "device_code",
wizard: {
choiceId: "openai-codex-device-code",
},
});
expect(provider.auth?.find((method) => method.id === "import-codex-cli")).toMatchObject({
label: "Import Codex CLI login",
hint: "Use existing .codex auth once",
@@ -150,6 +168,62 @@ describe("openai codex provider", () => {
});
});
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,
accountId: "acct-device-123",
idToken: "device-id-token",
});
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",
accountId: "acct-device-123",
idToken: "device-id-token",
},
},
],
defaultModel: "openai-codex/gpt-5.4",
});
});
it("exposes Codex CLI auth as a runtime-only external profile", () => {
const provider = buildOpenAICodexProviderPlugin();
const credential = {

View File

@@ -24,7 +24,13 @@ 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 { trimNonEmptyString } from "./openai-codex-shared.js";
import {
buildOpenAIResponsesProviderHooks,
buildOpenAISyntheticCatalogEntry,
@@ -35,6 +41,18 @@ 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 Login";
const OPENAI_CODEX_IMPORT_LABEL = "OpenAI Codex";
const OPENAI_CODEX_IMPORT_DETECTED_SUFFIX = "~/.codex existing key detected";
const OPENAI_CODEX_DEVICE_PAIRING_LABEL = "OpenAI Codex Device Pairing";
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,6 +310,68 @@ 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));
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}`,
`Code: ${userCode}`,
`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\nEnter this code:\n\n${userCode}\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,
credentialExtra: {
...(trimNonEmptyString(creds.accountId) ? { accountId: creds.accountId } : {}),
...(trimNonEmptyString(creds.idToken) ? { idToken: creds.idToken } : {}),
},
});
} 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,
@@ -344,7 +424,15 @@ function buildOpenAICodexAuthDoctorHint(ctx: { profileId?: string }) {
return "Deprecated profile. Run `openclaw models auth login --provider openai-codex` or `openclaw configure`.";
}
function buildOpenAICodexImportWizardLabel() {
if (!hasOpenAICodexCliOAuthCredential()) {
return OPENAI_CODEX_IMPORT_LABEL;
}
return `${OPENAI_CODEX_IMPORT_LABEL} (${OPENAI_CODEX_IMPORT_DETECTED_SUFFIX})`;
}
export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
const importWizardLabel = buildOpenAICodexImportWizardLabel();
return {
id: PROVIDER_ID,
label: "OpenAI Codex",
@@ -352,27 +440,53 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
auth: [
{
id: "oauth",
label: "ChatGPT OAuth",
label: OPENAI_CODEX_LOGIN_LABEL,
hint: "Browser sign-in",
kind: "oauth",
wizard: {
choiceId: "openai-codex",
choiceLabel: OPENAI_CODEX_LOGIN_LABEL,
choiceHint: "Browser sign-in",
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: "Pair in browser with a device code",
kind: "device_code",
wizard: {
choiceId: "openai-codex-device-code",
choiceLabel: OPENAI_CODEX_DEVICE_PAIRING_LABEL,
choiceHint: "Pair in browser with a device code",
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: "Import existing ~/.codex login once",
kind: "oauth",
wizard: {
choiceId: "openai-codex-import",
choiceLabel: importWizardLabel,
choiceHint: "Import existing ~/.codex login once",
assistantPriority: OPENAI_CODEX_IMPORT_ASSISTANT_PRIORITY,
...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) => {

View File

@@ -21,6 +21,16 @@
"groupLabel": "OpenAI",
"groupHint": "Codex OAuth + API key"
},
{
"provider": "openai-codex",
"method": "device-code",
"choiceId": "openai-codex-device-code",
"choiceLabel": "OpenAI Codex (device code)",
"choiceHint": "Browser device-code sign-in",
"groupId": "openai",
"groupLabel": "OpenAI",
"groupHint": "Codex OAuth + API key"
},
{
"provider": "openai",
"method": "api-key",

View File

@@ -14,16 +14,31 @@ export function createOpenAICodexProvider(): ProviderPlugin {
label: "ChatGPT OAuth",
hint: "Browser sign-in",
run: noopAuth,
wizard: {
choiceId: "openai-codex",
choiceLabel: "OpenAI Codex (ChatGPT OAuth)",
choiceHint: "Browser sign-in",
groupId: "openai",
groupLabel: "OpenAI",
groupHint: "Codex OAuth + API key",
},
},
{
id: "device-code",
kind: "device_code",
label: "ChatGPT device code",
hint: "Browser device-code sign-in",
run: noopAuth,
wizard: {
choiceId: "openai-codex-device-code",
choiceLabel: "OpenAI Codex (device code)",
choiceHint: "Browser device-code sign-in",
groupId: "openai",
groupLabel: "OpenAI",
groupHint: "Codex OAuth + API key",
},
},
],
wizard: {
setup: {
choiceId: "openai-codex",
choiceLabel: "OpenAI Codex (ChatGPT OAuth)",
choiceHint: "Browser sign-in",
methodId: "oauth",
},
},
};
}