From 8088218f4699990662a23c822b4b8c4ae07b3a83 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 5 Mar 2026 18:10:03 -0800 Subject: [PATCH] fix(openai-codex): request required oauth api scopes (#24720) --- CHANGELOG.md | 1 + src/commands/openai-codex-oauth.test.ts | 36 +++++++++++++++++++ src/commands/openai-codex-oauth.ts | 48 ++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 518743cd2ba..aa27b86e343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Gateway/chat streaming tool-boundary text retention: merge assistant delta segments into per-run chat buffers so pre-tool text is preserved in live chat deltas/finals when providers emit post-tool assistant segments as non-prefix snapshots. (#36957) Thanks @Datyedyeguy. - TUI/model indicator freshness: prevent stale session snapshots from overwriting freshly patched model selection (and reset per-session freshness when switching session keys) so `/model` updates reflect immediately instead of lagging by one or more commands. (#21255) Thanks @kowza. - OpenAI Codex OAuth/login hardening: fail OAuth completion early when the returned token is missing `api.responses.write`, and allow `openclaw models auth login --provider openai-codex` to use the built-in OAuth path even when no provider plugins are installed. (#36660) Thanks @driesvints. +- OpenAI Codex OAuth/scope request parity: augment the OAuth authorize URL with required API scopes (`api.responses.write`, `model.request`, `api.model.read`) before browser handoff so OAuth tokens include runtime model/request permissions expected by OpenAI API calls. (#24720) Thanks @Skippy-Gunboat. - Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn. - Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. - Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693. diff --git a/src/commands/openai-codex-oauth.test.ts b/src/commands/openai-codex-oauth.test.ts index 3bbdb82551b..c5fe6d535ba 100644 --- a/src/commands/openai-codex-oauth.test.ts +++ b/src/commands/openai-codex-oauth.test.ts @@ -104,6 +104,42 @@ describe("loginOpenAICodexOAuth", () => { expect(runtime.error).not.toHaveBeenCalled(); }); + it("augments OAuth authorize URL with required OpenAI API scopes", async () => { + const creds = { + provider: "openai-codex" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + email: "user@example.com", + }; + const onAuthSpy = vi.fn(); + mocks.createVpsAwareOAuthHandlers.mockReturnValue({ + onAuth: onAuthSpy, + onPrompt: vi.fn(), + }); + mocks.loginOpenAICodex.mockImplementation( + async (opts: { onAuth: (event: { url: string }) => Promise }) => { + await opts.onAuth({ + url: "https://auth.openai.com/oauth/authorize?scope=openid+profile+email+offline_access&state=abc", + }); + return creds; + }, + ); + + await runCodexOAuth({ isRemote: false }); + + expect(onAuthSpy).toHaveBeenCalledTimes(1); + const event = onAuthSpy.mock.calls[0]?.[0] as { url: string }; + const scopes = new Set((new URL(event.url).searchParams.get("scope") ?? "").split(/\s+/)); + expect(scopes.has("openid")).toBe(true); + expect(scopes.has("profile")).toBe(true); + expect(scopes.has("email")).toBe(true); + expect(scopes.has("offline_access")).toBe(true); + expect(scopes.has("api.responses.write")).toBe(true); + expect(scopes.has("model.request")).toBe(true); + expect(scopes.has("api.model.read")).toBe(true); + }); + it("reports oauth errors and rethrows", async () => { mocks.createVpsAwareOAuthHandlers.mockReturnValue({ onAuth: vi.fn(), diff --git a/src/commands/openai-codex-oauth.ts b/src/commands/openai-codex-oauth.ts index 342e2c6cc91..c9290d5617e 100644 --- a/src/commands/openai-codex-oauth.ts +++ b/src/commands/openai-codex-oauth.ts @@ -10,6 +10,46 @@ import { const OPENAI_RESPONSES_ENDPOINT = "https://api.openai.com/v1/responses"; const OPENAI_RESPONSES_WRITE_SCOPE = "api.responses.write"; +const OPENAI_REQUIRED_OAUTH_SCOPES = [ + OPENAI_RESPONSES_WRITE_SCOPE, + "model.request", + "api.model.read", +] as const; + +function augmentOpenAIOAuthScopes(authUrl: string): string { + try { + const parsed = new URL(authUrl); + const scopeParam = parsed.searchParams.get("scope"); + if (!scopeParam) { + return authUrl; + } + const scopes = scopeParam + .split(/\s+/) + .map((scope) => scope.trim()) + .filter(Boolean); + if (scopes.length === 0) { + return authUrl; + } + const seen = new Set(scopes.map((scope) => scope.toLowerCase())); + let changed = false; + for (const requiredScope of OPENAI_REQUIRED_OAUTH_SCOPES) { + const normalized = requiredScope.toLowerCase(); + if (seen.has(normalized)) { + continue; + } + scopes.push(requiredScope); + seen.add(normalized); + changed = true; + } + if (!changed) { + return authUrl; + } + parsed.searchParams.set("scope", scopes.join(" ")); + return parsed.toString(); + } catch { + return authUrl; + } +} function extractResponsesScopeErrorMessage(status: number, bodyText: string): string | null { if (status !== 401) { @@ -76,7 +116,7 @@ export async function loginOpenAICodexOAuth(params: { const spin = prompter.progress("Starting OAuth flow…"); try { - const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({ + const { onAuth: baseOnAuth, onPrompt } = createVpsAwareOAuthHandlers({ isRemote, prompter, runtime, @@ -84,6 +124,12 @@ export async function loginOpenAICodexOAuth(params: { openUrl, localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser…", }); + const onAuth = async (event: { url: string }) => { + await baseOnAuth({ + ...event, + url: augmentOpenAIOAuthScopes(event.url), + }); + }; const creds = await loginOpenAICodex({ onAuth,