From 34cf87517a5b9f429fd766a25ff934b3124fe9fe Mon Sep 17 00:00:00 2001 From: khhjoe Date: Tue, 17 Mar 2026 18:55:07 +0800 Subject: [PATCH 1/4] fix(oauth): proxy-aware fetch for OpenAI Codex token exchange Node.js native fetch() ignores HTTP_PROXY / HTTPS_PROXY env vars. Users behind a proxy (e.g. Hong Kong, mainland China) get 403 'unsupported_country_region_territory' during token exchange even though the browser OAuth flow succeeds through their system proxy. Wrap the loginOpenAICodex() call with withProxyFetch(), which temporarily patches globalThis.fetch with an undici ProxyAgent-backed implementation when proxy env vars are detected. The original fetch is restored after the call completes. No-op when no proxy is configured or undici is unavailable. Fixes #29418 --- src/plugins/provider-openai-codex-oauth.ts | 57 ++++++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/src/plugins/provider-openai-codex-oauth.ts b/src/plugins/provider-openai-codex-oauth.ts index 6e16cf863f0..b6fcba2301e 100644 --- a/src/plugins/provider-openai-codex-oauth.ts +++ b/src/plugins/provider-openai-codex-oauth.ts @@ -7,6 +7,51 @@ import { runOpenAIOAuthTlsPreflight, } from "./provider-openai-codex-oauth-tls.js"; +/** + * Node.js native fetch() ignores HTTP_PROXY / HTTPS_PROXY env vars. + * This helper temporarily patches globalThis.fetch with a proxy-aware + * implementation (via undici ProxyAgent) for the duration of `fn()`, + * then restores the original fetch. No-op when no proxy is configured + * or undici is unavailable. + */ +async function withProxyFetch(fn: () => Promise): Promise { + const proxyUrl = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + process.env.ALL_PROXY || + process.env.all_proxy; + if (!proxyUrl) return fn(); + + let restore: (() => void) | undefined; + try { + const { createRequire } = await import("node:module"); + const require_ = createRequire(import.meta.url); + // undici is a transitive dependency of OpenClaw (via Node internals / direct dep) + // eslint-disable-next-line @typescript-eslint/no-require-imports + const undici = require_("undici") as typeof import("undici"); + const agent = new undici.ProxyAgent(proxyUrl); + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: Parameters[0], init?: Parameters[1]) => + undici.fetch(url as Parameters[0], { + ...(init as Parameters[1]), + dispatcher: agent, + })) as typeof fetch; + restore = () => { + globalThis.fetch = origFetch; + }; + } catch { + // undici not available — proceed with unpatched fetch + } + + try { + return await fn(); + } finally { + restore?.(); + } +} + export async function loginOpenAICodexOAuth(params: { prompter: WizardPrompter; runtime: RuntimeEnv; @@ -49,11 +94,13 @@ export async function loginOpenAICodexOAuth(params: { localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser…", }); - const creds = await loginOpenAICodex({ - onAuth: baseOnAuth, - onPrompt, - onProgress: (msg: string) => spin.update(msg), - }); + const creds = await withProxyFetch(() => + loginOpenAICodex({ + onAuth: baseOnAuth, + onPrompt, + onProgress: (msg: string) => spin.update(msg), + }), + ); spin.stop("OpenAI OAuth complete"); return creds ?? null; } catch (err) { From 903d312ddcc535e3a91aaae57a42d7d9eacf6809 Mon Sep 17 00:00:00 2001 From: khhjoe Date: Tue, 17 Mar 2026 19:05:06 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20close?= =?UTF-8?q?=20ProxyAgent,=20concurrency-safe=20restore,=20respect=20NO=5FP?= =?UTF-8?q?ROXY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Close undici ProxyAgent in finally block to prevent resource leak - Guard globalThis.fetch restore with identity check (only restore if our patched function is still in place) - Add isHostExcludedByNoProxy() to respect NO_PROXY / no_proxy for the token endpoint host (supports bare hostnames, dotted-prefix, wildcard) --- src/plugins/provider-openai-codex-oauth.ts | 52 +++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/plugins/provider-openai-codex-oauth.ts b/src/plugins/provider-openai-codex-oauth.ts index b6fcba2301e..ed66ee0adac 100644 --- a/src/plugins/provider-openai-codex-oauth.ts +++ b/src/plugins/provider-openai-codex-oauth.ts @@ -7,12 +7,41 @@ import { runOpenAIOAuthTlsPreflight, } from "./provider-openai-codex-oauth-tls.js"; +/** OpenAI token endpoint host — used for NO_PROXY checks. */ +const TOKEN_HOST = "auth.openai.com"; + +/** + * Returns true when `host` is excluded from proxying by `NO_PROXY` / `no_proxy`. + * Supports bare hostnames, dotted-prefix matching (`.openai.com`), and the + * wildcard `*` (proxy nothing). Port and CIDR matching are intentionally + * omitted — they are unusual for OAuth token endpoints. + */ +function isHostExcludedByNoProxy(host: string): boolean { + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (!noProxy) return false; + + const entries = noProxy.split(",").map((e) => e.trim().toLowerCase()); + const lowerHost = host.toLowerCase(); + + for (const entry of entries) { + if (!entry) continue; + if (entry === "*") return true; + if (lowerHost === entry) return true; + // ".openai.com" matches "auth.openai.com" + if (entry.startsWith(".") && lowerHost.endsWith(entry)) return true; + // "openai.com" also matches "auth.openai.com" (curl convention) + if (lowerHost === entry || lowerHost.endsWith(`.${entry}`)) return true; + } + return false; +} + /** * Node.js native fetch() ignores HTTP_PROXY / HTTPS_PROXY env vars. * This helper temporarily patches globalThis.fetch with a proxy-aware * implementation (via undici ProxyAgent) for the duration of `fn()`, - * then restores the original fetch. No-op when no proxy is configured - * or undici is unavailable. + * then restores the original fetch. No-op when no proxy is configured, + * when the target host is excluded via NO_PROXY, or when undici is + * unavailable. */ async function withProxyFetch(fn: () => Promise): Promise { const proxyUrl = @@ -23,23 +52,29 @@ async function withProxyFetch(fn: () => Promise): Promise { process.env.ALL_PROXY || process.env.all_proxy; if (!proxyUrl) return fn(); + if (isHostExcludedByNoProxy(TOKEN_HOST)) return fn(); let restore: (() => void) | undefined; + let agent: { close(): Promise } | undefined; try { const { createRequire } = await import("node:module"); const require_ = createRequire(import.meta.url); // undici is a transitive dependency of OpenClaw (via Node internals / direct dep) // eslint-disable-next-line @typescript-eslint/no-require-imports const undici = require_("undici") as typeof import("undici"); - const agent = new undici.ProxyAgent(proxyUrl); + agent = new undici.ProxyAgent(proxyUrl); const origFetch = globalThis.fetch; - globalThis.fetch = ((url: Parameters[0], init?: Parameters[1]) => + const patchedFetch = ((url: Parameters[0], init?: Parameters[1]) => undici.fetch(url as Parameters[0], { ...(init as Parameters[1]), - dispatcher: agent, + dispatcher: agent as import("undici").Dispatcher, })) as typeof fetch; + globalThis.fetch = patchedFetch; restore = () => { - globalThis.fetch = origFetch; + // Only restore if our patch is still in place (concurrency-safe) + if (globalThis.fetch === patchedFetch) { + globalThis.fetch = origFetch; + } }; } catch { // undici not available — proceed with unpatched fetch @@ -49,6 +84,11 @@ async function withProxyFetch(fn: () => Promise): Promise { return await fn(); } finally { restore?.(); + try { + await agent?.close(); + } catch { + // ignore close errors + } } } From 2b0ed7a7f57f0564eb62f61644da9b68ce17401d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 03:36:14 +0000 Subject: [PATCH 3/4] fix(oauth): use existing proxy utilities for OpenAI Codex token exchange --- src/plugins/provider-openai-codex-oauth.ts | 100 +++++---------------- 1 file changed, 20 insertions(+), 80 deletions(-) diff --git a/src/plugins/provider-openai-codex-oauth.ts b/src/plugins/provider-openai-codex-oauth.ts index ed66ee0adac..0dd4ab00822 100644 --- a/src/plugins/provider-openai-codex-oauth.ts +++ b/src/plugins/provider-openai-codex-oauth.ts @@ -1,4 +1,5 @@ import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai/oauth"; +import { resolveProxyFetchFromEnv } from "../infra/net/proxy-fetch.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; @@ -7,87 +8,21 @@ import { runOpenAIOAuthTlsPreflight, } from "./provider-openai-codex-oauth-tls.js"; -/** OpenAI token endpoint host — used for NO_PROXY checks. */ -const TOKEN_HOST = "auth.openai.com"; - /** - * Returns true when `host` is excluded from proxying by `NO_PROXY` / `no_proxy`. - * Supports bare hostnames, dotted-prefix matching (`.openai.com`), and the - * wildcard `*` (proxy nothing). Port and CIDR matching are intentionally - * omitted — they are unusual for OAuth token endpoints. + * Temporarily replace globalThis.fetch with a proxy-aware variant for the + * duration of `fn`. The pi-ai library uses bare `fetch` internally and does + * not accept a custom fetch parameter, so this is the only injection point. + * The original fetch is restored in a finally block. */ -function isHostExcludedByNoProxy(host: string): boolean { - const noProxy = process.env.NO_PROXY || process.env.no_proxy; - if (!noProxy) return false; - - const entries = noProxy.split(",").map((e) => e.trim().toLowerCase()); - const lowerHost = host.toLowerCase(); - - for (const entry of entries) { - if (!entry) continue; - if (entry === "*") return true; - if (lowerHost === entry) return true; - // ".openai.com" matches "auth.openai.com" - if (entry.startsWith(".") && lowerHost.endsWith(entry)) return true; - // "openai.com" also matches "auth.openai.com" (curl convention) - if (lowerHost === entry || lowerHost.endsWith(`.${entry}`)) return true; - } - return false; -} - -/** - * Node.js native fetch() ignores HTTP_PROXY / HTTPS_PROXY env vars. - * This helper temporarily patches globalThis.fetch with a proxy-aware - * implementation (via undici ProxyAgent) for the duration of `fn()`, - * then restores the original fetch. No-op when no proxy is configured, - * when the target host is excluded via NO_PROXY, or when undici is - * unavailable. - */ -async function withProxyFetch(fn: () => Promise): Promise { - const proxyUrl = - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy || - process.env.ALL_PROXY || - process.env.all_proxy; - if (!proxyUrl) return fn(); - if (isHostExcludedByNoProxy(TOKEN_HOST)) return fn(); - - let restore: (() => void) | undefined; - let agent: { close(): Promise } | undefined; - try { - const { createRequire } = await import("node:module"); - const require_ = createRequire(import.meta.url); - // undici is a transitive dependency of OpenClaw (via Node internals / direct dep) - // eslint-disable-next-line @typescript-eslint/no-require-imports - const undici = require_("undici") as typeof import("undici"); - agent = new undici.ProxyAgent(proxyUrl); - const origFetch = globalThis.fetch; - const patchedFetch = ((url: Parameters[0], init?: Parameters[1]) => - undici.fetch(url as Parameters[0], { - ...(init as Parameters[1]), - dispatcher: agent as import("undici").Dispatcher, - })) as typeof fetch; - globalThis.fetch = patchedFetch; - restore = () => { - // Only restore if our patch is still in place (concurrency-safe) - if (globalThis.fetch === patchedFetch) { - globalThis.fetch = origFetch; - } - }; - } catch { - // undici not available — proceed with unpatched fetch - } - +async function withProxyFetch(proxyFetch: typeof fetch, fn: () => Promise): Promise { + const originalFetch = globalThis.fetch; + globalThis.fetch = proxyFetch; try { return await fn(); } finally { - restore?.(); - try { - await agent?.close(); - } catch { - // ignore close errors + // Only restore if we still own the slot (avoid clobbering a concurrent override) + if (globalThis.fetch === proxyFetch) { + globalThis.fetch = originalFetch; } } } @@ -100,7 +35,10 @@ export async function loginOpenAICodexOAuth(params: { localBrowserMessage?: string; }): Promise { const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params; - const preflight = await runOpenAIOAuthTlsPreflight(); + const proxyFetch = resolveProxyFetchFromEnv(); + const preflight = await runOpenAIOAuthTlsPreflight({ + fetchImpl: proxyFetch, + }); if (!preflight.ok && preflight.kind === "tls-cert") { const hint = formatOpenAIOAuthTlsPreflightFix(preflight); runtime.error(hint); @@ -134,13 +72,15 @@ export async function loginOpenAICodexOAuth(params: { localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser…", }); - const creds = await withProxyFetch(() => + const doLogin = () => loginOpenAICodex({ onAuth: baseOnAuth, onPrompt, onProgress: (msg: string) => spin.update(msg), - }), - ); + }); + + // pi-ai uses bare fetch internally; patch globalThis.fetch when a proxy is configured + const creds = proxyFetch ? await withProxyFetch(proxyFetch, doLogin) : await doLogin(); spin.stop("OpenAI OAuth complete"); return creds ?? null; } catch (err) { From dc7c3283c3146661fa8019ced33c75f6328c8a1b Mon Sep 17 00:00:00 2001 From: khhjoe Date: Sat, 21 Mar 2026 12:39:42 +0800 Subject: [PATCH 4/4] ci: retrigger checks