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..0d91ca2a90c 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 uniquement le proxy statique Windows ou macOS 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). 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..867561685b0 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 の静的プロキシ設定だけを読みます。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..261d64c5c17 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의 정적 프록시 설정만 읽습니다. 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 a9b694c93a3..efa34ae6f7c 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 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 macOS, `"auto"` reads `scutil --proxy`, maps enabled HTTP/HTTPS settings separately, and merges `ExceptionsList` into `NO_PROXY`. Existing HTTP(S) proxy environment variables skip discovery; a non-empty inherited lowercase `no_proxy` retains its precedence and receives only loopback addresses. 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. | | `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. | @@ -159,7 +159,7 @@ 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": "auto"` reads the Windows static WinINET proxy or macOS static system proxy once at startup; existing proxy environment variables take precedence. 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..a4c58305e6b 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"` читает только статические настройки прокси Windows или macOS при запуске. 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..1bb816f4c5f 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"` yalnızca başlangıçta Windows veya macOS 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. 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..5d60df350a3 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 静态代理设置,不自动处理 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..af93b8995d9 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 靜態代理設定,不會自動處理 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 new file mode 100644 index 00000000000..904aacdbeab --- /dev/null +++ b/src/config/macos-system-proxy.ts @@ -0,0 +1,65 @@ +import { execFileSync } from "node:child_process"; +import { isIP } from "node:net"; + +export type MacOSProxyReader = () => string | null; +type MacOSSystemProxyResult = + | { kind: "proxy"; httpUrl?: string; httpsUrl?: string; noProxy: string[] } + | { kind: "disabled" | "unreadable" }; + +function readScutilProxy(): string { + return execFileSync("/usr/sbin/scutil", ["--proxy"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2000, + 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 { + const url = new URL(`http://${isIP(bareHost) === 6 ? `[${bareHost}]` : host}:${port}`); + return url.origin; + } catch { + return undefined; + } +} + +/** Read only the effective top-level dictionary; scoped/supplemental proxies are not global. */ +export function readMacOSSystemProxy(reader: MacOSProxyReader = readScutilProxy): MacOSSystemProxyResult { + try { + const output = reader(); + if (!output || !/^\s*\s*\{/.test(output)) return { kind: "unreadable" }; + const values = new Map(); + const noProxy: string[] = []; + let depth = 0; + let exceptions = false; + for (const row of output.split(/\r?\n/)) { + const line = row.trim(); + if (line.endsWith("{")) { + if (depth === 1) exceptions = /^ExceptionsList\s*:\s*\s*\{$/.test(line); + depth++; + } else if (line === "}") { + if (--depth < 0) return { kind: "unreadable" }; + if (depth === 1) exceptions = false; + } else { + const entry = line.match(/^([^:]+)\s*:\s*(.*?)\s*$/); + if (!entry) continue; + if (depth === 1) values.set(entry[1]!.trim(), entry[2]!); + if (depth === 2 && exceptions && /^\d+$/.test(entry[1]!.trim())) { + const host = entry[2]!; + // Keep each exception one entry; never turn malformed output into additional bypasses. + if (host && !/[\s,{}]/.test(host)) noProxy.push(host); + } + } + } + if (depth !== 0) return { kind: "unreadable" }; + const httpUrl = values.get("HTTPEnable") === "1" ? proxyUrl(values.get("HTTPProxy"), values.get("HTTPPort")) : undefined; + const httpsUrl = values.get("HTTPSEnable") === "1" ? proxyUrl(values.get("HTTPSProxy"), values.get("HTTPSPort")) : undefined; + return httpUrl || httpsUrl ? { kind: "proxy", httpUrl, httpsUrl, noProxy } : { kind: "disabled" }; + } catch { + return { kind: "unreadable" }; + } +} diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index 09123fd81ad..1b197c4cc42 100644 --- a/src/config/proxy-env.ts +++ b/src/config/proxy-env.ts @@ -1,3 +1,4 @@ +import { readMacOSSystemProxy, type MacOSProxyReader } from "./macos-system-proxy"; import { configureSocks5Fetch, socks5ProxyFromEnv } from "../lib/proxy-env"; import { redactUrlForLog } from "../lib/redact"; import { join } from "node:path"; @@ -169,10 +170,10 @@ export function applyProxyEnv(config: OcxConfig, announce = false): void { if (outbound) console.log(` outbound proxy: ${redactUrlForLog(outbound)}`); } -/** Test seam for `proxy: "auto"`: the registry reader and platform are injectable. */ +/** Test seam for `proxy: "auto"`: the system readers 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 @@ -180,6 +181,7 @@ export function applyProxyEnvWith( // process entry point — the failure was a startup crash, not a degraded proxy. Ignore // malformed values with a privacy-safe warning instead: they cannot express a routing // intent, and refusing to start is a worse answer than starting without them. + let systemNoProxy: string[] = []; const rawProxy = config.proxy; let proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined; if (!proxy) { @@ -194,31 +196,36 @@ export function applyProxyEnvWith( return; } if (proxy.trim().toLowerCase() === "auto") { - // #1525 slice 1: one startup read of the Windows static proxy. Never copy the literal + // One startup read of the platform 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() || process.env.HTTPS_PROXY?.trim() || process.env.https_proxy?.trim()) { console.log("[opencodex] proxy \"auto\": existing HTTP_PROXY/HTTPS_PROXY environment wins; system proxy not consulted"); proxy = undefined; } else { - const found = readWindowsSystemProxy(auto.reader, auto.platform); + const platform = auto.platform ?? process.platform; + const system = platform === "darwin" ? "macOS" : "Windows"; + const found = platform === "darwin" + ? readMacOSSystemProxy(auto.macOSReader) + : { ...readWindowsSystemProxy(auto.reader, platform), noProxy: [] }; if (found.kind === "proxy") { const origins = [ found.httpUrl && `HTTP ${describeProxyForLog(found.httpUrl)}`, found.httpsUrl && `HTTPS ${describeProxyForLog(found.httpsUrl)}`, ].filter(Boolean).join(", "); - console.log(`[opencodex] proxy "auto": using Windows system proxy ${origins}`); + console.log(`[opencodex] proxy "auto": using ${system} system proxy ${origins}`); + systemNoProxy = found.noProxy; if (found.httpUrl) process.env.HTTP_PROXY = found.httpUrl; if (found.httpsUrl) process.env.HTTPS_PROXY = found.httpsUrl; 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" + ? `${system} system proxy is disabled; using direct egress` : found.kind === "socks-only" ? "Windows system proxy is SOCKS-only, which HTTP_PROXY cannot express; using direct egress" - : "Windows proxy settings could not be read; using direct egress"; + : `${system} proxy settings could not be read; using direct egress`; console.log(`[opencodex] proxy "auto": ${reason}`); proxy = undefined; } @@ -257,6 +264,6 @@ export function applyProxyEnvWith( const configured = configuredEntries .map(entry => entry.trim()) .filter(Boolean); - mergeNoProxyEntries(configured); + mergeNoProxyEntries([...configured, ...systemNoProxy]); configureSocks5Fetch(); } diff --git a/structure/config-proxy.md b/structure/config-proxy.md index a4c6e5af550..debd1c0299a 100644 --- a/structure/config-proxy.md +++ b/structure/config-proxy.md @@ -3,7 +3,7 @@ `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 supports Windows and macOS static system proxy discovery and 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 +29,11 @@ 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 `/usr/sbin/scutil --proxy` once +with a timeout and output bound. Only top-level enabled HTTP/HTTPS settings become +scheme proxies; nested scoped settings, PAC/WPAD and SOCKS are not selected. +System exceptions join configured and inherited `NO_PROXY` entries when a proxy is +found, with the lowercase precedence above unchanged. Invalid settings or command +failures leave scheme variables unset. Existing HTTP(S) variables skip discovery. +Regression coverage lives in `tests/server/proxy-env.test.ts`. diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts index 881b39af8e4..896b40937d7 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], @@ -534,3 +534,80 @@ describe("applyProxyEnv with proxy: \"auto\" (#1525)", () => { expect(process.env.HTTP_PROXY).toBeUndefined(); }); }); + +describe('macOS proxy: "auto" (#5853)', () => { + const { applyProxyEnvWith } = require("../../src/config") as typeof import("../../src/config"); + const { readMacOSSystemProxy } = require("../../src/config/macos-system-proxy") as typeof import("../../src/config/macos-system-proxy"); + const settings = (body: string) => ` {\n${body}\n}`; + const both = "HTTPEnable : 1\nHTTPProxy : proxy.example\nHTTPPort : 8080\nHTTPSEnable : 1\nHTTPSProxy : ::1\nHTTPSPort : 8443"; + + test("maps enabled schemes and merges system, configured, inherited and loopback exceptions", () => { + process.env.NO_PROXY = "existing.example,LOCALHOST"; + applyProxyEnvWith(configWithProxy("auto", ["configured.example"]), { + platform: "darwin", + macOSReader: () => settings(`${both}\nExceptionsList : {\n0 : *.local\n1 : 169.254/16\n2 : existing.example\n3 : localhost\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("existing.example,LOCALHOST,configured.example,*.local,169.254/16,127.0.0.1,::1,[::1]"); + expect(process.env.ALL_PROXY).toBeUndefined(); + }); + + test.each(["HTTP", "HTTPS"])("preserves %s-only scope", scheme => { + applyProxyEnvWith(configWithProxy(" AUTO "), { + platform: "darwin", + macOSReader: () => settings(`${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(["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"])("does not read macOS settings when %s is inherited", key => { + process.env[key] = "http://inherited.example:8080"; + let reads = 0; + applyProxyEnvWith(configWithProxy("auto"), { + platform: "darwin", macOSReader: () => { reads++; return settings(both); }, + }); + expect(reads).toBe(0); + expect(process.env[key]).toBe("http://inherited.example:8080"); + }); + + test("preserves inherited lowercase bypass precedence", () => { + process.env.no_proxy = "inherited.example"; + applyProxyEnvWith(configWithProxy("auto"), { + platform: "darwin", macOSReader: () => settings(`${both}\nExceptionsList : {\n0 : *.local\n}`), + }); + expect(process.env.NO_PROXY).toContain("*.local"); + if (process.platform !== "win32") expect(process.env.no_proxy).toBe("inherited.example,127.0.0.1,::1,[::1]"); + }); + + test.each([ + "", "garbage", " {\nHTTPEnable : 1", settings(""), + settings("HTTPEnable : 0\nHTTPProxy : proxy.example\nHTTPPort : 8080"), + settings("ProxyAutoConfigEnable : 1\nProxyAutoConfigURLString : https://example.test/proxy.pac"), + settings("SOCKSEnable : 1\nSOCKSProxy : localhost\nSOCKSPort : 1080"), + settings(`__SCOPED__ : {\nen0 : {\n${both}\n}\n}`), + ])("does not set proxy variables for unavailable static settings (%#)", output => { + applyProxyEnvWith(configWithProxy("auto"), { platform: "darwin", macOSReader: () => output }); + expect(process.env.HTTP_PROXY).toBeUndefined(); + expect(process.env.HTTPS_PROXY).toBeUndefined(); + }); + + test.each(["0", "65536", "-1", "8e3", "8080junk", ""])("rejects invalid port %s", port => { + expect(readMacOSSystemProxy(() => settings(`HTTPEnable : 1\nHTTPProxy : proxy.example\nHTTPPort : ${port}`))).toEqual({ kind: "disabled" }); + }); + + test.each(["user:password@host", "https://host", "host/path", "host?query", "host#fragment", "host name", ""])("rejects non-host proxy value (%#)", host => { + expect(readMacOSSystemProxy(() => settings(`HTTPEnable : 1\nHTTPProxy : ${host}\nHTTPPort : 8080`))).toEqual({ kind: "disabled" }); + }); + + test("ignores disabled and malformed schemes without losing a valid HTTPS proxy", () => { + const result = readMacOSSystemProxy(() => settings(`${both.replace("HTTPPort : 8080", "HTTPPort : invalid")}\nExceptionsList : {\n0 : good.example\n1 : bad,entry\n2 : bad entry\n}`)); + expect(result).toEqual({ kind: "proxy", httpUrl: undefined, httpsUrl: "http://[::1]:8443", noProxy: ["good.example"] }); + }); + + test("command failures degrade without exposing output or throwing", () => { + expect(readMacOSSystemProxy(() => null)).toEqual({ kind: "unreadable" }); + expect(readMacOSSystemProxy(() => { throw new Error("command failed"); })).toEqual({ kind: "unreadable" }); + }); +});