From 28953bba47d2e939fd27b89d1dd1b6eba5873c75 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 26 Sep 2026 20:17:13 +0900 Subject: [PATCH 01/13] fix(start): a second ocx instance never re-points shared client routing away from the live proxy Root cause: `ocx start --port ` beside a live proxy (the sibling path) ran the ordinary startup sync and exit teardown against the CODEX_HOME, ~/.claude, ~/.grok and launchd domain it shares with the live owner, so openai_base_url was left on the sibling's port and every Codex thread broke once it exited. Fix: handleStart marks the process (src/codex/sibling-start.ts) before binding. The local-client gate, restore/inject, catalog funnel, owned-client refresh, Claude and system-env writers, client connect, the exit and stop teardown and a management route guard (409 sibling_instance) refuse shared writes, and the runtime record's siblingOfPort tells `ocx stop` it is stopping a sibling. That stop claims no receipt, restores nothing and leaves the system env. Neither it nor the sibling's own POST /api/stop asks the service manager: a sibling never runs under one, and the installed service is the live owner's, whose ownership check used to fail the stop (or, with no resolvable service record, let the sibling boot the owner's launchd job out). A hard-killed sibling's stop no longer falls back to stopping the live owner: it clears the stale records and exits 0. A sibling's drain-and-restart and standalone recycle hand OCX_SIBLING_OF_PORT to the replacement, which honors it before any probe; every other detached `ocx start` (ensure, tray, the claude/opencode/minimax auto-starts, the updater's and the launcher's restarts) strips it. Archived-session cleanup, its policy run and trash restore are refused on a sibling, and the storage policy scheduler stands down there. Translated lifecycle docs gain the sibling exception. Security: the management guard narrows what a sibling's API can mutate in state shared with the live owner; no new surface is opened. Co-Authored-By: Claude Opus 5.5 (1M context) --- bin/ocx.mjs | 2 + .../docs/fr/reference/cli/lifecycle.md | 2 +- .../docs/ja/reference/cli/lifecycle.md | 2 +- .../docs/ko/reference/cli/lifecycle.md | 4 +- .../content/docs/reference/cli/lifecycle.md | 5 +- .../docs/ru/reference/cli/lifecycle.md | 5 +- .../docs/tr/reference/cli/lifecycle.md | 4 +- .../docs/zh-cn/reference/cli/lifecycle.md | 2 +- .../docs/zh-tw/reference/cli/lifecycle.md | 2 +- src/claude/agents-inject.ts | 4 + src/cli/claude.ts | 4 +- src/cli/dispatch.ts | 48 +++- src/cli/index.ts | 108 ++++----- src/cli/minimax.ts | 6 +- src/cli/opencode.ts | 4 +- src/client/connect.ts | 6 + src/client/runtime.ts | 6 +- src/codex/codex-write-lock.ts | 4 +- src/codex/desired-state.ts | 18 +- src/codex/inject-coordination.ts | 5 +- src/codex/inject.ts | 9 +- src/codex/inject/restore.ts | 17 ++ src/codex/management-convergence.ts | 9 + src/codex/sibling-start.ts | 133 ++++++++++ src/codex/sync.ts | 8 +- src/config/process-state.ts | 11 +- src/integrations/catalog-refresh.ts | 4 + src/lib/process-control.ts | 5 +- src/server/management-api.ts | 13 +- src/server/management/config-routes.ts | 5 +- src/server/management/sibling-guard.ts | 60 +++++ src/server/management/system-restart.ts | 4 +- src/server/stop-teardown.ts | 9 +- src/server/system-env.ts | 3 + src/storage/policy-job.ts | 4 + src/update/index.ts | 4 +- src/update/job.ts | 4 +- structure/clients/integrations.md | 3 + structure/codex-home.md | 29 +++ structure/config.md | 4 +- structure/gui-and-management-api.md | 14 +- structure/overview.md | 4 + structure/runtime.md | 4 +- tests/cli/cli-dispatch.test.ts | 193 ++++++++++++++- tests/cli/cli-start-journal-order.test.ts | 227 ++++++++++++++++++ tests/cli/hub-gated-local-clients.test.ts | 171 ++++++++++++- tests/clients/client-connect.test.ts | 41 +++- tests/codex-integration/codex-journal.test.ts | 59 +++++ tests/lib/process-control-graceful.test.ts | 16 ++ tests/providers/xai/grok-lifecycle.test.ts | 26 +- .../server/management-route-registry.test.ts | 109 +++++++++ tests/service/process-state.test.ts | 23 ++ tests/service/stop-deferred-teardown.test.ts | 28 +++ .../storage-policy-config-race.test.ts | 34 +++ 54 files changed, 1421 insertions(+), 107 deletions(-) create mode 100644 src/codex/sibling-start.ts create mode 100644 src/server/management/sibling-guard.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index dbf63b4ae2f..e273b9a120e 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -447,6 +447,8 @@ function runPackageManagerSelfUpdate(manager) { } const env = mutationChildEnvironment(); delete env.OCX_SERVICE; + // The restarted proxy is an ordinary owner; only a sibling's own replacement carries this. + delete env.OCX_SIBLING_OF_PORT; console.log(`Attempting to restart the proxy on port ${bakePort}.`); const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(bakePort)], { detached: true, 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 7a1b6cfedbb..59d53766063 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`. 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. Un autre `--port` explicite est également refusé avec le même `OPENCODEX_HOME`, car les modes d’observation et de plafond écrivent tous deux dans le même journal de dépenses. Utilisez un `OPENCODEX_HOME` distinct pour une instance sœur indépendante ; `port: 0` ne sépare que l’attribution du port, pas l’état. 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. Un autre `--port` explicite est également refusé avec le même `OPENCODEX_HOME`, car les modes d’observation et de plafond écrivent tous deux dans le même journal de dépenses. Utilisez un `OPENCODEX_HOME` distinct pour une instance sœur indépendante ; `port: 0` ne sépare que l’attribution du port, pas l’état. 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`). Une instance sœur démarrée à côté d’un proxy déjà actif ne fait ni l’un ni l’autre, même lorsqu’elle est arrêtée avec `ocx stop` ou par un signal : elle ne sert que les requêtes directes sur son propre port, et Codex, Grok et Claude restent dirigés vers le proxy qui tournait déjà. `--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/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index b6c5fe4a7db..c56f93e2d4b 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`) を起動します。PID/ランタイムポートの状態を書き込み、2 番目のライブインスタンスの起動を拒否します。優先ポートが使用中の場合、`start` はそのポートを使用しているプロセスを確認して、どちらの場合も停止します。opencodex が応答していれば起動を拒否し、それ以外は使用しているプロセスを特定できないと報告します。最初のプロキシを実行したまま Codex を 2 番目のプロキシへ向けることになるため、自動でリスナーを別のポートへ移すことはありません。同じ `OPENCODEX_HOME` では別の `--port` を明示しても拒否されます。監視のみの構成も上限を適用する構成も同じ支出ジャーナルへ書き込むためです。独立した sibling には別の `OPENCODEX_HOME` を使用してください。`port: 0` はポートだけを OS に割り当てさせ、状態を分離しません。開始時に、各プロバイダーのモデルを Codex のカタログに同期します。マネージド サービス (`OCX_SERVICE=1`) として起動されていない限り、シャットダウン時にネイティブ Codex が復元されます。 +プロキシ サーバー (優先ポート `10100`) を起動します。PID/ランタイムポートの状態を書き込み、2 番目のライブインスタンスの起動を拒否します。優先ポートが使用中の場合、`start` はそのポートを使用しているプロセスを確認して、どちらの場合も停止します。opencodex が応答していれば起動を拒否し、それ以外は使用しているプロセスを特定できないと報告します。最初のプロキシを実行したまま Codex を 2 番目のプロキシへ向けることになるため、自動でリスナーを別のポートへ移すことはありません。同じ `OPENCODEX_HOME` では別の `--port` を明示しても拒否されます。監視のみの構成も上限を適用する構成も同じ支出ジャーナルへ書き込むためです。独立した sibling には別の `OPENCODEX_HOME` を使用してください。`port: 0` はポートだけを OS に割り当てさせ、状態を分離しません。開始時に、各プロバイダーのモデルを Codex のカタログに同期します。マネージド サービス (`OCX_SERVICE=1`) として起動されていない限り、シャットダウン時にネイティブ Codex が復元されます。既に稼働中のプロキシの横で起動した sibling は、`ocx stop` やシグナルで停止した場合も含めてそのどちらも行わず、自身のポートで直接のリクエストを処理するだけで、Codex、Grok、Claude は既に稼働していたプロキシを指したままになります。 `--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/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index b9ec56615fb..74e4abca98d 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -26,7 +26,9 @@ Codex 자동 시작 shim도 설치합니다. 시작을 거부합니다. 관찰 전용과 제한 적용 모드 모두 같은 지출 저널에 기록하기 때문입니다. 독립된 형제 인스턴스에는 별도의 `OPENCODEX_HOME`을 사용하세요. `port: 0`은 포트만 OS에 맡기며 상태를 분리하지 않습니다. 시작할 때는 각 공급자의 모델을 Codex 카탈로그로 동기화합니다. 종료할 때는 -기본 Codex를 복원합니다. 단, 관리형 서비스로 실행한 경우(`OCX_SERVICE=1`)는 예외입니다. +기본 Codex를 복원합니다. 단, 관리형 서비스로 실행한 경우(`OCX_SERVICE=1`)는 예외입니다. 이미 실행 중인 +프록시 옆에서 시작한 형제 인스턴스는 `ocx stop`이나 시그널로 멈출 때도 동기화와 복원을 하지 않고 자신의 +포트에서 직접 요청만 처리하며, Codex, Grok, Claude는 원래 실행 중이던 프록시를 계속 가리킵니다. `--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 24a63d5c7ad..92702e03be7 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -27,7 +27,10 @@ would leave the first proxy running and re-point Codex at the second. An explici and enforced spend accounting both write the same journal. Use a separate `OPENCODEX_HOME` for an independent sibling; `port: 0` only asks the OS for that instance's port and does not separate its state. 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`). +native Codex — unless it was launched as a managed service (`OCX_SERVICE=1`). A sibling started +beside a running proxy does neither: it serves direct requests on its own port only, and Codex, +Grok and Claude stay pointed at the proxy that was already running. Stopping that sibling, with +`ocx stop` or a signal, leaves their configuration alone as well. `--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/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 3b7caf84a68..77b62ddc79b 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -30,7 +30,10 @@ PID/runtime-port, а попытка поднять второй живой эк Для независимого соседнего экземпляра используйте отдельный `OPENCODEX_HOME`; `port: 0` поручает ОС выбрать только порт и не разделяет состояние. На старте прокси синхронизирует модели каждого провайдера в каталог Codex. При shutdown он восстанавливает native Codex — если только прокси не -был запущен как managed service (`OCX_SERVICE=1`). +был запущен как managed service (`OCX_SERVICE=1`). Соседний экземпляр, запущенный рядом с уже работающим +прокси, не делает ни того, ни другого, в том числе при остановке через `ocx stop` или сигналом: он +обслуживает только прямые запросы на своём порту, а Codex, Grok и Claude остаются направленными на +прокси, который уже работал. `--socks5` (по умолчанию `127.0.0.1:10808`) сохраняет SOCKS5 URL в `config.proxy` и направляет исходящие HTTP(S)-запросы через настоящий SOCKS5-туннель. `--socks5-off` удаляет только сохранённый 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 d2c4a437743..700b7da51c4 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -32,7 +32,9 @@ yalnızca gözlem ve sınır uygulama kiplerinin ikisi de aynı harcama günlü bir kardeş örnek için ayrı bir `OPENCODEX_HOME` kullanın. `port: 0` yalnızca port seçimini işletim sistemine bırakır, durumu ayırmaz. 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. +(`OCX_SERVICE=1`) — yerel Codex'i geri yükler. Çalışan bir proxy'nin yanında başlatılan kardeş örnek, +`ocx stop` ya da bir sinyalle durdurulduğunda da dahil ikisini de yapmaz: yalnızca kendi portundaki +doğrudan istekleri karşılar ve Codex, Grok ile Claude zaten çalışmakta olan proxy'yi göstermeye devam eder. `--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/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index c5caee6abb1..6f4ab51582d 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`)。它会写入 PID/运行时端口状态,并拒绝启动第二个存活实例。当首选端口已被占用时,`start` 会探测占用者,并且无论结果如何都会停止:如果那里响应的是 opencodex,它会直接拒绝启动;否则会报告无法识别的占用者。它绝不会自行把监听地址移到其他端口,因为那会让第一个代理继续运行,并将 Codex 重新指向第二个代理。即使显式指定不同的 `--port`,共用同一个 `OPENCODEX_HOME` 时也会拒绝启动,因为仅观察模式和启用上限的模式都会写入同一个支出日志。独立的同级实例必须使用单独的 `OPENCODEX_HOME`;`port: 0` 只让操作系统分配端口,并不会隔离状态。启动时,它会把每个提供方的模型同步到 Codex 的目录中。关闭时,它会恢复原生 Codex,除非它是作为受管服务启动的(`OCX_SERVICE=1`)。 +启动代理服务器(首选端口 `10100`)。它会写入 PID/运行时端口状态,并拒绝启动第二个存活实例。当首选端口已被占用时,`start` 会探测占用者,并且无论结果如何都会停止:如果那里响应的是 opencodex,它会直接拒绝启动;否则会报告无法识别的占用者。它绝不会自行把监听地址移到其他端口,因为那会让第一个代理继续运行,并将 Codex 重新指向第二个代理。即使显式指定不同的 `--port`,共用同一个 `OPENCODEX_HOME` 时也会拒绝启动,因为仅观察模式和启用上限的模式都会写入同一个支出日志。独立的同级实例必须使用单独的 `OPENCODEX_HOME`;`port: 0` 只让操作系统分配端口,并不会隔离状态。启动时,它会把每个提供方的模型同步到 Codex 的目录中。关闭时,它会恢复原生 Codex,除非它是作为受管服务启动的(`OCX_SERVICE=1`)。在已运行的代理旁启动的同级实例两者都不做,即使通过 `ocx stop` 或信号停止也是如此:它只在自己的端口上处理直接请求,Codex、Grok 和 Claude 仍指向原本已在运行的代理。 `--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/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 16899542c02..6bd593004ae 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`)。它寫入 PID/runtime-port 狀態,並拒絕啟動第二個即時實例。偏好連接埠被佔用時,`start` 會探測佔用者,且無論結果如何都會停止:若回應的是 opencodex,它會直接拒絕啟動;否則會回報無法識別的佔用者。它絕不會自行將監聽位置移到其他連接埠,因為這會讓第一個代理繼續執行,並將 Codex 重新指向第二個代理。即使明確指定不同的 `--port`,共用同一個 `OPENCODEX_HOME` 時仍會拒絕啟動,因為僅觀察模式和啟用上限的模式都會寫入同一份支出日誌。獨立的同層實例必須使用不同的 `OPENCODEX_HOME`;`port: 0` 只讓作業系統指派連接埠,不會隔離狀態。啟動時它將每個供應商的模型同步到 Codex 目錄。關閉時它還原原生 Codex——除非它是作為受管服務啟動的(`OCX_SERVICE=1`)。 +啟動代理伺服器(偏好連接埠 `10100`)。它寫入 PID/runtime-port 狀態,並拒絕啟動第二個即時實例。偏好連接埠被佔用時,`start` 會探測佔用者,且無論結果如何都會停止:若回應的是 opencodex,它會直接拒絕啟動;否則會回報無法識別的佔用者。它絕不會自行將監聽位置移到其他連接埠,因為這會讓第一個代理繼續執行,並將 Codex 重新指向第二個代理。即使明確指定不同的 `--port`,共用同一個 `OPENCODEX_HOME` 時仍會拒絕啟動,因為僅觀察模式和啟用上限的模式都會寫入同一份支出日誌。獨立的同層實例必須使用不同的 `OPENCODEX_HOME`;`port: 0` 只讓作業系統指派連接埠,不會隔離狀態。啟動時它將每個供應商的模型同步到 Codex 目錄。關閉時它還原原生 Codex——除非它是作為受管服務啟動的(`OCX_SERVICE=1`)。在已執行的代理旁啟動的同層實例兩者皆不做,即使透過 `ocx stop` 或訊號停止也一樣:它只在自己的連接埠上處理直接請求,Codex、Grok 和 Claude 仍指向原本已在執行的代理。 `--socks5`(預設 `127.0.0.1:10808`)會將 SOCKS5 URL 儲存到 `config.proxy`,並透過真正的 SOCKS5 通道轉送對外 HTTP(S) 請求。`--socks5-off` 只會清除已儲存的 SOCKS5 代理,不會刪除 HTTP 代理。此值儲存在設定中,因此會在 `ocx update` 後保留。URL 可以包含使用者名稱和密碼,但啟動記錄會隱藏它們。 diff --git a/src/claude/agents-inject.ts b/src/claude/agents-inject.ts index 9614bf5c4cb..1af5317ae47 100644 --- a/src/claude/agents-inject.ts +++ b/src/claude/agents-inject.ts @@ -23,6 +23,7 @@ import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound"; import { AnthropicRequestError } from "./inbound-records"; import { knownModelIdsForProvider } from "../router"; import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec"; +import { siblingOfLivePort } from "../codex/sibling-start"; export interface ClaudeAgentDef { file: string; @@ -285,6 +286,9 @@ export function injectClaudeAgentDefs( /** Hub-sourced roster on a connected client; see `buildClaudeAgentDefs`. */ rosterOverride?: readonly string[], ): string[] | null { + // `~/.claude/agents` is shared with the live proxy a sibling instance runs beside, which owns + // both its roster and its pruning (`src/codex/sibling-start.ts`). + if (siblingOfLivePort() !== null) return null; if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) { // Disabled: prune verified-owned files so stale definitions stop loading // in future sessions (audit 071 #3). The roster override is irrelevant here by diff --git a/src/cli/claude.ts b/src/cli/claude.ts index de51b86f5cd..ca4d62e7888 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -34,6 +34,7 @@ import { aliasForNative, aliasForRoute, legacyAliasForNative, legacyAliasForRout import { desktop3pAlias } from "../claude/desktop-3p"; import { inspectDesktopFirstParty } from "../claude/desktop-first-party"; import { isClaudeInterceptProxyUrl, type ClaudeInterceptSettingsState } from "../claude/intercept/settings"; +import { withoutSiblingMarker } from "../codex/sibling-start"; export interface ClaudeLaunchEnv { [key: string]: string | undefined; @@ -506,7 +507,8 @@ export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Pr detached: true, stdio: "ignore", windowsHide: true, - env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }), + // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. + env: withProcessRuntimeProvenance(withoutSiblingMarker({ ...process.env, OCX_SERVICE: "1" })), }); child.unref(); const deadline = Date.now() + 8_000; diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 9df115fa24b..8a132a85813 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -21,7 +21,9 @@ import { localClientSkipMessage, setIntegrationEnabled, shouldSyncCodexOnStart, + type LocalClientSkipReason, } from "../codex/desired-state"; +import { siblingSkipMessage } from "../codex/sibling-start"; import { syncModelsToCodex } from "../codex/sync"; import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; import { restoreNativeCodexAsync, type CodexNativeRestoreResult } from "../codex/inject"; @@ -1021,13 +1023,57 @@ export function decideStartWithLiveOwner(input: { }): StartOwnerDecision { const sibling = input.requestedPort !== undefined && input.requestedPort !== input.livePort - // Only the exact "1" sentinel is service context — the same check syncCleanup + // Only the exact "1" sentinel is service context — the same check the exit teardown // uses — so an env value like "0" or "false" cannot reach the stay-out path. && input.ocxService !== "1"; if (sibling) return "sibling"; return input.ocxService === "1" ? "service-stay-out" : "refuse"; } +/** Which shared client state `handleStart`'s exit cleanup tears down. */ +export interface StartExitTeardown { + revertSystemEnv: boolean; + restoreNativeCodex: boolean; + stripGrokConfig: boolean; +} + +/** + * Pure exit-teardown decision for `handleStart`'s `syncCleanup`. + * + * A sibling instance tears down nothing: the Codex routing, the Grok fence and the system env + * belong to the live proxy it runs beside, and restoring them would take Codex off a proxy that + * is still serving it (`src/codex/sibling-start.ts`). A dashboard drain-and-restart (#563) keeps + * everything for the replacement process. Under a service manager — only the exact `"1"` + * sentinel — a crash/respawn keeps routing and the fence, and only the environment comes down. + * The caller still applies its own external-provider and service-ownership checks. + */ +export function decideStartExitTeardown(input: { + sibling: boolean; + recycling: boolean; + ocxService: string | undefined; +}): StartExitTeardown { + if (input.sibling || input.recycling) { + return { revertSystemEnv: false, restoreNativeCodex: false, stripGrokConfig: false }; + } + const preserveRouting = input.ocxService === "1"; + return { revertSystemEnv: true, restoreNativeCodex: !preserveRouting, stripGrokConfig: !preserveRouting }; +} + +/** + * The one startup line for "nothing was written to Codex". + * + * Three very different facts reach it: the user's own OFF switch, a hub declining to rewrite its + * own local clients, and a sibling instance leaving the live proxy's routing alone. Printing the + * toggle's wording for the gate is what made operators hunt for a switch they never set (#4236). + * `port` is the sibling's own bound port, named in its line. + */ +export function startupLeftCodexNativeLine(reason: LocalClientSkipReason, port?: number): string { + if (reason === "sibling") return ` ${siblingSkipMessage(port)}`; + return reason === "hub-gated" + ? ` ${HUB_GATED_SKIP_MESSAGE} Startup left Codex native.` + : " Codex integration OFF; startup left Codex native."; +} + /** What `chooseListenPort` does when the preferred port stayed busy through prefer-retry. */ export type BusyPreferredPortDecision = | "hop" diff --git a/src/cli/index.ts b/src/cli/index.ts index 827dbd6701b..35c18e6147f 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -83,7 +83,7 @@ import { requestBoundSystemRestart } from "./system-restart-client"; import { installCrashGuards } from "../lib/crash-guard"; import { SpendLedgerOwnerError } from "../lib/spend-ledger-owner"; import { redactUrlForLog } from "../lib/redact"; -import { dispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner } from "./dispatch"; +import { dispatchCommand, decideBusyPreferredPort, decideStartExitTeardown, decideStartWithLiveOwner, startupLeftCodexNativeLine } from "./dispatch"; import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; import { findLiveProxy, @@ -127,13 +127,8 @@ import { StartOwnershipRollbackUncertainError, } from "./start-ownership-publication"; import { syncModelsToCodex } from "../codex/sync"; -import { - HUB_GATED_SKIP_MESSAGE, - localClientSkipReason, - shouldSyncGrokOnStart, - syncCodexOnStartIfEnabled, - type LocalClientSkipReason, -} from "../codex/desired-state"; +import { localClientSkipReason, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; +import { honorSiblingMarker, markSiblingStart, siblingOfLivePort, siblingRuntimeField, siblingStopFoundOwner, withoutSiblingMarker } from "../codex/sibling-start"; import { reconcileClientStartupBeforeReady, syncClaudeAgentDefsAtProxyStartup, @@ -232,19 +227,6 @@ async function waitForProxy(timeoutMs = 8_000): Promise { return null; } -/** - * The one startup line for "nothing was written to Codex". - * - * Two very different facts reached it: the user's own OFF switch, and a hub declining to - * rewrite its own local clients. Printing the toggle's wording for the gate is what made - * operators hunt for a switch they never set (#4236). - */ -function startupLeftCodexNativeLine(reason: LocalClientSkipReason): string { - return reason === "hub-gated" - ? ` ${HUB_GATED_SKIP_MESSAGE} Startup left Codex native.` - : " Codex integration OFF; startup left Codex native."; -} - /** Argv for detached `start`, optionally hard-pinning the listen port. */ function startArgv(port?: number): string[] { const args = ["start"]; @@ -381,7 +363,8 @@ async function findProxyOwnerBeforeJournalRecovery( // The probe established that the snapshotted owner is stale. Compare before // deleting so a concurrent start that rewrote the PID file keeps its state. removePidIfValueIs(pidSnapshot); - if (!currentExternalCodexModelProvider()) { + // A marked sibling's owner can be down mid-restart; its journal is still not ours to replay. + if (!currentExternalCodexModelProvider() && siblingOfLivePort() === null) { const clientState = readClientConnectionState(); reconcileJournal(clientState.kind === "connected" ? { activeClientApiKeyId: clientState.value.apiKeyId } @@ -433,9 +416,10 @@ async function handleStart(options: { block?: boolean } = {}) { // configured port. Without the probe, `start` shadowed a healthy proxy with an // ephemeral-port copy and re-pointed client config at the copy; the next sibling // shutdown then left no runtime record for discovery at all. `handleEnsure` - // already passes this; `handleStart` is the path that did not. + // already passes this; `handleStart` is the path that did not. A sibling's own replacement is + // marked before it (`honorSiblingMarker`): an owner down for that moment must not make it one. + let siblingStart = honorSiblingMarker(process.env) !== null; const owner = await findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true }); - let siblingStart = false; if (owner.live) { // Rationale and the full decision table live on `decideStartWithLiveOwner`. const decision = decideStartWithLiveOwner({ @@ -456,13 +440,14 @@ async function handleStart(options: { block?: boolean } = {}) { console.error(`⚠️ Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}). Use 'ocx stop' first.`); process.exit(1); } - // Sibling path. Honest about the side effects it shares with any start in this home: - // the new instance takes over this home's ocx.pid / runtime-port.json while it runs, - // and re-points this home's Codex config at the new port when injection applies. - // What it must NOT do is persist its port into config.port: the configured-port - // proxy is still the owner of this home, and a later `ocx service` reads config.port - // to bake the service (observed: a probe on 10198 left the service pinned there). + // Sibling path. The new instance takes over this home's ocx.pid / runtime-port.json while + // it runs, and nothing else: Codex, Grok, Claude and the system env keep pointing at the + // live proxy for this process's whole lifetime, its exit and `ocx stop` included, so the + // mark goes down before any client write (`src/codex/sibling-start.ts`). Nor may it persist + // its port into config.port: the configured-port proxy still owns this home, and a later + // `ocx service` reads config.port to bake the service (a probe on 10198 pinned it there). siblingStart = true; + markSiblingStart(owner.live.port); console.warn( `Proxy already running on port ${owner.live.port}; requested a second instance on port ${requestedPort}. ` + `Startup continues only for an independent OPENCODEX_HOME; one state directory has one spend-ledger writer.`, @@ -485,8 +470,9 @@ async function handleStart(options: { block?: boolean } = {}) { // Interactive-only update prompt. Must run BEFORE we bind a port / write a // PID: choosing "Update now" installs globally and exits, so we never want a - // live daemon holding resources while it overwrites its own binary. - await maybeShowUpdatePrompt(); + // live daemon holding resources while it overwrites its own binary. Never from a + // sibling: replacing the global package makes the live proxy drain and restart. + if (!siblingStart) await maybeShowUpdatePrompt(); type StartServerModule = typeof import("../server"); type BoundStart = { @@ -520,6 +506,7 @@ async function handleStart(options: { block?: boolean } = {}) { throw new StartCommandExit(1); } siblingStart = true; + markSiblingStart(fencedLive.port); } // Port selection is check-then-bind. The lease prevents every cooperating start or @@ -566,6 +553,7 @@ async function handleStart(options: { block?: boolean } = {}) { port: bound.port, hostname: bound.config.hostname, attestationSecret: bound.localAttestationSecret, + ...siblingRuntimeField(), }), stopBound: bound => bound.server.stop(true), removeRuntime: () => removeRuntimePortIfPidIs(process.pid), @@ -601,15 +589,14 @@ async function handleStart(options: { block?: boolean } = {}) { try { guardian.stop(); } catch { /* best-effort */ } try { historyGuardian?.stop(); } catch { /* best-effort */ } // Dashboard drain-and-restart (#563) must not tear down injection: the replacement - // process expects Codex/Grok/env fences to still be in place. - const recycling = isRecyclingForExit(); - if (!recycling) { + // process expects Codex/Grok/env fences to still be in place. A sibling owns none of them. + const teardown = decideStartExitTeardown({ sibling: siblingStart, recycling: isRecyclingForExit(), ocxService: process.env.OCX_SERVICE }); + if (teardown.revertSystemEnv) { try { revertSystemEnv(); } catch { /* best-effort */ } } removePid(process.pid); removeRuntimePort(process.pid); - const preserveRouting = process.env.OCX_SERVICE === "1"; - if (!recycling && !preserveRouting && !currentExternalCodexModelProvider()) { + if (teardown.restoreNativeCodex && !currentExternalCodexModelProvider()) { try { const restored = restoreNativeCodex(); if (!restored.success) { @@ -625,7 +612,7 @@ async function handleStart(options: { block?: boolean } = {}) { // Grok fence is shared state we must not remove — that service keeps running and would be // left pointing nowhere. This guard also covers signal-driven exits, which is the path that // would otherwise bypass handleStop's gate entirely. - if (!recycling && !preserveRouting && serviceEnvironmentOwnedHere()) { + if (teardown.stripGrokConfig && serviceEnvironmentOwnedHere()) { try { stripGrokConfig(); } catch { /* best-effort restore */ } } return cleanupSucceeded; @@ -672,8 +659,9 @@ async function handleStart(options: { block?: boolean } = {}) { // syncCleanup reverts even if injection itself or subsequent startup steps fail). const systemEnv = await injectSystemEnv(port, config).catch(() => ({ injected: false })); // The hook is useful only for an installed Claude Code CLI. Reconcile instead of - // appending unconditionally so stale OpenCodex-owned hooks are removed as well. - reportShellHookFailure(reconcileShellHook(systemEnv.injected)); + // appending unconditionally so stale OpenCodex-owned hooks are removed as well. A sibling + // skips it: reconciling to "not injected" would uninstall the live owner's ~/.zshrc hook. + if (!siblingStart) reportShellHookFailure(reconcileShellHook(systemEnv.injected)); await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start // Codex sync owns the ready/failed verdict, but its successful transition is // deferred until the best-effort Claude roster and Desktop registry settle. This @@ -709,7 +697,7 @@ async function handleStart(options: { block?: boolean } = {}) { } }, ); - if (!startupSync.ran) console.log(startupLeftCodexNativeLine(localClientSkipReason(config))); + if (!startupSync.ran) console.log(startupLeftCodexNativeLine(localClientSkipReason(config), server.port ?? port)); await refreshOwnedRaycastCatalog(config, port); // #1046: one warning per startup, after BOTH writes. The server's cache // invalidation happens first and the catalog sync second, so the mtime is only @@ -720,7 +708,7 @@ async function handleStart(options: { block?: boolean } = {}) { const { warnIfStaleCodexAppServersAfterStartupWrite } = await import("../codex/app-server-processes"); warnIfStaleCodexAppServersAfterStartupWrite({ log: console }); } - if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) { + if (!siblingStart && !currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) { historyGuardian = startHistoryMigrationGuardian(); } // Grok Build auto-registration: additive fenced block in ~/.grok/config.toml so an installed @@ -751,9 +739,9 @@ async function handleStart(options: { block?: boolean } = {}) { } function detachedStartEnvironment(): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env }; - // Only a real service wrapper may claim supervision. A detached ensure/tray child - // is an ordinary owner: while live it maintains routing, and on exit it restores it. + const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env); + // Only a real service wrapper may claim supervision. A detached ensure/tray child is an + // ordinary owner, never a sibling: while live it maintains routing, and on exit restores it. delete env.OCX_SERVICE; return withProcessRuntimeProvenance(env); } @@ -1100,6 +1088,11 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { // service — the exact failure this flag prevents. A plain stop failure is different: we // tried, so local teardown still proceeds. let ownershipBlocked = false; + // A sibling instance's shared teardown is not this stop's to run: the client routing belongs + // to the live proxy it ran beside (`src/codex/sibling-start.ts`). Read before any stop removes + // the record; a record left behind by a hard-killed sibling still says so. + const siblingOfPort = readRuntimePort()?.siblingOfPort; + const stoppingSibling = siblingOfPort !== undefined; // Structured twin of the human lines below, for `ocx stop --json`: one document the // desktop shell can read across the process boundary (D4). Every field is assigned // where the corresponding boolean already flips — the summarizer never re-decides. @@ -1137,7 +1130,7 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { .filter(read => isPendingTeardownAbandoned(read, teardownOwnerStillRunning)); let teardownNonce: string | undefined; const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => { - if (teardownNonce) return; + if (teardownNonce || stoppingSibling) return; try { teardownNonce = claimPendingTeardown(endpoint, endpointSource).nonce; } catch (err) { @@ -1201,7 +1194,9 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { settle: () => settleApprovedTarget(snapshot.approval), managerState: () => observeGuardedManagerStopped(snapshot.manager), })).service - : stopServiceIfInstalledDetailed(); + // A sibling never runs under a service manager: an installed one belongs to the live owner, + // and its ownership check would fail this stop and skip the stale-record purge below. + : stoppingSibling ? "absent" : stopServiceIfInstalledDetailed(); record.service = serviceStop; stoppedService = serviceStop === "stopped" || serviceStop === "stopped-respawnable"; schedulerCanRespawn = serviceStop === "stopped-respawnable"; @@ -1295,7 +1290,11 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { // Orphan recovery: a live proxy can outlive its pid file (crash, manual delete, // corrupt file). Identity-checked liveness still finds it via the runtime record. const live = await findLiveProxy({ acceptPackageTreeFenced: true }); - if (live?.pid) { + if (siblingStopFoundOwner(siblingOfPort, live)) { + // A hard-killed sibling's record answered nowhere and discovery reached the live owner. + record.proxy = "not-running"; + console.log(`The sibling instance is already gone; the proxy on port ${siblingOfPort} was left running.`); + } else if (live?.pid) { try { // The probe already found where it answers, and on this path the runtime record is // typically what went missing in the first place. @@ -1343,9 +1342,9 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { } } } - // Environment ownership is independent from service ownership. Always roll back - // current-home variables; the helper refuses foreign markers on its own. - try { revertSystemEnv(); } catch { /* best-effort */ } + // Environment ownership is independent from service ownership. Roll back current-home + // variables (the helper refuses foreign markers on its own) unless a sibling never set them. + if (!stoppingSibling) { try { revertSystemEnv(); } catch { /* best-effort */ } } // A stopped Windows scheduler is not a proven-down proxy. `killWindowsSchedulerWrappers` // is explicitly best-effort and the `:loop` wrapper respawns its child after ~5s, so an // immediate probe can see a dead interval and an update can start replacing files right @@ -1432,8 +1431,9 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { console.error(" The obligation is preserved; retry once the proxy is confirmed stopped."); } } - if (nativeRestoreHandledByProxy) record.sharedTeardown = "performed-by-proxy"; - const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy; + if (nativeRestoreHandledByProxy && !stoppingSibling) record.sharedTeardown = "performed-by-proxy"; + if (stoppingSibling) console.log(`↩️ Client routing stays on the proxy at port ${siblingOfPort}; this sibling had no shared teardown to run.`); + const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling; if (!restoreBlocked) { if (recoveredNonces.length > 0) { // A previous deferred stop died before restoring, and the probe says its endpoint is @@ -1974,7 +1974,7 @@ process.exit(await dispatchCommand(head, { detached: true, stdio: "ignore", windowsHide: true, - env: withProcessRuntimeProvenance(process.env), + env: withProcessRuntimeProvenance(withoutSiblingMarker(process.env)), }); child.unref(); }, diff --git a/src/cli/minimax.ts b/src/cli/minimax.ts index 69d3cfe8dfe..8d0a98c273f 100644 --- a/src/cli/minimax.ts +++ b/src/cli/minimax.ts @@ -21,6 +21,7 @@ import { isLoopbackHostname } from "../server/auth-cors"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; import { opencodeProxyStartEnv } from "./opencode"; +import { withoutSiblingMarker } from "../codex/sibling-start"; export interface MinimaxLaunchEnv { [key: string]: string | undefined; @@ -270,8 +271,9 @@ async function ensureProxy(config: OcxConfig): Promise { stdio: "ignore", windowsHide: true, // Reuse the established service-token lookup so a detached start works - // when admission lives in the hardened token file rather than this shell. - env: withProcessRuntimeProvenance(opencodeProxyStartEnv(process.env) as NodeJS.ProcessEnv), + // when admission lives in the hardened token file rather than this shell. An ordinary + // owner, so a stray sibling marker is dropped. + env: withProcessRuntimeProvenance(opencodeProxyStartEnv(withoutSiblingMarker(process.env)) as NodeJS.ProcessEnv), }); child.on("error", () => { /* the bounded health poll reports failure */ }); child.unref(); diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index e39b438a10b..d70bf56302c 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -50,6 +50,7 @@ import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-li import type { OcxConfig } from "../types"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { selfLaunchArgv } from "../lib/self-launch-argv"; +import { withoutSiblingMarker } from "../codex/sibling-start"; /** * The provider-block serializer, its constants, and the config-path helpers now live in @@ -644,7 +645,8 @@ async function ensureProxyForOpencode(config: OcxConfig): Promise { + if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage()); const initial = withClientLifecycleSync(() => withConfigMutationLockSync(() => { assertNoClientDisconnectPending(); const state = readClientConnectionState(); @@ -891,6 +896,7 @@ export async function disconnectClient( desktopRestoration?: "owned_projection" | "standard_fallback" | "selection_preserved"; restartRequired: boolean; }> { + if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage()); const keepCatalog = options.keepCatalog === true; const prepared = withClientLifecycleSync(held => withConfigMutationLockSync(() => { const read = readDesktopDisconnectReceipt(); diff --git a/src/client/runtime.ts b/src/client/runtime.ts index ce3d726a8b2..8856e113817 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import type { Server } from "bun"; +import { siblingRuntimeField, withSiblingMarker } from "../codex/sibling-start"; import { loadConfig } from "../config"; import { removePid, removeRuntimePort, writePid, writeRuntimePort } from "../config/process-state"; import { installCrashGuards } from "../lib/crash-guard"; @@ -90,7 +91,8 @@ async function recycleStandalone(disconnectedTokenFingerprint: string): Promise< detached: true, stdio: "ignore", windowsHide: true, - env: standaloneRecycleEnv(process.env, disconnectedTokenFingerprint), + // A sibling's replacement stays a sibling even if the owner is down while it probes. + env: withSiblingMarker(standaloneRecycleEnv(process.env, disconnectedTokenFingerprint)), }); child.unref(); } @@ -134,7 +136,7 @@ export async function startClientRuntime( supervisor?.start(); installCrashGuards(); writePid(process.pid); - writeRuntimePort({ pid: process.pid, port: boundPort, hostname: "127.0.0.1" }); + writeRuntimePort({ pid: process.pid, port: boundPort, hostname: "127.0.0.1", ...siblingRuntimeField() }); let shuttingDown = false; const shutdown = () => { diff --git a/src/codex/codex-write-lock.ts b/src/codex/codex-write-lock.ts index 486d1d30e2d..3509f7201a6 100644 --- a/src/codex/codex-write-lock.ts +++ b/src/codex/codex-write-lock.ts @@ -144,9 +144,9 @@ export interface CodexWriteCommitContext { * * `hub-gated` is not the user's switch: a hub declines to rewrite its own local clients, and * reporting that as "integration is OFF" sent operators hunting for a toggle they never set - * (#4236). + * (#4236). `sibling` is a second instance beside a live proxy that owns the routing. */ -export type CodexWriteLockSkipReason = "desired_disabled" | "desired_enabled" | "hub-gated"; +export type CodexWriteLockSkipReason = "desired_disabled" | "desired_enabled" | "hub-gated" | "sibling"; /** A synchronous under-lock policy re-read proved the requested apply stale. */ export class CodexWriteLockSkipped extends Error { diff --git a/src/codex/desired-state.ts b/src/codex/desired-state.ts index a2cc3a96b7d..c9b29d083c8 100644 --- a/src/codex/desired-state.ts +++ b/src/codex/desired-state.ts @@ -23,6 +23,7 @@ import { deleteConfigTopLevelKey, loadConfig, mutatePersistedConfig } from "../config"; import type { OcxClientIntegrationsConfig, OcxConfig } from "../types"; import { runStartupReadinessSync, type ReadinessGate, type SyncOutcomeLike } from "../server/readiness"; +import { siblingOfLivePort, siblingSkipMessage } from "./sibling-start"; /** Clients whose durable intent this module owns. */ export type DurableIntentClientId = keyof OcxClientIntegrationsConfig; @@ -77,6 +78,8 @@ type LocalClientSyncConfig = Pick< >; export function localClientSyncAllowed(config: LocalClientSyncConfig): boolean { + // A sibling instance never writes the live owner's client routing (`sibling-start.ts`). + if (siblingOfLivePort() !== null) return false; return config.runtimeRole !== "hub" || config.unauthenticatedLoopbackListener?.enabled === true; } @@ -93,8 +96,11 @@ export const HUB_GATED_SKIP_MESSAGE = "This machine is a hub; it does not rewrite its own Codex/Grok/Claude configs unless " + "unauthenticatedLoopbackListener is enabled."; -/** Why a local-client write was skipped. The gate outranks the toggle: it is the surprising one. */ -export type LocalClientSkipReason = "desired_disabled" | "hub-gated"; +/** + * Why a local-client write was skipped. The gate outranks the toggle: it is the surprising one. + * `sibling` outranks both: this process is a second instance beside a live owner. + */ +export type LocalClientSkipReason = "desired_disabled" | "hub-gated" | "sibling"; /** * "hub-gated" is claimed only when the toggle is ON and the gate is what stopped the write. @@ -106,6 +112,7 @@ export function localClientSkipReason( config: LocalClientSyncConfig, client: DurableIntentClientId = "codex", ): LocalClientSkipReason { + if (siblingOfLivePort() !== null) return "sibling"; return integrationEnabled(config, client) && !localClientSyncAllowed(config) ? "hub-gated" : "desired_disabled"; @@ -123,7 +130,9 @@ export function localClientSkipMessage( hubSuffix?: string, client: DurableIntentClientId = "codex", ): string { - if (localClientSkipReason(config, client) !== "hub-gated") return integrationOffMessage; + const reason = localClientSkipReason(config, client); + if (reason === "sibling") return siblingSkipMessage(); + if (reason !== "hub-gated") return integrationOffMessage; return hubSuffix ? `${HUB_GATED_SKIP_MESSAGE} ${hubSuffix}` : HUB_GATED_SKIP_MESSAGE; } @@ -134,7 +143,8 @@ export function shouldSyncCodexOnStart(config: LocalClientSyncConfig): boolean { // /readyz failed because it tried to run the full local client sync). // A hub can be a local client only through its explicitly enabled loopback // listener. The public hub bind remains outside this gate and still requires - // admission; an explicit client OFF continues to win. + // admission; an explicit client OFF continues to win. A sibling instance is closed by the + // same gate: the live owner keeps the routing it maintains. return localClientSyncAllowed(config) && codexIntegrationEnabled(config); } diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 85b1e9b6629..7b3c67a2abc 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -10,6 +10,7 @@ import { existsSync, lstatSync, readFileSync } from "node:fs"; import { atomicWriteFile } from "../config"; import type { CodexWriteLockResult, CodexWriteLockSkipReason } from "./codex-write-lock"; import { HUB_GATED_SKIP_MESSAGE } from "./desired-state"; +import { siblingSkipMessage } from "./sibling-start"; import { inspectCodexCoordinatorPath } from "./coordinator-doctor"; import { JOURNAL_PATH } from "./journal"; import { updateIntegrationRecord } from "./integration-record"; @@ -452,10 +453,12 @@ export function codexInjectLockOutcome( success: true, status: "skipped", skippedReason: result.reason, - // Three distinct facts, three sentences. The hub gate in particular must not borrow the + // Four distinct facts, four sentences. The hub gate in particular must not borrow the // toggle's wording — that is the phantom "integration is OFF" report from #4236. message: result.reason === "hub-gated" ? `${HUB_GATED_SKIP_MESSAGE} No Codex config, catalog, cache, or history was changed.` + : result.reason === "sibling" + ? `${siblingSkipMessage()} No Codex config, catalog, cache, or history was changed.` : result.reason === "desired_disabled" ? "Codex integration is OFF; no Codex config, catalog, cache, or history was changed." : "Codex integration was re-enabled; native restore was skipped.", diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 4b020b92628..fa2d117407b 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -12,7 +12,9 @@ import { localClientSkipMessage, localClientSkipReason, shouldSyncCodexOnStart, + type LocalClientSkipReason, } from "./desired-state"; +import { siblingOfLivePort, siblingSkipMessage } from "./sibling-start"; import { resolveCodexHistoryTransition } from "./history-transition"; import { buildInjectWitness, @@ -147,7 +149,7 @@ export interface CodexInjectResult { /** Busy write lock, emitted by `codexInjectLockOutcome` and undeclared here until #4809. */ retryable?: boolean; /** `hub-gated` is the hub-role gate (#4236), distinct from the user's own OFF switch. */ - skippedReason?: "desired_disabled" | "desired_enabled" | "hub-gated"; + skippedReason?: LocalClientSkipReason | "desired_enabled"; nativeSubagentDefaultsWarning?: string; } @@ -184,6 +186,11 @@ export async function injectCodexConfig( config?: OcxConfig, options: InjectCodexOptions = {}, ): Promise { + // First, before the external-provider branch below removes the SHARED journal: a sibling owns + // none of this home's routing, not even the courtesy cleanup. + if (siblingOfLivePort() !== null) { + return { success: true, status: "skipped", skippedReason: "sibling", message: siblingSkipMessage() }; + } try { return await injectCodexConfigImpl(port, config, options); } catch (error) { if (error instanceof CodexHistoryPreflightRefusal) return { success: false, historyPreflightFailureReason: error.message, message: `Codex config injection refused: ${error.message}. Existing configuration and history were preserved.` }; diff --git a/src/codex/inject/restore.ts b/src/codex/inject/restore.ts index e5815f09123..42b357f85ed 100644 --- a/src/codex/inject/restore.ts +++ b/src/codex/inject/restore.ts @@ -1,5 +1,6 @@ import { loadConfig } from "../../config"; import { shouldSyncCodexOnStart } from "../desired-state"; +import { siblingOfLivePort, siblingSkipMessage } from "../sibling-start"; import { withCatalogWriteSerialization } from "../catalog-write-serialization"; import { restoreCodexCatalogWithPermit } from "../catalog/sync"; import { withCodexWriteLock, CodexWriteLockSkipped } from "../codex-write-lock"; @@ -253,6 +254,18 @@ function desiredEnabledRestoreSkip(): CodexNativeRestoreResult { return skippedRestoreEnvelope(true, message); } +/** + * A sibling instance restores nothing: the journal, config and catalog are the live owner's. + * + * It has to be checked before anything else, not left to the desired-state re-reads below. Those + * skip a restore when `shouldSyncCodexOnStart` says ON, and the sibling mark makes that gate + * answer OFF, so without this the gate would read as "the user turned Codex off" and the restore + * would replay the owner's journal. + */ +function siblingRestoreSkip(): CodexNativeRestoreResult | null { + return siblingOfLivePort() === null ? null : skippedRestoreEnvelope(true, siblingSkipMessage()); +} + /** * A schema-complete all-skipped envelope for outcomes decided before any * restore machinery runs. Every `restore --json` path must stay shape-stable @@ -471,6 +484,8 @@ function restoreCodexCatalogArtifact( export async function restoreNativeCodexAsync( options: { revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {}, ): Promise { + const sibling = siblingRestoreSkip(); + if (sibling) return sibling; try { return await restoreNativeCodexAsyncImpl(options); } catch (error) { @@ -669,6 +684,8 @@ async function restoreNativeCodexAsyncImpl( export function restoreNativeCodex( options: { skipHistory?: boolean; revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {}, ): CodexNativeRestoreResult { + const sibling = siblingRestoreSkip(); + if (sibling) return sibling; const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { removeJournal(); diff --git a/src/codex/management-convergence.ts b/src/codex/management-convergence.ts index 16035236228..4612a902ac6 100644 --- a/src/codex/management-convergence.ts +++ b/src/codex/management-convergence.ts @@ -2,6 +2,7 @@ import type { OcxConfig } from "../types"; import { resolvePendingInitialModelSelection } from "../providers/initial-model-selection-runtime"; import { captureCatalogAdmissionSnapshot } from "./catalog-admission"; import { convergeCodexCatalog } from "./convergence"; +import { siblingOfLivePort } from "./sibling-start"; import type { CatalogDisposition, CatalogFailureCause, @@ -155,6 +156,14 @@ export function createManagementConvergeCodex( } // Registration choices are committed independently, before sealing catalog authority. await resolvePendingInitialModelSelection(retainedConfig as OcxConfig); + // The catalog and models cache live in the shared `CODEX_HOME`; a sibling instance leaves + // them to the live owner. This funnel serves every management caller and the auto-refresh tick. + if (siblingOfLivePort() !== null) { + return projectCatalogOnlyOutcome({ + changed: false, + catalogRefresh: { status: "skipped", reason: "refused", retryable: false }, + }); + } const snapshot = captureCatalogAdmissionSnapshot(retainedConfig); const result = await convergeCodexCatalog(snapshot, request, { onCommitBegin: () => { commitBegan = true; }, diff --git a/src/codex/sibling-start.ts b/src/codex/sibling-start.ts new file mode 100644 index 00000000000..159be0014d3 --- /dev/null +++ b/src/codex/sibling-start.ts @@ -0,0 +1,133 @@ +/** + * The process-local "sibling instance" mark. + * + * A sibling is what `decideStartWithLiveOwner` calls `"sibling"`: `ocx start --port ` while a + * live proxy already serves the configured port. Only an independent `OPENCODEX_HOME` gets past the + * state-directory spend-ledger lease, and that independence ends at its own directory: the sibling + * still shares `CODEX_HOME`, `~/.claude`, `~/.grok`, the shell rc hook and the launchd domain with + * the live owner. A sibling that ran the ordinary startup sync therefore re-pointed Codex at its own + * port, and every thread broke with "Connection refused" once the sibling exited or was killed. The + * owner's client routing is the owner's, for this process's whole lifetime. + * + * So `handleStart` sets this mark the moment it takes the sibling path, before the server binds and + * before any client write, and the local-client gate (`localClientSyncAllowed` in + * `desired-state.ts`), the restore entry points, the catalog convergence funnel, the owned-catalog + * refresh, the Claude writers, the stop teardown and the management route guard all consult it. The + * sibling still serves direct requests on its own port. + * + * One-way and process-local on purpose. A mark that could be cleared would let a later code path + * resume writing mid-lifetime, and a persisted one would outlive the process it describes. Worker + * threads do not see it; that is acceptable because history jobs start only from inject and + * restore, and both return before spawning one. + * + * A sibling runs the no-op native-main lifecycle (the gate is closed), so it never contends for the + * native-main owner lease and cannot take ownership from the live proxy across that proxy's + * restarts. Its data-plane native-main admission is the same as a Codex-OFF proxy's: an `auth.json` + * refresh still runs under the machine-wide exclusive claim. The native-main mutation routes are + * refused by the management guard instead. + * + * The one hand-off is to this process's own replacement. A sibling's dashboard drain-and-restart and + * its standalone recycle spawn a fresh `ocx start` that re-probes; if the owner is down for that + * moment, the replacement used to start as an unmarked owner, re-point Codex at itself and persist + * `config.port`. So those spawns carry {@link SIBLING_OF_PORT_ENV} ({@link withSiblingMarker}), and + * `handleStart` honors it before any probe ({@link honorSiblingMarker}) and consumes it, so no other + * child of the replacement inherits it. + * + * Deliberately import-free: the gate, the server and the CLI all read it, and a leaf cannot form a + * cycle. + */ + +let livePort: number | null = null; + +/** Mark this process as a sibling of the proxy serving `port`. Idempotent; never unset. */ +export function markSiblingStart(port: number): void { + livePort = port; +} + +/** The live owner's port when this process is a sibling, otherwise `null`. */ +export function siblingOfLivePort(): number | null { + return livePort; +} + +/** Test seam only: production never clears the mark. */ +export function resetSiblingStartForTests(): void { + livePort = null; +} + +/** + * The one line a sibling prints wherever it declines a shared client write. + * + * With the sibling's own port it describes the split; without one (a skip deep inside a writer + * that does not know the port) it names what was left alone. + */ +export function siblingSkipMessage(ownPort?: number): string { + const live = livePort ?? "unknown"; + return ownPort === undefined + ? `Client routing stays on the proxy at port ${live}; this instance does not rewrite Codex, Grok or Claude configs.` + : `Client routing stays on the proxy at port ${live}; this instance serves direct requests on port ${ownPort} only.`; +} + +/** + * The optional runtime-record field that tells a later `ocx stop` it is stopping a sibling. + * Empty when unmarked, so a non-sibling record stays byte-identical. + */ +export function siblingRuntimeField(): { siblingOfPort?: number } { + return livePort === null ? {} : { siblingOfPort: livePort }; +} + +/** The env var a sibling hands its own replacement `ocx start`: the live owner's port. */ +export const SIBLING_OF_PORT_ENV = "OCX_SIBLING_OF_PORT"; + +type Env = Record; + +/** The owner port an inherited marker names, or `null` when absent or not a TCP port. */ +export function parseSiblingMarker(raw: string | undefined): number | null { + const trimmed = raw?.trim() ?? ""; + if (!/^[0-9]{1,5}$/.test(trimmed)) return null; + const port = Number(trimmed); + return port >= 1 && port <= 65535 ? port : null; +} + +/** + * Honor an inherited marker at the top of `handleStart`, before any probe decides ownership: a + * valid one marks this process whether or not the owner answers right now. The marker is removed + * from `env` either way, so only {@link withSiblingMarker} ever hands it on. Returns the owner port + * when this call marked the process. + */ +export function honorSiblingMarker(env: Env): number | null { + const port = parseSiblingMarker(env[SIBLING_OF_PORT_ENV]); + delete env[SIBLING_OF_PORT_ENV]; + if (port !== null) markSiblingStart(port); + return port; +} + +/** A copy of `env` for this process's own replacement: the marker exactly when this is a sibling. */ +export function withSiblingMarker(env: T): T { + const next: Env = withoutSiblingMarker(env); + if (livePort !== null) next[SIBLING_OF_PORT_ENV] = String(livePort); + return next as T; +} + +/** A copy of `env` for a child that must start as an ordinary owner (`ocx ensure`, the tray). */ +export function withoutSiblingMarker(env: T): T { + const next: Env = { ...env }; + delete next[SIBLING_OF_PORT_ENV]; + return next as T; +} + +/** + * Whether `ocx stop` of a sibling runtime found the live owner instead of the sibling. + * + * A hard-killed sibling leaves its record behind with a dead pid, so the stop falls through to + * discovery, and discovery ends on the configured port, where the live owner answers. Stopping that + * proxy is the outage the sibling design exists to prevent. The found proxy is the owner when it + * answers on `siblingOfPort`, or when only the configured-port fallback reached it (the sibling's + * own record did not answer). `live` is `findLiveProxy`'s result. + */ +export function siblingStopFoundOwner( + siblingOfPort: number | undefined, + live: { port: number; source: "runtime" | "config" } | null, +): boolean { + if (siblingOfPort === undefined || live === null) return false; + return live.port === siblingOfPort || live.source === "config"; +} diff --git a/src/codex/sync.ts b/src/codex/sync.ts index a49112c9648..87b336eea62 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -284,9 +284,11 @@ export async function syncModelsToCodex( if (result.status === "skipped") { return { status: "skipped", - // The apply direction's only under-lock policy skips are desired OFF and the hub gate; - // carry whichever the injector reported so the caller can say the honest thing. - skippedReason: result.skippedReason === "hub-gated" ? "hub-gated" : "desired_disabled", + // The apply direction's only policy skips are desired OFF, the hub gate and a sibling + // instance; carry whichever the injector reported so the caller can say the honest thing. + skippedReason: result.skippedReason === "hub-gated" || result.skippedReason === "sibling" + ? result.skippedReason + : "desired_disabled", ok: true, added: 0, catalogPath: null, diff --git a/src/config/process-state.ts b/src/config/process-state.ts index 79d40a0ffc3..cb98672acd4 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -36,6 +36,12 @@ export type RuntimePortState = { hostname?: string; /** Per-process proof key; protected by the config directory and never served. */ attestationSecret?: string; + /** + * The live proxy's port when this runtime is a sibling instance started beside it + * (`src/codex/sibling-start.ts`). `ocx stop` reads it to leave shared client routing alone. + * Absent for every other runtime, so those records keep their bytes. + */ + siblingOfPort?: number; }; function isValidRuntimePortState(value: unknown): value is RuntimePortState { @@ -43,13 +49,16 @@ function isValidRuntimePortState(value: unknown): value is RuntimePortState { const state = value as Record; const hostnameOk = state.hostname === undefined || typeof state.hostname === "string"; const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret); + const siblingOk = state.siblingOfPort === undefined + || (Number.isInteger(state.siblingOfPort) && Number(state.siblingOfPort) > 0 && Number(state.siblingOfPort) <= 65535); return Number.isSafeInteger(state.pid) && Number(state.pid) > 0 && Number.isInteger(state.port) && Number(state.port) > 0 && Number(state.port) <= 65535 && hostnameOk - && attestationOk; + && attestationOk + && siblingOk; } export function writeRuntimePort(state: RuntimePortState): void { diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 45e9b7a97b5..9f17ed6be2b 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -1,3 +1,4 @@ +import { siblingOfLivePort } from "../codex/sibling-start"; import { redactSecretString } from "../lib/redact"; import type { ExportModel } from "../clients/config-export"; import type { IntegrationClientId } from "./registry"; @@ -12,6 +13,9 @@ export async function refreshOwnedCatalogIntegrations( input: Omit, clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo"], ): Promise { + // Client files are shared with the live proxy a sibling instance runs beside; their entries + // point at the owner's port, and refreshing them here would re-point them at this one. + if (siblingOfLivePort() !== null) return []; let models: Promise | undefined; const loadModels = () => models ??= Promise.resolve().then(() => typeof input.models === "function" ? input.models() : input.models); diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index 7c5bfbc115a..34cbd90f58b 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -261,11 +261,14 @@ async function stopProxyGracefullyDetailed( if (!res.ok) return done(false); const body: unknown = await res.json().catch(() => null); const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed"; + // A sibling instance answers `not-owned`: it has no shared teardown to perform, so there is + // nothing left over for this caller either. A deferral it was sent is still not confirmed. sharedTeardownConfirmed = body !== null && typeof body === "object" && !Array.isArray(body) && "success" in body && body.success === true - && "sharedTeardown" in body && body.sharedTeardown === expectedTeardown; + && "sharedTeardown" in body && (body.sharedTeardown === expectedTeardown + || (!io.deferSharedTeardownNonce && body.sharedTeardown === "not-owned")); } catch { return done(false); } diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 6d2b87567ec..13c4a8fcf48 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -88,6 +88,8 @@ import type { CatalogDisposition, ConvergeCodex } from "../codex/convergence-typ import { normalizeCatalogDisposition } from "../codex/catalog-refresh-status"; import { managementBodyTooLargeResponse } from "./management/body"; import { handleSessionRoutes } from "./management/session-routes"; +import { siblingRefusesManagementRequest } from "./management/sibling-guard"; +import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start"; import { packageVersion } from "../lib/package-version"; import { isLocalAccountSwitchPath } from "../lib/local-account-switch-capability"; import { readVerifiedAccountSwitchBody } from "./local-account-switch-auth"; @@ -312,6 +314,10 @@ export async function handleManagementAPI( guiSessionIssuance: requestIngress.guiSessionIssuance ?? null, convergeCodexCatalog, syncClaudeAgentDefsBestEffort, }; + // Before any route module, including the link, native-main and codex-auth dispatch below. + if (siblingRefusesManagementRequest(req.method, url.pathname)) { + return jsonResponse({ error: siblingSkipMessage(), code: "sibling_instance" }, 409, req, config); + } let routed: Response | null | undefined; try { routed = handleSessionRoutes(ctx) @@ -381,7 +387,10 @@ export async function handleManagementAPI( const { deferralMatchesReceipt } = await import("../config/pending-teardown"); const { deferralHonored, performStopTeardown } = await import("./stop-teardown"); const holdsReceipt = deferralHonored(url, deferralMatchesReceipt); - const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk(); + // A sibling never runs under a service manager, and the installed service is the live + // owner's: asking the manager to stop from here would refuse, or boot the owner's job out. + const sibling = siblingOfLivePort() !== null; + const respawnRisk = holdsReceipt || sibling ? "none" : installedServiceRespawnRisk(); if (respawnRisk === "respawnable") { return jsonResponse({ success: false, @@ -415,7 +424,7 @@ export async function handleManagementAPI( } let serviceStop: import("../service").ServiceStopOutcome; try { - serviceStop = stopServiceIfInstalledDetailed(); + serviceStop = sibling ? "absent" : stopServiceIfInstalledDetailed(); } catch (err) { if (isServiceOwnershipError(err)) { // The installed service belongs to another CODEX_HOME/OPENCODEX_HOME: it would respawn diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index d439abb137c..ef326a768e6 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -69,6 +69,7 @@ import { initializeDefaultCodexAccountNamespaces, } from "../../codex/account-namespaces"; import { catalogRefreshIsPending } from "../../codex/catalog-refresh-status"; +import { siblingOfLivePort } from "../../codex/sibling-start"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; import { readUsageEntries } from "../../usage/log"; @@ -196,7 +197,9 @@ export async function syncEnabledClientIntegrations( deps: Pick = {}, ): Promise { - if (port === undefined) return []; + // A sibling instance passes its OWN port here; the Grok fence and the Desktop gateway profile + // stay on the live owner's (`src/codex/sibling-start.ts`). + if (port === undefined || siblingOfLivePort() !== null) return []; const { claudeDesktopIntegrationEnabled, grokIntegrationEnabled } = await import("../../codex/desired-state"); const out: ClientIntegrationSyncOutcome[] = []; diff --git a/src/server/management/sibling-guard.ts b/src/server/management/sibling-guard.ts new file mode 100644 index 00000000000..7c2f46473c8 --- /dev/null +++ b/src/server/management/sibling-guard.ts @@ -0,0 +1,60 @@ +import { siblingOfLivePort } from "../../codex/sibling-start"; +import { CODEX_RESTART_PATH } from "../../lib/codex-restart-contract"; + +/** + * Management mutations a sibling instance refuses (`src/codex/sibling-start.ts`). + * + * The owner-level gates stop the writers a sibling reaches on its own: startup sync, catalog + * convergence, owned-catalog refresh, the Claude roster, system env and the stop teardown. These + * routes reach shared client state through paths those gates do not cover, so the sibling's own + * management API refuses them outright: toggling or stripping the live owner's Codex, Grok or Claude + * integrations, rewriting client files at this port, the Codex prompt layers and `[features]` + * written into the shared `config.toml`, killing the user's Codex app-servers, joining a link (which + * writes the shared catalog and restarts as a client), staging or switching the physical native-main + * login, baking the machine service, PATH shim or tray from this home, replacing the global package + * (the live proxy drains on its package-tree refresh), Codex's own log database, and the + * archived-session cleanup, cleanup-policy run and trash restore that rewrite the shared CODEX_HOME. + * + * Everything else stays open: every read, `POST /api/stop`, `POST /api/system/restart`, and the + * own-home mutations (providers, keys, settings). `PUT /api/settings` is allowed because it carries + * own-home settings; its shared fan-out is gated in `syncEnabledClientIntegrations`. So is + * `PUT /api/storage/cleanup-policy`; the scheduled runs it enables stand down in + * `maybeRequestStorageCleanupPolicyRun` (`src/storage/policy-job.ts`). + * + * Paths are compared through this table and a variable, never as a quoted pathname equality: + * `tests/helpers/management-route-scan.ts` reads that form as a route declaration, and this file + * declares no route of its own. + */ +export const SIBLING_REFUSED_MANAGEMENT_PATHS: readonly { readonly path: string; readonly children: boolean }[] = [ + { path: "/api/sync", children: false }, + { path: "/api/client-integrations", children: true }, + { path: "/api/native-integrations", children: true }, + { path: "/api/claude-desktop", children: true }, + { path: "/api/claude-code", children: false }, + { path: "/api/grok/apply", children: false }, + { path: "/api/grok/selection", children: false }, + { path: "/api/codex-prompt", children: true }, + { path: CODEX_RESTART_PATH, children: false }, + { path: "/api/link/join", children: false }, + { path: "/api/native-main-profiles", children: true }, + { path: "/api/codex-auth/main", children: true }, + { path: "/api/startup-action", children: false }, + { path: "/api/windows-tray", children: false }, + { path: "/api/update/run", children: false }, + { path: "/api/v2", children: false }, + { path: "/api/codex-auth/features", children: true }, + { path: "/api/storage/codex-logs", children: true }, + // Archived-session cleanup, its policy run and trash restore rewrite the shared CODEX_HOME. + { path: "/api/storage/cleanup", children: false }, + { path: "/api/storage/cleanup-policy/run", children: false }, + { path: "/api/storage/trash/restore", children: false }, +]; + +const READ_METHODS: ReadonlySet = new Set(["GET", "HEAD"]); + +/** True when this process is a sibling and the request would mutate shared client state. */ +export function siblingRefusesManagementRequest(method: string, pathname: string): boolean { + if (siblingOfLivePort() === null || READ_METHODS.has(method.toUpperCase())) return false; + return SIBLING_REFUSED_MANAGEMENT_PATHS.some(entry => + pathname === entry.path || (entry.children && pathname.startsWith(`${entry.path}/`))); +} diff --git a/src/server/management/system-restart.ts b/src/server/management/system-restart.ts index 76708630943..98c32b59c87 100644 --- a/src/server/management/system-restart.ts +++ b/src/server/management/system-restart.ts @@ -34,6 +34,7 @@ import { stopServerListener, } from "../lifecycle"; import { isServiceViable } from "../../service"; +import { withSiblingMarker } from "../../codex/sibling-start"; import { readRuntimePort } from "../../config/process-state"; import { withProcessRuntimeProvenance } from "../../lib/bun-runtime"; import { selfLaunchArgv } from "../../lib/self-launch-argv"; @@ -246,7 +247,8 @@ function spawnDetachedStart( return new Promise((resolve, reject) => { let child: ReturnType; try { - const sourceEnv: NodeJS.ProcessEnv = { ...process.env }; + // A sibling's replacement stays a sibling even if the owner is down while it probes. + const sourceEnv: NodeJS.ProcessEnv = withSiblingMarker(process.env); delete sourceEnv.OCX_SERVICE; const env = spendLedgerRestartEnvironment( sourceEnv, diff --git a/src/server/stop-teardown.ts b/src/server/stop-teardown.ts index a386b8ff863..85447a43378 100644 --- a/src/server/stop-teardown.ts +++ b/src/server/stop-teardown.ts @@ -1,4 +1,5 @@ import type { CodexNativeRestoreResult } from "../codex/inject"; +import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start"; import { deferralMatchesReceipt } from "../config/pending-teardown"; /** @@ -22,7 +23,8 @@ export type StopTeardownIo = { export type StopTeardownBody = { success: boolean; message: string; - sharedTeardown: "deferred" | "performed"; + /** `not-owned`: a sibling instance, whose shared client routing belongs to the live owner. */ + sharedTeardown: "deferred" | "performed" | "not-owned"; }; /** @@ -45,6 +47,11 @@ export function deferralHonored(url: URL, ownsReceipt: (nonce: string | null) => /** Run (or skip) the shared teardown and describe the outcome truthfully. */ export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Promise { + // Before the deferral check: a sibling owns no shared teardown to perform OR to hand over. + // Restoring here would replay the live owner's journal and strip its Grok fence. + if (siblingOfLivePort() !== null) { + return { success: true, message: `Proxy stopping. ${siblingSkipMessage()}`, sharedTeardown: "not-owned" }; + } const ownsReceipt = io.ownsReceipt ?? deferralMatchesReceipt; if (deferralHonored(url, ownsReceipt)) { // Not "native Codex restored": nothing was restored here, and claiming otherwise diff --git a/src/server/system-env.ts b/src/server/system-env.ts index ee70f032bac..215f8bf51c7 100644 --- a/src/server/system-env.ts +++ b/src/server/system-env.ts @@ -11,6 +11,7 @@ import { localAdmissionToken, localInferenceDestination } from "../lib/local-des import { probeHostname } from "./proxy-liveness"; import { providerContextCap } from "../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { siblingOfLivePort } from "../codex/sibling-start"; export { getShellEnvFilePath, installShellHook, uninstallShellHook, claudeCodeCliInstalled, reconcileShellHook } from "./system-env-shell"; export type { SystemEnvDeps } from "./system-env-shell"; import { systemEnvMarkerMode, writeShellEnvFile, removeShellEnvFile } from "./system-env-shell"; @@ -196,6 +197,8 @@ export async function injectSystemEnv( config: OcxConfig, deps: SystemEnvDeps = {}, ): Promise { + // The launchd domain is machine-wide; a sibling instance leaves it to the live owner. + if (siblingOfLivePort() !== null) return { injected: false, reason: "sibling instance" }; if (process.platform !== "darwin") return { injected: false, reason: "not macOS" }; if (config.claudeCode?.enabled === false) return { injected: false, reason: "claude disabled" }; diff --git a/src/storage/policy-job.ts b/src/storage/policy-job.ts index dd03a9e76a1..419c1d3005c 100644 --- a/src/storage/policy-job.ts +++ b/src/storage/policy-job.ts @@ -8,6 +8,7 @@ import type { CleanupMode, CleanupResult } from "./cleanup"; import { spawnWorker } from "../lib/worker-embed"; import { resolveCodexHomeDir } from "../codex/home"; +import { siblingOfLivePort } from "../codex/sibling-start"; import { tryBeginStorageMutation, } from "./storage-mutation-coordinator"; @@ -452,11 +453,14 @@ export function requestStorageCleanupPolicyRun( /** * Fire-and-forget entry for startup / schedule ticks. * Skips the single-flight slot when disabled or not due so manual runs stay free. + * A sibling instance never runs one: the archived sessions belong to the shared CODEX_HOME the + * live owner serves (`src/codex/sibling-start.ts`), and the manual run route is refused there too. */ export function maybeRequestStorageCleanupPolicyRun( reason: PolicyRunReason, opts?: Omit, ): void { + if (siblingOfLivePort() !== null) return; try { const policy = readStorageCleanupPolicyFromConfig(); if (!policy.enabled) return; diff --git a/src/update/index.ts b/src/update/index.ts index 15ee935db62..098dcde03fd 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -38,6 +38,7 @@ import { } from "./npm-cache-preflight.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { withoutSiblingMarker } from "../codex/sibling-start"; import { packageVersion } from "../lib/package-version"; import { selfLaunchArgv } from "../lib/self-launch-argv"; @@ -529,7 +530,8 @@ export async function runUpdate(): Promise { }; const startProxyDirectly = async (): Promise => { if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) return false; - const env = mutation.controlEnvironment(); + // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. + const env = mutation.controlEnvironment(withoutSiblingMarker(process.env)); delete env.OCX_SERVICE; const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], { detached: true, stdio: "ignore", windowsHide: true, env: withProcessRuntimeProvenance(env), diff --git a/src/update/job.ts b/src/update/job.ts index bba2483f093..fd160f8d568 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -61,6 +61,7 @@ import { type NpmCachePreflightReason, } from "./npm-cache-preflight.mjs"; import { guiUpdateWorkerCommand } from "./worker-launch"; +import { withoutSiblingMarker } from "../codex/sibling-start"; const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; const UPDATE_JOB_FILENAME = "update-job.json"; @@ -934,7 +935,8 @@ function spawnDetachedStart( launcher = packageLauncherPath(), ): ChildProcess { const cmd = restartCommand(false, installer, launcher, port); - const env = { ...process.env }; + // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. + const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env); delete env.OCX_SERVICE; updateJob(job, {}, `$ ${cmd.display}`); let stdio: "ignore" | [ "ignore", number, number ] = "ignore"; diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 7fa2693795e..9a07703610f 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -143,6 +143,9 @@ fan-out loads the filtered roster lazily once, leaves unowned clients alone, and refusal independently. Existing coordinated writers retain all no-clobber and ownership checks. Implicit refresh operations use distinct flight keys: overlapping desired catalogs return busy rather than joining a write of a different catalog and reporting false success. +On a sibling instance ([Codex home](../codex-home.md#codex-home)) `src/integrations/catalog-refresh.ts` +and `syncEnabledClientIntegrations` in `src/server/management/config-routes.ts` refresh nothing: the +client files name the live owner's port, and a refresh from the sibling would re-point them at its own. ## Fast model selectors diff --git a/structure/codex-home.md b/structure/codex-home.md index 808e0bb6b8a..c0a2df1002a 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -141,6 +141,35 @@ cannot leave the process fenced for the servers that follow it; an entry created the same home arms its own gate, and the retired generation's late convergence writes are ignored. `tests/codex-integration/native-profile-startup-release.test.ts` pins that ordering. +A sibling instance — `ocx start --port ` while a live proxy serves the configured port, the +`"sibling"` outcome of `decideStartWithLiveOwner` in `src/cli/dispatch.ts` — gets past the spend-ledger +lease only with its own `OPENCODEX_HOME`, and still shares this Codex home, `~/.claude`, `~/.grok` and +the launchd domain with the live owner. `handleStart` marks the process through +`src/codex/sibling-start.ts` before the server binds, and the mark is one-way for the process's +lifetime. It closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason +`sibling`, so startup sync, cache invalidation, Grok, the retained catalog writers and the native-main +lifecycle stand down (the sibling runs the no-op lifecycle, so it never contends for the owner lease; +its data-plane `auth.json` refresh still runs under the machine-wide exclusive claim). Owner-level +checks cover what the gate reads backwards or never reaches: both restore entry points and the +injector return before their external-provider journal cleanup, the management catalog funnel, +`src/integrations/catalog-refresh.ts`, `connectClient`/`syncConnectedClient`/`disconnectClient`, +the Claude roster and system env refuse, the exit teardown comes from `decideStartExitTeardown`, and +`POST /api/stop` answers `sharedTeardown: "not-owned"` without touching the service manager. The guard refuses the native-main +profile and reauth routes among the others listed in +[`gui-and-management-api.md`](gui-and-management-api.md#api-ownership). The runtime record carries +`siblingOfPort`, and `ocx stop` of such a runtime, live or left behind by a hard kill, claims no +receipt, runs no shared teardown, does not revert the system env and does not ask the service +manager: a sibling never runs under one, so an installed service is the live owner's. When the +recorded sibling no longer answers and discovery reaches the owner instead (`siblingStopFoundOwner`), +the stop leaves that proxy running, clears the stale sibling records and exits 0. The sibling's own +drain-and-restart (`src/server/management/system-restart.ts`) and standalone recycle +(`src/client/runtime.ts`) hand the mark to their replacement through `OCX_SIBLING_OF_PORT`; +`handleStart` honors and consumes it before any probe, so an owner that is down for that moment +cannot turn the replacement into an owner, and the journal recovery in that probe stays skipped. +Every other detached `ocx start` (`ocx ensure`, the tray, the `ocx claude`/`opencode`/`minimax` +auto-start and the updater's restart) starts an ordinary owner and strips an inherited marker +through `withoutSiblingMarker`. + The native main slot also accepts one same-identity device reauth (#3898): `/api/codex-auth/main/reauth-device` (start/status/cancel) plus `ocx account main reauth`. The grant is the OpenAI deviceauth grant already diff --git a/structure/config.md b/structure/config.md index 32dc63be3ad..c4c84efdf96 100644 --- a/structure/config.md +++ b/structure/config.md @@ -64,8 +64,8 @@ silently move the active installation. `src/config/process-state.ts` derives `ocx.pid` and `runtime-port.json` from that resolved directory. It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command -identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` remains optional, -owner-only state and is validated before a record is returned. `src/config.ts` re-exports the same +identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` and `siblingOfPort` (the live owner's port, written only by a sibling instance) remain optional, +owner-only state and are validated before a record is returned. `src/config.ts` re-exports the same symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. Replacing config and process-state writes use `src/config/atomic-write.ts`. The leaf preserves the shared diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 6f9d8bd7495..54f10f363fc 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -178,6 +178,18 @@ because they are a different plane. Upstream account response reads and OrcaRout The registered route set is larger than the areas described below; the code is the route SOT. What this document owns is which module holds which area and what invariant that area must not break. +On a sibling instance ([Codex home](codex-home.md#codex-home)), `src/server/management/sibling-guard.ts` +refuses, before any route module runs, every non-read request to the paths that reach shared client +state: `/api/sync`, `/api/client-integrations/*`, `/api/native-integrations/*`, `/api/claude-desktop/*`, +`/api/claude-code`, `/api/grok/apply`, `/api/grok/selection`, `/api/codex-prompt/*`, +`/api/system/codex-restart`, `/api/link/join`, `/api/native-main-profiles/*`, `/api/codex-auth/main/*`, +`/api/startup-action`, `/api/windows-tray`, `/api/update/run`, `/api/v2`, `/api/codex-auth/features/*`, +`/api/storage/codex-logs/*`, `/api/storage/cleanup`, `/api/storage/cleanup-policy/run` and +`/api/storage/trash/restore`. The refusal is 409 with code `sibling_instance`. Reads, `POST /api/stop`, +`POST /api/system/restart` and own-home mutations stay open; the shared fan-out behind `PUT /api/settings` +is gated in `syncEnabledClientIntegrations` instead, and the storage policy's startup and scheduled runs +stand down in `maybeRequestStorageCleanupPolicyRun` (`src/storage/policy-job.ts`). + `GET /api/native-integrations` reads the Codex, Grok and Claude Desktop desired switch states from persisted configuration because those toggles write intent independently of the server's startup config snapshot; every other field still comes from that snapshot, and without a config file the snapshot's own intent stands. The dashboard can therefore refresh a switch immediately after a successful toggle while its routing badge remains based on observed routing. The Codex row reports the state its latest toggle in this process reported while the persisted intent still matches it, so a skipped or failed enable stays `absent` and an incomplete restore stays `unsafe` instead of being re-derived from intent alone. After `PUT /api/native-integrations/claude-desktop` persists its intent, and whenever the Desktop mode marker is @@ -201,7 +213,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. `GET /api/usage/timeline` uses the same ledger and canonical attribution helpers for bounded bucketed model series. Never exposes prompts. | | Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and receives a narrow final-request fact from `src/server/request-log.ts`; `src/server/index/serve-options.ts` creates one owner and injects the recorder and read-only snapshot into the request and management paths. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; its `spendLedger` block reports only ownership held/unheld, initialized/configured/degraded booleans and bounded persistence/corruption counters. Reading it never constructs, replays or prunes the ledger. Paths, scopes, accounts and request ids are absent, and the block never moves to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | -| Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | +| Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. A sibling instance restores nothing and answers `sharedTeardown: "not-owned"` ([Codex home](codex-home.md#codex-home)). | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend (web-search union: openai/anthropic/xai/gemini/exa; xAI is live through stored Grok OAuth, while Gemini/Exa remain inert until their executors ship) plus validated `webSearch.xSearch`, optional `webSearch.exaApiKey` (write/clear only — never echoed by GET or the PUT response; redact.ts strips it from logs), `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Provider/OAuth credentials live in their stores; `exaApiKey` is the one sidecar-owned secret and follows the write-only contract above. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-6-luna` and `gpt-5.6-luna`; the retired `gpt-5.4-mini` stays available as an explicit `sourceModels` entry for 0.144.x clients), so no client hard-codes a helper slug that a Codex release can invalidate. PUT refuses a qualified target that only the router's default-provider fallback accepts. Provider disable and delete report the target they leave behind as `dependentShadowIntercept` (`shadowInterceptProviderDependency` in `src/server/management/shadow-call-validation.ts`), and at request time an unresolvable target returns `409 intercept_target_unavailable` before any send (`src/server/responses/shadow-target-availability.ts`); it never falls back to the native source model or the default provider. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | diff --git a/structure/overview.md b/structure/overview.md index 186273f8b94..6e6ef5d7c76 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -147,6 +147,10 @@ still cover the rule, which is a judgement only review makes. 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`. +- **INV-START-02** — A sibling instance (`ocx start --port ` beside a live proxy, see + [`codex-home.md`](codex-home.md#codex-home)) never writes, restores or reverts the shared client + routing: not at startup, not in its own exit cleanup, and not through an `ocx stop` of its runtime. + Enforced by `tests/cli/cli-start-journal-order.test.ts`. - **INV-FENCE-01** — A proxy fenced by the package-tree guard stays discoverable by attested identity: `/healthz` keeps answering the local attestation challenge, and liveness accepts the fenced 503 only for opted-in callers and only with a proof from the pid and port this home's runtime record diff --git a/structure/runtime.md b/structure/runtime.md index 84c3c0d7095..2a57ac09662 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -185,7 +185,7 @@ described in [OpenAI quota ownership](providers/openai-tiers.md#public-provider- `ocx start` refuses a duplicate PID, starts the proxy, writes `~/.opencodex/ocx.pid` and `runtime-port.json` through `src/config/process-state.ts`, syncs Codex config/catalog, then serves -until shutdown. Normal shutdown restores native Codex. Service mode sets +until shutdown. Normal shutdown restores native Codex; a sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. Service mode sets `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and uninstall still restore. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. The package-tree integrity fence for live package replacement follows the @@ -342,7 +342,7 @@ field for every tool. Only bare or `default.`-prefixed `exec` and `apply_patch` one recognized alternate body field or remove one complete outer Markdown fence; ambiguous alternate fields and every other freeform grammar pass through unchanged. -The server exposes `POST /api/stop` which restores native Codex config, stops any installed service +The server exposes `POST /api/stop` which restores native Codex config (a sibling instance answers `sharedTeardown: "not-owned"` instead), stops any installed service (to prevent respawn), and exits the process. The GUI sidebar stop button calls this endpoint. > Decision record: [ADR-0004](decisions/ADR-0004-lifecycle.md) diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 2f474e054c1..1c6c7e9087b 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -1,6 +1,17 @@ import { describe, expect, spyOn, test } from "bun:test"; import { CLI_COMMANDS } from "../../src/cli/registry"; -import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner, selectDefaultGuiUrl } from "../../src/cli/dispatch"; +import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCommand, decideBusyPreferredPort, decideStartExitTeardown, decideStartWithLiveOwner, selectDefaultGuiUrl, startupLeftCodexNativeLine } from "../../src/cli/dispatch"; +import { + honorSiblingMarker, + markSiblingStart, + parseSiblingMarker, + resetSiblingStartForTests, + SIBLING_OF_PORT_ENV, + siblingOfLivePort, + siblingStopFoundOwner, + withoutSiblingMarker, + withSiblingMarker, +} from "../../src/codex/sibling-start"; import type { CliDispatchDeps } from "../../src/cli/dispatch"; import type { OcxConfig } from "../../src/types"; import { runGuiCommand } from "../../src/cli/gui"; @@ -451,6 +462,186 @@ describe("a busy preferred port never becomes a second proxy (#5004)", () => { }); }); +/** + * A sibling (`ocx start --port ` beside a live proxy) shares CODEX_HOME, ~/.claude, ~/.grok + * and the launchd domain with the live owner. It used to re-point Codex at itself on startup, and + * `openai_base_url` kept naming its port after it was killed. The exit-teardown decision is pure so + * its matrix runs here; the source oracle pins that handleStart and handleStop route through the + * mark. The end-to-end proof that shared bytes survive is in cli-start-journal-order.test.ts. + */ +describe("a sibling start leaves shared client routing to the live owner", () => { + const cliSource = readFileSync(repoPath("src/cli/index.ts"), "utf8"); + const slice = (from: string, to: string): string => { + const at = cliSource.indexOf(from); + const end = cliSource.indexOf(to, at); + expect(at).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(at); + return cliSource.slice(at, end); + }; + + test("the exit-teardown matrix", () => { + const none = { revertSystemEnv: false, restoreNativeCodex: false, stripGrokConfig: false }; + const all = { revertSystemEnv: true, restoreNativeCodex: true, stripGrokConfig: true }; + // A sibling owns nothing it could tear down, under any launcher. + expect(decideStartExitTeardown({ sibling: true, recycling: false, ocxService: undefined })).toEqual(none); + expect(decideStartExitTeardown({ sibling: true, recycling: false, ocxService: "1" })).toEqual(none); + // A drain-and-restart keeps everything for the replacement (#563). + expect(decideStartExitTeardown({ sibling: false, recycling: true, ocxService: undefined })).toEqual(none); + // Service context keeps routing and the fence; only the env comes down. + expect(decideStartExitTeardown({ sibling: false, recycling: false, ocxService: "1" })) + .toEqual({ revertSystemEnv: true, restoreNativeCodex: false, stripGrokConfig: false }); + // Only the exact "1" sentinel is service context. + expect(decideStartExitTeardown({ sibling: false, recycling: false, ocxService: "0" })).toEqual(all); + expect(decideStartExitTeardown({ sibling: false, recycling: false, ocxService: undefined })).toEqual(all); + }); + + test("the startup line names both ports for a sibling and keeps the other two sentences", () => { + try { + markSiblingStart(10100); + expect(startupLeftCodexNativeLine("sibling", 10199)) + .toBe(" Client routing stays on the proxy at port 10100; this instance serves direct requests on port 10199 only."); + } finally { + resetSiblingStartForTests(); + } + expect(startupLeftCodexNativeLine("desired_disabled")).toBe(" Codex integration OFF; startup left Codex native."); + expect(startupLeftCodexNativeLine("hub-gated")).toContain("Startup left Codex native."); + }); + + test("handleStart marks the sibling on both detection paths before the server binds", () => { + const start = slice("async function handleStart(", "function detachedStartEnvironment("); + const bindAt = start.indexOf("serverModule.startServer("); + expect(bindAt).toBeGreaterThan(-1); + for (const mark of ["siblingStart = true;\n markSiblingStart(owner.live.port);", "siblingStart = true;\n markSiblingStart(fencedLive.port);"]) { + expect(start.indexOf(mark)).toBeGreaterThan(-1); + expect(start.indexOf(mark)).toBeLessThan(bindAt); + } + // The old comment described the defect as intended behavior. + expect(start).not.toContain("re-points this home's Codex config"); + expect(start).toContain("...siblingRuntimeField(),"); + expect(start).toContain("if (!siblingStart) await maybeShowUpdatePrompt();"); + expect(start).toContain("if (!siblingStart) reportShellHookFailure(reconcileShellHook(systemEnv.injected));"); + expect(start).toContain("if (!siblingStart && !currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config)"); + expect(start).toContain("startupLeftCodexNativeLine(localClientSkipReason(config), server.port ?? port)"); + }); + + test("the exit cleanup routes every shared teardown through the decision", () => { + const cleanup = slice("const syncCleanup = () => {", "let shuttingDown = false;"); + expect(cleanup).toContain("decideStartExitTeardown({ sibling: siblingStart, recycling: isRecyclingForExit(), ocxService: process.env.OCX_SERVICE })"); + expect(cleanup).toContain("if (teardown.revertSystemEnv) {"); + expect(cleanup).toContain("if (teardown.restoreNativeCodex && !currentExternalCodexModelProvider()) {"); + expect(cleanup).toContain("if (teardown.stripGrokConfig && serviceEnvironmentOwnedHere()) {"); + expect(cleanup).not.toContain("preserveRouting"); + }); + + test("ocx stop of a sibling claims no receipt and restores nothing", () => { + const stop = slice("async function handleStopUnlocked(", "async function handleUninstall("); + const readAt = stop.indexOf("const siblingOfPort = readRuntimePort()?.siblingOfPort;"); + expect(readAt).toBeGreaterThan(-1); + // Read before the first stop can remove the record. + expect(readAt).toBeLessThan(stop.indexOf("stopServiceIfInstalledDetailed()")); + expect(stop).toContain("if (teardownNonce || stoppingSibling) return;"); + expect(stop).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling;"); + expect(stop).toContain('if (nativeRestoreHandledByProxy && !stoppingSibling) record.sharedTeardown = "performed-by-proxy";'); + // The system env was never the sibling's to set, so its stop does not roll it back either. + expect(stop).toContain("if (!stoppingSibling) { try { revertSystemEnv(); } catch { /* best-effort */ } }"); + expect(stop).not.toMatch(/\n {2}try \{ revertSystemEnv\(\); \}/); + // A sibling never runs under a service manager, so an installed one is the live owner's. Asking + // it failed the ownership check from the sibling's home: exit 1, and after a hard kill the + // stale sibling records were never purged. + expect(stop).toContain(': stoppingSibling ? "absent" : stopServiceIfInstalledDetailed();'); + // The only other caller is the guarded desktop step, which reaches the manager only when it + // provably owns the approved pid; a sibling's pid never is. + expect(stop.split("stopServiceIfInstalledDetailed()").length - 1).toBe(2); + }); + + test("ocx stop of a hard-killed sibling never stops the live owner discovery falls back to", () => { + // Behavior: the recorded sibling on 10199 is gone, and discovery answered with the owner. + expect(siblingStopFoundOwner(10100, { port: 10100, source: "config" })).toBe(true); + expect(siblingStopFoundOwner(10100, { port: 10100, source: "runtime" })).toBe(true); + // A configured-port answer is never the sibling's own record, whatever port it names. + expect(siblingStopFoundOwner(10100, { port: 10150, source: "config" })).toBe(true); + // The sibling itself still answering through its record is stopped normally. + expect(siblingStopFoundOwner(10100, { port: 10199, source: "runtime" })).toBe(false); + // Nothing answering, or not a sibling record at all: the ordinary stop paths decide. + expect(siblingStopFoundOwner(10100, null)).toBe(false); + expect(siblingStopFoundOwner(undefined, { port: 10100, source: "config" })).toBe(false); + + // Wiring: the orphan path asks before it may stop anything it found, and reports success. + const stop = slice("async function handleStopUnlocked(", "async function handleUninstall("); + const findAt = stop.indexOf("const live = await findLiveProxy({ acceptPackageTreeFenced: true });"); + const askAt = stop.indexOf("if (siblingStopFoundOwner(siblingOfPort, live)) {"); + expect(findAt).toBeGreaterThan(-1); + expect(askAt).toBeGreaterThan(findAt); + expect(askAt).toBeLessThan(stop.indexOf("} else if (live?.pid) {")); + const branch = stop.slice(askAt, stop.indexOf("} else if (live?.pid) {")); + expect(branch).toContain('record.proxy = "not-running";'); + expect(branch).toContain("was left running."); + expect(branch).not.toContain("stopFailed = true"); + expect(branch).not.toContain("stopWithDeferral"); + }); + + test("a sibling's replacement start inherits the mark through the env, and nothing else does", () => { + expect(SIBLING_OF_PORT_ENV).toBe("OCX_SIBLING_OF_PORT"); + for (const [raw, port] of [["10100", 10100], [" 1 ", 1], ["65535", 65535]] as const) { + expect(parseSiblingMarker(raw)).toBe(port); + } + for (const raw of [undefined, "", "0", "65536", "-1", "10100.5", "1e4", "abc", "10100abc", "123456"]) { + expect(parseSiblingMarker(raw), String(raw)).toBeNull(); + } + try { + // Unmarked: a replacement env carries no marker, and a stale inherited one is dropped. + expect(withSiblingMarker({ PATH: "/bin", OCX_SIBLING_OF_PORT: "9" })).toEqual({ PATH: "/bin" }); + // Honoring a valid marker marks this process before any probe and consumes the variable. + const env: Record = { PATH: "/bin", OCX_SIBLING_OF_PORT: "10100" }; + expect(honorSiblingMarker(env)).toBe(10100); + expect(siblingOfLivePort()).toBe(10100); + expect(env).toEqual({ PATH: "/bin" }); + // Marked: the replacement env names the owner; an ordinary-owner child env never does. + const source = { PATH: "/bin", OCX_SERVICE: "1" }; + expect(withSiblingMarker(source)).toEqual({ PATH: "/bin", OCX_SERVICE: "1", OCX_SIBLING_OF_PORT: "10100" }); + expect(source).toEqual({ PATH: "/bin", OCX_SERVICE: "1" }); + expect(withoutSiblingMarker({ PATH: "/bin", OCX_SIBLING_OF_PORT: "10100" })).toEqual({ PATH: "/bin" }); + } finally { + resetSiblingStartForTests(); + } + // A malformed marker marks nothing and is still consumed. + const bad: Record = { OCX_SIBLING_OF_PORT: "0" }; + expect(honorSiblingMarker(bad)).toBeNull(); + expect(siblingOfLivePort()).toBeNull(); + expect(bad).toEqual({}); + + // Wiring: handleStart honors it before the first probe; both replacement spawns hand it on; + // the ordinary-owner detached starts strip it. + const start = slice("async function handleStart(", "function detachedStartEnvironment("); + const honorAt = start.indexOf("let siblingStart = honorSiblingMarker(process.env) !== null;"); + expect(honorAt).toBeGreaterThan(-1); + expect(honorAt).toBeLessThan(start.indexOf("await findProxyOwnerBeforeJournalRecovery(")); + const owner = slice("async function findProxyOwnerBeforeJournalRecovery(", "async function handleStart("); + expect(owner).toContain("if (!currentExternalCodexModelProvider() && siblingOfLivePort() === null) {"); + expect(slice("function detachedStartEnvironment(", "async function handleEnsure(")) + .toContain("const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);"); + expect(cliSource).toContain("env: withProcessRuntimeProvenance(withoutSiblingMarker(process.env)),"); + // Every other detached `ocx start` is an ordinary owner too: the client auto-starts and the + // updater's restart. A stray marker would mark them before any probe. + const opencodeStartEnv = "env: withProcessRuntimeProvenance(opencodeProxyStartEnv(withoutSiblingMarker(process.env)) as NodeJS.ProcessEnv),"; + for (const [path, env] of [ + ["src/cli/claude.ts", 'env: withProcessRuntimeProvenance(withoutSiblingMarker({ ...process.env, OCX_SERVICE: "1" })),'], + ["src/cli/opencode.ts", opencodeStartEnv], + ["src/cli/minimax.ts", opencodeStartEnv], + ["src/update/job.ts", "const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);"], + ["src/update/index.ts", "const env = mutation.controlEnvironment(withoutSiblingMarker(process.env));"], + ] as const) { + expect(readFileSync(repoPath(path), "utf8"), path).toContain(env); + } + // The package launcher's own post-update restart cannot import the helper; it deletes inline. + expect(readFileSync(repoPath("bin/ocx.mjs"), "utf8")).toContain("delete env.OCX_SIBLING_OF_PORT;"); + expect(readFileSync(repoPath("src/server/management/system-restart.ts"), "utf8")) + .toContain("const sourceEnv: NodeJS.ProcessEnv = withSiblingMarker(process.env);"); + expect(readFileSync(repoPath("src/client/runtime.ts"), "utf8")) + .toContain("env: withSiblingMarker(standaloneRecycleEnv(process.env, disconnectedTokenFingerprint)),"); + }); +}); + 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-start-journal-order.test.ts b/tests/cli/cli-start-journal-order.test.ts index 95b5291a1ae..b3af7bdf006 100644 --- a/tests/cli/cli-start-journal-order.test.ts +++ b/tests/cli/cli-start-journal-order.test.ts @@ -6,6 +6,11 @@ import { join, resolve } from "node:path"; import { watchdogMs } from "../helpers/ci-watchdog"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; +import { + inspectServiceStateRecords, + selectAuthoritativeServiceState, + serviceStatePathsForHomes, +} from "../../src/service/state-record.mjs"; // Every wait here is bounded by a real `ocx start` child coming up: spawning Bun, // binding a port, and writing its runtime record. That is intrinsic to the @@ -285,3 +290,225 @@ describe("start and ensure journal ownership (#1230)", () => { } }, JOURNAL_OWNERSHIP_BUDGET_MS); }); + +// Owner up, then three sibling starts with readiness, two `ocx stop`s and the exits between them: +// ten bounded waits in series plus the exits, each normally well under a second. +const SIBLING_ROUTING_BUDGET_MS = Math.max(90_000, OWNER_WAIT_MS * 12); + +function freeLoopbackPort(): number { + const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response(null) }); + const port = probe.port!; + probe.stop(true); + return port; +} + +async function startSibling( + env: Record, + siblingHome: string, + port: number, + cwd: string, +): Promise<{ child: ReturnType; runtime: { pid: number; port: number; siblingOfPort?: number } }> { + const child = Bun.spawn([process.execPath, cliPath, "start", "--port", String(port)], { + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + children.push(child); + const runtimePath = join(siblingHome, "runtime-port.json"); + const runtime = await waitFor(async () => { + if (child.exitCode !== null) { + throw new Error(`sibling exited ${child.exitCode}: ${await new Response(child.stderr).text()}`); + } + if (!existsSync(runtimePath)) return null; + try { + const value = JSON.parse(readFileSync(runtimePath, "utf8")) as { pid: number; port: number; siblingOfPort?: number }; + return value.pid === child.pid && value.port === port ? value : null; + } catch { + return null; + } + }, "sibling runtime record"); + // /readyz settles after the startup sync. Without the sibling gate that sync is exactly + // what rewrote the shared config.toml, so the bytes are compared only once it has settled, + // and it has to settle as "ready": a sibling that wrote nothing has nothing to fail. + let settled: string | undefined; + await waitFor(async () => { + try { + const response = await fetch(`http://127.0.0.1:${port}/readyz`, { signal: AbortSignal.timeout(500) }); + const body = await response.json() as { status?: string; pid?: number }; + if (body.pid !== child.pid || body.status === undefined || body.status === "pending") return null; + settled = body.status; + return true; + } catch { + return null; + } + }, "sibling readiness"); + expect(settled).toBe("ready"); + return { child, runtime }; +} + +/** + * The 2026-09-26 incident: a second `ocx start --port 10199` beside the user's proxy on 10100 + * (another OPENCODEX_HOME, the same CODEX_HOME) re-pointed Codex's `openai_base_url` at 10199, + * and once it was killed every Codex thread failed with "Connection refused". A sibling must not + * write, restore or revert the shared client routing at startup, at exit, or through `ocx stop`. + * + * The owner keeps Codex OFF so it never touches the seeded routing itself; the sibling keeps it + * ON, so without the gate its startup sync injects and both of its shutdown paths replay the + * journal. HOME, CODEX_HOME and both OPENCODEX_HOMEs are temporary, and no service definition is + * written, so `ocx stop` has no manager to reach. Every stop runs with a service install recorded + * from the default home (only its state record, never a plist or unit, so nothing can reach + * launchctl), and after a hard kill `ocx stop` from the sibling's home must leave the owner running. + */ +describe("a sibling instance leaves the live owner's client routing alone", () => { + // INV-START-02 (structure/overview.md). + test("start, ocx stop, SIGTERM and a hard kill of a sibling leave config.toml, the journal and the owner alone", async () => { + const fx = fixture(); + const owner = await startOwner(fx); + const ownerRuntime = JSON.parse(readFileSync(join(fx.ocxHome, "runtime-port.json"), "utf8")) as { port: number }; + const injected = `# routed at the live owner\nmodel_provider = "opencodex"\nopenai_base_url = "http://127.0.0.1:${ownerRuntime.port}/v1"\n`; + writeFileSync(fx.configPath, injected); + writeFileSync(fx.journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from('# original\nmodel_provider = "openai"\n').toString("base64"), + originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, + pid: owner.pid, + timestamp: new Date().toISOString(), + })); + const snapshot = () => ({ + config: readFileSync(fx.configPath, "utf8"), + journal: existsSync(fx.journalPath) ? readFileSync(fx.journalPath, "utf8") : null, + }); + const before = snapshot(); + + const siblingHome = join(fx.root, "ocx-sibling"); + mkdirSync(siblingHome, { recursive: true }); + // Configured on the owner's port, as a copied home would be; Codex integration left ON. + writeFileSync(join(siblingHome, "config.json"), JSON.stringify({ + port: ownerRuntime.port, + hostname: "127.0.0.1", + codexAutoStart: false, + syncResumeHistory: false, + clientIntegrations: { grok: false, "claude-desktop": false }, + claudeCode: { systemEnv: false }, + providers: {}, + defaultProvider: "openai", + })); + const siblingEnv = { ...fx.env, OPENCODEX_HOME: siblingHome }; + + try { + const siblingPort = freeLoopbackPort(); + const sibling = await startSibling(siblingEnv, siblingHome, siblingPort, fx.root); + expect(sibling.runtime.siblingOfPort).toBe(ownerRuntime.port); + expect(snapshot()).toEqual(before); + + // The live owner's service, recorded as installed from the default home, for every stop leg + // below. Its ownership check fails from the sibling's home; a sibling never runs under a + // service manager, so neither `ocx stop` nor the sibling's own /api/stop may ask one. The + // record has to be the authority the child resolves, or these legs would prove nothing. + const defaultHome = join(fx.env.HOME, ".opencodex"); + mkdirSync(defaultHome, { recursive: true }); + writeFileSync(join(defaultHome, "service-state.json"), JSON.stringify({ + version: 1, codexHome: fx.codexHome, opencodexHome: defaultHome, + })); + const installed = selectAuthoritativeServiceState(inspectServiceStateRecords(serviceStatePathsForHomes(siblingHome, defaultHome))); + expect(installed.kind === "state" ? installed.state.opencodexHome : installed.kind).toBe(defaultHome); + + const stop = await runCli({ ...fx, env: siblingEnv }, ["stop"]); + expect(stop.exitCode, stop.stderr).toBe(0); + expect(stop.stdout).toContain(`Client routing stays on the proxy at port ${ownerRuntime.port}`); + await sibling.child.exited; + expect(await new Response(sibling.child.stdout).text()).toContain( + `Client routing stays on the proxy at port ${ownerRuntime.port}; this instance serves direct requests on port ${siblingPort} only.`, + ); + expect(snapshot()).toEqual(before); + + // A signal-driven exit runs the start process's own cleanup rather than `ocx stop`. POSIX + // only: on win32 a SIGTERM is TerminateProcess, so no cleanup runs and the leg proves nothing. + if (process.platform !== "win32") { + const second = await startSibling(siblingEnv, siblingHome, freeLoopbackPort(), fx.root); + second.child.kill("SIGTERM"); + await second.child.exited; + expect(snapshot()).toEqual(before); + } + + // A hard-killed sibling leaves its records behind with a dead pid. Discovery then falls back + // to the configured port, where the owner answers; `ocx stop` must not stop it. + const killed = await startSibling(siblingEnv, siblingHome, freeLoopbackPort(), fx.root); + killed.child.kill("SIGKILL"); + await killed.child.exited; + const orphanStop = await runCli({ ...fx, env: siblingEnv }, ["stop"]); + expect(orphanStop.exitCode, orphanStop.stderr).toBe(0); + expect(orphanStop.stdout).toContain(`The sibling instance is already gone; the proxy on port ${ownerRuntime.port} was left running.`); + expect(existsSync(join(siblingHome, "runtime-port.json"))).toBe(false); + expect(existsSync(join(siblingHome, "ocx.pid"))).toBe(false); + expect(snapshot()).toEqual(before); + + // The owner never noticed. + const health = await fetch(`http://127.0.0.1:${ownerRuntime.port}/healthz`).then(response => response.json()) as { pid?: number }; + expect(health.pid).toBe(owner.pid); + } finally { + owner.kill("SIGTERM"); + await owner.exited; + } + }, SIBLING_ROUTING_BUDGET_MS); + + test("a sibling's replacement that starts while the owner is down stays a sibling", async () => { + // A sibling's drain-and-restart or recycle spawns a fresh `ocx start` that re-probes. With the + // owner down for that moment it used to start as an ordinary owner: it replayed the owner's + // journal, re-pointed Codex at itself and persisted config.port. The spawn now carries + // OCX_SIBLING_OF_PORT, which handleStart honors before any probe. + const fx = fixture(); + const ownerPort = freeLoopbackPort(); + const injected = `# routed at the owner\nmodel_provider = "opencodex"\nopenai_base_url = "http://127.0.0.1:${ownerPort}/v1"\n`; + writeFileSync(fx.configPath, injected); + writeFileSync(fx.journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from('# original\nmodel_provider = "openai"\n').toString("base64"), + originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, + pid: 999_999, + timestamp: new Date().toISOString(), + })); + const snapshot = () => ({ + config: readFileSync(fx.configPath, "utf8"), + journal: existsSync(fx.journalPath) ? readFileSync(fx.journalPath, "utf8") : null, + }); + const before = snapshot(); + const siblingHome = join(fx.root, "ocx-sibling"); + mkdirSync(siblingHome, { recursive: true }); + const siblingConfig = join(siblingHome, "config.json"); + writeFileSync(siblingConfig, JSON.stringify({ + port: ownerPort, + hostname: "127.0.0.1", + codexAutoStart: false, + syncResumeHistory: false, + clientIntegrations: { grok: false, "claude-desktop": false }, + claudeCode: { systemEnv: false }, + providers: {}, + defaultProvider: "openai", + })); + const siblingPort = freeLoopbackPort(); + const replacement = await startSibling( + { ...fx.env, OPENCODEX_HOME: siblingHome, OCX_SIBLING_OF_PORT: String(ownerPort) }, + siblingHome, + siblingPort, + fx.root, + ); + expect(replacement.runtime.siblingOfPort).toBe(ownerPort); + expect(snapshot()).toEqual(before); + expect((JSON.parse(readFileSync(siblingConfig, "utf8")) as { port?: number }).port).toBe(ownerPort); + + const stop = await runCli({ ...fx, env: { ...fx.env, OPENCODEX_HOME: siblingHome } }, ["stop"]); + expect(stop.exitCode, stop.stderr).toBe(0); + await replacement.child.exited; + expect(await new Response(replacement.child.stdout).text()).toContain( + `Client routing stays on the proxy at port ${ownerPort}; this instance serves direct requests on port ${siblingPort} only.`, + ); + expect(snapshot()).toEqual(before); + expect((JSON.parse(readFileSync(siblingConfig, "utf8")) as { port?: number }).port).toBe(ownerPort); + }, JOURNAL_OWNERSHIP_BUDGET_MS); +}); diff --git a/tests/cli/hub-gated-local-clients.test.ts b/tests/cli/hub-gated-local-clients.test.ts index 9c7ad0f05e8..6bf4f147024 100644 --- a/tests/cli/hub-gated-local-clients.test.ts +++ b/tests/cli/hub-gated-local-clients.test.ts @@ -12,10 +12,14 @@ * * These tests pin the distinct reason and its sentence at each of those boundaries. */ -import { describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import type { ExportModel } from "../../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration } from "../../src/integrations/writer"; import { dispatchCommand, type CliDispatchDeps } from "../../src/cli/dispatch"; import { ensureGrokFenceMatchesDesired, type EnsureDesiredIntegrationsDeps } from "../../src/cli/ensure-desired-integrations"; import { codexInjectLockOutcome } from "../../src/codex/inject-coordination"; @@ -24,7 +28,18 @@ import { localClientSkipMessage, localClientSkipReason, localClientSyncAllowed, + shouldSyncCodexOnStart, + shouldSyncGrokOnStart, + syncCodexOnStartIfEnabled, } from "../../src/codex/desired-state"; +import { markSiblingStart, resetSiblingStartForTests, siblingSkipMessage } from "../../src/codex/sibling-start"; +import { createManagementConvergeCodex } from "../../src/codex/management-convergence"; +import { createCatalogConvergeRequest } from "../../src/codex/catalog-admission"; +import { injectClaudeAgentDefs } from "../../src/claude/agents-inject"; +import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; +import { syncEnabledClientIntegrations } from "../../src/server/management/config-routes"; +import { createReadinessGate } from "../../src/server/readiness"; +import { injectSystemEnv } from "../../src/server/system-env"; import { saveConfig } from "../../src/config"; import type { GrokInjectResult } from "../../src/grok/inject"; import type { OcxConfig } from "../../src/types"; @@ -236,3 +251,153 @@ describe("CLI output on a hub-gated host", () => { } }); }); + +/** + * A second `ocx start --port ` beside a live proxy (the "sibling" path) shares CODEX_HOME, + * ~/.claude, ~/.grok and the launchd domain with the live owner. It used to run the ordinary + * startup sync and re-point Codex at its own port, so every thread broke once it was killed. The + * mark closes the same central gate the hub uses, plus the owner-level writers the gate does not + * reach; these cases drive each one for real with the mark set, and check the mark is the reason. + */ +describe("a sibling instance never writes the live owner's client routing", () => { + afterEach(() => resetSiblingStartForTests()); + const plain = (overrides: Partial = {}): OcxConfig => ({ + port: 10_100, + providers: {}, + defaultProvider: "openai", + checkForUpdates: false, + ...overrides, + }) as unknown as OcxConfig; + + test("the central gate is closed with its own reason and sentence, and only while marked", () => { + expect(localClientSyncAllowed(plain())).toBe(true); + expect(shouldSyncCodexOnStart(plain())).toBe(true); + markSiblingStart(10_100); + expect(localClientSyncAllowed(plain())).toBe(false); + // The companion listener opens the HUB gate; it must not open this one. + expect(localClientSyncAllowed(plain({ unauthenticatedLoopbackListener: { enabled: true } }))).toBe(false); + expect(shouldSyncCodexOnStart(plain())).toBe(false); + expect(shouldSyncGrokOnStart(plain())).toBe(false); + // The sibling outranks both the toggle and the hub gate: it is the reason nothing was written. + expect(localClientSkipReason(plain())).toBe("sibling"); + expect(localClientSkipReason(hubConfig())).toBe("sibling"); + expect(localClientSkipReason(plain({ clientIntegrations: { codex: false } }))).toBe("sibling"); + expect(localClientSkipMessage(plain(), "Codex integration is OFF")).toBe(siblingSkipMessage()); + expect(siblingSkipMessage()).toContain("port 10100"); + expect(siblingSkipMessage(10_199)).toBe( + "Client routing stays on the proxy at port 10100; this instance serves direct requests on port 10199 only.", + ); + const lock = codexInjectLockOutcome({ status: "skipped", reason: "sibling", waitedMs: 0 }); + expect(lock).toMatchObject({ success: true, status: "skipped", skippedReason: "sibling" }); + expect(lock.message).toContain(siblingSkipMessage()); + expect(lock.message).not.toContain("integration is OFF"); + + resetSiblingStartForTests(); + expect(localClientSyncAllowed(plain())).toBe(true); + expect(localClientSkipReason(plain({ clientIntegrations: { codex: false } }))).toBe("desired_disabled"); + expect(localClientSkipReason(hubConfig())).toBe("hub-gated"); + }); + + test("startup sync never runs and readiness still settles", async () => { + markSiblingStart(10_100); + const gate = createReadinessGate(); + let syncCalls = 0; + const result = await syncCodexOnStartIfEnabled(10_199, plain(), async () => { + syncCalls += 1; + return { ok: true, catalogWritten: true, cacheSynced: true }; + }, gate); + expect(result).toEqual({ ran: false, catalogWritten: false, cacheSynced: false }); + expect(syncCalls).toBe(0); + // /readyz must not hang pending for an instance that deliberately wrote nothing. + expect(gate.getStatus()).toBe("ready"); + }); + + test("the Codex sync every caller runs reports the sibling, not the toggle", async () => { + markSiblingStart(10_100); + const { syncModelsToCodex } = await import("../../src/codex/sync"); + const result = await syncModelsToCodex(10_199, plain(), null); + expect(result).toMatchObject({ status: "skipped", skippedReason: "sibling", ok: true, catalogWritten: false, cacheSynced: false }); + expect(result.message).toBe(siblingSkipMessage()); + }); + + test("system env, the Claude roster and the catalog funnel all refuse", async () => { + markSiblingStart(10_100); + // Platform-independent on purpose: the refusal precedes the macOS check. + expect(await injectSystemEnv(10_199, plain({ claudeCode: { systemEnv: true } } as Partial))) + .toEqual({ injected: false, reason: "sibling instance" }); + + const agentsDir = mkdtempSync(join(tmpdir(), "ocx-sibling-agents-")); + try { + expect(injectClaudeAgentDefs(plain(), {}, agentsDir)).toBeNull(); + expect(readdirSync(agentsDir)).toEqual([]); + } finally { + removeTreeWithRetry(agentsDir); + } + + const converge = createManagementConvergeCodex(plain()); + const outcome = await converge(createCatalogConvergeRequest({ deadlineMs: 1_000 })); + expect(outcome).toMatchObject({ + kind: "catalog-only", + changed: false, + catalogRefresh: { status: "skipped", reason: "refused", retryable: false }, + }); + }); + + test("an owned client file keeps the owner's port while marked, and is refreshed once unmarked", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-sibling-owned-")); + try { + const env: NodeJS.ProcessEnv = {}; + const home = join(root, "home"); + const store = createIntegrationStateStore(join(root, "state", "integrations")); + const pi = INTEGRATION_CLIENTS.pi; + mkdirSync(pi.detectDir(env, home), { recursive: true }); + mkdirSync(dirname(pi.configPath(env, home)), { recursive: true }); + writeFileSync(pi.configPath(env, home), JSON.stringify({ providers: {} })); + const config = plain({ + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as Partial); + const models: ExportModel[] = [{ namespaced: "mock/a", provider: "mock", id: "a", contextWindow: 128_000 }]; + // Owned: connected by the live owner, at the owner's port. + expect(applyIntegration({ clientId: "pi", models, config, port: 10_100, env, home, store }).ok).toBe(true); + const owned = readFileSync(pi.configPath(env, home), "utf8"); + expect(owned).toContain("http://127.0.0.1:10100/v1"); + + let loads = 0; + const input = { models: async () => { loads += 1; return models; }, config, port: 10_199, env, home, store }; + markSiblingStart(10_100); + expect(await refreshOwnedCatalogIntegrations(input, ["pi"])).toEqual([]); + expect(loads).toBe(0); + expect(readFileSync(pi.configPath(env, home), "utf8")).toBe(owned); + + // Unmarked control: the same call does re-point the owned file, so the [] above is the mark's. + resetSiblingStartForTests(); + expect(await refreshOwnedCatalogIntegrations(input, ["pi"])).toEqual([{ client: "pi", ok: true, changed: true }]); + expect(loads).toBe(1); + expect(readFileSync(pi.configPath(env, home), "utf8")).toContain("http://127.0.0.1:10199/v1"); + } finally { + removeTreeWithRetry(root); + } + }); + + test("PUT /api/settings and /api/sync fan-out re-points no Grok fence or Desktop profile at this port", async () => { + const calls: string[] = []; + const deps = { + fetchAllModels: async () => { calls.push("fetchAllModels"); return []; }, + refreshOwnedCatalogIntegrations: async () => { calls.push("refreshOwned"); return []; }, + writeDesktop3pConfig: () => { calls.push("writeDesktop3pConfig"); return { written: false, reason: "test" }; }, + } as unknown as Parameters[2]; + + markSiblingStart(10_100); + // Every client ON: without the mark this would reach syncGrokConfig and the Desktop writer. + expect(await syncEnabledClientIntegrations(10_199, plain(), deps)).toEqual([]); + expect(calls).toEqual([]); + + // Unmarked, the same call does fan out; the mark is what stopped it. + resetSiblingStartForTests(); + const off = plain({ clientIntegrations: { grok: false, "claude-desktop": false } }); + await syncEnabledClientIntegrations(10_199, off, deps); + expect(calls).toEqual(["refreshOwned"]); + }); +}); diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index edfe84961a2..c99a04342ed 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -1,10 +1,10 @@ import { beforeAll, describe, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; import { spawn, spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER } from "../../src/codex/subagent-defaults"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { downloadClientCatalog, exchangeConnectPairingGrant, @@ -1398,3 +1398,40 @@ describe("Desktop copy coherence across client lifecycle", () => { expect(r.backupPresent).toBe(mode === "status-receipt"); }); }); + +describe("a sibling instance never connects, syncs or disconnects the shared Codex home", () => { + /** + * A sibling (`ocx start --port ` beside a live proxy) shares CODEX_HOME with that proxy. + * Connecting writes the shared `opencodex-catalog.json` BEFORE the injector runs, so a refusal + * at injection would come after the owner's catalog was already replaced. The refusal has to be + * the first thing each entry point does. In-process on purpose: the mark is process-local, and + * CODEX_HOME here is the preload sandbox. + */ + test("every entry point refuses before any catalog, journal or network write", async () => { + const { connectClient, disconnectClient, syncConnectedClient } = await import("../../src/client/connect"); + const { DEFAULT_CATALOG_PATH } = await import("../../src/codex/paths"); + const { markSiblingStart, resetSiblingStartForTests, siblingSkipMessage } = await import("../../src/codex/sibling-start"); + const ownerCatalog = JSON.stringify({ models: [{ slug: "owner-model" }] }); + mkdirSync(dirname(DEFAULT_CATALOG_PATH), { recursive: true }); + writeFileSync(DEFAULT_CATALOG_PATH, ownerCatalog); + let fetches = 0; + const deps = { fetchImpl: (async () => { fetches += 1; return new Response("{}"); }) as unknown as typeof fetch }; + markSiblingStart(10100); + try { + const message = siblingSkipMessage(); + await expect(connectClient({ + serverUrl: "https://hub.example.test", + credential: { kind: "invite", token: "x" }, + selectedClients: ["codex"], + managementTransport: "direct", + } as unknown as Parameters[0], deps)).rejects.toThrow(message); + await expect(syncConnectedClient({}, deps)).rejects.toThrow(message); + await expect(disconnectClient({})).rejects.toThrow(message); + expect(fetches).toBe(0); + expect(readFileSync(DEFAULT_CATALOG_PATH, "utf8")).toBe(ownerCatalog); + } finally { + resetSiblingStartForTests(); + rmSync(DEFAULT_CATALOG_PATH, { force: true }); + } + }); +}); diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index efb6faeb329..8d08d2adcf8 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -915,4 +915,63 @@ describe("codex-journal", () => { // And completeness still gates journal deletion. expect(body).toContain("if (complete) removeJournal();"); }); + + test("a sibling instance neither restores, injects over, nor drops the live owner's journal", () => { + // A sibling shares CODEX_HOME with the live proxy. The desired-state re-reads in the restore + // path would read its closed gate as "Codex is OFF" and replay the owner's journal, and the + // external-provider courtesy in both directions deletes the journal outright. The mark has to + // win before either runs. + for (const provider of ["opencodex", "custom-provider"]) { + const original = '# original\nmodel_provider = "openai"\n'; + const injected = `# injected\nmodel_provider = "${provider}"\nopenai_base_url = "http://127.0.0.1:10100/v1"\n`; + writeFileSync(join(testDir, "config.toml"), injected); + const journal = JSON.stringify({ + version: 1, + originalConfig: Buffer.from(original).toString("base64"), + originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, + pid: 999_999, + timestamp: new Date().toISOString(), + }); + writeFileSync(join(testDir, "opencodex-journal.json"), journal); + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { markSiblingStart, resetSiblingStartForTests } = require("./src/codex/sibling-start"); + const { injectCodexConfig, restoreNativeCodex, restoreNativeCodexAsync } = require("./src/codex/inject"); + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + const bytes = () => ({ + config: fs.readFileSync(configPath, "utf8"), + journal: fs.existsSync(journalPath) ? fs.readFileSync(journalPath, "utf8") : null, + }); + markSiblingStart(10100); + const sync = restoreNativeCodex(); + const afterSync = bytes(); + const async = await restoreNativeCodexAsync({ revalidateDesiredState: true }); + const afterAsync = bytes(); + const inject = await injectCodexConfig(10199, { port: 10199, providers: {}, defaultProvider: "openai" }); + const afterInject = bytes(); + // Unmarked, the same process restores: the mark is what held the bytes. + resetSiblingStartForTests(); + const unmarked = restoreNativeCodex(); + console.log(JSON.stringify({ sync, async, inject, afterSync, afterAsync, afterInject, unmarked, afterUnmarked: bytes() })); + `); + expect(r.status, r.stderr).toBe(0); + const out = JSON.parse(r.stdout.split("\n").at(-1)!); + for (const result of [out.sync, out.async]) { + expect(result.success).toBe(true); + expect(result.message).toContain("Client routing stays on the proxy at port 10100"); + expect(result.artifacts.config).toMatchObject({ state: "skipped", changed: false }); + } + expect(out.inject).toMatchObject({ success: true, status: "skipped", skippedReason: "sibling" }); + for (const snapshot of [out.afterSync, out.afterAsync, out.afterInject]) { + expect(snapshot).toEqual({ config: injected, journal }); + } + // Control: the unmarked restore does act (restores or, for an external provider, drops the + // stale journal), so the assertions above are not vacuous. + expect(out.afterUnmarked).not.toEqual({ config: injected, journal }); + } + }); }); diff --git a/tests/lib/process-control-graceful.test.ts b/tests/lib/process-control-graceful.test.ts index da9983d7cbf..f5086f49035 100644 --- a/tests/lib/process-control-graceful.test.ts +++ b/tests/lib/process-control-graceful.test.ts @@ -65,6 +65,22 @@ describe("stopProxyGracefully", () => { } }); + test("a sibling's not-owned answer confirms a plain stop but never a deferral", async () => { + // A sibling instance owns no shared teardown, so nothing is left for the caller to finish. + // A caller that handed over a receipt asked for something else, and still gets no confirmation. + for (const nonce of [undefined, "receipt-nonce"]) { + const result = await stopProxyGracefully(4242, { + readRuntime: () => ({ port: 10199 }), + fetchFn: (async () => new Response(JSON.stringify({ success: true, sharedTeardown: "not-owned" }))) as typeof fetch, + waitExit: () => true, + ...(nonce ? { deferSharedTeardownNonce: nonce } : {}), + exitTimeoutMs: 1, + env: {}, + }); + expect(result).toBe(nonce ? "teardown-unconfirmed" : true); + } + }); + test("an unconfirmed response still requires process exit", async () => { expect(await stopProxyGracefully(4242, { readRuntime: () => ({ port: 10100 }), diff --git a/tests/providers/xai/grok-lifecycle.test.ts b/tests/providers/xai/grok-lifecycle.test.ts index ba4557d9ba7..fb4408cd795 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { classifyWindowsServiceStop, installedServiceRespawnRisk, isServiceOwnershipError, ServiceOwnershipError } from "../../../src/service"; +import { decideStartExitTeardown } from "../../../src/cli/dispatch"; import { repoPath } from "../../helpers/repo-root"; const CLI_SOURCE = readFileSync(repoPath("src", "cli", "index.ts"), "utf8"); @@ -103,9 +104,10 @@ describe("Grok fence lifecycle wiring", () => { expect(stopFn).toContain("isServiceOwnershipError(err)"); expect(stopFn).toContain("ownershipBlocked = true"); - // Ownership is now one of two reasons to skip the restore; the other is an inherited - // obligation whose proxy could not be confirmed down (#3008). - expect(stopFn).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy;"); + // Ownership is one reason to skip the restore; others are an inherited obligation whose + // proxy could not be confirmed down (#3008) and a sibling runtime, whose shared client + // routing belongs to the live proxy it ran beside. + expect(stopFn).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling;"); expect(stopFn).toContain("if (!restoreBlocked) {"); expect(stopFn).toContain("await restoreSharedClientStateAfterStop()"); expect(restoreFn).toContain("restoreNativeCodexAsync()"); @@ -353,10 +355,13 @@ describe("Grok fence lifecycle wiring", () => { test("the daemon's exit cleanup keeps the OCX_SERVICE exclusion and adds the ownership check", () => { const startFn = sliceFn(CLI_SOURCE, "const syncCleanup = () => {", "let shuttingDown = false;"); - // Crash/respawn under a service manager must still keep the fence. - expect(startFn).toContain('process.env.OCX_SERVICE === "1"'); + // Crash/respawn under a service manager must still keep the fence. The exact-"1" sentinel + // lives in decideStartExitTeardown (src/cli/dispatch.ts), which the cleanup feeds the raw + // environment value and whose whole matrix runs in tests/cli/cli-dispatch.test.ts. + expect(startFn).toContain("ocxService: process.env.OCX_SERVICE"); + expect(decideStartExitTeardown({ sibling: false, recycling: false, ocxService: "1" }).stripGrokConfig).toBe(false); expect(startFn).not.toContain("OCX_KEEP_ROUTING"); - expect(startFn).toContain("!preserveRouting && serviceEnvironmentOwnedHere()"); + expect(startFn).toContain("teardown.stripGrokConfig && serviceEnvironmentOwnedHere()"); }); test("signal shutdown reports and exits nonzero when native Codex restore is incomplete", () => { @@ -410,6 +415,13 @@ describe("POST /api/stop teardown", () => { expect(refusalAt).toBeLessThan(shutdownAt); }); + test("a sibling's stop never asks the service manager, which belongs to the live owner", () => { + const handler = sliceFn(MANAGEMENT_SOURCE, '"/api/stop"', "/api/codex-auth/"); + expect(handler).toContain("const sibling = siblingOfLivePort() !== null;"); + expect(handler).toContain('const respawnRisk = holdsReceipt || sibling ? "none" : installedServiceRespawnRisk();'); + expect(handler).toContain('serviceStop = sibling ? "absent" : stopServiceIfInstalledDetailed();'); + }); + test("strips the Grok fence on an accepted stop", () => { // The teardown moved to src/server/stop-teardown.ts so a test can call it: the route // schedules process.exit 200ms after answering, which made the inline version @@ -478,7 +490,7 @@ describe("POST /api/stop teardown", () => { // Stopping the Task Scheduler task and then returning 409 left the proxy running with // its manager stopped — worse than either outcome, and the dashboard's Stop button // sends a bare request on every backend. - expect(handler).toContain('const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk();'); + expect(handler).toContain('const respawnRisk = holdsReceipt || sibling ? "none" : installedServiceRespawnRisk();'); expect(handler).toContain('code: "respawnable_service"'); expect(handler.indexOf("installedServiceRespawnRisk()")).toBeLessThan(handler.indexOf("stopServiceIfInstalledDetailed()")); // The refusal must say nothing was changed, because nothing was. diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index c58ac45d7ff..b184dcce86a 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { MANAGEMENT_ROUTES } from "../../src/server/management/route-registry"; +import { SIBLING_REFUSED_MANAGEMENT_PATHS, siblingRefusesManagementRequest } from "../../src/server/management/sibling-guard"; +import { markSiblingStart, resetSiblingStartForTests } from "../../src/codex/sibling-start"; import { scanRoutes, distinctRoutes } from "../helpers/management-route-scan"; import { repoRoot as resolveRepoRoot } from "../helpers/repo-root"; @@ -278,6 +280,113 @@ describe("route exemptions stay honest", () => { }); }); +describe("the sibling guard refuses only declared shared-state mutations", () => { + const matches = (entry: { path: string; children: boolean }, path: string): boolean => + path === entry.path || (entry.children && path.startsWith(`${entry.path}/`)); + + test("every guard entry names at least one declared mutating route", () => { + // A guard entry that matches no mutation is either a typo or a route that moved; either way + // the route it was meant to cover is open. + const orphans = SIBLING_REFUSED_MANAGEMENT_PATHS + .filter(entry => !MANAGEMENT_ROUTES.some(route => route.mutates && matches(entry, route.path))) + .map(entry => entry.path); + expect(orphans).toEqual([]); + }); + + test("unmarked, nothing is refused; marked, reads never are", () => { + for (const route of MANAGEMENT_ROUTES) { + expect(siblingRefusesManagementRequest(route.method, route.path)).toBe(false); + } + markSiblingStart(10100); + try { + for (const route of MANAGEMENT_ROUTES.filter(r => r.method === "GET" || r.method === "HEAD")) { + expect(siblingRefusesManagementRequest(route.method, route.path), key(route.method, route.path)).toBe(false); + } + } finally { + resetSiblingStartForTests(); + } + }); + + test("marked, shared-state writers are refused and own-home control stays open", () => { + markSiblingStart(10100); + try { + for (const [method, path] of [ + ["PUT", "/api/client-integrations/raycast"], + ["POST", "/api/sync"], + ["POST", "/api/link/join"], + ["POST", "/api/native-main-profiles/switch"], + ["POST", "/api/codex-auth/main/reauth-device"], + ["POST", "/api/startup-action"], + ["PUT", "/api/v2"], + ["PUT", "/api/native-integrations/grok"], + ["POST", "/api/system/codex-restart"], + ["PUT", "/api/codex-prompt/toggle"], + // Archived-session storage lives in the shared CODEX_HOME. + ["POST", "/api/storage/cleanup"], + ["POST", "/api/storage/cleanup-policy/run"], + ["POST", "/api/storage/trash/restore"], + ] as const) { + expect(siblingRefusesManagementRequest(method, path), `${method} ${path}`).toBe(true); + } + for (const [method, path] of [ + ["POST", "/api/stop"], + ["POST", "/api/system/restart"], + ["PUT", "/api/settings"], + ["POST", "/api/providers"], + // The preview reads, and the policy itself is own-home config. + ["POST", "/api/storage/cleanup/preview"], + ["PUT", "/api/storage/cleanup-policy"], + // A prefix is not a path: the guard must not swallow a sibling route that shares one. + ["POST", "/api/syncx"], + ["POST", "/api/link/joined"], + ] as const) { + expect(siblingRefusesManagementRequest(method, path), `${method} ${path}`).toBe(false); + } + } finally { + resetSiblingStartForTests(); + } + }); + + test("handleManagementAPI answers 409 sibling_instance before any route runs, and only while marked", async () => { + const { handleManagementAPI } = await import("../../src/server/management-api"); + const { ManagementRequest } = await import("../helpers/management-auth"); + const config = { port: 10199, hostname: "127.0.0.1", providers: {}, defaultProvider: "openai" } as unknown as Parameters[2]; + const call = async (method: string, path: string, body?: unknown) => { + const request = new ManagementRequest(`http://127.0.0.1:10199${path}`, { + method, + ...(body === undefined ? {} : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }), + }); + const response = await handleManagementAPI(request, new URL(request.url), config); + expect(response, `${method} ${path}`).not.toBeNull(); + return { status: response!.status, body: await response!.json() as { code?: string; error?: string } }; + }; + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-sibling-guard-")); + process.env.OPENCODEX_HOME = home; + try { + // Unmarked control: the same cleanup request reaches its handler and fails its own validation. + expect(await call("POST", "/api/storage/cleanup", { percent: -1 })).toEqual({ status: 400, body: { error: "invalid_percent" } }); + markSiblingStart(10100); + for (const [method, path] of [["POST", "/api/sync"], ["POST", "/api/storage/cleanup"]] as const) { + const refused = await call(method, path, { percent: -1 }); + expect(refused.status, `${method} ${path}`).toBe(409); + expect(refused.body.code).toBe("sibling_instance"); + expect(refused.body.error).toContain("Client routing stays on the proxy at port 10100"); + } + // A read, and the allowed POST cleanup preview, still reach their handlers. + const read = await call("GET", "/api/storage/cleanup-policy"); + expect(read.status).toBe(200); + expect(read.body.code).toBeUndefined(); + expect(await call("POST", "/api/storage/cleanup/preview", { percent: -1 })).toEqual({ status: 400, body: { error: "invalid_percent" } }); + } finally { + resetSiblingStartForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); + } + }); +}); + describe("the registry is inert data", () => { test("route-registry.ts imports nothing at all", () => { // It is imported by src/server/management-api.ts, which tests/core-lab-boundary diff --git a/tests/service/process-state.test.ts b/tests/service/process-state.test.ts index ffaf8484181..c7d31b5e8e5 100644 --- a/tests/service/process-state.test.ts +++ b/tests/service/process-state.test.ts @@ -21,6 +21,7 @@ import { writePid, writeRuntimePort, } from "../../src/config/process-state"; +import { markSiblingStart, resetSiblingStartForTests, siblingRuntimeField } from "../../src/codex/sibling-start"; import { setTrustedWindowsSystemDirectoryResolverForTests } from "../../src/lib/windows-elevation"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -262,4 +263,26 @@ describe("proxy process-state ownership", () => { ); expect(readRuntimePort()).toBeNull(); }); + + test("a sibling record carries the live owner's port; every other record keeps its bytes", () => { + // Absent means "not a sibling", and the writer must not add the key: a non-sibling record is + // byte-identical to the one written before the field existed. + writeRuntimePort({ pid: 1234, port: 58195, hostname: "127.0.0.1", ...siblingRuntimeField() }); + expect(readFileSync(getRuntimePortPath(), "utf-8")) + .toBe(`${JSON.stringify({ pid: 1234, port: 58195, hostname: "127.0.0.1" }, null, 2)}\n`); + expect(readRuntimePort()?.siblingOfPort).toBeUndefined(); + + markSiblingStart(10100); + try { + writeRuntimePort({ pid: 1234, port: 10199, hostname: "127.0.0.1", ...siblingRuntimeField() }); + } finally { + resetSiblingStartForTests(); + } + expect(readRuntimePort()).toEqual({ pid: 1234, port: 10199, hostname: "127.0.0.1", siblingOfPort: 10100 }); + + for (const siblingOfPort of [0, 70000, 1.5, "10100", null]) { + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 1234, port: 10199, siblingOfPort }), "utf-8"); + expect(readRuntimePort()).toBeNull(); + } + }); }); diff --git a/tests/service/stop-deferred-teardown.test.ts b/tests/service/stop-deferred-teardown.test.ts index e37599a762a..1f5e3c195d5 100644 --- a/tests/service/stop-deferred-teardown.test.ts +++ b/tests/service/stop-deferred-teardown.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { stopProxyGracefully } from "../../src/lib/process-control"; import { performStopTeardown } from "../../src/server/stop-teardown"; +import { markSiblingStart, resetSiblingStartForTests } from "../../src/codex/sibling-start"; import type { CodexNativeRestoreResult } from "../../src/codex/inject"; import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../../src/update/stop-contract.mjs"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -275,6 +276,33 @@ describe("performStopTeardown", () => { expect(body.message).toContain("native Codex restored"); }); + test("a sibling instance restores nothing and hands nothing over, with or without a receipt", async () => { + // Its CODEX_HOME journal and the Grok fence are the live owner's: restoring here would take + // Codex off a proxy that is still serving it. + let restored = 0; + let stripped = 0; + const io = { + ownsReceipt: () => true, + restoreNativeCodex: async () => { restored += 1; return restoreResult(true); }, + stripGrok: () => { stripped += 1; return { ok: true, changed: true, message: "Grok config restored" }; }, + }; + markSiblingStart(10100); + try { + for (const url of [ + "http://127.0.0.1:10199/api/stop", + `http://127.0.0.1:10199/api/stop?deferSharedTeardown=1&teardownNonce=${FOREIGN_NONCE}`, + ]) { + const body = await performStopTeardown(new URL(url), io); + expect(body).toMatchObject({ success: true, sharedTeardown: "not-owned" }); + expect(body.message).toContain("Client routing stays on the proxy at port 10100"); + } + } finally { + resetSiblingStartForTests(); + } + expect(restored).toBe(0); + expect(stripped).toBe(0); + }); + test("a degraded stop reports the retained provider table without turning success into deferral", async () => { const retained = { reason: "history_paginated_requires_native_writer" as const, diff --git a/tests/storage/storage-policy-config-race.test.ts b/tests/storage/storage-policy-config-race.test.ts index 7f43f6ba6da..96316a28a13 100644 --- a/tests/storage/storage-policy-config-race.test.ts +++ b/tests/storage/storage-policy-config-race.test.ts @@ -9,8 +9,10 @@ import { setPersistedConfigMutationBeforeCommitForTests, } from "../../src/config"; import { computeNextRun, runStorageCleanupPolicy } from "../../src/storage/policy"; +import { markSiblingStart, resetSiblingStartForTests } from "../../src/codex/sibling-start"; import { getStorageCleanupPolicyJobState, + maybeRequestStorageCleanupPolicyRun, requestStorageCleanupPolicyRun, resetStorageCleanupPolicyJobForTestsAsync, setStorageCleanupPolicyJobTestHooks, @@ -149,3 +151,35 @@ test("job outcome keeps successful cleanup when metadata cannot persist", async expect(existsSync(archived)).toBe(false); expect(existsSync(getConfigPath())).toBe(false); }, { timeout: 10_000 }); + +test("a sibling instance never starts a startup or scheduled policy run on the shared CODEX_HOME", async () => { + // A second `ocx start --port ` shares CODEX_HOME with the live owner + // (src/codex/sibling-start.ts); its archived sessions are the owner's to clean up. + const initial = loadConfig(); + initial.storageCleanupPolicy = { + enabled: true, + trigger: { archivedBytesOver: 1_000_000_000 }, + target: { removeOldestPercent: 10 }, + schedule: "daily", + mode: "quarantine", + }; + saveConfig(initial); + setStorageCleanupPolicyJobTestHooks({ runInProcess: true }); + markSiblingStart(10100); + try { + for (const reason of ["startup", "schedule"] as const) { + maybeRequestStorageCleanupPolicyRun(reason, { codexHome: configHome }); + expect(getStorageCleanupPolicyJobState(), reason).toEqual({ status: "idle" }); + } + } finally { + resetSiblingStartForTests(); + } + // Unmarked control: the same due policy does start a run, so the idle state above is the mark's. + maybeRequestStorageCleanupPolicyRun("schedule", { codexHome: configHome }); + expect(getStorageCleanupPolicyJobState()).toMatchObject({ status: "running", reason: "schedule" }); + const deadline = Date.now() + 5_000; + while (getStorageCleanupPolicyJobState().status !== "idle" && Date.now() < deadline) { + await Bun.sleep(10); + } + expect(getStorageCleanupPolicyJobState().lastOutcome).toMatchObject({ ok: true, skipped: "under_threshold" }); +}, { timeout: 10_000 }); From af89f8a7b30677ff8125b8f5e798a5d0c831a2de Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 26 Sep 2026 20:38:32 +0900 Subject: [PATCH 02/13] test(stop): follow the sibling-aware respawn pre-check in the #4023 deferral oracle The receipt-backed deferral path is unchanged; the pinned line now also skips the service-manager probe for a sibling, whose installed service is the live owner's. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/service/stop-deferred-teardown.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/service/stop-deferred-teardown.test.ts b/tests/service/stop-deferred-teardown.test.ts index 1f5e3c195d5..48cf7e0e8e1 100644 --- a/tests/service/stop-deferred-teardown.test.ts +++ b/tests/service/stop-deferred-teardown.test.ts @@ -835,6 +835,6 @@ test("the route refuses a self-unload before the manager is touched", () => { // `ocx stop` claims a receipt, defers the teardown, and performs it itself once the // proxy is proven down — so it must not be refused by the new branch. const source = readFileSync(repoPath("src", "server", "management-api.ts"), "utf8"); - expect(source).toContain('const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk();'); + expect(source).toContain('const respawnRisk = holdsReceipt || sibling ? "none" : installedServiceRespawnRisk();'); }); }); From bec5718646c97f644994c09a36359cb752f3e2f1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 26 Sep 2026 20:20:56 +0900 Subject: [PATCH 03/13] fix(link): Remote Link loads and probes SSH hosts from the local dashboard Root cause: every dashboard /api/link route required a paired session, but a standalone loopback dashboard (browser or desktop webview) only holds a loopback-issued session, so candidates, probe, confirm-host and apply all answered 403 and SSH hosts never loaded. Fix: the Home-side routes (status, candidates, probe, confirm-host, apply, DELETE) also admit the current loopback session on trusted loopback ingress of a standalone runtime. POST /api/link/join stays paired-only; pairing sessions exist only on hub runtimes and join requires standalone, so no dashboard can join as a Child in this release. Status reports joinAvailable to GUI sessions (admin-token keeps the exact K16 DTO), and the dashboard disables the Child role with a notice in all 10 locales that points to Home-initiated linking. Docs, structure notes and the route registry say the same. ssh runs with Homebrew and ~/.bun/bin appended to PATH, and every remote ocx call runs through a sh prelude that appends the fallback dirs after the remote PATH (exit 127 -> remote_ocx_missing). confirm-host requires ocx >= 2.66.0, parsed to a bounded semver shape. Link errors carry a bounded, redacted hint from ssh stderr, the ssh runner's own failure, or the parsed remote version; server and dashboard cap it at 160 code points without splitting a surrogate pair. Specific error guidance is translated in every locale. Security: the loopback session is minted without a credential, so this is casual-path protection like POST /api/github/star, not a secret-backed boundary; hubs and join keep the paired-only rule, Tailscale identity sessions are still refused, and hints are never logged or read from stdin. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/content/docs/fr/guides/remote-link.md | 15 +- .../src/content/docs/guides/remote-link.md | 24 +- .../src/content/docs/ja/guides/remote-link.md | 15 +- .../src/content/docs/ko/guides/remote-link.md | 15 +- .../src/content/docs/ru/guides/remote-link.md | 15 +- .../src/content/docs/tr/guides/remote-link.md | 15 +- .../content/docs/zh-cn/guides/remote-link.md | 15 +- .../content/docs/zh-tw/guides/remote-link.md | 15 +- gui/src/i18n/de.ts | 20 +- gui/src/i18n/en.ts | 20 +- gui/src/i18n/fr.ts | 20 +- gui/src/i18n/ja.ts | 20 +- gui/src/i18n/ko.ts | 20 +- gui/src/i18n/ru.ts | 20 +- gui/src/i18n/tr.ts | 20 +- gui/src/i18n/vi.ts | 20 +- gui/src/i18n/zh-TW.ts | 20 +- gui/src/i18n/zh.ts | 20 +- gui/src/pages/RemoteLink.tsx | 37 ++- gui/src/remote-link-api.ts | 37 ++- gui/src/styles-remote-link.css | 1 + gui/tests/remote-link.test.tsx | 136 ++++++++- src/client/link-join.ts | 28 +- src/client/link-teardown.ts | 4 +- src/link/ssh-argv.ts | 16 + src/link/ssh-runner.ts | 72 ++++- src/server/management/link-routes.ts | 151 +++++++--- src/server/management/route-registry.ts | 10 +- structure/gui-and-management-api.md | 2 +- structure/remote-link.md | 16 +- tests/clients/client-link-teardown.test.ts | 5 +- tests/clients/link-ssh-argv.test.ts | 133 ++++++++- tests/server/link-join-route.test.ts | 87 +++++- tests/server/link-management-routes.test.ts | 274 +++++++++++++++++- 34 files changed, 1074 insertions(+), 264 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md index 6c99cde0d98..4fe3cc878bc 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -9,20 +9,19 @@ Une liaison entre machines connecte un ordinateur OpenCodex **Home** à un ordin - Home peut se connecter à Child avec une clé OpenSSH. - Pour une liaison initiée par Child, Child peut se connecter à Home avec une clé OpenSSH (la connexion par mot de passe n’est pas prise en charge). -- OpenCodex est installé sur Child. +- OpenCodex 2.66.0 ou ultérieur est installé sur Child (et sur Home pour une liaison initiée par Child). - Les deux ordinateurs utilisent macOS ou Linux. -- Le tableau de bord Home dispose d’une session appairée complète. +- La liaison se lance depuis Home : son tableau de bord est ouvert sur l’ordinateur Home lui-même (navigateur ou application de bureau, installation autonome) ou via une session Hub appairée. -SSH par mot de passe et Windows restent hors du flux actuel. Pour démarrer une liaison depuis Child, ouvrez le tableau de bord du Child autonome, choisissez **Enfant** → **Trouver le Home**, sélectionnez l’hôte SSH de Home, vérifiez puis confirmez l’empreinte de la clé hôte, et choisissez **Connecter comme Enfant**. Child doit pouvoir se connecter à Home avec une clé SSH (les mots de passe ne sont pas pris en charge), et `ocx` doit être en cours d’exécution sur Home. Le port du tunnel client est `1024` ou supérieur. Après la jonction, Child redémarre et se connecte via Home. Cette option est disponible uniquement en mode autonome. +SSH par mot de passe et Windows restent hors du flux actuel. Connecter un ordinateur comme Child depuis le tableau de bord (liaison initiée par Child) n’est pas disponible dans cette version : la jonction redémarre OpenCodex sur cet ordinateur, ce qui couperait les connexions Codex déjà ouvertes ; le tableau de bord affiche donc le rôle **Enfant** comme indisponible. La liaison initiée par Home est la voie prise en charge : sur l’ordinateur qui doit servir de Home, choisissez **Home** et ajoutez l’autre ordinateur comme Child, comme décrit ci-dessous. ## Ajouter un Child depuis `#remote` 1. Ouvrez le tableau de bord sur `#remote` et activez Remote Link. -2. Choisissez **Home**. -3. Sélectionnez **Add child**. -4. Choisissez un hôte parmi les candidats SSH, ou saisissez un alias de configuration SSH. -5. Lancez le test de connexion et comparez l’empreinte proposée avec celle de l’ordinateur visé. Cette comparaison aide à détecter un mauvais hôte ou une clé d’hôte modifiée avant que SSH ne lui fasse confiance. -6. Confirmez l’empreinte, puis connectez Child. +2. Choisissez **Home**, puis **Continue**. La liste des hôtes SSH s’ouvre. +3. Choisissez un hôte parmi les candidats SSH, ou saisissez un alias de configuration SSH. +4. Lancez le test de connexion et comparez l’empreinte proposée avec celle de l’ordinateur visé. Cette comparaison aide à détecter un mauvais hôte ou une clé d’hôte modifiée avant que SSH ne lui fasse confiance. +5. Confirmez l’empreinte, puis connectez Child. Le tableau de bord ne demande pas de saisir un jeton. Il sonde d’abord l’hôte et ne peut appliquer la liaison qu’après votre confirmation explicite de l’empreinte. diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 7219f1210ed..6481d520dc0 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -9,20 +9,19 @@ A machine link connects an OpenCodex **Home** computer to a **Child** computer o - The Home computer can log in to the Child with an OpenSSH key. - For a Child-initiated link, the Child can log in to Home with an OpenSSH key (password login is not supported). -- OpenCodex is installed on the Child computer. +- OpenCodex 2.66.0 or later is installed on the Child computer, and on Home for a Child-initiated link. - Both computers run macOS or Linux. -- The Home dashboard has a full paired session. +- Links are started from the Home: its dashboard is opened on the Home computer itself (browser or desktop app, standalone install) or through a paired Hub session. -Password SSH and Windows are outside the current flow. For a Child-initiated link, open the standalone Child dashboard, choose **Child** → **Find Home**, select the SSH host for Home, check and confirm the host-key fingerprint, then choose **Connect as Child**. The Child must be able to log in to Home with an SSH key (password login is not supported), and `ocx` must be running on Home. The client tunnel port is `1024` or higher. After joining, the Child restarts and connects through Home. This option is available only on a standalone runtime. +Password SSH and Windows are outside the current flow. Connecting a computer as a Child from the dashboard (a Child-initiated link) is not available in this release: joining restarts OpenCodex on that computer, which would drop the Codex connections already running there, so the dashboard shows the **Child** role as unavailable. Home-initiated linking is the supported path: on the computer that should be Home, choose **Home** and add the other computer as a Child, as described below. ## Add a Child from `#remote` 1. Open the dashboard at `#remote` and switch Remote Link on. -2. Choose **Home**. -3. Select **Add child**. -4. Choose a host from the SSH candidates, or enter an SSH config alias. -5. Run the connection test and compare the offered host fingerprint with the fingerprint for the machine you intend to use. Comparing it helps detect a wrong host or a changed host key before SSH trusts the host. -6. Confirm the fingerprint, then connect the Child. +2. Choose **Home**, then **Continue**. The SSH host list opens. +3. Choose a host from the SSH candidates, or enter an SSH config alias. +4. Run the connection test and compare the offered host fingerprint with the fingerprint for the machine you intend to use. Comparing it helps detect a wrong host or a changed host key before SSH trusts the host. +5. Confirm the fingerprint, then connect the Child. The dashboard does not ask you to enter a token. It probes the host first, and it cannot apply the link until you explicitly confirm the fingerprint. @@ -46,6 +45,15 @@ ocx disconnect To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disconnects the client tunnel and revokes the link on Home over SSH. If Home revocation fails, it prints: `Home revoke failed; run ocx link revoke --link-id on the home.` +## Troubleshooting + +When a step fails, the dashboard shows the reason and, when SSH reported one, the last line of its error output under the message. + +- **Could not connect to the SSH host**: the host must accept your SSH key without a password prompt; `ssh -o BatchMode=yes true` must succeed from a terminal. A `ProxyCommand` helper such as `cloudflared` must be installed in `/opt/homebrew/bin`, `/usr/local/bin`, `~/.bun/bin`, `~/.local/bin` or another directory on the PATH OpenCodex runs with. +- **ocx was not found on the remote computer**: OpenCodex looks for `ocx` on the PATH of a non-interactive SSH session first, then in `~/.bun/bin`, `~/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin`. If it is installed elsewhere, add that directory to PATH in a file the remote shell reads for non-interactive sessions, such as `~/.zshenv` for zsh. +- **OpenCodex on the remote computer is too old**: run `ocx update` on that computer. Remote Link needs 2.66.0 or later. +- **The remote computer did not report an OpenCodex version**: `ocx --version` on that computer printed something else, for example the usage text of an unsupported Windows install. + ## Security The Child uses the Home computer's providers and provider credentials through the link. The Home creates a separate link key for each Child; removing the link revokes that key. Compare the host fingerprint before confirmation so a wrong machine or changed host key is not accepted by mistake. Dashboard sessions issued from a Tailscale identity cannot manage machine links. diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md index 0a959bb31b1..da3c499e3e0 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -9,20 +9,19 @@ description: SSH で OpenCodex の Home コンピューターと Child コンピ - Home から Child に OpenSSH キーでログインできること。 - Child から開始するリンクでは、Child から Home に OpenSSH キーでログインできる必要があります(パスワードログインには対応していません)。 -- Child に OpenCodex がインストールされていること。 +- Child に OpenCodex 2.66.0 以降がインストールされていること(Child から開始するリンクでは Home にも)。 - 両方のコンピューターが macOS または Linux であること。 -- Home のダッシュボードに完全なペアリング済みセッションがあること。 +- リンクは Home 側から開始すること。使うダッシュボードは、Home のコンピューター上で直接開いたもの(スタンドアロン環境のブラウザーまたはデスクトップアプリ)か、ペアリング済みの Hub セッションです。 -パスワード SSH と Windows は現在のフローに含まれません。Child からリンクを開始するには、スタンドアロンの Child ダッシュボードで **子** → **Home を探す** を選び、Home の SSH ホストを選択し、ホストキーのフィンガープリントを確認してから **子として接続** を選びます。Child から Home へ SSH キーでログインできる必要があり(パスワードログインには対応していません)、Home では `ocx` が実行中である必要があります。クライアントトンネルのポートは `1024` 以上です。参加後、Child は再起動して Home に接続します。この項目はスタンドアロンランタイムでのみ使用できます。 +パスワード SSH と Windows は現在のフローに含まれません。このリリースでは、ダッシュボードからコンピューターを Child として接続すること(Child から開始するリンク)はできません。参加するとそのコンピューターの OpenCodex が再起動し、すでに動いている Codex 接続が切断されるため、ダッシュボードでは **子** の役割を選択できません。サポートされているのは Home から開始するリンクです。Home にするコンピューターで **Home** を選び、下記の手順でもう一方のコンピューターを Child として追加してください。 ## `#remote` から Child を追加する 1. ダッシュボードで `#remote` を開き、Remote Link をオンにします。 -2. **Home** を選びます。 -3. **Add child** を選びます。 -4. SSH の候補からホストを選ぶか、SSH 設定のエイリアスを入力します。 -5. 接続テストを実行し、表示されたホストフィンガープリントを接続先コンピューターのものと比較します。比較すると、SSH がホストを信頼する前に、別のコンピューターや変更されたホストキーを検出できます。 -6. フィンガープリントを確認して Child を接続します。 +2. **Home** を選び、**Continue** を押します。SSH ホストの一覧が開きます。 +3. SSH の候補からホストを選ぶか、SSH 設定のエイリアスを入力します。 +4. 接続テストを実行し、表示されたホストフィンガープリントを接続先コンピューターのものと比較します。比較すると、SSH がホストを信頼する前に、別のコンピューターや変更されたホストキーを検出できます。 +5. フィンガープリントを確認して Child を接続します。 ダッシュボードはトークンの入力を求めません。先にホストをプローブし、フィンガープリントを明示的に確認するまでリンクを適用しません。 diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md index 8ef2559b0e7..457b95e8cfa 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -9,20 +9,19 @@ description: SSH로 OpenCodex Home 컴퓨터와 Child 컴퓨터를 연결합니 - Home 컴퓨터에서 OpenSSH 키 로그인으로 Child 컴퓨터에 접속할 수 있어야 합니다. - 자식이 시작하는 링크에서는 자식에서 OpenSSH 키 로그인으로 홈에 접속할 수 있어야 합니다(비밀번호 로그인은 지원하지 않음). -- Child 컴퓨터에 OpenCodex가 설치되어 있어야 합니다. +- Child 컴퓨터에 OpenCodex 2.66.0 이상이 설치되어 있어야 합니다(자식이 시작하는 링크에서는 Home에도). - 두 컴퓨터 모두 macOS 또는 Linux여야 합니다. -- Home 대시보드에 완전한 페어링 세션이 있어야 합니다. +- 링크는 홈에서 시작합니다. 홈 컴퓨터에서 직접 연 대시보드(독립형 설치의 브라우저 또는 데스크톱 앱)나 페어링된 Hub 세션을 사용해야 합니다. -비밀번호 SSH와 Windows는 현재 흐름에서 지원하지 않습니다. 자식이 연결을 시작하려면 독립형 런타임으로 실행 중인 자식의 대시보드에서 **자식** → **홈 찾기**를 선택하고, 홈(Home)으로 사용할 SSH 호스트를 고른 다음 호스트 키 지문을 확인하고 **자식으로 연결**을 누릅니다. 자식에서 홈으로 SSH 키 로그인을 할 수 있어야 하며(비밀번호 로그인은 지원하지 않음), 홈에서 `ocx`가 실행 중이어야 합니다. 클라이언트 터널 포트는 `1024` 이상이어야 합니다. 연결이 완료되면 자식이 재시작되고 홈에 연결됩니다. 이 메뉴는 독립형 런타임에서만 사용할 수 있습니다. +비밀번호 SSH와 Windows는 현재 흐름에서 지원하지 않습니다. 이번 릴리스에서는 대시보드에서 컴퓨터를 자식으로 연결하는 방식(자식이 시작하는 링크)을 쓸 수 없습니다. 자식으로 참여하면 그 컴퓨터의 OpenCodex가 다시 시작되어 이미 쓰고 있는 Codex 연결이 끊어지기 때문에, 대시보드에서는 **자식** 역할을 선택할 수 없습니다. 지원되는 방법은 홈에서 시작하는 링크입니다. 홈이 될 컴퓨터에서 **Home**을 선택하고, 아래 순서대로 다른 컴퓨터를 자식으로 추가하세요. ## `#remote`에서 Child 추가하기 1. 대시보드에서 `#remote`를 열고 Remote Link를 켭니다. -2. **Home**을 선택합니다. -3. **Add child**를 선택합니다. -4. SSH 후보에서 호스트를 선택하거나 SSH 설정의 alias를 입력합니다. -5. 연결 테스트를 실행하고 표시된 호스트 지문을 연결하려는 컴퓨터의 지문과 비교합니다. 비교하면 SSH가 호스트를 신뢰하기 전에 잘못된 컴퓨터나 변경된 호스트 키를 발견할 수 있습니다. -6. 지문을 확인한 뒤 Child를 연결합니다. +2. **Home**을 선택한 뒤 **Continue**를 누릅니다. SSH 호스트 목록이 열립니다. +3. SSH 후보에서 호스트를 선택하거나 SSH 설정의 alias를 입력합니다. +4. 연결 테스트를 실행하고 표시된 호스트 지문을 연결하려는 컴퓨터의 지문과 비교합니다. 비교하면 SSH가 호스트를 신뢰하기 전에 잘못된 컴퓨터나 변경된 호스트 키를 발견할 수 있습니다. +5. 지문을 확인한 뒤 Child를 연결합니다. 대시보드는 토큰 입력을 요구하지 않습니다. 먼저 호스트를 검사하며, 지문을 명시적으로 확인하기 전에는 링크를 적용하지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md index 1db5c571256..44906145861 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -9,20 +9,19 @@ description: Подключите компьютер OpenCodex Home к комп - Home может войти на Child по ключу OpenSSH. - Для связи, инициированной со стороны Child, Child должен входить на Home по ключу OpenSSH (вход по паролю не поддерживается). -- На Child установлен OpenCodex. +- На Child установлен OpenCodex 2.66.0 или новее (для связи со стороны Child — и на Home). - Оба компьютера работают под macOS или Linux. -- В панели Home есть полноценная сопряжённая сессия. +- Связь начинают со стороны Home: панель открыта на самом компьютере Home (браузер или настольное приложение в автономной установке) или через сопряжённую сессию Hub. -SSH с паролем и Windows сейчас не поддерживаются. Чтобы начать связь со стороны Child, откройте панель автономного Child, выберите **Дочерний** → **Найти Home**, укажите SSH-хост Home, проверьте и подтвердите отпечаток ключа хоста, затем выберите **Подключить как Child**. Child должен входить на Home по ключу SSH (вход по паролю не поддерживается), а на Home должен работать `ocx`. Порт клиентского туннеля должен быть `1024` или выше. После подключения Child перезапускается и подключается через Home. Этот пункт доступен только в автономном режиме. +SSH с паролем и Windows сейчас не поддерживаются. В этой версии подключить компьютер как Child из панели (связь со стороны Child) нельзя: подключение перезапускает OpenCodex на этом компьютере, из-за чего оборвутся уже работающие подключения Codex, поэтому в панели роль **Дочерний** недоступна. Поддерживаемый путь — связь со стороны Home: на компьютере, который должен стать Home, выберите **Home** и добавьте другой компьютер как Child, как описано ниже. ## Добавление Child из `#remote` 1. Откройте `#remote` в панели и включите Remote Link. -2. Выберите **Home**. -3. Выберите **Add child**. -4. Выберите хост среди кандидатов SSH или введите псевдоним из конфигурации SSH. -5. Запустите проверку соединения и сравните показанный отпечаток хоста с отпечатком нужного компьютера. Сравнение помогает обнаружить неправильный компьютер или изменённый ключ хоста до того, как SSH начнёт ему доверять. -6. Подтвердите отпечаток и подключите Child. +2. Выберите **Home**, затем **Continue**. Откроется список SSH-хостов. +3. Выберите хост среди кандидатов SSH или введите псевдоним из конфигурации SSH. +4. Запустите проверку соединения и сравните показанный отпечаток хоста с отпечатком нужного компьютера. Сравнение помогает обнаружить неправильный компьютер или изменённый ключ хоста до того, как SSH начнёт ему доверять. +5. Подтвердите отпечаток и подключите Child. Панель не просит вводить токен. Сначала выполняется проверка хоста, и применить связь можно только после явного подтверждения отпечатка. diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md index a4b85cb2277..764ee6cd3bd 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -9,20 +9,19 @@ Makine bağlantısı, bir OpenCodex **Home** bilgisayarını bir **Child** bilgi - Home bilgisayarı, Child bilgisayarına OpenSSH anahtarıyla giriş yapabilir. - Child tarafından başlatılan bağlantı için Child, Home bilgisayarına OpenSSH anahtarıyla giriş yapabilmelidir (parola girişi desteklenmez). -- Child bilgisayarında OpenCodex kuruludur. +- Child bilgisayarında OpenCodex 2.66.0 veya sonrası kuruludur (Child tarafından başlatılan bağlantıda Home üzerinde de). - Her iki bilgisayar da macOS veya Linux çalıştırır. -- Home kontrol panelinde tam bir eşleştirilmiş oturum vardır. +- Bağlantı Home tarafından başlatılır: kontrol paneli Home bilgisayarının kendisinde (bağımsız kurulumda tarayıcı veya masaüstü uygulaması) ya da eşleştirilmiş bir Hub oturumu üzerinden açılır. -Parolalı SSH ve Windows mevcut akışın dışındadır. Child üzerinden bağlantı başlatmak için bağımsız çalışan Child kontrol panelinde **Çocuk** → **Home'u bul** seçeneklerini izleyin, Home için SSH ana bilgisayarını seçin, ana bilgisayar anahtarı parmak izini kontrol edip onaylayın ve ardından **Çocuk olarak bağlan** seçeneğini seçin. Child, Home bilgisayarına SSH anahtarıyla giriş yapabilmelidir (parola girişi desteklenmez) ve Home üzerinde `ocx` çalışıyor olmalıdır. İstemci tüneli portu `1024` veya daha yüksek olmalıdır. Katılma işleminden sonra Child yeniden başlar ve Home bilgisayarına bağlanır. Bu seçenek yalnızca standalone çalışma zamanında kullanılabilir. +Parolalı SSH ve Windows mevcut akışın dışındadır. Bu sürümde bir bilgisayarı kontrol panelinden Child olarak bağlamak (Child tarafından başlatılan bağlantı) kullanılamaz: katılmak o bilgisayardaki OpenCodex'i yeniden başlatır ve çalışan Codex bağlantılarını keser; bu yüzden kontrol panelinde **Çocuk** rolü kullanılamaz. Desteklenen yol, Home tarafından başlatılan bağlantıdır: Home olacak bilgisayarda **Home** seçeneğini seçin ve diğer bilgisayarı aşağıda anlatıldığı gibi Child olarak ekleyin. ## `#remote` üzerinden Child ekleme 1. Kontrol panelinde `#remote` sayfasını açın ve Remote Link'i açın. -2. **Home** seçeneğini seçin. -3. **Add child** seçeneğini seçin. -4. SSH adaylarından bir ana bilgisayar seçin veya SSH yapılandırmasındaki diğer adı girin. -5. Bağlantı testini çalıştırın ve gösterilen ana bilgisayar parmak izini bağlanmak istediğiniz bilgisayarın parmak iziyle karşılaştırın. Karşılaştırma, SSH ana bilgisayara güvenmeden önce yanlış bilgisayarı veya değişmiş anahtarını fark etmenize yardımcı olur. -6. Parmak izini onaylayın, ardından Child'ı bağlayın. +2. **Home** seçeneğini seçin, ardından **Continue** düğmesine basın. SSH ana bilgisayar listesi açılır. +3. SSH adaylarından bir ana bilgisayar seçin veya SSH yapılandırmasındaki diğer adı girin. +4. Bağlantı testini çalıştırın ve gösterilen ana bilgisayar parmak izini bağlanmak istediğiniz bilgisayarın parmak iziyle karşılaştırın. Karşılaştırma, SSH ana bilgisayara güvenmeden önce yanlış bilgisayarı veya değişmiş anahtarını fark etmenize yardımcı olur. +5. Parmak izini onaylayın, ardından Child'ı bağlayın. Kontrol paneli belirteç girmenizi istemez. Önce ana bilgisayarı yoklar ve parmak izini açıkça onaylamadan bağlantıyı uygulamaz. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-link.md b/docs-site/src/content/docs/zh-cn/guides/remote-link.md index be53fe62bcd..f72177fa2c7 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -9,20 +9,19 @@ description: 通过 SSH 将 OpenCodex 主机与子机连接起来。 - 主机可以使用 OpenSSH 密钥登录子机。 - 对于由子机发起的链接,子机必须能使用 OpenSSH 密钥登录主机(不支持密码登录)。 -- 子机已安装 OpenCodex。 +- 子机已安装 OpenCodex 2.66.0 或更高版本(由子机发起的链接还要求主机也满足)。 - 两台电脑运行 macOS 或 Linux。 -- 主机控制台拥有完整的已配对会话。 +- 链接从 Home 一侧发起:控制台需在 Home 电脑本机打开(独立安装的浏览器或桌面应用),或通过已配对的 Hub 会话打开。 -密码 SSH 和 Windows 不在当前流程中。要从子机发起连接,请在独立运行的子机控制台中选择 **子设备** → **查找 Home**,选择 Home 的 SSH 主机,检查并确认主机密钥指纹,然后选择 **以子设备身份连接**。子机必须能使用 SSH 密钥登录 Home(不支持密码登录),并且 Home 上正在运行 `ocx`。客户端隧道端口必须为 `1024` 或更高。加入后,子机会重启并连接到 Home。此入口仅在 standalone 运行时提供。 +密码 SSH 和 Windows 不在当前流程中。此版本不支持从控制台把电脑连接为子机(即由子机发起的链接):加入会重新启动这台电脑上的 OpenCodex,已在运行的 Codex 连接会因此中断,因此控制台中的 **子设备** 角色不可选。受支持的方式是由 Home 发起链接:在要作为 Home 的电脑上选择 **Home**,再按下文步骤把另一台电脑添加为子机。 ## 从 `#remote` 添加子机 1. 打开控制台的 `#remote`,开启 Remote Link。 -2. 选择 **Home**。 -3. 选择 **Add child**。 -4. 从 SSH 候选主机中选择主机,或输入 SSH 配置别名。 -5. 运行连接测试,并将显示的主机指纹与目标电脑的指纹进行比较。比较指纹可以在 SSH 信任主机前发现错误的电脑或已更换的主机密钥。 -6. 确认指纹,然后连接子机。 +2. 选择 **Home**,然后点击 **Continue**。SSH 主机列表会打开。 +3. 从 SSH 候选主机中选择主机,或输入 SSH 配置别名。 +4. 运行连接测试,并将显示的主机指纹与目标电脑的指纹进行比较。比较指纹可以在 SSH 信任主机前发现错误的电脑或已更换的主机密钥。 +5. 确认指纹,然后连接子机。 控制台不会要求输入令牌。它会先探测主机,只有明确确认指纹后才能应用链接。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-link.md b/docs-site/src/content/docs/zh-tw/guides/remote-link.md index b8b6b5c51f7..611c366f271 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -9,20 +9,19 @@ description: 透過 SSH 連接 OpenCodex Home 電腦與 Child 電腦。 - Home 可以使用 OpenSSH 金鑰登入 Child。 - 對於由 Child 發起的連結,Child 必須能使用 OpenSSH 金鑰登入 Home(不支援密碼登入)。 -- Child 已安裝 OpenCodex。 +- Child 已安裝 OpenCodex 2.66.0 或更新版本(由 Child 發起的連結也要求 Home 符合)。 - 兩台電腦執行 macOS 或 Linux。 -- Home 儀表板擁有完整的已配對工作階段。 +- 連結由 Home 端發起:儀表板需在 Home 電腦本機開啟(獨立安裝的瀏覽器或桌面應用程式),或透過已配對的 Hub 工作階段開啟。 -密碼 SSH 和 Windows 不在目前流程中。若要從 Child 發起連線,請在獨立執行的 Child 儀表板中選擇 **子裝置** → **尋找 Home**,選取 Home 的 SSH 主機,檢查並確認主機金鑰指紋,然後選擇 **以子裝置身分連線**。Child 必須能使用 SSH 金鑰登入 Home(不支援密碼登入),而且 Home 上正在執行 `ocx`。用戶端通道連接埠必須是 `1024` 或更高。加入後,Child 會重新啟動並連線到 Home。這個入口只在 standalone 執行個體中提供。 +密碼 SSH 和 Windows 不在目前流程中。此版本不支援從儀表板將電腦連線為 Child(即由 Child 發起的連結):加入會重新啟動這台電腦上的 OpenCodex,已在執行的 Codex 連線會因此中斷,因此儀表板中的 **子裝置** 角色無法選取。受支援的方式是由 Home 發起連結:在要作為 Home 的電腦上選擇 **Home**,再依下方步驟將另一台電腦新增為 Child。 ## 從 `#remote` 新增 Child 1. 開啟儀表板的 `#remote`,開啟 Remote Link。 -2. 選擇 **Home**。 -3. 選擇 **Add child**。 -4. 從 SSH 候選主機選擇主機,或輸入 SSH 設定別名。 -5. 執行連線測試,並將顯示的主機指紋與目標電腦的指紋比較。比較指紋可在 SSH 信任主機前發現錯誤的電腦或已變更的主機金鑰。 -6. 確認指紋,然後連接 Child。 +2. 選擇 **Home**,然後按 **Continue**。SSH 主機列表會開啟。 +3. 從 SSH 候選主機選擇主機,或輸入 SSH 設定別名。 +4. 執行連線測試,並將顯示的主機指紋與目標電腦的指紋比較。比較指紋可在 SSH 信任主機前發現錯誤的電腦或已變更的主機金鑰。 +5. 確認指紋,然後連接 Child。 儀表板不會要求輸入權杖。它會先探測主機,只有明確確認指紋後才能套用連結。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 2be9e6b24f8..34266a80d56 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3323,6 +3323,7 @@ export const de: Record = { "link.close": "Schließen", "link.cancel": "Abbrechen", "remoteLink.childDisabled": "Kind-Verbindungen können nur von einer eigenständigen Laufzeit gestartet werden.", + "remoteLink.childJoinUnavailable": "Diesen Computer über das Dashboard als Kind zu verbinden ist in dieser Version nicht verfügbar: Dabei wird OpenCodex neu gestartet, und bestehende Codex-Verbindungen würden abbrechen. Starten Sie die Verbindung stattdessen vom Home aus: Wählen Sie auf dem Computer, der Home sein soll, „Zuhause“ und fügen Sie den anderen Computer als Kind hinzu.", "remoteLink.findHome.title": "Home suchen", "remoteLink.findHome.body": "Wählen Sie den Home-Computer für dieses Kind aus.", "remoteLink.findHome.action": "Home suchen", @@ -3354,8 +3355,8 @@ export const de: Record = { "remoteLink.direction.client": "Vom Client gestartet", "remoteLink.error.admission_timeout": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "remoteLink.error.compensation_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", - "remoteLink.error.fingerprint_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", - "remoteLink.error.forbidden": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.fingerprint_failed": "Der SSH-Hostschlüssel konnte nicht gelesen werden. Testen Sie die Verbindung erneut.", + "remoteLink.error.forbidden": "Diese Dashboard-Sitzung kann keine Remote-Links verwalten. Öffnen Sie das Dashboard auf diesem Computer (eigenständige Installation) oder verwenden Sie eine gekoppelte Hub-Sitzung.", "remoteLink.error.host_confirmation_expired": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "remoteLink.error.host_fingerprint_mismatch": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "remoteLink.error.host_not_confirmed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", @@ -3368,14 +3369,17 @@ export const de: Record = { "remoteLink.error.link_exists": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "remoteLink.error.link_not_found": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "remoteLink.error.link_remove_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", - "remoteLink.error.link_unavailable": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", - "remoteLink.error.listener_unavailable": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", - "remoteLink.error.probe_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", - "remoteLink.error.remote_connect_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.link_unavailable": "Remote Link ist in dieser Laufzeit nicht verfügbar. Starten Sie OpenCodex neu und versuchen Sie es erneut.", + "remoteLink.error.listener_unavailable": "Der Remote-Link-Listener konnte auf diesem Computer nicht starten. Prüfen Sie, ob ein Portkonflikt besteht, und versuchen Sie es erneut.", + "remoteLink.error.probe_failed": "Keine Verbindung zum SSH-Host möglich. Prüfen Sie, ob er Ihren SSH-Schlüssel akzeptiert und ob ein benötigter ProxyCommand-Helfer installiert ist.", + "remoteLink.error.remote_connect_failed": "Der entfernte Computer konnte sich nicht mit diesem Home verbinden. Prüfen Sie dort den OpenCodex-Dienst und versuchen Sie es erneut.", "remoteLink.error.remote_disconnect_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", - "remoteLink.error.remote_port_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.remote_ocx_missing": "ocx wurde auf dem entfernten Computer nicht gefunden. Installieren Sie dort OpenCodex oder stellen Sie sicher, dass ocx im PATH nicht interaktiver SSH-Sitzungen liegt.", + "remoteLink.error.remote_ocx_outdated": "OpenCodex auf dem entfernten Computer ist für Remote Link zu alt. Führen Sie dort ocx update aus (2.66.0 oder neuer) und versuchen Sie es erneut.", + "remoteLink.error.remote_ocx_unrecognized": "Der entfernte Computer hat keine OpenCodex-Version gemeldet. Remote Link benötigt OpenCodex 2.66.0 oder neuer unter macOS oder Linux.", + "remoteLink.error.remote_port_failed": "Der entfernte Computer konnte seinen Link-Port nicht melden. Prüfen Sie, ob dort OpenCodex 2.66.0 oder neuer läuft, und versuchen Sie es erneut.", "remoteLink.error.tailscale_session_refused": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", - "remoteLink.error.version_probe_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.version_probe_failed": "ocx konnte auf dem entfernten Computer nicht über SSH ausgeführt werden. Prüfen Sie den SSH-Zugang und versuchen Sie es erneut.", "remoteLink.error.generic": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "remoteLink.reason.auth": "Die Authentifizierung ist fehlgeschlagen.", "remoteLink.reason.hostkey": "Der Hostschlüssel konnte nicht verifiziert werden.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6e614cd9d45..ad00e8ad053 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3357,6 +3357,7 @@ export const en = { "link.close": "Close", "link.cancel": "Cancel", "remoteLink.childDisabled": "Child links can only be started from a standalone runtime.", + "remoteLink.childJoinUnavailable": "Connecting this computer as a Child from the dashboard is not available in this release: joining restarts OpenCodex and would drop existing Codex connections. Start the link from the Home instead: on the computer that should be Home, choose Home and add the other computer as a Child.", "remoteLink.findHome.title": "Find Home", "remoteLink.findHome.body": "Choose the Home computer to connect this Child to.", "remoteLink.findHome.action": "Find Home", @@ -3388,8 +3389,8 @@ export const en = { "remoteLink.direction.client": "Client initiated", "remoteLink.error.admission_timeout": "Remote link request could not be completed.", "remoteLink.error.compensation_failed": "Remote link request could not be completed.", - "remoteLink.error.fingerprint_failed": "Remote link request could not be completed.", - "remoteLink.error.forbidden": "Remote link request could not be completed.", + "remoteLink.error.fingerprint_failed": "The SSH host key could not be read. Test the connection again.", + "remoteLink.error.forbidden": "This dashboard session cannot manage remote links. Open the dashboard on this computer (standalone install), or use a paired Hub session.", "remoteLink.error.host_confirmation_expired": "Remote link request could not be completed.", "remoteLink.error.host_fingerprint_mismatch": "Remote link request could not be completed.", "remoteLink.error.host_not_confirmed": "Remote link request could not be completed.", @@ -3402,14 +3403,17 @@ export const en = { "remoteLink.error.link_exists": "Remote link request could not be completed.", "remoteLink.error.link_not_found": "Remote link request could not be completed.", "remoteLink.error.link_remove_failed": "Remote link request could not be completed.", - "remoteLink.error.link_unavailable": "Remote link request could not be completed.", - "remoteLink.error.listener_unavailable": "Remote link request could not be completed.", - "remoteLink.error.probe_failed": "Remote link request could not be completed.", - "remoteLink.error.remote_connect_failed": "Remote link request could not be completed.", + "remoteLink.error.link_unavailable": "Remote Link is not available in this runtime. Restart OpenCodex and retry.", + "remoteLink.error.listener_unavailable": "The Remote Link listener could not start on this computer. Check for a port conflict and retry.", + "remoteLink.error.probe_failed": "Could not connect to the SSH host. Check that it accepts your SSH key and that any ProxyCommand helper is installed.", + "remoteLink.error.remote_connect_failed": "The remote computer could not connect to this Home. Check its OpenCodex service and retry.", "remoteLink.error.remote_disconnect_failed": "Remote link request could not be completed.", - "remoteLink.error.remote_port_failed": "Remote link request could not be completed.", + "remoteLink.error.remote_ocx_missing": "ocx was not found on the remote computer. Install OpenCodex there, or make sure ocx is on the PATH of non-interactive SSH sessions.", + "remoteLink.error.remote_ocx_outdated": "OpenCodex on the remote computer is too old for Remote Link. Run ocx update there (2.66.0 or later), then retry.", + "remoteLink.error.remote_ocx_unrecognized": "The remote computer did not report an OpenCodex version. Remote Link needs OpenCodex 2.66.0 or later on macOS or Linux.", + "remoteLink.error.remote_port_failed": "The remote computer could not report its link port. Check that OpenCodex 2.66.0 or later is running there and retry.", "remoteLink.error.tailscale_session_refused": "Remote link request could not be completed.", - "remoteLink.error.version_probe_failed": "Remote link request could not be completed.", + "remoteLink.error.version_probe_failed": "Could not run ocx on the remote computer over SSH. Check SSH access and retry.", "remoteLink.error.generic": "Remote link request could not be completed.", "remoteLink.reason.auth": "Authentication failed.", "remoteLink.reason.hostkey": "The host key could not be verified.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 8b1012a1797..e8068e096f4 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3312,6 +3312,7 @@ export const fr: Record = { "link.close": "Fermer", "link.cancel": "Annuler", "remoteLink.childDisabled": "Les liens Enfant ne peuvent être lancés que depuis un runtime autonome.", + "remoteLink.childJoinUnavailable": "Connecter cet ordinateur comme Enfant depuis le tableau de bord n’est pas disponible dans cette version : la connexion redémarre OpenCodex et couperait les connexions Codex existantes. Lancez plutôt la liaison depuis le Home : sur l’ordinateur qui doit être le Home, choisissez « Accueil », puis ajoutez l’autre ordinateur comme Enfant.", "remoteLink.findHome.title": "Trouver le Home", "remoteLink.findHome.body": "Choisissez l’ordinateur Home auquel connecter cet Enfant.", "remoteLink.findHome.action": "Trouver le Home", @@ -3343,8 +3344,8 @@ export const fr: Record = { "remoteLink.direction.client": "Lancé par le client", "remoteLink.error.admission_timeout": "La demande de lien distant n’a pas pu aboutir.", "remoteLink.error.compensation_failed": "La demande de lien distant n’a pas pu aboutir.", - "remoteLink.error.fingerprint_failed": "La demande de lien distant n’a pas pu aboutir.", - "remoteLink.error.forbidden": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.fingerprint_failed": "La clé d’hôte SSH n’a pas pu être lue. Relancez le test de connexion.", + "remoteLink.error.forbidden": "Cette session du tableau de bord ne peut pas gérer les liens distants. Ouvrez le tableau de bord sur cet ordinateur (installation autonome) ou utilisez une session hub jumelée.", "remoteLink.error.host_confirmation_expired": "La demande de lien distant n’a pas pu aboutir.", "remoteLink.error.host_fingerprint_mismatch": "La demande de lien distant n’a pas pu aboutir.", "remoteLink.error.host_not_confirmed": "La demande de lien distant n’a pas pu aboutir.", @@ -3357,14 +3358,17 @@ export const fr: Record = { "remoteLink.error.link_exists": "La demande de lien distant n’a pas pu aboutir.", "remoteLink.error.link_not_found": "La demande de lien distant n’a pas pu aboutir.", "remoteLink.error.link_remove_failed": "La demande de lien distant n’a pas pu aboutir.", - "remoteLink.error.link_unavailable": "La demande de lien distant n’a pas pu aboutir.", - "remoteLink.error.listener_unavailable": "La demande de lien distant n’a pas pu aboutir.", - "remoteLink.error.probe_failed": "La demande de lien distant n’a pas pu aboutir.", - "remoteLink.error.remote_connect_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.link_unavailable": "Remote Link n’est pas disponible dans ce runtime. Redémarrez OpenCodex, puis réessayez.", + "remoteLink.error.listener_unavailable": "L’écouteur Remote Link n’a pas pu démarrer sur cet ordinateur. Vérifiez qu’aucun conflit de port n’existe, puis réessayez.", + "remoteLink.error.probe_failed": "Impossible de se connecter à l’hôte SSH. Vérifiez qu’il accepte votre clé SSH et que l’assistant ProxyCommand éventuellement requis est installé.", + "remoteLink.error.remote_connect_failed": "L’ordinateur distant n’a pas pu se connecter à ce Home. Vérifiez son service OpenCodex, puis réessayez.", "remoteLink.error.remote_disconnect_failed": "La demande de lien distant n’a pas pu aboutir.", - "remoteLink.error.remote_port_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.remote_ocx_missing": "ocx est introuvable sur l’ordinateur distant. Installez-y OpenCodex, ou vérifiez que ocx figure dans le PATH des sessions SSH non interactives.", + "remoteLink.error.remote_ocx_outdated": "OpenCodex est trop ancien sur l’ordinateur distant pour Remote Link. Exécutez-y ocx update (2.66.0 ou ultérieur), puis réessayez.", + "remoteLink.error.remote_ocx_unrecognized": "L’ordinateur distant n’a pas indiqué de version d’OpenCodex. Remote Link nécessite OpenCodex 2.66.0 ou ultérieur sous macOS ou Linux.", + "remoteLink.error.remote_port_failed": "L’ordinateur distant n’a pas pu indiquer son port de liaison. Vérifiez qu’OpenCodex 2.66.0 ou ultérieur y est en cours d’exécution, puis réessayez.", "remoteLink.error.tailscale_session_refused": "La demande de lien distant n’a pas pu aboutir.", - "remoteLink.error.version_probe_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.version_probe_failed": "Impossible d’exécuter ocx sur l’ordinateur distant via SSH. Vérifiez l’accès SSH, puis réessayez.", "remoteLink.error.generic": "La demande de lien distant n’a pas pu aboutir.", "remoteLink.reason.auth": "L’authentification a échoué.", "remoteLink.reason.hostkey": "La clé d’hôte n’a pas pu être vérifiée.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 34df0413060..c3c6d6eb60d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3345,6 +3345,7 @@ export const ja: Record = { "link.close": "閉じる", "link.cancel": "キャンセル", "remoteLink.childDisabled": "子リンクを開始できるのはスタンドアロンランタイムだけです。", + "remoteLink.childJoinUnavailable": "このリリースでは、ダッシュボードからこのコンピューターを子として接続することはできません。接続すると OpenCodex が再起動し、既存の Codex 接続が切断されるためです。代わりに Home 側からリンクを開始してください。Home にするコンピューターで「ホーム」を選び、もう一方のコンピューターを子として追加します。", "remoteLink.findHome.title": "Home を探す", "remoteLink.findHome.body": "この子コンピューターを接続する Home を選択してください。", "remoteLink.findHome.action": "Home を探す", @@ -3376,8 +3377,8 @@ export const ja: Record = { "remoteLink.direction.client": "クライアント開始", "remoteLink.error.admission_timeout": "リモートリンクのリクエストを完了できませんでした。", "remoteLink.error.compensation_failed": "リモートリンクのリクエストを完了できませんでした。", - "remoteLink.error.fingerprint_failed": "リモートリンクのリクエストを完了できませんでした。", - "remoteLink.error.forbidden": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.fingerprint_failed": "SSH ホストキーを読み取れませんでした。接続テストをもう一度実行してください。", + "remoteLink.error.forbidden": "このダッシュボードセッションではリモートリンクを管理できません。このコンピューター上でダッシュボードを開く(スタンドアロン環境)か、ペアリング済みのハブセッションを使用してください。", "remoteLink.error.host_confirmation_expired": "リモートリンクのリクエストを完了できませんでした。", "remoteLink.error.host_fingerprint_mismatch": "リモートリンクのリクエストを完了できませんでした。", "remoteLink.error.host_not_confirmed": "リモートリンクのリクエストを完了できませんでした。", @@ -3390,14 +3391,17 @@ export const ja: Record = { "remoteLink.error.link_exists": "リモートリンクのリクエストを完了できませんでした。", "remoteLink.error.link_not_found": "リモートリンクのリクエストを完了できませんでした。", "remoteLink.error.link_remove_failed": "リモートリンクのリクエストを完了できませんでした。", - "remoteLink.error.link_unavailable": "リモートリンクのリクエストを完了できませんでした。", - "remoteLink.error.listener_unavailable": "リモートリンクのリクエストを完了できませんでした。", - "remoteLink.error.probe_failed": "リモートリンクのリクエストを完了できませんでした。", - "remoteLink.error.remote_connect_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.link_unavailable": "このランタイムではリモートリンクを利用できません。OpenCodex を再起動してから再試行してください。", + "remoteLink.error.listener_unavailable": "このコンピューターでリモートリンクのリスナーを起動できませんでした。ポートの競合がないか確認してから再試行してください。", + "remoteLink.error.probe_failed": "SSH ホストに接続できませんでした。SSH キーで認証できるか、必要な ProxyCommand ヘルパーがインストールされているかを確認してください。", + "remoteLink.error.remote_connect_failed": "リモートのコンピューターがこの Home に接続できませんでした。そのコンピューターの OpenCodex サービスを確認してから再試行してください。", "remoteLink.error.remote_disconnect_failed": "リモートリンクのリクエストを完了できませんでした。", - "remoteLink.error.remote_port_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.remote_ocx_missing": "リモートのコンピューターで ocx が見つかりませんでした。そこに OpenCodex をインストールするか、非対話 SSH セッションの PATH に ocx があることを確認してください。", + "remoteLink.error.remote_ocx_outdated": "リモートのコンピューターの OpenCodex が古く、リモートリンクに対応していません。そのコンピューターで ocx update を実行して 2.66.0 以降にしてから再試行してください。", + "remoteLink.error.remote_ocx_unrecognized": "リモートのコンピューターが OpenCodex のバージョンを返しませんでした。リモートリンクには macOS または Linux 上の OpenCodex 2.66.0 以降が必要です。", + "remoteLink.error.remote_port_failed": "リモートのコンピューターがリンクポートを返しませんでした。そのコンピューターで OpenCodex 2.66.0 以降が動作しているか確認してから再試行してください。", "remoteLink.error.tailscale_session_refused": "リモートリンクのリクエストを完了できませんでした。", - "remoteLink.error.version_probe_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.version_probe_failed": "SSH 経由でリモートのコンピューターの ocx を実行できませんでした。SSH アクセスを確認してから再試行してください。", "remoteLink.error.generic": "リモートリンクのリクエストを完了できませんでした。", "remoteLink.reason.auth": "認証に失敗しました。", "remoteLink.reason.hostkey": "ホストキーを確認できませんでした。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 40d09ec0115..aa00df58565 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3345,6 +3345,7 @@ export const ko: Record = { "link.close": "닫기", "link.cancel": "취소", "remoteLink.childDisabled": "자식 링크는 독립형 런타임에서만 시작할 수 있습니다.", + "remoteLink.childJoinUnavailable": "이번 릴리스에서는 대시보드에서 이 컴퓨터를 자식으로 연결할 수 없습니다. 자식으로 연결하면 OpenCodex가 다시 시작되어 지금 쓰고 있는 Codex 연결이 끊어지기 때문입니다. 대신 홈에서 연결을 시작하세요. 홈이 될 컴퓨터에서 홈을 선택한 뒤 다른 컴퓨터를 자식으로 추가하면 됩니다.", "remoteLink.findHome.title": "홈 찾기", "remoteLink.findHome.body": "이 자식 컴퓨터를 연결할 홈 컴퓨터를 선택하세요.", "remoteLink.findHome.action": "홈 찾기", @@ -3376,8 +3377,8 @@ export const ko: Record = { "remoteLink.direction.client": "클라이언트 시작", "remoteLink.error.admission_timeout": "원격 연결 요청을 완료하지 못했습니다.", "remoteLink.error.compensation_failed": "원격 연결 요청을 완료하지 못했습니다.", - "remoteLink.error.fingerprint_failed": "원격 연결 요청을 완료하지 못했습니다.", - "remoteLink.error.forbidden": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.fingerprint_failed": "SSH 호스트 키를 읽지 못했습니다. 연결 테스트를 다시 실행하세요.", + "remoteLink.error.forbidden": "이 대시보드 세션으로는 원격 연결을 관리할 수 없습니다. 이 컴퓨터에서 대시보드를 열거나(독립형 설치), 페어링된 Hub 세션을 사용하세요.", "remoteLink.error.host_confirmation_expired": "원격 연결 요청을 완료하지 못했습니다.", "remoteLink.error.host_fingerprint_mismatch": "원격 연결 요청을 완료하지 못했습니다.", "remoteLink.error.host_not_confirmed": "원격 연결 요청을 완료하지 못했습니다.", @@ -3390,14 +3391,17 @@ export const ko: Record = { "remoteLink.error.link_exists": "원격 연결 요청을 완료하지 못했습니다.", "remoteLink.error.link_not_found": "원격 연결 요청을 완료하지 못했습니다.", "remoteLink.error.link_remove_failed": "원격 연결 요청을 완료하지 못했습니다.", - "remoteLink.error.link_unavailable": "원격 연결 요청을 완료하지 못했습니다.", - "remoteLink.error.listener_unavailable": "원격 연결 요청을 완료하지 못했습니다.", - "remoteLink.error.probe_failed": "원격 연결 요청을 완료하지 못했습니다.", - "remoteLink.error.remote_connect_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.link_unavailable": "이 런타임에서는 원격 연결을 사용할 수 없습니다. OpenCodex를 다시 시작한 뒤 다시 시도하세요.", + "remoteLink.error.listener_unavailable": "이 컴퓨터에서 원격 연결 리스너를 시작하지 못했습니다. 포트 충돌이 있는지 확인한 뒤 다시 시도하세요.", + "remoteLink.error.probe_failed": "SSH 호스트에 접속하지 못했습니다. SSH 키로 로그인할 수 있는지, ProxyCommand 도우미가 설치되어 있는지 확인하세요.", + "remoteLink.error.remote_connect_failed": "원격 컴퓨터를 이 홈에 연결하지 못했습니다. 그 컴퓨터의 OpenCodex 서비스를 확인한 뒤 다시 시도하세요.", "remoteLink.error.remote_disconnect_failed": "원격 연결 요청을 완료하지 못했습니다.", - "remoteLink.error.remote_port_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.remote_ocx_missing": "원격 컴퓨터에서 ocx를 찾지 못했습니다. 그 컴퓨터에 OpenCodex를 설치하거나, 비대화형 SSH 세션의 PATH에 ocx가 있는지 확인하세요.", + "remoteLink.error.remote_ocx_outdated": "원격 컴퓨터의 OpenCodex가 원격 연결을 쓰기에는 오래되었습니다. 그 컴퓨터에서 ocx update를 실행해 2.66.0 이상으로 올린 뒤 다시 시도하세요.", + "remoteLink.error.remote_ocx_unrecognized": "원격 컴퓨터가 OpenCodex 버전을 알려 주지 않았습니다. 원격 연결에는 macOS 또는 Linux의 OpenCodex 2.66.0 이상이 필요합니다.", + "remoteLink.error.remote_port_failed": "원격 컴퓨터의 링크 포트를 확인하지 못했습니다. 그 컴퓨터에서 OpenCodex 2.66.0 이상이 실행 중인지 확인한 뒤 다시 시도하세요.", "remoteLink.error.tailscale_session_refused": "원격 연결 요청을 완료하지 못했습니다.", - "remoteLink.error.version_probe_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.version_probe_failed": "SSH로 원격 컴퓨터의 ocx를 실행하지 못했습니다. SSH 접속을 확인한 뒤 다시 시도하세요.", "remoteLink.error.generic": "원격 연결 요청을 완료하지 못했습니다.", "remoteLink.reason.auth": "인증에 실패했습니다.", "remoteLink.reason.hostkey": "호스트 키를 확인하지 못했습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 6f4a0a42778..10ea91ba4f0 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3346,6 +3346,7 @@ export const ru: Record = { "link.close": "Закрыть", "link.cancel": "Отмена", "remoteLink.childDisabled": "Связь с дочерним компьютером можно начать только из автономного режима.", + "remoteLink.childJoinUnavailable": "В этой версии подключить этот компьютер как дочерний из панели нельзя: подключение перезапускает OpenCodex и оборвёт текущие подключения Codex. Начните связь со стороны Home: на компьютере, который должен стать Home, выберите «Главный» и добавьте другой компьютер как дочерний.", "remoteLink.findHome.title": "Найти Home", "remoteLink.findHome.body": "Выберите компьютер Home, к которому подключить этот Child.", "remoteLink.findHome.action": "Найти Home", @@ -3377,8 +3378,8 @@ export const ru: Record = { "remoteLink.direction.client": "Инициировано клиентом", "remoteLink.error.admission_timeout": "Не удалось завершить запрос удалённой связи.", "remoteLink.error.compensation_failed": "Не удалось завершить запрос удалённой связи.", - "remoteLink.error.fingerprint_failed": "Не удалось завершить запрос удалённой связи.", - "remoteLink.error.forbidden": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.fingerprint_failed": "Не удалось прочитать ключ SSH-хоста. Запустите проверку соединения ещё раз.", + "remoteLink.error.forbidden": "Этот сеанс панели не может управлять удалёнными связями. Откройте панель на этом компьютере (автономная установка) или используйте сопряжённый сеанс хаба.", "remoteLink.error.host_confirmation_expired": "Не удалось завершить запрос удалённой связи.", "remoteLink.error.host_fingerprint_mismatch": "Не удалось завершить запрос удалённой связи.", "remoteLink.error.host_not_confirmed": "Не удалось завершить запрос удалённой связи.", @@ -3391,14 +3392,17 @@ export const ru: Record = { "remoteLink.error.link_exists": "Не удалось завершить запрос удалённой связи.", "remoteLink.error.link_not_found": "Не удалось завершить запрос удалённой связи.", "remoteLink.error.link_remove_failed": "Не удалось завершить запрос удалённой связи.", - "remoteLink.error.link_unavailable": "Не удалось завершить запрос удалённой связи.", - "remoteLink.error.listener_unavailable": "Не удалось завершить запрос удалённой связи.", - "remoteLink.error.probe_failed": "Не удалось завершить запрос удалённой связи.", - "remoteLink.error.remote_connect_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.link_unavailable": "Удалённая связь недоступна в этой среде выполнения. Перезапустите OpenCodex и повторите попытку.", + "remoteLink.error.listener_unavailable": "Не удалось запустить приёмник удалённой связи на этом компьютере. Проверьте, нет ли конфликта портов, и повторите попытку.", + "remoteLink.error.probe_failed": "Не удалось подключиться к SSH-хосту. Убедитесь, что он принимает ваш ключ SSH и что нужная вспомогательная программа ProxyCommand установлена.", + "remoteLink.error.remote_connect_failed": "Удалённый компьютер не смог подключиться к этому Home. Проверьте службу OpenCodex на нём и повторите попытку.", "remoteLink.error.remote_disconnect_failed": "Не удалось завершить запрос удалённой связи.", - "remoteLink.error.remote_port_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.remote_ocx_missing": "На удалённом компьютере не найден ocx. Установите там OpenCodex или убедитесь, что ocx есть в PATH неинтерактивных SSH-сеансов.", + "remoteLink.error.remote_ocx_outdated": "OpenCodex на удалённом компьютере слишком старый для удалённой связи. Выполните там ocx update (версия 2.66.0 или новее) и повторите попытку.", + "remoteLink.error.remote_ocx_unrecognized": "Удалённый компьютер не сообщил версию OpenCodex. Для удалённой связи нужен OpenCodex 2.66.0 или новее на macOS или Linux.", + "remoteLink.error.remote_port_failed": "Удалённый компьютер не сообщил порт связи. Убедитесь, что на нём работает OpenCodex 2.66.0 или новее, и повторите попытку.", "remoteLink.error.tailscale_session_refused": "Не удалось завершить запрос удалённой связи.", - "remoteLink.error.version_probe_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.version_probe_failed": "Не удалось запустить ocx на удалённом компьютере по SSH. Проверьте доступ по SSH и повторите попытку.", "remoteLink.error.generic": "Не удалось завершить запрос удалённой связи.", "remoteLink.reason.auth": "Ошибка аутентификации.", "remoteLink.reason.hostkey": "Не удалось проверить ключ хоста.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 563823498a4..59eba015435 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3346,6 +3346,7 @@ export const tr: Record = { "link.close": "Kapat", "link.cancel": "İptal", "remoteLink.childDisabled": "Çocuk bağlantıları yalnızca bağımsız çalışma zamanından başlatılabilir.", + "remoteLink.childJoinUnavailable": "Bu sürümde bu bilgisayarı panodan Çocuk olarak bağlamak kullanılamaz: bağlanmak OpenCodex'i yeniden başlatır ve mevcut Codex bağlantılarını keser. Bağlantıyı bunun yerine Home tarafından başlatın: Home olacak bilgisayarda “Ana” seçeneğini seçin ve diğer bilgisayarı Çocuk olarak ekleyin.", "remoteLink.findHome.title": "Home\u0027u bul", "remoteLink.findHome.body": "Bu Çocuk bilgisayarının bağlanacağı Home bilgisayarını seçin.", "remoteLink.findHome.action": "Home'u bul", @@ -3377,8 +3378,8 @@ export const tr: Record = { "remoteLink.direction.client": "İstemci tarafından başlatıldı", "remoteLink.error.admission_timeout": "Uzak bağlantı isteği tamamlanamadı.", "remoteLink.error.compensation_failed": "Uzak bağlantı isteği tamamlanamadı.", - "remoteLink.error.fingerprint_failed": "Uzak bağlantı isteği tamamlanamadı.", - "remoteLink.error.forbidden": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.fingerprint_failed": "SSH ana bilgisayar anahtarı okunamadı. Bağlantıyı yeniden test edin.", + "remoteLink.error.forbidden": "Bu pano oturumu uzak bağlantıları yönetemez. Panoyu bu bilgisayarda açın (bağımsız kurulum) veya eşleştirilmiş bir Merkez oturumu kullanın.", "remoteLink.error.host_confirmation_expired": "Uzak bağlantı isteği tamamlanamadı.", "remoteLink.error.host_fingerprint_mismatch": "Uzak bağlantı isteği tamamlanamadı.", "remoteLink.error.host_not_confirmed": "Uzak bağlantı isteği tamamlanamadı.", @@ -3391,14 +3392,17 @@ export const tr: Record = { "remoteLink.error.link_exists": "Uzak bağlantı isteği tamamlanamadı.", "remoteLink.error.link_not_found": "Uzak bağlantı isteği tamamlanamadı.", "remoteLink.error.link_remove_failed": "Uzak bağlantı isteği tamamlanamadı.", - "remoteLink.error.link_unavailable": "Uzak bağlantı isteği tamamlanamadı.", - "remoteLink.error.listener_unavailable": "Uzak bağlantı isteği tamamlanamadı.", - "remoteLink.error.probe_failed": "Uzak bağlantı isteği tamamlanamadı.", - "remoteLink.error.remote_connect_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.link_unavailable": "Uzak bağlantı bu çalışma zamanında kullanılamıyor. OpenCodex'i yeniden başlatıp tekrar deneyin.", + "remoteLink.error.listener_unavailable": "Uzak bağlantı dinleyicisi bu bilgisayarda başlatılamadı. Port çakışması olup olmadığını kontrol edip tekrar deneyin.", + "remoteLink.error.probe_failed": "SSH ana bilgisayarına bağlanılamadı. SSH anahtarınızı kabul ettiğini ve gerekiyorsa ProxyCommand yardımcısının kurulu olduğunu kontrol edin.", + "remoteLink.error.remote_connect_failed": "Uzak bilgisayar bu Home'a bağlanamadı. Oradaki OpenCodex hizmetini kontrol edip tekrar deneyin.", "remoteLink.error.remote_disconnect_failed": "Uzak bağlantı isteği tamamlanamadı.", - "remoteLink.error.remote_port_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.remote_ocx_missing": "Uzak bilgisayarda ocx bulunamadı. Orada OpenCodex'i kurun veya ocx'in etkileşimsiz SSH oturumlarının PATH'inde olduğundan emin olun.", + "remoteLink.error.remote_ocx_outdated": "Uzak bilgisayardaki OpenCodex, uzak bağlantı için çok eski. Orada ocx update komutunu çalıştırıp 2.66.0 veya sonrasına güncelleyin, ardından yeniden deneyin.", + "remoteLink.error.remote_ocx_unrecognized": "Uzak bilgisayar bir OpenCodex sürümü bildirmedi. Uzak bağlantı için macOS veya Linux üzerinde OpenCodex 2.66.0 veya sonrası gerekir.", + "remoteLink.error.remote_port_failed": "Uzak bilgisayar bağlantı portunu bildiremedi. Orada OpenCodex 2.66.0 veya sonrasının çalıştığını kontrol edip tekrar deneyin.", "remoteLink.error.tailscale_session_refused": "Uzak bağlantı isteği tamamlanamadı.", - "remoteLink.error.version_probe_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.version_probe_failed": "Uzak bilgisayarda SSH üzerinden ocx çalıştırılamadı. SSH erişimini kontrol edip tekrar deneyin.", "remoteLink.error.generic": "Uzak bağlantı isteği tamamlanamadı.", "remoteLink.reason.auth": "Kimlik doğrulama başarısız oldu.", "remoteLink.reason.hostkey": "Ana bilgisayar anahtarı doğrulanamadı.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 8ce937847a7..cbd565d9a78 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3281,6 +3281,7 @@ export const vi: Record = { "link.close": "Đóng", "link.cancel": "Hủy", "remoteLink.childDisabled": "Chỉ có thể bắt đầu liên kết máy con từ runtime độc lập.", + "remoteLink.childJoinUnavailable": "Trong bản phát hành này, không thể kết nối máy này với vai trò máy con từ bảng điều khiển: việc kết nối sẽ khởi động lại OpenCodex và làm ngắt các kết nối Codex hiện có. Hãy bắt đầu liên kết từ phía Home: trên máy sẽ làm Home, chọn “Máy chủ” rồi thêm máy còn lại làm máy con.", "remoteLink.findHome.title": "Tìm Home", "remoteLink.findHome.body": "Chọn máy Home để kết nối máy con này.", "remoteLink.findHome.action": "Tìm Home", @@ -3312,8 +3313,8 @@ export const vi: Record = { "remoteLink.direction.client": "Do máy con khởi tạo", "remoteLink.error.admission_timeout": "Không thể hoàn tất yêu cầu liên kết từ xa.", "remoteLink.error.compensation_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", - "remoteLink.error.fingerprint_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", - "remoteLink.error.forbidden": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.fingerprint_failed": "Không đọc được khóa máy chủ SSH. Hãy chạy lại bước kiểm tra kết nối.", + "remoteLink.error.forbidden": "Phiên bảng điều khiển này không thể quản lý liên kết từ xa. Hãy mở bảng điều khiển trên máy này (bản cài độc lập) hoặc dùng phiên Hub đã ghép nối.", "remoteLink.error.host_confirmation_expired": "Không thể hoàn tất yêu cầu liên kết từ xa.", "remoteLink.error.host_fingerprint_mismatch": "Không thể hoàn tất yêu cầu liên kết từ xa.", "remoteLink.error.host_not_confirmed": "Không thể hoàn tất yêu cầu liên kết từ xa.", @@ -3326,14 +3327,17 @@ export const vi: Record = { "remoteLink.error.link_exists": "Không thể hoàn tất yêu cầu liên kết từ xa.", "remoteLink.error.link_not_found": "Không thể hoàn tất yêu cầu liên kết từ xa.", "remoteLink.error.link_remove_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", - "remoteLink.error.link_unavailable": "Không thể hoàn tất yêu cầu liên kết từ xa.", - "remoteLink.error.listener_unavailable": "Không thể hoàn tất yêu cầu liên kết từ xa.", - "remoteLink.error.probe_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", - "remoteLink.error.remote_connect_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.link_unavailable": "Liên kết từ xa không khả dụng trong runtime này. Hãy khởi động lại OpenCodex rồi thử lại.", + "remoteLink.error.listener_unavailable": "Không thể khởi động trình lắng nghe liên kết từ xa trên máy này. Hãy kiểm tra xung đột cổng rồi thử lại.", + "remoteLink.error.probe_failed": "Không kết nối được tới máy chủ SSH. Hãy kiểm tra máy đó chấp nhận khóa SSH của bạn và trình trợ giúp ProxyCommand cần thiết đã được cài đặt.", + "remoteLink.error.remote_connect_failed": "Máy từ xa không kết nối được tới Home này. Hãy kiểm tra dịch vụ OpenCodex trên máy đó rồi thử lại.", "remoteLink.error.remote_disconnect_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", - "remoteLink.error.remote_port_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.remote_ocx_missing": "Không tìm thấy ocx trên máy từ xa. Hãy cài OpenCodex trên máy đó hoặc kiểm tra ocx có trong PATH của phiên SSH không tương tác.", + "remoteLink.error.remote_ocx_outdated": "OpenCodex trên máy từ xa quá cũ để dùng liên kết từ xa. Hãy chạy ocx update trên máy đó (2.66.0 trở lên) rồi thử lại.", + "remoteLink.error.remote_ocx_unrecognized": "Máy từ xa không báo phiên bản OpenCodex. Liên kết từ xa cần OpenCodex 2.66.0 trở lên trên macOS hoặc Linux.", + "remoteLink.error.remote_port_failed": "Máy từ xa không báo được cổng liên kết. Hãy kiểm tra máy đó đang chạy OpenCodex 2.66.0 trở lên rồi thử lại.", "remoteLink.error.tailscale_session_refused": "Không thể hoàn tất yêu cầu liên kết từ xa.", - "remoteLink.error.version_probe_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.version_probe_failed": "Không chạy được ocx trên máy từ xa qua SSH. Hãy kiểm tra quyền truy cập SSH rồi thử lại.", "remoteLink.error.generic": "Không thể hoàn tất yêu cầu liên kết từ xa.", "remoteLink.reason.auth": "Xác thực không thành công.", "remoteLink.reason.hostkey": "Không thể xác minh khóa máy chủ.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index c92a5c5d5c4..357be1e9410 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3309,6 +3309,7 @@ export const zhTW: Record = { "link.close": "關閉", "link.cancel": "取消", "remoteLink.childDisabled": "只有獨立執行環境才能發起子裝置連線。", + "remoteLink.childJoinUnavailable": "此版本暫不支援從儀表板將這台電腦連線為子裝置:連線會重新啟動 OpenCodex,現有的 Codex 連線會因此中斷。請改由 Home 端發起連線:在要作為 Home 的電腦上選擇「主機」,再將另一台電腦新增為子裝置。", "remoteLink.findHome.title": "尋找 Home", "remoteLink.findHome.body": "選擇要連線此子裝置的 Home 電腦。", "remoteLink.findHome.action": "尋找 Home", @@ -3340,8 +3341,8 @@ export const zhTW: Record = { "remoteLink.direction.client": "用戶端發起", "remoteLink.error.admission_timeout": "無法完成遠端連線要求。", "remoteLink.error.compensation_failed": "無法完成遠端連線要求。", - "remoteLink.error.fingerprint_failed": "無法完成遠端連線要求。", - "remoteLink.error.forbidden": "無法完成遠端連線要求。", + "remoteLink.error.fingerprint_failed": "無法讀取 SSH 主機金鑰。請重新測試連線。", + "remoteLink.error.forbidden": "此儀表板工作階段無法管理遠端連線。請在這台電腦上開啟儀表板(獨立安裝),或使用已配對的中樞工作階段。", "remoteLink.error.host_confirmation_expired": "無法完成遠端連線要求。", "remoteLink.error.host_fingerprint_mismatch": "無法完成遠端連線要求。", "remoteLink.error.host_not_confirmed": "無法完成遠端連線要求。", @@ -3354,14 +3355,17 @@ export const zhTW: Record = { "remoteLink.error.link_exists": "無法完成遠端連線要求。", "remoteLink.error.link_not_found": "無法完成遠端連線要求。", "remoteLink.error.link_remove_failed": "無法完成遠端連線要求。", - "remoteLink.error.link_unavailable": "無法完成遠端連線要求。", - "remoteLink.error.listener_unavailable": "無法完成遠端連線要求。", - "remoteLink.error.probe_failed": "無法完成遠端連線要求。", - "remoteLink.error.remote_connect_failed": "無法完成遠端連線要求。", + "remoteLink.error.link_unavailable": "遠端連線在目前的執行環境中無法使用。請重新啟動 OpenCodex 後重試。", + "remoteLink.error.listener_unavailable": "無法在這台電腦上啟動遠端連線監聽器。請檢查是否有連接埠衝突後重試。", + "remoteLink.error.probe_failed": "無法連線到 SSH 主機。請確認該主機接受你的 SSH 金鑰,並已安裝所需的 ProxyCommand 輔助程式。", + "remoteLink.error.remote_connect_failed": "遠端電腦無法連線到此 Home。請檢查該電腦上的 OpenCodex 服務後重試。", "remoteLink.error.remote_disconnect_failed": "無法完成遠端連線要求。", - "remoteLink.error.remote_port_failed": "無法完成遠端連線要求。", + "remoteLink.error.remote_ocx_missing": "在遠端電腦上找不到 ocx。請在該電腦上安裝 OpenCodex,或確認 ocx 位於非互動式 SSH 工作階段的 PATH 中。", + "remoteLink.error.remote_ocx_outdated": "遠端電腦上的 OpenCodex 版本過舊,不支援遠端連線。請在該電腦上執行 ocx update 升級到 2.66.0 或更新版本後重試。", + "remoteLink.error.remote_ocx_unrecognized": "遠端電腦沒有回報 OpenCodex 版本。遠端連線需要 macOS 或 Linux 上的 OpenCodex 2.66.0 或更新版本。", + "remoteLink.error.remote_port_failed": "遠端電腦未能回報其連線連接埠。請確認該電腦上正在執行 OpenCodex 2.66.0 或更新版本後重試。", "remoteLink.error.tailscale_session_refused": "無法完成遠端連線要求。", - "remoteLink.error.version_probe_failed": "無法完成遠端連線要求。", + "remoteLink.error.version_probe_failed": "無法透過 SSH 在遠端電腦上執行 ocx。請檢查 SSH 存取權後重試。", "remoteLink.error.generic": "無法完成遠端連線要求。", "remoteLink.reason.auth": "驗證失敗。", "remoteLink.reason.hostkey": "無法驗證主機金鑰。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 69ef71c1ab5..791c6bdd116 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3344,6 +3344,7 @@ export const zh: Record = { "link.close": "关闭", "link.cancel": "取消", "remoteLink.childDisabled": "只有独立运行时才能发起子设备连接。", + "remoteLink.childJoinUnavailable": "此版本暂不支持从仪表板将这台电脑连接为子设备:连接会重启 OpenCodex,现有的 Codex 连接会因此中断。请改为从 Home 一侧发起连接:在要作为 Home 的电脑上选择“主机”,然后将另一台电脑添加为子设备。", "remoteLink.findHome.title": "查找 Home", "remoteLink.findHome.body": "选择要连接此子设备的 Home 电脑。", "remoteLink.findHome.action": "查找 Home", @@ -3375,8 +3376,8 @@ export const zh: Record = { "remoteLink.direction.client": "客户端发起", "remoteLink.error.admission_timeout": "无法完成远程连接请求。", "remoteLink.error.compensation_failed": "无法完成远程连接请求。", - "remoteLink.error.fingerprint_failed": "无法完成远程连接请求。", - "remoteLink.error.forbidden": "无法完成远程连接请求。", + "remoteLink.error.fingerprint_failed": "无法读取 SSH 主机密钥。请重新测试连接。", + "remoteLink.error.forbidden": "此仪表板会话无法管理远程连接。请在这台电脑上打开仪表板(独立安装),或使用已配对的中心会话。", "remoteLink.error.host_confirmation_expired": "无法完成远程连接请求。", "remoteLink.error.host_fingerprint_mismatch": "无法完成远程连接请求。", "remoteLink.error.host_not_confirmed": "无法完成远程连接请求。", @@ -3389,14 +3390,17 @@ export const zh: Record = { "remoteLink.error.link_exists": "无法完成远程连接请求。", "remoteLink.error.link_not_found": "无法完成远程连接请求。", "remoteLink.error.link_remove_failed": "无法完成远程连接请求。", - "remoteLink.error.link_unavailable": "无法完成远程连接请求。", - "remoteLink.error.listener_unavailable": "无法完成远程连接请求。", - "remoteLink.error.probe_failed": "无法完成远程连接请求。", - "remoteLink.error.remote_connect_failed": "无法完成远程连接请求。", + "remoteLink.error.link_unavailable": "远程连接在当前运行时中不可用。请重启 OpenCodex 后重试。", + "remoteLink.error.listener_unavailable": "无法在这台电脑上启动远程连接监听器。请检查是否存在端口冲突后重试。", + "remoteLink.error.probe_failed": "无法连接到 SSH 主机。请确认该主机接受你的 SSH 密钥,并已安装所需的 ProxyCommand 辅助程序。", + "remoteLink.error.remote_connect_failed": "远程电脑无法连接到此 Home。请检查该电脑上的 OpenCodex 服务后重试。", "remoteLink.error.remote_disconnect_failed": "无法完成远程连接请求。", - "remoteLink.error.remote_port_failed": "无法完成远程连接请求。", + "remoteLink.error.remote_ocx_missing": "在远程电脑上找不到 ocx。请在该电脑上安装 OpenCodex,或确认 ocx 位于非交互式 SSH 会话的 PATH 中。", + "remoteLink.error.remote_ocx_outdated": "远程电脑上的 OpenCodex 版本过旧,不支持远程连接。请在该电脑上运行 ocx update 升级到 2.66.0 或更高版本后重试。", + "remoteLink.error.remote_ocx_unrecognized": "远程电脑没有报告 OpenCodex 版本。远程连接需要 macOS 或 Linux 上的 OpenCodex 2.66.0 或更高版本。", + "remoteLink.error.remote_port_failed": "远程电脑未能报告其连接端口。请确认该电脑上正在运行 OpenCodex 2.66.0 或更高版本后重试。", "remoteLink.error.tailscale_session_refused": "无法完成远程连接请求。", - "remoteLink.error.version_probe_failed": "无法完成远程连接请求。", + "remoteLink.error.version_probe_failed": "无法通过 SSH 在远程电脑上运行 ocx。请检查 SSH 访问后重试。", "remoteLink.error.generic": "无法完成远程连接请求。", "remoteLink.reason.auth": "身份验证失败。", "remoteLink.reason.hostkey": "无法验证主机密钥。", diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index ece988c680c..1ba6945625d 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -57,6 +57,9 @@ const ERROR_TKEY: Record = { probe_failed: "remoteLink.error.probe_failed", remote_connect_failed: "remoteLink.error.remote_connect_failed", remote_disconnect_failed: "remoteLink.error.remote_disconnect_failed", + remote_ocx_missing: "remoteLink.error.remote_ocx_missing", + remote_ocx_outdated: "remoteLink.error.remote_ocx_outdated", + remote_ocx_unrecognized: "remoteLink.error.remote_ocx_unrecognized", remote_port_failed: "remoteLink.error.remote_port_failed", standalone_required: "remoteLink.error.standalone_required", tailscale_session_refused: "remoteLink.error.tailscale_session_refused", @@ -102,6 +105,13 @@ function errorKey(error: unknown): TKey { return "remoteLink.error.generic"; } +type LinkActionError = { key: TKey; hint: string | null }; +function linkActionError(error: unknown): LinkActionError { return { key: errorKey(error), hint: error instanceof LinkApiError ? error.hint : null }; } +function LinkErrorNotice({ error }: { error: LinkActionError }): ReactElement { + const t = useT(); + return {t(error.key)}{error.hint && {t("remoteLink.reason.generic")} {error.hint}}; +} + export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = false, onOpenWorkspace }: RemoteLinkProps): ReactElement { const t = useT(); const [uiState, setUiState] = useState("off"); @@ -115,7 +125,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = const [confirmation, setConfirmation] = useState(null); const [checkedFingerprint, setCheckedFingerprint] = useState(false); const [busy, setBusy] = useState<"candidates" | "probe" | "confirm" | "apply" | "join" | "remove" | null>(null); - const [actionError, setActionError] = useState(null); + const [actionError, setActionError] = useState(null); const [failedAction, setFailedAction] = useState(null); const [confirming, setConfirming] = useState<{ row: LinkRowWire; force: boolean } | null>(null); const [forceError, setForceError] = useState(null); @@ -209,6 +219,9 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = // Cancelling the sheet abandons the attempt, so late responses cannot recreate its state. const closeSheet = () => { cancelLinkAttempt(); setSheetOpen(false); setCandidates([]); setProbe(null); setConfirmation(null); setCheckedFingerprint(false); setActionError(null); setFailedAction(null); setBusy(null); setUiState(current => ["failed", "adding-child", "confirming-host", "applying", "joining"].includes(current) ? "adding-child" : current); addButtonRef.current?.focus(); }; const standaloneRuntime = isStandaloneRuntime(); + // Joining restarts this OpenCodex and moves Codex routing to the Home, so the server offers it + // only to a paired session; the local dashboard can still run the Home side. + const childSelectable = standaloneRuntime && status?.joinAvailable === true; const openSheet = async () => { const attempt = startLinkAttempt(); setSheetOpen(true); setUiState("adding-child"); setCandidates([]); setProbe(null); setConfirmation(null); setCheckedFingerprint(false); setActionError(null); setFailedAction(null); setBusy("candidates"); @@ -218,7 +231,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = setCandidates(parseCandidates(result)); } catch (error) { if (!isCurrentLinkAttempt(attempt)) return; - setActionError(errorKey(error)); + setActionError(linkActionError(error)); } finally { if (isCurrentLinkAttempt(attempt)) setBusy(null); } @@ -233,7 +246,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = setProbe(parseProbe(result)); } catch (error) { if (!isCurrentLinkAttempt(attempt)) return; - setActionError(errorKey(error)); setFailedAction({ phase: "probe", alias: value }); setUiState("failed"); + setActionError(linkActionError(error)); setFailedAction({ phase: "probe", alias: value }); setUiState("failed"); } finally { if (isCurrentLinkAttempt(attempt)) setBusy(null); } @@ -249,7 +262,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = setConfirmation(parseConfirmation(result)); } catch (error) { if (!isCurrentLinkAttempt(attempt)) return; - setActionError(errorKey(error)); + setActionError(linkActionError(error)); } finally { if (isCurrentLinkAttempt(attempt)) setBusy(null); } @@ -265,7 +278,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = void refreshStatus(); } catch (error) { if (!isCurrentLinkAttempt(attempt)) return; - setActionError(errorKey(error)); setFailedAction({ phase: "apply", alias: confirmed.alias }); setUiState("failed"); + setActionError(linkActionError(error)); setFailedAction({ phase: "apply", alias: confirmed.alias }); setUiState("failed"); } finally { if (isCurrentLinkAttempt(attempt)) setBusy(null); } @@ -273,7 +286,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = const joinLink = async (requestedAlias = confirmation?.alias, attempt = linkAttemptRef.current ?? startLinkAttempt()) => { const value = requestedAlias?.trim(); - if (!value) return; + if (!value || !childSelectable) return; setBusy("join"); setActionError(null); setFailedAction(null); setUiState("joining"); try { await requestLinkJson<{ linkId: string; alias: string; restarting: true }>(apiBase, "/api/link/join", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: value }), signal: attempt.controller.signal }); @@ -281,7 +294,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = closeSheet(); setUiState("restart-waiting"); } catch (error) { if (!isCurrentLinkAttempt(attempt)) return; - setActionError(errorKey(error)); setFailedAction({ phase: "join", alias: value }); setUiState("failed"); + setActionError(linkActionError(error)); setFailedAction({ phase: "join", alias: value }); setUiState("failed"); } finally { if (isCurrentLinkAttempt(attempt)) setBusy(null); } @@ -303,7 +316,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = const moveRole = (index: number, key: string) => { const next = key === "Home" ? 0 : key === "End" ? 1 : key === "ArrowRight" || key === "ArrowDown" ? (index + 1) % 2 : key === "ArrowLeft" || key === "ArrowUp" ? (index + 1) % 2 : index; - if (next === 1 && !isStandaloneRuntime()) return; + if (next === 1 && !childSelectable) return; if (next === index) return; roleRefs.current[next]?.focus(); setRole(next === 0 ? "home" : "child"); @@ -325,7 +338,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = // operator's choice so the heading does not contradict the action in front of them. const choseHome = role === "home" && (uiState === "adding-child" || uiState === "confirming-host" || uiState === "applying"); const roleLabel: TKey = status?.role === "home" || choseHome ? "remoteLink.role.home" : status?.role === "child" ? "remoteLink.role.child" : "remoteLink.role.standalone"; - const primaryActionDisabled = role === "child" && !standaloneRuntime; + const primaryActionDisabled = role === "child" && !childSelectable; if (!sessionReady) return

{t("link.title")}

{t("link.sessionRequired")}
; @@ -335,12 +348,12 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = {workspaceAvailable &&
{t("remoteLink.workspaceMoved.title")}

{t("remoteLink.workspaceMoved.body")}

} {statusError && {t(statusError)}} {statusRows.length === 0 && uiState === "off" &&
{t("link.switch")}

{t("link.switchOffHint")}

} - {uiState === "role-select" &&

{t("link.role.title")}

{t("link.role.hint")}

{!standaloneRuntime && {t("remoteLink.childDisabled")}}
} - {(uiState === "connected" || uiState === "reconnecting" || uiState === "failed" || uiState === "restart-waiting" || status?.role === "child" || statusRows.length > 0 || uiState === "adding-child" || uiState === "confirming-host" || uiState === "applying" || uiState === "joining") &&

{role === "child" && standaloneRuntime ? t("remoteLink.findHome.title") : t("link.children")}

{uiState === "restart-waiting" ? t("remoteLink.restart.waiting") : t(roleLabel)}

{uiState === "restart-waiting" ?
{t("remoteLink.restart.title")}

{t("remoteLink.restart.body")}

: status?.role === "child" ?
{status.child?.alias ?? t("remoteLink.role.child")}{status.child &&
{t(STATUS_LABEL[status.child.state])}
}
: statusRows.length > 0 ?
{statusRows.map(row =>
{row.alias}
{t(STATUS_LABEL[row.state])}{row.direction === "hub-initiated" ? t("remoteLink.direction.hub") : t("remoteLink.direction.client")}{row.reason && {row.reason in REASON_TKEY ? t(REASON_TKEY[row.reason]) : <>{t("remoteLink.reason.generic")} {row.reason}}}
)}
:

{t(role === "child" && standaloneRuntime ? "remoteLink.findHome.empty" : "link.noChildren")}

}{(uiState === "reconnecting" || (uiState === "failed" && ((failedAction !== null && actionError !== "remoteLink.error.join_restart_failed") || statusRows.some(row => row.state === "failed")))) &&
{t(STATUS_LABEL[uiState === "failed" ? "failed" : "reconnecting"])}
}{uiState === "joining" &&

{t("remoteLink.joining")}

}{actionError && {t(actionError)}}
} + {uiState === "role-select" &&

{t("link.role.title")}

{t("link.role.hint")}

{!standaloneRuntime && {t("remoteLink.childDisabled")}}{standaloneRuntime && status !== null && !status.joinAvailable && {t("remoteLink.childJoinUnavailable")}}
} + {(uiState === "connected" || uiState === "reconnecting" || uiState === "failed" || uiState === "restart-waiting" || status?.role === "child" || statusRows.length > 0 || uiState === "adding-child" || uiState === "confirming-host" || uiState === "applying" || uiState === "joining") &&

{role === "child" && standaloneRuntime ? t("remoteLink.findHome.title") : t("link.children")}

{uiState === "restart-waiting" ? t("remoteLink.restart.waiting") : t(roleLabel)}

{uiState === "restart-waiting" ?
{t("remoteLink.restart.title")}

{t("remoteLink.restart.body")}

: status?.role === "child" ?
{status.child?.alias ?? t("remoteLink.role.child")}{status.child &&
{t(STATUS_LABEL[status.child.state])}
}
: statusRows.length > 0 ?
{statusRows.map(row =>
{row.alias}
{t(STATUS_LABEL[row.state])}{row.direction === "hub-initiated" ? t("remoteLink.direction.hub") : t("remoteLink.direction.client")}{row.reason && {row.reason in REASON_TKEY ? t(REASON_TKEY[row.reason]) : <>{t("remoteLink.reason.generic")} {row.reason}}}
)}
:

{t(role === "child" && standaloneRuntime ? "remoteLink.findHome.empty" : "link.noChildren")}

}{(uiState === "reconnecting" || (uiState === "failed" && ((failedAction !== null && actionError?.key !== "remoteLink.error.join_restart_failed") || statusRows.some(row => row.state === "failed")))) &&
{t(STATUS_LABEL[uiState === "failed" ? "failed" : "reconnecting"])}
}{uiState === "joining" &&

{t("remoteLink.joining")}

}{actionError && }
} { event.preventDefault(); closeSheet(); }}>
-

{role === "child" && standaloneRuntime ? t("remoteLink.findHome.body") : t("link.candidates")}

{busy === "candidates" ?

{t("link.loading")}

: candidates.length > 0 ?
{candidates.map(candidate => )}
:

{t("link.noCandidates")}

}
setAlias(event.target.value)} placeholder={t("link.aliasPlaceholder")} autoComplete="off" />
{probe &&
{t("link.hostFingerprint")}

{probe.fingerprint}

{probe.keyType}
}{confirmation &&

{t("link.ocxVersion", { version: confirmation.ocxVersion })}

}{actionError && {t(actionError)}}
+

{role === "child" && standaloneRuntime ? t("remoteLink.findHome.body") : t("link.candidates")}

{busy === "candidates" ?

{t("link.loading")}

: candidates.length > 0 ?
{candidates.map(candidate => )}
:

{t("link.noCandidates")}

}
setAlias(event.target.value)} placeholder={t("link.aliasPlaceholder")} autoComplete="off" />
{probe &&
{t("link.hostFingerprint")}

{probe.fingerprint}

{probe.keyType}
}{confirmation &&

{t("link.ocxVersion", { version: confirmation.ocxVersion })}

}{actionError && }
{ event.preventDefault(); closeConfirmation(); }}> diff --git a/gui/src/remote-link-api.ts b/gui/src/remote-link-api.ts index 935a19032e7..cd4398dd709 100644 --- a/gui/src/remote-link-api.ts +++ b/gui/src/remote-link-api.ts @@ -28,6 +28,9 @@ export const LINK_ERROR_CODES = [ "probe_failed", "remote_connect_failed", "remote_disconnect_failed", + "remote_ocx_missing", + "remote_ocx_outdated", + "remote_ocx_unrecognized", "remote_port_failed", "standalone_required", "tailscale_session_refused", @@ -49,6 +52,11 @@ export interface RemoteLinkStatusWire { listener: { state: LinkListenerState; port: number | null }; links: LinkRowWire[]; child: null | { alias: string; state: LinkWireState; since: string; reason: string | null }; + /** + * Whether this dashboard session may join a Home as a Child. Only a paired session on a + * standalone runtime may; the server omits the field for non-dashboard callers, read as false. + */ + joinAvailable: boolean; } const LINK_STATES: readonly LinkWireState[] = ["connecting", "connected", "reconnecting", "failed", "idle"]; @@ -57,19 +65,42 @@ const LINK_ROLES = ["standalone", "home", "child"] as const; export class LinkApiError extends Error { readonly code: string; readonly status: number; + /** The server's bounded hint line (ssh stderr, the ssh runner's own failure, or the parsed remote version), shown under the translated message. */ + readonly hint: string | null; - constructor(code: string, status: number) { + constructor(code: string, status: number, hint: string | null = null) { super(code); this.name = "LinkApiError"; this.code = code; this.status = status; + this.hint = hint; } } +const HINT_MAX_CHARS = 160; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +/** C0/C1 controls plus the invisible and bidi formatting ranges, compared by code point. */ +function isHintControl(code: number): boolean { + return code < 0x20 || (code >= 0x7f && code <= 0x9f) || (code >= 0x200b && code <= 0x200f) + || (code >= 0x202a && code <= 0x202e) || (code >= 0x2060 && code <= 0x206f) || code === 0xfeff; +} + +/** + * The server already bounds hints; the dashboard re-bounds them so no response can grow the UI. + * The cap counts and cuts code points, so an astral character is never split into a lone surrogate. + */ +export function boundLinkHint(value: unknown): string | null { + if (typeof value !== "string") return null; + const clean = Array.from(value, char => isHintControl(char.codePointAt(0) ?? 0) ? " " : char).join("").replace(/\s+/g, " ").trim(); + if (!clean) return null; + const points = Array.from(clean); + return points.length > HINT_MAX_CHARS ? `${points.slice(0, HINT_MAX_CHARS - 1).join("")}\u2026` : clean; +} + function nonEmpty(value: unknown): value is string { return typeof value === "string" && value.length > 0; } function isLinkState(value: unknown): value is LinkWireState { return typeof value === "string" && LINK_STATES.includes(value as LinkWireState); } @@ -87,7 +118,7 @@ export function parseRemoteLinkStatus(value: unknown): RemoteLinkStatusWire { if (!isRecord(value.child) || !nonEmpty(value.child.alias) || !isLinkState(value.child.state) || !nonEmpty(value.child.since) || (value.child.reason !== null && typeof value.child.reason !== "string")) throw new Error("invalid child"); child = { alias: value.child.alias, state: value.child.state, since: value.child.since, reason: value.child.reason as string | null }; } - return { role: value.role as RemoteLinkStatusWire["role"], listener: { state: listener.state as LinkListenerState, port: listener.port as number | null }, links, child }; + return { role: value.role as RemoteLinkStatusWire["role"], listener: { state: listener.state as LinkListenerState, port: listener.port as number | null }, links, child, joinAvailable: value.joinAvailable === true }; } /** Read link-route JSON and preserve the server's machine-readable error code. */ @@ -100,7 +131,7 @@ export async function readLinkJson(response: Response): Promise { if (!response.ok) { const error = isRecord(body) && isRecord(body.error) ? body.error : null; const code = error && typeof error.code === "string" ? error.code : "unknown"; - throw new LinkApiError(code, response.status); + throw new LinkApiError(code, response.status, boundLinkHint(error?.hint)); } if (body === null || body === undefined) throw new LinkApiError("invalid_body", response.status); return body as T; diff --git a/gui/src/styles-remote-link.css b/gui/src/styles-remote-link.css index 14c53706fa4..c8cb45d7a2b 100644 --- a/gui/src/styles-remote-link.css +++ b/gui/src/styles-remote-link.css @@ -35,6 +35,7 @@ .remote-link-row-meta { display: flex; flex-wrap: wrap; gap: var(--space-3); color: var(--muted); font-size: var(--text-caption); } .remote-link-row .btn { min-height: var(--control-touch); } .remote-link-error { color: var(--red); } +.remote-link-hint { display: block; margin-top: var(--space-1); color: var(--muted); font-size: var(--text-label); overflow-wrap: anywhere; } .remote-link-info { color: var(--muted); font-size: var(--text-label); } .remote-link-restart { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--raised); } .remote-link-restart p { margin: 0; color: var(--muted); } diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index 14893a40416..65281866b35 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -3,11 +3,13 @@ import { Window } from "happy-dom"; import { createRoot, type Root } from "react-dom/client"; import { act } from "react"; import RemoteLink from "../src/pages/RemoteLink"; -import { LINK_ERROR_CODES, LinkApiError, parseRemoteLinkStatus, readLinkJson, type RemoteLinkStatusWire } from "../src/remote-link-api"; +import { boundLinkHint, LINK_ERROR_CODES, LinkApiError, parseRemoteLinkStatus, readLinkJson, type RemoteLinkStatusWire } from "../src/remote-link-api"; import { LanguageProvider } from "../src/i18n/provider"; import { LOCALES } from "../src/i18n/shared"; -const baseStatus: RemoteLinkStatusWire = { role: "home", listener: { state: "listening", port: 44123 }, links: [], child: null }; +const baseStatus: RemoteLinkStatusWire = { role: "home", listener: { state: "listening", port: 44123 }, links: [], child: null, joinAvailable: false }; +// A paired session on a standalone runtime: the only status that lets the dashboard join as a Child. +const joinableStatus: RemoteLinkStatusWire = { ...baseStatus, role: "standalone", joinAvailable: true }; let win: Window; let root: Root | null = null; let previous: Record; @@ -58,6 +60,15 @@ test("parses every wire state without changing the DTO", () => { expect(parseRemoteLinkStatus({ ...baseStatus, role: "standalone", listener: { state: "off", port: null }, child: { alias: "child", state: "idle", since: "now", reason: null } }).child?.state).toBe("idle"); }); +test("joinAvailable is true only when the server says exactly true", () => { + const withoutJoin: Record = { ...baseStatus }; + delete withoutJoin.joinAvailable; + expect(parseRemoteLinkStatus(withoutJoin).joinAvailable).toBe(false); + expect(parseRemoteLinkStatus({ ...baseStatus, joinAvailable: "true" }).joinAvailable).toBe(false); + expect(parseRemoteLinkStatus({ ...baseStatus, joinAvailable: 1 }).joinAvailable).toBe(false); + expect(parseRemoteLinkStatus({ ...baseStatus, joinAvailable: true }).joinAvailable).toBe(true); +}); + test("session gate makes no link request", async () => { const calls: string[] = []; globalThis.fetch = (async input => { calls.push(String(input)); return response(baseStatus); }) as typeof fetch; @@ -81,7 +92,7 @@ test("off state and role choice issue no mutation request", async () => { }); test("Child role is disabled unless the served runtime is standalone", async () => { - globalThis.fetch = (async () => response({ ...baseStatus, role: "home" })) as typeof fetch; + globalThis.fetch = (async () => response({ ...baseStatus, role: "home", joinAvailable: true })) as typeof fetch; const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); const child = [...host.querySelectorAll('[role="radio"]')][1] as HTMLButtonElement; @@ -98,6 +109,58 @@ test("Child role is disabled unless the served runtime is standalone", async () expect(standaloneChild.getAttribute("aria-disabled")).toBe("false"); }); +test("local dashboard without join keeps Child disabled, explains why, and never sends a join", async () => { + declareRuntimeRole("standalone"); + const calls: Array<{ path: string; method: string }> = []; + globalThis.fetch = (async (input, init) => { + const path = new URL(String(input)).pathname; + calls.push({ path, method: init?.method ?? "GET" }); + if (path === "/api/link/candidates") return response({ candidates: [{ alias: "home-one", source: "ssh_config" }] }); + if (path === "/api/link/probe") return response({ alias: "home-one", fingerprint: "SHA256:test", keyType: "ed25519" }); + if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.66.0" }); + if (path === "/api/link/join") return response({ linkId: "lnk_1234567890abcdef", alias: "home-one", restarting: true }, 202); + if (path === "/api/link/apply") return response({ linkId: "lnk_1234567890abcdef" }, 202); + return response({ ...baseStatus, role: "standalone", joinAvailable: false }); + }) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + const radios = () => [...host.querySelectorAll('[role="radio"]')] as HTMLButtonElement[]; + expect(radios()[1]?.getAttribute("aria-disabled")).toBe("true"); + expect(radios()[1]?.tabIndex).toBe(-1); + expect(host.textContent).toContain("Connecting this computer as a Child from the dashboard is not available in this release"); + expect(host.textContent).toContain("would drop existing Codex connections"); + expect(host.textContent).toContain("choose Home and add the other computer as a Child"); + expect(host.textContent).not.toContain("paired"); + expect(host.textContent).not.toContain("Child links can only be started from a standalone runtime."); + + await act(async () => { radios()[1]?.click(); }); + await act(async () => { radios()[1]?.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Home", bubbles: true })); }); + for (const key of ["ArrowRight", "ArrowDown", "End"]) { + await act(async () => { radios()[0]?.dispatchEvent(new win.KeyboardEvent("keydown", { key, bubbles: true })); }); + } + await flush(); + expect(radios()[1]?.getAttribute("aria-checked")).toBe("false"); + expect(radios()[1]?.tabIndex).toBe(-1); + expect((host.querySelector(".remote-link-sheet") as HTMLDialogElement).open).toBe(false); + expect(calls.some(call => call.path === "/api/link/candidates")).toBe(false); + + // The Home side still works from this dashboard: Continue opens the host sheet and applies. + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent === "Continue")?.click(); }); + await flush(); + expect((host.querySelector(".remote-link-sheet") as HTMLDialogElement).open).toBe(true); + await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); + await flush(); + await act(async () => { (host.querySelector('input[type="checkbox"]') as HTMLInputElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Confirm host"))?.click(); }); + await flush(); + expect([...host.querySelectorAll("button")].some(button => button.textContent?.includes("Connect as Child"))).toBe(false); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Connect child"))?.click(); }); + await flush(); + expect(calls.some(call => call.path === "/api/link/apply" && call.method === "POST")).toBe(true); + expect(calls.some(call => call.path === "/api/link/join")).toBe(false); +}); + test("standalone Child flow joins with exactly the confirmed alias and shows restart waiting", async () => { declareRuntimeRole("standalone"); const calls: Array<{ path: string; method: string; body?: string }> = []; @@ -108,7 +171,7 @@ test("standalone Child flow joins with exactly the confirmed alias and shows res if (path === "/api/link/probe") return response({ alias: "home-one", fingerprint: "SHA256:test", keyType: "ed25519" }); if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.0.0" }); if (path === "/api/link/join") return response({ linkId: "lnk_1234567890abcdef", alias: "home-one", restarting: true }, 202); - return response({ ...baseStatus, role: "standalone" }); + return response(joinableStatus); }) as typeof fetch; const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); @@ -120,6 +183,7 @@ test("standalone Child flow joins with exactly the confirmed alias and shows res await act(async () => { (host.querySelector('input[type="checkbox"]') as HTMLInputElement).click(); }); await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Confirm host"))?.click(); }); await flush(); + expect(calls.some(call => call.path === "/api/link/join")).toBe(false); await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Connect as Child"))?.click(); }); await flush(); expect(calls.find(call => call.path === "/api/link/join")?.body).toBe(JSON.stringify({ alias: "home-one" })); @@ -140,7 +204,7 @@ test("join failure maps actionable errors and Retry re-joins the confirmed alias joins += 1; return joins === 1 ? response({ error: { code: "join_tunnel_failed" } }, 502) : response({ linkId: "lnk_1234567890abcdef", alias: "home-one", restarting: true }, 202); } - return response({ ...baseStatus, role: "standalone" }); + return response(joinableStatus); }) as typeof fetch; const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); @@ -169,7 +233,7 @@ test("join maps standalone_required to an actionable message", async () => { if (path === "/api/link/probe") return response({ alias: "home-one", fingerprint: "SHA256:test", keyType: "ed25519" }); if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.0.0" }); if (path === "/api/link/join") return response({ error: { code: "standalone_required" } }, 409); - return response({ ...baseStatus, role: "standalone" }); + return response(joinableStatus); }) as typeof fetch; const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); @@ -194,7 +258,7 @@ test("join_restart_failed shows restart guidance without Retry", async () => { if (path === "/api/link/probe") return response({ alias: "home-one", fingerprint: "SHA256:test", keyType: "ed25519" }); if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.0.0" }); if (path === "/api/link/join") return response({ error: { code: "join_restart_failed" } }, 500); - return response({ ...baseStatus, role: "standalone" }); + return response(joinableStatus); }) as typeof fetch; const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); @@ -257,26 +321,71 @@ test("readLinkJson preserves unknown server codes and status", async () => { expect(caught).toBeInstanceOf(LinkApiError); expect((caught as LinkApiError).code).toBe("future_code"); expect((caught as LinkApiError).status).toBe(418); + expect((caught as LinkApiError).hint).toBeNull(); expect(LOCALES).toHaveLength(10); + let hinted: unknown; + try { await readLinkJson(new Response(JSON.stringify({ error: { code: "probe_failed", hint: `bad\u202e\u0007 line ${"x".repeat(300)}` } }), { status: 502 })); } catch (error) { hinted = error; } + const hint = (hinted as LinkApiError).hint ?? ""; + expect(hint.startsWith("bad line x")).toBe(true); + expect(hint).toHaveLength(160); + expect(hint.endsWith("…")).toBe(true); + expect(boundLinkHint(42)).toBeNull(); + expect(boundLinkHint(" \n ")).toBeNull(); +}); + +test("boundLinkHint caps astral hints by code point and never leaves a lone surrogate", () => { + const astral = String.fromCodePoint(0x1f511); + // 200 astral characters are 400 UTF-16 units; a unit-based cut at 159 would split a pair. + const points = Array.from(boundLinkHint(astral.repeat(200)) ?? ""); + expect(points).toHaveLength(160); + expect(points.at(-1)).toBe(String.fromCodePoint(0x2026)); + expect(points.slice(0, -1).every(point => point === astral)).toBe(true); + expect(points.every(point => { const code = point.codePointAt(0)!; return code < 0xd800 || code > 0xdfff; })).toBe(true); + expect(boundLinkHint(astral.repeat(160))).toBe(astral.repeat(160)); +}); + +test("choosing Home then Continue opens the SSH host sheet with candidates", async () => { + const calls: string[] = []; + globalThis.fetch = (async input => { + const path = new URL(String(input)).pathname; + calls.push(path); + if (path === "/api/link/candidates") return response({ candidates: [{ alias: "child-one", source: "ssh_config" }] }); + return response({ ...baseStatus, role: "standalone" }); + }) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + expect(host.querySelector('[role="radio"][aria-checked="true"]')?.textContent).toContain("Home"); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent === "Continue")?.click(); }); + await flush(); + expect((host.querySelector(".remote-link-sheet") as HTMLDialogElement).open).toBe(true); + expect(calls).toContain("/api/link/candidates"); + expect(host.querySelector(".remote-link-candidate")?.textContent).toContain("child-one"); +}); + +test("Continue stays disabled while this computer is already a Child", async () => { + globalThis.fetch = (async () => response({ ...baseStatus, role: "child", child: { alias: "home-one", state: "connected", since: "now", reason: null } })) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + expect(([...host.querySelectorAll("button")].find(button => button.textContent === "Continue") as HTMLButtonElement).disabled).toBe(true); }); test("probe failure stays visible and Retry probes the failed alias", async () => { let probes = 0; globalThis.fetch = (async input => { const path = new URL(String(input)).pathname; - if (path === "/api/link/probe") { probes += 1; return response({ error: { code: "probe_failed" } }, 502); } + if (path === "/api/link/probe") { probes += 1; return response({ error: { code: "probe_failed", hint: "child-one: Permission denied (publickey)." } }, 502); } if (path === "/api/link/candidates") return response({ candidates: [{ alias: "child-one", source: "ssh config" }] }); return response(baseStatus); }) as typeof fetch; const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); await act(async () => { (host.querySelector(".btn-primary") as HTMLButtonElement).click(); }); - await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Add child"))?.click(); }); await flush(); await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); await flush(); - expect(host.textContent).toContain("Remote link request could not be completed."); + expect(host.textContent).toContain("Could not connect to the SSH host. Check that it accepts your SSH key"); + expect(host.querySelector(".remote-link-hint code")?.textContent).toBe("child-one: Permission denied (publickey)."); expect(host.textContent).toContain("Retry"); await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Retry"))?.click(); }); await flush(); @@ -296,7 +405,6 @@ test("apply failure stays retryable and Retry reapplies the confirmed alias", as const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); await act(async () => { (host.querySelector(".btn-primary") as HTMLButtonElement).click(); }); - await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Add child"))?.click(); }); await flush(); await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); @@ -314,7 +422,7 @@ test("apply failure stays retryable and Retry reapplies the confirmed alias", as test("role radios use roving tabIndex and arrow, Home, and End keys", async () => { declareRuntimeRole("standalone"); - globalThis.fetch = (async () => response(baseStatus)) as typeof fetch; + globalThis.fetch = (async () => response(joinableStatus)) as typeof fetch; const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); const radios = () => [...host.querySelectorAll('[role="radio"]')] as HTMLButtonElement[]; @@ -397,7 +505,6 @@ test("cancelling a failed apply leaves no dead Retry behind", async () => { const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); await act(async () => { (host.querySelector(".btn-primary") as HTMLButtonElement).click(); }); - await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Add child"))?.click(); }); await flush(); await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); @@ -424,7 +531,7 @@ test("cancelling a join ignores a late failure", async () => { if (path === "/api/link/probe") return response({ alias: "home-one", fingerprint: "SHA256:test", keyType: "ed25519" }); if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.0.0" }); if (path === "/api/link/join") return joinResponse; - return response({ ...baseStatus, role: "standalone" }); + return response(joinableStatus); }) as typeof fetch; const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); @@ -459,7 +566,6 @@ test("cancelling candidates prevents a late response from appearing in a new att const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); await act(async () => { (host.querySelector(".btn-primary") as HTMLButtonElement).click(); }); - await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Add child"))?.click(); }); await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent === "Cancel")?.click(); }); await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Add child"))?.click(); }); await flush(); diff --git a/src/client/link-join.ts b/src/client/link-join.ts index b782ef42259..8d6f1c8c979 100644 --- a/src/client/link-join.ts +++ b/src/client/link-join.ts @@ -2,8 +2,8 @@ import { randomBytes } from "node:crypto"; import { hostname } from "node:os"; import { findAvailablePort } from "../server/ports"; import { isLinkPort } from "../link/ports"; -import { buildExecArgv } from "../link/ssh-argv"; -import type { SshRunner } from "../link/ssh-runner"; +import { buildExecArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../link/ssh-argv"; +import { sshFailureHint, sshRunnerErrorHint, type SshRunner, type SshRunResult } from "../link/ssh-runner"; import { connectClient, type ClientConnectDeps } from "./connect"; import { clearClientLinkState, @@ -40,6 +40,7 @@ export type JoinFailureCode = | "host_confirmation_expired" | "join_port_failed" | "join_issue_failed" + | "remote_ocx_missing" | "join_tunnel_failed" | "admission_failed" | "join_connect_failed" @@ -47,7 +48,8 @@ export type JoinFailureCode = | "join_restart_failed"; export class ClientLinkJoinError extends Error { - constructor(readonly code: JoinFailureCode, readonly linkId?: string) { + /** `hint` is a bounded line from ssh stderr or the ssh runner's own failure, for the dashboard; it is not part of the message. */ + constructor(readonly code: JoinFailureCode, readonly linkId?: string, readonly hint?: string) { super(linkId ? `${code}: ${linkId}` : code); this.name = "ClientLinkJoinError"; } @@ -148,7 +150,7 @@ async function revokeIssuedLink(deps: ClientLinkJoinDeps, linkId: string, alias const result = await deps.runner.run( buildExecArgv({ alias, - argv: ["ocx", "link", "revoke", "--link-id", linkId], + argv: remoteOcxArgv(["link", "revoke", "--link-id", linkId]), knownHostsFile: deps.knownHostsFile, }), { timeoutMs: JOIN_REVOKE_TIMEOUT_MS }, @@ -248,24 +250,24 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string } const thisAlias = localAlias(deps); - let issued: IssuedLink; + let result: SshRunResult; try { - const result = await deps.runner.run( + result = await deps.runner.run( buildExecArgv({ alias: input.alias, - argv: ["ocx", "link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), "--json"], + argv: remoteOcxArgv(["link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), "--json"]), knownHostsFile: deps.knownHostsFile, }), { timeoutMs: JOIN_REVOKE_TIMEOUT_MS }, ); - if (result.code !== 0) throw new Error("issue failed"); - const parsed = parseIssuedLink(result.stdout); - if (!parsed) throw new Error("invalid issue response"); - issued = parsed; } catch (error) { - void error; - throw new ClientLinkJoinError("join_issue_failed"); + throw new ClientLinkJoinError("join_issue_failed", undefined, sshRunnerErrorHint(error)); } + // Hints come from stderr only: a successful issue prints the new data key on stdout. + if (result.code === REMOTE_COMMAND_NOT_FOUND) throw new ClientLinkJoinError("remote_ocx_missing", undefined, sshFailureHint(result.stderr)); + const parsed = result.code === 0 ? parseIssuedLink(result.stdout) : null; + if (!parsed) throw new ClientLinkJoinError("join_issue_failed", undefined, result.code === 0 ? undefined : sshFailureHint(result.stderr)); + const issued: IssuedLink = parsed; let tunnel: ClientLinkTunnelHandle | null = null; try { diff --git a/src/client/link-teardown.ts b/src/client/link-teardown.ts index 71909fae32e..baaa8df6026 100644 --- a/src/client/link-teardown.ts +++ b/src/client/link-teardown.ts @@ -1,4 +1,4 @@ -import { buildExecArgv } from "../link/ssh-argv"; +import { buildExecArgv, remoteOcxArgv } from "../link/ssh-argv"; import type { SshRunner } from "../link/ssh-runner"; import type { ClientLinkState } from "./link-state"; import type { OrphanTunnelResult } from "./link-tunnel"; @@ -47,7 +47,7 @@ export async function teardownClientLink( const result = await deps.runner.run( buildExecArgv({ alias: sidecar.alias, - argv: ["ocx", "link", "revoke", "--link-id", sidecar.linkId], + argv: remoteOcxArgv(["link", "revoke", "--link-id", sidecar.linkId]), knownHostsFile: deps.knownHostsFile, }), { timeoutMs: deps.timeoutMs ?? HOME_REVOKE_TIMEOUT_MS }, diff --git a/src/link/ssh-argv.ts b/src/link/ssh-argv.ts index 54d458e9466..e9a82bc827c 100644 --- a/src/link/ssh-argv.ts +++ b/src/link/ssh-argv.ts @@ -12,6 +12,8 @@ * - Tunnel and exec commands use StrictHostKeyChecking=yes. Only the probe uses accept-new, * against an empty temporary file, so the key it records can be shown to the user first. * - Forwards always bind 127.0.0.1 on both ends. + * - Remote ocx runs through `remoteOcxArgv`: a non-interactive ssh session reads no interactive + * profile, so ~/.bun/bin and Homebrew are usually missing from the remote PATH. */ import { isAbsolute } from "node:path"; @@ -129,6 +131,20 @@ export function buildExecArgv(options: ExecArgvOptions): string[] { ]; } +/** + * The remote sh script for every ocx call. `quoteRemote` single-quotes it, so the login shell + * passes it through untouched and `$HOME`/`$PATH` expand in the remote `sh`; the arguments reach + * ocx as `"$@"` without another round of parsing. The fallback directories are appended, so an + * ocx the remote PATH already resolves keeps winning. A missing ocx exits 127. + */ +export const REMOTE_OCX_SCRIPT = 'PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"; exec ocx "$@"'; +/** The POSIX shell's exit status for a command it could not find. */ +export const REMOTE_COMMAND_NOT_FOUND = 127; + +export function remoteOcxArgv(args: readonly string[]): string[] { + return ["sh", "-c", REMOTE_OCX_SCRIPT, "ocx", ...args]; +} + export interface ProbeArgvOptions { alias: string; /** An empty, private, temporary file. The probe records the offered host key here. */ diff --git a/src/link/ssh-runner.ts b/src/link/ssh-runner.ts index b90ba2c1e1f..995de65ee99 100644 --- a/src/link/ssh-runner.ts +++ b/src/link/ssh-runner.ts @@ -1,4 +1,66 @@ +import { homedir } from "node:os"; + const DEFAULT_OUTPUT_BYTES = 64 * 1024; +const HINT_MAX_CHARS = 160; +// Written as escapes on purpose: invisible and bidi controls must never sit literally in source. +const ANSI_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-?]*[ -/]*[@-~]|\u001b[@-_]/g; +const HINT_CONTROL = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g; +const HINT_SECRET = /\bocx_(data|admin|session|pair)_[A-Za-z0-9_-]+/g; +const HINT_URL_QUERY = /\b(https?:\/\/[^\s?#]*)[?#]\S*/g; + +type SpawnEnv = Record; + +/** + * PATH for ssh and ssh-keygen children on POSIX. A desktop sidecar inherits launchd's minimal + * PATH, so a ProxyCommand helper installed by Homebrew or into ~/.bun/bin would not resolve. + * Inherited entries keep their order and precedence; the common helper directories are appended + * once. Windows keeps its inherited environment untouched and gets undefined. + */ +export function linkSshPath(env: SpawnEnv = process.env, platform: NodeJS.Platform = process.platform): string | undefined { + if (platform === "win32") return undefined; + const home = env.HOME || homedir(); + const extra = ["/opt/homebrew/bin", "/usr/local/bin", ...(home ? [`${home}/.bun/bin`, `${home}/.local/bin`] : [])]; + return [...new Set([...(env.PATH ?? "").split(":").filter(Boolean), ...extra])].join(":"); +} + +/** The environment ssh runs with: the inherited one with `linkSshPath`, or undefined on Windows. */ +export function linkSshSpawnEnv(env: SpawnEnv = process.env, platform: NodeJS.Platform = process.platform): SpawnEnv | undefined { + const path = linkSshPath(env, platform); + return path === undefined ? undefined : { ...env, PATH: path }; +} + +/** + * One hint line as the dashboard may see it: control and bidi characters removed, OpenCodex + * secrets and URL queries redacted, whitespace collapsed, capped at 160 code points. The cut is + * made between code points, so an astral character is never split into a lone surrogate. + */ +export function boundHint(line: string): string | undefined { + const clean = line.replace(HINT_CONTROL, " ").replace(HINT_SECRET, "ocx_$1_[redacted]") + .replace(HINT_URL_QUERY, "$1").replace(/\s+/g, " ").trim(); + if (!clean) return undefined; + const points = Array.from(clean); + return points.length > HINT_MAX_CHARS ? `${points.slice(0, HINT_MAX_CHARS - 1).join("")}\u2026` : clean; +} + +/** + * A short reason for a failed ssh command: the last non-empty stderr line, with terminal escapes, + * control and bidi characters removed, OpenCodex secrets and URL queries redacted, capped at 160 + * code points. It reads stderr only; stdout and stdin can carry keys. Callers return it to the + * dashboard and never log it. + */ +export function sshFailureHint(stderr: string): string | undefined { + const lines = stderr.replace(ANSI_SEQUENCE, "").split(/\r\n|\r|\n/); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const hint = boundHint(lines[index]!); + if (hint) return hint; + } + return undefined; +} + +/** The runner's own failure (spawn, timeout, output limit) as a hint; other errors give none. */ +export function sshRunnerErrorHint(error: unknown): string | undefined { + return error instanceof SshRunnerError ? boundHint(error.message) : undefined; +} export interface SshRunResult { code: number; @@ -71,14 +133,19 @@ function defaultKill(process: Bun.Subprocess, signal?: NodeJS.Signals): void { process.kill(signal); } -export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: number } = {}): SshRunner { +export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: number; env?: () => SpawnEnv | undefined } = {}): SshRunner { const spawn = deps.spawn ?? ((argv, options) => Bun.spawn(argv, options)); const defaultTimeoutMs = deps.timeoutMs ?? 30_000; + // Read per spawn so a PATH change reaches the next command; Windows passes no env key at all. + const spawnEnv = (): { env?: SpawnEnv } => { + const env = (deps.env ?? (() => linkSshSpawnEnv()))(); + return env ? { env } : {}; + }; const spawnChild = (argv: readonly string[]): SshChild => { let child: Bun.Subprocess; try { - child = spawn([...argv], { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + child = spawn([...argv], { stdin: "ignore", stdout: "pipe", stderr: "pipe", ...spawnEnv() }); } catch (error) { throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error }); } @@ -108,6 +175,7 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu stdin: options.stdin === undefined ? "ignore" : "pipe", stdout: "pipe", stderr: "pipe", + ...spawnEnv(), }); } catch (error) { throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error }); diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index 12c6eb9bed2..b9642cfebdf 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -2,14 +2,14 @@ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node: import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv } from "../../link/ssh-argv"; +import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../../link/ssh-argv"; import { parseFingerprintLine } from "../../link/fingerprint"; import { awaitFirstAdmission } from "../../link/admission-wait"; import { linkKnownHostsPath, linkStorePath } from "../../link/paths"; import { clearCompensationFailed, compensationPath, markCompensationFailed, readCompensation } from "../../link/compensation"; import { loadHostCandidates } from "../../link/ssh-config"; import { newLinkId, readLinkStore, writeLinkStore, type LinkStore } from "../../link/store"; -import { createSshRunner, type SshRunner } from "../../link/ssh-runner"; +import { boundHint, createSshRunner, sshFailureHint, sshRunnerErrorHint, type SshRunner, type SshRunResult } from "../../link/ssh-runner"; import { projectLinkStatus, type LinkStatusDto } from "../../link/status-projection"; import { isLinkPort } from "../../link/ports"; import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join"; @@ -22,6 +22,13 @@ import { acceptSystemRestart } from "./system-restart"; const PROBE_TTL_MS = 5 * 60_000; const APPLY_ADMISSION_TIMEOUT_MS = 15_000; const LINK_ID = /^lnk_[0-9a-f]{16}$/; +// `ocx link` and `ocx connect --link` first shipped in 2.66.0; an older remote passes --version +// and then fails at apply with remote_port_failed. +const MIN_REMOTE_OCX = [2, 66, 0] as const; +// A bounded semver shape: the parsed version reaches the confirm-host body and the outdated hint, +// so the tail admits only a pre-release and build of semver identifier characters and must end +// the token. Anything else attached to the version makes the output unrecognized. +const REMOTE_OCX_VERSION = /^opencodex (\d{1,9})\.(\d{1,9})\.(\d{1,9})(-[0-9A-Za-z.-]{1,64})?(\+[0-9A-Za-z.-]{1,64})?(?=\s|$)/; const isLinkPath = (path: string): boolean => path === "/api/link" || path.startsWith("/api/link/"); export interface PendingLinkHost { @@ -55,8 +62,13 @@ const states = new WeakMap(); // One process runs at most one join: a join ends by restarting this process as a client. let joinInProgress = false; -function fail(code: string, message: string, status: number): Response { - return Response.json({ error: { code, message } }, { status, headers: { "cache-control": "no-store" } }); +/** + * `hint` is one line bounded by `boundHint`: from ssh stderr (`sshFailureHint`), from the ssh + * runner's own failure (`sshRunnerErrorHint`), or the parsed remote version for + * `remote_ocx_outdated`. It is returned, never logged. + */ +function fail(code: string, message: string, status: number, hint?: string): Response { + return Response.json({ error: { code, message, ...(hint ? { hint } : {}) } }, { status, headers: { "cache-control": "no-store" } }); } function isRecord(value: unknown): value is Record { @@ -74,23 +86,44 @@ function port(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; } -function dashboardSession(ctx: ManagementContext): boolean { +/** A paired GUI session. `POST /api/link/join` admits only this. */ +function pairedSession(ctx: ManagementContext): boolean { return ctx.principal === "gui-session" && ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true; } +/** + * A paired session, or on a standalone runtime the current loopback-issued session that reached + * the public listener bound to a loopback hostname. The loopback bootstrap mints that session + * without a credential, so this is casual-path protection like POST /api/github/star, not a + * secret-backed boundary like the admin token. Hubs keep the paired-only rule, and so does join: + * a join restarts this runtime and moves Codex routing to the Home, which drops live connections. + */ +function dashboardSession(ctx: ManagementContext): boolean { + if (pairedSession(ctx)) return true; + return ctx.principal === "gui-session" + && (ctx.config.runtimeRole ?? "standalone") === "standalone" + && ctx.guiSessionIssuance === "loopback" + && ctx.trustedLoopbackIngress + && ctx.sessionControl?.isCurrent(ctx.req, ctx.config) === true; +} + function adminLoopback(ctx: ManagementContext): boolean { return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress; } -function auth(ctx: ManagementContext, kind: "dashboard" | "admin" | "either"): Response | null { +function auth(ctx: ManagementContext, kind: "paired" | "dashboard" | "admin" | "either"): Response | null { if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403); - const dashboard = dashboardSession(ctx); - const admin = adminLoopback(ctx); - if ((kind === "dashboard" && !dashboard) || (kind === "admin" && !admin) || (kind === "either" && !dashboard && !admin)) { - return fail("forbidden", "The required link authorization was not present.", 403); - } - return null; + const allowed = kind === "paired" ? pairedSession(ctx) + : kind === "dashboard" ? dashboardSession(ctx) + : kind === "admin" ? adminLoopback(ctx) + : dashboardSession(ctx) || adminLoopback(ctx); + return allowed ? null : fail("forbidden", "The required link authorization was not present.", 403); +} + +/** Whether `POST /api/link/join` would pass its admission and role gates for this caller. */ +function joinAvailable(ctx: ManagementContext): boolean { + return pairedSession(ctx) && (ctx.config.runtimeRole ?? "standalone") === "standalone"; } function runnerFor(ctx: ManagementContext): SshRunner { @@ -220,16 +253,16 @@ async function probe(ctx: ManagementContext, state: LinkRouteState): Promise { restoreKnownHost(path, before); return response; }; + let result: SshRunResult; try { - const result = await runnerFor(ctx).run(buildExecArgv({ alias: pending.alias, argv: ["ocx", "--version"], knownHostsFile: path }), { timeoutMs: 30_000 }); - if (result.code !== 0 || !result.stdout.trim()) throw new Error("version probe failed"); - const confirmed = { ...pending, ocxVersion: result.stdout.trim().split(/\r?\n/)[0]! }; - state.confirmedHosts ??= new Map(); - state.confirmedHosts.set(pending.alias, confirmed); - state.pendingHosts.delete(pending.alias); - return Response.json({ alias: pending.alias, fingerprint: pending.fingerprint, ocxVersion: confirmed.ocxVersion }); - } catch { - restoreKnownHost(path, before); - return fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502); + result = await runnerFor(ctx).run(buildExecArgv({ alias: pending.alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 }); + } catch (error) { + return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshRunnerErrorHint(error))); + } + if (result.code === REMOTE_COMMAND_NOT_FOUND) return refuse(fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr))); + if (result.code !== 0) return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshFailureHint(result.stderr))); + const version = parseRemoteOcxVersion(result.stdout); + if (!version) return refuse(fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502)); + if (!meetsRemoteOcxFloor(version.parts)) { + return refuse(fail("remote_ocx_outdated", `The remote OpenCodex is older than ${MIN_REMOTE_OCX.join(".")}.`, 409, boundHint(`opencodex ${version.version}`))); } + const confirmed = { ...pending, ocxVersion: version.version }; + state.confirmedHosts ??= new Map(); + state.confirmedHosts.set(pending.alias, confirmed); + state.pendingHosts.delete(pending.alias); + return Response.json({ alias: pending.alias, fingerprint: pending.fingerprint, ocxVersion: confirmed.ocxVersion }); +} + +/** + * `ocx --version` prints `opencodex ` first; a usage banner, an unbounded version tail or + * other output is refused. + */ +function parseRemoteOcxVersion(stdout: string): { version: string; parts: [number, number, number] } | null { + const match = REMOTE_OCX_VERSION.exec(stdout.trim().split(/\r?\n/)[0] ?? ""); + if (!match) return null; + return { version: `${match[1]}.${match[2]}.${match[3]}${match[4] ?? ""}${match[5] ?? ""}`, parts: [Number(match[1]), Number(match[2]), Number(match[3])] }; +} + +function meetsRemoteOcxFloor(parts: readonly [number, number, number]): boolean { + for (let index = 0; index < MIN_REMOTE_OCX.length; index += 1) { + if (parts[index] !== MIN_REMOTE_OCX[index]) return parts[index]! > MIN_REMOTE_OCX[index]!; + } + return true; } function parsePortOutput(stdout: string): number | null { @@ -274,11 +332,14 @@ async function apply(ctx: ManagementContext, state: LinkRouteState): Promise {}); const compensation = await compensateNewLink(ctx, state, record); - return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502); + if (compensation) return compensation; + const hint = sshFailureHint(result.stderr); + return result.code === REMOTE_COMMAND_NOT_FOUND + ? fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, hint) + : fail("remote_connect_failed", "The remote link connection failed.", 502, hint); } try { await admission; @@ -322,10 +387,10 @@ async function apply(ctx: ManagementContext, state: LinkRouteState): Promise {}); const compensation = await compensateNewLink(ctx, state, record); - return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502); + return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502, sshRunnerErrorHint(error)); } finally { input.fill(0); } @@ -383,11 +448,13 @@ type ManagementApiDepsWithJoinOverrides = ManagementContext["deps"] & LinkJoinRo function joinFailure(error: unknown): Response { const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : ""; + const hint = error && typeof error === "object" && "hint" in error && typeof error.hint === "string" ? error.hint : undefined; switch (code) { case "host_not_confirmed": return fail("host_not_confirmed", "Confirm the SSH host before joining the link.", 409); case "host_confirmation_expired": return fail("host_confirmation_expired", "The SSH host confirmation has expired.", 409); case "join_port_failed": return fail("join_port_failed", "No local port is available for the link tunnel.", 503); - case "join_issue_failed": return fail("join_issue_failed", "The home could not issue a link.", 502); + case "join_issue_failed": return fail("join_issue_failed", "The home could not issue a link.", 502, hint); + case "remote_ocx_missing": return fail("remote_ocx_missing", "ocx was not found on the home.", 502, hint); case "join_tunnel_failed": return fail("join_tunnel_failed", "The SSH tunnel to the home did not become ready.", 502); case "admission_failed": return fail("admission_failed", "The home refused the issued link key.", 502); case "join_rollback_failed": { @@ -442,14 +509,14 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string) }; if (!force && record.direction === "hub-initiated") { try { - const result = await runnerFor(ctx).run(buildExecArgv({ alias: record.alias, argv: ["ocx", "disconnect"], knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 }); + const result = await runnerFor(ctx).run(buildExecArgv({ alias: record.alias, argv: remoteOcxArgv(["disconnect"]), knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 }); if (result.code !== 0) { await restartTunnel(); - return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502); + return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshFailureHint(result.stderr)); } - } catch { + } catch (error) { await restartTunnel(); - return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502); + return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshRunnerErrorHint(error)); } } if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502); @@ -471,7 +538,8 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L if (!isLinkPath(path)) return null; if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403); if (url.pathname === "/api/link/join" && req.method === "POST") { - const denied = auth(ctx, "dashboard"); + // Paired only: a loopback dashboard session may run the Home side, never this restart. + const denied = auth(ctx, "paired"); if (denied) return denied; if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return fail("standalone_required", "Client initiated links require standalone runtime mode.", 409); const state = suppliedState ?? stateFor(ctx); @@ -497,7 +565,10 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L const failure = state.compensationFailures?.get(store.links[0].id); if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason }); } - return Response.json(dto, { headers: { "cache-control": "no-store" } }); + // A dashboard session also learns whether it may join as a Child. The admin-token answer stays + // the exact K16 document that `ocx link status` validates key by key. + const body: LinkStatusDto & { joinAvailable?: boolean } = ctx.principal === "gui-session" ? { ...dto, joinAvailable: joinAvailable(ctx) } : dto; + return Response.json(body, { headers: { "cache-control": "no-store" } }); } if (url.pathname === "/api/link/candidates" && req.method === "GET") { const denied = auth(ctx, "dashboard"); diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 23ed261db80..c094dc438af 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -368,11 +368,11 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/storage/codex-logs/unprotect", module: "server/management/storage-log-guard-routes", mutates: true }, // server/management/link-routes { method: "GET", path: "/api/link/status", module: "server/management/link-routes", mutates: false }, - { method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard pairing surface and are withheld from admin-token and Tailscale identity sessions." } }, - { method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "SSH probing and host-key presentation are part of the interactive pairing consent flow." } }, - { method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Persisting a host key requires the paired dashboard session that saw the fingerprint." } }, - { method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client." } }, - { method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Applying a link issues a data key and starts a remote tunnel, so it requires the paired dashboard session." } }, + { method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard surface: a paired session, or on a standalone runtime the current loopback dashboard session on trusted loopback ingress; admin-token and Tailscale identity sessions are refused." } }, + { method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "SSH probing and host-key presentation are part of the interactive dashboard consent flow (paired, or standalone loopback dashboard on trusted loopback ingress)." } }, + { method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Persisting a host key requires the dashboard session that saw the fingerprint (paired, or standalone loopback dashboard on trusted loopback ingress)." } }, + { method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client, which drops live Codex connections. The route admits only a paired session, which a standalone runtime never issues, so no dashboard can join in this release; the loopback dashboard session is refused." } }, + { method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Applying a link issues a data key and starts a remote tunnel, so it requires a dashboard session (paired, or standalone loopback dashboard on trusted loopback ingress)." } }, { method: "DELETE", path: "/api/link/{id}", module: "server/management/link-routes", mutates: true, mechanism: "regex" }, { method: "POST", path: "/api/link/issue", module: "server/management/link-routes", mutates: true }, // server/management/remote-workspace-routes diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 54f10f363fc..b8b0076246c 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -205,7 +205,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the five operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged; it never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | -| Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: paired dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | +| Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach the Home-side routes `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` stays paired-only, because a join restarts this runtime and moves Codex routing to the Home, and a standalone runtime never issues a paired session, so no dashboard can join as a Child in this release; `GET /api/link/status` reports `joinAvailable` (false in practice) to GUI-session callers so the dashboard disables the Child role and points to Home-initiated linking; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | diff --git a/structure/remote-link.md b/structure/remote-link.md index 70e6022dcd5..b5091a41f9f 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -4,6 +4,8 @@ `src/link/ssh-argv.ts` builds every OpenSSH argument vector. All commands run with BatchMode and trust only the link known_hosts file, keyed by `HostKeyAlias=`: the global file is disabled with `GlobalKnownHostsFile=none`, and `KnownHostsCommand=none`, `VerifyHostKeyDNS=no` and `CheckHostIP=no` shut out every other source of host-key trust. Command-line options take precedence over `~/.ssh/config`, so a user config cannot re-enable them. Tunnel and exec commands use `StrictHostKeyChecking=yes`; only the probe uses `accept-new`, against an empty temporary file, so an offered key can be shown before it is trusted. The known_hosts path must be absolute and free of ssh expansion syntax. Forwards bind 127.0.0.1 on both ends, and aliases that could be parsed as options are refused. +Every remote `ocx` call goes through `remoteOcxArgv`, which runs `sh -c` with a PATH prelude that appends `$HOME/.bun/bin`, `$HOME/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin` after the remote PATH and then `exec ocx "$@"`. A non-interactive ssh session reads no interactive profile, so without the fallbacks an ocx installed by Bun or Homebrew is not found; because they come last, an ocx the remote PATH already resolves keeps winning. `quoteRemote` single-quotes the script, so the login shell passes it through and `$HOME` and `$PATH` expand in the remote `sh`; the arguments reach ocx without another round of parsing. A remote exit status of 127 means ocx was not found and maps to `remote_ocx_missing`. + `src/link/ssh-config.ts` lists host candidates from `~/.ssh/config`. Arguments are split with the rules of OpenSSH's `argv_split`. Pattern hosts, `Match` blocks and aliases that fail the alias check produce no candidates, and only top-level `Include` directives are followed, because an include inside a `Host` or `Match` block is conditional. A candidate is an offer, not trust. `src/link/tunnel-state.ts` is the tunnel lifecycle reducer: connecting, connected, reconnecting with capped jittered backoff, and failed for auth, host-key and forward errors or after five minutes without a connection, whether or not an attempt is in flight. `src/link/supervisor.ts` keeps a spawned tunnel in connecting until its link key authenticates a catalog request or the child remains alive for five seconds. @@ -14,7 +16,7 @@ ## Client-initiated links -`src/server/management/link-routes.ts` accepts `POST /api/link/join` with exactly `{ "alias": string }`. The route admits only a paired GUI session on a standalone runtime; a Tailscale identity session receives `403 tailscale_session_refused`, another runtime role receives `409 standalone_required`, and these gates run before link state is read. The alias must have a confirmed, unexpired host entry in the same route state. Before choosing a port or issuing a new link, a valid stale client sidecar is compensated over SSH unless the machine is already connected to that link; a successful revoke clears the sidecar, while a failed revoke preserves it and returns `join_rollback_failed` with the link id. A corrupt sidecar is left for the next successful write. A successful join issues the Home link through SSH, records the client sidecar, starts the client tunnel and connects the client, then returns `202 { "linkId": string, "alias": string, "restarting": true }`. +`src/server/management/link-routes.ts` accepts `POST /api/link/join` with exactly `{ "alias": string }`. The route admits only a paired GUI session on a standalone runtime: the loopback dashboard session receives `403 forbidden` (see [Dashboard admission](#dashboard-admission)), a Tailscale identity session receives `403 tailscale_session_refused`, a paired session on another runtime role receives `409 standalone_required`, and these gates run before link state is read. A standalone runtime never issues a paired session, because `src/server/gui-session.ts` creates and redeems pairing grants only on a hub runtime, so no dashboard session passes both gates: in this release a computer cannot join as a Child from the dashboard, and Home-initiated linking through `POST /api/link/apply` is the supported path. The alias must have a confirmed, unexpired host entry in the same route state. Before choosing a port or issuing a new link, a valid stale client sidecar is compensated over SSH unless the machine is already connected to that link; a successful revoke clears the sidecar, while a failed revoke preserves it and returns `join_rollback_failed` with the link id. A corrupt sidecar is left for the next successful write. A successful join issues the Home link through SSH, records the client sidecar, starts the client tunnel and connects the client, then returns `202 { "linkId": string, "alias": string, "restarting": true }`. `src/client/link-state.ts` stores `/link/client-link.json` with mode 0600. The sidecar contains exactly `alias`, `hubHostKeyFingerprint`, `tunnelPort`, `peerListenerPort` and `linkId`; it contains no key. The client tunnel port uses `MIN_LINK_PORT = 1024` through `MAX_LINK_PORT = 65535` and `isLinkPort`; the Home listener port keeps its existing 1–65535 contract. @@ -24,9 +26,17 @@ The client tunnel pidfile is `/link/client-tunnel.pid` with `{ versio `ocx disconnect` on the client tears down a matching sidecar link by attempting one SSH `ocx link revoke --link-id ` on Home, then disconnecting the client state and deleting the sidecar after rechecking ownership. A revoke failure still completes local disconnect and prints `Home revoke failed; run ocx link revoke --link-id on the home.`; orphan cleanup runs through the same `reapOrphanTunnel` rule before sidecar parsing, including when the sidecar is corrupt or mismatched. After `connectClient` commits during a join, a restart scheduling failure leaves the connection and sidecar intact and returns `join_restart_failed`; the operator restarts OpenCodex to finish connecting as a Child. +## Dashboard admission + +The Home-side link routes (`GET /api/link/status`, `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply` and `DELETE /api/link/{id}`) admit a paired GUI session, or, on a standalone runtime only, the current loopback-issued GUI session that reached the public listener bound to a loopback hostname. None of them touches this machine's own `127.0.0.1:` listener or its Codex routing. The hub-link, hub-management and claude-intercept ingresses are never trusted loopback ingress, and a hub keeps the paired-only rule. The Tailscale identity refusal runs before either check. A loopback session is minted by the loopback dashboard bootstrap without a credential, so it proves possession, not user presence: any local process can fetch the bootstrap and replay its token and CSRF value, which is why `src/client/machine-listener.ts` (the `/api/machine/` block, lines ~125-131) keeps durable machine changes away from that session. The Home-side link routes deliberately accept this casual-path trade, the same as `POST /api/github/star` in `src/server/management/sidebar-routes.ts`; it is not a secret-backed boundary like the admin token. + +`POST /api/link/join` stays paired-only. A successful join restarts this proxy (a 503 drain, then a closed listener) and re-routes Codex and Claude through the Home tunnel, so turning Remote Link on from the local dashboard must never be able to drop the Codex connections already running on this machine. `GET /api/link/status` tells a GUI-session caller whether it may join: the response gains `joinAvailable`, computed from the same paired-session check the join route uses plus the standalone runtime role, so it is false for every dashboard session in this release. An admin-token caller gets the exact K16 document without that field, because `ocx link status` validates it key by key. The dashboard reads an absent field as false; while it is false, the Child role card cannot be selected by pointer or keyboard, a notice says that connecting this computer as a Child from the dashboard is not available in this release, because joining restarts OpenCodex and would drop existing Codex connections, and points to Home-initiated linking; no join request can be sent. + +`confirm-host` requires the remote `ocx --version` to print `opencodex ..` of at least 2.66.0, the first release with `ocx link`. The version is parsed to a bounded semver shape: each number has at most nine digits, an optional pre-release and build of at most 64 identifier characters each follow, and the token must end there. An older version answers `409 remote_ocx_outdated`, output that does not start with such a line (a usage banner, or a version with anything else attached) answers `502 remote_ocx_unrecognized`, and exit 127 answers `502 remote_ocx_missing`. Every refusal restores the link known_hosts file and keeps the pending probe, so a retry within the probe TTL needs no new probe. Link error bodies may carry `error.hint`, one line from one of three sources: the last non-empty ssh stderr line with terminal escapes removed, the ssh runner's own spawn, timeout or output-limit failure, or, for `remote_ocx_outdated`, `opencodex ` built only from the bounded version match. Every hint then has control and bidi characters removed, OpenCodex secrets and URL queries redacted, and is capped at 160 code points, cut between code points so a surrogate pair is never split. Hints never come from stdin and are never logged. + ## Tunnels, management and CLI -`src/link/ssh-runner.ts` runs every OpenSSH and `ssh-keygen` argv without a shell and caps captured output. `src/link/supervisor.ts` keeps one `ssh -R` child per hub-initiated link, drives it with the tunnel reducer, coalesces reloads without dropping a later request, reconciles unowned `link:` API keys at startup, and stops children before the hub-link listener on shutdown. It reaps a leftover tunnel only when Linux `/proc//cmdline` matches the recorded argv exactly; on other platforms a leftover is reported, never killed. `src/link/status-projection.ts` builds the status document the dashboard and `ocx link status` read, including persisted compensation failures, and `src/link/admission-wait.ts` waits for the first key-authenticated `/v1/catalog` read that proves a new link works. +`src/link/ssh-runner.ts` runs every OpenSSH and `ssh-keygen` argv without a shell and caps captured output. On POSIX it spawns them with the inherited PATH plus `/opt/homebrew/bin`, `/usr/local/bin`, `$HOME/.bun/bin` and `$HOME/.local/bin` appended once, so a ProxyCommand helper resolves inside a desktop sidecar that inherited a minimal PATH; Windows keeps the inherited environment. `src/link/supervisor.ts` keeps one `ssh -R` child per hub-initiated link, drives it with the tunnel reducer, coalesces reloads without dropping a later request, reconciles unowned `link:` API keys at startup, and stops children before the hub-link listener on shutdown. It reaps a leftover tunnel only when Linux `/proc//cmdline` matches the recorded argv exactly; on other platforms a leftover is reported, never killed. `src/link/status-projection.ts` builds the status document the dashboard and `ocx link status` read, including persisted compensation failures, and `src/link/admission-wait.ts` waits for the first key-authenticated `/v1/catalog` read that proves a new link works. Applying a link probes the host key into a temporary file, waits for the operator to confirm the fingerprint, issues a data key, records the link, starts the tunnel and runs the client's `ocx connect --link --key-stdin` over SSH with the key on standard input. A failed step revokes the new key first and removes the record only after revocation succeeds; if revocation fails the `src/link/` state persists a `compensation_failed` marker, and status reports the failed compensation after restart. A listener that is not listening fails the request instead of handing out a key. Removing a link stops its tunnel, disconnects the client, revokes the key and deletes the record; a failed client disconnect restarts the tunnel and keeps the record and key unless removal is forced. `src/cli/link.ts` provides `ocx link port|issue|revoke|status`. `ocx link revoke` is idempotent: a `404 link_not_found` answer exits 0, because removal revokes the key before it deletes the record, so a missing record means the key is already gone. A 404 without that code still fails. @@ -34,4 +44,4 @@ Applying a link probes the host key into a temporary file, waits for the operato A client connected with `transport: "link"` reaches its hub through an SSH tunnel instead of a public origin. Its `serverUrl` and `managementUrl` are both `http://127.0.0.1:`, and `ocx connect --link --key-stdin` accepts the data key on bounded standard input instead of issuing one over HTTP. The key is stored only in the service token file and is sent on readiness, catalog, hub-state and usage reads. Codex keeps routing to the client's own `http://localhost:`, with WebSockets forced off, and `src/client/link-relay.ts` forwards exactly the `linkRouteAllowed` routes from `src/link/routes.ts` through the tunnel. The relay adds no credential, rejects upgrades, keeps the hub-relay header and body bounds, streams SSE with caller-abort propagation and a 300-second idle limit, and answers 503 with Retry-After while the tunnel is down. Link mode binds the configured port or fails to start, turns the management relay off, and refuses key rotation and revocation, which belong to the hub. -Regression coverage lives in `tests/clients/link-ssh-argv.test.ts`, `tests/clients/link-ssh-config.test.ts`, `tests/clients/link-tunnel-state.test.ts`, `tests/clients/link-store.test.ts`, `tests/clients/link-boundary.test.ts`, `tests/clients/link-routes.test.ts`, `tests/clients/client-link-connect.test.ts`, `tests/clients/client-link-relay.test.ts`, `tests/clients/client-link-runtime.test.ts`, `tests/codex-integration/injection-link-websocket.test.ts`, `tests/clients/link-supervisor.test.ts`, `tests/clients/link-status-projection.test.ts`, `tests/clients/link-admission-wait.test.ts`, `tests/clients/link-fingerprint.test.ts`, `tests/cli/cli-link.test.ts` and `tests/server/link-management-routes.test.ts`. +Regression coverage lives in `tests/clients/link-ssh-argv.test.ts`, `tests/clients/link-ssh-config.test.ts`, `tests/clients/link-tunnel-state.test.ts`, `tests/clients/link-store.test.ts`, `tests/clients/link-boundary.test.ts`, `tests/clients/link-routes.test.ts`, `tests/clients/client-link-connect.test.ts`, `tests/clients/client-link-relay.test.ts`, `tests/clients/client-link-runtime.test.ts`, `tests/codex-integration/injection-link-websocket.test.ts`, `tests/clients/link-supervisor.test.ts`, `tests/clients/link-status-projection.test.ts`, `tests/clients/link-admission-wait.test.ts`, `tests/clients/link-fingerprint.test.ts`, `tests/cli/cli-link.test.ts`, `tests/server/link-management-routes.test.ts`, `tests/server/link-join-route.test.ts` and `tests/clients/client-link-teardown.test.ts`. diff --git a/tests/clients/client-link-teardown.test.ts b/tests/clients/client-link-teardown.test.ts index c232992011e..540bc7cf348 100644 --- a/tests/clients/client-link-teardown.test.ts +++ b/tests/clients/client-link-teardown.test.ts @@ -5,6 +5,7 @@ import { handleDisconnectCommand } from "../../src/cli/connect"; import { connectClient } from "../../src/client/connect"; import { teardownClientLink } from "../../src/client/link-teardown"; import { clientLinkStatePath, writeClientLinkState } from "../../src/client/link-state"; +import { quoteRemote, remoteOcxArgv } from "../../src/link/ssh-argv"; import { createTempHome } from "../helpers/temp-home"; const linkId = "lnk_0123456789abcdef"; @@ -39,8 +40,8 @@ test("teardown revokes the matching link once and returns tunnel state", async ( expect(calls).toHaveLength(1); expect(calls[0]?.timeoutMs).toBe(30_000); expect(calls[0]?.argv).toContain("home.example.test"); - expect(calls[0]?.argv.join(" ")).toContain("ocx"); - expect(calls[0]?.argv.join(" ")).toContain(linkId); + // The revoke runs through the remote PATH prelude, never as a bare `ocx`. + expect(calls[0]?.argv.at(-1)).toBe(quoteRemote(remoteOcxArgv(["link", "revoke", "--link-id", linkId]))); }); test("teardown reports revoke failure and leaves a mismatched sidecar alone", async () => { diff --git a/tests/clients/link-ssh-argv.test.ts b/tests/clients/link-ssh-argv.test.ts index 2b685dfea0d..8d17fe617da 100644 --- a/tests/clients/link-ssh-argv.test.ts +++ b/tests/clients/link-ssh-argv.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -8,8 +8,20 @@ import { buildTunnelArgv, LinkSshArgumentError, quoteRemote, + REMOTE_OCX_SCRIPT, + remoteOcxArgv, } from "../../src/link/ssh-argv"; +import { + boundHint, + createSshRunner, + linkSshPath, + linkSshSpawnEnv, + sshFailureHint, + sshRunnerErrorHint, + SshRunnerError, +} from "../../src/link/ssh-runner"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; const roots: string[] = []; @@ -122,3 +134,122 @@ test("quoteRemote escapes single quotes and rejects NUL", () => { expect(quoteRemote(["it's"])).toBe(`'it'"'"'s'`); expect(() => quoteRemote(["bad\0argument"])).toThrow(LinkSshArgumentError); }); + +test("remote ocx argv runs ocx through a single-quoted sh PATH prelude", () => { + expect(REMOTE_OCX_SCRIPT).toBe('PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"; exec ocx "$@"'); + expect(remoteOcxArgv(["link", "port"])).toEqual(["sh", "-c", REMOTE_OCX_SCRIPT, "ocx", "link", "port"]); + const argv = buildExecArgv({ alias: "delta.example.test", argv: remoteOcxArgv(["link", "issue", "--alias", "it's x", "--json"]), knownHostsFile: tempPath("remote-ocx") }); + expect(argv.at(-1)).toBe(`'sh' '-c' 'PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"; exec ocx "$@"' 'ocx' 'link' 'issue' '--alias' 'it'"'"'s x' '--json'`); +}); + +function fakeOcx(dir: string, label: string): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "ocx"), `#!/bin/sh\nprintf "%s\\n" "${label}" "$PATH"\nfor arg in "$@"; do printf "[%s]\\n" "$arg"; done\n`, { mode: 0o755 }); +} + +test.skipIf(process.platform === "win32")("the remote prelude appends ~/.bun/bin after the remote PATH and keeps every argument", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-link-remote-home-")); + roots.push(home); + fakeOcx(join(home, ".bun", "bin"), "bun"); + const command = quoteRemote(remoteOcxArgv(["link", "issue", "--alias", "it's x", "--json"])); + const result = Bun.spawnSync(["/bin/sh", "-c", command], { env: { HOME: home, PATH: "/usr/bin:/bin" } }); + expect(result.exitCode).toBe(0); + const lines = result.stdout.toString().trim().split("\n"); + expect(lines[0]).toBe("bun"); + expect(lines[1]).toBe(`/usr/bin:/bin:${home}/.bun/bin:${home}/.local/bin:/opt/homebrew/bin:/usr/local/bin`); + expect(lines.slice(2)).toEqual(["[link]", "[issue]", "[--alias]", "[it's x]", "[--json]"]); +}); + +test.skipIf(process.platform === "win32")("an ocx the remote PATH already resolves wins over the appended fallbacks", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-link-remote-first-")); + roots.push(home); + fakeOcx(join(home, "first"), "first"); + fakeOcx(join(home, ".bun", "bin"), "bun"); + const result = Bun.spawnSync(["/bin/sh", "-c", quoteRemote(remoteOcxArgv(["--version"]))], { env: { HOME: home, PATH: `${home}/first:/usr/bin:/bin` } }); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString().split("\n")[0]).toBe("first"); +}); + +test("ssh PATH appends helper directories once and leaves Windows untouched", () => { + expect(linkSshPath({ PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: "/Users/test" }, "darwin")) + .toBe("/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin:/Users/test/.bun/bin:/Users/test/.local/bin"); + expect(linkSshPath({ PATH: "/opt/homebrew/bin:/usr/bin:/usr/bin", HOME: "/h" }, "linux")) + .toBe("/opt/homebrew/bin:/usr/bin:/usr/local/bin:/h/.bun/bin:/h/.local/bin"); + expect(linkSshPath({ HOME: "/h" }, "darwin")).toBe("/opt/homebrew/bin:/usr/local/bin:/h/.bun/bin:/h/.local/bin"); + expect(linkSshPath({ PATH: "C:\\Windows", HOME: "C:\\Users\\t" }, "win32")).toBeUndefined(); + expect(linkSshSpawnEnv({ PATH: "C:\\Windows" }, "win32")).toBeUndefined(); + expect(linkSshSpawnEnv({ PATH: "/usr/bin", HOME: "/h", SSH_AUTH_SOCK: "/tmp/agent" }, "darwin")) + .toEqual({ PATH: "/usr/bin:/opt/homebrew/bin:/usr/local/bin:/h/.bun/bin:/h/.local/bin", HOME: "/h", SSH_AUTH_SOCK: "/tmp/agent" }); +}); + +function fakeSpawn(captured: Array>): typeof Bun.spawn { + const closed = () => new ReadableStream({ start(controller) { controller.close(); } }); + return ((_argv: string[], options: Record) => { + captured.push(options); + return { pid: 7, stdout: closed(), stderr: closed(), stdin: undefined, exited: Promise.resolve(0), kill() {} }; + }) as unknown as typeof Bun.spawn; +} + +test("the runner spawns commands and tunnels with the augmented environment", async () => { + const captured: Array> = []; + const runner = createSshRunner({ spawn: fakeSpawn(captured), env: () => linkSshSpawnEnv({ PATH: "/usr/bin:/bin", HOME: "/h" }, "darwin") }); + expect((await runner.run(["ssh", "-G", "--", "host"])).code).toBe(0); + runner.spawnTunnel(["ssh", "-N", "--", "host"]); + expect(captured.map(options => (options.env as Record).PATH)) + .toEqual(Array(2).fill("/usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/h/.bun/bin:/h/.local/bin")); + + const windows: Array> = []; + const windowsRunner = createSshRunner({ spawn: fakeSpawn(windows), env: () => linkSshSpawnEnv({ PATH: "C:\\Windows" }, "win32") }); + await windowsRunner.run(["ssh", "-G", "--", "host"]); + windowsRunner.spawnTunnel(["ssh", "-N", "--", "host"]); + expect(windows).toHaveLength(2); + expect(windows.every(options => !("env" in options))).toBe(true); + + if (process.platform !== "win32") { + const defaults: Array> = []; + await createSshRunner({ spawn: fakeSpawn(defaults) }).run(["ssh", "-G", "--", "host"]); + expect((defaults[0]?.env as Record).PATH.split(":")).toContain("/opt/homebrew/bin"); + } +}); + +test("ssh failure hints keep one clean stderr line and redact secrets", () => { + expect(sshFailureHint("\u001b[31mdebug noise\u001b[0m\nuser@host: Permission denied (publickey).\r\n\n")).toBe("user@host: Permission denied (publickey)."); + expect(sshFailureHint("bad\u202e line\u0007\there")).toBe("bad line here"); + expect(sshFailureHint(`issue failed ocx_data_${"a".repeat(40)} and ocx_session_x-y`)).toBe("issue failed ocx_data_[redacted] and ocx_session_[redacted]"); + expect(sshFailureHint("open https://team.example.test/cdn-cgi/access/cli?token=abc&aud=x to log in")).toBe("open https://team.example.test/cdn-cgi/access/cli to log in"); + const long = sshFailureHint("x".repeat(400)); + expect(long?.length).toBe(160); + expect(long?.endsWith("…")).toBe(true); + expect(sshFailureHint("")).toBeUndefined(); + expect(sshFailureHint("\n \u001b[0m\n")).toBeUndefined(); + expect(sshRunnerErrorHint(new SshRunnerError("timeout", "ssh command exceeded 30000ms"))).toBe("ssh command exceeded 30000ms"); + expect(sshRunnerErrorHint(new Error("unrelated"))).toBeUndefined(); +}); + +test("hint bounding caps astral text by code point and never leaves a lone surrogate", () => { + const astral = String.fromCodePoint(0x1f511); + // 200 astral characters are 400 UTF-16 units; a unit-based cut at 159 would split a pair. + for (const hint of [boundHint(astral.repeat(200)), sshFailureHint(`noise\n${astral.repeat(200)}\n`)]) { + const points = Array.from(hint ?? ""); + expect(points).toHaveLength(160); + expect(points.at(-1)).toBe(String.fromCodePoint(0x2026)); + expect(points.slice(0, -1).every(point => point === astral)).toBe(true); + expect(points.every(point => { const code = point.codePointAt(0)!; return code < 0xd800 || code > 0xdfff; })).toBe(true); + } + expect(boundHint(astral.repeat(160))).toBe(astral.repeat(160)); +}); + +/** Invisible and bidi formatting code points that must never sit literally in link hint sources. */ +function isInvisibleOrBidi(code: number): boolean { + return (code >= 0x7f && code <= 0x9f) || (code >= 0x200b && code <= 0x200f) || (code >= 0x202a && code <= 0x202e) + || (code >= 0x2060 && code <= 0x206f) || code === 0xfeff; +} + +test("link hint sources spell invisible and bidi characters as escapes, and hints still strip them", () => { + for (const file of ["src/link/ssh-runner.ts", "src/server/management/link-routes.ts", "gui/src/remote-link-api.ts"]) { + const literal = [...readFileSync(repoPath(file), "utf8")].map(char => char.codePointAt(0)!).filter(isInvisibleOrBidi); + expect({ file, literal: literal.map(code => code.toString(16)) }).toEqual({ file, literal: [] }); + } + const controls = [0x85, 0x200b, 0x200f, 0x202a, 0x202e, 0x2060, 0x206f, 0xfeff].map(code => String.fromCodePoint(code)).join(""); + expect(sshFailureHint(`left${controls}right`)).toBe("left right"); +}); diff --git a/tests/server/link-join-route.test.ts b/tests/server/link-join-route.test.ts index ca57e13df0c..80c7a9e9bbd 100644 --- a/tests/server/link-join-route.test.ts +++ b/tests/server/link-join-route.test.ts @@ -3,8 +3,19 @@ import { ClientLinkJoinError, joinHome, type ClientLinkJoinDeps } from "../../sr import { handleLinkRoutes, type LinkRouteState } from "../../src/server/management/link-routes"; import type { ManagementContext } from "../../src/server/management/context"; import type { SshRunner } from "../../src/link/ssh-runner"; +import { quoteRemote, remoteOcxArgv } from "../../src/link/ssh-argv"; const LINK_ID = "lnk_0123456789abcdef"; +const REVOKE_COMMAND = quoteRemote(remoteOcxArgv(["link", "revoke", "--link-id", LINK_ID])); + +/** The issue command up to its variable arguments, wrapped in the remote PATH prelude. */ +function isWrappedIssue(argv: readonly string[]): boolean { + return (argv.at(-1) ?? "").startsWith(`${quoteRemote(remoteOcxArgv(["link", "issue", "--alias"]))} `); +} + +function revokeCalls(calls: readonly string[][]): string[][] { + return calls.filter(argv => argv.at(-1) === REVOKE_COMMAND); +} const API_KEY_ID = "link-key-1"; const KEY = `ocx_data_${"a".repeat(40)}`; const FINGERPRINT = `SHA256:${"a".repeat(32)}`; @@ -13,7 +24,7 @@ function runnerFor(calls: string[][], issueResult = true): SshRunner { return { run: async argv => { calls.push([...argv]); - if (argv.some(value => value.includes("issue"))) { + if (isWrappedIssue(argv)) { return issueResult ? { code: 0, stdout: JSON.stringify({ linkId: LINK_ID, apiKeyId: API_KEY_ID, key: KEY, listenerPort: 45678 }), stderr: "" } : { code: 1, stdout: "", stderr: "failed" }; @@ -69,6 +80,7 @@ function context(options: { principal?: ManagementContext["principal"]; paired?: boolean; issuance?: ManagementContext["guiSessionIssuance"]; + trustedLoopback?: boolean; body?: unknown; deps?: Record; } = {}): ManagementContext { @@ -85,8 +97,8 @@ function context(options: { deps: options.deps ?? {}, version: "test", principal: options.principal, - sessionControl: { isPaired: () => options.paired === true }, - trustedLoopbackIngress: true, + sessionControl: { isPaired: () => options.paired === true, isCurrent: () => true, revokeCurrent: () => true }, + trustedLoopbackIngress: options.trustedLoopback ?? true, guiSessionIssuance: options.issuance ?? null, convergeCodexCatalog: async () => ({ status: "unchanged" } as never), syncClaudeAgentDefsBestEffort: async () => {}, @@ -110,6 +122,61 @@ describe("client initiated link join", () => { } }); + test("refuses every loopback dashboard session with 403 before a join starts", async () => { + // A join restarts this proxy and moves Codex routing to the Home, dropping live Codex + // connections, so the credentialless loopback session never reaches it; only pairing does. + let joins = 0; + const deps = { joinHome: async () => { joins += 1; return { linkId: LINK_ID, apiKeyId: API_KEY_ID }; } }; + for (const options of [ + { role: "standalone" as const, trustedLoopback: true }, + { role: "standalone" as const, trustedLoopback: false }, + { role: "hub" as const, trustedLoopback: true }, + { role: "client" as const, trustedLoopback: true }, + ]) { + const response = await handleLinkRoutes(context({ ...options, principal: "gui-session", issuance: "loopback", deps }), routeState()); + expect(response?.status).toBe(403); + expect(await response?.json()).toMatchObject({ error: { code: "forbidden" } }); + } + expect(joins).toBe(0); + + const paired = await handleLinkRoutes(context({ principal: "gui-session", issuance: "pairing", paired: true, deps }), routeState()); + expect(paired?.status).toBe(202); + expect(joins).toBe(1); + }); + + test("maps a missing ocx on Home to remote_ocx_missing with a redacted stderr hint", async () => { + const calls: string[][] = []; + const runner: SshRunner = { + run: async argv => { + calls.push([...argv]); + return isWrappedIssue(argv) + ? { code: 127, stdout: "", stderr: `sh: 1: exec: ocx: not found ${KEY}\n` } + : { code: 0, stdout: "", stderr: "" }; + }, + spawnTunnel: () => ({ pid: 1, argv: [], exited: Promise.resolve(0), kill: () => {} }), + }; + const response = await handleLinkRoutes(context({ + principal: "gui-session", + paired: true, + deps: { + sshRunner: runner, + linkKnownHostsPath: () => "/tmp/ocx-known-hosts", + joinHome: (async (deps, input) => joinHome({ + ...deps, + choosePort: async () => 23456, + now: () => 1, + readSidecar: () => null, + readConnectionState: () => ({ kind: "disconnected" }), + }, input)) as typeof import("../../src/client/link-join").joinHome, + }, + }), routeState()); + expect(response?.status).toBe(502); + expect(await response?.json()).toEqual({ + error: { code: "remote_ocx_missing", message: "ocx was not found on the home.", hint: "sh: 1: exec: ocx: not found ocx_data_[redacted]" }, + }); + expect(calls.filter(isWrappedIssue)).toHaveLength(1); + }); + test("requires the exact body and a confirmed host", async () => { const exact = await handleLinkRoutes(context({ principal: "gui-session", paired: true, body: { alias: "home", extra: true } }), routeState()); expect(exact?.status).toBe(400); @@ -172,8 +239,8 @@ describe("client initiated link join", () => { expect(responseBody).toEqual({ linkId: LINK_ID, alias: "home", restarting: true }); expect(sidecar).toMatchObject({ linkId: LINK_ID, tunnelPort: 23456, peerListenerPort: 45678 }); expect(order).toEqual(["write-state", "spawn-tunnel", "readyz:key", "connect", "stop-tunnel", "restart"]); - expect(calls[0]?.some(value => value.includes("issue"))).toBe(true); - expect(calls[0]?.some(value => value.includes("--json"))).toBe(true); + expect(isWrappedIssue(calls[0] ?? [])).toBe(true); + expect(calls[0]?.at(-1)?.endsWith(" '--json'")).toBe(true); }); test("rolls back sidecar and remote issue when sidecar write fails", async () => { @@ -185,7 +252,7 @@ describe("client initiated link join", () => { clearState: () => { cleared = true; }, spawnTunnel: () => { throw new Error("must not start"); }, }), { alias: "home" })).rejects.toMatchObject({ code: "join_tunnel_failed" }); - expect(calls.filter(argv => argv.some(value => value.includes("revoke")))).toHaveLength(1); + expect(revokeCalls(calls)).toHaveLength(1); expect(cleared).toBe(true); }); @@ -207,7 +274,7 @@ describe("client initiated link join", () => { await expect(joinHome(deps, { alias: "home" })).rejects.toMatchObject({ code: readiness === "timeout" ? "join_tunnel_failed" : "admission_failed", }); - expect(calls.filter(argv => argv.some(value => value.includes("revoke")))).toHaveLength(1); + expect(revokeCalls(calls)).toHaveLength(1); expect(stopped).toBe(1); expect(cleared).toBe(1); } @@ -228,7 +295,7 @@ describe("client initiated link join", () => { } finally { logs.mockRestore(); } - expect(calls.filter(argv => argv.some(value => value.includes("revoke")))).toHaveLength(1); + expect(revokeCalls(calls)).toHaveLength(1); expect(logs.mock.calls.flat().join(" ")).not.toContain(KEY); }); @@ -245,11 +312,11 @@ describe("client initiated link join", () => { const order: string[] = []; const runner: SshRunner = { run: async argv => { - if (argv.some(value => value.includes("issue"))) { + if (isWrappedIssue(argv)) { order.push("issue"); return { code: 0, stdout: JSON.stringify({ linkId: LINK_ID, apiKeyId: API_KEY_ID, key: KEY, listenerPort: 45678 }), stderr: "" }; } - if (argv.some(value => value.includes("revoke"))) { + if (argv.at(-1) === REVOKE_COMMAND) { revokeCount += 1; order.push(`revoke-${revokeCount}`); return { code: revokeCount === 1 ? 1 : 0, stdout: "", stderr: "failed" }; diff --git a/tests/server/link-management-routes.test.ts b/tests/server/link-management-routes.test.ts index 2cabc5d852c..f5db5b4c8ee 100644 --- a/tests/server/link-management-routes.test.ts +++ b/tests/server/link-management-routes.test.ts @@ -1,17 +1,37 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; +import { + createManagementSessionControl, + issueGuiSession, + managementPrincipal, + managementSessionIssuance, + type ManagementAuthState, +} from "../../src/server/management-auth"; import type { ManagementApiDeps } from "../../src/server/management/context"; import type { OcxConfig } from "../../src/types"; import type { LinkStore } from "../../src/link/store"; import type { LinkSupervisor } from "../../src/link/supervisor"; import type { SshRunner, SshChild, SshRunResult } from "../../src/link/ssh-runner"; +import { quoteRemote, remoteOcxArgv } from "../../src/link/ssh-argv"; import { trustedLoopbackForIngress, type ServerIngress } from "../../src/server/index/serve-options"; let temp = ""; +/** The remote command a call site must send: ocx wrapped in the PATH prelude, quoted once. */ +function remoteOcx(args: readonly string[]): string { + return quoteRemote(remoteOcxArgv(args)); +} + +/** A bare `ocx` exec (a call site that skipped `remoteOcxArgv`) behaves like a remote without it. */ +const BARE_OCX: SshRunResult = { code: 127, stdout: "", stderr: "bare ocx call: not wrapped by remoteOcxArgv" }; + +function isBareOcx(argv: readonly string[]): boolean { + return /^'ocx'( |$)/.test(argv.at(-1) ?? ""); +} + function config(): OcxConfig { return { port: 0, hostname: "127.0.0.1", runtimeRole: "hub", defaultProvider: "mock", providers: {}, apiKeys: [] } as OcxConfig; } @@ -72,14 +92,16 @@ function applyRunner(h: ReturnType, connectCode = 0): SshRunner if (text.includes("ssh-keygen")) return { code: 0, stdout: "256 SHA256:abcdefghijklmnop host (ED25519)", stderr: "" }; const knownHostOption = argv.find(value => value.startsWith("UserKnownHostsFile=")); if (knownHostOption) writeFileSync(knownHostOption.slice("UserKnownHostsFile=".length), "client ssh-ed25519 AAAA\n"); - if (text.includes("--version")) return { code: 0, stdout: "ocx 2.0.0\n", stderr: "" }; - if (text.includes("link' 'port")) return { code: 0, stdout: JSON.stringify({ port: 2200 }), stderr: "" }; - if (text.includes("connect")) { + if (isBareOcx(argv)) return BARE_OCX; + const remote = argv.at(-1) ?? ""; + if (remote === remoteOcx(["--version"])) return { code: 0, stdout: "opencodex 2.66.0\n", stderr: "" }; + if (remote === remoteOcx(["link", "port"])) return { code: 0, stdout: JSON.stringify({ port: 2200 }), stderr: "" }; + if (remote.startsWith(`${remoteOcx(["connect", "--link", "--key-stdin", "--tunnel-port", "2200", "--link-id"])} `)) { const raw = new TextDecoder().decode(options?.stdin as Uint8Array); const id = JSON.parse(raw).apiKeyId as string; for (const callback of h.callbacks) callback(id); + return { code: connectCode, stdout: "", stderr: "" }; } - if (text.includes("connect")) return { code: connectCode, stdout: "", stderr: "" }; return { code: 0, stdout: "", stderr: "" }; }, spawnTunnel: () => ({ pid: 1, argv: [], exited: Promise.resolve(0), kill() {} }), @@ -93,6 +115,34 @@ async function call(path: string, method: string, body: unknown, deps: Managemen return response; } +/** No isPaired stub: the principal, issuance and session control come from the real auth code. */ +async function sessionCall(url: string, headers: Record, state: ManagementAuthState, cfg: OcxConfig, deps: ManagementApiDeps, trustedLoopback: boolean, method = "GET", body?: unknown) { + const req = new Request(url, { method, headers: { "content-type": "application/json", ...headers }, body: body === undefined ? undefined : JSON.stringify(body) }); + return handleManagementAPI(req, new URL(url), cfg, deps, managementPrincipal(req, state, cfg) ?? undefined, createManagementSessionControl(state), { + trustedLoopback, + guiSessionIssuance: managementSessionIssuance(req, state), + }); +} + +function authState(): ManagementAuthState { + return { available: true, token: `ocx_admin_${"a".repeat(43)}`, source: "environment", sessions: new Map(), pairingGrants: new Map() }; +} + +function versionRunner(remote: { probeCode: number; probeStderr: string; code: number; stdout: string; stderr: string }): SshRunner { + return { + async run(argv) { + const text = argv.join(" "); + if (text.includes("ssh-keygen")) return { code: 0, stdout: "256 SHA256:abcdefghijklmnop host (ED25519)", stderr: "" }; + if (isBareOcx(argv)) return BARE_OCX; + if (argv.at(-1) === remoteOcx(["--version"])) return { code: remote.code, stdout: remote.stdout, stderr: remote.stderr }; + const knownHostOption = argv.find(value => value.startsWith("UserKnownHostsFile=")); + if (knownHostOption) writeFileSync(knownHostOption.slice("UserKnownHostsFile=".length), "client ssh-ed25519 AAAA\n"); + return argv.at(-1) === "true" ? { code: remote.probeCode, stdout: "", stderr: remote.probeStderr } : { code: 0, stdout: "", stderr: "" }; + }, + spawnTunnel: () => ({ pid: 1, argv: [], exited: Promise.resolve(0), kill() {} }), + }; +} + afterEach(() => { if (temp) rmSync(temp, { recursive: true, force: true }); temp = ""; @@ -114,6 +164,204 @@ describe("link management routes", () => { expect(ingresses.map(ingress => trustedLoopbackForIngress(ingress, "0.0.0.0"))).toEqual([false, true, false, false, false]); expect(trustedLoopbackForIngress("public", "127.0.0.1")).toBe(true); expect(trustedLoopbackForIngress("public", "::1")).toBe(true); + // A loopback hostname never makes the hub-link, hub-management or intercept ingress trusted. + for (const ingress of ["hub-link", "hub-management", "claude-intercept"] as const) { + expect(trustedLoopbackForIngress(ingress, "127.0.0.1")).toBe(false); + } + }); + + test("admits the current loopback dashboard session of a standalone runtime on trusted loopback ingress", async () => { + temp = mkdtempSync(join(tmpdir(), "ocx-link-loopback-session-")); + const h = harness(); + const state = authState(); + const cfg = { ...h.config, runtimeRole: "standalone" } as OcxConfig; + const session = issueGuiSession(new Request("http://127.0.0.1:10100/", { headers: { host: "127.0.0.1:10100" } }), cfg, state); + expect(session?.issuance).toBe("loopback"); + const headers = { host: "127.0.0.1:10100", authorization: `Bearer ${session!.token}`, "x-opencodex-gui-origin": session!.browserOrigin }; + const deps: ManagementApiDeps = { ...h.deps, sshRunner: applyRunner(h), loadLinkCandidates: () => [{ alias: "home", source: "ssh_config" }] } as ManagementApiDeps; + const base = "http://127.0.0.1:10100"; + + const status = await sessionCall(`${base}/api/link/status`, headers, state, cfg, deps, true); + expect(status?.status).toBe(200); + expect(await status!.json()).toMatchObject({ role: "standalone", joinAvailable: false }); + const listed = await sessionCall(`${base}/api/link/candidates`, headers, state, cfg, deps, true); + expect(listed?.status).toBe(200); + expect(await listed!.json()).toEqual({ candidates: [{ alias: "home", source: "ssh_config" }] }); + const mutation = { ...headers, origin: session!.browserOrigin, "x-opencodex-csrf-token": session!.csrfToken }; + expect((await sessionCall(`${base}/api/link/probe`, mutation, state, cfg, deps, true, "POST", { alias: "home" }))?.status).toBe(200); + expect((await sessionCall(`${base}/api/link/probe`, headers, state, cfg, deps, true, "POST", { alias: "home" }))?.status).toBe(403); + expect((await sessionCall(`${base}/api/link/confirm-host`, mutation, state, cfg, deps, true, "POST", { alias: "home", fingerprint: "SHA256:abcdefghijklmnop" }))?.status).toBe(200); + + // Joining restarts this runtime and moves Codex routing to the Home, so it stays paired-only. + const joined = await sessionCall(`${base}/api/link/join`, mutation, state, cfg, deps, true, "POST", { alias: "home" }); + expect(joined?.status).toBe(403); + expect(await joined!.json()).toMatchObject({ error: { code: "forbidden" } }); + + // The Home side runs end to end for this session: apply issues and connects, removal disconnects. + const applied = await sessionCall(`${base}/api/link/apply`, mutation, state, cfg, deps, true, "POST", { alias: "home" }); + expect(applied?.status).toBe(202); + const { linkId } = await applied!.json() as { linkId: string }; + expect(h.store.links.map(link => link.id)).toEqual([linkId]); + for (const runtimeRole of ["hub", "client"] as const) { + const refusedApply = await sessionCall(`${base}/api/link/apply`, mutation, state, { ...cfg, runtimeRole } as OcxConfig, deps, true, "POST", { alias: "home" }); + expect(refusedApply?.status).toBe(403); + expect(await refusedApply!.json()).toMatchObject({ error: { code: "forbidden" } }); + } + const removed = await sessionCall(`${base}/api/link/${linkId}`, mutation, state, cfg, deps, true, "DELETE", {}); + expect(removed?.status).toBe(200); + expect(await removed!.json()).toEqual({ linkId }); + expect(h.store.links).toEqual([]); + expect(cfg.apiKeys).toEqual([]); + + const untrusted = await sessionCall(`${base}/api/link/candidates`, headers, state, cfg, deps, false); + expect(untrusted?.status).toBe(403); + expect(await untrusted!.json()).toMatchObject({ error: { code: "forbidden" } }); + for (const runtimeRole of ["hub", "client"] as const) { + const other = await sessionCall(`${base}/api/link/candidates`, headers, state, { ...cfg, runtimeRole } as OcxConfig, deps, true); + expect(other?.status).toBe(403); + expect(await other!.json()).toMatchObject({ error: { code: "forbidden" } }); + } + + const tailscaleConfig = { + ...cfg, hostname: "0.0.0.0", runtimeRole: "hub", hub: { managementPublicOrigin: "https://hub.example.test" }, + remoteGui: { allowedTailscaleUsers: ["alice@example.test"] }, corsAllowOrigins: ["https://dashboard.example.test"], + } as OcxConfig; + const tailscale = issueGuiSession(new Request("https://hub.example.test/", { + headers: { host: "hub.example.test", origin: "https://dashboard.example.test", "Tailscale-User-Login": "alice@example.test" }, + }), tailscaleConfig, state, { trustedTailscaleIngress: true }); + expect(tailscale?.issuance).toBe("tailscale-identity"); + const refused = await sessionCall("https://hub.example.test/api/link/candidates", { + host: "hub.example.test", authorization: `Bearer ${tailscale!.token}`, "x-opencodex-gui-origin": tailscale!.browserOrigin, + }, state, tailscaleConfig, deps, true); + expect(refused?.status).toBe(403); + expect(await refused!.json()).toMatchObject({ error: { code: "tailscale_session_refused" } }); + }); + + test("status tells a dashboard session whether it may join and keeps the admin-token DTO exact", async () => { + temp = mkdtempSync(join(tmpdir(), "ocx-link-join-available-")); + const h = harness(); + const standalone = { ...h.config, runtimeRole: "standalone" } as OcxConfig; + const paired = await call("/api/link/status", "GET", undefined, h.deps, "gui-session", true, "pairing", true, standalone); + expect(paired?.status).toBe(200); + expect(await paired!.json()).toMatchObject({ role: "standalone", joinAvailable: true }); + const hub = await call("/api/link/status", "GET", undefined, h.deps, "gui-session", true, "pairing", true, { ...standalone, runtimeRole: "hub" } as OcxConfig); + expect(await hub!.json()).toMatchObject({ joinAvailable: false }); + // `ocx link status` validates the admin-token answer key by key, so it never gains the field. + const admin = await call("/api/link/status", "GET", undefined, h.deps, "admin-token", true, null, true, standalone); + expect(Object.keys(await admin!.json()).sort()).toEqual(["child", "links", "listener", "role"]); + }); + + test("confirm-host keeps the parsed remote version to a bounded semver shape", async () => { + temp = mkdtempSync(join(tmpdir(), "ocx-link-version-shape-")); + const h = harness(); + const remote = { probeCode: 0, probeStderr: "", code: 0, stdout: "", stderr: "" }; + const deps = { ...h.deps, sshRunner: versionRunner(remote) }; + const post = (path: string, body: unknown) => call(path, "POST", body, deps, "gui-session", true, "pairing", true, h.config); + expect((await post("/api/link/probe", { alias: "home" }))?.status).toBe(200); + const confirm = () => post("/api/link/confirm-host", { alias: "home", fingerprint: "SHA256:abcdefghijklmnop" }); + const unrecognized = { error: { code: "remote_ocx_unrecognized", message: "The remote ocx did not report an OpenCodex version." } }; + + for (const stdout of [ + `opencodex 2.66.0-${"a".repeat(65)}\n`, + `opencodex 2.66.0+${"b".repeat(200)}\n`, + `opencodex 2.66.0${String.fromCharCode(0x202e)}evil\n`, + `opencodex 2.66.0-rc.1${String.fromCharCode(0x7)}\n`, + `opencodex 2.66.0/../../x\n`, + `opencodex 1${"0".repeat(12)}.0.0\n`, + ]) { + Object.assign(remote, { stdout }); + const refused = await confirm(); + expect(refused?.status).toBe(502); + expect(await refused!.json()).toEqual(unrecognized); + } + + // The outdated hint goes through the stderr hint bounding, so even the longest accepted + // version cannot grow it past 160 characters. + Object.assign(remote, { stdout: `opencodex 000000001.000000002.000000003-${"a".repeat(64)}+${"b".repeat(64)}\n` }); + const outdated = await confirm(); + expect(outdated?.status).toBe(409); + const hint = ((await outdated!.json()) as { error: { hint: string } }).error.hint; + expect(hint.startsWith("opencodex 000000001.000000002.000000003-aaaa")).toBe(true); + expect(hint).toHaveLength(160); + expect(hint.endsWith("…")).toBe(true); + + Object.assign(remote, { stdout: "opencodex 2.70.0-rc.1+build.7 (darwin arm64)\n" }); + const confirmed = await confirm(); + expect(confirmed?.status).toBe(200); + expect(await confirmed!.json()).toEqual({ alias: "home", fingerprint: "SHA256:abcdefghijklmnop", ocxVersion: "2.70.0-rc.1+build.7" }); + }); + + test("confirm-host enforces the remote ocx floor, maps failures, and keeps the probe for a retry", async () => { + temp = mkdtempSync(join(tmpdir(), "ocx-link-version-floor-")); + const h = harness(); + const remote = { probeCode: 255, probeStderr: "\u001b[1mhome: Permission denied (publickey).\u001b[0m\n", code: 0, stdout: "opencodex 2.32.1\n", stderr: "" }; + const deps = { ...h.deps, sshRunner: versionRunner(remote) }; + const knownHosts = join(temp, "known_hosts"); + const post = (path: string, body: unknown) => call(path, "POST", body, deps, "gui-session", true, "pairing", true, h.config); + const confirm = () => post("/api/link/confirm-host", { alias: "home", fingerprint: "SHA256:abcdefghijklmnop" }); + + const probeFailed = await post("/api/link/probe", { alias: "home" }); + expect(probeFailed?.status).toBe(502); + expect(await probeFailed!.json()).toEqual({ error: { code: "probe_failed", message: "SSH host probing failed.", hint: "home: Permission denied (publickey)." } }); + remote.probeCode = 0; + expect((await post("/api/link/probe", { alias: "home" }))?.status).toBe(200); + + const outdated = await confirm(); + expect(outdated?.status).toBe(409); + expect(await outdated!.json()).toEqual({ error: { code: "remote_ocx_outdated", message: "The remote OpenCodex is older than 2.66.0.", hint: "opencodex 2.32.1" } }); + expect(existsSync(knownHosts)).toBe(false); + + for (const [code, stdout, stderr, status, body] of [ + [0, "opencodex (ocx) — Universal provider proxy for Codex\nUsage: ocx \n", "", 502, { code: "remote_ocx_unrecognized", message: "The remote ocx did not report an OpenCodex version." }], + [127, "", "sh: 1: exec: ocx: not found\n", 502, { code: "remote_ocx_missing", message: "ocx was not found on the remote host.", hint: "sh: 1: exec: ocx: not found" }], + [255, "", "ssh: connect to host home port 22: Connection refused\r\n", 502, { code: "version_probe_failed", message: "The remote ocx version could not be confirmed.", hint: "ssh: connect to host home port 22: Connection refused" }], + ] as const) { + Object.assign(remote, { code, stdout, stderr }); + const refused = await confirm(); + expect(refused?.status).toBe(status); + expect(await refused!.json()).toEqual({ error: body }); + expect(existsSync(knownHosts)).toBe(false); + } + + Object.assign(remote, { code: 0, stdout: "opencodex 2.66.0-preview.20260925\n", stderr: "" }); + const confirmed = await confirm(); + expect(confirmed?.status).toBe(200); + expect(await confirmed!.json()).toEqual({ alias: "home", fingerprint: "SHA256:abcdefghijklmnop", ocxVersion: "2.66.0-preview.20260925" }); + expect(existsSync(knownHosts)).toBe(true); + }); + + test("a probe hint of astral stderr is cut by code point, never before a lone surrogate", async () => { + temp = mkdtempSync(join(tmpdir(), "ocx-link-astral-hint-")); + const h = harness(); + const astral = String.fromCodePoint(0x1f511); + const deps = { ...h.deps, sshRunner: versionRunner({ probeCode: 255, probeStderr: `${astral.repeat(200)}\n`, code: 0, stdout: "", stderr: "" }) }; + const probed = await call("/api/link/probe", "POST", { alias: "home" }, deps, "gui-session", true, "pairing", true, h.config); + expect(probed?.status).toBe(502); + const points = Array.from(((await probed!.json()) as { error: { hint: string } }).error.hint); + expect(points).toHaveLength(160); + expect(points.slice(0, -1).every(point => point === astral)).toBe(true); + expect(points.at(-1)).toBe(String.fromCodePoint(0x2026)); + }); + + test("apply maps a missing remote ocx to remote_ocx_missing before issuing a key", async () => { + temp = mkdtempSync(join(tmpdir(), "ocx-link-apply-ocx-missing-")); + const h = harness(); + const base = applyRunner(h); + const runner: SshRunner = { + ...base, + async run(argv, options) { + if (argv.at(-1) === remoteOcx(["link", "port"])) return { code: 127, stdout: "", stderr: "zsh:1: command not found: ocx\n" }; + return base.run(argv, options); + }, + }; + const deps = { ...h.deps, sshRunner: runner }; + expect((await call("/api/link/probe", "POST", { alias: "no-ocx" }, deps, "gui-session", true, "pairing", true, h.config))?.status).toBe(200); + expect((await call("/api/link/confirm-host", "POST", { alias: "no-ocx", fingerprint: "SHA256:abcdefghijklmnop" }, deps, "gui-session", true, "pairing", true, h.config))?.status).toBe(200); + const response = await call("/api/link/apply", "POST", { alias: "no-ocx" }, deps, "gui-session", true, "pairing", true, h.config); + expect(response?.status).toBe(502); + expect(await response!.json()).toEqual({ error: { code: "remote_ocx_missing", message: "ocx was not found on the remote host.", hint: "zsh:1: command not found: ocx" } }); + expect(h.events).not.toContain("issue"); + expect(h.config.apiKeys).toEqual([]); }); test("issues and force-revokes a client-initiated link with the K2/K16 DTOs", async () => { @@ -182,7 +430,8 @@ describe("link management routes", () => { const other = { id: "lnk_fedcba9876543210", alias: "other", direction: "client-initiated" as const, hostKeyFingerprint: null, tunnelPort: 2201, apiKeyId: "other-key", createdAt: "2026-09-25T00:00:00.000Z" }; h.deps.writeLinkStore!({ ...h.store, links: [existing] }); const runner: SshRunner = { - async run() { + async run(argv) { + if (argv.at(-1) !== remoteOcx(["disconnect"])) return BARE_OCX; h.deps.writeLinkStore!({ ...h.store, links: [existing, other] }); return { code: 0, stdout: "", stderr: "" }; }, @@ -308,8 +557,9 @@ describe("link management routes", () => { if (text.includes("ssh-keygen")) return { code: 0, stdout: "256 SHA256:abcdefghijklmnop host (ED25519)", stderr: "" }; const knownHostOption = argv.find(value => value.startsWith("UserKnownHostsFile=")); if (knownHostOption) writeFileSync(knownHostOption.slice("UserKnownHostsFile=".length), "client ssh-ed25519 AAAA\n"); - if (text.includes("--version")) return { code: 0, stdout: "ocx 2.0.0\n", stderr: "" }; - if (text.includes("link' 'port")) return { code: 0, stdout: JSON.stringify({ port: 2200 }), stderr: "" }; + if (isBareOcx(argv)) return BARE_OCX; + if (argv.at(-1) === remoteOcx(["--version"])) return { code: 0, stdout: "opencodex 2.66.0\n", stderr: "" }; + if (argv.at(-1) === remoteOcx(["link", "port"])) return { code: 0, stdout: JSON.stringify({ port: 2200 }), stderr: "" }; return { code: 0, stdout: "", stderr: "" }; }, spawnTunnel: () => ({ pid: 1, argv: [], exited: Promise.resolve(0), kill() {} }), @@ -334,14 +584,16 @@ describe("link management routes", () => { if (text.includes("ssh-keygen")) return { code: 0, stdout: "256 SHA256:abcdefghijklmnop host (ED25519)", stderr: "" }; const knownHostOption = argv.find(value => value.startsWith("UserKnownHostsFile=")); if (knownHostOption) writeFileSync(knownHostOption.slice("UserKnownHostsFile=".length), "client ssh-ed25519 AAAA\n"); - if (text.includes("--version")) return { code: 0, stdout: "ocx 2.0.0\n", stderr: "" }; - if (text.includes("connect")) { + if (isBareOcx(argv)) return BARE_OCX; + const remote = argv.at(-1) ?? ""; + if (remote === remoteOcx(["--version"])) return { code: 0, stdout: "opencodex 2.66.0\n", stderr: "" }; + if (remote.startsWith(`${remoteOcx(["connect", "--link", "--key-stdin", "--tunnel-port", "2200", "--link-id"])} `)) { h.events.push("connect"); const raw = typeof options?.stdin === "string" ? options.stdin : new TextDecoder().decode(options?.stdin); const id = JSON.parse(raw ?? "{}").apiKeyId as string; for (const callback of h.callbacks) callback(id); } - if (text.includes("link' 'port")) return { code: 0, stdout: JSON.stringify({ port: 2200 }), stderr: "" }; + if (remote === remoteOcx(["link", "port"])) return { code: 0, stdout: JSON.stringify({ port: 2200 }), stderr: "" }; return { code: 0, stdout: "", stderr: "" }; }, spawnTunnel: (_argv: readonly string[]): SshChild => ({ pid: 1, argv: [], exited: Promise.resolve(0), kill() {} }), From e0ff14de240e2da6a51ce05bf294fe9ca57efb1e Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Sun, 27 Sep 2026 02:02:27 +0900 Subject: [PATCH 04/13] fix(oauth): keep Meta Muse login continuations current (#5911) Carried from #5911 as one squashed commit. Co-authored-by: Ingwannu Co-authored-by: codingbo --- .../src/content/docs/guides/providers.md | 11 ++- gui/src/components/login-url-block.tsx | 5 +- gui/src/components/use-add-provider-oauth.ts | 15 +++- gui/src/pages/providers-shared.ts | 1 + gui/src/pages/use-providers-oauth.ts | 3 + .../add-codex-account-device-code.test.tsx | 1 + .../add-provider-oauth-url-leak.test.tsx | 41 +++++++++- .../provider-auth-device-code-copy.test.tsx | 11 +++ src/oauth/index.ts | 16 +++- src/oauth/login-flow-state.ts | 8 +- src/server/management/oauth-account-routes.ts | 10 ++- structure/dashboard-and-usage.md | 3 + .../ADR-5877-oauth-login-continuations.md | 24 ++++++ structure/gui-and-management-api.md | 23 ++++++ structure/providers-and-adapters.md | 3 + tests/oauth/oauth-public-surface.test.ts | 79 +++++++++++++++++++ 16 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 structure/decisions/ADR-5877-oauth-login-continuations.md diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 54372cce2f9..7e0d6ef075d 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -284,8 +284,10 @@ desktop and the wrong one in two common cases: you need a different browser prof identity, a second account), or the dashboard is open against a proxy running somewhere else. Every login surface shows the authorization URL with a copy button, the device code when the -provider issues one, and a field to paste the redirect URL or authorization code back. So you can -always finish a login by hand. +provider issues one, and the current instructions. Browser callback flows also show a field to +paste the redirect URL or authorization code back. During device approval that field is hidden: +enter the displayed code on the provider's verification page instead. If the provider switches +to manual input, the dashboard replaces the old code and instructions on its next status poll. To stop the proxy from opening a browser at all, tick **Don't open a browser on the proxy machine** beside the login button, or set it permanently: @@ -302,7 +304,7 @@ Two cases behave differently, and it is worth knowing which you are in: - **A different browser profile on the same machine** works with the copied link alone. The loopback callback on `127.0.0.1` still completes the flow. -- **A browser on a different machine** also needs the paste fallback, because the redirect URI is +- **A browser callback flow on a different machine** also needs the paste fallback, because the redirect URI is still `http://127.0.0.1:/callback` on the proxy's host. Finish the login there, then paste the redirect URL (or just the code) back into the dashboard or `ocx account code`. @@ -766,6 +768,9 @@ including add-account and reauthentication. A raw admin token or forged GUI head `403 oauth_consent_required` before a credential is read or a grant starts. This gate uses the server-resolved session principal, not a separately recorded warning-checkbox receipt. Direct `ocx login meta-muse` and other OAuth providers keep their existing login policies. +The management OAuth provider list therefore omits Meta Muse for raw-admin-token dashboards; +open a session-authenticated dashboard to use that login flow. This changes discovery only, +not the admission checks on login start or manual continuation. Both seeded `meta-muse` models expose `minimal`/`low`/`medium`/`high`/`xhigh`/`max` to routed clients, including Grok's effort picker. Requests use diff --git a/gui/src/components/login-url-block.tsx b/gui/src/components/login-url-block.tsx index 6f9d30f85cb..a56227954d0 100644 --- a/gui/src/components/login-url-block.tsx +++ b/gui/src/components/login-url-block.tsx @@ -86,7 +86,8 @@ export type LoginHintPaste = { * * Order is deliberate: the device code first because it is the short thing a * human has to type, then the URL, then any provider prose, then the paste - * fallback for when the browser cannot reach the loopback callback. + * fallback for when the browser cannot reach the loopback callback. Device + * grants poll for approval instead: their human code is not a callback code. */ export function LoginHint({ hint, paste }: { hint: LoginHintData; paste?: LoginHintPaste }) { const t = useT(); @@ -119,7 +120,7 @@ export function LoginHint({ hint, paste }: { hint: LoginHintData; paste?: LoginH )} {hint.instructions &&
{hint.instructions}
} - {paste && ( + {paste && !deviceCode && (
{t("prov.pasteRedirectHint")}
diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts index b28fa4473c4..185e7f8ea55 100644 --- a/gui/src/components/use-add-provider-oauth.ts +++ b/gui/src/components/use-add-provider-oauth.ts @@ -3,6 +3,7 @@ import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; import { openBrowserRequestField } from "../oauth-open-browser-pref"; import { afterOAuthCancellation, cancelOAuthLogin } from "../oauth-cancellation-barrier"; +import type { LoginHintData } from "./login-url-block"; export const OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; @@ -123,7 +124,7 @@ export function useAddProviderOAuth({ await new Promise(r => setTimeout(r, OAUTH_LOGIN_POLL_INTERVAL_MS)); if (!aliveRef.current || !isCurrent()) return; const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${providerId}`).catch(() => null); - const s = sRes ? await readJsonIfOk<{ loggedIn?: boolean; error?: string }>(sRes) : null; + const s = sRes ? await readJsonIfOk<{ loggedIn?: boolean; error?: string; hint?: LoginHintData }>(sRes) : null; if (!aliveRef.current || !isCurrent()) return; if (s?.error) { activeProvidersRef.current.delete(providerId); @@ -133,9 +134,16 @@ export function useAddProviderOAuth({ } if (s?.loggedIn) { activeProvidersRef.current.delete(providerId); + setOauthMsg(""); onAdded(providerId); return; } + if (s?.hint) { + setOauthUrl(s.hint.url ?? "", providerId, s.hint.deviceCode, s.hint.instructions); + setOauthMsg(s.hint.url || s.hint.deviceCode + ? t("modal.waitingLogin") + : (s.hint.instructions || t("modal.loggingIn"))); + } } await cancelServerLogin(providerId); if (!aliveRef.current || !isCurrent()) return; @@ -150,7 +158,10 @@ export function useAddProviderOAuth({ setOauthMsg(t("modal.networkError")); } } finally { - if (aliveRef.current && isCurrent()) setOauthBusy(false); + if (aliveRef.current && isCurrent()) { + setOauthBusy(false); + setOauthUrl("", providerId); + } } }, [aliveRef, apiBase, bumpLoginGeneration, cancelServerLogin, onAdded, t]); diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index 99c9418a355..3408690f335 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -26,6 +26,7 @@ export interface OAuthStatus { email?: string; error?: string; done?: boolean; + hint?: import("../components/login-url-block").LoginHintData; needsReauth?: boolean; activeAccountId?: string | null; } diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index d97d28dfc0c..4a68c8b9c1a 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -199,6 +199,9 @@ export function useProvidersOAuth({ finished = true; break; } + // A later provider step replaces the initial POST hint (including an + // absent device code); generation checks above keep old polls out. + if (s.hint) setLoginInfo({ provider, url: s.hint.url, instructions: s.hint.instructions, deviceCode: s.hint.deviceCode }); } if (!finished && oauthLoginGenerationRef.current!.get(provider) === generation && aliveRef.current) { await cancelServerLogin(provider); diff --git a/gui/tests/add-codex-account-device-code.test.tsx b/gui/tests/add-codex-account-device-code.test.tsx index 64f139d7e9f..da8cde355ec 100644 --- a/gui/tests/add-codex-account-device-code.test.tsx +++ b/gui/tests/add-codex-account-device-code.test.tsx @@ -141,6 +141,7 @@ test("a device login renders the short code, not just the verification URL", asy expect(code).toBeTruthy(); expect(code?.textContent).toBe(DEVICE_CODE); expect(host.textContent).toContain(DEVICE_URL); + expect(host.querySelector(".login-hint-paste")).toBeNull(); }); test("the default browser flow does not ask for a device login", async () => { diff --git a/gui/tests/add-provider-oauth-url-leak.test.tsx b/gui/tests/add-provider-oauth-url-leak.test.tsx index 347e314b2de..8b558fe5f33 100644 --- a/gui/tests/add-provider-oauth-url-leak.test.tsx +++ b/gui/tests/add-provider-oauth-url-leak.test.tsx @@ -149,6 +149,8 @@ function ProvidersOAuthHarness({ provider = "orcarouter-oauth", apiBase = "", on {busy ?? "idle"} {loginInfo?.url ?? "no-login-info"} + {loginInfo?.deviceCode ?? ""} + {loginInfo?.instructions ?? ""}