fix(openai): prevent device-code login hangs on stalled requests (#109494)

* fix(openai): bound device code requests

* fix(openai): preserve proxy routing for device code auth

* test(openai): simplify device proxy assertions

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Alix-007
2026-07-16 22:26:56 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 085c1d4602
commit 6bd15d00c4
2 changed files with 336 additions and 43 deletions
@@ -1,5 +1,5 @@
// Openai tests cover openai chatgpt device code plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveCodexAccessTokenExpiry } from "./openai-chatgpt-auth-identity.js";
import { loginOpenAICodexDeviceCode } from "./openai-chatgpt-device-code.js";
@@ -48,7 +48,139 @@ function fetchCall(fetchMock: ReturnType<typeof vi.fn<typeof fetch>>, index: num
return call;
}
function waitForFetchAbort(init?: RequestInit): Promise<Response> {
const signal = init?.signal;
if (!signal) {
return Promise.reject(new Error("expected fetch signal"));
}
return new Promise((_resolve, reject) => {
const rejectWithReason = () =>
reject(signal.reason instanceof Error ? signal.reason : new Error("request aborted"));
if (signal.aborted) {
rejectWithReason();
return;
}
signal.addEventListener("abort", rejectWithReason, { once: true });
});
}
function createBodyThatStallsUntilAbort(init?: RequestInit): Response {
const signal = init?.signal;
if (!signal) {
throw new Error("expected fetch signal");
}
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
const fail = () => controller.error(signal.reason);
if (signal.aborted) {
fail();
return;
}
signal.addEventListener("abort", fail, { once: true });
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
describe("loginOpenAICodexDeviceCode", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllEnvs();
});
it("times out while waiting for device-code response headers", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn<typeof fetch>((_input, init) => waitForFetchAbort(init));
const login = loginOpenAICodexDeviceCode({
fetchFn: fetchMock,
onVerification: async () => {},
});
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[1]?.signal).toBeInstanceOf(AbortSignal);
const rejected = expect(login).rejects.toThrow(
"OpenAI device code user code request timed out after 30000ms",
);
await vi.advanceTimersByTimeAsync(30_000);
await rejected;
});
it("keeps the request timeout active while reading the response body", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn<typeof fetch>(async (_input, init) =>
createBodyThatStallsUntilAbort(init),
);
const login = loginOpenAICodexDeviceCode({
fetchFn: fetchMock,
onVerification: async () => {},
});
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledOnce();
const rejected = expect(login).rejects.toThrow(
"OpenAI device code user code request timed out after 30000ms",
);
await vi.advanceTimersByTimeAsync(30_000);
await rejected;
});
it("still honors caller cancellation during an active device-code request", async () => {
const callerController = new AbortController();
const fetchMock = vi.fn<typeof fetch>((_input, init) => waitForFetchAbort(init));
const login = loginOpenAICodexDeviceCode({
fetchFn: fetchMock,
onVerification: async () => {},
signal: callerController.signal,
});
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
callerController.abort(new Error("cancelled by caller"));
await expect(login).rejects.toThrow("cancelled by caller");
});
it("routes device-code auth through a configured HTTPS proxy", async () => {
vi.stubEnv("https_proxy", "http://127.0.0.1:7897");
vi.stubEnv("no_proxy", "");
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 }));
await expect(
loginOpenAICodexDeviceCode({
fetchFn: fetchMock,
onVerification: async () => {},
}),
).rejects.toThrow("OpenAI device code request failed: HTTP 503");
const requestInit = fetchCall(fetchMock, 0)[1] as
| (RequestInit & { dispatcher?: { constructor?: { name?: string } } })
| undefined;
expect(requestInit?.dispatcher?.constructor?.name).toContain("EnvHttpProxyAgent");
});
it("keeps strict guarded fetch when NO_PROXY bypasses OpenAI auth", async () => {
vi.stubEnv("https_proxy", "http://127.0.0.1:7897");
vi.stubEnv("no_proxy", "auth.openai.com");
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 }));
await expect(
loginOpenAICodexDeviceCode({
fetchFn: fetchMock,
onVerification: async () => {},
}),
).rejects.toThrow("OpenAI device code request failed: HTTP 503");
const requestInit = fetchCall(fetchMock, 0)[1] as
| (RequestInit & { dispatcher?: unknown })
| undefined;
expect(requestInit?.dispatcher).toBeUndefined();
});
it("requests a device code, polls for authorization, and exchanges OAuth tokens", async () => {
vi.useFakeTimers();
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
@@ -108,6 +240,7 @@ describe("loginOpenAICodexDeviceCode", () => {
const userCodeRequest = fetchCall(fetchMock, 0);
expect(userCodeRequest[0]).toBe("https://auth.openai.com/api/accounts/deviceauth/usercode");
expect(userCodeRequest[1]?.method).toBe("POST");
expect(userCodeRequest[1]?.signal).toBeInstanceOf(AbortSignal);
expect(userCodeRequest[1]?.headers).toEqual({
"Content-Type": "application/json",
originator: "openclaw",
@@ -118,6 +251,7 @@ describe("loginOpenAICodexDeviceCode", () => {
const deviceTokenRequest = fetchCall(fetchMock, 1);
expect(deviceTokenRequest[0]).toBe("https://auth.openai.com/api/accounts/deviceauth/token");
expect(deviceTokenRequest[1]?.method).toBe("POST");
expect(deviceTokenRequest[1]?.signal).toBeInstanceOf(AbortSignal);
expect(deviceTokenRequest[1]?.headers).toEqual({
"Content-Type": "application/json",
originator: "openclaw",
@@ -128,6 +262,7 @@ describe("loginOpenAICodexDeviceCode", () => {
const oauthTokenRequest = fetchCall(fetchMock, 3);
expect(oauthTokenRequest[0]).toBe("https://auth.openai.com/oauth/token");
expect(oauthTokenRequest[1]?.method).toBe("POST");
expect(oauthTokenRequest[1]?.signal).toBeInstanceOf(AbortSignal);
expect(oauthTokenRequest[1]?.headers).toEqual({
"Content-Type": "application/x-www-form-urlencoded",
originator: "openclaw",
@@ -153,6 +288,55 @@ describe("loginOpenAICodexDeviceCode", () => {
}
});
it("retries a timed-out authorization poll within the overall device deadline", async () => {
vi.useFakeTimers();
const accessToken = createJwt({
exp: Math.floor(Date.now() / 1000) + 600,
});
let pollAttempts = 0;
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith("/api/accounts/deviceauth/usercode")) {
return createJsonResponse({
device_auth_id: "device-auth-123",
user_code: "CODE-12345",
interval: "0",
});
}
if (url.endsWith("/api/accounts/deviceauth/token")) {
pollAttempts += 1;
if (pollAttempts === 1) {
return await waitForFetchAbort(init);
}
return createJsonResponse({
authorization_code: "authorization-code-123",
code_verifier: "code-verifier-123",
});
}
if (url.endsWith("/oauth/token")) {
return createJsonResponse({
access_token: accessToken,
refresh_token: "refresh-token-123",
expires_in: 600,
});
}
throw new Error(`unexpected OpenAI device-code URL: ${url}`);
});
const login = loginOpenAICodexDeviceCode({
fetchFn: fetchMock,
onVerification: async () => {},
});
await vi.advanceTimersByTimeAsync(0);
expect(pollAttempts).toBe(1);
const resolved = expect(login).resolves.toMatchObject({ refresh: "refresh-token-123" });
await vi.advanceTimersByTimeAsync(30_000);
await resolved;
expect(pollAttempts).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(4);
});
it("aborts device-code polling without another request", async () => {
vi.useFakeTimers();
const controller = new AbortController();
+151 -42
View File
@@ -1,15 +1,21 @@
// Openai plugin module implements openai chatgpt device code behavior.
import {
shouldUseEnvHttpProxyForUrl,
withTrustedEnvProxyGuardedFetchMode,
} from "openclaw/plugin-sdk/fetch-runtime";
import {
positiveSecondsToSafeMilliseconds,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveCodexAccessTokenExpiry } from "./openai-chatgpt-auth-identity.js";
import { trimNonEmptyString } from "./openai-chatgpt-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_REQUEST_TIMEOUT_MS = 30_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`;
@@ -69,6 +75,12 @@ type DeviceCodeAuthorizationCode = {
codeVerifier: string;
};
type DeviceCodeHttpResult = {
ok: boolean;
status: number;
bodyText: string;
};
function parseJsonObject(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text);
@@ -101,6 +113,20 @@ function resolveNextDeviceCodePollDelayMs(intervalMs: number, deadlineMs: number
return Math.min(Math.max(intervalMs, OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS), remainingMs);
}
function resolveDeviceCodePollRequestTimeoutMs(deadlineMs: number): number {
return Math.min(OPENAI_CODEX_DEVICE_REQUEST_TIMEOUT_MS, Math.max(0, deadlineMs - Date.now()));
}
function isDeviceCodeOperationTimeoutError(error: unknown): boolean {
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
}
function rethrowIfDeviceCodeCallerAborted(signal: AbortSignal | undefined, error: unknown): void {
if (signal?.aborted) {
throw signal.reason instanceof Error ? signal.reason : error;
}
}
function formatDeviceCodeError(params: {
prefix: string;
status: number;
@@ -132,23 +158,83 @@ async function readOpenAICodexDeviceBody(response: Response): Promise<string> {
);
}
async function runOpenAICodexDeviceRequest(params: {
fetchFn: typeof fetch;
url: string;
init: Omit<RequestInit, "signal">;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<DeviceCodeHttpResult> {
const guardedOptions = {
url: params.url,
fetchImpl: params.fetchFn,
init: params.init,
timeoutMs: params.timeoutMs,
...(params.signal ? { signal: params.signal } : {}),
requireHttps: true,
auditContext: "openai-chatgpt-device-code",
};
const { response, release } = await fetchWithSsrFGuard(
shouldUseEnvHttpProxyForUrl(params.url)
? withTrustedEnvProxyGuardedFetchMode(guardedOptions)
: guardedOptions,
);
try {
return {
ok: response.ok,
status: response.status,
bodyText: await readOpenAICodexDeviceBody(response),
};
} finally {
await release();
}
}
async function fetchOpenAICodexDeviceCode(params: {
fetchFn: typeof fetch;
url: string;
init: Omit<RequestInit, "signal">;
timeoutOperation: string;
signal?: AbortSignal;
}): Promise<DeviceCodeHttpResult> {
try {
return await runOpenAICodexDeviceRequest({
...params,
timeoutMs: OPENAI_CODEX_DEVICE_REQUEST_TIMEOUT_MS,
});
} catch (error) {
rethrowIfDeviceCodeCallerAborted(params.signal, error);
if (isDeviceCodeOperationTimeoutError(error)) {
throw new Error(
`OpenAI device code ${params.timeoutOperation} timed out after ${OPENAI_CODEX_DEVICE_REQUEST_TIMEOUT_MS}ms`,
{ cause: error },
);
}
throw error;
}
}
async function requestOpenAICodexDeviceCode(
fetchFn: typeof fetch,
signal?: AbortSignal,
): Promise<RequestedDeviceCode> {
signal?.throwIfAborted();
const response = await fetchFn(`${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/usercode`, {
method: "POST",
headers: resolveOpenAICodexDeviceCodeHeaders("application/json"),
body: JSON.stringify({
client_id: OPENAI_CODEX_CLIENT_ID,
}),
const result = await fetchOpenAICodexDeviceCode({
fetchFn,
url: `${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/usercode`,
init: {
method: "POST",
headers: resolveOpenAICodexDeviceCodeHeaders("application/json"),
body: JSON.stringify({
client_id: OPENAI_CODEX_CLIENT_ID,
}),
},
timeoutOperation: "user code request",
...(signal ? { signal } : {}),
});
const bodyText = await readOpenAICodexDeviceBody(response);
if (!response.ok) {
if (response.status === 404) {
if (!result.ok) {
if (result.status === 404) {
throw new Error(
"OpenAI Codex device code login is not enabled for this server. Use ChatGPT OAuth instead.",
);
@@ -156,13 +242,13 @@ async function requestOpenAICodexDeviceCode(
throw new Error(
formatDeviceCodeError({
prefix: "OpenAI device code request failed",
status: response.status,
bodyText,
status: result.status,
bodyText: result.bodyText,
}),
);
}
const body = parseJsonObject(bodyText) as DeviceCodeUserCodePayload | null;
const body = parseJsonObject(result.bodyText) as DeviceCodeUserCodePayload | null;
const deviceAuthId = trimNonEmptyString(body?.device_auth_id);
const userCode = trimNonEmptyString(body?.user_code) ?? trimNonEmptyString(body?.usercode);
if (!deviceAuthId || !userCode) {
@@ -190,19 +276,38 @@ async function pollOpenAICodexDeviceCode(params: {
while (Date.now() < deadline) {
params.signal?.throwIfAborted();
const response = await params.fetchFn(`${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/token`, {
method: "POST",
headers: resolveOpenAICodexDeviceCodeHeaders("application/json"),
body: JSON.stringify({
device_auth_id: params.deviceAuthId,
user_code: params.userCode,
}),
...(params.signal ? { signal: params.signal } : {}),
});
const requestTimeoutMs = resolveDeviceCodePollRequestTimeoutMs(deadline);
if (requestTimeoutMs <= 0) {
break;
}
const bodyText = await readOpenAICodexDeviceBody(response);
if (response.ok) {
const body = parseJsonObject(bodyText) as DeviceCodeTokenPayload | null;
let result: DeviceCodeHttpResult;
try {
result = await runOpenAICodexDeviceRequest({
fetchFn: params.fetchFn,
url: `${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/token`,
init: {
method: "POST",
headers: resolveOpenAICodexDeviceCodeHeaders("application/json"),
body: JSON.stringify({
device_auth_id: params.deviceAuthId,
user_code: params.userCode,
}),
},
timeoutMs: requestTimeoutMs,
...(params.signal ? { signal: params.signal } : {}),
});
} catch (error) {
rethrowIfDeviceCodeCallerAborted(params.signal, error);
// A stalled poll is transient; keep the overall 15-minute authorization deadline.
if (isDeviceCodeOperationTimeoutError(error)) {
continue;
}
throw error;
}
if (result.ok) {
const body = parseJsonObject(result.bodyText) as DeviceCodeTokenPayload | null;
const authorizationCode = trimNonEmptyString(body?.authorization_code);
const codeVerifier = trimNonEmptyString(body?.code_verifier);
if (!authorizationCode || !codeVerifier) {
@@ -214,7 +319,7 @@ async function pollOpenAICodexDeviceCode(params: {
};
}
if (response.status === 403 || response.status === 404) {
if (result.status === 403 || result.status === 404) {
await waitForDeviceCodePoll(
resolveNextDeviceCodePollDelayMs(params.intervalMs, deadline),
params.signal,
@@ -225,8 +330,8 @@ async function pollOpenAICodexDeviceCode(params: {
throw new Error(
formatDeviceCodeError({
prefix: "OpenAI device authorization failed",
status: response.status,
bodyText,
status: result.status,
bodyText: result.bodyText,
}),
);
}
@@ -241,31 +346,35 @@ async function exchangeOpenAICodexDeviceCode(params: {
signal?: AbortSignal;
}): Promise<OpenAICodexDeviceCodeCredentials> {
params.signal?.throwIfAborted();
const response = await params.fetchFn(`${OPENAI_AUTH_BASE_URL}/oauth/token`, {
method: "POST",
headers: resolveOpenAICodexDeviceCodeHeaders("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 result = await fetchOpenAICodexDeviceCode({
fetchFn: params.fetchFn,
url: `${OPENAI_AUTH_BASE_URL}/oauth/token`,
init: {
method: "POST",
headers: resolveOpenAICodexDeviceCodeHeaders("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,
}),
},
timeoutOperation: "token exchange",
...(params.signal ? { signal: params.signal } : {}),
});
const bodyText = await readOpenAICodexDeviceBody(response);
if (!response.ok) {
if (!result.ok) {
throw new Error(
formatDeviceCodeError({
prefix: "OpenAI device token exchange failed",
status: response.status,
bodyText,
status: result.status,
bodyText: result.bodyText,
}),
);
}
const body = parseJsonObject(bodyText) as OAuthTokenPayload | null;
const body = parseJsonObject(result.bodyText) as OAuthTokenPayload | null;
const access = trimNonEmptyString(body?.access_token);
const refresh = trimNonEmptyString(body?.refresh_token);
if (!access || !refresh) {