From 12fc878ca0acf04b48f97b7fd998b749d95f3b8f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 26 Sep 2026 23:36:46 +0900 Subject: [PATCH] feat(link): turn a Child on from its own dashboard and keep Codex on 127.0.0.1 Root cause: POST /api/link/join admitted only a paired session, which a standalone never issues, so no computer could become a Child from its own dashboard. A link Child also routed Codex through an env_key provider table (http://localhost:/v1 + OPENCODEX_API_AUTH_TOKEN) that a GUI-launched Codex cannot satisfy, and its relay kept the 4 MiB / 15 s management-relay bounds, forwarded caller credentials, relayed /readyz without a key, 404ed the Responses WebSocket probe, refused chunked uploads, and ran on Bun's 10 s idle default with the per-request idle timer still armed. Fix: - Join admits the same dashboard session as the Home-side routes (paired, or the current standalone loopback session on trusted loopback ingress) and answers 409 join_port_mismatch before any SSH when the live port is not the configured port. joinAvailable follows the same gates. - A link Child keeps the standalone root form openai_base_url = http://127.0.0.1:/v1 (no provider table, no env_key); for the same port the join writes the bytes the standalone wrote. - The link data plane (src/client/link-ingress.ts): standalone Host/Origin gate before any upstream fetch, 426 for a /v1/responses upgrade, local /readyz, 503 link_credential_unavailable without the committed key. A relayed request lifts its own idle timer (server.timeout(req, 0)), as a standalone data route does, so a quiet stretch longer than 255 s inside a long generation is not cut. - The relay drops Authorization, x-api-key, x-opencodex-api-key, chatgpt-account-id and cookie and sends the link key as a Bearer (x-opencodex-api-key for GET /v1/usage), so the Home serves with its own accounts. A lone Transfer-Encoding: chunked without Content-Length is admitted (the listener already de-chunked it); any other framing ambiguity still answers 400. - The Child listener serves its own read-only GET/HEAD /api/link/status (src/client/link-status.ts) and advertises its origin as the shared plane. - Dashboard: pre-join notice, /healthz pid read, 1 s /healthz poll after the 202, reload only onto role client with a new pid. The wait never gives up: past the server's handoff budget (60 s drain + 70 s replacement + 15 s) it shows a slow notice and keeps polling every 5 s, so a late Child still reloads the page. childJoinUnavailable removed in all 10 locales, join_port_mismatch and restart.slow added. Structure docs, route registry and docs-site guides (8 locales) updated. Performance: standalone and hub transport paths are unchanged (hub keeps the 4 MiB bound and default idle limit). In link mode the request body streams chunk by chunk with the caller's Content-Length and a byte-counting cap at the inbound limit (256 MiB default) instead of buffering up to 4 MiB; the header deadline is 300 s with caller abort; the link key is read once and cached, so no relayed request touches the disk; both the Child machine listener and the Home hub-link listener bind with idleTimeout 255, and each relayed request lifts its idle timer with one O(1) call (a probe cut a 5 s SSE gap at idleTimeout 1 without it). The idle-timer helper is local, so the Child does not load the Responses WebSocket upstream modules. No new server timers; the only new poll is the browser's /healthz read while a join restart is pending. Security: auth-boundary change. The Child's 127.0.0.1: becomes a keyless local path to the Home's providers, the same trust a standalone loopback bind gives, with browsers held off by the Host/Origin gate; the Child's own credentials no longer cross the tunnel. The loopback dashboard session can now join, the same casual-path trade apply already makes (key-only SSH, confirmed host key, 5-minute TTL, CSRF). The key is never logged or returned. Chunked admission is limited to the link data plane; the hub management relay stays strict. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/content/docs/fr/guides/remote-link.md | 19 +- .../src/content/docs/guides/remote-link.md | 19 +- .../src/content/docs/ja/guides/remote-link.md | 19 +- .../src/content/docs/ko/guides/remote-link.md | 19 +- .../src/content/docs/ru/guides/remote-link.md | 19 +- .../src/content/docs/tr/guides/remote-link.md | 19 +- .../content/docs/zh-cn/guides/remote-link.md | 19 +- .../content/docs/zh-tw/guides/remote-link.md | 19 +- gui/src/i18n/de.ts | 6 +- gui/src/i18n/en.ts | 6 +- gui/src/i18n/fr.ts | 6 +- gui/src/i18n/ja.ts | 6 +- gui/src/i18n/ko.ts | 6 +- gui/src/i18n/ru.ts | 6 +- gui/src/i18n/tr.ts | 6 +- gui/src/i18n/vi.ts | 6 +- gui/src/i18n/zh-TW.ts | 6 +- gui/src/i18n/zh.ts | 6 +- gui/src/pages/RemoteLink.tsx | 55 +++- gui/src/remote-link-api.ts | 81 +++++- gui/tests/remote-link.test.tsx | 184 +++++++++++- scripts/test-layout/layout.json | 1 + src/client/connect.ts | 43 ++- src/client/link-ingress.ts | 102 +++++++ src/client/link-relay.ts | 169 ++++++++--- src/client/link-status.ts | 36 +++ src/client/machine-api.ts | 14 +- src/client/machine-listener.ts | 62 +++- src/client/runtime.ts | 13 +- src/server/index/link-listener.ts | 3 + src/server/management/context.ts | 2 + src/server/management/link-routes.ts | 38 ++- src/server/management/route-registry.ts | 2 +- src/server/management/system-restart.ts | 3 +- structure/gui-and-management-api.md | 12 +- structure/remote-link.md | 18 +- tests/clients/client-link-connect.test.ts | 16 +- tests/clients/client-link-relay.test.ts | 267 +++++++++++++++--- tests/clients/client-link-status.test.ts | 58 ++++ tests/clients/client-machine-listener.test.ts | 167 +++++++++++ .../injection-link-websocket.test.ts | 59 ++-- tests/fixtures/test-layout-expected.json | 1 + tests/server/link-join-route.test.ts | 52 +++- tests/server/link-listener-lifecycle.test.ts | 19 ++ tests/server/link-management-routes.test.ts | 25 +- 45 files changed, 1478 insertions(+), 236 deletions(-) create mode 100644 src/client/link-ingress.ts create mode 100644 src/client/link-status.ts create mode 100644 tests/clients/client-link-status.test.ts 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 4fe3cc878bc..c760fe5105b 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -11,9 +11,9 @@ Une liaison entre machines connecte un ordinateur OpenCodex **Home** à un ordin - 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 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. -- 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. +- Le tableau de bord qui lance la liaison est ouvert sur cet ordinateur 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. 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. +SSH par mot de passe et Windows restent hors du flux actuel. Une liaison peut être lancée des deux côtés : depuis Home, comme décrit ci-dessous, ou depuis Child, comme décrit dans la section « Connecter cet ordinateur comme Child ». ## Ajouter un Child depuis `#remote` @@ -25,6 +25,19 @@ SSH par mot de passe et Windows restent hors du flux actuel. Connecter un ordina 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. +## Connecter cet ordinateur comme Child + +Sur l’ordinateur qui doit utiliser les fournisseurs de Home : + +1. Ouvrez le tableau de bord sur `#remote` et activez Remote Link. +2. Choisissez **Child**. La liste des hôtes SSH s’ouvre. +3. Choisissez l’hôte SSH de Home, lancez le test de connexion, puis comparez et confirmez son empreinte d’hôte. +4. Lisez l’avertissement et choisissez **Connect as Child**. + +La connexion redémarre OpenCodex sur cet ordinateur. Les tours Codex déjà en cours se terminent d’abord, et les nouvelles requêtes peuvent échouer pendant une minute au plus pendant le redémarrage. Le tableau de bord se recharge ensuite de lui-même et affiche la liaison Child. Codex continue d’utiliser `http://127.0.0.1:/v1` sur cet ordinateur, sans jeton ni variable d’environnement à définir : l’OpenCodex local relaie chaque requête vers Home, qui y répond avec ses propres fournisseurs et comptes. + +Le rôle **Child** n’est disponible que lorsque OpenCodex tourne sur son port configuré, car Child redémarre exactement sur ce port. Si le tableau de bord indique qu’OpenCodex ne tourne pas sur son port configuré, redémarrez-le d’abord sur ce port. + ## État de la liaison - **Connected** signifie que le tunnel SSH est prêt et que Child peut utiliser la liaison Home. @@ -47,7 +60,7 @@ Pour déconnecter une liaison initiée par Child, exécutez `ocx disconnect` sur ## Sécurité -Child utilise les fournisseurs et les identifiants de fournisseur de l’ordinateur Home via la liaison. Home crée une clé distincte pour chaque Child ; la suppression de la liaison révoque cette clé. Comparez l’empreinte de l’hôte avant de confirmer afin de ne pas accepter par erreur une mauvaise machine ou une clé modifiée. Les sessions du tableau de bord émises depuis une identité Tailscale ne peuvent pas gérer les liaisons. +Child utilise les fournisseurs et les identifiants de fournisseur de l’ordinateur Home via la liaison. Home crée une clé distincte pour chaque Child ; la suppression de la liaison révoque cette clé. Comparez l’empreinte de l’hôte avant de confirmer afin de ne pas accepter par erreur une mauvaise machine ou une clé modifiée. Les sessions du tableau de bord émises depuis une identité Tailscale ne peuvent pas gérer les liaisons. Sur Child, la clé reste dans OpenCodex : les identifiants que Codex ou Claude Code envoient sur Child ne sont pas transmis à Home, et tout programme de Child qui atteint `127.0.0.1:` utilise Home sans clé, avec la même confiance locale qu’une installation autonome. Les pages web d’autres sites sont refusées. ## Référence CLI diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 6481d520dc0..7031673bf32 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -11,9 +11,9 @@ A machine link connects an OpenCodex **Home** computer to a **Child** computer o - For a Child-initiated link, the Child can log in to Home with an OpenSSH key (password login is not supported). - 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. -- 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. +- The dashboard that starts the link is opened on that computer itself (browser or desktop app, standalone install) or through a paired Hub session. -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. +Password SSH and Windows are outside the current flow. A link can be started from either side: from the Home, as described next, or from the Child, as described in [Connect this computer as a Child](#connect-this-computer-as-a-child). ## Add a Child from `#remote` @@ -25,6 +25,19 @@ Password SSH and Windows are outside the current flow. Connecting a computer as 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. +## Connect this computer as a Child + +On the computer that should use the Home's providers: + +1. Open the dashboard at `#remote` and switch Remote Link on. +2. Choose **Child**. The SSH host list opens. +3. Choose the Home's SSH host, run the connection test, then compare and confirm its host fingerprint. +4. Read the notice and choose **Connect as Child**. + +Connecting restarts OpenCodex on this computer. Codex turns that are already running finish first, and new requests can fail for up to a minute while it restarts. The dashboard then reloads by itself and shows the Child link. Codex keeps using `http://127.0.0.1:/v1` on this computer, with no token and no environment variable to set: the local OpenCodex relays each request to the Home, which serves it with its own providers and accounts. + +The **Child** role is available only while OpenCodex runs on its configured port, because the Child restarts on exactly that port. If the dashboard says OpenCodex is not running on its configured port, restart it there first. + ## Link status - **Connected** means the SSH tunnel is ready and the Child can use the Home link. @@ -56,7 +69,7 @@ When a step fails, the dashboard shows the reason and, when SSH reported one, th ## 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. +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. On the Child, the key stays inside OpenCodex: credentials that Codex or Claude Code send there are not forwarded to the Home, and any program on the Child that reaches `127.0.0.1:` uses the Home without a key, the same local trust a standalone install gives. Web pages from other sites are refused. 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. ## CLI reference 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 da3c499e3e0..84f36392202 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -11,9 +11,9 @@ description: SSH で OpenCodex の Home コンピューターと Child コンピ - Child から開始するリンクでは、Child から Home に OpenSSH キーでログインできる必要があります(パスワードログインには対応していません)。 - Child に OpenCodex 2.66.0 以降がインストールされていること(Child から開始するリンクでは Home にも)。 - 両方のコンピューターが macOS または Linux であること。 -- リンクは Home 側から開始すること。使うダッシュボードは、Home のコンピューター上で直接開いたもの(スタンドアロン環境のブラウザーまたはデスクトップアプリ)か、ペアリング済みの Hub セッションです。 +- リンクを開始するダッシュボードは、そのコンピューター上で直接開いたもの(スタンドアロン環境のブラウザーまたはデスクトップアプリ)か、ペアリング済みの Hub セッションであること。 -パスワード SSH と Windows は現在のフローに含まれません。このリリースでは、ダッシュボードからコンピューターを Child として接続すること(Child から開始するリンク)はできません。参加するとそのコンピューターの OpenCodex が再起動し、すでに動いている Codex 接続が切断されるため、ダッシュボードでは **子** の役割を選択できません。サポートされているのは Home から開始するリンクです。Home にするコンピューターで **Home** を選び、下記の手順でもう一方のコンピューターを Child として追加してください。 +パスワード SSH と Windows は現在のフローに含まれません。リンクはどちら側からでも開始できます。次の手順のように Home から開始するか、後述の「このコンピューターを Child として接続する」のように Child から開始します。 ## `#remote` から Child を追加する @@ -25,6 +25,19 @@ description: SSH で OpenCodex の Home コンピューターと Child コンピ ダッシュボードはトークンの入力を求めません。先にホストをプローブし、フィンガープリントを明示的に確認するまでリンクを適用しません。 +## このコンピューターを Child として接続する + +Home のプロバイダーを使うコンピューターで次の操作を行います。 + +1. ダッシュボードで `#remote` を開き、Remote Link をオンにします。 +2. **Child** を選びます。SSH ホストの一覧が開きます。 +3. Home の SSH ホストを選び、接続テストを実行してから、ホストのフィンガープリントを比較して確認します。 +4. 注意事項を読み、**Connect as Child** を選びます。 + +接続すると、このコンピューターの OpenCodex が再起動します。すでに実行中の Codex ターンは先に完了し、再起動中の最大 1 分間は新しいリクエストが失敗することがあります。その後ダッシュボードは自動的に再読み込みされ、Child のリンクを表示します。Codex はこのコンピューターの `http://127.0.0.1:/v1` を使い続け、トークンや環境変数の設定は不要です。ローカルの OpenCodex が各リクエストを Home に中継し、Home が自身のプロバイダーとアカウントで応答します。 + +**Child** の役割は、OpenCodex が設定されたポートで動作している間だけ選択できます。Child はまさにそのポートで再起動するためです。設定されたポートで動作していないとダッシュボードに表示された場合は、先にそのポートで OpenCodex を再起動してください。 + ## リンクの状態 - **Connected** は SSH トンネルが準備でき、Child が Home のリンクを使える状態です。 @@ -47,7 +60,7 @@ Child から開始したリンクを切断するには、Child で `ocx disconne ## セキュリティ -Child はリンクを通じて Home コンピューターのプロバイダーとプロバイダー認証情報を使います。Home は Child ごとに別のリンクキーを作り、リンクを削除するとそのキーを失効させます。確認前にホストフィンガープリントを比較し、別のコンピューターや変更されたホストキーを誤って受け入れないようにしてください。Tailscale の ID から発行されたダッシュボードセッションはマシンリンクを管理できません。 +Child はリンクを通じて Home コンピューターのプロバイダーとプロバイダー認証情報を使います。Home は Child ごとに別のリンクキーを作り、リンクを削除するとそのキーを失効させます。確認前にホストフィンガープリントを比較し、別のコンピューターや変更されたホストキーを誤って受け入れないようにしてください。Tailscale の ID から発行されたダッシュボードセッションはマシンリンクを管理できません。 Child ではキーは OpenCodex の中にとどまります。Codex や Claude Code が Child で送る認証情報は Home に転送されず、Child で `127.0.0.1:` に到達できるプログラムはキーなしで Home を使えます。これはスタンドアロン環境がローカルのプログラムに与えるのと同じ信頼です。他のサイトの Web ページは拒否されます。 ## CLI リファレンス 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 457b95e8cfa..9cad2362ed5 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -11,9 +11,9 @@ description: SSH로 OpenCodex Home 컴퓨터와 Child 컴퓨터를 연결합니 - 자식이 시작하는 링크에서는 자식에서 OpenSSH 키 로그인으로 홈에 접속할 수 있어야 합니다(비밀번호 로그인은 지원하지 않음). - Child 컴퓨터에 OpenCodex 2.66.0 이상이 설치되어 있어야 합니다(자식이 시작하는 링크에서는 Home에도). - 두 컴퓨터 모두 macOS 또는 Linux여야 합니다. -- 링크는 홈에서 시작합니다. 홈 컴퓨터에서 직접 연 대시보드(독립형 설치의 브라우저 또는 데스크톱 앱)나 페어링된 Hub 세션을 사용해야 합니다. +- 링크를 시작하는 대시보드는 그 컴퓨터에서 직접 열거나(독립형 설치의 브라우저 또는 데스크톱 앱) 페어링된 Hub 세션으로 열어야 합니다. -비밀번호 SSH와 Windows는 현재 흐름에서 지원하지 않습니다. 이번 릴리스에서는 대시보드에서 컴퓨터를 자식으로 연결하는 방식(자식이 시작하는 링크)을 쓸 수 없습니다. 자식으로 참여하면 그 컴퓨터의 OpenCodex가 다시 시작되어 이미 쓰고 있는 Codex 연결이 끊어지기 때문에, 대시보드에서는 **자식** 역할을 선택할 수 없습니다. 지원되는 방법은 홈에서 시작하는 링크입니다. 홈이 될 컴퓨터에서 **Home**을 선택하고, 아래 순서대로 다른 컴퓨터를 자식으로 추가하세요. +비밀번호 SSH와 Windows는 현재 흐름에서 지원하지 않습니다. 링크는 어느 쪽에서든 시작할 수 있습니다. 아래 순서대로 Home에서 시작하거나, 이어지는 "이 컴퓨터를 Child로 연결하기" 절처럼 Child에서 시작합니다. ## `#remote`에서 Child 추가하기 @@ -25,6 +25,19 @@ description: SSH로 OpenCodex Home 컴퓨터와 Child 컴퓨터를 연결합니 대시보드는 토큰 입력을 요구하지 않습니다. 먼저 호스트를 검사하며, 지문을 명시적으로 확인하기 전에는 링크를 적용하지 않습니다. +## 이 컴퓨터를 Child로 연결하기 + +Home의 프로바이더를 사용할 컴퓨터에서 다음을 진행합니다. + +1. 대시보드에서 `#remote`를 열고 Remote Link를 켭니다. +2. **Child**를 선택합니다. SSH 호스트 목록이 열립니다. +3. Home의 SSH 호스트를 선택하고 연결 테스트를 실행한 뒤, 호스트 지문을 비교하고 확인합니다. +4. 안내를 읽고 **Connect as Child**를 선택합니다. + +연결하면 이 컴퓨터의 OpenCodex가 다시 시작됩니다. 이미 실행 중인 Codex 작업은 먼저 끝나고, 다시 시작되는 동안 최대 1분 정도 새 요청이 실패할 수 있습니다. 그 뒤 대시보드가 스스로 새로 고쳐지고 Child 링크를 보여 줍니다. Codex는 이 컴퓨터의 `http://127.0.0.1:/v1`을 그대로 사용하며 토큰이나 환경 변수를 설정할 필요가 없습니다. 로컬 OpenCodex가 각 요청을 Home으로 전달하고, Home은 자신의 프로바이더와 계정으로 응답합니다. + +**Child** 역할은 OpenCodex가 설정된 포트에서 실행 중일 때만 선택할 수 있습니다. Child는 정확히 그 포트에서 다시 시작하기 때문입니다. 대시보드가 설정된 포트에서 실행되고 있지 않다고 알리면, 먼저 그 포트에서 OpenCodex를 다시 시작하세요. + ## 링크 상태 - **Connected**는 SSH 터널이 준비되어 Child가 Home 링크를 사용할 수 있다는 뜻입니다. @@ -47,7 +60,7 @@ ocx disconnect ## 보안 -Child는 링크를 통해 Home 컴퓨터의 프로바이더와 프로바이더 인증 정보를 사용합니다. Home은 Child마다 별도의 링크 키를 만들며, 링크를 제거하면 그 키를 폐기합니다. 확인 전에 호스트 지문을 비교하여 잘못된 컴퓨터나 변경된 호스트 키를 실수로 허용하지 않도록 하세요. Tailscale identity로 발급된 대시보드 세션은 머신 링크를 관리할 수 없습니다. +Child는 링크를 통해 Home 컴퓨터의 프로바이더와 프로바이더 인증 정보를 사용합니다. Home은 Child마다 별도의 링크 키를 만들며, 링크를 제거하면 그 키를 폐기합니다. 확인 전에 호스트 지문을 비교하여 잘못된 컴퓨터나 변경된 호스트 키를 실수로 허용하지 않도록 하세요. Tailscale identity로 발급된 대시보드 세션은 머신 링크를 관리할 수 없습니다. Child에서 키는 OpenCodex 안에만 머뭅니다. Codex나 Claude Code가 Child에서 보내는 인증 정보는 Home으로 전달되지 않으며, Child에서 `127.0.0.1:`에 접근하는 프로그램은 키 없이 Home을 사용합니다. 이는 독립형 설치가 로컬 프로그램에 주는 것과 같은 신뢰 수준입니다. 다른 사이트의 웹 페이지는 거부됩니다. ## CLI 레퍼런스 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 44906145861..fb00add6694 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -11,9 +11,9 @@ description: Подключите компьютер OpenCodex Home к комп - Для связи, инициированной со стороны Child, Child должен входить на Home по ключу OpenSSH (вход по паролю не поддерживается). - На Child установлен OpenCodex 2.66.0 или новее (для связи со стороны Child — и на Home). - Оба компьютера работают под macOS или Linux. -- Связь начинают со стороны Home: панель открыта на самом компьютере Home (браузер или настольное приложение в автономной установке) или через сопряжённую сессию Hub. +- Панель, с которой начинают связь, открыта на самом этом компьютере (браузер или настольное приложение, автономная установка) или через сопряжённую сессию Hub. -SSH с паролем и Windows сейчас не поддерживаются. В этой версии подключить компьютер как Child из панели (связь со стороны Child) нельзя: подключение перезапускает OpenCodex на этом компьютере, из-за чего оборвутся уже работающие подключения Codex, поэтому в панели роль **Дочерний** недоступна. Поддерживаемый путь — связь со стороны Home: на компьютере, который должен стать Home, выберите **Home** и добавьте другой компьютер как Child, как описано ниже. +SSH с паролем и Windows сейчас не поддерживаются. Связь можно начать с любой стороны: с Home, как описано ниже, или с Child, как описано в разделе «Подключение этого компьютера как Child». ## Добавление Child из `#remote` @@ -25,6 +25,19 @@ SSH с паролем и Windows сейчас не поддерживаются. Панель не просит вводить токен. Сначала выполняется проверка хоста, и применить связь можно только после явного подтверждения отпечатка. +## Подключение этого компьютера как Child + +На компьютере, который должен использовать провайдеров Home: + +1. Откройте панель на `#remote` и включите Remote Link. +2. Выберите **Child**. Откроется список SSH-хостов. +3. Выберите SSH-хост Home, запустите проверку подключения, затем сравните и подтвердите отпечаток хоста. +4. Прочитайте предупреждение и выберите **Connect as Child**. + +При подключении OpenCodex на этом компьютере перезапустится. Уже идущие запросы Codex сначала завершатся, а новые запросы могут не проходить до минуты, пока идёт перезапуск. Затем панель перезагрузится сама и покажет связь Child. Codex продолжит использовать `http://127.0.0.1:/v1` на этом компьютере без токена и без переменных окружения: локальный OpenCodex передаёт каждый запрос на Home, а Home обслуживает его своими провайдерами и учётными записями. + +Роль **Child** доступна, только пока OpenCodex работает на настроенном порту, потому что Child перезапускается именно на нём. Если панель сообщает, что OpenCodex работает не на настроенном порту, сначала перезапустите его на этом порту. + ## Состояние связи - **Connected** означает, что SSH-туннель готов и Child может использовать связь Home. @@ -47,7 +60,7 @@ ocx disconnect ## Безопасность -Child использует через связь провайдеров и учётные данные провайдеров компьютера Home. Home создаёт отдельный ключ связи для каждого Child; удаление связи отзывает этот ключ. Сравнивайте отпечаток хоста перед подтверждением, чтобы случайно не принять другой компьютер или изменённый ключ. Сессии панели, выданные удостоверением Tailscale, не могут управлять связями машин. +Child использует через связь провайдеров и учётные данные провайдеров компьютера Home. Home создаёт отдельный ключ связи для каждого Child; удаление связи отзывает этот ключ. Сравнивайте отпечаток хоста перед подтверждением, чтобы случайно не принять другой компьютер или изменённый ключ. Сессии панели, выданные удостоверением Tailscale, не могут управлять связями машин. На Child ключ остаётся внутри OpenCodex: учётные данные, которые Codex или Claude Code отправляют на Child, не передаются на Home, а любая программа на Child, которая обращается к `127.0.0.1:`, использует Home без ключа; это то же локальное доверие, что и у автономной установки. Веб-страницы с других сайтов отклоняются. ## Справочник CLI 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 764ee6cd3bd..df5c33a0201 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -11,9 +11,9 @@ Makine bağlantısı, bir OpenCodex **Home** bilgisayarını bir **Child** bilgi - 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 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. -- 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. +- Bağlantıyı başlatan kontrol paneli o bilgisayarı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. 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. +Parolalı SSH ve Windows mevcut akışın dışındadır. Bağlantı iki taraftan da başlatılabilir: aşağıda anlatıldığı gibi Home tarafından ya da "Bu bilgisayarı Child olarak bağlama" bölümünde anlatıldığı gibi Child tarafından. ## `#remote` üzerinden Child ekleme @@ -25,6 +25,19 @@ Parolalı SSH ve Windows mevcut akışın dışındadır. Bu sürümde bir bilgi Kontrol paneli belirteç girmenizi istemez. Önce ana bilgisayarı yoklar ve parmak izini açıkça onaylamadan bağlantıyı uygulamaz. +## Bu bilgisayarı Child olarak bağlama + +Home'un sağlayıcılarını kullanacak bilgisayarda: + +1. Kontrol panelinde `#remote` sayfasını açın ve Remote Link'i açın. +2. **Child** seçeneğini seçin. SSH ana bilgisayar listesi açılır. +3. Home'un SSH ana bilgisayarını seçin, bağlantı testini çalıştırın, ardından ana bilgisayar parmak izini karşılaştırıp onaylayın. +4. Uyarıyı okuyun ve **Connect as Child** seçeneğini seçin. + +Bağlanmak bu bilgisayardaki OpenCodex'i yeniden başlatır. Zaten çalışan Codex istekleri önce tamamlanır ve yeniden başlatma sırasında yeni istekler bir dakikaya kadar başarısız olabilir. Ardından kontrol paneli kendiliğinden yeniden yüklenir ve Child bağlantısını gösterir. Codex bu bilgisayarda `http://127.0.0.1:/v1` adresini kullanmaya devam eder ve belirteç veya ortam değişkeni ayarlamanız gerekmez: yerel OpenCodex her isteği Home'a aktarır, Home da kendi sağlayıcıları ve hesaplarıyla yanıt verir. + +**Child** rolü yalnızca OpenCodex yapılandırılmış bağlantı noktasında çalışırken kullanılabilir, çünkü Child tam olarak o bağlantı noktasında yeniden başlar. Kontrol paneli OpenCodex'in yapılandırılmış bağlantı noktasında çalışmadığını söylerse önce onu o bağlantı noktasında yeniden başlatın. + ## Bağlantı durumu - **Connected**, SSH tünelinin hazır ve Child'ın Home bağlantısını kullanabilir olduğu anlamına gelir. @@ -47,7 +60,7 @@ Child tarafından başlatılan bağlantıyı kesmek için Child üzerinde `ocx d ## Güvenlik -Child, bağlantı üzerinden Home bilgisayarının sağlayıcılarını ve sağlayıcı kimlik bilgilerini kullanır. Home her Child için ayrı bir bağlantı anahtarı oluşturur; bağlantıyı kaldırmak bu anahtarı iptal eder. Onaylamadan önce ana bilgisayar parmak izini karşılaştırarak yanlış bilgisayarı veya değiştirilmiş anahtarı kabul etmediğinizden emin olun. Tailscale kimliğiyle verilen kontrol paneli oturumları makine bağlantılarını yönetemez. +Child, bağlantı üzerinden Home bilgisayarının sağlayıcılarını ve sağlayıcı kimlik bilgilerini kullanır. Home her Child için ayrı bir bağlantı anahtarı oluşturur; bağlantıyı kaldırmak bu anahtarı iptal eder. Onaylamadan önce ana bilgisayar parmak izini karşılaştırarak yanlış bilgisayarı veya değiştirilmiş anahtarı kabul etmediğinizden emin olun. Tailscale kimliğiyle verilen kontrol paneli oturumları makine bağlantılarını yönetemez. Child üzerinde anahtar OpenCodex içinde kalır: Codex veya Claude Code'un Child üzerinde gönderdiği kimlik bilgileri Home'a iletilmez ve Child üzerinde `127.0.0.1:` adresine ulaşan her program Home'u anahtarsız kullanır; bu, bağımsız bir kurulumun yerel programlara verdiği güvenle aynıdır. Başka sitelerden gelen web sayfaları reddedilir. ## CLI başvurusu 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 f72177fa2c7..1440a27c3fd 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 @@ -11,9 +11,9 @@ description: 通过 SSH 将 OpenCodex 主机与子机连接起来。 - 对于由子机发起的链接,子机必须能使用 OpenSSH 密钥登录主机(不支持密码登录)。 - 子机已安装 OpenCodex 2.66.0 或更高版本(由子机发起的链接还要求主机也满足)。 - 两台电脑运行 macOS 或 Linux。 -- 链接从 Home 一侧发起:控制台需在 Home 电脑本机打开(独立安装的浏览器或桌面应用),或通过已配对的 Hub 会话打开。 +- 发起链接的控制台需在那台电脑本机打开(独立安装的浏览器或桌面应用),或通过已配对的 Hub 会话打开。 -密码 SSH 和 Windows 不在当前流程中。此版本不支持从控制台把电脑连接为子机(即由子机发起的链接):加入会重新启动这台电脑上的 OpenCodex,已在运行的 Codex 连接会因此中断,因此控制台中的 **子设备** 角色不可选。受支持的方式是由 Home 发起链接:在要作为 Home 的电脑上选择 **Home**,再按下文步骤把另一台电脑添加为子机。 +密码 SSH 和 Windows 不在当前流程中。链接可以从任意一侧发起:按下文从主机发起,或按“将这台电脑连接为子机”一节从子机发起。 ## 从 `#remote` 添加子机 @@ -25,6 +25,19 @@ description: 通过 SSH 将 OpenCodex 主机与子机连接起来。 控制台不会要求输入令牌。它会先探测主机,只有明确确认指纹后才能应用链接。 +## 将这台电脑连接为子机 + +在要使用主机提供商的电脑上: + +1. 在控制台中打开 `#remote` 并开启 Remote Link。 +2. 选择 **Child**。SSH 主机列表会打开。 +3. 选择主机的 SSH 主机,运行连接测试,然后比较并确认其主机指纹。 +4. 阅读提示后选择 **Connect as Child**。 + +连接会重启这台电脑上的 OpenCodex。已在运行的 Codex 请求会先完成,重启期间新的请求可能在最多一分钟内失败。随后控制台会自动重新加载并显示子机链接。Codex 继续使用这台电脑上的 `http://127.0.0.1:/v1`,无需设置令牌或环境变量:本地 OpenCodex 会把每个请求转发给主机,由主机用它自己的提供商和账户提供服务。 + +只有当 OpenCodex 在其配置的端口上运行时,才能选择 **Child** 角色,因为子机会在正好这个端口上重启。如果控制台提示 OpenCodex 未在其配置的端口上运行,请先在该端口上重启它。 + ## 链接状态 - **Connected** 表示 SSH 隧道已就绪,子机可以使用主机链接。 @@ -47,7 +60,7 @@ ocx disconnect ## 安全 -子机会通过链接使用主机电脑上的提供商和提供商凭据。主机会为每台子机创建单独的链接密钥;移除链接会吊销该密钥。确认前比较主机指纹,避免误接受错误电脑或已更换的主机密钥。由 Tailscale 身份签发的控制台会话不能管理机器链接。 +子机会通过链接使用主机电脑上的提供商和提供商凭据。主机会为每台子机创建单独的链接密钥;移除链接会吊销该密钥。确认前比较主机指纹,避免误接受错误电脑或已更换的主机密钥。由 Tailscale 身份签发的控制台会话不能管理机器链接。 在子机上,密钥只保留在 OpenCodex 内部:Codex 或 Claude Code 在子机上发送的凭据不会转发给主机,子机上任何能访问 `127.0.0.1:` 的程序都可以无密钥使用主机,这与独立安装给予本地程序的信任相同。来自其他网站的网页会被拒绝。 ## CLI 参考 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 611c366f271..02258a4dc16 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 @@ -11,9 +11,9 @@ description: 透過 SSH 連接 OpenCodex Home 電腦與 Child 電腦。 - 對於由 Child 發起的連結,Child 必須能使用 OpenSSH 金鑰登入 Home(不支援密碼登入)。 - Child 已安裝 OpenCodex 2.66.0 或更新版本(由 Child 發起的連結也要求 Home 符合)。 - 兩台電腦執行 macOS 或 Linux。 -- 連結由 Home 端發起:儀表板需在 Home 電腦本機開啟(獨立安裝的瀏覽器或桌面應用程式),或透過已配對的 Hub 工作階段開啟。 +- 發起連結的儀表板需在那台電腦本機開啟(獨立安裝的瀏覽器或桌面應用程式),或透過已配對的 Hub 工作階段開啟。 -密碼 SSH 和 Windows 不在目前流程中。此版本不支援從儀表板將電腦連線為 Child(即由 Child 發起的連結):加入會重新啟動這台電腦上的 OpenCodex,已在執行的 Codex 連線會因此中斷,因此儀表板中的 **子裝置** 角色無法選取。受支援的方式是由 Home 發起連結:在要作為 Home 的電腦上選擇 **Home**,再依下方步驟將另一台電腦新增為 Child。 +密碼 SSH 和 Windows 不在目前流程中。連結可以從任一端發起:依下文從 Home 發起,或依「將這台電腦連線為 Child」一節從 Child 發起。 ## 從 `#remote` 新增 Child @@ -25,6 +25,19 @@ description: 透過 SSH 連接 OpenCodex Home 電腦與 Child 電腦。 儀表板不會要求輸入權杖。它會先探測主機,只有明確確認指紋後才能套用連結。 +## 將這台電腦連線為 Child + +在要使用 Home 供應商的電腦上: + +1. 在儀表板中開啟 `#remote` 並開啟 Remote Link。 +2. 選擇 **Child**。SSH 主機清單會開啟。 +3. 選擇 Home 的 SSH 主機,執行連線測試,然後比較並確認其主機指紋。 +4. 閱讀提示後選擇 **Connect as Child**。 + +連線會重新啟動這台電腦上的 OpenCodex。已在執行的 Codex 請求會先完成,重新啟動期間新的請求可能在最多一分鐘內失敗。之後儀表板會自動重新載入並顯示 Child 連結。Codex 會繼續使用這台電腦上的 `http://127.0.0.1:/v1`,不需要設定權杖或環境變數:本機 OpenCodex 會把每個請求轉送給 Home,由 Home 以它自己的供應商與帳戶提供服務。 + +只有當 OpenCodex 在其設定的連接埠上執行時,才能選擇 **Child** 角色,因為 Child 會在正好這個連接埠上重新啟動。如果儀表板提示 OpenCodex 未在其設定的連接埠上執行,請先在該連接埠上重新啟動它。 + ## 連結狀態 - **Connected** 表示 SSH 通道已準備好,Child 可以使用 Home 連結。 @@ -47,7 +60,7 @@ ocx disconnect ## 安全性 -Child 會透過連結使用 Home 電腦上的供應商與供應商憑證。Home 會為每個 Child 建立獨立的連結金鑰;移除連結會撤銷該金鑰。確認前請比較主機指紋,避免誤接受錯誤電腦或已變更的主機金鑰。由 Tailscale 身分簽發的儀表板工作階段無法管理機器連結。 +Child 會透過連結使用 Home 電腦上的供應商與供應商憑證。Home 會為每個 Child 建立獨立的連結金鑰;移除連結會撤銷該金鑰。確認前請比較主機指紋,避免誤接受錯誤電腦或已變更的主機金鑰。由 Tailscale 身分簽發的儀表板工作階段無法管理機器連結。 在 Child 上,金鑰只保留在 OpenCodex 內部:Codex 或 Claude Code 在 Child 上傳送的憑證不會轉送給 Home,Child 上任何能存取 `127.0.0.1:` 的程式都可以不用金鑰使用 Home,這與獨立安裝給予本機程式的信任相同。來自其他網站的網頁會被拒絕。 ## CLI 參考 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 34266a80d56..b53814b154c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3323,22 +3323,24 @@ 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", "remoteLink.findHome.connect": "Als Kind verbinden", + "remoteLink.findHome.notice": "Beim Verbinden wird OpenCodex auf diesem Computer neu gestartet. Laufende Codex-Anfragen werden zuerst abgeschlossen, und neue Anfragen können bis zu einer Minute fehlschlagen. Danach behält Codex dieselbe lokale Adresse, und das Home bedient sie mit seinen eigenen Anbietern und Konten.", "remoteLink.findHome.empty": "Noch mit keinem Home verbunden.", "remoteLink.joining": "Home wird verbunden …", "remoteLink.restart.title": "Dieser Computer wird neu gestartet, um sich als Kind zu verbinden.", - "remoteLink.restart.body": "Das Dashboard verbindet sich automatisch wieder, sobald das Kind bereit ist.", + "remoteLink.restart.body": "Laufende Codex-Anfragen werden zuerst abgeschlossen, dann startet OpenCodex als Kind neu. Diese Seite lädt sich selbst neu, sobald das Kind bereit ist.", "remoteLink.restart.waiting": "Warten auf die erneute Verbindung als Kind …", + "remoteLink.restart.slow": "Der Neustart dauert länger als üblich. Diese Seite prüft weiter und lädt sich selbst neu, sobald das Kind antwortet. Falls OpenCodex beendet wurde, starten Sie es erneut.", "remoteLink.error.standalone_required": "Kind-Verbindungen können nur von einer eigenständigen Laufzeit gestartet werden.", "remoteLink.error.join_tunnel_failed": "Der Tunnel zu Home konnte nicht gestartet werden. Prüfen Sie den SSH-Zugriff und versuchen Sie es erneut.", "remoteLink.error.admission_failed": "Home hat die neue Verbindung nicht angenommen. Prüfen Sie, ob Home läuft, und versuchen Sie es erneut.", "remoteLink.error.join_issue_failed": "Home konnte keinen Link ausstellen. Prüfen Sie, ob OpenCodex auf Home läuft, und versuchen Sie es erneut.", "remoteLink.error.join_in_progress": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "remoteLink.error.join_port_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.join_port_mismatch": "OpenCodex läuft nicht auf seinem konfigurierten Port und kann daher nicht als Kind neu starten. Starten Sie OpenCodex auf dem konfigurierten Port neu und versuchen Sie es dann erneut.", "remoteLink.error.join_rollback_failed": "Die Verbindung ist fehlgeschlagen und der Link auf Home konnte nicht entfernt werden. Wiederholen Sie die Bereinigung oder führen Sie auf Home ocx link revoke aus.", "remoteLink.error.join_restart_failed": "Der Link ist bereit. Starten Sie OpenCodex auf diesem Computer neu, um die Verbindung als Child abzuschließen.", "remoteLink.error.join_connect_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ad00e8ad053..79f1072e978 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3357,22 +3357,24 @@ 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", "remoteLink.findHome.connect": "Connect as Child", + "remoteLink.findHome.notice": "Connecting restarts OpenCodex on this computer. Codex turns already running finish first, and new requests can fail for up to a minute. Afterwards Codex keeps the same local address, and the Home serves it with its own providers and accounts.", "remoteLink.findHome.empty": "Not connected to a Home yet.", "remoteLink.joining": "Joining Home…", "remoteLink.restart.title": "This computer will restart to connect as a Child.", - "remoteLink.restart.body": "The dashboard will reconnect automatically when the Child is ready.", + "remoteLink.restart.body": "Running Codex turns finish first, then OpenCodex restarts as a Child. This page reloads by itself when the Child is ready.", "remoteLink.restart.waiting": "Waiting for this computer to reconnect as Child…", + "remoteLink.restart.slow": "Restarting is taking longer than usual. This page keeps checking and reloads by itself once the Child answers. If OpenCodex has stopped, start it again.", "remoteLink.error.standalone_required": "Child links can only be started from a standalone runtime.", "remoteLink.error.join_tunnel_failed": "The tunnel to Home could not be started. Check SSH access and retry.", "remoteLink.error.admission_failed": "Home did not accept the new link. Check that it is running and retry.", "remoteLink.error.join_issue_failed": "Home could not issue a link. Check that OpenCodex is running on Home and retry.", "remoteLink.error.join_in_progress": "Remote link request could not be completed.", "remoteLink.error.join_port_failed": "Remote link request could not be completed.", + "remoteLink.error.join_port_mismatch": "OpenCodex is not running on its configured port, so it cannot restart as a Child. Restart OpenCodex on its configured port, then try again.", "remoteLink.error.join_rollback_failed": "Joining failed and the link on Home could not be removed. Retry the cleanup, or run ocx link revoke on Home.", "remoteLink.error.join_restart_failed": "The link is ready. Restart OpenCodex on this computer to finish connecting as a Child.", "remoteLink.error.join_connect_failed": "Remote link request could not be completed.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e8068e096f4..72c770fa1a5 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3312,16 +3312,17 @@ 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", "remoteLink.findHome.connect": "Connecter comme Enfant", + "remoteLink.findHome.notice": "La connexion redémarre OpenCodex sur cet ordinateur. Les tours Codex en cours se terminent d’abord, et les nouvelles requêtes peuvent échouer pendant une minute au plus. Ensuite, Codex garde la même adresse locale, et le Home y répond avec ses propres fournisseurs et comptes.", "remoteLink.findHome.empty": "Pas encore connecté à un Home.", "remoteLink.joining": "Connexion au Home…", "remoteLink.restart.title": "Cet ordinateur va redémarrer pour se connecter comme Enfant.", - "remoteLink.restart.body": "Le tableau de bord se reconnectera automatiquement lorsque l’Enfant sera prêt.", + "remoteLink.restart.body": "Les tours Codex en cours se terminent d’abord, puis OpenCodex redémarre comme Enfant. Cette page se recharge d’elle-même lorsque l’Enfant est prêt.", "remoteLink.restart.waiting": "En attente de la reconnexion comme Enfant…", + "remoteLink.restart.slow": "Le redémarrage prend plus de temps que d’habitude. Cette page continue de vérifier et se recharge d’elle-même dès que l’Enfant répond. Si OpenCodex s’est arrêté, relancez-le.", "remoteLink.error.standalone_required": "Les liens Enfant ne peuvent être lancés que depuis un runtime autonome.", "remoteLink.error.join_tunnel_failed": "Le tunnel vers le Home n’a pas pu être démarré. Vérifiez l’accès SSH puis réessayez.", "remoteLink.error.admission_failed": "Le Home n’a pas accepté le nouveau lien. Vérifiez qu’il fonctionne puis réessayez.", @@ -3330,6 +3331,7 @@ export const fr: Record = { "remoteLink.error.join_rollback_failed": "La connexion a échoué et le lien sur le Home n’a pas pu être supprimé. Réessayez le nettoyage ou exécutez ocx link revoke sur le Home.", "remoteLink.error.join_restart_failed": "Le lien est prêt. Redémarrez OpenCodex sur cet ordinateur pour terminer la connexion en tant qu’Enfant.", "remoteLink.error.join_port_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.join_port_mismatch": "OpenCodex ne tourne pas sur son port configuré et ne peut donc pas redémarrer comme Enfant. Redémarrez OpenCodex sur son port configuré, puis réessayez.", "remoteLink.error.join_connect_failed": "La demande de lien distant n’a pas pu aboutir.", "link.noChildren": "Aucun ordinateur enfant connecté.", "remoteLink.status.connecting": "Connexion", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c3c6d6eb60d..06cc600b7bd 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3345,16 +3345,17 @@ 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 を探す", "remoteLink.findHome.connect": "子として接続", + "remoteLink.findHome.notice": "接続すると、このコンピューターの OpenCodex が再起動します。実行中の Codex ターンは先に完了し、再起動中の最大 1 分間は新しいリクエストが失敗することがあります。その後も Codex は同じローカルアドレスを使い続け、Home が自身のプロバイダーとアカウントで応答します。", "remoteLink.findHome.empty": "まだ Home に接続されていません。", "remoteLink.joining": "Home に接続しています…", "remoteLink.restart.title": "このコンピューターは子として接続するため再起動します。", - "remoteLink.restart.body": "子の準備ができるとダッシュボードは自動的に再接続します。", + "remoteLink.restart.body": "実行中の Codex ターンが完了した後、OpenCodex が子として再起動します。子の準備ができると、このページは自動的に再読み込みされます。", "remoteLink.restart.waiting": "子として再接続するまで待機しています…", + "remoteLink.restart.slow": "再起動に通常より時間がかかっています。このページは確認を続け、子が応答すると自動的に再読み込みされます。OpenCodex が停止している場合は、もう一度起動してください。", "remoteLink.error.standalone_required": "子リンクを開始できるのはスタンドアロンランタイムだけです。", "remoteLink.error.join_tunnel_failed": "Home へのトンネルを開始できませんでした。SSH 接続を確認して再試行してください。", "remoteLink.error.admission_failed": "Home が新しいリンクを受け付けませんでした。Home が起動しているか確認して再試行してください。", @@ -3363,6 +3364,7 @@ export const ja: Record = { "remoteLink.error.join_rollback_failed": "接続に失敗し、ホーム上のリンクを削除できませんでした。クリーンアップを再試行するか、ホームで ocx link revoke を実行してください。", "remoteLink.error.join_restart_failed": "リンクの準備ができました。このコンピューターで OpenCodex を再起動して、子としての接続を完了してください。", "remoteLink.error.join_port_failed": "リモートリンク要求を完了できませんでした。", + "remoteLink.error.join_port_mismatch": "OpenCodex が設定されたポートで動作していないため、子として再起動できません。設定されたポートで OpenCodex を再起動してから、もう一度お試しください。", "remoteLink.error.join_connect_failed": "リモートリンク要求を完了できませんでした。", "link.noChildren": "接続された子コンピューターはありません。", "remoteLink.status.connecting": "接続中", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index aa00df58565..b72a693699c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3345,16 +3345,17 @@ export const ko: Record = { "link.close": "닫기", "link.cancel": "취소", "remoteLink.childDisabled": "자식 링크는 독립형 런타임에서만 시작할 수 있습니다.", - "remoteLink.childJoinUnavailable": "이번 릴리스에서는 대시보드에서 이 컴퓨터를 자식으로 연결할 수 없습니다. 자식으로 연결하면 OpenCodex가 다시 시작되어 지금 쓰고 있는 Codex 연결이 끊어지기 때문입니다. 대신 홈에서 연결을 시작하세요. 홈이 될 컴퓨터에서 홈을 선택한 뒤 다른 컴퓨터를 자식으로 추가하면 됩니다.", "remoteLink.findHome.title": "홈 찾기", "remoteLink.findHome.body": "이 자식 컴퓨터를 연결할 홈 컴퓨터를 선택하세요.", "remoteLink.findHome.action": "홈 찾기", "remoteLink.findHome.connect": "자식으로 연결", + "remoteLink.findHome.notice": "연결하면 이 컴퓨터의 OpenCodex가 다시 시작됩니다. 실행 중인 Codex 작업은 먼저 끝나고, 다시 시작되는 동안 최대 1분 정도 새 요청이 실패할 수 있습니다. 그 뒤에도 Codex는 같은 로컬 주소를 그대로 쓰며, 홈이 자신의 제공자와 계정으로 응답합니다.", "remoteLink.findHome.empty": "아직 연결된 홈이 없습니다.", "remoteLink.joining": "홈에 연결하는 중…", "remoteLink.restart.title": "이 컴퓨터는 자식으로 연결하기 위해 재시작합니다.", - "remoteLink.restart.body": "자식이 준비되면 대시보드가 자동으로 다시 연결됩니다.", + "remoteLink.restart.body": "실행 중인 Codex 작업이 먼저 끝난 뒤 OpenCodex가 자식으로 다시 시작됩니다. 자식이 준비되면 이 페이지가 자동으로 새로 고쳐집니다.", "remoteLink.restart.waiting": "자식으로 다시 연결되기를 기다리는 중…", + "remoteLink.restart.slow": "재시작이 평소보다 오래 걸리고 있습니다. 이 페이지는 계속 확인하다가 자식이 응답하면 자동으로 새로 고쳐집니다. OpenCodex가 멈췄다면 다시 시작하세요.", "remoteLink.error.standalone_required": "자식 링크는 독립형 런타임에서만 시작할 수 있습니다.", "remoteLink.error.join_tunnel_failed": "홈으로 가는 터널을 시작하지 못했습니다. SSH 연결을 확인한 뒤 다시 시도하세요.", "remoteLink.error.admission_failed": "홈이 새 링크를 수락하지 않았습니다. 홈이 실행 중인지 확인한 뒤 다시 시도하세요.", @@ -3363,6 +3364,7 @@ export const ko: Record = { "remoteLink.error.join_rollback_failed": "연결에 실패했고 홈의 링크를 삭제하지 못했습니다. 정리를 다시 시도하거나 홈에서 ocx link revoke를 실행하세요.", "remoteLink.error.join_restart_failed": "링크가 준비되었습니다. 이 컴퓨터에서 OpenCodex를 다시 시작해 자식 연결을 완료하세요.", "remoteLink.error.join_port_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.join_port_mismatch": "OpenCodex가 설정된 포트에서 실행되고 있지 않아 자식으로 다시 시작할 수 없습니다. 설정된 포트에서 OpenCodex를 다시 시작한 뒤 다시 시도하세요.", "remoteLink.error.join_connect_failed": "원격 연결 요청을 완료하지 못했습니다.", "link.noChildren": "연결된 자식 컴퓨터가 없습니다.", "remoteLink.status.connecting": "연결 중", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 10ea91ba4f0..5065f2fad5e 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3346,16 +3346,17 @@ 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", "remoteLink.findHome.connect": "Подключить как Child", + "remoteLink.findHome.notice": "При подключении OpenCodex на этом компьютере перезапустится. Уже идущие запросы Codex сначала завершатся, а новые запросы могут не проходить до минуты. После этого Codex сохранит тот же локальный адрес, а Home будет обслуживать его своими провайдерами и учётными записями.", "remoteLink.findHome.empty": "Пока не подключено к Home.", "remoteLink.joining": "Подключение к Home…", "remoteLink.restart.title": "Компьютер перезапустится для подключения как Child.", - "remoteLink.restart.body": "Панель управления подключится автоматически, когда Child будет готов.", + "remoteLink.restart.body": "Сначала завершатся текущие запросы Codex, затем OpenCodex перезапустится как Child. Эта страница перезагрузится сама, когда Child будет готов.", "remoteLink.restart.waiting": "Ожидание повторного подключения как Child…", + "remoteLink.restart.slow": "Перезапуск занимает больше времени, чем обычно. Эта страница продолжает проверку и перезагрузится сама, как только Child ответит. Если OpenCodex остановлен, запустите его снова.", "remoteLink.error.standalone_required": "Связь с дочерним компьютером можно начать только из автономного режима.", "remoteLink.error.join_tunnel_failed": "Не удалось запустить туннель к Home. Проверьте SSH-доступ и повторите попытку.", "remoteLink.error.admission_failed": "Home не принял новую связь. Убедитесь, что он запущен, и повторите попытку.", @@ -3364,6 +3365,7 @@ export const ru: Record = { "remoteLink.error.join_rollback_failed": "Подключение не удалось, а связь на Home удалить не удалось. Повторите очистку или выполните на Home команду ocx link revoke.", "remoteLink.error.join_restart_failed": "Связь готова. Перезапустите OpenCodex на этом компьютере, чтобы завершить подключение в роли дочернего компьютера.", "remoteLink.error.join_port_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.join_port_mismatch": "OpenCodex работает не на настроенном порту, поэтому не может перезапуститься как Child. Перезапустите OpenCodex на настроенном порту и повторите попытку.", "remoteLink.error.join_connect_failed": "Не удалось завершить запрос удалённой связи.", "link.noChildren": "Дочерние компьютеры не подключены.", "remoteLink.status.connecting": "Подключение", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 59eba015435..5f8d553b18d 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3346,16 +3346,17 @@ 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", "remoteLink.findHome.connect": "Çocuk olarak bağlan", + "remoteLink.findHome.notice": "Bağlanmak bu bilgisayardaki OpenCodex'i yeniden başlatır. Çalışan Codex istekleri önce tamamlanır ve yeni istekler bir dakikaya kadar başarısız olabilir. Ardından Codex aynı yerel adresi kullanmaya devam eder ve Home bu adrese kendi sağlayıcıları ve hesaplarıyla yanıt verir.", "remoteLink.findHome.empty": "Henüz bir Home'a bağlı değil.", "remoteLink.joining": "Home'a bağlanılıyor…", "remoteLink.restart.title": "Bu bilgisayar Çocuk olarak bağlanmak için yeniden başlatılacak.", - "remoteLink.restart.body": "Çocuk hazır olduğunda pano otomatik olarak yeniden bağlanır.", + "remoteLink.restart.body": "Önce çalışan Codex istekleri tamamlanır, ardından OpenCodex Çocuk olarak yeniden başlar. Çocuk hazır olduğunda bu sayfa kendiliğinden yeniden yüklenir.", "remoteLink.restart.waiting": "Çocuk olarak yeniden bağlanılması bekleniyor…", + "remoteLink.restart.slow": "Yeniden başlatma normalden uzun sürüyor. Bu sayfa denetlemeyi sürdürür ve Çocuk yanıt verdiğinde kendiliğinden yeniden yüklenir. OpenCodex durduysa yeniden başlatın.", "remoteLink.error.standalone_required": "Çocuk bağlantıları yalnızca bağımsız çalışma zamanından başlatılabilir.", "remoteLink.error.join_tunnel_failed": "Home tüneli başlatılamadı. SSH erişimini kontrol edip yeniden deneyin.", "remoteLink.error.admission_failed": "Home yeni bağlantıyı kabul etmedi. Çalıştığını kontrol edip yeniden deneyin.", @@ -3364,6 +3365,7 @@ export const tr: Record = { "remoteLink.error.join_rollback_failed": "Katılma başarısız oldu ve Home üzerindeki bağlantı kaldırılamadı. Temizlemeyi yeniden deneyin veya Home üzerinde ocx link revoke komutunu çalıştırın.", "remoteLink.error.join_restart_failed": "Bağlantı hazır. Çocuk olarak bağlanmayı tamamlamak için bu bilgisayarda OpenCodex'i yeniden başlatın.", "remoteLink.error.join_port_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.join_port_mismatch": "OpenCodex yapılandırılmış bağlantı noktasında çalışmıyor, bu yüzden Çocuk olarak yeniden başlatılamıyor. OpenCodex'i yapılandırılmış bağlantı noktasında yeniden başlatın ve tekrar deneyin.", "remoteLink.error.join_connect_failed": "Uzak bağlantı isteği tamamlanamadı.", "link.noChildren": "Bağlı çocuk bilgisayarı yok.", "remoteLink.status.connecting": "Bağlanıyor", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index cbd565d9a78..bdd529fdce5 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3281,16 +3281,17 @@ 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", "remoteLink.findHome.connect": "Kết nối với vai trò máy con", + "remoteLink.findHome.notice": "Việc kết nối sẽ khởi động lại OpenCodex trên máy này. Các lượt Codex đang chạy sẽ hoàn tất trước, và yêu cầu mới có thể thất bại trong tối đa một phút. Sau đó Codex vẫn dùng cùng địa chỉ cục bộ, và Home phục vụ bằng các nhà cung cấp và tài khoản của chính nó.", "remoteLink.findHome.empty": "Chưa kết nối với Home nào.", "remoteLink.joining": "Đang kết nối với Home…", "remoteLink.restart.title": "Máy tính này sẽ khởi động lại để kết nối với vai trò máy con.", - "remoteLink.restart.body": "Bảng điều khiển sẽ tự động kết nối lại khi máy con sẵn sàng.", + "remoteLink.restart.body": "Các lượt Codex đang chạy sẽ hoàn tất trước, sau đó OpenCodex khởi động lại với vai trò máy con. Trang này sẽ tự tải lại khi máy con sẵn sàng.", "remoteLink.restart.waiting": "Đang chờ máy tính này kết nối lại với vai trò máy con…", + "remoteLink.restart.slow": "Quá trình khởi động lại đang lâu hơn bình thường. Trang này vẫn tiếp tục kiểm tra và sẽ tự tải lại khi máy con phản hồi. Nếu OpenCodex đã dừng, hãy khởi động lại.", "remoteLink.error.standalone_required": "Chỉ có thể bắt đầu liên kết máy con từ runtime độc lập.", "remoteLink.error.join_tunnel_failed": "Không thể khởi động đường hầm tới Home. Hãy kiểm tra quyền truy cập SSH rồi thử lại.", "remoteLink.error.admission_failed": "Home không chấp nhận liên kết mới. Hãy kiểm tra Home đang chạy rồi thử lại.", @@ -3299,6 +3300,7 @@ export const vi: Record = { "remoteLink.error.join_rollback_failed": "Không thể kết nối và cũng không thể xóa liên kết trên máy chủ. Hãy thử dọn dẹp lại hoặc chạy ocx link revoke trên máy chủ.", "remoteLink.error.join_restart_failed": "Liên kết đã sẵn sàng. Hãy khởi động lại OpenCodex trên máy tính này để hoàn tất kết nối với vai trò máy con.", "remoteLink.error.join_port_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.join_port_mismatch": "OpenCodex không chạy trên cổng đã cấu hình nên không thể khởi động lại với vai trò máy con. Hãy khởi động lại OpenCodex trên cổng đã cấu hình rồi thử lại.", "remoteLink.error.join_connect_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", "link.noChildren": "Chưa có máy con nào được kết nối.", "remoteLink.status.connecting": "Đang kết nối", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 357be1e9410..19902c3eff9 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3309,16 +3309,17 @@ 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", "remoteLink.findHome.connect": "以子裝置身分連線", + "remoteLink.findHome.notice": "連線會重新啟動這台電腦上的 OpenCodex。正在執行的 Codex 請求會先完成,重新啟動期間新的請求可能在最多一分鐘內失敗。之後 Codex 會繼續使用相同的本機位址,由 Home 以它自己的供應商與帳戶提供服務。", "remoteLink.findHome.empty": "尚未連線到 Home。", "remoteLink.joining": "正在連線 Home…", "remoteLink.restart.title": "此電腦將重新啟動,以子裝置身分連線。", - "remoteLink.restart.body": "子裝置準備好後,控制面板會自動重新連線。", + "remoteLink.restart.body": "正在執行的 Codex 請求會先完成,接著 OpenCodex 以子裝置身分重新啟動。子裝置就緒後,此頁面會自動重新載入。", "remoteLink.restart.waiting": "正在等待此電腦以子裝置身分重新連線…", + "remoteLink.restart.slow": "重新啟動所需時間比平常久。此頁面會持續檢查,並在子裝置回應後自動重新載入。若 OpenCodex 已停止,請再次啟動。", "remoteLink.error.standalone_required": "只有獨立執行環境才能發起子裝置連線。", "remoteLink.error.join_tunnel_failed": "無法啟動通往 Home 的通道。請檢查 SSH 存取權限後重試。", "remoteLink.error.admission_failed": "Home 未接受新連線。請確認 Home 正在執行後重試。", @@ -3327,6 +3328,7 @@ export const zhTW: Record = { "remoteLink.error.join_rollback_failed": "加入失敗,且無法刪除主機上的連線。請重試清理,或在主機上執行 ocx link revoke。", "remoteLink.error.join_restart_failed": "連線已準備就緒。請在此電腦上重新啟動 OpenCodex,以完成作為子裝置的連線。", "remoteLink.error.join_port_failed": "無法完成遠端連線要求。", + "remoteLink.error.join_port_mismatch": "OpenCodex 未在其設定的連接埠上執行,因此無法以子裝置身分重新啟動。請在設定的連接埠上重新啟動 OpenCodex,然後再試一次。", "remoteLink.error.join_connect_failed": "無法完成遠端連線要求。", "link.noChildren": "沒有已連線的子裝置。", "remoteLink.status.connecting": "連線中", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 791c6bdd116..fd662bf3960 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3344,16 +3344,17 @@ 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", "remoteLink.findHome.connect": "以子设备身份连接", + "remoteLink.findHome.notice": "连接会重启这台电脑上的 OpenCodex。正在运行的 Codex 请求会先完成,重启期间新的请求可能在最多一分钟内失败。之后 Codex 继续使用相同的本地地址,由 Home 用它自己的提供商和账户提供服务。", "remoteLink.findHome.empty": "尚未连接到 Home。", "remoteLink.joining": "正在连接 Home…", "remoteLink.restart.title": "此电脑将重启,以子设备身份连接。", - "remoteLink.restart.body": "子设备准备就绪后,控制面板会自动重新连接。", + "remoteLink.restart.body": "正在运行的 Codex 请求会先完成,然后 OpenCodex 以子设备身份重启。子设备就绪后,此页面会自动重新加载。", "remoteLink.restart.waiting": "正在等待此电脑以子设备身份重新连接…", + "remoteLink.restart.slow": "重启所需时间比平常长。此页面会持续检查,并在子设备响应后自动重新加载。如果 OpenCodex 已停止,请重新启动它。", "remoteLink.error.standalone_required": "只有独立运行时才能发起子设备连接。", "remoteLink.error.join_tunnel_failed": "无法启动到 Home 的隧道。请检查 SSH 访问权限后重试。", "remoteLink.error.admission_failed": "Home 未接受新连接。请确认 Home 正在运行后重试。", @@ -3362,6 +3363,7 @@ export const zh: Record = { "remoteLink.error.join_rollback_failed": "加入失败,且无法删除主机上的连接。请重试清理,或在主机上运行 ocx link revoke。", "remoteLink.error.join_restart_failed": "连接已准备就绪。请在此电脑上重启 OpenCodex,以完成作为子设备的连接。", "remoteLink.error.join_port_failed": "无法完成远程连接请求。", + "remoteLink.error.join_port_mismatch": "OpenCodex 未在其配置的端口上运行,因此无法以子设备身份重启。请在配置的端口上重启 OpenCodex,然后重试。", "remoteLink.error.join_connect_failed": "无法完成远程连接请求。", "link.noChildren": "没有已连接的子设备。", "remoteLink.status.connecting": "连接中", diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index 1ba6945625d..ff20e06451f 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useEffectEvent, useRef, useState, type ReactElement } from "react"; import { - LinkApiError, parseRemoteLinkStatus, requestLinkJson, type LinkCandidateView, type LinkConfirmHostView, + LinkApiError, parseRemoteLinkStatus, readRuntimeHealth, requestLinkJson, waitForChildRuntime, type ChildRestartWaitDeps, type LinkCandidateView, type LinkConfirmHostView, type LinkErrorCode, type LinkProbeView, type LinkRowWire, type LinkWireState, type RemoteLinkStatusWire, } from "../remote-link-api"; import { IconLink, IconPlus, IconRefresh, IconTrash, IconX } from "../icons"; @@ -18,8 +18,16 @@ export interface RemoteLinkProps { sessionReady: boolean; workspaceAvailable?: boolean; onOpenWorkspace?: () => void; + /** Called once the Child runtime answers after a join; the page reloads as that runtime. */ + onChildReady?: () => void; + /** Test seam for the restart wait (fetch, clock, sleep). A caller passes one stable object. */ + restartWait?: Omit; } +// The served document carries the runtime role and a fresh session, so only a reload makes the +// dashboard the Child's. +function reloadDashboard(): void { window.location.reload(); } + const STATUS_LABEL: Record = { connecting: "remoteLink.status.connecting", connected: "remoteLink.status.connected", @@ -43,6 +51,7 @@ const ERROR_TKEY: Record = { join_in_progress: "remoteLink.error.join_in_progress", join_issue_failed: "remoteLink.error.join_issue_failed", join_port_failed: "remoteLink.error.join_port_failed", + join_port_mismatch: "remoteLink.error.join_port_mismatch", join_restart_failed: "remoteLink.error.join_restart_failed", join_rollback_failed: "remoteLink.error.join_rollback_failed", join_tunnel_failed: "remoteLink.error.join_tunnel_failed", @@ -112,7 +121,7 @@ function LinkErrorNotice({ error }: { error: LinkActionError }): ReactElement { return {t(error.key)}{error.hint && {t("remoteLink.reason.generic")} {error.hint}}; } -export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = false, onOpenWorkspace }: RemoteLinkProps): ReactElement { +export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = false, onOpenWorkspace, onChildReady = reloadDashboard, restartWait }: RemoteLinkProps): ReactElement { const t = useT(); const [uiState, setUiState] = useState("off"); const [role, setRole] = useState("home"); @@ -138,6 +147,10 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = const statusSequenceRef = useRef(0); const linkAttemptRef = useRef(null); const linkAttemptSequenceRef = useRef(0); + // The standalone's pid before the join; the Child runtime that replaces it has another one. + const restartPidRef = useRef(null); + const restartingRef = useRef(false); + const [restartSlow, setRestartSlow] = useState(false); const startLinkAttempt = useCallback((): LinkAttempt => { linkAttemptRef.current?.controller.abort(); @@ -161,7 +174,9 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = }, []); const refreshStatus = useCallback(async () => { - if (!sessionReady || document.visibilityState === "hidden") return; + // While this computer restarts into a Child, the draining standalone cannot answer; the + // /healthz wait below owns the page until it reloads. + if (!sessionReady || document.visibilityState === "hidden" || restartingRef.current) return; statusRequestRef.current?.controller.abort(); const controller = new AbortController(); const sequence = ++statusSequenceRef.current; @@ -173,10 +188,11 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = setStatusError(null); if (failedAction) { setUiState("failed"); + } else if (value.role === "child") { + // A Child shows its own link row; the tunnel state is on that row. + setUiState("connected"); } else if (value.links.length === 0) { - setUiState(current => value.role === "child" && current === "restart-waiting" - ? "connected" - : ["role-select", "adding-child", "confirming-host", "applying", "joining", "restart-waiting"].includes(current) ? current : "off"); + setUiState(current => ["role-select", "adding-child", "confirming-host", "applying", "joining", "restart-waiting"].includes(current) ? current : "off"); } else if (value.links.some(link => link.state === "failed")) setUiState("failed"); else if (value.links.some(link => link.state === "reconnecting")) setUiState("reconnecting"); else if (value.links.some(link => link.state === "connected")) setUiState("connected"); @@ -199,6 +215,16 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = return () => { window.clearInterval(poll); document.removeEventListener("visibilitychange", onVisibility); abortStatusRequest(); }; }, [abortStatusRequest, sessionReady]); + useEffect(() => { + if (uiState !== "restart-waiting") return; + const controller = new AbortController(); + // The wait keeps checking past the slow notice, so a late Child still brings the page back. + void waitForChildRuntime(apiBase, restartPidRef.current, controller.signal, { ...restartWait, onSlow: () => setRestartSlow(true) }).then(outcome => { + if (outcome === "ready") onChildReady(); + }); + return () => controller.abort(); + }, [apiBase, onChildReady, restartWait, uiState]); + useEffect(() => { if (!sheetOpen) { sheetRef.current?.close?.(); return; } const dialog = sheetRef.current; @@ -219,8 +245,8 @@ 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. + // The server offers the join to this dashboard session only on a standalone runtime that runs + // on its configured port, the one port the Child runtime can restart on. const childSelectable = standaloneRuntime && status?.joinAvailable === true; const openSheet = async () => { const attempt = startLinkAttempt(); @@ -289,9 +315,14 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = if (!value || !childSelectable) return; setBusy("join"); setActionError(null); setFailedAction(null); setUiState("joining"); try { + // The pid before the restart, so the wait below reloads only onto the new Child runtime. + restartPidRef.current = (await readRuntimeHealth(apiBase, attempt.controller.signal))?.pid ?? null; + if (!isCurrentLinkAttempt(attempt)) return; 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 }); if (!isCurrentLinkAttempt(attempt)) return; - closeSheet(); setUiState("restart-waiting"); + restartingRef.current = true; + abortStatusRequest(); + closeSheet(); setRestartSlow(false); setUiState("restart-waiting"); } catch (error) { if (!isCurrentLinkAttempt(attempt)) return; setActionError(linkActionError(error)); setFailedAction({ phase: "join", alias: value }); setUiState("failed"); @@ -348,12 +379,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")}}{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 && }
} + {uiState === "role-select" &&

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

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

{!standaloneRuntime && {t("remoteLink.childDisabled")}}{standaloneRuntime && status !== null && !status.joinAvailable && {t("remoteLink.error.join_port_mismatch")}}
} + {(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")}

{restartSlow && {t("remoteLink.restart.slow")}}
: 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 && }
+

{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 })}

{role === "child" && standaloneRuntime && {t("remoteLink.findHome.notice")}}
}{actionError && }
{ event.preventDefault(); closeConfirmation(); }}> diff --git a/gui/src/remote-link-api.ts b/gui/src/remote-link-api.ts index cd4398dd709..3f8ed4b3c99 100644 --- a/gui/src/remote-link-api.ts +++ b/gui/src/remote-link-api.ts @@ -14,6 +14,7 @@ export const LINK_ERROR_CODES = [ "join_in_progress", "join_issue_failed", "join_port_failed", + "join_port_mismatch", "join_restart_failed", "join_rollback_failed", "join_tunnel_failed", @@ -53,8 +54,9 @@ export interface RemoteLinkStatusWire { 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. + * Whether this dashboard session may join a Home as a Child: a dashboard session on a + * standalone runtime that listens on its configured port. The server omits the field for + * non-dashboard callers, read as false. */ joinAvailable: boolean; } @@ -141,3 +143,78 @@ export async function requestLinkJson(apiBase: string, path: string, init?: R const response = await fetch(`${apiBase}${path}`, { ...init, cache: "no-store" }); return readLinkJson(response); } + +/** The identity `/healthz` reports without a session: the runtime role and process id. */ +export interface RuntimeHealthView { role: string | null; pid: number | null } + +/** Read the serving runtime's `/healthz`. Never throws: no answer, or not OpenCodex, is null. */ +export async function readRuntimeHealth(apiBase: string, signal?: AbortSignal, fetchImpl: typeof fetch = fetch): Promise { + try { + const response = await fetchImpl(`${apiBase}/healthz`, { cache: "no-store", signal }); + if (!response.ok) return null; + const body: unknown = await response.json(); + if (!isRecord(body) || body.service !== "opencodex") return null; + return { role: typeof body.role === "string" ? body.role : null, pid: typeof body.pid === "number" ? body.pid : null }; + } catch { + return null; + } +} + +export const CHILD_RESTART_POLL_MS = 1_000; +/** The poll interval once the restart is slower than the server's own handoff budget. */ +export const CHILD_RESTART_SLOW_POLL_MS = 5_000; +/** + * The server's handoff budget plus a margin, the window `ocx restart` also observes: up to 60 s of + * drain (`MEMORY_DRAIN_RESTART_MS`), then up to 70 s for the replacement to answer + * (`REPLACEMENT_READY_TIMEOUT_MS`), plus 15 s. Past it the page says the restart is slow and keeps + * checking, so a Child that comes up late still reloads the page. + */ +export const CHILD_RESTART_NOTICE_MS = 145_000; +const CHILD_RESTART_PROBE_TIMEOUT_MS = 2_000; + +export interface ChildRestartWaitDeps { + fetchImpl?: typeof fetch; + now?: () => number; + sleep?: (ms: number, signal: AbortSignal) => Promise; + /** Called once when the wait passes `CHILD_RESTART_NOTICE_MS`; the wait goes on. */ + onSlow?: () => void; +} + +function sleepUnlessAborted(ms: number, signal: AbortSignal): Promise { + return new Promise(resolve => { + const timer = setTimeout(done, ms); + function done() { clearTimeout(timer); signal.removeEventListener("abort", done); resolve(); } + signal.addEventListener("abort", done, { once: true }); + }); +} + +/** + * Wait for the Child runtime that replaces this standalone after a join. It is ready when + * `/healthz` reports `role: "client"` under a pid other than the one read before the join; only + * then is reloading safe, because the draining parent keeps answering as standalone until it + * exits. One small unauthenticated read per second until `CHILD_RESTART_NOTICE_MS`, then one + * every 5 seconds, until the Child answers or the caller aborts. The wait never gives up by + * itself: a Child that is late, or restarted by its supervisor, still brings the page back. + */ +export async function waitForChildRuntime( + apiBase: string, + previousPid: number | null, + signal: AbortSignal, + deps: ChildRestartWaitDeps = {}, +): Promise<"ready" | "aborted"> { + const now = deps.now ?? Date.now; + const sleep = deps.sleep ?? sleepUnlessAborted; + const noticeAt = now() + CHILD_RESTART_NOTICE_MS; + let slow = false; + for (;;) { + if (signal.aborted) return "aborted"; + const health = await readRuntimeHealth(apiBase, AbortSignal.any([signal, AbortSignal.timeout(CHILD_RESTART_PROBE_TIMEOUT_MS)]), deps.fetchImpl); + if (signal.aborted) return "aborted"; + if (health?.role === "client" && (previousPid === null || health.pid !== previousPid)) return "ready"; + if (!slow && now() >= noticeAt) { + slow = true; + deps.onSlow?.(); + } + await sleep(slow ? CHILD_RESTART_SLOW_POLL_MS : CHILD_RESTART_POLL_MS, signal); + } +} diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index 65281866b35..649a56f359c 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -3,12 +3,12 @@ import { Window } from "happy-dom"; import { createRoot, type Root } from "react-dom/client"; import { act } from "react"; import RemoteLink from "../src/pages/RemoteLink"; -import { boundLinkHint, LINK_ERROR_CODES, LinkApiError, parseRemoteLinkStatus, readLinkJson, type RemoteLinkStatusWire } from "../src/remote-link-api"; +import { boundLinkHint, CHILD_RESTART_NOTICE_MS, CHILD_RESTART_POLL_MS, CHILD_RESTART_SLOW_POLL_MS, LINK_ERROR_CODES, LinkApiError, parseRemoteLinkStatus, readLinkJson, waitForChildRuntime, 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, joinAvailable: false }; -// A paired session on a standalone runtime: the only status that lets the dashboard join as a Child. +// A dashboard session on a standalone runtime that runs on its configured port may join as a Child. const joinableStatus: RemoteLinkStatusWire = { ...baseStatus, role: "standalone", joinAvailable: true }; let win: Window; let root: Root | null = null; @@ -31,6 +31,11 @@ afterEach(async () => { function response(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); } async function flush(): Promise { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); } +/** Let real timers and fetch stubs run until `done` holds, bounded so a regression fails instead of hanging. */ +async function settleUntil(done: () => boolean, limitMs = 2_000): Promise { + const deadline = Date.now() + limitMs; + while (!done() && Date.now() < deadline) await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); +} function declareRuntimeRole(role: "standalone" | "hub" | "client"): void { const meta = win.document.createElement("meta"); meta.name = "opencodex-runtime-role"; @@ -109,7 +114,7 @@ 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 () => { +test("a standalone off its configured port 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) => { @@ -127,10 +132,8 @@ test("local dashboard without join keeps Child disabled, explains why, and never 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).toContain("OpenCodex is not running on its configured port, so it cannot restart as a Child."); + expect(host.textContent).not.toContain("not available in this release"); expect(host.textContent).not.toContain("Child links can only be started from a standalone runtime."); await act(async () => { radios()[1]?.click(); }); @@ -161,19 +164,31 @@ test("local dashboard without join keeps Child disabled, explains why, and never 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 () => { +test("standalone Child flow warns first, joins the confirmed alias, and reloads only onto the new Child runtime", async () => { declareRuntimeRole("standalone"); const calls: Array<{ path: string; method: string; body?: string }> = []; + let joined = false; + let healthReads = 0; + let reloads = 0; globalThis.fetch = (async (input, init) => { const path = new URL(String(input)).pathname; calls.push({ path, method: init?.method ?? "GET", body: typeof init?.body === "string" ? init.body : undefined }); + if (path === "/healthz") { + healthReads += 1; + // The draining standalone keeps answering until it exits; only then does the Child answer. + if (!joined || healthReads < 3) return response({ service: "opencodex", pid: 100, port: 10100 }); + return response({ service: "opencodex", role: "client", pid: 200, port: 10100 }); + } 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.0.0" }); - if (path === "/api/link/join") return response({ linkId: "lnk_1234567890abcdef", alias: "home-one", restarting: true }, 202); + if (path === "/api/link/join") { joined = true; return response({ linkId: "lnk_1234567890abcdef", alias: "home-one", restarting: true }, 202); } return response(joinableStatus); }) as typeof fetch; - const host = await mount(); + // The poll interval is a gate the test opens, so it waits on the stub's answers, not on a wall-clock second. + const pollGate: { open: (() => void) | null } = { open: null }; + const restartWait = { sleep: (_ms: number, signal: AbortSignal) => new Promise(resolve => { pollGate.open = resolve; signal.addEventListener("abort", () => resolve(), { once: true }); }) }; + const host = await mount({ onChildReady: () => { reloads += 1; }, restartWait }); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); await act(async () => { ([...host.querySelectorAll('[role="radio"]')][1] as HTMLButtonElement).click(); }); await flush(); @@ -184,12 +199,154 @@ test("standalone Child flow joins with exactly the confirmed alias and shows res 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); + // The pre-join notice says what the restart costs before the button is pressed. + expect(host.textContent).toContain("Connecting restarts OpenCodex on this computer."); + expect(host.textContent).toContain("Codex keeps the same local address"); 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" })); + expect(calls.findIndex(call => call.path === "/healthz")).toBeLessThan(calls.findIndex(call => call.path === "/api/link/join")); expect(calls.some(call => call.path === "/api/link/apply")).toBe(false); expect(host.textContent).toContain("This computer will restart to connect as a Child."); + expect(host.textContent).toContain("This page reloads by itself when the Child is ready."); expect(host.textContent).toContain("Waiting for this computer to reconnect as Child"); + // The draining standalone's answer is ignored; the wait sleeps before the next read. + await settleUntil(() => pollGate.open !== null); + expect(reloads).toBe(0); + // The next read finds the Child under a new pid, and the page reloads once. + await act(async () => { pollGate.open?.(); }); + await settleUntil(() => reloads > 0); + expect(reloads).toBe(1); + expect(healthReads).toBe(3); + const statusReadsAfterJoin = calls.slice(calls.findIndex(call => call.path === "/api/link/join")).filter(call => call.path === "/api/link/status"); + expect(statusReadsAfterJoin).toEqual([]); +}); + +test("the restart wait reloads only for a client runtime with a new pid and keeps checking past the slow notice", async () => { + let clock = 0; + const sleeps: number[] = []; + const answers = [ + { service: "opencodex", pid: 100 }, + { service: "opencodex", role: "client", pid: 100 }, + { error: "not opencodex" }, + { service: "opencodex", role: "client", pid: 200 }, + ]; + const ready = await waitForChildRuntime("http://fixture", 100, new AbortController().signal, { + now: () => clock, + sleep: async ms => { sleeps.push(ms); clock += ms; }, + fetchImpl: (async () => response(answers.shift())) as typeof fetch, + }); + expect(ready).toBe("ready"); + expect(sleeps).toEqual([CHILD_RESTART_POLL_MS, CHILD_RESTART_POLL_MS, CHILD_RESTART_POLL_MS]); + + // Nothing answers through the server's whole handoff budget; the Child comes up later anyway. + // The notice fires once, the wait slows to one read every 5 seconds, and it still returns ready. + clock = 0; + sleeps.length = 0; + const noticedAt: number[] = []; + const late = await waitForChildRuntime("http://fixture", 100, new AbortController().signal, { + now: () => clock, + sleep: async ms => { sleeps.push(ms); clock += ms; }, + onSlow: () => { noticedAt.push(clock); }, + fetchImpl: (async () => { + if (clock < CHILD_RESTART_NOTICE_MS + 60_000) throw new TypeError("connection refused"); + return response({ service: "opencodex", role: "client", pid: 200 }); + }) as typeof fetch, + }); + expect(late).toBe("ready"); + expect(noticedAt).toEqual([CHILD_RESTART_NOTICE_MS]); + expect(sleeps.filter(ms => ms === CHILD_RESTART_POLL_MS)).toHaveLength(CHILD_RESTART_NOTICE_MS / CHILD_RESTART_POLL_MS); + expect(sleeps.filter(ms => ms === CHILD_RESTART_SLOW_POLL_MS)).toHaveLength(60_000 / CHILD_RESTART_SLOW_POLL_MS); + + const controller = new AbortController(); + controller.abort(); + expect(await waitForChildRuntime("http://fixture", 100, controller.signal)).toBe("aborted"); +}); + +test("the slow-restart notice waits out the server's drain and replacement budgets", async () => { + const { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } = await import("../../src/lib/system-restart-contract"); + expect(CHILD_RESTART_NOTICE_MS).toBeGreaterThan(MEMORY_DRAIN_RESTART_MS + REPLACEMENT_READY_TIMEOUT_MS); +}); + +test("a Child that answers after the slow notice still reloads the page", async () => { + declareRuntimeRole("standalone"); + let joined = false; + let clock = 0; + let reloads = 0; + const child: { release: (() => void) | null } = { release: null }; + const childGate = new Promise(resolve => { child.release = resolve; }); + const calls: string[] = []; + globalThis.fetch = (async input => { + const path = new URL(String(input)).pathname; + calls.push(path); + if (path === "/healthz") { + if (!joined) return response({ service: "opencodex", role: "standalone", pid: 100, port: 10100 }); + // Nothing answers through the notice; after it the Child is still starting and answers + // once the test lets it. + if (clock <= CHILD_RESTART_NOTICE_MS) throw new TypeError("connection refused"); + await childGate; + return response({ service: "opencodex", role: "client", pid: 200, port: 10100 }); + } + 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.0.0" }); + if (path === "/api/link/join") { joined = true; return response({ linkId: "lnk_1234567890abcdef", alias: "home-one", restarting: true }, 202); } + return response(joinableStatus); + }) as typeof fetch; + const restartWait = { now: () => clock, sleep: async (ms: number) => { clock += ms; } }; + const host = await mount({ onChildReady: () => { reloads += 1; }, restartWait }); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + await act(async () => { ([...host.querySelectorAll('[role="radio"]')][1] as HTMLButtonElement).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(); + 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(); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Connect as Child"))?.click(); }); + await settleUntil(() => host.textContent?.includes("Restarting is taking longer than usual.") ?? false); + expect(host.textContent).toContain("Restarting is taking longer than usual."); + expect(host.textContent).toContain("This page keeps checking and reloads by itself once the Child answers."); + expect(reloads).toBe(0); + await act(async () => { child.release?.(); }); + await settleUntil(() => reloads > 0); + expect(reloads).toBe(1); + expect(calls.slice(calls.indexOf("/api/link/join")).filter(path => path === "/api/link/status")).toEqual([]); +}); + +test("after the reload a Child shows its own link row instead of the off switch", async () => { + declareRuntimeRole("client"); + globalThis.fetch = (async () => response({ ...baseStatus, role: "child", listener: { state: "off", port: null }, child: { alias: "home-one", state: "connected", since: "now", reason: null } })) as typeof fetch; + const host = await mount(); + expect(host.querySelector('[role="switch"]')).toBeNull(); + expect(host.textContent).toContain("home-one"); + expect(host.textContent).toContain("Connected"); +}); + +test("join maps join_port_mismatch to guidance about the configured port", async () => { + declareRuntimeRole("standalone"); + globalThis.fetch = (async input => { + const path = new URL(String(input)).pathname; + 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.0.0" }); + if (path === "/api/link/join") return response({ error: { code: "join_port_mismatch" } }, 409); + return response(joinableStatus); + }) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + await act(async () => { ([...host.querySelectorAll('[role="radio"]')][1] as HTMLButtonElement).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(); + 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(); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Connect as Child"))?.click(); }); + await flush(); + expect(host.textContent).toContain("Restart OpenCodex on its configured port, then try again."); }); test("join failure maps actionable errors and Retry re-joins the confirmed alias", async () => { @@ -362,11 +519,12 @@ test("choosing Home then Continue opens the SSH host sheet with candidates", asy expect(host.querySelector(".remote-link-candidate")?.textContent).toContain("child-one"); }); -test("Continue stays disabled while this computer is already a Child", async () => { +test("a computer that is already a Child cannot start another link", 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); + expect(host.querySelector('[role="switch"]')).toBeNull(); + expect([...host.querySelectorAll("button")].some(button => button.textContent === "Continue")).toBe(false); + expect(([...host.querySelectorAll("button")].find(button => button.textContent?.includes("Add child")) as HTMLButtonElement).disabled).toBe(true); }); test("probe failure stays visible and Retry probes the failed alias", async () => { diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c10d490a8e3..5577490e07b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1927,6 +1927,7 @@ "client-link-runtime.test.ts": "clients", "link-routes.test.ts": "clients", "client-link-state.test.ts": "clients", + "client-link-status.test.ts": "clients", "client-link-teardown.test.ts": "clients", "client-link-tunnel.test.ts": "clients", "link-ports.test.ts": "clients", diff --git a/src/client/connect.ts b/src/client/connect.ts index dbcc2b3792f..cfa469ac3d5 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -22,6 +22,7 @@ import { injectCodexConfig, currentExternalCodexModelProvider, isCodexRoutingInjected, + standaloneCodexRoutingTarget, type CodexRoutingTarget, } from "../codex/inject"; import { @@ -44,6 +45,7 @@ import { import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download"; import type { OcxClientConnectionConfig, + OcxConfig, OcxConnectedClientId, } from "../types"; import { @@ -169,20 +171,31 @@ function catalogMatchesFingerprint(body: string, fingerprint: string | undefined return createHash("sha256").update(body).digest("base64url") === fingerprint; } -export function routingTarget(serverUrl: string, localPort?: number): CodexRoutingTarget & { link?: true } { - const baseUrl = localPort === undefined - ? `${serverUrl}/v1` - : (() => { - if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) { - throw new Error("link mode requires a valid local config port"); - } - return `http://localhost:${localPort}/v1`; - })(); +/** + * Codex routing for a connected client. A hub client points Codex at the hub with the admission + * token in `env_key`. A link Child (`localPort` given) keeps exactly the standalone loopback form, + * root `openai_base_url = "http://127.0.0.1:/v1"` with no provider table and no `env_key`: + * its own listener relays to the Home and attaches the link key itself, so a GUI-launched Codex + * that never saw the key's environment variable still works, and joining changes no Codex bytes. + */ +export function routingTarget( + serverUrl: string, + localPort?: number, + config?: Pick, +): CodexRoutingTarget & { link?: true } { + if (localPort === undefined) { + return { baseUrl: `${serverUrl}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }; + } + if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) { + throw new Error("link mode requires a valid local config port"); + } return { - baseUrl, - requiresAdmissionToken: true, - tokenEnv: "OPENCODEX_API_AUTH_TOKEN", - ...(localPort === undefined ? {} : { link: true as const }), + ...standaloneCodexRoutingTarget(localPort, { + hostname: "127.0.0.1", + codexDesktopAuthless: config?.codexDesktopAuthless, + codexClientCompaction: config?.codexClientCompaction, + }), + link: true as const, }; } @@ -655,7 +668,7 @@ export async function connectClient( }), deps.lifecycleLockDeps); const config = earlyConfig ?? loadConfig(); - const target = routingTarget(serverUrl, linkMode ? config.port : undefined); + const target = routingTarget(serverUrl, linkMode ? config.port : undefined, config); const injectConfig = { ...config, syncResumeHistory: false }; const preflight = await injectCodexConfig(config.port, injectConfig, { validateOnly: true, @@ -796,7 +809,7 @@ export async function syncConnectedClient( if (next.selectedClients.includes("codex")) { const config = loadConfig(); const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, { - routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined), catalogPath: DEFAULT_CATALOG_PATH, + routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH, journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite, }); if (!result.success || result.status === "skipped") throw new Error(result.message); diff --git a/src/client/link-ingress.ts b/src/client/link-ingress.ts new file mode 100644 index 00000000000..d112bb6dbab --- /dev/null +++ b/src/client/link-ingress.ts @@ -0,0 +1,102 @@ +import type { Server } from "bun"; +import { formatErrorResponse } from "../bridge"; +import { readServiceApiTokenState, type ServiceApiTokenState } from "../lib/service-secrets"; +import { linkRouteAllowed } from "../link/routes"; +import { isAllowedRequestOrigin, type RequestPolicyView } from "../server/auth-cors"; +import { relayLinkDataRequest, type LinkRelayDeps } from "./link-relay"; + +/** How often an unavailable link key is looked for again. A valid key is never re-read. */ +export const LINK_KEY_RETRY_MS = 1_000; + +export interface LinkKeySourceDeps { + readToken?: () => ServiceApiTokenState; + now?: () => number; +} + +/** + * The link key for relayed requests, read from the service token file once and then held in + * memory, so no data-plane request touches the disk. The file must still hold the key this + * connection committed (`tokenFingerprint`); anything else yields null. Only while the key is + * unavailable is the file read again, at most once per `LINK_KEY_RETRY_MS`. The key is never + * logged, returned in a response, or written anywhere. + */ +export function createLinkKeySource(expectedFingerprint: string, deps: LinkKeySourceDeps = {}): () => string | null { + const readToken = deps.readToken ?? readServiceApiTokenState; + const now = deps.now ?? Date.now; + let key: string | null = null; + let lastRead = 0; + const load = (): void => { + lastRead = now(); + try { + const state = readToken(); + key = state.kind === "present" && state.fingerprint === expectedFingerprint ? state.token : null; + } catch { + key = null; + } + }; + load(); + return () => { + if (key === null && now() - lastRead >= LINK_KEY_RETRY_MS) load(); + return key; + }; +} + +export interface LinkIngress { + tunnelPort: number; + /** The listener's loopback policy: the standalone Host and Origin anti-rebinding gate. */ + policy: RequestPolicyView; + linkKey: () => string | null; + relay?: LinkRelayDeps; +} + +const RESPONSES_WEBSOCKET_DISABLED = "Responses WebSocket transport is disabled; use HTTP"; + +/** + * Lift Bun's per-request idle timer for a relayed request, as the standalone does on its data + * routes (`disableResponsesRequestTimeout`). Without it the listener's idle limit cuts a quiet + * stretch of a long generation: an SSE gap between events, or a Home that holds a turn after + * its headers. The relay bounds the wait itself (header deadline, SSE idle limit, caller abort). + * It is a local call rather than the standalone helper's import, which would load the Responses + * WebSocket upstream modules into every Child. + */ +function liftRequestIdleTimer(req: Request, server: Pick, "timeout"> | undefined): void { + if (!server) return; + try { server.timeout(req, 0); } catch { /* the request may already be closed */ } +} + +/** + * The link-mode data plane of the client machine listener. It answers only the requests it + * owns and returns null for everything else, which the listener handles as before. + * + * - A `/v1/responses` WebSocket upgrade gets 426, the answer codex-rs turns into an HTTP + * fallback; the link never carries a WebSocket. + * - Every relayed route passes the standalone loopback Host/Origin gate before any upstream + * fetch, so a rebinding or cross-site browser page cannot reach the Home through this port. + * - Without the committed link key nothing is fetched, and the caller gets + * `503 link_credential_unavailable`. + * - `/readyz` stays local: it reports this listener, not the tunnel. + * - A relayed request is exempt from the listener's idle limit, like a standalone data route. + */ +export function handleLinkIngress( + req: Request, + url: URL, + ingress: LinkIngress, + server?: Pick, "timeout">, +): Response | Promise | null { + const upgrade = req.headers.get("upgrade"); + if (upgrade !== null) { + if (url.pathname !== "/v1/responses" || upgrade.trim().toLowerCase() !== "websocket") return null; + if (!isAllowedRequestOrigin(req, ingress.policy)) { + return formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"); + } + return formatErrorResponse(426, "upgrade_required", RESPONSES_WEBSOCKET_DISABLED); + } + if (url.pathname === "/readyz" || !linkRouteAllowed(url, req)) return null; + if (!isAllowedRequestOrigin(req, ingress.policy)) { + return formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"); + } + const admissionKey = ingress.linkKey(); + if (!admissionKey) return Response.json({ error: "link_credential_unavailable" }, { status: 503 }); + liftRequestIdleTimer(req, server); + return relayLinkDataRequest(req, { tunnelPort: ingress.tunnelPort, admissionKey }, ingress.relay); +} diff --git a/src/client/link-relay.ts b/src/client/link-relay.ts index 147a9b250a9..683e9106b02 100644 --- a/src/client/link-relay.ts +++ b/src/client/link-relay.ts @@ -2,17 +2,19 @@ import { boundedRelayResponseStream, filterRelayHeaders, headersWithinLimit, - HUB_RELAY_DEFAULT_TIMEOUT_MS, - HUB_RELAY_REQUEST_BODY_MAX_BYTES, - HUB_RELAY_RESPONSE_BODY_MAX_BYTES, - readBoundedRelayRequestBody, validateHubRelayRequestHeaders, } from "./hub-relay"; import { linkRouteAllowed } from "../link/routes"; import { isLinkPort } from "../link/ports"; +import { resolveInboundBodyLimitBytes } from "../server/request-decompress"; export interface LinkRelayTarget { tunnelPort: number; + /** + * The stored link key. The relay sends it in place of every caller credential, so the Home + * admits the request as this link and serves it with its own accounts. + */ + admissionKey: string; } export interface LinkRelayClock { @@ -23,12 +25,22 @@ export interface LinkRelayClock { export interface LinkRelayDeps { fetchImpl?: typeof fetch; clock?: LinkRelayClock; - timeoutMs?: number; + /** Time allowed for the Home's response headers; a caller abort still ends the wait sooner. */ + headerTimeoutMs?: number; sseIdleTimeoutMs?: number; + /** Byte cap for the streamed request body and for a non-SSE response body. */ + bodyLimitBytes?: number; } export const LINK_RELAY_RETRY_AFTER_SECONDS = 1; export const LINK_RELAY_SSE_IDLE_TIMEOUT_MS = 300_000; +/** + * The Home may hold a turn (remote compaction, a slow first byte) far past the management + * relay's 15 s, so the data plane waits for response headers as long as its SSE idle limit. + */ +export const LINK_RELAY_HEADER_TIMEOUT_MS = 300_000; +/** The data-plane default: the same inbound limit a standalone listener admits. */ +export const LINK_RELAY_BODY_MAX_BYTES = resolveInboundBodyLimitBytes(undefined); const defaultClock: LinkRelayClock = { setTimeout: globalThis.setTimeout, @@ -36,24 +48,59 @@ const defaultClock: LinkRelayClock = { }; const REQUEST_OMITTED_HEADERS = new Set(["content-length", "host"]); const RESPONSE_OMITTED_HEADERS = new Set(["content-encoding", "content-length"]); +/** + * Caller credentials never cross the tunnel. The Child's own ChatGPT or Anthropic credential + * stays on the Child, and the Home sees exactly one admission: the link key. + */ +const CALLER_CREDENTIAL_HEADERS = ["authorization", "x-api-key", "x-opencodex-api-key", "chatgpt-account-id", "cookie"] as const; function jsonError(status: number, error: string, retry = false): Response { const headers = retry ? { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) } : undefined; return Response.json({ error }, { status, headers }); } -export function linkRelayDestination(url: URL, target: LinkRelayTarget): string { +export function linkRelayDestination(url: URL, target: Pick): string { if (!isLinkPort(target.tunnelPort)) { throw new RangeError("invalid link tunnel port"); } return `http://127.0.0.1:${target.tunnelPort}${url.pathname}${url.search}`; } -export function forwardLinkRequestHeaders(source: Headers): Headers { - const validation = validateHubRelayRequestHeaders([...source]); +/** + * The caller's headers for the framing check. The listener's HTTP parser has already de-chunked + * the body, and the relay re-frames it from the stream, so a lone `Transfer-Encoding: chunked` + * with no Content-Length is admitted, as a standalone admits it. Any other Transfer-Encoding, or + * one next to a Content-Length, stays in the list and is refused as ambiguous framing. + */ +function linkFramingHeaders(source: Headers): Array<[string, string]> { + const raw = [...source]; + const transferEncoding = source.get("transfer-encoding"); + if (transferEncoding === null || transferEncoding.trim().toLowerCase() !== "chunked" || source.has("content-length")) return raw; + return raw.filter(([name]) => name.toLowerCase() !== "transfer-encoding"); +} + +/** + * The headers sent to the Home: hop-by-hop, Connection-nominated and caller credential headers + * dropped, then the link key attached. `GET /v1/usage` admits only the dedicated header; every + * other link route takes the key as a Bearer, the same wire an `env_key` Codex config sent. + */ +export function forwardLinkRequestHeaders(source: Headers, admissionKey: string, pathname: string): Headers { + const validation = validateHubRelayRequestHeaders(linkFramingHeaders(source)); if (!validation.ok) return new Headers(); - const omitted = new Set([...REQUEST_OMITTED_HEADERS, ...validation.connectionNamed]); - return filterRelayHeaders(source, undefined, omitted); + return linkRequestHeaders(source, admissionKey, pathname, validation.connectionNamed); +} + +function linkRequestHeaders( + source: Headers, + admissionKey: string, + pathname: string, + connectionNamed: ReadonlySet, +): Headers { + const omitted = new Set([...REQUEST_OMITTED_HEADERS, ...CALLER_CREDENTIAL_HEADERS, ...connectionNamed]); + const headers = filterRelayHeaders(source, undefined, omitted); + if (pathname === "/v1/usage") headers.set("x-opencodex-api-key", admissionKey); + else headers.set("authorization", `Bearer ${admissionKey}`); + return headers; } export function sanitizeLinkResponseHeaders(source: Headers): Headers { @@ -66,6 +113,49 @@ function isSse(headers: Headers): boolean { return headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream"; } +function positive(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined; +} + +/** + * The caller's body streamed through unchanged while its bytes are counted. Nothing is + * buffered: each chunk goes to the upstream as it arrives, and crossing `limit` errors the + * stream, which fails the upstream fetch. + */ +function byteCappedRequestBody( + body: ReadableStream, + limit: number, + onOverflow: () => void, +): ReadableStream { + const reader = body.getReader(); + let bytes = 0; + return new ReadableStream({ + async pull(controller) { + try { + const next = await reader.read(); + if (next.done) { + controller.close(); + return; + } + bytes += next.value.byteLength; + if (bytes > limit) { + const error = new RangeError("link relay request body too large"); + onOverflow(); + try { await reader.cancel(error); } catch { /* best effort */ } + controller.error(error); + return; + } + controller.enqueue(next.value); + } catch (error) { + controller.error(error); + } + }, + async cancel(reason) { + try { await reader.cancel(reason); } catch { /* best effort */ } + }, + }); +} + function idleBoundedStream( body: ReadableStream, signal: AbortSignal, @@ -159,37 +249,46 @@ export async function relayLinkDataRequest( if (!linkRouteAllowed(url, req)) return jsonError(404, "not_found"); let destination: string; try { destination = linkRelayDestination(url, target); } catch { return jsonError(404, "not_found"); } - const validation = validateHubRelayRequestHeaders([...req.headers]); + const validation = validateHubRelayRequestHeaders(linkFramingHeaders(req.headers)); if (!validation.ok) return jsonError(400, "link relay request headers refused"); - let body: Uint8Array | null; - try { - body = req.method === "GET" || req.method === "HEAD" - ? null - : await readBoundedRelayRequestBody(req.body, req.headers.get("content-length"), HUB_RELAY_REQUEST_BODY_MAX_BYTES); - } catch { + const bodyLimit = positive(deps.bodyLimitBytes) ?? LINK_RELAY_BODY_MAX_BYTES; + // The check admits at most one all-digit Content-Length, and Transfer-Encoding only as a lone + // `chunked` without one; the byte cap below bounds a chunked body instead. + const declaredLength = req.headers.get("content-length")?.trim() ?? null; + if (declaredLength !== null && Number(declaredLength) > bodyLimit) { return jsonError(413, "link relay request body too large"); } - const headers = forwardLinkRequestHeaders(req.headers); + const headers = linkRequestHeaders(req.headers, target.admissionKey, url.pathname, validation.connectionNamed); if (!headersWithinLimit(headers)) { return jsonError(431, "link relay request headers too large"); } const relayAbort = new AbortController(); - const timeoutMs = typeof deps.timeoutMs === "number" && Number.isFinite(deps.timeoutMs) && deps.timeoutMs > 0 - ? Math.min(Math.floor(deps.timeoutMs), 120_000) - : HUB_RELAY_DEFAULT_TIMEOUT_MS; - const timeoutSignal = AbortSignal.timeout(timeoutMs); - const onTimeout = () => relayAbort.abort(timeoutSignal.reason); + const clock = deps.clock ?? defaultClock; + let headerTimer: ReturnType | undefined = clock.setTimeout(() => { + headerTimer = undefined; + relayAbort.abort(new DOMException("link relay header deadline", "TimeoutError")); + }, positive(deps.headerTimeoutMs) ?? LINK_RELAY_HEADER_TIMEOUT_MS); + const stopHeaderDeadline = () => { + if (headerTimer !== undefined) clock.clearTimeout(headerTimer); + headerTimer = undefined; + }; const onClientAbort = () => relayAbort.abort(req.signal.reason); - timeoutSignal.addEventListener("abort", onTimeout, { once: true }); req.signal.addEventListener("abort", onClientAbort, { once: true }); const cleanup = () => { - timeoutSignal.removeEventListener("abort", onTimeout); + stopHeaderDeadline(); req.signal.removeEventListener("abort", onClientAbort); }; if (req.signal.aborted) onClientAbort(); - else if (timeoutSignal.aborted) onTimeout(); + + let bodyOverflow = false; + const body = req.method === "GET" || req.method === "HEAD" || !req.body + ? null + : byteCappedRequestBody(req.body, bodyLimit, () => { bodyOverflow = true; }); + // A streamed body keeps the caller's Content-Length, so the Home sees the same framing a + // buffered body produced instead of a chunked upload. + if (body && declaredLength !== null) headers.set("content-length", declaredLength); let upstream: Response; try { @@ -203,7 +302,9 @@ export async function relayLinkDataRequest( upstream = await (deps.fetchImpl ?? fetch)(destination, init); } catch { cleanup(); - return jsonError(503, "link tunnel unavailable", true); + return bodyOverflow + ? jsonError(413, "link relay request body too large") + : jsonError(503, "link tunnel unavailable", true); } if (relayAbort.signal.aborted) { cleanup(); @@ -218,9 +319,9 @@ export async function relayLinkDataRequest( try { await upstream.body?.cancel(); } catch { /* best effort */ } return jsonError(502, "link relay response headers too large"); } - const declaredLength = upstream.headers.get("content-length"); - if (!sse && declaredLength !== null && (!/^\d+$/.test(declaredLength) - || Number(declaredLength) > HUB_RELAY_RESPONSE_BODY_MAX_BYTES)) { + const responseLength = upstream.headers.get("content-length"); + if (!sse && responseLength !== null && (!/^\d+$/.test(responseLength) + || Number(responseLength) > bodyLimit)) { cleanup(); try { await upstream.body?.cancel(); } catch { /* best effort */ } return jsonError(502, "link relay response body too large"); @@ -231,11 +332,11 @@ export async function relayLinkDataRequest( return new Response(null, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders }); } - // The handshake deadline ends once a response exists. The body owns cleanup after that. - timeoutSignal.removeEventListener("abort", onTimeout); + // The header deadline ends once a response exists. The body owns cleanup after that. + stopHeaderDeadline(); const responseBody = sse - ? idleBoundedStream(upstream.body, relayAbort.signal, deps.clock ?? defaultClock, + ? idleBoundedStream(upstream.body, relayAbort.signal, clock, deps.sseIdleTimeoutMs ?? LINK_RELAY_SSE_IDLE_TIMEOUT_MS, () => relayAbort.abort(new DOMException("link relay SSE idle timeout", "TimeoutError")), cleanup) - : boundedRelayResponseStream(upstream.body, HUB_RELAY_RESPONSE_BODY_MAX_BYTES, relayAbort.signal, cleanup); + : boundedRelayResponseStream(upstream.body, bodyLimit, relayAbort.signal, cleanup); return new Response(responseBody, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders }); } diff --git a/src/client/link-status.ts b/src/client/link-status.ts new file mode 100644 index 00000000000..a3245dee9cb --- /dev/null +++ b/src/client/link-status.ts @@ -0,0 +1,36 @@ +import type { LinkStatusDto } from "../link/status-projection"; +import type { ClientLinkState } from "./link-state"; +import type { ClientLinkSupervisorStatus } from "./link-tunnel"; + +/** What the status route read from the client sidecar: the state, none, or an unreadable file. */ +export type ClientLinkSidecarRead = ClientLinkState | null | "invalid"; + +/** + * The K16 status document of a connected Child, built from its own sidecar and tunnel + * supervisor. A client-initiated Child has no `links.json` record, so the Home-side projection + * (`projectLinkStatus`) cannot describe it; this one runs in the Child's own listener instead. + * A Home-initiated Child has no sidecar and reports `child: null`. + */ +export function projectClientLinkChild( + sidecar: ClientLinkSidecarRead, + supervisor: ClientLinkSupervisorStatus, + now: number, +): LinkStatusDto { + return { role: "child", listener: { state: "off", port: null }, links: [], child: childState(sidecar, supervisor, now) }; +} + +function childState(sidecar: ClientLinkSidecarRead, supervisor: ClientLinkSupervisorStatus, now: number): LinkStatusDto["child"] { + const alias = sidecar && sidecar !== "invalid" ? sidecar.alias : "unknown"; + if (sidecar === "invalid" || supervisor.kind === "failed") { + return { alias, state: "failed", since: new Date(now).toISOString(), reason: "sidecar_invalid" }; + } + if (!sidecar) return null; + if (supervisor.kind === "stopped") return { alias, state: "idle", since: new Date(now).toISOString(), reason: null }; + const tunnel = supervisor.state; + return { + alias, + state: tunnel.kind, + since: new Date(tunnel.kind === "idle" ? now : tunnel.since).toISOString(), + reason: tunnel.kind === "failed" ? tunnel.reason : null, + }; +} diff --git a/src/client/machine-api.ts b/src/client/machine-api.ts index 593c3bf8df9..4faae68bfbb 100644 --- a/src/client/machine-api.ts +++ b/src/client/machine-api.ts @@ -3,6 +3,7 @@ import { diagnoseCodexShim, installCodexShim, uninstallCodexShim } from "../code import { readManagementJsonBody } from "../server/management/body"; import type { OcxClientConnectionConfig } from "../types"; import { disconnectClient, syncConnectedClient } from "./connect"; +import { isLinkConnection } from "./state"; export type HubReachability = "unknown" | "online" | "offline" | "unauthorized"; @@ -50,14 +51,19 @@ async function jsonBody(req: Request): Promise { function statusPayload(req: Request, state: OcxClientConnectionConfig, deps: MachineApiDeps): MachineStatusV1 { const machineBase = new URL(req.url).origin; + // A link Child's management URL is the tunnel, whose hub-link ingress serves no /api/* and no + // session bootstrap. Its dashboard reads the Child's own listener instead (`/api/link/status`). + const linkMode = isLinkConnection(state); return { mode: "client", connected: true, machineBase, - sharedBase: state.managementTransport === "relay" - ? `${machineBase}/api/machine/hub-relay` - : state.managementUrl, - sharedServerOrigin: state.managementUrl, + sharedBase: linkMode + ? machineBase + : state.managementTransport === "relay" + ? `${machineBase}/api/machine/hub-relay` + : state.managementUrl, + sharedServerOrigin: linkMode ? machineBase : state.managementUrl, managementTransport: state.managementTransport, apiKeyId: state.apiKeyId, protocolVersion: state.protocolVersion, diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts index 9e1717e29de..093fa6d7a71 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -1,6 +1,6 @@ import type { Server } from "bun"; import { loadConfig } from "../config"; -import { browserSecurityHeaders } from "../server/auth-cors"; +import { browserSecurityHeaders, requestPolicyView } from "../server/auth-cors"; import { serveGuiFile, serveSessionBootstrap } from "../server/gui-static"; import { initializeManagementAuthState, @@ -9,13 +9,17 @@ import { requireManagementAuth, type ManagementAuthState, } from "../server/management-auth"; +import { resolveInboundBodyLimitBytes } from "../server/request-decompress"; import type { OcxClientConnectionConfig, OcxConfig } from "../types"; import { disconnectClient, syncConnectedClient } from "./connect"; import { isLinkConnection, readClientConnectionState } from "./state"; import { handleMachineApi, type HubReachability, type MachineApiDeps } from "./machine-api"; import { MACHINE_GUI_ORIGIN_HEADER, requireMachineAuth } from "./machine-auth"; import { HUB_RELAY_REQUEST_BODY_MAX_BYTES, relayHubManagementRequest } from "./hub-relay"; -import { relayLinkDataRequest } from "./link-relay"; +import { createLinkKeySource, handleLinkIngress, type LinkIngress, type LinkKeySourceDeps } from "./link-ingress"; +import { readClientLinkState, type ClientLinkState } from "./link-state"; +import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-status"; +import type { ClientLinkSupervisorStatus } from "./link-tunnel"; import { packageVersion } from "../lib/package-version"; import { linkRouteAllowed } from "../link/routes"; @@ -24,12 +28,23 @@ const GUI_SPA_PATHS = new Set([ "/dashboard", "/startup", "/providers", "/models", "/subagents", "/logs", "/usage", "/storage", "/codex-set", "/integrations", ]); +/** The standalone listener's idle limit: long generations and held turns are never cut. */ +const LINK_LISTENER_IDLE_TIMEOUT_SECONDS = 255; export interface MachineListenerDeps { state?: OcxClientConnectionConfig; managementAuthState?: ManagementAuthState; fetchImpl?: typeof fetch; machineApi?: Partial; + /** Link mode: the client tunnel supervisor state for `GET /api/link/status`. */ + linkStatus?: () => ClientLinkSupervisorStatus; + /** Link mode: the sidecar read for `GET /api/link/status`. */ + readSidecar?: () => ClientLinkState | null; + /** Link mode: how the link key is read; the listener reads it once and caches it. */ + linkKey?: LinkKeySourceDeps; + /** Link mode: relay seams (deadline clock and byte cap). */ + linkRelay?: LinkIngress["relay"]; + serve?: (options: Parameters[0]) => Server; } function json404(req: Request): Response { @@ -47,6 +62,8 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea const path = url.pathname; if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true; if ((req.method === "GET" || req.method === "HEAD") && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true; + // The one link route a connected Child serves: its own read-only link status. + if (linkMode && (req.method === "GET" || req.method === "HEAD") && path === "/api/link/status") return true; if (req.method === "POST" && (path === "/api/machine/sync" || path === "/api/machine/shim" || path === "/api/machine/disconnect")) return true; if (relayEnabled && path.startsWith("/api/machine/hub-relay/")) return true; // Known machine endpoints are admitted for every method so an unsupported @@ -60,6 +77,10 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea || /\.(?:css|gif|ico|jpe?g|js|json|map|png|svg|webp|woff2?)$/i.test(path); } +function readSidecarSafely(read: () => ClientLinkState | null): ClientLinkSidecarRead { + try { return read(); } catch { return "invalid"; } +} + export function startMachineListener( port?: number, deps: MachineListenerDeps = {}, @@ -84,17 +105,33 @@ export function startMachineListener( setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }), }; const relayEnabled = !linkMode && connection.managementTransport === "relay"; + // Link mode only. Everything is resolved once here, so a relayed request reads no file and + // builds no policy: the key is cached, the loopback policy is fixed at bind like the listener. + const inboundBodyLimit = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes); + const linkIngress: LinkIngress | null = linkMode + ? { + tunnelPort: connection.link!.tunnelPort, + policy: requestPolicyView(config, "127.0.0.1"), + linkKey: createLinkKeySource(connection.tokenFingerprint, deps.linkKey), + relay: { fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, ...deps.linkRelay }, + } + : null; + const readSidecar = deps.readSidecar ?? (() => readClientLinkState()); - return Bun.serve({ + return (deps.serve ?? (options => Bun.serve(options)))({ port: port ?? config.port ?? 10100, hostname: "127.0.0.1", - maxRequestBodySize: HUB_RELAY_REQUEST_BODY_MAX_BYTES, - async fetch(req, server) { + // A hub client relays only bounded management calls. A link Child carries the Codex data + // plane, so it admits what a standalone listener admits and keeps its idle limit. + maxRequestBodySize: linkMode ? inboundBodyLimit : HUB_RELAY_REQUEST_BODY_MAX_BYTES, + ...(linkMode ? { idleTimeout: LINK_LISTENER_IDLE_TIMEOUT_SECONDS } : {}), + async fetch(req: Request, server: Server) { const url = new URL(req.url); - if (!machineRouteAllowed(url, req, relayEnabled, linkMode)) return json404(req); - if (linkMode && linkRouteAllowed(url, req)) { - return relayLinkDataRequest(req, { tunnelPort: connection.link!.tunnelPort }, { fetchImpl: deps.fetchImpl }); + if (linkIngress) { + const handled = handleLinkIngress(req, url, linkIngress, server); + if (handled) return handled; } + if (!machineRouteAllowed(url, req, relayEnabled, linkMode)) return json404(req); if (url.pathname === "/healthz" && req.method === "GET") { return Response.json({ service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port }); } @@ -116,7 +153,7 @@ export function startMachineListener( else hubReachability = "online"; return response; } - if (url.pathname.startsWith("/api/machine/")) { + if (url.pathname.startsWith("/api/machine/") || (linkMode && url.pathname === "/api/link/status")) { const authError = requireManagementAuth(req, managementAuth, config); if (authError) return authError; if (managementPrincipal(req, managementAuth, config) !== "gui-session") { @@ -130,6 +167,11 @@ export function startMachineListener( if (req.method !== "GET" && req.method !== "HEAD") { return Response.json({ error: "opencodex machine changes require the local CLI" }, { status: 403 }); } + if (url.pathname === "/api/link/status") { + // A Child never joins again from here, so the dashboard reads `joinAvailable: false`. + const status = projectClientLinkChild(readSidecarSafely(readSidecar), deps.linkStatus?.() ?? { kind: "stopped" }, Date.now()); + return Response.json({ ...status, joinAvailable: false }, { headers: { "Cache-Control": "no-store" } }); + } return await handleMachineApi(req, url, connection, machineApiDeps) ?? json404(req); } @@ -158,5 +200,5 @@ export function startMachineListener( } return json404(req); }, - }); + } as Parameters[0]); } diff --git a/src/client/runtime.ts b/src/client/runtime.ts index ce3d726a8b2..ed7079d7917 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -121,15 +121,20 @@ export async function startClientRuntime( } throw error; } - const server = startMachineListener(port, { state: state.value }); - const boundPort = server.port ?? port; - activeServer = server; - activePort = boundPort; + // Created before the listener so its status route can read the tunnel state; it starts no + // process and no timer until start(). const supervisor = linkMode && existsSync(clientLinkStatePath()) ? createClientLinkSupervisor({ onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint), }) : null; + const server = startMachineListener(port, { + state: state.value, + ...(linkMode ? { linkStatus: () => supervisor?.status() ?? { kind: "stopped" as const } } : {}), + }); + const boundPort = server.port ?? port; + activeServer = server; + activePort = boundPort; activeSupervisor = supervisor; supervisor?.start(); installCrashGuards(); diff --git a/src/server/index/link-listener.ts b/src/server/index/link-listener.ts index c6d9c017c08..cdec0e00309 100644 --- a/src/server/index/link-listener.ts +++ b/src/server/index/link-listener.ts @@ -93,6 +93,9 @@ export function createLinkListenerLifecycle(deps: LinkListenerDeps = {}): Lin bound = serve({ hostname: "127.0.0.1", port: requestedPort, + // The public listener's idle limit (serve-options.ts). Bun's 10 s default would cut a + // relayed turn that the Home holds or that streams with a long gap. + idleTimeout: 255, maxRequestBodySize: startContext.maxRequestBodySize, fetch: (req: Request, server: Server) => startContext!.dispatch(req, server as Server), } as Parameters[0]); diff --git a/src/server/management/context.ts b/src/server/management/context.ts index c9f88230b65..0a515b78104 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -154,6 +154,8 @@ export interface ManagementApiDeps { issueApiKey?: (config: OcxConfig, name: string) => IssuedApiKey; revokeApiKey?: (config: OcxConfig, id: string) => boolean; loadLinkCandidates?: () => Array<{ alias: string; source: "ssh_config" | "tailscale" }>; + /** The port this runtime listens on; a join is refused unless it is the configured port. */ + liveListenPort?: () => number | undefined; now?: () => number; } diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index b9642cfebdf..5ef6916a200 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -17,7 +17,7 @@ import { clientLinkTunnelStatus } from "../../client/link-tunnel"; import type { ManagementContext } from "./context"; import { readManagementJsonBodyOr } from "./body"; import { issueApiKeyInProcess, revokeApiKeyInProcess, type IssuedApiKey } from "./oauth-account-routes"; -import { acceptSystemRestart } from "./system-restart"; +import { acceptSystemRestart, resolveListenPort } from "./system-restart"; const PROBE_TTL_MS = 5 * 60_000; const APPLY_ADMISSION_TIMEOUT_MS = 15_000; @@ -86,7 +86,7 @@ function port(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; } -/** A paired GUI session. `POST /api/link/join` admits only this. */ +/** A paired GUI session. Only hub runtimes issue these. */ function pairedSession(ctx: ManagementContext): boolean { return ctx.principal === "gui-session" && ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true; @@ -96,8 +96,9 @@ function pairedSession(ctx: ManagementContext): boolean { * 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. + * secret-backed boundary like the admin token. Hubs keep the paired-only rule. Join admits this + * session too: turning a Child on from its own dashboard is the point of the route, and the + * dashboard warns first that the restart briefly interrupts running Codex turns. */ function dashboardSession(ctx: ManagementContext): boolean { if (pairedSession(ctx)) return true; @@ -112,18 +113,27 @@ function adminLoopback(ctx: ManagementContext): boolean { return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress; } -function auth(ctx: ManagementContext, kind: "paired" | "dashboard" | "admin" | "either"): Response | null { +function auth(ctx: ManagementContext, kind: "dashboard" | "admin" | "either"): Response | null { if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403); - const allowed = kind === "paired" ? pairedSession(ctx) - : kind === "dashboard" ? dashboardSession(ctx) - : kind === "admin" ? adminLoopback(ctx) - : dashboardSession(ctx) || adminLoopback(ctx); + const allowed = 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. */ +/** + * The client runtime a join restarts into binds exactly the configured port, with no fallback. + * A standalone that runs elsewhere (`ocx start --port`, or a port fallback) would restart into a + * proxy that cannot bind where Codex is routed, so it may not join. + */ +function joinPortMatches(ctx: ManagementContext): boolean { + const live = (ctx.deps.liveListenPort ?? resolveListenPort)(); + return live !== undefined && live === ctx.config.port; +} + +/** Whether `POST /api/link/join` would pass its admission, role and port gates for this caller. */ function joinAvailable(ctx: ManagementContext): boolean { - return pairedSession(ctx) && (ctx.config.runtimeRole ?? "standalone") === "standalone"; + return dashboardSession(ctx) && (ctx.config.runtimeRole ?? "standalone") === "standalone" && joinPortMatches(ctx); } function runnerFor(ctx: ManagementContext): SshRunner { @@ -538,10 +548,12 @@ 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") { - // Paired only: a loopback dashboard session may run the Home side, never this restart. - const denied = auth(ctx, "paired"); + // The same dashboard admission as the Home side. Every refusal below runs before link state + // is read and before any SSH. + const denied = auth(ctx, "dashboard"); if (denied) return denied; if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return fail("standalone_required", "Client initiated links require standalone runtime mode.", 409); + if (!joinPortMatches(ctx)) return fail("join_port_mismatch", "OpenCodex is not running on its configured port, so it cannot restart as a Child.", 409); const state = suppliedState ?? stateFor(ctx); if (!state) return fail("link_unavailable", "The link lifecycle is unavailable.", 503); return handleJoin(ctx, state); diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index c094dc438af..b9b9c4220a8 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -371,7 +371,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { 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/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 on its configured port, which briefly interrupts running Codex turns. It is the dashboard's turn-on flow with a pre-join notice (paired, or standalone loopback dashboard on trusted loopback ingress); admin-token and Tailscale identity sessions are 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 }, diff --git a/src/server/management/system-restart.ts b/src/server/management/system-restart.ts index 76708630943..0162ceed553 100644 --- a/src/server/management/system-restart.ts +++ b/src/server/management/system-restart.ts @@ -161,7 +161,8 @@ export function setSystemRestartIoForTests(io: SystemRestartIo = {}): void { explicitShutdownRequested = false; } -function resolveListenPort(): number | undefined { +/** The port this process is listening on, or undefined when that cannot be established. */ +export function resolveListenPort(): number | undefined { const live = getServerListenPort(); if (live) return live; const runtime = readRuntimePort(process.pid); diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 761a43350eb..c5519d47ef0 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -183,7 +183,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`: 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. | +| 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 `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply` and `POST /api/link/join`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; join also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` to GUI-session callers so the dashboard enables the Child role only when a join can succeed; `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. | @@ -276,10 +276,12 @@ when the value is omitted. Ordering invariants live in A connected client machine runs `src/client/machine-listener.ts` instead of the standalone server. It binds the address the standalone proxy would (`port ?? config.port ?? 10100`) and serves -`GET /healthz`, `/readyz`, the packaged GUI/SPA routes, and `/api/machine/*`. Every other `/api/*` -and `/v1/*` path is refused before dispatch with a JSON 404 naming the method and path. There is no -second management port on such a machine: management rides the same listener a standalone or hub -install runs, so a connected client has no `/api/*` management surface at all. +`GET /healthz`, `/readyz`, the packaged GUI/SPA routes, and `/api/machine/*`. A link-transport client +also relays the link data routes to its Home and serves its own read-only `GET /api/link/status` +([Remote Link](remote-link.md#dashboard-admission)). Every other `/api/*` and `/v1/*` path is +refused before dispatch with a JSON 404 naming the method and path. There is no second management +port on such a machine: management rides the same listener a standalone or hub install runs, so a +connected client has no mutating `/api/*` management surface at all. The discriminator is `role` on `/healthz` and `/readyz`. The machine listener reports `role: "client"`; the standalone and hub server omit the field. `src/server/proxy-liveness.ts` parses diff --git a/structure/remote-link.md b/structure/remote-link.md index b5091a41f9f..71151482b90 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -16,7 +16,7 @@ Every remote `ocx` call goes through `remoteOcxArgv`, which runs `sh -c` with a ## 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: 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/server/management/link-routes.ts` accepts `POST /api/link/join` with exactly `{ "alias": string }`. The route admits the same dashboard sessions as the Home-side routes (see [Dashboard admission](#dashboard-admission)), so a standalone computer turns itself into a Child from its own dashboard. A Tailscale identity session receives `403 tailscale_session_refused`, any other caller `403 forbidden`, a runtime that is not standalone `409 standalone_required`, and a standalone whose live listener port (`resolveListenPort` in `src/server/management/system-restart.ts`) is not its configured `port`, or cannot be determined, `409 join_port_mismatch`, because the client runtime it restarts into binds exactly the configured port. These gates run before link state is read and before any SSH. 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. @@ -28,9 +28,11 @@ The client tunnel pidfile is `/link/client-tunnel.pid` with `{ versio ## 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. +The dashboard link routes (`GET /api/link/status`, `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply`, `POST /api/link/join` 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. The hub-link, hub-management and claude-intercept ingresses are never trusted loopback ingress, a stale session is refused, 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` keeps durable machine changes on the connected listener away from that session. The 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. For join the trade is the same one apply already makes: another local user who can mint the session can move this machine's Codex and Claude traffic to an SSH host this user's key already reaches, after an explicit fingerprint confirmation, and restart the proxy. -`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. +A successful join restarts this proxy (a 503 drain of up to a minute while running turns finish, then a closed listener) into the client runtime on the configured port. `GET /api/link/status` tells a GUI-session caller whether it may join: the response gains `joinAvailable`, true only for a dashboard session on a standalone runtime whose live port is its configured port. 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 and a notice names the port mismatch. Before **Connect as Child** the confirmation panel says that the restart briefly interrupts Codex and that Codex keeps its local address. The dashboard reads the standalone's pid from same-origin `/healthz`, sends the join, then reads `/healthz` once a second, skipping status polls meanwhile, and reloads only when it reports `role: "client"` under another pid, so the reloaded document carries the client role and a fresh session. It never gives up by itself: past the server's own handoff budget (60 s drain plus 70 s replacement readiness, plus a margin: 145 seconds) it says the restart is slow and keeps reading `/healthz` every 5 seconds until the Child answers or the page is left. + +A connected Child answers `GET` and `HEAD /api/link/status` on its own listener for a GUI session: `src/client/link-status.ts` projects the client sidecar and the tunnel supervisor into the K16 document with `role: "child"`, the listener off, no links and the child row, plus `joinAvailable: false`. A Home-initiated Child has no sidecar and reports `child: null`. In link mode `/api/machine/status` advertises the machine origin as the shared plane, because the tunnel's hub-link ingress serves no `/api/*` and no session bootstrap. `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. @@ -42,6 +44,12 @@ Applying a link probes the host key into a temporary file, waits for the operato ## Client link transport -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. +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 the standalone loopback routing: `routingTarget` in `src/client/connect.ts` returns the `standaloneCodexRoutingTarget` form for the configured port, root `openai_base_url = "http://127.0.0.1:/v1"` plus the realtime override, with no provider table and no `env_key`. For the same port the join therefore writes the bytes the standalone injection wrote, and a Codex app launched without the shim's environment keeps working; an earlier `env_key` table is rebuilt into this form on the next sync. + +`src/client/link-ingress.ts` is the link-mode data plane of the machine listener. A `/v1/responses` WebSocket upgrade answers `426 upgrade_required`, which codex-rs maps to its HTTP fallback, and no upgrade is ever relayed. Every relayed route first passes the standalone loopback Host and Origin gate (`isAllowedRequestOrigin` in `src/server/auth-cors.ts`), so a rebinding or cross-site page gets `403 origin_rejected` and nothing is fetched upstream. `/readyz` is answered locally. The link key is read from the service token file once, when the listener starts, and held in memory; the file must hold the key whose fingerprint the connection committed. While it does not, relayed routes answer `503 link_credential_unavailable` without an upstream fetch, and the file is read again at most once a second; a valid key is never re-read. The key is never logged or returned. + +`src/client/link-relay.ts` forwards exactly the `linkRouteAllowed` routes from `src/link/routes.ts` through the tunnel. It drops the caller's `Authorization`, `x-api-key`, `x-opencodex-api-key`, `chatgpt-account-id` and `cookie` and sends the link key as `Authorization: Bearer`, the wire an `env_key` config sent; `GET /v1/usage` takes it as `x-opencodex-api-key`, the only header that route admits. The Home admits the key and serves the Child with its own accounts. The request body is streamed chunk by chunk with the caller's `Content-Length` and a byte-counting cap at the inbound limit (`resolveInboundBodyLimitBytes`, 256 MiB by default); a larger declared or streamed body answers 413. A lone `Transfer-Encoding: chunked` without `Content-Length` is admitted as a standalone admits it, because the listener has already de-chunked the body; any other Transfer-Encoding, or one next to a `Content-Length`, answers 400. The Home's response headers may take up to 300 seconds, and a caller abort ends the wait sooner. SSE passes through chunk by chunk with caller-abort propagation and a 300-second idle limit, other response bodies stream under the same byte cap, and the relay answers 503 with Retry-After while the tunnel is down. Both the Child's machine listener and the Home's hub-link listener bind with `idleTimeout: 255`, the public listener's limit, so a held or slow turn is not cut by Bun's 10-second default. Like a standalone data route, a relayed request then lifts its own idle timer (`server.timeout(req, 0)` in `src/client/link-ingress.ts`), so a quiet stretch longer than 255 seconds inside a long generation is not cut either; the relay's header deadline, SSE idle limit and caller abort bound the wait instead. Hub transport keeps the 4 MiB management-relay listener bound and its default idle limit. 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`, `tests/server/link-management-routes.test.ts`, `tests/server/link-join-route.test.ts` and `tests/clients/client-link-teardown.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-machine-listener.test.ts`, `tests/clients/client-link-status.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`, `tests/server/link-listener-lifecycle.test.ts`, `tests/clients/client-link-teardown.test.ts` and `gui/tests/remote-link.test.tsx`. diff --git a/tests/clients/client-link-connect.test.ts b/tests/clients/client-link-connect.test.ts index 4ecc34b38be..9f323c9ead2 100644 --- a/tests/clients/client-link-connect.test.ts +++ b/tests/clients/client-link-connect.test.ts @@ -41,10 +41,20 @@ describe("client link connection contracts", () => { expect(clientConnectionSchema.safeParse(client({ serverUrl: "https://127.0.0.1:34567" })).success).toBe(false); }); - test("uses the local configured port for Codex while retaining link mode identity", () => { + test("keeps Codex on the standalone 127.0.0.1 form of the local configured port while retaining link identity", () => { const target = routingTarget("http://127.0.0.1:34567", 10100); - expect(target.baseUrl).toBe("http://localhost:10100/v1"); - expect(target.requiresAdmissionToken).toBe(true); + expect(target).toEqual({ + baseUrl: "http://127.0.0.1:10100/v1", + requiresAdmissionToken: false, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + link: true, + }); + // The loopback opt-ins follow the standalone target, so a join changes no Codex routing bytes. + expect(routingTarget("http://127.0.0.1:34567", 10100, { codexClientCompaction: true }).clientCompaction).toBe(true); + // A hub client still points Codex at the hub with the admission token in env_key. + expect(routingTarget("https://hub.example.test")).toEqual({ + baseUrl: "https://hub.example.test/v1", requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + }); expect(isLinkConnection(client() as never)).toBe(true); expect(isLinkConnection(undefined)).toBe(false); }); diff --git a/tests/clients/client-link-relay.test.ts b/tests/clients/client-link-relay.test.ts index 44b62cf0640..f92b6435011 100644 --- a/tests/clients/client-link-relay.test.ts +++ b/tests/clients/client-link-relay.test.ts @@ -5,19 +5,21 @@ import { join } from "node:path"; import type { Server } from "bun"; import { forwardLinkRequestHeaders, + LINK_RELAY_BODY_MAX_BYTES, + LINK_RELAY_HEADER_TIMEOUT_MS, LINK_RELAY_SSE_IDLE_TIMEOUT_MS, relayLinkDataRequest, sanitizeLinkResponseHeaders, + type LinkRelayClock, } from "../../src/client/link-relay"; -import { - HUB_RELAY_REQUEST_BODY_MAX_BYTES, - HUB_RELAY_RESPONSE_BODY_MAX_BYTES, -} from "../../src/client/hub-relay"; +import { HUB_RELAY_REQUEST_BODY_MAX_BYTES } from "../../src/client/hub-relay"; import { startMachineListener } from "../../src/client/machine-listener"; +import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; import type { OcxClientConnectionConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -const target = { tunnelPort: 12000 }; +const LINK_KEY = `ocx_data_${"d".repeat(40)}`; +const target = { tunnelPort: 12000, admissionKey: LINK_KEY }; let servers: Server[] = []; let root = ""; let previousHome: string | undefined; @@ -32,7 +34,7 @@ function linkConnection(tunnelPort: number): OcxClientConnectionConfig { selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "ocx_data_fixture", - tokenFingerprint: "b".repeat(64), + tokenFingerprint: serviceApiTokenFingerprint(LINK_KEY), protocolVersion: 1, connectedAt: "2026-09-25T00:00:00.000Z", catalogSyncedAt: "2026-09-25T00:00:01.000Z", @@ -43,6 +45,34 @@ function relayRequest(init: RequestInit = {}): Request { return new Request("http://127.0.0.1:10100/v1/responses?trace=1", init); } +/** A clock whose timers fire only when the test advances it. */ +function manualClock() { + let now = 0; + const timers = new Map void; ms: number }>(); + let next = 1; + const clock: LinkRelayClock = { + setTimeout: ((callback: () => void, ms: number) => { + const id = next++; + timers.set(id, { at: now + ms, callback, ms }); + return id as unknown as ReturnType; + }) as typeof setTimeout, + clearTimeout: ((id: number) => { timers.delete(id); }) as typeof clearTimeout, + }; + return { + clock, + delays: () => [...timers.values()].map(timer => timer.ms), + advance(ms: number) { + now += ms; + for (const [id, timer] of [...timers]) { + if (timer.at <= now) { + timers.delete(id); + timer.callback(); + } + } + }, + }; +} + beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; root = mkdtempSync(join(tmpdir(), "ocx-link-relay-")); @@ -61,22 +91,30 @@ afterEach(async () => { }); describe("client link HTTP relay", () => { - test("filters hop-by-hop and Connection-nominated headers while preserving caller credentials", () => { - const forwarded = forwardLinkRequestHeaders(new Headers({ - Authorization: "Bearer caller-token", + test("replaces every caller credential with the link key and filters hop-by-hop headers", () => { + const caller = new Headers({ + Authorization: "Bearer caller-chatgpt-oauth", "X-OpenCodex-API-Key": "ocx_data_caller", + "X-Api-Key": "sk-ant-caller", + "ChatGPT-Account-Id": "acct-caller", + Cookie: "session=caller", "X-Trace": "trace-1", Connection: "keep-alive, X-Remove", "X-Remove": "secret", Host: "caller.example.test", "Content-Length": "2", - })); - expect(forwarded.get("authorization")).toBe("Bearer caller-token"); - expect(forwarded.get("x-opencodex-api-key")).toBe("ocx_data_caller"); + }); + const forwarded = forwardLinkRequestHeaders(caller, LINK_KEY, "/v1/responses"); + expect(forwarded.get("authorization")).toBe(`Bearer ${LINK_KEY}`); expect(forwarded.get("x-trace")).toBe("trace-1"); - for (const name of ["connection", "keep-alive", "x-remove", "host", "content-length"]) { + for (const name of ["x-opencodex-api-key", "x-api-key", "chatgpt-account-id", "cookie", "connection", "keep-alive", "x-remove", "host", "content-length"]) { expect(forwarded.get(name)).toBeNull(); } + // /v1/usage admits only the dedicated header on the Home. + const usage = forwardLinkRequestHeaders(caller, LINK_KEY, "/v1/usage"); + expect(usage.get("x-opencodex-api-key")).toBe(LINK_KEY); + expect(usage.get("authorization")).toBeNull(); + expect(usage.get("x-api-key")).toBeNull(); const response = sanitizeLinkResponseHeaders(new Headers({ Connection: "X-Response-Secret", @@ -91,6 +129,25 @@ describe("client link HTTP relay", () => { } }); + test("sends the link key, not the caller's credential, to the Home", async () => { + const sent: Headers[] = []; + const fetchImpl = (async (_input, init) => { sent.push(new Headers(init?.headers)); return Response.json({ ok: true }); }) as typeof fetch; + const response = await relayLinkDataRequest(relayRequest({ + method: "POST", + headers: { Authorization: "Bearer caller-chatgpt-oauth", "ChatGPT-Account-Id": "acct-caller", "Content-Type": "application/json" }, + body: "{}", + }), target, { fetchImpl }); + expect(response.status).toBe(200); + expect(sent[0]?.get("authorization")).toBe(`Bearer ${LINK_KEY}`); + expect(sent[0]?.get("chatgpt-account-id")).toBeNull(); + const usage = await relayLinkDataRequest(new Request("http://127.0.0.1:10100/v1/usage", { + headers: { Authorization: "Bearer caller-chatgpt-oauth" }, + }), target, { fetchImpl }); + expect(usage.status).toBe(200); + expect(sent[1]?.get("x-opencodex-api-key")).toBe(LINK_KEY); + expect(sent[1]?.get("authorization")).toBeNull(); + }); + test("rejects TE/CL ambiguity and oversized requests before outbound I/O", async () => { let calls = 0; const fetchImpl = (async () => { calls += 1; return new Response(); }) as typeof fetch; @@ -100,35 +157,156 @@ describe("client link HTTP relay", () => { body: "{}", }); expect((await relayLinkDataRequest(ambiguous, target, { fetchImpl })).status).toBe(400); - const oversized = new Request("http://127.0.0.1:10100/v1/responses", { + for (const [limit, declared] of [[undefined, LINK_RELAY_BODY_MAX_BYTES + 1], [1024, 1025]] as const) { + const oversized = new Request("http://127.0.0.1:10100/v1/responses", { + method: "POST", + headers: { "Content-Length": String(declared) }, + body: "{}", + }); + expect((await relayLinkDataRequest(oversized, target, { fetchImpl, bodyLimitBytes: limit })).status).toBe(413); + } + expect(calls).toBe(0); + }); + + test("streams a body larger than the management relay cap without buffering it", async () => { + const size = 5 * 1024 * 1024; + expect(size).toBeGreaterThan(HUB_RELAY_REQUEST_BODY_MAX_BYTES); + let streamed = false; + let received = 0; + let length: string | null = null; + const response = await relayLinkDataRequest(relayRequest({ method: "POST", - headers: { "Content-Length": String(HUB_RELAY_REQUEST_BODY_MAX_BYTES + 1) }, - body: "{}", + headers: { "Content-Type": "application/json", "Content-Length": String(size) }, + body: new Uint8Array(size).fill(0x61), + }), target, { + fetchImpl: (async (_input, init) => { + streamed = init?.body instanceof ReadableStream; + length = new Headers(init?.headers).get("content-length"); + received = (await new Response(init!.body).arrayBuffer()).byteLength; + return Response.json({ received }); + }) as typeof fetch, }); - expect((await relayLinkDataRequest(oversized, target, { fetchImpl })).status).toBe(413); + expect(response.status).toBe(200); + expect(streamed).toBe(true); + expect(length).toBe(String(size)); + expect(received).toBe(size); + }); + + test("admits a lone chunked upload as a standalone does and refuses any other Transfer-Encoding", async () => { + const chunkedBody = () => new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"input":')); + controller.enqueue(new TextEncoder().encode('"hello"}')); + controller.close(); + }, + }); + let sentHeaders: Headers | undefined; + let sentBody = ""; + const fetchImpl = (async (_input, init) => { + sentHeaders = new Headers(init?.headers); + sentBody = await new Response(init!.body).text(); + return Response.json({ relayed: true }); + }) as typeof fetch; + const response = await relayLinkDataRequest(relayRequest({ + method: "POST", headers: { "Content-Type": "application/json", "Transfer-Encoding": "chunked" }, body: chunkedBody(), duplex: "half", + } as RequestInit), target, { fetchImpl }); + expect(response.status).toBe(200); + expect(sentBody).toBe('{"input":"hello"}'); + // The framing is the fetch's own: neither the caller's Transfer-Encoding nor a Content-Length is copied. + expect(sentHeaders?.get("transfer-encoding")).toBeNull(); + expect(sentHeaders?.get("content-length")).toBeNull(); + expect(sentHeaders?.get("authorization")).toBe(`Bearer ${LINK_KEY}`); + + let calls = 0; + const counted = (async () => { calls += 1; return new Response(); }) as typeof fetch; + for (const encoding of ["gzip, chunked", "chunked, chunked", "identity"]) { + const refused = await relayLinkDataRequest(relayRequest({ + method: "POST", headers: { "Transfer-Encoding": encoding }, body: chunkedBody(), duplex: "half", + } as RequestInit), target, { fetchImpl: counted }); + expect(refused.status).toBe(400); + } expect(calls).toBe(0); + + const oversized = await relayLinkDataRequest(relayRequest({ + method: "POST", headers: { "Transfer-Encoding": "chunked" }, body: chunkedBody(), duplex: "half", + } as RequestInit), target, { + bodyLimitBytes: 8, + fetchImpl: (async (_input, init) => { + await new Response(init!.body).arrayBuffer(); + return Response.json({ unreachable: true }); + }) as typeof fetch, + }); + expect(oversized.status).toBe(413); + }); + + test("fails a streamed body that grows past the cap with 413", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(600)); + controller.enqueue(new Uint8Array(600)); + controller.close(); + }, + }); + const response = await relayLinkDataRequest(relayRequest({ method: "POST", body, duplex: "half" } as RequestInit), target, { + bodyLimitBytes: 1024, + fetchImpl: (async (_input, init) => { + await new Response(init!.body).arrayBuffer(); + return Response.json({ unreachable: true }); + }) as typeof fetch, + }); + expect(response.status).toBe(413); + }); + + test("waits up to 300 seconds for the Home's response headers", async () => { + const manual = manualClock(); + const response = await relayLinkDataRequest(relayRequest({ method: "POST", body: "{}" }), target, { + clock: manual.clock, + fetchImpl: (async (_input, init) => { + // A Home that answers after 20 s: past the management relay's 15 s deadline. + manual.advance(20_000); + if (init?.signal?.aborted) throw init.signal.reason; + return Response.json({ late: true }); + }) as typeof fetch, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ late: true }); + expect(manual.delays()).toEqual([]); + + const stalled = manualClock(); + const refused = await relayLinkDataRequest(relayRequest({ method: "POST", body: "{}" }), target, { + clock: stalled.clock, + fetchImpl: (async (_input, init) => { + expect(stalled.delays()).toEqual([LINK_RELAY_HEADER_TIMEOUT_MS]); + stalled.advance(LINK_RELAY_HEADER_TIMEOUT_MS); + if (init?.signal?.aborted) throw init.signal.reason; + return Response.json({ unreachable: true }); + }) as typeof fetch, + }); + expect(refused.status).toBe(503); + expect(refused.headers.get("retry-after")).toBe("1"); }); - test("returns a retryable JSON 503 when the tunnel is refused", async () => { - const key = "ocx_data_secret_should_not_escape"; + test("returns a retryable JSON 503 when the tunnel is refused, without echoing the key", async () => { const response = await relayLinkDataRequest(relayRequest({ method: "POST", - headers: { "X-OpenCodex-API-Key": key, "Content-Type": "application/json" }, + headers: { "X-OpenCodex-API-Key": "ocx_data_caller", "Content-Type": "application/json" }, body: JSON.stringify({ input: "private" }), }), target, { - fetchImpl: (async () => { throw new Error("connection refused"); }) as typeof fetch, + fetchImpl: (async () => { throw new Error(`connection refused ${LINK_KEY}`); }) as typeof fetch, }); expect(response.status).toBe(503); expect(response.headers.get("retry-after")).toBe("1"); const body = await response.text(); - expect(body).not.toContain(key); + expect(body).not.toContain(LINK_KEY); + expect(body).not.toContain("ocx_data_caller"); expect(body).not.toContain("private"); }); - test("applies hub response caps to non-SSE responses", async () => { + test("applies the response byte cap to non-SSE responses", async () => { const response = await relayLinkDataRequest(relayRequest({ method: "POST" }), target, { + bodyLimitBytes: 1024, fetchImpl: (async () => new Response("too large", { - headers: { "Content-Length": String(HUB_RELAY_RESPONSE_BODY_MAX_BYTES + 1) }, + headers: { "Content-Length": "1025" }, })) as typeof fetch, }); expect(response.status).toBe(502); @@ -164,8 +342,8 @@ describe("client link HTTP relay", () => { const response = await relayLinkDataRequest(relayRequest({ method: "POST" }), target, { clock: { setTimeout: ((callback, ms) => { - expect(ms).toBe(LINK_RELAY_SSE_IDLE_TIMEOUT_MS); - fireIdle = callback; + // The header deadline is armed first and cleared once headers arrive. + if (ms === LINK_RELAY_SSE_IDLE_TIMEOUT_MS) fireIdle = callback; return 1 as unknown as ReturnType; }) as typeof setTimeout, clearTimeout: (() => {}) as typeof clearTimeout, @@ -185,8 +363,9 @@ describe("client link HTTP relay", () => { expect(cancelled).toBe(true); }); - test("relays POST /v1/responses through a real machine listener socket", async () => { - let received: { method: string; path: string; host: string | null; key: string | null; body: string } | undefined; + test("relays POST /v1/responses through a real machine listener socket with the stored key", async () => { + writeFileSync(join(root, "service-api-token"), `${LINK_KEY}\n`, { mode: 0o600 }); + let received: Record | undefined; const hub = Bun.serve({ hostname: "127.0.0.1", port: 0, @@ -195,27 +374,49 @@ describe("client link HTTP relay", () => { method: req.method, path: new URL(req.url).pathname + new URL(req.url).search, host: req.headers.get("host"), - key: req.headers.get("x-opencodex-api-key"), + authorization: req.headers.get("authorization"), + dedicated: req.headers.get("x-opencodex-api-key"), + contentLength: req.headers.get("content-length"), + transferEncoding: req.headers.get("transfer-encoding"), body: await req.text(), }; return Response.json({ relayed: true }); }, }); servers.push(hub); - const machine = startMachineListener(0, { state: linkConnection(hub.port) }); + const machine = startMachineListener(0, { state: linkConnection(hub.port!) }); servers.push(machine); + const body = JSON.stringify({ input: "hello" }); const response = await fetch(new URL("/v1/responses?trace=1", machine.url), { method: "POST", - headers: { "Content-Type": "application/json", "X-OpenCodex-API-Key": "ocx_data_link" }, - body: JSON.stringify({ input: "hello" }), + headers: { "Content-Type": "application/json", Authorization: "Bearer caller-chatgpt-oauth", "X-OpenCodex-API-Key": "ocx_data_caller" }, + body, }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ relayed: true }); expect(received).toEqual({ method: "POST", path: "/v1/responses?trace=1", host: `127.0.0.1:${hub.port}`, - key: "ocx_data_link", body: JSON.stringify({ input: "hello" }), + authorization: `Bearer ${LINK_KEY}`, dedicated: null, + contentLength: String(body.length), transferEncoding: null, body, }); expect((await fetch(new URL("/v1/unknown", machine.url))).status).toBe(404); expect((await fetch(new URL("/api/machine/hub-relay/api/config", machine.url))).status).toBe(404); + + // A chunked upload (a streamed body with no Content-Length) passes through as on a standalone. + const encoder = new TextEncoder(); + const chunked = await fetch(new URL("/v1/responses", machine.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"input":')); + controller.enqueue(encoder.encode('"chunked"}')); + controller.close(); + }, + }), + duplex: "half", + } as RequestInit); + expect(chunked.status).toBe(200); + expect(received).toMatchObject({ authorization: `Bearer ${LINK_KEY}`, contentLength: null, transferEncoding: "chunked", body: '{"input":"chunked"}' }); }); }); diff --git a/tests/clients/client-link-status.test.ts b/tests/clients/client-link-status.test.ts new file mode 100644 index 00000000000..4e497192911 --- /dev/null +++ b/tests/clients/client-link-status.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { projectClientLinkChild } from "../../src/client/link-status"; +import type { ClientLinkState } from "../../src/client/link-state"; +import type { ClientLinkSupervisorStatus } from "../../src/client/link-tunnel"; +import type { TunnelState } from "../../src/link/tunnel-state"; + +const sidecar: ClientLinkState = { + linkId: "lnk_0123456789abcdef", + alias: "home-mac", + hubHostKeyFingerprint: `SHA256:${"a".repeat(43)}`, + peerListenerPort: 45678, + tunnelPort: 23456, +}; +const NOW = Date.parse("2026-09-26T12:00:00.000Z"); +const SINCE = Date.parse("2026-09-26T11:59:00.000Z"); + +function tunnel(state: TunnelState): ClientLinkSupervisorStatus { + return { kind: "tunnel", linkId: sidecar.linkId, state, pid: 4242 }; +} + +describe("a Child's own link status", () => { + test("is the K16 document with the Child role and no Home-side listener or links", () => { + const status = projectClientLinkChild(sidecar, tunnel({ kind: "connected", since: SINCE }), NOW); + expect(Object.keys(status).sort()).toEqual(["child", "links", "listener", "role"]); + expect(status).toEqual({ + role: "child", + listener: { state: "off", port: null }, + links: [], + child: { alias: "home-mac", state: "connected", since: new Date(SINCE).toISOString(), reason: null }, + }); + }); + + test.each([ + { state: { kind: "connecting", since: SINCE } as TunnelState, wire: "connecting", since: SINCE, reason: null }, + { state: { kind: "connected", since: SINCE } as TunnelState, wire: "connected", since: SINCE, reason: null }, + { state: { kind: "reconnecting", since: SINCE, attempt: 2, retryAt: NOW + 1_000, inFlight: false } as TunnelState, wire: "reconnecting", since: SINCE, reason: null }, + { state: { kind: "failed", since: SINCE, reason: "auth" } as TunnelState, wire: "failed", since: SINCE, reason: "auth" }, + { state: { kind: "idle" } as TunnelState, wire: "idle", since: NOW, reason: null }, + ])("maps a $wire tunnel with an ISO since and a reason only when failed", ({ state, wire, since, reason }) => { + expect(projectClientLinkChild(sidecar, tunnel(state), NOW).child).toEqual({ + alias: "home-mac", state: wire, since: new Date(since).toISOString(), reason, + } as never); + }); + + test("reports a stopped supervisor as idle and an unreadable sidecar as failed", () => { + expect(projectClientLinkChild(sidecar, { kind: "stopped" }, NOW).child).toEqual({ + alias: "home-mac", state: "idle", since: new Date(NOW).toISOString(), reason: null, + }); + const invalid = { alias: "unknown", state: "failed", since: new Date(NOW).toISOString(), reason: "sidecar_invalid" }; + expect(projectClientLinkChild("invalid", { kind: "stopped" }, NOW).child).toEqual(invalid as never); + expect(projectClientLinkChild(sidecar, { kind: "failed", reason: "sidecar_invalid" }, NOW).child) + .toEqual({ ...invalid, alias: "home-mac" } as never); + }); + + test("a Home-initiated Child without a sidecar reports no child row", () => { + expect(projectClientLinkChild(null, { kind: "stopped" }, NOW).child).toBeNull(); + }); +}); diff --git a/tests/clients/client-machine-listener.test.ts b/tests/clients/client-machine-listener.test.ts index 40963105c95..d83440e508a 100644 --- a/tests/clients/client-machine-listener.test.ts +++ b/tests/clients/client-machine-listener.test.ts @@ -12,6 +12,8 @@ import { handleManagementAPI } from "../../src/server/management-api"; import { createManagementSessionControl, type ManagementAuthState } from "../../src/server/management-auth"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; +import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; +import type { ClientLinkSupervisorStatus } from "../../src/client/link-tunnel"; let root = ""; let previousHome: string | undefined; @@ -307,3 +309,168 @@ describe("the served document states the client role", () => { expect(call![1]).toContain('"client"'); }); }); + +describe("client machine listener in link mode", () => { + const LINK_KEY = `ocx_data_${"e".repeat(40)}`; + const TUNNEL_PORT = 23456; + const linkConnection = (fingerprint = serviceApiTokenFingerprint(LINK_KEY)): OcxClientConnectionConfig => ({ + serverUrl: `http://127.0.0.1:${TUNNEL_PORT}`, + managementUrl: `http://127.0.0.1:${TUNNEL_PORT}`, + managementTransport: "direct", + transport: "link", + link: { tunnelPort: TUNNEL_PORT, linkId: `lnk_${"b".repeat(16)}` }, + selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "link-key-1", + tokenFingerprint: fingerprint, + protocolVersion: 1, + connectedAt: "2026-09-26T00:00:00.000Z", + }); + const writeKey = () => writeFileSync(join(root, "service-api-token"), `${LINK_KEY}\n`, { mode: 0o600 }); + + function linkListener(options: { + fingerprint?: string; + linkStatus?: () => ClientLinkSupervisorStatus; + serve?: (options: Parameters[0]) => Server; + reply?: () => Response; + } = {}) { + const upstream: Request[] = []; + const server = startMachineListener(0, { + state: linkConnection(options.fingerprint), + managementAuthState: authState(), + fetchImpl: (async (input, init) => { + upstream.push(new Request(String(input), init)); + return options.reply?.() ?? Response.json({ relayed: true }); + }) as typeof fetch, + readSidecar: () => ({ + linkId: `lnk_${"b".repeat(16)}`, alias: "home-mac", hubHostKeyFingerprint: `SHA256:${"a".repeat(43)}`, + peerListenerPort: 45678, tunnelPort: TUNNEL_PORT, + }), + ...(options.linkStatus ? { linkStatus: options.linkStatus } : {}), + ...(options.serve ? { serve: options.serve } : {}), + }); + servers.push(server); + return { server, upstream }; + } + + test("refuses a non-loopback Host or a foreign Origin before any upstream fetch", async () => { + writeKey(); + const { server, upstream } = linkListener(); + const url = new URL("/v1/responses", server.url); + const post = (headers: Record) => fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: "{}" }); + const rebinding = await post({ Host: "evil.example" }); + expect(rebinding.status).toBe(403); + const crossSite = await post({ Origin: "https://evil.example" }); + expect(crossSite.status).toBe(403); + expect(upstream).toHaveLength(0); + for (const refused of [rebinding, crossSite]) expect(await refused.text()).not.toContain(LINK_KEY); + const allowed = await post({ Origin: `http://127.0.0.1:${server.port}` }); + expect(allowed.status).toBe(200); + expect(upstream).toHaveLength(1); + expect(upstream[0]!.headers.get("authorization")).toBe(`Bearer ${LINK_KEY}`); + }); + + test("answers a Responses WebSocket upgrade with 426 so Codex falls back to HTTP", async () => { + writeKey(); + const { server, upstream } = linkListener(); + const response = await fetch(new URL("/v1/responses", server.url), { + headers: { Upgrade: "websocket", Connection: "Upgrade", "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version": "13" }, + }); + expect(response.status).toBe(426); + expect(JSON.stringify(await response.json())).toContain("upgrade_required"); + expect(upstream).toHaveLength(0); + }); + + test("answers /readyz locally instead of relaying it to the Home", async () => { + writeKey(); + const { server, upstream } = linkListener(); + const response = await fetch(new URL("/readyz", server.url)); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ role: "client", status: "ready", pid: process.pid }); + expect(upstream).toHaveLength(0); + }); + + test("refuses to relay without the committed link key", async () => { + writeKey(); + const { server, upstream } = linkListener({ fingerprint: "f".repeat(64) }); + const response = await fetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBeNull(); + expect(await response.json()).toEqual({ error: "link_credential_unavailable" }); + expect(upstream).toHaveLength(0); + }); + + test("keeps the standalone idle limit and data-plane body limit on its socket", () => { + writeKey(); + let captured: Parameters[0] | undefined; + linkListener({ serve: options => { captured = options; return Bun.serve(options); } }); + expect((captured as { idleTimeout?: number }).idleTimeout).toBe(255); + expect((captured as { maxRequestBodySize?: number }).maxRequestBodySize).toBe(256 * 1024 * 1024); + }); + + test("a relayed stream survives a quiet stretch longer than the listener's idle limit", async () => { + writeKey(); + const encoder = new TextEncoder(); + // Bun sweeps idle sockets every 4 s, so idleTimeout 1 cuts a socket within 4 s of its last + // byte. A 5 s gap between two SSE events stands in for a long reasoning pause at 255 s. + const { server, upstream } = linkListener({ + serve: options => Bun.serve({ ...options, idleTimeout: 1 } as Parameters[0]), + reply: () => new Response(new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode("data: first\n\n")); + await Bun.sleep(5_000); + controller.enqueue(encoder.encode("data: last\n\n")); + controller.close(); + }, + }), { headers: { "Content-Type": "text/event-stream" } }), + }); + const response = await fetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe("data: first\n\ndata: last\n\n"); + expect(upstream).toHaveLength(1); + }, 15_000); + + test("hub transport keeps its management-relay socket bounds", () => { + let captured: Parameters[0] | undefined; + servers.push(startMachineListener(0, { + state: connection(), managementAuthState: authState(), + serve: options => { captured = options; return Bun.serve(options); }, + })); + expect((captured as { idleTimeout?: number }).idleTimeout).toBeUndefined(); + expect((captured as { maxRequestBodySize?: number }).maxRequestBodySize).toBe(4 * 1024 * 1024); + }); + + test("serves the Child's own link status to a GUI session and nothing else", async () => { + writeKey(); + const { server } = linkListener({ + linkStatus: () => ({ kind: "tunnel", linkId: `lnk_${"b".repeat(16)}`, state: { kind: "connected", since: Date.parse("2026-09-26T01:00:00.000Z") }, pid: 4242 }), + }); + const url = new URL("/api/link/status", server.url); + expect((await fetch(url)).status).toBe(401); + const headers = await guiHeaders(server); + const status = await fetch(url, { headers }); + expect(status.status).toBe(200); + expect(await status.json()).toEqual({ + role: "child", + listener: { state: "off", port: null }, + links: [], + child: { alias: "home-mac", state: "connected", since: "2026-09-26T01:00:00.000Z", reason: null }, + joinAvailable: false, + }); + expect((await fetch(url, { method: "POST", headers: await guiHeaders(server, true), body: "{}" })).status).toBe(404); + + const machine = await fetch(new URL("/api/machine/status", server.url), { headers }); + const body = await machine.json() as { machineBase: string; sharedBase: string; sharedServerOrigin: string }; + expect(body.sharedBase).toBe(body.machineBase); + expect(body.sharedServerOrigin).toBe(body.machineBase); + expect(body.machineBase).toBe(`http://127.0.0.1:${server.port}`); + }); + + test("a hub-transport client has no link status route", async () => { + const server = startMachineListener(0, { state: connection(), managementAuthState: authState() }); + servers.push(server); + expect((await fetch(new URL("/api/link/status", server.url), { headers: await guiHeaders(server) })).status).toBe(404); + }); +}); diff --git a/tests/codex-integration/injection-link-websocket.test.ts b/tests/codex-integration/injection-link-websocket.test.ts index 55b497cd7ad..9b6abd61124 100644 --- a/tests/codex-integration/injection-link-websocket.test.ts +++ b/tests/codex-integration/injection-link-websocket.test.ts @@ -1,32 +1,49 @@ import { describe, expect, test } from "bun:test"; import { deriveCodexInjectionPlan } from "../../src/codex/inject/plan"; +import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; import { routingTarget } from "../../src/client/connect"; import type { OcxConfig } from "../../src/types"; +function plan(source: string, target: ReturnType) { + const derived = deriveCodexInjectionPlan(source, { + config: { port: 10100, websockets: true } as OcxConfig, + routingTarget: target, + catalogPathOption: null, + journalReadOnly: true, + }); + expect(derived.kind).toBe("ok"); + if (derived.kind !== "ok") throw new Error("plan refused"); + return derived; +} + describe("link Codex injection", () => { - test("forces websocket support off for the local link routing target", () => { - const plan = deriveCodexInjectionPlan("# existing config\n", { - config: { port: 10100, websockets: true } as OcxConfig, - routingTarget: routingTarget("http://127.0.0.1:34567", 10100), - catalogPathOption: null, - journalReadOnly: true, - }); - expect(plan.kind).toBe("ok"); - if (plan.kind !== "ok") return; - expect(`${plan.content}\n${plan.profileContent}`).toContain("base_url = \"http://localhost:10100/v1\""); - expect(`${plan.content}\n${plan.profileContent}`).not.toContain("supports_websockets = true"); + test("writes the standalone root form: 127.0.0.1 base and realtime URLs, no provider table or env_key", () => { + const link = plan("# existing config\n", routingTarget("http://127.0.0.1:34567", 10100)); + const written = `${link.content}\n${link.profileContent}`; + expect(link.content).toContain("openai_base_url = \"http://127.0.0.1:10100/v1\""); + expect(link.content).toContain("experimental_realtime_ws_base_url"); + expect(link.providerTableMode).toBe(false); + expect(written).not.toContain("env_key"); + expect(written).not.toContain("model_provider = \"opencodex\""); + expect(written).not.toContain("localhost:10100"); + expect(written).not.toContain("supports_websockets = true"); + // For the same port the join writes exactly the bytes the standalone injection writes. + const standalone = plan("# existing config\n", standaloneCodexRoutingTarget(10100, { hostname: "127.0.0.1" })); + expect(link.content).toBe(standalone.content); + }); + + test("rebuilds an earlier env_key link table into the root form", () => { + const legacy = plan("# existing config\n", { baseUrl: "http://localhost:10100/v1", requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN", link: true } as ReturnType); + expect(legacy.content).toContain("env_key"); + const rebuilt = plan(legacy.content, routingTarget("http://127.0.0.1:34567", 10100)); + expect(rebuilt.content).toContain("openai_base_url = \"http://127.0.0.1:10100/v1\""); + expect(rebuilt.content).not.toContain("env_key"); + expect(rebuilt.content).not.toContain("model_provider = \"opencodex\""); }); test("keeps websocket support for a localhost hub routing target", () => { - const plan = deriveCodexInjectionPlan("# existing config\n", { - config: { port: 10100, websockets: true } as OcxConfig, - routingTarget: routingTarget("http://localhost:34567"), - catalogPathOption: null, - journalReadOnly: true, - }); - expect(plan.kind).toBe("ok"); - if (plan.kind !== "ok") return; - expect(`${plan.content}\n${plan.profileContent}`).toContain("base_url = \"http://localhost:34567/v1\""); - expect(`${plan.content}\n${plan.profileContent}`).toContain("supports_websockets = true"); + const hub = plan("# existing config\n", routingTarget("http://localhost:34567")); + expect(`${hub.content}\n${hub.profileContent}`).toContain("base_url = \"http://localhost:34567/v1\""); + expect(`${hub.content}\n${hub.profileContent}`).toContain("supports_websockets = true"); }); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 22679a1b04f..8fd643190fd 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1758,6 +1758,7 @@ "client-link-runtime.test.ts": "clients", "link-routes.test.ts": "clients", "client-link-state.test.ts": "clients", + "client-link-status.test.ts": "clients", "client-link-teardown.test.ts": "clients", "client-link-tunnel.test.ts": "clients", "link-ports.test.ts": "clients", diff --git a/tests/server/link-join-route.test.ts b/tests/server/link-join-route.test.ts index 80c7a9e9bbd..4f432eff68a 100644 --- a/tests/server/link-join-route.test.ts +++ b/tests/server/link-join-route.test.ts @@ -75,12 +75,16 @@ function routeState(confirmed = true): LinkRouteState { }; } +const CONFIG_PORT = 10100; + function context(options: { role?: "standalone" | "hub" | "client"; principal?: ManagementContext["principal"]; paired?: boolean; + current?: boolean; issuance?: ManagementContext["guiSessionIssuance"]; trustedLoopback?: boolean; + livePort?: number; body?: unknown; deps?: Record; } = {}): ManagementContext { @@ -93,11 +97,11 @@ function context(options: { return { req, url: new URL(req.url), - config: { runtimeRole: options.role ?? "standalone" } as ManagementContext["config"], - deps: options.deps ?? {}, + config: { runtimeRole: options.role ?? "standalone", port: CONFIG_PORT } as ManagementContext["config"], + deps: { liveListenPort: () => options.livePort ?? CONFIG_PORT, ...options.deps }, version: "test", principal: options.principal, - sessionControl: { isPaired: () => options.paired === true, isCurrent: () => true, revokeCurrent: () => true }, + sessionControl: { isPaired: () => options.paired === true, isCurrent: () => options.current ?? true, revokeCurrent: () => true }, trustedLoopbackIngress: options.trustedLoopback ?? true, guiSessionIssuance: options.issuance ?? null, convergeCodexCatalog: async () => ({ status: "unchanged" } as never), @@ -122,26 +126,54 @@ 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. + test("admits the current loopback dashboard session of a standalone on trusted loopback ingress", async () => { + // Turning a Child on from its own dashboard: the local session joins, while stale sessions, + // untrusted ingress and other runtime roles are refused before a join starts. let joins = 0; const deps = { joinHome: async () => { joins += 1; return { linkId: LINK_ID, apiKeyId: API_KEY_ID }; } }; + const loopback = { principal: "gui-session" as const, issuance: "loopback" as const, deps }; + const joined = await handleLinkRoutes(context({ ...loopback, trustedLoopback: true }), routeState()); + expect(joined?.status).toBe(202); + expect(await joined?.json()).toEqual({ linkId: LINK_ID, alias: "home", restarting: true }); + expect(joins).toBe(1); + for (const options of [ - { role: "standalone" as const, trustedLoopback: true }, + { role: "standalone" as const, trustedLoopback: true, current: false }, { 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()); + const response = await handleLinkRoutes(context({ ...loopback, ...options }), routeState()); expect(response?.status).toBe(403); expect(await response?.json()).toMatchObject({ error: { code: "forbidden" } }); } - expect(joins).toBe(0); + const tailscale = await handleLinkRoutes(context({ ...loopback, issuance: "tailscale-identity" }), routeState()); + expect(tailscale?.status).toBe(403); + expect(await tailscale?.json()).toMatchObject({ error: { code: "tailscale_session_refused" } }); + expect(joins).toBe(1); const paired = await handleLinkRoutes(context({ principal: "gui-session", issuance: "pairing", paired: true, deps }), routeState()); expect(paired?.status).toBe(202); - expect(joins).toBe(1); + expect(joins).toBe(2); + }); + + test("refuses a join whose restart could not bind the configured port, before any SSH", async () => { + const calls: string[][] = []; + let joins = 0; + const deps = { + sshRunner: runnerFor(calls), + joinHome: async () => { joins += 1; return { linkId: LINK_ID, apiKeyId: API_KEY_ID }; }, + }; + for (const livePort of [CONFIG_PORT + 1, undefined]) { + const response = await handleLinkRoutes({ + ...context({ principal: "gui-session", issuance: "loopback", deps }), + deps: { ...deps, liveListenPort: () => livePort }, + }, routeState()); + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ error: { code: "join_port_mismatch" } }); + } + expect(joins).toBe(0); + expect(calls).toEqual([]); }); test("maps a missing ocx on Home to remote_ocx_missing with a redacted stderr hint", async () => { diff --git a/tests/server/link-listener-lifecycle.test.ts b/tests/server/link-listener-lifecycle.test.ts index 1c6c5d4ee8c..9d11eaf7979 100644 --- a/tests/server/link-listener-lifecycle.test.ts +++ b/tests/server/link-listener-lifecycle.test.ts @@ -279,4 +279,23 @@ describe("hub-link listener lifecycle", () => { await lifecycle.close(); await expect(fetch(`http://127.0.0.1:${port}/`)).rejects.toThrow(); }); + + test("binds with the public listener's 255-second idle limit so a held relayed turn is not cut", async () => { + tempHome = mkdtempSync(join(tmpdir(), "ocx-link-idle-")); + const current = { value: store() }; + let options: Parameters[0] | undefined; + const lifecycle = makeLifecycle(current, { + writeStore: (_path, next) => { current.value = next; }, + serve: served => { + options = served; + const actual = Bun.serve(served); + servers.push(actual); + return actual; + }, + }); + lifecycle.start(context()); + await lifecycle.ensureStarted(); + expect((options as { idleTimeout?: number } | undefined)?.idleTimeout).toBe(255); + await lifecycle.stop(); + }); }); diff --git a/tests/server/link-management-routes.test.ts b/tests/server/link-management-routes.test.ts index f5db5b4c8ee..6d9ed586877 100644 --- a/tests/server/link-management-routes.test.ts +++ b/tests/server/link-management-routes.test.ts @@ -33,7 +33,7 @@ function isBareOcx(argv: readonly string[]): boolean { } function config(): OcxConfig { - return { port: 0, hostname: "127.0.0.1", runtimeRole: "hub", defaultProvider: "mock", providers: {}, apiKeys: [] } as OcxConfig; + return { port: 10100, hostname: "127.0.0.1", runtimeRole: "hub", defaultProvider: "mock", providers: {}, apiKeys: [] } as OcxConfig; } function store(): LinkStore { @@ -69,6 +69,7 @@ function harness() { linkSupervisor: () => supervisor(events), linkListener: () => listener, linkKnownHostsPath: () => join(temp, "known_hosts"), + liveListenPort: () => 10100, issueApiKey: (cfg, name) => { const value = { id: `key-${cfg.apiKeys?.length ?? 0}`, name, key: "ocx_data_" + "a".repeat(40), createdAt: "2026-09-25T00:00:00.000Z" }; cfg.apiKeys = [...(cfg.apiKeys ?? []), value]; @@ -183,7 +184,7 @@ describe("link management routes", () => { 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 }); + expect(await status!.json()).toMatchObject({ role: "standalone", joinAvailable: true }); 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" }] }); @@ -192,10 +193,14 @@ describe("link management routes", () => { 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 same session may also turn this computer into a Child: join reaches the join step. + let joins = 0; + const joinDeps = { ...deps, joinHome: async () => { joins += 1; return { linkId: "lnk_0123456789abcdef", apiKeyId: "key-join" }; } } as ManagementApiDeps; + const joined = await sessionCall(`${base}/api/link/join`, mutation, state, cfg, joinDeps, true, "POST", { alias: "home" }); + expect(joined?.status).toBe(202); + expect(joins).toBe(1); + expect((await sessionCall(`${base}/api/link/join`, headers, state, cfg, joinDeps, true, "POST", { alias: "home" }))?.status).toBe(403); + expect(joins).toBe(1); // 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" }); @@ -246,6 +251,14 @@ describe("link management routes", () => { 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 }); + // The local dashboard session of a standalone may join, unless this runtime is not on its + // configured port: the client runtime a join restarts into binds only that port. + const loopback = await call("/api/link/status", "GET", undefined, h.deps, "gui-session", true, "loopback", false, standalone); + expect(await loopback!.json()).toMatchObject({ role: "standalone", joinAvailable: true }); + const moved = await call("/api/link/status", "GET", undefined, { ...h.deps, liveListenPort: () => 10200 }, "gui-session", true, "loopback", false, standalone); + expect(await moved!.json()).toMatchObject({ joinAvailable: false }); + const unknownPort = await call("/api/link/status", "GET", undefined, { ...h.deps, liveListenPort: () => undefined }, "gui-session", true, "loopback", false, standalone); + expect(await unknownPort!.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"]);