mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:30:42 +00:00
feat(openai): add codex device-code auth
This commit is contained in:
committed by
Val Alexander
parent
d4eb236523
commit
5c8c2f48da
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
129
extensions/openai/openai-codex-device-code.test.ts
Normal file
129
extensions/openai/openai-codex-device-code.test.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
289
extensions/openai/openai-codex-device-code.ts
Normal file
289
extensions/openai/openai-codex-device-code.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user