diff --git a/README.md b/README.md index c7ce31acceb..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] # start the proxy in the foreground +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 152108fd881..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,13 +13,21 @@ 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] | --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 +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..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) 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 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 ee7604688bf..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,13 +13,17 @@ description: セットアップ、開始、停止、サービス、診断、同 ## プロキシのライフサイクル -### `ocx start [--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 +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..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) プロキシ URL または `${ENV_VAR}`。これらの変数が設定されていない場合にのみ、`HTTP_PROXY` / `HTTPS_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 2cac37ed244..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,16 +16,23 @@ Codex 자동 시작 shim도 설치합니다. ## 프록시 수명 주기 -### `ocx start [--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 +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..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) 프록시 URL 또는 `${ENV_VAR}`입니다. 해당 변수가 비어 있을 때만 `HTTP_PROXY` / `HTTPS_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 32ff382d697..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,24 @@ optionally installs the Codex autostart shim. ## Proxy lifecycle -### `ocx start [--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 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 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..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) 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 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. | @@ -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..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 ]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` Запустить proxy server (предпочтительный порт `10100`). Если этот порт занят, opencodex выбирает и записывает другой свободный порт. При запуске пишется состояние PID/runtime-port, а попытка @@ -26,9 +26,17 @@ 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 +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..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)-прокси или `${ENV_VAR}`. Применяется к `HTTP_PROXY` / `HTTPS_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 ca59f39db9d..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 ]` +### `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,9 +28,16 @@ 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 +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..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) 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 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 2c5e3a49150..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,13 +13,17 @@ description: 安装、启动、停止、服务、诊断、同步和更新命令 ## 代理生命周期 -### `ocx start [--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 +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..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) 代理 URL,或 `${ENV_VAR}`。仅当 `HTTP_PROXY` / `HTTPS_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 117133bc187..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,13 +13,17 @@ description: 安裝、啟動、停止、服務、診斷、同步與更新指令 ## 代理生命週期 -### `ocx start [--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 +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..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) 代理 URL 或 `${ENV_VAR}`。僅在那些變數未設定時套用至 `HTTP_PROXY` / `HTTPS_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/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/help.ts b/src/cli/help.ts index 6765d27fcf0..0be199f1a5a 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] | --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) @@ -102,6 +103,8 @@ 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 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 9df96b3fad5..86150097921 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, @@ -63,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"; @@ -150,21 +152,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 +303,26 @@ 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 || startOpts.socks5Off) { + const proxyConfig = loadConfig(); + 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 proxy off)."); + } + } else { + proxyConfig.proxy = startOpts.socks5!; + saveConfig(proxyConfig); + console.log(`Outbound SOCKS5: ${redactUrlForLog(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..f183635b0cc 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] | --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 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..ee4892c09ff --- /dev/null +++ b/src/cli/start-args.ts @@ -0,0 +1,94 @@ +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 { + constructor(message: string) { + super(message); + this.name = "StartArgsError"; + } +} + +export type StartOptions = { + port?: number; + /** SOCKS5 URL to persist. */ + socks5?: string; + socks5Off?: boolean; +}; + +export function isSocksProxyUrl(proxy: string): boolean { + 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)) { + 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; expected socks5://host:port"); + } + } + 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"); + } + 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; expected socks5://host:port"); +} + +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") { + 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); + i += 1; + } else { + options.socks5 = DEFAULT_SOCKS5_PROXY; + } + continue; + } + if (arg === "--socks5-off") { + if (options.socks5 !== undefined) throw new StartArgsError("--socks5 and --socks5-off cannot be used together"); + options.socks5Off = true; + continue; + } + throw new StartArgsError(START_USAGE); + } + return options; +} diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts index 649c8b6f1ff..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,8 +153,18 @@ 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 (/^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]; + } + 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); @@ -184,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..ab2e222e2b0 --- /dev/null +++ b/src/lib/socks5-fetch.ts @@ -0,0 +1,575 @@ +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 MAX_BODY_SLICE_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) < 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"); + 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 failure: Error | undefined; + 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); + socket.resume(); + } + + 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(); + 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); + }; + + private readonly onEnd = (): void => { + this.ended = true; + 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 { + 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 (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(this.failure ?? new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived")); + return new Promise((resolve, reject) => { + const onAbort = () => { + const index = this.exactWaiters.indexOf(waiter); + if (index >= 0) this.exactWaiters.splice(index, 1); + waiter.reject(signal?.reason ?? new Error("The operation was aborted")); + }; + // Store this exact object so cancellation removes the queued waiter. + const waiter = { + length, + 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 { + // 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; + 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 (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 onAbort = () => { + const index = this.anyWaiters.indexOf(waiter); + if (index >= 0) this.anyWaiters.splice(index, 1); + 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 }); + 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")); + } +} + +async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): Promise { + const parsedProxy = validateProxy(proxy); + const credentials = proxyCredentials(parsedProxy); + 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")); + }); + 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]})`); + 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); + 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 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"); + 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 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, + signal: AbortSignal, + headers: Headers, + status: number, + method: string, +): ReadableStream | 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)) { + 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(); + socket.setTimeout(0); + socket.destroy(); + }; + const onAbort = () => socket.destroy(); + 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 = parseChunkSize(line); + 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(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, MAX_BODY_SLICE_BYTES), 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 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; + 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); + 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 waitForDrain(socket, request.signal); + } + if (headers.chunked && !socket.write("0\r\n\r\n")) await waitForDrain(socket, request.signal); + } finally { + bodyReader.releaseLock(); + } + } + 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); + return new Response(body, { + status: responseHead.status, + statusText: responseHead.statusText, + headers: responseHead.headers, + }); + } catch (error) { + request.signal.removeEventListener("abort", onAbort); + reader?.dispose(); + socket.destroy(); + throw error; + } +} diff --git a/src/server/index.ts b/src/server/index.ts index 4892f4aaf35..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, @@ -226,6 +227,8 @@ 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 17bf93218b1..d7a7667c38f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -722,9 +722,11 @@ export interface OcxConfig { | { enabled: false } | { enabled: true; port?: number }; /** - * Outbound HTTP(S) proxy URL for provider requests (e.g. "http://user:pass@proxy:8080", or - * "${HTTPS_PROXY}"-style env reference). Mirrored into HTTP_PROXY/HTTPS_PROXY at startup when - * those are unset — Bun's fetch honors them for all outbound calls; localhost is excluded. + * 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. 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/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 6b55bbaf0da..78c80207c70 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,23 @@ 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. + +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/cli/cli-help.test.ts b/tests/cli/cli-help.test.ts index 1020439e496..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 ]"); + 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 ]" }, - { args: ["start", "--port", "1234", "--extra"], expected: "Usage: ocx start [--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 ]"); + 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/cli/start-args.test.ts b/tests/cli/start-args.test.ts new file mode 100644 index 00000000000..e5589cda5ef --- /dev/null +++ b/tests/cli/start-args.test.ts @@ -0,0 +1,89 @@ +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 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", () => { + 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"); + }); + + 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"); + }); +}); + +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/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/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/lib/socks5-fetch.test.ts b/tests/lib/socks5-fetch.test.ts new file mode 100644 index 00000000000..ef58ed40171 --- /dev/null +++ b/tests/lib/socks5-fetch.test.ts @@ -0,0 +1,630 @@ +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 { 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", + "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]])); +const openConnections = new WeakMap>(); + +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 { + 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); + }); + return (server.address() as AddressInfo).port; +} + +async function close(server: TcpServer | ReturnType): Promise { + for (const socket of openConnections.get(server) ?? []) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); +} + +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); + 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); + 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); + 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("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("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(0); + expect(clientSocket.listenerCount("close")).toBe(0); + } 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" }); + 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("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("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; + 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)]); + } + }); +}); + +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"); + } +}); 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(); 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", 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 c795c6cf2d0..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 { @@ -282,6 +283,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)", () => {