From ad62ce4b81d84c7d29e3e1bc20016f581b702183 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 18 Sep 2026 12:35:43 +0900 Subject: [PATCH 1/2] fix(cli): never hop off a busy port into a duplicate proxy (#5004) A bare `ocx start` beside a healthy proxy could print "Port 58285 is busy; starting opencodex on 62254", bind a second listener, take over this home's pid/runtime records, and re-point Codex at the duplicate while the original kept serving. The hop path never asked who held the preferred port: it read this home's bookkeeping, and `findLiveProxy` returning null - a stale record, a probe that lost a race, a loopback family split - was enough to reach it. chooseListenPort now probes the busy port directly through probePortOwner and routes the answer through the pure decideBusyPreferredPort. An opencodex holder gets the refusal the owner check already prints (exit 0 under OCX_SERVICE=1 so the wrapper loop still terminates); a holder that did not identify as opencodex is reported as such rather than called foreign, because an identity probe cannot tell a foreign server from an unreachable one. Either way the start stops instead of taking an arbitrary port. An explicit --port still waits for its pin, and a configured port of 0 still means "ask the OS". The pre-bind owner probe and the busy-port probe both spend START_OWNERSHIP_LIVENESS (1500ms x 3) instead of one 750ms attempt. A negative answer there is acted on twice over - the start walks past a live proxy AND deletes this home's pid record - so one unanswered probe is not enough evidence. probePortOwner also asks both loopback families, because startServer canonicalizes a localhost bind to 127.0.0.1 while probeHostname leaves the name to the resolver, which on Windows answers ::1 first. Closes #5004 --- README.md | 5 +- .../docs/fr/getting-started/installation.md | 2 +- .../docs/fr/getting-started/quickstart.md | 6 +- .../docs/fr/reference/cli/lifecycle.md | 2 +- .../docs/getting-started/installation.md | 2 +- .../docs/getting-started/quickstart.md | 6 +- .../docs/ja/getting-started/installation.md | 2 +- .../docs/ja/getting-started/quickstart.md | 2 +- .../docs/ja/reference/cli/lifecycle.md | 2 +- .../docs/ko/getting-started/installation.md | 2 +- .../docs/ko/getting-started/quickstart.md | 2 +- .../docs/ko/reference/cli/lifecycle.md | 11 ++- .../content/docs/reference/cli/lifecycle.md | 12 ++- .../docs/ru/getting-started/installation.md | 2 +- .../docs/ru/getting-started/quickstart.md | 6 +- .../docs/ru/reference/cli/lifecycle.md | 10 ++- .../docs/tr/getting-started/installation.md | 2 +- .../docs/tr/getting-started/quickstart.md | 6 +- .../docs/tr/reference/cli/lifecycle.md | 16 ++-- .../zh-cn/getting-started/installation.md | 2 +- .../docs/zh-cn/getting-started/quickstart.md | 2 +- .../docs/zh-cn/reference/cli/lifecycle.md | 2 +- .../zh-tw/getting-started/installation.md | 2 +- .../docs/zh-tw/getting-started/quickstart.md | 5 +- .../docs/zh-tw/reference/cli/lifecycle.md | 2 +- src/cli/dispatch.ts | 54 +++++++++++++ src/cli/index.ts | 48 ++++++++++-- src/server/proxy-liveness.ts | 62 +++++++++++++++ structure/overview.md | 5 ++ structure/runtime.md | 13 ++++ tests/cli/cli-dispatch.test.ts | 75 ++++++++++++++++++- tests/cli/cli-ready.test.ts | 8 +- tests/server/proxy-liveness.test.ts | 71 ++++++++++++++++++ 33 files changed, 397 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 4fe5927eae4..dda3b64a4c4 100644 --- a/README.md +++ b/README.md @@ -313,8 +313,9 @@ ocx v2 <...> # multi-agent v1/v2 surface controls ocx update [--tag preview] # update opencodex ``` -Unpinned starts may pick another free port if the preferred one is busy; an explicit `--port` -never hops. Full reference: [CLI docs](https://opencodex.me/reference/cli/). +A start whose preferred port is busy stops and names the holder instead of moving to another port, +so it can never leave a second proxy running beside the first. Free the port, or name a different +one with `--port`. Full reference: [CLI docs](https://opencodex.me/reference/cli/). ### Health and readiness diff --git a/docs-site/src/content/docs/fr/getting-started/installation.md b/docs-site/src/content/docs/fr/getting-started/installation.md index 5a9ae40163f..65fb3d91133 100644 --- a/docs-site/src/content/docs/fr/getting-started/installation.md +++ b/docs-site/src/content/docs/fr/getting-started/installation.md @@ -82,7 +82,7 @@ Codex actifs se trouvent sous `$CODEX_HOME` (par défaut `~/.codex`). | --- | --- | | `$OPENCODEX_HOME/config.json` | Vos fournisseurs, fournisseur par défaut, port et options. | | `$OPENCODEX_HOME/ocx.pid` | PID du proxy en cours d'exécution (garde à instance unique). | -| `$OPENCODEX_HOME/runtime-port.json` | Le PID en direct, le nom d'hôte et le port, y compris un port de secours sélectionné automatiquement. | +| `$OPENCODEX_HOME/runtime-port.json` | Le PID actif, le nom d’hôte et le port, y compris un port attribué par le système d’exploitation lorsque `config.port` vaut `0`. | | `$OPENCODEX_HOME/auth.json` | Informations d’identification OAuth enregistrées après `ocx login`. | | `$OPENCODEX_HOME/catalog-backup*.json` | Sauvegardes du catalogue de modèles Codex créées avant toute modification par opencodex. | | `$CODEX_HOME/config.toml` | Avec une liaison de bouclage, opencodex ajoute une valeur racine `openai_base_url` délimitée par ses marqueurs. Les liaisons hors bouclage utilisent `model_provider = "opencodex"` avec `[model_providers.opencodex]` afin que Codex puisse envoyer l’en-tête d’authentification API. | diff --git a/docs-site/src/content/docs/fr/getting-started/quickstart.md b/docs-site/src/content/docs/fr/getting-started/quickstart.md index ce253efd899..7adfd06f762 100644 --- a/docs-site/src/content/docs/fr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md @@ -46,8 +46,10 @@ Au démarrage, opencodex : le catalogue de modèles de Codex**, - écoute sur `http://localhost:/v1`. -Si le port demandé est occupé, `ocx start` sélectionne un port libre, l'enregistre dans `runtime-port.json`, -et met à jour Codex pour utiliser l'écouteur en direct. +Si le port demandé est occupé, `ocx start` s’arrête et indique ce qui l’occupe : exécutez d’abord +`ocx stop` si un processus opencodex y répond, ou démarrez sur un port libre avec +`ocx start --port `. La commande ne change jamais de port d’elle-même : auparavant, ce +comportement laissait deux proxys en cours d’exécution et redirigeait Codex vers le plus récent. Vérifiez-le : 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 791bca924fc..148febbf448 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -15,7 +15,7 @@ Assistant de configuration interactif (`setup` est un alias de `init`). Il deman ### `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`). +Démarre le serveur proxy, de préférence sur le port `10100`. La commande écrit l’état du PID et du port d’exécution, et refuse de démarrer une deuxième instance active. Lorsque le port préféré est occupé, `start` interroge le processus qui l’occupe puis s’arrête dans tous les cas : elle refuse de démarrer si un processus opencodex y répond et signale sinon que le processus est inconnu. Elle ne déplace jamais l’écouteur vers un autre port d’elle-même, car cela laisserait le premier proxy en cours d’exécution et redirigerait Codex vers le second. Indiquez un autre port avec `--port`, ou définissez `port: 0` dans la configuration pour demander au système d’exploitation d’en attribuer un. Au démarrage, elle synchronise dans le catalogue Codex les modèles de chaque fournisseur. À l’arrêt, elle rétablit le fonctionnement natif de Codex, sauf si le proxy a été lancé comme service géré (`OCX_SERVICE=1`). `--socks5` (par défaut `127.0.0.1:10808`) enregistre l’URL SOCKS5 dans `config.proxy` et achemine les requêtes HTTP(S) sortantes dans un véritable tunnel SOCKS5. `--socks5-off` supprime uniquement diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index 4ef61c55663..c5a7d36578e 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -87,7 +87,7 @@ under `$CODEX_HOME` (default `~/.codex`). | --- | --- | | `$OPENCODEX_HOME/config.json` | Your providers, default provider, port, and options. | | `$OPENCODEX_HOME/ocx.pid` | PID of the running proxy (single-instance guard). | -| `$OPENCODEX_HOME/runtime-port.json` | The live PID, hostname, and port, including an automatically selected fallback port. | +| `$OPENCODEX_HOME/runtime-port.json` | The live PID, hostname, and port — including an OS-assigned port when `config.port` is `0`. | | `$OPENCODEX_HOME/auth.json` | Stored OAuth credentials (when you `ocx login`). | | `$OPENCODEX_HOME/catalog-backup*.json` | Codex model catalog backups made before opencodex edits it. | | `$CODEX_HOME/config.toml` | On loopback, opencodex adds a marker-owned root `openai_base_url`; non-loopback binds use `model_provider = "opencodex"` plus `[model_providers.opencodex]` so Codex can send the API-auth header. | diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 82158a2a99f..de2fd9a229e 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -86,8 +86,10 @@ On start, opencodex: Codex's model catalog**, - listens on `http://localhost:/v1`. -If the requested port is busy, `ocx start` selects a free port, records it in `runtime-port.json`, -and updates Codex to use the live listener. +If the requested port is busy, `ocx start` stops and tells you what holds it: run `ocx stop` first +when the holder is an opencodex, or start on a free port with `ocx start --port `. It does not +move to another port by itself — that is what used to leave two proxies running with Codex pointed +at the newer one. Check it: diff --git a/docs-site/src/content/docs/ja/getting-started/installation.md b/docs-site/src/content/docs/ja/getting-started/installation.md index 52ff9d1781e..a62daede0a6 100644 --- a/docs-site/src/content/docs/ja/getting-started/installation.md +++ b/docs-site/src/content/docs/ja/getting-started/installation.md @@ -80,7 +80,7 @@ opencodex の状態ファイルは `$OPENCODEX_HOME`(デフォルト `~/.opencod --- | --- | | `$OPENCODEX_HOME/config.json` | プロバイダー、デフォルトプロバイダー、ポート、オプション。 | | `$OPENCODEX_HOME/ocx.pid` | 実行中のプロキシの PID(単一インスタンスガード)。 | -| `$OPENCODEX_HOME/runtime-port.json` | 自動で選んだ代替ポートを含む現在の PID、ホスト名、ポート。 | +| `$OPENCODEX_HOME/runtime-port.json` | 現在の PID、ホスト名、ポート。`config.port` が `0` の場合に OS が割り当てたポートも含みます。 | | `$OPENCODEX_HOME/auth.json` | 保存された OAuth 認証情報(`ocx login` 時)。 | | `$OPENCODEX_HOME/catalog-backup*.json` | opencodex が変更する前に作成した Codex モデルカタログのバックアップ。 | | `$CODEX_HOME/config.toml` | ローカル専用構成では opencodex が管理するルート `openai_base_url` を追加します。ローカル以外のアドレスにバインドする場合は Codex が API 認証ヘッダーを送れるよう `model_provider = "opencodex"` と `[model_providers.opencodex]` を使います。 | diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index 4e7d3051657..2bfa90f2c82 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -41,7 +41,7 @@ ocx start --port 8080 Codex のモデル カタログ**、 - `http://localhost:/v1`で聴いています。 -要求されたポートがビジーの場合、`ocx start` は空きポートを選択し、それを `runtime-port.json` に記録し、ライブ リスナーを使用するように Codex を更新します。 +要求されたポートが使用中の場合、`ocx start` は停止し、そのポートを使用しているプロセスを通知します。opencodex が応答している場合は先に `ocx stop` を実行し、空いているポートで起動する場合は `ocx start --port ` を使用してください。`ocx start` が自動で別のポートへ移ることはありません。以前はこの動作によって 2 つのプロキシが同時に動作し、Codex が後から起動した方を指すことがありました。 確認してください: 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 f59ff74ebe5..6939c537d00 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -15,7 +15,7 @@ description: セットアップ、開始、停止、サービス、診断、同 ### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` -プロキシ サーバー (優先ポート `10100`) を起動します。そのポートが占有されている場合、opencodex は別の使用可能なポートを選択して記録します。 PID/ランタイムポートの状態を書き込み、2 番目のライブインスタンスの起動を拒否します。開始時に、各プロバイダーのモデルを Codex のカタログに同期します。マネージド サービス (`OCX_SERVICE=1`) として起動されていない限り、シャットダウン時にネイティブ Codex が復元されます。 +プロキシ サーバー (優先ポート `10100`) を起動します。PID/ランタイムポートの状態を書き込み、2 番目のライブインスタンスの起動を拒否します。優先ポートが使用中の場合、`start` はそのポートを使用しているプロセスを確認して、どちらの場合も停止します。opencodex が応答していれば起動を拒否し、それ以外は使用しているプロセスを特定できないと報告します。最初のプロキシを実行したまま Codex を 2 番目のプロキシへ向けることになるため、自動でリスナーを別のポートへ移すことはありません。別のポートは `--port` で指定するか、設定で `port: 0` を指定して OS に割り当てを依頼してください。開始時に、各プロバイダーのモデルを Codex のカタログに同期します。マネージド サービス (`OCX_SERVICE=1`) として起動されていない限り、シャットダウン時にネイティブ Codex が復元されます。 `--socks5`(デフォルト `127.0.0.1:10808`)は SOCKS5 URL を `config.proxy` に保存し、送信 HTTP(S) リクエストを実際の SOCKS5 トンネル経由で送信します。`--socks5-off` は保存された SOCKS5 プロキシだけを削除し、HTTP プロキシは削除しません。値は設定に保存されるため、`ocx update` 後も保持されます。URL にユーザー名とパスワードを含めることはできますが、起動ログでは非表示になります。 diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md index 1dfff4b156a..70a62f86099 100644 --- a/docs-site/src/content/docs/ko/getting-started/installation.md +++ b/docs-site/src/content/docs/ko/getting-started/installation.md @@ -80,7 +80,7 @@ opencodex 상태 파일은 `$OPENCODEX_HOME`(기본값 `~/.opencodex`) 아래에 | --- | --- | | `$OPENCODEX_HOME/config.json` | 프로바이더, 기본 프로바이더, 포트, 옵션. | | `$OPENCODEX_HOME/ocx.pid` | 실행 중인 프록시의 PID(단일 인스턴스 가드). | -| `$OPENCODEX_HOME/runtime-port.json` | 자동으로 고른 대체 포트를 포함한 현재 PID, 호스트명, 포트. | +| `$OPENCODEX_HOME/runtime-port.json` | 현재 PID, 호스트명, 포트. `config.port`가 `0`이면 OS가 할당한 포트도 포함합니다. | | `$OPENCODEX_HOME/auth.json` | 저장된 OAuth 자격 증명(`ocx login` 시). | | `$OPENCODEX_HOME/catalog-backup*.json` | opencodex가 수정하기 전에 만든 Codex 모델 카탈로그 백업. | | `$CODEX_HOME/config.toml` | 로컬 전용 구성에서는 opencodex가 관리하는 루트 `openai_base_url`을 추가합니다. 로컬이 아닌 주소에 바인딩할 때는 Codex가 API 인증 헤더를 보낼 수 있도록 `model_provider = "opencodex"`와 `[model_providers.opencodex]`를 사용합니다. | diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index 520cae9209f..4dc6866898f 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -39,7 +39,7 @@ ocx start --port 8080 - 프로바이더가 지원하는 경우 실시간 모델을 찾아 네이티브와 라우팅 항목을 **Codex 모델 카탈로그에 동기화**하고, - `http://localhost:/v1`에서 수신 대기합니다. -요청한 포트가 이미 사용 중이면 `ocx start`가 빈 포트를 고르고, 그 값을 `runtime-port.json`에 기록한 뒤 Codex가 실시간 리스너를 쓰도록 갱신합니다. +요청한 포트가 이미 사용 중이면 `ocx start`는 실행을 멈추고 무엇이 포트를 점유하고 있는지 알려 줍니다. opencodex가 응답하면 먼저 `ocx stop`을 실행하고, 빈 포트에서 시작하려면 `ocx start --port `를 사용하세요. 이제 다른 포트로 자동 이동하지 않습니다. 이전 동작은 프록시 두 개를 동시에 실행한 채 Codex가 나중에 시작한 프록시를 가리키게 했습니다. 확인: 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 070662b4287..4db8c09b695 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -18,10 +18,13 @@ Codex 자동 시작 shim도 설치합니다. ### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` -프록시 서버를 시작합니다(권장 포트는 `10100`). 해당 포트가 이미 사용 중이면 opencodex가 다른 -사용 가능한 포트를 골라 기록합니다. PID와 런타임 포트 상태를 기록하고, 두 번째 활성 인스턴스는 시작하지 -않습니다. 시작할 때는 각 공급자의 모델을 Codex 카탈로그로 동기화합니다. 종료할 때는 기본 Codex를 -복원합니다. 단, 관리형 서비스로 실행한 경우(`OCX_SERVICE=1`)는 예외입니다. +프록시 서버를 시작합니다(권장 포트는 `10100`). PID와 런타임 포트 상태를 기록하고, 두 번째 활성 +인스턴스는 시작하지 않습니다. 권장 포트가 이미 사용 중이면 `start`가 점유자를 확인한 뒤 어느 경우든 +실행을 멈춥니다. opencodex가 응답하면 시작을 거부하고, 그렇지 않으면 점유자를 식별할 수 없다고 +알립니다. 첫 번째 프록시를 실행한 채 Codex가 두 번째 프록시를 가리키게 되므로, 리스너를 다른 포트로 +자동 이동하지 않습니다. 다른 포트는 `--port`로 지정하거나, OS에 포트 할당을 요청하려면 구성에서 +`port: 0`을 설정하세요. 시작할 때는 각 공급자의 모델을 Codex 카탈로그로 동기화합니다. 종료할 때는 +기본 Codex를 복원합니다. 단, 관리형 서비스로 실행한 경우(`OCX_SERVICE=1`)는 예외입니다. `--socks5`(기본값 `127.0.0.1:10808`)는 SOCKS5 URL을 `config.proxy`에 저장하고 실제 SOCKS5 터널을 통해 송신 HTTP(S) 요청을 전달합니다. `--socks5-off`는 저장된 SOCKS5 프록시만 지우며 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 770043baf5f..54c42a9d68b 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -18,10 +18,14 @@ optionally installs the Codex autostart shim. ### `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`). +Start the proxy server (preferred port `10100`). It writes PID/runtime-port state and refuses to +start a second live instance. When the preferred port is occupied, `start` asks the holder who it +is and stops either way: it refuses outright when an opencodex answers there, and reports an +unidentified holder otherwise. It never moves the listener to another port on its own, because that +would leave the first proxy running and re-point Codex at the second. Name a different port with +`--port`, or set `port: 0` in the config to ask the OS for one. 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 diff --git a/docs-site/src/content/docs/ru/getting-started/installation.md b/docs-site/src/content/docs/ru/getting-started/installation.md index 837178f7e4f..a1f3724a4ba 100644 --- a/docs-site/src/content/docs/ru/getting-started/installation.md +++ b/docs-site/src/content/docs/ru/getting-started/installation.md @@ -83,7 +83,7 @@ bun run dev:gui # запускает dev-сервер панели упра | --- | --- | | `$OPENCODEX_HOME/config.json` | Ваши провайдеры, провайдер по умолчанию, порт и параметры. | | `$OPENCODEX_HOME/ocx.pid` | PID запущенного прокси (защита от повторного запуска). | -| `$OPENCODEX_HOME/runtime-port.json` | Текущие PID, имя хоста и порт, включая автоматически выбранный запасной порт. | +| `$OPENCODEX_HOME/runtime-port.json` | Текущие PID, имя хоста и порт, включая порт, назначенный ОС, когда `config.port` равен `0`. | | `$OPENCODEX_HOME/auth.json` | Сохранённые учётные данные OAuth (после `ocx login`). | | `$OPENCODEX_HOME/catalog-backup*.json` | Резервные копии каталога моделей Codex, создаваемые перед тем, как opencodex его изменит. | | `$CODEX_HOME/config.toml` | На loopback-адресе opencodex добавляет корневой `openai_base_url`, отмеченный собственным маркером; при привязке не к loopback используются `model_provider = "opencodex"` и `[model_providers.opencodex]`, чтобы Codex мог отправлять заголовок API-аутентификации. | diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index 3f83bb952a5..d841ded5abe 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -50,8 +50,10 @@ ocx start --port 8080 маршрутизируемые записи в каталог моделей Codex**, - слушает `http://localhost:/v1`. -Если запрошенный порт занят, `ocx start` выбирает свободный порт, записывает его в -`runtime-port.json` и обновляет настройки Codex, чтобы тот использовал актуальный адрес. +Если запрошенный порт занят, `ocx start` останавливается и сообщает, какой процесс его удерживает: +если там отвечает opencodex, сначала выполните `ocx stop`; либо запустите прокси на свободном порту +через `ocx start --port `. Команда не переходит на другой порт сама — раньше из-за этого два +прокси продолжали работать одновременно, а Codex перенаправлялся на более новый. Проверьте: 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 0de34171f87..5bdde33ae2d 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -20,9 +20,13 @@ opencodex и его интеграцию с Codex. ### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` -Запустить proxy server (предпочтительный порт `10100`). Если этот порт занят, opencodex выбирает и -записывает другой свободный порт. При запуске пишется состояние PID/runtime-port, а попытка -поднять второй живой экземпляр отвергается. На старте прокси синхронизирует модели каждого +Запустить proxy server (предпочтительный порт `10100`). При запуске пишется состояние +PID/runtime-port, а попытка поднять второй живой экземпляр отвергается. Если предпочтительный порт +занят, `start` проверяет, кто его удерживает, и в любом случае останавливается: он отказывается от +запуска, если там отвечает opencodex, а иначе сообщает о неопознанном владельце порта. Прокси +никогда сам не переносит listener на другой порт, поскольку тогда первый прокси продолжил бы +работать, а Codex был бы перенаправлен на второй. Укажите другой порт через `--port` или задайте +`port: 0` в конфигурации, чтобы порт назначила ОС. На старте прокси синхронизирует модели каждого провайдера в каталог Codex. При shutdown он восстанавливает native Codex — если только прокси не был запущен как managed service (`OCX_SERVICE=1`). diff --git a/docs-site/src/content/docs/tr/getting-started/installation.md b/docs-site/src/content/docs/tr/getting-started/installation.md index e138735b818..14f26330d21 100644 --- a/docs-site/src/content/docs/tr/getting-started/installation.md +++ b/docs-site/src/content/docs/tr/getting-started/installation.md @@ -86,7 +86,7 @@ alır. | --- | --- | | `$OPENCODEX_HOME/config.json` | Sağlayıcılarınız, varsayılan sağlayıcı, port ve seçenekler. | | `$OPENCODEX_HOME/ocx.pid` | Çalışan proxy'nin PID'si (tek örnek koruması). | -| `$OPENCODEX_HOME/runtime-port.json` | Otomatik olarak seçilen bir yedek port dahil olmak üzere canlı PID, ana bilgisayar adı ve port. | +| `$OPENCODEX_HOME/runtime-port.json` | Canlı PID, ana bilgisayar adı ve port; `config.port` `0` olduğunda işletim sistemi tarafından atanan port da buna dahildir. | | `$OPENCODEX_HOME/auth.json` | Saklanan OAuth kimlik bilgileri (`ocx login` yaptığınızda). | | `$OPENCODEX_HOME/catalog-backup*.json` | opencodex düzenlemeden önce alınan Codex model kataloğu yedekleri. | | `$CODEX_HOME/config.toml` | Geri döngüde opencodex işaretçi sahipliğindeki kök `openai_base_url` ekler; geri döngü olmayan bağlantılar `model_provider = "opencodex"` artı `[model_providers.opencodex]` kullanır, böylece Codex API kimlik doğrulama başlığını gönderebilir. | diff --git a/docs-site/src/content/docs/tr/getting-started/quickstart.md b/docs-site/src/content/docs/tr/getting-started/quickstart.md index bf99bf5bd0b..2c9445ffd77 100644 --- a/docs-site/src/content/docs/tr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/tr/getting-started/quickstart.md @@ -55,8 +55,10 @@ Başlatıldığında opencodex: yönlendirilen girdileri Codex'in model kataloğuna senkronize eder**, - `http://localhost:/v1` üzerinde dinler. -İstenen port meşgulse `ocx start` boş bir port seçer, bunu `runtime-port.json` -dosyasına kaydeder ve canlı dinleyiciyi kullanmak için Codex'i günceller. +İstenen port meşgulse `ocx start` durur ve portu neyin tuttuğunu bildirir: orada bir +opencodex yanıt veriyorsa önce `ocx stop` çalıştırın veya `ocx start --port ` ile +boş bir portta başlatın. Kendiliğinden başka bir porta geçmez; önceki davranış, iki +proxy'nin yan yana çalışmasına ve Codex'in daha yeni olana yönlendirilmesine neden oluyordu. Kontrol edin: 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 d7796b66c61..f2b72af51c0 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -21,12 +21,16 @@ otomatik başlatma dolgusunu kurar. ### `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ı -portu durumunu yazar ve ikinci bir canlı örneği başlatmayı reddeder. Başlangıçta -her sağlayıcının modellerini Codex'in kataloğuna senkronize eder. Kapatıldığında -— yönetilen bir servis olarak başlatılmadığı sürece (`OCX_SERVICE=1`) — yerel -Codex'i geri yükler. +Proxy sunucusunu başlatın (tercih edilen port `10100`). PID/çalışma zamanı portu +durumunu yazar ve ikinci bir canlı örneği başlatmayı reddeder. Tercih edilen port +doluysa `start`, portu tutan süreci sorgular ve her iki durumda da durur: orada bir +opencodex yanıt veriyorsa başlatmayı reddeder, aksi halde portu tutan sürecin +tanımlanamadığını bildirir. İlk proxy'yi çalışır durumda bırakıp Codex'i ikinciye +yönlendireceği için dinleyiciyi kendiliğinden başka bir porta taşımaz. `--port` ile +farklı bir port belirtin veya işletim sisteminden bir port istemek için yapılandırmada +`port: 0` ayarlayın. Başlangıçta her sağlayıcının modellerini Codex'in kataloğuna +senkronize eder. Kapatıldığında — yönetilen bir servis olarak başlatılmadığı sürece +(`OCX_SERVICE=1`) — yerel Codex'i geri yükler. `--socks5` (varsayılan `127.0.0.1:10808`) SOCKS5 URL'sini `config.proxy` içine kaydeder ve giden HTTP(S) isteklerini gerçek bir SOCKS5 tünelinden yönlendirir. `--socks5-off` yalnızca kaydedilmiş diff --git a/docs-site/src/content/docs/zh-cn/getting-started/installation.md b/docs-site/src/content/docs/zh-cn/getting-started/installation.md index 8730fd20a42..eb5b02ec948 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/installation.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/installation.md @@ -78,7 +78,7 @@ opencodex 状态文件位于 `$OPENCODEX_HOME`(默认 `~/.opencodex`),Code | --- | --- | | `$OPENCODEX_HOME/config.json` | 你的 provider、默认 provider、端口及选项。 | | `$OPENCODEX_HOME/ocx.pid` | 正在运行的代理的 PID(单实例保护)。 | -| `$OPENCODEX_HOME/runtime-port.json` | 当前 PID、主机名和端口,包括自动选择的备用端口。 | +| `$OPENCODEX_HOME/runtime-port.json` | 当前 PID、主机名和端口,包括 `config.port` 为 `0` 时由操作系统分配的端口。 | | `$OPENCODEX_HOME/auth.json` | 执行 `ocx login` 后保存的 OAuth 凭据。 | | `$OPENCODEX_HOME/catalog-backup*.json` | opencodex 修改 Codex 模型目录前创建的备份。 | | `$CODEX_HOME/config.toml` | 仅监听回环地址时,opencodex 会添加由自身标记管理的根级 `openai_base_url`;监听非回环地址时,则使用 `model_provider = "opencodex"` 和 `[model_providers.opencodex]`,以便 Codex 发送 API 认证 header。 | diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index 23d4134b1ae..4d005640355 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -39,7 +39,7 @@ ocx start --port 8080 - 在 provider 支持时发现实时模型,并**把原生与已路由条目同步进 Codex 的模型目录**; - 监听 `http://localhost:/v1`。 -如果请求的端口已被占用,`ocx start` 会选择一个空闲端口,将其记录到 `runtime-port.json`,并更新 Codex 以使用这个实际监听地址。 +如果请求的端口已被占用,`ocx start` 会停止并告知你是什么占用了该端口:如果那里响应的是 opencodex,请先运行 `ocx stop`;也可以使用 `ocx start --port ` 在空闲端口上启动。它不会自行切换到其他端口;旧行为会让两个代理同时运行,并将 Codex 重新指向后启动的代理。 检查它: 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 eee05c7c391..f482b1701e2 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 @@ -15,7 +15,7 @@ description: 安装、启动、停止、服务、诊断、同步和更新命令 ### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` -启动代理服务器(首选端口 `10100`)。如果该端口已被占用,opencodex 会选择并记录另一个可用端口。它会写入 PID/运行时端口状态,并拒绝启动第二个存活实例。启动时,它会把每个提供方的模型同步到 Codex 的目录中。关闭时,它会恢复原生 Codex,除非它是作为受管服务启动的(`OCX_SERVICE=1`)。 +启动代理服务器(首选端口 `10100`)。它会写入 PID/运行时端口状态,并拒绝启动第二个存活实例。当首选端口已被占用时,`start` 会探测占用者,并且无论结果如何都会停止:如果那里响应的是 opencodex,它会直接拒绝启动;否则会报告无法识别的占用者。它绝不会自行把监听地址移到其他端口,因为那会让第一个代理继续运行,并将 Codex 重新指向第二个代理。使用 `--port` 指定其他端口,或在配置中设置 `port: 0`,让操作系统分配端口。启动时,它会把每个提供方的模型同步到 Codex 的目录中。关闭时,它会恢复原生 Codex,除非它是作为受管服务启动的(`OCX_SERVICE=1`)。 `--socks5`(默认 `127.0.0.1:10808`)会将 SOCKS5 URL 保存到 `config.proxy`,并通过真正的 SOCKS5 隧道转发出站 HTTP(S) 请求。`--socks5-off` 只会清除已保存的 SOCKS5 代理,不会删除 HTTP 代理。该值保存在配置中,因此会在 `ocx update` 后保留。URL 可以包含用户名和密码,但启动日志会将其隐藏。 diff --git a/docs-site/src/content/docs/zh-tw/getting-started/installation.md b/docs-site/src/content/docs/zh-tw/getting-started/installation.md index 23b36b97da4..f954cb7175a 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/installation.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/installation.md @@ -79,7 +79,7 @@ opencodex 狀態檔案位於 `$OPENCODEX_HOME`(預設 `~/.opencodex`),Code | --- | --- | | `$OPENCODEX_HOME/config.json` | 你的 provider、預設 provider、埠及選項。 | | `$OPENCODEX_HOME/ocx.pid` | 正在執行的代理的 PID(單例項保護)。 | -| `$OPENCODEX_HOME/runtime-port.json` | 目前 PID、主機名和埠,包括自動選擇的備用埠。 | +| `$OPENCODEX_HOME/runtime-port.json` | 目前 PID、主機名和埠,包括 `config.port` 為 `0` 時由作業系統指派的埠。 | | `$OPENCODEX_HOME/auth.json` | 執行 `ocx login` 後儲存的 OAuth 憑證。 | | `$OPENCODEX_HOME/catalog-backup*.json` | opencodex 修改 Codex 模型目錄前建立的備份。 | | `$CODEX_HOME/config.toml` | 僅監聽迴環地址時,opencodex 會新增由自身標記管理的根級 `openai_base_url`;監聽非迴環地址時,則使用 `model_provider = "opencodex"` 和 `[model_providers.opencodex]`,以便 Codex 傳送 API 認證 header。 | diff --git a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md index 23eff3f4ae8..54a69275182 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md @@ -45,8 +45,9 @@ ocx start --port 8080 - 在 provider 支援時發現即時模型,並**把原生與已路由條目同步進 Codex 的模型目錄**,以及 - 在 `http://localhost:/v1` 上監聽。 -如果請求的埠已被佔用,`ocx start` 會選擇一個空閒埠,將其寫入 `runtime-port.json`,並更新 -Codex 設定以使用實際監聽埠。 +如果請求的埠已被佔用,`ocx start` 會停止並告訴你是什麼佔用了該埠:如果回應的是 +opencodex,請先執行 `ocx stop`;也可以使用 `ocx start --port ` 在空閒埠上啟動。 +它不會自行切換到其他埠;舊行為會讓兩個代理同時執行,並將 Codex 重新指向後啟動的代理。 檢查它: 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 52c2cad2eb1..da2a7e08479 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 @@ -15,7 +15,7 @@ description: 安裝、啟動、停止、服務、診斷、同步與更新指令 ### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` -啟動代理伺服器(偏好連接埠 `10100`)。若該連接埠被佔用,opencodex 會選擇並記錄另一個可用連接埠。它寫入 PID/runtime-port 狀態,並拒絕啟動第二個即時實例。啟動時它將每個供應商的模型同步到 Codex 目錄。關閉時它還原原生 Codex——除非它是作為受管服務啟動的(`OCX_SERVICE=1`)。 +啟動代理伺服器(偏好連接埠 `10100`)。它寫入 PID/runtime-port 狀態,並拒絕啟動第二個即時實例。偏好連接埠被佔用時,`start` 會探測佔用者,且無論結果如何都會停止:若回應的是 opencodex,它會直接拒絕啟動;否則會回報無法識別的佔用者。它絕不會自行將監聽位置移到其他連接埠,因為這會讓第一個代理繼續執行,並將 Codex 重新指向第二個代理。請用 `--port` 指定其他連接埠,或在設定中設為 `port: 0`,讓作業系統指派連接埠。啟動時它將每個供應商的模型同步到 Codex 目錄。關閉時它還原原生 Codex——除非它是作為受管服務啟動的(`OCX_SERVICE=1`)。 `--socks5`(預設 `127.0.0.1:10808`)會將 SOCKS5 URL 儲存到 `config.proxy`,並透過真正的 SOCKS5 通道轉送對外 HTTP(S) 請求。`--socks5-off` 只會清除已儲存的 SOCKS5 代理,不會刪除 HTTP 代理。此值儲存在設定中,因此會在 `ocx update` 後保留。URL 可以包含使用者名稱和密碼,但啟動記錄會隱藏它們。 diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index fe32b199ea4..d152d673f52 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -965,6 +965,60 @@ export function decideStartWithLiveOwner(input: { return input.ocxService === "1" ? "service-stay-out" : "refuse"; } +/** What `chooseListenPort` does when the preferred port stayed busy through prefer-retry. */ +export type BusyPreferredPortDecision = + | "hop" + | "refuse-live-proxy" + | "service-stay-out" + | "refuse-unidentified-holder"; + +/** + * Pure decision for a soft `start` whose preferred port is busy and whose only remaining + * option is an ephemeral port. + * + * The hop exists so a first start is not defeated by a port this machine happens to be + * using. What it must never be is a silent answer to "someone is already here": a start + * that hops takes over this home's pid and runtime-port records and re-points Codex at + * itself, so hopping past a live opencodex leaves two proxies running and the editor + * talking to the one the user did not mean (#5004). The hop path never asked who held the + * port, and `findLiveProxy` returning null — a stale record, a probe that lost a race, a + * loopback family split — was enough to reach it. + * + * So the decision is made from the holder's own answer rather than from this home's + * bookkeeping, and both outcomes stop the start. An opencodex answer is the duplicate this + * closes. A holder that does not answer as opencodex is deliberately NOT called foreign: + * an identity probe returns the same nothing for a foreign server, an unreachable one, and + * one that lost a race, so all the start can honestly say is that the port it was told to + * use is taken by something it could not identify — and moving to an arbitrary port is the + * one response that hides that from the user while re-pointing Codex. An explicit + * `--port` never reaches here (`findAvailablePort` refuses the fallback instead), and a + * configured port of 0 is a request for an ephemeral port, not a collision. + * + * Service-wrapper context keeps the semantics `decideStartWithLiveOwner` gives it: a + * healthy proxy on the port means the port is served, and the wrapper's + * `if %ERRORLEVEL% NEQ 0` loop must see a zero exit rather than respawn every 5 seconds. + */ +export function decideBusyPreferredPort(input: { + preferredPort: number; + selectedPort: number; + hardPin: boolean; + holderIsOpencodex: boolean; + ocxService: string | undefined; +}): BusyPreferredPortDecision { + // Port 0 (or an unusable preference) asked the OS to choose; nothing was taken away. + if (input.preferredPort <= 0) return "hop"; + // The preferred port was obtained — no hop happened, nothing to decide. + if (input.selectedPort === input.preferredPort) return "hop"; + // Defensive: a hard pin cannot reach a different port, and if it ever did, the pin is + // the user's explicit instruction and not something to answer with a refusal here. + if (input.hardPin) return "hop"; + if (input.holderIsOpencodex) { + // Same sentinel rule as decideStartWithLiveOwner: only the exact "1" is service context. + return input.ocxService === "1" ? "service-stay-out" : "refuse-live-proxy"; + } + return "refuse-unidentified-holder"; +} + export function resolveDispatchCommand(command: string | undefined): string | undefined { if (command === undefined) return undefined; if (Object.prototype.hasOwnProperty.call(commandRunners, command)) return command; diff --git a/src/cli/index.ts b/src/cli/index.ts index d435461a3dc..fb68ae09f2d 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -79,9 +79,9 @@ import { import { requestBoundSystemRestart } from "./system-restart-client"; import { installCrashGuards } from "../lib/crash-guard"; import { redactUrlForLog } from "../lib/redact"; -import { dispatchCommand, decideStartWithLiveOwner } from "./dispatch"; +import { dispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner } from "./dispatch"; import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; -import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; +import { findLiveProxy, probeHostname, probePortOwner, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness"; import { createReadinessGate } from "../server/readiness"; import { isApiAuthRequired } from "../server/auth-cors"; import { runReady, type ReadyArgs } from "./ready"; @@ -267,8 +267,41 @@ async function chooseListenPort( // ever a config collision. ...(reservedLoopbackPort !== undefined ? { reservedPort: reservedLoopbackPort } : {}), }); - if (preferred > 0 && selected !== preferred) { - console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); + if (selected !== preferred) { + // The hop used to be automatic, and that is how a bare `start` beside a healthy + // proxy produced a second one (#5004): nothing on this path ever asked who held the + // preferred port. Ask the holder itself — not this home's pid/runtime bookkeeping, + // which is exactly what was wrong when the duplicate happened — and give the + // question a budget that cannot mistake one lost probe for an empty port. + const holder = preferred > 0 && !hardPin + ? await probePortOwner(preferred, { hostname: config.hostname }, START_OWNERSHIP_LIVENESS) + : null; + const decision = decideBusyPreferredPort({ + preferredPort: preferred, + selectedPort: selected, + hardPin, + holderIsOpencodex: holder !== null, + ocxService: process.env.OCX_SERVICE, + }); + if (decision === "service-stay-out") { + // Same contract as the pre-bind owner check: the wrapper's retry loop terminates + // on a zero exit, and the port it was asked to serve is already served. + console.log(`Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}); service wrapper staying out of the way.`); + process.exit(0); + } + if (decision === "refuse-live-proxy") { + console.error(`⚠️ Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}). Use 'ocx stop' first.`); + process.exit(1); + } + if (decision === "refuse-unidentified-holder") { + console.error(`❌ Port ${preferred} is busy and its holder did not identify as opencodex.`); + console.error(" Starting on another port would leave Codex pointed at a proxy you did not ask for."); + console.error(" Stop whatever holds that port, or start on a free one with 'ocx start --port '."); + process.exit(1); + } + if (preferred > 0) { + console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); + } } if (shouldPersistSelectedPort(config.port, selected, preferred, options)) { config.port = selected; @@ -291,7 +324,12 @@ async function findProxyOwnerBeforeJournalRecovery( const pidSnapshot = readPidFileValue(); const hasRuntimeOwner = readRuntimePort() !== null; const shouldProbe = pidSnapshot !== null || hasRuntimeOwner || options.probeConfiguredPort === true; - const live = shouldProbe ? await findLiveProxy() : null; + // A negative answer here is acted on twice over: the caller walks past a proxy it was + // supposed to find, and the lines below delete this home's pid record and reconcile the + // journal. One 750ms probe is not enough evidence for either (#5004) — a transport + // failure is indistinguishable from an empty port, and the reported Windows duplicate + // came from exactly that answer on a proxy the previous command had just found healthy. + const live = shouldProbe ? await findLiveProxy(START_OWNERSHIP_LIVENESS) : null; if (live) return { live, pidSnapshot }; // The probe established that the snapshotted owner is stale. Compare before diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index d9d8693c411..594be867b02 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -69,6 +69,22 @@ export const SERVICE_STOP_LIVENESS: Pick = attempts: 3, }; +/** + * Probe budget for a decision whose wrong answer starts a DUPLICATE proxy (#5004). + * + * `start` used the 750ms single-attempt default for both the pre-bind owner probe and + * (implicitly) the busy-port question behind the ephemeral hop. On Windows that answered + * "nothing is listening" for a proxy the previous command had just refused to shadow, and + * the hop then spawned a second instance that took over this home's pid/runtime records + * and re-pointed Codex at itself. A single unanswered probe is not evidence of absence + * when the failure mode is a duplicate instance, so the start path borrows the numbers + * the stop path already uses for the mirror-image decision. + */ +export const START_OWNERSHIP_LIVENESS: Pick = { + timeoutMs: 1500, + attempts: 3, +}; + export interface LiveProxy { pid: number | null; port: number; @@ -279,6 +295,52 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise { + for (const hostname of loopbackProbeHosts(opts.hostname)) { + const identity = await proxyIdentityAt(port, { hostname }, io); + if (identity) return { ...identity, hostname }; + } + return null; +} + // ───────────────────────────────────────────────────────────────────────────── // Readiness (/readyz) strict probe. // diff --git a/structure/overview.md b/structure/overview.md index 02482d2368c..3e2c7395683 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -119,6 +119,11 @@ still cover the rule, which is a judgement only review makes. through `tests/helpers/repo-root.ts`, never `import.meta.dir + "/.."`. Provider additions register their focused test in both the explicit layout map and its expected-map fixture. Enforced by `tests/test-layout.test.ts`. +- **INV-START-01** — `ocx start` never answers a busy preferred port by starting on another one. It + identifies the holder first and stops either way: refused as a duplicate when an opencodex answers + there, reported as an unidentified holder otherwise. A configured `port: 0` still asks the OS for a + port, and an explicit `--port` still waits for its pin instead of hopping. + Enforced by `tests/cli/cli-dispatch.test.ts`. CI enumerates that domain layout through `scripts/ci/run-bun-test-batches.sh`. Its default general scope and 12-file/120-second process shape leave the dedicated Linux storage-policy and api-usage diff --git a/structure/runtime.md b/structure/runtime.md index 45a24aae5c2..c055ef112fd 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -149,6 +149,19 @@ until shutdown. Normal shutdown restores native Codex. Service mode sets `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and uninstall still restore. +A busy preferred port is never resolved by starting somewhere else. Both questions a start asks +about an existing proxy — the pre-bind owner check and the port-is-busy check in `src/cli/index.ts` +— are identity probes with a retry budget, because a start that answers "nobody is there" on one +lost probe deletes this home's pid record and then binds a second listener that takes over the +records and re-points Codex at itself. `probePortOwner` in `src/server/proxy-liveness.ts` asks the +busy port directly, on both loopback families, independent of the pid and runtime records; the +outcome is the pure decision `decideBusyPreferredPort` in `src/cli/dispatch.ts`. An opencodex +holder is refused with the same message the owner check prints (exit 0 instead under +`OCX_SERVICE=1`, so the wrapper loop terminates), and a holder that does not identify as opencodex +is reported as such rather than called foreign, because an identity probe cannot distinguish a +foreign server from an unreachable one. An explicit `--port` still never hops — it waits for the +pin through `src/server/port-reclaim.ts` — and a configured `port: 0` still means "ask the OS". + An explicit Codex integration OFF skips startup cache invalidation before the user-scoped catalog serialization lock is resolved. Explicit `sync` and `sync-cache` retain their catalog-only override. diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 7ffec5adc64..7cf4877feae 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -1,6 +1,6 @@ import { describe, expect, spyOn, test } from "bun:test"; import { CLI_COMMANDS } from "../../src/cli/registry"; -import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCommand, decideStartWithLiveOwner, selectDefaultGuiUrl } from "../../src/cli/dispatch"; +import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner, selectDefaultGuiUrl } from "../../src/cli/dispatch"; import type { CliDispatchDeps } from "../../src/cli/dispatch"; import type { OcxConfig } from "../../src/types"; import { runGuiCommand } from "../../src/cli/gui"; @@ -354,6 +354,79 @@ describe("start probes the configured port before shadowing it (source-level)", }); }); +/** + * #5004. The pre-bind owner check is not the last chance to notice a live proxy: when it + * answers "nothing is there" — a stale record, a probe that lost a race, a Windows loopback + * family split — the start walked on to `chooseListenPort`, found the port busy, and hopped. + * The hopped instance takes over this home's pid/runtime records and re-points Codex at + * itself, so the reporter was left with two proxies and an editor talking to the wrong one. + * + * The decision is a pure function so the matrix runs here at runtime; the source oracle + * below pins that `chooseListenPort` asks the port before it walks away from it. + */ +describe("a busy preferred port never becomes a second proxy (#5004)", () => { + // INV-START-01 (structure/overview.md). + const cliSource = readFileSync(repoPath("src/cli/index.ts"), "utf8"); + + test("the busy-preferred-port decision matrix", () => { + // The exact reported shape: preferred 58285 held by a live proxy, ephemeral 62254 free. + const reported = { preferredPort: 58285, selectedPort: 62254, hardPin: false, ocxService: undefined }; + + expect(decideBusyPreferredPort({ ...reported, holderIsOpencodex: true })).toBe("refuse-live-proxy"); + // An unidentified holder stops the start too: hopping re-points Codex either way, and a + // silent probe is not proof that the port is free for the taking. + expect(decideBusyPreferredPort({ ...reported, holderIsOpencodex: false })).toBe("refuse-unidentified-holder"); + // The service wrapper keeps the stay-out contract decideStartWithLiveOwner gives it. + expect(decideBusyPreferredPort({ ...reported, holderIsOpencodex: true, ocxService: "1" })).toBe("service-stay-out"); + // Only the exact "1" sentinel is service context. + expect(decideBusyPreferredPort({ ...reported, holderIsOpencodex: true, ocxService: "0" })).toBe("refuse-live-proxy"); + expect(decideBusyPreferredPort({ ...reported, holderIsOpencodex: false, ocxService: "1" })).toBe("refuse-unidentified-holder"); + // No hop happened — the preferred port was obtained. + expect(decideBusyPreferredPort({ ...reported, selectedPort: 58285, holderIsOpencodex: true })).toBe("hop"); + // `port: 0` asks the OS for a port; nothing was taken from anybody. + expect(decideBusyPreferredPort({ ...reported, preferredPort: 0, holderIsOpencodex: false })).toBe("hop"); + // An explicit `--port` is the user's own instruction and never reaches the fallback: + // findAvailablePort throws PortUnavailableError for a hard pin instead of hopping. + expect(decideBusyPreferredPort({ ...reported, hardPin: true, holderIsOpencodex: true })).toBe("hop"); + }); + + test("chooseListenPort asks who holds the port before it accepts a different one", () => { + const at = cliSource.indexOf("async function chooseListenPort("); + expect(at).toBeGreaterThan(-1); + const end = cliSource.indexOf("async function findProxyOwnerBeforeJournalRecovery("); + expect(end).toBeGreaterThan(at); + const fn = cliSource.slice(at, end); + + const probeAt = fn.indexOf("await probePortOwner("); + const decisionAt = fn.indexOf("decideBusyPreferredPort({"); + const hopLogAt = fn.indexOf("is busy; starting opencodex on"); + expect(probeAt).toBeGreaterThan(-1); + expect(decisionAt).toBeGreaterThan(probeAt); + // The hop message is downstream of the decision, so no path can print it without one. + expect(hopLogAt).toBeGreaterThan(decisionAt); + // One 750ms probe is what produced the duplicate; the guard spends the larger budget. + expect(fn).toContain("START_OWNERSHIP_LIVENESS"); + + // Both refusals end the process, and the refusal a user sees is the one they already + // know from the owner path. + expect(fn).toMatch(/decision === "refuse-live-proxy"[\s\S]{0,400}?process\.exit\(1\)/); + expect(fn).toContain("Use 'ocx stop' first."); + expect(fn).toMatch(/decision === "refuse-unidentified-holder"[\s\S]{0,700}?process\.exit\(1\)/); + // The wrapper's `if %ERRORLEVEL% NEQ 0` loop still terminates on a served port. + expect(fn).toMatch(/decision === "service-stay-out"[\s\S]{0,500}?process\.exit\(0\)/); + }); + + test("the pre-bind owner probe spends the same budget before it deletes state", () => { + const at = cliSource.indexOf("async function findProxyOwnerBeforeJournalRecovery("); + expect(at).toBeGreaterThan(-1); + const fn = cliSource.slice(at, at + 1400); + expect(fn).toContain("await findLiveProxy(START_OWNERSHIP_LIVENESS)"); + // A negative answer here removes this home's pid record. That is the other half of why + // one unanswered probe must not be enough. + expect(fn).toContain("removePidIfValueIs(pidSnapshot)"); + }); +}); + describe("logout parses argv before touching the credential store", () => { /** * `ocx logout --json` used to lowercase `--json`, pass it to removeCredential as a provider diff --git a/tests/cli/cli-ready.test.ts b/tests/cli/cli-ready.test.ts index 2ee2f4d11c7..197c0ae276f 100644 --- a/tests/cli/cli-ready.test.ts +++ b/tests/cli/cli-ready.test.ts @@ -856,9 +856,13 @@ describe("handleStart OCX_SERVICE exit guard (source-level)", () => { // across the whole matrix (tests/cli/cli-dispatch.test.ts). This oracle pins the // exits that the decision routes to: stay-out exits 0, the conflict exits 1. expect(cliSource).toMatch(/decideStartWithLiveOwner\(\{/); - const stayOut = cliSource.match(/decision === "service-stay-out"[\s\S]{0,800}?process\.exit\(0\)/); + // Anchored at the owner branch. `chooseListenPort` carries its own stay-out/refusal pair + // for the busy-port guard (#5004) and it sits EARLIER in the file, so an unanchored match + // would quietly move to that one and stop asserting anything about this branch. + const ownerBranch = cliSource.slice(cliSource.indexOf("decideStartWithLiveOwner({")); + const stayOut = ownerBranch.match(/decision === "service-stay-out"[\s\S]{0,800}?process\.exit\(0\)/); expect(stayOut, "the service stay-out decision must exit 0 when the port is already served").not.toBeNull(); - const nonService = cliSource.match(/Proxy already running[\s\S]{0,300}?process\.exit\(1\)/); + const nonService = ownerBranch.match(/Proxy already running[\s\S]{0,300}?process\.exit\(1\)/); expect(nonService, "non-service refusal keeps the exit 1 conflict error").not.toBeNull(); }); diff --git a/tests/server/proxy-liveness.test.ts b/tests/server/proxy-liveness.test.ts index 327725fd3d0..3d83e113ff8 100644 --- a/tests/server/proxy-liveness.test.ts +++ b/tests/server/proxy-liveness.test.ts @@ -4,11 +4,15 @@ import { runStartupReadinessSync, } from "../../src/server/readiness"; import { + DEFAULT_PROBE_TIMEOUT_MS, findLiveProxy, isOpencodexHealthz, + loopbackProbeHosts, probeHostname, + probePortOwner, probeReadiness, proxyIdentityAt, + START_OWNERSHIP_LIVENESS, validateReadyzBody, } from "../../src/server/proxy-liveness"; import { @@ -164,6 +168,73 @@ describe("proxyIdentityAt", () => { }); }); +/** + * #5004. A bare `ocx start` beside a healthy proxy printed the port-busy warning, hopped to + * an ephemeral port, and left two proxies running with Codex pointed at the second. The hop + * path never asked who held the port — it read this home's records, and a probe that came + * back empty was enough. These pin the narrower question the start path now asks instead. + */ +describe("probePortOwner asks the port itself who holds it", () => { + test("a loopback bind is asked on both families; anything else is asked where it was configured", () => { + expect(loopbackProbeHosts(undefined)).toEqual(["127.0.0.1", "[::1]"]); + expect(loopbackProbeHosts("127.0.0.1")).toEqual(["127.0.0.1", "[::1]"]); + expect(loopbackProbeHosts("0.0.0.0")).toEqual(["127.0.0.1", "[::1]"]); + // `startServer` canonicalizes a literal `localhost` bind to 127.0.0.1 exactly because + // Windows resolves the name ::1-first. Leaving the family to the resolver here is how a + // healthy listener reads as an empty port. + expect(loopbackProbeHosts("localhost")).toEqual(["127.0.0.1", "[::1]"]); + expect(loopbackProbeHosts("::1")).toEqual(["[::1]", "127.0.0.1"]); + expect(loopbackProbeHosts("192.168.1.20")).toEqual(["192.168.1.20"]); + }); + + test("finds the owner when it answers on the other loopback family", async () => { + const urls: string[] = []; + const owner = await probePortOwner(58285, { hostname: "localhost" }, { + fetchFn: (async (url: string | URL | Request) => { + urls.push(String(url)); + if (String(url).includes("[::1]")) return healthz(OURS); + throw new Error("ECONNREFUSED"); + }) as typeof fetch, + }); + + expect(owner).toEqual({ pid: 4242, version: "2.6.17", hostname: "[::1]" }); + expect(urls).toEqual(["http://127.0.0.1:58285/healthz", "http://[::1]:58285/healthz"]); + }); + + test("one lost probe is not an empty port", async () => { + let calls = 0; + const owner = await probePortOwner(58285, {}, { + ...START_OWNERSHIP_LIVENESS, + sleepFn: async () => { /* no real delay */ }, + fetchFn: (async () => { + calls += 1; + if (calls < 3) throw new Error("timeout"); + return healthz(OURS); + }) as typeof fetch, + }); + + expect(owner).toEqual({ pid: 4242, version: "2.6.17", hostname: "127.0.0.1" }); + expect(calls).toBe(3); + }); + + test("a holder that does not identify as opencodex is not reported as one", async () => { + const foreign = await probePortOwner(58285, {}, { + fetchFn: (async () => healthz({ ok: true })) as typeof fetch, + }); + expect(foreign).toBeNull(); + + const silent = await probePortOwner(58285, {}, { + fetchFn: (async () => { throw new Error("ECONNREFUSED"); }) as typeof fetch, + }); + expect(silent).toBeNull(); + }); + + test("the start-ownership budget is larger than the default single short probe", () => { + expect(START_OWNERSHIP_LIVENESS.attempts ?? 1).toBeGreaterThan(1); + expect(START_OWNERSHIP_LIVENESS.timeoutMs ?? 0).toBeGreaterThan(DEFAULT_PROBE_TIMEOUT_MS); + }); +}); + describe("findLiveProxy", () => { test("prefers the runtime-port record over config.port (fallback-port starts are found)", async () => { const urls: string[] = []; From 8aa5adcfb32d0bddb4ccb07b51932d8e9437caa5 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 18 Sep 2026 12:57:06 +0900 Subject: [PATCH 2/2] docs: resync README translations after the start-port wording change tests/ci-workflows/docs-readme-translation-parity.test.ts registers each readme/README..md against the SHA-256 of README.md it was last synced to, so editing the port-fallback sentence in README.md made all seven locales stale. The same sentence is rewritten in each locale and every sourceSha256 is set to the current README.md hash, in this commit as the manifest requires. --- readme/README.fr.md | 5 +++-- readme/README.ja.md | 5 +++-- readme/README.ko.md | 5 +++-- readme/README.ru.md | 5 +++-- readme/README.tr.md | 5 +++-- readme/README.zh-CN.md | 4 ++-- readme/README.zh-TW.md | 4 ++-- readme/i18n-manifest.json | 14 +++++++------- 8 files changed, 26 insertions(+), 21 deletions(-) diff --git a/readme/README.fr.md b/readme/README.fr.md index 4314dc83531..43468a7f917 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -317,8 +317,9 @@ ocx v2 <...> # contrôle les surfaces multi-agents v1/v2 ocx update [--tag preview] # met à jour opencodex ``` -Les démarrages sans port imposé peuvent choisir un autre port libre si celui qui est préféré est occupé ; un `--port` -explicite ne change jamais de port. Référence complète : [documentation de la CLI](https://opencodex.me/fr/reference/cli/). +Si le port préféré est occupé, le démarrage s'arrête et indique le processus qui l'occupe au lieu de passer à un autre port, +afin de ne jamais laisser un second proxy fonctionner aux côtés du premier. Libérez le port ou indiquez-en un autre avec +`--port`. Référence complète : [documentation de la CLI](https://opencodex.me/fr/reference/cli/). ### État de fonctionnement et disponibilité diff --git a/readme/README.ja.md b/readme/README.ja.md index fb31ddde62f..ffbb83b5514 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -314,8 +314,9 @@ ocx v2 <...> # マルチエージェント v1/v2 の表面制 ocx update [--tag preview] # opencodex の更新 ``` -ポートを固定せずに起動した場合、希望のポートが埋まっていれば別の空きポートへ移ることがあります。 -`--port` を明示した起動は決して移りません。全リファレンスは +希望するポートが使用中の場合、起動は別のポートへ移らずに停止し、そのポートを保持しているプロセスを示します。 +そのため、既存のプロキシと並んで 2 つ目のプロキシが動き続けることはありません。ポートを空けるか、`--port` で +別のポートを指定してください。全リファレンスは [CLI のドキュメント](https://opencodex.me/ja/reference/cli/)にあります。 ### ヘルスと準備状態 diff --git a/readme/README.ko.md b/readme/README.ko.md index f75a9e1e056..eaf029fec30 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -303,8 +303,9 @@ ocx v2 <...> # 멀티에이전트 v1/v2 표면 제어 ocx update [--tag preview] # opencodex 업데이트 ``` -포트를 고정하지 않고 시작하면 선호 포트가 사용 중일 때 다른 빈 포트로 옮겨갈 수 있습니다. `--port`를 -명시하면 절대 옮기지 않습니다. 전체 레퍼런스: [CLI 문서](https://opencodex.me/ko/reference/cli/). +선호 포트가 사용 중이면 다른 포트로 옮겨가지 않고 시작을 중단한 뒤 해당 포트의 점유자를 알려줍니다. 따라서 기존 +프록시 옆에 두 번째 프록시가 실행된 채 남을 수 없습니다. 포트를 비우거나 `--port`로 다른 포트를 지정하세요. +전체 레퍼런스: [CLI 문서](https://opencodex.me/ko/reference/cli/). ### 상태 확인과 준비 diff --git a/readme/README.ru.md b/readme/README.ru.md index 70401f05516..6a0b09b9762 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -321,8 +321,9 @@ ocx v2 <...> # управление мультиагентны ocx update [--tag preview] # обновить opencodex ``` -Запуски без закреплённого порта могут выбрать другой свободный порт, если предпочтительный занят; -явный `--port` никогда не перескакивает. Полный справочник: [документация CLI](https://opencodex.me/ru/reference/cli/). +Если предпочтительный порт занят, запуск останавливается и сообщает, какой процесс его занимает, вместо перехода +на другой порт, поэтому второй прокси никогда не останется работать рядом с первым. Освободите порт или укажите +другой с помощью `--port`. Полный справочник: [документация CLI](https://opencodex.me/ru/reference/cli/). ### Здоровье и готовность diff --git a/readme/README.tr.md b/readme/README.tr.md index 7991738c334..4c7396aa46f 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -314,8 +314,9 @@ ocx v2 <...> # çoklu ajan v1/v2 yüzey denetimleri ocx update [--tag preview] # opencodex'i güncelle ``` -Sabitlenmemiş başlatmalar, tercih edilen bağlantı noktası meşgulse başka bir boş bağlantı noktasına -geçebilir; açıkça verilen bir `--port` asla değişmez. Tam başvuru: +Tercih edilen bağlantı noktası meşgulse başlatma başka bir bağlantı noktasına geçmek yerine durur ve bağlantı +noktasını hangi işlemin tuttuğunu bildirir; böylece ilkinin yanında ikinci bir proxy çalışır durumda kalamaz. +Bağlantı noktasını boşaltın veya `--port` ile farklı bir tane belirtin. Tam başvuru: [CLI belgeleri](https://opencodex.me/tr/reference/cli/). ### Sağlık ve hazırlık diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index da83c81e281..145155437d4 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -303,8 +303,8 @@ ocx v2 <...> # 多智能体 v1/v2 表面控制 ocx update [--tag preview] # 更新 opencodex ``` -未固定端口的启动在首选端口被占用时可能改选其他空闲端口;显式 `--port` -绝不会换端口。完整参考:[CLI 文档](https://opencodex.me/zh-cn/reference/cli/)。 +首选端口被占用时,启动会停止并指出占用者,而不会改用其他端口,因此绝不会在第一个代理旁留下另一个 +运行中的代理。请释放该端口,或用 `--port` 指定其他端口。完整参考:[CLI 文档](https://opencodex.me/zh-cn/reference/cli/)。 ### 健康与就绪 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index c2f71c1d709..72d9a49efdc 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -301,8 +301,8 @@ ocx v2 <...> # 多代理 v1/v2 介面控制 ocx update [--tag preview] # 更新 opencodex ``` -未釘選連接埠的啟動,在偏好連接埠被占用時可能改選其他空閒連接埠;明確的 `--port` -絕不會跳號。完整參考:[CLI 文件](https://opencodex.me/zh-tw/reference/cli/)。 +偏好的連接埠被占用時,啟動會停止並指出占用者,而不會改用其他連接埠,因此絕不會在第一個代理旁留下另一個 +執行中的代理。請釋放該連接埠,或用 `--port` 指定其他連接埠。完整參考:[CLI 文件](https://opencodex.me/zh-tw/reference/cli/)。 ### 健康狀態與就緒 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index 8ecf86dbf50..5479089ac4a 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "ddc979fcbe7a7d3c3566b034c6ef4ba7585062ad984ea860e6a1378c335576bb" + "sourceSha256": "82b0a4aec1d7ecbe572ca7d888d6a87f1ee788ef14e45bdd3d20cb8b439127bb" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "ddc979fcbe7a7d3c3566b034c6ef4ba7585062ad984ea860e6a1378c335576bb" + "sourceSha256": "82b0a4aec1d7ecbe572ca7d888d6a87f1ee788ef14e45bdd3d20cb8b439127bb" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "ddc979fcbe7a7d3c3566b034c6ef4ba7585062ad984ea860e6a1378c335576bb" + "sourceSha256": "82b0a4aec1d7ecbe572ca7d888d6a87f1ee788ef14e45bdd3d20cb8b439127bb" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "ddc979fcbe7a7d3c3566b034c6ef4ba7585062ad984ea860e6a1378c335576bb" + "sourceSha256": "82b0a4aec1d7ecbe572ca7d888d6a87f1ee788ef14e45bdd3d20cb8b439127bb" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "ddc979fcbe7a7d3c3566b034c6ef4ba7585062ad984ea860e6a1378c335576bb" + "sourceSha256": "82b0a4aec1d7ecbe572ca7d888d6a87f1ee788ef14e45bdd3d20cb8b439127bb" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "ddc979fcbe7a7d3c3566b034c6ef4ba7585062ad984ea860e6a1378c335576bb" + "sourceSha256": "82b0a4aec1d7ecbe572ca7d888d6a87f1ee788ef14e45bdd3d20cb8b439127bb" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "ddc979fcbe7a7d3c3566b034c6ef4ba7585062ad984ea860e6a1378c335576bb" + "sourceSha256": "82b0a4aec1d7ecbe572ca7d888d6a87f1ee788ef14e45bdd3d20cb8b439127bb" } } }