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 97c2a5bd774..52f45296021 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -11,7 +11,7 @@ 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. -- 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. +- Le tableau de bord qui ajoute un Child depuis Home est ouvert sur Home ou via une session Hub appairée. Transformer l’ordinateur actuel en Child exige une session de tableau de bord appairée par l’opérateur ; une session locale sans identifiant ne peut pas valider ce changement de routage. 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 ». @@ -47,7 +47,9 @@ Sur l’ordinateur qui doit utiliser les fournisseurs de Home : 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. +Si **Child** demande de jumeler d’abord cet ordinateur, ouvrez son tableau de bord HTTP à l’adresse IP de bouclage configurée, par exemple `http://127.0.0.1:`. Le formulaire de jumelage local apparaît uniquement lorsque le jumelage manque et que le tableau de bord et l’API utilisent la même origine de bouclage. Copiez la commande `ocx gui pair --origin "http://127.0.0.1:"` du formulaire, exécutez-la dans un terminal sur cet ordinateur, puis collez le code à usage unique dans le formulaire. Utilisez exactement l’origine affichée ; une clé API de fournisseur ou un jeton d’administration n’est pas un code de jumelage. L’absence de jumelage et un port différent du port configuré sont deux causes distinctes. + +Le rôle **Child** exige également qu’OpenCodex fonctionne en mode autonome 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 diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 66df21314fc..b4d1e76beb1 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -11,7 +11,7 @@ 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. -- The dashboard that starts the link is opened on that computer itself (browser or desktop app, standalone install) or through a paired Hub session. +- The dashboard that adds a Child from Home is opened on Home itself or through a paired Hub session. Turning the current computer into a Child requires an operator-paired dashboard session; a credentialless local dashboard session cannot commit that routing change. 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). @@ -49,7 +49,9 @@ Connecting restarts OpenCodex on this computer. Codex turns that are already run The Child waits for its configured port while the old process releases it. If a CLI-managed restart still fails, run `ocx start` on the Child and check `~/.opencodex/restart-handoff.log`. In the desktop app, the app starts and supervises the replacement automatically. -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. +If **Child** says to pair this machine first, open this computer's configured literal-loopback HTTP dashboard, for example `http://127.0.0.1:`. The local pairing form appears only when pairing is missing and the dashboard and API use the same loopback origin. Copy the form's `ocx gui pair --origin "http://127.0.0.1:"` command, run it in a terminal on this computer, and paste the one-use code into the form. Use the exact origin shown in the form; a provider API key or admin token is not a pairing code. Missing pairing is separate from a configured-port mismatch. + +The **Child** role also requires a standalone OpenCodex runtime running 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 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 7df5f508e6d..770e9a0ec5c 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -11,7 +11,7 @@ description: SSH で OpenCodex の Home コンピューターと Child コンピ - Child から開始するリンクでは、Child から Home に OpenSSH キーでログインできる必要があります(パスワードログインには対応していません)。 - Child に OpenCodex 2.66.0 以降がインストールされていること(Child から開始するリンクでは Home にも)。 - 両方のコンピューターが macOS または Linux であること。 -- リンクを開始するダッシュボードは、そのコンピューター上で直接開いたもの(スタンドアロン環境のブラウザーまたはデスクトップアプリ)か、ペアリング済みの Hub セッションであること。 +- Home から Child を追加するダッシュボードは Home 上で開くか、ペアリング済みの Hub セッションを使用します。現在のコンピューターを Child にするには、オペレーターがペアリングしたダッシュボードセッションが必要です。認証情報なしのローカルセッションでは、このルーティング変更を確定できません。 パスワード SSH と Windows は現在のフローに含まれません。リンクはどちら側からでも開始できます。次の手順のように Home から開始するか、後述の「このコンピューターを Child として接続する」のように Child から開始します。 @@ -47,7 +47,9 @@ Home のプロバイダーを使うコンピューターで次の操作を行い 接続すると、このコンピューターの OpenCodex が再起動します。すでに実行中の Codex ターンは先に完了し、再起動中の最大 1 分間は新しいリクエストが失敗することがあります。その後ダッシュボードは自動的に再読み込みされ、Child のリンクを表示します。Codex はこのコンピューターの `http://127.0.0.1:/v1` を使い続け、トークンや環境変数の設定は不要です。ローカルの OpenCodex が各リクエストを Home に中継し、Home が自身のプロバイダーとアカウントで応答します。 -**Child** の役割は、OpenCodex が設定されたポートで動作している間だけ選択できます。Child はまさにそのポートで再起動するためです。設定されたポートで動作していないとダッシュボードに表示された場合は、先にそのポートで OpenCodex を再起動してください。 +**Child** にこのコンピューターを先にペアリングするよう表示された場合は、このコンピューターに設定された HTTP ループバック IP アドレスのダッシュボード(例: `http://127.0.0.1:`)を開いてください。ローカルのペアリングフォームは、ペアリングが必要で、ダッシュボードと API が同じループバックオリジンを使う場合にのみ表示されます。フォームの `ocx gui pair --origin "http://127.0.0.1:"` コマンドをコピーしてこのコンピューターのターミナルで実行し、使い捨てコードをフォームに貼り付けてください。フォームに表示された正確なオリジンを使ってください。プロバイダーの API キーや管理者トークンはペアリングコードではありません。ペアリングの不足と設定されたポートの不一致は別の原因です。 + +**Child** の役割には、OpenCodex がスタンドアロンモードで設定されたポートで動作していることも必要です。Child はまさにそのポートで再起動するためです。設定されたポートで動作していないとダッシュボードに表示された場合は、先にそのポートで OpenCodex を再起動してください。 ## リンクの状態 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 78d076d0a0f..0198c2ef3ae 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -11,7 +11,7 @@ description: SSH로 OpenCodex Home 컴퓨터와 Child 컴퓨터를 연결합니 - 자식이 시작하는 링크에서는 자식에서 OpenSSH 키 로그인으로 홈에 접속할 수 있어야 합니다(비밀번호 로그인은 지원하지 않음). - Child 컴퓨터에 OpenCodex 2.66.0 이상이 설치되어 있어야 합니다(자식이 시작하는 링크에서는 Home에도). - 두 컴퓨터 모두 macOS 또는 Linux여야 합니다. -- 링크를 시작하는 대시보드는 그 컴퓨터에서 직접 열거나(독립형 설치의 브라우저 또는 데스크톱 앱) 페어링된 Hub 세션으로 열어야 합니다. +- Home에서 Child를 추가하는 대시보드는 Home에서 직접 열거나 페어링된 Hub 세션을 사용해야 합니다. 현재 컴퓨터를 Child로 전환하려면 운영자가 페어링한 대시보드 세션이 필요하며, 자격 증명 없이 발급된 로컬 세션은 이 라우팅 변경을 확정할 수 없습니다. 비밀번호 SSH와 Windows는 현재 흐름에서 지원하지 않습니다. 링크는 어느 쪽에서든 시작할 수 있습니다. 아래 순서대로 Home에서 시작하거나, 이어지는 "이 컴퓨터를 Child로 연결하기" 절처럼 Child에서 시작합니다. @@ -47,7 +47,9 @@ Home의 프로바이더를 사용할 컴퓨터에서 다음을 진행합니다. 연결하면 이 컴퓨터의 OpenCodex가 다시 시작됩니다. 이미 실행 중인 Codex 작업은 먼저 끝나고, 다시 시작되는 동안 최대 1분 정도 새 요청이 실패할 수 있습니다. 그 뒤 대시보드가 스스로 새로 고쳐지고 Child 링크를 보여 줍니다. Codex는 이 컴퓨터의 `http://127.0.0.1:/v1`을 그대로 사용하며 토큰이나 환경 변수를 설정할 필요가 없습니다. 로컬 OpenCodex가 각 요청을 Home으로 전달하고, Home은 자신의 프로바이더와 계정으로 응답합니다. -**Child** 역할은 OpenCodex가 설정된 포트에서 실행 중일 때만 선택할 수 있습니다. Child는 정확히 그 포트에서 다시 시작하기 때문입니다. 대시보드가 설정된 포트에서 실행되고 있지 않다고 알리면, 먼저 그 포트에서 OpenCodex를 다시 시작하세요. +**Child**에 이 컴퓨터를 먼저 페어링하라는 안내가 나오면, 이 컴퓨터에 설정된 IP 주소 형식의 HTTP 루프백 대시보드(예: `http://127.0.0.1:`)를 여세요. 로컬 페어링 양식은 페어링이 필요하고 대시보드와 API가 같은 루프백 출처를 사용할 때만 표시됩니다. 양식의 `ocx gui pair --origin "http://127.0.0.1:"` 명령을 복사해 이 컴퓨터의 터미널에서 실행한 뒤, 일회용 코드를 양식에 붙여 넣으세요. 양식에 표시된 정확한 출처를 사용해야 하며, 프로바이더 API 키나 관리자 토큰은 페어링 코드가 아닙니다. 페어링 누락과 설정된 포트의 불일치는 서로 다른 사유입니다. + +**Child** 역할을 선택하려면 OpenCodex가 독립형 런타임으로 설정된 포트에서 실행 중이어야 합니다. Child는 정확히 그 포트에서 다시 시작하기 때문입니다. 대시보드가 설정된 포트에서 실행되고 있지 않다고 알리면, 먼저 그 포트에서 OpenCodex를 다시 시작하세요. ## 링크 상태 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 5f33aa39f4b..b88687261d5 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -11,7 +11,7 @@ description: Подключите компьютер OpenCodex Home к комп - Для связи, инициированной со стороны Child, Child должен входить на Home по ключу OpenSSH (вход по паролю не поддерживается). - На Child установлен OpenCodex 2.66.0 или новее (для связи со стороны Child — и на Home). - Оба компьютера работают под macOS или Linux. -- Панель, с которой начинают связь, открыта на самом этом компьютере (браузер или настольное приложение, автономная установка) или через сопряжённую сессию Hub. +- Панель для добавления Child со стороны Home открыта на Home или через сопряжённую сессию Hub. Чтобы превратить текущий компьютер в Child, нужна сессия панели, сопряжённая оператором; локальная сессия без учётных данных не может подтвердить это изменение маршрутизации. SSH с паролем и Windows сейчас не поддерживаются. Связь можно начать с любой стороны: с Home, как описано ниже, или с Child, как описано в разделе «Подключение этого компьютера как Child». @@ -47,7 +47,9 @@ Host devbox При подключении OpenCodex на этом компьютере перезапустится. Уже идущие запросы Codex сначала завершатся, а новые запросы могут не проходить до минуты, пока идёт перезапуск. Затем панель перезагрузится сама и покажет связь Child. Codex продолжит использовать `http://127.0.0.1:/v1` на этом компьютере без токена и без переменных окружения: локальный OpenCodex передаёт каждый запрос на Home, а Home обслуживает его своими провайдерами и учётными записями. -Роль **Child** доступна, только пока OpenCodex работает на настроенном порту, потому что Child перезапускается именно на нём. Если панель сообщает, что OpenCodex работает не на настроенном порту, сначала перезапустите его на этом порту. +Если **Child** просит сначала выполнить сопряжение этого компьютера, откройте на нём HTTP-панель по настроенному IP-адресу обратной петли, например `http://127.0.0.1:`. Локальная форма сопряжения появляется только при отсутствии сопряжения, когда панель и API используют один и тот же origin обратной петли. Скопируйте из формы команду `ocx gui pair --origin "http://127.0.0.1:"`, выполните её в терминале на этом компьютере и вставьте одноразовый код в форму. Используйте точно тот origin, который показан в форме; API-ключ провайдера или токен администратора не является кодом сопряжения. Отсутствие сопряжения и несовпадение настроенного порта — разные причины. + +Роль **Child** также требует, чтобы OpenCodex работал в автономном режиме на настроенном порту, потому что Child перезапускается именно на нём. Если панель сообщает, что OpenCodex работает не на настроенном порту, сначала перезапустите его на этом порту. ## Состояние связи 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 48b692f8670..9a866de7bd4 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -11,7 +11,7 @@ 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ı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. +- Home üzerinden Child ekleyen kontrol paneli Home üzerinde ya da eşleştirilmiş bir Hub oturumunda açılır. Geçerli bilgisayarı Child'a dönüştürmek için operatörün eşleştirdiği bir kontrol paneli oturumu gerekir; kimlik bilgisi olmadan oluşturulan yerel oturum bu yönlendirme değişikliğini onaylayamaz. 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. @@ -47,7 +47,9 @@ Home'un sağlayıcılarını kullanacak bilgisayarda: 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. +**Child** önce bu bilgisayarı eşleştirmenizi istiyorsa bu bilgisayarın yapılandırılmış HTTP geri döngü IP adresindeki kontrol panelini açın; örneğin `http://127.0.0.1:`. Yerel eşleştirme formu yalnızca eşleştirme eksik olduğunda ve kontrol paneli ile API aynı geri döngü kaynağını kullandığında görünür. Formdaki `ocx gui pair --origin "http://127.0.0.1:"` komutunu kopyalayıp bu bilgisayarın terminalinde çalıştırın, ardından tek kullanımlık kodu forma yapıştırın. Formda gösterilen kaynak adresini aynen kullanın; sağlayıcı API anahtarı veya yönetici belirteci eşleştirme kodu değildir. Eksik eşleştirme ile yapılandırılmış bağlantı noktası uyuşmazlığı farklı nedenlerdir. + +**Child** rolü ayrıca OpenCodex'in bağımsız çalışma modunda, yapılandırılmış bağlantı noktasında çalışmasını gerektirir, çü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 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 aad1d98ab04..a59e2a57fb1 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,7 +11,7 @@ description: 通过 SSH 将 OpenCodex 主机与子机连接起来。 - 对于由子机发起的链接,子机必须能使用 OpenSSH 密钥登录主机(不支持密码登录)。 - 子机已安装 OpenCodex 2.66.0 或更高版本(由子机发起的链接还要求主机也满足)。 - 两台电脑运行 macOS 或 Linux。 -- 发起链接的控制台需在那台电脑本机打开(独立安装的浏览器或桌面应用),或通过已配对的 Hub 会话打开。 +- 从主机添加子机的控制台需在主机上打开,或使用已配对的 Hub 会话。将当前电脑转换为子机需要操作员配对的控制台会话;无凭据签发的本地会话不能提交这项路由更改。 密码 SSH 和 Windows 不在当前流程中。链接可以从任意一侧发起:按下文从主机发起,或按“将这台电脑连接为子机”一节从子机发起。 @@ -47,7 +47,9 @@ Host devbox 连接会重启这台电脑上的 OpenCodex。已在运行的 Codex 请求会先完成,重启期间新的请求可能在最多一分钟内失败。随后控制台会自动重新加载并显示子机链接。Codex 继续使用这台电脑上的 `http://127.0.0.1:/v1`,无需设置令牌或环境变量:本地 OpenCodex 会把每个请求转发给主机,由主机用它自己的提供商和账户提供服务。 -只有当 OpenCodex 在其配置的端口上运行时,才能选择 **Child** 角色,因为子机会在正好这个端口上重启。如果控制台提示 OpenCodex 未在其配置的端口上运行,请先在该端口上重启它。 +如果 **Child** 提示先配对这台电脑,请在这台电脑上打开配置的 HTTP 回环 IP 地址控制台,例如 `http://127.0.0.1:`。只有在缺少配对且控制台与 API 使用同一个回环源时,才会显示本地配对表单。复制表单中的 `ocx gui pair --origin "http://127.0.0.1:"` 命令,在这台电脑的终端中运行,再将一次性代码粘贴到表单中。必须使用表单显示的确切源;提供商 API 密钥或管理员令牌不是配对代码。缺少配对与配置端口不匹配是不同的原因。 + +选择 **Child** 角色还要求 OpenCodex 以独立运行模式在其配置的端口上运行,因为子机会在正好这个端口上重启。如果控制台提示 OpenCodex 未在其配置的端口上运行,请先在该端口上重启它。 ## 链接状态 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 8e2e7ab4106..87233145b80 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,7 +11,7 @@ description: 透過 SSH 連接 OpenCodex Home 電腦與 Child 電腦。 - 對於由 Child 發起的連結,Child 必須能使用 OpenSSH 金鑰登入 Home(不支援密碼登入)。 - Child 已安裝 OpenCodex 2.66.0 或更新版本(由 Child 發起的連結也要求 Home 符合)。 - 兩台電腦執行 macOS 或 Linux。 -- 發起連結的儀表板需在那台電腦本機開啟(獨立安裝的瀏覽器或桌面應用程式),或透過已配對的 Hub 工作階段開啟。 +- 從 Home 新增 Child 的儀表板需在 Home 上開啟,或使用已配對的 Hub 工作階段。將目前電腦轉換為 Child 需要由操作者配對的儀表板工作階段;未經憑證簽發的本機工作階段不能提交這項路由變更。 密碼 SSH 和 Windows 不在目前流程中。連結可以從任一端發起:依下文從 Home 發起,或依「將這台電腦連線為 Child」一節從 Child 發起。 @@ -47,7 +47,9 @@ Host devbox 連線會重新啟動這台電腦上的 OpenCodex。已在執行的 Codex 請求會先完成,重新啟動期間新的請求可能在最多一分鐘內失敗。之後儀表板會自動重新載入並顯示 Child 連結。Codex 會繼續使用這台電腦上的 `http://127.0.0.1:/v1`,不需要設定權杖或環境變數:本機 OpenCodex 會把每個請求轉送給 Home,由 Home 以它自己的供應商與帳戶提供服務。 -只有當 OpenCodex 在其設定的連接埠上執行時,才能選擇 **Child** 角色,因為 Child 會在正好這個連接埠上重新啟動。如果儀表板提示 OpenCodex 未在其設定的連接埠上執行,請先在該連接埠上重新啟動它。 +如果 **Child** 提示先配對這台電腦,請在這台電腦上開啟設定的 HTTP 回環 IP 位址儀表板,例如 `http://127.0.0.1:`。只有在缺少配對且儀表板與 API 使用相同的回環來源時,才會顯示本機配對表單。複製表單中的 `ocx gui pair --origin "http://127.0.0.1:"` 命令,在這台電腦的終端機中執行,再將一次性代碼貼到表單中。必須使用表單顯示的確切來源;供應商 API 金鑰或管理員權杖不是配對代碼。缺少配對與設定的連接埠不符是不同的原因。 + +選擇 **Child** 角色還要求 OpenCodex 以獨立執行模式在其設定的連接埠上執行,因為 Child 會在正好這個連接埠上重新啟動。如果儀表板提示 OpenCodex 未在其設定的連接埠上執行,請先在該連接埠上重新啟動它。 ## 連結狀態 diff --git a/gui/src/api.ts b/gui/src/api.ts index 420c04d3f39..48d47eac70c 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -320,7 +320,11 @@ export function installApiAuthFetch(): void { if (!classified) return originalFetch(input, init); const state = runtime(classified.plane); const token = state.session.token; - const [firstInput, firstInit] = withAuth(classified.plane, input, init); + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + // Pairing exchanges must not carry the old shared-plane credential. The relay's + // separate machine-session headers are still attached by sessionHeaders(). + const pairingExchange = classified.bootstrap && method === "POST"; + const [firstInput, firstInit] = withAuth(classified.plane, input, init, pairingExchange ? null : undefined); const response = await originalFetch(firstInput, firstInit); if (classified.bootstrap || response.status !== 401) return response; const refreshed = state.session.token; diff --git a/gui/src/connect-pairing.tsx b/gui/src/connect-pairing.tsx index c6c9750b1ab..e541b6e1cbb 100644 --- a/gui/src/connect-pairing.tsx +++ b/gui/src/connect-pairing.tsx @@ -7,9 +7,12 @@ import { useCopyFeedback } from "./components/use-copy-feedback"; export function ConnectPairingForm({ target, onConnected, + local = false, }: { target: ApiTarget; onConnected: () => void; + /** Explicit local pairing for a standalone link join; never an automatic bootstrap. */ + local?: boolean; }) { const t = useT(); const [grant, setGrant] = useState(""); @@ -40,15 +43,15 @@ export function ConnectPairingForm({ }; return
-

{t("connection.pairing.title")}

-

{t("connection.pairing.hub")}: {target.serverOrigin}

-

{t("connection.pairing.getCode")}

+

{t(local ? "connection.pairing.code" : "connection.pairing.title")}

+

{!local && <>{t("connection.pairing.hub")}: }{target.serverOrigin}

+ {!local &&

{t("connection.pairing.getCode")}

}
{command}
{copied === "unavailable" &&

{t("prov.linkCopyUnavailable")}

} -

{t("connection.pairing.askOperator")}

+ {!local &&

{t("connection.pairing.askOperator")}

}

{t("connection.pairing.notApiKey")}

diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index aa41a2bfec2..fc9eee0c806 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3643,6 +3643,8 @@ export const de: Record = { "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.joinDenied.pairing_required": "Koppeln Sie diesen Computer zuerst, um ihn als Kind zu verbinden. Öffnen Sie das Dashboard dieses Computers über seine Loopback-Adresse und geben Sie einen vom Betreiber erstellten einmaligen Kopplungscode ein.", + "remoteLink.joinDenied.unavailable": "Eine Verbindung als Kind ist in dieser Dashboard-Sitzung nicht verfügbar. Aktualisieren Sie den Status und prüfen Sie die Kopplungs- und Laufzeiteinstellungen dieses Computers.", "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 4b2d3e94d64..045601993e1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3677,6 +3677,8 @@ export const en = { "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.joinDenied.pairing_required": "Pair this machine first to join as a Child. Open this computer's loopback dashboard and enter an operator-created one-use pairing code.", + "remoteLink.joinDenied.unavailable": "Joining as a Child is unavailable for this dashboard session. Refresh the status and check this machine's pairing and runtime settings.", "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 d40dd64ff0a..eb629193027 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3634,6 +3634,8 @@ export const fr: Record = { "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.joinDenied.pairing_required": "Associez d’abord cet ordinateur pour le connecter en tant qu’Enfant. Ouvrez son tableau de bord via son adresse de bouclage et saisissez un code d’association à usage unique créé par l’opérateur.", + "remoteLink.joinDenied.unavailable": "La connexion en tant qu’Enfant n’est pas disponible dans cette session du tableau de bord. Actualisez l’état et vérifiez les paramètres d’association et d’exécution de cet ordinateur.", "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 f28dc826b58..a298a02b15e 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3667,6 +3667,8 @@ export const ja: Record = { "remoteLink.error.join_restart_failed": "リンクの準備ができました。このコンピューターで OpenCodex を再起動して、子としての接続を完了してください。", "remoteLink.error.join_port_failed": "リモートリンク要求を完了できませんでした。", "remoteLink.error.join_port_mismatch": "OpenCodex が設定されたポートで動作していないため、子として再起動できません。設定されたポートで OpenCodex を再起動してから、もう一度お試しください。", + "remoteLink.joinDenied.pairing_required": "子として接続するには、まずこのコンピューターをペアリングしてください。このコンピューターの loopback アドレスでダッシュボードを開き、管理者が作成したワンタイムペアリングコードを入力してください。", + "remoteLink.joinDenied.unavailable": "このダッシュボードセッションでは、子として接続できません。状態を更新し、このコンピューターのペアリングとランタイムの設定を確認してください。", "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 fa3cb7da1c3..d32503633ed 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3667,6 +3667,8 @@ export const ko: Record = { "remoteLink.error.join_restart_failed": "링크가 준비되었습니다. 이 컴퓨터에서 OpenCodex를 다시 시작해 자식 연결을 완료하세요.", "remoteLink.error.join_port_failed": "원격 연결 요청을 완료하지 못했습니다.", "remoteLink.error.join_port_mismatch": "OpenCodex가 설정된 포트에서 실행되고 있지 않아 자식으로 다시 시작할 수 없습니다. 설정된 포트에서 OpenCodex를 다시 시작한 뒤 다시 시도하세요.", + "remoteLink.joinDenied.pairing_required": "자식으로 연결하려면 먼저 이 컴퓨터를 페어링하세요. 루프백 주소로 이 컴퓨터의 대시보드를 열고 운영자가 생성한 일회용 페어링 코드를 입력하세요.", + "remoteLink.joinDenied.unavailable": "이 대시보드 세션에서는 자식으로 연결할 수 없습니다. 상태를 새로고침하고 이 컴퓨터의 페어링 및 런타임 설정을 확인하세요.", "remoteLink.error.join_connect_failed": "원격 연결 요청을 완료하지 못했습니다.", "link.noChildren": "연결된 자식 컴퓨터가 없습니다.", "remoteLink.status.connecting": "연결 중", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 9aefb5a2fb5..8916a78ba48 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3638,6 +3638,8 @@ export const pt: Record = { "remoteLink.error.join_in_progress": "Não foi possível concluir a solicitação de vínculo remoto.", "remoteLink.error.join_port_failed": "Não foi possível concluir a solicitação de vínculo remoto.", "remoteLink.error.join_port_mismatch": "O OpenCodex não está rodando na porta configurada, por isso não pode reiniciar como Child. Reinicie o OpenCodex na porta configurada e tente novamente.", + "remoteLink.joinDenied.pairing_required": "Emparelhe este computador primeiro para conectá-lo como Child. Abra o painel deste computador pelo endereço de loopback e insira um código de emparelhamento de uso único criado pelo operador.", + "remoteLink.joinDenied.unavailable": "Não é possível conectar como Child nesta sessão do painel. Atualize o status e verifique as configurações de emparelhamento e de runtime deste computador.", "remoteLink.error.join_rollback_failed": "A entrada falhou e o vínculo no Home não pôde ser removido. Tente a limpeza novamente ou execute ocx link revoke no Home.", "remoteLink.error.join_restart_failed": "O vínculo está pronto. Reinicie o OpenCodex neste computador para concluir a conexão como Child.", "remoteLink.error.join_connect_failed": "Não foi possível concluir a solicitação de vínculo remoto.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0299adf3253..2dd8ef7f73f 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3668,6 +3668,8 @@ export const ru: Record = { "remoteLink.error.join_restart_failed": "Связь готова. Перезапустите OpenCodex на этом компьютере, чтобы завершить подключение в роли дочернего компьютера.", "remoteLink.error.join_port_failed": "Не удалось завершить запрос удалённой связи.", "remoteLink.error.join_port_mismatch": "OpenCodex работает не на настроенном порту, поэтому не может перезапуститься как Child. Перезапустите OpenCodex на настроенном порту и повторите попытку.", + "remoteLink.joinDenied.pairing_required": "Сначала выполните сопряжение этого компьютера, чтобы подключить его в роли дочернего. Откройте дашборд этого компьютера по loopback-адресу и введите одноразовый код сопряжения, созданный оператором.", + "remoteLink.joinDenied.unavailable": "Подключение в роли дочернего компьютера недоступно в этой сессии дашборда. Обновите статус и проверьте настройки сопряжения и среды выполнения на этом компьютере.", "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 a9d811f404e..050daecfe11 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3668,6 +3668,8 @@ export const tr: Record = { "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.joinDenied.pairing_required": "Çocuk olarak bağlanmak için önce bu bilgisayarı eşleştirin. Bu bilgisayarın geri döngü (loopback) adresindeki gösterge panelini açın ve operatörün oluşturduğu tek kullanımlık eşleştirme kodunu girin.", + "remoteLink.joinDenied.unavailable": "Bu gösterge paneli oturumunda Çocuk olarak bağlanılamıyor. Durumu yenileyin ve bu bilgisayarın eşleştirme ve çalışma zamanı ayarlarını kontrol edin.", "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 483e89c6a06..3074945f070 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3603,6 +3603,8 @@ export const vi: Record = { "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.joinDenied.pairing_required": "Hãy ghép nối máy tính này trước để kết nối với vai trò máy con. Mở bảng điều khiển của máy tính này qua địa chỉ loopback rồi nhập mã ghép nối dùng một lần do người vận hành tạo.", + "remoteLink.joinDenied.unavailable": "Không thể kết nối với vai trò máy con trong phiên bảng điều khiển này. Hãy làm mới trạng thái và kiểm tra cài đặt ghép nối và runtime của máy tính này.", "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 2235aa93181..8159f43a3bb 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3631,6 +3631,8 @@ export const zhTW: Record = { "remoteLink.error.join_restart_failed": "連線已準備就緒。請在此電腦上重新啟動 OpenCodex,以完成作為子裝置的連線。", "remoteLink.error.join_port_failed": "無法完成遠端連線要求。", "remoteLink.error.join_port_mismatch": "OpenCodex 未在其設定的連接埠上執行,因此無法以子裝置身分重新啟動。請在設定的連接埠上重新啟動 OpenCodex,然後再試一次。", + "remoteLink.joinDenied.pairing_required": "請先配對此電腦,再以子裝置身分連線。透過此電腦的 loopback 位址開啟儀表板,並輸入管理員建立的一次性配對碼。", + "remoteLink.joinDenied.unavailable": "此儀表板工作階段無法以子裝置身分連線。請重新整理狀態,並檢查此電腦的配對與執行環境設定。", "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 df098d9308a..31764564d3c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3666,6 +3666,8 @@ export const zh: Record = { "remoteLink.error.join_restart_failed": "连接已准备就绪。请在此电脑上重启 OpenCodex,以完成作为子设备的连接。", "remoteLink.error.join_port_failed": "无法完成远程连接请求。", "remoteLink.error.join_port_mismatch": "OpenCodex 未在其配置的端口上运行,因此无法以子设备身份重启。请在配置的端口上重启 OpenCodex,然后重试。", + "remoteLink.joinDenied.pairing_required": "请先配对此电脑,再以子设备身份连接。通过此电脑的 loopback 地址打开仪表盘,并输入管理员创建的一次性配对码。", + "remoteLink.joinDenied.unavailable": "此仪表盘会话无法以子设备身份连接。请刷新状态,并检查此电脑的配对与运行时设置。", "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 1c36c1e106b..c29708156b1 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -1,13 +1,14 @@ import { useCallback, useEffect, useEffectEvent, useRef, useState, type ReactElement } from "react"; import { LinkApiError, parseRemoteLinkStatus, readRuntimeHealth, requestLinkJson, waitForChildRuntime, type ChildRestartWaitDeps, type LinkCandidateView, type LinkConfirmHostView, - type LinkErrorCode, type LinkProbeView, type LinkRowWire, type LinkWireState, type RemoteLinkStatusWire, + type LinkErrorCode, type LinkJoinDenied, type LinkProbeView, type LinkRowWire, type LinkWireState, type RemoteLinkStatusWire, } from "../remote-link-api"; import { IconLink, IconPlus, IconRefresh, IconTrash, IconX } from "../icons"; import { Trans } from "../i18n/provider"; import { type TKey, useT } from "../i18n/shared"; import { Notice } from "../ui"; -import { isStandaloneRuntime } from "../api-targets"; +import { isStandaloneRuntime, standaloneApiTargets } from "../api-targets"; +import { ConnectPairingForm } from "../connect-pairing"; import "../styles-remote-link.css"; type RemoteLinkRole = "home" | "child"; @@ -84,6 +85,11 @@ const REASON_TKEY: Record = { compensation_failed: "remoteLink.reason.compensation_failed", "stale tunnel may hold the port": "remoteLink.reason.staleTunnel", }; +const JOIN_DENIED_TKEY: Record = { + pairing_required: "remoteLink.joinDenied.pairing_required", + standalone_required: "remoteLink.childDisabled", + join_port_mismatch: "remoteLink.error.join_port_mismatch", +}; type FailedAction = { phase: "probe" | "apply" | "join"; alias: string }; type LinkAttempt = { controller: AbortController; sequence: number }; @@ -254,6 +260,13 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = // 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 joinDeniedKey = status?.joinDenied ? JOIN_DENIED_TKEY[status.joinDenied] : "remoteLink.joinDenied.unavailable"; + // Offer code entry only after the operator opens link setup, and only on the + // literal same-origin loopback transport accepted by the standalone mint. + const localPairingTarget = standaloneApiTargets(apiBase).shared; + const canPairLocally = standaloneRuntime && window.location.protocol === "http:" + && ["127.0.0.1", "[::1]"].includes(window.location.hostname) + && localPairingTarget.serverOrigin === window.location.origin; const loadCandidates = async () => { const attempt = startLinkAttempt(); setDiscovery("loading"); setCandidates([]); setActionError(null); setFailedAction(null); setBusy("candidates"); @@ -391,7 +404,10 @@ 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.error.join_port_mismatch")}}
} + {uiState === "role-select" &&

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

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

{!standaloneRuntime && {t("remoteLink.childDisabled")}}{standaloneRuntime && status !== null && !status.joinAvailable && {t(joinDeniedKey)}}
} + {uiState === "role-select" && canPairLocally && status?.joinAvailable === false && status.joinDenied === "pairing_required" && ( + { void refreshStatus(); }} /> + )} {(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")}

}{(!sheetOpen && (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")}

}{!sheetOpen && actionError && }
} { event.preventDefault(); closeSheet(); }}> diff --git a/gui/src/remote-link-api.ts b/gui/src/remote-link-api.ts index 3f8ed4b3c99..9dec2e1cea6 100644 --- a/gui/src/remote-link-api.ts +++ b/gui/src/remote-link-api.ts @@ -43,6 +43,8 @@ export type LinkErrorCode = typeof LINK_ERROR_CODES[number]; export type LinkWireDirection = "hub-initiated" | "client-initiated"; export type LinkWireState = "connecting" | "connected" | "reconnecting" | "failed" | "idle"; export type LinkListenerState = "off" | "listening" | "failed"; +export const LINK_JOIN_DENIALS = ["pairing_required", "standalone_required", "join_port_mismatch"] as const; +export type LinkJoinDenied = typeof LINK_JOIN_DENIALS[number]; export interface LinkCandidateView { alias: string; source: string } export interface LinkProbeView { alias: string; fingerprint: string; keyType: string } @@ -54,11 +56,13 @@ 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: a dashboard session on a + * Whether this dashboard session may join a Home as a Child: a paired 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; + /** GUI-only gate explanation. Older servers may omit it; unknown reasons normalize to null. */ + joinDenied?: LinkJoinDenied | null; } const LINK_STATES: readonly LinkWireState[] = ["connecting", "connected", "reconnecting", "failed", "idle"]; @@ -120,7 +124,8 @@ export function parseRemoteLinkStatus(value: unknown): RemoteLinkStatusWire { if (!isRecord(value.child) || !nonEmpty(value.child.alias) || !isLinkState(value.child.state) || !nonEmpty(value.child.since) || (value.child.reason !== null && typeof value.child.reason !== "string")) throw new Error("invalid child"); child = { alias: value.child.alias, state: value.child.state, since: value.child.since, reason: value.child.reason as string | null }; } - return { role: value.role as RemoteLinkStatusWire["role"], listener: { state: listener.state as LinkListenerState, port: listener.port as number | null }, links, child, joinAvailable: value.joinAvailable === true }; + const joinDenied = LINK_JOIN_DENIALS.includes(value.joinDenied as LinkJoinDenied) ? value.joinDenied as LinkJoinDenied : null; + return { role: value.role as RemoteLinkStatusWire["role"], listener: { state: listener.state as LinkListenerState, port: listener.port as number | null }, links, child, joinAvailable: value.joinAvailable === true, joinDenied }; } /** Read link-route JSON and preserve the server's machine-readable error code. */ diff --git a/gui/tests/local-link-pairing.test.ts b/gui/tests/local-link-pairing.test.ts new file mode 100644 index 00000000000..0488a02eb09 --- /dev/null +++ b/gui/tests/local-link-pairing.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; +import { configureApiTargets, installApiAuthFetch, installApiSessionFromHtml, resetApiAuthFetchForTests } from "../src/api"; +import type { ApiTargets } from "../src/api-targets"; + +const origin = "http://127.0.0.1:10100"; +function sessionHtml(token: string, serverOrigin = origin): string { + return ``; +} + +// TRANSPORT_REGRESSIONS_BEGIN +for (const relay of [false, true]) test(`pairing omits stale shared authentication and preserves the machine boundary (relay: ${relay})`, async () => { + const keys = ["window", "document", "sessionStorage"] as const; + const previous = Object.fromEntries(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const win = new Window({ url: origin }); + const sent: Headers[] = []; + const rawFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sent.push(new Headers(init?.headers)); + return new Response(null, { status: 204 }); + }) as typeof fetch; + Object.defineProperties(globalThis, { + window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, + sessionStorage: { configurable: true, value: win.sessionStorage }, + }); + Object.defineProperty(win, "fetch", { configurable: true, value: rawFetch, writable: true }); + const targets: ApiTargets = { connected: relay, + machine: { id: "machine", baseUrl: "", serverOrigin: origin, bootstrapPath: "/opencodex-session", transport: "same-origin" }, + shared: { id: "shared", baseUrl: relay ? "/api/machine/hub-relay" : "", serverOrigin: relay ? "https://hub.example.test" : origin, + bootstrapPath: relay ? "/api/machine/hub-relay/opencodex-session" : "/opencodex-session", transport: relay ? "relay" : "same-origin" }, + }; + try { + resetApiAuthFetchForTests(); configureApiTargets(targets); installApiAuthFetch(); + expect(installApiSessionFromHtml("machine", sessionHtml("ocx_session_machine"))).toBe(true); + expect(installApiSessionFromHtml("shared", sessionHtml("ocx_session_old", targets.shared.serverOrigin))).toBe(true); + await win.fetch(targets.shared.bootstrapPath, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant: `ocx_pair_${"a".repeat(43)}` }) }); + expect(sent[0]!.get("x-opencodex-api-key")).toBeNull(); + expect(sent[0]!.get("x-opencodex-gui-origin")).toBeNull(); + expect(sent[0]!.get("x-opencodex-csrf-token")).toBeNull(); + expect(sent[0]!.get("x-opencodex-machine-session")).toBe(relay ? "ocx_session_machine" : null); + expect(sent[0]!.get("x-opencodex-machine-csrf-token")).toBe(relay ? "ocx_session_machine-csrf" : null); + await win.fetch(`${targets.shared.baseUrl}/api/link/join`, { method: "POST" }); + expect(sent[1]!.get("x-opencodex-api-key")).toBe("ocx_session_old"); + expect(sent[1]!.get("x-opencodex-csrf-token")).toBe("ocx_session_old-csrf"); + // Explicit mixed credentials are not silently stripped: the server still refuses them. + await win.fetch(targets.shared.bootstrapPath, { method: "POST", headers: { authorization: "Bearer explicit" } }); + expect(sent[2]!.get("authorization")).toBe("Bearer explicit"); + // A successful exchange installs the returned session without reloading into an unpaired bootstrap. + expect(installApiSessionFromHtml("shared", sessionHtml("ocx_session_paired", targets.shared.serverOrigin))).toBe(true); + await win.fetch(`${targets.shared.baseUrl}/api/link/status`); + expect(sent[3]!.get("x-opencodex-api-key")).toBe("ocx_session_paired"); + } finally { + resetApiAuthFetchForTests(); win.close(); + for (const key of keys) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } +}); +// TRANSPORT_REGRESSIONS_END + +test("local pairing form names the local origin, makes no automatic exchange, and waits for explicit submission", async () => { + const keys = ["window", "document", "navigator", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + const previous = Object.fromEntries(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const win = new Window({ url: origin }); + let requests = 0; + Object.defineProperties(globalThis, { + window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, + navigator: { configurable: true, value: win.navigator }, sessionStorage: { configurable: true, value: win.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + Object.defineProperty(win, "fetch", { configurable: true, value: async () => { requests++; return new Response(null, { status: 403 }); } }); + const container = document.createElement("div"); document.body.append(container); + const { LanguageProvider } = await import("../src/i18n/provider"); + const { ConnectPairingForm } = await import("../src/connect-pairing"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container); + try { + await act(async () => root.render(createElement(LanguageProvider, null, createElement(ConnectPairingForm, { + local: true, target: { id: "shared", baseUrl: "", serverOrigin: origin, bootstrapPath: "/opencodex-session", transport: "same-origin" }, + onConnected: () => { throw new Error("unexpected success"); }, + })))); + expect(container.textContent).toContain(`ocx gui pair --origin "${origin}"`); + expect(container.textContent).not.toContain("Connect this dashboard to the hub"); + expect(requests).toBe(0); + expect((container.querySelector('button[type="submit"]') as HTMLButtonElement).disabled).toBe(true); + const input = container.querySelector("#connect-pairing-code") as HTMLInputElement; + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, `ocx_pair_${"a".repeat(43)}`); + await act(async () => { input.dispatchEvent(new win.Event("input", { bubbles: true })); }); + await act(async () => { input.closest("form")!.dispatchEvent(new win.Event("submit", { bubbles: true, cancelable: true })); }); + expect(requests).toBe(1); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + } finally { + await act(async () => root.unmount()); container.remove(); win.close(); + for (const key of keys) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } +}); diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index 65737af682f..b422360d4d7 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -3,7 +3,7 @@ import { Window } from "happy-dom"; import type { Root } from "react-dom/client"; import { act } from "react"; import RemoteLink from "../src/pages/RemoteLink"; -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 { boundLinkHint, CHILD_RESTART_NOTICE_MS, CHILD_RESTART_POLL_MS, CHILD_RESTART_SLOW_POLL_MS, LINK_ERROR_CODES, LINK_JOIN_DENIALS, LinkApiError, parseRemoteLinkStatus, readLinkJson, waitForChildRuntime, type RemoteLinkStatusWire } from "../src/remote-link-api"; import { LanguageProvider } from "../src/i18n/provider"; import { LOCALES } from "../src/i18n/shared"; @@ -74,6 +74,69 @@ test("joinAvailable is true only when the server says exactly true", () => { expect(parseRemoteLinkStatus({ ...baseStatus, joinAvailable: true }).joinAvailable).toBe(true); }); +test("joinDenied accepts only known causes and tolerates legacy or future status responses", () => { + for (const joinDenied of LINK_JOIN_DENIALS) { + expect(parseRemoteLinkStatus({ ...baseStatus, joinDenied }).joinDenied).toBe(joinDenied); + } + for (const joinDenied of [undefined, null, "future_gate", true, 1, {}, []]) { + const parsed = parseRemoteLinkStatus({ ...baseStatus, joinDenied }); + expect(parsed.joinDenied).toBeNull(); + expect(parsed.joinAvailable).toBe(false); + } + expect(parseRemoteLinkStatus(joinableStatus).joinAvailable).toBe(true); +}); + +test.each([ + ["pairing_required", "Pair this machine first to join as a Child.", true], + ["standalone_required", "Child links can only be started from a standalone runtime.", false], + ["join_port_mismatch", "OpenCodex is not running on its configured port", false], + [undefined, "Joining as a Child is unavailable for this dashboard session.", false], + ["future_gate", "Joining as a Child is unavailable for this dashboard session.", false], +] as const)("disabled Child explains %s without issuing a mutation", async (joinDenied, message, showPairing) => { + win.happyDOM.setURL("http://127.0.0.1:10100/#remote"); + declareRuntimeRole("standalone"); + const calls: Array<{ path: string; method: string }> = []; + globalThis.fetch = (async (input, init) => { + calls.push({ path: new URL(String(input)).pathname, method: init?.method ?? "GET" }); + return response({ ...baseStatus, role: "standalone", joinAvailable: false, joinDenied }); + }) as typeof fetch; + const host = await mount({ apiBase: win.location.origin }); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + const radios = [...host.querySelectorAll('[role="radio"]')] as HTMLButtonElement[]; + expect(radios[1]?.getAttribute("aria-disabled")).toBe("true"); + expect(host.textContent).toContain(message); + if (joinDenied !== "join_port_mismatch") expect(host.textContent).not.toContain("OpenCodex is not running on its configured port"); + expect(host.querySelector(".connect-pairing") !== null).toBe(showPairing); + await act(async () => { radios[1]?.click(); }); + for (const key of ["ArrowRight", "ArrowDown", "End"]) { + await act(async () => { radios[0]?.dispatchEvent(new win.KeyboardEvent("keydown", { key, bubbles: true })); }); + } + expect(radios[1]?.getAttribute("aria-checked")).toBe("false"); + expect(radios[1]?.tabIndex).toBe(-1); + expect(calls.every(call => call.path === "/api/link/status" && call.method === "GET")).toBe(true); +}); + +test("refreshing a now-paired status removes pairing guidance without automatically joining", async () => { + win.happyDOM.setURL("http://127.0.0.1:10100/#remote"); + declareRuntimeRole("standalone"); + let current: RemoteLinkStatusWire = { ...baseStatus, role: "standalone", joinDenied: "pairing_required" }; + const calls: Array<{ path: string; method: string }> = []; + globalThis.fetch = (async (input, init) => { + calls.push({ path: new URL(String(input)).pathname, method: init?.method ?? "GET" }); + return response(current); + }) as typeof fetch; + const host = await mount({ apiBase: win.location.origin }); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + expect(host.querySelector(".connect-pairing")).not.toBeNull(); + current = { ...joinableStatus, joinDenied: null }; + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent === "Refresh")?.click(); }); + await flush(); + expect(host.textContent).not.toContain("Pair this machine first"); + expect(host.querySelector(".connect-pairing")).toBeNull(); + expect(host.querySelectorAll('[role="radio"]')[1]?.getAttribute("aria-disabled")).toBe("false"); + expect(calls.every(call => call.path === "/api/link/status" && call.method === "GET")).toBe(true); +}); + test("session gate makes no link request", async () => { const calls: string[] = []; globalThis.fetch = (async input => { calls.push(String(input)); return response(baseStatus); }) as typeof fetch; @@ -125,7 +188,7 @@ test("a standalone off its configured port keeps Child disabled, explains why, a if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.66.0" }); if (path === "/api/link/join") return response({ linkId: "lnk_1234567890abcdef", alias: "home-one", restarting: true }, 202); if (path === "/api/link/apply") return response({ linkId: "lnk_1234567890abcdef" }, 202); - return response({ ...baseStatus, role: "standalone", joinAvailable: false }); + return response({ ...baseStatus, role: "standalone", joinAvailable: false, joinDenied: "join_port_mismatch" }); }) as typeof fetch; const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); diff --git a/src/cli/gui.ts b/src/cli/gui.ts index 9308dad496f..dd87cce1e15 100644 --- a/src/cli/gui.ts +++ b/src/cli/gui.ts @@ -1,5 +1,5 @@ import type { OcxConfig } from "../types"; -import { canonicalGuiBrowserOrigin } from "../lib/gui-pair-capability"; +import { canonicalGuiBrowserOrigin, standaloneGuiPairingOrigin } from "../lib/gui-pair-capability"; import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; import { requestBoundGuiPairingGrant, @@ -23,7 +23,7 @@ export interface GuiCommandDeps extends RuntimeApiDeps { } function allowedPairingOrigin(origin: string, config: OcxConfig): boolean { - if (config.runtimeRole !== "hub") return false; + if (config.runtimeRole !== "hub") return standaloneGuiPairingOrigin(config) === origin; if (canonicalGuiBrowserOrigin(config.hub?.managementPublicOrigin) === origin) return true; return (config.corsAllowOrigins ?? []).some(value => canonicalGuiBrowserOrigin(value) === origin); } @@ -62,7 +62,7 @@ export async function runGuiCommand(args: string[], deps: GuiCommandDeps): Promi } const config = deps.loadConfig(); if (!allowedPairingOrigin(canonicalOrigin, config)) { - console.error("The pairing origin is not enabled by hub.managementPublicOrigin or corsAllowOrigins."); + console.error("Pairing requires the standalone configured literal loopback origin, or an allowed hub origin."); return 1; } const target = await (deps.findLiveProxy ?? findLiveProxy)(); @@ -70,6 +70,11 @@ export async function runGuiCommand(args: string[], deps: GuiCommandDeps): Promi console.error("No running attested OpenCodex proxy is available for GUI pairing."); return 1; } + if ((config.runtimeRole ?? "standalone") === "standalone" + && (target.port !== config.port || (target.hostname ?? "127.0.0.1") !== (config.hostname ?? "127.0.0.1"))) { + console.error("Standalone pairing requires the running proxy to use its configured loopback address and port."); + return 1; + } const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, canonicalOrigin, { ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}), }); diff --git a/src/lib/gui-pair-capability.ts b/src/lib/gui-pair-capability.ts index 9acb526258e..8a8c30f97a2 100644 --- a/src/lib/gui-pair-capability.ts +++ b/src/lib/gui-pair-capability.ts @@ -65,6 +65,23 @@ export function canonicalHttpOrigin(value: unknown): string | null { } } +/** + * Standalone pairing is only for this process's configured literal loopback origin. + * No CORS entry, public hub URL, wildcard bind, alias or client role can widen it. + * The CLI separately checks the attested runtime's actual port before requesting a grant. + */ +export function standaloneGuiPairingOrigin(config: { + runtimeRole?: string; + hostname?: string; + port: number; +}): string | null { + if ((config.runtimeRole ?? "standalone") !== "standalone") return null; + const hostname = config.hostname ?? "127.0.0.1"; + if (hostname !== "127.0.0.1" && hostname !== "::1") return null; + if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) return null; + return new URL(`http://${hostname === "::1" ? "[::1]" : hostname}:${config.port}`).origin; +} + function capabilityPayload( nonce: string, method: string, diff --git a/src/server/gui-session.ts b/src/server/gui-session.ts index 954414639be..daaf5a865f3 100644 --- a/src/server/gui-session.ts +++ b/src/server/gui-session.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import type { OcxConfig } from "../types"; -import { canonicalGuiBrowserOrigin } from "../lib/gui-pair-capability"; +import { canonicalGuiBrowserOrigin, standaloneGuiPairingOrigin } from "../lib/gui-pair-capability"; import { isAllowedManagementOrigin, isApiAuthRequired, @@ -20,6 +20,8 @@ export interface GuiSessionRecord { csrfToken: string; expiresAt: number; issuance: GuiSessionIssuance; + /** Process-local marker; never accepted from a browser. */ + localPairing?: true; } export interface GuiSessionBootstrap extends GuiSessionRecord { @@ -31,6 +33,7 @@ export interface GuiPairingGrantRecord { browserOrigin: string; expiresAt: number; failedAttempts?: number; + localPairing?: true; } export interface PairingAttemptContext { @@ -131,6 +134,7 @@ function mintSession( issuance: GuiSessionIssuance, state: GuiSessionState, now: number, + localPairing = false, ): GuiSessionBootstrap { pruneExpired(state, now); evictOldestSession(state); @@ -142,8 +146,9 @@ function mintSession( serverOrigin, browserOrigin, csrfToken: randomBytes(32).toString("base64url"), - expiresAt: now + (issuance === "loopback" ? LOOPBACK_GUI_SESSION_TTL_MS : REMOTE_GUI_SESSION_TTL_MS), + expiresAt: now + (issuance === "loopback" || localPairing ? LOOPBACK_GUI_SESSION_TTL_MS : REMOTE_GUI_SESSION_TTL_MS), issuance, + ...(localPairing ? { localPairing: true as const } : {}), }; state.sessions.set(token, session); return { @@ -258,13 +263,16 @@ export function createGuiPairingGrant( now = Date.now(), ): { grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number } { const canonicalBrowserOrigin = canonicalGuiBrowserOrigin(browserOrigin); - const serverOrigin = canonicalHttpOrigin(config.hub?.managementPublicOrigin); + const localOrigin = standaloneGuiPairingOrigin(config); + const localPairing = localOrigin !== null && canonicalBrowserOrigin === localOrigin; + const serverOrigin = config.runtimeRole === "hub" + ? canonicalHttpOrigin(config.hub?.managementPublicOrigin) : localOrigin; if ( - config.runtimeRole !== "hub" - || !canonicalBrowserOrigin + !canonicalBrowserOrigin || canonicalBrowserOrigin !== browserOrigin || !serverOrigin - || !isRemoteGuiBrowserOriginAllowed(canonicalBrowserOrigin, config) + || (config.runtimeRole === "hub" + ? !isRemoteGuiBrowserOriginAllowed(canonicalBrowserOrigin, config) : !localPairing) ) throw new TypeError("remote GUI origin is not allowed"); pruneExpired(state, now); consumeGrantRateSlot(state, now); @@ -276,7 +284,8 @@ export function createGuiPairingGrant( digest = pairingGrantDigest(grant); } while (state.pairingGrants.has(digest)); const expiresAt = now + GUI_PAIRING_GRANT_TTL_MS; - state.pairingGrants.set(digest, { browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt }); + state.pairingGrants.set(digest, { browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt, + ...(localPairing ? { localPairing: true as const } : {}) }); return { grant, browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt }; } @@ -316,7 +325,8 @@ export function consumeGuiPairingGrant( now = Date.now(), attemptContext?: PairingAttemptContext, ): GuiSessionBootstrap | PairingAttemptRefusal | null { - if (req.method !== "POST" || hasAlternateCredential(req) || config.runtimeRole !== "hub") return null; + const localOrigin = standaloneGuiPairingOrigin(config); + if (req.method !== "POST" || hasAlternateCredential(req) || (config.runtimeRole !== "hub" && !localOrigin)) return null; // Scheme check FIRST, before the grant is parsed or looked up. // // A grant is single-use, so consuming one and then refusing to mint would burn the @@ -333,6 +343,11 @@ export function consumeGuiPairingGrant( const grant = strictPairingGrantBody(body); const browserOrigin = canonicalGuiBrowserOrigin(req.headers.get("Origin")); if (!grant || !browserOrigin) return null; + // Unlike the hub path, standalone redemption never accepts a proxy-origin claim: + // both origins must be the configured loopback origin and the kernel peer must be local. + if (localOrigin && (destination !== localOrigin || browserOrigin !== localOrigin + || attemptContext?.ingress !== "public" + || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(attemptContext.peerAddress ?? ""))) return null; const context = attemptContext ?? { ingress: "public", peerAddress: null, @@ -344,11 +359,14 @@ export function consumeGuiPairingGrant( const found = findPairingGrant(grant, state); if (!found) { // Cross-origin browser requests must not create limiter state as a side effect. - if (!isRemoteGuiBrowserOriginAllowed(browserOrigin, config)) return null; + if (localOrigin ? browserOrigin !== localOrigin : !isRemoteGuiBrowserOriginAllowed(browserOrigin, config)) return null; const source = recordSourceFailure(state, context, now); return attemptContext && !source.allowed ? source : null; } const [digest, record] = found; + // A live role/config change must not turn a hub grant into local operator consent, or vice versa. + if (record.localPairing ? !localOrigin || record.serverOrigin !== localOrigin + : config.runtimeRole !== "hub") return null; if (record.expiresAt <= now) { state.pairingGrants.delete(digest); return null; @@ -369,7 +387,7 @@ export function consumeGuiPairingGrant( // the session is actually minted from. if (!isPairingTransportPermitted(record.serverOrigin)) return null; state.pairingGrants.delete(digest); - return mintSession(record.serverOrigin, record.browserOrigin, "pairing", state, now); + return mintSession(record.serverOrigin, record.browserOrigin, "pairing", state, now, record.localPairing === true); } /** @@ -420,6 +438,10 @@ export function authorizeGuiSessionRequest( state.sessions.delete(token); return { ok: false, reason: "expired" }; } + if (session.localPairing && standaloneGuiPairingOrigin(config) !== session.serverOrigin) { + state.sessions.delete(token); + return { ok: false, reason: "server-origin" }; + } if (managementRequestOrigin(req, config) !== session.serverOrigin) { return { ok: false, reason: "server-origin" }; } @@ -435,6 +457,6 @@ export function authorizeGuiSessionRequest( const csrf = req.headers.get("x-opencodex-csrf-token")?.trim(); if (!csrf || !equalSecret(csrf, session.csrfToken)) return { ok: false, reason: "csrf" }; } - if (session.issuance !== "loopback") session.expiresAt = now + REMOTE_GUI_SESSION_TTL_MS; + if (session.issuance !== "loopback" && !session.localPairing) session.expiresAt = now + REMOTE_GUI_SESSION_TTL_MS; return { ok: true, principal: "gui-session", session }; } diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index 4cec62bb8df..5c1cf203c01 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -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. Only hub runtimes issue these. */ +/** A GUI session redeemed from an operator-created, one-use pairing grant. */ function pairedSession(ctx: ManagementContext): boolean { return ctx.principal === "gui-session" && ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true; @@ -96,9 +96,8 @@ 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. 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. + * secret-backed boundary like the admin token. Hubs keep the paired-only rule. Durable operations + * that require operator approval, such as joining this machine as a Child, use pairedSession. */ function dashboardSession(ctx: ManagementContext): boolean { if (pairedSession(ctx)) return true; @@ -113,9 +112,10 @@ function adminLoopback(ctx: ManagementContext): boolean { return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress; } -function auth(ctx: ManagementContext, kind: "dashboard" | "admin" | "either"): Response | null { +function auth(ctx: ManagementContext, kind: "dashboard" | "paired" | "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 === "dashboard" ? dashboardSession(ctx) + : kind === "paired" ? pairedSession(ctx) : kind === "admin" ? adminLoopback(ctx) : dashboardSession(ctx) || adminLoopback(ctx); return allowed ? null : fail("forbidden", "The required link authorization was not present.", 403); @@ -131,9 +131,14 @@ function joinPortMatches(ctx: ManagementContext): boolean { 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 dashboardSession(ctx) && (ctx.config.runtimeRole ?? "standalone") === "standalone" && joinPortMatches(ctx); +type LinkJoinDenied = "pairing_required" | "standalone_required" | "join_port_mismatch"; + +/** Read-only explanation of the join gates, in the same order as `POST /api/link/join`. */ +function joinDenied(ctx: ManagementContext): LinkJoinDenied | null { + if (!pairedSession(ctx)) return "pairing_required"; + if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return "standalone_required"; + if (!joinPortMatches(ctx)) return "join_port_mismatch"; + return null; } function runnerFor(ctx: ManagementContext): SshRunner { @@ -549,9 +554,9 @@ 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") { - // 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"); + // Joining durably redirects local client traffic, so a credentiallessly bootstrapped loopback + // session is insufficient. Every refusal below runs before link state and before any SSH. + const denied = auth(ctx, "paired"); if (denied) return denied; if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return fail("standalone_required", "Client initiated links require standalone runtime mode.", 409); if (!joinPortMatches(ctx)) return fail("join_port_mismatch", "OpenCodex is not running on its configured port, so it cannot restart as a Child.", 409); @@ -578,9 +583,11 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L const failure = state.compensationFailures?.get(store.links[0].id); if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason }); } - // A dashboard session also learns whether it may join as a Child. The admin-token answer stays + // A dashboard session also learns why it cannot join as a Child. The admin-token answer stays // the exact K16 document that `ocx link status` validates key by key. - const body: LinkStatusDto & { joinAvailable?: boolean } = ctx.principal === "gui-session" ? { ...dto, joinAvailable: joinAvailable(ctx) } : dto; + const denial = ctx.principal === "gui-session" ? joinDenied(ctx) : null; + const body: LinkStatusDto & { joinAvailable?: boolean; joinDenied?: LinkJoinDenied | null } = ctx.principal === "gui-session" + ? { ...dto, joinAvailable: denial === null, joinDenied: denial } : dto; return Response.json(body, { headers: { "cache-control": "no-store" } }); } if (url.pathname === "/api/link/candidates" && req.method === "GET") { diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 891ff528ca1..f2441b824da 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -389,7 +389,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 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/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 requires an operator-paired dashboard session and a pre-join notice; unpaired standalone loopback, 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/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index d543d2a9ef3..9ae2019fe9b 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -268,7 +268,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 eight 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 keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning` accepts a boolean or `null` to clear it. 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. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. Inventory already drifted from disk uses a detached policy projection: arrivals remain disabled in management rows without changing live config or disk. | | 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`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. 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 `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. | +| 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` 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` instead requires an operator-paired session because it durably redirects local client traffic, and 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` and typed `joinDenied` causes only to GUI-session callers, distinguishing pairing, runtime-role and port refusals without changing the admin/CLI DTO, so the dashboard enables the Child role only when the join gates pass; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | diff --git a/structure/remote-link.md b/structure/remote-link.md index d01ea610995..14fa5629a6c 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 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/server/management/link-routes.ts` accepts `POST /api/link/join` with exactly `{ "alias": string }`. Because joining durably redirects local client traffic, the route requires an operator-paired dashboard session; a credentiallessly bootstrapped loopback session may inspect and confirm a host but cannot join. 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 }`. During enrollment, `src/client/link-join.ts` watches the spawned SSH tunnel through its 100 ms spawn grace, readiness checks and the connection attempt. Before an unauthenticated `/readyz` probe and again before the keyed request, the only LISTEN owner serving `127.0.0.1:` must be that tunnel PID. Both requests use `redirect: "manual"`; only a 401 challenge permits the keyed request. A failed, empty, foreign or ambiguous ownership recheck withholds the key and reaches the same 15-second deadline check and up-to-100 ms polling delay as any other not-ready iteration. Repeated recheck failures therefore reach rollback instead of bypassing it. The deadline is checked between operations, not an independent per-fetch cancellation timer. An observed tunnel exit winning the readiness or connection race fails the join and runs compensation. @@ -36,9 +36,9 @@ The client tunnel pidfile is `/link/client-tunnel.pid` with `{ versio ## Dashboard admission -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. +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` 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. `POST /api/link/join` is stricter and requires the paired session because it durably redirects this machine's client traffic. 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 it cannot authorize joining and why `src/client/machine-listener.ts` keeps durable machine changes on the connected listener away from that session. The remaining link routes 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 an operator-created pairing grant or the admin token. -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 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 paired dashboard session on a standalone runtime whose live port is its configured port, and `joinDenied`, which is `null` when allowed or the first failed gate (`pairing_required`, `standalone_required`, then `join_port_mismatch`). An admin-token caller gets the exact K16 document without either field, because `ocx link status` validates it key by key. The dashboard reads an absent availability field as false and an absent or unknown denial as generic unavailability; while unavailable the Child role card cannot be selected by pointer or keyboard. A pairing refusal says to pair this machine first and offers code entry only on the existing same-origin literal-loopback transport. A port refusal instead asks the operator to restart on the configured port; neither status reads nor displaying this guidance mint a grant or change pairing, keys, routing, or runtime state. 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. diff --git a/tests/gui/gui-pair-client.test.ts b/tests/gui/gui-pair-client.test.ts index 7e1809554d4..94fef8e70f1 100644 --- a/tests/gui/gui-pair-client.test.ts +++ b/tests/gui/gui-pair-client.test.ts @@ -1,4 +1,10 @@ -import { describe, expect, test } from "bun:test"; +import type { OcxConfig } from "../../src/types"; +import { runGuiCommand } from "../../src/cli/gui"; +import { createGuiPairingGrant, consumeGuiPairingGrant, issueGuiSession, authorizeGuiSessionRequest } from "../../src/server/gui-session"; +import { createManagementSessionControl, managementPrincipal, managementSessionIssuance, requireManagementAuth, type ManagementAuthState } from "../../src/server/management-auth"; +import { handleLinkRoutes, type LinkRouteState } from "../../src/server/management/link-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import { describe, expect, spyOn, test } from "bun:test"; import { requestBoundGuiPairingGrant } from "../../src/cli/gui-pair-client"; import { LOCAL_ATTESTATION_CHALLENGE_HEADER, @@ -160,3 +166,166 @@ describe("GUI pairing client", () => { expect(JSON.stringify(rejected)).not.toContain("secret-must-not-surface"); }); }); + +const localConfig: OcxConfig = { port: 10100, hostname: "127.0.0.1", runtimeRole: "standalone", defaultProvider: "test", providers: {} }; +const localOrigin = "http://127.0.0.1:10100"; +function pairingState(): Extract { + return { available: true, token: `ocx_admin_${"D".repeat(43)}`, source: "environment", sessions: new Map(), pairingGrants: new Map() }; +} +function pairingRequest(origin = localOrigin, extra: Record = {}): Request { + return new Request(`${origin}/opencodex-session`, { method: "POST", headers: { Host: new URL(origin).host, Origin: origin, ...extra } }); +} +const localAttempt = { ingress: "public" as const, peerAddress: "127.0.0.1", tailscaleUser: null, browserOrigin: localOrigin }; + +// PURE_PAIRING_REGRESSIONS_BEGIN + +describe("standalone one-use pairing boundaries", () => { + test("mint, redeem once, enforce CSRF and expire without sliding", () => { + const state = pairingState(), now = Date.now(); + const created = createGuiPairingGrant(localOrigin, localConfig, state, now); + expect(created.serverOrigin).toBe(localOrigin); + expect(state.pairingGrants.has(created.grant)).toBe(false); + const session = consumeGuiPairingGrant(pairingRequest(), { grant: created.grant }, localConfig, state, now, localAttempt); + expect(session).toMatchObject({ issuance: "pairing", serverOrigin: localOrigin, browserOrigin: localOrigin }); + if (!session || "allowed" in session) throw new Error("expected a real redeemed session"); + expect(consumeGuiPairingGrant(pairingRequest(), { grant: created.grant }, localConfig, state, now, localAttempt)).toBeNull(); + const request = (csrf?: string) => new Request(`${localOrigin}/api/link/join`, { method: "POST", headers: { + Host: "127.0.0.1:10100", Origin: localOrigin, "x-opencodex-gui-origin": localOrigin, + "x-opencodex-api-key": session.token, ...(csrf ? { "x-opencodex-csrf-token": csrf } : {}), + } }); + expect(authorizeGuiSessionRequest(request(), localConfig, state, now).ok).toBe(false); + expect(authorizeGuiSessionRequest(request(session.csrfToken), localConfig, state, now + 1000).ok).toBe(true); + expect(state.sessions.get(session.token)?.expiresAt).toBe(now + 300000); + expect(authorizeGuiSessionRequest(request(session.csrfToken), localConfig, state, now + 300000).ok).toBe(false); + }); + test("wrong address, alternate credentials, missing peer and nonlocal peer cannot burn a valid code", () => { + const state = pairingState(), now = Date.now(); + const created = createGuiPairingGrant(localOrigin, localConfig, state, now); + for (const peerAddress of [null, "", "192.0.2.3"]) { + expect(consumeGuiPairingGrant(pairingRequest(), { grant: created.grant }, localConfig, state, now, + { ...localAttempt, peerAddress })).toBeNull(); + } + expect(consumeGuiPairingGrant(pairingRequest(), { grant: created.grant }, localConfig, state, now)).toBeNull(); + expect(consumeGuiPairingGrant(pairingRequest("http://127.0.0.1:10101"), { grant: created.grant }, localConfig, state, now, localAttempt)).toBeNull(); + expect(consumeGuiPairingGrant(pairingRequest(localOrigin, { Origin: "https://foreign.example.test" }), { grant: created.grant }, localConfig, state, now, localAttempt)).toBeNull(); + for (const name of ["authorization", "x-opencodex-api-key", "x-api-key"]) { + expect(consumeGuiPairingGrant(pairingRequest(localOrigin, { [name]: "old-credential" }), { grant: created.grant }, localConfig, state, now, localAttempt)).toBeNull(); + } + expect(state.pairingGrants.size).toBe(1); + expect(consumeGuiPairingGrant(pairingRequest(), { grant: created.grant }, localConfig, state, now, + { ...localAttempt, peerAddress: "::ffff:127.0.0.1" })).toMatchObject({ issuance: "pairing" }); + }); + test("CORS and hub hints cannot widen the standalone mint", () => { + for (const origin of ["http://localhost:10100", "http://127.0.0.1:10101", "https://127.0.0.1:10100", "https://foreign.example.test", "http://127.0.0.1:10100/"]) { + const state = pairingState(); + expect(() => createGuiPairingGrant(origin, { ...localConfig, corsAllowOrigins: [origin], hub: { managementPublicOrigin: origin } }, state)).toThrow(); + expect(state.pairingGrants.size).toBe(0); + } + for (const config of [{ ...localConfig, hostname: "0.0.0.0" }, { ...localConfig, hostname: "localhost" }, + { ...localConfig, runtimeRole: "client" as const }, { ...localConfig, port: 0 }]) { + expect(() => createGuiPairingGrant(localOrigin, config, pairingState())).toThrow(); + } + }); + test("default standalone and IPv6 use the same exact-origin contract", () => { + for (const [config, origin, peerAddress] of [ + [{ ...localConfig, runtimeRole: undefined, hostname: undefined }, localOrigin, "127.0.0.1"], + [{ ...localConfig, hostname: "::1" }, "http://[::1]:10100", "::1"], + ] as const) { + const state = pairingState(), created = createGuiPairingGrant(origin, config, state); + expect(consumeGuiPairingGrant(pairingRequest(origin), { grant: created.grant }, config, state, Date.now(), + { ...localAttempt, browserOrigin: origin, peerAddress })).toMatchObject({ issuance: "pairing" }); + } + }); + test("role changes cannot convert grants and invalidate local paired sessions", () => { + const state = pairingState(), now = Date.now(); + const hub = { ...localConfig, runtimeRole: "hub" as const, hub: { managementPublicOrigin: localOrigin } }; + const localGrant = createGuiPairingGrant(localOrigin, localConfig, state, now); + expect(consumeGuiPairingGrant(pairingRequest(), { grant: localGrant.grant }, hub, state, now, localAttempt)).toBeNull(); + const hubGrant = createGuiPairingGrant(localOrigin, hub, state, now); + expect(consumeGuiPairingGrant(pairingRequest(), { grant: hubGrant.grant }, localConfig, state, now, localAttempt)).toBeNull(); + const session = consumeGuiPairingGrant(pairingRequest(), { grant: localGrant.grant }, localConfig, state, now, localAttempt); + if (!session || "allowed" in session) throw new Error("expected local pairing"); + const request = new Request(`${localOrigin}/api/link/status`, { headers: { Host: "127.0.0.1:10100", + "x-opencodex-api-key": session.token, "x-opencodex-gui-origin": localOrigin } }); + expect(authorizeGuiSessionRequest(request, hub, state, now).ok).toBe(false); + expect(state.sessions.has(session.token)).toBe(false); + }); + test("remote hub pairing remains origin-bound with its existing lifetime", () => { + const state = pairingState(), now = Date.now(), origin = "https://hub.example.test"; + const config: OcxConfig = { ...localConfig, runtimeRole: "hub", hostname: "0.0.0.0", hub: { managementPublicOrigin: origin } }; + const created = createGuiPairingGrant(origin, config, state, now); + const session = consumeGuiPairingGrant(pairingRequest(origin), { grant: created.grant }, config, state, now); + expect(session).toMatchObject({ issuance: "pairing", expiresAt: now + 12 * 60 * 60000 }); + }); + test("ordinary local bootstrap remains unpaired", () => { + const state = pairingState(); + const session = issueGuiSession(new Request(`${localOrigin}/opencodex-session`, { + headers: { Host: "127.0.0.1:10100", Origin: localOrigin }, + }), localConfig, state); + expect(session).toMatchObject({ issuance: "loopback" }); + expect(state.pairingGrants.size).toBe(0); + }); +}); + +// PURE_PAIRING_REGRESSIONS_END + +test("operator CLI proof -> one-use grant -> real paired session -> guarded join, without an isPaired stub", async () => { + const state = pairingState(), output: string[] = []; + const log = spyOn(console, "log").mockImplementation(value => { output.push(String(value)); }); + const local = { attestationSecret: secret, pid: target.pid!, port: target.port }; + let joins = 0; + try { + const result = await runGuiCommand(["pair", "--origin", localOrigin, "--json"], { + loadConfig: () => localConfig, findLiveProxy: async () => target, openDefaultGui: async () => 0, + requestPairingGrant: (runtime, origin) => requestBoundGuiPairingGrant(runtime, origin, { + readRuntime: () => ({ ...target, attestationSecret: secret }), + fetchImpl: (async (input, init) => { + if (!init?.method) return proofResponse(init); + const req = new Request(input, init); + expect(requireManagementAuth(req, state, localConfig, local)).toBeNull(); + expect(managementPrincipal(req, state, localConfig, local)).toBe("gui-pair-capability"); + return Response.json(createGuiPairingGrant(req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER)!, localConfig, state), { status: 201 }); + }) as typeof fetch, + }), + }); + expect(result).toBe(0); expect(output).toHaveLength(1); + const grant = JSON.parse(output[0]!).grant; + const paired = consumeGuiPairingGrant(pairingRequest(), { grant }, localConfig, state, Date.now(), localAttempt); + if (!paired || "allowed" in paired) throw new Error("operator grant was not redeemed"); + const ordinary = issueGuiSession(new Request(`${localOrigin}/`, { headers: { Host: "127.0.0.1:10100" } }), localConfig, state)!; + const sessionControl = createManagementSessionControl(state); + const routeState: LinkRouteState = { pendingHosts: new Map(), confirmedHosts: new Map([["home", { + alias: "home", fingerprint: `SHA256:${"a".repeat(32)}`, keyType: "ed25519", knownHostLine: "home ssh-ed25519 AAAA", probedAt: Date.now(), ocxVersion: "2.71.0", + }]]), supervisor: {} as LinkRouteState["supervisor"], listener: {} as LinkRouteState["listener"] }; + for (const [session, expected] of [[ordinary, 403], [paired, 202]] as const) { + const req = new Request(`${localOrigin}/api/link/join`, { method: "POST", headers: { + Host: "127.0.0.1:10100", Origin: localOrigin, "content-type": "application/json", "x-opencodex-api-key": session.token, + "x-opencodex-gui-origin": localOrigin, "x-opencodex-csrf-token": session.csrfToken, + }, body: JSON.stringify({ alias: "home" }) }); + expect(requireManagementAuth(req, state, localConfig)).toBeNull(); + const ctx: ManagementContext = { req, url: new URL(req.url), config: localConfig, version: "test", + principal: managementPrincipal(req, state, localConfig) ?? undefined, sessionControl, + trustedLoopbackIngress: true, guiSessionIssuance: managementSessionIssuance(req, state), + deps: { liveListenPort: () => localConfig.port, linkKnownHostsPath: () => "/unused/known_hosts", + sshRunner: { run: async () => { throw new Error("unexpected SSH"); } } as never, ...{ joinHome: async () => { joins++; return { linkId: "lnk_0123456789abcdef", apiKeyId: "link-key-1" }; } } }, + convergeCodexCatalog: async () => ({ status: "unchanged" } as never), syncClaudeAgentDefsBestEffort: async () => {}, + }; + expect((await handleLinkRoutes(ctx, routeState))?.status).toBe(expected); + } + expect(joins).toBe(1); + } finally { log.mockRestore(); } +}); + +test("standalone CLI rejects actual runtime address/port mismatch before requesting a grant", async () => { + let requests = 0; + const log = spyOn(console, "error").mockImplementation(() => {}); + try { + for (const runtime of [{ ...target, port: 10101 }, { ...target, hostname: "0.0.0.0" }]) { + expect(await runGuiCommand(["pair", "--origin", localOrigin], { loadConfig: () => localConfig, + findLiveProxy: async () => runtime, openDefaultGui: async () => 0, + requestPairingGrant: async () => { requests++; return { kind: "unavailable", reason: "rejected" }; }, + })).toBe(1); + } + expect(requests).toBe(0); + } finally { log.mockRestore(); } +}); diff --git a/tests/server/link-join-route.test.ts b/tests/server/link-join-route.test.ts index 4736a9607a6..e09b69c607f 100644 --- a/tests/server/link-join-route.test.ts +++ b/tests/server/link-join-route.test.ts @@ -151,16 +151,13 @@ describe("client initiated link join", () => { } }); - 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. + test("requires an operator-paired dashboard session before joining", async () => { 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); + const credentialless = await handleLinkRoutes(context({ ...loopback, trustedLoopback: true }), routeState()); + expect(credentialless?.status).toBe(403); + expect(joins).toBe(0); for (const options of [ { role: "standalone" as const, trustedLoopback: true, current: false }, @@ -175,11 +172,11 @@ describe("client initiated link join", () => { 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); + expect(joins).toBe(0); const paired = await handleLinkRoutes(context({ principal: "gui-session", issuance: "pairing", paired: true, deps }), routeState()); expect(paired?.status).toBe(202); - expect(joins).toBe(2); + expect(joins).toBe(1); }); test("refuses a join whose restart could not bind the configured port, before any SSH", async () => { @@ -191,7 +188,7 @@ describe("client initiated link join", () => { }; for (const livePort of [CONFIG_PORT + 1, undefined]) { const response = await handleLinkRoutes({ - ...context({ principal: "gui-session", issuance: "loopback", deps }), + ...context({ principal: "gui-session", issuance: "pairing", paired: true, deps }), deps: { ...deps, liveListenPort: () => livePort }, }, routeState()); expect(response?.status).toBe(409); diff --git a/tests/server/link-management-routes.test.ts b/tests/server/link-management-routes.test.ts index 3e753bb18d0..63f4b721c02 100644 --- a/tests/server/link-management-routes.test.ts +++ b/tests/server/link-management-routes.test.ts @@ -184,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: true }); + expect(await status!.json()).toMatchObject({ role: "standalone", joinAvailable: false, joinDenied: "pairing_required" }); 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" }] }); @@ -193,14 +193,15 @@ 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); - // The same session may also turn this computer into a Child: join reaches the join step. + // Credentialless loopback sessions may inspect and confirm a host, but cannot commit the + // durable routing change that turns this computer into a Child. 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(joined?.status).toBe(403); + expect(joins).toBe(0); expect((await sessionCall(`${base}/api/link/join`, headers, state, cfg, joinDeps, true, "POST", { alias: "home" }))?.status).toBe(403); - expect(joins).toBe(1); + expect(joins).toBe(0); // 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" }); @@ -242,26 +243,36 @@ describe("link management routes", () => { expect(await refused!.json()).toMatchObject({ error: { code: "tailscale_session_refused" } }); }); - test("status tells a dashboard session whether it may join and keeps the admin-token DTO exact", async () => { + test("status explains join gates without mutations and keeps the admin-token DTO exact", async () => { temp = mkdtempSync(join(tmpdir(), "ocx-link-join-available-")); const h = harness(); const standalone = { ...h.config, runtimeRole: "standalone" } as OcxConfig; const paired = await call("/api/link/status", "GET", undefined, h.deps, "gui-session", true, "pairing", true, standalone); expect(paired?.status).toBe(200); - expect(await paired!.json()).toMatchObject({ role: "standalone", joinAvailable: true }); + expect(await paired!.json()).toMatchObject({ role: "standalone", joinAvailable: true, joinDenied: null }); + expect(paired!.headers.get("cache-control")).toBe("no-store"); 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. + expect(await hub!.json()).toMatchObject({ joinAvailable: false, joinDenied: "standalone_required" }); + const client = await call("/api/link/status", "GET", undefined, h.deps, "gui-session", true, "pairing", true, { ...standalone, runtimeRole: "client" } as OcxConfig); + expect(await client!.json()).toMatchObject({ joinAvailable: false, joinDenied: "standalone_required" }); + // A credentialless local session cannot join even on the configured 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 }); + expect(await loopback!.json()).toMatchObject({ role: "standalone", joinAvailable: false, joinDenied: "pairing_required" }); 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 }); + expect(await moved!.json()).toMatchObject({ joinAvailable: false, joinDenied: "pairing_required" }); 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. + expect(await unknownPort!.json()).toMatchObject({ joinAvailable: false, joinDenied: "pairing_required" }); + // Pairing takes precedence; only a paired standalone is told to fix the listening port. + for (const liveListenPort of [() => 10200, () => undefined]) { + const portDenied = await call("/api/link/status", "GET", undefined, { ...h.deps, liveListenPort }, "gui-session", true, "pairing", true, standalone); + expect(await portDenied!.json()).toMatchObject({ joinAvailable: false, joinDenied: "join_port_mismatch" }); + } + // `ocx link status` validates the admin-token answer key by key, so it gains neither 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"]); + expect(h.events).toEqual([]); + expect(standalone.apiKeys).toEqual([]); + expect(h.store.links).toEqual([]); }); test("confirm-host keeps the parsed remote version to a bounded semver shape", async () => {