From ad02b702b60349da32e8124c5d70074e2f96ecae Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 00:50:12 +0900 Subject: [PATCH 1/6] feat(proxy): safely discover macOS static system proxy Carry the bounded #5893 behavior with fail-closed exception translation and inherited proxy precedence. Co-authored-by: codingbo <9621077+codingbooo@users.noreply.github.com> --- .../docs/fr/reference/configuration/server.md | 2 +- .../docs/ja/reference/configuration/server.md | 2 +- .../docs/ko/reference/configuration/server.md | 2 +- .../docs/reference/configuration/server.md | 9 +- .../docs/ru/reference/configuration/server.md | 2 +- .../docs/tr/reference/configuration/server.md | 2 +- .../zh-cn/reference/configuration/server.md | 2 +- .../zh-tw/reference/configuration/server.md | 2 +- scripts/test-layout/layout.json | 1 + src/config/macos-system-proxy.ts | 100 ++++++++++ src/config/proxy-env.ts | 77 ++++++-- src/types/config.ts | 8 +- structure/config-proxy.md | 17 +- tests/fixtures/test-layout-expected.json | 1 + tests/server/proxy-env-macos.test.ts | 175 ++++++++++++++++++ tests/server/proxy-env.test.ts | 2 +- 16 files changed, 368 insertions(+), 36 deletions(-) create mode 100644 src/config/macos-system-proxy.ts create mode 100644 tests/server/proxy-env-macos.test.ts diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index 3a9ca385079..1899fd9e0fc 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -273,4 +273,4 @@ compte et la charge de travail prévus. ## Diagnostic réseau des quotas Codex -Le champ `quotaRefresh` de la ligne du compte Codex principal décrit la récupération du quota, pas le quota restant ni les droits d’accès au modèle. Il peut être absent lorsque les données sont en cache ou qu’aucune récupération n’a eu lieu. La requête utilise l’environnement du service proxy en cours d’exécution, pas celui du terminal interactif. Sans `proxy`, l’environnement existant est conservé ; `"auto"` lit uniquement le proxy statique Windows au démarrage. PAC/WPAD, les paramètres SOCKS seuls et les changements à chaud ne sont pas pris en compte automatiquement. Un succès avec TUN ne valide pas à lui seul le chemin du proxy HTTP. Consultez [les commandes et les états en anglais](/reference/configuration/server/#codex-quota-network-diagnostics). +Le champ `quotaRefresh` de la ligne du compte Codex principal décrit la récupération du quota, pas le quota restant ni les droits d’accès au modèle. Il peut être absent lorsque les données sont en cache ou qu’aucune récupération n’a eu lieu. La requête utilise l’environnement du service proxy en cours d’exécution, pas celui du terminal interactif. Sans `proxy`, l’environnement existant est conservé ; `"auto"` lit les paramètres HTTP/HTTPS statiques de Windows ou macOS au démarrage. Sur macOS, un proxy hérité empêche cette lecture. Sur macOS, une exception autre qu’une adresse IP ou `*` annule la découverte sans modifier l’environnement. PAC/WPAD, les paramètres SOCKS seuls et les changements à chaud ne sont pas pris en compte automatiquement. Un succès avec TUN ne valide pas à lui seul le chemin du proxy HTTP. Consultez [les commandes et les états en anglais](/reference/configuration/server/#codex-quota-network-diagnostics). diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 27dbf08b986..d324a3a51ea 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -184,6 +184,6 @@ Anthropic OAuth サイドカーは、opencodex の既存のクロード コー ## Codex クォータのネットワーク診断 -メイン Codex アカウント行の `quotaRefresh` はクォータ取得の診断情報であり、残量やモデルへのアクセス権を示すものではありません。キャッシュ利用時や取得を行わない場合は省略されることがあります。取得には操作中のシェルではなく、実行中のプロキシサービスの環境が使われます。`proxy` 未設定では既存の環境を維持し、`"auto"` は起動時に Windows の静的プロキシ設定だけを読みます。PAC/WPAD、SOCKS のみの設定、実行中の変更は自動反映されません。TUN での成功だけでは HTTP プロキシ経路の正常性は確認できません。[コマンドと状態の説明(英語)](/reference/configuration/server/#codex-quota-network-diagnostics)を参照してください。 +メイン Codex アカウント行の `quotaRefresh` はクォータ取得の診断情報であり、残量やモデルへのアクセス権を示すものではありません。キャッシュ利用時や取得を行わない場合は省略されることがあります。取得には操作中のシェルではなく、実行中のプロキシサービスの環境が使われます。`proxy` 未設定では既存の環境を維持し、`"auto"` は起動時の Windows または macOS の静的 HTTP/HTTPS 設定を読みます。macOS では継承したプロキシがある場合、読み取りを行いません。macOS で IP アドレスまたは `*` 以外の例外があれば、環境を変更せず自動検出を中止します。PAC/WPAD、SOCKS のみの設定、実行中の変更は自動反映されません。TUN での成功だけでは HTTP プロキシ経路の正常性は確認できません。[コマンドと状態の説明(英語)](/reference/configuration/server/#codex-quota-network-diagnostics)を参照してください。 `dropCodexSafetyBuffering`: プロバイダーの安全性の適用と拒否応答は変更しません。native `codex.response.metadata.headers` WebSocket メタデータと `/responses/compact` は対象外です。 diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index b2a71843a4e..c3e4c92fbbb 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -243,4 +243,4 @@ Anthropic OAuth 사이드카는 opencodex의 기존 Claude Code OAuth fingerprin ## Codex 할당량 네트워크 진단 -메인 Codex 계정 행의 `quotaRefresh`는 할당량 조회 결과를 분류하는 진단값입니다. 남은 할당량이나 모델 접근 권한을 뜻하지 않으며, 캐시를 쓰거나 조회하지 않았다면 생략될 수 있습니다. 요청은 명령을 입력한 터미널이 아니라 실행 중인 프록시 서비스의 환경을 따릅니다. `proxy`를 지정하지 않으면 기존 환경을 유지하고, `"auto"`는 시작할 때 Windows의 정적 프록시 설정만 읽습니다. PAC/WPAD, SOCKS 전용 설정과 실행 중 변경은 자동으로 반영하지 않습니다. TUN에서 성공했다고 HTTP 프록시 경로도 정상이라는 뜻은 아닙니다. 명령과 상태값은 [네트워크 진단(영문)](/reference/configuration/server/#codex-quota-network-diagnostics)에서 확인하세요. +메인 Codex 계정 행의 `quotaRefresh`는 할당량 조회 결과를 분류하는 진단값입니다. 남은 할당량이나 모델 접근 권한을 뜻하지 않으며, 캐시를 쓰거나 조회하지 않았다면 생략될 수 있습니다. 요청은 명령을 입력한 터미널이 아니라 실행 중인 프록시 서비스의 환경을 따릅니다. `proxy`를 지정하지 않으면 기존 환경을 유지하고, `"auto"`는 시작 시 Windows 또는 macOS의 정적 HTTP/HTTPS 설정을 읽습니다. macOS에서는 상속된 프록시가 있으면 읽지 않습니다. macOS 예외가 IP 주소나 `*`가 아니면 환경을 변경하지 않고 자동 탐색을 거부합니다. PAC/WPAD, SOCKS 전용 설정과 실행 중 변경은 자동으로 반영하지 않습니다. TUN에서 성공했다고 HTTP 프록시 경로도 정상이라는 뜻은 아닙니다. 명령과 상태값은 [네트워크 진단(영문)](/reference/configuration/server/#codex-quota-network-diagnostics)에서 확인하세요. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index c8e9f967ffb..b32bd4d3cbd 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -12,7 +12,7 @@ runs helper features around provider requests. | --- | --- | --- | --- | | `port` | `number` | `10100` | Proxy listen port. | | `hostname?` | `string` | `"127.0.0.1"` | Bind address. A non-loopback bind requires a data-admission token, resolved from `OPENCODEX_API_AUTH_TOKEN`, then `OCX_API_TOKEN_FILE`, then the installed owner-only `service-api-token` — nothing has to be exported by hand. See [Remote access](#remote-access). | -| `proxy?` | `string` | — | Outbound HTTP(S) or SOCKS5 proxy URL (`socks5://host:port`), `${ENV_VAR}`, or `"auto"`. HTTP URLs apply to `HTTP_PROXY` / `HTTPS_PROXY` when those are unset. SOCKS5 URLs use OpenCodex's real SOCKS5 transport and are also exposed through `ALL_PROXY` (`ocx start --socks5`); inherited `HTTP(S)_PROXY` is cleared in this process. Loopback stays in `NO_PROXY`. `"auto"` reads the Windows system proxy (WinINET `ProxyEnable`/`ProxyServer`) once at process start, preserves distinct `http=` and `https=` entries, and logs the hosts it chose. A bare `ProxyServer` value applies to both schemes. On other platforms, or when the system proxy is off, SOCKS-only, or unreadable, it uses direct egress and says so. PAC/WPAD and live proxy changes are not followed; restart the service after changing the system proxy. | +| `proxy?` | `string` | — | Outbound HTTP(S) or SOCKS5 proxy URL (`socks5://host:port`), `${ENV_VAR}`, or `"auto"`. HTTP URLs apply to `HTTP_PROXY` / `HTTPS_PROXY` when those are unset. SOCKS5 URLs use OpenCodex's real SOCKS5 transport and are also exposed through `ALL_PROXY` (`ocx start --socks5`); inherited `HTTP(S)_PROXY` is cleared in this process. Loopback stays in `NO_PROXY`. `"auto"` reads Windows WinINET or macOS static HTTP/HTTPS settings once at startup. Inherited HTTP(S) proxy variables skip discovery; on macOS, inherited `ALL_PROXY`/`all_proxy` also skips it. Windows keeps separate `http=` and `https=` entries; a bare `ProxyServer` applies to both. macOS translates only IP-literal and `*` exceptions into the effective bypass variables; other exceptions refuse discovery without changing the proxy environment. Disabled, malformed, PAC/WPAD, SOCKS-only, and live changes are not followed. Restart after changing system settings. | | `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. | | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Remove optional client-facing hints from canonical Codex Responses passthrough: the two `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` response headers, `response.metadata` events whose metadata type is `safety_buffering`, and top-level `safety_buffering` fields. Other headers, response data, policy refusals and failures are preserved. This does not disable provider safety enforcement or upstream buffering. Native `codex.response.metadata.headers` WebSocket metadata and `/responses/compact` are outside this filter. | @@ -164,8 +164,11 @@ terminal does not update an already running service. An unset `proxy` leaves inherited proxy variables unchanged. An explicit HTTP(S) proxy URL fills `HTTP_PROXY` and `HTTPS_PROXY` only where they are unset. -`"proxy": "auto"` reads the Windows static WinINET proxy once at startup; existing -proxy environment variables take precedence. Auto discovery does not resolve +`"proxy": "auto"` reads Windows static WinINET or macOS static HTTP/HTTPS +settings once at startup. Existing proxy environment variables take precedence; +macOS discovery also skips inherited `ALL_PROXY`/`all_proxy`. macOS exceptions +must all be safely expressible as IP literals or `*`; otherwise discovery +refuses without changing proxy variables. Auto discovery does not resolve PAC/WPAD, SOCKS-only settings or live proxy changes. Use a supported static HTTP proxy setting or an explicit HTTP(S) proxy URL when needed. diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index f2a7bc704ab..9b14ff19d7d 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -232,6 +232,6 @@ opencodex. Перед использованием прогоните soak-test ## Сетевая диагностика квоты Codex -Поле `quotaRefresh` в строке основного аккаунта Codex описывает получение квоты, а не её остаток или право доступа к модели. Оно может отсутствовать при чтении кэша или если запрос не выполнялся. Используется окружение работающего прокси-сервиса, а не текущего терминала. Если `proxy` не задан, существующее окружение сохраняется; `"auto"` читает только статические настройки прокси Windows при запуске. PAC/WPAD, настройки только SOCKS и изменения во время работы автоматически не учитываются. Успех через TUN сам по себе не подтверждает исправность пути HTTP-прокси. См. [команды и состояния на английском](/reference/configuration/server/#codex-quota-network-diagnostics). +Поле `quotaRefresh` в строке основного аккаунта Codex описывает получение квоты, а не её остаток или право доступа к модели. Оно может отсутствовать при чтении кэша или если запрос не выполнялся. Используется окружение работающего прокси-сервиса, а не текущего терминала. Если `proxy` не задан, существующее окружение сохраняется; `"auto"` при запуске читает статические настройки HTTP/HTTPS Windows или macOS. На macOS унаследованный прокси отменяет это чтение. На macOS исключение, отличное от IP-адреса или `*`, отменяет обнаружение без изменения окружения. PAC/WPAD, настройки только SOCKS и изменения во время работы автоматически не учитываются. Успех через TUN сам по себе не подтверждает исправность пути HTTP-прокси. См. [команды и состояния на английском](/reference/configuration/server/#codex-quota-network-diagnostics). `dropCodexSafetyBuffering`: не меняет проверки безопасности провайдера или отказы. Native WebSocket `codex.response.metadata.headers` и `/responses/compact` не входят в область фильтра. diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index 71524df464a..dca859b79e9 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -304,4 +304,4 @@ yeniden kullanır. Hedeflenen hesap ve iş yükünü kapsamlı bir şekilde test ## Codex kota ağı tanılaması -Ana Codex hesabının satırındaki `quotaRefresh`, kalan kotayı veya model erişim yetkisini değil, kota sorgusunun sonucunu açıklar. Önbellek kullanıldığında ya da sorgu yapılmadığında alan bulunmayabilir. Sorgu, etkileşimli terminalin değil çalışan proxy servisinin ortamını kullanır. `proxy` ayarlanmazsa mevcut ortam korunur; `"auto"` yalnızca başlangıçta Windows’un statik proxy ayarlarını okur. PAC/WPAD, yalnızca SOCKS ayarları ve çalışma sırasındaki değişiklikler otomatik uygulanmaz. TUN ile başarı, HTTP proxy yolunun da çalıştığını tek başına göstermez. [Komutlar ve durumlar için İngilizce bölüme](/reference/configuration/server/#codex-quota-network-diagnostics) bakın. +Ana Codex hesabının satırındaki `quotaRefresh`, kalan kotayı veya model erişim yetkisini değil, kota sorgusunun sonucunu açıklar. Önbellek kullanıldığında ya da sorgu yapılmadığında alan bulunmayabilir. Sorgu, etkileşimli terminalin değil çalışan proxy servisinin ortamını kullanır. `proxy` ayarlanmazsa mevcut ortam korunur; `"auto"` başlangıçta Windows veya macOS statik HTTP/HTTPS ayarlarını okur. macOS üzerinde devralınmış proxy varsa bu ayarlar okunmaz. macOS istisnası IP adresi veya `*` değilse ortamı değiştirmeden keşfi reddeder. PAC/WPAD, yalnızca SOCKS ayarları ve çalışma sırasındaki değişiklikler otomatik uygulanmaz. TUN ile başarı, HTTP proxy yolunun da çalıştığını tek başına göstermez. [Komutlar ve durumlar için İngilizce bölüme](/reference/configuration/server/#codex-quota-network-diagnostics) bakın. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 76efbe5738e..2bd458fb5fa 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -198,6 +198,6 @@ Anthropic OAuth 侧车会复用 opencodex 现有的 Claude Code OAuth 指纹。 ## Codex 额度网络诊断 -主 Codex 账户行中的 `quotaRefresh` 描述额度查询结果,并不代表剩余额度或模型访问权限。读取缓存或未执行查询时,该字段可能省略。查询使用正在运行的代理服务的环境,而不是当前终端的环境。未设置 `proxy` 时保留现有环境;`"auto"` 只在启动时读取 Windows 静态代理设置,不自动处理 PAC/WPAD、仅 SOCKS 的设置或运行中的更改。TUN 测试成功并不能单独证明 HTTP 代理路径正常。命令和状态说明见[英文网络诊断章节](/reference/configuration/server/#codex-quota-network-diagnostics)。 +主 Codex 账户行中的 `quotaRefresh` 描述额度查询结果,并不代表剩余额度或模型访问权限。读取缓存或未执行查询时,该字段可能省略。查询使用正在运行的代理服务的环境,而不是当前终端的环境。未设置 `proxy` 时保留现有环境;`"auto"` 在启动时读取 Windows 或 macOS 静态 HTTP/HTTPS 设置;macOS 上若有继承代理则跳过读取。macOS 例外项若不是 IP 地址或 `*`,则拒绝自动发现且不修改环境。不自动处理 PAC/WPAD、仅 SOCKS 的设置或运行中的更改。TUN 测试成功并不能单独证明 HTTP 代理路径正常。命令和状态说明见[英文网络诊断章节](/reference/configuration/server/#codex-quota-network-diagnostics)。 `dropCodexSafetyBuffering`: 不会改变供应商安全策略或拒绝响应。原生 WebSocket `codex.response.metadata.headers` 和 `/responses/compact` 不在过滤范围内。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index ec8ffe6e41f..a9defff5ae6 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -217,4 +217,4 @@ Anthropic OAuth sidecar 重用 opencodex 既有的 Claude Code OAuth 指紋。 ## Codex 配額網路診斷 -主 Codex 帳戶列中的 `quotaRefresh` 描述配額查詢結果,並不代表剩餘配額或模型存取權限。讀取快取或未執行查詢時,這個欄位可能省略。查詢使用執行中代理服務的環境,而不是目前終端機的環境。未設定 `proxy` 時保留既有環境;`"auto"` 只在啟動時讀取 Windows 靜態代理設定,不會自動處理 PAC/WPAD、僅 SOCKS 的設定或執行中的變更。TUN 測試成功本身不能證明 HTTP 代理路徑正常。命令與狀態說明請見[英文網路診斷章節](/reference/configuration/server/#codex-quota-network-diagnostics)。 +主 Codex 帳戶列中的 `quotaRefresh` 描述配額查詢結果,並不代表剩餘配額或模型存取權限。讀取快取或未執行查詢時,這個欄位可能省略。查詢使用執行中代理服務的環境,而不是目前終端機的環境。未設定 `proxy` 時保留既有環境;`"auto"` 在啟動時讀取 Windows 或 macOS 靜態 HTTP/HTTPS 設定;macOS 上若有繼承代理則略過讀取。macOS 例外項若不是 IP 位址或 `*`,就會拒絕自動探索且不修改環境。不會自動處理 PAC/WPAD、僅 SOCKS 的設定或執行中的變更。TUN 測試成功本身不能證明 HTTP 代理路徑正常。命令與狀態說明請見[英文網路診斷章節](/reference/configuration/server/#codex-quota-network-diagnostics)。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f3f16fae58b..16323b9d2a2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1447,6 +1447,7 @@ "provider-workspace-data.test.ts": "gui", "provider-workspace-rail.test.ts": "gui", "provider-workspace-state.test.ts": "gui", + "proxy-env-macos.test.ts": "server", "proxy-env.test.ts": "server", "proxy-liveness-package-tree-fence.test.ts": "server", "proxy-liveness.test.ts": "server", diff --git a/src/config/macos-system-proxy.ts b/src/config/macos-system-proxy.ts new file mode 100644 index 00000000000..69580b3d726 --- /dev/null +++ b/src/config/macos-system-proxy.ts @@ -0,0 +1,100 @@ +import { execFileSync } from "node:child_process"; +import { isIP } from "node:net"; + +export type MacOSProxyReader = () => string | null; +export type MacOSSystemProxyResult = + | { kind: "proxy"; httpUrl?: string; httpsUrl?: string; exceptions: string[] } + | { kind: "disabled" | "unreadable" | "unsafe-exceptions" }; + +function readScutilProxy(): string { + return execFileSync("/usr/sbin/scutil", ["--proxy"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2_000, + maxBuffer: 64 * 1024, + }); +} + +function proxyUrl(host: string | undefined, port: string | undefined): string | undefined { + if (!host || !port || !/^\d+$/.test(port) || +port < 1 || +port > 65535) return undefined; + const bareHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + if (!isIP(bareHost) && !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.?$/i.test(host)) return undefined; + try { + return new URL(`http://${isIP(bareHost) === 6 ? `[${bareHost}]` : host}:${port}`).origin; + } catch { + return undefined; + } +} + +// Bun's no_proxy parser matches names by suffix, even bare "localhost". The only +// macOS exceptions we can translate without changing their scope are IP literals +// and the all-host wildcard. CIDR, domain glob, and simple-host rules must refuse +// discovery rather than quietly proxy a host macOS intended to send direct. +function translateException(value: string): string | undefined { + if (value === "*") return value; + if (isIP(value) === 4) { + const canonical = new URL(`http://${value}`).hostname; + return value === canonical ? value : undefined; + } + const bare = value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value; + return isIP(bare) === 6 ? new URL(`http://[${bare}]`).hostname : undefined; +} + +/** Read only the global dictionary; scoped service dictionaries do not apply globally. */ +export function readMacOSSystemProxy(reader: MacOSProxyReader = readScutilProxy): MacOSSystemProxyResult { + try { + const output = reader(); + if (!output || output.length > 64 * 1024 || !/^\s*\s*\{/.test(output)) return { kind: "unreadable" }; + const values = new Map(); + const exceptions: string[] = []; + let depth = 0; + let inExceptions = false; + for (const row of output.split(/\r?\n/)) { + const line = row.trim(); + if (line.endsWith("{")) { + if (inExceptions) return { kind: "unreadable" }; + if (depth === 1) { + inExceptions = /^ExceptionsList\s*:\s*\s*\{$/.test(line); + if (line.startsWith("ExceptionsList") && !inExceptions) return { kind: "unreadable" }; + } + depth++; + } else if (line === "}") { + if (--depth < 0) return { kind: "unreadable" }; + if (depth === 1) inExceptions = false; + } else { + const entry = /^([^:]+)\s*:\s*(.*?)\s*$/.exec(line); + if (!entry) { + if (inExceptions) return { kind: "unreadable" }; + continue; + } + if (depth === 1) { + if (entry[1]!.trim() === "ExceptionsList") return { kind: "unreadable" }; + values.set(entry[1]!.trim(), entry[2]!); + } + if (depth === 2 && inExceptions) { + if (!/^\d+$/.test(entry[1]!.trim())) return { kind: "unreadable" }; + exceptions.push(entry[2]!); + } + } + } + if (depth !== 0) return { kind: "unreadable" }; + if (values.get("ExcludeSimpleHostnames") === "1" || values.get("ProxyAutoConfigEnable") === "1" + || values.get("ProxyAutoDiscoveryEnable") === "1") return { kind: "unsafe-exceptions" }; + for (const key of ["HTTPEnable", "HTTPSEnable", "ExcludeSimpleHostnames", "ProxyAutoConfigEnable", "ProxyAutoDiscoveryEnable"]) { + const value = values.get(key); + if (value !== undefined && value !== "0" && value !== "1") return { kind: "unreadable" }; + } + const translated = exceptions.map(translateException); + if (translated.some(value => value === undefined)) return { kind: "unsafe-exceptions" }; + const httpEnabled = values.get("HTTPEnable") === "1"; + const httpsEnabled = values.get("HTTPSEnable") === "1"; + const httpUrl = httpEnabled ? proxyUrl(values.get("HTTPProxy"), values.get("HTTPPort")) : undefined; + const httpsUrl = httpsEnabled ? proxyUrl(values.get("HTTPSProxy"), values.get("HTTPSPort")) : undefined; + if ((httpEnabled && !httpUrl) || (httpsEnabled && !httpsUrl)) return { kind: "unreadable" }; + return httpUrl || httpsUrl + ? { kind: "proxy", httpUrl, httpsUrl, exceptions: translated.filter((value): value is string => value !== undefined) } + : { kind: "disabled" }; + } catch { + return { kind: "unreadable" }; + } +} diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index 09123fd81ad..1e6070939bd 100644 --- a/src/config/proxy-env.ts +++ b/src/config/proxy-env.ts @@ -5,6 +5,7 @@ import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./subagent-mod import { MULTI_AGENT_SURFACE_ADVISORY_VERSION } from "./multi-agent-surface"; import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES } from "../lib/app-owned-memory"; import { describeProxyForLog, readWindowsSystemProxy, type WindowsProxyRegistryReader } from "../lib/windows-system-proxy"; +import { readMacOSSystemProxy, type MacOSProxyReader } from "./macos-system-proxy"; import { OPENAI_PROVIDER_TIER_VERSION, type OcxConfig } from "../types"; import type { OcxRuntimeRole } from "../types/config"; @@ -152,6 +153,23 @@ function mergeNoProxyEntries(configured: readonly string[] = [], loopback: reado } } +function configuredNoProxyEntries(config: OcxConfig): string[] { + const raw = config.noProxy; + let entries: string[]; + if (Array.isArray(raw)) { + if (raw.some(entry => typeof entry !== "string")) warnProxyConfigDiscardOnce("noProxyElements"); + entries = raw.filter((entry): entry is string => typeof entry === "string"); + } else if (typeof raw === "string") { + const resolved = resolveEnvValue(raw); + if (raw && resolved === undefined) warnProxyConfigDiscardOnce("noProxy"); + entries = (resolved ?? "").split(","); + } else { + if (raw !== undefined) warnProxyConfigDiscardOnce("noProxy"); + entries = []; + } + return entries.map(entry => entry.trim()).filter(Boolean); +} + /** * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY @@ -172,7 +190,7 @@ export function applyProxyEnv(config: OcxConfig, announce = false): void { /** Test seam for `proxy: "auto"`: the registry reader and platform are injectable. */ export function applyProxyEnvWith( config: OcxConfig, - auto: { reader?: WindowsProxyRegistryReader; platform?: NodeJS.Platform } = {}, + auto: { reader?: WindowsProxyRegistryReader; macOSReader?: MacOSProxyReader; platform?: NodeJS.Platform } = {}, ): void { // `proxy` and `noProxy` are not declared in the top-level schema, which ends in // `.passthrough()`, so whatever is on disk arrives here verbatim. A non-string value @@ -194,6 +212,42 @@ export function applyProxyEnvWith( return; } if (proxy.trim().toLowerCase() === "auto") { + if ((auto.platform ?? process.platform) === "darwin") { + // An inherited scheme or ALL_PROXY route owns both its proxy and bypass + // variables. Combining it with system exceptions would change that route. + if (["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"] + .some(key => process.env[key]?.trim())) { + console.log('[opencodex] proxy "auto": existing proxy environment wins; macOS system proxy not consulted'); + configureSocks5Fetch(); + return; + } + const found = readMacOSSystemProxy(auto.macOSReader); + if (found.kind !== "proxy") { + const reason = found.kind === "unsafe-exceptions" + ? "macOS exceptions cannot be safely translated; discovery refused" + : found.kind === "disabled" + ? "macOS system proxy is disabled" + : "macOS proxy settings could not be read"; + console.log(`[opencodex] proxy "auto": ${reason}; proxy environment unchanged`); + return; + } + const origins = [ + found.httpUrl && `HTTP ${describeProxyForLog(found.httpUrl)}`, + found.httpsUrl && `HTTPS ${describeProxyForLog(found.httpsUrl)}`, + ].filter(Boolean).join(", "); + console.log(`[opencodex] proxy "auto": using macOS system proxy ${origins}`); + if (found.httpUrl) process.env.HTTP_PROXY = found.httpUrl; + if (found.httpsUrl) process.env.HTTPS_PROXY = found.httpsUrl; + // Bun gives non-empty lowercase no_proxy priority over NO_PROXY. Add the + // proven-safe system exceptions to both; keep name-based loopback out of + // the suffix matcher on both paths. + mergeNoProxyEntries([...configuredNoProxyEntries(config), ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY); + if (process.env.no_proxy?.trim()) { + process.env.no_proxy = withNoProxyEntries(process.env.no_proxy, found.exceptions, LOOPBACK_ADDRESS_NO_PROXY); + } + configureSocks5Fetch(); + return; + } // #1525 slice 1: one startup read of the Windows static proxy. Never copy the literal // "auto" into HTTP_PROXY; every non-proxy outcome leaves outbound routing as it was. if (process.env.HTTP_PROXY?.trim() || process.env.http_proxy?.trim() @@ -213,7 +267,7 @@ export function applyProxyEnvWith( proxy = undefined; } else { const reason = found.kind === "unsupported" - ? "only Windows system proxy discovery is supported; using direct egress on this OS" + ? "only Windows and macOS system proxy discovery is supported; using direct egress on this OS" : found.kind === "disabled" ? "Windows system proxy is disabled; using direct egress" : found.kind === "socks-only" @@ -240,23 +294,6 @@ export function applyProxyEnvWith( } // Configured entries first, then loopback: loopback is unconditional, so appending it last // keeps it present even when the operator lists a loopback host themselves. - const raw = config.noProxy; - let configuredEntries: string[]; - if (Array.isArray(raw)) { - // One unusable element must not discard the operator's other entries. - if (raw.some(entry => typeof entry !== "string")) warnProxyConfigDiscardOnce("noProxyElements"); - configuredEntries = raw.filter((entry): entry is string => typeof entry === "string"); - } else if (typeof raw === "string") { - const resolved = resolveEnvValue(raw); - if (raw && resolved === undefined) warnProxyConfigDiscardOnce("noProxy"); - configuredEntries = (resolved ?? "").split(","); - } else { - if (raw !== undefined) warnProxyConfigDiscardOnce("noProxy"); - configuredEntries = []; - } - const configured = configuredEntries - .map(entry => entry.trim()) - .filter(Boolean); - mergeNoProxyEntries(configured); + mergeNoProxyEntries(configuredNoProxyEntries(config)); configureSocks5Fetch(); } diff --git a/src/types/config.ts b/src/types/config.ts index 940aa2b06fd..872a43fe8cd 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -883,10 +883,10 @@ export interface OcxConfig { * HTTP URLs are mirrored into HTTP_PROXY/HTTPS_PROXY when unset. SOCKS5 URLs are mirrored * into ALL_PROXY, clear inherited HTTP(S)_PROXY, and use OpenCodex's SOCKS5 transport. * Loopback stays in NO_PROXY. - * The literal `"auto"` reads the Windows WinINET static proxy (`ProxyEnable`/`ProxyServer`) - * once at process start, preserving separate HTTP and HTTPS entries; on other platforms, or - * when the system proxy is off, SOCKS-only, or unreadable, it degrades to direct egress with - * one log line (#1525). PAC/WPAD and live changes are not followed. + * The literal `"auto"` reads Windows WinINET or macOS static HTTP/HTTPS proxy settings + * once at startup. Inherited scheme proxies win; on macOS, inherited ALL_PROXY also skips + * discovery, and unsafe system exceptions refuse discovery without environment writes. + * PAC/WPAD, SOCKS-only settings, and live changes are not followed. */ proxy?: string; /** diff --git a/structure/config-proxy.md b/structure/config-proxy.md index a4c6e5af550..ef6886e83a8 100644 --- a/structure/config-proxy.md +++ b/structure/config-proxy.md @@ -3,7 +3,9 @@ `src/config/proxy-env.ts` remains the single application owner for global proxy configuration. An explicit SOCKS5 or SOCKS5h URL selects ALL_PROXY and removes stale scheme-proxy variables; HTTP(S) settings retain their existing environment -precedence. Activation keeps the existing Windows auto-discovery path and loopback +precedence. Activation keeps the existing Windows auto-discovery path and adds opt-in +macOS discovery for `proxy: "auto"`. It never consults macOS settings when any scheme +proxy or `ALL_PROXY`/`all_proxy` is inherited. The shared path keeps loopback NO_PROXY entries; the no-configured-proxy return merges all of them only when an inherited SOCKS proxy is the only inherited proxy; whenever Bun applies an inherited HTTP(S) scheme proxy or HTTP(S) `ALL_PROXY`/`all_proxy`, it matches by domain suffix, so activation adds only the @@ -29,3 +31,16 @@ userinfo is stripped while host and port stay visible, `direct` and credential-l print unchanged, and a non-URL value that is not `direct` is masked whole. `config export` keeps the raw file so exports can restore credentials. Get and mutation output select redaction by the normalized final path segment, matching lookup and mutation semantics. + +On macOS, `src/config/macos-system-proxy.ts` reads the top-level static HTTP/HTTPS +settings from `/usr/sbin/scutil --proxy` once, with a timeout and output bound. +Only enabled schemes are installed. PAC/WPAD, simple-host bypasses, malformed +settings, and exception patterns whose semantics cannot be represented safely +refuse discovery before any proxy-environment write. Only IP literals and the +all-host `*` exception are translated. Accepted exceptions enter both `NO_PROXY` +and an inherited non-empty `no_proxy`, since Bun gives lowercase precedence; +only loopback addresses are appended, never the bare `localhost` suffix. +Inherited SOCKS routes keep their existing uppercase bypass semantics and do +not receive macOS exceptions. The diagnostic reports a category, never raw +settings or credential-bearing URLs. Regression cases live in +`tests/server/proxy-env-macos.test.ts`. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 01768b541df..8244f099727 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1293,6 +1293,7 @@ "provider-workspace-data.test.ts": "gui", "provider-workspace-rail.test.ts": "gui", "provider-workspace-state.test.ts": "gui", + "proxy-env-macos.test.ts": "server", "proxy-env.test.ts": "server", "proxy-liveness-package-tree-fence.test.ts": "server", "proxy-liveness.test.ts": "server", diff --git a/tests/server/proxy-env-macos.test.ts b/tests/server/proxy-env-macos.test.ts new file mode 100644 index 00000000000..9301816a27e --- /dev/null +++ b/tests/server/proxy-env-macos.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { applyProxyEnvWith } from "../../src/config"; +import { readMacOSSystemProxy } from "../../src/config/macos-system-proxy"; +import { noProxyMatches, resolveProxyRoute, configureSocks5Fetch } from "../../src/lib/proxy-env"; +import type { OcxConfig } from "../../src/types"; + +const KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"] as const; +let saved: Record; +const config = (proxy?: string, noProxy?: string): OcxConfig => ({ proxy, noProxy, providers: {} }) as OcxConfig; +const scutil = (body: string): string => ` {\n${body}\n}`; +const both = "HTTPEnable : 1\nHTTPProxy : proxy.example\nHTTPPort : 8080\nHTTPSEnable : 1\nHTTPSProxy : ::1\nHTTPSPort : 8443"; +const snapshot = (): Record => Object.fromEntries(KEYS.map(key => [key, process.env[key]])); + +beforeEach(() => { + saved = snapshot(); + for (const key of KEYS) delete process.env[key]; +}); +afterEach(() => { + for (const key of KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + configureSocks5Fetch(); +}); + +describe('macOS proxy: "auto" (#5853)', () => { + test("enabled schemes and safe IP exceptions reach Bun's lowercase bypass", () => { + process.env.NO_PROXY = "upper.example"; + process.env.no_proxy = "lower.example"; + applyProxyEnvWith(config("auto", "configured.example"), { + platform: "darwin", + macOSReader: () => scutil(`${both}\nExceptionsList : {\n0 : 203.0.113.7\n1 : ::1\n}`), + }); + expect(process.env.HTTP_PROXY).toBe("http://proxy.example:8080"); + expect(process.env.HTTPS_PROXY).toBe("http://[::1]:8443"); + expect(process.env.NO_PROXY).toBe("upper.example,configured.example,203.0.113.7,[::1],127.0.0.1,::1"); + expect(process.env.no_proxy).toBe("lower.example,127.0.0.1,::1,[::1],203.0.113.7"); + expect(noProxyMatches(new URL("http://203.0.113.7"), { no_proxy: process.env.no_proxy })).toBe(true); + expect(noProxyMatches(new URL("http://203.0.113.70"), { no_proxy: process.env.no_proxy })).toBe(false); + expect(resolveProxyRoute(new URL("https://example.org"))).toEqual({ kind: "proxy", proxy: "http://[::1]:8443" }); + }); + + test("the all-host wildcard has the same bypass scope on both transports", () => { + process.env.no_proxy = "lower.example"; + applyProxyEnvWith(config("auto"), { + platform: "darwin", + macOSReader: () => scutil(`${both}\nExceptionsList : {\n0 : *\n}`), + }); + expect(process.env.NO_PROXY?.split(",")).toContain("*"); + expect(process.env.no_proxy?.split(",")).toContain("*"); + }); + + test.each(["HTTP", "HTTPS"])("preserves %s-only settings", scheme => { + applyProxyEnvWith(config(" AUTO "), { + platform: "darwin", + macOSReader: () => scutil(`${scheme}Enable : 1\n${scheme}Proxy : 127.0.0.1\n${scheme}Port : 7890`), + }); + expect(process.env[`${scheme}_PROXY`]).toBe("http://127.0.0.1:7890"); + expect(process.env[scheme === "HTTP" ? "HTTPS_PROXY" : "HTTP_PROXY"]).toBeUndefined(); + }); + + test.each([ + "localhost", "*.local", "169.254/16", "example.com", "bad entry", + ])("refuses an unrepresentable exception %s without any environment write", exception => { + process.env.NO_PROXY = "upper.example"; + process.env.no_proxy = "lower.example"; + const before = snapshot(); + const lines: string[] = []; + const original = console.log; + console.log = (...args) => { lines.push(args.join(" ")); }; + try { + applyProxyEnvWith(config("auto", "configured.example"), { + platform: "darwin", + macOSReader: () => scutil(`${both}\nExceptionsList : {\n0 : ${exception}\n}`), + }); + } finally { console.log = original; } + expect(snapshot()).toEqual(before); + expect(lines.join(" ")).toContain("discovery refused"); + expect(lines.join(" ")).not.toContain(exception); + }); + + test.each([ + ["disabled", "HTTPEnable : 0"], + ["bad port", "HTTPEnable : 1\nHTTPProxy : proxy.example\nHTTPPort : 0"], + ["bad enable", `${both}\nHTTPEnable : maybe`], + ["bad syntax", "HTTPEnable : 1\nHTTPProxy : proxy.example\nHTTPPort : 8080\nExceptionsList : {\n0 : 127.0.0.1"], + ["simple host bypass", `${both}\nExcludeSimpleHostnames : 1`], + ["PAC", `${both}\nProxyAutoConfigEnable : 1`], + ])("%s settings leave egress unchanged", (_case, body) => { + process.env.NO_PROXY = "upper.example"; + process.env.no_proxy = "lower.example"; + const before = snapshot(); + applyProxyEnvWith(config("auto"), { platform: "darwin", macOSReader: () => scutil(body) }); + expect(snapshot()).toEqual(before); + }); + + test("a failed scutil read leaves egress unchanged", () => { + const before = snapshot(); + applyProxyEnvWith(config("auto"), { platform: "darwin", macOSReader: () => { throw new Error("secret"); } }); + expect(snapshot()).toEqual(before); + expect(readMacOSSystemProxy(() => "garbage")).toEqual({ kind: "unreadable" }); + }); + + test("a credential-shaped system proxy host is rejected without logging it", () => { + const before = snapshot(); + const lines: string[] = []; + const original = console.log; + console.log = (...args) => { lines.push(args.join(" ")); }; + try { + applyProxyEnvWith(config("auto"), { + platform: "darwin", + macOSReader: () => scutil("HTTPEnable : 1\nHTTPProxy : user:secret@proxy\nHTTPPort : 8080"), + }); + } finally { console.log = original; } + expect(snapshot()).toEqual(before); + expect(lines.join(" ")).not.toContain("secret"); + }); + + test("proxy unset never consults the system and leaves a proxy-free environment alone", () => { + let called = false; + const before = snapshot(); + applyProxyEnvWith(config(), { platform: "darwin", macOSReader: () => { called = true; return scutil(both); } }); + expect(called).toBe(false); + expect(snapshot()).toEqual(before); + }); + + test.each(["HTTP_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"])("inherited %s wins without system discovery", key => { + process.env[key] = key.toLowerCase().includes("all") ? "socks5h://socks.example:1080" : "http://inherited.example:8080"; + process.env.NO_PROXY = "upper.example"; + process.env.no_proxy = "lower.example"; + const before = snapshot(); + let called = false; + applyProxyEnvWith(config("auto"), { platform: "darwin", macOSReader: () => { called = true; return scutil(both); } }); + expect(called).toBe(false); + expect(snapshot()).toEqual(before); + if (key.toLowerCase().includes("all")) { + // SOCKS wrapper reads uppercase; Bun's native HTTP transport reads lowercase. + expect(resolveProxyRoute(new URL("http://upper.example"))).toEqual({ kind: "direct" }); + expect(resolveProxyRoute(new URL("http://lower.example")).kind).toBe("fallback"); + } + }); + + test("mixed inherited SOCKS and HTTP routes keep their distinct bypass decisions", () => { + process.env.ALL_PROXY = "socks5h://socks.example:1080"; + process.env.HTTP_PROXY = "http://http.example:8080"; + process.env.NO_PROXY = "upper.example"; + process.env.no_proxy = "lower.example"; + const before = snapshot(); + applyProxyEnvWith(config("auto"), { platform: "darwin", macOSReader: () => { throw new Error("must not read"); } }); + expect(snapshot()).toEqual(before); + for (const [hostname, socksBypass, bunBypass] of [ + ["upper.example", true, false], + ["lower.example", false, true], + ] as const) { + const url = new URL(`http://${hostname}/`); + expect(noProxyMatches(url, { NO_PROXY: process.env.NO_PROXY })).toBe(socksBypass); + expect(noProxyMatches(url, { no_proxy: process.env.no_proxy })).toBe(bunBypass); + } + }); + + test("the outbound proxy matcher does not widen localhost to app.localhost", () => { + process.env.no_proxy = "lower.example"; + applyProxyEnvWith(config("auto"), { + platform: "darwin", + macOSReader: () => scutil("HTTPEnable : 1\nHTTPProxy : 127.0.0.1\nHTTPPort : 8080"), + }); + expect(process.env.no_proxy).not.toContain("localhost"); + for (const hostname of ["localhost", "app.localhost"]) { + const url = new URL(`http://${hostname}:12345/`); + expect(noProxyMatches(url, { no_proxy: process.env.no_proxy })).toBe(false); + expect(resolveProxyRoute(url, { HTTP_PROXY: process.env.HTTP_PROXY, no_proxy: process.env.no_proxy })) + .toEqual({ kind: "proxy", proxy: "http://127.0.0.1:8080" }); + } + }); +}); diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts index 881b39af8e4..a65258b1d07 100644 --- a/tests/server/proxy-env.test.ts +++ b/tests/server/proxy-env.test.ts @@ -511,7 +511,7 @@ describe("applyProxyEnv with proxy: \"auto\" (#1525)", () => { test("auto never leaks the literal into HTTP_PROXY when discovery yields nothing", () => { for (const [platform, reader] of [ - ["darwin", () => ({ proxyEnable: "0x1", proxyServer: "127.0.0.1:1" })], + ["linux", () => ({ proxyEnable: "0x1", proxyServer: "127.0.0.1:1" })], ["win32", () => ({ proxyEnable: "0x0", proxyServer: "127.0.0.1:1" })], ["win32", () => ({ proxyEnable: "0x1", proxyServer: "socks=127.0.0.1:1080" })], ["win32", () => null], From 3004540592222890d3dad433baef8cd441fce72e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 00:59:46 +0900 Subject: [PATCH 2/6] test(proxy): assert split transport bypass precedence Cover inherited SOCKS activation skips and the distinct lowercase Bun and uppercase WebSocket bypass decisions, including an explicitly empty uppercase value. --- tests/server/proxy-env-macos.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/server/proxy-env-macos.test.ts b/tests/server/proxy-env-macos.test.ts index 9301816a27e..96eef60045a 100644 --- a/tests/server/proxy-env-macos.test.ts +++ b/tests/server/proxy-env-macos.test.ts @@ -84,6 +84,8 @@ describe('macOS proxy: "auto" (#5853)', () => { ["bad port", "HTTPEnable : 1\nHTTPProxy : proxy.example\nHTTPPort : 0"], ["bad enable", `${both}\nHTTPEnable : maybe`], ["bad syntax", "HTTPEnable : 1\nHTTPProxy : proxy.example\nHTTPPort : 8080\nExceptionsList : {\n0 : 127.0.0.1"], + ["SOCKS-only", "SOCKSEnable : 1\nSOCKSProxy : socks.example\nSOCKSPort : 1080"], + ["scoped-only", `__SCOPED__ : {\nen0 : {\n${both}\n}\n}`], ["simple host bypass", `${both}\nExcludeSimpleHostnames : 1`], ["PAC", `${both}\nProxyAutoConfigEnable : 1`], ])("%s settings leave egress unchanged", (_case, body) => { @@ -158,6 +160,25 @@ describe('macOS proxy: "auto" (#5853)', () => { } }); + test.skipIf(process.platform === "win32")("Bun's lowercase bypass and the WebSocket route's uppercase bypass remain distinct", () => { + process.env.HTTP_PROXY = "http://http.example:8080"; + process.env.NO_PROXY = "upper.example.com"; + process.env.no_proxy = "lower.example.com"; + const before = snapshot(); + applyProxyEnvWith(config("auto"), { platform: "darwin", macOSReader: () => { throw new Error("must not read"); } }); + expect(snapshot()).toEqual(before); + const upper = new URL("http://upper.example.com/"); + const lower = new URL("http://lower.example.com/"); + // Bun uses the non-empty lowercase value; resolveProxyRoute uses uppercase + // even when that key is explicitly defined as an empty string. + expect(noProxyMatches(upper, { no_proxy: process.env.no_proxy })).toBe(false); + expect(noProxyMatches(lower, { no_proxy: process.env.no_proxy })).toBe(true); + expect(resolveProxyRoute(new URL("ws://upper.example.com/"))).toEqual({ kind: "direct" }); + expect(resolveProxyRoute(new URL("ws://lower.example.com/"))).toEqual({ kind: "proxy", proxy: process.env.HTTP_PROXY }); + process.env.NO_PROXY = ""; + expect(resolveProxyRoute(new URL("ws://lower.example.com/"))).toEqual({ kind: "proxy", proxy: process.env.HTTP_PROXY }); + }); + test("the outbound proxy matcher does not widen localhost to app.localhost", () => { process.env.no_proxy = "lower.example"; applyProxyEnvWith(config("auto"), { From 9e67bb5f6b0aac63e84b3aa913b7e92e25a83238 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 01:14:16 +0900 Subject: [PATCH 3/6] fix(proxy): activate macOS defaults with bounded exception translation Map valid leading domain globs to Bun label-boundary suffixes and report their apex widening. Drop only exact link-local default ranges with a privacy-safe diagnostic; keep other unrepresentable rules fail-closed. --- .../docs/fr/reference/configuration/server.md | 2 +- .../docs/ja/reference/configuration/server.md | 2 +- .../docs/ko/reference/configuration/server.md | 2 +- .../docs/reference/configuration/server.md | 11 +++-- .../docs/ru/reference/configuration/server.md | 2 +- .../docs/tr/reference/configuration/server.md | 2 +- .../zh-cn/reference/configuration/server.md | 2 +- .../zh-tw/reference/configuration/server.md | 2 +- src/config/macos-system-proxy.ts | 28 +++++++++---- src/config/proxy-env.ts | 3 ++ src/types/config.ts | 4 +- structure/config-proxy.md | 20 +++++---- tests/server/proxy-env-macos.test.ts | 41 ++++++++++++++++++- 13 files changed, 94 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index 1899fd9e0fc..6fe894b567f 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -273,4 +273,4 @@ compte et la charge de travail prévus. ## Diagnostic réseau des quotas Codex -Le champ `quotaRefresh` de la ligne du compte Codex principal décrit la récupération du quota, pas le quota restant ni les droits d’accès au modèle. Il peut être absent lorsque les données sont en cache ou qu’aucune récupération n’a eu lieu. La requête utilise l’environnement du service proxy en cours d’exécution, pas celui du terminal interactif. Sans `proxy`, l’environnement existant est conservé ; `"auto"` lit les paramètres HTTP/HTTPS statiques de Windows ou macOS au démarrage. Sur macOS, un proxy hérité empêche cette lecture. Sur macOS, une exception autre qu’une adresse IP ou `*` annule la découverte sans modifier l’environnement. PAC/WPAD, les paramètres SOCKS seuls et les changements à chaud ne sont pas pris en compte automatiquement. Un succès avec TUN ne valide pas à lui seul le chemin du proxy HTTP. Consultez [les commandes et les états en anglais](/reference/configuration/server/#codex-quota-network-diagnostics). +Le champ `quotaRefresh` de la ligne du compte Codex principal décrit la récupération du quota, pas le quota restant ni les droits d’accès au modèle. Il peut être absent lorsque les données sont en cache ou qu’aucune récupération n’a eu lieu. La requête utilise l’environnement du service proxy en cours d’exécution, pas celui du terminal interactif. Sans `proxy`, l’environnement existant est conservé ; `"auto"` lit les paramètres HTTP/HTTPS statiques de Windows ou macOS au démarrage. Sur macOS, un proxy hérité empêche cette lecture. Sur macOS, un motif valide `*.` devient `.` : `foo.local` contourne le proxy pour `*.local`, `xlocal` non, et le nom racine `local` le contourne aussi. Les plages exactes `169.254/16`, `169.254.0.0/16` et `fe80::/10` sont ignorées avec un diagnostic : les adresses IP link-local passent par le proxy. Les autres plages CIDR, motifs glob et exceptions de noms simples refusent la découverte sans modifier l’environnement. Les adresses IP et `*` restent acceptés. PAC/WPAD, les paramètres SOCKS seuls et les changements à chaud ne sont pas pris en compte automatiquement. Un succès avec TUN ne valide pas à lui seul le chemin du proxy HTTP. Consultez [les commandes et les états en anglais](/reference/configuration/server/#codex-quota-network-diagnostics). diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index d324a3a51ea..7eeac7e4776 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -184,6 +184,6 @@ Anthropic OAuth サイドカーは、opencodex の既存のクロード コー ## Codex クォータのネットワーク診断 -メイン Codex アカウント行の `quotaRefresh` はクォータ取得の診断情報であり、残量やモデルへのアクセス権を示すものではありません。キャッシュ利用時や取得を行わない場合は省略されることがあります。取得には操作中のシェルではなく、実行中のプロキシサービスの環境が使われます。`proxy` 未設定では既存の環境を維持し、`"auto"` は起動時の Windows または macOS の静的 HTTP/HTTPS 設定を読みます。macOS では継承したプロキシがある場合、読み取りを行いません。macOS で IP アドレスまたは `*` 以外の例外があれば、環境を変更せず自動検出を中止します。PAC/WPAD、SOCKS のみの設定、実行中の変更は自動反映されません。TUN での成功だけでは HTTP プロキシ経路の正常性は確認できません。[コマンドと状態の説明(英語)](/reference/configuration/server/#codex-quota-network-diagnostics)を参照してください。 +メイン Codex アカウント行の `quotaRefresh` はクォータ取得の診断情報であり、残量やモデルへのアクセス権を示すものではありません。キャッシュ利用時や取得を行わない場合は省略されることがあります。取得には操作中のシェルではなく、実行中のプロキシサービスの環境が使われます。`proxy` 未設定では既存の環境を維持し、`"auto"` は起動時の Windows または macOS の静的 HTTP/HTTPS 設定を読みます。macOS では継承したプロキシがある場合、読み取りを行いません。macOS では有効な `*.` を `.` に変換します。`*.local` は `foo.local` と基底名 `local` を直接接続にしますが、`xlocal` は対象外です。`169.254/16`、`169.254.0.0/16`、`fe80::/10` は診断を出して省略し、リンクローカル IP アドレスはプロキシを使います。IP アドレスと `*` は受け入れますが、その他の CIDR、glob、単純ホスト名の例外では環境を変更せず検出を中止します。PAC/WPAD、SOCKS のみの設定、実行中の変更は自動反映されません。TUN での成功だけでは HTTP プロキシ経路の正常性は確認できません。[コマンドと状態の説明(英語)](/reference/configuration/server/#codex-quota-network-diagnostics)を参照してください。 `dropCodexSafetyBuffering`: プロバイダーの安全性の適用と拒否応答は変更しません。native `codex.response.metadata.headers` WebSocket メタデータと `/responses/compact` は対象外です。 diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index c3e4c92fbbb..e166d1e50ba 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -243,4 +243,4 @@ Anthropic OAuth 사이드카는 opencodex의 기존 Claude Code OAuth fingerprin ## Codex 할당량 네트워크 진단 -메인 Codex 계정 행의 `quotaRefresh`는 할당량 조회 결과를 분류하는 진단값입니다. 남은 할당량이나 모델 접근 권한을 뜻하지 않으며, 캐시를 쓰거나 조회하지 않았다면 생략될 수 있습니다. 요청은 명령을 입력한 터미널이 아니라 실행 중인 프록시 서비스의 환경을 따릅니다. `proxy`를 지정하지 않으면 기존 환경을 유지하고, `"auto"`는 시작 시 Windows 또는 macOS의 정적 HTTP/HTTPS 설정을 읽습니다. macOS에서는 상속된 프록시가 있으면 읽지 않습니다. macOS 예외가 IP 주소나 `*`가 아니면 환경을 변경하지 않고 자동 탐색을 거부합니다. PAC/WPAD, SOCKS 전용 설정과 실행 중 변경은 자동으로 반영하지 않습니다. TUN에서 성공했다고 HTTP 프록시 경로도 정상이라는 뜻은 아닙니다. 명령과 상태값은 [네트워크 진단(영문)](/reference/configuration/server/#codex-quota-network-diagnostics)에서 확인하세요. +메인 Codex 계정 행의 `quotaRefresh`는 할당량 조회 결과를 분류하는 진단값입니다. 남은 할당량이나 모델 접근 권한을 뜻하지 않으며, 캐시를 쓰거나 조회하지 않았다면 생략될 수 있습니다. 요청은 명령을 입력한 터미널이 아니라 실행 중인 프록시 서비스의 환경을 따릅니다. `proxy`를 지정하지 않으면 기존 환경을 유지하고, `"auto"`는 시작 시 Windows 또는 macOS의 정적 HTTP/HTTPS 설정을 읽습니다. macOS에서는 상속된 프록시가 있으면 읽지 않습니다. macOS에서는 유효한 `*.`을 `.`으로 바꿉니다. `*.local`은 `foo.local`과 최상위 이름 `local`을 직접 연결하지만 `xlocal`은 제외합니다. `169.254/16`, `169.254.0.0/16`, `fe80::/10`은 진단 메시지와 함께 생략하므로 링크 로컬 IP 주소는 프록시를 사용합니다. IP 주소와 `*`는 허용하지만 다른 CIDR, glob, 단순 호스트명 예외는 환경 변경 전에 탐색을 거부합니다. PAC/WPAD, SOCKS 전용 설정과 실행 중 변경은 자동으로 반영하지 않습니다. TUN에서 성공했다고 HTTP 프록시 경로도 정상이라는 뜻은 아닙니다. 명령과 상태값은 [네트워크 진단(영문)](/reference/configuration/server/#codex-quota-network-diagnostics)에서 확인하세요. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index b32bd4d3cbd..83362a18a21 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -12,7 +12,7 @@ runs helper features around provider requests. | --- | --- | --- | --- | | `port` | `number` | `10100` | Proxy listen port. | | `hostname?` | `string` | `"127.0.0.1"` | Bind address. A non-loopback bind requires a data-admission token, resolved from `OPENCODEX_API_AUTH_TOKEN`, then `OCX_API_TOKEN_FILE`, then the installed owner-only `service-api-token` — nothing has to be exported by hand. See [Remote access](#remote-access). | -| `proxy?` | `string` | — | Outbound HTTP(S) or SOCKS5 proxy URL (`socks5://host:port`), `${ENV_VAR}`, or `"auto"`. HTTP URLs apply to `HTTP_PROXY` / `HTTPS_PROXY` when those are unset. SOCKS5 URLs use OpenCodex's real SOCKS5 transport and are also exposed through `ALL_PROXY` (`ocx start --socks5`); inherited `HTTP(S)_PROXY` is cleared in this process. Loopback stays in `NO_PROXY`. `"auto"` reads Windows WinINET or macOS static HTTP/HTTPS settings once at startup. Inherited HTTP(S) proxy variables skip discovery; on macOS, inherited `ALL_PROXY`/`all_proxy` also skips it. Windows keeps separate `http=` and `https=` entries; a bare `ProxyServer` applies to both. macOS translates only IP-literal and `*` exceptions into the effective bypass variables; other exceptions refuse discovery without changing the proxy environment. Disabled, malformed, PAC/WPAD, SOCKS-only, and live changes are not followed. Restart after changing system settings. | +| `proxy?` | `string` | — | Outbound HTTP(S) or SOCKS5 proxy URL (`socks5://host:port`), `${ENV_VAR}`, or `"auto"`. HTTP URLs apply to `HTTP_PROXY` / `HTTPS_PROXY` when those are unset. SOCKS5 URLs use OpenCodex's real SOCKS5 transport and are also exposed through `ALL_PROXY` (`ocx start --socks5`); inherited `HTTP(S)_PROXY` is cleared in this process. Loopback stays in `NO_PROXY`. `"auto"` reads Windows WinINET or macOS static HTTP/HTTPS settings once at startup. Inherited HTTP(S) proxy variables skip discovery; on macOS, inherited `ALL_PROXY`/`all_proxy` also skips it. Windows keeps separate `http=` and `https=` entries; a bare `ProxyServer` applies to both. macOS translates IP literals, `*`, and a valid `*.` glob to Bun's `.` bypass. That glob also bypasses the bare apex ``. The exact link-local ranges `169.254/16`, `169.254.0.0/16`, and `fe80::/10` are omitted with a diagnostic: link-local IP literals use the proxy. Other CIDRs, globs, and simple-host exceptions refuse discovery without changing the proxy environment. Disabled, malformed, PAC/WPAD, SOCKS-only, and live changes are not followed. Restart after changing system settings. | | `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. | | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Remove optional client-facing hints from canonical Codex Responses passthrough: the two `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` response headers, `response.metadata` events whose metadata type is `safety_buffering`, and top-level `safety_buffering` fields. Other headers, response data, policy refusals and failures are preserved. This does not disable provider safety enforcement or upstream buffering. Native `codex.response.metadata.headers` WebSocket metadata and `/responses/compact` are outside this filter. | @@ -166,9 +166,12 @@ An unset `proxy` leaves inherited proxy variables unchanged. An explicit HTTP(S) proxy URL fills `HTTP_PROXY` and `HTTPS_PROXY` only where they are unset. `"proxy": "auto"` reads Windows static WinINET or macOS static HTTP/HTTPS settings once at startup. Existing proxy environment variables take precedence; -macOS discovery also skips inherited `ALL_PROXY`/`all_proxy`. macOS exceptions -must all be safely expressible as IP literals or `*`; otherwise discovery -refuses without changing proxy variables. Auto discovery does not resolve +macOS discovery also skips inherited `ALL_PROXY`/`all_proxy`. A macOS `*.` +exception becomes `.`: `foo.local` bypasses for `*.local`, `xlocal` +does not, and the bare `local` apex also bypasses. Exact link-local CIDRs +are dropped with a warning, so link-local IP literals use the proxy. Other +unrepresentable exceptions refuse discovery without changing proxy variables. +Auto discovery does not resolve PAC/WPAD, SOCKS-only settings or live proxy changes. Use a supported static HTTP proxy setting or an explicit HTTP(S) proxy URL when needed. diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 9b14ff19d7d..2a4dcb20c68 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -232,6 +232,6 @@ opencodex. Перед использованием прогоните soak-test ## Сетевая диагностика квоты Codex -Поле `quotaRefresh` в строке основного аккаунта Codex описывает получение квоты, а не её остаток или право доступа к модели. Оно может отсутствовать при чтении кэша или если запрос не выполнялся. Используется окружение работающего прокси-сервиса, а не текущего терминала. Если `proxy` не задан, существующее окружение сохраняется; `"auto"` при запуске читает статические настройки HTTP/HTTPS Windows или macOS. На macOS унаследованный прокси отменяет это чтение. На macOS исключение, отличное от IP-адреса или `*`, отменяет обнаружение без изменения окружения. PAC/WPAD, настройки только SOCKS и изменения во время работы автоматически не учитываются. Успех через TUN сам по себе не подтверждает исправность пути HTTP-прокси. См. [команды и состояния на английском](/reference/configuration/server/#codex-quota-network-diagnostics). +Поле `quotaRefresh` в строке основного аккаунта Codex описывает получение квоты, а не её остаток или право доступа к модели. Оно может отсутствовать при чтении кэша или если запрос не выполнялся. Используется окружение работающего прокси-сервиса, а не текущего терминала. Если `proxy` не задан, существующее окружение сохраняется; `"auto"` при запуске читает статические настройки HTTP/HTTPS Windows или macOS. На macOS унаследованный прокси отменяет это чтение. На macOS допустимый шаблон `*.` преобразуется в `.`: для `*.local` прямое соединение получают `foo.local` и само имя `local`, но не `xlocal`. Точные диапазоны `169.254/16`, `169.254.0.0/16` и `fe80::/10` пропускаются с диагностикой: link-local IP-адреса используют прокси. IP-адреса и `*` принимаются; прочие CIDR, glob-шаблоны и исключения простых имён отменяют обнаружение без изменения окружения. PAC/WPAD, настройки только SOCKS и изменения во время работы автоматически не учитываются. Успех через TUN сам по себе не подтверждает исправность пути HTTP-прокси. См. [команды и состояния на английском](/reference/configuration/server/#codex-quota-network-diagnostics). `dropCodexSafetyBuffering`: не меняет проверки безопасности провайдера или отказы. Native WebSocket `codex.response.metadata.headers` и `/responses/compact` не входят в область фильтра. diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index dca859b79e9..a4d69c1f4a0 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -304,4 +304,4 @@ yeniden kullanır. Hedeflenen hesap ve iş yükünü kapsamlı bir şekilde test ## Codex kota ağı tanılaması -Ana Codex hesabının satırındaki `quotaRefresh`, kalan kotayı veya model erişim yetkisini değil, kota sorgusunun sonucunu açıklar. Önbellek kullanıldığında ya da sorgu yapılmadığında alan bulunmayabilir. Sorgu, etkileşimli terminalin değil çalışan proxy servisinin ortamını kullanır. `proxy` ayarlanmazsa mevcut ortam korunur; `"auto"` başlangıçta Windows veya macOS statik HTTP/HTTPS ayarlarını okur. macOS üzerinde devralınmış proxy varsa bu ayarlar okunmaz. macOS istisnası IP adresi veya `*` değilse ortamı değiştirmeden keşfi reddeder. PAC/WPAD, yalnızca SOCKS ayarları ve çalışma sırasındaki değişiklikler otomatik uygulanmaz. TUN ile başarı, HTTP proxy yolunun da çalıştığını tek başına göstermez. [Komutlar ve durumlar için İngilizce bölüme](/reference/configuration/server/#codex-quota-network-diagnostics) bakın. +Ana Codex hesabının satırındaki `quotaRefresh`, kalan kotayı veya model erişim yetkisini değil, kota sorgusunun sonucunu açıklar. Önbellek kullanıldığında ya da sorgu yapılmadığında alan bulunmayabilir. Sorgu, etkileşimli terminalin değil çalışan proxy servisinin ortamını kullanır. `proxy` ayarlanmazsa mevcut ortam korunur; `"auto"` başlangıçta Windows veya macOS statik HTTP/HTTPS ayarlarını okur. macOS üzerinde devralınmış proxy varsa bu ayarlar okunmaz. macOS üzerinde geçerli `*.` kalıbı `.` olur: `*.local` için `foo.local` ve yalın `local` doğrudan gider, `xlocal` gitmez. Tam `169.254/16`, `169.254.0.0/16` ve `fe80::/10` aralıkları bir tanıyla atlanır; link-local IP adresleri proxy kullanır. IP adresleri ve `*` kabul edilir; diğer CIDR, glob ve yalın ana makine istisnaları ortam değiştirilmeden keşfi reddeder. PAC/WPAD, yalnızca SOCKS ayarları ve çalışma sırasındaki değişiklikler otomatik uygulanmaz. TUN ile başarı, HTTP proxy yolunun da çalıştığını tek başına göstermez. [Komutlar ve durumlar için İngilizce bölüme](/reference/configuration/server/#codex-quota-network-diagnostics) bakın. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 2bd458fb5fa..1fc4ee8ccc7 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -198,6 +198,6 @@ Anthropic OAuth 侧车会复用 opencodex 现有的 Claude Code OAuth 指纹。 ## Codex 额度网络诊断 -主 Codex 账户行中的 `quotaRefresh` 描述额度查询结果,并不代表剩余额度或模型访问权限。读取缓存或未执行查询时,该字段可能省略。查询使用正在运行的代理服务的环境,而不是当前终端的环境。未设置 `proxy` 时保留现有环境;`"auto"` 在启动时读取 Windows 或 macOS 静态 HTTP/HTTPS 设置;macOS 上若有继承代理则跳过读取。macOS 例外项若不是 IP 地址或 `*`,则拒绝自动发现且不修改环境。不自动处理 PAC/WPAD、仅 SOCKS 的设置或运行中的更改。TUN 测试成功并不能单独证明 HTTP 代理路径正常。命令和状态说明见[英文网络诊断章节](/reference/configuration/server/#codex-quota-network-diagnostics)。 +主 Codex 账户行中的 `quotaRefresh` 描述额度查询结果,并不代表剩余额度或模型访问权限。读取缓存或未执行查询时,该字段可能省略。查询使用正在运行的代理服务的环境,而不是当前终端的环境。未设置 `proxy` 时保留现有环境;`"auto"` 在启动时读取 Windows 或 macOS 静态 HTTP/HTTPS 设置;macOS 上若有继承代理则跳过读取。macOS 将有效的 `*.` 转为 `.`:`*.local` 使 `foo.local` 和裸域名 `local` 直连,但不匹配 `xlocal`。精确的 `169.254/16`、`169.254.0.0/16`、`fe80::/10` 网段会跳过并给出诊断,因此链路本地 IP 地址使用代理。IP 地址和 `*` 仍可用;其他 CIDR、通配形式和简单主机名例外会在修改环境前拒绝自动发现。不自动处理 PAC/WPAD、仅 SOCKS 的设置或运行中的更改。TUN 测试成功并不能单独证明 HTTP 代理路径正常。命令和状态说明见[英文网络诊断章节](/reference/configuration/server/#codex-quota-network-diagnostics)。 `dropCodexSafetyBuffering`: 不会改变供应商安全策略或拒绝响应。原生 WebSocket `codex.response.metadata.headers` 和 `/responses/compact` 不在过滤范围内。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index a9defff5ae6..ee1b7e72c77 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -217,4 +217,4 @@ Anthropic OAuth sidecar 重用 opencodex 既有的 Claude Code OAuth 指紋。 ## Codex 配額網路診斷 -主 Codex 帳戶列中的 `quotaRefresh` 描述配額查詢結果,並不代表剩餘配額或模型存取權限。讀取快取或未執行查詢時,這個欄位可能省略。查詢使用執行中代理服務的環境,而不是目前終端機的環境。未設定 `proxy` 時保留既有環境;`"auto"` 在啟動時讀取 Windows 或 macOS 靜態 HTTP/HTTPS 設定;macOS 上若有繼承代理則略過讀取。macOS 例外項若不是 IP 位址或 `*`,就會拒絕自動探索且不修改環境。不會自動處理 PAC/WPAD、僅 SOCKS 的設定或執行中的變更。TUN 測試成功本身不能證明 HTTP 代理路徑正常。命令與狀態說明請見[英文網路診斷章節](/reference/configuration/server/#codex-quota-network-diagnostics)。 +主 Codex 帳戶列中的 `quotaRefresh` 描述配額查詢結果,並不代表剩餘配額或模型存取權限。讀取快取或未執行查詢時,這個欄位可能省略。查詢使用執行中代理服務的環境,而不是目前終端機的環境。未設定 `proxy` 時保留既有環境;`"auto"` 在啟動時讀取 Windows 或 macOS 靜態 HTTP/HTTPS 設定;macOS 上若有繼承代理則略過讀取。macOS 會將有效的 `*.` 轉成 `.`:`*.local` 讓 `foo.local` 與裸網域 `local` 直連,但不比對 `xlocal`。精確的 `169.254/16`、`169.254.0.0/16`、`fe80::/10` 網段會略過並顯示診斷,因此鏈路本機 IP 位址使用代理。IP 位址與 `*` 仍可使用;其他 CIDR、萬用字元形式及簡單主機名稱例外會在修改環境前拒絕自動探索。不會自動處理 PAC/WPAD、僅 SOCKS 的設定或執行中的變更。TUN 測試成功本身不能證明 HTTP 代理路徑正常。命令與狀態說明請見[英文網路診斷章節](/reference/configuration/server/#codex-quota-network-diagnostics)。 diff --git a/src/config/macos-system-proxy.ts b/src/config/macos-system-proxy.ts index 69580b3d726..c54c3938f7f 100644 --- a/src/config/macos-system-proxy.ts +++ b/src/config/macos-system-proxy.ts @@ -3,7 +3,7 @@ import { isIP } from "node:net"; export type MacOSProxyReader = () => string | null; export type MacOSSystemProxyResult = - | { kind: "proxy"; httpUrl?: string; httpsUrl?: string; exceptions: string[] } + | { kind: "proxy"; httpUrl?: string; httpsUrl?: string; exceptions: string[]; droppedLinkLocal: boolean } | { kind: "disabled" | "unreadable" | "unsafe-exceptions" }; function readScutilProxy(): string { @@ -26,12 +26,24 @@ function proxyUrl(host: string | undefined, port: string | undefined): string | } } -// Bun's no_proxy parser matches names by suffix, even bare "localhost". The only -// macOS exceptions we can translate without changing their scope are IP literals -// and the all-host wildcard. CIDR, domain glob, and simple-host rules must refuse -// discovery rather than quietly proxy a host macOS intended to send direct. -function translateException(value: string): string | undefined { +// Bun matches a leading-dot entry at DNS-label boundaries and also bypasses the +// bare apex. Translating "*.local" to ".local" therefore widens only to "local"; +// other glob shapes are refused. Bun cannot represent the default link-local +// CIDRs, so they are dropped with a diagnostic instead of blocking discovery. +// null means one of those exact ranges was dropped; undefined refuses discovery. +function translateException(value: string): string | null | undefined { if (value === "*") return value; + if (value === "169.254/16" || value === "169.254.0.0/16") return null; + const ipv6Range = /^(?:\[([0-9a-f:]+)\]|([0-9a-f:]+))\/10$/i.exec(value); + const ipv6Base = ipv6Range?.[1] ?? ipv6Range?.[2]; + if (ipv6Base && isIP(ipv6Base) === 6 + && new URL(`http://[${ipv6Base}]`).hostname === "[fe80::]") return null; + if (value.startsWith("*.")) { + const domain = value.slice(2); + if (domain.length > 253 || !domain.split(".").every(label => label.length <= 63 + && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label))) return undefined; + return `.${domain.toLowerCase()}`; + } if (isIP(value) === 4) { const canonical = new URL(`http://${value}`).hostname; return value === canonical ? value : undefined; @@ -92,7 +104,9 @@ export function readMacOSSystemProxy(reader: MacOSProxyReader = readScutilProxy) const httpsUrl = httpsEnabled ? proxyUrl(values.get("HTTPSProxy"), values.get("HTTPSPort")) : undefined; if ((httpEnabled && !httpUrl) || (httpsEnabled && !httpsUrl)) return { kind: "unreadable" }; return httpUrl || httpsUrl - ? { kind: "proxy", httpUrl, httpsUrl, exceptions: translated.filter((value): value is string => value !== undefined) } + ? { kind: "proxy", httpUrl, httpsUrl, + exceptions: translated.filter((value): value is string => typeof value === "string"), + droppedLinkLocal: translated.includes(null) } : { kind: "disabled" }; } catch { return { kind: "unreadable" }; diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index 1e6070939bd..415d84842fc 100644 --- a/src/config/proxy-env.ts +++ b/src/config/proxy-env.ts @@ -236,6 +236,9 @@ export function applyProxyEnvWith( found.httpsUrl && `HTTPS ${describeProxyForLog(found.httpsUrl)}`, ].filter(Boolean).join(", "); console.log(`[opencodex] proxy "auto": using macOS system proxy ${origins}`); + if (found.droppedLinkLocal) { + console.log('[opencodex] proxy "auto": link-local IP literals use the proxy; macOS link-local range exceptions are not expressible'); + } if (found.httpUrl) process.env.HTTP_PROXY = found.httpUrl; if (found.httpsUrl) process.env.HTTPS_PROXY = found.httpsUrl; // Bun gives non-empty lowercase no_proxy priority over NO_PROXY. Add the diff --git a/src/types/config.ts b/src/types/config.ts index 872a43fe8cd..b49f0004a7d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -885,7 +885,9 @@ export interface OcxConfig { * Loopback stays in NO_PROXY. * The literal `"auto"` reads Windows WinINET or macOS static HTTP/HTTPS proxy settings * once at startup. Inherited scheme proxies win; on macOS, inherited ALL_PROXY also skips - * discovery, and unsafe system exceptions refuse discovery without environment writes. + * discovery. A macOS `*.` exception maps to `.` (including the apex), + * exact link-local CIDRs are omitted with a warning, and other unsafe exceptions + * refuse discovery without environment writes. * PAC/WPAD, SOCKS-only settings, and live changes are not followed. */ proxy?: string; diff --git a/structure/config-proxy.md b/structure/config-proxy.md index ef6886e83a8..a3507fd3b57 100644 --- a/structure/config-proxy.md +++ b/structure/config-proxy.md @@ -15,7 +15,7 @@ matcher treats a bare `localhost` or IP-literal entry as one host, never a suffi `ALL_PROXY` and `all_proxy` provide SOCKS and HTTP(S) together, the SOCKS wrapper forces an exact `localhost` request direct while keeping the address-only environment bypass. An inherited non-empty lowercase `no_proxy`, which Bun fetch reads first with suffix matching, receives only the loopback -addresses, never a name it would match as a suffix. When the +addresses from the shared path; macOS auto-discovery adds its translated exceptions separately. When the environment no longer selects SOCKS, activation restores the native fetch; removing a saved field alone does not erase inherited process environment variables. @@ -34,12 +34,18 @@ redaction by the normalized final path segment, matching lookup and mutation sem On macOS, `src/config/macos-system-proxy.ts` reads the top-level static HTTP/HTTPS settings from `/usr/sbin/scutil --proxy` once, with a timeout and output bound. -Only enabled schemes are installed. PAC/WPAD, simple-host bypasses, malformed -settings, and exception patterns whose semantics cannot be represented safely -refuse discovery before any proxy-environment write. Only IP literals and the -all-host `*` exception are translated. Accepted exceptions enter both `NO_PROXY` -and an inherited non-empty `no_proxy`, since Bun gives lowercase precedence; -only loopback addresses are appended, never the bare `localhost` suffix. +Only enabled schemes are installed. IP literals and the all-host `*` exception +are translated. A single leading `*.` followed by a valid DNS name maps to +`.`; Bun matches at label boundaries, so `foo.local` bypasses for +`*.local` while `xlocal` does not. Bun also bypasses the bare apex `local`, +the one widening of that translation. The exact link-local ranges +`169.254/16`, `169.254.0.0/16`, and `fe80::/10` are omitted because Bun +cannot represent them; one generic diagnostic says link-local IP literals +use the proxy. Other CIDRs or glob forms, simple-host bypasses, PAC/WPAD, +and malformed settings refuse discovery before any proxy-environment write. +Accepted exceptions enter both `NO_PROXY` and an inherited non-empty +`no_proxy`, since Bun gives lowercase precedence. For loopback, only addresses +are appended, never the bare `localhost` suffix. Inherited SOCKS routes keep their existing uppercase bypass semantics and do not receive macOS exceptions. The diagnostic reports a category, never raw settings or credential-bearing URLs. Regression cases live in diff --git a/tests/server/proxy-env-macos.test.ts b/tests/server/proxy-env-macos.test.ts index 96eef60045a..58f89f6ffe1 100644 --- a/tests/server/proxy-env-macos.test.ts +++ b/tests/server/proxy-env-macos.test.ts @@ -50,6 +50,44 @@ describe('macOS proxy: "auto" (#5853)', () => { expect(process.env.no_proxy?.split(",")).toContain("*"); }); + test("default macOS exceptions activate .local without routing link-local literals direct", () => { + process.env.NO_PROXY = "upper.example"; + process.env.no_proxy = "lower.example"; + const lines: string[] = []; + const original = console.log; + console.log = (...args) => { lines.push(args.join(" ")); }; + try { + applyProxyEnvWith(config("auto"), { + platform: "darwin", + macOSReader: () => scutil(`${both}\nExceptionsList : {\n0 : *.local\n1 : 169.254/16\n}`), + }); + } finally { console.log = original; } + expect(process.env.HTTP_PROXY).toBe("http://proxy.example:8080"); + expect(process.env.NO_PROXY?.split(",")).toContain(".local"); + expect(process.env.no_proxy?.split(",")).toContain(".local"); + expect(process.env.NO_PROXY).not.toContain("169.254/16"); + expect(process.env.no_proxy).not.toContain("169.254/16"); + expect(lines.filter(line => line.includes("link-local"))).toHaveLength(1); + expect(lines.join(" ")).not.toContain("169.254/16"); + for (const hostname of ["foo.local", "a.b.local", "local"]) { + const url = new URL(`http://${hostname}/`); + expect(noProxyMatches(url, { no_proxy: process.env.no_proxy })).toBe(true); + expect(resolveProxyRoute(new URL(`ws://${hostname}/`))).toEqual({ kind: "direct" }); + } + for (const hostname of ["xlocal", "169.254.1.2"]) { + const url = new URL(`http://${hostname}/`); + expect(noProxyMatches(url, { no_proxy: process.env.no_proxy })).toBe(false); + expect(resolveProxyRoute(new URL(`ws://${hostname}/`))).toEqual({ kind: "proxy", proxy: process.env.HTTP_PROXY }); + } + }); + + test.each(["169.254/16", "169.254.0.0/16", "fe80::/10", "FE80:0:0:0:0:0:0:0/10", "[fe80::]/10"])( + "drops only the exact link-local range %s", exception => { + expect(readMacOSSystemProxy(() => scutil(`${both}\nExceptionsList : {\n0 : ${exception}\n}`))) + .toEqual({ kind: "proxy", httpUrl: "http://proxy.example:8080", httpsUrl: "http://[::1]:8443", exceptions: [], droppedLinkLocal: true }); + }, + ); + test.each(["HTTP", "HTTPS"])("preserves %s-only settings", scheme => { applyProxyEnvWith(config(" AUTO "), { platform: "darwin", @@ -60,7 +98,8 @@ describe('macOS proxy: "auto" (#5853)', () => { }); test.each([ - "localhost", "*.local", "169.254/16", "example.com", "bad entry", + "localhost", "example.com", "bad entry", "10.0.0.0/8", "169.254.0.0/15", + "fe80::/9", "fe80::1/10", "*.*.local", "foo*.local", "*.bad_name", "*.", ])("refuses an unrepresentable exception %s without any environment write", exception => { process.env.NO_PROXY = "upper.example"; process.env.no_proxy = "lower.example"; From e1a81eeb6989d2ceb4a6f15ac30472df7dcaba12 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 01:20:21 +0900 Subject: [PATCH 4/6] fix(proxy): preserve configured macOS auto bypass in lowercase env When macOS auto installs a previously absent proxy, copy configured noProxy entries into an inherited non-empty lowercase no_proxy so Bun and the WebSocket route keep configured destinations direct. Co-authored-by: codingbo <9621077+codingbooo@users.noreply.github.com> --- src/config/proxy-env.ts | 13 ++++++++----- structure/config-proxy.md | 10 +++++++--- tests/server/proxy-env-macos.test.ts | 15 ++++++++++++--- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index 415d84842fc..47f300c61dc 100644 --- a/src/config/proxy-env.ts +++ b/src/config/proxy-env.ts @@ -241,12 +241,15 @@ export function applyProxyEnvWith( } if (found.httpUrl) process.env.HTTP_PROXY = found.httpUrl; if (found.httpsUrl) process.env.HTTPS_PROXY = found.httpsUrl; - // Bun gives non-empty lowercase no_proxy priority over NO_PROXY. Add the - // proven-safe system exceptions to both; keep name-based loopback out of - // the suffix matcher on both paths. - mergeNoProxyEntries([...configuredNoProxyEntries(config), ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY); + // Bun gives non-empty lowercase no_proxy priority over NO_PROXY. Before + // discovery there was no proxy, so suffix matching of a configured name + // can only keep that name and its subdomains on their prior direct route. + // Add configured entries and system exceptions to both effective paths; + // keep name-based loopback out of the suffix matcher. + const configured = configuredNoProxyEntries(config); + mergeNoProxyEntries([...configured, ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY); if (process.env.no_proxy?.trim()) { - process.env.no_proxy = withNoProxyEntries(process.env.no_proxy, found.exceptions, LOOPBACK_ADDRESS_NO_PROXY); + process.env.no_proxy = withNoProxyEntries(process.env.no_proxy, [...configured, ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY); } configureSocks5Fetch(); return; diff --git a/structure/config-proxy.md b/structure/config-proxy.md index a3507fd3b57..3cd1e033ddd 100644 --- a/structure/config-proxy.md +++ b/structure/config-proxy.md @@ -43,9 +43,13 @@ the one widening of that translation. The exact link-local ranges cannot represent them; one generic diagnostic says link-local IP literals use the proxy. Other CIDRs or glob forms, simple-host bypasses, PAC/WPAD, and malformed settings refuse discovery before any proxy-environment write. -Accepted exceptions enter both `NO_PROXY` and an inherited non-empty -`no_proxy`, since Bun gives lowercase precedence. For loopback, only addresses -are appended, never the bare `localhost` suffix. +Accepted exceptions and configured `noProxy` entries enter both `NO_PROXY` +and an inherited non-empty `no_proxy`, since Bun gives lowercase precedence. +Before macOS discovery there is no inherited proxy, so Bun's suffix matching +of a configured name can only keep that name and its subdomains on their +pre-discovery direct route; it cannot move a host onto the proxy. This applies +only to macOS discovery, not inherited or explicit proxy activation. For +loopback, only addresses are appended, never the bare `localhost` suffix. Inherited SOCKS routes keep their existing uppercase bypass semantics and do not receive macOS exceptions. The diagnostic reports a category, never raw settings or credential-bearing URLs. Regression cases live in diff --git a/tests/server/proxy-env-macos.test.ts b/tests/server/proxy-env-macos.test.ts index 58f89f6ffe1..059f724f8e1 100644 --- a/tests/server/proxy-env-macos.test.ts +++ b/tests/server/proxy-env-macos.test.ts @@ -27,14 +27,23 @@ describe('macOS proxy: "auto" (#5853)', () => { test("enabled schemes and safe IP exceptions reach Bun's lowercase bypass", () => { process.env.NO_PROXY = "upper.example"; process.env.no_proxy = "lower.example"; - applyProxyEnvWith(config("auto", "configured.example"), { + applyProxyEnvWith(config("auto", "private.example"), { platform: "darwin", macOSReader: () => scutil(`${both}\nExceptionsList : {\n0 : 203.0.113.7\n1 : ::1\n}`), }); expect(process.env.HTTP_PROXY).toBe("http://proxy.example:8080"); expect(process.env.HTTPS_PROXY).toBe("http://[::1]:8443"); - expect(process.env.NO_PROXY).toBe("upper.example,configured.example,203.0.113.7,[::1],127.0.0.1,::1"); - expect(process.env.no_proxy).toBe("lower.example,127.0.0.1,::1,[::1],203.0.113.7"); + expect(process.env.NO_PROXY).toBe("upper.example,private.example,203.0.113.7,[::1],127.0.0.1,::1"); + expect(process.env.no_proxy).toBe("lower.example,127.0.0.1,::1,[::1],private.example,203.0.113.7"); + for (const hostname of ["private.example", "child.private.example"]) { + const url = new URL(`https://${hostname}/`); + expect(noProxyMatches(url, { no_proxy: process.env.no_proxy })).toBe(true); + expect(resolveProxyRoute(new URL(`wss://${hostname}/`))).toEqual({ kind: "direct" }); + } + const unrelated = new URL("https://unrelated.example/"); + expect(noProxyMatches(unrelated, { no_proxy: process.env.no_proxy })).toBe(false); + expect(resolveProxyRoute(new URL("wss://unrelated.example/"))) + .toEqual({ kind: "proxy", proxy: "http://[::1]:8443" }); expect(noProxyMatches(new URL("http://203.0.113.7"), { no_proxy: process.env.no_proxy })).toBe(true); expect(noProxyMatches(new URL("http://203.0.113.70"), { no_proxy: process.env.no_proxy })).toBe(false); expect(resolveProxyRoute(new URL("https://example.org"))).toEqual({ kind: "proxy", proxy: "http://[::1]:8443" }); From 1fc688fe4b921b7230bd3c16e7e9e5dec7b67777 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 01:24:37 +0900 Subject: [PATCH 5/6] fix(proxy): keep bare localhost out of macOS lowercase bypass Preserve configured localhost in uppercase NO_PROXY for the exact-host WebSocket route, while leaving it out of Bun lowercase no_proxy so app.localhost remains proxied. Co-authored-by: codingbo <9621077+codingbooo@users.noreply.github.com> --- src/config/proxy-env.ts | 8 +++++--- structure/config-proxy.md | 12 ++++++++---- tests/server/proxy-env-macos.test.ts | 21 ++++++++++++++++++++- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index 47f300c61dc..49096aa96ca 100644 --- a/src/config/proxy-env.ts +++ b/src/config/proxy-env.ts @@ -244,12 +244,14 @@ export function applyProxyEnvWith( // Bun gives non-empty lowercase no_proxy priority over NO_PROXY. Before // discovery there was no proxy, so suffix matching of a configured name // can only keep that name and its subdomains on their prior direct route. - // Add configured entries and system exceptions to both effective paths; - // keep name-based loopback out of the suffix matcher. + // Add configured entries and system exceptions to both effective paths, + // except bare localhost: Bun would match app.localhost as a suffix while + // the WebSocket matcher treats that name as exact. const configured = configuredNoProxyEntries(config); mergeNoProxyEntries([...configured, ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY); if (process.env.no_proxy?.trim()) { - process.env.no_proxy = withNoProxyEntries(process.env.no_proxy, [...configured, ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY); + const bunConfigured = configured.filter(host => !/^localhost\.?$/i.test(host)); + process.env.no_proxy = withNoProxyEntries(process.env.no_proxy, [...bunConfigured, ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY); } configureSocks5Fetch(); return; diff --git a/structure/config-proxy.md b/structure/config-proxy.md index 3cd1e033ddd..e0b2f2b07aa 100644 --- a/structure/config-proxy.md +++ b/structure/config-proxy.md @@ -46,10 +46,14 @@ and malformed settings refuse discovery before any proxy-environment write. Accepted exceptions and configured `noProxy` entries enter both `NO_PROXY` and an inherited non-empty `no_proxy`, since Bun gives lowercase precedence. Before macOS discovery there is no inherited proxy, so Bun's suffix matching -of a configured name can only keep that name and its subdomains on their -pre-discovery direct route; it cannot move a host onto the proxy. This applies -only to macOS discovery, not inherited or explicit proxy activation. For -loopback, only addresses are appended, never the bare `localhost` suffix. +of an ordinary configured name can only keep that name and its subdomains on +their pre-discovery direct route; it cannot move a host onto the proxy. Bare +`localhost` (case-insensitive, with or without a trailing dot) stays in +uppercase `NO_PROXY` but is excluded from additions to lowercase `no_proxy`: +Bun would bypass `app.localhost` as a suffix while the WebSocket matcher +treats `localhost` as exact. This applies only to macOS discovery, not +inherited or explicit proxy activation. For loopback, only addresses are +appended, never the bare `localhost` suffix. Inherited SOCKS routes keep their existing uppercase bypass semantics and do not receive macOS exceptions. The diagnostic reports a category, never raw settings or credential-bearing URLs. Regression cases live in diff --git a/tests/server/proxy-env-macos.test.ts b/tests/server/proxy-env-macos.test.ts index 059f724f8e1..62d5896447e 100644 --- a/tests/server/proxy-env-macos.test.ts +++ b/tests/server/proxy-env-macos.test.ts @@ -6,7 +6,7 @@ import type { OcxConfig } from "../../src/types"; const KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"] as const; let saved: Record; -const config = (proxy?: string, noProxy?: string): OcxConfig => ({ proxy, noProxy, providers: {} }) as OcxConfig; +const config = (proxy?: string, noProxy?: string | string[]): OcxConfig => ({ proxy, noProxy, providers: {} }) as OcxConfig; const scutil = (body: string): string => ` {\n${body}\n}`; const both = "HTTPEnable : 1\nHTTPProxy : proxy.example\nHTTPPort : 8080\nHTTPSEnable : 1\nHTTPSProxy : ::1\nHTTPSPort : 8443"; const snapshot = (): Record => Object.fromEntries(KEYS.map(key => [key, process.env[key]])); @@ -49,6 +49,25 @@ describe('macOS proxy: "auto" (#5853)', () => { expect(resolveProxyRoute(new URL("https://example.org"))).toEqual({ kind: "proxy", proxy: "http://[::1]:8443" }); }); + test.each(["localhost", "LOCALHOST", "LoCaLhOsT."])( + "configured %s stays uppercase without widening Bun's lowercase bypass", localhost => { + process.env.no_proxy = "lower.example"; + applyProxyEnvWith(config("auto", [localhost, "private.example"]), { + platform: "darwin", macOSReader: () => scutil(both), + }); + expect(process.env.NO_PROXY?.split(",")).toContain(localhost); + expect(process.env.NO_PROXY?.split(",")).toContain("private.example"); + expect(process.env.no_proxy?.split(",")).toContain("private.example"); + expect(process.env.no_proxy?.split(",").some(entry => /^localhost\.?$/i.test(entry))).toBe(false); + const app = new URL("http://app.localhost/"); + expect(noProxyMatches(app, { no_proxy: process.env.no_proxy })).toBe(false); + expect(resolveProxyRoute(new URL("ws://app.localhost/"))) + .toEqual({ kind: "proxy", proxy: "http://proxy.example:8080" }); + expect(noProxyMatches(new URL("http://private.example/"), { no_proxy: process.env.no_proxy })).toBe(true); + expect(resolveProxyRoute(new URL("ws://private.example/"))).toEqual({ kind: "direct" }); + }, + ); + test("the all-host wildcard has the same bypass scope on both transports", () => { process.env.no_proxy = "lower.example"; applyProxyEnvWith(config("auto"), { From 001b83317b98c7a14837f91af9d102cfc6d388d0 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 01:28:44 +0900 Subject: [PATCH 6/6] fix(proxy): refuse unrepresentable localhost with inherited lowercase bypass When macOS auto discovers a proxy, a configured exact localhost bypass cannot be preserved by non-empty inherited lowercase no_proxy. Refuse before writing proxy variables and retain the existing uppercase-only path when lowercase is absent. Co-authored-by: codingbo <9621077+codingbooo@users.noreply.github.com> --- src/config/proxy-env.ts | 18 ++++++------ structure/config-proxy.md | 16 ++++++----- tests/server/proxy-env-macos.test.ts | 41 ++++++++++++++++++---------- 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index 49096aa96ca..e83f740b3d2 100644 --- a/src/config/proxy-env.ts +++ b/src/config/proxy-env.ts @@ -231,6 +231,12 @@ export function applyProxyEnvWith( console.log(`[opencodex] proxy "auto": ${reason}; proxy environment unchanged`); return; } + const configured = configuredNoProxyEntries(config); + const inheritedLowercase = process.env.no_proxy?.trim(); + if (inheritedLowercase && configured.some(host => /^localhost\.?$/i.test(host))) { + console.log('[opencodex] proxy "auto": configured noProxy "localhost" cannot be represented exactly for Bun while an inherited no_proxy is set; discovery refused'); + return; + } const origins = [ found.httpUrl && `HTTP ${describeProxyForLog(found.httpUrl)}`, found.httpsUrl && `HTTPS ${describeProxyForLog(found.httpsUrl)}`, @@ -242,16 +248,12 @@ export function applyProxyEnvWith( if (found.httpUrl) process.env.HTTP_PROXY = found.httpUrl; if (found.httpsUrl) process.env.HTTPS_PROXY = found.httpsUrl; // Bun gives non-empty lowercase no_proxy priority over NO_PROXY. Before - // discovery there was no proxy, so suffix matching of a configured name - // can only keep that name and its subdomains on their prior direct route. - // Add configured entries and system exceptions to both effective paths, - // except bare localhost: Bun would match app.localhost as a suffix while - // the WebSocket matcher treats that name as exact. - const configured = configuredNoProxyEntries(config); + // discovery there was no proxy, so an ordinary configured name and its + // subdomains can stay direct in both paths. Bare localhost is refused + // above when lowercase is inherited: Bun cannot match it exactly there. mergeNoProxyEntries([...configured, ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY); if (process.env.no_proxy?.trim()) { - const bunConfigured = configured.filter(host => !/^localhost\.?$/i.test(host)); - process.env.no_proxy = withNoProxyEntries(process.env.no_proxy, [...bunConfigured, ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY); + process.env.no_proxy = withNoProxyEntries(process.env.no_proxy, [...configured, ...found.exceptions], LOOPBACK_ADDRESS_NO_PROXY); } configureSocks5Fetch(); return; diff --git a/structure/config-proxy.md b/structure/config-proxy.md index e0b2f2b07aa..bf344981509 100644 --- a/structure/config-proxy.md +++ b/structure/config-proxy.md @@ -47,13 +47,15 @@ Accepted exceptions and configured `noProxy` entries enter both `NO_PROXY` and an inherited non-empty `no_proxy`, since Bun gives lowercase precedence. Before macOS discovery there is no inherited proxy, so Bun's suffix matching of an ordinary configured name can only keep that name and its subdomains on -their pre-discovery direct route; it cannot move a host onto the proxy. Bare -`localhost` (case-insensitive, with or without a trailing dot) stays in -uppercase `NO_PROXY` but is excluded from additions to lowercase `no_proxy`: -Bun would bypass `app.localhost` as a suffix while the WebSocket matcher -treats `localhost` as exact. This applies only to macOS discovery, not -inherited or explicit proxy activation. For loopback, only addresses are -appended, never the bare `localhost` suffix. +their pre-discovery direct route; it cannot move a host onto the proxy. When +an inherited non-empty lowercase `no_proxy` exists and configured `noProxy` +contains bare `localhost` (any case, with or without a trailing dot), discovery +refuses before any environment write. Bun cannot represent that exact-host +bypass in lowercase: adding it would also bypass `app.localhost`, while omitting +it would send exact `localhost` through the new proxy. Without inherited +lowercase `no_proxy`, the existing uppercase-only merge remains. This applies +only to macOS discovery, not inherited or explicit proxy activation. For +loopback, only addresses are appended, never an automatic bare `localhost` suffix. Inherited SOCKS routes keep their existing uppercase bypass semantics and do not receive macOS exceptions. The diagnostic reports a category, never raw settings or credential-bearing URLs. Regression cases live in diff --git a/tests/server/proxy-env-macos.test.ts b/tests/server/proxy-env-macos.test.ts index 62d5896447e..500ea93ca18 100644 --- a/tests/server/proxy-env-macos.test.ts +++ b/tests/server/proxy-env-macos.test.ts @@ -50,24 +50,37 @@ describe('macOS proxy: "auto" (#5853)', () => { }); test.each(["localhost", "LOCALHOST", "LoCaLhOsT."])( - "configured %s stays uppercase without widening Bun's lowercase bypass", localhost => { + "configured %s refuses discovery with inherited lowercase bypass", localhost => { process.env.no_proxy = "lower.example"; - applyProxyEnvWith(config("auto", [localhost, "private.example"]), { - platform: "darwin", macOSReader: () => scutil(both), - }); - expect(process.env.NO_PROXY?.split(",")).toContain(localhost); - expect(process.env.NO_PROXY?.split(",")).toContain("private.example"); - expect(process.env.no_proxy?.split(",")).toContain("private.example"); - expect(process.env.no_proxy?.split(",").some(entry => /^localhost\.?$/i.test(entry))).toBe(false); - const app = new URL("http://app.localhost/"); - expect(noProxyMatches(app, { no_proxy: process.env.no_proxy })).toBe(false); - expect(resolveProxyRoute(new URL("ws://app.localhost/"))) - .toEqual({ kind: "proxy", proxy: "http://proxy.example:8080" }); - expect(noProxyMatches(new URL("http://private.example/"), { no_proxy: process.env.no_proxy })).toBe(true); - expect(resolveProxyRoute(new URL("ws://private.example/"))).toEqual({ kind: "direct" }); + process.env.NO_PROXY = "upper.example"; + const before = snapshot(); + const lines: string[] = []; + const original = console.log; + console.log = (...args) => { lines.push(args.join(" ")); }; + try { + applyProxyEnvWith(config("auto", [localhost, "private.example"]), { + platform: "darwin", macOSReader: () => scutil(both), + }); + } finally { console.log = original; } + expect(snapshot()).toEqual(before); + expect(lines.join(" ")).toContain("discovery refused"); + expect(lines.join(" ")).not.toContain("private.example"); }, ); + test("without inherited lowercase bypass, configured localhost keeps the uppercase-only route", () => { + applyProxyEnvWith(config("auto", ["localhost", "private.example"]), { + platform: "darwin", macOSReader: () => scutil(both), + }); + expect(process.env.NO_PROXY?.split(",")).toContain("localhost"); + expect(process.env.NO_PROXY?.split(",")).toContain("private.example"); + expect(process.env.no_proxy).toBeUndefined(); + expect(resolveProxyRoute(new URL("ws://localhost/"))).toEqual({ kind: "direct" }); + expect(resolveProxyRoute(new URL("ws://app.localhost/"))) + .toEqual({ kind: "proxy", proxy: "http://proxy.example:8080" }); + expect(resolveProxyRoute(new URL("ws://private.example/"))).toEqual({ kind: "direct" }); + }); + test("the all-host wildcard has the same bypass scope on both transports", () => { process.env.no_proxy = "lower.example"; applyProxyEnvWith(config("auto"), {