diff --git a/devlog/_plan/260925_remote_home_child_link/050_wp5_remote_gui.md b/devlog/_plan/260925_remote_home_child_link/050_wp5_remote_gui.md index 40f875627a..692f91cdfe 100644 --- a/devlog/_plan/260925_remote_home_child_link/050_wp5_remote_gui.md +++ b/devlog/_plan/260925_remote_home_child_link/050_wp5_remote_gui.md @@ -318,7 +318,7 @@ gui/src/fetch-json.ts:28-43의 error boundary를 따른다. | candidate/manual alias probe | POST /api/link/probe | { alias } | 응답 { alias, fingerprint, keyType }을 confirmation view에 고정. ocxVersion은 사용자가 지문을 확인한 뒤 POST /api/link/confirm-host 응답 { alias, fingerprint, ocxVersion }에서만 받아 표시 | | fingerprint confirm | POST /api/link/confirm-host | { alias, fingerprint } | 확인된 alias를 apply 단계로 전환 | | apply | POST /api/link/apply | { alias } | 즉시 status polling; 성공 toast와 connected row | -| disconnect | DELETE /api/link/{id} | 없음 | status 재조회; 204/JSON 양쪽의 성공 envelope을 허용 | +| disconnect | DELETE /api/link/{id} | 없음 | status 재조회; 204/JSON (무효: 감사 반영 Averroes 절 참조) 양쪽의 성공 envelope을 허용 | 확인 전에는 apply를 호출하지 않는다. probe 오류, malformed JSON, 401/403, 409, 5xx는 모두 localized error와 retry path로 매핑한다. HTTP error message를 logic discriminator로 사용하지 않는다. @@ -581,13 +581,13 @@ headroom을 계산하지 않고, locale parity와 bun run lint:i18n을 gate로 | fingerprint cancel | confirmation에서 cancel | sheet가 alias step으로 돌아가고 confirm-host/apply 호출 0 | | confirm-host 성공 | checkbox + confirm | POST body가 { alias, fingerprint }; apply 전 confirmed view 유지 | | confirm-host 실패 | non-2xx | localized confirm error, apply disabled | -| apply 성공 | apply 201/200 후 status connected | POST { alias }, polling 결과 connected dot/label과 row 생성 | +| apply 성공 | apply 201/200 (무효: 감사 반영 Averroes 절 참조) 후 status connected | POST { alias }, polling 결과 connected dot/label과 row 생성 | | apply timeout/failure | request reject 또는 failed status | failed label, retry visible, silent fallback 없음 | | reconnecting | `links[].state = "reconnecting"` | amber label/live region, request action remains disabled or retry-only | | reconnecting → connected | next poll connected | green label and connected row | | reconnecting → failed | next poll failed | red label and actionable retry | | disconnect confirm cancel | confirm dialog negative | DELETE 호출 0, row 유지 | -| disconnect success | confirm + DELETE 204/JSON | DELETE /api/link/{id}, row 제거, last row면 off state | +| disconnect success | confirm + DELETE 204/JSON (무효: 감사 반영 Averroes 절 참조) | DELETE /api/link/{id}, row 제거, last row면 off state | | disconnect failure | DELETE non-2xx | error notice, row/status 유지 | | workspace unavailable | App availability false + #remote-workspace | nav entry와 component가 없고 localized unavailable copy만 표시 | | workspace available | App availability true | #remote-workspace nav row와 existing component가 표시 | @@ -761,3 +761,73 @@ routes and does not weaken them”을 명시한다. MAINTAINERS.md:11의 securit - `role`별 열 개 locale label과 link/listener state별 status dot mapping을 추가했다. - 기존 실행 역할(`hub`/`client`) wire 가정과 status의 `hostKeyFingerprint`/`ocxVersion` 가정을 제거했다. (Pauli r2 반영) probe 응답은 K16대로 `{alias, fingerprint, keyType}`이고 `ocxVersion`은 confirm-host 응답에서만 받는다. + +## wp5 P 재검증 (아키텍트 Descartes, gpt-6-sol high, 2026-09-25) — 이 절이 앞선 내용보다 우선한다 + +| ID | 제안 | 처분 | +|---|---|---| +| W5-1 | Remote Link 활성 조건은 `sharedSessionReady`만(standalone은 targets.connected=false, api-targets.ts:117-124) | 수용 | +| W5-2 | 권한 표: candidates/probe/confirm-host/apply는 페어링 대시보드 세션만, status·DELETE는 세션 또는 신뢰 루프백 관리자 토큰(link-routes.ts:388-434) | 수용. GUI는 세션 경로만 쓴다 | +| W5-3 | 응답 모양: candidates `{candidates:[...]}`, apply 202 `{linkId}`, DELETE 200 `{linkId}`, `child.state`는 `LinkWireState`(status-projection.ts:19-24) | 수용 | +| W5-4 | App의 가용성 조회가 RemoteWorkspace 3초 폴링(RemoteWorkspace.tsx:87-97)과 겹침 | 결정: App은 원격 워크스페이스 가용성을 마운트와 경로 변경 시 한 번만 조회한다. 3초 폴링은 RemoteWorkspace 화면이 열려 있을 때만 기존대로. Remote Link 화면은 자기 status를 5초 간격으로, 화면이 보일 때만 폴링 | +| W5-5 | gui/tests/locale-parity.test.ts:3이 vi를 빠뜨림 | 수용: 목록을 `LOCALES`에서 파생 | +| W5-6 | 번역된 docs 트리 7개(astro.config.mjs:64-72)에 Remote Workspace 번역본이 있음 | 결정: 영어 원문 + 7개 언어 번역 페이지를 모두 추가하고 사이드바에 등록. 자식 흐름은 wp6 전까지 "준비 중" 문장 | +| W5-7 | #remote → Remote Link, #remote-workspace 분리 | 유지 | +| W5-8 | Switch·Notice·확인 대화상자 재사용, 추가 시트는 네이티브 ``(Escape, 포커스 가두기·복원, OAuthTosWarningModal.tsx:34-70 관행) | 수용 | +| W5-9 | 스크린샷 절차 | 결정: `gui/scripts/remote-link-fixture.ts`(Bun 서버: `gui/dist` 정적 제공 + 고정 `/api/link/*`·세션·워크스페이스 응답, 상태별 쿼리 스위치 off/role/home-connected/add-sheet/fingerprint)를 만들고, agbrowse로 1440×900과 390×844를 찍는다. 이미지는 저장소 브랜치에 커밋하지 않고 `pr-assets` 브랜치에 올려 커밋 SHA로 링크(AGENTS.md 규칙). 픽스처 스크립트는 저장소에 남겨 재현 가능하게 한다 | + +- 반영 확인(Descartes MISALIGNED 2건): + - W5-4 확정: App의 효과는 `useEffect(..., [page, sharedSessionReady, sharedBase])`. `sharedSessionReady`가 false면 조회하지 않고 원격 워크스페이스 내비 항목을 숨긴다. 조회 실패도 숨김(가용성 불명 = 비노출). 페이지 이동마다 한 번, 중복 요청은 진행 중 요청을 재사용. + - W5-9 확정: 파일 변경 지도에 NEW `gui/scripts/remote-link-fixture.ts` 추가. 계약: `bun gui/scripts/remote-link-fixture.ts --port `이 `gui/dist`를 제공하고, `GET /opencodex-session`·`/api/remote-workspace/status (무효: 감사 반영 Averroes 절 참조)`·`/api/link/status`·`/api/link/candidates`·`POST /api/link/probe`·`/api/link/confirm-host`·`/api/link/apply`에 고정 JSON을 돌려준다. 상태는 URL 쿼리 `?fixture=off|role|home-connected|add-sheet|fingerprint`로 고른다(서버는 Referer 쿼리 또는 쿠키 `ocx-fixture`로 판별). 재현 명령: `cd gui && bun run build && bun scripts/remote-link-fixture.ts --port 5199` 후 agbrowse로 각 상태를 1440×900, 390×844로 캡처. 검증 표에 이 명령과 `bun run lint:i18n`, `bun test tests`, `bun run lint`, `bun run build`, docs-site 빌드(`cd docs-site && bun run build`)를 추가한다. + - 실행 증거는 B/C 단계에서 만든다(계획 단계 문서는 결정 기록). + +## 감사 반영 (Averroes FAIL r1, wp5 계획 감사) — 이 절이 앞선 모든 내용보다 우선한다 + +1. `#remote` 호환(반박 + 대안): 사용자가 `#remote`에서 링크 화면을 원한다고 명시했으므로(PRD 문제 정의) `#remote`는 Remote Link로 바꾼다. 옛 북마크 대책: 원격 워크스페이스가 사용 가능한 설치(App 가용성 조회가 true)에서는 Remote Link 화면 맨 위에 "원격 워크스페이스는 이제 별도 화면에 있어요 → 열기"(`#remote-workspace`) 카드를 항상 보여 준다. 테스트: 가용성 true면 카드와 링크가 렌더링되고 클릭 시 hash가 `#remote-workspace`, false면 카드 없음. `#remote-workspace` 직접 진입은 가용성과 무관하게 기존 RemoteWorkspace 화면(비활성 안내 포함)을 연다. +2. 오류 코드: `gui/src/remote-link-api.ts`(NEW)에 `readLinkJson(res): Promise`를 두고, 비정상 응답은 본문 `{error:{code,message}}`에서 code를 꺼내 `LinkApiError(code, status)`로 던진다(기존 `readJsonOrThrow`는 건드리지 않음). code → i18n 키 표: `invalid_body`, `invalid_alias`, `ssh_unreachable`, `probe_failed`, `host_confirmation_expired`, `host_fingerprint_mismatch`, `remote_ocx_missing`, `listener_unavailable`, `key_issue_failed`, `link_apply_failed`, `link_connect_timeout`, `compensation_failed`, `remote_disconnect_failed`, `key_revoke_failed`, `link_remove_failed`, `tailscale_session_refused`, `link_unavailable` + 알 수 없는 코드용 `remoteLink.error.generic`. 구현 시 link-routes.ts에서 실제 code 목록을 `rg -o 'fail\("[a-z_]+"'`로 뽑아 표와 대조하고, 표에 없는 코드가 있으면 추가한다. 테스트: 각 code가 해당 문구를 보여 줌, 알 수 없는 code는 generic. +3. 강제 해제: DELETE가 `remote_disconnect_failed`(502)면 두 번째 확인 대화상자("기기에 연결할 수 없어요. 이 컴퓨터에서만 링크를 지울까요? 그 기기는 직접 `ocx disconnect` 해야 해요")를 띄우고 확인 시 `DELETE /api/link/{id}` 본문 `{"force":true}`. `compensation_failed` 행은 실패 점(빨강) + 사유 문구 + "링크 지우기" 버튼(같은 DELETE 흐름). 테스트: 502 → 강제 확인 → force 본문 전송, compensation_failed 행 렌더링. +4. 상태 코드 고정: apply 202 `{linkId}`, DELETE 200 `{linkId}`, probe 200, confirm-host 200, candidates 200. 테스트·픽스처 모두 이 값만. +5. 픽스처 엔드포인트: 원격 워크스페이스 가용성은 `GET /api/remote-workspace`(remote-workspace-routes.ts:61). W5-9 표기의 `/api/remote-workspace/status (무효: 감사 반영 Averroes 절 참조)`는 폐기. 파일 변경 지도에 NEW `gui/scripts/remote-link-fixture.ts` 포함. +6. 문서 파일: NEW `docs-site/src/content/docs/guides/remote-link.md`와 7개 번역 `docs-site/src/content/docs/{fr,ko,zh-cn,zh-tw,ru,ja,tr}/guides/remote-link.md`, 그리고 `docs-site/astro.config.mjs` 사이드바의 Guides 그룹에 remote-hub 옆으로 등록(기존 remote-workspace 등록 방식과 같게). 기존 remote-hub/remote-workspace 가이드에서 새 페이지로 한 줄 링크. +7. 비차단 반영: 테스트에 `role: "home", links: [], child: null` 홈 빈 상태와, 꺼짐 스위치·역할 선택만으로는 GET 외 요청이 없음을 요청 기록으로 확인. CSS는 gui/design-system의 토큰(간격·반경·색)만 쓰고 새 원시 값이 필요하면 토큰을 먼저 추가한다. + +## 감사 반영 (Averroes FAIL r2) + +1. 오류 코드 목록은 구현된 라우트에서 뽑은 것이 전부다(`rg -o 'fail\("[a-z_]+"' src/server/management/link-routes.ts`, 2026-09-25 기준 24개). r1 절 2번의 목록은 이 표로 대체한다(`ssh_unreachable`, `remote_ocx_missing`, `link_connect_timeout`은 존재하지 않으므로 폐기). 구현은 이 목록을 `gui/src/remote-link-api.ts`에 `LINK_ERROR_CODES` 상수로 두고, GUI 테스트가 같은 명령에 해당하는 정규식으로 `src/server/management/link-routes.ts`를 읽어 뽑은 집합과 상수가 같은지 확인한다(서버가 코드를 추가하면 GUI 테스트가 실패). + +| code | i18n 키 | +|---|---| +| `admission_timeout` | `remoteLink.error.admission_timeout` | +| `compensation_failed` | `remoteLink.error.compensation_failed` | +| `fingerprint_failed` | `remoteLink.error.fingerprint_failed` | +| `forbidden` | `remoteLink.error.forbidden` | +| `host_confirmation_expired` | `remoteLink.error.host_confirmation_expired` | +| `host_fingerprint_mismatch` | `remoteLink.error.host_fingerprint_mismatch` | +| `host_not_confirmed` | `remoteLink.error.host_not_confirmed` | +| `invalid_alias` | `remoteLink.error.invalid_alias` | +| `invalid_body` | `remoteLink.error.invalid_body` | +| `invalid_link_id` | `remoteLink.error.invalid_link_id` | +| `key_issue_failed` | `remoteLink.error.key_issue_failed` | +| `key_revoke_failed` | `remoteLink.error.key_revoke_failed` | +| `link_apply_failed` | `remoteLink.error.link_apply_failed` | +| `link_exists` | `remoteLink.error.link_exists` | +| `link_not_found` | `remoteLink.error.link_not_found` | +| `link_remove_failed` | `remoteLink.error.link_remove_failed` | +| `link_unavailable` | `remoteLink.error.link_unavailable` | +| `listener_unavailable` | `remoteLink.error.listener_unavailable` | +| `probe_failed` | `remoteLink.error.probe_failed` | +| `remote_connect_failed` | `remoteLink.error.remote_connect_failed` | +| `remote_disconnect_failed` | `remoteLink.error.remote_disconnect_failed` | +| `remote_port_failed` | `remoteLink.error.remote_port_failed` | +| `tailscale_session_refused` | `remoteLink.error.tailscale_session_refused` | +| `version_probe_failed` | `remoteLink.error.version_probe_failed` | +| (알 수 없는 code) | `remoteLink.error.generic` | + +2. 새 문구 키(영어 원문, 10개 로케일 모두 추가, `bun run lint:i18n` 통과). 위 표의 오류 키도 모두 10개 로케일에 추가한다. + - `remoteLink.workspaceMoved.title`: "Remote Workspace has its own page now" + - `remoteLink.workspaceMoved.open`: "Open Remote Workspace" + - `remoteLink.forceRemove.title`: "This machine could not reach {alias}" + - `remoteLink.forceRemove.body`: "Remove the link only on this machine? Afterwards run {cmd} on {alias} yourself." ({cmd}는 ``의 `ocx disconnect` 코드 칩) + - `remoteLink.forceRemove.confirm`: "Remove here only" + - `remoteLink.row.removeFailedLink`: "Remove link" +3. 앞 절의 `201/200`, `204/JSON`, `/api/remote-workspace/status` 표기는 본문에서 무효로 표시했다. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 5d5c0b35d5..c0171bbc38 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -87,6 +87,7 @@ export default defineConfig({ translations: { fr: "Guides", ko: "가이드", "zh-CN": "指南", "zh-TW": "指南", ru: "Руководства", ja: "ガイド", tr: "Kılavuzlar" }, items: [ { label: "Remote Hub Deployment", translations: { fr: "Déploiement Remote Hub", ko: "Remote Hub 배포", "zh-CN": "Remote Hub 部署", "zh-TW": "Remote Hub 部署", ru: "Развёртывание Remote Hub", ja: "Remote Hub のデプロイ", tr: "Remote Hub Dağıtımı" }, slug: "guides/remote-hub" }, + { label: "Remote Link", translations: { fr: "Liaison distante", ko: "Remote Link", "zh-CN": "远程链接", "zh-TW": "遠端連結", ru: "Удалённая связь", ja: "リモートリンク", tr: "Uzak Bağlantı" }, slug: "guides/remote-link" }, { label: "Response Inspection", translations: { fr: "Inspection des réponses et réponses volumineuses", ko: "응답 검사와 대용량 응답", "zh-CN": "响应检查与大型响应", "zh-TW": "回應檢查與大型回應", ru: "Проверка ответов и большие ответы", ja: "レスポンスの検査と大きなレスポンス", tr: "Yanıt incelemesi ve büyük yanıtlar" }, slug: "guides/response-inspection" }, { label: "Remote Workspace", translations: { fr: "Espace de travail distant", ko: "원격 워크스페이스", "zh-CN": "远程工作区", "zh-TW": "遠端工作區", ru: "Удалённая рабочая область", ja: "リモートワークスペース", tr: "Uzak Çalışma Alanı" }, slug: "guides/remote-workspace" }, { label: "Providers", translations: { fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" }, diff --git a/docs-site/src/content/docs/fr/guides/remote-hub.md b/docs-site/src/content/docs/fr/guides/remote-hub.md index 96cbb63a0e..abda45d604 100644 --- a/docs-site/src/content/docs/fr/guides/remote-hub.md +++ b/docs-site/src/content/docs/fr/guides/remote-hub.md @@ -3,6 +3,8 @@ title: Déploiement Remote Hub description: Déployer un hub opencodex avec une gestion locale, Tailscale Serve et OAuth sans interface locale. --- +Pour les liaisons SSH entre machines, consultez [Liaison distante](/fr/guides/remote-link/). + Un hub conserve les identifiants fournisseur, le catalogue et l’usage sur un hôte. Les clients authentifiés appellent directement son plan de données. Le plan de gestion est distinct : son écoute facultative reste sur `127.0.0.1` et ne sert que le tableau de bord et `/api/*`. Elle ne sert jamais `/v1/*`, `/healthz`, `/readyz` ni WebSocket. Ne publiez pas le port `10101` et n’utilisez pas Tailscale Funnel. ## Rôles, connexion et sécurité diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md new file mode 100644 index 0000000000..db7c497abf --- /dev/null +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -0,0 +1,62 @@ +--- +title: Liaison distante +description: Connecter un ordinateur OpenCodex Home à un ordinateur Child avec SSH. +--- + +Une liaison entre machines connecte un ordinateur OpenCodex **Home** à un ordinateur **Child** avec SSH. Home sert le trafic de Child par le tunnel SSH, et les deux ordinateurs gardent leur service OpenCodex local sur le port `10100`. Le tableau de bord transmet la clé de liaison propre à Child par SSH, sans vous demander de saisir un jeton. + +## Conditions requises + +- Home peut se connecter à Child avec une clé OpenSSH. +- OpenCodex est installé sur Child. +- Les deux ordinateurs utilisent macOS ou Linux. +- Le tableau de bord Home dispose d’une session appairée complète. + +SSH par mot de passe, Windows et la liaison initiée par Child ne font pas partie du flux actuel. Le flux initié par Child est **bientôt disponible**. + +## Ajouter un Child depuis `#remote` + +1. Ouvrez le tableau de bord sur `#remote` et activez Remote Link. +2. Choisissez **Home**. +3. Sélectionnez **Add child**. +4. Choisissez un hôte parmi les candidats SSH, ou saisissez un alias de configuration SSH. +5. Lancez le test de connexion et comparez l’empreinte proposée avec celle de l’ordinateur visé. Cette comparaison aide à détecter un mauvais hôte ou une clé d’hôte modifiée avant que SSH ne lui fasse confiance. +6. Confirmez l’empreinte, puis connectez Child. + +Le tableau de bord ne demande pas de saisir un jeton. Il sonde d’abord l’hôte et ne peut appliquer la liaison qu’après votre confirmation explicite de l’empreinte. + +## État de la liaison + +- **Connected** signifie que le tunnel SSH est prêt et que Child peut utiliser la liaison Home. +- **Reconnecting** signifie que le tunnel est réessayé. Les requêtes peuvent temporairement renvoyer `503` avec `Retry-After`. +- **Failed** signifie que la liaison nécessite une intervention. Vérifiez l’authentification SSH, la clé d’hôte confirmée, la redirection ou le délai indiqué. + +Une liaison en échec ne bascule pas silencieusement vers un fournisseur local. + +## Supprimer un Child + +Sélectionnez **Disconnect** pour Child et confirmez son alias. Home arrête le tunnel, révoque la clé de liaison de Child et supprime l’enregistrement enregistré. + +Si Home ne peut pas joindre Child pour exécuter la déconnexion, choisissez **Remove here only**. Cela supprime le tunnel, la clé et l’enregistrement locaux. Connectez-vous ensuite à Child et exécutez : + +```bash +ocx disconnect +``` + +## Sécurité + +Child utilise les fournisseurs et les identifiants de fournisseur de l’ordinateur Home via la liaison. Home crée une clé distincte pour chaque Child ; la suppression de la liaison révoque cette clé. Comparez l’empreinte de l’hôte avant de confirmer afin de ne pas accepter par erreur une mauvaise machine ou une clé modifiée. Les sessions du tableau de bord émises depuis une identité Tailscale ne peuvent pas gérer les liaisons. + +## Référence CLI + +```text +ocx link port [--json] +ocx link issue --alias --tunnel-port [--json] +ocx link status [--json] +ocx link revoke --link-id [--json] +``` + +## Guides associés + +- [Déploiement Remote Hub](/fr/guides/remote-hub/) +- [Remote Workspace](/fr/guides/remote-workspace/) diff --git a/docs-site/src/content/docs/fr/guides/remote-workspace.md b/docs-site/src/content/docs/fr/guides/remote-workspace.md index 2d7dffac4f..707c61bfa1 100644 --- a/docs-site/src/content/docs/fr/guides/remote-workspace.md +++ b/docs-site/src/content/docs/fr/guides/remote-workspace.md @@ -3,6 +3,8 @@ title: Espace de travail distant description: Conservez Codex, Claude Code, Pi et leurs connexions sur un même OCX Hub, tandis que des ordinateurs équipés seulement d'OCX fournissent l'espace de travail et l'environnement de compilation. --- +Pour les liaisons SSH entre machines, consultez [Liaison distante](/fr/guides/remote-link/). + Remote Workspace permet à un OpenCodex Hub d'exécuter vos agents de programmation tandis qu'un autre ordinateur fournit les fichiers du projet, les commandes, les tests et la puissance de calcul. Un téléphone ou un troisième ordinateur peut piloter la session depuis le tableau de diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 1cca04995d..775ceeb1cc 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -3,6 +3,8 @@ title: Remote Hub Deployment description: Run a one-port opencodex hub on Linux, macOS, or Docker with a loopback companion listener, a self-provisioned data token, ocx hub invite, a loopback-only management ingress, Tailscale Serve, and headless OAuth. --- +For SSH machine links, see [Remote Link](/guides/remote-link/). + An opencodex hub keeps provider credentials and usage state on one host while authenticated clients use its data plane remotely. The browser-facing management plane is separate: an optional listener binds only `127.0.0.1`, serves the dashboard and `/api/*`, and is intended to sit behind Tailscale diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md new file mode 100644 index 0000000000..d60e39eda4 --- /dev/null +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -0,0 +1,62 @@ +--- +title: Remote Link +description: Connect an OpenCodex Home computer to a Child computer over SSH. +--- + +A machine link connects an OpenCodex **Home** computer to a **Child** computer over SSH. The Home serves the Child through the SSH tunnel, while both computers keep their local OpenCodex service on port `10100`. The dashboard transfers the per-child link key through SSH, so you do not type a token. + +## Requirements + +- The Home computer can log in to the Child with an OpenSSH key. +- OpenCodex is installed on the Child computer. +- Both computers run macOS or Linux. +- The Home dashboard has a full paired session. + +Password SSH, Windows, and a Child-initiated link are outside the current flow. The Child-initiated flow is **coming soon**. + +## Add a Child from `#remote` + +1. Open the dashboard at `#remote` and switch Remote Link on. +2. Choose **Home**. +3. Select **Add child**. +4. Choose a host from the SSH candidates, or enter an SSH config alias. +5. Run the connection test and compare the offered host fingerprint with the fingerprint for the machine you intend to use. Comparing it helps detect a wrong host or a changed host key before SSH trusts the host. +6. Confirm the fingerprint, then connect the Child. + +The dashboard does not ask you to enter a token. It probes the host first, and it cannot apply the link until you explicitly confirm the fingerprint. + +## Link status + +- **Connected** means the SSH tunnel is ready and the Child can use the Home link. +- **Reconnecting** means the tunnel is being retried. Requests can temporarily return `503` with `Retry-After` while the retry is in progress. +- **Failed** means the link needs attention. Check SSH authentication, the confirmed host key, forwarding, or the timeout reason shown in the dashboard. + +A failed link does not silently switch to a local provider. + +## Remove a Child + +Select **Disconnect** for the Child and confirm the alias. The Home stops the tunnel, revokes that Child's link key, and removes the saved link record. + +If the Home cannot reach the Child to run its disconnect command, choose **Remove here only**. This removes the local tunnel, key, and record. Then log in to the Child and run: + +```bash +ocx disconnect +``` + +## Security + +The Child uses the Home computer's providers and provider credentials through the link. The Home creates a separate link key for each Child; removing the link revokes that key. Compare the host fingerprint before confirmation so a wrong machine or changed host key is not accepted by mistake. Dashboard sessions issued from a Tailscale identity cannot manage machine links. + +## CLI reference + +```text +ocx link port [--json] +ocx link issue --alias --tunnel-port [--json] +ocx link status [--json] +ocx link revoke --link-id [--json] +``` + +## Related guides + +- [Remote Hub Deployment](/guides/remote-hub/) +- [Remote Workspace](/guides/remote-workspace/) diff --git a/docs-site/src/content/docs/guides/remote-workspace.md b/docs-site/src/content/docs/guides/remote-workspace.md index e477f46473..33db5f4eac 100644 --- a/docs-site/src/content/docs/guides/remote-workspace.md +++ b/docs-site/src/content/docs/guides/remote-workspace.md @@ -3,6 +3,8 @@ title: Remote Workspace description: Keep Codex, Claude Code, Pi, and their logins on one OCX Hub while OCX-only computers provide the workspace and build environment. --- +For SSH machine links, see [Remote Link](/guides/remote-link/). + Remote Workspace lets one OpenCodex Hub run your coding agents while another computer supplies the project files, commands, tests, and build compute. A phone or third computer can control the session through the Hub dashboard. diff --git a/docs-site/src/content/docs/ja/guides/remote-hub.md b/docs-site/src/content/docs/ja/guides/remote-hub.md index d8dd1f202d..89bf4499ab 100644 --- a/docs-site/src/content/docs/ja/guides/remote-hub.md +++ b/docs-site/src/content/docs/ja/guides/remote-hub.md @@ -3,6 +3,8 @@ title: Remote Hub のデプロイ description: 管理ポートをループバックに限定し、Tailscale Serve とヘッドレス OAuth で運用します。 --- +SSH のマシンリンクについては、[リモートリンク](/ja/guides/remote-link/) を参照してください。 + Remote Hub はプロバイダー認証情報、カタログ、使用量を一台のホストに保持し、認証済みクライアントからデータプレーンへ直接接続します。管理プレーンは別系統で、任意の管理リスナーは `127.0.0.1` にのみバインドされ、ダッシュボードと `/api/*` だけを提供します。`/v1/*`、`/healthz`、`/readyz`、WebSocket は提供しません。`10101` を公開したり Tailscale Funnel を使ったりしないでください。 ## 役割、接続、信頼境界 diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md new file mode 100644 index 0000000000..1d9fbd230c --- /dev/null +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -0,0 +1,62 @@ +--- +title: リモートリンク +description: SSH で OpenCodex の Home コンピューターと Child コンピューターを接続します。 +--- + +マシンリンクは SSH で OpenCodex の **Home** コンピューターと **Child** コンピューターを接続します。Home は SSH トンネルを通じて Child にサービスを提供し、両方のコンピューターはローカルの OpenCodex サービスを `10100` ポートで維持します。ダッシュボードは Child 専用のリンクキーを SSH で渡すため、トークンを入力する必要はありません。 + +## 要件 + +- Home から Child に OpenSSH キーでログインできること。 +- Child に OpenCodex がインストールされていること。 +- 両方のコンピューターが macOS または Linux であること。 +- Home のダッシュボードに完全なペアリング済みセッションがあること。 + +パスワード SSH、Windows、Child から開始するリンクは現在のフローに含まれません。Child 開始フローは**近日対応予定**です。 + +## `#remote` から Child を追加する + +1. ダッシュボードで `#remote` を開き、Remote Link をオンにします。 +2. **Home** を選びます。 +3. **Add child** を選びます。 +4. SSH の候補からホストを選ぶか、SSH 設定のエイリアスを入力します。 +5. 接続テストを実行し、表示されたホストフィンガープリントを接続先コンピューターのものと比較します。比較すると、SSH がホストを信頼する前に、別のコンピューターや変更されたホストキーを検出できます。 +6. フィンガープリントを確認して Child を接続します。 + +ダッシュボードはトークンの入力を求めません。先にホストをプローブし、フィンガープリントを明示的に確認するまでリンクを適用しません。 + +## リンクの状態 + +- **Connected** は SSH トンネルが準備でき、Child が Home のリンクを使える状態です。 +- **Reconnecting** はトンネルを再試行している状態です。再試行中はリクエストが `Retry-After` 付きの `503` を一時的に返すことがあります。 +- **Failed** は対応が必要な状態です。SSH 認証、確認済みのホストキー、転送、タイムアウトの理由を確認してください。 + +リンクが失敗しても、ローカルプロバイダーへ自動的に切り替わることはありません。 + +## Child を削除する + +Child の **Disconnect** を選び、エイリアスを確認します。Home はトンネルを停止し、その Child のリンクキーを失効させ、保存されたリンク記録を削除します。 + +Home から Child に接続解除コマンドを実行できない場合は **Remove here only** を選びます。これでこのコンピューターのトンネル、キー、記録だけを削除します。その後 Child にログインして実行します。 + +```bash +ocx disconnect +``` + +## セキュリティ + +Child はリンクを通じて Home コンピューターのプロバイダーとプロバイダー認証情報を使います。Home は Child ごとに別のリンクキーを作り、リンクを削除するとそのキーを失効させます。確認前にホストフィンガープリントを比較し、別のコンピューターや変更されたホストキーを誤って受け入れないようにしてください。Tailscale の ID から発行されたダッシュボードセッションはマシンリンクを管理できません。 + +## CLI リファレンス + +```text +ocx link port [--json] +ocx link issue --alias --tunnel-port [--json] +ocx link status [--json] +ocx link revoke --link-id [--json] +``` + +## 関連ガイド + +- [Remote Hub のデプロイ](/ja/guides/remote-hub/) +- [リモートワークスペース](/ja/guides/remote-workspace/) diff --git a/docs-site/src/content/docs/ja/guides/remote-workspace.md b/docs-site/src/content/docs/ja/guides/remote-workspace.md index a35f2abcfa..be1812cdaf 100644 --- a/docs-site/src/content/docs/ja/guides/remote-workspace.md +++ b/docs-site/src/content/docs/ja/guides/remote-workspace.md @@ -3,6 +3,8 @@ title: Remote Workspace description: Codex、Claude Code、Pi とそのログインを 1 台の OCX Hub に集め、OCX 専用のコンピューターで作業環境とビルド環境を提供します。 --- +SSH のマシンリンクについては、[リモートリンク](/ja/guides/remote-link/) を参照してください。 + Remote Workspace では、OpenCodex Hub でコーディングエージェントを実行し、別のコンピューターからプロジェクトファイル、コマンド、テスト、ビルド用の計算資源を提供できます。スマートフォンや 3 台目のコンピューターから、Hub のダッシュボードを通じてセッションを操作できます。 ```text diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index 585b6ea645..89f15bc784 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -3,6 +3,8 @@ title: Remote Hub 배포 description: Linux, macOS, Docker에서 포트 하나로 동작하는 opencodex 허브를 구성합니다. 루프백 companion 리스너, 스스로 준비되는 데이터 토큰, ocx hub invite, 로컬 전용 관리 인그레스, Tailscale Serve, 헤드리스 OAuth를 다룹니다. --- +SSH 머신 링크는 [Remote Link](/ko/guides/remote-link/)를 참조하세요. + Remote Hub를 쓰면 프로바이더 인증 정보와 사용량 기록은 허브 한 곳에 두고, 인증된 클라이언트가 허브의 데이터 API를 직접 사용합니다. 브라우저용 관리 API는 별도입니다. 선택 사항인 관리 리스너는 `127.0.0.1`에만 열리며 대시보드와 `/api/*`만 제공합니다. 데이터 플레인은 **포트 하나**입니다. 원격 컴퓨터는 `hostname:port`를 자기 전용 키로 호출하고, 허브 자신의 프로세스는 **같은 포트**의 `127.0.0.1`을 자격 증명 없이 호출합니다. 후자를 담당하는 것이 루프백 companion 리스너입니다. 아래 [설치 레시피](#systemd-또는-launchd)로 시작한 뒤, [`ocx hub invite`](#다른-컴퓨터-초대하기)로 다른 컴퓨터에 그대로 붙여 넣을 명령을 건네세요. diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md new file mode 100644 index 0000000000..ab20112f67 --- /dev/null +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -0,0 +1,62 @@ +--- +title: Remote Link +description: SSH로 OpenCodex Home 컴퓨터와 Child 컴퓨터를 연결합니다. +--- + +머신 링크는 SSH를 통해 OpenCodex **Home** 컴퓨터와 **Child** 컴퓨터를 연결합니다. Home이 SSH 터널을 통해 Child를 서비스하고, 두 컴퓨터는 각각 로컬 OpenCodex 서비스를 `10100` 포트에서 계속 실행합니다. 대시보드는 Child 전용 링크 키를 SSH로 전달하므로 토큰을 직접 입력하지 않습니다. + +## 요구 사항 + +- Home 컴퓨터에서 OpenSSH 키 로그인으로 Child 컴퓨터에 접속할 수 있어야 합니다. +- Child 컴퓨터에 OpenCodex가 설치되어 있어야 합니다. +- 두 컴퓨터 모두 macOS 또는 Linux여야 합니다. +- Home 대시보드에 완전한 페어링 세션이 있어야 합니다. + +비밀번호 SSH, Windows, Child가 시작하는 링크 흐름은 현재 지원 범위가 아닙니다. Child가 시작하는 흐름은 **준비 중**입니다. + +## `#remote`에서 Child 추가하기 + +1. 대시보드에서 `#remote`를 열고 Remote Link를 켭니다. +2. **Home**을 선택합니다. +3. **Add child**를 선택합니다. +4. SSH 후보에서 호스트를 선택하거나 SSH 설정의 alias를 입력합니다. +5. 연결 테스트를 실행하고 표시된 호스트 지문을 연결하려는 컴퓨터의 지문과 비교합니다. 비교하면 SSH가 호스트를 신뢰하기 전에 잘못된 컴퓨터나 변경된 호스트 키를 발견할 수 있습니다. +6. 지문을 확인한 뒤 Child를 연결합니다. + +대시보드는 토큰 입력을 요구하지 않습니다. 먼저 호스트를 검사하며, 지문을 명시적으로 확인하기 전에는 링크를 적용하지 않습니다. + +## 링크 상태 + +- **Connected**는 SSH 터널이 준비되어 Child가 Home 링크를 사용할 수 있다는 뜻입니다. +- **Reconnecting**은 터널을 다시 연결하는 중이라는 뜻입니다. 재시도 중에는 요청이 일시적으로 `Retry-After`와 함께 `503`을 반환할 수 있습니다. +- **Failed**는 조치가 필요하다는 뜻입니다. SSH 인증, 확인한 호스트 키, 포워딩 또는 타임아웃 사유를 확인하세요. + +링크가 실패해도 로컬 프로바이더로 조용히 전환하지 않습니다. + +## Child 제거하기 + +Child의 **Disconnect**를 선택하고 alias를 확인합니다. Home은 터널을 중지하고 해당 Child의 링크 키를 폐기한 뒤 저장된 링크 기록을 삭제합니다. + +Home에서 Child의 연결 해제 명령을 실행할 수 없으면 **Remove here only**를 선택합니다. 그러면 이 컴퓨터의 터널, 키, 기록만 삭제됩니다. 그 다음 Child에 로그인하여 실행합니다. + +```bash +ocx disconnect +``` + +## 보안 + +Child는 링크를 통해 Home 컴퓨터의 프로바이더와 프로바이더 인증 정보를 사용합니다. Home은 Child마다 별도의 링크 키를 만들며, 링크를 제거하면 그 키를 폐기합니다. 확인 전에 호스트 지문을 비교하여 잘못된 컴퓨터나 변경된 호스트 키를 실수로 허용하지 않도록 하세요. Tailscale identity로 발급된 대시보드 세션은 머신 링크를 관리할 수 없습니다. + +## CLI 레퍼런스 + +```text +ocx link port [--json] +ocx link issue --alias --tunnel-port [--json] +ocx link status [--json] +ocx link revoke --link-id [--json] +``` + +## 관련 가이드 + +- [Remote Hub 배포](/ko/guides/remote-hub/) +- [Remote Workspace](/ko/guides/remote-workspace/) diff --git a/docs-site/src/content/docs/ko/guides/remote-workspace.md b/docs-site/src/content/docs/ko/guides/remote-workspace.md index a6d6dab273..ef5b6490ae 100644 --- a/docs-site/src/content/docs/ko/guides/remote-workspace.md +++ b/docs-site/src/content/docs/ko/guides/remote-workspace.md @@ -3,6 +3,8 @@ title: 원격 작업 공간 description: Codex, Claude Code, Pi와 로그인을 한 OCX Hub에 두고 OCX만 설치한 다른 컴퓨터에서 작업 공간과 빌드 환경을 제공합니다. --- +SSH 머신 링크는 [Remote Link](/ko/guides/remote-link/)를 참조하세요. + 원격 작업 공간에서는 한 opencodex Hub가 코딩 에이전트를 실행하고 다른 컴퓨터가 프로젝트 파일, 명령, 테스트, 빌드 연산을 제공합니다. 휴대전화나 세 번째 컴퓨터에서도 Hub 대시보드로 세션을 제어할 수 있습니다. ```text diff --git a/docs-site/src/content/docs/ru/guides/remote-hub.md b/docs-site/src/content/docs/ru/guides/remote-hub.md index 53f64a64b2..97195ad354 100644 --- a/docs-site/src/content/docs/ru/guides/remote-hub.md +++ b/docs-site/src/content/docs/ru/guides/remote-hub.md @@ -3,6 +3,8 @@ title: Развёртывание Remote Hub description: Hub с локальным контуром управления, Tailscale Serve и OAuth без локального браузера. --- +О связях машин по SSH см. [Удалённая связь](/ru/guides/remote-link/). + Remote Hub хранит учётные данные провайдеров, каталог и статистику на одном хосте. Авторизованные клиенты обращаются непосредственно к его плоскости данных. Контур управления отделён: необязательный listener привязан только к `127.0.0.1` и обслуживает панель и `/api/*`, но не `/v1/*`, `/healthz`, `/readyz` или WebSocket. Не публикуйте `10101` и не используйте Tailscale Funnel. ## Роли и границы доверия diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md new file mode 100644 index 0000000000..fa082805a5 --- /dev/null +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -0,0 +1,62 @@ +--- +title: Удалённая связь +description: Подключите компьютер OpenCodex Home к компьютеру Child по SSH. +--- + +Связь между машинами соединяет компьютер OpenCodex **Home** с компьютером **Child** по SSH. Home обслуживает Child через SSH-туннель, а оба компьютера сохраняют локальную службу OpenCodex на порту `10100`. Панель передаёт ключ связи для Child через SSH, поэтому вводить токен вручную не нужно. + +## Требования + +- Home может войти на Child по ключу OpenSSH. +- На Child установлен OpenCodex. +- Оба компьютера работают под macOS или Linux. +- В панели Home есть полноценная сопряжённая сессия. + +SSH с паролем, Windows и запуск связи со стороны Child сейчас не входят в поток. Поток, инициируемый Child, **появится позже**. + +## Добавление Child из `#remote` + +1. Откройте `#remote` в панели и включите Remote Link. +2. Выберите **Home**. +3. Выберите **Add child**. +4. Выберите хост среди кандидатов SSH или введите псевдоним из конфигурации SSH. +5. Запустите проверку соединения и сравните показанный отпечаток хоста с отпечатком нужного компьютера. Сравнение помогает обнаружить неправильный компьютер или изменённый ключ хоста до того, как SSH начнёт ему доверять. +6. Подтвердите отпечаток и подключите Child. + +Панель не просит вводить токен. Сначала выполняется проверка хоста, и применить связь можно только после явного подтверждения отпечатка. + +## Состояние связи + +- **Connected** означает, что SSH-туннель готов и Child может использовать связь Home. +- **Reconnecting** означает, что туннель переподключается. Во время повторных попыток запросы могут временно получать `503` с `Retry-After`. +- **Failed** означает, что связь требует действий. Проверьте SSH-аутентификацию, подтверждённый ключ хоста, перенаправление или причину тайм-аута. + +При сбое связи система молча не переключается на локального провайдера. + +## Удаление Child + +Выберите **Disconnect** для Child и подтвердите псевдоним. Home остановит туннель, отзовёт ключ связи этого Child и удалит сохранённую запись. + +Если Home не может подключиться к Child для выполнения команды отключения, выберите **Remove here only**. Это удаляет локальные туннель, ключ и запись. Затем войдите на Child и выполните: + +```bash +ocx disconnect +``` + +## Безопасность + +Child использует через связь провайдеров и учётные данные провайдеров компьютера Home. Home создаёт отдельный ключ связи для каждого Child; удаление связи отзывает этот ключ. Сравнивайте отпечаток хоста перед подтверждением, чтобы случайно не принять другой компьютер или изменённый ключ. Сессии панели, выданные удостоверением Tailscale, не могут управлять связями машин. + +## Справочник CLI + +```text +ocx link port [--json] +ocx link issue --alias --tunnel-port [--json] +ocx link status [--json] +ocx link revoke --link-id [--json] +``` + +## Связанные руководства + +- [Развёртывание Remote Hub](/ru/guides/remote-hub/) +- [Удалённое рабочее пространство](/ru/guides/remote-workspace/) diff --git a/docs-site/src/content/docs/ru/guides/remote-workspace.md b/docs-site/src/content/docs/ru/guides/remote-workspace.md index 354066ebb4..4fca322dff 100644 --- a/docs-site/src/content/docs/ru/guides/remote-workspace.md +++ b/docs-site/src/content/docs/ru/guides/remote-workspace.md @@ -3,6 +3,8 @@ title: Удалённое рабочее пространство description: Держите Codex, Claude Code, Pi и их входы на одном OCX Hub, а рабочее пространство и среду сборки предоставляйте с компьютеров, где установлен только OCX. --- +О связях машин по SSH см. [Удалённая связь](/ru/guides/remote-link/). + Remote Workspace позволяет запустить агентов разработки на одном OpenCodex Hub, а файлы проекта, команды, тесты и вычисления для сборки предоставить с другого компьютера. Телефон или третий компьютер могут управлять сессией через дашборд Hub. diff --git a/docs-site/src/content/docs/tr/guides/remote-hub.md b/docs-site/src/content/docs/tr/guides/remote-hub.md index 83dbfde4a6..22ac9f44ad 100644 --- a/docs-site/src/content/docs/tr/guides/remote-hub.md +++ b/docs-site/src/content/docs/tr/guides/remote-hub.md @@ -3,6 +3,8 @@ title: Remote Hub Dağıtımı description: Loopback yönetimi, Tailscale Serve ve başsız OAuth ile opencodex hub çalıştırma. --- +SSH makine bağlantıları için [Uzak Bağlantı](/tr/guides/remote-link/) kılavuzuna bakın. + Remote Hub sağlayıcı kimlik bilgilerini, kataloğu ve kullanım kayıtlarını tek ana bilgisayarda tutar. Kimliği doğrulanmış istemciler veri düzlemine doğrudan bağlanır. Yönetim düzlemi ayrıdır: isteğe bağlı dinleyici yalnızca `127.0.0.1` üzerinde çalışır ve pano ile `/api/*` yollarını sunar; `/v1/*`, `/healthz`, `/readyz` veya WebSocket sunmaz. `10101` portunu yayımlamayın ve Tailscale Funnel kullanmayın. ## Roller ve güven sınırı diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md new file mode 100644 index 0000000000..aed6d18aab --- /dev/null +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -0,0 +1,62 @@ +--- +title: Uzak Bağlantı +description: OpenCodex Home bilgisayarını bir Child bilgisayarına SSH üzerinden bağlayın. +--- + +Makine bağlantısı, bir OpenCodex **Home** bilgisayarını bir **Child** bilgisayarına SSH üzerinden bağlar. Home, Child'a SSH tüneli üzerinden hizmet verir; iki bilgisayar da yerel OpenCodex hizmetini `10100` portunda tutar. Kontrol paneli Child'a özel bağlantı anahtarını SSH üzerinden aktarır, bu nedenle bir belirteç yazmanız gerekmez. + +## Gereksinimler + +- Home bilgisayarı, Child bilgisayarına OpenSSH anahtarıyla giriş yapabilir. +- Child bilgisayarında OpenCodex kuruludur. +- Her iki bilgisayar da macOS veya Linux çalıştırır. +- Home kontrol panelinde tam bir eşleştirilmiş oturum vardır. + +Parola SSH, Windows ve Child tarafından başlatılan bağlantı mevcut akışın dışındadır. Child tarafından başlatılan akış **yakında geliyor**. + +## `#remote` üzerinden Child ekleme + +1. Kontrol panelinde `#remote` sayfasını açın ve Remote Link'i açın. +2. **Home** seçeneğini seçin. +3. **Add child** seçeneğini seçin. +4. SSH adaylarından bir ana bilgisayar seçin veya SSH yapılandırmasındaki diğer adı girin. +5. Bağlantı testini çalıştırın ve gösterilen ana bilgisayar parmak izini bağlanmak istediğiniz bilgisayarın parmak iziyle karşılaştırın. Karşılaştırma, SSH ana bilgisayara güvenmeden önce yanlış bilgisayarı veya değişmiş anahtarını fark etmenize yardımcı olur. +6. Parmak izini onaylayın, ardından Child'ı bağlayın. + +Kontrol paneli belirteç girmenizi istemez. Önce ana bilgisayarı yoklar ve parmak izini açıkça onaylamadan bağlantıyı uygulamaz. + +## Bağlantı durumu + +- **Connected**, SSH tünelinin hazır ve Child'ın Home bağlantısını kullanabilir olduğu anlamına gelir. +- **Reconnecting**, tünelin yeniden denendiği anlamına gelir. Yeniden deneme sırasında istekler geçici olarak `Retry-After` ile birlikte `503` döndürebilir. +- **Failed**, bağlantının ilgilenilmesi gerektiği anlamına gelir. SSH kimlik doğrulamasını, onaylanan ana bilgisayar anahtarını, yönlendirmeyi veya zaman aşımı nedenini kontrol edin. + +Bağlantı başarısız olduğunda sistem sessizce yerel bir sağlayıcıya geçmez. + +## Child'ı kaldırma + +Child için **Disconnect** seçeneğini seçin ve diğer adı onaylayın. Home tüneli durdurur, Child'ın bağlantı anahtarını iptal eder ve kayıtlı bağlantı kaydını kaldırır. + +Home, bağlantıyı kesme komutunu çalıştırmak için Child'a ulaşamıyorsa **Remove here only** seçeneğini seçin. Bu işlem yalnızca bu bilgisayardaki tüneli, anahtarı ve kaydı kaldırır. Ardından Child'a giriş yapıp şunu çalıştırın: + +```bash +ocx disconnect +``` + +## Güvenlik + +Child, bağlantı üzerinden Home bilgisayarının sağlayıcılarını ve sağlayıcı kimlik bilgilerini kullanır. Home her Child için ayrı bir bağlantı anahtarı oluşturur; bağlantıyı kaldırmak bu anahtarı iptal eder. Onaylamadan önce ana bilgisayar parmak izini karşılaştırarak yanlış bilgisayarı veya değiştirilmiş anahtarı kabul etmediğinizden emin olun. Tailscale kimliğiyle verilen kontrol paneli oturumları makine bağlantılarını yönetemez. + +## CLI başvurusu + +```text +ocx link port [--json] +ocx link issue --alias --tunnel-port [--json] +ocx link status [--json] +ocx link revoke --link-id [--json] +``` + +## İlgili kılavuzlar + +- [Remote Hub Dağıtımı](/tr/guides/remote-hub/) +- [Uzak Çalışma Alanı](/tr/guides/remote-workspace/) diff --git a/docs-site/src/content/docs/tr/guides/remote-workspace.md b/docs-site/src/content/docs/tr/guides/remote-workspace.md index a658ef4178..65797d801e 100644 --- a/docs-site/src/content/docs/tr/guides/remote-workspace.md +++ b/docs-site/src/content/docs/tr/guides/remote-workspace.md @@ -3,6 +3,8 @@ title: Uzak Çalışma Alanı description: Codex, Claude Code, Pi ve bunların girişlerini tek OCX Hub üzerinde tutarken yalnızca OCX kurulu bilgisayarlar çalışma alanını ve derleme ortamını sağlar. --- +SSH makine bağlantıları için [Uzak Bağlantı](/tr/guides/remote-link/) kılavuzuna bakın. + Remote Workspace, bir OpenCodex Hub üzerinde kodlama ajanlarını çalıştırırken başka bir bilgisayarın proje dosyalarını, komutları, testleri ve derleme kaynaklarını sağlamasına olanak verir. Telefon veya üçüncü bir bilgisayar, diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md index dcdfa00c0e..ff9e48c99c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md @@ -3,6 +3,8 @@ title: Remote Hub 部署 description: 使用仅回环管理入口、Tailscale Serve 和无头 OAuth 运行 opencodex hub。 --- +有关 SSH 机器链接,请参阅[远程链接](/zh-cn/guides/remote-link/)。 + Remote Hub 将提供商凭据、模型目录和使用记录保存在一台主机上,经过身份验证的客户端直接访问其数据平面。管理平面相互独立:可选管理监听器只绑定 `127.0.0.1`,仅提供控制台和 `/api/*`。它不提供 `/v1/*`、`/healthz`、`/readyz` 或 WebSocket。不要直接发布 `10101`,也不要使用 Tailscale Funnel。 ## 角色与信任边界 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 new file mode 100644 index 0000000000..6d11b9289d --- /dev/null +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -0,0 +1,62 @@ +--- +title: 远程链接 +description: 通过 SSH 将 OpenCodex 主机与子机连接起来。 +--- + +机器链接通过 SSH 连接 OpenCodex **主机(Home)** 与 **子机(Child)**。主机通过 SSH 隧道为子机提供服务,两台电脑各自继续在 `10100` 端口运行本地 OpenCodex 服务。控制台会通过 SSH 传递子机专用的链接密钥,因此不需要手动输入令牌。 + +## 要求 + +- 主机可以使用 OpenSSH 密钥登录子机。 +- 子机已安装 OpenCodex。 +- 两台电脑运行 macOS 或 Linux。 +- 主机控制台拥有完整的已配对会话。 + +密码 SSH、Windows 以及由子机发起的链接不在当前流程中。子机发起的流程**即将推出**。 + +## 从 `#remote` 添加子机 + +1. 打开控制台的 `#remote`,开启 Remote Link。 +2. 选择 **Home**。 +3. 选择 **Add child**。 +4. 从 SSH 候选主机中选择主机,或输入 SSH 配置别名。 +5. 运行连接测试,并将显示的主机指纹与目标电脑的指纹进行比较。比较指纹可以在 SSH 信任主机前发现错误的电脑或已更换的主机密钥。 +6. 确认指纹,然后连接子机。 + +控制台不会要求输入令牌。它会先探测主机,只有明确确认指纹后才能应用链接。 + +## 链接状态 + +- **Connected** 表示 SSH 隧道已就绪,子机可以使用主机链接。 +- **Reconnecting** 表示正在重试隧道。重试期间请求可能暂时返回带有 `Retry-After` 的 `503`。 +- **Failed** 表示链接需要处理。请检查 SSH 身份验证、已确认的主机密钥、转发或超时原因。 + +链接失败时不会静默切换到本地提供商。 + +## 移除子机 + +选择子机的 **Disconnect** 并确认别名。主机会停止隧道、吊销该子机的链接密钥并删除保存的链接记录。 + +如果主机无法连接子机来运行断开命令,请选择 **Remove here only**。这会只删除本机的隧道、密钥和记录。然后登录子机并运行: + +```bash +ocx disconnect +``` + +## 安全 + +子机会通过链接使用主机电脑上的提供商和提供商凭据。主机会为每台子机创建单独的链接密钥;移除链接会吊销该密钥。确认前比较主机指纹,避免误接受错误电脑或已更换的主机密钥。由 Tailscale 身份签发的控制台会话不能管理机器链接。 + +## CLI 参考 + +```text +ocx link port [--json] +ocx link issue --alias --tunnel-port [--json] +ocx link status [--json] +ocx link revoke --link-id [--json] +``` + +## 相关指南 + +- [Remote Hub 部署](/zh-cn/guides/remote-hub/) +- [远程工作区](/zh-cn/guides/remote-workspace/) diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-workspace.md b/docs-site/src/content/docs/zh-cn/guides/remote-workspace.md index d9946e8f63..950e6bf3bf 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-workspace.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-workspace.md @@ -3,6 +3,8 @@ title: Remote Workspace description: 将 Codex、Claude Code、Pi 及其登录保留在同一个 OCX Hub,让仅安装 OCX 的计算机提供工作区和构建环境。 --- +有关 SSH 机器链接,请参阅[远程链接](/zh-cn/guides/remote-link/)。 + Remote Workspace 让一台 OpenCodex Hub 运行编程代理,由另一台计算机提供项目文件、命令、测试和构建算力。手机或第三台计算机可通过 Hub 仪表盘控制会话。 ```text diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-hub.md b/docs-site/src/content/docs/zh-tw/guides/remote-hub.md index 9b473c95b0..5b51a41bdd 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-hub.md @@ -3,6 +3,8 @@ title: Remote Hub 部署 description: 使用僅限迴路的管理入口、Tailscale Serve 與無頭 OAuth 執行 opencodex hub。 --- +如需 SSH 機器連結,請參閱[遠端連結](/zh-tw/guides/remote-link/)。 + Remote Hub 把供應商憑證、模型目錄與用量記錄保存在一台主機上,已驗證的用戶端直接連到資料平面。管理平面彼此分離:選用的管理監聽器只綁定 `127.0.0.1`,僅提供儀表板與 `/api/*`。它不提供 `/v1/*`、`/healthz`、`/readyz` 或 WebSocket。不要直接發布 `10101`,也不要使用 Tailscale Funnel。 ## 角色與信任邊界 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 new file mode 100644 index 0000000000..d0e3f774bb --- /dev/null +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -0,0 +1,62 @@ +--- +title: 遠端連結 +description: 透過 SSH 連接 OpenCodex Home 電腦與 Child 電腦。 +--- + +機器連結透過 SSH 連接 OpenCodex **Home** 電腦與 **Child** 電腦。Home 透過 SSH 通道為 Child 提供服務,兩台電腦各自繼續在 `10100` 連接埠執行本機 OpenCodex 服務。儀表板會透過 SSH 傳送 Child 專用的連結金鑰,因此不需要手動輸入權杖。 + +## 需求 + +- Home 可以使用 OpenSSH 金鑰登入 Child。 +- Child 已安裝 OpenCodex。 +- 兩台電腦執行 macOS 或 Linux。 +- Home 儀表板擁有完整的已配對工作階段。 + +密碼 SSH、Windows,以及由 Child 發起的連結不在目前流程中。Child 發起的流程**即將推出**。 + +## 從 `#remote` 新增 Child + +1. 開啟儀表板的 `#remote`,開啟 Remote Link。 +2. 選擇 **Home**。 +3. 選擇 **Add child**。 +4. 從 SSH 候選主機選擇主機,或輸入 SSH 設定別名。 +5. 執行連線測試,並將顯示的主機指紋與目標電腦的指紋比較。比較指紋可在 SSH 信任主機前發現錯誤的電腦或已變更的主機金鑰。 +6. 確認指紋,然後連接 Child。 + +儀表板不會要求輸入權杖。它會先探測主機,只有明確確認指紋後才能套用連結。 + +## 連結狀態 + +- **Connected** 表示 SSH 通道已準備好,Child 可以使用 Home 連結。 +- **Reconnecting** 表示正在重試通道。重試期間請求可能暫時回傳帶有 `Retry-After` 的 `503`。 +- **Failed** 表示連結需要處理。請檢查 SSH 驗證、已確認的主機金鑰、轉送或逾時原因。 + +連結失敗時不會靜默切換到本機供應商。 + +## 移除 Child + +選擇 Child 的 **Disconnect** 並確認別名。Home 會停止通道、撤銷該 Child 的連結金鑰,並刪除已儲存的連結記錄。 + +如果 Home 無法連到 Child 來執行中斷連線命令,請選擇 **Remove here only**。這只會刪除本機的通道、金鑰與記錄。然後登入 Child 並執行: + +```bash +ocx disconnect +``` + +## 安全性 + +Child 會透過連結使用 Home 電腦上的供應商與供應商憑證。Home 會為每個 Child 建立獨立的連結金鑰;移除連結會撤銷該金鑰。確認前請比較主機指紋,避免誤接受錯誤電腦或已變更的主機金鑰。由 Tailscale 身分簽發的儀表板工作階段無法管理機器連結。 + +## CLI 參考 + +```text +ocx link port [--json] +ocx link issue --alias --tunnel-port [--json] +ocx link status [--json] +ocx link revoke --link-id [--json] +``` + +## 相關指南 + +- [Remote Hub 部署](/zh-tw/guides/remote-hub/) +- [遠端工作區](/zh-tw/guides/remote-workspace/) diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-workspace.md b/docs-site/src/content/docs/zh-tw/guides/remote-workspace.md index 0d6d14a189..a63a80285b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-workspace.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-workspace.md @@ -3,6 +3,8 @@ title: 遠端工作區 description: 將 Codex、Claude Code、Pi 及其登入集中在一台 OCX Hub,並由只安裝 OCX 的電腦提供工作區與建置環境。 --- +如需 SSH 機器連結,請參閱[遠端連結](/zh-tw/guides/remote-link/)。 + Remote Workspace 讓一台 OpenCodex Hub 執行程式碼代理,另一台電腦則提供專案檔案、指令、測試與建置運算。手機或第三台電腦可透過 Hub 儀表板控制工作階段。 ```text diff --git a/gui/scripts/remote-link-fixture.ts b/gui/scripts/remote-link-fixture.ts new file mode 100644 index 0000000000..c80d2816cb --- /dev/null +++ b/gui/scripts/remote-link-fixture.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env bun +import { readFile } from "node:fs/promises"; +import { join, normalize } from "node:path"; + +const portFlag = Bun.argv.indexOf("--port"); +const port = portFlag >= 0 ? Number(Bun.argv[portFlag + 1]) : 5199; +const root = join(import.meta.dir, "..", "dist"); +const json = (value: unknown, init: ResponseInit = {}) => Response.json(value, { headers: { "cache-control": "no-store", ...init.headers }, ...init }); +const status = (fixture: string) => { + if (fixture === "home-connected") return { role: "home", listener: { state: "listening", port: 44123 }, links: [{ id: "fixture-link-1", alias: "child-workstation", direction: "hub-initiated", state: "connected", since: "2026-09-25T00:00:00.000Z", reason: null, tunnelPort: 43110 }], child: null }; + if (fixture === "fingerprint") return { role: "home", listener: { state: "listening", port: 44123 }, links: [], child: null }; + if (fixture === "add-sheet") return { role: "home", listener: { state: "listening", port: 44123 }, links: [], child: null }; + return { role: "standalone", listener: { state: "off", port: null }, links: [], child: null }; +}; + +function fixtureFor(request: Request, url: URL): string { + const direct = url.searchParams.get("fixture"); + if (direct) return direct; + const cookie = request.headers.get("cookie")?.match(/(?:^|;\s*)ocx-fixture=([^;]+)/)?.[1]; + if (cookie) return decodeURIComponent(cookie); + const referer = request.headers.get("referer"); + try { return referer ? new URL(referer).searchParams.get("fixture") ?? "off" : "off"; } catch { return "off"; } +} + +async function staticFile(pathname: string): Promise { + const requested = pathname === "/" ? "/index.html" : pathname; + const file = normalize(join(root, requested.replace(/^\//, ""))); + if (!file.startsWith(root)) return new Response("Not found", { status: 404 }); + try { + const body = await readFile(file); + const type = file.endsWith(".html") ? "text/html" : file.endsWith(".css") ? "text/css" : file.endsWith(".js") ? "text/javascript" : "application/octet-stream"; + return new Response(body, { headers: { "content-type": type } }); + } catch { + return staticFile("/index.html"); + } +} + +async function appDocument(request: Request): Promise { + const body = await readFile(join(root, "index.html"), "utf8"); + const origin = new URL(request.url).origin; + const tags = ``; + return new Response(body.replace("", `${tags}`), { headers: { "content-type": "text/html" } }); +} + +const server = Bun.serve({ + port, + async fetch(request) { + const url = new URL(request.url); + const fixture = fixtureFor(request, url); + if (url.pathname === "/opencodex-session") return new Response(``, { headers: { "content-type": "text/html" } }); + if (url.pathname === "/api/remote-workspace" && request.method === "GET") return json({ available: true, devices: [], runtimes: {}, sessions: [] }); + if (url.pathname === "/api/link/status" && request.method === "GET") return json(status(fixture)); + if (url.pathname === "/api/link/candidates" && request.method === "GET") return json({ candidates: [{ alias: "child-workstation", source: "ssh_config" }, { alias: "tailscale-child", source: "tailscale" }] }); + if (url.pathname === "/api/link/probe" && request.method === "POST") return json({ alias: "child-workstation", fingerprint: "SHA256:fixture-host-key", keyType: "ED25519" }); + if (url.pathname === "/api/link/confirm-host" && request.method === "POST") return json({ alias: "child-workstation", fingerprint: "SHA256:fixture-host-key", ocxVersion: "0.0.0-fixture" }); + if (url.pathname === "/api/link/apply" && request.method === "POST") return json({ linkId: "fixture-link-1" }, { status: 202 }); + if (url.pathname.startsWith("/api/link/") && request.method === "DELETE") return json({ linkId: url.pathname.slice("/api/link/".length) }); + if (url.pathname === "/" || url.pathname === "/index.html") return appDocument(request); + return staticFile(url.pathname); + }, +}); + +console.log(`Remote link fixture listening on http://127.0.0.1:${server.port}`); diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 84e750f301..7e6b055eff 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactElement } from "react"; import { useKeyedClientResource } from "./client-resource"; import Dashboard from "./pages/Dashboard"; import Providers from "./pages/Providers"; @@ -11,13 +11,14 @@ import CodexSet from "./pages/CodexSet"; import Integrations from "./pages/Integrations"; import Startup from "./pages/Startup"; import RemoteWorkspace from "./pages/RemoteWorkspace"; +import RemoteLink from "./pages/RemoteLink"; import ErrorBoundary from "./components/ErrorBoundary"; import QuotaSummaryBar from "./components/quota-summary-bar/QuotaSummaryBar"; import { SidebarGithubRow } from "./components/sidebar-github-row"; import { DesktopStarOnboarding } from "./components/desktop-star-onboarding"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; -import { Select, ToastNotice, type NoticeTone } from "./ui"; +import { Notice, Select, ToastNotice, type NoticeTone } from "./ui"; import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api"; import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; @@ -41,6 +42,7 @@ const PAGE_TKEY: Record = { usage: "nav.usage", storage: "nav.storage", remote: "nav.remote", + "remote-workspace": "nav.remoteWorkspace", "codex-set": "nav.codexSet", integrations: "nav.integrations", }; @@ -75,12 +77,28 @@ const NAV: NavEntry[] = [ { id: "usage", tkey: "nav.usage", Icon: IconActivity }, { id: "storage", tkey: "nav.storage", Icon: IconHardDrive }, { id: "remote", tkey: "nav.remote", Icon: IconMonitor }, + { id: "remote-workspace", tkey: "nav.remoteWorkspace", Icon: IconMonitor }, { id: "integrations", tkey: "nav.integrations", Icon: IconGlobe }, ]; const THEME_ICON = { light: IconSun, dark: IconMoon, system: IconMonitor } as const; const THEME_TKEY: Record = { light: "theme.light", dark: "theme.dark", system: "theme.system" }; +export interface RemoteWorkspaceRouteProps { + available: boolean; + apiBase: string; + hubOrigin: string; + onOpenRemoteLink: () => void; +} + +export function RemoteWorkspaceRoute({ available, apiBase, hubOrigin, onOpenRemoteLink }: RemoteWorkspaceRouteProps): ReactElement { + const t = useT(); + if (!available) { + return

{t("nav.remoteWorkspace")}

{t("link.workspaceUnavailable")}
; + } + return ; +} + function readRuntimeVersion(data: unknown): string | null { if (!data || typeof data !== "object" || !("version" in data)) return null; const version = (data as { version?: unknown }).version; @@ -119,6 +137,7 @@ export default function App() { const [targetError, setTargetError] = useState(false); const [sharedSessionReady, setSharedSessionReady] = useState(() => hasApiSession("shared")); const [sharedSessionEpoch, setSharedSessionEpoch] = useState(0); + const [remoteWorkspaceAvailableState, setRemoteWorkspaceAvailable] = useState(false); const [sessionLoggingOut, setSessionLoggingOut] = useState(false); /* * Results from the two sidebar orbs used to be `alert()`, which the app's webview draws @@ -171,6 +190,17 @@ export default function App() { const machineBase = apiBaseForPlane("machine", targets); const sharedBase = apiBaseForPlane("shared", targets); + useEffect(() => { + if (!sharedSessionReady) return; + const controller = new AbortController(); + void fetch(`${sharedBase}/api/remote-workspace`, { signal: controller.signal, cache: "no-store" }) + .then(response => response.ok ? response.json() as Promise<{ available?: unknown }> : Promise.reject(new Error("unavailable"))) + .then(value => { if (!controller.signal.aborted) setRemoteWorkspaceAvailable(value.available === true); }) + .catch(() => { if (!controller.signal.aborted) setRemoteWorkspaceAvailable(false); }); + return () => controller.abort(); + }, [page, sharedSessionReady, sharedBase]); + const remoteWorkspaceAvailable = sharedSessionReady && remoteWorkspaceAvailableState; + // Narrow screens: the sidebar becomes an off-canvas drawer behind a hamburger toggle. const [navOpen, setNavOpen] = useState(false); const menuBtnRef = useRef(null); @@ -394,6 +424,7 @@ export default function App() { ClaudeCode owns GET/PUT /api/claude-code now, and the row itself is gone. */} {NAV.map(entry => { + if (entry.id === "remote-workspace" && !remoteWorkspaceAvailable) return null; const { id, tkey, Icon } = entry; const active = id === page; return ( @@ -514,7 +545,8 @@ export default function App() { {page === "logs" && } {page === "usage" && } {page === "storage" && } - {page === "remote" && } + {page === "remote" && navigateToPage("remote-workspace")} />} + {page === "remote-workspace" && navigateToPage("remote")} />} {page === "codex-set" && } {page === "integrations" && } diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 9dbf4d28bc..f31583e904 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -12,6 +12,7 @@ export type Page = | "usage" | "storage" | "remote" + | "remote-workspace" | "codex-set" | "integrations"; @@ -25,6 +26,7 @@ export const VALID_PAGES = new Set([ "usage", "storage", "remote", + "remote-workspace", "codex-set", "integrations", ]); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6733e44eb2..f91ee8102f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3131,7 +3131,97 @@ export const de: Record = { "models.pickerOrder.saveDraft": "Entwurf speichern", "models.pickerOrder.reloadDraft": "Neu laden und Entwurf verwerfen", "models.pickerOrder.catalogRequired": "Modellidentitäten fehlen oder sind mehrdeutig. Laden Sie die Modellseite neu, um den Katalog vor der Bearbeitung zu aktualisieren.", - "nav.remote": "Remote-Arbeitsbereich", + "nav.remote": "Remote-Link", + "nav.remoteWorkspace": "Remote-Arbeitsbereich", + "link.title": "Remote-Link", + "link.subtitle": "Verbinden Sie einen weiteren OpenCodex-Computer über SSH.", + "link.switch": "Remote-Verbindung", + "link.switchOffHint": "Aktivieren Sie die Verbindung, um die Rolle dieses Computers auszuwählen.", + "link.role.title": "Rolle dieses Computers auswählen", + "link.role.hint": "Home verwaltet Verbindungen. Die Einrichtung als Kind folgt später.", + "link.role.home": "Zuhause", + "link.role.homeHint": "Verwalten Sie Kindercomputer über dieses Dashboard.", + "link.role.child": "Kind", + "link.role.childHint": "Nutzen Sie die OpenCodex-Einrichtung eines anderen Computers.", + "link.continue": "Weiter", + "link.sessionRequired": "Melden Sie sich bei der lokalen Dashboard-Sitzung an, um Verbindungen zu verwalten.", + "link.workspaceUnavailable": "Remote Workspace ist auf dieser Installation nicht verfügbar.", + "link.loading": "Wird geladen…", + "link.loadFailed": "Status der Remote-Verbindung konnte nicht geladen werden.", + "link.refresh": "Aktualisieren", + "link.children": "Kindercomputer", + "link.addChild": "Kind hinzufügen", + "link.sheetTitle": "Kindercomputer hinzufügen", + "link.candidates": "SSH-Hosts", + "link.noCandidates": "Keine SSH-Hostkandidaten gefunden.", + "link.alias": "SSH-Host-Alias", + "link.aliasPlaceholder": "Alias aus der SSH-Konfiguration eingeben", + "link.probe": "Verbindung testen", + "link.probing": "Verbindung wird getestet…", + "link.hostFingerprint": "Hostschlüssel-Fingerabdruck", + "link.confirmFingerprint": "Ich erkenne diesen Hostschlüssel", + "link.confirming": "Wird bestätigt…", + "link.confirm": "Host bestätigen", + "link.ocxVersion": "OpenCodex-Version {version}", + "link.apply": "Kind verbinden", + "link.applying": "Verbindung wird hergestellt…", + "link.retry": "Erneut versuchen", + "link.disconnect": "Trennen", + "link.disconnectConfirm": "{alias} trennen? Der Verbindungsschlüssel wird widerrufen.", + "link.close": "Schließen", + "link.cancel": "Abbrechen", + "link.childPending": "Die Einrichtung als Kind ist im clientgestarteten Ablauf verfügbar.", + "link.noChildren": "Keine Kindercomputer verbunden.", + "remoteLink.status.connecting": "Verbindung wird hergestellt", + "remoteLink.status.connected": "Verbunden", + "remoteLink.status.reconnecting": "Wird neu verbunden", + "remoteLink.status.failed": "Verbindung benötigt Aufmerksamkeit", + "remoteLink.status.idle": "Leerlauf", + "remoteLink.role.standalone": "Eigenständig", + "remoteLink.role.home": "Zuhause", + "remoteLink.role.child": "Kind", + "remoteLink.direction.hub": "Vom Hub gestartet", + "remoteLink.direction.client": "Vom Client gestartet", + "remoteLink.error.admission_timeout": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.compensation_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.fingerprint_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.forbidden": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.host_confirmation_expired": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.host_fingerprint_mismatch": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.host_not_confirmed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.invalid_alias": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.invalid_body": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.invalid_link_id": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.key_issue_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.key_revoke_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.link_apply_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.link_exists": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.link_not_found": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.link_remove_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.link_unavailable": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.listener_unavailable": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.probe_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.remote_connect_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.remote_disconnect_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.remote_port_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.tailscale_session_refused": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.version_probe_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.generic": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.reason.auth": "Die Authentifizierung ist fehlgeschlagen.", + "remoteLink.reason.hostkey": "Der Hostschlüssel konnte nicht verifiziert werden.", + "remoteLink.reason.forward": "Die Portweiterleitung ist fehlgeschlagen.", + "remoteLink.reason.timeout": "Die Verbindung ist wegen einer Zeitüberschreitung fehlgeschlagen.", + "remoteLink.reason.bind": "Der lokale Port konnte nicht gebunden werden.", + "remoteLink.reason.persist": "Der Linkstatus konnte nicht gespeichert werden.", + "remoteLink.reason.compensation_failed": "Die Bereinigung nach dem Verknüpfen ist fehlgeschlagen.", + "remoteLink.reason.staleTunnel": "Ein veralteter Tunnel könnte den Port noch belegen.", + "remoteLink.reason.generic": "Grund:", + "remoteLink.workspaceMoved.title": "Remote Workspace hat jetzt eine eigene Seite", + "remoteLink.workspaceMoved.body": "Verwenden Sie die separate Remote-Workspace-Seite.", + "remoteLink.workspaceMoved.open": "Remote Workspace öffnen", + "remoteLink.forceRemove.title": "Dieses Gerät konnte {alias} nicht erreichen", + "remoteLink.forceRemove.body": "Link nur auf diesem Gerät entfernen? Führen Sie danach selbst {cmd} auf {alias} aus.", + "remoteLink.forceRemove.confirm": "Nur hier entfernen", "remote.title": "Remote-Arbeitsbereich", "remote.subtitle": "Codex, Claude Code oder Pi laufen auf diesem Hub; Dateien, Befehle, Tests und Builds bleiben auf dem ausgewählten Computer.", "remote.loading": "Remote-Arbeitsbereich wird geladen…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index fafa711a6d..419c1004c9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3165,7 +3165,97 @@ export const en = { "models.pickerOrder.saveDraft": "Save draft", "models.pickerOrder.reloadDraft": "Reload and discard draft", "models.pickerOrder.catalogRequired": "Model identities are missing or ambiguous. Reload the Models page to refresh its catalog before editing Custom.", - "nav.remote": "Remote Workspace", + "nav.remote": "Remote Link", + "nav.remoteWorkspace": "Remote Workspace", + "link.title": "Remote Link", + "link.subtitle": "Connect another OpenCodex computer over SSH.", + "link.switch": "Remote linking", + "link.switchOffHint": "Turn on linking to choose this computer's role.", + "link.role.title": "Choose this computer's role", + "link.role.hint": "Home manages links. Child setup is coming later.", + "link.role.home": "Home", + "link.role.homeHint": "Manage child computers from this dashboard.", + "link.role.child": "Child", + "link.role.childHint": "Use another computer's OpenCodex setup.", + "link.continue": "Continue", + "link.sessionRequired": "Sign in to the local dashboard session to manage links.", + "link.workspaceUnavailable": "Remote Workspace is unavailable on this installation.", + "link.loading": "Loading…", + "link.loadFailed": "Could not load remote link status.", + "link.refresh": "Refresh", + "link.children": "Child computers", + "link.addChild": "Add child", + "link.sheetTitle": "Add a child computer", + "link.candidates": "SSH hosts", + "link.noCandidates": "No SSH host candidates were found.", + "link.alias": "SSH host alias", + "link.aliasPlaceholder": "Enter an alias from your SSH config", + "link.probe": "Test connection", + "link.probing": "Testing connection…", + "link.hostFingerprint": "Host key fingerprint", + "link.confirmFingerprint": "I recognize this host key", + "link.confirming": "Confirming…", + "link.confirm": "Confirm host", + "link.ocxVersion": "OpenCodex version {version}", + "link.apply": "Connect child", + "link.applying": "Connecting…", + "link.retry": "Retry", + "link.disconnect": "Disconnect", + "link.disconnectConfirm": "Disconnect {alias}? Its link key will be revoked.", + "link.close": "Close", + "link.cancel": "Cancel", + "link.childPending": "Child setup is available in the client-started flow.", + "link.noChildren": "No child computers are connected.", + "remoteLink.status.connecting": "Connecting", + "remoteLink.status.connected": "Connected", + "remoteLink.status.reconnecting": "Reconnecting", + "remoteLink.status.failed": "Connection needs attention", + "remoteLink.status.idle": "Idle", + "remoteLink.role.standalone": "Standalone", + "remoteLink.role.home": "Home", + "remoteLink.role.child": "Child", + "remoteLink.direction.hub": "Hub initiated", + "remoteLink.direction.client": "Client initiated", + "remoteLink.error.admission_timeout": "Remote link request could not be completed.", + "remoteLink.error.compensation_failed": "Remote link request could not be completed.", + "remoteLink.error.fingerprint_failed": "Remote link request could not be completed.", + "remoteLink.error.forbidden": "Remote link request could not be completed.", + "remoteLink.error.host_confirmation_expired": "Remote link request could not be completed.", + "remoteLink.error.host_fingerprint_mismatch": "Remote link request could not be completed.", + "remoteLink.error.host_not_confirmed": "Remote link request could not be completed.", + "remoteLink.error.invalid_alias": "Remote link request could not be completed.", + "remoteLink.error.invalid_body": "Remote link request could not be completed.", + "remoteLink.error.invalid_link_id": "Remote link request could not be completed.", + "remoteLink.error.key_issue_failed": "Remote link request could not be completed.", + "remoteLink.error.key_revoke_failed": "Remote link request could not be completed.", + "remoteLink.error.link_apply_failed": "Remote link request could not be completed.", + "remoteLink.error.link_exists": "Remote link request could not be completed.", + "remoteLink.error.link_not_found": "Remote link request could not be completed.", + "remoteLink.error.link_remove_failed": "Remote link request could not be completed.", + "remoteLink.error.link_unavailable": "Remote link request could not be completed.", + "remoteLink.error.listener_unavailable": "Remote link request could not be completed.", + "remoteLink.error.probe_failed": "Remote link request could not be completed.", + "remoteLink.error.remote_connect_failed": "Remote link request could not be completed.", + "remoteLink.error.remote_disconnect_failed": "Remote link request could not be completed.", + "remoteLink.error.remote_port_failed": "Remote link request could not be completed.", + "remoteLink.error.tailscale_session_refused": "Remote link request could not be completed.", + "remoteLink.error.version_probe_failed": "Remote link request could not be completed.", + "remoteLink.error.generic": "Remote link request could not be completed.", + "remoteLink.reason.auth": "Authentication failed.", + "remoteLink.reason.hostkey": "The host key could not be verified.", + "remoteLink.reason.forward": "Port forwarding failed.", + "remoteLink.reason.timeout": "The connection timed out.", + "remoteLink.reason.bind": "The local port could not be bound.", + "remoteLink.reason.persist": "The link state could not be saved.", + "remoteLink.reason.compensation_failed": "Cleanup after linking failed.", + "remoteLink.reason.staleTunnel": "A stale tunnel may still hold the port.", + "remoteLink.reason.generic": "Reason:", + "remoteLink.workspaceMoved.title": "Remote Workspace has its own page now", + "remoteLink.workspaceMoved.body": "Use the separate Remote Workspace page.", + "remoteLink.workspaceMoved.open": "Open Remote Workspace", + "remoteLink.forceRemove.title": "This machine could not reach {alias}", + "remoteLink.forceRemove.body": "Remove the link only on this machine? Afterwards run {cmd} on {alias} yourself.", + "remoteLink.forceRemove.confirm": "Remove here only", "remote.title": "Remote Workspace", "remote.subtitle": "Run Codex, Claude Code, or Pi from this Hub while files, commands, tests, and builds stay on the computer you select.", "remote.loading": "Loading Remote Workspace…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 377c5c7b69..45e984d200 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3120,7 +3120,97 @@ export const fr: Record = { "models.pickerOrder.saveDraft": "Enregistrer le brouillon", "models.pickerOrder.reloadDraft": "Recharger et supprimer le brouillon", "models.pickerOrder.catalogRequired": "Les identités des modèles sont manquantes ou ambiguës. Rechargez la page Modèles pour actualiser le catalogue avant de personnaliser l’ordre.", - "nav.remote": "Espace distant", + "nav.remote": "Lien distant", + "nav.remoteWorkspace": "Espace de travail distant", + "link.title": "Lien distant", + "link.subtitle": "Connectez un autre ordinateur OpenCodex via SSH.", + "link.switch": "Connexion distante", + "link.switchOffHint": "Activez la connexion pour choisir le rôle de cet ordinateur.", + "link.role.title": "Choisir le rôle de cet ordinateur", + "link.role.hint": "Accueil gère les connexions. La configuration Enfant arrive plus tard.", + "link.role.home": "Accueil", + "link.role.homeHint": "Gérez les ordinateurs enfants depuis ce tableau de bord.", + "link.role.child": "Enfant", + "link.role.childHint": "Utilisez la configuration OpenCodex d’un autre ordinateur.", + "link.continue": "Continuer", + "link.sessionRequired": "Connectez-vous à la session locale du tableau de bord pour gérer les liens.", + "link.workspaceUnavailable": "Remote Workspace n’est pas disponible sur cette installation.", + "link.loading": "Chargement…", + "link.loadFailed": "Impossible de charger l’état du lien distant.", + "link.refresh": "Actualiser", + "link.children": "Ordinateurs enfants", + "link.addChild": "Ajouter un enfant", + "link.sheetTitle": "Ajouter un ordinateur enfant", + "link.candidates": "Hôtes SSH", + "link.noCandidates": "Aucun hôte SSH candidat trouvé.", + "link.alias": "Alias de l’hôte SSH", + "link.aliasPlaceholder": "Saisissez un alias de votre configuration SSH", + "link.probe": "Tester la connexion", + "link.probing": "Test de la connexion…", + "link.hostFingerprint": "Empreinte de clé hôte", + "link.confirmFingerprint": "Je reconnais cette clé hôte", + "link.confirming": "Confirmation…", + "link.confirm": "Confirmer l’hôte", + "link.ocxVersion": "Version OpenCodex {version}", + "link.apply": "Connecter l’enfant", + "link.applying": "Connexion…", + "link.retry": "Réessayer", + "link.disconnect": "Déconnecter", + "link.disconnectConfirm": "Déconnecter {alias} ? Sa clé de lien sera révoquée.", + "link.close": "Fermer", + "link.cancel": "Annuler", + "link.childPending": "La configuration Enfant sera disponible dans le flux lancé par le client.", + "link.noChildren": "Aucun ordinateur enfant connecté.", + "remoteLink.status.connecting": "Connexion", + "remoteLink.status.connected": "Connecté", + "remoteLink.status.reconnecting": "Reconnexion", + "remoteLink.status.failed": "Connexion à vérifier", + "remoteLink.status.idle": "Inactif", + "remoteLink.role.standalone": "Autonome", + "remoteLink.role.home": "Accueil", + "remoteLink.role.child": "Enfant", + "remoteLink.direction.hub": "Lancé par le Hub", + "remoteLink.direction.client": "Lancé par le client", + "remoteLink.error.admission_timeout": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.compensation_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.fingerprint_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.forbidden": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.host_confirmation_expired": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.host_fingerprint_mismatch": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.host_not_confirmed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.invalid_alias": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.invalid_body": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.invalid_link_id": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.key_issue_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.key_revoke_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.link_apply_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.link_exists": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.link_not_found": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.link_remove_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.link_unavailable": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.listener_unavailable": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.probe_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.remote_connect_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.remote_disconnect_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.remote_port_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.tailscale_session_refused": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.version_probe_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.generic": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.reason.auth": "L’authentification a échoué.", + "remoteLink.reason.hostkey": "La clé d’hôte n’a pas pu être vérifiée.", + "remoteLink.reason.forward": "La redirection de port a échoué.", + "remoteLink.reason.timeout": "La connexion a expiré.", + "remoteLink.reason.bind": "Le port local n’a pas pu être réservé.", + "remoteLink.reason.persist": "L’état du lien n’a pas pu être enregistré.", + "remoteLink.reason.compensation_failed": "Le nettoyage après la liaison a échoué.", + "remoteLink.reason.staleTunnel": "Un ancien tunnel peut encore occuper le port.", + "remoteLink.reason.generic": "Motif :", + "remoteLink.workspaceMoved.title": "Remote Workspace a maintenant sa propre page", + "remoteLink.workspaceMoved.body": "Utilisez la page Remote Workspace séparée.", + "remoteLink.workspaceMoved.open": "Ouvrir Remote Workspace", + "remoteLink.forceRemove.title": "Cet appareil n’a pas pu joindre {alias}", + "remoteLink.forceRemove.body": "Supprimer le lien uniquement ici ? Exécutez ensuite vous-même {cmd} sur {alias}.", + "remoteLink.forceRemove.confirm": "Supprimer ici seulement", "remote.title": "Espace de travail distant", "remote.subtitle": "Codex, Claude Code ou Pi s'exécutent sur ce Hub tandis que fichiers, commandes, tests et builds restent sur l'ordinateur choisi.", "remote.loading": "Chargement de l'espace distant…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e48ae03020..4579ca39df 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3153,7 +3153,97 @@ export const ja: Record = { "models.pickerOrder.saveDraft": "下書きを保存", "models.pickerOrder.reloadDraft": "下書きを破棄して再読み込み", "models.pickerOrder.catalogRequired": "モデルの識別情報が不足しているか曖昧です。モデルページを再読み込みしてカタログを更新してからカスタム順序を編集してください。", - "nav.remote": "リモートワークスペース", + "nav.remote": "リモートリンク", + "nav.remoteWorkspace": "リモートワークスペース", + "link.title": "リモートリンク", + "link.subtitle": "SSH で別のコンピューター上の OpenCodex に接続します。", + "link.switch": "リモート接続", + "link.switchOffHint": "接続をオンにして、このコンピューターの役割を選びます。", + "link.role.title": "このコンピューターの役割を選択", + "link.role.hint": "ホームが接続を管理します。子の設定は後日対応します。", + "link.role.home": "ホーム", + "link.role.homeHint": "このダッシュボードから子コンピューターを管理します。", + "link.role.child": "子", + "link.role.childHint": "別のコンピューターの OpenCodex 設定を使います。", + "link.continue": "続行", + "link.sessionRequired": "リンクを管理するにはローカルダッシュボードにログインします。", + "link.workspaceUnavailable": "このインストールでは Remote Workspace を利用できません。", + "link.loading": "読み込み中…", + "link.loadFailed": "リモートリンクの状態を読み込めませんでした。", + "link.refresh": "更新", + "link.children": "子コンピューター", + "link.addChild": "子を追加", + "link.sheetTitle": "子コンピューターを追加", + "link.candidates": "SSH ホスト", + "link.noCandidates": "SSH ホスト候補が見つかりません。", + "link.alias": "SSH ホスト別名", + "link.aliasPlaceholder": "SSH 設定の別名を入力", + "link.probe": "接続をテスト", + "link.probing": "接続をテスト中…", + "link.hostFingerprint": "ホストキーのフィンガープリント", + "link.confirmFingerprint": "このホストキーを確認しました", + "link.confirming": "確認中…", + "link.confirm": "ホストを確認", + "link.ocxVersion": "OpenCodex バージョン {version}", + "link.apply": "子を接続", + "link.applying": "接続中…", + "link.retry": "再試行", + "link.disconnect": "切断", + "link.disconnectConfirm": "{alias} を切断しますか?リンクキーは失効します。", + "link.close": "閉じる", + "link.cancel": "キャンセル", + "link.childPending": "子の設定はクライアント開始フローで提供されます。", + "link.noChildren": "接続された子コンピューターはありません。", + "remoteLink.status.connecting": "接続中", + "remoteLink.status.connected": "接続済み", + "remoteLink.status.reconnecting": "再接続中", + "remoteLink.status.failed": "接続の確認が必要", + "remoteLink.status.idle": "アイドル", + "remoteLink.role.standalone": "スタンドアロン", + "remoteLink.role.home": "ホーム", + "remoteLink.role.child": "子", + "remoteLink.direction.hub": "ホーム開始", + "remoteLink.direction.client": "クライアント開始", + "remoteLink.error.admission_timeout": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.compensation_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.fingerprint_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.forbidden": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.host_confirmation_expired": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.host_fingerprint_mismatch": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.host_not_confirmed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.invalid_alias": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.invalid_body": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.invalid_link_id": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.key_issue_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.key_revoke_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.link_apply_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.link_exists": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.link_not_found": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.link_remove_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.link_unavailable": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.listener_unavailable": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.probe_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.remote_connect_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.remote_disconnect_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.remote_port_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.tailscale_session_refused": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.version_probe_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.generic": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.reason.auth": "認証に失敗しました。", + "remoteLink.reason.hostkey": "ホストキーを確認できませんでした。", + "remoteLink.reason.forward": "ポート転送に失敗しました。", + "remoteLink.reason.timeout": "接続がタイムアウトしました。", + "remoteLink.reason.bind": "ローカルポートをバインドできませんでした。", + "remoteLink.reason.persist": "リンク状態を保存できませんでした。", + "remoteLink.reason.compensation_failed": "リンク後のクリーンアップに失敗しました。", + "remoteLink.reason.staleTunnel": "古いトンネルがまだポートを使用している可能性があります。", + "remoteLink.reason.generic": "理由:", + "remoteLink.workspaceMoved.title": "Remote Workspace は専用ページになりました", + "remoteLink.workspaceMoved.body": "専用の Remote Workspace ページを使用します。", + "remoteLink.workspaceMoved.open": "Remote Workspace を開く", + "remoteLink.forceRemove.title": "このコンピューターは {alias} に接続できませんでした", + "remoteLink.forceRemove.body": "このコンピューターだけでリンクを削除しますか?その後 {alias} で自分で {cmd} を実行してください。", + "remoteLink.forceRemove.confirm": "ここだけ削除", "remote.title": "リモートワークスペース", "remote.subtitle": "Codex、Claude Code、Pi はこの Hub で実行し、ファイル、コマンド、テスト、ビルドは選択したコンピューターで処理します。", "remote.loading": "リモートワークスペースを読み込み中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 78642b59ca..7cf007a388 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3153,7 +3153,97 @@ export const ko: Record = { "models.pickerOrder.saveDraft": "초안 저장", "models.pickerOrder.reloadDraft": "초안 버리고 다시 불러오기", "models.pickerOrder.catalogRequired": "모델 식별 정보가 없거나 모호합니다. 모델 페이지를 새로고침해 목록을 갱신한 뒤 사용자 지정 순서를 편집하세요.", - "nav.remote": "원격 워크스페이스", + "nav.remote": "원격 연결", + "nav.remoteWorkspace": "원격 워크스페이스", + "link.title": "원격 연결", + "link.subtitle": "SSH로 다른 OpenCodex 컴퓨터에 연결합니다.", + "link.switch": "원격 연결", + "link.switchOffHint": "연결을 켜고 이 컴퓨터의 역할을 선택하세요.", + "link.role.title": "이 컴퓨터의 역할 선택", + "link.role.hint": "홈이 연결을 관리합니다. 자식 설정은 준비 중입니다.", + "link.role.home": "홈", + "link.role.homeHint": "이 대시보드에서 자식 컴퓨터를 관리합니다.", + "link.role.child": "자식", + "link.role.childHint": "다른 컴퓨터의 OpenCodex 설정을 사용합니다.", + "link.continue": "계속", + "link.sessionRequired": "연결을 관리하려면 로컬 대시보드 세션에 로그인하세요.", + "link.workspaceUnavailable": "이 설치에서는 원격 워크스페이스를 사용할 수 없습니다.", + "link.loading": "불러오는 중…", + "link.loadFailed": "원격 연결 상태를 불러오지 못했습니다.", + "link.refresh": "새로고침", + "link.children": "자식 컴퓨터", + "link.addChild": "자식 추가", + "link.sheetTitle": "자식 컴퓨터 추가", + "link.candidates": "SSH 호스트", + "link.noCandidates": "SSH 호스트 후보를 찾지 못했습니다.", + "link.alias": "SSH 호스트 별칭", + "link.aliasPlaceholder": "SSH 설정의 별칭을 입력하세요", + "link.probe": "연결 테스트", + "link.probing": "연결 테스트 중…", + "link.hostFingerprint": "호스트 키 지문", + "link.confirmFingerprint": "이 호스트 키를 확인했습니다", + "link.confirming": "확인 중…", + "link.confirm": "호스트 확인", + "link.ocxVersion": "OpenCodex 버전 {version}", + "link.apply": "자식 연결", + "link.applying": "연결 중…", + "link.retry": "다시 시도", + "link.disconnect": "연결 해제", + "link.disconnectConfirm": "{alias}의 연결을 해제할까요? 연결 키가 폐기됩니다.", + "link.close": "닫기", + "link.cancel": "취소", + "link.childPending": "자식 설정은 클라이언트 시작 흐름에서 제공될 예정입니다.", + "link.noChildren": "연결된 자식 컴퓨터가 없습니다.", + "remoteLink.status.connecting": "연결 중", + "remoteLink.status.connected": "연결됨", + "remoteLink.status.reconnecting": "재연결 중", + "remoteLink.status.failed": "연결 확인 필요", + "remoteLink.status.idle": "유휴", + "remoteLink.role.standalone": "독립형", + "remoteLink.role.home": "홈", + "remoteLink.role.child": "자식", + "remoteLink.direction.hub": "Hub 시작", + "remoteLink.direction.client": "클라이언트 시작", + "remoteLink.error.admission_timeout": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.compensation_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.fingerprint_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.forbidden": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.host_confirmation_expired": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.host_fingerprint_mismatch": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.host_not_confirmed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.invalid_alias": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.invalid_body": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.invalid_link_id": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.key_issue_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.key_revoke_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.link_apply_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.link_exists": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.link_not_found": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.link_remove_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.link_unavailable": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.listener_unavailable": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.probe_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.remote_connect_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.remote_disconnect_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.remote_port_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.tailscale_session_refused": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.version_probe_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.generic": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.reason.auth": "인증에 실패했습니다.", + "remoteLink.reason.hostkey": "호스트 키를 확인하지 못했습니다.", + "remoteLink.reason.forward": "포트 전달에 실패했습니다.", + "remoteLink.reason.timeout": "연결 시간이 초과되었습니다.", + "remoteLink.reason.bind": "로컬 포트에 바인딩하지 못했습니다.", + "remoteLink.reason.persist": "링크 상태를 저장하지 못했습니다.", + "remoteLink.reason.compensation_failed": "링크 연결 후 정리에 실패했습니다.", + "remoteLink.reason.staleTunnel": "오래된 터널이 포트를 아직 사용 중일 수 있습니다.", + "remoteLink.reason.generic": "이유:", + "remoteLink.workspaceMoved.title": "원격 워크스페이스가 별도 페이지로 이동했습니다", + "remoteLink.workspaceMoved.body": "별도의 원격 워크스페이스 페이지를 사용하세요.", + "remoteLink.workspaceMoved.open": "원격 워크스페이스 열기", + "remoteLink.forceRemove.title": "이 컴퓨터가 {alias}에 연결하지 못했습니다", + "remoteLink.forceRemove.body": "이 컴퓨터에서만 링크를 지울까요? 그 후 {alias}에서 직접 {cmd}를 실행하세요.", + "remoteLink.forceRemove.confirm": "여기에서만 삭제", "remote.title": "원격 워크스페이스", "remote.subtitle": "Codex, Claude Code, Pi는 이 Hub에서 실행하고 파일·명령·테스트·빌드는 선택한 컴퓨터에서 처리합니다.", "remote.loading": "원격 워크스페이스 불러오는 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 6e8a82ce15..3af97d8c24 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3154,7 +3154,97 @@ export const ru: Record = { "models.pickerOrder.saveDraft": "Сохранить черновик", "models.pickerOrder.reloadDraft": "Перезагрузить и сбросить черновик", "models.pickerOrder.catalogRequired": "Идентификаторы моделей отсутствуют или неоднозначны. Перезагрузите страницу моделей, чтобы обновить каталог перед редактированием порядка.", - "nav.remote": "Удалённое рабочее пространство", + "nav.remote": "Удалённая связь", + "nav.remoteWorkspace": "Удалённое рабочее пространство", + "link.title": "Удалённая связь", + "link.subtitle": "Подключите другой компьютер OpenCodex по SSH.", + "link.switch": "Удалённое подключение", + "link.switchOffHint": "Включите связь, чтобы выбрать роль этого компьютера.", + "link.role.title": "Выберите роль этого компьютера", + "link.role.hint": "Главный управляет связями. Настройка дочернего режима появится позже.", + "link.role.home": "Главный", + "link.role.homeHint": "Управляйте дочерними компьютерами из этой панели.", + "link.role.child": "Дочерний", + "link.role.childHint": "Используйте настройку OpenCodex другого компьютера.", + "link.continue": "Продолжить", + "link.sessionRequired": "Войдите в локальную сессию панели для управления связями.", + "link.workspaceUnavailable": "Remote Workspace недоступен в этой установке.", + "link.loading": "Загрузка…", + "link.loadFailed": "Не удалось загрузить состояние удалённой связи.", + "link.refresh": "Обновить", + "link.children": "Дочерние компьютеры", + "link.addChild": "Добавить дочерний", + "link.sheetTitle": "Добавить дочерний компьютер", + "link.candidates": "SSH-хосты", + "link.noCandidates": "Кандидаты SSH-хостов не найдены.", + "link.alias": "Псевдоним SSH-хоста", + "link.aliasPlaceholder": "Введите псевдоним из конфигурации SSH", + "link.probe": "Проверить соединение", + "link.probing": "Проверка соединения…", + "link.hostFingerprint": "Отпечаток ключа хоста", + "link.confirmFingerprint": "Я подтверждаю этот ключ хоста", + "link.confirming": "Подтверждение…", + "link.confirm": "Подтвердить хост", + "link.ocxVersion": "Версия OpenCodex {version}", + "link.apply": "Подключить дочерний", + "link.applying": "Подключение…", + "link.retry": "Повторить", + "link.disconnect": "Отключить", + "link.disconnectConfirm": "Отключить {alias}? Ключ связи будет отозван.", + "link.close": "Закрыть", + "link.cancel": "Отмена", + "link.childPending": "Настройка дочернего режима появится в потоке, запущенном клиентом.", + "link.noChildren": "Дочерние компьютеры не подключены.", + "remoteLink.status.connecting": "Подключение", + "remoteLink.status.connected": "Подключено", + "remoteLink.status.reconnecting": "Переподключение", + "remoteLink.status.failed": "Связь требует внимания", + "remoteLink.status.idle": "Неактивно", + "remoteLink.role.standalone": "Автономный", + "remoteLink.role.home": "Главный", + "remoteLink.role.child": "Дочерний", + "remoteLink.direction.hub": "Инициировано главным", + "remoteLink.direction.client": "Инициировано клиентом", + "remoteLink.error.admission_timeout": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.compensation_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.fingerprint_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.forbidden": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.host_confirmation_expired": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.host_fingerprint_mismatch": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.host_not_confirmed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.invalid_alias": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.invalid_body": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.invalid_link_id": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.key_issue_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.key_revoke_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.link_apply_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.link_exists": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.link_not_found": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.link_remove_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.link_unavailable": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.listener_unavailable": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.probe_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.remote_connect_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.remote_disconnect_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.remote_port_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.tailscale_session_refused": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.version_probe_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.generic": "Не удалось завершить запрос удалённой связи.", + "remoteLink.reason.auth": "Ошибка аутентификации.", + "remoteLink.reason.hostkey": "Не удалось проверить ключ хоста.", + "remoteLink.reason.forward": "Не удалось настроить перенаправление порта.", + "remoteLink.reason.timeout": "Время ожидания подключения истекло.", + "remoteLink.reason.bind": "Не удалось привязать локальный порт.", + "remoteLink.reason.persist": "Не удалось сохранить состояние связи.", + "remoteLink.reason.compensation_failed": "Не удалось выполнить очистку после связывания.", + "remoteLink.reason.staleTunnel": "Старый туннель всё ещё может занимать порт.", + "remoteLink.reason.generic": "Причина:", + "remoteLink.workspaceMoved.title": "У Remote Workspace теперь отдельная страница", + "remoteLink.workspaceMoved.body": "Используйте отдельную страницу Remote Workspace.", + "remoteLink.workspaceMoved.open": "Открыть Remote Workspace", + "remoteLink.forceRemove.title": "Этому компьютеру не удалось связаться с {alias}", + "remoteLink.forceRemove.body": "Удалить связь только на этом компьютере? Затем самостоятельно выполните {cmd} на {alias}.", + "remoteLink.forceRemove.confirm": "Удалить только здесь", "remote.title": "Удалённое рабочее пространство", "remote.subtitle": "Codex, Claude Code или Pi работают на этом Hub, а файлы, команды, тесты и сборки остаются на выбранном компьютере.", "remote.loading": "Загрузка удалённого рабочего пространства…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 65c58d3572..cba018ea8f 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3154,7 +3154,97 @@ export const tr: Record = { "models.pickerOrder.saveDraft": "Taslağı kaydet", "models.pickerOrder.reloadDraft": "Yeniden yükle ve taslağı sil", "models.pickerOrder.catalogRequired": "Model kimlikleri eksik veya belirsiz. Özel sırayı düzenlemeden önce kataloğu yenilemek için Modeller sayfasını yeniden yükleyin.", - "nav.remote": "Uzak Çalışma Alanı", + "nav.remote": "Uzak bağlantı", + "nav.remoteWorkspace": "Uzak Çalışma Alanı", + "link.title": "Uzak bağlantı", + "link.subtitle": "SSH üzerinden başka bir OpenCodex bilgisayarına bağlanın.", + "link.switch": "Uzak bağlantı", + "link.switchOffHint": "Bu bilgisayarın rolünü seçmek için bağlantıyı açın.", + "link.role.title": "Bu bilgisayarın rolünü seçin", + "link.role.hint": "Ana bağlantıları yönetir. Çocuk kurulumu daha sonra gelecek.", + "link.role.home": "Ana", + "link.role.homeHint": "Çocuk bilgisayarlarını bu panodan yönetin.", + "link.role.child": "Çocuk", + "link.role.childHint": "Başka bir bilgisayarın OpenCodex kurulumunu kullanın.", + "link.continue": "Devam", + "link.sessionRequired": "Bağlantıları yönetmek için yerel pano oturumunda oturum açın.", + "link.workspaceUnavailable": "Remote Workspace bu kurulumda kullanılamıyor.", + "link.loading": "Yükleniyor…", + "link.loadFailed": "Uzak bağlantı durumu yüklenemedi.", + "link.refresh": "Yenile", + "link.children": "Çocuk bilgisayarları", + "link.addChild": "Çocuk ekle", + "link.sheetTitle": "Çocuk bilgisayarı ekle", + "link.candidates": "SSH ana bilgisayarları", + "link.noCandidates": "SSH ana bilgisayarı adayı bulunamadı.", + "link.alias": "SSH ana bilgisayar takma adı", + "link.aliasPlaceholder": "SSH yapılandırmanızdaki takma adı girin", + "link.probe": "Bağlantıyı test et", + "link.probing": "Bağlantı test ediliyor…", + "link.hostFingerprint": "Ana bilgisayar anahtar parmak izi", + "link.confirmFingerprint": "Bu ana bilgisayar anahtarını tanıyorum", + "link.confirming": "Onaylanıyor…", + "link.confirm": "Ana bilgisayarı onayla", + "link.ocxVersion": "OpenCodex sürümü {version}", + "link.apply": "Çocuğu bağla", + "link.applying": "Bağlanıyor…", + "link.retry": "Tekrar dene", + "link.disconnect": "Bağlantıyı kes", + "link.disconnectConfirm": "{alias} bağlantısı kesilsin mi? Bağlantı anahtarı iptal edilir.", + "link.close": "Kapat", + "link.cancel": "İptal", + "link.childPending": "Çocuk kurulumu istemci başlatmalı akışta sunulacak.", + "link.noChildren": "Bağlı çocuk bilgisayarı yok.", + "remoteLink.status.connecting": "Bağlanıyor", + "remoteLink.status.connected": "Bağlandı", + "remoteLink.status.reconnecting": "Yeniden bağlanıyor", + "remoteLink.status.failed": "Bağlantı ilgilenilmesini gerektiriyor", + "remoteLink.status.idle": "Boşta", + "remoteLink.role.standalone": "Bağımsız", + "remoteLink.role.home": "Ana", + "remoteLink.role.child": "Çocuk", + "remoteLink.direction.hub": "Ana tarafından başlatıldı", + "remoteLink.direction.client": "İstemci tarafından başlatıldı", + "remoteLink.error.admission_timeout": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.compensation_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.fingerprint_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.forbidden": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.host_confirmation_expired": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.host_fingerprint_mismatch": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.host_not_confirmed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.invalid_alias": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.invalid_body": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.invalid_link_id": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.key_issue_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.key_revoke_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.link_apply_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.link_exists": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.link_not_found": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.link_remove_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.link_unavailable": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.listener_unavailable": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.probe_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.remote_connect_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.remote_disconnect_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.remote_port_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.tailscale_session_refused": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.version_probe_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.generic": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.reason.auth": "Kimlik doğrulama başarısız oldu.", + "remoteLink.reason.hostkey": "Ana bilgisayar anahtarı doğrulanamadı.", + "remoteLink.reason.forward": "Bağlantı noktası yönlendirmesi başarısız oldu.", + "remoteLink.reason.timeout": "Bağlantı zaman aşımına uğradı.", + "remoteLink.reason.bind": "Yerel bağlantı noktası bağlanamadı.", + "remoteLink.reason.persist": "Bağlantı durumu kaydedilemedi.", + "remoteLink.reason.compensation_failed": "Bağlantı sonrası temizleme başarısız oldu.", + "remoteLink.reason.staleTunnel": "Eski bir tünel bağlantı noktasını hâlâ kullanıyor olabilir.", + "remoteLink.reason.generic": "Neden:", + "remoteLink.workspaceMoved.title": "Remote Workspace artık ayrı bir sayfada", + "remoteLink.workspaceMoved.body": "Ayrı Remote Workspace sayfasını kullanın.", + "remoteLink.workspaceMoved.open": "Remote Workspace'i aç", + "remoteLink.forceRemove.title": "Bu bilgisayar {alias} adresine ulaşamadı", + "remoteLink.forceRemove.body": "Bağlantı yalnızca bu bilgisayardan kaldırılsın mı? Ardından {alias} üzerinde {cmd} komutunu kendiniz çalıştırın.", + "remoteLink.forceRemove.confirm": "Yalnızca burada kaldır", "remote.title": "Uzak Çalışma Alanı", "remote.subtitle": "Codex, Claude Code veya Pi bu Hub üzerinde çalışır; dosyalar, komutlar, testler ve derlemeler seçtiğiniz bilgisayarda kalır.", "remote.loading": "Uzak çalışma alanı yükleniyor…", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 26839b38fa..7963d3f1a7 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1930,9 +1930,9 @@ export const vi: Record = { "integrations.cursor.effortRowsMany": "{n} dòng effort được công bố", "integrations.cursor.effortRowsOff": "không có dòng effort", "integrations.cursor.tableLessHint": "Các dòng được đánh dấu — sẽ không có nút điều khiển Reasoning trong Cursor. Bật cursorEffortRows để xuất ra một mục lựa chọn cho mỗi effort (id--effort), hoặc đặt modelDefaultReasoningEfforts trên provider để dùng mặc định cố định.", - "integrations.cursor.colModel": "Model", - "integrations.cursor.colReasoning": "Reasoning", - "integrations.cursor.colContext": "Context", + "integrations.cursor.colModel": "Mô hình", + "integrations.cursor.colReasoning": "Suy luận", + "integrations.cursor.colContext": "Ngữ cảnh", "integrations.cursor.guide": "Mở hướng dẫn Cursor Private Inference", "integrations.dialog.codex.title": "Vô hiệu hóa tích hợp Codex?", "integrations.dialog.codex.changes": "opencodex sẽ gỡ bỏ routing của nó khỏi {path}, gỡ bỏ profile nó tạo ra, khôi phục lại model catalog mặc định, và đổi thẻ các luồng có thể tiếp tục về native Codex.", @@ -3089,7 +3089,97 @@ export const vi: Record = { "models.pickerOrder.saveDraft": "Lưu bản nháp", "models.pickerOrder.reloadDraft": "Tải lại và bỏ bản nháp", "models.pickerOrder.catalogRequired": "Danh tính của các model bị thiếu hoặc không rõ ràng. Hãy tải lại trang Models để làm mới danh mục của nó trước khi chỉnh sửa Tùy chỉnh (Custom).", - "nav.remote": "Không gian làm việc từ xa", + "nav.remote": "Liên kết từ xa", + "nav.remoteWorkspace": "Không gian làm việc từ xa", + "link.title": "Liên kết từ xa", + "link.subtitle": "Kết nối một máy OpenCodex khác qua SSH.", + "link.switch": "Kết nối từ xa", + "link.switchOffHint": "Bật kết nối để chọn vai trò của máy tính này.", + "link.role.title": "Chọn vai trò của máy tính này", + "link.role.hint": "Máy chủ quản lý liên kết. Thiết lập máy con sẽ có sau.", + "link.role.home": "Máy chủ", + "link.role.homeHint": "Quản lý máy con từ bảng điều khiển này.", + "link.role.child": "Máy con", + "link.role.childHint": "Dùng thiết lập OpenCodex của máy khác.", + "link.continue": "Tiếp tục", + "link.sessionRequired": "Đăng nhập phiên bảng điều khiển cục bộ để quản lý liên kết.", + "link.workspaceUnavailable": "Remote Workspace không khả dụng trong bản cài đặt này.", + "link.loading": "Đang tải…", + "link.loadFailed": "Không thể tải trạng thái liên kết từ xa.", + "link.refresh": "Làm mới", + "link.children": "Máy con", + "link.addChild": "Thêm máy con", + "link.sheetTitle": "Thêm máy con", + "link.candidates": "Máy chủ SSH", + "link.noCandidates": "Không tìm thấy máy chủ SSH phù hợp.", + "link.alias": "Bí danh máy chủ SSH", + "link.aliasPlaceholder": "Nhập bí danh từ cấu hình SSH", + "link.probe": "Kiểm tra kết nối", + "link.probing": "Đang kiểm tra kết nối…", + "link.hostFingerprint": "Dấu vân tay khóa máy chủ", + "link.confirmFingerprint": "Tôi xác nhận khóa máy chủ này", + "link.confirming": "Đang xác nhận…", + "link.confirm": "Xác nhận máy chủ", + "link.ocxVersion": "Phiên bản OpenCodex {version}", + "link.apply": "Kết nối máy con", + "link.applying": "Đang kết nối…", + "link.retry": "Thử lại", + "link.disconnect": "Ngắt kết nối", + "link.disconnectConfirm": "Ngắt kết nối {alias}? Khóa liên kết sẽ bị thu hồi.", + "link.close": "Đóng", + "link.cancel": "Hủy", + "link.childPending": "Thiết lập máy con sẽ có trong luồng do máy con khởi tạo.", + "link.noChildren": "Chưa có máy con nào được kết nối.", + "remoteLink.status.connecting": "Đang kết nối", + "remoteLink.status.connected": "Đã kết nối", + "remoteLink.status.reconnecting": "Đang kết nối lại", + "remoteLink.status.failed": "Kết nối cần được xử lý", + "remoteLink.status.idle": "Nhàn rỗi", + "remoteLink.role.standalone": "Độc lập", + "remoteLink.role.home": "Máy chủ", + "remoteLink.role.child": "Máy con", + "remoteLink.direction.hub": "Do máy chủ khởi tạo", + "remoteLink.direction.client": "Do máy con khởi tạo", + "remoteLink.error.admission_timeout": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.compensation_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.fingerprint_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.forbidden": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.host_confirmation_expired": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.host_fingerprint_mismatch": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.host_not_confirmed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.invalid_alias": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.invalid_body": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.invalid_link_id": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.key_issue_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.key_revoke_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.link_apply_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.link_exists": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.link_not_found": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.link_remove_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.link_unavailable": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.listener_unavailable": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.probe_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.remote_connect_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.remote_disconnect_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.remote_port_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.tailscale_session_refused": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.version_probe_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.generic": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.reason.auth": "Xác thực không thành công.", + "remoteLink.reason.hostkey": "Không thể xác minh khóa máy chủ.", + "remoteLink.reason.forward": "Chuyển tiếp cổng không thành công.", + "remoteLink.reason.timeout": "Kết nối đã hết thời gian chờ.", + "remoteLink.reason.bind": "Không thể liên kết cổng cục bộ.", + "remoteLink.reason.persist": "Không thể lưu trạng thái liên kết.", + "remoteLink.reason.compensation_failed": "Không thể dọn dẹp sau khi liên kết.", + "remoteLink.reason.staleTunnel": "Một đường hầm cũ vẫn có thể đang giữ cổng.", + "remoteLink.reason.generic": "Lý do:", + "remoteLink.workspaceMoved.title": "Không gian làm việc từ xa nay có trang riêng", + "remoteLink.workspaceMoved.body": "Hãy dùng trang Không gian làm việc từ xa riêng.", + "remoteLink.workspaceMoved.open": "Mở Không gian làm việc từ xa", + "remoteLink.forceRemove.title": "Máy này không thể kết nối tới {alias}", + "remoteLink.forceRemove.body": "Chỉ xóa liên kết trên máy này? Sau đó tự chạy {cmd} trên {alias}.", + "remoteLink.forceRemove.confirm": "Chỉ xóa tại đây", "remote.title": "Không gian làm việc từ xa", "remote.subtitle": "Chạy Codex, Claude Code hoặc Pi từ Hub này trong khi tệp, lệnh, kiểm thử và bản dựng vẫn ở trên máy tính bạn chọn.", "remote.loading": "Đang tải không gian làm việc từ xa…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 350eb7d519..a1f403554a 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3117,7 +3117,97 @@ export const zhTW: Record = { "models.pickerOrder.saveDraft": "儲存草稿", "models.pickerOrder.reloadDraft": "捨棄草稿並重新載入", "models.pickerOrder.catalogRequired": "模型識別資訊缺失或不明確。請重新載入模型頁面以更新目錄,再編輯自訂順序。", - "nav.remote": "遠端工作區", + "nav.remote": "遠端連線", + "nav.remoteWorkspace": "遠端工作區", + "link.title": "遠端連線", + "link.subtitle": "透過 SSH 連線另一台 OpenCodex 電腦。", + "link.switch": "遠端連線", + "link.switchOffHint": "開啟連線以選擇此電腦的角色。", + "link.role.title": "選擇此電腦的角色", + "link.role.hint": "主機管理連線。子裝置設定即將推出。", + "link.role.home": "主機", + "link.role.homeHint": "從此儀表板管理子裝置。", + "link.role.child": "子裝置", + "link.role.childHint": "使用另一台電腦的 OpenCodex 設定。", + "link.continue": "繼續", + "link.sessionRequired": "登入本機儀表板工作階段以管理連線。", + "link.workspaceUnavailable": "此安裝無法使用遠端工作區。", + "link.loading": "載入中…", + "link.loadFailed": "無法載入遠端連線狀態。", + "link.refresh": "重新整理", + "link.children": "子裝置", + "link.addChild": "新增子裝置", + "link.sheetTitle": "新增子裝置", + "link.candidates": "SSH 主機", + "link.noCandidates": "找不到 SSH 主機候選項。", + "link.alias": "SSH 主機別名", + "link.aliasPlaceholder": "輸入 SSH 設定中的別名", + "link.probe": "測試連線", + "link.probing": "正在測試連線…", + "link.hostFingerprint": "主機金鑰指紋", + "link.confirmFingerprint": "我確認此主機金鑰", + "link.confirming": "正在確認…", + "link.confirm": "確認主機", + "link.ocxVersion": "OpenCodex 版本 {version}", + "link.apply": "連線子裝置", + "link.applying": "正在連線…", + "link.retry": "重試", + "link.disconnect": "中斷連線", + "link.disconnectConfirm": "要中斷 {alias} 的連線嗎?其連線金鑰將被撤銷。", + "link.close": "關閉", + "link.cancel": "取消", + "link.childPending": "子裝置設定將在用戶端啟動流程中提供。", + "link.noChildren": "沒有已連線的子裝置。", + "remoteLink.status.connecting": "連線中", + "remoteLink.status.connected": "已連線", + "remoteLink.status.reconnecting": "重新連線中", + "remoteLink.status.failed": "連線需要處理", + "remoteLink.status.idle": "閒置", + "remoteLink.role.standalone": "獨立", + "remoteLink.role.home": "主機", + "remoteLink.role.child": "子裝置", + "remoteLink.direction.hub": "主機發起", + "remoteLink.direction.client": "用戶端發起", + "remoteLink.error.admission_timeout": "無法完成遠端連線要求。", + "remoteLink.error.compensation_failed": "無法完成遠端連線要求。", + "remoteLink.error.fingerprint_failed": "無法完成遠端連線要求。", + "remoteLink.error.forbidden": "無法完成遠端連線要求。", + "remoteLink.error.host_confirmation_expired": "無法完成遠端連線要求。", + "remoteLink.error.host_fingerprint_mismatch": "無法完成遠端連線要求。", + "remoteLink.error.host_not_confirmed": "無法完成遠端連線要求。", + "remoteLink.error.invalid_alias": "無法完成遠端連線要求。", + "remoteLink.error.invalid_body": "無法完成遠端連線要求。", + "remoteLink.error.invalid_link_id": "無法完成遠端連線要求。", + "remoteLink.error.key_issue_failed": "無法完成遠端連線要求。", + "remoteLink.error.key_revoke_failed": "無法完成遠端連線要求。", + "remoteLink.error.link_apply_failed": "無法完成遠端連線要求。", + "remoteLink.error.link_exists": "無法完成遠端連線要求。", + "remoteLink.error.link_not_found": "無法完成遠端連線要求。", + "remoteLink.error.link_remove_failed": "無法完成遠端連線要求。", + "remoteLink.error.link_unavailable": "無法完成遠端連線要求。", + "remoteLink.error.listener_unavailable": "無法完成遠端連線要求。", + "remoteLink.error.probe_failed": "無法完成遠端連線要求。", + "remoteLink.error.remote_connect_failed": "無法完成遠端連線要求。", + "remoteLink.error.remote_disconnect_failed": "無法完成遠端連線要求。", + "remoteLink.error.remote_port_failed": "無法完成遠端連線要求。", + "remoteLink.error.tailscale_session_refused": "無法完成遠端連線要求。", + "remoteLink.error.version_probe_failed": "無法完成遠端連線要求。", + "remoteLink.error.generic": "無法完成遠端連線要求。", + "remoteLink.reason.auth": "驗證失敗。", + "remoteLink.reason.hostkey": "無法驗證主機金鑰。", + "remoteLink.reason.forward": "連接埠轉送失敗。", + "remoteLink.reason.timeout": "連線逾時。", + "remoteLink.reason.bind": "無法繫結本機連接埠。", + "remoteLink.reason.persist": "無法儲存連線狀態。", + "remoteLink.reason.compensation_failed": "連線後的清理失敗。", + "remoteLink.reason.staleTunnel": "舊隧道可能仍佔用該連接埠。", + "remoteLink.reason.generic": "原因:", + "remoteLink.workspaceMoved.title": "遠端工作區現在有獨立頁面", + "remoteLink.workspaceMoved.body": "請使用獨立的遠端工作區頁面。", + "remoteLink.workspaceMoved.open": "開啟遠端工作區", + "remoteLink.forceRemove.title": "此電腦無法連線到 {alias}", + "remoteLink.forceRemove.body": "只在此電腦刪除連線?之後請在 {alias} 上自行執行 {cmd}。", + "remoteLink.forceRemove.confirm": "僅在此處刪除", "remote.title": "遠端工作區", "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 執行,檔案、命令、測試與建置則留在所選電腦上處理。", "remote.loading": "正在載入遠端工作區…", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e8b5109c54..e9a03e5f60 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3152,7 +3152,97 @@ export const zh: Record = { "models.pickerOrder.saveDraft": "保存草稿", "models.pickerOrder.reloadDraft": "丢弃草稿并重新加载", "models.pickerOrder.catalogRequired": "模型标识信息缺失或不明确。请重新加载模型页面以刷新目录,再编辑自定义顺序。", - "nav.remote": "远程工作区", + "nav.remote": "远程连接", + "nav.remoteWorkspace": "远程工作区", + "link.title": "远程连接", + "link.subtitle": "通过 SSH 连接另一台 OpenCodex 电脑。", + "link.switch": "远程连接", + "link.switchOffHint": "打开连接以选择此电脑的角色。", + "link.role.title": "选择此电脑的角色", + "link.role.hint": "主机管理连接。子设备设置即将推出。", + "link.role.home": "主机", + "link.role.homeHint": "从此仪表板管理子设备。", + "link.role.child": "子设备", + "link.role.childHint": "使用另一台电脑的 OpenCodex 设置。", + "link.continue": "继续", + "link.sessionRequired": "登录本地仪表板会话以管理连接。", + "link.workspaceUnavailable": "此安装无法使用远程工作区。", + "link.loading": "正在加载…", + "link.loadFailed": "无法加载远程连接状态。", + "link.refresh": "刷新", + "link.children": "子设备", + "link.addChild": "添加子设备", + "link.sheetTitle": "添加子设备", + "link.candidates": "SSH 主机", + "link.noCandidates": "未找到 SSH 主机候选项。", + "link.alias": "SSH 主机别名", + "link.aliasPlaceholder": "输入 SSH 配置中的别名", + "link.probe": "测试连接", + "link.probing": "正在测试连接…", + "link.hostFingerprint": "主机密钥指纹", + "link.confirmFingerprint": "我确认此主机密钥", + "link.confirming": "正在确认…", + "link.confirm": "确认主机", + "link.ocxVersion": "OpenCodex 版本 {version}", + "link.apply": "连接子设备", + "link.applying": "正在连接…", + "link.retry": "重试", + "link.disconnect": "断开连接", + "link.disconnectConfirm": "断开 {alias}?其连接密钥将被撤销。", + "link.close": "关闭", + "link.cancel": "取消", + "link.childPending": "子设备设置将在客户端发起流程中提供。", + "link.noChildren": "没有已连接的子设备。", + "remoteLink.status.connecting": "连接中", + "remoteLink.status.connected": "已连接", + "remoteLink.status.reconnecting": "重新连接中", + "remoteLink.status.failed": "连接需要处理", + "remoteLink.status.idle": "空闲", + "remoteLink.role.standalone": "独立", + "remoteLink.role.home": "主机", + "remoteLink.role.child": "子设备", + "remoteLink.direction.hub": "主机发起", + "remoteLink.direction.client": "客户端发起", + "remoteLink.error.admission_timeout": "无法完成远程连接请求。", + "remoteLink.error.compensation_failed": "无法完成远程连接请求。", + "remoteLink.error.fingerprint_failed": "无法完成远程连接请求。", + "remoteLink.error.forbidden": "无法完成远程连接请求。", + "remoteLink.error.host_confirmation_expired": "无法完成远程连接请求。", + "remoteLink.error.host_fingerprint_mismatch": "无法完成远程连接请求。", + "remoteLink.error.host_not_confirmed": "无法完成远程连接请求。", + "remoteLink.error.invalid_alias": "无法完成远程连接请求。", + "remoteLink.error.invalid_body": "无法完成远程连接请求。", + "remoteLink.error.invalid_link_id": "无法完成远程连接请求。", + "remoteLink.error.key_issue_failed": "无法完成远程连接请求。", + "remoteLink.error.key_revoke_failed": "无法完成远程连接请求。", + "remoteLink.error.link_apply_failed": "无法完成远程连接请求。", + "remoteLink.error.link_exists": "无法完成远程连接请求。", + "remoteLink.error.link_not_found": "无法完成远程连接请求。", + "remoteLink.error.link_remove_failed": "无法完成远程连接请求。", + "remoteLink.error.link_unavailable": "无法完成远程连接请求。", + "remoteLink.error.listener_unavailable": "无法完成远程连接请求。", + "remoteLink.error.probe_failed": "无法完成远程连接请求。", + "remoteLink.error.remote_connect_failed": "无法完成远程连接请求。", + "remoteLink.error.remote_disconnect_failed": "无法完成远程连接请求。", + "remoteLink.error.remote_port_failed": "无法完成远程连接请求。", + "remoteLink.error.tailscale_session_refused": "无法完成远程连接请求。", + "remoteLink.error.version_probe_failed": "无法完成远程连接请求。", + "remoteLink.error.generic": "无法完成远程连接请求。", + "remoteLink.reason.auth": "身份验证失败。", + "remoteLink.reason.hostkey": "无法验证主机密钥。", + "remoteLink.reason.forward": "端口转发失败。", + "remoteLink.reason.timeout": "连接超时。", + "remoteLink.reason.bind": "无法绑定本地端口。", + "remoteLink.reason.persist": "无法保存连接状态。", + "remoteLink.reason.compensation_failed": "连接后的清理失败。", + "remoteLink.reason.staleTunnel": "旧隧道可能仍占用该端口。", + "remoteLink.reason.generic": "原因:", + "remoteLink.workspaceMoved.title": "远程工作区现在有独立页面", + "remoteLink.workspaceMoved.body": "请使用单独的远程工作区页面。", + "remoteLink.workspaceMoved.open": "打开远程工作区", + "remoteLink.forceRemove.title": "此电脑无法连接 {alias}", + "remoteLink.forceRemove.body": "只在此电脑删除连接?之后请在 {alias} 上自行运行 {cmd}。", + "remoteLink.forceRemove.confirm": "仅在此处删除", "remote.title": "远程工作区", "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 上运行,文件、命令、测试和构建则留在所选电脑上执行。", "remote.loading": "正在加载远程工作区…", diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx new file mode 100644 index 0000000000..491e8bbf0e --- /dev/null +++ b/gui/src/pages/RemoteLink.tsx @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useEffectEvent, useRef, useState, type ReactElement } from "react"; +import { + LinkApiError, parseRemoteLinkStatus, requestLinkJson, type LinkCandidateView, type LinkConfirmHostView, + type LinkErrorCode, type LinkProbeView, type LinkRowWire, type LinkWireState, type RemoteLinkStatusWire, +} from "../remote-link-api"; +import { IconLink, IconPlus, IconRefresh, IconTrash, IconX } from "../icons"; +import { Trans } from "../i18n/provider"; +import { type TKey, useT } from "../i18n/shared"; +import { Notice } from "../ui"; +import "../styles-remote-link.css"; + +type RemoteLinkRole = "home" | "child"; +type RemoteLinkUiState = "off" | "role-select" | "adding-child" | "confirming-host" | "applying" | "connected" | "reconnecting" | "failed"; + +export interface RemoteLinkProps { + apiBase: string; + sessionReady: boolean; + workspaceAvailable?: boolean; + onOpenWorkspace?: () => void; +} + +const STATUS_LABEL: Record = { + connecting: "remoteLink.status.connecting", + connected: "remoteLink.status.connected", + reconnecting: "remoteLink.status.reconnecting", + failed: "remoteLink.status.failed", + idle: "remoteLink.status.idle", +}; +const ERROR_TKEY: Record = { + admission_timeout: "remoteLink.error.admission_timeout", + compensation_failed: "remoteLink.error.compensation_failed", + fingerprint_failed: "remoteLink.error.fingerprint_failed", + forbidden: "remoteLink.error.forbidden", + host_confirmation_expired: "remoteLink.error.host_confirmation_expired", + host_fingerprint_mismatch: "remoteLink.error.host_fingerprint_mismatch", + host_not_confirmed: "remoteLink.error.host_not_confirmed", + invalid_alias: "remoteLink.error.invalid_alias", + invalid_body: "remoteLink.error.invalid_body", + invalid_link_id: "remoteLink.error.invalid_link_id", + key_issue_failed: "remoteLink.error.key_issue_failed", + key_revoke_failed: "remoteLink.error.key_revoke_failed", + link_apply_failed: "remoteLink.error.link_apply_failed", + link_exists: "remoteLink.error.link_exists", + link_not_found: "remoteLink.error.link_not_found", + link_remove_failed: "remoteLink.error.link_remove_failed", + link_unavailable: "remoteLink.error.link_unavailable", + listener_unavailable: "remoteLink.error.listener_unavailable", + probe_failed: "remoteLink.error.probe_failed", + remote_connect_failed: "remoteLink.error.remote_connect_failed", + remote_disconnect_failed: "remoteLink.error.remote_disconnect_failed", + remote_port_failed: "remoteLink.error.remote_port_failed", + tailscale_session_refused: "remoteLink.error.tailscale_session_refused", + version_probe_failed: "remoteLink.error.version_probe_failed", +}; +const REASON_TKEY: Record = { + auth: "remoteLink.reason.auth", + hostkey: "remoteLink.reason.hostkey", + forward: "remoteLink.reason.forward", + timeout: "remoteLink.reason.timeout", + bind: "remoteLink.reason.bind", + persist: "remoteLink.reason.persist", + compensation_failed: "remoteLink.reason.compensation_failed", + "stale tunnel may hold the port": "remoteLink.reason.staleTunnel", +}; + +type FailedAction = { phase: "probe" | "apply"; alias: string }; + +function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function nonEmpty(value: unknown): value is string { return typeof value === "string" && value.length > 0; } + +function parseCandidates(value: unknown): LinkCandidateView[] { + if (!isRecord(value) || !Array.isArray(value.candidates)) throw new Error("invalid candidates"); + return value.candidates.map(candidate => { + if (!isRecord(candidate) || !nonEmpty(candidate.alias) || !nonEmpty(candidate.source)) throw new Error("invalid candidate"); + return { alias: candidate.alias, source: candidate.source }; + }); +} + +function parseProbe(value: unknown): LinkProbeView { + if (!isRecord(value) || !nonEmpty(value.alias) || !nonEmpty(value.fingerprint) || !nonEmpty(value.keyType)) throw new Error("invalid probe"); + return { alias: value.alias, fingerprint: value.fingerprint, keyType: value.keyType }; +} + +function parseConfirmation(value: unknown): LinkConfirmHostView { + if (!isRecord(value) || !nonEmpty(value.alias) || !nonEmpty(value.fingerprint) || !nonEmpty(value.ocxVersion)) throw new Error("invalid confirmation"); + return { alias: value.alias, fingerprint: value.fingerprint, ocxVersion: value.ocxVersion }; +} + +function errorKey(error: unknown): TKey { + if (error instanceof LinkApiError && error.code in ERROR_TKEY) return ERROR_TKEY[error.code as LinkErrorCode]; + return "remoteLink.error.generic"; +} + +export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = false, onOpenWorkspace }: RemoteLinkProps): ReactElement { + const t = useT(); + const [uiState, setUiState] = useState("off"); + const [role, setRole] = useState("home"); + const [status, setStatus] = useState(null); + const [statusError, setStatusError] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [candidates, setCandidates] = useState([]); + const [alias, setAlias] = useState(""); + const [probe, setProbe] = useState(null); + const [confirmation, setConfirmation] = useState(null); + const [checkedFingerprint, setCheckedFingerprint] = useState(false); + const [busy, setBusy] = useState<"candidates" | "probe" | "confirm" | "apply" | "remove" | null>(null); + const [actionError, setActionError] = useState(null); + const [failedAction, setFailedAction] = useState(null); + const [confirming, setConfirming] = useState<{ row: LinkRowWire; force: boolean } | null>(null); + const [forceError, setForceError] = useState(null); + const addButtonRef = useRef(null); + const sheetRef = useRef(null); + const confirmRef = useRef(null); + const disconnectTriggerRef = useRef(null); + const roleRefs = useRef>([]); + const statusRequestRef = useRef<{ controller: AbortController; sequence: number } | null>(null); + const statusSequenceRef = useRef(0); + + const abortStatusRequest = useCallback(() => { + statusSequenceRef.current += 1; + statusRequestRef.current?.controller.abort(); + statusRequestRef.current = null; + }, []); + + const refreshStatus = useCallback(async () => { + if (!sessionReady || document.visibilityState === "hidden") return; + statusRequestRef.current?.controller.abort(); + const controller = new AbortController(); + const sequence = ++statusSequenceRef.current; + statusRequestRef.current = { controller, sequence }; + try { + const value = parseRemoteLinkStatus(await requestLinkJson(apiBase, "/api/link/status", { signal: controller.signal })); + if (controller.signal.aborted || statusSequenceRef.current !== sequence) return; + setStatus(value); + setStatusError(null); + if (failedAction) { + setUiState("failed"); + } else if (value.links.length === 0) { + setUiState(current => ["role-select", "adding-child", "confirming-host", "applying"].includes(current) ? current : "off"); + } else if (value.links.some(link => link.state === "failed")) setUiState("failed"); + else if (value.links.some(link => link.state === "reconnecting")) setUiState("reconnecting"); + else if (value.links.some(link => link.state === "connected")) setUiState("connected"); + } catch (error) { + if (controller.signal.aborted || statusSequenceRef.current !== sequence) return; + setStatusError(errorKey(error)); + } finally { + if (statusRequestRef.current?.sequence === sequence) statusRequestRef.current = null; + } + }, [apiBase, failedAction, sessionReady]); + + const refreshStatusOnEffect = useEffectEvent(() => { void refreshStatus(); }); + + useEffect(() => { + if (!sessionReady) return; + void Promise.resolve().then(refreshStatusOnEffect); + const poll = window.setInterval(refreshStatusOnEffect, 5_000); + const onVisibility = () => { if (document.visibilityState === "visible") refreshStatusOnEffect(); else abortStatusRequest(); }; + document.addEventListener("visibilitychange", onVisibility); + return () => { window.clearInterval(poll); document.removeEventListener("visibilitychange", onVisibility); abortStatusRequest(); }; + }, [abortStatusRequest, sessionReady]); + + useEffect(() => { + if (!sheetOpen) { sheetRef.current?.close?.(); return; } + const dialog = sheetRef.current; + if (dialog && !dialog.open) { + if (typeof dialog.showModal === "function") dialog.showModal(); else dialog.setAttribute("open", ""); + } + window.setTimeout(() => dialog?.querySelector("input, button")?.focus(), 0); + }, [sheetOpen]); + + useEffect(() => { + const dialog = confirmRef.current; + if (!confirming) { dialog?.close?.(); return; } + if (dialog && !dialog.open) { + if (typeof dialog.showModal === "function") dialog.showModal(); else dialog.setAttribute("open", ""); + } + }, [confirming]); + + // Cancelling the sheet abandons the attempt, so a failed attempt's banner must not outlive it: + // leaving uiState at "failed" would show a Retry with nothing left to retry. + const closeSheet = () => { setSheetOpen(false); setProbe(null); setConfirmation(null); setCheckedFingerprint(false); setActionError(null); setFailedAction(null); setUiState(current => (current === "failed" ? "adding-child" : current)); addButtonRef.current?.focus(); }; + const openSheet = async () => { + setSheetOpen(true); setUiState("adding-child"); setActionError(null); setBusy("candidates"); + try { setCandidates(parseCandidates(await requestLinkJson(apiBase, "/api/link/candidates"))); } + catch (error) { setActionError(errorKey(error)); } + finally { setBusy(null); } + }; + const runProbe = async (requestedAlias = alias.trim()) => { + const value = requestedAlias.trim(); + if (!value) return; + setBusy("probe"); setActionError(null); setProbe(null); setConfirmation(null); setCheckedFingerprint(false); setFailedAction(null); setUiState("adding-child"); + try { setProbe(parseProbe(await requestLinkJson(apiBase, "/api/link/probe", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: value }) }))); } + catch (error) { setActionError(errorKey(error)); setFailedAction({ phase: "probe", alias: value }); setUiState("failed"); } + finally { setBusy(null); } + }; + const confirmHost = async () => { + if (!probe || !checkedFingerprint) return; + setBusy("confirm"); setActionError(null); + try { setConfirmation(parseConfirmation(await requestLinkJson(apiBase, "/api/link/confirm-host", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: probe.alias, fingerprint: probe.fingerprint }) }))); } + catch (error) { setActionError(errorKey(error)); } + finally { setBusy(null); } + }; + const applyLink = async () => { + if (!confirmation) return; + setBusy("apply"); setActionError(null); setFailedAction(null); setUiState("applying"); + try { await requestLinkJson<{ linkId: string }>(apiBase, "/api/link/apply", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: confirmation.alias }) }); closeSheet(); await refreshStatus(); } + catch (error) { setActionError(errorKey(error)); setFailedAction({ phase: "apply", alias: confirmation.alias }); setUiState("failed"); } + finally { setBusy(null); } + }; + + const retryFailedAction = () => { + if (!failedAction) { void refreshStatus(); return; } + if (failedAction.phase === "probe") { setAlias(failedAction.alias); void runProbe(failedAction.alias); return; } + void applyLink(); + }; + + const closeConfirmation = () => { + setConfirming(null); + setForceError(null); + window.setTimeout(() => disconnectTriggerRef.current?.focus(), 0); + }; + + const moveRole = (index: number, key: string) => { + const next = key === "Home" ? 0 : key === "End" ? 1 : key === "ArrowRight" || key === "ArrowDown" ? (index + 1) % 2 : key === "ArrowLeft" || key === "ArrowUp" ? (index + 1) % 2 : index; + if (next === index) return; + roleRefs.current[next]?.focus(); + setRole(next === 0 ? "home" : "child"); + }; + const removeLink = async () => { + if (!confirming) return; + const pending = confirming; + setBusy("remove"); setForceError(null); + try { await requestLinkJson<{ linkId: string }>(apiBase, `/api/link/${encodeURIComponent(pending.row.id)}`, pending.force ? { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ force: true }) } : { method: "DELETE" }); closeConfirmation(); await refreshStatus(); } + catch (error) { + if (!pending.force && error instanceof LinkApiError && error.code === "remote_disconnect_failed") setConfirming({ row: pending.row, force: true }); + else setForceError(errorKey(error)); + } + finally { setBusy(null); } + }; + + const statusRows = status?.links ?? []; + // A machine that chose Home but has no link yet still reports standalone; the panel follows the + // operator's choice so the heading does not contradict the action in front of them. + const choseHome = role === "home" && (uiState === "adding-child" || uiState === "confirming-host" || uiState === "applying"); + const roleLabel: TKey = status?.role === "home" || choseHome ? "remoteLink.role.home" : status?.role === "child" ? "remoteLink.role.child" : "remoteLink.role.standalone"; + const primaryActionDisabled = role === "child"; + + if (!sessionReady) return

{t("link.title")}

{t("link.sessionRequired")}
; + + return ( +
+

{t("link.title")}

{t("link.subtitle")}

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

{role === "child" && {t("link.childPending")}}
} + {(uiState === "connected" || uiState === "reconnecting" || uiState === "failed" || statusRows.length > 0 || uiState === "adding-child" || uiState === "confirming-host" || uiState === "applying") &&

{t("link.children")}

{t(roleLabel)}

{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("link.noChildren")}

}{(uiState === "reconnecting" || uiState === "failed") &&
{t(STATUS_LABEL[uiState === "failed" ? "failed" : "reconnecting"])}
}{actionError && {t(actionError)}}
} + + { event.preventDefault(); closeSheet(); }}> +
+

{t("link.candidates")}

{busy === "candidates" ?

{t("link.loading")}

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

{t("link.noCandidates")}

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

{probe.fingerprint}

{probe.keyType}
}{confirmation &&

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

}{actionError && {t(actionError)}}
+
+ + { event.preventDefault(); closeConfirmation(); }}> + {confirming && <>

{confirming.force ? : t("link.disconnectConfirm", { alias: confirming.row.alias })}

{forceError &&

{t(forceError)}

}
} +
+
+ ); +} diff --git a/gui/src/remote-link-api.ts b/gui/src/remote-link-api.ts new file mode 100644 index 0000000000..38fc5dff27 --- /dev/null +++ b/gui/src/remote-link-api.ts @@ -0,0 +1,103 @@ +export const LINK_ERROR_CODES = [ + "admission_timeout", + "compensation_failed", + "fingerprint_failed", + "forbidden", + "host_confirmation_expired", + "host_fingerprint_mismatch", + "host_not_confirmed", + "invalid_alias", + "invalid_body", + "invalid_link_id", + "key_issue_failed", + "key_revoke_failed", + "link_apply_failed", + "link_exists", + "link_not_found", + "link_remove_failed", + "link_unavailable", + "listener_unavailable", + "probe_failed", + "remote_connect_failed", + "remote_disconnect_failed", + "remote_port_failed", + "tailscale_session_refused", + "version_probe_failed", +] as const; + +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 interface LinkCandidateView { alias: string; source: string } +export interface LinkProbeView { alias: string; fingerprint: string; keyType: string } +export interface LinkConfirmHostView { alias: string; fingerprint: string; ocxVersion: string } +export interface LinkRowWire { id: string; alias: string; direction: LinkWireDirection; state: LinkWireState; since: string; reason: string | null; tunnelPort: number } +export interface RemoteLinkStatusWire { + role: "standalone" | "home" | "child"; + listener: { state: LinkListenerState; port: number | null }; + links: LinkRowWire[]; + child: null | { alias: string; state: LinkWireState; since: string; reason: string | null }; +} + +const LINK_STATES: readonly LinkWireState[] = ["connecting", "connected", "reconnecting", "failed", "idle"]; +const LINK_ROLES = ["standalone", "home", "child"] as const; + +export class LinkApiError extends Error { + readonly code: string; + readonly status: number; + + constructor(code: string, status: number) { + super(code); + this.name = "LinkApiError"; + this.code = code; + this.status = status; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function nonEmpty(value: unknown): value is string { return typeof value === "string" && value.length > 0; } +function isLinkState(value: unknown): value is LinkWireState { return typeof value === "string" && LINK_STATES.includes(value as LinkWireState); } + +export function parseRemoteLinkStatus(value: unknown): RemoteLinkStatusWire { + if (!isRecord(value) || !LINK_ROLES.includes(value.role as typeof LINK_ROLES[number])) throw new Error("invalid status"); + const listener = value.listener; + if (!isRecord(listener) || !["off", "listening", "failed"].includes(String(listener.state)) || (listener.port !== null && typeof listener.port !== "number")) throw new Error("invalid listener"); + if (!Array.isArray(value.links)) throw new Error("invalid links"); + const links = value.links.map(item => { + if (!isRecord(item) || !nonEmpty(item.id) || !nonEmpty(item.alias) || !["hub-initiated", "client-initiated"].includes(String(item.direction)) || !isLinkState(item.state) || !nonEmpty(item.since) || (item.reason !== null && typeof item.reason !== "string") || typeof item.tunnelPort !== "number") throw new Error("invalid link"); + return { id: item.id, alias: item.alias, direction: item.direction as LinkWireDirection, state: item.state, since: item.since, reason: item.reason as string | null, tunnelPort: item.tunnelPort }; + }); + let child: RemoteLinkStatusWire["child"] = null; + if (value.child !== null) { + 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 }; +} + +/** Read link-route JSON and preserve the server's machine-readable error code. */ +export async function readLinkJson(response: Response): Promise { + let body: unknown; + try { + body = await response.json(); + } catch { /* malformed response is handled by the success-body guard below */ } + + if (!response.ok) { + const error = isRecord(body) && isRecord(body.error) ? body.error : null; + const code = error && typeof error.code === "string" ? error.code : "unknown"; + throw new LinkApiError(code, response.status); + } + if (body === null || body === undefined) throw new LinkApiError("invalid_body", response.status); + return body as T; +} + +export async function requestLinkJson(apiBase: string, path: string, init?: RequestInit): Promise { + const response = await fetch(`${apiBase}${path}`, { ...init, cache: "no-store" }); + return readLinkJson(response); +} diff --git a/gui/src/styles-remote-link.css b/gui/src/styles-remote-link.css new file mode 100644 index 0000000000..e0bef5bc3b --- /dev/null +++ b/gui/src/styles-remote-link.css @@ -0,0 +1,64 @@ +.remote-link-page { display: grid; gap: var(--space-4); } +.remote-link-page .page-head { align-items: flex-start; } +.remote-link-page .page-sub { max-width: var(--prose-measure); margin: 0; } +.remote-link-panel { display: grid; gap: var(--space-4); } +.remote-link-off-preview { position: relative; min-height: 260px; overflow: hidden; } +.remote-link-off-preview::after { position: absolute; inset: 0; content: ""; pointer-events: none; backdrop-filter: blur(7px); background: color-mix(in srgb, var(--bg) 24%, transparent); } +.remote-link-preview-content { display: grid; gap: var(--space-3); filter: grayscale(1); opacity: .64; user-select: none; } +.remote-link-preview-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--raised); } +.remote-link-preview-lines { display: grid; gap: var(--space-2); width: min(55%, 300px); } +.remote-link-preview-lines span { display: block; height: 8px; border-radius: var(--radius-pill); background: var(--border); } +.remote-link-switch-row { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); } +.remote-link-switch { width: var(--control-touch); height: var(--control-md); padding: 3px; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--toggle-off-bg); cursor: pointer; } +.remote-link-switch::after { display: block; width: 24px; height: 24px; content: ""; border-radius: 50%; background: var(--toggle-dot-color); transition: transform var(--motion-fast) ease; } +.remote-link-switch[aria-checked="true"] { background: var(--toggle-on-bg); } +.remote-link-switch[aria-checked="true"]::after { transform: translateX(16px); } +.remote-link-switch:focus-visible, .remote-link-role-card:focus-visible, .remote-link-sheet button:focus-visible, .remote-link-row button:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; } +.remote-link-role-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.remote-link-role-card { min-height: 132px; padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--raised); color: var(--text); text-align: left; cursor: pointer; } +.remote-link-role-card:hover { background: var(--raised-hover); } +.remote-link-role-card[aria-checked="true"] { border-color: var(--text); box-shadow: 0 0 0 1px var(--text); } +.remote-link-role-card strong, .remote-link-role-card span { display: block; } +.remote-link-role-card span { margin-top: var(--space-2); color: var(--muted); font-size: var(--text-label); } +.remote-link-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } +.remote-link-status { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--muted); font-size: var(--text-label); } +.remote-link-status::before { width: 8px; height: 8px; flex: 0 0 auto; content: ""; border-radius: 50%; background: var(--faint); } +.remote-link-status[data-state="connected"]::before { background: var(--green); } +.remote-link-status[data-state="reconnecting"]::before { background: var(--amber); } +.remote-link-status[data-state="failed"]::before { background: var(--red); } +.remote-link-status[data-state="connecting"]::before { background: var(--amber); } +.remote-link-children { display: grid; gap: var(--space-2); } +.remote-link-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: var(--space-3); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--raised); } +.remote-link-row-main { min-width: 0; display: grid; gap: var(--space-2); } +.remote-link-row-meta { display: flex; flex-wrap: wrap; gap: var(--space-3); color: var(--muted); font-size: var(--text-caption); } +.remote-link-row .btn { min-height: var(--control-touch); } +.remote-link-error { color: var(--red); } +.remote-link-info { color: var(--muted); font-size: var(--text-label); } +.remote-link-fingerprint { margin: 0; padding: var(--space-3); overflow-wrap: anywhere; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--raised); font-family: var(--font-code); font-size: var(--text-label); } +.remote-link-workspace-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); } +.remote-link-workspace-card p { margin: var(--space-1) 0 0; color: var(--muted); font-size: var(--text-label); } +.remote-link-sheet { width: min(620px, calc(100vw - var(--space-4))); max-height: min(760px, calc(100dvh - var(--space-4))); margin: auto 0 auto auto; padding: var(--space-6); overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-lg) 0 0 var(--radius-lg); background: var(--bg); color: var(--text); box-shadow: var(--shadow); } +.remote-link-sheet::backdrop { background: color-mix(in srgb, var(--text) 24%, transparent); } +.remote-link-sheet-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); } +.remote-link-sheet-head h3 { margin: 0; font-size: var(--text-title); } +.remote-link-sheet-body { display: grid; gap: var(--space-4); margin-top: var(--space-5); } +.remote-link-candidates { display: grid; gap: var(--space-2); } +.remote-link-candidate { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); min-height: var(--control-touch); padding: var(--space-2) var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--raised); color: var(--text); cursor: pointer; text-align: left; } +.remote-link-candidate:hover { background: var(--raised-hover); } +.remote-link-form { display: grid; gap: var(--space-2); } +.remote-link-form label { font-size: var(--text-label); font-weight: var(--weight-semibold); } +.remote-link-form input { width: 100%; } +.remote-link-sheet-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } +.remote-link-confirm-dialog { width: min(460px, calc(100vw - var(--space-4))); margin: auto; padding: var(--space-5); border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); box-shadow: var(--shadow); } +.remote-link-confirm-dialog::backdrop { background: color-mix(in srgb, var(--text) 24%, transparent); } +.remote-link-confirm-dialog h3 { margin: 0; font-size: var(--text-subtitle); } +.remote-link-confirm-dialog p { color: var(--muted); } +@media (max-width: 620px) { + .remote-link-role-grid { grid-template-columns: 1fr; } + .remote-link-workspace-card, .remote-link-switch-row { align-items: stretch; flex-direction: column; } + .remote-link-switch { align-self: flex-start; } + .remote-link-sheet { width: calc(100vw - var(--space-2)); max-height: 88dvh; margin: auto auto 0; border-radius: var(--radius-lg) var(--radius-lg) 0 0; } +} +@media (prefers-reduced-motion: reduce) { + .remote-link-page *, .remote-link-page *::before, .remote-link-page *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } +} diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 57d6ad3d7f..b71dccfc80 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; +import { LOCALES } from "../src/i18n/shared"; -const LOCALES = ["en", "de", "fr", "ja", "ko", "ru", "tr", "zh", "zh-TW"] as const; +const LOCALE_CODES = LOCALES.map(locale => locale.code); async function readDict(locale: string): Promise> { const src = await Bun.file(new URL(`../src/i18n/${locale}.ts`, import.meta.url)).text(); @@ -222,7 +223,7 @@ test("zh-TW ships no untranslated English placeholders beyond the intentional al // complete parity guard, independent of the claude-desktop-locale file. test("every locale key set matches the English source", async () => { const en = [...(await readDict("en")).keys()].sort(); - for (const locale of LOCALES.filter(l => l !== "en")) { + for (const locale of LOCALE_CODES.filter(l => l !== "en")) { const other = [...(await readDict(locale)).keys()].sort(); expect(`${locale} key count: ${other.length}`).toBe(`${locale} key count: ${en.length}`); expect(other).toEqual(en); @@ -253,7 +254,7 @@ const CURSOR_KEEP_ENGLISH_BY_LOCALE: Record> = { test("every locale translates the Cursor tab beyond the brand labels", async () => { const en = await readDict("en"); - for (const locale of LOCALES.filter(l => l !== "en")) { + for (const locale of LOCALE_CODES.filter(l => l !== "en")) { const dict = await readDict(locale); const stale: string[] = []; for (const [key, value] of dict) { @@ -267,7 +268,7 @@ test("every locale translates the Cursor tab beyond the brand labels", async () } }); -const DSH_VISIBLE_COPY: Record<(typeof LOCALES)[number], readonly [string, string, string]> = { +const DSH_VISIBLE_COPY: Record<(typeof LOCALE_CODES)[number], readonly [string, string, string]> = { en: [ "DeepSeek Harness (DSH)", "DSH", @@ -313,10 +314,15 @@ const DSH_VISIBLE_COPY: Record<(typeof LOCALES)[number], readonly [string, strin "DSH", "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 會熱重載該 provider;你的預設模型與 deepseek-official 維持不變。目前僅支援 loopback,且不會寫入真實憑證。", ], + vi: [ + "DeepSeek Harness (DSH)", + "DSH", + "OpenCodex chỉ quản lý llm-pi-ai.providers.opencodex trong $DSH_HOME/settings.yaml. DSH sẽ hot reload provider này; model mặc định của bạn và deepseek-official không thay đổi. Hiện chỉ hỗ trợ loopback; không ghi credential thật nào.", + ], }; test("every locale carries the exact DSH label and ownership semantics", async () => { - for (const locale of LOCALES) { + for (const locale of LOCALE_CODES) { const dict = await readDict(locale); const expected = DSH_VISIBLE_COPY[locale]; expect(dict.get("api.clientConfig.clientDsh")).toBe(expected[0]); @@ -339,7 +345,7 @@ test("every locale carries the exact DSH label and ownership semantics", async ( * are the part that must survive translation unchanged. */ test("every locale keeps the three facts Aside's ownership sentence carries", async () => { - for (const locale of LOCALES) { + for (const locale of LOCALE_CODES) { const dict = await readDict(locale); expect(dict.get("api.clientConfig.clientAside"), locale).toBe("Aside"); expect(dict.get("integrations.tab.aside"), locale).toBe("Aside"); diff --git a/gui/tests/remote-link-route.test.tsx b/gui/tests/remote-link-route.test.tsx new file mode 100644 index 0000000000..df5c3ce58c --- /dev/null +++ b/gui/tests/remote-link-route.test.tsx @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; + +/** + * The Home and a standalone Child reach Remote Link through their own loopback dashboard session. + * Gating the page on a connected-client target left both on the sign-in notice forever, because + * only a Child that is already connected has one; a fixture that pretended to be a client hid it. + */ + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +let linkStatusReads = 0; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function mountWindow(role: "standalone" | "hub"): void { + testWindow = new Window({ url: "http://localhost/#remote" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + const head = testWindow.document.head; + for (const [name, content] of [ + ["opencodex-runtime-role", role], + ["opencodex-session-token", "ocx_session_route_test"], + ["opencodex-session-csrf", "route-test-csrf"], + ["opencodex-session-origin", "http://localhost"], + ["opencodex-session-server-origin", "http://localhost"], + ] as const) { + const meta = testWindow.document.createElement("meta"); + meta.setAttribute("name", name); + meta.setAttribute("content", content); + head.appendChild(meta); + } + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + (globalThis as Record).__APP_VERSION__ = "0.0.0-test"; + const mockFetch = (async (input: RequestInfo | URL) => { + const url = String(input instanceof Request ? input.url : input); + if (url.includes("/api/link/status")) { + linkStatusReads += 1; + return jsonResponse({ role: "standalone", listener: { state: "off", port: null }, links: [], child: null }); + } + if (url.includes("/api/machine/status")) return jsonResponse({}, 404); + if (url.includes("/api/remote-workspace")) return jsonResponse({ available: false }); + if (url.includes("/healthz")) return jsonResponse({ status: "ok", version: "0.0.0-test", uptime: 1 }); + return jsonResponse({}); + }) as typeof fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: mockFetch }); + Object.defineProperty(testWindow, "fetch", { configurable: true, value: mockFetch }); + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + linkStatusReads = 0; +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + testWindow.close(); + const { resetApiAuthFetchForTests } = await import("../src/api"); + resetApiAuthFetchForTests(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 10)); }); + } +} + +for (const role of ["standalone", "hub"] as const) { + test(`a ${role} dashboard opens Remote Link with its own session`, async () => { + mountWindow(role); + const { resetApiAuthFetchForTests, installApiAuthFetch } = await import("../src/api"); + resetApiAuthFetchForTests(); + installApiAuthFetch(); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: window.fetch }); + const [{ createRoot }, { LanguageProvider }, { default: App }] = await Promise.all([ + import("react-dom/client"), + import("../src/i18n/provider"), + import("../src/App"), + ]); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await waitFor(() => container.querySelector('.remote-link-page [role="switch"], [role="switch"]') !== null || (container.textContent ?? "").includes("Sign in to the local dashboard session")); + expect(container.textContent).not.toContain("Sign in to the local dashboard session"); + expect(container.querySelector('[role="switch"]')).not.toBeNull(); + expect(linkStatusReads).toBeGreaterThan(0); + }); +} diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx new file mode 100644 index 0000000000..2c03610813 --- /dev/null +++ b/gui/tests/remote-link.test.tsx @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react"; +import RemoteLink from "../src/pages/RemoteLink"; +import { LINK_ERROR_CODES, LinkApiError, parseRemoteLinkStatus, readLinkJson, type RemoteLinkStatusWire } from "../src/remote-link-api"; +import { LanguageProvider } from "../src/i18n/provider"; +import { LOCALES } from "../src/i18n/shared"; + +const baseStatus: RemoteLinkStatusWire = { role: "home", listener: { state: "listening", port: 44123 }, links: [], child: null }; +let win: Window; +let root: Root | null = null; +let previous: Record; +const globals = ["window", "document", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])); + win = new Window({ url: "http://localhost/#remote" }); + Object.defineProperties(globalThis, { + window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, navigator: { configurable: true, value: win.navigator }, localStorage: { configurable: true, value: win.localStorage }, IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); +}); + +afterEach(async () => { + if (root) await act(async () => { root?.unmount(); }); + root = null; + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); +}); + +function response(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); } +async function flush(): Promise { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); } +async function mount(props: Partial> = {}): Promise { + const host = win.document.createElement("div"); + win.document.body.append(host); + root = createRoot(host); + await act(async () => { root?.render(); }); + await flush(); + return host; +} + +test("LINK_ERROR_CODES stays in exact parity with link-routes.ts", async () => { + const source = await Bun.file("../src/server/management/link-routes.ts").text(); + const fromRoutes = [...source.matchAll(/fail\("([a-z_]+)"/g)].map(match => match[1]).filter((value, index, all) => all.indexOf(value) === index).sort(); + expect([...LINK_ERROR_CODES].sort()).toEqual(fromRoutes); +}); + +test("parses every wire state without changing the DTO", () => { + for (const state of ["connecting", "connected", "reconnecting", "failed", "idle"] as const) { + const parsed = parseRemoteLinkStatus({ ...baseStatus, links: [{ id: state, alias: "child", direction: "hub-initiated", state, since: "now", reason: state === "failed" ? "compensation_failed" : null, tunnelPort: 43110 }] }); + expect(parsed.links[0]?.state).toBe(state); + } + expect(parseRemoteLinkStatus({ ...baseStatus, role: "standalone", listener: { state: "off", port: null }, child: { alias: "child", state: "idle", since: "now", reason: null } }).child?.state).toBe("idle"); +}); + +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; + const host = await mount({ sessionReady: false }); + expect(host.textContent).toContain("Sign in to the local dashboard session"); + expect(calls).toEqual([]); +}); + +test("off state and role choice issue no mutation request", async () => { + 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); }) as typeof fetch; + const host = await mount(); + expect(host.querySelector('[role="switch"]')).not.toBeNull(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + await flush(); + expect(host.textContent).toContain("Choose this computer's role"); + await act(async () => { (host.querySelector('[role="radio"][aria-checked="false"]') as HTMLButtonElement).click(); }); + await flush(); + expect(host.textContent).toContain("Child setup is available"); + expect(calls.every(call => call.method === "GET")).toBe(true); +}); + +test("workspace card is available only through the availability prop", async () => { + globalThis.fetch = (async () => response(baseStatus)) as typeof fetch; + const host = await mount({ workspaceAvailable: true }); + expect(host.textContent).toContain("Remote Workspace has its own page now"); + expect(host.textContent).toContain("Open Remote Workspace"); +}); + +test("compensation failure is rendered with a removal action", async () => { + const status = { ...baseStatus, links: [{ id: "link-1", alias: "child", direction: "hub-initiated" as const, state: "failed" as const, since: "now", reason: "compensation_failed", tunnelPort: 43110 }] }; + globalThis.fetch = (async () => response(status)) as typeof fetch; + const host = await mount(); + expect(host.querySelector(".remote-link-error")?.textContent).toBe("Cleanup after linking failed."); + expect(host.textContent).toContain("Disconnect"); +}); + +test("disconnect failure opens force confirmation and sends force body", async () => { + const calls: Array<{ method: string; body?: string }> = []; + globalThis.fetch = (async (input, init) => { + const path = new URL(String(input)).pathname; + calls.push({ method: init?.method ?? "GET", body: typeof init?.body === "string" ? init.body : undefined }); + if (path === "/api/link/status") return response({ ...baseStatus, links: [{ id: "link-1", alias: "child", direction: "hub-initiated", state: "connected", since: "now", reason: null, tunnelPort: 43110 }] }); + if (path === "/api/link/link-1" && init?.method === "DELETE" && init.body === undefined) return response({ error: { code: "remote_disconnect_failed" } }, 502); + return response({ linkId: "link-1" }); + }) as typeof fetch; + const host = await mount(); + const disconnectButton = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Disconnect")) as HTMLButtonElement; + await act(async () => { disconnectButton.click(); }); + await flush(); + const dialogs = [...host.querySelectorAll("dialog")]; + const confirm = dialogs.at(-1) as HTMLDialogElement; + expect(confirm.textContent).toContain("Disconnect"); + await act(async () => { (confirm.querySelector(".btn-danger") as HTMLButtonElement).click(); }); + await flush(); + expect(confirm.textContent).toContain("Remove here only"); + await act(async () => { (confirm.querySelector(".btn-danger") as HTMLButtonElement).click(); }); + await flush(); + expect(calls.some(call => call.method === "DELETE" && call.body === JSON.stringify({ force: true }))).toBe(true); +}); + +test("readLinkJson preserves unknown server codes and status", async () => { + let caught: unknown; + try { await readLinkJson(new Response(JSON.stringify({ error: { code: "future_code" } }), { status: 418 })); } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(LinkApiError); + expect((caught as LinkApiError).code).toBe("future_code"); + expect((caught as LinkApiError).status).toBe(418); + expect(LOCALES).toHaveLength(10); +}); + +test("probe failure stays visible and Retry probes the failed alias", async () => { + let probes = 0; + globalThis.fetch = (async input => { + const path = new URL(String(input)).pathname; + if (path === "/api/link/probe") { probes += 1; return response({ error: { code: "probe_failed" } }, 502); } + if (path === "/api/link/candidates") return response({ candidates: [{ alias: "child-one", source: "ssh config" }] }); + return response(baseStatus); + }) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + await act(async () => { (host.querySelector(".btn-primary") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Add child"))?.click(); }); + await flush(); + await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); + await flush(); + expect(host.textContent).toContain("Remote link request could not be completed."); + expect(host.textContent).toContain("Retry"); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Retry"))?.click(); }); + await flush(); + expect(probes).toBe(2); +}); + +test("apply failure stays retryable and Retry reapplies the confirmed alias", async () => { + let applies = 0; + globalThis.fetch = (async input => { + const path = new URL(String(input)).pathname; + if (path === "/api/link/candidates") return response({ candidates: [{ alias: "child-one", source: "ssh config" }] }); + if (path === "/api/link/probe") return response({ alias: "child-one", fingerprint: "SHA256:test", keyType: "ed25519" }); + if (path === "/api/link/confirm-host") return response({ alias: "child-one", fingerprint: "SHA256:test", ocxVersion: "2.0.0" }); + if (path === "/api/link/apply") { applies += 1; return applies === 1 ? response({ error: { code: "link_apply_failed" } }, 502) : response({ linkId: "link-1" }, 202); } + return response(baseStatus); + }) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + await act(async () => { (host.querySelector(".btn-primary") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Add child"))?.click(); }); + await flush(); + await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); + await flush(); + await act(async () => { (host.querySelector('input[type="checkbox"]') as HTMLInputElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Confirm host"))?.click(); }); + await flush(); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Connect child"))?.click(); }); + await flush(); + expect(host.textContent).toContain("Retry"); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Retry"))?.click(); }); + await flush(); + expect(applies).toBe(2); +}); + +test("role radios use roving tabIndex and arrow, Home, and End keys", async () => { + globalThis.fetch = (async () => response(baseStatus)) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + const radios = () => [...host.querySelectorAll('[role="radio"]')] as HTMLButtonElement[]; + expect(radios()[0]?.tabIndex).toBe(0); + expect(radios()[1]?.tabIndex).toBe(-1); + await act(async () => { radios()[0]?.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); }); + expect(radios()[1]?.tabIndex).toBe(0); + expect(win.document.activeElement).toBe(radios()[1]); + await act(async () => { radios()[1]?.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Home", bubbles: true })); }); + expect(radios()[0]?.tabIndex).toBe(0); + await act(async () => { radios()[0]?.dispatchEvent(new win.KeyboardEvent("keydown", { key: "End", bubbles: true })); }); + expect(radios()[1]?.tabIndex).toBe(0); +}); + +test("status polling ignores a delayed older response", async () => { + let releaseOld: (() => void) | null = null; + let oldSignal: AbortSignal | undefined; + const old = new Promise(resolve => { releaseOld = () => resolve(response(baseStatus)); }); + let statusCalls = 0; + globalThis.fetch = (async (input, init) => { + if (new URL(String(input)).pathname !== "/api/link/status") return response(baseStatus); + statusCalls += 1; + if (statusCalls === 1) oldSignal = init?.signal; + return statusCalls === 1 ? old : response({ ...baseStatus, links: [{ id: "link-1", alias: "newer", direction: "hub-initiated", state: "connected", since: "now", reason: null, tunnelPort: 43110 }] }); + }) as typeof fetch; + const host = await mount(); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Refresh"))?.click(); }); + await flush(); + expect(oldSignal?.aborted).toBe(true); + expect(host.textContent).toContain("newer"); + releaseOld?.(); + await flush(); + expect(host.textContent).toContain("newer"); +}); + +test("disconnect confirmation restores focus on cancel, Escape, and completion", async () => { + globalThis.fetch = (async (input, init) => { + const path = new URL(String(input)).pathname; + if (path === "/api/link/status") return response({ ...baseStatus, links: [{ id: "link-1", alias: "child", direction: "hub-initiated", state: "connected", since: "now", reason: null, tunnelPort: 43110 }] }); + if (init?.method === "DELETE") return response({ linkId: "link-1" }); + return response(baseStatus); + }) as typeof fetch; + const host = await mount(); + const disconnect = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Disconnect")) as HTMLButtonElement; + await act(async () => { disconnect.click(); }); + await act(async () => { (host.querySelector(".remote-link-confirm-dialog .btn-ghost") as HTMLButtonElement).click(); }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(win.document.activeElement).toBe(disconnect); + await act(async () => { disconnect.click(); }); + await act(async () => { host.querySelector(".remote-link-confirm-dialog")?.dispatchEvent(new win.Event("cancel", { bubbles: true, cancelable: true })); }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(win.document.activeElement).toBe(disconnect); + await act(async () => { disconnect.click(); }); + await act(async () => { (host.querySelector(".remote-link-confirm-dialog .btn-danger") as HTMLButtonElement).click(); }); + await flush(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(win.document.activeElement).toBe(disconnect); +}); + +test("known and unknown status reasons remain understandable", async () => { + const status = { ...baseStatus, links: [ + { id: "known", alias: "known", direction: "hub-initiated" as const, state: "failed" as const, since: "now", reason: "timeout", tunnelPort: 43110 }, + { id: "unknown", alias: "unknown", direction: "hub-initiated" as const, state: "failed" as const, since: "now", reason: "future reason", tunnelPort: 43111 }, + ] }; + globalThis.fetch = (async () => response(status)) as typeof fetch; + const host = await mount(); + expect(host.textContent).toContain("The connection timed out."); + expect(host.querySelector("code")?.textContent).toBe("future reason"); +}); + +test("cancelling a failed apply leaves no dead Retry behind", async () => { + globalThis.fetch = (async (input) => { + const path = new URL(String(input)).pathname; + if (path === "/api/link/candidates") return response({ candidates: [{ alias: "child-one", source: "ssh config" }] }); + if (path === "/api/link/probe") return response({ alias: "child-one", fingerprint: "SHA256:test", keyType: "ed25519" }); + if (path === "/api/link/confirm-host") return response({ alias: "child-one", fingerprint: "SHA256:test", ocxVersion: "2.0.0" }); + if (path === "/api/link/apply") return response({ error: { code: "link_apply_failed" } }, 502); + return response(baseStatus); + }) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + await act(async () => { (host.querySelector(".btn-primary") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Add child"))?.click(); }); + await flush(); + await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); + await flush(); + await act(async () => { (host.querySelector('input[type="checkbox"]') as HTMLInputElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Confirm host"))?.click(); }); + await flush(); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Connect child"))?.click(); }); + await flush(); + expect(host.textContent).toContain("Retry"); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent === "Cancel")?.click(); }); + await flush(); + expect([...host.querySelectorAll("button")].some(button => button.textContent === "Retry")).toBe(false); + expect([...host.querySelectorAll("button")].some(button => button.textContent?.includes("Add child"))).toBe(true); +}); diff --git a/gui/tests/remote-workspace.test.tsx b/gui/tests/remote-workspace.test.tsx index 6d409570fa..3ebd0069f0 100644 --- a/gui/tests/remote-workspace.test.tsx +++ b/gui/tests/remote-workspace.test.tsx @@ -8,8 +8,35 @@ const DEVICE_ID = "11111111-1111-4111-8111-111111111111"; const ROOT_ID = "22222222-2222-4222-8222-222222222222"; const SESSION_ID = "33333333-3333-4333-8333-333333333333"; -test("#remote resolves to the Remote Workspace page", () => { +test("#remote resolves to the Remote Link page", () => { expect(readPageFromHash("#remote")).toBe("remote"); + expect(readPageFromHash("#remote-workspace")).toBe("remote-workspace"); +}); + +test("Remote Workspace route gates unavailable deep links and mounts when available", async () => { + const [{ RemoteWorkspaceRoute }, { act }, { createRoot }, { LanguageProvider }] = await Promise.all([ + import("../src/App"), import("react"), import("react-dom/client"), import("../src/i18n/provider"), + ]); + const host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); + let navigated = false; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async () => jsonResponse({ available: false, devices: [], runtimes: {}, sessions: [] }) }); + await act(async () => { + root = createRoot(host); + root.render( { navigated = true; }} />); + }); + expect(host.textContent).toContain("Remote Workspace is unavailable"); + expect(host.querySelector("button.link-btn")?.textContent).toBe("Remote Link"); + expect(host.querySelector(".remote-workspace-page")).toBeNull(); + await act(async () => { (host.querySelector("button.link-btn") as HTMLButtonElement).click(); }); + expect(navigated).toBe(true); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async () => jsonResponse({ available: true, devices: [], runtimes: {}, sessions: [] }) }); + await act(async () => { + root?.render( { navigated = true; }} />); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(host.querySelector(".remote-workspace-page")).not.toBeNull(); }); test("pairing commands use native POSIX and PowerShell syntax", () => { diff --git a/gui/tests/sidebar-rows.test.ts b/gui/tests/sidebar-rows.test.ts index fbc682632a..8165e0d743 100644 --- a/gui/tests/sidebar-rows.test.ts +++ b/gui/tests/sidebar-rows.test.ts @@ -31,7 +31,7 @@ test("every row maps one-to-one onto a page", () => { // another, and Routing folding into Models is precisely that kind of change. expect(ids).toEqual([ "dashboard", "codex-set", "providers", "models", "subagents", - "logs", "usage", "storage", "remote", "integrations", + "logs", "usage", "storage", "remote", "remote-workspace", "integrations", ]); // No two rows share a page id, which is what made the correction helper necessary. expect(new Set(ids).size).toBe(ids.length); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 52a910377b..d369297a84 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -492,6 +492,10 @@ describe("headless GUI parity CLI", () => { // the management route registry land. Naming the family here does not claim those // local and Hub status payloads are equivalent. ["/api/remote-workspace", "ocx remote-workspace"], + // Remote Link: status and revoke are `ocx link status|revoke`. Candidates, probe, host + // confirmation, and apply are the dashboard's guided pairing; the headless route is + // `ocx link port|issue` plus `ocx connect --link`, which the apply flow drives over SSH. + ["/api/link", "ocx link"], ["/api/shadow", "ocx models"], ["/api/sidecar", "ocx agent"], ["/api/startup", "ocx system"],