diff --git a/README.md b/README.md index ce0e6daef21..0eb79d8d1d9 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,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 59652bbaef7..f62344989ed 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -13,13 +13,15 @@ Assistant de configuration interactif (`setup` est un alias de `init`). Il deman ## Cycle de vie du proxy -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port] | --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`). ```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 27fd80b36a5..99915f426ba 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`, `ocx start --socks5`) ou `${ENV_VAR}`. Les URL HTTP s’appliquent à `HTTP_PROXY` / `HTTPS_PROXY` uniquement lorsque ces variables ne sont pas définies ; SOCKS5 est exposé via `ALL_PROXY` et le transport SOCKS5 d’OpenCodex. 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` | Nombre de secondes sans données en amont avant `response.incomplete`. 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 b7952b5c280..0145dbff5bb 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -13,13 +13,15 @@ description: セットアップ、開始、停止、サービス、診断、同 ## プロキシのライフサイクル -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` プロキシ サーバー (優先ポート `10100`) を起動します。そのポートが占有されている場合、opencodex は別の使用可能なポートを選択して記録します。 PID/ランタイムポートの状態を書き込み、2 番目のライブインスタンスの起動を拒否します。開始時に、各プロバイダーのモデルを Codex のカタログに同期します。マネージド サービス (`OCX_SERVICE=1`) として起動されていない限り、シャットダウン時にネイティブ Codex が復元されます。 ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index b1dd316c7fa..245c748c01a 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`、`ocx start --socks5`)または `${ENV_VAR}`。HTTP URL はこれらの変数が設定されていない場合にのみ `HTTP_PROXY` / `HTTPS_PROXY` に適用され、SOCKS5 は `ALL_PROXY` と OpenCodex の SOCKS5 転送を使います。ループバックは `NO_PROXY` に残ります。 | | `emptyCompletionRetry?` | `boolean` | `false` | テキストもツール呼び出しもない Responses ターンを、ターミナルイベント前にストリームが終了した場合も含め、同一リクエストで 1 回再試行するよう明示的に有効化します。再試行は課金対象になる場合があります。`OCX_EMPTY_COMPLETION_RETRY=0` で設定を変更せず無効化できます。combo と routed-compaction turn は対象外です。 | | `stallTimeoutSec?` | `number` | `300` | `response.incomplete` より前にアップストリーム データがない秒数。最小 1。 | `connectTimeoutMs?` | `number` | `200000` |試行ごとの DNS/TCP/TLS/最終ヘッダーの期限。本体が生成される前に終了します。 | 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 d4c19ebd461..5f513dd0e8b 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -16,7 +16,7 @@ Codex 자동 시작 shim도 설치합니다. ## 프록시 수명 주기 -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` 프록시 서버를 시작합니다(권장 포트는 `10100`). 해당 포트가 이미 사용 중이면 opencodex가 다른 사용 가능한 포트를 골라 기록합니다. PID와 런타임 포트 상태를 기록하고, 두 번째 활성 인스턴스는 시작하지 @@ -26,6 +26,8 @@ Codex 자동 시작 shim도 설치합니다. ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 1ccaa8851ae..5621eb8550b 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"` | 바인드 주소입니다. 루프백이 아닌 바인드에는 `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`, `ocx start --socks5`) 또는 `${ENV_VAR}`입니다. HTTP URL은 해당 변수가 비어 있을 때만 `HTTP_PROXY` / `HTTPS_PROXY`에 적용되며, SOCKS5는 `ALL_PROXY`와 OpenCodex SOCKS5 전송을 사용합니다. 루프백은 `NO_PROXY`에 그대로 남습니다. | | `emptyCompletionRetry?` | `boolean` | `false` | 텍스트나 도구 호출이 없는 Responses 턴을, 터미널 이벤트 전에 스트림이 종료된 경우를 포함해 동일한 요청으로 한 번 재시도하도록 선택합니다. 재시도에는 비용이 발생할 수 있습니다. `OCX_EMPTY_COMPLETION_RETRY=0`은 설정을 바꾸지 않고 비활성화하며, combo 및 routed-compaction turn은 제외됩니다. | | `stallTimeoutSec?` | `number` | `300` | 업스트림 데이터가 없을 때 `response.incomplete`가 되기까지의 초 수입니다. 최소 1입니다. | | `connectTimeoutMs?` | `number` | `200000` | 시도별 DNS/TCP/TLS/최종 헤더 기한입니다. 본문 생성 전에 끝납니다. | diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index a7d61a0bee3..18357aa5c5d 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 cdc8d6af523..9ff9e966daa 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. Non-loopback binds require `OPENCODEX_API_AUTH_TOKEN`. | -| `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. | | `stallTimeoutSec?` | `number` | `300` | Seconds without upstream data before `response.incomplete`. Minimum 1. | @@ -45,6 +45,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 @@ -91,10 +99,12 @@ terminal does not update an already running service. An unset `proxy` leaves inherited proxy variables unchanged. An explicit HTTP(S) proxy URL fills `HTTP_PROXY` and `HTTPS_PROXY` only where they are unset. -`"proxy": "auto"` reads the Windows static WinINET proxy once at startup; existing +An explicit SOCKS5 URL (`socks5://host:port`) is mirrored into `ALL_PROXY`, +clears inherited `HTTP(S)_PROXY` in this process, and uses OpenCodex's SOCKS5 +transport. `"proxy": "auto"` reads the Windows static WinINET proxy once at startup; existing proxy environment variables take precedence. Auto discovery does not resolve PAC/WPAD, SOCKS-only settings or live proxy changes. Use a supported static HTTP -proxy setting or an explicit HTTP(S) proxy URL when needed. +proxy setting, an explicit HTTP(S) proxy URL, or `ocx start --socks5` when needed. Compare the diagnostic on the same machine and account under the two network modes. A successful TUN test alone does not identify why the service's HTTP proxy 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 f743f5f4687..87e57d5d78f 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, а попытка @@ -29,6 +29,8 @@ opencodex и его интеграцию с Codex. ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index f8bc9f2a255..5070260a5c6 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`, `ocx start --socks5`) или `${ENV_VAR}`. HTTP URL применяется к `HTTP_PROXY` / `HTTPS_PROXY` только когда эти переменные не заданы; SOCKS5 пишется в `ALL_PROXY` и использует транспорт SOCKS5 OpenCodex. loopback всегда остаётся в `NO_PROXY`. | | `emptyCompletionRetry?` | `boolean` | `false` | Явно включает один идентичный повтор Responses, если в turn нет ни текста, ни tool call, включая случай, когда stream завершается до terminal event. Повтор может тарифицироваться. `OCX_EMPTY_COMPLETION_RETRY=0` отключает его без изменения config; combo и routed-compaction turn исключены. | | `stallTimeoutSec?` | `number` | `300` | Секунды без upstream-данных до `response.incomplete`. Минимум 1. | | `connectTimeoutMs?` | `number` | `200000` | Дедлайн одной попытки DNS/TCP/TLS/final-header; он завершается до генерации тела ответа. | 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 6a7a5651396..c2c6fea289a 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ı @@ -31,6 +31,8 @@ Codex'i geri yükler. ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index 3b6ee8a9f3c..56ca5a07ec2 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`, `ocx start --socks5`) veya `${ENV_VAR}`. HTTP URL'leri yalnızca bu değişkenler ayarlanmadığında `HTTP_PROXY` / `HTTPS_PROXY`'ye uygulanır; SOCKS5 `ALL_PROXY` ve OpenCodex SOCKS5 aktarımını kullanır. 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` | `response.incomplete` öncesinde yukarı akış verisi olmadan geçen saniye. Minimum 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 5977c734de5..3997b5d91c8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -13,13 +13,15 @@ description: 安装、启动、停止、服务、诊断、同步和更新命令 ## 代理生命周期 -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` 启动代理服务器(首选端口 `10100`)。如果该端口已被占用,opencodex 会选择并记录另一个可用端口。它会写入 PID/运行时端口状态,并拒绝启动第二个存活实例。启动时,它会把每个提供方的模型同步到 Codex 的目录中。关闭时,它会恢复原生 Codex,除非它是作为受管服务启动的(`OCX_SERVICE=1`)。 ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 211e1416501..f40c64e7214 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`,`ocx start --socks5`),或 `${ENV_VAR}`。HTTP URL 仅当 `HTTP_PROXY` / `HTTPS_PROXY` 未设置时才会应用;SOCKS5 写入 `ALL_PROXY` 并使用 OpenCodex SOCKS5 传输。回环地址始终保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 显式启用:当 Responses turn 既无文本也无工具调用时,使用相同请求重试一次,包括流在终止事件之前结束的情况。重试可能产生费用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不修改配置的情况下禁用;combo 与 routed-compaction turn 不参与。 | | `stallTimeoutSec?` | `number` | `300` | 在上游没有数据之前可等待的秒数,超过后返回 `response.incomplete`。最小值为 1。 | | `connectTimeoutMs?` | `number` | `200000` | 每次尝试的 DNS/TCP/TLS/最终响应头截止时间;它在正文生成之前结束。 | 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 71d575e7740..4023612fc43 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -13,13 +13,15 @@ description: 安裝、啟動、停止、服務、診斷、同步與更新指令 ## 代理生命週期 -### `ocx start [--port ]` +### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` 啟動代理伺服器(偏好連接埠 `10100`)。若該連接埠被佔用,opencodex 會選擇並記錄另一個可用連接埠。它寫入 PID/runtime-port 狀態,並拒絕啟動第二個即時實例。啟動時它將每個供應商的模型同步到 Codex 目錄。關閉時它還原原生 Codex——除非它是作為受管服務啟動的(`OCX_SERVICE=1`)。 ```bash ocx start ocx start --port 8080 +ocx start --port 10100 --socks5 +ocx start --socks5-off ``` ### `ocx stop` diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index 4649b51f977..4f5852b1786 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`,`ocx start --socks5`)或 `${ENV_VAR}`。HTTP URL 僅在那些變數未設定時套用至 `HTTP_PROXY` / `HTTPS_PROXY`;SOCKS5 寫入 `ALL_PROXY` 並使用 OpenCodex SOCKS5 傳輸。回送保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 明確啟用:當 Responses 完成時沒有文字或工具呼叫,以相同請求重試一次。重試可能產生費用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不變更設定的情況下停用;combo 與 routed-compaction turn 不適用。 | | `stallTimeoutSec?` | `number` | `300` | 在 `response.incomplete` 前無上游資料的秒數。最小 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 0ff7d4861ce..0ee18edc19a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -304,6 +304,7 @@ "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", + "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", "claude-management-api.test.ts": "claude-integration", @@ -1152,6 +1153,7 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", + "socks5-fetch.test.ts": "server", "sse-client-frame-bounds.test.ts": "responses", "sse-decoder.test.ts": "responses", "sse-failed-tail.test.ts": "responses", @@ -1162,6 +1164,7 @@ "stale-state-purge.test.ts": "service", "stall-timeout.test.ts": "lib", "star-deferral.test.ts": "cli", + "start-args.test.ts": "cli", "startup-action-control-elevation.test.ts": "server", "startup-action-control.test.ts": "server", "startup-health-ui.test.ts": "gui", diff --git a/src/claude/inbound-cache-stabilize.ts b/src/claude/inbound-cache-stabilize.ts new file mode 100644 index 00000000000..1b74e549e1a --- /dev/null +++ b/src/claude/inbound-cache-stabilize.ts @@ -0,0 +1,42 @@ +/** + * Claude Code appends growing `` footers (and + * occasional TaskCreate nudges) into system text that becomes Responses + * `instructions`. That churn breaks Muse/Go prefix cache on the instructions + * prefix even when tools stay stable. Strip dynamics from instructions; + * surface the latest notice on `input` instead. + */ + +const TOTAL_TOKENS_RE = /[\s\S]*?<\/total_tokens>/g; + +/** Matches TaskCreate nudge paragraphs appended mid-session. */ +const TASKCREATE_NUDGE_RE = + /The task tools haven't been used recently[\s\S]*?TaskCreate[\s\S]*?(?:Only use these if relevant to the current work\.\s*This is just a gentle reminder - ignore if not applicable\.|[^\n]*)/g; + +export function stabilizeClaudeInstructionsForPromptCache( + instructions: string, +): { instructions: string; dynamicNotice: string | null } { + if (!instructions) { + return { instructions: "", dynamicNotice: null }; + } + + let latestTotal: string | null = null; + for (const m of instructions.matchAll(TOTAL_TOKENS_RE)) { + latestTotal = m[0]; + } + + let latestNudge: string | null = null; + for (const m of instructions.matchAll(TASKCREATE_NUDGE_RE)) { + latestNudge = m[0].trim(); + } + + let cleaned = instructions.replace(TOTAL_TOKENS_RE, ""); + cleaned = cleaned.replace(TASKCREATE_NUDGE_RE, ""); + cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim(); + + const noticeParts: string[] = []; + if (latestTotal) noticeParts.push(latestTotal); + if (latestNudge) noticeParts.push(latestNudge); + const dynamicNotice = noticeParts.length > 0 ? noticeParts.join("\n\n") : null; + + return { instructions: cleaned, dynamicNotice }; +} diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index c2e3ded9b27..e788778140e 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -17,6 +17,7 @@ export { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, e import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options"; import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options"; +import { stabilizeClaudeInstructionsForPromptCache } from "./inbound-cache-stabilize"; import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget"; @@ -345,7 +346,21 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef stream: raw.stream === true, }; - if (systemParts.length > 0) body.instructions = systemParts.join("\n\n"); + if (systemParts.length > 0) { + // Claude Code appends growing footers (and occasional + // TaskCreate nudges) into system text. That churn breaks Muse/Go prefix + // cache on the Responses instructions prefix even when tools stay stable. + // Strip dynamics from instructions; surface the latest notice on input. + const stabilized = stabilizeClaudeInstructionsForPromptCache(systemParts.join("\n\n")); + if (stabilized.instructions) body.instructions = stabilized.instructions; + if (stabilized.dynamicNotice) { + input.push({ + type: "message", + role: "user", + content: [{ type: "input_text", text: stabilized.dynamicNotice }], + }); + } + } const tools = toolsToResponses(raw.tools); if (tools) body.tools = tools; diff --git a/src/cli/help.ts b/src/cli/help.ts index 0cd6bec4dc1..7fa34d6deea 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) @@ -96,6 +97,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 sync Sync available models to Codex`); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 663514a1209..3be1158c45d 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -51,6 +51,7 @@ import { import { collectStatus, 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, @@ -62,7 +63,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 { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import { createReadinessGate } from "../server/readiness"; @@ -145,21 +147,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 { @@ -281,7 +275,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 467a0f7971a..960ebe9210d 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..0c767ea512c --- /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: ${trimmed}`); + } + } + if (/^socks4a?:\/\//i.test(trimmed)) { + throw new StartArgsError("Only SOCKS5 proxy URLs are supported"); + } + if (/^https?:\/\//i.test(trimmed)) { + throw new StartArgsError("SOCKS5 proxy must be socks5://host:port, not an HTTP URL"); + } + if (/^\d+$/.test(trimmed)) { + const port = Number(trimmed); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new StartArgsError("Invalid SOCKS5 port number"); + } + return `socks5://127.0.0.1:${port}`; + } + const hostPort = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(trimmed); + if (hostPort) { + const port = Number(hostPort[2]); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new StartArgsError("Invalid SOCKS5 port number"); + } + return `socks5://${trimmed}`; + } + throw new StartArgsError(`Invalid SOCKS5 address: ${trimmed}`); +} + +export function parseStartOptions(argv: string[]): StartOptions { + const options: StartOptions = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === "--port") { + const value = argv[++i]; + const port = value && /^\d+$/.test(value) ? Number(value) : NaN; + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new StartArgsError("Invalid port number"); + } + options.port = port; + continue; + } + if (arg === "--socks5") { + 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.ts b/src/config.ts index 8da89cbfdfb..5210328d222 100644 --- a/src/config.ts +++ b/src/config.ts @@ -65,6 +65,7 @@ import { recordOwnedConfigPath } from "./lib/config-ownership"; import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { redactSecretString } from "./lib/redact"; +import { configureSocks5Fetch } from "./lib/proxy-env"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases"; import { MODEL_DISCOVERY_MAX_MODELS } from "./providers/model-discovery-limits"; @@ -3871,12 +3872,11 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements } /** - * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports - * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY - * variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS. - * localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and - * running-proxy API calls stay direct. Call once per process entry that makes outbound provider - * requests (server start, catalog sync). + * Mirror `config.proxy` into outbound-proxy env vars. HTTP(S) URLs go to + * HTTP_PROXY/HTTPS_PROXY when those are unset. SOCKS5 URLs go to ALL_PROXY, + * clear inherited HTTP(S)_PROXY, and install the explicit SOCKS5 fetch wrapper. + * Loopback is always merged into NO_PROXY. Call once per process entry that + * makes outbound provider requests (server start, catalog sync). */ export function applyProxyEnv(config: OcxConfig): void { applyProxyEnvWith(config); @@ -3897,6 +3897,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") { @@ -3925,8 +3926,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); @@ -3958,6 +3969,7 @@ export function applyProxyEnvWith( } } process.env.NO_PROXY = entries.join(","); + configureSocks5Fetch(); } function warnConfigRepaired(configPath: string, error: z.ZodError): void { 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..cf85b3d18f0 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; @@ -84,6 +86,67 @@ export function outboundProxyConfigured( return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env)); } +export function isSocks5ProxyUrl(proxy: string): boolean { + return /^socks5h?:\/\//i.test(proxy.trim()); +} + +/** Only ALL_PROXY / all_proxy; first value that is socks5(h). */ +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 proxy = 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:") && !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; + +/** Install/uninstall globalThis.fetch wrapper when ALL_PROXY is socks5. Preserves .preconnect. */ +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; +} + /** * The proxy URL that Bun's fetch will actually use for `url`, or null when none applies. * diff --git a/src/lib/socks5-fetch.ts b/src/lib/socks5-fetch.ts new file mode 100644 index 00000000000..b77a5f8d529 --- /dev/null +++ b/src/lib/socks5-fetch.ts @@ -0,0 +1,550 @@ +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) > 65535)) { + throw new Socks5FetchError("SOCKS5 proxy port is invalid"); + } + if (parsed.search || parsed.hash) throw new Socks5FetchError("SOCKS5 proxy URL must not contain a query or fragment"); + proxyCredentials(parsed); + return parsed; +} + +function targetPort(target: URL): number { + if (target.port) return Number(target.port); + return target.protocol === "https:" ? 443 : 80; +} + +function connectSocket(hostname: string, port: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + return; + } + const socket = net.createConnection({ host: hostname, port }); + let settled = false; + let timer: ReturnType | undefined = setTimeout(() => { + fail(new Socks5FetchError("SOCKS5 proxy connection timed out")); + }, SOCKS5_CONNECT_TIMEOUT_MS); + const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + const onConnect = () => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + cleanup(); + resolve(socket); + }; + const onError = (error: Error) => fail(error); + const onClose = () => fail(new Socks5FetchError("SOCKS5 proxy closed before connecting")); + const cleanup = () => { + signal?.removeEventListener("abort", onAbort); + socket.removeListener("connect", onConnect); + socket.removeListener("error", onError); + socket.removeListener("close", onClose); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + cleanup(); + socket.destroy(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + socket.once("connect", onConnect); + socket.once("error", onError); + socket.once("close", onClose); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +class SocketReader { + private buffer = Buffer.alloc(0); + private ended = false; + private readonly exactWaiters: Array<{ + length: number; + resolve: (value: Buffer) => void; + reject: (error: unknown) => void; + }> = []; + private readonly anyWaiters: Array<{ + resolve: (value: Buffer) => void; + reject: (error: unknown) => void; + }> = []; + + constructor(private readonly socket: Socket) { + socket.on("data", this.onData); + socket.once("error", this.onError); + socket.once("end", this.onEnd); + socket.once("close", this.onEnd); + } + + private readonly onData = (chunk: Buffer | string): void => { + const value = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + if (this.anyWaiters.length > 0) { + this.anyWaiters.shift()!.resolve(value); + return; + } + this.buffer = Buffer.concat([this.buffer, value]); + this.flushExact(); + }; + + private readonly onError = (error: Error): void => { + this.ended = true; + this.rejectExact(error); + this.rejectAny(error); + }; + + private readonly onEnd = (): void => { + this.ended = true; + this.rejectExact(new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived")); + while (this.anyWaiters.length > 0) this.anyWaiters.shift()!.resolve(Buffer.alloc(0)); + }; + + private flushExact(): void { + while (this.exactWaiters.length > 0 && this.buffer.byteLength >= this.exactWaiters[0]!.length) { + const waiter = this.exactWaiters.shift()!; + const value = this.buffer.subarray(0, waiter.length); + this.buffer = this.buffer.subarray(waiter.length); + waiter.resolve(value); + } + } + + private rejectExact(error: unknown): void { + while (this.exactWaiters.length > 0) this.exactWaiters.shift()!.reject(error); + } + + private rejectAny(error: unknown): void { + while (this.anyWaiters.length > 0) this.anyWaiters.shift()!.reject(error); + } + + read(length: number, signal?: AbortSignal): Promise { + if (this.buffer.byteLength >= length) { + const value = this.buffer.subarray(0, length); + this.buffer = this.buffer.subarray(length); + return Promise.resolve(value); + } + if (this.ended) return Promise.reject(new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived")); + return new Promise((resolve, reject) => { + const waiter = { length, resolve, reject }; + const onAbort = () => { + signal?.removeEventListener("abort", onAbort); + const index = this.exactWaiters.indexOf(waiter); + if (index >= 0) this.exactWaiters.splice(index, 1); + reject(signal?.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + this.exactWaiters.push({ + length, + resolve: value => { + signal?.removeEventListener("abort", onAbort); + resolve(value); + }, + reject: error => { + signal?.removeEventListener("abort", onAbort); + reject(error); + }, + }); + }); + } + + async readUntil(delimiter: Buffer, maxBytes: number, signal?: AbortSignal): Promise { + while (true) { + const index = this.buffer.indexOf(delimiter); + if (index >= 0) { + const end = index + delimiter.byteLength; + const value = this.buffer.subarray(0, end); + this.buffer = this.buffer.subarray(end); + return value; + } + if (this.buffer.byteLength > maxBytes) throw new Socks5FetchError("SOCKS5 upstream response headers are too large"); + const chunk = await this.readAny(signal); + if (chunk.byteLength === 0) throw new Socks5FetchError("SOCKS5 upstream closed before response headers"); + this.buffer = Buffer.concat([this.buffer, chunk]); + } + } + + readAny(signal?: AbortSignal): Promise { + if (this.buffer.byteLength > 0) { + const value = this.buffer; + this.buffer = Buffer.alloc(0); + return Promise.resolve(value); + } + if (this.ended) return Promise.resolve(Buffer.alloc(0)); + return new Promise((resolve, reject) => { + const waiter = { resolve, reject }; + const onAbort = () => { + signal?.removeEventListener("abort", onAbort); + const index = this.anyWaiters.indexOf(waiter); + if (index >= 0) this.anyWaiters.splice(index, 1); + reject(signal?.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + this.anyWaiters.push({ + resolve: value => { + signal?.removeEventListener("abort", onAbort); + resolve(value); + }, + reject: error => { + signal?.removeEventListener("abort", onAbort); + reject(error); + }, + }); + }); + } + + dispose(): void { + this.socket.removeListener("data", this.onData); + this.socket.removeListener("error", this.onError); + this.socket.removeListener("end", this.onEnd); + this.socket.removeListener("close", this.onEnd); + if (this.buffer.byteLength > 0) this.socket.unshift(this.buffer); + this.rejectExact(new Socks5FetchError("SOCKS5 reader disposed")); + this.rejectAny(new Socks5FetchError("SOCKS5 reader disposed")); + } +} + +async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): Promise { + const parsedProxy = validateProxy(proxy); + const credentials = proxyCredentials(parsedProxy); + const socket = await connectSocket(parsedProxy.hostname, Number(parsedProxy.port) || DEFAULT_SOCKS5_PORT, signal); + socket.setTimeout(SOCKS5_CONNECT_TIMEOUT_MS, () => { + socket.destroy(new Socks5FetchError("SOCKS5 handshake timed out")); + }); + const reader = new SocketReader(socket); + try { + const methods = credentials.username ? Buffer.from([SOCKS5_NO_AUTH, SOCKS5_USER_PASS]) : Buffer.from([SOCKS5_NO_AUTH]); + socket.write(Buffer.from([SOCKS5_VERSION, methods.byteLength, ...methods])); + const greeting = await reader.read(2, signal); + if (greeting[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid greeting"); + if (greeting[1] === SOCKS5_USER_PASS && credentials.username && credentials.password) { + socket.write(Buffer.from([ + 0x01, + credentials.username.byteLength, + ...credentials.username, + credentials.password.byteLength, + ...credentials.password, + ])); + const auth = await reader.read(2, signal); + if (auth[0] !== 0x01 || auth[1] !== 0x00) throw new Socks5FetchError("SOCKS5 proxy authentication failed"); + } else if (greeting[1] !== SOCKS5_NO_AUTH) { + throw new Socks5FetchError("SOCKS5 proxy does not accept an offered authentication method"); + } + + const hostname = new TextEncoder().encode(target.hostname); + if (hostname.byteLength > 255) throw new Socks5FetchError("SOCKS5 target hostname is too long"); + const port = targetPort(target); + socket.write(Buffer.from([ + SOCKS5_VERSION, + SOCKS5_CONNECT, + 0x00, + SOCKS5_DOMAIN, + hostname.byteLength, + ...hostname, + port >> 8, + port & 0xff, + ])); + const reply = await reader.read(4, signal); + if (reply[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid connect response"); + if (reply[1] !== SOCKS5_SUCCESS) throw new Socks5FetchError(`SOCKS5 proxy refused the connection (code ${reply[1]})`); + const addressLength = reply[3] === 0x01 ? 4 : reply[3] === SOCKS5_DOMAIN ? (await reader.read(1, signal))[0]! : 16; + await reader.read(addressLength + 2, signal); + socket.setTimeout(0); + return socket; + } catch (error) { + socket.destroy(); + throw error; + } finally { + reader.dispose(); + } +} + +function requestHeaders(request: Request, target: URL): { text: string; chunked: boolean } { + const headers = new Headers(request.headers); + if (!headers.has("host")) headers.set("host", target.host); + if (!headers.has("connection")) headers.set("connection", "close"); + const chunked = request.body !== null + && !headers.has("content-length") + && !headers.has("transfer-encoding"); + if (chunked) headers.set("transfer-encoding", "chunked"); + return { + text: [...headers.entries()].map(([key, value]) => `${key}: ${value}\r\n`).join(""), + chunked, + }; +} + +async function secureSocket(socket: Socket, target: URL, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const tlsSocket = tls.connect({ + socket, + servername: target.hostname, + rejectUnauthorized: true, + }); + const onAbort = () => { + tlsSocket.destroy(); + reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + }; + const onSecureConnect = () => { + signal.removeEventListener("abort", onAbort); + resolve(tlsSocket); + }; + tlsSocket.once("secureConnect", onSecureConnect); + tlsSocket.once("error", error => { + signal.removeEventListener("abort", onAbort); + reject(error); + }); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function 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.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + signal.addEventListener("abort", onAbort, { once: true }); + const readChunk = async (): Promise => { + if (chunked) { + if (chunkRemaining === 0) { + const line = (await reader.readUntil(CRLF, MAX_RESPONSE_HEADER_BYTES, signal)).subarray(0, -2).toString("ascii"); + const size = 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 request = new Request(input, init); + const target = new URL(request.url); + if (target.protocol !== "http:" && target.protocol !== "https:") { + throw new Socks5FetchError(`SOCKS5 fetch only supports HTTP(S) URLs, got ${target.protocol}`); + } + const tunnel = await socks5Connect(proxy, target, request.signal); + let socket: Socket = tunnel; + const onAbort = () => socket.destroy(request.signal.reason instanceof Error ? request.signal.reason : new Error("The operation was aborted")); + request.signal.addEventListener("abort", onAbort, { once: true }); + try { + if (target.protocol === "https:") socket = await secureSocket(tunnel, target, request.signal); + socket.setTimeout(SOCKS5_RESPONSE_TIMEOUT_MS, () => { + socket.destroy(new Socks5FetchError("SOCKS5 upstream request timed out")); + }); + const headers = requestHeaders(request, target); + const head = `${request.method} ${target.pathname}${target.search} HTTP/1.1\r\n${headers.text}\r\n`; + socket.write(head); + if (request.body) { + const bodyReader = request.body.getReader(); + try { + while (true) { + const next = await bodyReader.read(); + if (next.done) break; + const body = headers.chunked + ? Buffer.concat([Buffer.from(`${next.value.byteLength.toString(16)}\r\n`), Buffer.from(next.value), CRLF]) + : next.value; + if (!socket.write(body)) await waitForDrain(socket, request.signal); + } + if (headers.chunked && !socket.write("0\r\n\r\n")) await waitForDrain(socket, request.signal); + } finally { + bodyReader.releaseLock(); + } + } + const reader = new SocketReader(socket); + let responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal)); + while (responseHead.status >= 100 && responseHead.status < 200 && responseHead.status !== 101) { + responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal)); + } + const body = responseBody(reader, socket, request.signal, responseHead.headers, responseHead.status, request.method); + request.signal.removeEventListener("abort", onAbort); + return new Response(body, { + status: responseHead.status, + statusText: responseHead.statusText, + headers: responseHead.headers, + }); + } catch (error) { + request.signal.removeEventListener("abort", onAbort); + socket.destroy(); + throw error; + } +} diff --git a/src/server/index.ts b/src/server/index.ts index 8d46c30ab6e..68933db7d17 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,5 @@ import { markActivity } from "../lib/sidecar-tracker"; +import { redactUrlForLog } from "../lib/redact"; import { knownModelIdsForProvider } from "../router"; import { buildWarmupCompletionFrames, @@ -664,6 +665,10 @@ 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 87b3767d2b6..b38eae02143 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 0b0a2b2b983..3a7e12f9930 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -647,9 +647,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/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index c98c837cec5..98989349ac1 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -61,8 +61,8 @@ PATH, so a launcher-backed job is never misreported as an older plist (#3464). 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, while SOCKS5 requests +use the explicit dependency-free SOCKS5 tunnel fetch. Both preserve 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. @@ -78,8 +78,9 @@ rather than environment inference. Both gates live in the outbound wrapper, not `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 provider request bodies and response streams, while per-hop +redirect review remains outside this transport boundary. 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. @@ -93,8 +94,9 @@ Responses-compatible streaming output. ### Fetch-helper import boundary `src/server/responses/fetch-helpers.ts` is a transport leaf shared by Responses, compact, and native -Chat. Its runtime imports are limited to the Codex WebSocket transport, provider request pacing, and -the upstream HTTP-version helper. Server, provider, and WebSocket data types remain type-only edges. +Chat. Its runtime imports are limited to the Codex WebSocket transport, provider request pacing, +the upstream HTTP-version helper, and the configured outbound fetch boundary. Server, provider, and +WebSocket data types remain type-only edges. It must not import routing, combos, OAuth, adapters, sidecars, response parsing, logging, or relay modules merely because those imports existed in the pre-split `responses.ts` monolith. diff --git a/tests/claude-integration/claude-inbound-cache-stabilize.test.ts b/tests/claude-integration/claude-inbound-cache-stabilize.test.ts new file mode 100644 index 00000000000..3f0102eeb4e --- /dev/null +++ b/tests/claude-integration/claude-inbound-cache-stabilize.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { stabilizeClaudeInstructionsForPromptCache } from "../../src/claude/inbound-cache-stabilize"; +import { anthropicToResponsesTranslation } from "../../src/claude/inbound"; + +const TASKCREATE_NUDGE = [ + "The task tools haven't been used recently. If you're working on tasks that would benefit from tracking, consider using TaskCreate to add them.", + "Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable.", +].join(" "); + +function footer(used: number): string { + return `${used}`; +} + +describe("stabilizeClaudeInstructionsForPromptCache", () => { + test("empty input is a no-op", () => { + expect(stabilizeClaudeInstructionsForPromptCache("")).toEqual({ + instructions: "", + dynamicNotice: null, + }); + }); + + test("stable instructions without dynamics pass through", () => { + const instructions = "You are Claude Code.\n\nPrefer terse answers."; + expect(stabilizeClaudeInstructionsForPromptCache(instructions)).toEqual({ + instructions, + dynamicNotice: null, + }); + }); + + test("three total_tokens footers keep only the latest in the notice", () => { + const stable = "You are Claude Code."; + const first = footer(1000); + const second = footer(4000); + const third = footer(8000); + const result = stabilizeClaudeInstructionsForPromptCache( + [stable, first, second, third].join("\n\n"), + ); + expect(result.instructions).toBe(stable); + expect(result.instructions).not.toContain(""); + expect(result.dynamicNotice).toBe(third); + }); + + test("instructions contain zero total_tokens after stripping", () => { + const result = stabilizeClaudeInstructionsForPromptCache( + `System.\n${footer(1)}\nMore system.\n${footer(3)}`, + ); + expect(result.instructions).not.toMatch(//); + expect(result.instructions).toContain("System."); + expect(result.instructions).toContain("More system."); + expect(result.dynamicNotice).toBe(footer(3)); + }); + + test("TaskCreate nudge is stripped from instructions and kept in the notice", () => { + const stable = "You are Claude Code."; + const result = stabilizeClaudeInstructionsForPromptCache( + `${stable}\n\n${TASKCREATE_NUDGE}`, + ); + expect(result.instructions).toBe(stable); + expect(result.instructions).not.toContain("TaskCreate"); + expect(result.dynamicNotice).toBe(TASKCREATE_NUDGE); + }); + + test("latest footer and latest nudge both surface in the notice", () => { + const stable = "Stay stable."; + const older = footer(10); + const latest = footer(50); + const result = stabilizeClaudeInstructionsForPromptCache( + [stable, older, TASKCREATE_NUDGE, latest].join("\n\n"), + ); + expect(result.instructions).toBe(stable); + expect(result.dynamicNotice).toBe(`${latest}\n\n${TASKCREATE_NUDGE}`); + }); +}); + +describe("anthropicToResponsesTranslation cache-stabilize wire-in", () => { + test("moves the latest total_tokens footer onto a trailing input user message", () => { + const first = footer(1000); + const latest = footer(8000); + const { body } = anthropicToResponsesTranslation({ + model: "m", + max_tokens: 1, + system: ["You are Claude Code.", first, latest].join("\n\n"), + messages: [{ role: "user", content: "hi" }], + }); + expect(body.instructions).toBe("You are Claude Code."); + expect(String(body.instructions)).not.toContain(""); + const input = body.input as Array>; + const last = input[input.length - 1]!; + expect(last).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: latest }], + }); + expect(input.some(item => item.role === "user" && item !== last)).toBe(true); + }); +}); diff --git a/tests/cli/cli-help.test.ts b/tests/cli/cli-help.test.ts index d101b75bc80..44c1eedf467 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."); }); @@ -348,8 +348,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) { @@ -366,7 +366,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..275bf7728a8 --- /dev/null +++ b/tests/cli/start-args.test.ts @@ -0,0 +1,76 @@ +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"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 98907bd26ac..fa42027ffbe 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -139,6 +139,7 @@ "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", + "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", "claude-management-api.test.ts": "claude-integration", @@ -987,6 +988,7 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", + "socks5-fetch.test.ts": "server", "sse-client-frame-bounds.test.ts": "responses", "sse-decoder.test.ts": "responses", "sse-failed-tail.test.ts": "responses", @@ -997,6 +999,7 @@ "stale-state-purge.test.ts": "service", "stall-timeout.test.ts": "lib", "star-deferral.test.ts": "cli", + "start-args.test.ts": "cli", "startup-action-control-elevation.test.ts": "server", "startup-action-control.test.ts": "server", "startup-health-ui.test.ts": "gui", 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 3ae551e63d7..71e2cbf06c1 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -22,6 +22,7 @@ import { CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, shouldUseCodexWsUpstream as rawShouldUseCodexWsUpstream, } from "../../src/server/responses/ws-upstream"; +import { configureSocks5Fetch } from "../../src/lib/proxy-env"; import type { OcxProviderConfig } from "../../src/types"; import type { OcxConfig } from "../../src/types"; @@ -124,6 +125,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..229b937f8cc 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 { configureSocks5Fetch, resolveProxyRoute } 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)", () => { diff --git a/tests/server/socks5-fetch.test.ts b/tests/server/socks5-fetch.test.ts new file mode 100644 index 00000000000..5ffb3b28f55 --- /dev/null +++ b/tests/server/socks5-fetch.test.ts @@ -0,0 +1,542 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createServer as createHttpServer } from "node:http"; +import net, { createConnection, createServer as createTcpServer, Socket, type Server as TcpServer } from "node:net"; +import type { AddressInfo } from "node:net"; +import { configureSocks5Fetch } from "../../src/lib/proxy-env"; +import { providerOutboundGet } from "../../src/lib/provider-outbound"; +import { socks5Fetch } from "../../src/lib/socks5-fetch"; +import { applyProxyEnv } from "../../src/config"; +import { providerFetch } from "../../src/server/responses/fetch-helpers"; +import type { OcxProviderConfig } from "../../src/types"; + +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(listenerCountsBeforeWait.error); + expect(clientSocket.listenerCount("close")).toBe(listenerCountsBeforeWait.close); + } finally { + Socket.prototype.write = originalWrite; + heldSocket?.destroy(); + await Promise.race([pendingHandled, Bun.sleep(250)]); + await Promise.race([close(proxy), Bun.sleep(250)]); + } + }); + + test("supports RFC 1929 username/password authentication", async () => { + const target = createHttpServer((_request, response) => response.end("authenticated")); + const proxy = socksProxy({ username: "user", password: "pass" }); + 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)]); + } + }); +});