From 44d06ad1887ce09f9545f9645f03fe3f3acb2a92 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 17 Jun 2026 03:57:31 +0000 Subject: [PATCH] fix(ai): repaired chatgpt token exchange Added Codex connector OAuth scopes to browser login and preserved structured token endpoint error details instead of object coercion.\n\nFixes #2825 --- packages/ai/CHANGELOG.md | 4 + .../ai/src/registry/oauth/openai-codex.ts | 85 +++++++++++++------ packages/ai/test/openai-codex.test.ts | 26 +++++- 3 files changed, 88 insertions(+), 27 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b23030ad3..93372c262 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed ChatGPT/Codex browser login missing connector OAuth scopes and rendering object-shaped token endpoint errors as `[object Object]`. ([#2825](https://github.com/can1357/oh-my-pi/issues/2825)) + ## [16.0.3] - 2026-06-16 ### Added diff --git a/packages/ai/src/registry/oauth/openai-codex.ts b/packages/ai/src/registry/oauth/openai-codex.ts index 73237359a..6c9cfb7a4 100644 --- a/packages/ai/src/registry/oauth/openai-codex.ts +++ b/packages/ai/src/registry/oauth/openai-codex.ts @@ -3,6 +3,8 @@ */ import { OPENAI_HEADER_VALUES } from "@oh-my-pi/pi-catalog/wire/codex"; +import type { FetchImpl } from "../../types"; +import { isRecord } from "../../utils"; import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server"; import { generatePKCE } from "./pkce"; import type { OAuthController, OAuthCredentials } from "./types"; @@ -12,7 +14,7 @@ const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; const TOKEN_URL = "https://auth.openai.com/oauth/token"; const CALLBACK_PORT = 1455; const CALLBACK_PATH = "/auth/callback"; -const SCOPE = "openid profile email offline_access"; +const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke"; const JWT_CLAIM_PATH = "https://api.openai.com/auth"; const JWT_PROFILE_CLAIM = "https://api.openai.com/profile"; const TOKEN_REQUEST_TIMEOUT_MS = 15_000; @@ -62,6 +64,37 @@ interface PKCE { verifier: string; challenge: string; } +function describeTokenEndpointValue(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (!isRecord(value)) return undefined; + + const code = describeTokenEndpointValue(value.code ?? value.error); + const message = describeTokenEndpointValue(value.message ?? value.error_description ?? value.description); + if (code && message && code !== message) return `${code}: ${message}`; + return code ?? message ?? JSON.stringify(value); +} + +/** Formats OpenAI Codex OAuth token endpoint errors for login and refresh failures. */ +export function formatOpenAICodexTokenEndpointError(status: number, bodyText: string): string { + const trimmed = bodyText.trim(); + if (trimmed.length === 0) return `${status}`; + + try { + const body: unknown = JSON.parse(trimmed); + if (!isRecord(body)) return `${status} ${trimmed}`; + + const error = describeTokenEndpointValue(body.error); + const description = describeTokenEndpointValue(body.error_description); + if (error && description && error !== description) return `${status} ${error}: ${description}`; + return `${status} ${error ?? description ?? describeTokenEndpointValue(body.message) ?? trimmed}`; + } catch { + return `${status} ${trimmed}`; + } +} /** Builds the Codex browser OAuth URL used by browser login; exported for auth regression tests. */ export function createOpenAICodexAuthorizationUrl(args: { state: string; @@ -87,11 +120,11 @@ export function createOpenAICodexAuthorizationUrl(args: { } class OpenAICodexOAuthFlow extends OAuthCallbackFlow { - constructor( - ctrl: OAuthController, - private readonly pkce: PKCE, - private readonly originator: string, - ) { + #pkce: PKCE; + #originator: string; + #fetch: FetchImpl; + + constructor(ctrl: OAuthController, pkce: PKCE, originator: string, fetchImpl: FetchImpl) { super(ctrl, { preferredPort: CALLBACK_PORT, callbackPath: CALLBACK_PATH, @@ -101,25 +134,33 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow { // registered allowlist entry. redirectUri: `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`, } satisfies OAuthCallbackFlowOptions); + this.#pkce = pkce; + this.#originator = originator; + this.#fetch = fetchImpl; } async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> { const url = createOpenAICodexAuthorizationUrl({ state, redirectUri, - challenge: this.pkce.challenge, - originator: this.originator, + challenge: this.#pkce.challenge, + originator: this.#originator, }); return { url, instructions: "A browser window should open. Complete login to finish." }; } async exchangeToken(code: string, _state: string, redirectUri: string): Promise { - return exchangeCodeForToken(code, this.pkce.verifier, redirectUri); + return exchangeCodeForToken(code, this.#pkce.verifier, redirectUri, this.#fetch); } } -async function exchangeCodeForToken(code: string, verifier: string, redirectUri: string): Promise { - const tokenResponse = await fetch(TOKEN_URL, { +async function exchangeCodeForToken( + code: string, + verifier: string, + redirectUri: string, + fetchImpl: FetchImpl = fetch, +): Promise { + const tokenResponse = await fetchImpl(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -133,13 +174,8 @@ async function exchangeCodeForToken(code: string, verifier: string, redirectUri: }); if (!tokenResponse.ok) { - let detail = `${tokenResponse.status}`; - try { - const body = (await tokenResponse.json()) as { error?: string; error_description?: string }; - if (body.error) - detail = `${tokenResponse.status} ${body.error}${body.error_description ? `: ${body.error_description}` : ""}`; - } catch {} - throw new Error(`Token exchange failed: ${detail}`); + const bodyText = await tokenResponse.text(); + throw new Error(`Token exchange failed: ${formatOpenAICodexTokenEndpointError(tokenResponse.status, bodyText)}`); } const tokenData = (await tokenResponse.json()) as { @@ -177,7 +213,7 @@ export type OpenAICodexLoginOptions = OAuthController & { export async function loginOpenAICodex(options: OpenAICodexLoginOptions): Promise { const pkce = await generatePKCE(); const originator = options.originator?.trim() || OPENAI_HEADER_VALUES.ORIGINATOR_CODEX; - const flow = new OpenAICodexOAuthFlow(options, pkce, originator); + const flow = new OpenAICodexOAuthFlow(options, pkce, originator, options.fetch ?? fetch); return flow.login(); } @@ -285,13 +321,10 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise { expect(new URL(authUrl).searchParams.get("originator")).toBe(OPENAI_HEADER_VALUES.ORIGINATOR_CODEX); }); + + it("requests Codex connector scopes during browser login", () => { + const authUrl = createOpenAICodexAuthorizationUrl({ + state: "state", + redirectUri: "http://localhost:1455/auth/callback", + challenge: "challenge", + }); + + const scopes = new Set((new URL(authUrl).searchParams.get("scope") ?? "").split(" ")); + expect(scopes.has("api.connectors.read")).toBe(true); + expect(scopes.has("api.connectors.invoke")).toBe(true); + }); + + it("formats object token endpoint errors without object coercion", () => { + const detail = formatOpenAICodexTokenEndpointError( + 403, + JSON.stringify({ error: { code: "access_denied", message: "Connector scope missing" } }), + ); + + expect(detail).toBe("403 access_denied: Connector scope missing"); + }); }); describe("openai-codex tool schemas", () => {