diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 0bc705a5..62c4c352 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -30,7 +30,7 @@ const SCOPE = "openid profile email offline_access"; const JWT_CLAIM_PATH = "https://api.openai.com/auth"; type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; -type TokenFailure = { type: "failed" }; +type TokenFailure = { type: "failed"; message: string; status?: number }; type TokenResult = TokenSuccess | TokenFailure; type JwtPayload = { @@ -108,8 +108,11 @@ async function exchangeAuthorizationCode( if (!response.ok) { const text = await response.text().catch(() => ""); - console.error("[openai-codex] code->token failed:", response.status, text); - return { type: "failed" }; + return { + type: "failed", + status: response.status, + message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`, + }; } const json = (await response.json()) as { @@ -119,8 +122,10 @@ async function exchangeAuthorizationCode( }; if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - console.error("[openai-codex] token response missing fields:", json); - return { type: "failed" }; + return { + type: "failed", + message: `OpenAI Codex token exchange response missing fields: ${JSON.stringify(json)}`, + }; } return { @@ -145,8 +150,11 @@ async function refreshAccessToken(refreshToken: string): Promise { if (!response.ok) { const text = await response.text().catch(() => ""); - console.error("[openai-codex] Token refresh failed:", response.status, text); - return { type: "failed" }; + return { + type: "failed", + status: response.status, + message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`, + }; } const json = (await response.json()) as { @@ -156,8 +164,10 @@ async function refreshAccessToken(refreshToken: string): Promise { }; if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - console.error("[openai-codex] Token refresh response missing fields:", json); - return { type: "failed" }; + return { + type: "failed", + message: `OpenAI Codex token refresh response missing fields: ${JSON.stringify(json)}`, + }; } return { @@ -167,8 +177,10 @@ async function refreshAccessToken(refreshToken: string): Promise { expires: Date.now() + json.expires_in * 1000, }; } catch (error) { - console.error("[openai-codex] Token refresh error:", error); - return { type: "failed" }; + return { + type: "failed", + message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`, + }; } } @@ -258,12 +270,7 @@ function startLocalOAuthServer(state: string): Promise { waitForCode: () => waitForCodePromise, }); }) - .on("error", (err: NodeJS.ErrnoException) => { - console.error( - `[openai-codex] Failed to bind http://${CALLBACK_HOST}:1455 (`, - err.code, - ") Falling back to manual paste.", - ); + .on("error", (_err: NodeJS.ErrnoException) => { settleWait?.(null); resolve({ close: () => { @@ -386,7 +393,7 @@ export async function loginOpenAICodex(options: { const tokenResult = await exchangeAuthorizationCode(code, verifier); if (tokenResult.type !== "success") { - throw new Error("Token exchange failed"); + throw new Error(tokenResult.message); } const accountId = getAccountId(tokenResult.access); @@ -411,7 +418,7 @@ export async function loginOpenAICodex(options: { export async function refreshOpenAICodexToken(refreshToken: string): Promise { const result = await refreshAccessToken(refreshToken); if (result.type !== "success") { - throw new Error("Failed to refresh OpenAI Codex token"); + throw new Error(result.message); } const accountId = getAccountId(result.access); diff --git a/packages/ai/test/openai-codex-oauth.test.ts b/packages/ai/test/openai-codex-oauth.test.ts new file mode 100644 index 00000000..276abe1b --- /dev/null +++ b/packages/ai/test/openai-codex-oauth.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { refreshOpenAICodexToken } from "../src/utils/oauth/openai-codex.js"; + +describe("OpenAI Codex OAuth", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("does not write token refresh failures to stderr", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn(async (): Promise => { + return new Response( + JSON.stringify({ + error: { + message: "Could not validate your token. Please try signing in again.", + type: "invalid_request_error", + }, + }), + { status: 401, statusText: "Unauthorized", headers: { "Content-Type": "application/json" } }, + ); + }), + ); + + await expect(refreshOpenAICodexToken("invalid-refresh-token")).rejects.toThrow( + /OpenAI Codex token refresh failed \(401\).*Could not validate your token/, + ); + expect(consoleError).not.toHaveBeenCalled(); + }); +});