From 799cd29ee7204bca1f4fd4b5cd764e74ca78fd8a Mon Sep 17 00:00:00 2001 From: Warexpor Date: Sat, 29 Aug 2026 18:09:14 +0300 Subject: [PATCH 1/9] feat(cli): persist SOCKS5 outbound via config.proxy Clash mixed-port HTTP_PROXY cannot CONNECT a SOCKS listener. ocx start --socks5 writes socks5:// into config.proxy and ALL_PROXY so routing survives ocx update. --- README.md | 2 +- .../docs/fr/reference/cli/lifecycle.md | 4 +- .../docs/fr/reference/configuration/server.md | 2 +- .../docs/ja/reference/cli/lifecycle.md | 4 +- .../docs/ja/reference/configuration/server.md | 2 +- .../docs/ko/reference/cli/lifecycle.md | 4 +- .../docs/ko/reference/configuration/server.md | 2 +- .../content/docs/reference/cli/lifecycle.md | 8 +- .../docs/reference/configuration/server.md | 10 ++- .../docs/ru/reference/cli/lifecycle.md | 4 +- .../docs/ru/reference/configuration/server.md | 2 +- .../docs/tr/reference/cli/lifecycle.md | 4 +- .../docs/tr/reference/configuration/server.md | 2 +- .../docs/zh-cn/reference/cli/lifecycle.md | 4 +- .../zh-cn/reference/configuration/server.md | 2 +- .../docs/zh-tw/reference/cli/lifecycle.md | 4 +- .../zh-tw/reference/configuration/server.md | 2 +- src/cli/help.ts | 4 +- src/cli/index.ts | 36 +++++---- src/cli/registry.ts | 10 ++- src/cli/start-args.ts | 77 +++++++++++++++++++ src/config/proxy-env.ts | 11 ++- src/server/index.ts | 2 + src/types/config.ts | 7 +- tests/cli/cli-help.test.ts | 8 +- tests/server/proxy-env.test.ts | 17 ++++ tests/start-args.test.ts | 61 +++++++++++++++ 27 files changed, 253 insertions(+), 42 deletions(-) create mode 100644 src/cli/start-args.ts create mode 100644 tests/start-args.test.ts diff --git a/README.md b/README.md index c7ce31acceb..214e47f6e77 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ Qwen Cloud, Qoder Global and CN (official PAT + CLI), SiliconFlow, and more. Ful ```bash ocx init # interactive setup (writes config, wires Codex, offers the shim) -ocx start [--port 10100] # start the proxy in the foreground +ocx start [--port 10100] [--socks5] # start the proxy; --socks5 saves SOCKS outbound ocx stop # stop + restore native Codex ocx service [install|repair|restart|start|stop|status|uninstall|remove] # background service ocx codex-shim install # start the proxy on demand whenever `codex` launches diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 152108fd881..eaa895ee712 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -13,13 +13,15 @@ Assistant de configuration interactif (`setup` est un alias de `init`). Il deman ## Cycle de vie du proxy -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port]]` Démarre le serveur proxy, de préférence sur le port `10100`. Si ce port est occupé, opencodex en choisit un autre qui est disponible et l’enregistre. La commande écrit l’état du PID et du port d’exécution, et refuse de démarrer une deuxième instance active. Au démarrage, elle synchronise dans le catalogue Codex les modèles de chaque fournisseur. À l’arrêt, elle rétablit le fonctionnement natif de Codex, sauf si le proxy a été lancé comme service géré (`OCX_SERVICE=1`). ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` 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 cde1b2c5f61..5d02f0c479d 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -12,7 +12,7 @@ exécute des fonctionnalités d'assistance autour des demandes du fournisseur. | --- | --- | --- | --- | | `port` | `number` | `10100` | Port d'écoute proxy. | | `hostname?` | `string` | `"127.0.0.1"` | Adresse de liaison. Les liaisons hors bouclage nécessitent `OPENCODEX_API_AUTH_TOKEN`. | -| `proxy?` | `string` | — | URL du proxy HTTP(S) sortant ou `${ENV_VAR}`. Appliquée à `HTTP_PROXY` / `HTTPS_PROXY` uniquement lorsque ces variables ne sont pas définies ; le bouclage reste dans `NO_PROXY`. | +| `proxy?` | `string` | — | URL du proxy HTTP(S) ou SOCKS sortant (`socks5://host:port`) ou `${ENV_VAR}`. Les URL HTTP s’appliquent à `HTTP_PROXY` / `HTTPS_PROXY` si elles sont vides. Les URL SOCKS s’appliquent à `ALL_PROXY` (`ocx start --socks5`) et effacent `HTTP(S)_PROXY` héritées dans ce processus. Le bouclage reste dans `NO_PROXY`. | | `emptyCompletionRetry?` | `boolean` | `false` | Active une nouvelle tentative Responses identique lorsqu’une réponse ne contient ni texte ni appel d’outil. Cette tentative peut être facturée. `OCX_EMPTY_COMPLETION_RETRY=0` la désactive sans modifier la configuration ; les combinaisons et les tours de compactage routés restent exclus. | | `stallTimeoutSec?` | `number` | `300` | Secondes sans progression utile en amont, pour Responses et le Chat natif. Minimum : 1. | | `connectTimeoutMs?` | `number` | `200000` | Délai maximal par tentative pour DNS/TCP/TLS et les en-têtes finaux ; il prend fin avant la génération du corps. | diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index ee7604688bf..0bd43f87359 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -13,13 +13,15 @@ description: セットアップ、開始、停止、サービス、診断、同 ## プロキシのライフサイクル -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port]]` プロキシ サーバー (優先ポート `10100`) を起動します。そのポートが占有されている場合、opencodex は別の使用可能なポートを選択して記録します。 PID/ランタイムポートの状態を書き込み、2 番目のライブインスタンスの起動を拒否します。開始時に、各プロバイダーのモデルを Codex のカタログに同期します。マネージド サービス (`OCX_SERVICE=1`) として起動されていない限り、シャットダウン時にネイティブ Codex が復元されます。 ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` 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 9ce4c6d8780..65db69733cc 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -11,7 +11,7 @@ description: リスナー、リモート アクセス、アドミッション | --- | --- | --- | --- | | `port` | `number` | `10100` |プロキシリッスンポート。 | | `hostname?` | `string` | `"127.0.0.1"` |バインドアドレス。非ループバック バインドには `OPENCODEX_API_AUTH_TOKEN` が必要です。 | -| `proxy?` | `string` | — |送信 HTTP(S) プロキシ URL または `${ENV_VAR}`。これらの変数が設定されていない場合にのみ、`HTTP_PROXY` / `HTTPS_PROXY` に適用されます。ループバックは `NO_PROXY` に残ります。 | +| `proxy?` | `string` | — |送信 HTTP(S) または SOCKS プロキシ URL(`socks5://host:port`)または `${ENV_VAR}`。HTTP URL は未設定時のみ `HTTP_PROXY` / `HTTPS_PROXY` に適用されます。SOCKS URL は `ALL_PROXY` に適用され(`ocx start --socks5`)、このプロセスで継承した `HTTP(S)_PROXY` をクリアします。ループバックは `NO_PROXY` に残ります。 | | `emptyCompletionRetry?` | `boolean` | `false` | テキストもツール呼び出しもない Responses ターンを、ターミナルイベント前にストリームが終了した場合も含め、同一リクエストで 1 回再試行するよう明示的に有効化します。再試行は課金対象になる場合があります。`OCX_EMPTY_COMPLETION_RETRY=0` で設定を変更せず無効化できます。combo と routed-compaction turn は対象外です。 | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Codex Responses パススルーから Codex の safety-buffering ヒントを除去します。対象は `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` 応答ヘッダー、`safety_buffering` 型の `response.metadata` SSE イベント、およびその他の SSE イベントにある `safety_buffering` フィールドです。Codex TUI はこれらを、既定の操作でセッションをより弱いモデルに切り替える「より高速なモデルで再試行」プロンプトとして表示します。その他の `x-codex-*` ヘッダーと SSE イベントの内容は、そのフィールドの除去を除いて変更せずに転送されます。既定ではオフです。 | | `stallTimeoutSec?` | `number` | `300` | Responses とネイティブ Chat の有効な上流進捗がない秒数。最小 1 秒。 | diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 2cac37ed244..ee644f9b9e9 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -16,7 +16,7 @@ Codex 자동 시작 shim도 설치합니다. ## 프록시 수명 주기 -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port]]` 프록시 서버를 시작합니다(권장 포트는 `10100`). 해당 포트가 이미 사용 중이면 opencodex가 다른 사용 가능한 포트를 골라 기록합니다. PID와 런타임 포트 상태를 기록하고, 두 번째 활성 인스턴스는 시작하지 @@ -26,6 +26,8 @@ Codex 자동 시작 shim도 설치합니다. ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` 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 5727ba13d49..e3fc9ef6b52 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -11,7 +11,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | --- | --- | --- | --- | | `port` | `number` | `10100` | 프록시 수신 포트입니다. | | `hostname?` | `string` | `"127.0.0.1"` | 바인드 주소입니다. 루프백이 아닌 바인드에는 데이터 admission 토큰이 필요하며, `OPENCODEX_API_AUTH_TOKEN` → `OCX_API_TOKEN_FILE` → 설치된 owner-only `service-api-token` 순서로 결정됩니다. 손으로 내보낼 값은 없습니다. [Remote access](#remote-access)를 보세요. | -| `proxy?` | `string` | — | 송신용 HTTP(S) 프록시 URL 또는 `${ENV_VAR}`입니다. 해당 변수가 비어 있을 때만 `HTTP_PROXY` / `HTTPS_PROXY`에 적용되며, 루프백은 `NO_PROXY`에 그대로 남습니다. | +| `proxy?` | `string` | — | 송신용 HTTP(S) 또는 SOCKS 프록시 URL(`socks5://host:port`) 또는 `${ENV_VAR}`입니다. HTTP URL은 해당 변수가 비어 있을 때 `HTTP_PROXY` / `HTTPS_PROXY`에 적용됩니다. SOCKS URL은 `ALL_PROXY`에 적용되며(`ocx start --socks5`) 이 프로세스에서 상속된 `HTTP(S)_PROXY`를 지웁니다. 루프백은 `NO_PROXY`에 그대로 남습니다. | | `emptyCompletionRetry?` | `boolean` | `false` | 텍스트나 도구 호출이 없는 Responses 턴을, 터미널 이벤트 전에 스트림이 종료된 경우를 포함해 동일한 요청으로 한 번 재시도하도록 선택합니다. 재시도에는 비용이 발생할 수 있습니다. `OCX_EMPTY_COMPLETION_RETRY=0`은 설정을 바꾸지 않고 비활성화하며, combo 및 routed-compaction turn은 제외됩니다. | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Canonical Codex Responses 응답의 선택적 safety-buffering 헤더 두 개와 SSE 힌트를 제거합니다. 공급자의 안전 정책이나 거절 응답은 바뀌지 않습니다. Native WS 메타데이터와 compact는 제외됩니다. | | `stallTimeoutSec?` | `number` | `300` | Responses 및 네이티브 Chat에서 유효한 업스트림 진행이 없는 시간(초). 최소 1초. | diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 32ff382d697..e52ca2ef154 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -16,16 +16,22 @@ optionally installs the Codex autostart shim. ## Proxy lifecycle -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port]]` Start the proxy server (preferred port `10100`). If that port is occupied, opencodex selects and records another available port. It writes PID/runtime-port state and refuses to start a second live instance. On start it syncs each provider's models into Codex's catalog. On shutdown it restores native Codex — unless it was launched as a managed service (`OCX_SERVICE=1`). +`--socks5` (default `127.0.0.1:10808`) saves `config.proxy` as a SOCKS URL so outbound provider +calls use `ALL_PROXY`. `--socks5-off` clears that saved proxy. The value survives `ocx update` +because it lives in config, not in the installed package. + ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 4d1fcd20da9..b6f961c2cf0 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) proxy URL, `${ENV_VAR}`, or `"auto"`. Applied to `HTTP_PROXY` / `HTTPS_PROXY` only when those variables are unset; loopback remains in `NO_PROXY`. `"auto"` reads the Windows system proxy (WinINET `ProxyEnable`/`ProxyServer`, `https=` then `http=` entry) once at process start and logs the host it chose. 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 SOCKS proxy URL (`socks5://host:port`), `${ENV_VAR}`, or `"auto"`. HTTP URLs apply to `HTTP_PROXY` / `HTTPS_PROXY` when those are unset. SOCKS URLs apply to `ALL_PROXY` (`ocx start --socks5`) and clear inherited `HTTP(S)_PROXY` in this process so fetch does not HTTP-CONNECT a SOCKS listener. Loopback stays in `NO_PROXY`. `"auto"` reads the Windows system proxy (WinINET `ProxyEnable`/`ProxyServer`, `https=` then `http=` entry) once at process start and logs the host it chose. 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. | @@ -71,6 +71,14 @@ replacing an inherited `NO_PROXY`: { "proxy": "http://proxy.corp:8080", "noProxy": ["internal.example", "10.0.0.0/8"] } ``` +SOCKS5 (Clash mixed-port listeners included) belongs on `ALL_PROXY`, not `HTTP_PROXY`: + +```jsonc +{ "proxy": "socks5://127.0.0.1:10808" } +``` + +`ocx start --socks5` writes that value; `ocx start --socks5-off` clears it. + If an older development build changed resume-history metadata before backup support existed, run `ocx recover-history --legacy-openai --yes` to force native-provider recovery. It force-relabels every user-message `opencodex` row, including legitimate dedicated-provider diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 4de2c646247..420630f9534 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -18,7 +18,7 @@ opencodex и его интеграцию с Codex. ## Жизненный цикл прокси -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port]]` Запустить proxy server (предпочтительный порт `10100`). Если этот порт занят, opencodex выбирает и записывает другой свободный порт. При запуске пишется состояние PID/runtime-port, а попытка @@ -29,6 +29,8 @@ opencodex и его интеграцию с Codex. ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` 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 b2907d4d842..932d571643e 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -12,7 +12,7 @@ description: Listener, удалённый доступ, admission key, тайм | --- | --- | --- | --- | | `port` | `number` | `10100` | Порт, который слушает прокси. | | `hostname?` | `string` | `"127.0.0.1"` | Адрес bind'а. Не-loopback bind требует `OPENCODEX_API_AUTH_TOKEN`. | -| `proxy?` | `string` | — | URL исходящего HTTP(S)-прокси или `${ENV_VAR}`. Применяется к `HTTP_PROXY` / `HTTPS_PROXY` только когда эти переменные не заданы; loopback всегда остаётся в `NO_PROXY`. | +| `proxy?` | `string` | — | URL исходящего HTTP(S) или SOCKS-прокси (`socks5://host:port`) или `${ENV_VAR}`. HTTP URL пишутся в `HTTP_PROXY` / `HTTPS_PROXY`, если те не заданы. SOCKS URL пишутся в `ALL_PROXY` (`ocx start --socks5`) и сбрасывают унаследованные `HTTP(S)_PROXY` в этом процессе. Loopback всегда остаётся в `NO_PROXY`. | | `emptyCompletionRetry?` | `boolean` | `false` | Явно включает один идентичный повтор Responses, если в turn нет ни текста, ни tool call, включая случай, когда stream завершается до terminal event. Повтор может тарифицироваться. `OCX_EMPTY_COMPLETION_RETRY=0` отключает его без изменения config; combo и routed-compaction turn исключены. | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Удаляет подсказки Codex safety-buffering из passthrough-ответов Codex Responses: заголовки `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model`, SSE-события `response.metadata` типа `safety_buffering` и поле `safety_buffering` в других SSE-событиях. Codex TUI отображает их как предложение повторить запрос с более быстрой моделью, действие по умолчанию в котором переключает сессию на более слабую модель. Остальные заголовки `x-codex-*` и содержимое других SSE-событий передаются без изменений, кроме удаления этого поля. По умолчанию выключено. | | `stallTimeoutSec?` | `number` | `300` | Секунды без полезного прогресса upstream для Responses и нативного Chat. Минимум 1. | diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index ca59f39db9d..ef92f55738b 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -19,7 +19,7 @@ otomatik başlatma dolgusunu kurar. ## Proxy yaşam döngüsü -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port]]` Proxy sunucusunu başlatın (tercih edilen port `10100`). Bu port doluysa opencodex başka bir kullanılabilir port seçer ve kaydeder. PID/çalışma zamanı @@ -31,6 +31,8 @@ Codex'i geri yükler. ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` 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 880af54edbe..89c3aabba32 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -13,7 +13,7 @@ yardımcı özellikleri nasıl çalıştıracağını kontrol eder. | --- | --- | --- | --- | | `port` | `number` | `10100` | Proxy dinleme portu. | | `hostname?` | `string` | `"127.0.0.1"` | Bağlama adresi. Geri döngü olmayan bağlamalar `OPENCODEX_API_AUTH_TOKEN` gerektirir. | -| `proxy?` | `string` | — | Giden HTTP(S) proxy URL'si veya `${ENV_VAR}`. Yalnızca bu değişkenler ayarlanmadığında `HTTP_PROXY` / `HTTPS_PROXY`'ye uygulanır; geri döngü `NO_PROXY` içinde kalır. | +| `proxy?` | `string` | — | Giden HTTP(S) veya SOCKS proxy URL'si (`socks5://host:port`) ya da `${ENV_VAR}`. HTTP URL'leri değişkenler boşsa `HTTP_PROXY` / `HTTPS_PROXY`'ye yazılır. SOCKS URL'leri `ALL_PROXY`'ye yazılır (`ocx start --socks5`) ve bu süreçte miras `HTTP(S)_PROXY`'yi temizler. Geri döngü `NO_PROXY` içinde kalır. | | `emptyCompletionRetry?` | `boolean` | `false` | Metin veya araç çağrısı içermeyen bir Responses tamamlamasını aynı istekle bir kez yeniden denemeyi açıkça etkinleştirir. Yeniden deneme ücretlendirilebilir. `OCX_EMPTY_COMPLETION_RETRY=0`, yapılandırmayı değiştirmeden devre dışı bırakır; combo ve routed-compaction turları hariçtir. | | `stallTimeoutSec?` | `number` | `300` | Responses ve yerel Chat için anlamlı üst sunucu ilerlemesi olmadan geçen saniye. En az 1. | | `connectTimeoutMs?` | `number` | `200000` | Deneme başına DNS/TCP/TLS/nihai başlık son tarihi; gövde üretiminden önce biter. | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 2c5e3a49150..9bd030950d3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -13,13 +13,15 @@ description: 安装、启动、停止、服务、诊断、同步和更新命令 ## 代理生命周期 -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port]]` 启动代理服务器(首选端口 `10100`)。如果该端口已被占用,opencodex 会选择并记录另一个可用端口。它会写入 PID/运行时端口状态,并拒绝启动第二个存活实例。启动时,它会把每个提供方的模型同步到 Codex 的目录中。关闭时,它会恢复原生 Codex,除非它是作为受管服务启动的(`OCX_SERVICE=1`)。 ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` 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 13513481f07..8ce61b354d0 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 @@ -12,7 +12,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | --- | --- | --- | --- | | `port` | `number` | `10100` | 代理监听端口。 | | `hostname?` | `string` | `"127.0.0.1"` | 绑定地址。非回环绑定需要 `OPENCODEX_API_AUTH_TOKEN`。 | -| `proxy?` | `string` | — | 出站 HTTP(S) 代理 URL,或 `${ENV_VAR}`。仅当 `HTTP_PROXY` / `HTTPS_PROXY` 未设置时才会应用;回环地址始终保留在 `NO_PROXY` 中。 | +| `proxy?` | `string` | — | 出站 HTTP(S) 或 SOCKS 代理 URL(`socks5://host:port`),或 `${ENV_VAR}`。HTTP URL 仅在未设置时写入 `HTTP_PROXY` / `HTTPS_PROXY`。SOCKS URL 写入 `ALL_PROXY`(`ocx start --socks5`),并清除本进程继承的 `HTTP(S)_PROXY`。回环地址始终保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 显式启用:当 Responses turn 既无文本也无工具调用时,使用相同请求重试一次,包括流在终止事件之前结束的情况。重试可能产生费用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不修改配置的情况下禁用;combo 与 routed-compaction turn 不参与。 | | `dropCodexSafetyBuffering?` | `boolean` | `false` | 从 Codex Responses 透传响应中移除 Codex safety-buffering 提示:`x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` 响应头、类型为 `safety_buffering` 的 `response.metadata` SSE 事件,以及其他 SSE 事件中的 `safety_buffering` 字段。Codex TUI 会将这些提示显示为“使用更快模型重试”的提示框,其默认操作会把会话切换到较弱的模型。其他 `x-codex-*` 响应头和其他所有 SSE 事件内容均保持不变,但会移除该字段。默认关闭。 | | `stallTimeoutSec?` | `number` | `300` | 上游无有效进展的秒数,适用于 Responses 和原生 Chat;最小 1 秒。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 117133bc187..b8a5310f806 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -13,13 +13,15 @@ description: 安裝、啟動、停止、服務、診斷、同步與更新指令 ## 代理生命週期 -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port]]` 啟動代理伺服器(偏好連接埠 `10100`)。若該連接埠被佔用,opencodex 會選擇並記錄另一個可用連接埠。它寫入 PID/runtime-port 狀態,並拒絕啟動第二個即時實例。啟動時它將每個供應商的模型同步到 Codex 目錄。關閉時它還原原生 Codex——除非它是作為受管服務啟動的(`OCX_SERVICE=1`)。 ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` 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 87307ffe0b9..7c1ae37a0ec 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 @@ -11,7 +11,7 @@ description: 監聽器、遠端存取、許可金鑰、逾時、儲存、sidecar | --- | --- | --- | --- | | `port` | `number` | `10100` | 代理監聽連接埠。 | | `hostname?` | `string` | `"127.0.0.1"` | 綁定位址。非回送綁定需要 `OPENCODEX_API_AUTH_TOKEN`。 | -| `proxy?` | `string` | — | 對外 HTTP(S) 代理 URL 或 `${ENV_VAR}`。僅在那些變數未設定時套用至 `HTTP_PROXY` / `HTTPS_PROXY`;回送保留在 `NO_PROXY` 中。 | +| `proxy?` | `string` | — | 對外 HTTP(S) 或 SOCKS 代理 URL(`socks5://host:port`)或 `${ENV_VAR}`。HTTP URL 僅在那些變數未設定時套用至 `HTTP_PROXY` / `HTTPS_PROXY`。SOCKS URL 套用至 `ALL_PROXY`(`ocx start --socks5`),並清除此行程繼承的 `HTTP(S)_PROXY`。回送保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 明確啟用:當 Responses 完成時沒有文字或工具呼叫,以相同請求重試一次。重試可能產生費用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不變更設定的情況下停用;combo 與 routed-compaction turn 不適用。 | | `stallTimeoutSec?` | `number` | `300` | 上游無有效進展的秒數,適用於 Responses 與原生 Chat;最小 1 秒。 | | `connectTimeoutMs?` | `number` | `200000` | 每次嘗試的 DNS/TCP/TLS/final-header 截止時間;它在 body 生成前結束。 | diff --git a/src/cli/help.ts b/src/cli/help.ts index 6765d27fcf0..29f3e7ea6d9 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -27,7 +27,8 @@ export function printUsage(): void { Usage: ocx setup Interactive setup (alias: init) - ocx start [--port ] Start the proxy server (auto-syncs models to Codex) + ocx start [--port ] [--socks5 [host:port]] + Start the proxy (optional SOCKS5 outbound, default 127.0.0.1:10808) ocx stop Stop the proxy AND restore native Codex (plain codex works again) ocx restore Restore native Codex without stopping (alias: eject) ocx restore back Re-point codex at the running proxy (undo restore) @@ -102,6 +103,7 @@ Examples: ocx init Set up provider and inject into Codex ocx start Start on default port (10100) ocx start --port 8080 Start on custom port + ocx start --socks5 Outbound via SOCKS5 at 127.0.0.1:10808 (saved) ocx help service Show service command help ocx help hub Explain the hub topology, token file, and invites ocx sync Sync available models to Codex`); diff --git a/src/cli/index.ts b/src/cli/index.ts index 9df96b3fad5..482d3f7be50 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -52,6 +52,7 @@ import { import { collectStatus, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status"; import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; import { takeFlag } from "./runtime-api"; +import { parseStartOptions, StartArgsError } from "./start-args"; import { discoverStableProxyForRestart, @@ -150,21 +151,13 @@ const head = await runCli(process.argv.slice(2)); const args = head.args; const command = head.command; -function parsePortOption(): number | undefined { - if (args.length === 1) return undefined; - if (args.length !== 3 || args[1] !== "--port") { - console.error("Usage: ocx start [--port ]"); - process.exit(1); - } - const portIdx = args.indexOf("--port"); - if (portIdx === -1) return undefined; - const value = args[portIdx + 1]; - const port = value && /^\d+$/.test(value) ? Number(value) : NaN; - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - console.error("Invalid port number"); +function parseStartCliOptions(): ReturnType { + try { + return parseStartOptions(args.slice(1)); + } catch (error) { + console.error(error instanceof StartArgsError ? error.message : String(error)); process.exit(1); } - return port; } async function waitForProxy(timeoutMs = 8_000): Promise { @@ -309,7 +302,22 @@ async function handleStart(options: { block?: boolean } = {}) { // already-broken file cannot fence /api/* closed at boot (#2696). const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim(); if (present) assertNotAdminToken(present); - const requestedPort = parsePortOption(); + const startOpts = parseStartCliOptions(); + if (startOpts.socks5 !== undefined) { + const proxyConfig = loadConfig(); + if (startOpts.socks5 === null) { + if (proxyConfig.proxy) { + delete proxyConfig.proxy; + saveConfig(proxyConfig); + console.log("Cleared config.proxy (outbound SOCKS5/HTTP proxy off)."); + } + } else { + proxyConfig.proxy = startOpts.socks5; + saveConfig(proxyConfig); + console.log(`Outbound SOCKS5: ${startOpts.socks5} (saved to config.proxy)`); + } + } + const requestedPort = startOpts.port; // Always probe the configured port, even when both state files are absent. A // fallback-port sibling overwrites the pid/runtime records when it starts and // removes them on its own shutdown, so their absence proves nothing about the diff --git a/src/cli/registry.ts b/src/cli/registry.ts index d716ccffef0..06f843c608f 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -20,7 +20,15 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ usage: "ocx setup", summary: "Interactive setup for providers and Codex config injection (alias of init).", }, - { name: "start", usage: "ocx start [--port ]", summary: "Start the proxy server and sync models to Codex." }, + { + name: "start", + usage: "ocx start [--port ] [--socks5 [host:port]]", + summary: "Start the proxy server and sync models to Codex.", + details: [ + "--socks5 [host:port] Route outbound provider traffic through SOCKS5 (default 127.0.0.1:10808). Saved as config.proxy.", + "--socks5-off Clear a saved SOCKS5/HTTP outbound proxy from config.proxy.", + ], + }, { name: "stop", usage: "ocx stop", summary: "Stop the proxy and restore native Codex config." }, { name: "restore", diff --git a/src/cli/start-args.ts b/src/cli/start-args.ts new file mode 100644 index 00000000000..8f467aa2bcc --- /dev/null +++ b/src/cli/start-args.ts @@ -0,0 +1,77 @@ +export const START_USAGE = "Usage: ocx start [--port ] [--socks5 [host:port]]"; +export const START_USAGE_LINE = "ocx start [--port ] [--socks5 [host:port]]"; +export const DEFAULT_SOCKS5_PROXY = "socks5://127.0.0.1:10808"; + +export class StartArgsError extends Error { + constructor(message: string) { + super(message); + this.name = "StartArgsError"; + } +} + +export type StartOptions = { + port?: number; + /** SOCKS5 URL to persist, or `null` to clear `config.proxy`. */ + socks5?: string | null; +}; + +export function isSocksProxyUrl(proxy: string): boolean { + return /^(socks5h?|socks4a?):\/\//i.test(proxy.trim()); +} + +export function normalizeSocks5(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return DEFAULT_SOCKS5_PROXY; + if (isSocksProxyUrl(trimmed)) return trimmed; + if (/^https?:\/\//i.test(trimmed)) { + throw new StartArgsError("SOCKS5 proxy must be socks5://host:port, not an HTTP URL"); + } + if (/^\d+$/.test(trimmed)) { + const port = Number(trimmed); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new StartArgsError("Invalid SOCKS5 port number"); + } + return `socks5://127.0.0.1:${port}`; + } + const hostPort = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(trimmed); + if (hostPort) { + const port = Number(hostPort[2]); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new StartArgsError("Invalid SOCKS5 port number"); + } + return `socks5://${trimmed}`; + } + throw new StartArgsError(`Invalid SOCKS5 address: ${trimmed}`); +} + +export function parseStartOptions(argv: string[]): StartOptions { + const options: StartOptions = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === "--port") { + const value = argv[++i]; + const port = value && /^\d+$/.test(value) ? Number(value) : NaN; + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new StartArgsError("Invalid port number"); + } + options.port = port; + continue; + } + if (arg === "--socks5") { + const next = argv[i + 1]; + if (next && !next.startsWith("-")) { + options.socks5 = normalizeSocks5(next); + i += 1; + } else { + options.socks5 = DEFAULT_SOCKS5_PROXY; + } + continue; + } + if (arg === "--socks5-off") { + options.socks5 = null; + continue; + } + throw new StartArgsError(START_USAGE); + } + return options; +} diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index 649c8b6f1ff..a20bca1e303 100644 --- a/src/config/proxy-env.ts +++ b/src/config/proxy-env.ts @@ -151,8 +151,15 @@ export function applyProxyEnvWith( } } if (proxy) { - if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy; - if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy; + if (/^(socks5h?|socks4a?):\/\//i.test(proxy.trim())) { + for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"] as const) { + delete process.env[key]; + } + process.env.ALL_PROXY = proxy; + } else { + if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy; + if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy; + } } const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; const entries = existing.split(",").map(s => s.trim()).filter(Boolean); diff --git a/src/server/index.ts b/src/server/index.ts index 4892f4aaf35..13a47014570 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -226,6 +226,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { expectSpawnFinished(result, "ocx help start"); expect(result.status).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain("Usage: ocx start [--port ]"); + expect(result.stdout).toContain("Usage: ocx start [--port ] [--socks5 [host:port]]"); expect(result.stdout).toContain("Start the proxy server and sync models to Codex."); }); @@ -390,8 +390,8 @@ describe("CLI subcommand help", () => { test("start rejects unknown and partially numeric port arguments", () => { const cases = [ { args: ["start", "--port", "123abc"], expected: "Invalid port number" }, - { args: ["start", "--bad"], expected: "Usage: ocx start [--port ]" }, - { args: ["start", "--port", "1234", "--extra"], expected: "Usage: ocx start [--port ]" }, + { args: ["start", "--bad"], expected: "Usage: ocx start [--port ] [--socks5 [host:port]]" }, + { args: ["start", "--port", "1234", "--extra"], expected: "Usage: ocx start [--port ] [--socks5 [host:port]]" }, ]; for (const testCase of cases) { @@ -408,7 +408,7 @@ describe("CLI subcommand help", () => { expectSpawnFinished(result, "ocx start --port 123abc --help"); expect(result.status).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain("Usage: ocx start [--port ]"); + expect(result.stdout).toContain("Usage: ocx start [--port ] [--socks5 [host:port]]"); }); test("invalid service and codex-shim usage include remove alias", () => { diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts index c795c6cf2d0..bd4ad290f94 100644 --- a/tests/server/proxy-env.test.ts +++ b/tests/server/proxy-env.test.ts @@ -282,6 +282,23 @@ describe("applyProxyEnv", () => { applyProxyEnv(configWithProxy("${OCX_TEST_PROXY_REF}")); expect(process.env.HTTP_PROXY).toBe("http://ref-proxy:9999"); }); + + test("mirrors SOCKS URLs into ALL_PROXY and leaves HTTP(S)_PROXY unset", () => { + applyProxyEnv(configWithProxy("socks5://127.0.0.1:10808")); + expect(process.env.ALL_PROXY).toBe("socks5://127.0.0.1:10808"); + expect(process.env.HTTP_PROXY).toBeUndefined(); + expect(process.env.HTTPS_PROXY).toBeUndefined(); + expect(process.env.NO_PROXY).toBe("localhost,127.0.0.1,::1,[::1]"); + }); + + test("SOCKS config.proxy wins over inherited HTTP(S)_PROXY in this process", () => { + process.env.HTTP_PROXY = "http://127.0.0.1:10808"; + process.env.HTTPS_PROXY = "http://127.0.0.1:10808"; + applyProxyEnv(configWithProxy("socks5://127.0.0.1:10808")); + expect(process.env.ALL_PROXY).toBe("socks5://127.0.0.1:10808"); + expect(process.env.HTTP_PROXY).toBeUndefined(); + expect(process.env.HTTPS_PROXY).toBeUndefined(); + }); }); describe("applyProxyEnv with proxy: \"auto\" (#1525)", () => { diff --git a/tests/start-args.test.ts b/tests/start-args.test.ts new file mode 100644 index 00000000000..898f81e555b --- /dev/null +++ b/tests/start-args.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_SOCKS5_PROXY, + START_USAGE, + normalizeSocks5, + parseStartOptions, + StartArgsError, +} from "../src/cli/start-args"; + +describe("parseStartOptions", () => { + test("accepts an empty start argv", () => { + expect(parseStartOptions([])).toEqual({}); + }); + + test("parses --port", () => { + expect(parseStartOptions(["--port", "8080"])).toEqual({ port: 8080 }); + }); + + test("defaults --socks5 to 127.0.0.1:10808", () => { + expect(parseStartOptions(["--socks5"])).toEqual({ socks5: DEFAULT_SOCKS5_PROXY }); + }); + + test("accepts host:port and port-only SOCKS5 values", () => { + expect(parseStartOptions(["--socks5", "10.0.0.2:1080"])).toEqual({ + socks5: "socks5://10.0.0.2:1080", + }); + expect(parseStartOptions(["--socks5", "1080"])).toEqual({ + socks5: "socks5://127.0.0.1:1080", + }); + expect(parseStartOptions(["--socks5", "socks5://example.test:9050"])).toEqual({ + socks5: "socks5://example.test:9050", + }); + }); + + test("parses --port and --socks5 together", () => { + expect(parseStartOptions(["--port", "10100", "--socks5"])).toEqual({ + port: 10100, + socks5: DEFAULT_SOCKS5_PROXY, + }); + }); + + test("--socks5-off clears a saved outbound proxy", () => { + expect(parseStartOptions(["--socks5-off"])).toEqual({ socks5: null }); + }); + + test("rejects unknown flags with the start usage line", () => { + expect(() => parseStartOptions(["--bad"])).toThrow(StartArgsError); + try { + parseStartOptions(["--bad"]); + } catch (error) { + expect(error).toBeInstanceOf(StartArgsError); + expect((error as StartArgsError).message).toBe(START_USAGE); + } + }); +}); + +describe("normalizeSocks5", () => { + test("rejects HTTP URLs", () => { + expect(() => normalizeSocks5("http://127.0.0.1:10808")).toThrow("not an HTTP URL"); + }); +}); From 7cb60332f32462aec51fa12d143abf97467d8177 Mon Sep 17 00:00:00 2001 From: Warexpor Date: Sat, 29 Aug 2026 19:17:47 +0300 Subject: [PATCH 2/9] feat(proxy): implement real SOCKS5 outbound transport Route configured provider and ordinary HTTP(S) fetches through a dependency-free SOCKS5 tunnel with authentication, streaming, aborts, and NO_PROXY support. Persist safe CLI controls, disable incompatible WebSocket routing, and synchronize tests and documentation. --- README.md | 2 +- .../docs/fr/reference/cli/lifecycle.md | 8 +- .../docs/fr/reference/configuration/server.md | 2 +- .../docs/ja/reference/cli/lifecycle.md | 4 +- .../docs/ja/reference/configuration/server.md | 2 +- .../docs/ko/reference/cli/lifecycle.md | 7 +- .../docs/ko/reference/configuration/server.md | 2 +- .../content/docs/reference/cli/lifecycle.md | 10 +- .../docs/reference/configuration/server.md | 2 +- .../docs/ru/reference/cli/lifecycle.md | 8 +- .../docs/ru/reference/configuration/server.md | 2 +- .../docs/tr/reference/cli/lifecycle.md | 7 +- .../docs/tr/reference/configuration/server.md | 2 +- .../docs/zh-cn/reference/cli/lifecycle.md | 4 +- .../zh-cn/reference/configuration/server.md | 2 +- .../docs/zh-tw/reference/cli/lifecycle.md | 4 +- .../zh-tw/reference/configuration/server.md | 2 +- src/cli/help.ts | 5 +- src/cli/index.ts | 17 +- src/cli/registry.ts | 4 +- src/cli/start-args.ts | 31 +- src/config/proxy-env.ts | 8 +- src/lib/provider-outbound.ts | 6 +- src/lib/proxy-env.ts | 73 ++- src/lib/socks5-fetch.ts | 496 ++++++++++++++++++ src/server/index.ts | 3 +- src/server/responses/fetch-helpers.ts | 7 +- src/server/responses/ws-upstream.ts | 3 +- src/types/config.ts | 5 +- structure/transports/inventory.md | 23 +- tests/cli/cli-help.test.ts | 8 +- tests/responses/ws-upstream.test.ts | 13 + tests/server/proxy-env.test.ts | 3 +- tests/socks5-fetch.test.ts | 234 +++++++++ tests/start-args.test.ts | 19 +- 35 files changed, 966 insertions(+), 62 deletions(-) create mode 100644 src/lib/socks5-fetch.ts create mode 100644 tests/socks5-fetch.test.ts diff --git a/README.md b/README.md index 214e47f6e77..4fe5927eae4 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ Qwen Cloud, Qoder Global and CN (official PAT + CLI), SiliconFlow, and more. Ful ```bash ocx init # interactive setup (writes config, wires Codex, offers the shim) -ocx start [--port 10100] [--socks5] # start the proxy; --socks5 saves SOCKS outbound +ocx start [--port 10100] [--socks5 [host:port] | --socks5-off] # SOCKS5 defaults to socks5://127.0.0.1:10808 ocx stop # stop + restore native Codex ocx service [install|repair|restart|start|stop|status|uninstall|remove] # background service ocx codex-shim install # start the proxy on demand whenever `codex` launches diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index eaa895ee712..48df694ed94 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -13,10 +13,16 @@ Assistant de configuration interactif (`setup` est un alias de `init`). Il deman ## Cycle de vie du proxy -### `ocx start [--port ] [--socks5 [host:port]]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` Démarre le serveur proxy, de préférence sur le port `10100`. Si ce port est occupé, opencodex en choisit un autre qui est disponible et l’enregistre. La commande écrit l’état du PID et du port d’exécution, et refuse de démarrer une deuxième instance active. Au démarrage, elle synchronise dans le catalogue Codex les modèles de chaque fournisseur. À l’arrêt, elle rétablit le fonctionnement natif de Codex, sauf si le proxy a été lancé comme service géré (`OCX_SERVICE=1`). +`--socks5` (par défaut `127.0.0.1:10808`) enregistre l’URL SOCKS5 dans `config.proxy` et achemine +les requêtes HTTP(S) sortantes dans un véritable tunnel SOCKS5. `--socks5-off` supprime uniquement +le proxy SOCKS5 enregistré et ne supprime pas un proxy HTTP. La valeur reste après `ocx update`, +car elle est stockée dans la configuration. L’URL peut contenir un nom d’utilisateur et un mot de +passe, mais les journaux de démarrage les masquent. + ```bash ocx start ocx start --port 8080 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 5d02f0c479d..f0e4b9415a8 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -12,7 +12,7 @@ exécute des fonctionnalités d'assistance autour des demandes du fournisseur. | --- | --- | --- | --- | | `port` | `number` | `10100` | Port d'écoute proxy. | | `hostname?` | `string` | `"127.0.0.1"` | Adresse de liaison. Les liaisons hors bouclage nécessitent `OPENCODEX_API_AUTH_TOKEN`. | -| `proxy?` | `string` | — | URL du proxy HTTP(S) ou SOCKS sortant (`socks5://host:port`) ou `${ENV_VAR}`. Les URL HTTP s’appliquent à `HTTP_PROXY` / `HTTPS_PROXY` si elles sont vides. Les URL SOCKS s’appliquent à `ALL_PROXY` (`ocx start --socks5`) et effacent `HTTP(S)_PROXY` héritées dans ce processus. Le bouclage reste dans `NO_PROXY`. | +| `proxy?` | `string` | — | URL du proxy HTTP(S) ou SOCKS5 sortant (`socks5://host:port`) ou `${ENV_VAR}`. Les URL HTTP s’appliquent à `HTTP_PROXY` / `HTTPS_PROXY` si elles sont vides. Les URL SOCKS5 utilisent le tunnel SOCKS5 intégré et sont aussi exposées via `ALL_PROXY` (`ocx start --socks5`); `HTTP(S)_PROXY` héritées sont effacées dans ce processus. Le bouclage reste dans `NO_PROXY`. | | `emptyCompletionRetry?` | `boolean` | `false` | Active une nouvelle tentative Responses identique lorsqu’une réponse ne contient ni texte ni appel d’outil. Cette tentative peut être facturée. `OCX_EMPTY_COMPLETION_RETRY=0` la désactive sans modifier la configuration ; les combinaisons et les tours de compactage routés restent exclus. | | `stallTimeoutSec?` | `number` | `300` | Secondes sans progression utile en amont, pour Responses et le Chat natif. Minimum : 1. | | `connectTimeoutMs?` | `number` | `200000` | Délai maximal par tentative pour DNS/TCP/TLS et les en-têtes finaux ; il prend fin avant la génération du corps. | diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 0bd43f87359..7b0f6c08486 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -13,10 +13,12 @@ description: セットアップ、開始、停止、サービス、診断、同 ## プロキシのライフサイクル -### `ocx start [--port ] [--socks5 [host:port]]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` プロキシ サーバー (優先ポート `10100`) を起動します。そのポートが占有されている場合、opencodex は別の使用可能なポートを選択して記録します。 PID/ランタイムポートの状態を書き込み、2 番目のライブインスタンスの起動を拒否します。開始時に、各プロバイダーのモデルを Codex のカタログに同期します。マネージド サービス (`OCX_SERVICE=1`) として起動されていない限り、シャットダウン時にネイティブ Codex が復元されます。 +`--socks5`(デフォルト `127.0.0.1:10808`)は SOCKS5 URL を `config.proxy` に保存し、送信 HTTP(S) リクエストを実際の SOCKS5 トンネル経由で送信します。`--socks5-off` は保存された SOCKS5 プロキシだけを削除し、HTTP プロキシは削除しません。値は設定に保存されるため、`ocx update` 後も保持されます。URL にユーザー名とパスワードを含めることはできますが、起動ログでは非表示になります。 + ```bash ocx start ocx start --port 8080 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 65db69733cc..96f3e618f42 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -11,7 +11,7 @@ description: リスナー、リモート アクセス、アドミッション | --- | --- | --- | --- | | `port` | `number` | `10100` |プロキシリッスンポート。 | | `hostname?` | `string` | `"127.0.0.1"` |バインドアドレス。非ループバック バインドには `OPENCODEX_API_AUTH_TOKEN` が必要です。 | -| `proxy?` | `string` | — |送信 HTTP(S) または SOCKS プロキシ URL(`socks5://host:port`)または `${ENV_VAR}`。HTTP URL は未設定時のみ `HTTP_PROXY` / `HTTPS_PROXY` に適用されます。SOCKS URL は `ALL_PROXY` に適用され(`ocx start --socks5`)、このプロセスで継承した `HTTP(S)_PROXY` をクリアします。ループバックは `NO_PROXY` に残ります。 | +| `proxy?` | `string` | — |送信 HTTP(S) または SOCKS5 プロキシ URL(`socks5://host:port`)または `${ENV_VAR}`。HTTP URL は未設定時のみ `HTTP_PROXY` / `HTTPS_PROXY` に適用されます。SOCKS5 URL は組み込みの SOCKS5 トンネルを使用し、`ALL_PROXY` にも適用されます(`ocx start --socks5`)。このプロセスで継承した `HTTP(S)_PROXY` はクリアされます。ループバックは `NO_PROXY` に残ります。 | | `emptyCompletionRetry?` | `boolean` | `false` | テキストもツール呼び出しもない Responses ターンを、ターミナルイベント前にストリームが終了した場合も含め、同一リクエストで 1 回再試行するよう明示的に有効化します。再試行は課金対象になる場合があります。`OCX_EMPTY_COMPLETION_RETRY=0` で設定を変更せず無効化できます。combo と routed-compaction turn は対象外です。 | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Codex Responses パススルーから Codex の safety-buffering ヒントを除去します。対象は `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` 応答ヘッダー、`safety_buffering` 型の `response.metadata` SSE イベント、およびその他の SSE イベントにある `safety_buffering` フィールドです。Codex TUI はこれらを、既定の操作でセッションをより弱いモデルに切り替える「より高速なモデルで再試行」プロンプトとして表示します。その他の `x-codex-*` ヘッダーと SSE イベントの内容は、そのフィールドの除去を除いて変更せずに転送されます。既定ではオフです。 | | `stallTimeoutSec?` | `number` | `300` | Responses とネイティブ Chat の有効な上流進捗がない秒数。最小 1 秒。 | diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index ee644f9b9e9..58150c4a62f 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -16,13 +16,18 @@ Codex 자동 시작 shim도 설치합니다. ## 프록시 수명 주기 -### `ocx start [--port ] [--socks5 [host:port]]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` 프록시 서버를 시작합니다(권장 포트는 `10100`). 해당 포트가 이미 사용 중이면 opencodex가 다른 사용 가능한 포트를 골라 기록합니다. PID와 런타임 포트 상태를 기록하고, 두 번째 활성 인스턴스는 시작하지 않습니다. 시작할 때는 각 공급자의 모델을 Codex 카탈로그로 동기화합니다. 종료할 때는 기본 Codex를 복원합니다. 단, 관리형 서비스로 실행한 경우(`OCX_SERVICE=1`)는 예외입니다. +`--socks5`(기본값 `127.0.0.1:10808`)는 SOCKS5 URL을 `config.proxy`에 저장하고 실제 SOCKS5 +터널을 통해 송신 HTTP(S) 요청을 전달합니다. `--socks5-off`는 저장된 SOCKS5 프록시만 지우며 +HTTP 프록시는 삭제하지 않습니다. 값은 구성에 저장되므로 `ocx update` 후에도 유지됩니다. URL에 +사용자 이름과 비밀번호를 포함할 수 있지만 시작 로그에서는 숨겨집니다. + ```bash ocx start ocx start --port 8080 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 e3fc9ef6b52..da43750851a 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -11,7 +11,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | --- | --- | --- | --- | | `port` | `number` | `10100` | 프록시 수신 포트입니다. | | `hostname?` | `string` | `"127.0.0.1"` | 바인드 주소입니다. 루프백이 아닌 바인드에는 데이터 admission 토큰이 필요하며, `OPENCODEX_API_AUTH_TOKEN` → `OCX_API_TOKEN_FILE` → 설치된 owner-only `service-api-token` 순서로 결정됩니다. 손으로 내보낼 값은 없습니다. [Remote access](#remote-access)를 보세요. | -| `proxy?` | `string` | — | 송신용 HTTP(S) 또는 SOCKS 프록시 URL(`socks5://host:port`) 또는 `${ENV_VAR}`입니다. HTTP URL은 해당 변수가 비어 있을 때 `HTTP_PROXY` / `HTTPS_PROXY`에 적용됩니다. SOCKS URL은 `ALL_PROXY`에 적용되며(`ocx start --socks5`) 이 프로세스에서 상속된 `HTTP(S)_PROXY`를 지웁니다. 루프백은 `NO_PROXY`에 그대로 남습니다. | +| `proxy?` | `string` | — | 송신용 HTTP(S) 또는 SOCKS5 프록시 URL(`socks5://host:port`) 또는 `${ENV_VAR}`입니다. HTTP URL은 해당 변수가 비어 있을 때 `HTTP_PROXY` / `HTTPS_PROXY`에 적용됩니다. SOCKS5 URL은 내장 SOCKS5 터널을 사용하고 `ALL_PROXY`에도 적용되며(`ocx start --socks5`), 이 프로세스에서 상속된 `HTTP(S)_PROXY`를 지웁니다. 루프백은 `NO_PROXY`에 그대로 남습니다. | | `emptyCompletionRetry?` | `boolean` | `false` | 텍스트나 도구 호출이 없는 Responses 턴을, 터미널 이벤트 전에 스트림이 종료된 경우를 포함해 동일한 요청으로 한 번 재시도하도록 선택합니다. 재시도에는 비용이 발생할 수 있습니다. `OCX_EMPTY_COMPLETION_RETRY=0`은 설정을 바꾸지 않고 비활성화하며, combo 및 routed-compaction turn은 제외됩니다. | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Canonical Codex Responses 응답의 선택적 safety-buffering 헤더 두 개와 SSE 힌트를 제거합니다. 공급자의 안전 정책이나 거절 응답은 바뀌지 않습니다. Native WS 메타데이터와 compact는 제외됩니다. | | `stallTimeoutSec?` | `number` | `300` | Responses 및 네이티브 Chat에서 유효한 업스트림 진행이 없는 시간(초). 최소 1초. | diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index e52ca2ef154..9289feae577 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -16,16 +16,18 @@ optionally installs the Codex autostart shim. ## Proxy lifecycle -### `ocx start [--port ] [--socks5 [host:port]]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` Start the proxy server (preferred port `10100`). If that port is occupied, opencodex selects and records another available port. It writes PID/runtime-port state and refuses to start a second live instance. On start it syncs each provider's models into Codex's catalog. On shutdown it restores native Codex — unless it was launched as a managed service (`OCX_SERVICE=1`). -`--socks5` (default `127.0.0.1:10808`) saves `config.proxy` as a SOCKS URL so outbound provider -calls use `ALL_PROXY`. `--socks5-off` clears that saved proxy. The value survives `ocx update` -because it lives in config, not in the installed package. +`--socks5` (default `127.0.0.1:10808`) saves `config.proxy` as a SOCKS5 URL and routes outbound +HTTP(S) through a real SOCKS5 tunnel. `--socks5-off` clears only that saved SOCKS5 proxy; it +does not remove an HTTP proxy. The value survives `ocx update` because it lives in config, not in +the installed package. A proxy username and password may be included in the URL, but startup +logs redact them. ```bash ocx start diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index b6f961c2cf0..ee094e156e3 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 SOCKS proxy URL (`socks5://host:port`), `${ENV_VAR}`, or `"auto"`. HTTP URLs apply to `HTTP_PROXY` / `HTTPS_PROXY` when those are unset. SOCKS URLs apply to `ALL_PROXY` (`ocx start --socks5`) and clear inherited `HTTP(S)_PROXY` in this process so fetch does not HTTP-CONNECT a SOCKS listener. Loopback stays in `NO_PROXY`. `"auto"` reads the Windows system proxy (WinINET `ProxyEnable`/`ProxyServer`, `https=` then `http=` entry) once at process start and logs the host it chose. 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`, `https=` then `http=` entry) once at process start and logs the host it chose. 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. | diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 420630f9534..990d364e5aa 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -18,7 +18,7 @@ opencodex и его интеграцию с Codex. ## Жизненный цикл прокси -### `ocx start [--port ] [--socks5 [host:port]]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` Запустить proxy server (предпочтительный порт `10100`). Если этот порт занят, opencodex выбирает и записывает другой свободный порт. При запуске пишется состояние PID/runtime-port, а попытка @@ -26,6 +26,12 @@ opencodex и его интеграцию с Codex. провайдера в каталог Codex. При shutdown он восстанавливает native Codex — если только прокси не был запущен как managed service (`OCX_SERVICE=1`). +`--socks5` (по умолчанию `127.0.0.1:10808`) сохраняет SOCKS5 URL в `config.proxy` и направляет +исходящие HTTP(S)-запросы через настоящий SOCKS5-туннель. `--socks5-off` удаляет только сохранённый +SOCKS5-прокси и не удаляет HTTP-прокси. Значение переживает `ocx update`, поскольку хранится в +конфигурации, а не в установленном пакете. Имя пользователя и пароль можно указать в URL, но в +startup-логах они скрываются. + ```bash ocx start ocx start --port 8080 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 932d571643e..e7de12e052a 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -12,7 +12,7 @@ description: Listener, удалённый доступ, admission key, тайм | --- | --- | --- | --- | | `port` | `number` | `10100` | Порт, который слушает прокси. | | `hostname?` | `string` | `"127.0.0.1"` | Адрес bind'а. Не-loopback bind требует `OPENCODEX_API_AUTH_TOKEN`. | -| `proxy?` | `string` | — | URL исходящего HTTP(S) или SOCKS-прокси (`socks5://host:port`) или `${ENV_VAR}`. HTTP URL пишутся в `HTTP_PROXY` / `HTTPS_PROXY`, если те не заданы. SOCKS URL пишутся в `ALL_PROXY` (`ocx start --socks5`) и сбрасывают унаследованные `HTTP(S)_PROXY` в этом процессе. Loopback всегда остаётся в `NO_PROXY`. | +| `proxy?` | `string` | — | URL исходящего HTTP(S) или SOCKS5-прокси (`socks5://host:port`) или `${ENV_VAR}`. HTTP URL пишутся в `HTTP_PROXY` / `HTTPS_PROXY`, если те не заданы. SOCKS5 используют встроенный SOCKS5-туннель и также пишутся в `ALL_PROXY` (`ocx start --socks5`); унаследованные `HTTP(S)_PROXY` сбрасываются в этом процессе. Loopback всегда остаётся в `NO_PROXY`. | | `emptyCompletionRetry?` | `boolean` | `false` | Явно включает один идентичный повтор Responses, если в turn нет ни текста, ни tool call, включая случай, когда stream завершается до terminal event. Повтор может тарифицироваться. `OCX_EMPTY_COMPLETION_RETRY=0` отключает его без изменения config; combo и routed-compaction turn исключены. | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Удаляет подсказки Codex safety-buffering из passthrough-ответов Codex Responses: заголовки `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model`, SSE-события `response.metadata` типа `safety_buffering` и поле `safety_buffering` в других SSE-событиях. Codex TUI отображает их как предложение повторить запрос с более быстрой моделью, действие по умолчанию в котором переключает сессию на более слабую модель. Остальные заголовки `x-codex-*` и содержимое других SSE-событий передаются без изменений, кроме удаления этого поля. По умолчанию выключено. | | `stallTimeoutSec?` | `number` | `300` | Секунды без полезного прогресса upstream для Responses и нативного Chat. Минимум 1. | diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index ef92f55738b..047dd4c7797 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -19,7 +19,7 @@ otomatik başlatma dolgusunu kurar. ## Proxy yaşam döngüsü -### `ocx start [--port ] [--socks5 [host:port]]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` Proxy sunucusunu başlatın (tercih edilen port `10100`). Bu port doluysa opencodex başka bir kullanılabilir port seçer ve kaydeder. PID/çalışma zamanı @@ -28,6 +28,11 @@ her sağlayıcının modellerini Codex'in kataloğuna senkronize eder. Kapatıld — yönetilen bir servis olarak başlatılmadığı sürece (`OCX_SERVICE=1`) — yerel Codex'i geri yükler. +`--socks5` (varsayılan `127.0.0.1:10808`) SOCKS5 URL'sini `config.proxy` içine kaydeder ve giden +HTTP(S) isteklerini gerçek bir SOCKS5 tünelinden yönlendirir. `--socks5-off` yalnızca kaydedilmiş +SOCKS5 proxy'sini temizler; HTTP proxy'sini silmez. Değer yapılandırmada tutulduğu için `ocx update` +sonrasında da korunur. URL kullanıcı adı ve parola içerebilir, ancak başlangıç günlüklerinde gizlenir. + ```bash ocx start ocx start --port 8080 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 89c3aabba32..4472046cdc9 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -13,7 +13,7 @@ yardımcı özellikleri nasıl çalıştıracağını kontrol eder. | --- | --- | --- | --- | | `port` | `number` | `10100` | Proxy dinleme portu. | | `hostname?` | `string` | `"127.0.0.1"` | Bağlama adresi. Geri döngü olmayan bağlamalar `OPENCODEX_API_AUTH_TOKEN` gerektirir. | -| `proxy?` | `string` | — | Giden HTTP(S) veya SOCKS proxy URL'si (`socks5://host:port`) ya da `${ENV_VAR}`. HTTP URL'leri değişkenler boşsa `HTTP_PROXY` / `HTTPS_PROXY`'ye yazılır. SOCKS URL'leri `ALL_PROXY`'ye yazılır (`ocx start --socks5`) ve bu süreçte miras `HTTP(S)_PROXY`'yi temizler. Geri döngü `NO_PROXY` içinde kalır. | +| `proxy?` | `string` | — | Giden HTTP(S) veya SOCKS5 proxy URL'si (`socks5://host:port`) ya da `${ENV_VAR}`. HTTP URL'leri değişkenler boşsa `HTTP_PROXY` / `HTTPS_PROXY`'ye yazılır. SOCKS5 URL'leri yerleşik gerçek SOCKS5 tünelini kullanır ve `ALL_PROXY`'ye de yazılır (`ocx start --socks5`); bu süreçte miras `HTTP(S)_PROXY` temizlenir. Geri döngü `NO_PROXY` içinde kalır. | | `emptyCompletionRetry?` | `boolean` | `false` | Metin veya araç çağrısı içermeyen bir Responses tamamlamasını aynı istekle bir kez yeniden denemeyi açıkça etkinleştirir. Yeniden deneme ücretlendirilebilir. `OCX_EMPTY_COMPLETION_RETRY=0`, yapılandırmayı değiştirmeden devre dışı bırakır; combo ve routed-compaction turları hariçtir. | | `stallTimeoutSec?` | `number` | `300` | Responses ve yerel Chat için anlamlı üst sunucu ilerlemesi olmadan geçen saniye. En az 1. | | `connectTimeoutMs?` | `number` | `200000` | Deneme başına DNS/TCP/TLS/nihai başlık son tarihi; gövde üretiminden önce biter. | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 9bd030950d3..101b511ad44 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -13,10 +13,12 @@ description: 安装、启动、停止、服务、诊断、同步和更新命令 ## 代理生命周期 -### `ocx start [--port ] [--socks5 [host:port]]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` 启动代理服务器(首选端口 `10100`)。如果该端口已被占用,opencodex 会选择并记录另一个可用端口。它会写入 PID/运行时端口状态,并拒绝启动第二个存活实例。启动时,它会把每个提供方的模型同步到 Codex 的目录中。关闭时,它会恢复原生 Codex,除非它是作为受管服务启动的(`OCX_SERVICE=1`)。 +`--socks5`(默认 `127.0.0.1:10808`)会将 SOCKS5 URL 保存到 `config.proxy`,并通过真正的 SOCKS5 隧道转发出站 HTTP(S) 请求。`--socks5-off` 只会清除已保存的 SOCKS5 代理,不会删除 HTTP 代理。该值保存在配置中,因此会在 `ocx update` 后保留。URL 可以包含用户名和密码,但启动日志会将其隐藏。 + ```bash ocx start ocx start --port 8080 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 8ce61b354d0..ef0699f2045 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 @@ -12,7 +12,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | --- | --- | --- | --- | | `port` | `number` | `10100` | 代理监听端口。 | | `hostname?` | `string` | `"127.0.0.1"` | 绑定地址。非回环绑定需要 `OPENCODEX_API_AUTH_TOKEN`。 | -| `proxy?` | `string` | — | 出站 HTTP(S) 或 SOCKS 代理 URL(`socks5://host:port`),或 `${ENV_VAR}`。HTTP URL 仅在未设置时写入 `HTTP_PROXY` / `HTTPS_PROXY`。SOCKS URL 写入 `ALL_PROXY`(`ocx start --socks5`),并清除本进程继承的 `HTTP(S)_PROXY`。回环地址始终保留在 `NO_PROXY` 中。 | +| `proxy?` | `string` | — | 出站 HTTP(S) 或 SOCKS5 代理 URL(`socks5://host:port`),或 `${ENV_VAR}`。HTTP URL 仅在未设置时写入 `HTTP_PROXY` / `HTTPS_PROXY`。SOCKS5 URL 使用内置的真实 SOCKS5 隧道,也会写入 `ALL_PROXY`(`ocx start --socks5`);并清除本进程继承的 `HTTP(S)_PROXY`。回环地址始终保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 显式启用:当 Responses turn 既无文本也无工具调用时,使用相同请求重试一次,包括流在终止事件之前结束的情况。重试可能产生费用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不修改配置的情况下禁用;combo 与 routed-compaction turn 不参与。 | | `dropCodexSafetyBuffering?` | `boolean` | `false` | 从 Codex Responses 透传响应中移除 Codex safety-buffering 提示:`x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` 响应头、类型为 `safety_buffering` 的 `response.metadata` SSE 事件,以及其他 SSE 事件中的 `safety_buffering` 字段。Codex TUI 会将这些提示显示为“使用更快模型重试”的提示框,其默认操作会把会话切换到较弱的模型。其他 `x-codex-*` 响应头和其他所有 SSE 事件内容均保持不变,但会移除该字段。默认关闭。 | | `stallTimeoutSec?` | `number` | `300` | 上游无有效进展的秒数,适用于 Responses 和原生 Chat;最小 1 秒。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index b8a5310f806..80638b9ba03 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -13,10 +13,12 @@ description: 安裝、啟動、停止、服務、診斷、同步與更新指令 ## 代理生命週期 -### `ocx start [--port ] [--socks5 [host:port]]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` 啟動代理伺服器(偏好連接埠 `10100`)。若該連接埠被佔用,opencodex 會選擇並記錄另一個可用連接埠。它寫入 PID/runtime-port 狀態,並拒絕啟動第二個即時實例。啟動時它將每個供應商的模型同步到 Codex 目錄。關閉時它還原原生 Codex——除非它是作為受管服務啟動的(`OCX_SERVICE=1`)。 +`--socks5`(預設 `127.0.0.1:10808`)會將 SOCKS5 URL 儲存到 `config.proxy`,並透過真正的 SOCKS5 通道轉送對外 HTTP(S) 請求。`--socks5-off` 只會清除已儲存的 SOCKS5 代理,不會刪除 HTTP 代理。此值儲存在設定中,因此會在 `ocx update` 後保留。URL 可以包含使用者名稱和密碼,但啟動記錄會隱藏它們。 + ```bash ocx start ocx start --port 8080 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 7c1ae37a0ec..5fb998fb33f 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 @@ -11,7 +11,7 @@ description: 監聽器、遠端存取、許可金鑰、逾時、儲存、sidecar | --- | --- | --- | --- | | `port` | `number` | `10100` | 代理監聽連接埠。 | | `hostname?` | `string` | `"127.0.0.1"` | 綁定位址。非回送綁定需要 `OPENCODEX_API_AUTH_TOKEN`。 | -| `proxy?` | `string` | — | 對外 HTTP(S) 或 SOCKS 代理 URL(`socks5://host:port`)或 `${ENV_VAR}`。HTTP URL 僅在那些變數未設定時套用至 `HTTP_PROXY` / `HTTPS_PROXY`。SOCKS URL 套用至 `ALL_PROXY`(`ocx start --socks5`),並清除此行程繼承的 `HTTP(S)_PROXY`。回送保留在 `NO_PROXY` 中。 | +| `proxy?` | `string` | — | 對外 HTTP(S) 或 SOCKS5 代理 URL(`socks5://host:port`)或 `${ENV_VAR}`。HTTP URL 僅在那些變數未設定時套用至 `HTTP_PROXY` / `HTTPS_PROXY`。SOCKS5 URL 使用內建的真實 SOCKS5 通道,也會套用至 `ALL_PROXY`(`ocx start --socks5`),並清除此行程繼承的 `HTTP(S)_PROXY`。回送保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 明確啟用:當 Responses 完成時沒有文字或工具呼叫,以相同請求重試一次。重試可能產生費用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不變更設定的情況下停用;combo 與 routed-compaction turn 不適用。 | | `stallTimeoutSec?` | `number` | `300` | 上游無有效進展的秒數,適用於 Responses 與原生 Chat;最小 1 秒。 | | `connectTimeoutMs?` | `number` | `200000` | 每次嘗試的 DNS/TCP/TLS/final-header 截止時間;它在 body 生成前結束。 | diff --git a/src/cli/help.ts b/src/cli/help.ts index 29f3e7ea6d9..0be199f1a5a 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -27,8 +27,8 @@ export function printUsage(): void { Usage: ocx setup Interactive setup (alias: init) - ocx start [--port ] [--socks5 [host:port]] - Start the proxy (optional SOCKS5 outbound, default 127.0.0.1:10808) + ocx start [--port ] [--socks5 [host:port] | --socks5-off] + Start the proxy; SOCKS5 defaults to 127.0.0.1:10808 ocx stop Stop the proxy AND restore native Codex (plain codex works again) ocx restore Restore native Codex without stopping (alias: eject) ocx restore back Re-point codex at the running proxy (undo restore) @@ -104,6 +104,7 @@ Examples: ocx start Start on default port (10100) ocx start --port 8080 Start on custom port ocx start --socks5 Outbound via SOCKS5 at 127.0.0.1:10808 (saved) + ocx start --socks5-off Clear a saved SOCKS5 outbound proxy ocx help service Show service command help ocx help hub Explain the hub topology, token file, and invites ocx sync Sync available models to Codex`); diff --git a/src/cli/index.ts b/src/cli/index.ts index 482d3f7be50..86150097921 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -64,7 +64,8 @@ import { } from "./tray-proxy"; import { requestBoundSystemRestart } from "./system-restart-client"; import { installCrashGuards } from "../lib/crash-guard"; -import { dispatchCommand , decideStartWithLiveOwner } from "./dispatch"; +import { redactUrlForLog } from "../lib/redact"; +import { dispatchCommand, decideStartWithLiveOwner } from "./dispatch"; import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import { createReadinessGate } from "../server/readiness"; @@ -303,18 +304,22 @@ async function handleStart(options: { block?: boolean } = {}) { const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim(); if (present) assertNotAdminToken(present); const startOpts = parseStartCliOptions(); - if (startOpts.socks5 !== undefined) { + if (startOpts.socks5 !== undefined || startOpts.socks5Off) { const proxyConfig = loadConfig(); - if (startOpts.socks5 === null) { + if (startOpts.socks5Off) { + if (proxyConfig.proxy && !/^socks5h?:\/\//i.test(proxyConfig.proxy.trim())) { + console.error("Cannot use --socks5-off: config.proxy is not a SOCKS5 URL; it was left unchanged."); + process.exit(1); + } if (proxyConfig.proxy) { delete proxyConfig.proxy; saveConfig(proxyConfig); - console.log("Cleared config.proxy (outbound SOCKS5/HTTP proxy off)."); + console.log("Cleared config.proxy (outbound SOCKS5 proxy off)."); } } else { - proxyConfig.proxy = startOpts.socks5; + proxyConfig.proxy = startOpts.socks5!; saveConfig(proxyConfig); - console.log(`Outbound SOCKS5: ${startOpts.socks5} (saved to config.proxy)`); + console.log(`Outbound SOCKS5: ${redactUrlForLog(startOpts.socks5!)} (saved to config.proxy)`); } } const requestedPort = startOpts.port; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 06f843c608f..f183635b0cc 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -22,11 +22,11 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "start", - usage: "ocx start [--port ] [--socks5 [host:port]]", + usage: "ocx start [--port ] [--socks5 [host:port] | --socks5-off]", summary: "Start the proxy server and sync models to Codex.", details: [ "--socks5 [host:port] Route outbound provider traffic through SOCKS5 (default 127.0.0.1:10808). Saved as config.proxy.", - "--socks5-off Clear a saved SOCKS5/HTTP outbound proxy from config.proxy.", + "--socks5-off Clear a saved SOCKS5 outbound proxy from config.proxy.", ], }, { name: "stop", usage: "ocx stop", summary: "Stop the proxy and restore native Codex config." }, diff --git a/src/cli/start-args.ts b/src/cli/start-args.ts index 8f467aa2bcc..0c767ea512c 100644 --- a/src/cli/start-args.ts +++ b/src/cli/start-args.ts @@ -1,5 +1,5 @@ -export const START_USAGE = "Usage: ocx start [--port ] [--socks5 [host:port]]"; -export const START_USAGE_LINE = "ocx start [--port ] [--socks5 [host:port]]"; +export const START_USAGE = "Usage: ocx start [--port ] [--socks5 [host:port] | --socks5-off]"; +export const START_USAGE_LINE = "ocx start [--port ] [--socks5 [host:port] | --socks5-off]"; export const DEFAULT_SOCKS5_PROXY = "socks5://127.0.0.1:10808"; export class StartArgsError extends Error { @@ -11,18 +11,33 @@ export class StartArgsError extends Error { export type StartOptions = { port?: number; - /** SOCKS5 URL to persist, or `null` to clear `config.proxy`. */ - socks5?: string | null; + /** SOCKS5 URL to persist. */ + socks5?: string; + socks5Off?: boolean; }; export function isSocksProxyUrl(proxy: string): boolean { - return /^(socks5h?|socks4a?):\/\//i.test(proxy.trim()); + return /^socks5h?:\/\//i.test(proxy.trim()); } export function normalizeSocks5(raw: string): string { const trimmed = raw.trim(); if (!trimmed) return DEFAULT_SOCKS5_PROXY; - if (isSocksProxyUrl(trimmed)) return trimmed; + if (isSocksProxyUrl(trimmed)) { + try { + const parsed = new URL(trimmed); + const port = Number(parsed.port); + if (!parsed.hostname || !parsed.port || !Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error("invalid host or port"); + } + return trimmed; + } catch { + throw new StartArgsError(`Invalid SOCKS5 address: ${trimmed}`); + } + } + if (/^socks4a?:\/\//i.test(trimmed)) { + throw new StartArgsError("Only SOCKS5 proxy URLs are supported"); + } if (/^https?:\/\//i.test(trimmed)) { throw new StartArgsError("SOCKS5 proxy must be socks5://host:port, not an HTTP URL"); } @@ -58,6 +73,7 @@ export function parseStartOptions(argv: string[]): StartOptions { continue; } if (arg === "--socks5") { + if (options.socks5Off) throw new StartArgsError("--socks5 and --socks5-off cannot be used together"); const next = argv[i + 1]; if (next && !next.startsWith("-")) { options.socks5 = normalizeSocks5(next); @@ -68,7 +84,8 @@ export function parseStartOptions(argv: string[]): StartOptions { continue; } if (arg === "--socks5-off") { - options.socks5 = null; + if (options.socks5 !== undefined) throw new StartArgsError("--socks5 and --socks5-off cannot be used together"); + options.socks5Off = true; continue; } throw new StartArgsError(START_USAGE); diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index a20bca1e303..7504ecff10e 100644 --- a/src/config/proxy-env.ts +++ b/src/config/proxy-env.ts @@ -1,3 +1,4 @@ +import { configureSocks5Fetch } from "../lib/proxy-env"; import { join } from "node:path"; import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./subagent-models"; import { MULTI_AGENT_SURFACE_ADVISORY_VERSION } from "./multi-agent-surface"; @@ -123,6 +124,7 @@ export function applyProxyEnvWith( let proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined; if (!proxy) { if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy"); + configureSocks5Fetch(); return; } if (proxy.trim().toLowerCase() === "auto") { @@ -151,7 +153,10 @@ export function applyProxyEnvWith( } } if (proxy) { - if (/^(socks5h?|socks4a?):\/\//i.test(proxy.trim())) { + if (/^socks/i.test(proxy.trim()) && !/^socks5h?:\/\//i.test(proxy.trim())) { + throw new Error("Only SOCKS5 proxy URLs are supported; use socks5://host:port"); + } + if (/^socks5h?:\/\//i.test(proxy.trim())) { for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"] as const) { delete process.env[key]; } @@ -191,5 +196,6 @@ export function applyProxyEnvWith( } } process.env.NO_PROXY = entries.join(","); + configureSocks5Fetch(); } diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 334f46dad56..87bfa94584b 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -7,7 +7,7 @@ import { resolvePublicAddresses, } from "./destination-policy"; import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; -import { effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env"; +import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; type ProviderGetInit = Omit; @@ -184,7 +184,7 @@ async function providerOutboundRequest( if (!proxyConfigured) throw error; warnProxyBoundaryOnce(); warnProxyDnsDegradationOnce(); - return globalThis.fetch(url, { ...init, method, redirect: "manual" }); + return configuredOutboundFetch(url, { ...init, method, redirect: "manual" }); } // A canonical TUN exception with no scheme-matched proxy must retain the // validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present. @@ -193,7 +193,7 @@ async function providerOutboundRequest( // When the Mihomo exception could have admitted an answer, pin the transport to the // proxy the admission assumed instead of letting fetch re-infer it from the environment. const proxy = (allowMihomoIpv6FakeIp && effectiveProxy) ? effectiveProxy : undefined; - return globalThis.fetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) }); + return configuredOutboundFetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) }); } if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) { const hostname = normalizeProxyHostname(parsed.hostname); diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index 0ac9ed735c8..aebf62730bc 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -1,3 +1,5 @@ +import { socks5Fetch } from "./socks5-fetch"; + export const OUTBOUND_PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"] as const; export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const; @@ -85,11 +87,11 @@ export function outboundProxyConfigured( } /** - * The proxy URL that Bun's fetch will actually use for `url`, or null when none applies. + * The proxy URL selected by configured outbound fetch for `url`, or null when none applies. * * Bun selects by scheme: `HTTPS_PROXY` for `https:` targets, `HTTP_PROXY` for `http:`. - * `ALL_PROXY` is deliberately not consulted here — fetch does not honour it, so a caller - * that needs "this request will ride the proxy" as a precondition must not count it. + * A SOCKS5 `ALL_PROXY` is selected by the explicit wrapper first; other ALL_PROXY + * schemes remain excluded because the native HTTP fetch does not honor them. * Presence of *some* proxy variable (`outboundProxyConfigured`) is not that guarantee. */ export function effectiveProxyFor( @@ -102,6 +104,71 @@ export function effectiveProxyFor( ? "HTTP_PROXY" : null; if (!key) return null; + // The installed SOCKS wrapper takes this route before Bun sees scheme proxies. + const socksProxy = socks5ProxyFromEnv(env); + if (socksProxy) return socksProxy; const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); return value ? value : null; } + +export function isSocks5ProxyUrl(proxy: string): boolean { + return /^socks5h?:\/\//i.test(proxy.trim()); +} + +export function socks5ProxyFromEnv(env: ProxyEnvMap = process.env): string | undefined { + const candidates = [env.ALL_PROXY, env.all_proxy]; + return candidates.find(value => typeof value === "string" && isSocks5ProxyUrl(value)); +} + +export function configuredOutboundFetch( + input: RequestInfo | URL, + init?: RequestInit, + fallback?: typeof globalThis.fetch, +): Promise { + const base = fallback ?? (globalThis.fetch === installedFetch ? nativeFetch : globalThis.fetch); + const explicitProxy = (init as (RequestInit & { proxy?: string }) | undefined)?.proxy; + const proxy = typeof explicitProxy === "string" + ? (isSocks5ProxyUrl(explicitProxy) ? explicitProxy : undefined) + : socks5ProxyFromEnv(); + let url: URL; + try { + url = new URL(input instanceof Request ? input.url : String(input)); + } catch { + return base!(input, init); + } + if (proxy && (url.protocol === "http:" || url.protocol === "https:") && (explicitProxy !== undefined || !noProxyMatches(url))) { + return socks5Fetch(input, init, proxy); + } + return base!(input, init); +} + +type FetchWithPreconnect = typeof globalThis.fetch & { + preconnect?: typeof globalThis.fetch.preconnect; +}; + +let nativeFetch: FetchWithPreconnect | undefined; +let installedFetch: FetchWithPreconnect | undefined; + +export function configureSocks5Fetch(): void { + if (globalThis.fetch !== installedFetch) { + nativeFetch = globalThis.fetch as FetchWithPreconnect; + installedFetch = undefined; + } + const proxy = socks5ProxyFromEnv(); + if (!proxy) { + if (installedFetch && globalThis.fetch === installedFetch && nativeFetch) globalThis.fetch = nativeFetch; + installedFetch = undefined; + return; + } + if (installedFetch && globalThis.fetch === installedFetch) return; + const base = nativeFetch ?? globalThis.fetch as FetchWithPreconnect; + nativeFetch = base; + const wrapped = Object.assign( + (input: RequestInfo | URL, init?: RequestInit) => { + return configuredOutboundFetch(input, init, base); + }, + { preconnect: base.preconnect?.bind(base) }, + ) as FetchWithPreconnect; + installedFetch = wrapped; + globalThis.fetch = wrapped; +} diff --git a/src/lib/socks5-fetch.ts b/src/lib/socks5-fetch.ts new file mode 100644 index 00000000000..ad7e44ac330 --- /dev/null +++ b/src/lib/socks5-fetch.ts @@ -0,0 +1,496 @@ +import net, { type Socket } from "node:net"; +import tls, { type TLSSocket } from "node:tls"; + +const DEFAULT_SOCKS5_PORT = 1080; +const SOCKS5_CONNECT_TIMEOUT_MS = 30_000; +const SOCKS5_RESPONSE_TIMEOUT_MS = 200_000; +const MAX_RESPONSE_HEADER_BYTES = 64 * 1024; +const SOCKS5_VERSION = 0x05; +const SOCKS5_NO_AUTH = 0x00; +const SOCKS5_USER_PASS = 0x02; +const SOCKS5_CONNECT = 0x01; +const SOCKS5_DOMAIN = 0x03; +const SOCKS5_SUCCESS = 0x00; +const CRLF = Buffer.from("\r\n"); +const HEADER_END = Buffer.from("\r\n\r\n"); + +export class Socks5FetchError extends Error { + override readonly name = "Socks5FetchError"; +} + +function proxyCredentials(proxy: URL): { username?: Uint8Array; password?: Uint8Array } { + if (!proxy.username && !proxy.password) return {}; + let username: string; + let password: string; + try { + username = decodeURIComponent(proxy.username); + password = decodeURIComponent(proxy.password); + } catch { + throw new Socks5FetchError("SOCKS5 proxy credentials contain invalid percent encoding"); + } + const usernameBytes = new TextEncoder().encode(username); + const passwordBytes = new TextEncoder().encode(password); + if (usernameBytes.byteLength > 255 || passwordBytes.byteLength > 255) { + throw new Socks5FetchError("SOCKS5 proxy credentials must each fit in 255 UTF-8 bytes"); + } + return { username: usernameBytes, password: passwordBytes }; +} + +function validateProxy(proxy: string): URL { + let parsed: URL; + try { + parsed = new URL(proxy); + } catch { + throw new Socks5FetchError("SOCKS5 proxy URL is invalid"); + } + if (parsed.protocol !== "socks5:" && parsed.protocol !== "socks5h:") { + throw new Socks5FetchError(`unsupported SOCKS5 proxy protocol: ${parsed.protocol}`); + } + if (!parsed.hostname) throw new Socks5FetchError("SOCKS5 proxy URL has no host"); + if (parsed.port && (!/^\d+$/.test(parsed.port) || Number(parsed.port) > 65535)) { + throw new Socks5FetchError("SOCKS5 proxy port is invalid"); + } + if (parsed.search || parsed.hash) throw new Socks5FetchError("SOCKS5 proxy URL must not contain a query or fragment"); + proxyCredentials(parsed); + return parsed; +} + +function targetPort(target: URL): number { + if (target.port) return Number(target.port); + return target.protocol === "https:" ? 443 : 80; +} + +function connectSocket(hostname: string, port: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + return; + } + const socket = net.createConnection({ host: hostname, port }); + let settled = false; + let timer: ReturnType | undefined = setTimeout(() => { + fail(new Socks5FetchError("SOCKS5 proxy connection timed out")); + }, SOCKS5_CONNECT_TIMEOUT_MS); + const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + const onConnect = () => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + cleanup(); + resolve(socket); + }; + const onError = (error: Error) => fail(error); + const onClose = () => fail(new Socks5FetchError("SOCKS5 proxy closed before connecting")); + const cleanup = () => { + signal?.removeEventListener("abort", onAbort); + socket.removeListener("connect", onConnect); + socket.removeListener("error", onError); + socket.removeListener("close", onClose); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + cleanup(); + socket.destroy(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + socket.once("connect", onConnect); + socket.once("error", onError); + socket.once("close", onClose); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +class SocketReader { + private buffer = Buffer.alloc(0); + private ended = false; + private readonly exactWaiters: Array<{ + length: number; + resolve: (value: Buffer) => void; + reject: (error: unknown) => void; + }> = []; + private readonly anyWaiters: Array<{ + resolve: (value: Buffer) => void; + reject: (error: unknown) => void; + }> = []; + + constructor(private readonly socket: Socket) { + socket.on("data", this.onData); + socket.once("error", this.onError); + socket.once("end", this.onEnd); + socket.once("close", this.onEnd); + } + + private readonly onData = (chunk: Buffer | string): void => { + const value = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + if (this.anyWaiters.length > 0) { + this.anyWaiters.shift()!.resolve(value); + return; + } + this.buffer = Buffer.concat([this.buffer, value]); + this.flushExact(); + }; + + private readonly onError = (error: Error): void => { + this.ended = true; + this.rejectExact(error); + this.rejectAny(error); + }; + + private readonly onEnd = (): void => { + this.ended = true; + this.rejectExact(new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived")); + while (this.anyWaiters.length > 0) this.anyWaiters.shift()!.resolve(Buffer.alloc(0)); + }; + + private flushExact(): void { + while (this.exactWaiters.length > 0 && this.buffer.byteLength >= this.exactWaiters[0]!.length) { + const waiter = this.exactWaiters.shift()!; + const value = this.buffer.subarray(0, waiter.length); + this.buffer = this.buffer.subarray(waiter.length); + waiter.resolve(value); + } + } + + private rejectExact(error: unknown): void { + while (this.exactWaiters.length > 0) this.exactWaiters.shift()!.reject(error); + } + + private rejectAny(error: unknown): void { + while (this.anyWaiters.length > 0) this.anyWaiters.shift()!.reject(error); + } + + read(length: number, signal?: AbortSignal): Promise { + if (this.buffer.byteLength >= length) { + const value = this.buffer.subarray(0, length); + this.buffer = this.buffer.subarray(length); + return Promise.resolve(value); + } + if (this.ended) return Promise.reject(new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived")); + return new Promise((resolve, reject) => { + const waiter = { length, resolve, reject }; + const onAbort = () => { + signal?.removeEventListener("abort", onAbort); + const index = this.exactWaiters.indexOf(waiter); + if (index >= 0) this.exactWaiters.splice(index, 1); + reject(signal?.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + this.exactWaiters.push({ + length, + resolve: value => { + signal?.removeEventListener("abort", onAbort); + resolve(value); + }, + reject: error => { + signal?.removeEventListener("abort", onAbort); + reject(error); + }, + }); + }); + } + + async readUntil(delimiter: Buffer, maxBytes: number, signal?: AbortSignal): Promise { + while (true) { + const index = this.buffer.indexOf(delimiter); + if (index >= 0) { + const end = index + delimiter.byteLength; + const value = this.buffer.subarray(0, end); + this.buffer = this.buffer.subarray(end); + return value; + } + if (this.buffer.byteLength > maxBytes) throw new Socks5FetchError("SOCKS5 upstream response headers are too large"); + const chunk = await this.readAny(signal); + if (chunk.byteLength === 0) throw new Socks5FetchError("SOCKS5 upstream closed before response headers"); + this.buffer = Buffer.concat([this.buffer, chunk]); + } + } + + readAny(signal?: AbortSignal): Promise { + if (this.buffer.byteLength > 0) { + const value = this.buffer; + this.buffer = Buffer.alloc(0); + return Promise.resolve(value); + } + if (this.ended) return Promise.resolve(Buffer.alloc(0)); + return new Promise((resolve, reject) => { + const waiter = { resolve, reject }; + const onAbort = () => { + signal?.removeEventListener("abort", onAbort); + const index = this.anyWaiters.indexOf(waiter); + if (index >= 0) this.anyWaiters.splice(index, 1); + reject(signal?.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + this.anyWaiters.push({ + resolve: value => { + signal?.removeEventListener("abort", onAbort); + resolve(value); + }, + reject: error => { + signal?.removeEventListener("abort", onAbort); + reject(error); + }, + }); + }); + } + + dispose(): void { + this.socket.removeListener("data", this.onData); + this.socket.removeListener("error", this.onError); + this.socket.removeListener("end", this.onEnd); + this.socket.removeListener("close", this.onEnd); + if (this.buffer.byteLength > 0) this.socket.unshift(this.buffer); + this.rejectExact(new Socks5FetchError("SOCKS5 reader disposed")); + this.rejectAny(new Socks5FetchError("SOCKS5 reader disposed")); + } +} + +async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): Promise { + const parsedProxy = validateProxy(proxy); + const credentials = proxyCredentials(parsedProxy); + const socket = await connectSocket(parsedProxy.hostname, Number(parsedProxy.port) || DEFAULT_SOCKS5_PORT, signal); + socket.setTimeout(SOCKS5_CONNECT_TIMEOUT_MS, () => { + socket.destroy(new Socks5FetchError("SOCKS5 handshake timed out")); + }); + const reader = new SocketReader(socket); + try { + const methods = credentials.username ? Buffer.from([SOCKS5_NO_AUTH, SOCKS5_USER_PASS]) : Buffer.from([SOCKS5_NO_AUTH]); + socket.write(Buffer.from([SOCKS5_VERSION, methods.byteLength, ...methods])); + const greeting = await reader.read(2, signal); + if (greeting[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid greeting"); + if (greeting[1] === SOCKS5_USER_PASS && credentials.username && credentials.password) { + socket.write(Buffer.from([ + 0x01, + credentials.username.byteLength, + ...credentials.username, + credentials.password.byteLength, + ...credentials.password, + ])); + const auth = await reader.read(2, signal); + if (auth[0] !== 0x01 || auth[1] !== 0x00) throw new Socks5FetchError("SOCKS5 proxy authentication failed"); + } else if (greeting[1] !== SOCKS5_NO_AUTH) { + throw new Socks5FetchError("SOCKS5 proxy does not accept an offered authentication method"); + } + + const hostname = new TextEncoder().encode(target.hostname); + if (hostname.byteLength > 255) throw new Socks5FetchError("SOCKS5 target hostname is too long"); + const port = targetPort(target); + socket.write(Buffer.from([ + SOCKS5_VERSION, + SOCKS5_CONNECT, + 0x00, + SOCKS5_DOMAIN, + hostname.byteLength, + ...hostname, + port >> 8, + port & 0xff, + ])); + const reply = await reader.read(4, signal); + if (reply[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid connect response"); + if (reply[1] !== SOCKS5_SUCCESS) throw new Socks5FetchError(`SOCKS5 proxy refused the connection (code ${reply[1]})`); + const addressLength = reply[3] === 0x01 ? 4 : reply[3] === SOCKS5_DOMAIN ? (await reader.read(1, signal))[0]! : 16; + await reader.read(addressLength + 2, signal); + socket.setTimeout(0); + return socket; + } catch (error) { + socket.destroy(); + throw error; + } finally { + reader.dispose(); + } +} + +function requestHeaders(request: Request, target: URL): { text: string; chunked: boolean } { + const headers = new Headers(request.headers); + if (!headers.has("host")) headers.set("host", target.host); + if (!headers.has("connection")) headers.set("connection", "close"); + const chunked = request.body !== null + && !headers.has("content-length") + && !headers.has("transfer-encoding"); + if (chunked) headers.set("transfer-encoding", "chunked"); + return { + text: [...headers.entries()].map(([key, value]) => `${key}: ${value}\r\n`).join(""), + chunked, + }; +} + +async function secureSocket(socket: Socket, target: URL, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const tlsSocket = tls.connect({ + socket, + servername: target.hostname, + rejectUnauthorized: true, + }); + const onAbort = () => { + tlsSocket.destroy(); + reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + }; + const onSecureConnect = () => { + signal.removeEventListener("abort", onAbort); + resolve(tlsSocket); + }; + tlsSocket.once("secureConnect", onSecureConnect); + tlsSocket.once("error", error => { + signal.removeEventListener("abort", onAbort); + reject(error); + }); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function parseResponseHead(raw: Buffer): { status: number; statusText: string; headers: Headers } { + const text = raw.toString("latin1"); + const lines = text.split("\r\n"); + const statusLine = lines.shift() ?? ""; + const match = /^HTTP\/\d(?:\.\d)?\s+(\d{3})(?:\s+(.*))?$/.exec(statusLine); + if (!match) throw new Socks5FetchError("SOCKS5 upstream returned an invalid HTTP response"); + const headers = new Headers(); + for (const line of lines) { + if (!line) continue; + const separator = line.indexOf(":"); + if (separator <= 0) throw new Socks5FetchError("SOCKS5 upstream returned an invalid HTTP header"); + headers.append(line.slice(0, separator), line.slice(separator + 1).trim()); + } + return { status: Number(match[1]), statusText: match[2] ?? "", headers }; +} + +function responseBody( + reader: SocketReader, + socket: Socket, + signal: AbortSignal, + headers: Headers, + status: number, + method: string, +): ReadableStream | null { + if (method === "HEAD" || status === 204 || status === 304 || (status >= 100 && status < 200)) return null; + const lengthHeader = headers.get("content-length"); + const contentLength = lengthHeader === null ? undefined : Number(lengthHeader); + if (contentLength !== undefined && (!Number.isSafeInteger(contentLength) || contentLength < 0)) { + throw new Socks5FetchError("SOCKS5 upstream returned an invalid content-length"); + } + const chunked = (headers.get("transfer-encoding") ?? "").toLowerCase().split(",").some(value => value.trim() === "chunked"); + let remaining = contentLength; + let chunkRemaining = 0; + let complete = false; + const finish = () => { + if (complete) return; + complete = true; + signal.removeEventListener("abort", onAbort); + reader.dispose(); + }; + const onAbort = () => socket.destroy(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + signal.addEventListener("abort", onAbort, { once: true }); + const readChunk = async (): Promise => { + if (chunked) { + if (chunkRemaining === 0) { + const line = (await reader.readUntil(CRLF, MAX_RESPONSE_HEADER_BYTES, signal)).subarray(0, -2).toString("ascii"); + const size = Number.parseInt(line.split(";", 1)[0]!.trim(), 16); + if (!Number.isSafeInteger(size) || size < 0) throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk size"); + if (size === 0) { + while (true) { + const trailer = await reader.readUntil(CRLF, MAX_RESPONSE_HEADER_BYTES, signal); + if (trailer.equals(CRLF)) break; + } + return null; + } + chunkRemaining = size; + } + const value = await reader.read(chunkRemaining, signal); + chunkRemaining = 0; + const ending = await reader.read(2, signal); + if (!ending.equals(CRLF)) throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk terminator"); + return value; + } + if (remaining !== undefined) { + if (remaining === 0) return null; + const value = await reader.read(Math.min(remaining, 64 * 1024), signal); + remaining -= value.byteLength; + return value; + } + const value = await reader.readAny(signal); + return value.byteLength === 0 ? null : value; + }; + return new ReadableStream({ + async pull(controller) { + try { + const value = await readChunk(); + if (value === null) { + finish(); + controller.close(); + return; + } + controller.enqueue(value); + } catch (error) { + finish(); + controller.error(error); + socket.destroy(); + } + }, + cancel() { + finish(); + socket.destroy(); + }, + }); +} + +export async function socks5Fetch( + input: RequestInfo | URL, + init: RequestInit | undefined, + proxy: string, +): Promise { + const request = new Request(input, init); + const target = new URL(request.url); + if (target.protocol !== "http:" && target.protocol !== "https:") { + throw new Socks5FetchError(`SOCKS5 fetch only supports HTTP(S) URLs, got ${target.protocol}`); + } + const tunnel = await socks5Connect(proxy, target, request.signal); + let socket: Socket = tunnel; + const onAbort = () => socket.destroy(request.signal.reason instanceof Error ? request.signal.reason : new Error("The operation was aborted")); + request.signal.addEventListener("abort", onAbort, { once: true }); + try { + if (target.protocol === "https:") socket = await secureSocket(tunnel, target, request.signal); + socket.setTimeout(SOCKS5_RESPONSE_TIMEOUT_MS, () => { + socket.destroy(new Socks5FetchError("SOCKS5 upstream request timed out")); + }); + const headers = requestHeaders(request, target); + const head = `${request.method} ${target.pathname}${target.search} HTTP/1.1\r\n${headers.text}\r\n`; + socket.write(head); + if (request.body) { + const bodyReader = request.body.getReader(); + try { + while (true) { + const next = await bodyReader.read(); + if (next.done) break; + const body = headers.chunked + ? Buffer.concat([Buffer.from(`${next.value.byteLength.toString(16)}\r\n`), Buffer.from(next.value), CRLF]) + : next.value; + if (!socket.write(body)) await new Promise(resolve => socket.once("drain", resolve)); + } + if (headers.chunked) socket.write("0\r\n\r\n"); + } finally { + bodyReader.releaseLock(); + } + } + const reader = new SocketReader(socket); + let responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal)); + while (responseHead.status >= 100 && responseHead.status < 200 && responseHead.status !== 101) { + responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal)); + } + const body = responseBody(reader, socket, request.signal, responseHead.headers, responseHead.status, request.method); + request.signal.removeEventListener("abort", onAbort); + if (body === null) reader.dispose(); + return new Response(body, { + status: responseHead.status, + statusText: responseHead.statusText, + headers: responseHead.headers, + }); + } catch (error) { + request.signal.removeEventListener("abort", onAbort); + socket.destroy(); + throw error; + } +} diff --git a/src/server/index.ts b/src/server/index.ts index 13a47014570..bcafb669155 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,6 @@ import { remoteWorkspaceEnabled } from "../remote-control/workspace-activation"; import { AuxiliaryListenerBindError } from "./ports"; +import { redactUrlForLog } from "../lib/redact"; import { buildWarmupCompletionFrames, buildWsErrorFrame, @@ -227,7 +228,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server[0], init?: RequestInit) => configuredOutboundFetch(input, init), + { preconnect: globalThis.fetch.preconnect?.bind(globalThis.fetch) }, + ) as typeof globalThis.fetch; + const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? configuredFetch; const preconnect = (...args: Parameters): void => { base.preconnect?.(...args); }; diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts index efa5dbb4663..acb8f0f14c7 100644 --- a/src/server/responses/ws-upstream.ts +++ b/src/server/responses/ws-upstream.ts @@ -13,7 +13,7 @@ // (passthrough relay, adapter parsers, usage sniffing) is unchanged. import { compareBunVersions } from "../../lib/bun-stream-caps"; -import { resolveProxyRoute } from "../../lib/proxy-env"; +import { resolveProxyRoute, socks5ProxyFromEnv } from "../../lib/proxy-env"; import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request"; import { codexWsExchange } from "./codex-ws-exchange"; @@ -104,6 +104,7 @@ export function shouldUseCodexWsUpstream( upstreamWebsocketConfigured = false, ): boolean { if (!bunSupportsBoundedCodexWsRelay(runtime)) return false; + if (socks5ProxyFromEnv()) return false; if (url !== CODEX_RESPONSES_HTTP_URL && !upstreamWebsocketConfigured) return false; if (upstreamWebsocketConfigured && !isResponsesWebsocketEligibleUrl(url)) return false; if ((init?.method ?? "GET").toUpperCase() !== "POST") return false; diff --git a/src/types/config.ts b/src/types/config.ts index e840eafd5e6..d7a7667c38f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -724,8 +724,9 @@ export interface OcxConfig { /** * Outbound proxy URL for provider requests. HTTP(S) example: "http://user:pass@proxy:8080" * or "${HTTPS_PROXY}". SOCKS5 example: "socks5://127.0.0.1:10808" (`ocx start --socks5`). - * HTTP URLs are mirrored into HTTP_PROXY/HTTPS_PROXY when unset. SOCKS URLs are mirrored - * into ALL_PROXY and clear inherited HTTP(S)_PROXY in this process. Loopback stays in NO_PROXY. + * 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; 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 diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 6b55bbaf0da..ea64eee3269 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -75,8 +75,8 @@ both execution paths. Provider connection tests and live model discovery share the GET-only provider outbound wrapper. Direct HTTP(S) resolves once and pins the validated address; HTTPS preserves the original Host/SNI -and always verifies certificates. Proxy-configured requests stay on Bun fetch so HTTP(S)_PROXY, -ALL_PROXY, and NO_PROXY semantics remain authoritative. The wrapper classifies successful local DNS answers, but +and always verifies certificates. HTTP(S)-proxy requests stay on Bun fetch; configured SOCKS5 +requests use the explicit tunnel fetch. Both retain NO_PROXY semantics. The wrapper classifies successful local DNS answers, but only a typed DNS-resolution failure degrades to proxy resolution; every literal, metadata, and resolved-address policy error still rejects. Proxy mode logs once that the proxy-selected peer cannot be pinned. Private destinations additionally require allowPrivateNetwork plus NO_PROXY. @@ -86,14 +86,15 @@ still rejects). The IANA benchmark range (198.18/15 and its IPv4-mapped IPv6 spe whenever any outbound proxy applies to the host, because the range itself marks the answer synthetic. Mihomo's default IPv6 fake-IP range (fdfe:dcba:9876::/48) is ULA and carries no such mark, so it is admitted for fixed canonical destinations under the transparent TUN exception, or when the proxy variable that matches the URL scheme is set (HTTPS_PROXY for https:, -HTTP_PROXY for http:; ALL_PROXY is not consulted because Bun fetch does not honour it), the host is -not in NO_PROXY, and the request is then bound to that proxy through Bun's explicit `proxy` option +HTTP_PROXY for http:; a SOCKS5 ALL_PROXY takes precedence through the configured wrapper), the host is +not in NO_PROXY, and the request is then bound to that same proxy through the explicit `proxy` option rather than environment inference. Both gates live in the outbound wrapper, not in classification: `classifyIpv6` and config-time validation (`providerDestinationResolvedError`) never admit the ULA, so provider save-time checks are unaffected (#3462). -Both paths reject redirects and expose only credential-stripped final-address guidance. This phase -does not cover ordinary requests, streaming, retries, or per-hop redirect review on those paths. +Both paths reject redirects and expose only credential-stripped final-address guidance. The shared +SOCKS5 fetch also carries ordinary request bodies and response streams, while redirect decisions +remain with their request owners. Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. @@ -152,3 +153,13 @@ Translated audio/file admission follows the [final-adapter input contract](../ad Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged. + +## SOCKS5 dispatch boundary + +`src/config/proxy-env.ts` activates configured SOCKS5 through `src/lib/proxy-env.ts`; +the compatibility config facade does not own a second activation path. +`src/server/responses/fetch-helpers.ts` routes the built-in HTTP executor through +configured outbound fetch, preserving physical-send admission and dispatch override. +Native WebSocket selection stays on HTTP SSE while SOCKS5 is configured. +Proxy-selected discovery peers remain unpinnable, and private destinations still +require explicit private-network permission plus NO_PROXY before direct transport. diff --git a/tests/cli/cli-help.test.ts b/tests/cli/cli-help.test.ts index 369f15267b1..980f556ecb3 100644 --- a/tests/cli/cli-help.test.ts +++ b/tests/cli/cli-help.test.ts @@ -62,7 +62,7 @@ describe("CLI subcommand help", () => { expectSpawnFinished(result, "ocx help start"); expect(result.status).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain("Usage: ocx start [--port ] [--socks5 [host:port]]"); + expect(result.stdout).toContain("Usage: ocx start [--port ] [--socks5 [host:port] | --socks5-off]"); expect(result.stdout).toContain("Start the proxy server and sync models to Codex."); }); @@ -390,8 +390,8 @@ describe("CLI subcommand help", () => { test("start rejects unknown and partially numeric port arguments", () => { const cases = [ { args: ["start", "--port", "123abc"], expected: "Invalid port number" }, - { args: ["start", "--bad"], expected: "Usage: ocx start [--port ] [--socks5 [host:port]]" }, - { args: ["start", "--port", "1234", "--extra"], expected: "Usage: ocx start [--port ] [--socks5 [host:port]]" }, + { args: ["start", "--bad"], expected: "Usage: ocx start [--port ] [--socks5 [host:port] | --socks5-off]" }, + { args: ["start", "--port", "1234", "--extra"], expected: "Usage: ocx start [--port ] [--socks5 [host:port] | --socks5-off]" }, ]; for (const testCase of cases) { @@ -408,7 +408,7 @@ describe("CLI subcommand help", () => { expectSpawnFinished(result, "ocx start --port 123abc --help"); expect(result.status).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain("Usage: ocx start [--port ] [--socks5 [host:port]]"); + expect(result.stdout).toContain("Usage: ocx start [--port ] [--socks5 [host:port] | --socks5-off]"); }); test("invalid service and codex-shim usage include remove alias", () => { diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index c9afb0cd7d9..a929126fe10 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -1,3 +1,4 @@ +import { configureSocks5Fetch } from "../../src/lib/proxy-env"; import { PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE, PLAINTEXT_V2_COLLABORATION_NAMESPACE, @@ -130,6 +131,18 @@ describe("shouldUseCodexWsUpstream", () => { expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "POST", body: new Blob(["x"]) as unknown as string })).toBe(false); }); + test("uses HTTP SSE when SOCKS5 outbound transport is configured", () => { + const previous = process.env.ALL_PROXY; + process.env.ALL_PROXY = "socks5://127.0.0.1:10808"; + try { + expect(rawShouldUseCodexWsUpstream(CODEX_URL, streamingInit(), BOUNDED_WS_RUNTIME)).toBe(false); + } finally { + if (previous === undefined) delete process.env.ALL_PROXY; + else process.env.ALL_PROXY = previous; + configureSocks5Fetch(); + } + }); + test("requires a ROOT-level stream flag, not a serialized substring", () => { // Nested stream:true must not flip the transport. expect(shouldUseCodexWsUpstream(CODEX_URL, { diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts index bd4ad290f94..c2584ffb2f3 100644 --- a/tests/server/proxy-env.test.ts +++ b/tests/server/proxy-env.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createServer } from "node:http"; import { applyProxyEnv } from "../../src/config"; -import { resolveProxyRoute } from "../../src/lib/proxy-env"; +import { resolveProxyRoute, configureSocks5Fetch } from "../../src/lib/proxy-env"; import type { OcxConfig } from "../../src/types"; const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; @@ -20,6 +20,7 @@ afterEach(() => { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } + configureSocks5Fetch(); }); function configWithProxy(proxy?: string, noProxy?: string | string[]): OcxConfig { diff --git a/tests/socks5-fetch.test.ts b/tests/socks5-fetch.test.ts new file mode 100644 index 00000000000..3492b49e29a --- /dev/null +++ b/tests/socks5-fetch.test.ts @@ -0,0 +1,234 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createServer as createHttpServer } from "node:http"; +import { createConnection, createServer as createTcpServer, type Server as TcpServer } from "node:net"; +import type { AddressInfo } from "node:net"; +import { configureSocks5Fetch } from "../src/lib/proxy-env"; +import { providerOutboundGet } from "../src/lib/provider-outbound"; +import { socks5Fetch } from "../src/lib/socks5-fetch"; +import { applyProxyEnv } from "../src/config"; +import { providerFetch } from "../src/server/responses/fetch-helpers"; +import type { OcxProviderConfig } from "../src/types"; + +const proxyEnvKeys = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +] as const; +const originalFetch = globalThis.fetch; +const originalEnv = Object.fromEntries(proxyEnvKeys.map(key => [key, process.env[key]])); + +afterEach(() => { + globalThis.fetch = originalFetch; + for (const key of proxyEnvKeys) { + const value = originalEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + configureSocks5Fetch(); +}); + +async function listen(server: TcpServer | ReturnType): Promise { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + return (server.address() as AddressInfo).port; +} + +async function close(server: TcpServer | ReturnType): Promise { + await new Promise(resolve => server.close(() => resolve())); +} + +function socksProxy(options: { username?: string; password?: string } = {}): TcpServer { + const proxy = createTcpServer(socket => { + let stage: "greeting" | "auth" | "connect" = "greeting"; + let buffer = Buffer.alloc(0); + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + while (true) { + if (stage === "greeting") { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]!) return; + const methods = buffer.subarray(2, 2 + buffer[1]!); + buffer = buffer.subarray(2 + methods.length); + const needsAuth = options.username !== undefined; + if (needsAuth && !methods.includes(0x02)) { + socket.end(Buffer.from([0x05, 0xff])); + return; + } + socket.write(Buffer.from([0x05, needsAuth ? 0x02 : 0x00])); + stage = needsAuth ? "auth" : "connect"; + continue; + } + if (stage === "auth") { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]! + 1) return; + const usernameLength = buffer[1]!; + if (buffer.length < 3 + usernameLength) return; + const passwordLength = buffer[2 + usernameLength]!; + if (buffer.length < 3 + usernameLength + passwordLength) return; + const username = buffer.subarray(2, 2 + usernameLength).toString(); + const password = buffer.subarray(3 + usernameLength, 3 + usernameLength + passwordLength).toString(); + buffer = buffer.subarray(3 + usernameLength + passwordLength); + const valid = username === options.username && password === options.password; + socket.write(Buffer.from([0x01, valid ? 0x00 : 0xff])); + if (!valid) return; + stage = "connect"; + continue; + } + if (buffer.length < 7) return; + const addressType = buffer[3]!; + if (addressType !== 0x03) throw new Error(`test proxy expected a domain target, got ${addressType}`); + const hostnameLength = buffer[4]!; + const requestLength = 7 + hostnameLength; + if (buffer.length < requestLength) return; + const port = buffer.readUInt16BE(5 + hostnameLength); + buffer = buffer.subarray(requestLength); + const targetSocket = createConnection({ host: "127.0.0.1", port }, () => { + socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 1])); + socket.removeListener("data", onData); + if (buffer.length > 0) socket.unshift(buffer); + socket.pipe(targetSocket); + targetSocket.pipe(socket); + }); + targetSocket.once("error", error => socket.destroy(error)); + return; + } + }; + socket.on("data", onData); + socket.once("error", () => undefined); + }); + return proxy; +} + +describe("socks5Fetch", () => { + test("performs a real domain CONNECT and streams the HTTP response", async () => { + const target = createHttpServer((_request, response) => { + response.writeHead(200, { "content-type": "text/plain" }); + response.write("first"); + setTimeout(() => response.end(" second"), 10); + }); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + try { + const response = await socks5Fetch( + `http://provider.invalid:${targetPort}/models`, + { headers: { authorization: "Bearer test" } }, + `socks5://127.0.0.1:${proxyPort}`, + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("first second"); + } finally { + await Promise.all([close(proxy), close(target)]); + } + }); + + test("supports RFC 1929 username/password authentication", async () => { + const target = createHttpServer((_request, response) => response.end("authenticated")); + const proxy = socksProxy({ username: "user", password: "pass" }); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + try { + const response = await socks5Fetch( + `http://provider.invalid:${targetPort}/`, + undefined, + `socks5://user:pass@127.0.0.1:${proxyPort}`, + ); + expect(await response.text()).toBe("authenticated"); + } finally { + await Promise.all([close(proxy), close(target)]); + } + }); + + test("forwards POST bodies through a chunked SOCKS5 tunnel", async () => { + const target = createHttpServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", chunk => chunks.push(Buffer.from(chunk))); + request.on("end", () => { + expect(request.method).toBe("POST"); + expect(Buffer.concat(chunks).toString()).toBe('{"hello":"socks"}'); + response.end("posted"); + }); + }); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + try { + const response = await socks5Fetch( + `http://provider.invalid:${targetPort}/submit`, + { method: "POST", body: '{"hello":"socks"}' }, + `socks5://127.0.0.1:${proxyPort}`, + ); + expect(await response.text()).toBe("posted"); + } finally { + await Promise.all([close(proxy), close(target)]); + } + }); + + test("rejects SOCKS4 URLs", async () => { + await expect(socks5Fetch("http://provider.invalid/", undefined, "socks4://127.0.0.1:1080")) + .rejects.toThrow("unsupported SOCKS5 proxy protocol"); + }); + + test("aborts while the SOCKS5 proxy is still handshaking", async () => { + const proxy = createTcpServer(() => undefined); + const proxyPort = await listen(proxy); + const controller = new AbortController(); + const pending = socks5Fetch( + "http://provider.invalid/", + { signal: controller.signal }, + `socks5://127.0.0.1:${proxyPort}`, + ); + controller.abort(new Error("test abort")); + try { + await expect(pending).rejects.toThrow("test abort"); + } finally { + await close(proxy); + } + }); +}); + +describe("configured SOCKS5 fetch", () => { + test("routes ordinary global fetch through the real SOCKS5 transport", async () => { + const target = createHttpServer((_request, response) => response.end("global")); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + applyProxyEnv({ + proxy: `socks5://127.0.0.1:${proxyPort}`, + noProxy: "localhost,127.0.0.1,::1,[::1]", + } as OcxConfig); + try { + const response = await fetch(`http://provider.invalid:${targetPort}/`); + expect(await response.text()).toBe("global"); + const providerResponse = await providerFetch({ + baseUrl: `http://provider.invalid:${targetPort}/v1`, + } as OcxProviderConfig)(`http://provider.invalid:${targetPort}/v1/models`); + expect(await providerResponse.text()).toBe("global"); + const discoveryResponse = await providerOutboundGet( + "provider", + { baseUrl: `http://provider.invalid:${targetPort}/v1` }, + `http://provider.invalid:${targetPort}/v1/models`, + ); + expect(await discoveryResponse.text()).toBe("global"); + } finally { + await Promise.all([close(proxy), close(target)]); + } + }); + + test("bypasses the SOCKS5 tunnel for NO_PROXY hosts", async () => { + const target = createHttpServer((_request, response) => response.end("direct")); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + applyProxyEnv({ + proxy: `socks5://127.0.0.1:${proxyPort}`, + noProxy: "localhost", + } as OcxConfig); + try { + const response = await fetch(`http://localhost:${targetPort}/`); + expect(await response.text()).toBe("direct"); + } finally { + await Promise.all([close(proxy), close(target)]); + } + }); +}); diff --git a/tests/start-args.test.ts b/tests/start-args.test.ts index 898f81e555b..27abd65e13f 100644 --- a/tests/start-args.test.ts +++ b/tests/start-args.test.ts @@ -39,8 +39,13 @@ describe("parseStartOptions", () => { }); }); - test("--socks5-off clears a saved outbound proxy", () => { - expect(parseStartOptions(["--socks5-off"])).toEqual({ socks5: null }); + test("--socks5-off clears a saved SOCKS5 proxy", () => { + expect(parseStartOptions(["--socks5-off"])).toEqual({ socks5Off: true }); + }); + + test("rejects conflicting SOCKS5 flags in either order", () => { + expect(() => parseStartOptions(["--socks5", "--socks5-off"])).toThrow("cannot be used together"); + expect(() => parseStartOptions(["--socks5-off", "--socks5"])).toThrow("cannot be used together"); }); test("rejects unknown flags with the start usage line", () => { @@ -58,4 +63,14 @@ describe("normalizeSocks5", () => { test("rejects HTTP URLs", () => { expect(() => normalizeSocks5("http://127.0.0.1:10808")).toThrow("not an HTTP URL"); }); + + test("rejects SOCKS4 URLs", () => { + expect(() => normalizeSocks5("socks4://127.0.0.1:1080")).toThrow("Only SOCKS5"); + expect(() => normalizeSocks5("socks4a://127.0.0.1:1080")).toThrow("Only SOCKS5"); + }); + + test("rejects SOCKS5 URLs without a valid host and port", () => { + expect(() => normalizeSocks5("socks5://")).toThrow("Invalid SOCKS5 address"); + expect(() => normalizeSocks5("socks5://127.0.0.1:0")).toThrow("Invalid SOCKS5 address"); + }); }); From 15ce5b7e85d9f1fcf99081ce46498a3999dde0ba Mon Sep 17 00:00:00 2001 From: Warexpor Date: Sat, 29 Aug 2026 20:08:17 +0300 Subject: [PATCH 3/9] fix(proxy): close SOCKS5 sockets after response completion Prevent one-shot SOCKS5 requests from leaking connections or hanging forever when request backpressure ends in an error, close, or abort. --- src/lib/socks5-fetch.ts | 48 ++++++++++++++++++++++++++++--- tests/socks5-fetch.test.ts | 58 +++++++++++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/src/lib/socks5-fetch.ts b/src/lib/socks5-fetch.ts index ad7e44ac330..932fc3d6d01 100644 --- a/src/lib/socks5-fetch.ts +++ b/src/lib/socks5-fetch.ts @@ -358,6 +358,39 @@ function parseResponseHead(raw: Buffer): { status: number; statusText: string; h return { status: Number(match[1]), statusText: match[2] ?? "", headers }; } +function waitForDrain(socket: Socket, signal: AbortSignal): Promise { + if (socket.destroyed) return Promise.reject(new Socks5FetchError("SOCKS5 socket closed while sending the request")); + if (signal.aborted) { + return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + } + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + socket.removeListener("drain", onDrain); + socket.removeListener("error", onError); + socket.removeListener("close", onClose); + signal.removeEventListener("abort", onAbort); + }; + const finish = (error?: unknown) => { + if (settled) return; + settled = true; + cleanup(); + if (error === undefined) resolve(); + else reject(error instanceof Error ? error : new Error(String(error))); + }; + const onDrain = () => finish(); + const onError = (error: Error) => finish(error); + const onClose = () => finish(new Socks5FetchError("SOCKS5 socket closed while sending the request")); + const onAbort = () => finish(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + socket.once("drain", onDrain); + socket.once("error", onError); + socket.once("close", onClose); + signal.addEventListener("abort", onAbort, { once: true }); + if (socket.destroyed) onClose(); + else if (signal.aborted) onAbort(); + }); +} + function responseBody( reader: SocketReader, socket: Socket, @@ -366,7 +399,13 @@ function responseBody( status: number, method: string, ): ReadableStream | null { - if (method === "HEAD" || status === 204 || status === 304 || (status >= 100 && status < 200)) return null; + const bodyless = method === "HEAD" || status === 204 || status === 304 || (status >= 100 && status < 200); + if (bodyless) { + reader.dispose(); + socket.setTimeout(0); + socket.destroy(); + return null; + } const lengthHeader = headers.get("content-length"); const contentLength = lengthHeader === null ? undefined : Number(lengthHeader); if (contentLength !== undefined && (!Number.isSafeInteger(contentLength) || contentLength < 0)) { @@ -381,6 +420,8 @@ function responseBody( complete = true; signal.removeEventListener("abort", onAbort); reader.dispose(); + socket.setTimeout(0); + socket.destroy(); }; const onAbort = () => socket.destroy(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); signal.addEventListener("abort", onAbort, { once: true }); @@ -468,9 +509,9 @@ export async function socks5Fetch( const body = headers.chunked ? Buffer.concat([Buffer.from(`${next.value.byteLength.toString(16)}\r\n`), Buffer.from(next.value), CRLF]) : next.value; - if (!socket.write(body)) await new Promise(resolve => socket.once("drain", resolve)); + if (!socket.write(body)) await waitForDrain(socket, request.signal); } - if (headers.chunked) socket.write("0\r\n\r\n"); + if (headers.chunked && !socket.write("0\r\n\r\n")) await waitForDrain(socket, request.signal); } finally { bodyReader.releaseLock(); } @@ -482,7 +523,6 @@ export async function socks5Fetch( } const body = responseBody(reader, socket, request.signal, responseHead.headers, responseHead.status, request.method); request.signal.removeEventListener("abort", onAbort); - if (body === null) reader.dispose(); return new Response(body, { status: responseHead.status, statusText: responseHead.statusText, diff --git a/tests/socks5-fetch.test.ts b/tests/socks5-fetch.test.ts index 3492b49e29a..b77a6d8b146 100644 --- a/tests/socks5-fetch.test.ts +++ b/tests/socks5-fetch.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer as createHttpServer } from "node:http"; -import { createConnection, createServer as createTcpServer, type Server as TcpServer } from "node:net"; +import { createConnection, createServer as createTcpServer, type Server as TcpServer, type Socket } from "node:net"; import type { AddressInfo } from "node:net"; import { configureSocks5Fetch } from "../src/lib/proxy-env"; import { providerOutboundGet } from "../src/lib/provider-outbound"; @@ -126,6 +126,62 @@ describe("socks5Fetch", () => { } }); + test("closes a keep-alive socket after a fixed-length response completes", async () => { + let targetConnection: Socket | undefined; + const target = createTcpServer(socket => { + targetConnection = socket; + socket.once("error", () => undefined); + let request = Buffer.alloc(0); + socket.on("data", chunk => { + request = Buffer.concat([request, chunk]); + if (!request.toString("latin1").includes("\r\n\r\n")) return; + socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok"); + }); + }); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + try { + const response = await socks5Fetch( + `http://provider.invalid:${targetPort}/keep-alive`, + undefined, + `socks5://127.0.0.1:${proxyPort}`, + ); + expect(await response.text()).toBe("ok"); + await Bun.sleep(50); + expect(targetConnection?.destroyed).toBe(true); + } finally { + targetConnection?.destroy(); + await Promise.all([close(proxy), close(target)]); + } + }); + + test("closes a keep-alive socket for a bodyless response", async () => { + let targetConnection: Socket | undefined; + const target = createTcpServer(socket => { + targetConnection = socket; + socket.once("error", () => undefined); + socket.on("data", chunk => { + if (!chunk.toString("latin1").includes("\r\n\r\n")) return; + socket.write("HTTP/1.1 204 No Content\r\nConnection: keep-alive\r\n\r\n"); + }); + }); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + try { + const response = await socks5Fetch( + `http://provider.invalid:${targetPort}/no-content`, + undefined, + `socks5://127.0.0.1:${proxyPort}`, + ); + expect(await response.text()).toBe(""); + await Bun.sleep(50); + expect(targetConnection?.destroyed).toBe(true); + } finally { + targetConnection?.destroy(); + await Promise.all([close(proxy), close(target)]); + } + }); + test("supports RFC 1929 username/password authentication", async () => { const target = createHttpServer((_request, response) => response.end("authenticated")); const proxy = socksProxy({ username: "user", password: "pass" }); From 4ab3d13543934cae549f00e8527205fec4ebaa09 Mon Sep 17 00:00:00 2001 From: Warexpor Date: Sat, 29 Aug 2026 20:52:07 +0300 Subject: [PATCH 4/9] test(proxy): cover SOCKS5 backpressure failure cleanup Exercise the request-body backpressure path and verify socket failures settle the request without leaving listeners behind. --- tests/socks5-fetch.test.ts | 85 +++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/tests/socks5-fetch.test.ts b/tests/socks5-fetch.test.ts index b77a6d8b146..f02fecb75a9 100644 --- a/tests/socks5-fetch.test.ts +++ b/tests/socks5-fetch.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer as createHttpServer } from "node:http"; -import { createConnection, createServer as createTcpServer, type Server as TcpServer, type Socket } from "node:net"; +import { createConnection, createServer as createTcpServer, Socket, type Server as TcpServer } from "node:net"; import type { AddressInfo } from "node:net"; import { configureSocks5Fetch } from "../src/lib/proxy-env"; import { providerOutboundGet } from "../src/lib/provider-outbound"; @@ -44,7 +44,12 @@ async function close(server: TcpServer | ReturnType): P await new Promise(resolve => server.close(() => resolve())); } -function socksProxy(options: { username?: string; password?: string } = {}): TcpServer { +function socksProxy(options: { + username?: string; + password?: string; + holdAfterConnect?: boolean; + onHold?: (socket: Socket) => void; +} = {}): TcpServer { const proxy = createTcpServer(socket => { let stage: "greeting" | "auth" | "connect" = "greeting"; let buffer = Buffer.alloc(0); @@ -87,6 +92,13 @@ function socksProxy(options: { username?: string; password?: string } = {}): Tcp if (buffer.length < requestLength) return; const port = buffer.readUInt16BE(5 + hostnameLength); buffer = buffer.subarray(requestLength); + if (options.holdAfterConnect) { + socket.removeListener("data", onData); + socket.pause(); + options.onHold?.(socket); + socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 1])); + return; + } const targetSocket = createConnection({ host: "127.0.0.1", port }, () => { socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 1])); socket.removeListener("data", onData); @@ -182,6 +194,75 @@ describe("socks5Fetch", () => { } }); + test("rejects a request stuck in body backpressure when the socket errors", async () => { + let heldSocket: Socket | undefined; + const proxy = socksProxy({ holdAfterConnect: true, onHold: socket => { heldSocket = socket; } }); + const proxyPort = await listen(proxy); + let resolveBackpressure: (() => void) | undefined; + const backpressure = new Promise(resolve => { + resolveBackpressure = resolve; + }); + let clientSocket: Socket | undefined; + let backpressureObserved = false; + let listenerCountsBeforeWait: { drain: number; error: number; close: number } | undefined; + const writeCounts = new Map(); + const originalWrite = Socket.prototype.write; + Socket.prototype.write = function(this: Socket, ...args: Parameters): boolean { + const result = originalWrite.apply(this, args); + const writeCount = (writeCounts.get(this) ?? 0) + 1; + writeCounts.set(this, writeCount); + if (!backpressureObserved && writeCount === 4) { + backpressureObserved = true; + clientSocket = this; + listenerCountsBeforeWait = { + drain: this.listenerCount("drain"), + error: this.listenerCount("error"), + close: this.listenerCount("close"), + }; + resolveBackpressure?.(); + queueMicrotask(() => this.destroy(new Error("test write failure"))); + return false; + } + return result; + }; + const init = { + method: "POST", + body: new Uint8Array(8 * 1024 * 1024), + duplex: "half", + } satisfies RequestInit & { duplex: "half" }; + const pending = socks5Fetch("http://provider.invalid/", init, `socks5://127.0.0.1:${proxyPort}`); + const pendingHandled = pending.catch(() => undefined); + try { + const backpressureOutcome = await Promise.race([ + backpressure.then(() => "ready"), + Bun.sleep(1_000).then(() => "timed out"), + ]); + if (backpressureOutcome !== "ready") { + throw new Error(`backpressure was not observed; writes=${JSON.stringify([...writeCounts.values()])}`); + } + expect(backpressureObserved).toBe(true); + const outcome = await Promise.race([ + pending.then( + () => "resolved", + error => error, + ), + Bun.sleep(1_000).then(() => "timed out"), + ]); + expect(outcome).toBeInstanceOf(Error); + if (!(outcome instanceof Error)) throw new Error("failed backpressure did not settle"); + expect(outcome.message).toBe("test write failure"); + if (!clientSocket || !listenerCountsBeforeWait) throw new Error("backpressure socket was not captured"); + expect(clientSocket.listenerCount("drain")).toBe(listenerCountsBeforeWait.drain); + expect(clientSocket.listenerCount("error")).toBe(listenerCountsBeforeWait.error); + expect(clientSocket.listenerCount("close")).toBe(listenerCountsBeforeWait.close); + } finally { + Socket.prototype.write = originalWrite; + heldSocket?.destroy(); + await Promise.race([pendingHandled, Bun.sleep(250)]); + await Promise.race([close(proxy), Bun.sleep(250)]); + } + }); + test("supports RFC 1929 username/password authentication", async () => { const target = createHttpServer((_request, response) => response.end("authenticated")); const proxy = socksProxy({ username: "user", password: "pass" }); From 0036f874b43e42671c51e0c0690dc10252c80976 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 09:15:56 +0000 Subject: [PATCH 5/9] test(proxy): allow SOCKS5 outbound import in fetch-helper boundary Current dev added an allowlist for fetch-helpers runtime imports. The SOCKS5 transport already loads configuredOutboundFetch from lib/proxy-env; include that specifier so the boundary matches the rebased transport. Co-authored-by: Warexpor --- tests/responses/responses-fetch-helpers-boundary.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/responses/responses-fetch-helpers-boundary.test.ts b/tests/responses/responses-fetch-helpers-boundary.test.ts index 55130f57cf6..787ffe6e286 100644 --- a/tests/responses/responses-fetch-helpers-boundary.test.ts +++ b/tests/responses/responses-fetch-helpers-boundary.test.ts @@ -45,6 +45,7 @@ function expectRuntimeImportBoundary(source: string): string[] { describe("Responses fetch-helper import boundary", () => { test("loads only transport-owned runtime dependencies", () => { expect(expectRuntimeImportBoundary(readFileSync(helperPath, "utf8"))).toEqual([ + "../../lib/proxy-env", "../../lib/upstream-http-version", "../../providers/request-pacing", "./ws-upstream", From 9b87fe7420d69dfff088a1e2f0ff16ce1e85a2be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 15:54:52 +0000 Subject: [PATCH 6/9] fix(proxy): stream SOCKS5 chunked bodies in bounded slices Do not wait for a declared HTTP chunk of size N to fill RAM before emitting. Read at most 64KiB, write immediately, and only then read the trailing CRLF. Reject incomplete hex chunk-size lines instead of parseInt prefixes. Co-authored-by: Warexpor --- src/lib/socks5-fetch.ts | 28 +++++++--- tests/socks5-fetch.test.ts | 112 ++++++++++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/src/lib/socks5-fetch.ts b/src/lib/socks5-fetch.ts index 932fc3d6d01..b77a5f8d529 100644 --- a/src/lib/socks5-fetch.ts +++ b/src/lib/socks5-fetch.ts @@ -5,6 +5,7 @@ const DEFAULT_SOCKS5_PORT = 1080; const SOCKS5_CONNECT_TIMEOUT_MS = 30_000; const SOCKS5_RESPONSE_TIMEOUT_MS = 200_000; const MAX_RESPONSE_HEADER_BYTES = 64 * 1024; +const MAX_BODY_SLICE_BYTES = 64 * 1024; const SOCKS5_VERSION = 0x05; const SOCKS5_NO_AUTH = 0x00; const SOCKS5_USER_PASS = 0x02; @@ -342,6 +343,18 @@ async function secureSocket(socket: Socket, target: URL, signal: AbortSignal): P }); } +function parseChunkSize(line: string): number { + const token = line.split(";", 1)[0]!.trim(); + if (!/^[0-9a-fA-F]+$/.test(token)) { + throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk size"); + } + const size = Number.parseInt(token, 16); + if (!Number.isSafeInteger(size) || size < 0) { + throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk size"); + } + return size; +} + function parseResponseHead(raw: Buffer): { status: number; statusText: string; headers: Headers } { const text = raw.toString("latin1"); const lines = text.split("\r\n"); @@ -429,8 +442,7 @@ function responseBody( if (chunked) { if (chunkRemaining === 0) { const line = (await reader.readUntil(CRLF, MAX_RESPONSE_HEADER_BYTES, signal)).subarray(0, -2).toString("ascii"); - const size = Number.parseInt(line.split(";", 1)[0]!.trim(), 16); - if (!Number.isSafeInteger(size) || size < 0) throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk size"); + const size = parseChunkSize(line); if (size === 0) { while (true) { const trailer = await reader.readUntil(CRLF, MAX_RESPONSE_HEADER_BYTES, signal); @@ -440,15 +452,17 @@ function responseBody( } chunkRemaining = size; } - const value = await reader.read(chunkRemaining, signal); - chunkRemaining = 0; - const ending = await reader.read(2, signal); - if (!ending.equals(CRLF)) throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk terminator"); + const value = await reader.read(Math.min(chunkRemaining, MAX_BODY_SLICE_BYTES), signal); + chunkRemaining -= value.byteLength; + if (chunkRemaining === 0) { + const ending = await reader.read(2, signal); + if (!ending.equals(CRLF)) throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk terminator"); + } return value; } if (remaining !== undefined) { if (remaining === 0) return null; - const value = await reader.read(Math.min(remaining, 64 * 1024), signal); + const value = await reader.read(Math.min(remaining, MAX_BODY_SLICE_BYTES), signal); remaining -= value.byteLength; return value; } diff --git a/tests/socks5-fetch.test.ts b/tests/socks5-fetch.test.ts index f02fecb75a9..eb35b700e3a 100644 --- a/tests/socks5-fetch.test.ts +++ b/tests/socks5-fetch.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer as createHttpServer } from "node:http"; -import { createConnection, createServer as createTcpServer, Socket, type Server as TcpServer } from "node:net"; +import net, { createConnection, createServer as createTcpServer, Socket, type Server as TcpServer } from "node:net"; import type { AddressInfo } from "node:net"; import { configureSocks5Fetch } from "../src/lib/proxy-env"; import { providerOutboundGet } from "../src/lib/provider-outbound"; @@ -303,6 +303,116 @@ describe("socks5Fetch", () => { } }); + test("streams a large declared chunk as bounded slices instead of one buffer", async () => { + const slice = 64 * 1024; + const payload = Buffer.alloc(slice * 3 + 128, 0x61); + const received: Buffer[] = []; + const target = createTcpServer(socket => { + socket.once("error", () => undefined); + let request = Buffer.alloc(0); + socket.on("data", chunk => { + request = Buffer.concat([request, chunk]); + if (!request.toString("latin1").includes("\r\n\r\n")) return; + socket.write( + `HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n${payload.byteLength.toString(16)}\r\n`, + ); + socket.write(payload); + socket.write("\r\n0\r\n\r\n"); + }); + }); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + try { + const response = await socks5Fetch( + `http://provider.invalid:${targetPort}/chunked`, + undefined, + `socks5://127.0.0.1:${proxyPort}`, + ); + const reader = response.body?.getReader(); + if (!reader) throw new Error("chunked response had no body"); + while (true) { + const next = await reader.read(); + if (next.done) break; + received.push(Buffer.from(next.value)); + } + expect(received.length).toBeGreaterThan(1); + expect(Math.max(...received.map(chunk => chunk.byteLength))).toBeLessThanOrEqual(slice); + expect(Buffer.concat(received).equals(payload)).toBe(true); + } finally { + await Promise.all([close(proxy), close(target)]); + } + }); + + test("rejects a chunk size that is only a hexadecimal prefix", async () => { + const target = createTcpServer(socket => { + socket.once("error", () => undefined); + socket.on("data", chunk => { + if (!chunk.toString("latin1").includes("\r\n\r\n")) return; + socket.write("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n1g\r\nxxxx\r\n0\r\n\r\n"); + }); + }); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + try { + const response = await socks5Fetch( + `http://provider.invalid:${targetPort}/bad-chunk-size`, + undefined, + `socks5://127.0.0.1:${proxyPort}`, + ); + await expect(response.text()).rejects.toThrow("invalid chunk size"); + } finally { + await Promise.all([close(proxy), close(target)]); + } + }); + + test("cancels a huge declared chunk and releases the socket", async () => { + const originalCreateConnection = net.createConnection; + let clientSocket: Socket | undefined; + const target = createTcpServer(socket => { + socket.once("error", () => undefined); + let request = Buffer.alloc(0); + socket.on("data", chunk => { + request = Buffer.concat([request, chunk]); + if (!request.toString("latin1").includes("\r\n\r\n")) return; + socket.write("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n7fffffff\r\n"); + socket.write(Buffer.alloc(8 * 1024, 0x62)); + }); + }); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + net.createConnection = ((...args: Parameters) => { + const socket = originalCreateConnection(...(args as Parameters)); + const opts = args[0]; + if (typeof opts === "object" && opts !== null && "port" in opts && Number(opts.port) === proxyPort) { + clientSocket = socket; + } + return socket; + }) as typeof net.createConnection; + try { + const response = await socks5Fetch( + `http://provider.invalid:${targetPort}/huge-chunk`, + undefined, + `socks5://127.0.0.1:${proxyPort}`, + ); + const reader = response.body?.getReader(); + if (!reader) throw new Error("huge chunked response had no body"); + const pending = reader.read(); + await Bun.sleep(50); + await reader.cancel(); + await Promise.race([pending.catch(() => undefined), Bun.sleep(250)]); + await Bun.sleep(50); + if (!clientSocket) throw new Error("SOCKS5 client socket was not captured"); + expect(clientSocket.destroyed).toBe(true); + expect(clientSocket.listenerCount("data")).toBe(0); + expect(clientSocket.listenerCount("error")).toBe(0); + expect(clientSocket.listenerCount("end")).toBe(0); + expect(clientSocket.listenerCount("close")).toBe(0); + } finally { + net.createConnection = originalCreateConnection; + await Promise.all([close(proxy), close(target)]); + } + }); + test("rejects SOCKS4 URLs", async () => { await expect(socks5Fetch("http://provider.invalid/", undefined, "socks4://127.0.0.1:1080")) .rejects.toThrow("unsupported SOCKS5 proxy protocol"); From 2ddf0c4a2c1ed86293ff6147474eb577b2c0f2ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 16:01:26 +0000 Subject: [PATCH 7/9] test(proxy): cover SOCKS5 chunk cancel/error cleanup Assert bounded chunked delivery, complete hex chunk sizes, and socket listener cleanup after cancel or error. Destroy leftover test-server connections on close so bun's HTTP server.close does not hang the suite. Co-authored-by: Warexpor --- tests/socks5-fetch.test.ts | 63 +++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/tests/socks5-fetch.test.ts b/tests/socks5-fetch.test.ts index eb35b700e3a..fc7c534f3e9 100644 --- a/tests/socks5-fetch.test.ts +++ b/tests/socks5-fetch.test.ts @@ -21,6 +21,7 @@ const proxyEnvKeys = [ ] as const; const originalFetch = globalThis.fetch; const originalEnv = Object.fromEntries(proxyEnvKeys.map(key => [key, process.env[key]])); +const openConnections = new WeakMap>(); afterEach(() => { globalThis.fetch = originalFetch; @@ -33,6 +34,12 @@ afterEach(() => { }); async function listen(server: TcpServer | ReturnType): Promise { + const sockets = new Set(); + openConnections.set(server, sockets); + server.on("connection", socket => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); @@ -41,6 +48,7 @@ async function listen(server: TcpServer | ReturnType): } async function close(server: TcpServer | ReturnType): Promise { + for (const socket of openConnections.get(server) ?? []) socket.destroy(); await new Promise(resolve => server.close(() => resolve())); } @@ -405,7 +413,60 @@ describe("socks5Fetch", () => { expect(clientSocket.destroyed).toBe(true); expect(clientSocket.listenerCount("data")).toBe(0); expect(clientSocket.listenerCount("error")).toBe(0); - expect(clientSocket.listenerCount("end")).toBe(0); + expect(clientSocket.listenerCount("close")).toBe(0); + } finally { + net.createConnection = originalCreateConnection; + await Promise.all([close(proxy), close(target)]); + } + }); + + test("errors a huge declared chunk and releases the socket", async () => { + const originalCreateConnection = net.createConnection; + let clientSocket: Socket | undefined; + const target = createTcpServer(socket => { + socket.once("error", () => undefined); + let request = Buffer.alloc(0); + socket.on("data", chunk => { + request = Buffer.concat([request, chunk]); + if (!request.toString("latin1").includes("\r\n\r\n")) return; + socket.write("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n7fffffff\r\n"); + socket.write(Buffer.alloc(8 * 1024, 0x62)); + }); + }); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + net.createConnection = ((...args: Parameters) => { + const socket = originalCreateConnection(...(args as Parameters)); + const opts = args[0]; + if (typeof opts === "object" && opts !== null && "port" in opts && Number(opts.port) === proxyPort) { + clientSocket = socket; + } + return socket; + }) as typeof net.createConnection; + try { + const response = await socks5Fetch( + `http://provider.invalid:${targetPort}/huge-chunk-error`, + undefined, + `socks5://127.0.0.1:${proxyPort}`, + ); + const reader = response.body?.getReader(); + if (!reader) throw new Error("huge chunked response had no body"); + const pending = reader.read(); + await Bun.sleep(50); + if (!clientSocket) throw new Error("SOCKS5 client socket was not captured"); + clientSocket.destroy(new Error("test chunk stream failure")); + const outcome = await Promise.race([ + pending.then( + result => result, + error => error, + ), + Bun.sleep(1_000).then(() => "timed out"), + ]); + expect(outcome).toBeInstanceOf(Error); + await Bun.sleep(50); + expect(clientSocket.destroyed).toBe(true); + expect(clientSocket.listenerCount("data")).toBe(0); + expect(clientSocket.listenerCount("error")).toBe(0); expect(clientSocket.listenerCount("close")).toBe(0); } finally { net.createConnection = originalCreateConnection; From 0a87e6dc35b59b69691a6eb1ba68aa93048591e4 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 22:51:04 +0900 Subject: [PATCH 8/9] fix(proxy): preserve SOCKS framing and current transport contracts after rebase Manual port retains effective-proxy admission, private-network checks, physical-send hooks and split configuration ownership. Fix fragmented framing, bounded headers, cancelled waiters and explicit HTTP/2 refusal. Move tests to domain directories and register both layout manifests. Static inspection only; no connected-host tests, builds or typecheck were run. Co-authored-by: Warexpor --- scripts/test-layout/layout.json | 2 + src/cli/start-args.ts | 4 +- src/lib/socks5-fetch.ts | 113 ++++++++++++++--------- structure/config.md | 11 +++ structure/runtime.md | 8 ++ structure/transports/inventory.md | 10 ++ tests/{ => cli}/start-args.test.ts | 15 ++- tests/fixtures/test-layout-expected.json | 2 + tests/{ => lib}/socks5-fetch.test.ts | 104 +++++++++++++++++++-- 9 files changed, 214 insertions(+), 55 deletions(-) rename tests/{ => cli}/start-args.test.ts (82%) rename tests/{ => lib}/socks5-fetch.test.ts (82%) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2106a9614be..dd1e5c6682c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -168,6 +168,8 @@ }, "explicit": { "key-attribution.test.ts": "usage", + "socks5-fetch.test.ts": "lib", + "start-args.test.ts": "cli", "responses-core-modules.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", "responses-send-budget-errors.test.ts": "responses", diff --git a/src/cli/start-args.ts b/src/cli/start-args.ts index 0c767ea512c..ee4892c09ff 100644 --- a/src/cli/start-args.ts +++ b/src/cli/start-args.ts @@ -32,7 +32,7 @@ export function normalizeSocks5(raw: string): string { } return trimmed; } catch { - throw new StartArgsError(`Invalid SOCKS5 address: ${trimmed}`); + throw new StartArgsError("Invalid SOCKS5 address; expected socks5://host:port"); } } if (/^socks4a?:\/\//i.test(trimmed)) { @@ -56,7 +56,7 @@ export function normalizeSocks5(raw: string): string { } return `socks5://${trimmed}`; } - throw new StartArgsError(`Invalid SOCKS5 address: ${trimmed}`); + throw new StartArgsError("Invalid SOCKS5 address; expected socks5://host:port"); } export function parseStartOptions(argv: string[]): StartOptions { diff --git a/src/lib/socks5-fetch.ts b/src/lib/socks5-fetch.ts index b77a5f8d529..ab2e222e2b0 100644 --- a/src/lib/socks5-fetch.ts +++ b/src/lib/socks5-fetch.ts @@ -48,7 +48,7 @@ function validateProxy(proxy: string): URL { throw new Socks5FetchError(`unsupported SOCKS5 proxy protocol: ${parsed.protocol}`); } if (!parsed.hostname) throw new Socks5FetchError("SOCKS5 proxy URL has no host"); - if (parsed.port && (!/^\d+$/.test(parsed.port) || Number(parsed.port) > 65535)) { + if (parsed.port && (!/^\d+$/.test(parsed.port) || Number(parsed.port) < 1 || Number(parsed.port) > 65535)) { throw new Socks5FetchError("SOCKS5 proxy port is invalid"); } if (parsed.search || parsed.hash) throw new Socks5FetchError("SOCKS5 proxy URL must not contain a query or fragment"); @@ -108,6 +108,7 @@ function connectSocket(hostname: string, port: number, signal?: AbortSignal): Pr class SocketReader { private buffer = Buffer.alloc(0); private ended = false; + private failure: Error | undefined; private readonly exactWaiters: Array<{ length: number; resolve: (value: Buffer) => void; @@ -123,6 +124,7 @@ class SocketReader { socket.once("error", this.onError); socket.once("end", this.onEnd); socket.once("close", this.onEnd); + socket.resume(); } private readonly onData = (chunk: Buffer | string): void => { @@ -133,9 +135,11 @@ class SocketReader { } this.buffer = Buffer.concat([this.buffer, value]); this.flushExact(); + if (this.buffer.byteLength >= MAX_BODY_SLICE_BYTES) this.socket.pause(); }; private readonly onError = (error: Error): void => { + this.failure = error; this.ended = true; this.rejectExact(error); this.rejectAny(error); @@ -143,8 +147,9 @@ class SocketReader { private readonly onEnd = (): void => { this.ended = true; - this.rejectExact(new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived")); - while (this.anyWaiters.length > 0) this.anyWaiters.shift()!.resolve(Buffer.alloc(0)); + this.rejectExact(this.failure ?? new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived")); + if (this.failure) this.rejectAny(this.failure); + else while (this.anyWaiters.length > 0) this.anyWaiters.shift()!.resolve(Buffer.alloc(0)); }; private flushExact(): void { @@ -165,86 +170,89 @@ class SocketReader { } read(length: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("The operation was aborted")); if (this.buffer.byteLength >= length) { const value = this.buffer.subarray(0, length); this.buffer = this.buffer.subarray(length); + if (this.buffer.byteLength < MAX_BODY_SLICE_BYTES) this.socket.resume(); return Promise.resolve(value); } - if (this.ended) return Promise.reject(new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived")); + if (this.ended) return Promise.reject(this.failure ?? new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived")); return new Promise((resolve, reject) => { - const waiter = { length, resolve, reject }; const onAbort = () => { - signal?.removeEventListener("abort", onAbort); const index = this.exactWaiters.indexOf(waiter); if (index >= 0) this.exactWaiters.splice(index, 1); - reject(signal?.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + waiter.reject(signal?.reason ?? new Error("The operation was aborted")); }; - signal?.addEventListener("abort", onAbort, { once: true }); - this.exactWaiters.push({ + // Store this exact object so cancellation removes the queued waiter. + const waiter = { length, - resolve: value => { - signal?.removeEventListener("abort", onAbort); - resolve(value); - }, - reject: error => { - signal?.removeEventListener("abort", onAbort); - reject(error); - }, - }); + resolve: (value: Buffer) => { signal?.removeEventListener("abort", onAbort); resolve(value); }, + reject: (error: unknown) => { signal?.removeEventListener("abort", onAbort); reject(error); }, + }; + this.exactWaiters.push(waiter); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + else this.socket.resume(); }); } async readUntil(delimiter: Buffer, maxBytes: number, signal?: AbortSignal): Promise { - while (true) { - const index = this.buffer.indexOf(delimiter); + // Keep partial framing outside the shared buffer: readAny must wait for NEW + // bytes instead of returning the same unterminated prefix in a microtask loop. + let pending = Buffer.alloc(0); + for (;;) { + const chunk = await this.readAny(signal); + if (chunk.byteLength === 0) throw new Socks5FetchError("SOCKS5 upstream closed before response headers"); + pending = Buffer.concat([pending, chunk]); + const index = pending.indexOf(delimiter); if (index >= 0) { const end = index + delimiter.byteLength; - const value = this.buffer.subarray(0, end); - this.buffer = this.buffer.subarray(end); - return value; + if (end > maxBytes) throw new Socks5FetchError("SOCKS5 upstream response headers are too large"); + // Another data event may have arrived while the read promise resumed. + this.buffer = Buffer.concat([pending.subarray(end), this.buffer]); + if (this.buffer.byteLength >= MAX_BODY_SLICE_BYTES) this.socket.pause(); + return pending.subarray(0, end); } - if (this.buffer.byteLength > maxBytes) throw new Socks5FetchError("SOCKS5 upstream response headers are too large"); - const chunk = await this.readAny(signal); - if (chunk.byteLength === 0) throw new Socks5FetchError("SOCKS5 upstream closed before response headers"); - this.buffer = Buffer.concat([this.buffer, chunk]); + if (pending.byteLength >= maxBytes) throw new Socks5FetchError("SOCKS5 upstream response headers are too large"); } } readAny(signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("The operation was aborted")); if (this.buffer.byteLength > 0) { const value = this.buffer; this.buffer = Buffer.alloc(0); + this.socket.resume(); return Promise.resolve(value); } + if (this.failure) return Promise.reject(this.failure); if (this.ended) return Promise.resolve(Buffer.alloc(0)); return new Promise((resolve, reject) => { - const waiter = { resolve, reject }; const onAbort = () => { - signal?.removeEventListener("abort", onAbort); const index = this.anyWaiters.indexOf(waiter); if (index >= 0) this.anyWaiters.splice(index, 1); - reject(signal?.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + waiter.reject(signal?.reason ?? new Error("The operation was aborted")); + }; + const waiter = { + resolve: (value: Buffer) => { signal?.removeEventListener("abort", onAbort); resolve(value); }, + reject: (error: unknown) => { signal?.removeEventListener("abort", onAbort); reject(error); }, }; + this.anyWaiters.push(waiter); signal?.addEventListener("abort", onAbort, { once: true }); - this.anyWaiters.push({ - resolve: value => { - signal?.removeEventListener("abort", onAbort); - resolve(value); - }, - reject: error => { - signal?.removeEventListener("abort", onAbort); - reject(error); - }, - }); + if (signal?.aborted) onAbort(); + else this.socket.resume(); }); } dispose(): void { + this.socket.pause(); this.socket.removeListener("data", this.onData); this.socket.removeListener("error", this.onError); this.socket.removeListener("end", this.onEnd); this.socket.removeListener("close", this.onEnd); if (this.buffer.byteLength > 0) this.socket.unshift(this.buffer); + this.buffer = Buffer.alloc(0); this.rejectExact(new Socks5FetchError("SOCKS5 reader disposed")); this.rejectAny(new Socks5FetchError("SOCKS5 reader disposed")); } @@ -253,7 +261,8 @@ class SocketReader { async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): Promise { const parsedProxy = validateProxy(proxy); const credentials = proxyCredentials(parsedProxy); - const socket = await connectSocket(parsedProxy.hostname, Number(parsedProxy.port) || DEFAULT_SOCKS5_PORT, signal); + const proxyHost = parsedProxy.hostname.replace(/^\[|\]$/g, ""); + const socket = await connectSocket(proxyHost, Number(parsedProxy.port) || DEFAULT_SOCKS5_PORT, signal); socket.setTimeout(SOCKS5_CONNECT_TIMEOUT_MS, () => { socket.destroy(new Socks5FetchError("SOCKS5 handshake timed out")); }); @@ -293,6 +302,9 @@ async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): const reply = await reader.read(4, signal); if (reply[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid connect response"); if (reply[1] !== SOCKS5_SUCCESS) throw new Socks5FetchError(`SOCKS5 proxy refused the connection (code ${reply[1]})`); + if (reply[2] !== 0x00 || ![0x01, SOCKS5_DOMAIN, 0x04].includes(reply[3]!)) { + throw new Socks5FetchError("SOCKS5 proxy returned an invalid address type or reserved byte"); + } const addressLength = reply[3] === 0x01 ? 4 : reply[3] === SOCKS5_DOMAIN ? (await reader.read(1, signal))[0]! : 16; await reader.read(addressLength + 2, signal); socket.setTimeout(0); @@ -436,7 +448,7 @@ function responseBody( socket.setTimeout(0); socket.destroy(); }; - const onAbort = () => socket.destroy(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + const onAbort = () => socket.destroy(); signal.addEventListener("abort", onAbort, { once: true }); const readChunk = async (): Promise => { if (chunked) { @@ -497,20 +509,33 @@ export async function socks5Fetch( init: RequestInit | undefined, proxy: string, ): Promise { + const protocol = (init as (RequestInit & { protocol?: string }) | undefined)?.protocol; + if (protocol === "http2" || protocol === "h2") { + throw new Socks5FetchError("SOCKS5 transport cannot honor an explicit HTTP/2 pin"); + } const request = new Request(input, init); const target = new URL(request.url); if (target.protocol !== "http:" && target.protocol !== "https:") { throw new Socks5FetchError(`SOCKS5 fetch only supports HTTP(S) URLs, got ${target.protocol}`); } const tunnel = await socks5Connect(proxy, target, request.signal); + if (request.signal.aborted) { + tunnel.destroy(); + throw request.signal.reason ?? new Error("The operation was aborted"); + } let socket: Socket = tunnel; - const onAbort = () => socket.destroy(request.signal.reason instanceof Error ? request.signal.reason : new Error("The operation was aborted")); + let reader: SocketReader | undefined; + // Pending reads carry the abort reason; destruction must not enqueue an + // unhandled socket error after their listeners have been removed. + const onAbort = () => socket.destroy(); request.signal.addEventListener("abort", onAbort, { once: true }); try { if (target.protocol === "https:") socket = await secureSocket(tunnel, target, request.signal); socket.setTimeout(SOCKS5_RESPONSE_TIMEOUT_MS, () => { socket.destroy(new Socks5FetchError("SOCKS5 upstream request timed out")); }); + // Observe socket errors and early responses while uploading, not only after it. + reader = new SocketReader(socket); const headers = requestHeaders(request, target); const head = `${request.method} ${target.pathname}${target.search} HTTP/1.1\r\n${headers.text}\r\n`; socket.write(head); @@ -530,7 +555,6 @@ export async function socks5Fetch( bodyReader.releaseLock(); } } - const reader = new SocketReader(socket); let responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal)); while (responseHead.status >= 100 && responseHead.status < 200 && responseHead.status !== 101) { responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal)); @@ -544,6 +568,7 @@ export async function socks5Fetch( }); } catch (error) { request.signal.removeEventListener("abort", onAbort); + reader?.dispose(); socket.destroy(); throw error; } diff --git a/structure/config.md b/structure/config.md index e756575a910..929fc66cf45 100644 --- a/structure/config.md +++ b/structure/config.md @@ -322,3 +322,14 @@ The text-only consumer reads exact inputModalities declarations before legacy hi ## Catalog auto-refresh `catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults off: an absent key, an explicit false, and a malformed value all leave the scheduler dormant. `src/config/feature-flags.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` is the module-singleton interval `src/server/background-lifecycle.ts` starts beside the quota reset poller; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures) and carries no provider or account detail. + +## SOCKS5 activation owner + +`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 +NO_PROXY entries. When the environment no longer selects SOCKS, activation +restores the native fetch; removing a saved field alone does not erase inherited +process environment variables. +SOCKS4 is rejected instead of being advertised as a working transport. diff --git a/structure/runtime.md b/structure/runtime.md index 0b32111ea2e..07a6172407b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -509,3 +509,11 @@ stamps the configured key selected for the physical request. `src/server/request retains per-key attempt usage, and `src/usage/log.ts` validates and persists labels. The [account attribution contract](gui-and-management-api.md#upstream-key-account-attribution) defines identity, unknown records, and aggregation boundaries. + +## Explicit SOCKS5 CLI selection + +`src/cli/start-args.ts` parses `ocx start --socks5 [host:port]` and the mutually +exclusive `--socks5-off`. The start owner persists only an explicitly requested +change; the off flag refuses to erase a non-SOCKS proxy. Invalid-address errors +never echo user-supplied credentials, and status messages redact proxy URLs. +The parser regression cases live in `tests/cli/start-args.test.ts`. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index ea64eee3269..78c80207c70 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -163,3 +163,13 @@ configured outbound fetch, preserving physical-send admission and dispatch overr Native WebSocket selection stays on HTTP SSE while SOCKS5 is configured. Proxy-selected discovery peers remain unpinnable, and private destinations still require explicit private-network permission plus NO_PROXY before direct transport. + +The tunnel reader keeps incomplete framing separate from queued socket bytes, +waits for new input, and caps headers even when the terminating delimiter arrives +in the same chunk. Cancellation removes the exact queued waiter; socket errors +remain errors on later reads rather than turning into clean EOF. Buffered body +reads pause the socket at the local high-water mark, and upload errors are observed +before the response reader takes ownership. `tests/lib/socks5-fetch.test.ts` covers +fragmented framing, header limits and explicit-route snapshot preservation. +Explicit `http2` / `h2` pins reject before network I/O: this HTTP/1.1 tunnel cannot +honor them and must not silently downgrade the provider contract. diff --git a/tests/start-args.test.ts b/tests/cli/start-args.test.ts similarity index 82% rename from tests/start-args.test.ts rename to tests/cli/start-args.test.ts index 27abd65e13f..e5589cda5ef 100644 --- a/tests/start-args.test.ts +++ b/tests/cli/start-args.test.ts @@ -5,7 +5,7 @@ import { normalizeSocks5, parseStartOptions, StartArgsError, -} from "../src/cli/start-args"; +} from "../../src/cli/start-args"; describe("parseStartOptions", () => { test("accepts an empty start argv", () => { @@ -74,3 +74,16 @@ describe("normalizeSocks5", () => { expect(() => normalizeSocks5("socks5://127.0.0.1:0")).toThrow("Invalid SOCKS5 address"); }); }); + +test("invalid SOCKS addresses never echo embedded credentials", () => { + for (const value of ["socks5://user:private-secret@host:0", "user:private-secret@host:bad"]) { + try { + normalizeSocks5(value); + throw new Error("invalid address was accepted"); + } catch (error) { + expect(error).toBeInstanceOf(StartArgsError); + expect((error as Error).message).toContain("Invalid SOCKS5 address"); + expect((error as Error).message).not.toContain("private-secret"); + } + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f5c49f9959c..8a2a8d2b5c1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,7 @@ { "key-attribution.test.ts": "usage", + "socks5-fetch.test.ts": "lib", + "start-args.test.ts": "cli", "responses-core-modules.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", "responses-send-budget-errors.test.ts": "responses", diff --git a/tests/socks5-fetch.test.ts b/tests/lib/socks5-fetch.test.ts similarity index 82% rename from tests/socks5-fetch.test.ts rename to tests/lib/socks5-fetch.test.ts index fc7c534f3e9..ef58ed40171 100644 --- a/tests/socks5-fetch.test.ts +++ b/tests/lib/socks5-fetch.test.ts @@ -2,12 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer as createHttpServer } from "node:http"; import net, { createConnection, createServer as createTcpServer, Socket, type Server as TcpServer } from "node:net"; import type { AddressInfo } from "node:net"; -import { configureSocks5Fetch } from "../src/lib/proxy-env"; -import { providerOutboundGet } from "../src/lib/provider-outbound"; -import { socks5Fetch } from "../src/lib/socks5-fetch"; -import { applyProxyEnv } from "../src/config"; -import { providerFetch } from "../src/server/responses/fetch-helpers"; -import type { OcxProviderConfig } from "../src/types"; +import { configuredOutboundFetch, effectiveProxyFor, configureSocks5Fetch } from "../../src/lib/proxy-env"; +import { providerOutboundGet } from "../../src/lib/provider-outbound"; +import { socks5Fetch } from "../../src/lib/socks5-fetch"; +import { applyProxyEnv } from "../../src/config"; +import { providerFetch } from "../../src/server/responses/fetch-helpers"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; const proxyEnvKeys = [ "HTTP_PROXY", @@ -261,8 +261,8 @@ describe("socks5Fetch", () => { expect(outcome.message).toBe("test write failure"); if (!clientSocket || !listenerCountsBeforeWait) throw new Error("backpressure socket was not captured"); expect(clientSocket.listenerCount("drain")).toBe(listenerCountsBeforeWait.drain); - expect(clientSocket.listenerCount("error")).toBe(listenerCountsBeforeWait.error); - expect(clientSocket.listenerCount("close")).toBe(listenerCountsBeforeWait.close); + expect(clientSocket.listenerCount("error")).toBe(0); + expect(clientSocket.listenerCount("close")).toBe(0); } finally { Socket.prototype.write = originalWrite; heldSocket?.destroy(); @@ -540,3 +540,91 @@ describe("configured SOCKS5 fetch", () => { } }); }); + +describe("SOCKS5 framing after modularization", () => { + test.each([ + ["headers", ["HTTP/1.1 200 OK\r\nContent-Len", "gth: 2\r\n", "\r\nok"]], + ["chunk sizes", ["HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n", "2\r", "\nok\r\n", "0\r", "\n\r\n"]], + ] as Array<[string, string[]]>)("waits for fresh bytes across fragmented %s", async (_name, fragments) => { + const timers: Array> = []; + const target = createTcpServer(socket => { + socket.on("error", () => undefined); + let request = ""; + const onRequest = (data: Buffer) => { + request += data.toString("latin1"); + if (!request.includes("\r\n\r\n")) return; + socket.removeListener("data", onRequest); + const send = (index: number) => { + if (socket.destroyed || index >= fragments.length) return; + socket.write(fragments[index]!); + if (index + 1 < fragments.length) timers.push(setTimeout(() => send(index + 1), 10)); + }; + send(0); + }; + socket.on("data", onRequest); + }); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + try { + const response = await socks5Fetch( + "http://provider.invalid:" + targetPort + "/fragmented", undefined, + "socks5://127.0.0.1:" + proxyPort, + ); + expect(await response.text()).toBe("ok"); + } finally { + for (const timer of timers) clearTimeout(timer); + await Promise.all([close(proxy), close(target)]); + } + }); + + test("rejects an over-limit header even when its delimiter is already buffered", async () => { + const target = createTcpServer(socket => { + socket.on("error", () => undefined); + let request = ""; + const onRequest = (data: Buffer) => { + request += data.toString("latin1"); + if (!request.includes("\r\n\r\n")) return; + socket.removeListener("data", onRequest); + socket.end("HTTP/1.1 200 OK\r\nX-Large: " + "x".repeat(64 * 1024) + "\r\nContent-Length: 0\r\n\r\n"); + }; + socket.on("data", onRequest); + }); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + try { + await expect(socks5Fetch("http://provider.invalid:" + targetPort + "/headers", undefined, + "socks5://127.0.0.1:" + proxyPort)).rejects.toThrow("headers are too large"); + } finally { await Promise.all([close(proxy), close(target)]); } + }); + + test("an admitted explicit SOCKS route cannot be replaced by changed environment routing", async () => { + const target = createHttpServer((_request, response) => response.end("snapshot")); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + process.env.ALL_PROXY = "socks5://127.0.0.1:1"; + process.env.NO_PROXY = "*"; + let fallbackCalls = 0; + const fallback = (async () => { fallbackCalls++; throw new Error("snapshot must remain proxied"); }) as typeof fetch; + try { + const init: RequestInit & { proxy: string } = { proxy: "socks5://127.0.0.1:" + proxyPort }; + const response = await configuredOutboundFetch("http://provider.invalid:" + targetPort + "/snapshot", init, fallback); + expect(await response.text()).toBe("snapshot"); + expect(fallbackCalls).toBe(0); + } finally { await Promise.all([close(proxy), close(target)]); } + }); + + test("effective proxy selection agrees with the SOCKS wrapper while ignoring HTTP ALL_PROXY", () => { + const url = new URL("https://provider.invalid/"); + expect(effectiveProxyFor(url, { ALL_PROXY: "socks5://127.0.0.1:1080", HTTPS_PROXY: "http://other:8080" })) + .toBe("socks5://127.0.0.1:1080"); + expect(effectiveProxyFor(url, { ALL_PROXY: "http://other:8080" })).toBeNull(); + }); +}); + +test("SOCKS5 does not silently downgrade an explicit HTTP/2 protocol pin", async () => { + for (const protocol of ["http2", "h2"]) { + const init = { protocol } as RequestInit & { protocol: string }; + await expect(socks5Fetch("https://provider.invalid/", init, "socks5://127.0.0.1:1")) + .rejects.toThrow("cannot honor an explicit HTTP/2 pin"); + } +}); From 0f7c476686cc8e6a373e2e4ff1a27b798e655845 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 15:46:26 +0000 Subject: [PATCH 9/9] test(proxy): treat HTTP ALL_PROXY as unused after SOCKS5 rebase #3462 used ALL_PROXY=socks5:// as a stand-in for a variable Bun fetch does not honour. The SOCKS5 wrapper now owns that scheme, so keep the Mihomo unused-proxy cases on HTTP ALL_PROXY and assert SOCKS5 ALL_PROXY is the scheme-matched route. Co-authored-by: Warexpor --- tests/fixtures/provider-outbound-mihomo.ts | 4 +++- tests/providers/provider-outbound.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/fixtures/provider-outbound-mihomo.ts b/tests/fixtures/provider-outbound-mihomo.ts index e376d8246b4..a5d12557aad 100644 --- a/tests/fixtures/provider-outbound-mihomo.ts +++ b/tests/fixtures/provider-outbound-mihomo.ts @@ -83,7 +83,9 @@ for (const method of ["GET", "POST"] as const) { } // TUN handles the validated IPv6 address even if unrelated proxy variables exist. - const directEnvs: Record[] = [{}, { HTTP_PROXY: "http://127.0.0.1:7897" }, { ALL_PROXY: "socks5://127.0.0.1:7891" }]; + // HTTP ALL_PROXY remains unused (Bun fetch does not honour it). A SOCKS5 ALL_PROXY + // is a real wrapper route and is not an unrelated variable. + const directEnvs: Record[] = [{}, { HTTP_PROXY: "http://127.0.0.1:7897" }, { ALL_PROXY: "http://127.0.0.1:7891" }]; for (const env of directEnvs) { await attempt(env, [fake], "pinned"); } diff --git a/tests/providers/provider-outbound.test.ts b/tests/providers/provider-outbound.test.ts index 54c82a5435a..f1613f3703c 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -508,8 +508,8 @@ describe("#3462 Mihomo IPv6 fake-IP admission is gated on the scheme-matched pro expect(fetchInits).toHaveLength(0); }); - test("HTTPS target + ALL_PROXY only: not admitted", async () => { - const { resolveOptions, fetchInits } = await run({ ALL_PROXY: "socks5://127.0.0.1:7891" }, { admit: false }); + test("HTTPS target + HTTP ALL_PROXY only: not admitted", async () => { + const { resolveOptions, fetchInits } = await run({ ALL_PROXY: "http://127.0.0.1:7891" }, { admit: false }); expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: false }]); expect(fetchInits).toHaveLength(0); }); @@ -544,7 +544,7 @@ describe("#3462 Mihomo IPv6 fake-IP admission is gated on the scheme-matched pro }); describe("effectiveProxyFor picks the variable Bun fetch actually honours", () => { - test("scheme-matched selection; ALL_PROXY is never consulted", async () => { + test("scheme-matched selection; HTTP ALL_PROXY is never consulted", async () => { const { effectiveProxyFor } = await import("../../src/lib/proxy-env"); const https = new URL("https://opencode.ai/zen/v1/models"); const http = new URL("http://ollama.lan:11434/v1/models"); @@ -552,6 +552,7 @@ describe("effectiveProxyFor picks the variable Bun fetch actually honours", () = expect(effectiveProxyFor(https, { https_proxy: " http://p:2 " })).toBe("http://p:2"); expect(effectiveProxyFor(https, { HTTP_PROXY: "http://p:3" })).toBeNull(); expect(effectiveProxyFor(https, { ALL_PROXY: "http://p:4" })).toBeNull(); + expect(effectiveProxyFor(https, { ALL_PROXY: "socks5://127.0.0.1:1080" })).toBe("socks5://127.0.0.1:1080"); expect(effectiveProxyFor(http, { HTTP_PROXY: "http://p:5" })).toBe("http://p:5"); expect(effectiveProxyFor(http, { HTTPS_PROXY: "http://p:6" })).toBeNull(); expect(effectiveProxyFor(https, { HTTPS_PROXY: " " })).toBeNull();